Merge branch 'workstream-f1'
This commit is contained in:
@@ -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:*",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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/*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string> => {
|
||||
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("");
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<EmailResult> => {
|
||||
|
||||
// ===== 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<OrgRole, string> = {
|
||||
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
|
||||
|
||||
@@ -27,7 +27,7 @@ export async function createSession(
|
||||
options: CreateSessionOptions,
|
||||
): Promise<SessionResult> {
|
||||
const token = generateSessionToken();
|
||||
const tokenHash = hashToken(token);
|
||||
const tokenHash = await hashToken(token);
|
||||
const expiresAt = generateExpiry(COOKIE_DURATIONS.SESSION);
|
||||
|
||||
const result = await db
|
||||
|
||||
11
bun.lock
11
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=="],
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -2,6 +2,9 @@ import { configs } from "@macalinao/eslint-config";
|
||||
|
||||
export default [
|
||||
...configs.fast,
|
||||
{
|
||||
ignores: ["**/*.test.ts"],
|
||||
},
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "bun test",
|
||||
"clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache",
|
||||
"lint": "eslint . --cache"
|
||||
},
|
||||
|
||||
81
packages/api-contract/src/schemas/common.test.ts
Normal file
81
packages/api-contract/src/schemas/common.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -2,5 +2,6 @@
|
||||
"extends": "@macalinao/tsconfig/tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"isolatedDeclarations": false
|
||||
}
|
||||
},
|
||||
"exclude": ["**/*.test.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user