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 15dff7b..cdf8caa 100644 --- a/apps/api-server/src/router.ts +++ b/apps/api-server/src/router.ts @@ -124,14 +124,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(); }); // Me procedures imported from ./procedures/me/* 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 3d4311c..e9cfbee 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` - [x] **F2**: Implement `me.updateProfile` - [x] **F3**: Implement `me.setPassword` - [x] **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"] }