me.ts335 lines · main
| 1 | import { Hono } from 'hono'; |
| 2 | import { eq } from 'drizzle-orm'; |
| 3 | import { z } from 'zod'; |
| 4 | |
| 5 | import { getDb } from '../db/client.js'; |
| 6 | import { users } from '../db/schema.js'; |
| 7 | import { auth } from '../lib/auth.js'; |
| 8 | import { requireAuth } from '../middleware/session.js'; |
| 9 | import { rateLimit } from '../middleware/rate-limit.js'; |
| 10 | import type { AppEnv } from '../types/app-env.js'; |
| 11 | import { softDeleteAccount } from '../services/account-deletion.js'; |
| 12 | import { audit, hashIp } from '../services/audit.js'; |
| 13 | import { checkVatWithVies } from '../services/billing.js'; |
| 14 | import { |
| 15 | getCurrentVat, |
| 16 | getProfile, |
| 17 | setAvatar, |
| 18 | updateProfile, |
| 19 | type ProfilePatch, |
| 20 | } from '../services/me.js'; |
| 21 | import { listOrgsForUser } from '../services/orgs.js'; |
| 22 | import { listProjectsForUser } from '../services/projects.js'; |
| 23 | import { listMyStorageUsage } from '../services/storage-admin.js'; |
| 24 | |
| 25 | const 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 | |
| 63 | export const meRouter = new Hono<AppEnv>(); |
| 64 | |
| 65 | meRouter.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. |
| 78 | meRouter.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. |
| 101 | meRouter.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 | |
| 107 | meRouter.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. |
| 170 | const AVATAR_DATA_URI_RE = /^data:image\/(png|jpe?g|webp);base64,([A-Za-z0-9+/=]+)$/; |
| 171 | const AVATAR_MAX_DECODED_BYTES = 256 * 1024; // 256 KiB after base64-decode |
| 172 | const AVATAR_MAX_ENCODED_CHARS = Math.ceil(AVATAR_MAX_DECODED_BYTES * 1.4) + 64; |
| 173 | |
| 174 | const avatarSchema = z.object({ |
| 175 | dataUri: z.string().max(AVATAR_MAX_ENCODED_CHARS), |
| 176 | }); |
| 177 | |
| 178 | meRouter.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 | |
| 212 | meRouter.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 | |
| 220 | const 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 | |
| 229 | meRouter.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 | |
| 277 | const 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 | */ |
| 292 | meRouter.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 | ); |