diff --git a/app/db/schema.sql b/app/db/schema.sql
index 79f59d5..615dcce 100644
--- a/app/db/schema.sql
+++ b/app/db/schema.sql
@@ -594,6 +594,15 @@ CREATE TABLE IF NOT EXISTS bb_pair_burns (
PRIMARY KEY (chain_id, tx_hash, log_index)
);
CREATE INDEX IF NOT EXISTS bb_pair_burns_app_idx ON bb_pair_burns (application_id);
+-- The end of the latest fee quote, kept when a landed burn spends the quote (that clears quote_until): another transfer
+-- sent under it may still be indexing, so a fee waiver waits for it as well.
+ALTER TABLE bb_pair_applications ADD COLUMN IF NOT EXISTS last_quote_until timestamptz;
+-- Backfill: a live quote's own end. Where a burn already spent the quote (that cleared quote_until) its end is unknown,
+-- but every such quote began before this runs, so it ends by now + 30 minutes: an application a waiver could still
+-- apply to waits that long. Later quotes record last_quote_until themselves.
+UPDATE bb_pair_applications SET last_quote_until = quote_until WHERE last_quote_until IS NULL AND quote_until IS NOT NULL;
+UPDATE bb_pair_applications a SET last_quote_until = now() + interval '30 minutes'
+ WHERE a.last_quote_until IS NULL AND a.status IN ('open', 'changes') AND EXISTS (SELECT 1 FROM bb_pair_burns b WHERE b.application_id = a.id);
-- ── admin sign-in (lib/admin) ───────────────────────────────────────────────
-- Server-issued nonces for Sign-In with Ethereum messages: 5 minutes, single use.
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..5c43d36 100644
--- a/app/src/components/launchpad/PairReviewAdmin.tsx
+++ b/app/src/components/launchpad/PairReviewAdmin.tsx
@@ -39,6 +39,14 @@ export default function PairReviewAdmin() {
const id = setTimeout(() => void load(), 0);
return () => clearTimeout(id);
}, [load]);
+ // a waiver held back while the applicant may be paying opens at its settling_until: reload the queue then, so the
+ // button shows without a manual reload (at least 15 s apart, should this device's clock run ahead of the server's)
+ useEffect(() => {
+ const next = Math.min(...(apps ?? []).map((a) => (a.settling_until ? Date.parse(a.settling_until) : Infinity)));
+ if (!Number.isFinite(next)) return;
+ const id = setTimeout(() => void load(), Math.max(15_000, next - nowMs() + 1_000));
+ return () => clearTimeout(id);
+ }, [apps, load]);
async function decide(id: number, decision: PairReviewDecision) {
if (!address || busy) return;
@@ -86,8 +94,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..34e1480 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\)\n\s*AND \(last_quote_until IS NULL OR last_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\) \?