@@ -19,9 +19,11 @@ import {
1919 getTld , listTlds , listTldsForUser , registerTld , normalizeLabel ,
2020 setAlias , clearAlias , listExempt , setExempt , clearExempt ,
2121 listNames , registerName , setNameTarget , releaseName ,
22+ setTldPrice , listTldsNotOwnedBy , quoteName , openNamePurchase ,
2223 resolveMoshpitName , normalizeTld , tldRejection ,
2324 normalizeMode , resolutionPreference ,
2425} from "../moshpit.mjs" ;
26+ import { config } from "../config.mjs" ;
2527
2628export const moshpitRouter = Router ( ) ;
2729
@@ -145,6 +147,87 @@ moshpitRouter.delete("/api/moshpit/tlds/:tld/names", async (req, res) => {
145147 res . json ( { tld : normalizeTld ( req . params . tld ) , label : normalizeLabel ( req . body ?. label ) , released : true } ) ;
146148} ) ;
147149
150+ /* ---- the market ---- */
151+
152+ /** TLDs other people hold. `?for_sale=1` narrows to the buyable ones. */
153+ moshpitRouter . get ( "/api/moshpit/market" , async ( req , res ) => {
154+ const tlds = await listTldsNotOwnedBy ( req . user ?. id ?? null , { forSale : Boolean ( req . query . for_sale ) } ) ;
155+ res . json ( {
156+ tlds : tlds . map ( ( t ) => ( {
157+ tld : t . tld , alias_of : t . alias_of , price_usd : t . price_usd ,
158+ for_sale : t . price_usd !== null && t . price_usd !== undefined ,
159+ } ) ) ,
160+ } ) ;
161+ } ) ;
162+
163+ /** What a name would cost, and whether it can be bought at all. */
164+ moshpitRouter . get ( "/api/moshpit/tlds/:tld/quote" , async ( req , res ) => {
165+ const q = await quoteName ( { tld : req . params . tld , label : req . query . label , buyerId : req . user ?. id ?? null } ) ;
166+ if ( ! q . ok ) return bad ( res , q . error , q . taken ? 409 : 400 ) ;
167+ res . json ( { tld : q . tld , label : q . label , price_usd : q . priceUsd } ) ;
168+ } ) ;
169+
170+ moshpitRouter . put ( "/api/moshpit/tlds/:tld/price" , async ( req , res ) => {
171+ if ( ! req . user ) return unauthorized ( res ) ;
172+ const result = await setTldPrice ( { tld : req . params . tld , userId : req . user . id , priceUsd : req . body ?. price_usd } ) ;
173+ if ( ! result . ok ) return bad ( res , result . error || "could not set that price" ) ;
174+ res . json ( { tld : result . tld , price_usd : result . priceUsd } ) ;
175+ } ) ;
176+
177+ /**
178+ * Start a CoinPay checkout for `label.tld`.
179+ *
180+ * The quote is taken again here rather than trusted from the page the buyer was
181+ * looking at: prices change, and names get taken, between rendering and
182+ * clicking. settleNamePurchase checks a third time, because the gap between
183+ * paying and confirming is where the last race lives.
184+ */
185+ async function startCheckout ( req , res , { json } ) {
186+ const q = await quoteName ( { tld : req . params . tld , label : req . body ?. label , buyerId : req . user . id } ) ;
187+ if ( ! q . ok ) {
188+ return json ? bad ( res , q . error , q . taken ? 409 : 400 ) : back ( res , { err : q . error } ) ;
189+ }
190+ if ( ! config . coinpay . businessId ) {
191+ const msg = "payments are not configured yet" ;
192+ return json ? bad ( res , msg , 503 ) : back ( res , { err : msg } ) ;
193+ }
194+
195+ try {
196+ const r = await fetch ( `${ config . coinpay . apiBase } /api/payments/create` , {
197+ method : "POST" ,
198+ headers : { "content-type" : "application/json" } ,
199+ body : JSON . stringify ( {
200+ business_id : config . coinpay . businessId ,
201+ amount : q . priceUsd ,
202+ currency : "USD" ,
203+ payment_method : "both" ,
204+ metadata : { app : "moshcode" , kind : "moshpit_name" , user_id : req . user . id , tld : q . tld , label : q . label } ,
205+ redirect_url : `${ config . origin } /pit?bought=${ encodeURIComponent ( `${ q . label } .${ q . tld } ` ) } ` ,
206+ } ) ,
207+ } ) ;
208+ const pay = await r . json ( ) ;
209+ const payId = pay . id || pay . payment_id ;
210+ if ( ! payId ) throw new Error ( "no payment id in response" ) ;
211+
212+ // Recorded before the buyer leaves for the payment page: the webhook can
213+ // arrive before they are redirected back, and with no row it has nothing
214+ // to settle.
215+ await openNamePurchase ( { paymentId : payId , tld : q . tld , label : q . label , userId : req . user . id , amountUsd : q . priceUsd } ) ;
216+
217+ const url = pay . hosted_url || pay . url || `${ config . coinpay . apiBase } /pay/${ payId } ` ;
218+ return json ? res . status ( 201 ) . json ( { payment_id : payId , checkout_url : url , price_usd : q . priceUsd } ) : res . redirect ( url ) ;
219+ } catch ( e ) {
220+ console . error ( "[moshpit] checkout failed:" , e . message ) ;
221+ const msg = "could not start checkout" ;
222+ return json ? bad ( res , msg , 502 ) : back ( res , { err : msg } ) ;
223+ }
224+ }
225+
226+ moshpitRouter . post ( "/api/moshpit/tlds/:tld/buy" , async ( req , res ) => {
227+ if ( ! req . user ) return unauthorized ( res ) ;
228+ return startCheckout ( req , res , { json : true } ) ;
229+ } ) ;
230+
148231/**
149232 * Resolve a name, and say what a resolver should DO with the answer.
150233 *
@@ -196,13 +279,16 @@ const PIT_CSS = `
196279.pit-names{margin-top:12px;padding-top:10px;border-top:1px dashed var(--line)}
197280.pit-name{background:var(--bg-tint);border-radius:8px;padding:6px 10px;margin-bottom:6px}
198281.pit-name .mono{min-width:150px}
282+ .pit-forsale{border-color:color-mix(in srgb,var(--acid) 35%,var(--line))}
199283.pit-msg{border-radius:8px;padding:10px 14px;margin:14px 0;font-family:var(--mono);font-size:.84rem}
200284.pit-msg.err{border:1px solid var(--danger);color:var(--danger)}
201285.pit-msg.ok{border:1px solid var(--acid);color:var(--acid)}` ;
202286
287+ const forSale = ( t ) => t . price_usd !== null && t . price_usd !== undefined ;
288+
203289moshpitRouter . get ( "/pit" , async ( req , res ) => {
204- const [ registry , mine , bal ] = await Promise . all ( [
205- listTlds ( 50 ) ,
290+ const [ theirs , mine , bal ] = await Promise . all ( [
291+ listTldsNotOwnedBy ( req . user ?. id ?? null , { limit : 100 } ) ,
206292 req . user ? listTldsForUser ( req . user . id ) : [ ] ,
207293 req . user ? balance ( req . user . id ) : 0 ,
208294 ] ) ;
@@ -263,13 +349,42 @@ moshpitRouter.get("/pit", async (req, res) => {
263349 ( exemptions . get ( t . tld ) || [ ] ) . map ( ( l ) => `${ esc ( l ) } .${ esc ( t . tld ) } ` ) . join ( " · " ) || "none held back"
264350 } </span>
265351 </form>` : "" }
352+ <form method="post" action="/pit/${ esc ( t . tld ) } /price" class="pit-row">
353+ ${ csrfInput ( req ) }
354+ <span class="mono faint" style="font-size:.72rem">sell names for $</span>
355+ <input name="price_usd" inputmode="decimal" placeholder="0.00"
356+ value="${ t . price_usd === null || t . price_usd === undefined ? "" : esc ( String ( t . price_usd ) ) } "
357+ autocomplete="off" style="min-width:90px">
358+ <button class="btn" type="submit">${ forSale ( t ) ? "Update price" : "List for sale" } </button>
359+ ${ forSale ( t ) ? `<button class="btn" type="submit" name="unlist" value="1">Unlist</button>` : "" }
360+ <span class="mono ${ forSale ( t ) ? "acid" : "faint" } " style="font-size:.72rem">${
361+ forSale ( t ) ? `anyone can buy a name under .${ esc ( t . tld ) } ` : "closed — only you can mint here"
362+ } </span>
363+ </form>
266364 </div>` ) . join ( "" )
267365 : `<p class="dim">You don't hold a TLD yet. Claim one above.</p>` ;
268366
269- const registryHtml = registry . length
270- ? `<ul class="mono dim" style="line-height:1.9;padding-left:18px">${ registry . map ( ( t ) =>
271- `<li><span class="acid">.${ esc ( t . tld ) } </span>${ t . alias_of ? ` → .${ esc ( t . alias_of ) } ` : "" } </li>` ) . join ( "" ) } </ul>`
272- : `<p class="dim">Nothing claimed yet.</p>` ;
367+ // Sorted so the ones you can actually act on come first.
368+ const theirsHtml = theirs . length
369+ ? theirs . map ( ( t ) => `
370+ <div class="pit-tld${ forSale ( t ) ? " pit-forsale" : "" } ">
371+ <div class="pit-row" style="margin:0;justify-content:space-between">
372+ <h3 class="${ forSale ( t ) ? "acid" : "dim" } " style="font-family:var(--mono);font-size:1.05rem;text-transform:none">
373+ .${ esc ( t . tld ) } ${ t . alias_of ? `<span class="faint"> → .${ esc ( t . alias_of ) } </span>` : "" }
374+ </h3>
375+ <span class="pill${ forSale ( t ) ? " on" : "" } ">${ forSale ( t ) ? `$${ esc ( String ( t . price_usd ) ) } a name` : "not for sale" } </span>
376+ </div>
377+ ${ forSale ( t ) ? ( req . user ? `
378+ <form method="post" action="/pit/${ esc ( t . tld ) } /buy" class="pit-row">
379+ ${ csrfInput ( req ) }
380+ <input name="label" placeholder="the name you want" autocomplete="off" spellcheck="false" required>
381+ <span class="mono faint">.${ esc ( t . tld ) } </span>
382+ <button class="btn acid" type="submit">Buy for $${ esc ( String ( t . price_usd ) ) } </button>
383+ </form>`
384+ : `<p class="mono faint" style="font-size:.72rem;margin:8px 0 0"><a class="acid" href="/">Sign in</a> to buy a name here.</p>` )
385+ : "" }
386+ </div>` ) . join ( "" )
387+ : `<p class="dim">Nobody else holds a TLD yet.</p>` ;
273388
274389 res . type ( "html" ) . send ( page ( {
275390 title : "moshcode ▸ the pit" ,
@@ -288,9 +403,18 @@ moshpitRouter.get("/pit", async (req, res) => {
288403 ${ msg }
289404 ${ req . user ? claimForm ( req ) : "" }
290405 <h2 style="margin-top:34px;font-size:1.2rem">Yours</h2>
406+ <p class="dim" style="max-width:62ch;margin:4px 0 14px">
407+ Endings you hold. Names under them are yours to mint for nothing — or put a price on the
408+ ending and let anyone buy one.
409+ </p>
291410 ${ mineHtml }
292- <h2 style="margin-top:34px;font-size:1.2rem">The registry</h2>
293- ${ registryHtml }
411+ <h2 style="margin-top:34px;font-size:1.2rem">Theirs</h2>
412+ <p class="dim" style="max-width:62ch;margin:4px 0 14px">
413+ Endings somebody else holds. Where the operator has set a price you can buy a name under it —
414+ <span class="mono">foo.whatever</span> without owning <span class="mono">.whatever</span>. Paid in crypto
415+ through CoinPay; the name lands the moment the payment confirms.
416+ </p>
417+ ${ theirsHtml }
294418</main>${ footer } ` ,
295419 } ) ) ;
296420} ) ;
@@ -338,6 +462,21 @@ moshpitRouter.post("/pit/:tld/names", requireAuth, async (req, res) => {
338462 back ( res , { ok : done } ) ;
339463} ) ;
340464
465+ moshpitRouter . post ( "/pit/:tld/price" , requireAuth , async ( req , res ) => {
466+ const result = await setTldPrice ( {
467+ tld : req . params . tld , userId : req . user . id ,
468+ priceUsd : req . body ?. unlist ? null : req . body ?. price_usd ,
469+ } ) ;
470+ if ( ! result . ok ) return back ( res , { err : result . error || "could not set that price" } ) ;
471+ back ( res , {
472+ ok : result . priceUsd === null
473+ ? `.${ result . tld } is no longer for sale.`
474+ : `.${ result . tld } names now cost $${ result . priceUsd } .` ,
475+ } ) ;
476+ } ) ;
477+
478+ moshpitRouter . post ( "/pit/:tld/buy" , requireAuth , ( req , res ) => startCheckout ( req , res , { json : false } ) ) ;
479+
341480moshpitRouter . post ( "/pit/:tld/exempt" , requireAuth , async ( req , res ) => {
342481 const result = await setExempt ( { tld : req . params . tld , label : req . body ?. label , userId : req . user . id } ) ;
343482 if ( ! result . ok ) return back ( res , { err : result . error || "could not exempt that name" } ) ;
0 commit comments