me.ts335 lines · main
1import { Hono } from 'hono';
2import { eq } from 'drizzle-orm';
3import { z } from 'zod';
4
5import { getDb } from '../db/client.js';
6import { users } from '../db/schema.js';
7import { auth } from '../lib/auth.js';
8import { requireAuth } from '../middleware/session.js';
9import { rateLimit } from '../middleware/rate-limit.js';
10import type { AppEnv } from '../types/app-env.js';
11import { softDeleteAccount } from '../services/account-deletion.js';
12import { audit, hashIp } from '../services/audit.js';
13import { checkVatWithVies } from '../services/billing.js';
14import {
15 getCurrentVat,
16 getProfile,
17 setAvatar,
18 updateProfile,
19 type ProfilePatch,
20} from '../services/me.js';
21import { listOrgsForUser } from '../services/orgs.js';
22import { listProjectsForUser } from '../services/projects.js';
23import { listMyStorageUsage } from '../services/storage-admin.js';
24
25const patchSchema = z.object({
26 name: z.string().min(1).max(200).nullable().optional(),
27 legalName: z.string().min(1).max(200).nullable().optional(),
28 companyName: z.string().max(200).nullable().optional(),
29 companyRegistrationNumber: z.string().max(64).nullable().optional(),
30 vatId: z.string().max(32).nullable().optional(),
31 addressLine1: z.string().max(200).nullable().optional(),
32 addressLine2: z.string().max(200).nullable().optional(),
33 addressCity: z.string().max(120).nullable().optional(),
34 addressPostalCode: z.string().max(32).nullable().optional(),
35 addressRegion: z.string().max(120).nullable().optional(),
36 addressCountry: z
37 .string()
38 .regex(/^[A-Z]{2}$/u, 'country must be an ISO 3166-1 alpha-2 code')
39 .nullable()
40 .optional(),
41 dateOfBirth: z
42 .string()
43 .regex(/^\d{4}-\d{2}-\d{2}$/u, 'date of birth must be ISO yyyy-mm-dd')
44 .nullable()
45 .optional(),
46 countryOfBirth: z
47 .string()
48 .regex(/^[A-Z]{2}$/u, 'country of birth must be an ISO 3166-1 alpha-2 code')
49 .nullable()
50 .optional(),
51 timezone: z
52 .string()
53 .min(1)
54 .max(64)
55 .regex(
56 /^[A-Za-z]+(?:\/[A-Za-z0-9_+-]+)+$/u,
57 'timezone must be an IANA zone (e.g. Europe/Brussels)',
58 )
59 .nullable()
60 .optional(),
61});
62
63export const meRouter = new Hono<AppEnv>();
64
65meRouter.get('/v1/me', requireAuth(), async (c) => {
66 const user = c.get('user');
67 if (!user) return c.json({ code: 'unauthorized', message: 'authentication required' }, 401);
68 const profile = await getProfile(user.id);
69 return c.json(profile);
70});
71
72// Lightweight "what projects can I see?" list used by the CLI's wizard
73// (`existingBranch`) and by any caller that wants the minimum-viable
74// numbered-list payload without the dashboard-only enrichments returned
75// by `/v1/projects`. Six fields, one per project: id, slug, name, region,
76// tier, orgName. Same membership semantics as `listProjectsForUser` —
77// org-scoped OR project-scoped — joined to org for the org name.
78meRouter.get('/v1/me/projects', requireAuth(), async (c) => {
79 const user = c.get('user');
80 if (!user) return c.json({ code: 'unauthorized', message: 'authentication required' }, 401);
81 const [rows, orgs] = await Promise.all([
82 listProjectsForUser(user.id),
83 listOrgsForUser(user.id),
84 ]);
85 const orgsById = new Map(orgs.map((o) => [o.id, o]));
86 const projects = rows.map((p) => ({
87 id: p.id,
88 slug: p.slug,
89 name: p.name,
90 region: p.region,
91 tier: p.tier,
92 orgName: orgsById.get(p.orgId)?.name ?? null,
93 }));
94 return c.json({ projects });
95});
96
97// Cross-project object-storage ("S3 bucket") usage for the dashboard home:
98// every project the caller belongs to, with its file bytes used vs tier cap
99// and active storage-key count. User-scoped via listMyStorageUsage — only the
100// caller's own projects are ever returned.
101meRouter.get('/v1/me/storage', requireAuth(), async (c) => {
102 const user = c.get('user');
103 if (!user) return c.json({ code: 'unauthorized', message: 'authentication required' }, 401);
104 return c.json({ usage: await listMyStorageUsage(user.id) });
105});
106
107meRouter.patch('/v1/me', requireAuth(), async (c) => {
108 const user = c.get('user');
109 if (!user) return c.json({ code: 'unauthorized', message: 'authentication required' }, 401);
110
111 const body = await c.req.json().catch(() => null);
112 const parsed = patchSchema.safeParse(body);
113 if (!parsed.success) {
114 return c.json(
115 { code: 'validation_failed', message: 'invalid request body', issues: parsed.error.issues },
116 400,
117 );
118 }
119
120 // VAT is special. If the caller is changing vatId, we need to:
121 // 1. reject the edit if their existing VAT is already verified — support
122 // has to handle that change (tax treatment is tied to a point-in-time
123 // attestation we relied on).
124 // 2. otherwise, call VIES and stamp vat_verified_at ONLY on 'valid'.
125 // 'invalid' or 'unverifiable' both save without the timestamp — a
126 // VIES registry outage or a user typing their own number that VIES
127 // temporarily doesn't recognise must not block the rest of the form.
128 // The live debounced check (GET /v1/billing/vat/check) already warns
129 // the user about the state; the save is intentionally permissive.
130 const patch: ProfilePatch = { ...parsed.data };
131 if ('vatId' in patch) {
132 const current = await getCurrentVat(user.id);
133 if (current.vatVerifiedAt && patch.vatId !== current.vatId) {
134 return c.json(
135 {
136 code: 'vat_locked',
137 message:
138 'VAT is verified and locked — changes require a support request (phase 3 will surface a contact flow)',
139 },
140 403,
141 );
142 }
143 if (patch.vatId) {
144 const check = await checkVatWithVies(patch.vatId);
145 patch.vatVerifiedAt = check.state === 'valid' ? new Date() : null;
146 } else {
147 patch.vatVerifiedAt = null;
148 }
149 }
150
151 await updateProfile(user.id, patch);
152
153 // Audit which fields changed; never log the values themselves.
154 await audit({
155 actorId: user.id,
156 projectId: null,
157 action: 'me.update',
158 ipHash: hashIp(c.req.raw.headers.get('x-forwarded-for')),
159 userAgent: c.req.header('user-agent') ?? null,
160 metadata: { fields: Object.keys(parsed.data) },
161 });
162
163 const profile = await getProfile(user.id);
164 return c.json(profile);
165});
166
167// Avatar lives as a data: URI in users.image — small PNG/JPEG/WEBP, client-
168// side-resized before upload. Server re-validates the payload shape and the
169// decoded image byte size to keep the column bounded.
170const AVATAR_DATA_URI_RE = /^data:image\/(png|jpe?g|webp);base64,([A-Za-z0-9+/=]+)$/;
171const AVATAR_MAX_DECODED_BYTES = 256 * 1024; // 256 KiB after base64-decode
172const AVATAR_MAX_ENCODED_CHARS = Math.ceil(AVATAR_MAX_DECODED_BYTES * 1.4) + 64;
173
174const avatarSchema = z.object({
175 dataUri: z.string().max(AVATAR_MAX_ENCODED_CHARS),
176});
177
178meRouter.post('/v1/me/avatar', requireAuth(), async (c) => {
179 const user = c.get('user');
180 if (!user) return c.json({ code: 'unauthorized', message: 'authentication required' }, 401);
181
182 const body = await c.req.json().catch(() => null);
183 const parsed = avatarSchema.safeParse(body);
184 if (!parsed.success) {
185 return c.json(
186 { code: 'validation_failed', message: 'invalid request body', issues: parsed.error.issues },
187 400,
188 );
189 }
190
191 const match = AVATAR_DATA_URI_RE.exec(parsed.data.dataUri);
192 if (!match) {
193 return c.json(
194 { code: 'bad_image', message: 'dataUri must be a base64 png/jpeg/webp image' },
195 400,
196 );
197 }
198 const base64 = match[2]!;
199 const decodedBytes = Math.floor((base64.length * 3) / 4);
200 if (decodedBytes > AVATAR_MAX_DECODED_BYTES) {
201 return c.json(
202 { code: 'too_large', message: `avatar must be ≤ ${AVATAR_MAX_DECODED_BYTES} bytes` },
203 413,
204 );
205 }
206
207 await setAvatar(user.id, parsed.data.dataUri);
208 const profile = await getProfile(user.id);
209 return c.json(profile);
210});
211
212meRouter.delete('/v1/me/avatar', requireAuth(), async (c) => {
213 const user = c.get('user');
214 if (!user) return c.json({ code: 'unauthorized', message: 'authentication required' }, 401);
215 await setAvatar(user.id, null);
216 const profile = await getProfile(user.id);
217 return c.json(profile);
218});
219
220const deleteAccountSchema = z.object({
221 // Typed-email confirmation prevents an accidental click — the route
222 // refuses unless this matches the signed-in user's address.
223 confirmation: z.string().email(),
224 // Optional short justification surfaced only in audit_logs for the
225 // operator. Capped to a sane length so a user can't dump an essay.
226 reason: z.string().min(1).max(2000).optional(),
227});
228
229meRouter.post(
230 '/v1/me/delete-account',
231 requireAuth(),
232 // Tight rate-limit so a hijacked session can't grief: 3 attempts per
233 // hour per session ip. Real deletes are once-in-a-lifetime; the limit
234 // is mostly to absorb double-clicks + the typed-email retry path.
235 rateLimit({
236 scope: 'me-delete-account',
237 limit: 3,
238 windowMs: 60 * 60_000,
239 key: (c) => c.req.raw.headers.get('cf-connecting-ip') ?? null,
240 }),
241 async (c) => {
242 const user = c.get('user');
243 if (!user) return c.json({ code: 'unauthorized', message: 'authentication required' }, 401);
244 const body = await c.req.json().catch(() => null);
245 const parsed = deleteAccountSchema.safeParse(body);
246 if (!parsed.success) {
247 return c.json({ code: 'validation_failed', issues: parsed.error.issues }, 400);
248 }
249 if (parsed.data.confirmation.toLowerCase() !== user.email.toLowerCase()) {
250 return c.json(
251 {
252 code: 'confirmation_mismatch',
253 message: 'type your email exactly as registered to confirm deletion',
254 },
255 400,
256 );
257 }
258 const counts = await softDeleteAccount({
259 userId: user.id,
260 reason: parsed.data.reason,
261 });
262 // Audit-side IP hash so an operator can correlate the request with a
263 // specific session if it ever needs to be reverted within the grace
264 // window. The cascade summary is already on account-deletion.audit.
265 await audit({
266 actorId: user.id,
267 projectId: null,
268 action: 'account.delete_requested',
269 ipHash: hashIp(c.req.raw.headers.get('cf-connecting-ip') ?? null),
270 userAgent: c.req.header('user-agent') ?? null,
271 metadata: { counts },
272 });
273 return c.json({ deleted: true });
274 },
275);
276
277const stepUpSchema = z.object({
278 password: z.string().min(1).max(200),
279});
280
281/**
282 * Step-up authentication. The caller re-supplies their account password;
283 * on success we bump `users.last_mfa_at` to now. The `requireRecentMfa`
284 * middleware on admin routes then accepts requests for the next 10 min.
285 *
286 * Verification delegates to Better Auth's `signInEmail` — it's the
287 * supported way to validate a password without re-implementing the
288 * scrypt cost params. The extra session row that Better Auth mints is
289 * accepted noise for v1; the existing session continues to drive the
290 * request. A real TOTP/WebAuthn upgrade is a Phase 3 follow-up.
291 */
292meRouter.post(
293 '/v1/me/step-up',
294 requireAuth(),
295 rateLimit({
296 scope: 'me-step-up',
297 limit: 10,
298 windowMs: 5 * 60_000,
299 key: (c) => c.req.raw.headers.get('cf-connecting-ip') ?? null,
300 }),
301 async (c) => {
302 const user = c.get('user');
303 if (!user) return c.json({ code: 'unauthorized', message: 'authentication required' }, 401);
304 const body = await c.req.json().catch(() => null);
305 const parsed = stepUpSchema.safeParse(body);
306 if (!parsed.success) {
307 return c.json({ code: 'validation_failed', issues: parsed.error.issues }, 400);
308 }
309 // Delegate password verification to Better Auth. signInEmail throws
310 // on bad password — treat any error as "wrong password" so we don't
311 // leak whether the email exists vs whether the password's wrong.
312 try {
313 await auth.api.signInEmail({
314 body: { email: user.email, password: parsed.data.password },
315 headers: c.req.raw.headers,
316 });
317 } catch {
318 return c.json({ code: 'invalid_credentials', message: 'password incorrect' }, 401);
319 }
320 const db = getDb();
321 await db
322 .update(users)
323 .set({ lastMfaAt: new Date(), updatedAt: new Date() })
324 .where(eq(users.id, user.id));
325 await audit({
326 actorId: user.id,
327 projectId: null,
328 action: 'auth.step_up',
329 ipHash: hashIp(c.req.raw.headers.get('cf-connecting-ip') ?? null),
330 userAgent: c.req.header('user-agent') ?? null,
331 metadata: {},
332 });
333 return c.json({ ok: true, validUntilMs: Date.now() + 10 * 60_000 });
334 },
335);