Merge branch 'workstream-f1'

This commit is contained in:
RevIQ
2026-01-09 03:31:58 -05:00
16 changed files with 205 additions and 61 deletions

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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/*

View File

@@ -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

View File

@@ -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("");
};
/**

View File

@@ -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

View File

@@ -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