From c75a4c9afc46f5edd81947a111dc0a8928611f0f Mon Sep 17 00:00:00 2001 From: dhkatz Date: Tue, 3 Nov 2026 04:15:47 -0800 Subject: [PATCH] feat: age verification with an ID document (Didit), and closed registration Age: a Didit provider behind the vendor-neutral age interface. A person verifies once (ID document, passive liveness, face match) and all their linked IMVU accounts count as adult. Webhooks are checked against Didit's signatures; only "over 18" is kept. A later answer replaces an earlier one (an approval taken back is revoked). Checks are limited: one unfinished check per person, 3 in 30 days, none once verified, and a monthly cap (DIDIT_MONTHLY_LIMIT, default 30: Didit's free plan is $10 of credit a month and a check costs $0.30). The Account page gets an Age Verification card. Registration: open or closed, an admin's setting in the console (default from REGISTRATION). Closed: new accounts can only sign in until a moderator approves them (or declines, deleting the account); the website shows them a waiting page and the API treats them as signed out. Co-Authored-By: Claude Opus 5.5 --- README.md | 7 +- apps/api/src/age/didit.ts | 123 + apps/api/src/age/imvu.ts | 1 + apps/api/src/age/index.ts | 97 +- apps/api/src/app.ts | 137 +- apps/api/src/auth.ts | 11 +- apps/api/src/index.ts | 45 +- apps/api/src/settings.ts | 29 + apps/api/test/age.test.ts | 153 +- apps/api/test/registration.test.ts | 104 + apps/web/app/account/actions.ts | 13 + apps/web/app/account/age-card.tsx | 68 + apps/web/app/account/page.tsx | 14 +- apps/web/app/console/actions.ts | 23 + apps/web/app/console/page.tsx | 45 +- apps/web/app/console/people/[id]/page.tsx | 32 +- apps/web/app/console/people/page.tsx | 43 +- apps/web/app/layout.tsx | 16 +- apps/web/app/signup/page.tsx | 9 +- apps/web/components/status.tsx | 4 +- apps/web/lib/api.ts | 1 + docker-compose.yml | 7 + docs/api.md | 16 +- docs/design.md | 27 +- packages/db/drizzle/0009_registration.sql | 7 + packages/db/drizzle/meta/0009_snapshot.json | 2301 +++++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/schema.ts | 9 + 28 files changed, 3260 insertions(+), 89 deletions(-) create mode 100644 apps/api/src/age/didit.ts create mode 100644 apps/api/src/settings.ts create mode 100644 apps/api/test/registration.test.ts create mode 100644 apps/web/app/account/age-card.tsx create mode 100644 packages/db/drizzle/0009_registration.sql create mode 100644 packages/db/drizzle/meta/0009_snapshot.json diff --git a/README.md b/README.md index 8b12618..ee7738a 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,14 @@ catalogs, T5DE-hosted rooms, and the moderation console. Part of T5DE (git.t5de. ## What works - **Accounts** (Better Auth): sign-up with email, username and password; sign-in by username. The first account is - an admin; roles user, moderator, admin. + an admin; roles user, moderator, admin. Registration open or closed (new accounts wait for a moderator's + approval): an admin's setting in the console, defaulting to the API's `REGISTRATION`. - **Linking IMVU accounts**: the person puts a code in the account's tagline; the hub reads it from IMVU's public API. The most recent proof wins (an account linked elsewhere moves, with a new pairwise ID). Link caps per person (default 10). -- **Age**: an IMVU account counts as adult if IMVU says it's age verified or has Access Pass, checked by the hub. +- **Age**: an IMVU account counts as adult if IMVU says it's age verified or has Access Pass, checked by the hub, or + if its person verified their age with an ID document and a selfie (Didit, when configured: `AGE_PROVIDERS`, see + `apps/api/src/index.ts` and docs/design.md, "Age"). - **Proxy sign-in**: OAuth 2.0 device authorization (`t5de-proxy signin` shows a code to enter at `/device`); the proxy then uses its session as a bearer token. JWTs with published keys (`/.well-known/jwks.json`) for catalogs. - **Presence**: signed-in proxies keep a WebSocket (`/api/v1/realtime`) telling the hub which IMVU account is in diff --git a/apps/api/src/age/didit.ts b/apps/api/src/age/didit.ts new file mode 100644 index 0000000..6a3cfcf --- /dev/null +++ b/apps/api/src/age/didit.ts @@ -0,0 +1,123 @@ +// Age from an ID document and a selfie, by Didit's hosted check (https://docs.didit.me). Its workflow (set up in +// Didit's console) checks the document, liveness and that the face matches the document; the hub only keeps whether +// the person is over 18, worked out from the age Didit read off the document. Didit keeps the rest. +// +// A check: the hub creates a session (vendor_data is the person's hub ID, so Didit gives an unfinished session +// again rather than a new one), sends the person to its page, and Didit reports the outcome by a signed webhook. +import { createHmac, timingSafeEqual } from "node:crypto"; +import type { AgeResult, Provider } from "./index"; + +export type DiditConfig = { + apiKey: string; + workflowId: string; + /** The webhook destination's secret_shared_key. */ + webhookSecret: string; + /** Checks started a month at most (what the month's credit pays for). */ + monthlyLimit?: number; + baseURL?: string; + fetch?: typeof fetch; +}; + +/** How far a webhook's X-Timestamp may be from now. */ +const timestampTolerance = 300; + +/** Statuses with an answer: a session approved, or one that will never be. The rest (in progress, in review) wait. */ +const failedStatuses = new Set(["Declined", "Abandoned", "Expired"]); + +type Decision = { id_verifications?: { age?: number | null; date_of_birth?: string | null }[] }; + +/** Age in whole years on a date, from an ISO birth date. */ +export function ageOn(birth: string, today: Date): number | undefined { + const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(birth); + if (!m) return undefined; + const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])]; + let age = today.getUTCFullYear() - y; + if (today.getUTCMonth() + 1 < mo || (today.getUTCMonth() + 1 === mo && today.getUTCDate() < d)) age--; + return age; +} + +/** The age on the session's ID document, or undefined if it has none. */ +function documentAge(decision: Decision | undefined): number | undefined { + const id = decision?.id_verifications?.[0]; + if (typeof id?.age === "number") return id.age; + return id?.date_of_birth ? ageOn(id.date_of_birth, new Date()) : undefined; +} + +/** Didit's V2 signature input: the body's JSON with keys sorted, compact. */ +function canonical(v: unknown): unknown { + if (Array.isArray(v)) return v.map(canonical); + if (v && typeof v === "object") + return Object.fromEntries(Object.keys(v).sort().map((k) => [k, canonical((v as Record)[k])])); + return v; +} + +function hmacMatches(secret: string, input: string, signature: string | null): boolean { + if (!signature) return false; + const expected = Buffer.from(createHmac("sha256", secret).update(input, "utf8").digest("hex")); + const given = Buffer.from(signature.trim().toLowerCase()); + return expected.length === given.length && timingSafeEqual(expected, given); +} + +export function diditAgeProvider(config: DiditConfig): Provider { + const base = (config.baseURL ?? "https://verification.didit.me").replace(/\/+$/, ""); + const fetcher = config.fetch ?? fetch; + const call = async (method: string, path: string, body?: unknown) => { + const res = await fetcher(base + path, { + method, + headers: { "x-api-key": config.apiKey, ...(body !== undefined ? { "Content-Type": "application/json" } : {}) }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + if (!res.ok) throw new Error(`Didit ${method} ${path}: ${res.status} ${await res.text()}`); + return res.json(); + }; + + return { + id: "didit", + label: "ID document and selfie", + vendor: "Didit", + scope: "person", + monthlyLimit: config.monthlyLimit, + + async start(subject, { returnURL }) { + const s = (await call("POST", "/v3/session/", { + workflow_id: config.workflowId, + vendor_data: subject.userId, + callback: returnURL, + })) as { session_id: string; url: string }; + return { kind: "redirect", url: s.url, ref: s.session_id }; + }, + + async webhook(request) { + const raw = await request.text(); + const timestamp = Number(request.headers.get("x-timestamp")); + if (!(Math.abs(Date.now() / 1000 - timestamp) <= timestampTolerance)) throw new Error("stale webhook"); + const body = JSON.parse(raw) as { + webhook_type?: string; + session_id?: string; + status?: string; + decision?: Decision; + }; + // The raw body's signature, else the V2 one (of the parsed body), each also with the previous secret's header + // during a rotation. + const v2 = JSON.stringify(canonical(body)); + const signed = ["", "-previous"].some( + (suffix) => + hmacMatches(config.webhookSecret, raw, request.headers.get(`x-signature${suffix}`)) || + hmacMatches(config.webhookSecret, v2, request.headers.get(`x-signature-v2${suffix}`)), + ); + if (!signed) throw new Error("bad signature"); + + if (body.webhook_type !== "status.updated" && body.webhook_type !== "data.updated") return null; + if (!body.session_id || !body.status) return null; + const ref = body.session_id; + if (failedStatuses.has(body.status)) return { ref, result: "failed" }; + if (body.status !== "Approved") return null; + const decision = + body.decision ?? ((await call("GET", `/v3/session/${encodeURIComponent(ref)}/decision/`)) as Decision); + const age = documentAge(decision); + if (age === undefined) return { ref, result: "failed" }; + const result: AgeResult = { overEighteen: age >= 18, method: "id_document", ref }; + return { ref, result }; + }, + }; +} diff --git a/apps/api/src/age/imvu.ts b/apps/api/src/age/imvu.ts index 33238b3..d12a7be 100644 --- a/apps/api/src/age/imvu.ts +++ b/apps/api/src/age/imvu.ts @@ -19,6 +19,7 @@ export function imvuAgeProvider(imvu: ImvuClient): Provider { return { id: "imvu", label: "IMVU's age verification or Access Pass", + vendor: "IMVU", scope: "imvu_account", start: async (subject) => ({ kind: "done", result: await check(subject) }), recheck: check, diff --git a/apps/api/src/age/index.ts b/apps/api/src/age/index.ts index 58d4687..5a07277 100644 --- a/apps/api/src/age/index.ts +++ b/apps/api/src/age/index.ts @@ -1,7 +1,7 @@ // Age verification, vendor-neutral: providers (IMVU, later phone wallets and Didit) plug in behind one interface, // results go in one table (age_check), and one rule says whether an IMVU account counts as adult. // See docs/design.md, "Age". -import { and, eq, gt, inArray, isNull, lte, or, schema, type Database } from "@t5de/db"; +import { and, count, eq, gt, gte, inArray, isNull, lte, or, schema, type Database } from "@t5de/db"; const { ageCheck } = schema; @@ -27,13 +27,19 @@ export type Started = export type Provider = { id: string; - /** Shown to people. */ + /** Shown to people: the way of checking, and who does it. */ label: string; + vendor: string; /** Whether it checks a person, or an IMVU account. */ scope: "person" | "imvu_account"; + /** At most this many checks started each calendar month (UTC), for a provider's free allowance; none: no cap. */ + monthlyLimit?: number; start(subject: Subject, options: { returnURL: string }): Promise; - /** Finishes a check from the provider's webhook; throws if the request isn't really from the provider. */ - webhook?(request: Request): Promise<{ ref: string; result: AgeResult | "failed" }>; + /** + * A check's answer from the provider's webhook, or null if it has none yet (still in progress or in review). Throws + * if the request isn't really from the provider. + */ + webhook?(request: Request): Promise<{ ref: string; result: AgeResult | "failed" } | null>; /** Finishes a check the browser completed (wallets): the browser's response for ref. */ complete?(ref: string, response: unknown): Promise; /** Checks a subject again, for answers that can change. */ @@ -41,6 +47,12 @@ export type Provider = { }; export class AgeError extends Error {} +/** A check that can't be started now; the message is for the person. */ +export class AgeRefused extends Error {} + +/** Checks of a person a provider will start for them in attemptWindow (failed and abandoned ones count). */ +export const attemptsPerPerson = 3; +const attemptWindow = 30 * 24 * 60 * 60 * 1000; const randomID = () => Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString("base64url"); @@ -56,7 +68,7 @@ export class AgeService { /** The providers in use, for the website. */ list() { - return [...this.providers.values()].map((p) => ({ id: p.id, label: p.label, scope: p.scope })); + return [...this.providers.values()].map((p) => ({ id: p.id, label: p.label, vendor: p.vendor, scope: p.scope })); } provider(id: string): Provider { @@ -87,13 +99,25 @@ export class AgeService { await this.db.insert(ageCheck).values({ id: randomID(), ...values }); } - /** Begins a check with a provider; checks answered at once are recorded now, the rest stay pending. */ + /** + * Begins a check with a provider; checks answered at once are recorded now, the rest stay pending. A person whose + * check is unfinished goes back to it (providers give the same session again); new checks of a person are + * limited, per person and by the provider's monthly allowance. + */ async start(subject: Subject, providerId: string, returnURL: string): Promise { const p = this.provider(providerId); + const ofPerson = subject.imvuId === undefined; + const pending = ofPerson ? await this.pendingCheck(subject.userId, p.id) : undefined; + if (ofPerson) { + if (await this.personAdult(subject.userId)) throw new AgeRefused("Your age is already verified."); + if (!pending) await this.checkLimits(subject.userId, p); + } const started = await p.start(subject, { returnURL }); if (started.kind === "done") { await this.record(subject, p.id, started.result); - } else { + } else if (pending?.providerRef !== started.ref) { + // An unfinished check the provider replaced with a new one (it expired there) won't be answered. + if (pending) await this.db.update(ageCheck).set({ status: "failed", checkedAt: new Date() }).where(eq(ageCheck.id, pending.id)); await this.db.insert(ageCheck).values({ id: randomID(), userId: subject.userId, @@ -106,11 +130,55 @@ export class AgeService { return started; } - /** Finishes a pending check (from a webhook or the browser). */ - async finish(providerId: string, ref: string, result: AgeResult | "failed") { + private async pendingCheck(userId: string, provider: string) { + const [row] = await this.db + .select() + .from(ageCheck) + .where( + and(eq(ageCheck.userId, userId), eq(ageCheck.provider, provider), isNull(ageCheck.imvuId), eq(ageCheck.status, "pending")), + ); + return row; + } + + private async personAdult(userId: string) { + const now = new Date(); + return (await this.personChecks(userId)).some( + (c) => c.status === "complete" && c.overEighteen && (c.expiresAt === null || c.expiresAt > now), + ); + } + + private async checkLimits(userId: string, p: Provider) { + const started = async (...where: Parameters) => + (await this.db.select({ n: count() }).from(ageCheck).where(and(eq(ageCheck.provider, p.id), isNull(ageCheck.imvuId), ...where)))[0]!.n; + const since = new Date(Date.now() - attemptWindow); + if ((await started(eq(ageCheck.userId, userId), gte(ageCheck.createdAt, since))) >= attemptsPerPerson) + throw new AgeRefused(`You've started ${attemptsPerPerson} checks in the last 30 days. Try again later.`); + if (p.monthlyLimit !== undefined) { + const now = new Date(); + const month = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); + if ((await started(gte(ageCheck.createdAt, month))) >= p.monthlyLimit) + throw new AgeRefused("This month's ID checks are used up. Try again next month."); + } + } + + /** + * Finishes a check. From the browser (wallets), only a pending check; from a provider's webhook (anyStatus), also + * one already answered, since the provider can change its answer (say, after a manual review). An adult answer + * taken back is revoked. + */ + async finish(providerId: string, ref: string, result: AgeResult | "failed", { anyStatus = false } = {}) { + const [row] = await this.db + .select() + .from(ageCheck) + .where(and(eq(ageCheck.provider, providerId), eq(ageCheck.providerRef, ref))); + if (!row || (!anyStatus && row.status !== "pending")) throw new AgeError("no such pending check"); const done = result === "failed" - ? { status: "failed", overEighteen: false, checkedAt: new Date() } + ? { + status: (row.status === "complete" && row.overEighteen) || row.status === "revoked" ? "revoked" : "failed", + overEighteen: false, + checkedAt: new Date(), + } : { status: "complete", overEighteen: result.overEighteen, @@ -118,13 +186,8 @@ export class AgeService { checkedAt: new Date(), expiresAt: result.expiresAt ?? null, }; - const rows = await this.db - .update(ageCheck) - .set(done) - .where(and(eq(ageCheck.provider, providerId), eq(ageCheck.providerRef, ref), eq(ageCheck.status, "pending"))) - .returning(); - if (rows.length === 0) throw new AgeError("no such pending check"); - return rows[0]!; + const [updated] = await this.db.update(ageCheck).set(done).where(eq(ageCheck.id, row.id)).returning(); + return updated!; } /** Removes an IMVU account's checks (it was unlinked or moved to another person). */ diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 7a38c4c..7c52b50 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -19,7 +19,7 @@ import { type RoomModerator, } from "@t5de/db"; import type { Auth } from "./auth"; -import { AgeError, AgeService } from "./age"; +import { AgeError, AgeRefused, AgeService } from "./age"; import { imvuAgeProvider, imvuResult } from "./age/imvu"; import { ImvuClient, ImvuUnavailable, NoSuchImvuUser } from "./imvu"; import { Realtime } from "./realtime"; @@ -40,6 +40,7 @@ import { type Viewer, } from "./hosted"; import { DecorationError, Decorations, maxBatch, maxDecorationPlacement, maxDecorations } from "./decorations"; +import { Settings } from "./settings"; const { user, session, imvuLink, linkChallenge, audit } = schema; @@ -52,6 +53,10 @@ export type Deps = { rooms?: Rooms; /** The hub's API URL, the issuer of its tokens. */ apiURL?: string; + /** The website's address: providers send people back only to it. */ + webURL?: string; + /** The hub's settings (default: kept in the database, with no environment defaults). */ + settings?: Settings; }; /** An error shown to the person: the website displays message as is. */ @@ -102,8 +107,19 @@ class Limiter { } } -export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms: roomsService, apiURL }: Deps) { +export function createApp({ + db, + auth, + imvu, + realtime: rt, + age: ageService, + rooms: roomsService, + apiURL, + webURL, + settings: hubSettings, +}: Deps) { const age = ageService ?? new AgeService(db, [imvuAgeProvider(imvu)]); + const settings = hubSettings ?? new Settings(db); const signer = new Signer(db, apiURL ?? "http://localhost:4101"); const products = new Products(db, signer, imvu); const rooms = roomsService ?? new Rooms(db, imvu); @@ -150,6 +166,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms createdAt: p.createdAt.toISOString(), banned: p.banned === true, banReason: p.banReason ?? "", + approved: p.approved, }); /** A person's picture, for the website's header: their first linked IMVU account's avatar picture (null if none, @@ -449,17 +466,33 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms .derive(async ({ request }) => { const s = await auth.api.getSession({ headers: request.headers }); const person = s?.user ? ((await db.select().from(user).where(eq(user.id, s.user.id)))[0] ?? undefined) : undefined; - return { person: person && !person.banned ? person : undefined, sessionId: s?.session.id }; + // A person waiting for approval counts as signed out everywhere but their own account (/me). + const usable = person && !person.banned ? person : undefined; + return { + person: usable?.approved ? usable : undefined, + waiting: usable && !usable.approved ? usable : undefined, + sessionId: s?.session.id, + }; }) .macro({ + /** Signed in, approved or still waiting. */ + account: { + resolve: ({ person, waiting }) => { + const me = person ?? waiting; + if (!me) throw new Problem(401, "signed_out", "Sign in first."); + return { me }; + }, + }, signedIn: { - resolve: ({ person }) => { + resolve: ({ person, waiting }) => { + if (waiting) throw new Problem(403, "awaiting_approval", "Your account is waiting for a moderator's approval."); if (!person) throw new Problem(401, "signed_out", "Sign in first."); return { me: person }; }, }, moderator: { - resolve: ({ person }) => { + resolve: ({ person, waiting }) => { + if (waiting) throw new Problem(403, "awaiting_approval", "Your account is waiting for a moderator's approval."); if (!person) throw new Problem(401, "signed_out", "Sign in first."); if (!isModerator(person)) throw new Problem(403, "forbidden", "Only moderators can do this."); return { me: person }; @@ -468,7 +501,9 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms }) // Who's signed in, and the home page. - .get("/me", async ({ me }) => ({ person: { ...personView(me), picture: await picture(me.id) } }), { signedIn: true }) + .get("/me", async ({ me }) => ({ person: { ...personView(me), picture: await picture(me.id) } }), { account: true }) + // The hub's public settings: whether new accounts wait for approval. + .get("/hub", async () => ({ registration: await settings.registration() })) .get("/home", async ({ person }) => { const online = (await visibleOnline(person)).length; if (!person) return { onlineCount: online }; @@ -1283,7 +1318,23 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms throw new Problem(404, "not_found", "That way of verifying age isn't available."); } if (p.scope !== "person") throw new Problem(400, "invalid", "That one checks an IMVU account: link it instead."); - const started = await age.start({ userId: me.id }, p.id, body.returnURL); + // A path on the website, or an address on it. + const web = new URL(webURL ?? "http://localhost:4100"); + let back: URL | undefined; + try { + back = new URL(body.returnURL, web); + } catch {} + if (!back || back.origin !== web.origin) + throw new Problem(400, "invalid", "The return address must be on the hub's website."); + let started; + try { + started = await age.start({ userId: me.id }, p.id, back.href); + } catch (e) { + if (e instanceof AgeRefused) throw new Problem(409, "refused", e.message); + console.error(`age ${p.id}:`, e); + throw new Problem(502, "provider_unavailable", "The age check didn't start. Try again in a moment."); + } + await audited(me.id, "age.start", p.id, { ref: started.kind === "done" ? null : started.ref }); return started.kind === "done" ? { kind: "done" as const } : started; }, { signedIn: true, params: t.Object({ provider: t.String() }), body: t.Object({ returnURL: t.String({ maxLength: 500 }) }) }, @@ -1317,13 +1368,17 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms let answer; try { answer = await p.webhook(request); - } catch { + } catch (e) { + // Not from the provider, or its answer couldn't be read: either way the provider tries again later. + console.error(`age webhook ${p.id}:`, e instanceof Error ? e.message : e); throw new Problem(401, "forbidden", "Not from the provider."); } - try { - await age.finish(p.id, answer.ref, answer.result); - } catch (e) { - if (!(e instanceof AgeError)) throw e; // already finished: webhooks can repeat + if (answer) { + try { + await age.finish(p.id, answer.ref, answer.result, { anyStatus: true }); + } catch (e) { + if (!(e instanceof AgeError)) throw e; // a check that isn't the hub's (another environment's) + } } set.status = 204; }, @@ -1353,6 +1408,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms const [counts] = await db .select({ people: sql`(select count(*) from ${user})::int`, + waiting: sql`(select count(*) from ${user} where not ${user.approved} and coalesce(${user.banned}, false) = false)::int`, links: sql`(select count(*) from ${imvuLink})::int`, computers: sql`(select count(*) from ${session} where ${session.userAgent} ilike 't5de-proxy%' and ${session.expiresAt} > now())::int`, }) @@ -1381,18 +1437,61 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms .select() .from(user) .where( - q - ? or( - ilike(user.username, like), - inArray(user.id, db.select({ id: imvuLink.userId }).from(imvuLink).where(ilike(imvuLink.username, like))), - ) - : undefined, + and( + q + ? or( + ilike(user.username, like), + inArray(user.id, db.select({ id: imvuLink.userId }).from(imvuLink).where(ilike(imvuLink.username, like))), + ) + : undefined, + query.waiting ? and(eq(user.approved, false), sql`coalesce(${user.banned}, false) = false`) : undefined, + ), ) .orderBy(desc(user.createdAt)) .limit(100); return { people: rows.map(personView) }; }, - { moderator: true, query: t.Object({ q: t.Optional(t.String({ maxLength: 64 })) }) }, + { + moderator: true, + query: t.Object({ q: t.Optional(t.String({ maxLength: 64 })), waiting: t.Optional(t.BooleanString()) }), + }, + ) + // Accounts waiting for approval (registration closed): approve, or decline (the account is deleted). + .post( + "/console/people/:id/approve", + async ({ me, params }) => { + const p = await personById(params.id); + const [updated] = await db.update(user).set({ approved: true }).where(eq(user.id, p.id)).returning(); + if (!p.approved) await audited(me.id, "person.approve", p.username ?? p.id); + return { person: personView(updated!) }; + }, + { moderator: true }, + ) + .post( + "/console/people/:id/decline", + async ({ me, params }) => { + const p = await personById(params.id); + if (p.approved) throw new Problem(400, "invalid", "Only accounts waiting for approval can be declined."); + await db.delete(user).where(eq(user.id, p.id)); + await audited(me.id, "person.decline", p.username ?? p.id); + return { declined: true }; + }, + { moderator: true }, + ) + .get("/console/settings", async () => ({ registration: await settings.registration() }), { moderator: true }) + .put( + "/console/settings", + async ({ me, body }) => { + if (me.role !== "admin") throw new Problem(403, "forbidden", "Only admins can change the hub's settings."); + const from = await settings.registration(); + await settings.setRegistration(body.registration); + if (from !== body.registration) await audited(me.id, "hub.registration", body.registration, { from }); + return { registration: body.registration }; + }, + { + moderator: true, + body: t.Object({ registration: t.Union([t.Literal("open"), t.Literal("closed")]) }), + }, ) .get( "/console/people/:id", diff --git a/apps/api/src/auth.ts b/apps/api/src/auth.ts index 4a0a7b8..65dae89 100644 --- a/apps/api/src/auth.ts +++ b/apps/api/src/auth.ts @@ -5,6 +5,7 @@ import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { admin, bearer, deviceAuthorization, jwt, username } from "better-auth/plugins"; import { count, schema, type Database } from "@t5de/db"; +import { Settings } from "./settings"; export type AuthConfig = { /** The API's own address, e.g. https://hub-api.t5de.dev. */ @@ -15,12 +16,15 @@ export type AuthConfig = { secret: string; /** Rate limits on sign-up, sign-in and the rest (on unless false: tests turn them off). */ rateLimit?: boolean; + /** Whether new accounts wait for approval (default: the hub's settings, with no environment defaults). */ + settings?: Settings; }; /** The proxy's client ID in device sign-in. */ export const proxyClientID = "t5de-proxy"; export function createAuth(db: Database, config: AuthConfig) { + const settings = config.settings ?? new Settings(db); return betterAuth({ baseURL: config.apiURL, basePath: "/api/auth", @@ -31,6 +35,7 @@ export function createAuth(db: Database, config: AuthConfig) { user: { additionalFields: { linkCap: { type: "number", defaultValue: 10, input: false }, + approved: { type: "boolean", defaultValue: true, input: false }, }, }, // Proxies stay signed in for months; a session is extended when it's used. @@ -40,10 +45,12 @@ export function createAuth(db: Database, config: AuthConfig) { databaseHooks: { user: { create: { - // The first account runs the hub. + // The first account runs the hub; with registration closed, the others wait for approval. before: async (u) => { const [row] = await db.select({ n: count() }).from(schema.user); - return { data: { ...u, role: (row?.n ?? 0) === 0 ? "admin" : "user" } }; + const first = (row?.n ?? 0) === 0; + const approved = first || (await settings.registration()) === "open"; + return { data: { ...u, role: first ? "admin" : "user", approved } }; }, }, }, diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index c98ed76..1276ee1 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -4,9 +4,19 @@ // API_URL the API's address as the website and proxies reach it (default http://localhost:4101) // WEB_URL the website's address (default http://localhost:4100) // PORT default 4101 +// REGISTRATION open or closed (new accounts wait for a moderator's approval), until an admin sets it in the console +// AGE_PROVIDERS ways to verify age, comma-separated (default imvu): imvu, didit +// DIDIT_API_KEY, DIDIT_WORKFLOW_ID, DIDIT_WEBHOOK_SECRET +// Didit's ID document check (docs/design.md, "Age"); its webhook is API_URL/api/v1/age/webhook/didit +// DIDIT_MONTHLY_LIMIT checks started a month at most (default 30: Didit's free plan has $10 of credit a month, and a +// check with ID document, liveness and face match costs $0.30) import { openDatabase } from "@t5de/db"; +import { AgeService, type Provider } from "./age"; +import { diditAgeProvider } from "./age/didit"; +import { imvuAgeProvider } from "./age/imvu"; import { createApp } from "./app"; import { createAuth } from "./auth"; +import { Settings } from "./settings"; import { ImvuClient } from "./imvu"; const env = process.env; @@ -21,8 +31,39 @@ const apiURL = env.API_URL ?? `http://localhost:${port}`; const webURL = env.WEB_URL ?? "http://localhost:4100"; const { db } = await openDatabase(env.DATABASE_URL ?? "./data/db"); -const auth = createAuth(db, { apiURL, webURL, secret }); -const { app, age, rooms, products } = createApp({ db, auth, imvu: new ImvuClient(), apiURL }); +const settings = new Settings(db, { registration: env.REGISTRATION }); +const auth = createAuth(db, { apiURL, webURL, secret, settings }); +const imvu = new ImvuClient(); + +function required(name: string): string { + const v = env[name]; + if (!v) { + console.error(`${name} must be set`); + process.exit(1); + } + return v; +} +const ageProviders: Record Provider> = { + imvu: () => imvuAgeProvider(imvu), + didit: () => + diditAgeProvider({ + apiKey: required("DIDIT_API_KEY"), + workflowId: required("DIDIT_WORKFLOW_ID"), + webhookSecret: required("DIDIT_WEBHOOK_SECRET"), + monthlyLimit: Number(env.DIDIT_MONTHLY_LIMIT ?? 30), + }), +}; +const providers = (env.AGE_PROVIDERS ?? "imvu").split(",").map((id) => id.trim()).filter(Boolean); +if (!providers.includes("imvu")) providers.unshift("imvu"); // linking records IMVU's answer +for (const id of providers) { + if (!ageProviders[id]) { + console.error(`AGE_PROVIDERS: no provider ${id}`); + process.exit(1); + } +} +const ageService = new AgeService(db, providers.map((id) => ageProviders[id]!())); + +const { app, age, rooms, products } = createApp({ db, auth, imvu, age: ageService, apiURL, webURL, settings }); app.listen({ port, hostname: env.HOST ?? "0.0.0.0" }); // Age answers that can change (IMVU's) are checked again when they expire, a few at a time; revisions missing their diff --git a/apps/api/src/settings.ts b/apps/api/src/settings.ts new file mode 100644 index 0000000..243f8a6 --- /dev/null +++ b/apps/api/src/settings.ts @@ -0,0 +1,29 @@ +// The hub's settings that admins change from the console, kept in hub_setting. A setting never changed there has its +// default from the environment (so a new staging hub can start with registration closed). +import { eq, schema, type Database } from "@t5de/db"; + +const { hubSetting } = schema; + +/** Open: new accounts can use the hub at once. Closed: they can sign in, and wait for a moderator's approval. */ +export type Registration = "open" | "closed"; +export const registrations: readonly Registration[] = ["open", "closed"]; + +export class Settings { + constructor( + private readonly db: Database, + private readonly defaults: { registration?: string } = {}, + ) {} + + async registration(): Promise { + const [row] = await this.db.select().from(hubSetting).where(eq(hubSetting.key, "registration")); + const value = row?.value ?? this.defaults.registration; + return value === "closed" ? "closed" : "open"; + } + + async setRegistration(value: Registration) { + await this.db + .insert(hubSetting) + .values({ key: "registration", value }) + .onConflictDoUpdate({ target: hubSetting.key, set: { value, updatedAt: new Date() } }); + } +} diff --git a/apps/api/test/age.test.ts b/apps/api/test/age.test.ts index 455d983..d82c7b8 100644 --- a/apps/api/test/age.test.ts +++ b/apps/api/test/age.test.ts @@ -1,8 +1,10 @@ -// Age verification: the one rule, providers that check a person (here a fake document-check vendor with signed -// webhooks), and IMVU's answers changing. +// Age verification: the one rule, providers that check a person (a fake document-check vendor with signed webhooks, +// and Didit's adapter against a fake Didit), limits on checks, and IMVU's answers changing. +import { createHmac } from "node:crypto"; import { afterAll, beforeAll, expect, test } from "bun:test"; -import { openDatabase, schema, type Database } from "@t5de/db"; -import { AgeService, type Provider } from "../src/age"; +import { eq, openDatabase, schema, type Database } from "@t5de/db"; +import { AgeService, attemptsPerPerson, type Provider } from "../src/age"; +import { ageOn, diditAgeProvider } from "../src/age/didit"; import { imvuAgeProvider } from "../src/age/imvu"; import { createApp } from "../src/app"; import { createAuth } from "../src/auth"; @@ -27,6 +29,7 @@ const webhookSecret = "vendor-secret"; const fakeVendor: Provider = { id: "vendor", label: "ID document", + vendor: "Vendor", scope: "person", start: async () => { const ref = `session-${crypto.randomUUID()}`; @@ -39,6 +42,45 @@ const fakeVendor: Provider = { }, }; +// Didit: sessions per person (an unfinished one is given again), and each session's decision. +const diditSecret = "didit-secret"; +const diditSessions = new Map(); +const fakeDidit: typeof fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(String(input)); + if (new Headers(init?.headers).get("x-api-key") !== "didit-key") return new Response("no key", { status: 401 }); + if (init?.method === "POST" && url.pathname === "/v3/session/") { + const body = JSON.parse(String(init.body)); + expect(body.workflow_id).toBe("workflow"); + let s = [...diditSessions.values()].find((s) => s.vendor === body.vendor_data && !s.finished); + if (!s) { + s = { id: crypto.randomUUID(), vendor: body.vendor_data, finished: false }; + diditSessions.set(s.id, s); + } + return Response.json({ session_id: s.id, url: `https://verify.didit.test/session/${s.id}`, status: "Not Started" }, { status: 201 }); + } + const m = /^\/v3\/session\/([^/]+)\/decision\/$/.exec(url.pathname); + if (m && diditSessions.get(m[1]!)) return Response.json(diditSessions.get(m[1]!)!.decision ?? {}); + return new Response("not found", { status: 404 }); +}) as typeof fetch; +const didit = diditAgeProvider({ apiKey: "didit-key", workflowId: "workflow", webhookSecret: diditSecret, baseURL: "https://didit.test", fetch: fakeDidit }); + +/** A Didit webhook request, signed as Didit signs it (the raw body, or V2), or forged. */ +function diditHook(body: Record, sign: "raw" | "v2" | "forged" = "raw", timestamp = Math.floor(Date.now() / 1000)) { + const raw = JSON.stringify(body); + const hmac = (s: string, key = diditSecret) => createHmac("sha256", key).update(s).digest("hex"); + const sortKeys = (v: unknown): unknown => + Array.isArray(v) + ? v.map(sortKeys) + : v && typeof v === "object" + ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, sortKeys((v as Record)[k])])) + : v; + const headers: Record = { "X-Timestamp": String(timestamp), "Content-Type": "application/json" }; + if (sign === "raw") headers["X-Signature"] = hmac(raw); + if (sign === "v2") headers["X-Signature-V2"] = hmac(JSON.stringify(sortKeys(body))); + if (sign === "forged") headers["X-Signature"] = hmac(raw, "wrong"); + return fetch(`${base}/api/v1/age/webhook/didit`, { method: "POST", headers, body: raw }); +} + let db: Database; let close: () => Promise; let base: string; @@ -49,8 +91,8 @@ beforeAll(async () => { ({ db, close } = await openDatabase("memory")); const auth = createAuth(db, { apiURL: "http://api.test", webURL, secret: "test-secret-test-secret-test-secret-1234", rateLimit: false }); const imvu = new ImvuClient("http://imvu.test", fakeIMVU); - age = new AgeService(db, [imvuAgeProvider(imvu), fakeVendor]); - const hub = createApp({ db, auth, imvu, age }); + age = new AgeService(db, [imvuAgeProvider(imvu), fakeVendor, didit]); + const hub = createApp({ db, auth, imvu, age, webURL }); hub.app.listen({ port: 0, hostname: "127.0.0.1" }); const server = hub.app.server as unknown as { port: number; stop: () => void }; stop = () => server.stop(); @@ -86,6 +128,13 @@ async function personWithAccounts(name: string, ids: { id: number; ageVerified?: return cookie; } +const userOf = async (name: string) => (await db.select().from(schema.user)).find((u) => u.username === name)!.id; +const startedThisMonth = async () => { + const now = new Date(); + const month = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); + return (await db.select().from(schema.ageCheck).where(eq(schema.ageCheck.provider, "didit"))).filter((r) => r.createdAt >= month).length; +}; + const adultOf = async (cookie: { v: string }) => Object.fromEntries(((await call(cookie, "GET", "/api/v1/account")).json.links as any[]).map((l) => [l.imvuId, l.adult])); @@ -102,7 +151,7 @@ test("IMVU's answer is per account, and re-checked when it expires", async () => test("a person's check covers all their accounts; webhooks must be signed", async () => { const b = await personWithAccounts("ben", [{ id: 10 }, { id: 11 }]); - expect((await call(b, "GET", "/api/v1/age")).json.providers.map((p: any) => p.id)).toEqual(["imvu", "vendor"]); + expect((await call(b, "GET", "/api/v1/age")).json.providers.map((p: any) => p.id)).toEqual(["imvu", "vendor", "didit"]); // IMVU checks accounts, not people: it can't be started this way. expect((await call(b, "POST", "/api/v1/age/imvu/start", { returnURL: `${webURL}/account` })).status).toBe(400); @@ -118,6 +167,96 @@ test("a person's check covers all their accounts; webhooks must be signed", asyn expect(await adultOf(b)).toEqual({ 10: true, 11: true }); // A repeated webhook is harmless. expect((await hook(webhookSecret, true)).status).toBe(204); + // Verified already: no more checks, with this provider or another. + const again = await call(b, "POST", "/api/v1/age/didit/start", { returnURL: `${webURL}/account` }); + expect(again).toMatchObject({ status: 409, json: { error: "refused" } }); +}); + +test("people are sent back only to the hub's website", async () => { + const e = await personWithAccounts("eve", []); + expect((await call(e, "POST", "/api/v1/age/didit/start", { returnURL: "https://evil.test/account" })).status).toBe(400); + expect((await call(e, "POST", "/api/v1/age/didit/start", { returnURL: "//evil.test/account" })).status).toBe(400); + // A path is on the website. + expect((await call(e, "POST", "/api/v1/age/didit/start", { returnURL: "/account?age=back" })).json.kind).toBe("redirect"); +}); + +test("Didit: signed webhooks, the age on the document, and answers changed later", async () => { + const f = await personWithAccounts("fay", [{ id: 30 }]); + const started = (await call(f, "POST", "/api/v1/age/didit/start", { returnURL: `${webURL}/account?age=didit` })).json; + expect(started).toMatchObject({ kind: "redirect", url: `https://verify.didit.test/session/${started.ref}` }); + // Going back to an unfinished check gives the same session, and doesn't count as another. + const resumed = (await call(f, "POST", "/api/v1/age/didit/start", { returnURL: `${webURL}/account` })).json; + expect(resumed.ref).toBe(started.ref); + expect((await call(f, "GET", "/api/v1/age")).json.checks).toHaveLength(1); + + const event = (status: string, decision?: unknown) => ({ + event_id: crypto.randomUUID(), + webhook_type: "status.updated", + session_id: started.ref, + status, + timestamp: Math.floor(Date.now() / 1000), + ...(decision ? { decision } : {}), + }); + const approved = event("Approved", { id_verifications: [{ status: "Approved", age: 31, date_of_birth: "1995-01-01" }] }); + expect((await diditHook(approved, "forged")).status).toBe(401); + expect((await diditHook(approved, "raw", Math.floor(Date.now() / 1000) - 3600)).status).toBe(401); // stale + // In review: no answer yet. + expect((await diditHook(event("In Review"))).status).toBe(204); + expect((await call(f, "GET", "/api/v1/age")).json.checks[0].status).toBe("pending"); + expect(await adultOf(f)).toEqual({ 30: false }); + + expect((await diditHook(approved, "v2")).status).toBe(204); + expect(await adultOf(f)).toEqual({ 30: true }); + const [row] = await db.select().from(schema.ageCheck).where(eq(schema.ageCheck.providerRef, started.ref)); + expect(row).toMatchObject({ provider: "didit", method: "id_document", status: "complete", overEighteen: true, imvuId: null }); + + // A reviewer at Didit declines it afterwards: the answer is taken back. + expect((await diditHook(event("Declined"))).status).toBe(204); + expect(await adultOf(f)).toEqual({ 30: false }); + expect((await call(f, "GET", "/api/v1/age")).json.checks[0].status).toBe("revoked"); +}); + +test("Didit: under 18, and a decision fetched when the webhook has none", async () => { + const g = await personWithAccounts("gus", [{ id: 40 }]); + const started = (await call(g, "POST", "/api/v1/age/didit/start", { returnURL: `${webURL}/account` })).json; + const session = diditSessions.get(started.ref)!; + session.finished = true; + session.decision = { id_verifications: [{ date_of_birth: `${new Date().getUTCFullYear() - 17}-12-31` }] }; + const body = { webhook_type: "status.updated", session_id: started.ref, status: "Approved", timestamp: 0 }; + expect((await diditHook(body)).status).toBe(204); + expect(await adultOf(g)).toEqual({ 40: false }); + expect((await call(g, "GET", "/api/v1/age")).json.checks[0]).toMatchObject({ status: "complete", overEighteen: false }); +}); + +test("checks are limited per person and by a provider's monthly allowance", async () => { + const h = await personWithAccounts("hal", []); + // Each abandoned check is a new one. + for (let i = 0; i < attemptsPerPerson; i++) { + const s = (await call(h, "POST", "/api/v1/age/didit/start", { returnURL: `${webURL}/account` })).json; + diditSessions.get(s.ref)!.finished = true; + const body = { webhook_type: "status.updated", session_id: s.ref, status: "Abandoned", timestamp: 0 }; + expect((await diditHook(body)).status).toBe(204); + } + const limited = await call(h, "POST", "/api/v1/age/didit/start", { returnURL: `${webURL}/account` }); + expect(limited).toMatchObject({ status: 409, json: { error: "refused" } }); + expect(limited.json.message).toContain("30 days"); + + // The month's allowance is everyone's (here a service with room for one more). + const capped = new AgeService(db, [{ ...didit, monthlyLimit: (await startedThisMonth()) + 1 }]); + await personWithAccounts("ivy", []); + await personWithAccounts("joe", []); + const ivy = await capped.start({ userId: await userOf("ivy") }, "didit", `${webURL}/account`); + await expect(capped.start({ userId: await userOf("joe") }, "didit", `${webURL}/account`)).rejects.toThrow("used up"); + // Ivy can still go back to hers. + expect(await capped.start({ userId: await userOf("ivy") }, "didit", `${webURL}/account`)).toEqual(ivy); +}); + +test("birth dates to ages", () => { + const day = new Date("2026-11-03T12:00:00Z"); + expect(ageOn("2008-11-03", day)).toBe(18); + expect(ageOn("2008-11-04", day)).toBe(17); + expect(ageOn("2008-12-01", day)).toBe(17); + expect(ageOn("nonsense", day)).toBeUndefined(); }); test("a moved account takes nobody's age with it", async () => { diff --git a/apps/api/test/registration.test.ts b/apps/api/test/registration.test.ts new file mode 100644 index 0000000..f55a3b8 --- /dev/null +++ b/apps/api/test/registration.test.ts @@ -0,0 +1,104 @@ +// Registration: open (new accounts can use the hub at once) or closed (they sign in, and wait for a moderator's +// approval); admins switch it in the console, and the environment gives the default. +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { openDatabase, type Database } from "@t5de/db"; +import { createApp } from "../src/app"; +import { createAuth } from "../src/auth"; +import { ImvuClient } from "../src/imvu"; +import { Settings } from "../src/settings"; + +const webURL = "http://web.test"; +let db: Database; +let close: () => Promise; +let base: string; +let stop: () => void; + +beforeAll(async () => { + ({ db, close } = await openDatabase("memory")); + // Closed by default (as on staging), until an admin changes it. + const settings = new Settings(db, { registration: "closed" }); + const auth = createAuth(db, { apiURL: "http://api.test", webURL, secret: "test-secret-test-secret-test-secret-1234", rateLimit: false, settings }); + const imvu = new ImvuClient("http://imvu.test", (async () => Response.json({ denormalized: {} })) as unknown as typeof fetch); + const hub = createApp({ db, auth, imvu, webURL, settings }); + hub.app.listen({ port: 0, hostname: "127.0.0.1" }); + const server = hub.app.server as unknown as { port: number; stop: () => void }; + stop = () => server.stop(); + base = `http://127.0.0.1:${server.port}`; +}); + +afterAll(async () => { + stop(); + await close(); +}); + +async function call(cookie: { v: string }, method: string, path: string, body?: unknown) { + const res = await fetch(base + path, { + method, + headers: { Origin: webURL, ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...(cookie.v ? { Cookie: cookie.v } : {}) }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + const set = res.headers.getSetCookie(); + if (set.length) cookie.v = set.map((c) => c.split(";")[0]).join("; "); + const text = await res.text(); + return { status: res.status, json: text ? JSON.parse(text) : undefined }; +} + +async function signUp(name: string) { + const cookie = { v: "" }; + expect((await call(cookie, "POST", "/api/auth/sign-up/email", { email: `${name}@example.test`, password: "a long password", name, username: name })).status).toBe(200); + return cookie; +} + +test("closed: new accounts wait for approval; the first one runs the hub", async () => { + expect((await call({ v: "" }, "GET", "/api/v1/hub")).json).toEqual({ registration: "closed" }); + const admin = await signUp("ada"); + expect((await call(admin, "GET", "/api/v1/me")).json.person).toMatchObject({ role: "admin", approved: true }); + expect((await call(admin, "GET", "/api/v1/account")).status).toBe(200); + + const bo = await signUp("bo_"); + // Signed in, but nothing else. + expect((await call(bo, "GET", "/api/v1/me")).json.person).toMatchObject({ role: "user", approved: false }); + expect(await call(bo, "GET", "/api/v1/account")).toMatchObject({ status: 403, json: { error: "awaiting_approval" } }); + expect((await call(bo, "POST", "/api/v1/account/links", { username: "someone" })).status).toBe(403); + expect((await call(bo, "GET", "/api/v1/console")).status).toBe(403); + + // Moderators see who's waiting, and approve them. + expect((await call(admin, "GET", "/api/v1/console")).json.counts.waiting).toBe(1); + const waiting = (await call(admin, "GET", "/api/v1/console/people?waiting=true")).json.people; + expect(waiting.map((p: any) => p.username)).toEqual(["bo_"]); + expect((await call(admin, "POST", `/api/v1/console/people/${waiting[0].id}/approve`)).json.person.approved).toBe(true); + expect((await call(bo, "GET", "/api/v1/account")).status).toBe(200); + expect((await call(admin, "GET", "/api/v1/console/people?waiting=true")).json.people).toEqual([]); + // An approved account can't be declined. + expect((await call(admin, "POST", `/api/v1/console/people/${waiting[0].id}/decline`)).status).toBe(400); +}); + +test("declining deletes the account", async () => { + const admin = { v: "" }; + await call(admin, "POST", "/api/auth/sign-in/username", { username: "ada", password: "a long password" }); + const cy = await signUp("cyd"); + const [p] = (await call(admin, "GET", "/api/v1/console/people?waiting=true")).json.people; + expect(p.username).toBe("cyd"); + expect((await call(admin, "POST", `/api/v1/console/people/${p.id}/decline`)).status).toBe(200); + expect((await call(cy, "GET", "/api/v1/me")).status).toBe(401); + expect((await call({ v: "" }, "POST", "/api/auth/sign-in/username", { username: "cyd", password: "a long password" })).status).toBe(401); +}); + +test("only admins open or close registration", async () => { + const admin = { v: "" }; + await call(admin, "POST", "/api/auth/sign-in/username", { username: "ada", password: "a long password" }); + const mod = await signUp("dot"); + const dot = (await call(admin, "GET", "/api/v1/console/people?waiting=true")).json.people[0]; + await call(admin, "POST", `/api/v1/console/people/${dot.id}/approve`); + await call(admin, "POST", `/api/v1/console/people/${dot.id}/role`, { role: "moderator" }); + expect((await call(mod, "GET", "/api/v1/console/settings")).json).toEqual({ registration: "closed" }); + expect((await call(mod, "PUT", "/api/v1/console/settings", { registration: "open" })).status).toBe(403); + + expect((await call(admin, "PUT", "/api/v1/console/settings", { registration: "open" })).json).toEqual({ registration: "open" }); + expect((await call({ v: "" }, "GET", "/api/v1/hub")).json).toEqual({ registration: "open" }); + // Open: new accounts can use the hub at once. + const eve = await signUp("eve"); + expect((await call(eve, "GET", "/api/v1/account")).status).toBe(200); + const audit = (await call(admin, "GET", "/api/v1/console")).json.audit.map((e: any) => e.action); + expect(audit).toContain("hub.registration"); +}); diff --git a/apps/web/app/account/actions.ts b/apps/web/app/account/actions.ts index 0461bc7..224cb30 100644 --- a/apps/web/app/account/actions.ts +++ b/apps/web/app/account/actions.ts @@ -1,5 +1,6 @@ "use server"; import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; import { api, unwrap } from "@/lib/api"; import { attempt, field, type ActionState } from "@/lib/actions"; @@ -58,3 +59,15 @@ export async function signOutComputers(_: ActionState, _form: FormData): Promise return "Signed out everywhere."; }); } + +/** Starts an age check with a provider that checks the person, and sends them to its page. */ +export async function startAgeCheck(_: ActionState, form: FormData): Promise { + let url: string | undefined; + const state = await attempt(async () => { + const started = unwrap(await api.age({ provider: field(form, "provider") }).start.post({ returnURL: "/account?age=back" })); + if (started.kind === "redirect") url = started.url; + done(); + }); + if (url) redirect(url); + return state; +} diff --git a/apps/web/app/account/age-card.tsx b/apps/web/app/account/age-card.tsx new file mode 100644 index 0000000..8c54881 --- /dev/null +++ b/apps/web/app/account/age-card.tsx @@ -0,0 +1,68 @@ +import { ActionForm, SubmitButton } from "@/components/forms"; +import { Tag } from "@/components/status"; +import { TimeAgo } from "@/components/time"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import type { AgeInfo } from "@/lib/api"; +import { startAgeCheck } from "./actions"; + +/** The person's own age check (an ID document, say), which covers all their IMVU accounts. */ +export function AgeCard({ age, back }: { age: AgeInfo; back: boolean }) { + const ways = age.providers.filter((p) => p.scope === "person"); + if (ways.length === 0) return null; + const now = Date.now(); + const verified = age.checks.find( + (c) => c.status === "complete" && c.overEighteen && (!c.expiresAt || new Date(c.expiresAt).getTime() > now), + ); + const latest = age.checks[0]; + const pending = latest?.status === "pending" ? latest : undefined; + const vendorOf = (id: string) => age.providers.find((p) => p.id === id)?.vendor ?? id; + + return ( + + + Age Verification + + + {verified ? ( +

