From 17adf2015f36e9c888b0b0060049de87643f988c Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Sat, 17 Oct 2026 16:29:02 +0800 Subject: [PATCH 1/6] fix(admin): three lows from the v89 pre-deploy review - A request never ends admin sessions. While a change to ADMIN_WALLETS rolls out, a machine still on the old list ended a newly added admin's sessions; it now only refuses them. Ending a removed wallet's sessions for good stays with the boot sweep, which now retries (3 tries, 10 s apart). - Admin actions use their own gate, requireAdminAction(req, wallet): the site's own Origin, a session, and the session's wallet signed it. A missing or malformed wallet is a 400, never let through as a read. requireAdmin(req) is for reads only. - "Approve without fee" waits while the applicant may be paying: under a live fee quote, or in the hour after it while their transfer may still be indexing (the same hour expiry already waits). Checked before the claim and again inside it; the panel shows when it can be waived instead of the button. Applications carry settling_until for that. --- app/src/app/api/boost/mod/route.ts | 4 +- app/src/app/api/pairs/mod/route.ts | 4 +- app/src/app/api/pairs/review/route.ts | 4 +- app/src/app/api/points/admin/route.ts | 4 +- app/src/app/api/profile/admin/route.ts | 4 +- .../components/launchpad/PairReviewAdmin.tsx | 5 +- app/src/lib/admin/admin.test.ts | 34 +++++++---- app/src/lib/admin/session.ts | 58 ++++++++++++------- app/src/lib/launchpad/pairApply.test.ts | 27 ++++++++- app/src/lib/launchpad/pairApply.ts | 12 ++++ app/src/lib/launchpad/pairApplyServer.ts | 20 +++++-- docs/admin.md | 12 ++-- docs/pairs.md | 2 +- 13 files changed, 134 insertions(+), 56 deletions(-) diff --git a/app/src/app/api/boost/mod/route.ts b/app/src/app/api/boost/mod/route.ts index 743cc87..ab97c23 100644 --- a/app/src/app/api/boost/mod/route.ts +++ b/app/src/app/api/boost/mod/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; import { rateLimited } from "@/lib/launchpad/editServer"; import { clientIp, readJson } from "@/lib/profiles/http"; -import { requireAdmin } from "@/lib/admin/session"; +import { requireAdminAction } from "@/lib/admin/session"; import { getBoostLane, moderateBoost } from "@/lib/launchpad/boostServer"; import { ethUsd } from "@/lib/launchpad/ethPrice"; @@ -17,7 +17,7 @@ export async function POST(req: Request) { if (rateLimited(`boost-mod:ip:${clientIp(req)}`, 30)) return NextResponse.json({ error: "slow down" }, { status: 429 }); const b = await readJson(req); if (!b) return NextResponse.json({ error: "bad json" }, { status: 400 }); - const a = await requireAdmin(req, b.wallet); + const a = await requireAdminAction(req, b.wallet); if (!a.ok) return NextResponse.json({ error: a.error }, { status: a.status }); const r = await moderateBoost({ action: b.action, token: b.token, wallet: b.wallet, nonce: b.nonce, ts: b.ts, signature: b.signature }); if (!r.ok) return NextResponse.json({ error: r.error }, { status: r.status }); diff --git a/app/src/app/api/pairs/mod/route.ts b/app/src/app/api/pairs/mod/route.ts index d4cf5d0..aedee2f 100644 --- a/app/src/app/api/pairs/mod/route.ts +++ b/app/src/app/api/pairs/mod/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; import { rateLimited } from "@/lib/launchpad/editServer"; import { clientIp, readJson } from "@/lib/profiles/http"; -import { requireAdmin } from "@/lib/admin/session"; +import { requireAdminAction } from "@/lib/admin/session"; import { moderatePair } from "@/lib/launchpad/pairsServer"; export const dynamic = "force-dynamic"; @@ -15,7 +15,7 @@ export async function POST(req: Request) { if (rateLimited(`pairs-mod:ip:${clientIp(req)}`, 30)) return NextResponse.json({ error: "slow down" }, { status: 429 }); const b = await readJson(req); if (!b) return NextResponse.json({ error: "bad json" }, { status: 400 }); - const a = await requireAdmin(req, b.wallet); + const a = await requireAdminAction(req, b.wallet); if (!a.ok) return NextResponse.json({ error: a.error }, { status: a.status }); const r = await moderatePair(b); if (!r.ok) return NextResponse.json({ error: r.error }, { status: r.status }); diff --git a/app/src/app/api/pairs/review/route.ts b/app/src/app/api/pairs/review/route.ts index 44cb704..9cdfcfe 100644 --- a/app/src/app/api/pairs/review/route.ts +++ b/app/src/app/api/pairs/review/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; import { rateLimited } from "@/lib/launchpad/editServer"; import { clientIp, readJson } from "@/lib/profiles/http"; -import { requireAdmin } from "@/lib/admin/session"; +import { requireAdminAction } from "@/lib/admin/session"; import { reviewApplication } from "@/lib/launchpad/pairApplyServer"; export const dynamic = "force-dynamic"; @@ -11,7 +11,7 @@ export async function POST(req: Request) { if (rateLimited(`pairs-review:ip:${clientIp(req)}`, 30)) return NextResponse.json({ error: "slow down" }, { status: 429 }); const b = await readJson(req); if (!b) return NextResponse.json({ error: "bad json" }, { status: 400 }); - const a = await requireAdmin(req, b.wallet); + const a = await requireAdminAction(req, b.wallet); if (!a.ok) return NextResponse.json({ error: a.error }, { status: a.status }); const r = await reviewApplication(b); if (!r.ok) return NextResponse.json({ error: r.error }, { status: r.status }); diff --git a/app/src/app/api/points/admin/route.ts b/app/src/app/api/points/admin/route.ts index 0fd9771..bf59803 100644 --- a/app/src/app/api/points/admin/route.ts +++ b/app/src/app/api/points/admin/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; import { rateLimited } from "@/lib/launchpad/editServer"; import { clientIp, readJson } from "@/lib/profiles/http"; import { admitAdmin } from "@/lib/profiles/server"; -import { requireAdmin } from "@/lib/admin/session"; +import { requireAdmin, requireAdminAction } from "@/lib/admin/session"; import { SEASON_ACTIONS, buildPointsAdminMessage, isPointsAdminAction, normalizeSeasonDays, normalizeSeasonId } from "@/lib/points/auth"; import { currentSeason, endSeasonNow, finalizeIfEnded, hideSeason, previewBoards, previousPublishedSeason, publishSeason, recomputeNow, seasonById, seasonEnded, seasonFinal, startSeason, type Season } from "@/lib/points/server"; @@ -47,7 +47,7 @@ export async function POST(req: Request) { if (rateLimited(`points-admin:ip:${clientIp(req)}`, 30)) return NextResponse.json({ error: "slow down" }, { status: 429 }); const b = await readJson(req); if (!b) return NextResponse.json({ error: "bad json" }, { status: 400 }); - const session = await requireAdmin(req, b.wallet); + const session = await requireAdminAction(req, b.wallet); if (!session.ok) return NextResponse.json({ error: session.error }, { status: session.status }); const action = b.action; if (!isPointsAdminAction(action)) return NextResponse.json({ error: "bad action" }, { status: 400 }); diff --git a/app/src/app/api/profile/admin/route.ts b/app/src/app/api/profile/admin/route.ts index 002badf..22782eb 100644 --- a/app/src/app/api/profile/admin/route.ts +++ b/app/src/app/api/profile/admin/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; import { rateLimited } from "@/lib/launchpad/editServer"; import { clientIp, readJson } from "@/lib/profiles/http"; import { listProfilesForReview, moderateProfile } from "@/lib/profiles/server"; -import { requireAdmin } from "@/lib/admin/session"; +import { requireAdmin, requireAdminAction } from "@/lib/admin/session"; export const dynamic = "force-dynamic"; @@ -32,7 +32,7 @@ export async function POST(req: Request) { if (rateLimited(`pmod:ip:${clientIp(req)}`, 60)) return NextResponse.json({ error: "slow down" }, { status: 429 }); const b = await readJson(req); if (!b) return NextResponse.json({ error: "bad json" }, { status: 400 }); - const a = await requireAdmin(req, b.wallet); + const a = await requireAdminAction(req, b.wallet); if (!a.ok) return NextResponse.json({ error: a.error }, { status: a.status }); try { const r = await moderateProfile({ action: b.action, target: b.target, reason: b.reason, claim: b.claim, chain: b.chain, wallet: b.wallet, nonce: b.nonce, ts: b.ts, signature: b.signature }); diff --git a/app/src/components/launchpad/PairReviewAdmin.tsx b/app/src/components/launchpad/PairReviewAdmin.tsx index afa08b5..4925157 100644 --- a/app/src/components/launchpad/PairReviewAdmin.tsx +++ b/app/src/components/launchpad/PairReviewAdmin.tsx @@ -86,8 +86,9 @@ export default function PairReviewAdmin() { {a.status === "open" || a.status === "changes" ? (
setReasons((r) => ({ ...r, [a.id]: e.target.value }))} placeholder="Reason (the applicant sees it; optional to waive the fee)" maxLength={300} className="min-h-9 min-w-0 flex-1 rounded-lg border border-line bg-paper px-3 text-xs text-ink" /> - {/* a paid fee is never waived (the server refuses it too, for a burn that lands meanwhile) */} - {!isPaid(a.paid_usd, a.fee_usd) ? : null} + {/* a paid fee is never waived, nor one the applicant may be paying now (the server refuses both too, for a burn or a quote that lands meanwhile) */} + {!isPaid(a.paid_usd, a.fee_usd) && a.settling_until ? The applicant may be paying the fee now: waive it after {new Date(a.settling_until).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} : null} + {!isPaid(a.paid_usd, a.fee_usd) && !a.settling_until ? : null}
) : null} diff --git a/app/src/lib/admin/admin.test.ts b/app/src/lib/admin/admin.test.ts index ba39e4d..7496aa8 100644 --- a/app/src/lib/admin/admin.test.ts +++ b/app/src/lib/admin/admin.test.ts @@ -5,7 +5,7 @@ import path from "node:path"; import { createSiweMessage } from "viem/siwe"; import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; import { ADMIN_MESSAGE_TTL_MS, ADMIN_SESSION_MS, ADMIN_SESSIONS_MAX, ADMIN_SIGN_IN_STATEMENT, buildAdminSignInMessage, checkAdminSignIn, deviceLabel } from "./signIn.ts"; -import { ADMIN_COOKIE, adminCookieOptions, hashToken, isToken, requireAdmin, tokenFromCookieHeader } from "./session.ts"; +import { ADMIN_COOKIE, adminCookieOptions, hashToken, isToken, requireAdmin, requireAdminAction, tokenFromCookieHeader } from "./session.ts"; import { verifySig } from "../profiles/server.ts"; const SRC = path.resolve(import.meta.dirname, "../.."); @@ -101,8 +101,12 @@ test("the token: 32 random bytes; only its SHA-256 is ever stored or looked up", test("a session counts only while its wallet is still an admin, a removed wallet's sessions end for good, and past the cap the oldest end", () => { const s = src("lib/admin/session.ts"); - assert.match(s, /if \(!adminWallets\(\)\.has\(row\.wallet\)\) \{\n[^\n]*\n\s*void db`UPDATE bb_admin_sessions SET revoked_at = now\(\) WHERE wallet = \$\{row\.wallet\} AND revoked_at IS NULL`\.catch\(\(\) => \{\}\);\n\s*return null;/); + const fromToken = s.slice(s.indexOf("export async function adminSessionFromToken("), s.indexOf("export async function adminFromRequest(")); + assert.match(fromToken, /if \(!adminWallets\(\)\.has\(row\.wallet\)\) return null;/); + // a machine still on the old ADMIN_WALLETS while a secret change rolls out must not end a newly added admin's sessions + assert.doesNotMatch(fromToken, /revoked_at = now\(\)/, "a request never ends sessions"); assert.match(s, /UPDATE bb_admin_sessions SET revoked_at = now\(\) WHERE revoked_at IS NULL AND wallet <> ALL\(\$\{\[\.\.\.adminWallets\(\)\]\}::text\[\]\)/, "the boot sweep"); + assert.match(s, /export async function revokeNonAdminSessions\(tries = 3, waitMs = 10_000\)[\s\S]*?if \(i >= tries\) throw err;\n\s*await new Promise\(\(r\) => setTimeout\(r, waitMs\)\);/, "a database blip at boot does not skip the sweep"); assert.match(src("instrumentation.ts"), /const \{ revokeNonAdminSessions \} = await import\("\.\/lib\/admin\/session"\);\n\s*void revokeNonAdminSessions\(\)/, "runs at boot, which a secret change triggers"); assert.match(s, /ORDER BY created_at DESC, id DESC LIMIT \$\{ADMIN_SESSIONS_MAX\}\)/); }); @@ -121,11 +125,16 @@ test("an unknown token, a missing cookie or a wallet in the URL is no session; n assert.deepEqual(await requireAdmin(req({ cookie: `${ADMIN_COOKIE}=${"A".repeat(43)}` })), none); assert.deepEqual(await requireAdmin(req({})), none); assert.deepEqual(await requireAdmin(req({}, "https://openlaunch.lol/api/posts/reported?wallet=0x0000000000000000000000000000000000000001")), none); - // an action (a signing wallet given) must also come from the site's own pages + // an action must also come from the site's own pages and name the wallet that signed it const w = "0x0000000000000000000000000000000000000001"; - assert.deepEqual(await requireAdmin(req({}), w), { ok: false, error: "bad origin", status: 403 }); - assert.deepEqual(await requireAdmin(req({ origin: "https://evil.example" }), w), { ok: false, error: "bad origin", status: 403 }); - assert.deepEqual(await requireAdmin(req({ origin: "http://localhost:3000" }), w), none, "the right origin still needs a session"); + const site = { origin: "http://localhost:3000" }; + assert.deepEqual(await requireAdminAction(req({}), w), { ok: false, error: "bad origin", status: 403 }); + assert.deepEqual(await requireAdminAction(req({ origin: "https://evil.example" }), w), { ok: false, error: "bad origin", status: 403 }); + assert.deepEqual(await requireAdminAction(req(site), w), none, "the right origin still needs a session"); + // a missing or malformed wallet is refused, never let through as a read would be + for (const bad of [undefined, null, 1, "", "0x01", `${w}0`, { wallet: w }]) { + assert.deepEqual(await requireAdminAction(req({ ...site, cookie: `${ADMIN_COOKIE}=${"A".repeat(43)}` }), bad), { ok: false, error: "bad wallet", status: 400 }, String(bad)); + } const s = src("lib/admin/session.ts"); assert.match(s, /if \(!maybeDb\(\)\) return fail\("db unconfigured", 503\);/); assert.match(s, /\} catch \(err\) \{\n\s*console\.error\("\[admin\] session check failed:"[^\n]*\n\s*return fail\("admin check unavailable, try again", 503\);/); @@ -167,7 +176,7 @@ test("every handler of every admin route requires the admin session", () => { const body = s.slice(h.index, handlers[i + 1]?.index ?? s.length); if (SIGN_IN.has(`${rel}:${h[1]}`)) continue; // posts/mod: a creator mutes their own token with the signature alone; moderate() wants the session for anything admin - const gated = /requireAdmin\(req\b/.test(body) || (rel === "posts/mod" && /adminFromRequest\(req\)/.test(body) && /, session\);/.test(body)); + const gated = /requireAdmin(Action)?\(req\b/.test(body) || (rel === "posts/mod" && /adminFromRequest\(req\)/.test(body) && /, session\);/.test(body)); assert.ok(gated, `${rel} ${h[1]} does not check the admin session`); } } @@ -178,17 +187,20 @@ test("admin actions: the session is checked before the action, against the walle for (const [rel, call] of [["boost/mod", "moderateBoost("], ["pairs/mod", "moderatePair("], ["pairs/review", "reviewApplication("], ["profile/admin", "moderateProfile("], ["points/admin", "admitAdmin("]] as const) { const s = src(`app/api/${rel}/route.ts`); const post = s.slice(s.indexOf("export async function POST(")); - const gate = post.search(/requireAdmin\(req, b\.wallet\)/); - assert.ok(gate > 0 && gate < post.indexOf(call), `${rel}: requireAdmin(req, b.wallet) comes before ${call}`); + const gate = post.search(/requireAdminAction\(req, b\.wallet\)/); + assert.ok(gate > 0 && gate < post.indexOf(call), `${rel}: requireAdminAction(req, b.wallet) comes before ${call}`); + assert.doesNotMatch(post, /requireAdmin\(req/, `${rel}: an action uses the action gate`); } const s = src("lib/admin/session.ts"); - assert.match(s, /signedWallet !== undefined && \(typeof signedWallet !== "string" \|\| signedWallet\.toLowerCase\(\) !== session\.wallet\)/); + const action = s.slice(s.indexOf("export async function requireAdminAction(")); + assert.match(action, /if \(typeof signedWallet !== "string" \|\| !\/\^0x\[0-9a-fA-F\]\{40\}\$\/\.test\(signedWallet\)\) return fail\("bad wallet", 400\);/); + assert.match(action, /if \(signedWallet\.toLowerCase\(\) !== a\.session\.wallet\) return fail\(/); const mod = src("lib/launchpad/postsServer.ts"); const fn = mod.slice(mod.indexOf("export async function moderate("), mod.indexOf("const message = buildModMessage", mod.indexOf("export async function moderate("))); assert.match(fn, /if \(l\.launcher !== wallet && session !== wallet\) return fail\(ADMIN_SESSION_NEEDED, 401\);/, "an admin muting a token they did not launch"); assert.match(fn, /if \(!admins\.has\(wallet\)\) return fail\("admin only", 403\);\n\s*if \(session !== wallet\) return fail\(ADMIN_SESSION_NEEDED, 401\);/, "hiding a post"); assert.match(src("app/api/posts/mod/route.ts"), /session = fromAdminOrigin\(req\) \? \(\(await adminFromRequest\(req\)\)\?\.wallet \?\? null\) : null;/, "the session counts only from the site's own pages"); - assert.match(s, /if \(signedWallet !== undefined && !fromAdminOrigin\(req\)\) return fail\("bad origin", 403\);/); + assert.match(action, /if \(!fromAdminOrigin\(req\)\) return fail\("bad origin", 403\);/); }); test("sign-in: same-origin, rate-limited, nonce spent only after the signature checks out", () => { diff --git a/app/src/lib/admin/session.ts b/app/src/lib/admin/session.ts index 9f7e660..f2b64bd 100644 --- a/app/src/lib/admin/session.ts +++ b/app/src/lib/admin/session.ts @@ -9,9 +9,8 @@ import { ADMIN_NONCE_TTL_MS, ADMIN_SESSION_MS, ADMIN_SESSIONS_MAX } from "./sign * Admin sessions. Signing in (signIn.ts) creates one: a random 32-byte token goes to the browser in an HttpOnly, * Secure, SameSite=Strict cookie, and the database keeps only its SHA-256, so a leaked database row cannot be used as a * cookie. A session counts only while it exists, is not ended, has not passed its 8 hours, and its wallet is still in - * ADMIN_WALLETS. Removing a wallet from the secret ends its sessions for good (at boot, which a secret change triggers, - * and whenever one of them turns up), so adding the wallet back revives none. Signing out ends it on the server, not - * only in the browser. + * ADMIN_WALLETS. Removing a wallet from the secret ends its sessions for good at boot (a secret change restarts every + * machine), so adding the wallet back revives none. Signing out ends it on the server, not only in the browser. * * The session gates the admin page and the admin reads. Every admin action still needs its own wallet signature as * well, from the same wallet: a stolen cookie cannot act without the wallet, and a phished signature cannot act @@ -118,11 +117,9 @@ export async function adminSessionFromToken(token: string | null | undefined): P SELECT id, wallet, expires_at FROM bb_admin_sessions WHERE token_hash = ${hashToken(token)} AND revoked_at IS NULL AND expires_at > now()`; if (!row) return null; - if (!adminWallets().has(row.wallet)) { - // no longer an admin: end all of that wallet's sessions, so adding it back later revives none of them - void db`UPDATE bb_admin_sessions SET revoked_at = now() WHERE wallet = ${row.wallet} AND revoked_at IS NULL`.catch(() => {}); - return null; - } + // not an admin on this machine: refused, but never ended from here. While a secret change rolls out, a machine still on + // the old list would end a newly added admin's sessions; ending a removed wallet's is the boot sweep's job + if (!adminWallets().has(row.wallet)) return null; void db`UPDATE bb_admin_sessions SET last_seen_at = now() WHERE id = ${row.id} AND last_seen_at < now() - interval '1 minute'`.catch(() => {}); return { id: Number(row.id), wallet: row.wallet, expiresAt: new Date(row.expires_at).toISOString() }; } @@ -131,21 +128,29 @@ export async function adminFromRequest(req: Request): Promise { +/** + * At boot (a change to ADMIN_WALLETS restarts the machines): end, for good, every session of a wallet no longer listed, + * so adding it back later revives none. Retried (3 tries, 10 s apart) so a database blip at boot does not skip it. + */ +export async function revokeNonAdminSessions(tries = 3, waitMs = 10_000): Promise { const db = maybeDb(); if (!db) return 0; - const rows = await db`UPDATE bb_admin_sessions SET revoked_at = now() WHERE revoked_at IS NULL AND wallet <> ALL(${[...adminWallets()]}::text[]) RETURNING id`; - return rows.length; + for (let i = 1; ; i++) { + try { + const rows = await db`UPDATE bb_admin_sessions SET revoked_at = now() WHERE revoked_at IS NULL AND wallet <> ALL(${[...adminWallets()]}::text[]) RETURNING id`; + return rows.length; + } catch (err) { + if (i >= tries) throw err; + await new Promise((r) => setTimeout(r, waitMs)); + } + } } -/** - * The gate for every admin route: a session, and for an action (`signedWallet` given) a request from the site's own - * pages and the same wallet that signed it. Fails closed: no database or a failed read refuses. - */ -export async function requireAdmin(req: Request, signedWallet?: unknown): Promise<{ ok: true; session: AdminSession } | Fail> { +type Gate = { ok: true; session: AdminSession } | Fail; + +/** The gate for every admin read: a session that still counts. Fails closed: no database or a failed read refuses. */ +export async function requireAdmin(req: Request): Promise { if (!maybeDb()) return fail("db unconfigured", 503); - if (signedWallet !== undefined && !fromAdminOrigin(req)) return fail("bad origin", 403); let session: AdminSession | null; try { session = await adminFromRequest(req); @@ -154,12 +159,23 @@ export async function requireAdmin(req: Request, signedWallet?: unknown): Promis return fail("admin check unavailable, try again", 503); } if (!session) return fail("admin session needed: sign in at /admin", 401); - if (signedWallet !== undefined && (typeof signedWallet !== "string" || signedWallet.toLowerCase() !== session.wallet)) { - return fail("signed by a different wallet than the signed-in admin: switch back to it", 403); - } return { ok: true, session }; } +/** + * The gate for every admin action: a request from the site's own pages, a session, and the session's wallet is the one + * that signed the action. A missing or malformed wallet is refused here, never let through as a read would be. + */ +export async function requireAdminAction(req: Request, signedWallet: unknown): Promise { + if (!maybeDb()) return fail("db unconfigured", 503); + if (!fromAdminOrigin(req)) return fail("bad origin", 403); + if (typeof signedWallet !== "string" || !/^0x[0-9a-fA-F]{40}$/.test(signedWallet)) return fail("bad wallet", 400); + const a = await requireAdmin(req); + if (!a.ok) return a; + if (signedWallet.toLowerCase() !== a.session.wallet) return fail("signed by a different wallet than the signed-in admin: switch back to it", 403); + return a; +} + export type AdminSessionRow = { id: number; device: string; created_at: string; last_seen_at: string; expires_at: string }; /** A wallet's sessions that still count, newest first. */ diff --git a/app/src/lib/launchpad/pairApply.test.ts b/app/src/lib/launchpad/pairApply.test.ts index 21f9525..f687dc1 100644 --- a/app/src/lib/launchpad/pairApply.test.ts +++ b/app/src/lib/launchpad/pairApply.test.ts @@ -4,6 +4,8 @@ import { readFileSync } from "node:fs"; import path from "node:path"; import { PAIR_APPLY_COPY, + PAIR_QUOTE_LOCK_MS, + PAIR_QUOTE_SETTLE_MS, PAIR_REVIEWS_MAX, PAIR_REVIEW_WINDOW_MS, PAIR_TAG_BASE, @@ -18,6 +20,7 @@ import { pairBurnUsd, pairFeeQuote, pairListingUsd, + pairSettlingUntil, pairTagOf, proofPostText, proofPostUrl, @@ -159,7 +162,7 @@ test("nothing holds a token's place for good: unpaid applications expire after 4 const s = src("pairApplyServer.ts"); const exp = s.slice(s.indexOf("export async function expireStaleApplications"), s.indexOf("/** Whether a wallet has a visible profile")); assert.match(exp, /a\.status = 'open' AND a\.paid_usd = 0 AND a\.created_at < now\(\)/); - assert.match(exp, /a\.quote_until IS NULL OR a\.quote_until < now\(\) - interval '1 hour'/, "never under a live quote"); + assert.match(exp, /a\.quote_until IS NULL OR a\.quote_until < now\(\) - \$\{`\$\{PAIR_QUOTE_SETTLE_MS \/ 1000\} seconds`\}::interval/, "never under a live quote, nor in the hour after it"); assert.match(exp, /NOT EXISTS \(SELECT 1 FROM bb_pair_burns b WHERE b\.application_id = a\.id AND b\.block_time >= a\.created_at\)/, "never once anything was burned for it (a stray burn from before it existed does not count)"); assert.match(exp, /a\.status = 'changes' AND a\.paid_at < now\(\)/); const apply = s.slice(s.indexOf("export async function applicantAction")); @@ -225,10 +228,10 @@ test("an admin can approve an unpaid application with the fee waived, and an alr // a paid fee is never recorded as waived assert.match(decide, /if \(decision === "waive" && isPaid\(app\.paid_usd, app\.fee_usd\)\) return fail\(/); // and the claim itself re-checks it, so a burn settling between the check and the claim cannot be waived away - assert.match(decide, /WHERE id = \$\{id\} AND status = \$\{app\.status\} AND \(\$\{decision\} <> 'waive' OR paid_usd < fee_usd \* \$\{1 - PAIR_FEE_TOLERANCE\}\) RETURNING id/); + assert.match(decide, /WHERE id = \$\{id\} AND status = \$\{app\.status\} AND \(\$\{decision\} <> 'waive' OR \(paid_usd < fee_usd \* \$\{1 - PAIR_FEE_TOLERANCE\}\n\s*AND \(quote_until IS NULL OR quote_until < now\(\) - \$\{`\$\{PAIR_QUOTE_SETTLE_MS \/ 1000\} seconds`\}::interval\)\)\) RETURNING id/); assert.match(decide, /const restore = \(\) => db`\s*UPDATE bb_pair_applications SET\s*status = CASE WHEN \$\{app\.status\} = 'open' AND paid_usd >= fee_usd \* \$\{1 - PAIR_FEE_TOLERANCE\} THEN 'paid' ELSE \$\{app\.status\} END,[^`]*reviews_used = \$\{app\.reviews_used\}[^`]*AND status = 'approved'`/, "a failed listing puts the application back where it was (as paid when a burn settled meanwhile)"); assert.match(decide, /\(decision === "changes" \|\| decision === "reject"\) && !reason/, "approving and waiving need no reason"); - assert.match(src("../../components/launchpad/PairReviewAdmin.tsx"), /\{!isPaid\(a\.paid_usd, a\.fee_usd\) \?