Skip to content
File

Blob: src/worker/api/account.ts

typescript82 lines
1import { eq } from "drizzle-orm";
2 
3import { readJsonBody } from "@/worker/api/request";
4import { users } from "@/worker/db/schema";
5import type { AppContext } from "@/worker/hono";
6import { HttpError } from "@/worker/http";
7import { requireSession } from "@/worker/middleware/auth";
8 
9// Slugs that would clash with tessera's own routes or with conventional
10// system roles in downstream RPs (git-on-cloudflare's `:owner` URL segment,
11// for example). Keep this list narrow — RFC says `preferred_username` need
12// not be unique, so this is just a footgun guard, not an identity policy.
13const RESERVED_HANDLES = new Set([
14 "admin",
15 "api",
16 "auth",
17 "oauth",
18 "oauth2",
19 "well-known",
20 "wellknown",
21 "callback",
22 "callbacks",
23 "consent",
24 "sign-in",
25 "sign-out",
26 "sign-up",
27 "signin",
28 "signout",
29 "signup",
30 "account",
31 "accounts",
32 "invite",
33 "invites",
34 "user",
35 "users",
36 "anonymous",
37 "system",
38]);
39 
40interface UpdateHandleBody {
41 preferredUsername?: unknown;
42}
43 
44const validateHandle = (raw: string): { ok: true; value: string } | { ok: false; error: string } => {
45 const v = raw.trim().toLowerCase();
46 if (v.length < 1) return { ok: false, error: "Username can't be empty." };
47 if (v.length > 32) return { ok: false, error: "Username must be 32 characters or fewer." };
48 if (!/^[a-z0-9][a-z0-9_-]*$/.test(v)) {
49 return {
50 ok: false,
51 error: "Use lowercase letters, digits, hyphens, or underscores. Must start with a letter or digit.",
52 };
53 }
54 if (RESERVED_HANDLES.has(v)) {
55 return { ok: false, error: `"${v}" is reserved. Pick a different one.` };
56 }
57 return { ok: true, value: v };
58};
59 
60export const handleUpdateHandle = async (c: AppContext): Promise<Response> => {
61 const session = requireSession(c);
62 const logger = c.var.log.child({ component: "account" });
63 const body = await readJsonBody<UpdateHandleBody>(c);
64 
65 if (typeof body.preferredUsername !== "string") {
66 throw new HttpError(400, "invalid_body", "preferredUsername (string) required.");
67 }
68 
69 const result = validateHandle(body.preferredUsername);
70 if (!result.ok) {
71 throw new HttpError(400, "invalid_username", result.error);
72 }
73 
74 await c.var.db
75 .update(users)
76 .set({ preferredUsername: result.value, updatedAt: new Date() })
77 .where(eq(users.id, session.user.id));
78 
79 logger.info("handle_updated", { userId: session.user.id, preferredUsername: result.value });
80 return c.json({ preferredUsername: result.value });
81};