+ Age verified + + with {vendorOf(verified.provider)} . All your + IMVU accounts count as adult. + +

+ ) : ( + <> + {pending ? ( +

+ {back + ? "Thanks. Your check is being processed; this page shows the result when it's done." + : "Your check isn't finished yet."} +

+ ) : latest?.status === "complete" && !latest.overEighteen ? ( +

Your last check found you're under 18.

+ ) : latest?.status === "failed" || latest?.status === "revoked" ? ( +

Your last check didn't pass. You can try again.

+ ) : ( +

+ Verify your age once to count as adult on all your IMVU accounts, not only those IMVU has age verified. +

+ )} + {ways.map((p) => ( + +
+ + {pending?.provider === p.id ? "Continue check" : `Verify with ${p.label}`} + +

+ Done by {p.vendor}. T5DE only keeps whether you're over 18, never your ID, photo or birth date. +

+
+
+ ))} + + )} +
+
+ ); +} diff --git a/apps/web/app/account/page.tsx b/apps/web/app/account/page.tsx index 2511836..b93235f 100644 --- a/apps/web/app/account/page.tsx +++ b/apps/web/app/account/page.tsx @@ -8,17 +8,19 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { Input } from "@/components/ui/input"; import { api, getMe, isModerator, unwrap } from "@/lib/api"; import { linkAccount, signOutComputers } from "./actions"; +import { AgeCard } from "./age-card"; import { ChallengeCard } from "./challenge-card"; import { LinkCard } from "./link-card"; export const metadata: Metadata = { title: "Account" }; -export default async function Account({ searchParams }: { searchParams: Promise<{ welcome?: string }> }) { +export default async function Account({ searchParams }: { searchParams: Promise<{ welcome?: string; age?: string }> }) { const me = await getMe(); if (!me) redirect("/signin?next=/account"); - const account = unwrap(await api.account.get()); + const [account, age] = await Promise.all([api.account.get().then(unwrap), api.age.get().then(unwrap)]); const { person, links, challenges, computers } = account; - const welcome = (await searchParams).welcome !== undefined; + const params = await searchParams; + const welcome = params.welcome !== undefined; return (
@@ -78,12 +80,14 @@ export default async function Account({ searchParams }: { searchParams: Promise<

)}

- Other people can't see which IMVU accounts are yours. Age-verified IMVU accounts (or with Access Pass) - can see adult community products; each account is checked on its own. + Other people can't see which IMVU accounts are yours. Adult IMVU accounts can see adult community + products: those IMVU has age verified (or with Access Pass), or all of them once you verify your age below.

+ + T5DE on your computers diff --git a/apps/web/app/console/actions.ts b/apps/web/app/console/actions.ts index 9bc643a..7a4c5cc 100644 --- a/apps/web/app/console/actions.ts +++ b/apps/web/app/console/actions.ts @@ -1,5 +1,6 @@ "use server"; import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; import { api, unwrap } from "@/lib/api"; import { attempt, field, type ActionState } from "@/lib/actions"; @@ -52,3 +53,25 @@ export async function decideCatalog(_: ActionState, form: FormData): Promise { + const id = field(form, "id"); + const decline = field(form, "decision") === "decline"; + const state = await attempt(async () => { + if (decline) unwrap(await api.console.people({ id }).decline.post()); + else unwrap(await api.console.people({ id }).approve.post()); + revalidatePath("/console", "layout"); + }); + if (decline && !state?.error) redirect("/console/people?waiting=true"); + return state; +} + +export async function setRegistration(_: ActionState, form: FormData): Promise { + return attempt(async () => { + const registration = field(form, "registration") === "closed" ? "closed" : "open"; + unwrap(await api.console.settings.put({ registration })); + revalidatePath("/console"); + return registration === "closed" ? "Registration closed: new accounts wait for approval." : "Registration open."; + }); +} diff --git a/apps/web/app/console/page.tsx b/apps/web/app/console/page.tsx index 30a89b9..80e4fec 100644 --- a/apps/web/app/console/page.tsx +++ b/apps/web/app/console/page.tsx @@ -1,19 +1,24 @@ import type { Metadata } from "next"; +import Link from "next/link"; +import { ActionForm, SubmitButton } from "@/components/forms"; import { OnlineList } from "@/components/online-list"; import { PageHeader } from "@/components/status"; import { DateTime } from "@/components/time"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { api, unwrap, type AuditEntry } from "@/lib/api"; +import { setRegistration } from "./actions"; import { ConsoleNav, requireModerator } from "./console-nav"; export const metadata: Metadata = { title: "Console" }; export default async function Console() { - await requireModerator("/console"); - const data = unwrap(await api.console.get()); + const me = await requireModerator("/console"); + const [data, settings] = await Promise.all([api.console.get().then(unwrap), api.console.settings.get().then(unwrap)]); const stats = [ { label: "People", value: data.counts.people, note: "accounts" }, + { label: "Waiting", value: data.counts.waiting, note: "for approval", href: "/console/people?waiting=true" }, { label: "IMVU accounts", value: data.counts.links, note: "linked" }, { label: "Computers", value: data.counts.computers, note: "signed in" }, ]; @@ -23,18 +28,50 @@ export default async function Console() { Moderation.
-
+
{stats.map((s) => ( {s.label} {s.value} - {s.note} + + {s.href ? ( + + {s.note} + + ) : ( + s.note + )} + ))}
+ + + Registration + + {settings.registration === "closed" + ? "Closed: new accounts can sign in, but wait for a moderator's approval." + : "Open: new accounts can use the hub at once."} + + + {me.role === "admin" && ( + + +
+ + Open + Closed + + Set +
+
+
+ )} +
+ Online now diff --git a/apps/web/app/console/people/[id]/page.tsx b/apps/web/app/console/people/[id]/page.tsx index a73e868..db3c448 100644 --- a/apps/web/app/console/people/[id]/page.tsx +++ b/apps/web/app/console/people/[id]/page.tsx @@ -8,7 +8,7 @@ import { Input } from "@/components/ui/input"; import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { api, ApiError, unwrap } from "@/lib/api"; -import { ban, setLinkCap, setRole, unban } from "../../actions"; +import { ban, decideAccount, setLinkCap, setRole, unban } from "../../actions"; import { ConsoleNav, requireModerator } from "../../console-nav"; export const metadata: Metadata = { title: "Person" }; @@ -35,11 +35,41 @@ export default async function PersonPage({ params }: { params: Promise<{ id: str joined {person.banned && banned: {person.banReason}} + {!person.approved && waiting for approval}
+ {!person.approved && ( + + + Waiting for Approval + + {person.email}. Until approved, they can only sign in. Declining deletes the account. + + + + +
+ + Approve + + + Decline + +
+
+
+
+ )} + IMVU accounts diff --git a/apps/web/app/console/people/page.tsx b/apps/web/app/console/people/page.tsx index 136bd88..11f2ad5 100644 --- a/apps/web/app/console/people/page.tsx +++ b/apps/web/app/console/people/page.tsx @@ -11,11 +11,17 @@ import { ConsoleNav, requireModerator } from "../console-nav"; export const metadata: Metadata = { title: "People" }; -export default async function People({ searchParams }: { searchParams: Promise<{ q?: string | string[] }> }) { - const raw = (await searchParams).q; - const q = typeof raw === "string" ? raw.slice(0, 64) : ""; - await requireModerator(q ? `/console/people?q=${encodeURIComponent(q)}` : "/console/people"); - const { people } = unwrap(await api.console.people.get({ query: q ? { q } : {} })); +export default async function People({ + searchParams, +}: { + searchParams: Promise<{ q?: string | string[]; waiting?: string | string[] }>; +}) { + const params = await searchParams; + const q = typeof params.q === "string" ? params.q.slice(0, 64) : ""; + const waiting = params.waiting === "true"; + const query = new URLSearchParams({ ...(q ? { q } : {}), ...(waiting ? { waiting: "true" } : {}) }).toString(); + await requireModerator(`/console/people${query ? `?${query}` : ""}`); + const { people } = unwrap(await api.console.people.get({ query: { ...(q ? { q } : {}), ...(waiting ? { waiting: true } : {}) } })); return ( <> @@ -23,14 +29,23 @@ export default async function People({ searchParams }: { searchParams: Promise<{ -
- - -
+
+
+ + {waiting && } + +
+ + {waiting ? "Show everyone" : "Waiting for approval"} + +
{people.length === 0 ? ( -

Nobody found.

+

{waiting ? "Nobody is waiting." : "Nobody found."}

) : ( @@ -55,7 +70,9 @@ export default async function People({ searchParams }: { searchParams: Promise<{ - {p.banned && banned} + + {p.banned ? banned : !p.approved && waiting} + ))} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 2ae3679..9c9ea64 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,7 +1,9 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import { Header } from "@/components/header"; +import { NarrowCard } from "@/components/narrow-card"; import { StagingBanner } from "@/components/staging-banner"; +import { getMe } from "@/lib/api"; import { cn } from "@/lib/utils"; import "./globals.css"; @@ -13,13 +15,23 @@ export const metadata: Metadata = { description: "One account for all your IMVU avatars.", }; -export default function RootLayout({ children }: { children: React.ReactNode }) { +export default async function RootLayout({ children }: { children: React.ReactNode }) { + const me = await getMe(); return (
-
{children}
+
+ {me && !me.approved ? ( + + ) : ( + children + )} +
T5DE is unofficial: not made by or affiliated with IMVU. diff --git a/apps/web/app/signup/page.tsx b/apps/web/app/signup/page.tsx index 1b53bec..c232928 100644 --- a/apps/web/app/signup/page.tsx +++ b/apps/web/app/signup/page.tsx @@ -1,15 +1,20 @@ import type { Metadata } from "next"; import { redirect } from "next/navigation"; import { NarrowCard } from "@/components/narrow-card"; -import { getMe } from "@/lib/api"; +import { api, getMe, unwrap } from "@/lib/api"; import { SignUpForm } from "./signup-form"; export const metadata: Metadata = { title: "Create account" }; export default async function SignUp() { if (await getMe()) redirect("/account"); + const { registration } = unwrap(await api.hub.get()); + const description = + registration === "closed" + ? "One T5DE account for all your IMVU accounts. New accounts need a moderator's approval." + : "One T5DE account for all your IMVU accounts."; return ( - + ); diff --git a/apps/web/components/status.tsx b/apps/web/components/status.tsx index 374c5b2..ae2d777 100644 --- a/apps/web/components/status.tsx +++ b/apps/web/components/status.tsx @@ -75,8 +75,8 @@ export function PageHeader({ title, children }: { title: React.ReactNode; childr } /** An IMVU account's age status: blue when it can see adult community products. */ -export function AgeTag({ link }: { link: Pick }) { - if (link.ageVerified) return Age verified; +export function AgeTag({ link }: { link: Pick }) { + if (link.ageVerified || (link.adult && !link.accessPass)) return Age verified; if (link.accessPass) return Access Pass; return Not age verified; } diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index c79bd72..bef0177 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -37,6 +37,7 @@ export type Person = Data["person"]; export type Account = Data; export type Link = Account["links"][number]; export type Challenge = Account["challenges"][number]; +export type AgeInfo = Data; export type OnlineEntry = Data["online"][number]; export type ConsoleData = Data; export type AuditEntry = ConsoleData["audit"][number]; diff --git a/docker-compose.yml b/docker-compose.yml index b1f38fb..140b36a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,6 +22,13 @@ services: # T5DE's proxies reach the API directly (sign-in and the WebSocket); the website reaches it through its own /api. API_URL: ${API_PUBLIC_URL:-http://localhost:4101} WEB_URL: ${WEB_PUBLIC_URL:-http://localhost:4100} + # open, or closed (new accounts wait for a moderator's approval) until an admin changes it in the console. + REGISTRATION: ${REGISTRATION:-open} + # Ways to verify age (apps/api/src/index.ts): imvu, and didit with its keys. + AGE_PROVIDERS: ${AGE_PROVIDERS:-imvu} + DIDIT_API_KEY: ${DIDIT_API_KEY:-} + DIDIT_WORKFLOW_ID: ${DIDIT_WORKFLOW_ID:-} + DIDIT_WEBHOOK_SECRET: ${DIDIT_WEBHOOK_SECRET:-} ports: ["4101:4101"] depends_on: postgres: { condition: service_healthy } diff --git a/docs/api.md b/docs/api.md index 2734209..03b5647 100644 --- a/docs/api.md +++ b/docs/api.md @@ -17,10 +17,11 @@ is. Codes: `invalid`, `signed_out`, `forbidden`, `not_found`, `no_such_imvu_acco | | | | --- | --- | -| `POST /api/auth/sign-up/email` | `{email, password, name, username}`. Usernames 3-32 of `A-Za-z0-9_`; passwords 10-256 characters. The first account is an admin. | +| `POST /api/auth/sign-up/email` | `{email, password, name, username}`. Usernames 3-32 of `A-Za-z0-9_`; passwords 10-256 characters. The first account is an admin. With registration closed, the others wait for approval (`approved: false`): every route but `/me` answers 403 `awaiting_approval`, and the rest of the hub treats them as signed out. | | `POST /api/auth/sign-in/username` | `{username, password}` | | `POST /api/auth/sign-out` | | -| `GET /api/v1/me` | `{person}`, with `picture`: the avatar picture of their first linked IMVU account (from IMVU, kept an hour), or `null` | +| `GET /api/v1/me` | `{person}` (`approved` false while waiting), with `picture`: the avatar picture of their first linked IMVU account (from IMVU, kept an hour), or `null` | +| `GET /api/v1/hub` | (anyone) `{registration}`: `open` or `closed` | ## Proxies @@ -97,17 +98,20 @@ person-level providers (phone wallets, document checks) are started here. | | | | --- | --- | -| `GET /api/v1/age` | `{providers: [{id, label, scope}], checks}`: the providers in use and the person's own checks | -| `POST /api/v1/age/:provider/start` | `{returnURL}` → `{kind: "done"}`, `{kind: "redirect", url, ref}` (send the person there) or `{kind: "browser", request, ref}` (hand `request` to the browser's Digital Credentials API) | +| `GET /api/v1/age` | `{providers: [{id, label, vendor, scope}], checks}`: the providers in use and the person's own checks (newest first; status `pending`, `complete`, `failed` or `revoked`) | +| `POST /api/v1/age/:provider/start` | `{returnURL}` (a path on the website, or an address on it) → `{kind: "done"}`, `{kind: "redirect", url, ref}` (send the person there) or `{kind: "browser", request, ref}` (hand `request` to the browser's Digital Credentials API). An unfinished check is given again. 409 `refused`: already verified, or a limit reached (`message` says which); 502 `provider_unavailable` | | `POST /api/v1/age/:provider/complete` | `{ref, response}`: the browser's answer, for wallet providers | -| `POST /api/v1/age/webhook/:provider` | the provider's webhook; the provider checks its signature | +| `POST /api/v1/age/webhook/:provider` | the provider's webhook; the provider checks its signature (401 if it isn't the provider's). A later answer replaces an earlier one (an adult answer taken back is `revoked`) | ## Console (moderators; roles: admins) | | | | --- | --- | | `GET /api/v1/console` | `{counts, connections, online, audit}` | -| `GET /api/v1/console/people?q=` | `{people}` | +| `GET /api/v1/console/people?q=&waiting=true` | `{people}` (`waiting`: only accounts waiting for approval) | +| `POST /api/v1/console/people/:id/approve` | `{person}` | +| `POST /api/v1/console/people/:id/decline` | an account waiting for approval: deletes it | +| `GET /api/v1/console/settings`, `PUT` (admins) | `{registration}`: `open` or `closed` (default: the API's `REGISTRATION`, else open) | | `GET /api/v1/console/people/:id` | `{person, links, computers}` | | `POST /api/v1/console/people/:id/link-cap` | `{linkCap}` (0-1000) | | `POST /api/v1/console/people/:id/ban` | `{reason}`; only admins ban moderators | diff --git a/docs/design.md b/docs/design.md index fa5e49e..9760ce4 100644 --- a/docs/design.md +++ b/docs/design.md @@ -100,6 +100,9 @@ age, not verified) and `tagline`. - **Switching accounts in the client**: the proxy sees which IMVU account logs in and tells the hub. Not linked: offer to link. Linked to another hub account: no community features that session. - **Bans** apply to the person, so all linked accounts. +- **Registration** is open or closed, an admin's setting in the console (default from `REGISTRATION`; staging is + meant to be closed, for testers only). Closed: anyone can sign up, but the account can only sign in until a + moderator approves it (or declines it, deleting it); the hub treats it as signed out everywhere else. ## Age (**Decided**: free, several providers, vendor-neutral) @@ -112,8 +115,11 @@ No payment for verification (adult content rules out mainstream merchants of rec 2. **Phone wallets** (planned): a government ID in Google Wallet, Apple Wallet or an EU age-verification wallet, asked for "age over 18" only, through the browser's Digital Credentials API. Free for the hub; no documents or birth date reach it. Scope: the person. -3. **ID document and selfie** (planned): Didit's hosted check, within its free monthly allowance. Scope: the person - (the liveness step stops someone using another person's ID). +3. **ID document and selfie** (live, `didit`): Didit's hosted check: ID document $0.15, passive liveness $0.10 and + face match $0.05, so $0.30 a check, paid from the free plan's $10 of credit a month (since 2026-11-01; the 500 + free checks a month are gone), so about 30 a month unless topped up. Scope: the person (liveness and face match + stop someone using another person's ID). Over 18 is worked out from the age Didit reads off the document; + nothing else is kept. With so few checks, phone wallets (free) should become the main way. ### How it's built @@ -128,14 +134,25 @@ No payment for verification (adult content rules out mainstream merchants of rec webhook), and optionally `recheck` (for providers whose answer can change, like IMVU). Which ones run is configuration (`AGE_PROVIDERS`), with each provider's keys in its own settings. Adding or replacing a vendor is one new adapter; nothing else changes. -- Webhooks arrive at `/api/v1/age/webhook/` and each provider checks its own signature. +- Webhooks arrive at `/api/v1/age/webhook/` and each provider checks its own signature. The provider's + latest answer stands: one taken back after approval (say, by a manual review) becomes `revoked`. +- **Limits** on checks of a person, for every provider: one unfinished check at a time (starting again gives the + same one), at most 3 started in 30 days, none once verified, and a provider's monthly cap (`monthlyLimit`; Didit's + `DIDIT_MONTHLY_LIMIT`, default 30), counted from the checks started this month (UTC). +- People are sent back only to the hub's website (`returnURL` is a path on it, or an address on `WEB_URL`). - Enforced at three points: catalogs (adult files need an adult token), the hub (adult items left out of room channels for viewers who aren't adult and opted in), and the viewer's proxy. +### Setting up Didit + +In Didit's console (business.didit.me): an application; a KYC workflow with ID verification, passive liveness and +face match only (no IP analysis, AML or proof of address, which cost extra; no white label, $0.10 a session more); +an API key; and a webhook destination +`/api/v1/age/webhook/didit` with its secret. Then the API's environment: `AGE_PROVIDERS=imvu,didit`, +`DIDIT_API_KEY`, `DIDIT_WORKFLOW_ID`, `DIDIT_WEBHOOK_SECRET`. Use the sandbox application on staging. + ### To add the planned providers -- Didit: an account, an API key and webhook secret, and a workflow set to ID document plus liveness, returning - only "over 18". Confirm its free monthly allowance covers age checks. - Wallets: Google's Digital Credentials verifier registration (free), and an `age_over_18` request (OpenID4VP, mdoc); Apple and the EU wallet through the same browser API where supported. diff --git a/packages/db/drizzle/0009_registration.sql b/packages/db/drizzle/0009_registration.sql new file mode 100644 index 0000000..698538a --- /dev/null +++ b/packages/db/drizzle/0009_registration.sql @@ -0,0 +1,7 @@ +CREATE TABLE "hub_setting" ( + "key" text PRIMARY KEY NOT NULL, + "value" text NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "user" ADD COLUMN "approved" boolean DEFAULT true NOT NULL; \ No newline at end of file diff --git a/packages/db/drizzle/meta/0009_snapshot.json b/packages/db/drizzle/meta/0009_snapshot.json new file mode 100644 index 0000000..365ae9b --- /dev/null +++ b/packages/db/drizzle/meta/0009_snapshot.json @@ -0,0 +1,2301 @@ +{ + "id": "03ea80f3-9f56-4e9a-8fa9-7faec3d8c472", + "prevId": "ac850a36-cd53-47e5-bc3f-79d7541821ae", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_user": { + "name": "account_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.age_check": { + "name": "age_check", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "imvu_id": { + "name": "imvu_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "over_eighteen": { + "name": "over_eighteen", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "provider_ref": { + "name": "provider_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "checked_at": { + "name": "checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "age_check_user": { + "name": "age_check_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "age_check_imvu": { + "name": "age_check_imvu", + "columns": [ + { + "expression": "imvu_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "age_check_provider_ref": { + "name": "age_check_provider_ref", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "age_check_user_id_user_id_fk": { + "name": "age_check_user_id_user_id_fk", + "tableFrom": "age_check", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit": { + "name": "audit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "detail": { + "name": "detail", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "audit_at": { + "name": "audit_at", + "columns": [ + { + "expression": "at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_actor_id_user_id_fk": { + "name": "audit_actor_id_user_id_fk", + "tableFrom": "audit", + "tableTo": "user", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.catalog": { + "name": "catalog", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contact": { + "name": "contact", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "membership": { + "name": "membership", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "catalog_url": { + "name": "catalog_url", + "columns": [ + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "catalog_owner_id_user_id_fk": { + "name": "catalog_owner_id_user_id_fk", + "tableFrom": "catalog", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_code": { + "name": "device_code", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "device_code": { + "name": "device_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_code": { + "name": "user_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "polling_interval": { + "name": "polling_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "device_code_user_id_user_id_fk": { + "name": "device_code_user_id_user_id_fk", + "tableFrom": "device_code", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hosted_room": { + "name": "hosted_room", + "schema": "", + "columns": { + "n": { + "name": "n", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_imvu_id": { + "name": "owner_imvu_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "room_pid": { + "name": "room_pid", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "max_users": { + "name": "max_users", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "scene": { + "name": "scene", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "scene_hash": { + "name": "scene_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "closed": { + "name": "closed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "privacy": { + "name": "privacy", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "moderators": { + "name": "moderators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "image_hash": { + "name": "image_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "hosted_room_owner": { + "name": "hosted_room_owner", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "hosted_room_created": { + "name": "hosted_room_created", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "hosted_room_owner_id_user_id_fk": { + "name": "hosted_room_owner_id_user_id_fk", + "tableFrom": "hosted_room", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hosted_room_ban": { + "name": "hosted_room_ban", + "schema": "", + "columns": { + "n": { + "name": "n", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "imvu_id": { + "name": "imvu_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "by_imvu_id": { + "name": "by_imvu_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "until": { + "name": "until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "hosted_room_ban_n_hosted_room_n_fk": { + "name": "hosted_room_ban_n_hosted_room_n_fk", + "tableFrom": "hosted_room_ban", + "tableTo": "hosted_room", + "columnsFrom": [ + "n" + ], + "columnsTo": [ + "n" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "hosted_room_ban_n_imvu_id_pk": { + "name": "hosted_room_ban_n_imvu_id_pk", + "columns": [ + "n", + "imvu_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hosted_room_favorite": { + "name": "hosted_room_favorite", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "n": { + "name": "n", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "hosted_room_favorite_user_id_user_id_fk": { + "name": "hosted_room_favorite_user_id_user_id_fk", + "tableFrom": "hosted_room_favorite", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hosted_room_favorite_n_hosted_room_n_fk": { + "name": "hosted_room_favorite_n_hosted_room_n_fk", + "tableFrom": "hosted_room_favorite", + "tableTo": "hosted_room", + "columnsFrom": [ + "n" + ], + "columnsTo": [ + "n" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "hosted_room_favorite_user_id_n_pk": { + "name": "hosted_room_favorite_user_id_n_pk", + "columns": [ + "user_id", + "n" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hosted_room_image": { + "name": "hosted_room_image", + "schema": "", + "columns": { + "n": { + "name": "n", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "hosted_room_image_n_hosted_room_n_fk": { + "name": "hosted_room_image_n_hosted_room_n_fk", + "tableFrom": "hosted_room_image", + "tableTo": "hosted_room", + "columnsFrom": [ + "n" + ], + "columnsTo": [ + "n" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hosted_room_rating": { + "name": "hosted_room_rating", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "n": { + "name": "n", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "hosted_room_rating_room": { + "name": "hosted_room_rating_room", + "columns": [ + { + "expression": "n", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "hosted_room_rating_user_id_user_id_fk": { + "name": "hosted_room_rating_user_id_user_id_fk", + "tableFrom": "hosted_room_rating", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hosted_room_rating_n_hosted_room_n_fk": { + "name": "hosted_room_rating_n_hosted_room_n_fk", + "tableFrom": "hosted_room_rating", + "tableTo": "hosted_room", + "columnsFrom": [ + "n" + ], + "columnsTo": [ + "n" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "hosted_room_rating_user_id_n_pk": { + "name": "hosted_room_rating_user_id_n_pk", + "columns": [ + "user_id", + "n" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hosted_room_visit": { + "name": "hosted_room_visit", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "n": { + "name": "n", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "imvu_id": { + "name": "imvu_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "hosted_room_visit_at": { + "name": "hosted_room_visit_at", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "hosted_room_visit_user_id_user_id_fk": { + "name": "hosted_room_visit_user_id_user_id_fk", + "tableFrom": "hosted_room_visit", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hosted_room_visit_n_hosted_room_n_fk": { + "name": "hosted_room_visit_n_hosted_room_n_fk", + "tableFrom": "hosted_room_visit", + "tableTo": "hosted_room", + "columnsFrom": [ + "n" + ], + "columnsTo": [ + "n" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "hosted_room_visit_user_id_n_pk": { + "name": "hosted_room_visit_user_id_n_pk", + "columns": [ + "user_id", + "n" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hosting": { + "name": "hosting", + "schema": "", + "columns": { + "catalog_id": { + "name": "catalog_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "hosting_product": { + "name": "hosting_product", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "hosting_catalog_id_catalog_id_fk": { + "name": "hosting_catalog_id_catalog_id_fk", + "tableFrom": "hosting", + "tableTo": "catalog", + "columnsFrom": [ + "catalog_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "hosting_catalog_id_product_id_number_pk": { + "name": "hosting_catalog_id_product_id_number_pk", + "columns": [ + "catalog_id", + "product_id", + "number" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hub_setting": { + "name": "hub_setting", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.imvu_link": { + "name": "imvu_link", + "schema": "", + "columns": { + "imvu_id": { + "name": "imvu_id", + "type": "bigint", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pairwise_id": { + "name": "pairwise_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "linked_at": { + "name": "linked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "show_badge": { + "name": "show_badge", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "show_presence": { + "name": "show_presence", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "list_rooms": { + "name": "list_rooms", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "imvu_link_user": { + "name": "imvu_link_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "imvu_link_user_id_user_id_fk": { + "name": "imvu_link_user_id_user_id_fk", + "tableFrom": "imvu_link", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "imvu_link_pairwise_id_unique": { + "name": "imvu_link_pairwise_id_unique", + "nullsNotDistinct": false, + "columns": [ + "pairwise_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "alg": { + "name": "alg", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "crv": { + "name": "crv", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.library": { + "name": "library", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stay_on": { + "name": "stay_on", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "added_at": { + "name": "added_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "library_user_id_user_id_fk": { + "name": "library_user_id_user_id_fk", + "tableFrom": "library", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "library_product_id_product_id_fk": { + "name": "library_product_id_product_id_fk", + "tableFrom": "library", + "tableTo": "product", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "library_user_id_product_id_pk": { + "name": "library_user_id_product_id_pk", + "columns": [ + "user_id", + "product_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.link_challenge": { + "name": "link_challenge", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "imvu_id": { + "name": "imvu_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "link_challenge_user_id_user_id_fk": { + "name": "link_challenge_user_id_user_id_fk", + "tableFrom": "link_challenge", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "link_challenge_user_id_imvu_id_pk": { + "name": "link_challenge_user_id_imvu_id_pk", + "columns": [ + "user_id", + "imvu_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.product": { + "name": "product", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "rating": { + "name": "rating", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "latest": { + "name": "latest", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "removed_reason": { + "name": "removed_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "product_creator": { + "name": "product_creator", + "columns": [ + { + "expression": "creator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "product_created": { + "name": "product_created", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "product_creator_id_user_id_fk": { + "name": "product_creator_id_user_id_fk", + "tableFrom": "product", + "tableTo": "user", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.product_revision": { + "name": "product_revision", + "schema": "", + "columns": { + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent": { + "name": "parent", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "parent_info": { + "name": "parent_info", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "retracted": { + "name": "retracted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "product_revision_product_id_product_id_fk": { + "name": "product_revision_product_id_product_id_fk", + "tableFrom": "product_revision", + "tableTo": "product", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "product_revision_uploaded_by_user_id_fk": { + "name": "product_revision_uploaded_by_user_id_fk", + "tableFrom": "product_revision", + "tableTo": "user", + "columnsFrom": [ + "uploaded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "product_revision_product_id_number_pk": { + "name": "product_revision_product_id_number_pk", + "columns": [ + "product_id", + "number" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.room": { + "name": "room", + "schema": "", + "columns": { + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_imvu_id": { + "name": "owner_imvu_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "privacy": { + "name": "privacy", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_rating": { + "name": "content_rating", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "access_pass": { + "name": "access_pass", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "occupancy": { + "name": "occupancy", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "capacity": { + "name": "capacity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expired": { + "name": "expired", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "checked_at": { + "name": "checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "seen_at": { + "name": "seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "room_owner": { + "name": "room_owner", + "columns": [ + { + "expression": "owner_imvu_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.room_decoration": { + "name": "room_decoration", + "schema": "", + "columns": { + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_imvu_id": { + "name": "owner_imvu_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "contents": { + "name": "contents", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "room_decoration_owner": { + "name": "room_decoration_owner", + "columns": [ + { + "expression": "owner_imvu_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user": { + "name": "session_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.signing_key": { + "name": "signing_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_username": { + "name": "display_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "link_cap": { + "name": "link_cap", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "approved": { + "name": "approved", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "user_username_lower": { + "name": "user_username_lower", + "columns": [ + { + "expression": "lower(\"username\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "user_username_unique": { + "name": "user_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier": { + "name": "verification_identifier", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 833fc4b..93e0f41 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1793372872749, "tag": "0008_product_deletion", "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1793707706030, + "tag": "0009_registration", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index a7dce7c..564f808 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -43,6 +43,8 @@ export const user = pgTable( banExpires: timestamp("ban_expires", { withTimezone: true }), // How many IMVU accounts the person may link; moderators raise it on request. linkCap: integer("link_cap").notNull().default(10), + // False while a new account waits for a moderator's approval (registration closed); it can only sign in. + approved: boolean("approved").notNull().default(true), }, (t) => [uniqueIndex("user_username_lower").on(sql`lower(${t.username})`)], ); @@ -473,3 +475,10 @@ export const roomDecoration = pgTable( }, (t) => [index("room_decoration_owner").on(t.ownerImvuId)], ); + +// The hub's settings that admins change from the console (e.g. registration: "open" or "closed"). +export const hubSetting = pgTable("hub_setting", { + key: text("key").primaryKey(), + value: text("value").notNull(), + updatedAt: updatedAt(), +});