CI / test (push) Successful in 36s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
585 lines
23 KiB
TypeScript
585 lines
23 KiB
TypeScript
// The hub's API: Better Auth at /api/auth, the hub's own routes at /api/v1. The website calls it through its own
|
|
// origin (Next.js rewrites /api to here), so session cookies are first-party; T5DE's proxy calls it with a bearer
|
|
// token.
|
|
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 { 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 };
|
|
|
|
/** An error shown to the person: the website displays message as is. */
|
|
export class Problem extends Error {
|
|
constructor(
|
|
readonly status: number,
|
|
readonly slug: string,
|
|
message: string,
|
|
) {
|
|
super(message);
|
|
}
|
|
}
|
|
|
|
type Person = typeof user.$inferSelect;
|
|
type Link = typeof imvuLink.$inferSelect;
|
|
|
|
const roles = ["user", "moderator", "admin"] as const;
|
|
const isModerator = (p: Person) => p.role === "moderator" || p.role === "admin";
|
|
|
|
// Link codes: "t5de-" and eight letters and digits without look-alikes.
|
|
function linkCode(): string {
|
|
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
const bytes = crypto.getRandomValues(new Uint8Array(8));
|
|
const s = [...bytes].map((b) => alphabet[b % alphabet.length]).join("");
|
|
return `t5de-${s.slice(0, 4)}-${s.slice(4)}`;
|
|
}
|
|
|
|
function randomID(bytes = 16): string {
|
|
return Buffer.from(crypto.getRandomValues(new Uint8Array(bytes))).toString("base64url");
|
|
}
|
|
|
|
const challengeLifetime = 24 * 60 * 60 * 1000;
|
|
|
|
/** Allows n attempts per key in a window (IMVU lookups, which cost IMVU a request each). */
|
|
class Limiter {
|
|
private hits = new Map<string, number[]>();
|
|
allow(key: string, n: number, windowMs: number): boolean {
|
|
const now = Date.now();
|
|
const recent = (this.hits.get(key) ?? []).filter((t) => now - t < windowMs);
|
|
if (recent.length >= n) {
|
|
this.hits.set(key, recent);
|
|
return false;
|
|
}
|
|
recent.push(now);
|
|
this.hits.set(key, recent);
|
|
if (this.hits.size > 100_000) this.hits.clear();
|
|
return true;
|
|
}
|
|
}
|
|
|
|
export function createApp({ db, auth, imvu, realtime: rt }: Deps) {
|
|
const realtime =
|
|
rt ??
|
|
new Realtime(async (imvuId) => {
|
|
const [l] = await db.select({ userId: imvuLink.userId }).from(imvuLink).where(eq(imvuLink.imvuId, imvuId));
|
|
return l?.userId;
|
|
});
|
|
const limiter = new Limiter();
|
|
|
|
const personView = (p: Person) => ({
|
|
id: p.id,
|
|
username: p.displayUsername ?? p.username ?? p.name,
|
|
email: p.email,
|
|
role: (roles as readonly string[]).includes(p.role) ? (p.role as (typeof roles)[number]) : "user",
|
|
linkCap: p.linkCap,
|
|
createdAt: p.createdAt.toISOString(),
|
|
banned: p.banned === true,
|
|
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 ?? [],
|
|
};
|
|
};
|
|
|
|
const audited = (actorId: string | null, action: string, target: string, detail: Record<string, unknown> = {}) =>
|
|
db.insert(audit).values({ actorId, action, target, detail });
|
|
|
|
/** Who's online that viewer may see: accounts showing their presence (moderators see all). */
|
|
const visibleOnline = async (viewer: Person | undefined) => {
|
|
const online = realtime.online();
|
|
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);
|
|
return online.flatMap((o) => {
|
|
const l = links.find((l) => l.imvuId === o.imvuId);
|
|
if (!l || (!l.showPresence && !mod)) return [];
|
|
return [
|
|
{
|
|
imvuId: o.imvuId,
|
|
username: l.username,
|
|
badge: l.showBadge,
|
|
since: o.since.toISOString(),
|
|
outfit: o.outfit,
|
|
...(mod ? { hidden: !l.showPresence, personId: o.userId, adult: l.ageVerified || l.accessPass } : {}),
|
|
},
|
|
];
|
|
});
|
|
};
|
|
|
|
const imvuIdParam = t.Object({ imvu: t.Numeric({ minimum: 1 }) });
|
|
|
|
const ownLink = async (p: Person, imvuId: number) => {
|
|
const [l] = await db.select().from(imvuLink).where(and(eq(imvuLink.imvuId, imvuId), eq(imvuLink.userId, p.id)));
|
|
if (!l) throw new Problem(404, "not_found", "That account isn't linked to you.");
|
|
return l;
|
|
};
|
|
|
|
const imvuCall = async <T>(p: Person, call: () => Promise<T>): Promise<T> => {
|
|
if (!limiter.allow(`imvu:${p.id}`, 20, 60 * 60 * 1000))
|
|
throw new Problem(429, "rate_limited", "Too many lookups. Try again in a while.");
|
|
try {
|
|
return await call();
|
|
} catch (e) {
|
|
if (e instanceof NoSuchImvuUser) throw new Problem(404, "no_such_imvu_account", "IMVU has no account by that name.");
|
|
if (e instanceof ImvuUnavailable) throw new Problem(502, "imvu_unavailable", "IMVU didn't answer. Try again in a moment.");
|
|
throw e;
|
|
}
|
|
};
|
|
|
|
const personById = async (id: string) => {
|
|
const [p] = await db.select().from(user).where(eq(user.id, id));
|
|
if (!p) throw new Problem(404, "not_found", "No such person.");
|
|
return p;
|
|
};
|
|
|
|
const api = new Elysia({ prefix: "/api/v1" })
|
|
.error({ Problem })
|
|
.onError(({ code, error, set }) => {
|
|
if (code === "Problem") {
|
|
set.status = error.status;
|
|
return { error: error.slug, message: error.message };
|
|
}
|
|
if (code === "VALIDATION") {
|
|
set.status = 400;
|
|
return { error: "invalid", message: "Some of that isn't right. Check it and try again." };
|
|
}
|
|
if (code === "NOT_FOUND") {
|
|
set.status = 404;
|
|
return { error: "not_found", message: "Not found." };
|
|
}
|
|
})
|
|
.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 };
|
|
})
|
|
.macro({
|
|
signedIn: {
|
|
resolve: ({ person }) => {
|
|
if (!person) throw new Problem(401, "signed_out", "Sign in first.");
|
|
return { me: person };
|
|
},
|
|
},
|
|
moderator: {
|
|
resolve: ({ person }) => {
|
|
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 };
|
|
},
|
|
},
|
|
})
|
|
|
|
// Who's signed in, and the home page.
|
|
.get("/me", ({ me }) => ({ person: personView(me) }), { signedIn: true })
|
|
.get("/home", async ({ person }) => {
|
|
const online = (await visibleOnline(person)).length;
|
|
if (!person) return { onlineCount: online };
|
|
const [row] = await db.select({ n: count() }).from(imvuLink).where(eq(imvuLink.userId, person.id));
|
|
return { onlineCount: online, links: row?.n ?? 0 };
|
|
})
|
|
|
|
// Who's online.
|
|
.get("/online", async ({ person }) => ({ online: await visibleOnline(person) }))
|
|
.get("/online/events", ({ person, request }) => {
|
|
const encoder = new TextEncoder();
|
|
let stop = () => {};
|
|
const stream = new ReadableStream({
|
|
start(controller) {
|
|
const send = async () => {
|
|
const online = await visibleOnline(person);
|
|
try {
|
|
controller.enqueue(encoder.encode(`event: online\ndata: ${JSON.stringify({ online })}\n\n`));
|
|
} catch {}
|
|
};
|
|
const unsubscribe = realtime.subscribe(() => void send());
|
|
const keepAlive = setInterval(() => {
|
|
try {
|
|
controller.enqueue(encoder.encode(": keep-alive\n\n"));
|
|
} catch {}
|
|
}, 25_000);
|
|
stop = () => {
|
|
unsubscribe();
|
|
clearInterval(keepAlive);
|
|
};
|
|
request.signal.addEventListener("abort", () => {
|
|
stop();
|
|
try {
|
|
controller.close();
|
|
} catch {}
|
|
});
|
|
void send();
|
|
},
|
|
cancel() {
|
|
stop();
|
|
},
|
|
});
|
|
return new Response(stream, {
|
|
headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-store", Connection: "keep-alive" },
|
|
});
|
|
})
|
|
|
|
// Your account.
|
|
.get(
|
|
"/account",
|
|
async ({ me }) => {
|
|
const links = await db.select().from(imvuLink).where(eq(imvuLink.userId, me.id)).orderBy(imvuLink.linkedAt);
|
|
const challenges = await db
|
|
.select()
|
|
.from(linkChallenge)
|
|
.where(and(eq(linkChallenge.userId, me.id), sql`${linkChallenge.createdAt} > now() - interval '1 day'`))
|
|
.orderBy(linkChallenge.createdAt);
|
|
const [computers] = await db
|
|
.select({ n: count() })
|
|
.from(session)
|
|
.where(and(eq(session.userId, me.id), ilike(session.userAgent, "t5de-proxy%"), sql`${session.expiresAt} > now()`));
|
|
return {
|
|
person: personView(me),
|
|
links: links.map(linkView),
|
|
challenges: challenges.map((c) => ({
|
|
imvuId: c.imvuId,
|
|
username: c.username,
|
|
code: c.code,
|
|
createdAt: c.createdAt.toISOString(),
|
|
expiresAt: new Date(c.createdAt.getTime() + challengeLifetime).toISOString(),
|
|
})),
|
|
computers: computers?.n ?? 0,
|
|
canLink: links.length < me.linkCap,
|
|
};
|
|
},
|
|
{ signedIn: true },
|
|
)
|
|
.post(
|
|
"/account/links",
|
|
async ({ me, body, set }) => {
|
|
const found = await imvuCall(me, () => imvu.byUsername(body.username));
|
|
const [existing] = await db.select().from(imvuLink).where(eq(imvuLink.imvuId, found.id));
|
|
if (existing?.userId === me.id)
|
|
throw new Problem(409, "already_linked", `${found.username} is already linked.`);
|
|
const code = linkCode();
|
|
const [c] = await db
|
|
.insert(linkChallenge)
|
|
.values({ userId: me.id, imvuId: found.id, username: found.username, code })
|
|
.onConflictDoUpdate({
|
|
target: [linkChallenge.userId, linkChallenge.imvuId],
|
|
set: { code, username: found.username, createdAt: new Date() },
|
|
})
|
|
.returning();
|
|
set.status = 201;
|
|
return {
|
|
challenge: {
|
|
imvuId: c!.imvuId,
|
|
username: c!.username,
|
|
code: c!.code,
|
|
createdAt: c!.createdAt.toISOString(),
|
|
expiresAt: new Date(c!.createdAt.getTime() + challengeLifetime).toISOString(),
|
|
},
|
|
};
|
|
},
|
|
{ signedIn: true, body: t.Object({ username: t.String({ minLength: 1, maxLength: 64 }) }) },
|
|
)
|
|
.post(
|
|
"/account/links/:imvu/check",
|
|
async ({ me, params }) => {
|
|
const [c] = await db
|
|
.select()
|
|
.from(linkChallenge)
|
|
.where(
|
|
and(
|
|
eq(linkChallenge.userId, me.id),
|
|
eq(linkChallenge.imvuId, params.imvu),
|
|
sql`${linkChallenge.createdAt} > now() - interval '1 day'`,
|
|
),
|
|
);
|
|
if (!c) throw new Problem(410, "expired", "That code expired. Start again.");
|
|
const found = await imvuCall(me, () => imvu.byId(params.imvu));
|
|
if (!found.tagline.toLowerCase().includes(c.code.toLowerCase()))
|
|
throw new Problem(
|
|
409,
|
|
"code_missing",
|
|
`${found.username}'s tagline doesn't have the code yet. IMVU can take a minute to show a new tagline.`,
|
|
);
|
|
const result = await db.transaction(async (tx) => {
|
|
const [{ n } = { n: 0 }] = await tx
|
|
.select({ n: count() })
|
|
.from(imvuLink)
|
|
.where(and(eq(imvuLink.userId, me.id), ne(imvuLink.imvuId, found.id)));
|
|
if (n >= me.linkCap)
|
|
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(),
|
|
};
|
|
let link: Link;
|
|
if (!prev) {
|
|
[link] = (await tx.insert(imvuLink).values({ imvuId: found.id, pairwiseId: randomID(), ...values }).returning()) as [Link];
|
|
} else if (moved) {
|
|
// A moved account gets a new pairwise ID and starts with nothing shown: nothing connects the new owner
|
|
// to the old one.
|
|
[link] = (await tx
|
|
.update(imvuLink)
|
|
.set({ ...values, pairwiseId: randomID(), linkedAt: new Date(), showBadge: false, showPresence: false })
|
|
.where(eq(imvuLink.imvuId, found.id))
|
|
.returning()) as [Link];
|
|
} else {
|
|
[link] = (await tx.update(imvuLink).set(values).where(eq(imvuLink.imvuId, found.id)).returning()) as [Link];
|
|
}
|
|
await tx.delete(linkChallenge).where(eq(linkChallenge.imvuId, found.id));
|
|
await tx.insert(audit).values({
|
|
actorId: me.id,
|
|
action: "link.add",
|
|
target: String(found.id),
|
|
detail: moved ? { imvu: found.username, movedFrom: prev!.userId } : { imvu: found.username },
|
|
});
|
|
return { link, moved, previousOwner: prev?.userId };
|
|
});
|
|
if (result.moved) realtime.unlinked(result.previousOwner!, found.id);
|
|
realtime.linked(me.id, found.id);
|
|
return { link: linkView(result.link), moved: result.moved };
|
|
},
|
|
{ signedIn: true, params: imvuIdParam },
|
|
)
|
|
.delete(
|
|
"/account/challenges/:imvu",
|
|
async ({ me, params, set }) => {
|
|
await db.delete(linkChallenge).where(and(eq(linkChallenge.userId, me.id), eq(linkChallenge.imvuId, params.imvu)));
|
|
set.status = 204;
|
|
},
|
|
{ signedIn: true, params: imvuIdParam },
|
|
)
|
|
.patch(
|
|
"/account/links/:imvu",
|
|
async ({ me, params, body }) => {
|
|
await ownLink(me, params.imvu);
|
|
const [l] = await db
|
|
.update(imvuLink)
|
|
.set({
|
|
...(body.showBadge !== undefined ? { showBadge: body.showBadge } : {}),
|
|
...(body.showPresence !== undefined ? { showPresence: body.showPresence } : {}),
|
|
})
|
|
.where(eq(imvuLink.imvuId, params.imvu))
|
|
.returning();
|
|
return { link: linkView(l!) };
|
|
},
|
|
{
|
|
signedIn: true,
|
|
params: imvuIdParam,
|
|
body: t.Object({ showBadge: t.Optional(t.Boolean()), showPresence: t.Optional(t.Boolean()) }),
|
|
},
|
|
)
|
|
.post(
|
|
"/account/links/:imvu/recheck",
|
|
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!) };
|
|
},
|
|
{ signedIn: true, params: imvuIdParam },
|
|
)
|
|
.delete(
|
|
"/account/links/:imvu",
|
|
async ({ me, params, set }) => {
|
|
const l = await ownLink(me, params.imvu);
|
|
await db.delete(imvuLink).where(eq(imvuLink.imvuId, l.imvuId));
|
|
realtime.unlinked(me.id, l.imvuId);
|
|
await audited(me.id, "link.remove", String(l.imvuId), { imvu: l.username });
|
|
set.status = 204;
|
|
},
|
|
{ signedIn: true, params: imvuIdParam },
|
|
)
|
|
.post(
|
|
"/account/signout-computers",
|
|
async ({ me, set }) => {
|
|
await db.delete(session).where(and(eq(session.userId, me.id), ilike(session.userAgent, "t5de-proxy%")));
|
|
realtime.disconnect(me.id);
|
|
set.status = 204;
|
|
},
|
|
{ signedIn: true },
|
|
)
|
|
|
|
// A proxy's sign-in code, approved by the person on the website.
|
|
.post(
|
|
"/device/approve",
|
|
async ({ me, body, request, set }) => {
|
|
const raw = body.code.toUpperCase().replace(/[\s-]/g, "");
|
|
try {
|
|
// Viewing the code claims it for this person; then they approve it.
|
|
await auth.api.deviceVerify({ query: { user_code: raw }, headers: request.headers });
|
|
await auth.api.deviceApprove({ body: { userCode: raw }, headers: request.headers });
|
|
} catch {
|
|
throw new Problem(404, "not_found", "That code isn't right, or it expired. Check the code T5DE shows.");
|
|
}
|
|
await audited(me.id, "proxy.signin", "");
|
|
set.status = 204;
|
|
},
|
|
{ signedIn: true, body: t.Object({ code: t.String({ minLength: 4, maxLength: 32 }) }) },
|
|
)
|
|
|
|
// The console.
|
|
.get(
|
|
"/console",
|
|
async ({ me }) => {
|
|
const [counts] = await db
|
|
.select({
|
|
people: sql<number>`(select count(*) from ${user})::int`,
|
|
links: sql<number>`(select count(*) from ${imvuLink})::int`,
|
|
computers: sql<number>`(select count(*) from ${session} where ${session.userAgent} ilike 't5de-proxy%' and ${session.expiresAt} > now())::int`,
|
|
})
|
|
.from(sql`(select 1) as one`);
|
|
const log = await db
|
|
.select({ at: audit.at, actor: user.username, action: audit.action, target: audit.target, detail: audit.detail })
|
|
.from(audit)
|
|
.leftJoin(user, eq(user.id, audit.actorId))
|
|
.orderBy(desc(audit.id))
|
|
.limit(50);
|
|
return {
|
|
counts: counts!,
|
|
connections: realtime.connections(),
|
|
online: await visibleOnline(me),
|
|
audit: log.map((e) => ({ ...e, at: e.at.toISOString(), actor: e.actor ?? "" })),
|
|
};
|
|
},
|
|
{ moderator: true },
|
|
)
|
|
.get(
|
|
"/console/people",
|
|
async ({ query }) => {
|
|
const q = (query.q ?? "").trim();
|
|
const like = `%${q.replace(/[%_\\]/g, (c) => "\\" + c)}%`;
|
|
const rows = await db
|
|
.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,
|
|
)
|
|
.orderBy(desc(user.createdAt))
|
|
.limit(100);
|
|
return { people: rows.map(personView) };
|
|
},
|
|
{ moderator: true, query: t.Object({ q: t.Optional(t.String({ maxLength: 64 })) }) },
|
|
)
|
|
.get(
|
|
"/console/people/:id",
|
|
async ({ params }) => {
|
|
const p = await personById(params.id);
|
|
const links = await db.select().from(imvuLink).where(eq(imvuLink.userId, p.id)).orderBy(imvuLink.linkedAt);
|
|
const [computers] = await db
|
|
.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 };
|
|
},
|
|
{ moderator: true },
|
|
)
|
|
.post(
|
|
"/console/people/:id/link-cap",
|
|
async ({ me, params, body }) => {
|
|
const p = await personById(params.id);
|
|
const [updated] = await db.update(user).set({ linkCap: body.linkCap }).where(eq(user.id, p.id)).returning();
|
|
await audited(me.id, "person.link_cap", p.username ?? p.id, { from: p.linkCap, to: body.linkCap });
|
|
return { person: personView(updated!) };
|
|
},
|
|
{ moderator: true, body: t.Object({ linkCap: t.Integer({ minimum: 0, maximum: 1000 }) }) },
|
|
)
|
|
.post(
|
|
"/console/people/:id/ban",
|
|
async ({ me, params, body }) => {
|
|
const p = await personById(params.id);
|
|
if (p.id === me.id) throw new Problem(400, "invalid", "You can't ban yourself.");
|
|
if (isModerator(p) && me.role !== "admin") throw new Problem(403, "forbidden", "Only admins can ban moderators.");
|
|
const [updated] = await db.update(user).set({ banned: true, banReason: body.reason }).where(eq(user.id, p.id)).returning();
|
|
await db.delete(session).where(eq(session.userId, p.id));
|
|
realtime.disconnect(p.id);
|
|
await audited(me.id, "person.ban", p.username ?? p.id, { reason: body.reason });
|
|
return { person: personView(updated!) };
|
|
},
|
|
{ moderator: true, body: t.Object({ reason: t.String({ minLength: 1, maxLength: 500 }) }) },
|
|
)
|
|
.post(
|
|
"/console/people/:id/unban",
|
|
async ({ me, params }) => {
|
|
const p = await personById(params.id);
|
|
const [updated] = await db.update(user).set({ banned: false, banReason: null }).where(eq(user.id, p.id)).returning();
|
|
await audited(me.id, "person.unban", p.username ?? p.id);
|
|
return { person: personView(updated!) };
|
|
},
|
|
{ moderator: true },
|
|
)
|
|
.post(
|
|
"/console/people/:id/role",
|
|
async ({ me, params, body }) => {
|
|
if (me.role !== "admin") throw new Problem(403, "forbidden", "Only admins can change roles.");
|
|
const p = await personById(params.id);
|
|
if (p.id === me.id) throw new Problem(400, "invalid", "Another admin has to change your role.");
|
|
const [updated] = await db.update(user).set({ role: body.role }).where(eq(user.id, p.id)).returning();
|
|
await audited(me.id, "person.role", p.username ?? p.id, { from: p.role, to: body.role });
|
|
return { person: personView(updated!) };
|
|
},
|
|
{
|
|
moderator: true,
|
|
body: t.Object({ role: t.Union([t.Literal("user"), t.Literal("moderator"), t.Literal("admin")]) }),
|
|
},
|
|
)
|
|
|
|
// T5DE's proxy: the live connection.
|
|
.ws("/realtime", {
|
|
async beforeHandle({ person, set }) {
|
|
if (!person) {
|
|
set.status = 401;
|
|
return { error: "signed_out", message: "Sign in first." };
|
|
}
|
|
},
|
|
open(ws) {
|
|
const { person } = ws.data as unknown as { person: Person };
|
|
realtime.open(ws.id, person.id, () => ws.close(4001, "signed out"));
|
|
ws.send(JSON.stringify({ type: "welcome" }));
|
|
},
|
|
async message(ws, message) {
|
|
const reply = await realtime.message(ws.id, message);
|
|
if (reply) ws.send(JSON.stringify(reply));
|
|
},
|
|
close(ws) {
|
|
realtime.close(ws.id);
|
|
},
|
|
});
|
|
|
|
const app = new Elysia()
|
|
.all("/api/auth/*", ({ request }) => auth.handler(request))
|
|
.get("/.well-known/jwks.json", async () => auth.api.getJwks())
|
|
.get("/health", () => ({ ok: true }))
|
|
.use(api);
|
|
|
|
return { app, realtime };
|
|
}
|
|
|
|
export type App = ReturnType<typeof createApp>["app"];
|