Age checks are recorded in one table (age_check) by provider, method and result, never with documents or birth dates; one rule decides whether an IMVU account counts as adult (its person's check, or its own). Providers implement a small interface (start, webhook or browser completion, re-check) and are chosen by configuration, so phone wallets and Didit can be added, or swapped, as adapters. IMVU's answer per account is re-checked when it expires (about daily). Existing links' results move into the new table. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
339ba8e2d6
commit
7db9fc0370
11 files changed
+1602
-48
No files matched your search
@@ -0,0 +1,35 @@
|
||||
// Age from IMVU itself: an IMVU account that IMVU has age verified, or that has Access Pass, counts as adult. Read
|
||||
// from IMVU's public API; IMVU can change it (verification revoked, Access Pass lapsed), so it's checked again
|
||||
// about daily.
|
||||
import type { ImvuClient } from "../imvu";
|
||||
import type { AgeResult, Provider, Subject } from "./index";
|
||||
|
||||
const recheckAfter = 24 * 60 * 60 * 1000;
|
||||
|
||||
export function imvuAgeProvider(imvu: ImvuClient): Provider {
|
||||
const check = async (subject: Subject): Promise<AgeResult> => {
|
||||
if (subject.imvuId === undefined) throw new Error("IMVU checks an IMVU account");
|
||||
const u = await imvu.byId(subject.imvuId);
|
||||
return {
|
||||
overEighteen: u.ageVerified || u.accessPass,
|
||||
method: u.ageVerified ? "imvu_age_verified" : u.accessPass ? "imvu_access_pass" : "imvu_none",
|
||||
expiresAt: new Date(Date.now() + recheckAfter),
|
||||
};
|
||||
};
|
||||
return {
|
||||
id: "imvu",
|
||||
label: "IMVU's age verification or Access Pass",
|
||||
scope: "imvu_account",
|
||||
start: async (subject) => ({ kind: "done", result: await check(subject) }),
|
||||
recheck: check,
|
||||
};
|
||||
}
|
||||
|
||||
/** The IMVU result from a lookup already made (when linking). */
|
||||
export function imvuResult(u: { ageVerified: boolean; accessPass: boolean }): AgeResult {
|
||||
return {
|
||||
overEighteen: u.ageVerified || u.accessPass,
|
||||
method: u.ageVerified ? "imvu_age_verified" : u.accessPass ? "imvu_access_pass" : "imvu_none",
|
||||
expiresAt: new Date(Date.now() + recheckAfter),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
// 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";
|
||||
|
||||
const { ageCheck } = schema;
|
||||
|
||||
/** What's checked: a person (all their IMVU accounts), or one of their linked IMVU accounts. */
|
||||
export type Subject = { userId: string; imvuId?: number };
|
||||
|
||||
/** A provider's answer. Only the outcome: never a birth date, document, photo or name. */
|
||||
export type AgeResult = {
|
||||
overEighteen: boolean;
|
||||
/** How it was established, e.g. "imvu_age_verified", "id_document", "wallet_mdoc". */
|
||||
method: string;
|
||||
/** The provider's session or transaction ID, if it has one. */
|
||||
ref?: string;
|
||||
/** When the answer should be checked again (providers whose answer can change); none: it stands. */
|
||||
expiresAt?: Date;
|
||||
};
|
||||
|
||||
/** How a check begins. */
|
||||
export type Started =
|
||||
| { kind: "done"; result: AgeResult } // answered at once (IMVU)
|
||||
| { kind: "redirect"; url: string; ref: string } // send the person to the provider's page (document checks)
|
||||
| { kind: "browser"; request: unknown; ref: string }; // hand a request to the browser (Digital Credentials API)
|
||||
|
||||
export type Provider = {
|
||||
id: string;
|
||||
/** Shown to people. */
|
||||
label: string;
|
||||
/** Whether it checks a person, or an IMVU account. */
|
||||
scope: "person" | "imvu_account";
|
||||
start(subject: Subject, options: { returnURL: string }): Promise<Started>;
|
||||
/** 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" }>;
|
||||
/** Finishes a check the browser completed (wallets): the browser's response for ref. */
|
||||
complete?(ref: string, response: unknown): Promise<AgeResult | "failed">;
|
||||
/** Checks a subject again, for answers that can change. */
|
||||
recheck?(subject: Subject): Promise<AgeResult>;
|
||||
};
|
||||
|
||||
export class AgeError extends Error {}
|
||||
|
||||
const randomID = () => Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString("base64url");
|
||||
|
||||
export class AgeService {
|
||||
private providers = new Map<string, Provider>();
|
||||
|
||||
constructor(
|
||||
private readonly db: Database,
|
||||
providers: Provider[],
|
||||
) {
|
||||
for (const p of providers) this.providers.set(p.id, p);
|
||||
}
|
||||
|
||||
/** The providers in use, for the website. */
|
||||
list() {
|
||||
return [...this.providers.values()].map((p) => ({ id: p.id, label: p.label, scope: p.scope }));
|
||||
}
|
||||
|
||||
provider(id: string): Provider {
|
||||
const p = this.providers.get(id);
|
||||
if (!p) throw new AgeError(`no age provider ${id}`);
|
||||
return p;
|
||||
}
|
||||
|
||||
/** Records a finished check. An IMVU account's check replaces that provider's earlier check of it. */
|
||||
async record(subject: Subject, provider: string, result: AgeResult) {
|
||||
const now = new Date();
|
||||
const values = {
|
||||
userId: subject.userId,
|
||||
imvuId: subject.imvuId ?? null,
|
||||
provider,
|
||||
method: result.method,
|
||||
status: "complete",
|
||||
overEighteen: result.overEighteen,
|
||||
providerRef: result.ref ?? null,
|
||||
checkedAt: now,
|
||||
expiresAt: result.expiresAt ?? null,
|
||||
};
|
||||
if (subject.imvuId !== undefined) {
|
||||
await this.db
|
||||
.delete(ageCheck)
|
||||
.where(and(eq(ageCheck.provider, provider), eq(ageCheck.imvuId, subject.imvuId)));
|
||||
}
|
||||
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. */
|
||||
async start(subject: Subject, providerId: string, returnURL: string): Promise<Started> {
|
||||
const p = this.provider(providerId);
|
||||
const started = await p.start(subject, { returnURL });
|
||||
if (started.kind === "done") {
|
||||
await this.record(subject, p.id, started.result);
|
||||
} else {
|
||||
await this.db.insert(ageCheck).values({
|
||||
id: randomID(),
|
||||
userId: subject.userId,
|
||||
imvuId: subject.imvuId ?? null,
|
||||
provider: p.id,
|
||||
status: "pending",
|
||||
providerRef: started.ref,
|
||||
});
|
||||
}
|
||||
return started;
|
||||
}
|
||||
|
||||
/** Finishes a pending check (from a webhook or the browser). */
|
||||
async finish(providerId: string, ref: string, result: AgeResult | "failed") {
|
||||
const done =
|
||||
result === "failed"
|
||||
? { status: "failed", overEighteen: false, checkedAt: new Date() }
|
||||
: {
|
||||
status: "complete",
|
||||
overEighteen: result.overEighteen,
|
||||
method: result.method,
|
||||
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]!;
|
||||
}
|
||||
|
||||
/** Removes an IMVU account's checks (it was unlinked or moved to another person). */
|
||||
async forgetAccount(imvuId: number) {
|
||||
await this.db.delete(ageCheck).where(eq(ageCheck.imvuId, imvuId));
|
||||
}
|
||||
|
||||
/**
|
||||
* The one rule: which of a person's IMVU accounts count as adult. An account does if the person has a current
|
||||
* complete check saying over 18, or the account itself has one.
|
||||
*/
|
||||
async adultAccounts(userId: string, imvuIds: number[]): Promise<Set<number>> {
|
||||
const now = new Date();
|
||||
const checks = await this.db
|
||||
.select({ imvuId: ageCheck.imvuId })
|
||||
.from(ageCheck)
|
||||
.where(
|
||||
and(
|
||||
eq(ageCheck.userId, userId),
|
||||
eq(ageCheck.status, "complete"),
|
||||
eq(ageCheck.overEighteen, true),
|
||||
or(isNull(ageCheck.expiresAt), gt(ageCheck.expiresAt, now)),
|
||||
imvuIds.length ? or(isNull(ageCheck.imvuId), inArray(ageCheck.imvuId, imvuIds)) : isNull(ageCheck.imvuId),
|
||||
),
|
||||
);
|
||||
if (checks.some((c) => c.imvuId === null)) return new Set(imvuIds);
|
||||
return new Set(checks.map((c) => c.imvuId!).filter((id) => imvuIds.includes(id)));
|
||||
}
|
||||
|
||||
/** The latest complete check of each of these IMVU accounts by a provider (for showing how they stand). */
|
||||
async accountChecks(provider: string, imvuIds: number[]) {
|
||||
if (imvuIds.length === 0) return new Map<number, typeof ageCheck.$inferSelect>();
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(ageCheck)
|
||||
.where(and(eq(ageCheck.provider, provider), eq(ageCheck.status, "complete"), inArray(ageCheck.imvuId, imvuIds)));
|
||||
return new Map(rows.map((r) => [r.imvuId!, r]));
|
||||
}
|
||||
|
||||
/** The person's own checks (not of an IMVU account), newest first. */
|
||||
async personChecks(userId: string) {
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(ageCheck)
|
||||
.where(and(eq(ageCheck.userId, userId), isNull(ageCheck.imvuId)));
|
||||
return rows.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
}
|
||||
|
||||
/** Checks again the IMVU-account checks that expired (at most limit), with providers that can re-check. */
|
||||
async recheckDue(limit = 50): Promise<number> {
|
||||
const due = await this.db
|
||||
.select()
|
||||
.from(ageCheck)
|
||||
.where(and(eq(ageCheck.status, "complete"), lte(ageCheck.expiresAt, new Date())))
|
||||
.limit(limit);
|
||||
let n = 0;
|
||||
for (const c of due) {
|
||||
const p = this.providers.get(c.provider);
|
||||
if (!p?.recheck) continue;
|
||||
const subject = { userId: c.userId, imvuId: c.imvuId ?? undefined };
|
||||
try {
|
||||
await this.record(subject, p.id, await p.recheck(subject));
|
||||
n++;
|
||||
} catch {
|
||||
// Try again next time; the old answer stays expired (not adult) meanwhile.
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
}
|
||||
+121
-36
@@ -4,12 +4,14 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import { and, desc, eq, ilike, inArray, ne, or, sql, count, schema, type Database } from "@t5de/db";
|
||||
import type { Auth } from "./auth";
|
||||
import { AgeError, AgeService } from "./age";
|
||||
import { imvuAgeProvider, imvuResult } from "./age/imvu";
|
||||
import { ImvuClient, ImvuUnavailable, NoSuchImvuUser } from "./imvu";
|
||||
import { Realtime } from "./realtime";
|
||||
|
||||
const { user, session, imvuLink, linkChallenge, audit } = schema;
|
||||
|
||||
export type Deps = { db: Database; auth: Auth; imvu: ImvuClient; realtime?: Realtime };
|
||||
export type Deps = { db: Database; auth: Auth; imvu: ImvuClient; realtime?: Realtime; age?: AgeService };
|
||||
|
||||
/** An error shown to the person: the website displays message as is. */
|
||||
export class Problem extends Error {
|
||||
@@ -59,7 +61,8 @@ class Limiter {
|
||||
}
|
||||
}
|
||||
|
||||
export function createApp({ db, auth, imvu, realtime: rt }: Deps) {
|
||||
export function createApp({ db, auth, imvu, realtime: rt, age: ageService }: Deps) {
|
||||
const age = ageService ?? new AgeService(db, [imvuAgeProvider(imvu)]);
|
||||
const realtime =
|
||||
rt ??
|
||||
new Realtime(async (imvuId) => {
|
||||
@@ -79,22 +82,31 @@ export function createApp({ db, auth, imvu, realtime: rt }: Deps) {
|
||||
banReason: p.banReason ?? "",
|
||||
});
|
||||
|
||||
const linkView = (l: Link) => {
|
||||
const live = realtime.online().find((o) => o.imvuId === l.imvuId);
|
||||
return {
|
||||
imvuId: l.imvuId,
|
||||
username: l.username,
|
||||
linkedAt: l.linkedAt.toISOString(),
|
||||
ageVerified: l.ageVerified,
|
||||
accessPass: l.accessPass,
|
||||
adult: l.ageVerified || l.accessPass,
|
||||
checkedAt: l.checkedAt.toISOString(),
|
||||
showBadge: l.showBadge,
|
||||
showPresence: l.showPresence,
|
||||
online: live !== undefined,
|
||||
outfit: live?.outfit ?? [],
|
||||
};
|
||||
/** Links as the website shows them: with live presence, and age by the one rule. */
|
||||
const linkViews = async (userId: string, links: Link[]) => {
|
||||
const ids = links.map((l) => l.imvuId);
|
||||
const adult = await age.adultAccounts(userId, ids);
|
||||
const imvuChecks = await age.accountChecks("imvu", ids);
|
||||
const online = realtime.online();
|
||||
return links.map((l) => {
|
||||
const live = online.find((o) => o.imvuId === l.imvuId);
|
||||
const check = imvuChecks.get(l.imvuId);
|
||||
return {
|
||||
imvuId: l.imvuId,
|
||||
username: l.username,
|
||||
linkedAt: l.linkedAt.toISOString(),
|
||||
ageVerified: check?.method === "imvu_age_verified",
|
||||
accessPass: check?.method === "imvu_access_pass",
|
||||
adult: adult.has(l.imvuId),
|
||||
checkedAt: (check?.checkedAt ?? l.linkedAt).toISOString(),
|
||||
showBadge: l.showBadge,
|
||||
showPresence: l.showPresence,
|
||||
online: live !== undefined,
|
||||
outfit: live?.outfit ?? [],
|
||||
};
|
||||
});
|
||||
};
|
||||
const linkView = async (l: Link) => (await linkViews(l.userId, [l]))[0]!;
|
||||
|
||||
const audited = (actorId: string | null, action: string, target: string, detail: Record<string, unknown> = {}) =>
|
||||
db.insert(audit).values({ actorId, action, target, detail });
|
||||
@@ -105,6 +117,10 @@ export function createApp({ db, auth, imvu, realtime: rt }: Deps) {
|
||||
if (online.length === 0) return [];
|
||||
const links = await db.select().from(imvuLink).where(inArray(imvuLink.imvuId, online.map((o) => o.imvuId)));
|
||||
const mod = viewer !== undefined && isModerator(viewer);
|
||||
const adult = new Set<number>();
|
||||
if (mod) {
|
||||
for (const l of links) if ((await age.adultAccounts(l.userId, [l.imvuId])).has(l.imvuId)) adult.add(l.imvuId);
|
||||
}
|
||||
return online.flatMap((o) => {
|
||||
const l = links.find((l) => l.imvuId === o.imvuId);
|
||||
if (!l || (!l.showPresence && !mod)) return [];
|
||||
@@ -115,7 +131,7 @@ export function createApp({ db, auth, imvu, realtime: rt }: Deps) {
|
||||
badge: l.showBadge,
|
||||
since: o.since.toISOString(),
|
||||
outfit: o.outfit,
|
||||
...(mod ? { hidden: !l.showPresence, personId: o.userId, adult: l.ageVerified || l.accessPass } : {}),
|
||||
...(mod ? { hidden: !l.showPresence, personId: o.userId, adult: adult.has(o.imvuId) } : {}),
|
||||
},
|
||||
];
|
||||
});
|
||||
@@ -249,7 +265,7 @@ export function createApp({ db, auth, imvu, realtime: rt }: Deps) {
|
||||
.where(and(eq(session.userId, me.id), ilike(session.userAgent, "t5de-proxy%"), sql`${session.expiresAt} > now()`));
|
||||
return {
|
||||
person: personView(me),
|
||||
links: links.map(linkView),
|
||||
links: await linkViews(me.id, links),
|
||||
challenges: challenges.map((c) => ({
|
||||
imvuId: c.imvuId,
|
||||
username: c.username,
|
||||
@@ -322,13 +338,7 @@ export function createApp({ db, auth, imvu, realtime: rt }: Deps) {
|
||||
throw new Problem(403, "link_cap", `You can link up to ${me.linkCap} IMVU accounts. Ask a moderator if you need more.`);
|
||||
const [prev] = await tx.select().from(imvuLink).where(eq(imvuLink.imvuId, found.id));
|
||||
const moved = prev !== undefined && prev.userId !== me.id;
|
||||
const values = {
|
||||
userId: me.id,
|
||||
username: found.username,
|
||||
ageVerified: found.ageVerified,
|
||||
accessPass: found.accessPass,
|
||||
checkedAt: new Date(),
|
||||
};
|
||||
const values = { userId: me.id, username: found.username };
|
||||
let link: Link;
|
||||
if (!prev) {
|
||||
[link] = (await tx.insert(imvuLink).values({ imvuId: found.id, pairwiseId: randomID(), ...values }).returning()) as [Link];
|
||||
@@ -352,9 +362,12 @@ export function createApp({ db, auth, imvu, realtime: rt }: Deps) {
|
||||
});
|
||||
return { link, moved, previousOwner: prev?.userId };
|
||||
});
|
||||
// Age from IMVU, for this account; a moved account's earlier checks belonged to its previous owner.
|
||||
if (result.moved) await age.forgetAccount(found.id);
|
||||
await age.record({ userId: me.id, imvuId: found.id }, "imvu", imvuResult(found));
|
||||
if (result.moved) realtime.unlinked(result.previousOwner!, found.id);
|
||||
realtime.linked(me.id, found.id);
|
||||
return { link: linkView(result.link), moved: result.moved };
|
||||
return { link: await linkView(result.link), moved: result.moved };
|
||||
},
|
||||
{ signedIn: true, params: imvuIdParam },
|
||||
)
|
||||
@@ -378,7 +391,7 @@ export function createApp({ db, auth, imvu, realtime: rt }: Deps) {
|
||||
})
|
||||
.where(eq(imvuLink.imvuId, params.imvu))
|
||||
.returning();
|
||||
return { link: linkView(l!) };
|
||||
return { link: await linkView(l!) };
|
||||
},
|
||||
{
|
||||
signedIn: true,
|
||||
@@ -391,12 +404,9 @@ export function createApp({ db, auth, imvu, realtime: rt }: Deps) {
|
||||
async ({ me, params }) => {
|
||||
await ownLink(me, params.imvu);
|
||||
const found = await imvuCall(me, () => imvu.byId(params.imvu));
|
||||
const [l] = await db
|
||||
.update(imvuLink)
|
||||
.set({ username: found.username, ageVerified: found.ageVerified, accessPass: found.accessPass, checkedAt: new Date() })
|
||||
.where(eq(imvuLink.imvuId, params.imvu))
|
||||
.returning();
|
||||
return { link: linkView(l!) };
|
||||
await age.record({ userId: me.id, imvuId: params.imvu }, "imvu", imvuResult(found));
|
||||
const [l] = await db.update(imvuLink).set({ username: found.username }).where(eq(imvuLink.imvuId, params.imvu)).returning();
|
||||
return { link: await linkView(l!) };
|
||||
},
|
||||
{ signedIn: true, params: imvuIdParam },
|
||||
)
|
||||
@@ -405,6 +415,7 @@ export function createApp({ db, auth, imvu, realtime: rt }: Deps) {
|
||||
async ({ me, params, set }) => {
|
||||
const l = await ownLink(me, params.imvu);
|
||||
await db.delete(imvuLink).where(eq(imvuLink.imvuId, l.imvuId));
|
||||
await age.forgetAccount(l.imvuId);
|
||||
realtime.unlinked(me.id, l.imvuId);
|
||||
await audited(me.id, "link.remove", String(l.imvuId), { imvu: l.username });
|
||||
set.status = 204;
|
||||
@@ -439,6 +450,80 @@ export function createApp({ db, auth, imvu, realtime: rt }: Deps) {
|
||||
{ signedIn: true, body: t.Object({ code: t.String({ minLength: 4, maxLength: 32 }) }) },
|
||||
)
|
||||
|
||||
// Age verification beyond IMVU's: the providers in use, and the person's own checks.
|
||||
.get(
|
||||
"/age",
|
||||
async ({ me }) => ({
|
||||
providers: age.list(),
|
||||
checks: (await age.personChecks(me.id)).map((c) => ({
|
||||
provider: c.provider,
|
||||
method: c.method,
|
||||
status: c.status,
|
||||
overEighteen: c.overEighteen,
|
||||
createdAt: c.createdAt.toISOString(),
|
||||
checkedAt: c.checkedAt?.toISOString() ?? null,
|
||||
expiresAt: c.expiresAt?.toISOString() ?? null,
|
||||
})),
|
||||
}),
|
||||
{ signedIn: true },
|
||||
)
|
||||
.post(
|
||||
"/age/:provider/start",
|
||||
async ({ me, params, body }) => {
|
||||
let p;
|
||||
try {
|
||||
p = age.provider(params.provider);
|
||||
} catch {
|
||||
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);
|
||||
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 }) }) },
|
||||
)
|
||||
.post(
|
||||
"/age/:provider/complete",
|
||||
async ({ params, body }) => {
|
||||
const p = age.provider(params.provider);
|
||||
if (!p.complete) throw new Problem(404, "not_found", "Not found.");
|
||||
try {
|
||||
const c = await age.finish(p.id, body.ref, await p.complete(body.ref, body.response));
|
||||
return { status: c.status, overEighteen: c.overEighteen };
|
||||
} catch (e) {
|
||||
if (e instanceof AgeError) throw new Problem(404, "not_found", "That check isn't waiting for an answer.");
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
{ signedIn: true, params: t.Object({ provider: t.String() }), body: t.Object({ ref: t.String(), response: t.Unknown() }) },
|
||||
)
|
||||
// Providers' webhooks: each checks the request is really from it.
|
||||
.post(
|
||||
"/age/webhook/:provider",
|
||||
async ({ params, request, set }) => {
|
||||
let p;
|
||||
try {
|
||||
p = age.provider(params.provider);
|
||||
} catch {
|
||||
throw new Problem(404, "not_found", "Not found.");
|
||||
}
|
||||
if (!p.webhook) throw new Problem(404, "not_found", "Not found.");
|
||||
let answer;
|
||||
try {
|
||||
answer = await p.webhook(request);
|
||||
} catch {
|
||||
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
|
||||
}
|
||||
set.status = 204;
|
||||
},
|
||||
{ params: t.Object({ provider: t.String() }), parse: "none" },
|
||||
)
|
||||
|
||||
// The console.
|
||||
.get(
|
||||
"/console",
|
||||
@@ -496,7 +581,7 @@ export function createApp({ db, auth, imvu, realtime: rt }: Deps) {
|
||||
.select({ n: count() })
|
||||
.from(session)
|
||||
.where(and(eq(session.userId, p.id), ilike(session.userAgent, "t5de-proxy%"), sql`${session.expiresAt} > now()`));
|
||||
return { person: personView(p), links: links.map(linkView), computers: computers?.n ?? 0 };
|
||||
return { person: personView(p), links: await linkViews(p.id, links), computers: computers?.n ?? 0 };
|
||||
},
|
||||
{ moderator: true },
|
||||
)
|
||||
@@ -578,7 +663,7 @@ export function createApp({ db, auth, imvu, realtime: rt }: Deps) {
|
||||
.get("/health", () => ({ ok: true }))
|
||||
.use(api);
|
||||
|
||||
return { app, realtime };
|
||||
return { app, realtime, age };
|
||||
}
|
||||
|
||||
export type App = ReturnType<typeof createApp>["app"];
|
||||
@@ -22,6 +22,11 @@ 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 } = createApp({ db, auth, imvu: new ImvuClient() });
|
||||
const { app, age } = createApp({ db, auth, imvu: new ImvuClient() });
|
||||
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.
|
||||
setInterval(() => {
|
||||
age.recheckDue(50).catch((e) => console.error("age re-checks:", e));
|
||||
}, 10 * 60 * 1000);
|
||||
console.log(`t5de-hub API on ${apiURL} (website ${webURL})`);
|
||||
@@ -0,0 +1,136 @@
|
||||
// Age verification: the one rule, providers that check a person (here a fake document-check vendor with signed
|
||||
// webhooks), and IMVU's answers changing.
|
||||
import { afterAll, beforeAll, expect, test } from "bun:test";
|
||||
import { openDatabase, schema, type Database } from "@t5de/db";
|
||||
import { AgeService, type Provider } from "../src/age";
|
||||
import { imvuAgeProvider } from "../src/age/imvu";
|
||||
import { createApp } from "../src/app";
|
||||
import { createAuth } from "../src/auth";
|
||||
import { ImvuClient, type ImvuUser } from "../src/imvu";
|
||||
|
||||
const webURL = "http://web.test";
|
||||
const accounts = new Map<number, ImvuUser>();
|
||||
const fakeIMVU: typeof fetch = (async (input: RequestInfo | URL) => {
|
||||
const url = new URL(String(input));
|
||||
const u = [...accounts.values()].find(
|
||||
(u) => url.pathname === `/user/user-${u.id}` || url.searchParams.get("username")?.toLowerCase() === u.username.toLowerCase(),
|
||||
);
|
||||
return Response.json({
|
||||
denormalized: u
|
||||
? { [`https://api.imvu.com/user/user-${u.id}`]: { data: { legacy_cid: u.id, username: u.username, tagline: u.tagline, is_ageverified: u.ageVerified, is_ap: u.accessPass } } }
|
||||
: {},
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
// A document-check vendor: sends the person to its page, then reports by a webhook signed with a shared secret.
|
||||
const webhookSecret = "vendor-secret";
|
||||
const fakeVendor: Provider = {
|
||||
id: "vendor",
|
||||
label: "ID document",
|
||||
scope: "person",
|
||||
start: async () => {
|
||||
const ref = `session-${crypto.randomUUID()}`;
|
||||
return { kind: "redirect", url: `https://vendor.test/check/${ref}`, ref };
|
||||
},
|
||||
webhook: async (request) => {
|
||||
if (request.headers.get("x-signature") !== webhookSecret) throw new Error("bad signature");
|
||||
const body = (await request.json()) as { session: string; approved: boolean; over18: boolean };
|
||||
return { ref: body.session, result: body.approved ? { overEighteen: body.over18, method: "id_document" } : "failed" };
|
||||
},
|
||||
};
|
||||
|
||||
let db: Database;
|
||||
let close: () => Promise<void>;
|
||||
let base: string;
|
||||
let age: AgeService;
|
||||
let stop: () => void;
|
||||
|
||||
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 });
|
||||
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, headers: Record<string, string> = {}) {
|
||||
const res = await fetch(base + path, {
|
||||
method,
|
||||
headers: { Origin: webURL, ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...(cookie.v ? { Cookie: cookie.v } : {}), ...headers },
|
||||
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 personWithAccounts(name: string, ids: { id: number; ageVerified?: boolean; accessPass?: boolean }[]) {
|
||||
const cookie = { v: "" };
|
||||
await call(cookie, "POST", "/api/auth/sign-up/email", { email: `${name}@example.test`, password: "a long password", name, username: name });
|
||||
for (const a of ids) {
|
||||
accounts.set(a.id, { id: a.id, username: `${name}${a.id}`, tagline: "", ageVerified: !!a.ageVerified, accessPass: !!a.accessPass });
|
||||
const started = await call(cookie, "POST", "/api/v1/account/links", { username: `${name}${a.id}` });
|
||||
accounts.get(a.id)!.tagline = started.json.challenge.code;
|
||||
expect((await call(cookie, "POST", `/api/v1/account/links/${a.id}/check`)).status).toBe(200);
|
||||
}
|
||||
return cookie;
|
||||
}
|
||||
|
||||
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]));
|
||||
|
||||
test("IMVU's answer is per account, and re-checked when it expires", async () => {
|
||||
const a = await personWithAccounts("ann", [{ id: 1, ageVerified: true }, { id: 2 }, { id: 3, accessPass: true }]);
|
||||
expect(await adultOf(a)).toEqual({ 1: true, 2: false, 3: true });
|
||||
|
||||
// IMVU revokes account 1's verification; once its answer expires, the re-check sees it.
|
||||
accounts.get(1)!.ageVerified = false;
|
||||
await db.update(schema.ageCheck).set({ expiresAt: new Date(Date.now() - 1000) });
|
||||
expect(await age.recheckDue()).toBeGreaterThanOrEqual(3);
|
||||
expect(await adultOf(a)).toEqual({ 1: false, 2: false, 3: true });
|
||||
});
|
||||
|
||||
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"]);
|
||||
// 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);
|
||||
|
||||
const started = (await call(b, "POST", "/api/v1/age/vendor/start", { returnURL: `${webURL}/account` })).json;
|
||||
expect(started.kind).toBe("redirect");
|
||||
expect((await call(b, "GET", "/api/v1/age")).json.checks[0]).toMatchObject({ provider: "vendor", status: "pending" });
|
||||
|
||||
const hook = (signature: string, approved: boolean) =>
|
||||
call({ v: "" }, "POST", "/api/v1/age/webhook/vendor", { session: started.ref, approved, over18: true }, { "x-signature": signature });
|
||||
expect((await hook("forged", true)).status).toBe(401);
|
||||
expect(await adultOf(b)).toEqual({ 10: false, 11: false });
|
||||
expect((await hook(webhookSecret, true)).status).toBe(204);
|
||||
expect(await adultOf(b)).toEqual({ 10: true, 11: true });
|
||||
// A repeated webhook is harmless.
|
||||
expect((await hook(webhookSecret, true)).status).toBe(204);
|
||||
});
|
||||
|
||||
test("a moved account takes nobody's age with it", async () => {
|
||||
const c = await personWithAccounts("cal", [{ id: 20, ageVerified: true }]);
|
||||
expect(await adultOf(c)).toEqual({ 20: true });
|
||||
// Dee proves the account is hers; IMVU now says it isn't verified (say, the verification was the old owner's).
|
||||
accounts.get(20)!.ageVerified = false;
|
||||
const d = { v: "" };
|
||||
await call(d, "POST", "/api/auth/sign-up/email", { email: "[email protected]", password: "a long password", name: "dee", username: "dee" });
|
||||
const started = await call(d, "POST", "/api/v1/account/links", { username: "cal20" });
|
||||
accounts.get(20)!.tagline = started.json.challenge.code;
|
||||
expect((await call(d, "POST", "/api/v1/account/links/20/check")).json.moved).toBe(true);
|
||||
expect(await adultOf(d)).toEqual({ 20: false });
|
||||
const leftovers = await db.select().from(schema.ageCheck);
|
||||
expect(leftovers.filter((c) => c.imvuId === 20)).toHaveLength(1);
|
||||
});
|
||||
+12
@@ -48,6 +48,18 @@ is. Codes: `invalid`, `signed_out`, `forbidden`, `not_found`, `no_such_imvu_acco
|
||||
| `DELETE /api/v1/account/links/:imvu` | unlink |
|
||||
| `POST /api/v1/account/signout-computers` | signs T5DE out on all their computers |
|
||||
|
||||
## Age verification
|
||||
|
||||
Vendor-neutral (docs/design.md, "Age"): IMVU's answer is recorded when an account is linked or checked again;
|
||||
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) |
|
||||
| `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 |
|
||||
|
||||
## Console (moderators; roles: admins)
|
||||
|
||||
| | |
|
||||
|
||||
+35
-8
@@ -101,16 +101,43 @@ age, not verified) and `tagline`.
|
||||
offer to link. Linked to another hub account: no community features that session.
|
||||
- **Bans** apply to the person, so all linked accounts.
|
||||
|
||||
## Age (**Decided**: IMVU's verification)
|
||||
## Age (**Decided**: free, several providers, vendor-neutral)
|
||||
|
||||
- Adult content requires the **IMVU account in use** to be age verified (`is_ageverified`) or to have Access Pass
|
||||
(`is_ap`; **Decided**: it counts), checked by the hub
|
||||
itself (never reported by the proxy, which anyone can change): at linking and at most daily after, cached.
|
||||
Revocations show up at the next check.
|
||||
- Per IMVU account, not per person, so one verified account doesn't unlock others a minor might use.
|
||||
No payment for verification (adult content rules out mainstream merchants of record; see the discussion of
|
||||
2026-10-29). Several ways to prove age, tried in order of privacy, behind one interface so any can be swapped:
|
||||
|
||||
1. **IMVU** (live): the IMVU account is age verified (`is_ageverified`) or has Access Pass (`is_ap`), read by the hub
|
||||
from IMVU's public API. Scope: that IMVU account. Re-checked about daily; a revocation shows at the next check.
|
||||
Self-reported `is_adult` doesn't count.
|
||||
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).
|
||||
|
||||
### How it's built
|
||||
|
||||
- **One table of results**, `age_check`: subject (a person, or one of their linked IMVU accounts), provider,
|
||||
method (e.g. `imvu_age_verified`, `imvu_access_pass`, `wallet_mdoc`, `id_document`), status (`pending`,
|
||||
`complete`, `failed`, `revoked`), over 18 yes/no, the provider's reference, when it was checked and when it
|
||||
expires. Never a birth date, document, photo or name: providers that see those keep them.
|
||||
- **One rule**: an IMVU account counts as adult if its person has a current complete check that says over 18, or
|
||||
the account itself has one. Everything (website, tokens for catalogs, room channels) asks this one function.
|
||||
- **Providers** implement a small interface: `start` (begin a check: done at once, a page to send the person to,
|
||||
or a request for the browser to hand to a wallet), `complete` (finish from the provider's callback or signed
|
||||
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/<provider>` and each provider checks its own signature.
|
||||
- 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 verified and opted in), and the viewer's proxy.
|
||||
- Self-reported `is_adult` doesn't count.
|
||||
channels for viewers who aren't adult and opted in), and the viewer's proxy.
|
||||
|
||||
### 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.
|
||||
|
||||
## Products and catalogs
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
CREATE TABLE "age_check" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"imvu_id" bigint,
|
||||
"provider" text NOT NULL,
|
||||
"method" text DEFAULT '' NOT NULL,
|
||||
"status" text NOT NULL,
|
||||
"over_eighteen" boolean DEFAULT false NOT NULL,
|
||||
"provider_ref" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"checked_at" timestamp with time zone,
|
||||
"expires_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "age_check" ADD CONSTRAINT "age_check_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "age_check_user" ON "age_check" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "age_check_imvu" ON "age_check" USING btree ("imvu_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "age_check_provider_ref" ON "age_check" USING btree ("provider","provider_ref");--> statement-breakpoint
|
||||
-- Existing links' IMVU results become IMVU age checks (expiring at once, so they're checked again soon).
|
||||
INSERT INTO "age_check" ("id", "user_id", "imvu_id", "provider", "method", "status", "over_eighteen", "checked_at", "expires_at")
|
||||
SELECT md5(random()::text || "imvu_id"::text), "user_id", "imvu_id", 'imvu',
|
||||
CASE WHEN "age_verified" THEN 'imvu_age_verified' WHEN "access_pass" THEN 'imvu_access_pass' ELSE 'imvu_none' END,
|
||||
'complete', "age_verified" OR "access_pass", "checked_at", now()
|
||||
FROM "imvu_link";--> statement-breakpoint
|
||||
ALTER TABLE "imvu_link" DROP COLUMN "age_verified";--> statement-breakpoint
|
||||
ALTER TABLE "imvu_link" DROP COLUMN "access_pass";--> statement-breakpoint
|
||||
ALTER TABLE "imvu_link" DROP COLUMN "checked_at";
|
||||
@@ -0,0 +1,999 @@
|
||||
{
|
||||
"id": "6311568f-0471-47a9-8199-229d365d3dc4",
|
||||
"prevId": "47a7aacf-4e5f-4181-9ac1-0100ba416d0c",
|
||||
"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.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.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
|
||||
}
|
||||
},
|
||||
"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.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.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.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
|
||||
}
|
||||
},
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,13 @@
|
||||
"when": 1793273462704,
|
||||
"tag": "0000_init",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1793277687785,
|
||||
"tag": "0001_age_checks",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -137,15 +137,39 @@ export const imvuLink = pgTable(
|
||||
pairwiseId: text("pairwise_id").notNull().unique(),
|
||||
username: text("username").notNull(),
|
||||
linkedAt: timestamp("linked_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
ageVerified: boolean("age_verified").notNull().default(false),
|
||||
accessPass: boolean("access_pass").notNull().default(false),
|
||||
checkedAt: timestamp("checked_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
showBadge: boolean("show_badge").notNull().default(false),
|
||||
showPresence: boolean("show_presence").notNull().default(false),
|
||||
},
|
||||
(t) => [index("imvu_link_user").on(t.userId)],
|
||||
);
|
||||
|
||||
// An age check by one provider: of a person, or of one of their linked IMVU accounts. Only the outcome: never a
|
||||
// birth date, document, photo or name (providers that see those keep them).
|
||||
export const ageCheck = pgTable(
|
||||
"age_check",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
// Set when the check is of one IMVU account (IMVU's own verification); null when it's of the person.
|
||||
imvuId: bigint("imvu_id", { mode: "number" }),
|
||||
provider: text("provider").notNull(), // "imvu", "didit", "wallet", ...
|
||||
method: text("method").notNull().default(""), // e.g. "imvu_age_verified", "id_document", "wallet_mdoc"
|
||||
status: text("status").notNull(), // "pending", "complete", "failed", "revoked"
|
||||
overEighteen: boolean("over_eighteen").notNull().default(false),
|
||||
providerRef: text("provider_ref"), // the provider's session or transaction ID
|
||||
createdAt: createdAt(),
|
||||
checkedAt: timestamp("checked_at", { withTimezone: true }),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }),
|
||||
},
|
||||
(t) => [
|
||||
index("age_check_user").on(t.userId),
|
||||
index("age_check_imvu").on(t.imvuId),
|
||||
uniqueIndex("age_check_provider_ref").on(t.provider, t.providerRef),
|
||||
],
|
||||
);
|
||||
|
||||
// A code the person is to put in an IMVU account's tagline, proving it's theirs.
|
||||
export const linkChallenge = pgTable(
|
||||
"link_challenge",
|
||||
|
||||
Reference in new issue
Block a user