Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions app/db/schema.sql
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
-- basebid.lol · free token launchpad on Base.
--
-- Applied by scripts/migrate.mjs (Fly release_command) against the DEDICATED
Expand Down Expand Up @@ -594,6 +594,15 @@
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.
Expand Down
4 changes: 2 additions & 2 deletions app/src/app/api/boost/mod/route.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -17,7 +17,7 @@
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 });
Expand Down
4 changes: 2 additions & 2 deletions app/src/app/api/pairs/mod/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -15,7 +15,7 @@
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 });
Expand Down
4 changes: 2 additions & 2 deletions app/src/app/api/pairs/review/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -11,7 +11,7 @@
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 });
Expand Down
4 changes: 2 additions & 2 deletions app/src/app/api/points/admin/route.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
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";

Expand Down Expand Up @@ -47,7 +47,7 @@
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 });
Expand Down
4 changes: 2 additions & 2 deletions app/src/app/api/profile/admin/route.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
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";

Expand Down Expand Up @@ -32,7 +32,7 @@
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 });
Expand Down
13 changes: 11 additions & 2 deletions app/src/components/launchpad/PairReviewAdmin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -86,8 +94,9 @@ export default function PairReviewAdmin() {
{a.status === "open" || a.status === "changes" ? (
<div className="flex flex-wrap gap-2">
<input aria-label={`Reason for closing ${a.symbol}`} value={reasons[a.id] ?? ""} onChange={(e) => 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) ? <button type="button" disabled={!address || busy} onClick={() => void decide(a.id, "waive")} className={btn.primarySm} title="List it now; the application is marked approved with the fee waived">Approve without fee</button> : 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 ? <span className="self-center text-xs text-muted">The applicant may be paying the fee now: waive it after {new Date(a.settling_until).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</span> : null}
{!isPaid(a.paid_usd, a.fee_usd) && !a.settling_until ? <button type="button" disabled={!address || busy} onClick={() => void decide(a.id, "waive")} className={btn.primarySm} title="List it now; the application is marked approved with the fee waived">Approve without fee</button> : null}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<button type="button" disabled={!address || busy || !(reasons[a.id] ?? "").trim()} onClick={() => void decide(a.id, "reject")} className={`${btn.secondarySm} text-down-ink`}>Close</button>
</div>
) : null}
Expand Down
34 changes: 23 additions & 11 deletions app/src/lib/admin/admin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, "../..");
Expand Down Expand Up @@ -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\}\)/);
});
Expand All @@ -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\);/);
Expand Down Expand Up @@ -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`);
}
}
Expand All @@ -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", () => {
Expand Down
Loading
Loading