From 23d8fd77927bdcd3aeb8f74b2fd34bd4e468da0f Mon Sep 17 00:00:00 2001 From: RevIQ Date: Fri, 9 Jan 2026 15:48:40 +0800 Subject: [PATCH 1/3] Update workstream completion status in initial-app.md Mark completed tasks across all phases: - Phase 1 (A, B, C): All foundation tasks complete - Phase 2 (D, E): All auth and WebAuthn procedures complete - Phase 2 (F): Passkey management (F4) complete - Phase 2 (G): Email helpers stubbed (G3-G5) - Phase 4 (K): All admin procedures complete - Phase 5 (N1-N16): All CLI commands complete Co-Authored-By: Claude Opus 4.5 --- docs/initial-app.md | 98 ++++++++++++++++++++++----------------------- 1 file changed, 49 insertions(+), 49 deletions(-) diff --git a/docs/initial-app.md b/docs/initial-app.md index e126777..23a535b 100644 --- a/docs/initial-app.md +++ b/docs/initial-app.md @@ -2197,23 +2197,23 @@ All Phase 1 tasks can run in parallel. #### Workstream A: Database & Schema -- [ ] **A1**: Create dbmate migration `001_initial_schema.sql` with all tables, enums, indexes -- [ ] **A2**: Set up `@publisher-dashboard/db-schema` package with kysely-codegen -- [ ] **A3**: Set up `@publisher-dashboard/db` package with Kysely client +- [x] **A1**: Create dbmate migration `001_initial_schema.sql` with all tables, enums, indexes +- [x] **A2**: Set up `@publisher-dashboard/db-schema` package with kysely-codegen +- [x] **A3**: Set up `@publisher-dashboard/db` package with Kysely client #### Workstream B: API Contract -- [ ] **B1**: Create `@publisher-dashboard/api-contract` package structure -- [ ] **B2**: Define Zod schemas for all input/output types (auth, user, org, admin) -- [ ] **B3**: Define oRPC contract with all procedure signatures +- [x] **B1**: Create `@publisher-dashboard/api-contract` package structure +- [x] **B2**: Define Zod schemas for all input/output types (auth, user, org, admin) +- [x] **B3**: Define oRPC contract with all procedure signatures #### Workstream C: Project Infrastructure -- [ ] **C1**: Initialize monorepo with workspace config (`package.json`, `bun.lockb`) -- [ ] **C2**: Set up `apps/api-server` with Bun.serve entry point -- [ ] **C3**: Set up `apps/publisher-dashboard` SvelteKit project with TanStack Query -- [ ] **C4**: Set up `apps/cli` with stricli framework -- [ ] **C5**: Create `devenv.nix` with scripts and environment variables +- [x] **C1**: Initialize monorepo with workspace config (`package.json`, `bun.lockb`) +- [x] **C2**: Set up `apps/api-server` with Bun.serve entry point +- [x] **C3**: Set up `apps/publisher-dashboard` SvelteKit project with TanStack Query +- [x] **C4**: Set up `apps/cli` with stricli framework +- [x] **C5**: Create `devenv.nix` with scripts and environment variables --- @@ -2223,25 +2223,25 @@ All Phase 1 tasks can run in parallel. _Depends on: A3, B3, C2_ -- [ ] **D1**: Implement auth middleware (session cookie + API key header) -- [ ] **D2**: Implement `auth.signup` (email + password or passkey) -- [ ] **D3**: Implement `auth.createLoginRequest` with device/geo capture -- [ ] **D4**: Implement `auth.loginPassword` (trusted vs untrusted device flow) -- [ ] **D5**: Implement `auth.loginPasswordConfirm` (email link handler) -- [ ] **D6**: Implement `auth.loginIfRequestIsCompleted` (polling + session creation) -- [ ] **D7**: Implement `auth.logout` -- [ ] **D8**: Implement `auth.verifyEmail` and `auth.resendVerificationEmail` -- [ ] **D9**: Implement `auth.forgotPassword` and `auth.resetPassword` +- [x] **D1**: Implement auth middleware (session cookie + API key header) +- [x] **D2**: Implement `auth.signup` (email + password or passkey) +- [x] **D3**: Implement `auth.createLoginRequest` with device/geo capture +- [x] **D4**: Implement `auth.loginPassword` (trusted vs untrusted device flow) +- [x] **D5**: Implement `auth.loginPasswordConfirm` (email link handler) +- [x] **D6**: Implement `auth.loginIfRequestIsCompleted` (polling + session creation) +- [x] **D7**: Implement `auth.logout` +- [x] **D8**: Implement `auth.verifyEmail` and `auth.resendVerificationEmail` +- [x] **D9**: Implement `auth.forgotPassword` and `auth.resetPassword` #### Workstream E: WebAuthn Procedures (Backend) _Depends on: A3, B3, C2_ _Can run parallel to D_ -- [ ] **E1**: Implement `auth.webauthn.createRegistrationOptions` -- [ ] **E2**: Implement `auth.webauthn.verifyRegistration` -- [ ] **E3**: Implement `auth.webauthn.createAuthenticationOptions` -- [ ] **E4**: Implement `auth.webauthn.verifyAuthentication` +- [x] **E1**: Implement `auth.webauthn.createRegistrationOptions` +- [x] **E2**: Implement `auth.webauthn.verifyRegistration` +- [x] **E3**: Implement `auth.webauthn.createAuthenticationOptions` +- [x] **E4**: Implement `auth.webauthn.verifyAuthentication` #### Workstream F: User Procedures (Backend) @@ -2250,7 +2250,7 @@ _Depends on: D1 (auth middleware)_ - [ ] **F1**: Implement `me.get` and `me.setupProfile` - [ ] **F2**: Implement `me.updateProfile` - [ ] **F3**: Implement `me.setPassword` -- [ ] **F4**: Implement `me.listPasskeys`, `me.createPasskey`, `me.renamePasskey`, `me.deletePasskey` +- [x] **F4**: Implement `me.listPasskeys`, `me.createPasskey`, `me.renamePasskey`, `me.deletePasskey` - [ ] **F5**: Implement `me.listSessions`, `me.revokeSession`, `me.revokeAllSessions` - [ ] **F6**: Implement `me.getDeviceInfo`, `me.trustDevice`, `me.listTrustedDevices`, `me.untrustDevice`, `me.revokeAllTrustedDevices` - [ ] **F7**: Implement `me.delete` (account deletion) @@ -2262,9 +2262,9 @@ _Can run parallel to D, E, F_ - [ ] **G1**: Set up Postmark client with env config - [ ] **G2**: Create email templates (verification, password reset, login confirmation, org invite) -- [ ] **G3**: Implement `sendVerificationEmail()` helper -- [ ] **G4**: Implement `sendPasswordResetEmail()` helper -- [ ] **G5**: Implement `sendLoginConfirmationEmail()` helper +- [~] **G3**: Implement `sendVerificationEmail()` helper _(stubbed - console.log only)_ +- [~] **G4**: Implement `sendPasswordResetEmail()` helper _(stubbed - console.log only)_ +- [~] **G5**: Implement `sendLoginConfirmationEmail()` helper _(stubbed - console.log only)_ - [ ] **G6**: Implement `sendOrgInviteEmail()` helper --- @@ -2316,11 +2316,11 @@ _Depends on: D1 (auth middleware)_ _Depends on: D1 (auth middleware), J1_ _Can run parallel to J2-J6_ -- [ ] **K1**: Implement superuser middleware -- [ ] **K2**: Implement `admin.orgs.*` procedures -- [ ] **K3**: Implement `admin.users.*` procedures -- [ ] **K4**: Implement `admin.orgs.addSite`, `admin.orgs.removeSite` -- [ ] **K5**: Implement `admin.auth.completeLogin` (dev helper) +- [x] **K1**: Implement superuser middleware +- [x] **K2**: Implement `admin.orgs.*` procedures +- [x] **K3**: Implement `admin.users.*` procedures +- [x] **K4**: Implement `admin.orgs.addSite`, `admin.orgs.removeSite` +- [x] **K5**: Implement `admin.auth.completeLogin` (dev helper) #### Workstream L: Org Pages (Frontend) @@ -2349,47 +2349,47 @@ _Can run parallel to L_ _Depends on: C4_ -- [ ] **N1**: Set up stricli CLI structure with command routing -- [ ] **N2**: Implement config file handling (`~/.config/reviq/credentials.json`) -- [ ] **N3**: Implement API client wrapper for CLI (reads token from config) +- [x] **N1**: Set up stricli CLI structure with command routing +- [x] **N2**: Implement config file handling (`~/.config/reviq/credentials.json`) +- [x] **N3**: Implement API client wrapper for CLI (reads token from config) #### Workstream N-Bootstrap: CLI Bootstrap (Direct DB) _Depends on: A3, N1, N2_ -- [ ] **N4**: Implement `reviq bootstrap` - create superuser with password -- [ ] **N5**: Implement `reviq bootstrap` - create "reviq" org with user as owner -- [ ] **N6**: Implement `reviq bootstrap` - generate API token and save to config +- [x] **N4**: Implement `reviq bootstrap` - create superuser with password +- [x] **N5**: Implement `reviq bootstrap` - create "reviq" org with user as owner +- [x] **N6**: Implement `reviq bootstrap` - generate API token and save to config #### Workstream N-Auth: CLI Auth Commands _Depends on: N1, N2, N3, D1-D9_ -- [ ] **N7**: Implement `reviq auth login` (open browser, poll for token, save to config) -- [ ] **N8**: Implement `reviq auth logout` (revoke token, delete from config) -- [ ] **N9**: Implement `reviq auth status` (show current user, API URL, config path) +- [x] **N7**: Implement `reviq auth login` (open browser, poll for token, save to config) +- [x] **N8**: Implement `reviq auth logout` (revoke token, delete from config) +- [x] **N9**: Implement `reviq auth status` (show current user, API URL, config path) #### Workstream N-User: CLI User Commands _Depends on: N3, K3_ -- [ ] **N10**: Implement `reviq user create --email --name [--org --role]` -- [ ] **N11**: Implement `reviq user confirm-email --email` (dev helper) +- [x] **N10**: Implement `reviq user create --email --name [--org --role]` +- [x] **N11**: Implement `reviq user confirm-email --email` (dev helper) #### Workstream N-Admin: CLI Admin Commands _Depends on: N3, K5_ -- [ ] **N12**: Implement `reviq admin complete-login --email` (dev helper) +- [x] **N12**: Implement `reviq admin complete-login --email` (dev helper) #### Workstream N-Org: CLI Org Commands _Depends on: N3, K2, K4_ -- [ ] **N13**: Implement `reviq org create --owner --name` -- [ ] **N14**: Implement `reviq org list` -- [ ] **N15**: Implement `reviq org add-site --org --domain` -- [ ] **N16**: Implement `reviq org remove-site --org --domain` +- [x] **N13**: Implement `reviq org create --owner --name` +- [x] **N14**: Implement `reviq org list` +- [x] **N15**: Implement `reviq org add-site --org --domain` +- [x] **N16**: Implement `reviq org remove-site --org --domain` #### Workstream N-Completions: CLI Shell Completions From 9b898678c799afc8acd26a96dea65593d071907e Mon Sep 17 00:00:00 2001 From: RevIQ Date: Fri, 9 Jan 2026 16:24:10 +0800 Subject: [PATCH 2/3] Refactor me.* procedures with code review fixes - Fix silent failures: add 404 NOT_FOUND for invalid resources in passkeysRename, revokeSession, trustDevice, untrustDevice - Fix race condition in passkeysDelete using transaction - Extract helper functions: requireDeviceFingerprint, defaultDeviceName - Improve type safety in updateProfile with Kysely's Updateable - Extract me.* procedures to separate files under procedures/me/ - Standardize naming to verb-first: listPasskeys, renamePasskey, deletePasskey Co-Authored-By: Claude Opus 4.5 --- apps/api-server/src/procedures/me/delete.ts | 50 ++++++ apps/api-server/src/procedures/me/devices.ts | 131 ++++++++++++++++ apps/api-server/src/procedures/me/helpers.ts | 40 +++++ apps/api-server/src/procedures/me/index.ts | 20 +++ apps/api-server/src/procedures/me/passkeys.ts | 95 +++++++++++ apps/api-server/src/procedures/me/sessions.ts | 86 ++++++++++ .../src/procedures/me/set-password.ts | 61 ++++++++ .../src/procedures/me/update-profile.ts | 39 +++++ apps/api-server/src/router.ts | 148 ++++-------------- docs/initial-app.md | 10 +- 10 files changed, 555 insertions(+), 125 deletions(-) create mode 100644 apps/api-server/src/procedures/me/delete.ts create mode 100644 apps/api-server/src/procedures/me/devices.ts create mode 100644 apps/api-server/src/procedures/me/helpers.ts create mode 100644 apps/api-server/src/procedures/me/index.ts create mode 100644 apps/api-server/src/procedures/me/passkeys.ts create mode 100644 apps/api-server/src/procedures/me/sessions.ts create mode 100644 apps/api-server/src/procedures/me/set-password.ts create mode 100644 apps/api-server/src/procedures/me/update-profile.ts diff --git a/apps/api-server/src/procedures/me/delete.ts b/apps/api-server/src/procedures/me/delete.ts new file mode 100644 index 0000000..a58f0c4 --- /dev/null +++ b/apps/api-server/src/procedures/me/delete.ts @@ -0,0 +1,50 @@ +/** + * Delete account procedure - permanently deletes user account + */ + +import { ORPCError } from "@orpc/server"; +import { COOKIE_NAMES, deleteCookie } from "../../utils/cookies.js"; +import { verifyPassword } from "../../utils/password.js"; +import { authMiddleware, os } from "../base.js"; + +/** + * Delete account handler + * - Requires authentication + * - Requires password confirmation (passkey-only users must set password first) + * - Deletes user record (cascades to sessions, devices, passkeys, etc.) + * - Clears session cookie + */ +export const meDelete = os.me.delete + .use(authMiddleware) + .handler(async ({ input, context }) => { + const { password } = input; + + // Fetch user with password hash + const user = await context.db + .selectFrom("users") + .select(["password_hash"]) + .where("id", "=", context.user.id) + .executeTakeFirstOrThrow(); + + // Verify password (required for account deletion) + if (!user.password_hash) { + throw new ORPCError("BAD_REQUEST", { + message: + "Cannot delete account without a password. Please set a password first.", + }); + } + + const valid = await verifyPassword(password, user.password_hash); + if (!valid) { + throw new ORPCError("BAD_REQUEST", { message: "Incorrect password" }); + } + + // Delete user (cascades to sessions, devices, passkeys, etc.) + await context.db + .deleteFrom("users") + .where("id", "=", context.user.id) + .execute(); + + // Clear session cookie + deleteCookie(context.resHeaders, COOKIE_NAMES.SESSION_TOKEN); + }); diff --git a/apps/api-server/src/procedures/me/devices.ts b/apps/api-server/src/procedures/me/devices.ts new file mode 100644 index 0000000..2941480 --- /dev/null +++ b/apps/api-server/src/procedures/me/devices.ts @@ -0,0 +1,131 @@ +/** + * Device management procedures - getInfo, trust, listTrusted, untrust, revokeAll + */ + +import { ORPCError } from "@orpc/server"; +import { authMiddleware, os } from "../base.js"; +import { defaultDeviceName, requireDeviceFingerprint } from "./helpers.js"; + +/** + * Get device info handler + * - Requires authentication + * - Returns info for the current device based on fingerprint cookie + * @throws BAD_REQUEST if no device fingerprint found + * @throws NOT_FOUND if device doesn't exist + */ +export const getDeviceInfo = os.me.getDeviceInfo + .use(authMiddleware) + .handler(async ({ context }) => { + const fingerprint = requireDeviceFingerprint(context.reqHeaders); + + const device = await context.db + .selectFrom("user_devices") + .selectAll() + .where("user_id", "=", context.user.id) + .where("device_fingerprint", "=", fingerprint) + .executeTakeFirst(); + + if (!device) { + throw new ORPCError("NOT_FOUND", { message: "Device not found" }); + } + + return { + id: Number(device.id), + name: device.name ?? defaultDeviceName(device.user_agent), + ip: device.ip_address ?? "", + city: device.city, + region: device.region, + country: device.country, + lastUsedAt: device.last_used_at, + isTrusted: device.is_trusted, + }; + }); + +/** + * Trust device handler + * - Requires authentication + * - Marks current device as trusted with a given name + * @throws BAD_REQUEST if no device fingerprint found + * @throws NOT_FOUND if device doesn't exist + */ +export const trustDevice = os.me.trustDevice + .use(authMiddleware) + .handler(async ({ input, context }) => { + const { name } = input; + const fingerprint = requireDeviceFingerprint(context.reqHeaders); + + const result = await context.db + .updateTable("user_devices") + .set({ is_trusted: true, name }) + .where("user_id", "=", context.user.id) + .where("device_fingerprint", "=", fingerprint) + .executeTakeFirst(); + + if (!result.numUpdatedRows || result.numUpdatedRows === 0n) { + throw new ORPCError("NOT_FOUND", { message: "Device not found" }); + } + }); + +/** + * List trusted devices handler + * - Requires authentication + * - Returns all trusted devices for the current user + */ +export const listTrustedDevices = os.me.listTrustedDevices + .use(authMiddleware) + .handler(async ({ context }) => { + const devices = await context.db + .selectFrom("user_devices") + .selectAll() + .where("user_id", "=", context.user.id) + .where("is_trusted", "=", true) + .orderBy("last_used_at", "desc") + .execute(); + + return devices.map((d) => ({ + id: Number(d.id), + name: d.name ?? "Unknown device", + ip: d.ip_address ?? "", + city: d.city, + region: d.region, + country: d.country, + lastUsedAt: d.last_used_at, + isTrusted: d.is_trusted, + })); + }); + +/** + * Untrust device handler + * - Requires authentication + * - Marks device as untrusted by ID + * @throws NOT_FOUND if device doesn't exist + */ +export const untrustDevice = os.me.untrustDevice + .use(authMiddleware) + .handler(async ({ input, context }) => { + const result = await context.db + .updateTable("user_devices") + .set({ is_trusted: false }) + .where("id", "=", String(input.deviceId)) + .where("user_id", "=", context.user.id) + .executeTakeFirst(); + + if (!result.numUpdatedRows || result.numUpdatedRows === 0n) { + throw new ORPCError("NOT_FOUND", { message: "Device not found" }); + } + }); + +/** + * Revoke all trusted devices handler + * - Requires authentication + * - Marks all devices as untrusted + */ +export const revokeAllTrustedDevices = os.me.revokeAllTrustedDevices + .use(authMiddleware) + .handler(async ({ context }) => { + await context.db + .updateTable("user_devices") + .set({ is_trusted: false }) + .where("user_id", "=", context.user.id) + .execute(); + }); diff --git a/apps/api-server/src/procedures/me/helpers.ts b/apps/api-server/src/procedures/me/helpers.ts new file mode 100644 index 0000000..5ad2a21 --- /dev/null +++ b/apps/api-server/src/procedures/me/helpers.ts @@ -0,0 +1,40 @@ +/** + * Helper functions for me.* procedures + */ + +import type { Users } from "@reviq/db-schema"; +import type { Updateable } from "kysely"; +import { ORPCError } from "@orpc/server"; +import { COOKIE_NAMES, getCookie } from "../../utils/cookies.js"; + +// ===== Types ===== + +/** Fields that can be updated via updateProfile */ +export type ProfileUpdate = Pick< + Updateable, + "display_name" | "full_name" | "phone_number" | "avatar_url" | "updated_at" +>; + +// ===== Helper Functions ===== + +/** + * Get device fingerprint from request cookies + * @throws ORPCError if fingerprint is missing + */ +export const requireDeviceFingerprint = (reqHeaders: Headers): string => { + const fingerprint = getCookie(reqHeaders, COOKIE_NAMES.DEVICE_FINGERPRINT); + if (!fingerprint) { + throw new ORPCError("BAD_REQUEST", { + message: "No device fingerprint found", + }); + } + return fingerprint; +}; + +/** + * Get default device name from user agent string + */ +export const defaultDeviceName = (userAgent: string): string => { + const part = userAgent.split("/")[0]?.trim(); + return part ? `${part} device` : "Unknown device"; +}; diff --git a/apps/api-server/src/procedures/me/index.ts b/apps/api-server/src/procedures/me/index.ts new file mode 100644 index 0000000..4c993c8 --- /dev/null +++ b/apps/api-server/src/procedures/me/index.ts @@ -0,0 +1,20 @@ +/** + * Me procedures - user profile and account management + */ + +export { meDelete } from "./delete.js"; +export { + getDeviceInfo, + listTrustedDevices, + revokeAllTrustedDevices, + trustDevice, + untrustDevice, +} from "./devices.js"; +export { deletePasskey, listPasskeys, renamePasskey } from "./passkeys.js"; +export { + listSessions, + revokeAllSessions, + revokeSession, +} from "./sessions.js"; +export { setPassword } from "./set-password.js"; +export { updateProfile } from "./update-profile.js"; diff --git a/apps/api-server/src/procedures/me/passkeys.ts b/apps/api-server/src/procedures/me/passkeys.ts new file mode 100644 index 0000000..e6ea66d --- /dev/null +++ b/apps/api-server/src/procedures/me/passkeys.ts @@ -0,0 +1,95 @@ +/** + * Passkey management procedures - list, rename, delete passkeys + */ + +import { ORPCError } from "@orpc/server"; +import { getUserPasskeys } from "../../utils/webauthn.js"; +import { authMiddleware, os } from "../base.js"; + +/** + * List passkeys handler + * - Requires authentication + * - Returns all passkeys for the current user + */ +export const listPasskeys = os.me.passkeys.list + .use(authMiddleware) + .handler(async ({ context }) => { + const passkeys = await getUserPasskeys(context.db, context.user.id); + + return passkeys.map((p) => ({ + id: p.id, + name: p.name, + createdAt: p.createdAt, + lastUsedAt: p.lastUsedAt, + })); + }); + +/** + * Rename passkey handler + * - Requires authentication + * - Updates passkey name + * @throws NOT_FOUND if passkey doesn't exist + */ +export const renamePasskey = os.me.passkeys.rename + .use(authMiddleware) + .handler(async ({ input, context }) => { + const { passkeyId, name } = input; + + const result = await context.db + .updateTable("passkeys") + .set({ name }) + .where("id", "=", String(passkeyId)) + .where("user_id", "=", context.user.id) + .executeTakeFirst(); + + if (!result.numUpdatedRows || result.numUpdatedRows === 0n) { + throw new ORPCError("NOT_FOUND", { message: "Passkey not found" }); + } + }); + +/** + * Delete passkey handler + * - Requires authentication + * - Prevents deleting last passkey if user has no password + * - Uses transaction to prevent race conditions + * @throws NOT_FOUND if passkey doesn't exist + * @throws BAD_REQUEST if trying to delete last passkey without password + */ +export const deletePasskey = os.me.passkeys.delete + .use(authMiddleware) + .handler(async ({ input, context }) => { + const { passkeyId } = input; + + // Use transaction to prevent race condition when checking last passkey + await context.db.transaction().execute(async (trx) => { + // Check if this is the last passkey and user has no password + const user = await trx + .selectFrom("users") + .select(["password_hash"]) + .where("id", "=", context.user.id) + .executeTakeFirstOrThrow(); + + const passkeyCount = await trx + .selectFrom("passkeys") + .select(trx.fn.countAll().as("count")) + .where("user_id", "=", context.user.id) + .executeTakeFirst(); + + if (!user.password_hash && Number(passkeyCount?.count ?? 0) <= 1) { + throw new ORPCError("BAD_REQUEST", { + message: + "Cannot delete the last passkey when you have no password set", + }); + } + + const result = await trx + .deleteFrom("passkeys") + .where("id", "=", String(passkeyId)) + .where("user_id", "=", context.user.id) + .executeTakeFirst(); + + if (!result.numDeletedRows || result.numDeletedRows === 0n) { + throw new ORPCError("NOT_FOUND", { message: "Passkey not found" }); + } + }); + }); diff --git a/apps/api-server/src/procedures/me/sessions.ts b/apps/api-server/src/procedures/me/sessions.ts new file mode 100644 index 0000000..f8d1fdd --- /dev/null +++ b/apps/api-server/src/procedures/me/sessions.ts @@ -0,0 +1,86 @@ +/** + * Session management procedures - list, revoke, revokeAll sessions + */ + +import { ORPCError } from "@orpc/server"; +import { authMiddleware, os } from "../base.js"; + +/** + * List sessions handler + * - Requires authentication + * - Returns all sessions for the current user + * - Includes isCurrent flag to identify active session + */ +export const listSessions = os.me.listSessions + .use(authMiddleware) + .handler(async ({ context }) => { + const sessions = await context.db + .selectFrom("sessions") + .selectAll() + .where("user_id", "=", context.user.id) + .orderBy("created_at", "desc") + .execute(); + + return sessions.map((s) => ({ + id: Number(s.id), + ip: s.ip_address ?? "", + city: s.city, + region: s.region, + country: s.country, + userAgent: s.user_agent ?? "", + trustedMode: s.trusted_mode, + createdAt: s.created_at, + isCurrent: s.id === context.session.id, + revokedAt: s.revoked_at, + })); + }); + +/** + * Revoke session handler + * - Requires authentication + * - Cannot revoke current session (use logout instead) + * @throws NOT_FOUND if session doesn't exist + * @throws BAD_REQUEST if trying to revoke current session + */ +export const revokeSession = os.me.revokeSession + .use(authMiddleware) + .handler(async ({ input, context }) => { + const { sessionId } = input; + + // Prevent revoking current session (use logout instead) + if (String(sessionId) === context.session.id) { + throw new ORPCError("BAD_REQUEST", { + message: "Cannot revoke current session. Use logout instead.", + }); + } + + const result = await context.db + .updateTable("sessions") + .set({ revoked_at: new Date() }) + .where("id", "=", String(sessionId)) + .where("user_id", "=", context.user.id) + .where("revoked_at", "is", null) + .executeTakeFirst(); + + if (!result.numUpdatedRows || result.numUpdatedRows === 0n) { + throw new ORPCError("NOT_FOUND", { message: "Session not found" }); + } + }); + +/** + * Revoke all sessions handler + * - Requires authentication + * - Revokes all sessions except current + */ +export const revokeAllSessions = os.me.revokeAllSessions + .use(authMiddleware) + .handler(async ({ context }) => { + // Revoke all sessions except current + await context.db + .updateTable("sessions") + .set({ revoked_at: new Date() }) + .where("user_id", "=", context.user.id) + .where("id", "!=", context.session.id) + .where("revoked_at", "is", null) + .execute(); + }); diff --git a/apps/api-server/src/procedures/me/set-password.ts b/apps/api-server/src/procedures/me/set-password.ts new file mode 100644 index 0000000..ba4663d --- /dev/null +++ b/apps/api-server/src/procedures/me/set-password.ts @@ -0,0 +1,61 @@ +/** + * Set password procedure - sets or changes user password + */ + +import { ORPCError } from "@orpc/server"; +import { + hashPassword, + validatePassword, + verifyPassword, +} from "../../utils/password.js"; +import { authMiddleware, os } from "../base.js"; + +/** + * Set password handler + * - Requires authentication + * - If user has existing password, currentPassword is required + * - Validates new password strength using zxcvbn + */ +export const setPassword = os.me.setPassword + .use(authMiddleware) + .handler(async ({ input, context }) => { + const { currentPassword, newPassword } = input; + + // Fetch current password hash + const user = await context.db + .selectFrom("users") + .select(["password_hash"]) + .where("id", "=", context.user.id) + .executeTakeFirstOrThrow(); + + // If user has a password, verify current password + if (user.password_hash) { + if (!currentPassword) { + throw new ORPCError("BAD_REQUEST", { + message: "Current password required", + }); + } + const valid = await verifyPassword(currentPassword, user.password_hash); + if (!valid) { + throw new ORPCError("BAD_REQUEST", { + message: "Current password is incorrect", + }); + } + } + + // Validate new password strength + const validation = validatePassword(newPassword, [context.user.email]); + if (!validation.valid) { + throw new ORPCError("BAD_REQUEST", { + message: validation.feedback[0] ?? "Password is too weak", + }); + } + + // Hash and update + const newHash = await hashPassword(newPassword); + await context.db + .updateTable("users") + .set({ password_hash: newHash, updated_at: new Date() }) + .where("id", "=", context.user.id) + .execute(); + }); diff --git a/apps/api-server/src/procedures/me/update-profile.ts b/apps/api-server/src/procedures/me/update-profile.ts new file mode 100644 index 0000000..3d59510 --- /dev/null +++ b/apps/api-server/src/procedures/me/update-profile.ts @@ -0,0 +1,39 @@ +/** + * Update profile procedure - updates user profile fields + */ + +import type { ProfileUpdate } from "./helpers.js"; +import { authMiddleware, os } from "../base.js"; + +/** + * Update profile handler + * - Requires authentication + * - Allows partial updates to display_name, full_name, phone_number, avatar_url + * - Automatically sets updated_at timestamp + */ +export const updateProfile = os.me.updateProfile + .use(authMiddleware) + .handler(async ({ input, context }) => { + const updates: Partial = {}; + if (input.displayName !== undefined) { + updates.display_name = input.displayName; + } + if (input.fullName !== undefined) { + updates.full_name = input.fullName || null; + } + if (input.phoneNumber !== undefined) { + updates.phone_number = input.phoneNumber || null; + } + if (input.avatarUrl !== undefined) { + updates.avatar_url = input.avatarUrl || null; + } + + if (Object.keys(updates).length > 0) { + updates.updated_at = new Date(); + await context.db + .updateTable("users") + .set(updates) + .where("id", "=", context.user.id) + .execute(); + } + }); diff --git a/apps/api-server/src/router.ts b/apps/api-server/src/router.ts index 7743973..15dff7b 100644 --- a/apps/api-server/src/router.ts +++ b/apps/api-server/src/router.ts @@ -14,11 +14,30 @@ import { loginRequestMiddleware, os, } from "./procedures/base.js"; +import { meDelete } from "./procedures/me/delete.js"; +import { + getDeviceInfo, + listTrustedDevices, + revokeAllTrustedDevices, + trustDevice, + untrustDevice, +} from "./procedures/me/devices.js"; +import { + deletePasskey, + listPasskeys, + renamePasskey, +} from "./procedures/me/passkeys.js"; +import { + listSessions, + revokeAllSessions, + revokeSession, +} from "./procedures/me/sessions.js"; +import { setPassword } from "./procedures/me/set-password.js"; +import { updateProfile } from "./procedures/me/update-profile.js"; import { createAuthenticationOptions as createAuthOptions, createRegistrationOptions as createRegOptions, getRPInfo, - getUserPasskeys, verifyAuthentication as verifyAuth, verifyRegistration as verifyReg, } from "./utils/webauthn.js"; @@ -115,122 +134,11 @@ const setupProfile = os.me.setupProfile throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); }); -const updateProfile = os.me.updateProfile - .use(authMiddleware) - .handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); - }); - -const meDelete = os.me.delete.use(authMiddleware).handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); -}); - -const setPassword = os.me.setPassword.use(authMiddleware).handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); -}); - -const passkeysList = os.me.passkeys.list - .use(authMiddleware) - .handler(async ({ context }) => { - const passkeys = await getUserPasskeys(context.db, context.user.id); - - return passkeys.map((p) => ({ - id: p.id, - name: p.name, - createdAt: p.createdAt, - lastUsedAt: p.lastUsedAt, - })); - }); - -const passkeysRename = os.me.passkeys.rename - .use(authMiddleware) - .handler(async ({ input, context }) => { - const { passkeyId, name } = input; - - await context.db - .updateTable("passkeys") - .set({ name }) - .where("id", "=", String(passkeyId)) - .where("user_id", "=", context.user.id) - .execute(); - }); - -const passkeysDelete = os.me.passkeys.delete - .use(authMiddleware) - .handler(async ({ input, context }) => { - const { passkeyId } = input; - - // Check if this is the last passkey and user has no password - const user = await context.db - .selectFrom("users") - .select(["password_hash"]) - .where("id", "=", context.user.id) - .executeTakeFirst(); - - const passkeyCount = await context.db - .selectFrom("passkeys") - .select(context.db.fn.countAll().as("count")) - .where("user_id", "=", context.user.id) - .executeTakeFirst(); - - if (!user?.password_hash && Number(passkeyCount?.count ?? 0) <= 1) { - throw new ORPCError("BAD_REQUEST", { - message: "Cannot delete the last passkey when you have no password set", - }); - } - - await context.db - .deleteFrom("passkeys") - .where("id", "=", String(passkeyId)) - .where("user_id", "=", context.user.id) - .execute(); - }); - -const listSessions = os.me.listSessions - .use(authMiddleware) - .handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); - }); - -const revokeSession = os.me.revokeSession - .use(authMiddleware) - .handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); - }); - -const revokeAllSessions = os.me.revokeAllSessions - .use(authMiddleware) - .handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); - }); - -const getDeviceInfo = os.me.getDeviceInfo - .use(authMiddleware) - .handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); - }); - -const trustDevice = os.me.trustDevice.use(authMiddleware).handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); -}); - -const listTrustedDevices = os.me.listTrustedDevices - .use(authMiddleware) - .handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); - }); - -const untrustDevice = os.me.untrustDevice - .use(authMiddleware) - .handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); - }); - -const revokeAllTrustedDevices = os.me.revokeAllTrustedDevices - .use(authMiddleware) - .handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); - }); +// Me procedures imported from ./procedures/me/* +// - updateProfile, setPassword, meDelete +// - listPasskeys, renamePasskey, deletePasskey +// - listSessions, revokeSession, revokeAllSessions +// - getDeviceInfo, trustDevice, listTrustedDevices, untrustDevice, revokeAllTrustedDevices // Orgs procedures (all require auth) const orgsList = os.orgs.list.use(authMiddleware).handler(async () => { @@ -418,9 +326,9 @@ export const router = os.router({ delete: meDelete, setPassword, passkeys: { - list: passkeysList, - rename: passkeysRename, - delete: passkeysDelete, + list: listPasskeys, + rename: renamePasskey, + delete: deletePasskey, }, listSessions, revokeSession, diff --git a/docs/initial-app.md b/docs/initial-app.md index 0733adb..3d4311c 100644 --- a/docs/initial-app.md +++ b/docs/initial-app.md @@ -2248,12 +2248,12 @@ _Can run parallel to D_ _Depends on: D1 (auth middleware)_ - [ ] **F1**: Implement `me.get` and `me.setupProfile` -- [ ] **F2**: Implement `me.updateProfile` -- [ ] **F3**: Implement `me.setPassword` +- [x] **F2**: Implement `me.updateProfile` +- [x] **F3**: Implement `me.setPassword` - [x] **F4**: Implement `me.listPasskeys`, `me.createPasskey`, `me.renamePasskey`, `me.deletePasskey` -- [ ] **F5**: Implement `me.listSessions`, `me.revokeSession`, `me.revokeAllSessions` -- [ ] **F6**: Implement `me.getDeviceInfo`, `me.trustDevice`, `me.listTrustedDevices`, `me.untrustDevice`, `me.revokeAllTrustedDevices` -- [ ] **F7**: Implement `me.delete` (account deletion) +- [x] **F5**: Implement `me.listSessions`, `me.revokeSession`, `me.revokeAllSessions` +- [x] **F6**: Implement `me.getDeviceInfo`, `me.trustDevice`, `me.listTrustedDevices`, `me.untrustDevice`, `me.revokeAllTrustedDevices` +- [x] **F7**: Implement `me.delete` (account deletion) #### Workstream G: Email Service (Backend) From 860d791125ee9f1cfd87453d08a0ae4cd5b36df0 Mon Sep 17 00:00:00 2001 From: RevIQ Date: Fri, 9 Jan 2026 16:29:41 +0800 Subject: [PATCH 3/3] Implement Workstream F1: me.get and me.setupProfile procedures - Add me.get procedure returning user profile with needsSetup flag - Add me.setupProfile procedure for initial profile setup after signup - Add nonEmptyString/optionalString schema helpers with tests - Use Web Crypto API (SubtleCrypto) for Cloudflare Workers compatibility - Use @formatjs/intl-durationformat for duration formatting - Remove node:crypto dependency from crypto utilities Co-Authored-By: Claude Opus 4.5 --- apps/api-server/package.json | 1 + apps/api-server/src/middleware/auth.ts | 4 +- apps/api-server/src/procedures/base.ts | 4 +- apps/api-server/src/router.ts | 44 +++++++++- apps/api-server/src/utils/auth.ts | 11 +-- apps/api-server/src/utils/crypto.ts | 20 +++-- apps/api-server/src/utils/email.ts | 46 ++++------- apps/api-server/src/utils/session.ts | 2 +- bun.lock | 11 +++ docs/initial-app.md | 2 +- packages/api-contract/eslint.config.js | 3 + packages/api-contract/package.json | 1 + .../api-contract/src/schemas/common.test.ts | 81 +++++++++++++++++++ packages/api-contract/src/schemas/common.ts | 19 +++++ packages/api-contract/src/schemas/user.ts | 14 ++-- packages/api-contract/tsconfig.json | 3 +- 16 files changed, 205 insertions(+), 61 deletions(-) create mode 100644 packages/api-contract/src/schemas/common.test.ts diff --git a/apps/api-server/package.json b/apps/api-server/package.json index 8d6705c..f13c4e6 100644 --- a/apps/api-server/package.json +++ b/apps/api-server/package.json @@ -11,6 +11,7 @@ "clean": "rm -rf dist .eslintcache" }, "dependencies": { + "@formatjs/intl-durationformat": "^0.9.2", "@noble/hashes": "^2.0.1", "@orpc/server": "^1.13.2", "@reviq/api-contract": "workspace:*", diff --git a/apps/api-server/src/middleware/auth.ts b/apps/api-server/src/middleware/auth.ts index 7b8b7ce..c8deee8 100644 --- a/apps/api-server/src/middleware/auth.ts +++ b/apps/api-server/src/middleware/auth.ts @@ -36,13 +36,13 @@ export const createAuthMiddleware = () => { let tokenHash: string | undefined; const sessionToken = getCookie(reqHeaders, COOKIE_NAMES.SESSION_TOKEN); if (sessionToken) { - tokenHash = hashToken(sessionToken); + tokenHash = await hashToken(sessionToken); } // Fall back to API key header (for CLI) const apiKey = reqHeaders.get("x-api-key"); if (!tokenHash && apiKey) { - tokenHash = hashToken(apiKey); + tokenHash = await hashToken(apiKey); } if (!tokenHash) { diff --git a/apps/api-server/src/procedures/base.ts b/apps/api-server/src/procedures/base.ts index 4f4f34d..f8527df 100644 --- a/apps/api-server/src/procedures/base.ts +++ b/apps/api-server/src/procedures/base.ts @@ -34,13 +34,13 @@ export const authMiddleware = os.middleware(async ({ context, next }) => { let tokenHash: string | undefined; const sessionToken = getCookie(reqHeaders, COOKIE_NAMES.SESSION_TOKEN); if (sessionToken) { - tokenHash = hashToken(sessionToken); + tokenHash = await hashToken(sessionToken); } // Fall back to API key header (for CLI) const apiKey = reqHeaders.get("x-api-key"); if (!tokenHash && apiKey) { - tokenHash = hashToken(apiKey); + tokenHash = await hashToken(apiKey); } if (!tokenHash) { diff --git a/apps/api-server/src/router.ts b/apps/api-server/src/router.ts index 7743973..80dcdd3 100644 --- a/apps/api-server/src/router.ts +++ b/apps/api-server/src/router.ts @@ -105,14 +105,50 @@ const verifyAuthentication = os.auth.webauthn.verifyAuthentication }); // Me procedures -const meGet = os.me.get.use(authMiddleware).handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); +const meGet = os.me.get.use(authMiddleware).handler(async ({ context }) => { + const user = await context.db + .selectFrom("users") + .select([ + "id", + "email", + "display_name", + "full_name", + "phone_number", + "avatar_url", + "email_verified_at", + "is_superuser", + ]) + .where("id", "=", context.user.id) + .executeTakeFirstOrThrow(); + + return { + id: user.id, + email: user.email, + displayName: user.display_name, + fullName: user.full_name, + phoneNumber: user.phone_number, + avatarUrl: user.avatar_url, + emailVerified: user.email_verified_at !== null, + needsSetup: user.display_name === null, + isSuperuser: user.is_superuser, + }; }); const setupProfile = os.me.setupProfile .use(authMiddleware) - .handler(async () => { - throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" }); + .handler(async ({ input, context }) => { + const { displayName, fullName, phoneNumber } = input; + + await context.db + .updateTable("users") + .set({ + display_name: displayName, + full_name: fullName ?? null, + phone_number: phoneNumber ?? null, + updated_at: new Date(), + }) + .where("id", "=", context.user.id) + .execute(); }); const updateProfile = os.me.updateProfile diff --git a/apps/api-server/src/utils/auth.ts b/apps/api-server/src/utils/auth.ts index 690581a..d0439cc 100644 --- a/apps/api-server/src/utils/auth.ts +++ b/apps/api-server/src/utils/auth.ts @@ -4,7 +4,7 @@ import type { Database } from "@reviq/db-schema"; import type { Kysely } from "kysely"; -import { sha256 } from "@noble/hashes/sha2.js"; +import { hashToken } from "./crypto.js"; export interface AuthenticatedUser { id: number; @@ -12,13 +12,6 @@ export interface AuthenticatedUser { isSuperuser: boolean; } -/** - * Hash a token using SHA-256 - */ -export const hashToken = (token: string): string => { - return Buffer.from(sha256(Buffer.from(token))).toString("hex"); -}; - /** * Authenticate a request using session token or API key * Returns the authenticated user or null if not authenticated @@ -34,7 +27,7 @@ export const authenticateRequest = async ( return null; } - const tokenHash = hashToken(token); + const tokenHash = await hashToken(token); // Check sessions table const session = await db diff --git a/apps/api-server/src/utils/crypto.ts b/apps/api-server/src/utils/crypto.ts index 8193901..3528a83 100644 --- a/apps/api-server/src/utils/crypto.ts +++ b/apps/api-server/src/utils/crypto.ts @@ -1,11 +1,16 @@ -import { createHash, randomBytes } from "node:crypto"; - /** * Hash a token with SHA-256 for storage in database * Never store raw tokens - always hash first + * Uses Web Crypto API for Cloudflare Workers compatibility */ -export const hashToken = (token: string): string => { - return createHash("sha256").update(token).digest("hex"); +export const hashToken = async (token: string): Promise => { + const encoder = new TextEncoder(); + const data = encoder.encode(token); + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + const hashArray = new Uint8Array(hashBuffer); + return Array.from(hashArray) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); }; /** @@ -25,9 +30,14 @@ export const generateDeviceFingerprint = (): string => { /** * Generate a secure random token for email verification, password reset, etc. * Uses 32 bytes (256 bits) of entropy + * Uses Web Crypto API for Cloudflare Workers compatibility */ export const generateSecureToken = (): string => { - return randomBytes(32).toString("hex"); + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); }; /** diff --git a/apps/api-server/src/utils/email.ts b/apps/api-server/src/utils/email.ts index 4c16f18..dc4aa35 100644 --- a/apps/api-server/src/utils/email.ts +++ b/apps/api-server/src/utils/email.ts @@ -4,6 +4,7 @@ */ import type { OrgRole } from "@reviq/db-schema"; +import { DurationFormat } from "@formatjs/intl-durationformat"; import { ServerClient } from "postmark"; import { BASE_URL, @@ -113,37 +114,24 @@ const sendEmail = async (params: SendEmailParams): Promise => { // ===== Template Helpers ===== -const formatExpiryHours = (hours: number): string => { - if (hours === 1) { - return "1 hour"; - } - return `${hours} hours`; +const durationFormatter = new DurationFormat("en", { style: "long" }); + +const formatExpiryHours = (hours: number): string => + durationFormatter.format({ hours }); + +const formatExpiryMinutes = (minutes: number): string => + durationFormatter.format({ minutes }); + +const formatExpiryDays = (days: number): string => + durationFormatter.format({ days }); + +const roleLabels: Record = { + owner: "Owner", + admin: "Admin", + member: "Member", }; -const formatExpiryMinutes = (minutes: number): string => { - if (minutes === 1) { - return "1 minute"; - } - return `${minutes} minutes`; -}; - -const formatExpiryDays = (days: number): string => { - if (days === 1) { - return "1 day"; - } - return `${days} days`; -}; - -const formatRoleDisplay = (role: OrgRole): string => { - switch (role) { - case "owner": - return "Owner"; - case "admin": - return "Admin"; - case "member": - return "Member"; - } -}; +const formatRoleDisplay = (role: OrgRole): string => roleLabels[role]; /** * Get the correct article (a/an) for a role diff --git a/apps/api-server/src/utils/session.ts b/apps/api-server/src/utils/session.ts index 4e0549e..8473fbc 100644 --- a/apps/api-server/src/utils/session.ts +++ b/apps/api-server/src/utils/session.ts @@ -27,7 +27,7 @@ export async function createSession( options: CreateSessionOptions, ): Promise { const token = generateSessionToken(); - const tokenHash = hashToken(token); + const tokenHash = await hashToken(token); const expiresAt = generateExpiry(COOKIE_DURATIONS.SESSION); const result = await db diff --git a/bun.lock b/bun.lock index 8bbdd2d..cbc6b1b 100644 --- a/bun.lock +++ b/bun.lock @@ -15,6 +15,7 @@ "name": "api-server", "version": "0.0.0", "dependencies": { + "@formatjs/intl-durationformat": "^0.9.2", "@noble/hashes": "^2.0.1", "@orpc/server": "^1.13.2", "@reviq/api-contract": "workspace:*", @@ -248,6 +249,14 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], + "@formatjs/ecma402-abstract": ["@formatjs/ecma402-abstract@3.0.8", "", { "dependencies": { "@formatjs/fast-memoize": "3.0.3", "@formatjs/intl-localematcher": "0.7.5", "decimal.js": "^10.4.3", "tslib": "^2.8.0" } }, "sha512-NRiqvxAvhbARZRFSRFPjN0y8txxmVutv2vMYvW2HSdCVf58w9l4osLj6Ujif643vImwZBcbKqhiKE0IOhY+DvA=="], + + "@formatjs/fast-memoize": ["@formatjs/fast-memoize@3.0.3", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-CArYtQKGLAOruCMeq5/RxCg6vUXFx3OuKBdTm30Wn/+gCefehmZ8Y2xSMxMrO2iel7hRyE3HKfV56t3vAU6D4Q=="], + + "@formatjs/intl-durationformat": ["@formatjs/intl-durationformat@0.9.2", "", { "dependencies": { "@formatjs/ecma402-abstract": "3.0.8", "@formatjs/intl-localematcher": "0.7.5", "tslib": "^2.8.0" } }, "sha512-/QOJeY96qGj1j9saz32VANfgDYhChbbTRyjWLzjf7dc4OHIEWqGBIO4rQzUKDBVzqtRLJQMh4QKp37Uxkk0d8g=="], + + "@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.7.5", "", { "dependencies": { "@formatjs/fast-memoize": "3.0.3", "tslib": "^2.8.0" } }, "sha512-7/nd90cn5CT7SVF71/ybUKAcnvBlr9nZlJJp8O8xIZHXFgYOC4SXExZlSdgHv2l6utjw1byidL06QzChvQMHwA=="], + "@hexagon/base64": ["@hexagon/base64@1.1.28", "", {}, "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="], "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], @@ -538,6 +547,8 @@ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], diff --git a/docs/initial-app.md b/docs/initial-app.md index 9c00cda..c2a07d6 100644 --- a/docs/initial-app.md +++ b/docs/initial-app.md @@ -2247,7 +2247,7 @@ _Can run parallel to D_ _Depends on: D1 (auth middleware)_ -- [ ] **F1**: Implement `me.get` and `me.setupProfile` +- [x] **F1**: Implement `me.get` and `me.setupProfile` - [ ] **F2**: Implement `me.updateProfile` - [ ] **F3**: Implement `me.setPassword` - [ ] **F4**: Implement `me.listPasskeys`, `me.createPasskey`, `me.renamePasskey`, `me.deletePasskey` diff --git a/packages/api-contract/eslint.config.js b/packages/api-contract/eslint.config.js index ee789e3..6ae3806 100644 --- a/packages/api-contract/eslint.config.js +++ b/packages/api-contract/eslint.config.js @@ -2,6 +2,9 @@ import { configs } from "@macalinao/eslint-config"; export default [ ...configs.fast, + { + ignores: ["**/*.test.ts"], + }, { languageOptions: { parserOptions: { diff --git a/packages/api-contract/package.json b/packages/api-contract/package.json index 427b28d..965517e 100644 --- a/packages/api-contract/package.json +++ b/packages/api-contract/package.json @@ -12,6 +12,7 @@ }, "scripts": { "build": "tsc", + "test": "bun test", "clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache", "lint": "eslint . --cache" }, diff --git a/packages/api-contract/src/schemas/common.test.ts b/packages/api-contract/src/schemas/common.test.ts new file mode 100644 index 0000000..2481a0e --- /dev/null +++ b/packages/api-contract/src/schemas/common.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from "bun:test"; +import { nonEmptyString, optionalString } from "./common.js"; + +describe("nonEmptyString", () => { + const schema = nonEmptyString(100); + + test("accepts valid non-empty string", () => { + expect(schema.parse("hello")).toBe("hello"); + }); + + test("trims whitespace", () => { + expect(schema.parse(" hello ")).toBe("hello"); + }); + + test("rejects empty string", () => { + expect(() => schema.parse("")).toThrow(); + }); + + test("rejects whitespace-only string", () => { + expect(() => schema.parse(" ")).toThrow(); + }); + + test("rejects string exceeding max length", () => { + const shortSchema = nonEmptyString(5); + expect(() => shortSchema.parse("123456")).toThrow(); + }); + + test("accepts string at max length", () => { + const shortSchema = nonEmptyString(5); + expect(shortSchema.parse("12345")).toBe("12345"); + }); + + test("works without max length", () => { + const noMaxSchema = nonEmptyString(); + expect(noMaxSchema.parse("a".repeat(1000))).toBe("a".repeat(1000)); + }); +}); + +describe("optionalString", () => { + const schema = optionalString(200); + + test("accepts valid non-empty string", () => { + expect(schema.parse("hello")).toBe("hello"); + }); + + test("trims whitespace", () => { + expect(schema.parse(" hello ")).toBe("hello"); + }); + + test("transforms empty string to undefined", () => { + expect(schema.parse("")).toBeUndefined(); + }); + + test("transforms whitespace-only string to undefined", () => { + expect(schema.parse(" ")).toBeUndefined(); + }); + + test("accepts undefined input", () => { + expect(schema.parse(undefined)).toBeUndefined(); + }); + + test("rejects string exceeding max length", () => { + const shortSchema = optionalString(5); + expect(() => shortSchema.parse("123456")).toThrow(); + }); + + test("accepts string at max length", () => { + const shortSchema = optionalString(5); + expect(shortSchema.parse("12345")).toBe("12345"); + }); + + test("works without max length", () => { + const noMaxSchema = optionalString(); + expect(noMaxSchema.parse("a".repeat(1000))).toBe("a".repeat(1000)); + }); + + test("transforms empty to undefined without max length", () => { + const noMaxSchema = optionalString(); + expect(noMaxSchema.parse("")).toBeUndefined(); + }); +}); diff --git a/packages/api-contract/src/schemas/common.ts b/packages/api-contract/src/schemas/common.ts index cd2c172..a4cd986 100644 --- a/packages/api-contract/src/schemas/common.ts +++ b/packages/api-contract/src/schemas/common.ts @@ -4,6 +4,25 @@ import { } from "libphonenumber-js"; import * as z from "zod"; +/** + * Non-empty string schema - trims whitespace and ensures at least 1 char + * Use for required text fields that shouldn't be blank + */ +export const nonEmptyString = (maxLength?: number) => { + const base = z.string().trim().min(1); + return maxLength ? base.max(maxLength) : base; +}; + +/** + * Optional non-empty string - trims and converts empty/whitespace to undefined + * Use for optional text fields where blank should be treated as not provided + */ +export const optionalString = (maxLength?: number) => { + const base = z.string().trim(); + const withMax = maxLength ? base.max(maxLength) : base; + return withMax.optional().transform((v) => (v === "" ? undefined : v)); +}; + /** * Email schema - validates email format and transforms to lowercase */ diff --git a/packages/api-contract/src/schemas/user.ts b/packages/api-contract/src/schemas/user.ts index a7db2d2..d2dfb95 100644 --- a/packages/api-contract/src/schemas/user.ts +++ b/packages/api-contract/src/schemas/user.ts @@ -1,5 +1,5 @@ import * as z from "zod"; -import { phoneSchema } from "./common.js"; +import { nonEmptyString, optionalString, phoneSchema } from "./common.js"; /** * User profile schema @@ -22,8 +22,8 @@ export const userProfileSchema = z.object({ * Used after signup to collect profile information */ export const setupProfileInputSchema = z.object({ - displayName: z.string().min(1).max(100), - fullName: z.string().max(200).optional(), + displayName: nonEmptyString(100), + fullName: optionalString(200), phoneNumber: phoneSchema, }); @@ -32,10 +32,10 @@ export const setupProfileInputSchema = z.object({ * All fields optional for partial updates */ export const updateProfileInputSchema = z.object({ - displayName: z.string().min(1).max(100).optional(), - fullName: z.string().max(200).optional(), + displayName: nonEmptyString(100).optional(), + fullName: optionalString(200), phoneNumber: phoneSchema, - avatarUrl: z.string().optional(), + avatarUrl: optionalString(), }); /** @@ -95,5 +95,5 @@ export const deviceOutputSchema = z.object({ * Used to name and trust the current device */ export const trustDeviceInputSchema = z.object({ - name: z.string().min(1).max(100), + name: nonEmptyString(100), }); diff --git a/packages/api-contract/tsconfig.json b/packages/api-contract/tsconfig.json index c9d5403..75f2c0f 100644 --- a/packages/api-contract/tsconfig.json +++ b/packages/api-contract/tsconfig.json @@ -2,5 +2,6 @@ "extends": "@macalinao/tsconfig/tsconfig.base.json", "compilerOptions": { "isolatedDeclarations": false - } + }, + "exclude": ["**/*.test.ts"] }