diff --git a/src/lib/client/appointment-crypto.ts b/src/lib/client/appointment-crypto.ts index 7f51419..c46ffec 100644 --- a/src/lib/client/appointment-crypto.ts +++ b/src/lib/client/appointment-crypto.ts @@ -47,6 +47,7 @@ * ``` */ +import type { BootstrapChallengeResponse, BootstrapVerifyResponse } from "$lib/types/appointment"; import { OptimizedArgon2 } from "$lib/crypto/hashing"; import { AESCrypto, BufferUtils, KyberCrypto, ShamirSecretSharing } from "$lib/crypto/utils"; import type { ClientTunnelResponse } from "$lib/server/services/appointment-service"; @@ -138,6 +139,7 @@ export class UnifiedAppointmentCrypto { private clientAuthenticated: boolean = false; private serverPrivateKeyShare: string | null = null; // Server share of the private key private pin: string | null = null; + private bookingAccessToken: string | null = null; // Staff-specific properties private staffKeyPair: StaffKeyPair | null = null; @@ -197,14 +199,17 @@ export class UnifiedAppointmentCrypto { pin, ); - // 6. Fetch staff public keys from server + // 6. Complete bootstrap challenge to obtain a short-lived booking token + await this.bootstrapNewClientAccess(tenantId); + + // 7. Fetch staff public keys from server const staffPublicKeys = await this.fetchStaffPublicKeys(tenantId); - // 7. Encrypt tunnel key for all staff members + // 8. Encrypt tunnel key for all staff members // Note: staffKeyShares will be used during actual appointment creation await this.encryptTunnelKeyForStaff(staffPublicKeys); - // 8. Encrypt tunnel key for client (for later use) + // 9. Encrypt tunnel key for client (for later use) // Note: clientKeyShare will be used during actual appointment creation await this.encryptTunnelKeyForClient(); @@ -400,6 +405,7 @@ export class UnifiedAppointmentCrypto { // 6. Decrypt tunnel key and store tunnel ID this.tunnelKey = await this.decryptTunnelKey(verificationData.encryptedTunnelKey, privateKey); this.tunnelId = verificationData.tunnelId; + this.bookingAccessToken = verificationData.bookingAccessToken; this.clientAuthenticated = true; @@ -473,7 +479,12 @@ export class UnifiedAppointmentCrypto { const response = await fetch(endpoint, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + ...(isFirstAppointment && this.bookingAccessToken + ? { Authorization: `Bearer ${this.bookingAccessToken}` } + : {}), + }, body: JSON.stringify(requestData), }); @@ -534,7 +545,7 @@ export class UnifiedAppointmentCrypto { emailHash: await hashEmail(params.email), clientPublicKey: params.tunnel.clientPublicKey, decryptedTunnelKey, - staffKeyShares: await this.getStaffKeyShares(params.tenantId, decryptedTunnelKey), + staffKeyShares: [], // Not needed for existing clients as tunnel key is already encrypted for staff in this flow }; }; @@ -643,12 +654,17 @@ export class UnifiedAppointmentCrypto { throw new Error("Client not authenticated"); } + if (!this.bookingAccessToken) { + throw new Error("Missing booking access token. Please authenticate first."); + } + try { const response = await fetch(`/api/tenants/${tenantId}/appointments/my-appointments`, { method: "GET", headers: { "Content-Type": "application/json", "X-Email-Hash": this.emailHash!, + Authorization: `Bearer ${this.bookingAccessToken}`, }, }); @@ -901,6 +917,7 @@ export class UnifiedAppointmentCrypto { this.emailHash = null; this.tunnelId = null; this.serverPrivateKeyShare = null; + this.bookingAccessToken = null; this.clientAuthenticated = false; this.pin = null; } @@ -1204,9 +1221,16 @@ export class UnifiedAppointmentCrypto { } async fetchStaffPublicKeys(tenantId: string): Promise { + if (!this.bookingAccessToken) { + throw new Error("Missing booking access token. Please authenticate or complete bootstrap."); + } + const response = await fetch(`/api/tenants/${tenantId}/appointments/staff-public-keys`, { method: "GET", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.bookingAccessToken}`, + }, }); if (!response.ok) { @@ -1217,6 +1241,78 @@ export class UnifiedAppointmentCrypto { return data.staffPublicKeys; } + private async bootstrapNewClientAccess(tenantId: string): Promise { + if (!this.tunnelId || !this.clientKeyPair) { + throw new Error("Bootstrap requires generated client tunnel and key pair"); + } + + const challengeResponse = await fetch( + `/api/tenants/${tenantId}/appointments/bootstrap-challenge`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + tunnelId: this.tunnelId, + clientPublicKey: this.clientKeyPair.publicKey, + emailHash: this.emailHash ?? undefined, + }), + }, + ); + + if (!challengeResponse.ok) { + throw new Error(`Failed to request bootstrap challenge: ${challengeResponse.statusText}`); + } + + const challengeData: BootstrapChallengeResponse = await challengeResponse.json(); + const counter = await this.solveBootstrapProofOfWork( + challengeData.nonce, + this.tunnelId, + this.clientKeyPair.publicKey, + challengeData.difficulty, + ); + + const verifyResponse = await fetch(`/api/tenants/${tenantId}/appointments/bootstrap-verify`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + challengeId: challengeData.challengeId, + tunnelId: this.tunnelId, + clientPublicKey: this.clientKeyPair.publicKey, + counter, + emailHash: this.emailHash ?? undefined, + }), + }); + + if (!verifyResponse.ok) { + throw new Error(`Failed to verify bootstrap challenge: ${verifyResponse.statusText}`); + } + + const verificationData: BootstrapVerifyResponse = await verifyResponse.json(); + this.bookingAccessToken = verificationData.bookingAccessToken; + } + + private async solveBootstrapProofOfWork( + nonce: string, + tunnelId: string, + clientPublicKey: string, + difficulty: number, + ): Promise { + const targetPrefix = "0".repeat(difficulty); + const encoder = new TextEncoder(); + + for (let counter = 0; ; counter += 1) { + const input = `${nonce}:${tunnelId}:${clientPublicKey}:${counter}`; + const digestBuffer = await crypto.subtle.digest("SHA-256", encoder.encode(input)); + const digestHex = Array.from(new Uint8Array(digestBuffer)) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); + + if (digestHex.startsWith(targetPrefix)) { + return counter; + } + } + } + // getTenantId method removed - tenantId is now always passed explicitly async encryptTunnelKeyForStaff( diff --git a/src/lib/server/auth/__tests__/booking-access-token.test.ts b/src/lib/server/auth/__tests__/booking-access-token.test.ts new file mode 100644 index 0000000..412226c --- /dev/null +++ b/src/lib/server/auth/__tests__/booking-access-token.test.ts @@ -0,0 +1,143 @@ +import { randomUUID } from "node:crypto"; +import { env } from "$env/dynamic/private"; +import { SignJWT } from "jose"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +vi.mock("$lib/server/services/booking-access-token-store", () => ({ + bookingAccessTokenStore: { + store: vi.fn(), + isActive: vi.fn(), + consume: vi.fn(), + }, +})); + +import { + consumeBookingAccessToken, + generateBookingAccessToken, + generateNewClientBootstrapToken, + verifyBookingAccessToken, +} from "$lib/server/auth/booking-access-token"; +import { bookingAccessTokenStore } from "$lib/server/services/booking-access-token-store"; + +const JWT_SECRET = new TextEncoder().encode(env.JWT_SECRET); + +async function signTestToken(payload: { + tenantId: string; + tunnelId: string; + scope: "appointments:client" | "appointments:new-client-bootstrap"; + emailHash?: string; + clientPublicKey?: string; + jti?: string; +}): Promise { + return await new SignJWT({ + tenantId: payload.tenantId, + tunnelId: payload.tunnelId, + scope: payload.scope, + emailHash: payload.emailHash, + clientPublicKey: payload.clientPublicKey, + }) + .setProtectedHeader({ alg: "HS256" }) + .setJti(payload.jti ?? randomUUID()) + .setIssuedAt() + .setExpirationTime("10m") + .sign(JWT_SECRET); +} + +describe("booking-access-token", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(bookingAccessTokenStore.isActive).mockResolvedValue(true); + vi.mocked(bookingAccessTokenStore.consume).mockResolvedValue(true); + }); + + it("generates and verifies a valid booking access token", async () => { + const token = await generateBookingAccessToken({ + tenantId: "123e4567-e89b-12d3-a456-426614174000", + emailHash: "a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3", + tunnelId: "550e8400-e29b-41d4-a716-446655440000", + }); + + const payload = await verifyBookingAccessToken(token); + + expect(payload).not.toBeNull(); + expect(payload?.tenantId).toBe("123e4567-e89b-12d3-a456-426614174000"); + expect(payload?.tunnelId).toBe("550e8400-e29b-41d4-a716-446655440000"); + expect(payload?.scope).toBe("appointments:client"); + expect(payload?.jti).toBeTypeOf("string"); + expect(bookingAccessTokenStore.store).toHaveBeenCalledOnce(); + }); + + it("rejects malformed token", async () => { + const payload = await verifyBookingAccessToken("invalid.token"); + expect(payload).toBeNull(); + }); + + it("generates and verifies a valid bootstrap booking access token", async () => { + const token = await generateNewClientBootstrapToken({ + tenantId: "123e4567-e89b-12d3-a456-426614174000", + tunnelId: "550e8400-e29b-41d4-a716-446655440000", + clientPublicKey: "bootstrap-client-public-key", + emailHash: "a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3", + }); + + const payload = await verifyBookingAccessToken(token); + + expect(payload).not.toBeNull(); + expect(payload?.tenantId).toBe("123e4567-e89b-12d3-a456-426614174000"); + expect(payload?.tunnelId).toBe("550e8400-e29b-41d4-a716-446655440000"); + expect(payload?.clientPublicKey).toBe("bootstrap-client-public-key"); + expect(payload?.scope).toBe("appointments:new-client-bootstrap"); + }); + + it("rejects existing-client token without emailHash", async () => { + const token = await signTestToken({ + tenantId: "123e4567-e89b-12d3-a456-426614174000", + tunnelId: "550e8400-e29b-41d4-a716-446655440000", + scope: "appointments:client", + }); + + const payload = await verifyBookingAccessToken(token); + expect(payload).toBeNull(); + }); + + it("rejects bootstrap token without clientPublicKey", async () => { + const token = await signTestToken({ + tenantId: "123e4567-e89b-12d3-a456-426614174000", + tunnelId: "550e8400-e29b-41d4-a716-446655440000", + scope: "appointments:new-client-bootstrap", + emailHash: "a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3", + }); + + const payload = await verifyBookingAccessToken(token); + expect(payload).toBeNull(); + }); + + it("rejects token that is not active in token store", async () => { + vi.mocked(bookingAccessTokenStore.isActive).mockResolvedValue(false); + + const token = await generateBookingAccessToken({ + tenantId: "123e4567-e89b-12d3-a456-426614174000", + emailHash: "a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3", + tunnelId: "550e8400-e29b-41d4-a716-446655440000", + }); + + const payload = await verifyBookingAccessToken(token); + expect(payload).toBeNull(); + }); + + it("consumes token when requested", async () => { + const token = await generateNewClientBootstrapToken({ + tenantId: "123e4567-e89b-12d3-a456-426614174000", + tunnelId: "550e8400-e29b-41d4-a716-446655440000", + clientPublicKey: "bootstrap-client-public-key", + }); + + const payload = await verifyBookingAccessToken(token); + expect(payload).not.toBeNull(); + + if (payload) { + await consumeBookingAccessToken(payload); + } + + expect(bookingAccessTokenStore.consume).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/lib/server/auth/booking-access-token.ts b/src/lib/server/auth/booking-access-token.ts new file mode 100644 index 0000000..1bd02d2 --- /dev/null +++ b/src/lib/server/auth/booking-access-token.ts @@ -0,0 +1,172 @@ +import { SignJWT, jwtVerify } from "jose"; +import { randomUUID } from "node:crypto"; +import { env } from "$env/dynamic/private"; +import { UniversalLogger } from "$lib/logger"; +import { bookingAccessTokenStore } from "$lib/server/services/booking-access-token-store"; + +const logger = new UniversalLogger().setContext("BookingAccessToken"); + +const BOOKING_ACCESS_EXPIRES = "10m"; +const BOOKING_ACCESS_TTL_MS = 10 * 60 * 1000; +export const EXISTING_CLIENT_BOOKING_SCOPE = "appointments:client"; +export const NEW_CLIENT_BOOTSTRAP_SCOPE = "appointments:new-client-bootstrap"; +export type BookingAccessScope = + | typeof EXISTING_CLIENT_BOOKING_SCOPE + | typeof NEW_CLIENT_BOOTSTRAP_SCOPE; + +if (!env.JWT_SECRET) { + throw new Error("Mandatory ENV variable JWT_SECRET is missing!"); +} + +const JWT_SECRET = new TextEncoder().encode(env.JWT_SECRET); + +export interface BookingAccessTokenPayload { + tenantId: string; + emailHash?: string; + tunnelId: string; + clientPublicKey?: string; + scope: BookingAccessScope; + jti?: string; + iat?: number; + exp?: number; +} + +async function signBookingAccessToken(payload: { + tenantId: string; + emailHash?: string; + tunnelId: string; + clientPublicKey?: string; + scope: BookingAccessScope; +}): Promise { + const now = Math.floor(Date.now() / 1000); + const jti = randomUUID(); + const expiresAt = new Date(Date.now() + BOOKING_ACCESS_TTL_MS); + + const token = await new SignJWT({ + tenantId: payload.tenantId, + emailHash: payload.emailHash, + tunnelId: payload.tunnelId, + clientPublicKey: payload.clientPublicKey, + scope: payload.scope, + }) + .setProtectedHeader({ alg: "HS256" }) + .setJti(jti) + .setIssuedAt(now) + .setExpirationTime(BOOKING_ACCESS_EXPIRES) + .sign(JWT_SECRET); + + await bookingAccessTokenStore.store({ + id: jti, + scope: payload.scope, + tenantId: payload.tenantId, + emailHash: payload.emailHash, + tunnelId: payload.tunnelId, + clientPublicKey: payload.clientPublicKey, + expiresAt, + }); + + return token; +} + +export async function generateBookingAccessToken(payload: { + tenantId: string; + emailHash: string; + tunnelId: string; +}): Promise { + return await signBookingAccessToken({ + ...payload, + scope: EXISTING_CLIENT_BOOKING_SCOPE, + }); +} + +export async function generateNewClientBootstrapToken(payload: { + tenantId: string; + tunnelId: string; + clientPublicKey: string; + emailHash?: string; +}): Promise { + return await signBookingAccessToken({ + ...payload, + scope: NEW_CLIENT_BOOTSTRAP_SCOPE, + }); +} + +export async function verifyBookingAccessToken( + token: string, +): Promise { + try { + const { payload } = await jwtVerify(token, JWT_SECRET); + + if ( + typeof payload.tenantId !== "string" || + typeof payload.tunnelId !== "string" || + typeof payload.jti !== "string" || + (payload.scope !== EXISTING_CLIENT_BOOKING_SCOPE && + payload.scope !== NEW_CLIENT_BOOTSTRAP_SCOPE) + ) { + logger.warn("Invalid booking access token payload"); + return null; + } + + const isActive = await bookingAccessTokenStore.isActive({ + id: payload.jti, + tenantId: payload.tenantId, + scope: payload.scope, + }); + if (!isActive) { + logger.warn("Booking access token is not active", { + tenantId: payload.tenantId, + jti: payload.jti, + scope: payload.scope, + }); + return null; + } + + if (payload.scope === EXISTING_CLIENT_BOOKING_SCOPE && typeof payload.emailHash !== "string") { + logger.warn("Existing client booking access token missing emailHash"); + return null; + } + + if ( + payload.scope === NEW_CLIENT_BOOTSTRAP_SCOPE && + typeof payload.clientPublicKey !== "string" + ) { + logger.warn("New client bootstrap token missing clientPublicKey"); + return null; + } + + return { + tenantId: payload.tenantId, + emailHash: payload.emailHash as string | undefined, + tunnelId: payload.tunnelId, + clientPublicKey: payload.clientPublicKey as string | undefined, + scope: payload.scope, + jti: payload.jti, + iat: payload.iat, + exp: payload.exp, + }; + } catch (error) { + logger.warn("Booking access token verification failed", { error: String(error) }); + return null; + } +} + +export async function consumeBookingAccessToken(payload: BookingAccessTokenPayload): Promise { + if (!payload.jti) { + return; + } + + const consumed = await bookingAccessTokenStore.consume({ + id: payload.jti, + tenantId: payload.tenantId, + scope: payload.scope, + }); + + if (!consumed) { + logger.warn("Booking access token could not be consumed", { + tenantId: payload.tenantId, + jti: payload.jti, + scope: payload.scope, + }); + } +} diff --git a/src/lib/server/db/tenant-schema.ts b/src/lib/server/db/tenant-schema.ts index 5d2fa89..64eaf7a 100644 --- a/src/lib/server/db/tenant-schema.ts +++ b/src/lib/server/db/tenant-schema.ts @@ -368,6 +368,32 @@ export const authChallenge = pgTable("auth_challenge", { consumed: boolean("consumed").default(false).notNull(), }); +/** + * BookingAccessToken table - stores short-lived booking JWT sessions by JTI + * Enables token allowlisting, revocation and one-time consumption for bootstrap flow + * @table bookingAccessToken + */ +export const bookingAccessToken = pgTable("booking_access_token", { + /** JWT ID (jti) as primary key */ + id: text("id").primaryKey(), + /** Token scope (appointments:client / appointments:new-client-bootstrap) */ + scope: text("scope").notNull(), + /** Tenant binding */ + tenantId: uuid("tenant_id").notNull(), + /** Optional client email hash binding */ + emailHash: text("email_hash"), + /** Tunnel binding */ + tunnelId: uuid("tunnel_id").notNull(), + /** Optional client key binding for bootstrap */ + clientPublicKey: text("client_public_key"), + /** Creation timestamp */ + createdAt: timestamp("created_at").defaultNow().notNull(), + /** Expiry timestamp */ + expiresAt: timestamp("expires_at").notNull(), + /** Whether token was consumed (one-time semantics) */ + consumed: boolean("consumed").default(false).notNull(), +}); + /** * ClientPinResetToken table - stores temporary PIN reset tokens for clients * Used for secure PIN reset via QR code or email link @@ -399,3 +425,6 @@ export type SelectClientTunnelStaffKeyShare = InferSelectModel; + +/** BookingAccessToken record type for database queries */ +export type SelectBookingAccessToken = InferSelectModel; diff --git a/src/lib/server/services/booking-access-token-store.ts b/src/lib/server/services/booking-access-token-store.ts new file mode 100644 index 0000000..f5e168e --- /dev/null +++ b/src/lib/server/services/booking-access-token-store.ts @@ -0,0 +1,96 @@ +import { and, eq, lt } from "drizzle-orm"; +import { logger } from "$lib/logger"; +import { getTenantDb } from "$lib/server/db"; +import { bookingAccessToken } from "$lib/server/db/tenant-schema"; + +interface StoreBookingTokenInput { + id: string; + scope: string; + tenantId: string; + emailHash?: string; + tunnelId: string; + clientPublicKey?: string; + expiresAt: Date; +} + +class BookingAccessTokenStore { + async store(input: StoreBookingTokenInput): Promise { + const db = await getTenantDb(input.tenantId); + + await db.insert(bookingAccessToken).values({ + id: input.id, + scope: input.scope, + tenantId: input.tenantId, + emailHash: input.emailHash, + tunnelId: input.tunnelId, + clientPublicKey: input.clientPublicKey, + expiresAt: input.expiresAt, + consumed: false, + }); + + await this.cleanup(input.tenantId); + } + + async isActive(input: { id: string; tenantId: string; scope: string }): Promise { + const db = await getTenantDb(input.tenantId); + + const rows = await db + .select({ id: bookingAccessToken.id, expiresAt: bookingAccessToken.expiresAt }) + .from(bookingAccessToken) + .where( + and( + eq(bookingAccessToken.id, input.id), + eq(bookingAccessToken.tenantId, input.tenantId), + eq(bookingAccessToken.scope, input.scope), + eq(bookingAccessToken.consumed, false), + ), + ) + .limit(1); + + if (rows.length === 0) { + return false; + } + + if (rows[0].expiresAt < new Date()) { + await db.delete(bookingAccessToken).where(eq(bookingAccessToken.id, input.id)); + return false; + } + + return true; + } + + async consume(input: { id: string; tenantId: string; scope: string }): Promise { + const db = await getTenantDb(input.tenantId); + + const result = await db + .update(bookingAccessToken) + .set({ consumed: true }) + .where( + and( + eq(bookingAccessToken.id, input.id), + eq(bookingAccessToken.tenantId, input.tenantId), + eq(bookingAccessToken.scope, input.scope), + eq(bookingAccessToken.consumed, false), + ), + ) + .returning({ id: bookingAccessToken.id }); + + return result.length > 0; + } + + private async cleanup(tenantId: string): Promise { + try { + const db = await getTenantDb(tenantId); + const now = new Date(); + + await db.delete(bookingAccessToken).where(lt(bookingAccessToken.expiresAt, now)); + } catch (error) { + logger.warn("Failed to cleanup expired booking access tokens", { + tenantId, + error: String(error), + }); + } + } +} + +export const bookingAccessTokenStore = new BookingAccessTokenStore(); diff --git a/src/lib/server/services/bootstrap-challenge.ts b/src/lib/server/services/bootstrap-challenge.ts new file mode 100644 index 0000000..ba95aeb --- /dev/null +++ b/src/lib/server/services/bootstrap-challenge.ts @@ -0,0 +1,35 @@ +import { createHash } from "node:crypto"; + +export const NEW_CLIENT_BOOTSTRAP_DIFFICULTY = 4; + +export function createBootstrapBinding(input: { + tenantId: string; + tunnelId: string; + clientPublicKey: string; + emailHash?: string; +}): string { + return createHash("sha256") + .update( + [input.tenantId, input.tunnelId, input.clientPublicKey, input.emailHash ?? ""].join(":"), + "utf8", + ) + .digest("hex"); +} + +export function createBootstrapPowDigest(input: { + nonce: string; + tunnelId: string; + clientPublicKey: string; + counter: number; +}): string { + return createHash("sha256") + .update( + [input.nonce, input.tunnelId, input.clientPublicKey, input.counter.toString()].join(":"), + "utf8", + ) + .digest("hex"); +} + +export function matchesPowDifficulty(digest: string, difficulty: number): boolean { + return digest.startsWith("0".repeat(difficulty)); +} diff --git a/src/lib/types/appointment.ts b/src/lib/types/appointment.ts index b47cdf8..0539dc0 100644 --- a/src/lib/types/appointment.ts +++ b/src/lib/types/appointment.ts @@ -58,6 +58,32 @@ export interface ChallengeVerificationResponse { valid: boolean; encryptedTunnelKey: string; // Encrypted with client public key tunnelId: string; // ID of client tunnel (to save or load appointments) + bookingAccessToken: string; // Short-lived token for booking-scoped API calls +} + +export interface BootstrapChallengeRequest { + tunnelId: string; + clientPublicKey: string; + emailHash?: string; +} + +export interface BootstrapChallengeResponse { + challengeId: string; + nonce: string; + difficulty: number; +} + +export interface BootstrapVerifyRequest { + challengeId: string; + tunnelId: string; + clientPublicKey: string; + counter: number; + emailHash?: string; +} + +export interface BootstrapVerifyResponse { + valid: boolean; + bookingAccessToken: string; } // Existing Client - New Appointment diff --git a/src/routes/api/tenants/[id]/appointments/bootstrap-challenge/+server.ts b/src/routes/api/tenants/[id]/appointments/bootstrap-challenge/+server.ts new file mode 100644 index 0000000..898dc60 --- /dev/null +++ b/src/routes/api/tenants/[id]/appointments/bootstrap-challenge/+server.ts @@ -0,0 +1,149 @@ +import { json, type RequestHandler } from "@sveltejs/kit"; +import { z } from "zod"; +import { randomBytes } from "node:crypto"; +import { logger } from "$lib/logger"; +import { registerOpenAPIRoute } from "$lib/server/openapi"; +import { challengeStore } from "$lib/server/services/challenge-store"; +import { challengeThrottleService } from "$lib/server/services/challenge-throttle"; +import { BackendError, InternalError, ValidationError, logError } from "$lib/server/utils/errors"; +import { + createBootstrapBinding, + NEW_CLIENT_BOOTSTRAP_DIFFICULTY, +} from "$lib/server/services/bootstrap-challenge"; +import type { BootstrapChallengeResponse } from "$lib/types/appointment"; + +const requestSchema = z.object({ + tunnelId: z.string().uuid(), + clientPublicKey: z.string().min(1), + emailHash: z.string().optional(), +}); + +registerOpenAPIRoute("/tenants/{id}/appointments/bootstrap-challenge", "POST", { + summary: "Create bootstrap challenge for new client", + description: + "Creates a nonce-based proof-of-work challenge for a new client before first appointment creation.", + tags: ["Appointments", "Clients"], + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string", format: "uuid" }, + description: "Tenant ID", + }, + ], + requestBody: { + description: "Bootstrap challenge input", + content: { + "application/json": { + schema: { + type: "object", + properties: { + tunnelId: { type: "string", format: "uuid" }, + clientPublicKey: { type: "string" }, + emailHash: { type: "string" }, + }, + required: ["tunnelId", "clientPublicKey"], + }, + }, + }, + }, + responses: { + "200": { + description: "Bootstrap challenge created successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + challengeId: { type: "string" }, + nonce: { type: "string" }, + difficulty: { type: "integer" }, + }, + required: ["challengeId", "nonce", "difficulty"], + }, + }, + }, + }, + "422": { + description: "Invalid request data", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "429": { + description: "Too many bootstrap attempts", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, +}); + +export const POST: RequestHandler = async ({ request, params }) => { + try { + const tenantId = params.id; + if (!tenantId) { + throw new ValidationError("Tenant ID is required"); + } + + const body = requestSchema.parse(await request.json()); + const binding = createBootstrapBinding({ + tenantId, + tunnelId: body.tunnelId, + clientPublicKey: body.clientPublicKey, + emailHash: body.emailHash, + }); + + const throttleResult = await challengeThrottleService.checkThrottle(binding, "passkey"); + if (!throttleResult.allowed) { + return json( + { + error: "Too many bootstrap attempts. Please try again later.", + retryAfterMs: throttleResult.retryAfterMs, + }, + { status: 429 }, + ); + } + + const challengeId = randomBytes(16).toString("hex"); + const nonce = randomBytes(32).toString("base64"); + + await challengeStore.store(challengeId, nonce, binding, tenantId); + + const response: BootstrapChallengeResponse = { + challengeId, + nonce, + difficulty: NEW_CLIENT_BOOTSTRAP_DIFFICULTY, + }; + + logger.info("Created bootstrap challenge for new client", { + tenantId, + challengeId, + tunnelId: body.tunnelId, + }); + + return json(response); + } catch (error) { + logError(logger)("Failed to create bootstrap challenge", error); + if (error instanceof z.ZodError) { + return new ValidationError("Invalid request data").toJson(); + } + if (error instanceof BackendError) { + return error.toJson(); + } + return new InternalError().toJson(); + } +}; diff --git a/src/routes/api/tenants/[id]/appointments/bootstrap-challenge/__tests__/bootstrap-challenge-api.test.ts b/src/routes/api/tenants/[id]/appointments/bootstrap-challenge/__tests__/bootstrap-challenge-api.test.ts new file mode 100644 index 0000000..78399fe --- /dev/null +++ b/src/routes/api/tenants/[id]/appointments/bootstrap-challenge/__tests__/bootstrap-challenge-api.test.ts @@ -0,0 +1,80 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { RequestEvent } from "@sveltejs/kit"; +import { POST } from "../+server"; + +vi.mock("$lib/logger", () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +vi.mock("$lib/server/services/challenge-store", () => ({ + challengeStore: { + store: vi.fn(), + }, +})); + +vi.mock("$lib/server/services/challenge-throttle", () => ({ + challengeThrottleService: { + checkThrottle: vi.fn(), + }, +})); + +import { challengeStore } from "$lib/server/services/challenge-store"; +import { challengeThrottleService } from "$lib/server/services/challenge-throttle"; + +describe("Bootstrap Challenge API", () => { + const tenantId = "123e4567-e89b-12d3-a456-426614174000"; + const validBody = { + tunnelId: "550e8400-e29b-41d4-a716-446655440000", + clientPublicKey: "client-public-key", + emailHash: "email-hash", + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(challengeThrottleService.checkThrottle).mockResolvedValue({ + allowed: true, + failedAttempts: 0, + retryAfterMs: 1500, + }); + }); + + function createEvent(body: unknown = validBody): RequestEvent { + return { + params: { id: tenantId }, + request: { + json: vi.fn().mockResolvedValue(body), + } as any, + } as RequestEvent; + } + + it("returns bootstrap challenge when request is valid", async () => { + const response = await POST(createEvent()); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.challengeId).toBeTypeOf("string"); + expect(data.nonce).toBeTypeOf("string"); + expect(data.difficulty).toBe(4); + expect(challengeStore.store).toHaveBeenCalledOnce(); + }); + + it("returns 429 when throttled", async () => { + vi.mocked(challengeThrottleService.checkThrottle).mockResolvedValue({ + allowed: false, + failedAttempts: 0, + retryAfterMs: 15000, + }); + + const response = await POST(createEvent()); + const data = await response.json(); + + expect(response.status).toBe(429); + expect(data.error).toBe("Too many bootstrap attempts. Please try again later."); + }); +}); diff --git a/src/routes/api/tenants/[id]/appointments/bootstrap-verify/+server.ts b/src/routes/api/tenants/[id]/appointments/bootstrap-verify/+server.ts new file mode 100644 index 0000000..1c237f1 --- /dev/null +++ b/src/routes/api/tenants/[id]/appointments/bootstrap-verify/+server.ts @@ -0,0 +1,174 @@ +import { json, type RequestHandler } from "@sveltejs/kit"; +import { z } from "zod"; +import { logger } from "$lib/logger"; +import { registerOpenAPIRoute } from "$lib/server/openapi"; +import { challengeStore } from "$lib/server/services/challenge-store"; +import { challengeThrottleService } from "$lib/server/services/challenge-throttle"; +import { + BackendError, + InternalError, + NotFoundError, + ValidationError, + logError, +} from "$lib/server/utils/errors"; +import { + createBootstrapBinding, + createBootstrapPowDigest, + matchesPowDifficulty, + NEW_CLIENT_BOOTSTRAP_DIFFICULTY, +} from "$lib/server/services/bootstrap-challenge"; +import { generateNewClientBootstrapToken } from "$lib/server/auth/booking-access-token"; +import type { BootstrapVerifyResponse } from "$lib/types/appointment"; + +const requestSchema = z.object({ + challengeId: z.string().min(1), + tunnelId: z.string().uuid(), + clientPublicKey: z.string().min(1), + counter: z.number().int().min(0), + emailHash: z.string().optional(), +}); + +registerOpenAPIRoute("/tenants/{id}/appointments/bootstrap-verify", "POST", { + summary: "Verify bootstrap challenge for new client", + description: + "Verifies the nonce-based proof-of-work challenge for a new client and returns a short-lived bootstrap token.", + tags: ["Appointments", "Clients"], + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string", format: "uuid" }, + description: "Tenant ID", + }, + ], + requestBody: { + description: "Bootstrap verification data", + content: { + "application/json": { + schema: { + type: "object", + properties: { + challengeId: { type: "string" }, + tunnelId: { type: "string", format: "uuid" }, + clientPublicKey: { type: "string" }, + counter: { type: "integer" }, + emailHash: { type: "string" }, + }, + required: ["challengeId", "tunnelId", "clientPublicKey", "counter"], + }, + }, + }, + }, + responses: { + "200": { + description: "Bootstrap challenge verified successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + valid: { type: "boolean" }, + bookingAccessToken: { type: "string" }, + }, + required: ["valid", "bookingAccessToken"], + }, + }, + }, + }, + "404": { + description: "Bootstrap challenge not found or expired", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "422": { + description: "Invalid request data or proof of work", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, +}); + +export const POST: RequestHandler = async ({ request, params }) => { + try { + const tenantId = params.id; + if (!tenantId) { + throw new ValidationError("Tenant ID is required"); + } + + const body = requestSchema.parse(await request.json()); + const binding = createBootstrapBinding({ + tenantId, + tunnelId: body.tunnelId, + clientPublicKey: body.clientPublicKey, + emailHash: body.emailHash, + }); + + const storedChallenge = await challengeStore.consume(body.challengeId, tenantId); + if (!storedChallenge) { + throw new NotFoundError("Bootstrap challenge not found or expired"); + } + + if (storedChallenge.emailHash !== binding) { + await challengeThrottleService.recordFailedAttempt(binding, "passkey"); + throw new ValidationError("Invalid bootstrap challenge binding"); + } + + const digest = createBootstrapPowDigest({ + nonce: storedChallenge.challenge, + tunnelId: body.tunnelId, + clientPublicKey: body.clientPublicKey, + counter: body.counter, + }); + + if (!matchesPowDifficulty(digest, NEW_CLIENT_BOOTSTRAP_DIFFICULTY)) { + await challengeThrottleService.recordFailedAttempt(binding, "passkey"); + throw new ValidationError("Invalid bootstrap proof of work"); + } + + await challengeThrottleService.clearThrottle(binding, "passkey"); + + const bookingAccessToken = await generateNewClientBootstrapToken({ + tenantId, + tunnelId: body.tunnelId, + clientPublicKey: body.clientPublicKey, + emailHash: body.emailHash, + }); + + const response: BootstrapVerifyResponse = { + valid: true, + bookingAccessToken, + }; + + logger.info("Verified bootstrap challenge for new client", { + tenantId, + challengeId: body.challengeId, + tunnelId: body.tunnelId, + }); + + return json(response); + } catch (error) { + logError(logger)("Failed to verify bootstrap challenge", error); + if (error instanceof z.ZodError) { + return new ValidationError("Invalid request data").toJson(); + } + if (error instanceof BackendError) { + return error.toJson(); + } + return new InternalError().toJson(); + } +}; diff --git a/src/routes/api/tenants/[id]/appointments/bootstrap-verify/__tests__/bootstrap-verify-api.test.ts b/src/routes/api/tenants/[id]/appointments/bootstrap-verify/__tests__/bootstrap-verify-api.test.ts new file mode 100644 index 0000000..51b07f0 --- /dev/null +++ b/src/routes/api/tenants/[id]/appointments/bootstrap-verify/__tests__/bootstrap-verify-api.test.ts @@ -0,0 +1,139 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { RequestEvent } from "@sveltejs/kit"; +import { POST } from "../+server"; +import { createBootstrapPowDigest } from "$lib/server/services/bootstrap-challenge"; + +vi.mock("$lib/logger", () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +vi.mock("$lib/server/services/challenge-store", () => ({ + challengeStore: { + consume: vi.fn(), + }, +})); + +vi.mock("$lib/server/services/challenge-throttle", () => ({ + challengeThrottleService: { + recordFailedAttempt: vi.fn(), + clearThrottle: vi.fn(), + }, +})); + +vi.mock("$lib/server/auth/booking-access-token", () => ({ + generateNewClientBootstrapToken: vi.fn(), +})); + +import { challengeStore } from "$lib/server/services/challenge-store"; +import { challengeThrottleService } from "$lib/server/services/challenge-throttle"; +import { generateNewClientBootstrapToken } from "$lib/server/auth/booking-access-token"; + +describe("Bootstrap Verify API", () => { + const tenantId = "123e4567-e89b-12d3-a456-426614174000"; + const nonce = "bootstrap-nonce"; + const tunnelId = "550e8400-e29b-41d4-a716-446655440000"; + const clientPublicKey = "client-public-key"; + const emailHash = "email-hash"; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(generateNewClientBootstrapToken).mockResolvedValue("bootstrap-token"); + }); + + function createEvent(counter: number): RequestEvent { + return { + params: { id: tenantId }, + request: { + json: vi.fn().mockResolvedValue({ + challengeId: "challenge-id", + tunnelId, + clientPublicKey, + counter, + emailHash, + }), + } as any, + } as RequestEvent; + } + + async function findValidCounter(): Promise { + for (let counter = 0; ; counter += 1) { + const digest = createBootstrapPowDigest({ + nonce, + tunnelId, + clientPublicKey, + counter, + }); + + if (digest.startsWith("0000")) { + return counter; + } + } + } + + it("returns bootstrap token for valid proof of work", async () => { + vi.mocked(challengeStore.consume).mockResolvedValue({ + challenge: nonce, + emailHash: "fdb1dce4daaadf27027857ba4b1947e6260fa7d8d40b975639096d5f04f0a96c", + } as any); + + const validCounter = await findValidCounter(); + const { createBootstrapBinding } = await import("$lib/server/services/bootstrap-challenge"); + vi.mocked(challengeStore.consume).mockResolvedValue({ + challenge: nonce, + emailHash: createBootstrapBinding({ tenantId, tunnelId, clientPublicKey, emailHash }), + } as any); + + const response = await POST(createEvent(validCounter)); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.valid).toBe(true); + expect(data.bookingAccessToken).toBe("bootstrap-token"); + expect(challengeThrottleService.clearThrottle).toHaveBeenCalledOnce(); + }); + + it("returns 422 for invalid proof of work", async () => { + const { createBootstrapBinding } = await import("$lib/server/services/bootstrap-challenge"); + vi.mocked(challengeStore.consume).mockResolvedValue({ + challenge: nonce, + emailHash: createBootstrapBinding({ tenantId, tunnelId, clientPublicKey, emailHash }), + } as any); + + const response = await POST(createEvent(0)); + const data = await response.json(); + + expect(response.status).toBe(422); + expect(data.error).toBe("Invalid bootstrap proof of work"); + expect(challengeThrottleService.recordFailedAttempt).toHaveBeenCalledOnce(); + }); + + it("returns 404 when challenge is missing or expired", async () => { + vi.mocked(challengeStore.consume).mockResolvedValue(null); + + const response = await POST(createEvent(0)); + const data = await response.json(); + + expect(response.status).toBe(404); + expect(data.error).toBe("Bootstrap challenge not found or expired"); + }); + + it("returns 422 when challenge binding does not match", async () => { + vi.mocked(challengeStore.consume).mockResolvedValue({ + challenge: nonce, + emailHash: "other-binding", + } as any); + + const response = await POST(createEvent(0)); + const data = await response.json(); + + expect(response.status).toBe(422); + expect(data.error).toBe("Invalid bootstrap challenge binding"); + expect(challengeThrottleService.recordFailedAttempt).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/routes/api/tenants/[id]/appointments/create-new-client/+server.ts b/src/routes/api/tenants/[id]/appointments/create-new-client/+server.ts index da033d4..9061d68 100644 --- a/src/routes/api/tenants/[id]/appointments/create-new-client/+server.ts +++ b/src/routes/api/tenants/[id]/appointments/create-new-client/+server.ts @@ -1,8 +1,20 @@ import { json, type RequestHandler } from "@sveltejs/kit"; import { z } from "zod"; import { logger } from "$lib/logger"; +import { + consumeBookingAccessToken, + NEW_CLIENT_BOOTSTRAP_SCOPE, + verifyBookingAccessToken, +} from "$lib/server/auth/booking-access-token"; import { AppointmentService } from "$lib/server/services/appointment-service"; -import { BackendError, InternalError, logError, ValidationError } from "$lib/server/utils/errors"; +import { + AuthenticationError, + AuthorizationError, + BackendError, + InternalError, + logError, + ValidationError, +} from "$lib/server/utils/errors"; import { registerOpenAPIRoute } from "$lib/server/openapi"; const requestSchema = z.object({ @@ -31,6 +43,55 @@ const requestSchema = z.object({ clientEncryptedTunnelKey: z.string(), }); +type CreateNewClientRequest = z.infer; + +async function requireBootstrapBookingAccessToken( + request: Request, + tenantId: string, +): Promise>>> { + const authorizationHeader = request.headers.get("Authorization"); + if (!authorizationHeader?.startsWith("Bearer ")) { + throw new AuthenticationError("Bootstrap booking access token is required"); + } + + const token = authorizationHeader.substring("Bearer ".length).trim(); + if (!token) { + throw new AuthenticationError("Bootstrap booking access token is required"); + } + + const tokenPayload = await verifyBookingAccessToken(token); + if (!tokenPayload) { + throw new AuthenticationError("Invalid or expired booking access token"); + } + + if (tokenPayload.scope !== NEW_CLIENT_BOOTSTRAP_SCOPE) { + throw new AuthorizationError("Booking access token is not valid for new client bootstrap"); + } + + if (tokenPayload.tenantId !== tenantId) { + throw new AuthorizationError("Booking access token is not valid for this tenant"); + } + + return tokenPayload; +} + +function validateBootstrapTokenBinding( + tokenPayload: NonNullable>>, + requestData: CreateNewClientRequest, +): void { + if (tokenPayload.tunnelId !== requestData.tunnelId) { + throw new AuthorizationError("Booking access token is not valid for this tunnel"); + } + + if (tokenPayload.clientPublicKey !== requestData.clientPublicKey) { + throw new AuthorizationError("Booking access token is not valid for this client key"); + } + + if (tokenPayload.emailHash && tokenPayload.emailHash !== requestData.emailHash) { + throw new AuthorizationError("Booking access token is not valid for this email hash"); + } +} + // Register OpenAPI documentation for POST registerOpenAPIRoute("/tenants/{id}/appointments/create-new-client", "POST", { summary: "Create new client with appointment", @@ -45,6 +106,13 @@ registerOpenAPIRoute("/tenants/{id}/appointments/create-new-client", "POST", { schema: { type: "string", format: "uuid" }, description: "Tenant ID", }, + { + name: "Authorization", + in: "header", + required: true, + schema: { type: "string" }, + description: "Bearer bootstrap booking access token from /appointments/bootstrap-verify", + }, ], requestBody: { description: "New client appointment data", @@ -218,6 +286,7 @@ registerOpenAPIRoute("/tenants/{id}/appointments/create-new-client", "POST", { * * Creates a new client tunnel with their first appointment. * This handles the complete setup for new clients including tunnel creation. + * Requires bootstrap booking access token from bootstrap-verify endpoint. */ export const POST: RequestHandler = async ({ request, params }) => { try { @@ -226,8 +295,11 @@ export const POST: RequestHandler = async ({ request, params }) => { throw new ValidationError("Tenant ID is required"); } + const tokenPayload = await requireBootstrapBookingAccessToken(request, tenantId); + const body = await request.json(); const validatedData = requestSchema.parse(body); + validateBootstrapTokenBinding(tokenPayload, validatedData); if (validatedData.salutation) { throw new ValidationError("Bees incoming"); @@ -241,6 +313,8 @@ export const POST: RequestHandler = async ({ request, params }) => { const appointmentService = await AppointmentService.forTenant(tenantId); const response = await appointmentService.createNewClientWithAppointment(validatedData); + await consumeBookingAccessToken(tokenPayload); + return json(response); } catch (error) { logError(logger)("Failed to create new client appointment", error); diff --git a/src/routes/api/tenants/[id]/appointments/create-new-client/__tests__/create-new-client-api.test.ts b/src/routes/api/tenants/[id]/appointments/create-new-client/__tests__/create-new-client-api.test.ts index ffe01cb..c2be121 100644 --- a/src/routes/api/tenants/[id]/appointments/create-new-client/__tests__/create-new-client-api.test.ts +++ b/src/routes/api/tenants/[id]/appointments/create-new-client/__tests__/create-new-client-api.test.ts @@ -20,6 +20,12 @@ vi.mock("$lib/logger", () => ({ }, })); +vi.mock("$lib/server/auth/booking-access-token", () => ({ + NEW_CLIENT_BOOTSTRAP_SCOPE: "appointments:new-client-bootstrap", + verifyBookingAccessToken: vi.fn(), + consumeBookingAccessToken: vi.fn(), +})); + describe("Create New Client API Route", () => { const mockTenantId = "123e4567-e89b-12d3-a456-426614174000"; const mockTunnelId = "tunnel-123"; @@ -50,6 +56,14 @@ describe("Create New Client API Route", () => { clientEncryptedTunnelKey: "client-encrypted-tunnel-key", }; + const validTokenPayload = { + tenantId: mockTenantId, + tunnelId: mockTunnelId, + clientPublicKey: validRequestBody.clientPublicKey, + emailHash: validRequestBody.emailHash, + scope: "appointments:new-client-bootstrap", + }; + beforeEach(() => { vi.clearAllMocks(); }); @@ -62,6 +76,7 @@ describe("Create New Client API Route", () => { params: { id: mockTenantId }, request: { json: vi.fn().mockResolvedValue(body), + headers: new Headers({ Authorization: "Bearer valid-bootstrap-token" }), } as any, locals: { user: { @@ -78,6 +93,9 @@ describe("Create New Client API Route", () => { it("should return 200 when service successfully creates appointment", async () => { const { AppointmentService } = await import("$lib/server/services/appointment-service"); const { logger } = await import("$lib/logger"); + const { verifyBookingAccessToken, consumeBookingAccessToken } = await import( + "$lib/server/auth/booking-access-token" + ); // Mock successful service response const mockAppointment = { @@ -89,6 +107,7 @@ describe("Create New Client API Route", () => { createNewClientWithAppointment: vi.fn().mockResolvedValue(mockAppointment), }; vi.mocked(AppointmentService.forTenant).mockResolvedValue(mockService as any); + vi.mocked(verifyBookingAccessToken).mockResolvedValue(validTokenPayload as any); const event = createMockRequestEvent(); const response = await POST(event); @@ -97,6 +116,7 @@ describe("Create New Client API Route", () => { expect(response.status).toBe(200); expect(data).toEqual(mockAppointment); expect(mockService.createNewClientWithAppointment).toHaveBeenCalledWith(validRequestBody); + expect(consumeBookingAccessToken).toHaveBeenCalledOnce(); expect(logger.debug).toHaveBeenCalledWith("Creating new client appointment tunnel", { tenantId: mockTenantId, tunnelId: mockTunnelId, @@ -105,6 +125,7 @@ describe("Create New Client API Route", () => { it("should return 200 with CONFIRMED status when service returns it", async () => { const { AppointmentService } = await import("$lib/server/services/appointment-service"); + const { verifyBookingAccessToken } = await import("$lib/server/auth/booking-access-token"); const mockAppointment = { id: "appointment-123", @@ -115,6 +136,7 @@ describe("Create New Client API Route", () => { createNewClientWithAppointment: vi.fn().mockResolvedValue(mockAppointment), }; vi.mocked(AppointmentService.forTenant).mockResolvedValue(mockService as any); + vi.mocked(verifyBookingAccessToken).mockResolvedValue(validTokenPayload as any); const event = createMockRequestEvent(); const response = await POST(event); @@ -126,12 +148,30 @@ describe("Create New Client API Route", () => { }); describe("Validation Errors", () => { + it("should return 401 when bootstrap token is missing", async () => { + const event = createMockRequestEvent(validRequestBody, { + request: { + json: vi.fn().mockResolvedValue(validRequestBody), + headers: new Headers(), + } as any, + }); + + const response = await POST(event); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.error).toBe("Bootstrap booking access token is required"); + }); + it("should return 422 for invalid request data", async () => { + const { verifyBookingAccessToken } = await import("$lib/server/auth/booking-access-token"); const invalidBody = { tunnelId: mockTunnelId, // Missing required fields }; + vi.mocked(verifyBookingAccessToken).mockResolvedValue(validTokenPayload as any); + const event = createMockRequestEvent(invalidBody); const response = await POST(event); const data = await response.json(); @@ -141,6 +181,9 @@ describe("Create New Client API Route", () => { }); it("should return 422 for missing tenant ID", async () => { + const { verifyBookingAccessToken } = await import("$lib/server/auth/booking-access-token"); + vi.mocked(verifyBookingAccessToken).mockResolvedValue(validTokenPayload as any); + const event = createMockRequestEvent(validRequestBody, { params: {}, // No id parameter }); @@ -153,10 +196,14 @@ describe("Create New Client API Route", () => { }); it("should return 500 for invalid JSON in request", async () => { + const { verifyBookingAccessToken } = await import("$lib/server/auth/booking-access-token"); + vi.mocked(verifyBookingAccessToken).mockResolvedValue(validTokenPayload as any); + const event = { params: { id: mockTenantId }, request: { json: vi.fn().mockRejectedValue(new Error("Invalid JSON")), + headers: new Headers({ Authorization: "Bearer valid-bootstrap-token" }), }, } as any; @@ -172,6 +219,7 @@ describe("Create New Client API Route", () => { it("should return 409 when service throws ConflictError for no authorized users", async () => { const { AppointmentService } = await import("$lib/server/services/appointment-service"); const { logger } = await import("$lib/logger"); + const { verifyBookingAccessToken } = await import("$lib/server/auth/booking-access-token"); // Mock service to throw ConflictError (should return 409) const mockService = { @@ -184,6 +232,7 @@ describe("Create New Client API Route", () => { ), }; vi.mocked(AppointmentService.forTenant).mockResolvedValue(mockService as any); + vi.mocked(verifyBookingAccessToken).mockResolvedValue(validTokenPayload as any); const event = createMockRequestEvent(); const response = await POST(event); @@ -201,11 +250,13 @@ describe("Create New Client API Route", () => { it("should return 500 for service initialization errors", async () => { const { AppointmentService } = await import("$lib/server/services/appointment-service"); + const { verifyBookingAccessToken } = await import("$lib/server/auth/booking-access-token"); // Mock service initialization failure vi.mocked(AppointmentService.forTenant).mockRejectedValue( new Error("Database connection failed"), ); + vi.mocked(verifyBookingAccessToken).mockResolvedValue(validTokenPayload as any); const event = createMockRequestEvent(); const response = await POST(event); @@ -217,6 +268,7 @@ describe("Create New Client API Route", () => { it("should return 404 when service throws NotFoundError", async () => { const { AppointmentService } = await import("$lib/server/services/appointment-service"); + const { verifyBookingAccessToken } = await import("$lib/server/auth/booking-access-token"); const mockService = { createNewClientWithAppointment: vi @@ -224,6 +276,7 @@ describe("Create New Client API Route", () => { .mockRejectedValue(new NotFoundError("Channel not found")), }; vi.mocked(AppointmentService.forTenant).mockResolvedValue(mockService as any); + vi.mocked(verifyBookingAccessToken).mockResolvedValue(validTokenPayload as any); const event = createMockRequestEvent(); const response = await POST(event); @@ -238,6 +291,7 @@ describe("Create New Client API Route", () => { it("should log appointment creation attempts", async () => { const { AppointmentService } = await import("$lib/server/services/appointment-service"); const { logger } = await import("$lib/logger"); + const { verifyBookingAccessToken } = await import("$lib/server/auth/booking-access-token"); const mockService = { createNewClientWithAppointment: vi.fn().mockResolvedValue({ @@ -247,6 +301,7 @@ describe("Create New Client API Route", () => { }), }; vi.mocked(AppointmentService.forTenant).mockResolvedValue(mockService as any); + vi.mocked(verifyBookingAccessToken).mockResolvedValue(validTokenPayload as any); const event = createMockRequestEvent(); await POST(event); @@ -259,11 +314,13 @@ describe("Create New Client API Route", () => { it("should log errors when service fails", async () => { const { AppointmentService } = await import("$lib/server/services/appointment-service"); + const { verifyBookingAccessToken } = await import("$lib/server/auth/booking-access-token"); const mockService = { createNewClientWithAppointment: vi.fn().mockRejectedValue(new Error("Service error")), }; vi.mocked(AppointmentService.forTenant).mockResolvedValue(mockService as any); + vi.mocked(verifyBookingAccessToken).mockResolvedValue(validTokenPayload as any); const event = createMockRequestEvent(); await POST(event); @@ -271,5 +328,20 @@ describe("Create New Client API Route", () => { // Logger error calls are handled by logError function, so we just verify the endpoint doesn't crash expect(true).toBe(true); }); + + it("should return 403 when token binding does not match request", async () => { + const { verifyBookingAccessToken } = await import("$lib/server/auth/booking-access-token"); + vi.mocked(verifyBookingAccessToken).mockResolvedValue({ + ...validTokenPayload, + clientPublicKey: "other-client-key", + } as any); + + const event = createMockRequestEvent(); + const response = await POST(event); + const data = await response.json(); + + expect(response.status).toBe(403); + expect(data.error).toBe("Booking access token is not valid for this client key"); + }); }); }); diff --git a/src/routes/api/tenants/[id]/appointments/my-appointments/+server.ts b/src/routes/api/tenants/[id]/appointments/my-appointments/+server.ts index 9103b92..c90ff03 100644 --- a/src/routes/api/tenants/[id]/appointments/my-appointments/+server.ts +++ b/src/routes/api/tenants/[id]/appointments/my-appointments/+server.ts @@ -9,18 +9,29 @@ import type { RequestHandler } from "@sveltejs/kit"; import { json } from "@sveltejs/kit"; import { z } from "zod"; import { logger } from "$lib/logger"; -import { BackendError, InternalError, logError, ValidationError } from "$lib/server/utils/errors"; +import { + AuthenticationError, + AuthorizationError, + BackendError, + InternalError, + logError, + ValidationError, +} from "$lib/server/utils/errors"; import { AppointmentService } from "$lib/server/services/appointment-service"; import { getTenantDb } from "$lib/server/db"; import { clientAppointmentTunnel } from "$lib/server/db/tenant-schema"; import { eq } from "drizzle-orm"; import { registerOpenAPIRoute } from "$lib/server/openapi"; +import { + EXISTING_CLIENT_BOOKING_SCOPE, + verifyBookingAccessToken, +} from "$lib/server/auth/booking-access-token"; // Register OpenAPI documentation for GET registerOpenAPIRoute("/tenants/{id}/appointments/my-appointments", "GET", { summary: "Get client's future appointments", description: - "Returns all future appointments for a client. Requires the client to be authenticated via PIN challenge-response flow. The emailHash header must match the authenticated client.", + "Returns all future appointments for a client. Requires a valid existing-client booking access token and matching email hash.", tags: ["Appointments", "Clients"], parameters: [ { @@ -37,6 +48,13 @@ registerOpenAPIRoute("/tenants/{id}/appointments/my-appointments", "GET", { schema: { type: "string" }, description: "SHA-256 hash of client email for authentication", }, + { + name: "Authorization", + in: "header", + required: true, + schema: { type: "string" }, + description: "Bearer booking access token from /appointments/verify-challenge", + }, ], responses: { "200": { @@ -107,6 +125,22 @@ registerOpenAPIRoute("/tenants/{id}/appointments/my-appointments", "GET", { }, }, }, + "401": { + description: "Missing or invalid booking access token", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "403": { + description: "Booking access token does not match requested tenant or email hash", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, "404": { description: "Client tunnel not found", content: { @@ -132,7 +166,7 @@ const emailHashSchema = z.string().min(1); * GET /api/tenants/[id]/appointments/my-appointments * * Returns all future appointments for an authenticated client. - * The client must provide their email hash in the X-Email-Hash header. + * Requires existing-client booking token and matching email hash. */ export const GET: RequestHandler = async ({ request, params }) => { const log = logger.setContext("API.MyAppointments"); @@ -151,6 +185,33 @@ export const GET: RequestHandler = async ({ request, params }) => { const validatedEmailHash = emailHashSchema.parse(emailHash); + const authorizationHeader = request.headers.get("Authorization"); + if (!authorizationHeader?.startsWith("Bearer ")) { + throw new AuthenticationError("Booking access token is required"); + } + + const token = authorizationHeader.substring("Bearer ".length).trim(); + if (!token) { + throw new AuthenticationError("Booking access token is required"); + } + + const tokenPayload = await verifyBookingAccessToken(token); + if (!tokenPayload) { + throw new AuthenticationError("Invalid or expired booking access token"); + } + + if (tokenPayload.scope !== EXISTING_CLIENT_BOOKING_SCOPE) { + throw new AuthorizationError("Booking access token is not valid for this endpoint"); + } + + if (tokenPayload.tenantId !== tenantId) { + throw new AuthorizationError("Booking access token is not valid for this tenant"); + } + + if (tokenPayload.emailHash !== validatedEmailHash) { + throw new AuthorizationError("Booking access token is not valid for this email hash"); + } + log.debug("Fetching appointments for client", { tenantId, emailHashPrefix: validatedEmailHash.slice(0, 8), diff --git a/src/routes/api/tenants/[id]/appointments/my-appointments/__tests__/my-appointments.test.ts b/src/routes/api/tenants/[id]/appointments/my-appointments/__tests__/my-appointments.test.ts index 3714c21..800cf4d 100644 --- a/src/routes/api/tenants/[id]/appointments/my-appointments/__tests__/my-appointments.test.ts +++ b/src/routes/api/tenants/[id]/appointments/my-appointments/__tests__/my-appointments.test.ts @@ -24,6 +24,10 @@ vi.mock("$lib/server/db", () => ({ })); vi.mock("$lib/server/services/appointment-service"); +vi.mock("$lib/server/auth/booking-access-token", () => ({ + EXISTING_CLIENT_BOOKING_SCOPE: "appointments:client", + verifyBookingAccessToken: vi.fn(), +})); const mockTenantId = "tenant-123"; const mockEmailHash = "email-hash-abc123"; @@ -55,11 +59,14 @@ describe("GET /api/tenants/[id]/appointments/my-appointments", () => { vi.clearAllMocks(); }); - const createMockRequest = (emailHash?: string): RequestEvent => { + const createMockRequest = (emailHash?: string, authorization?: string): RequestEvent => { const headers = new Headers(); if (emailHash) { headers.set("X-Email-Hash", emailHash); } + if (authorization) { + headers.set("Authorization", authorization); + } return { request: { @@ -72,6 +79,14 @@ describe("GET /api/tenants/[id]/appointments/my-appointments", () => { it("should return future appointments for authenticated client", async () => { const { getTenantDb } = await import("$lib/server/db"); const { AppointmentService } = await import("$lib/server/services/appointment-service"); + const { verifyBookingAccessToken } = await import("$lib/server/auth/booking-access-token"); + + vi.mocked(verifyBookingAccessToken).mockResolvedValue({ + tenantId: mockTenantId, + tunnelId: mockTunnelId, + emailHash: mockEmailHash, + scope: "appointments:client", + } as any); // Mock database query for tunnel const mockDb = { @@ -91,7 +106,7 @@ describe("GET /api/tenants/[id]/appointments/my-appointments", () => { }; vi.mocked(AppointmentService.forTenant).mockResolvedValue(mockService as any); - const event = createMockRequest(mockEmailHash); + const event = createMockRequest(mockEmailHash, "Bearer valid-booking-token"); const response = await GET(event); const data = await response.json(); @@ -114,6 +129,14 @@ describe("GET /api/tenants/[id]/appointments/my-appointments", () => { it("should return empty array when client has no future appointments", async () => { const { getTenantDb } = await import("$lib/server/db"); const { AppointmentService } = await import("$lib/server/services/appointment-service"); + const { verifyBookingAccessToken } = await import("$lib/server/auth/booking-access-token"); + + vi.mocked(verifyBookingAccessToken).mockResolvedValue({ + tenantId: mockTenantId, + tunnelId: mockTunnelId, + emailHash: mockEmailHash, + scope: "appointments:client", + } as any); const mockDb = { select: vi.fn().mockReturnValue({ @@ -131,7 +154,7 @@ describe("GET /api/tenants/[id]/appointments/my-appointments", () => { }; vi.mocked(AppointmentService.forTenant).mockResolvedValue(mockService as any); - const event = createMockRequest(mockEmailHash); + const event = createMockRequest(mockEmailHash, "Bearer valid-booking-token"); const response = await GET(event); const data = await response.json(); @@ -139,8 +162,25 @@ describe("GET /api/tenants/[id]/appointments/my-appointments", () => { expect(data.appointments).toHaveLength(0); }); + it("should return 401 when Authorization header is missing", async () => { + const event = createMockRequest(mockEmailHash); + const response = await GET(event); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.error).toBe("Booking access token is required"); + }); + it("should return 422 when X-Email-Hash header is missing", async () => { - const event = createMockRequest(); + const { verifyBookingAccessToken } = await import("$lib/server/auth/booking-access-token"); + vi.mocked(verifyBookingAccessToken).mockResolvedValue({ + tenantId: mockTenantId, + tunnelId: mockTunnelId, + emailHash: mockEmailHash, + scope: "appointments:client", + } as any); + + const event = createMockRequest(undefined, "Bearer valid-booking-token"); const response = await GET(event); const data = await response.json(); @@ -149,9 +189,20 @@ describe("GET /api/tenants/[id]/appointments/my-appointments", () => { }); it("should return 422 when tenant ID is missing", async () => { + const { verifyBookingAccessToken } = await import("$lib/server/auth/booking-access-token"); + vi.mocked(verifyBookingAccessToken).mockResolvedValue({ + tenantId: mockTenantId, + tunnelId: mockTunnelId, + emailHash: mockEmailHash, + scope: "appointments:client", + } as any); + const event = { request: { - headers: new Headers({ "X-Email-Hash": mockEmailHash }), + headers: new Headers({ + "X-Email-Hash": mockEmailHash, + Authorization: "Bearer valid-booking-token", + }), } as Request, params: { id: undefined }, } as any; @@ -165,6 +216,14 @@ describe("GET /api/tenants/[id]/appointments/my-appointments", () => { it("should return 422 when client tunnel is not found", async () => { const { getTenantDb } = await import("$lib/server/db"); + const { verifyBookingAccessToken } = await import("$lib/server/auth/booking-access-token"); + + vi.mocked(verifyBookingAccessToken).mockResolvedValue({ + tenantId: mockTenantId, + tunnelId: mockTunnelId, + emailHash: mockEmailHash, + scope: "appointments:client", + } as any); const mockDb = { select: vi.fn().mockReturnValue({ @@ -177,7 +236,7 @@ describe("GET /api/tenants/[id]/appointments/my-appointments", () => { }; vi.mocked(getTenantDb).mockResolvedValue(mockDb as any); - const event = createMockRequest(mockEmailHash); + const event = createMockRequest(mockEmailHash, "Bearer valid-booking-token"); const response = await GET(event); const data = await response.json(); @@ -186,7 +245,15 @@ describe("GET /api/tenants/[id]/appointments/my-appointments", () => { }); it("should return 422 when email hash is empty", async () => { - const event = createMockRequest(""); + const { verifyBookingAccessToken } = await import("$lib/server/auth/booking-access-token"); + vi.mocked(verifyBookingAccessToken).mockResolvedValue({ + tenantId: mockTenantId, + tunnelId: mockTunnelId, + emailHash: mockEmailHash, + scope: "appointments:client", + } as any); + + const event = createMockRequest("", "Bearer valid-booking-token"); const response = await GET(event); const data = await response.json(); @@ -196,10 +263,18 @@ describe("GET /api/tenants/[id]/appointments/my-appointments", () => { it("should handle database errors gracefully", async () => { const { getTenantDb } = await import("$lib/server/db"); + const { verifyBookingAccessToken } = await import("$lib/server/auth/booking-access-token"); + + vi.mocked(verifyBookingAccessToken).mockResolvedValue({ + tenantId: mockTenantId, + tunnelId: mockTunnelId, + emailHash: mockEmailHash, + scope: "appointments:client", + } as any); vi.mocked(getTenantDb).mockRejectedValue(new Error("Database connection failed")); - const event = createMockRequest(mockEmailHash); + const event = createMockRequest(mockEmailHash, "Bearer valid-booking-token"); const response = await GET(event); const data = await response.json(); @@ -210,6 +285,14 @@ describe("GET /api/tenants/[id]/appointments/my-appointments", () => { it("should handle service errors gracefully", async () => { const { getTenantDb } = await import("$lib/server/db"); const { AppointmentService } = await import("$lib/server/services/appointment-service"); + const { verifyBookingAccessToken } = await import("$lib/server/auth/booking-access-token"); + + vi.mocked(verifyBookingAccessToken).mockResolvedValue({ + tenantId: mockTenantId, + tunnelId: mockTunnelId, + emailHash: mockEmailHash, + scope: "appointments:client", + } as any); const mockDb = { select: vi.fn().mockReturnValue({ @@ -227,11 +310,28 @@ describe("GET /api/tenants/[id]/appointments/my-appointments", () => { }; vi.mocked(AppointmentService.forTenant).mockResolvedValue(mockService as any); - const event = createMockRequest(mockEmailHash); + const event = createMockRequest(mockEmailHash, "Bearer valid-booking-token"); const response = await GET(event); const data = await response.json(); expect(response.status).toBe(500); expect(data.error).toBeDefined(); }); + + it("should return 403 when token email hash does not match header", async () => { + const { verifyBookingAccessToken } = await import("$lib/server/auth/booking-access-token"); + vi.mocked(verifyBookingAccessToken).mockResolvedValue({ + tenantId: mockTenantId, + tunnelId: mockTunnelId, + emailHash: "other-email-hash", + scope: "appointments:client", + } as any); + + const event = createMockRequest(mockEmailHash, "Bearer valid-booking-token"); + const response = await GET(event); + const data = await response.json(); + + expect(response.status).toBe(403); + expect(data.error).toBe("Booking access token is not valid for this email hash"); + }); }); diff --git a/src/routes/api/tenants/[id]/appointments/staff-public-keys/+server.ts b/src/routes/api/tenants/[id]/appointments/staff-public-keys/+server.ts index a9e6efc..35451cd 100644 --- a/src/routes/api/tenants/[id]/appointments/staff-public-keys/+server.ts +++ b/src/routes/api/tenants/[id]/appointments/staff-public-keys/+server.ts @@ -1,14 +1,22 @@ import { json, type RequestHandler } from "@sveltejs/kit"; import { logger } from "$lib/logger"; import { StaffCryptoService } from "$lib/server/services/staff-crypto.service"; -import { BackendError, InternalError, logError, ValidationError } from "$lib/server/utils/errors"; +import { + AuthenticationError, + AuthorizationError, + BackendError, + InternalError, + logError, + ValidationError, +} from "$lib/server/utils/errors"; import { registerOpenAPIRoute } from "$lib/server/openapi"; +import { verifyBookingAccessToken } from "$lib/server/auth/booking-access-token"; // Register OpenAPI documentation for GET registerOpenAPIRoute("/tenants/{id}/appointments/staff-public-keys", "GET", { summary: "Get staff public keys", description: - "Returns public encryption keys for all staff members. Used by clients to encrypt appointment data for staff access. This is a public endpoint that doesn't require authentication.", + "Returns public encryption keys for all staff members. Requires a valid short-lived booking access token from /appointments/verify-challenge or /appointments/bootstrap-verify.", tags: ["Appointments", "Encryption"], parameters: [ { @@ -18,6 +26,14 @@ registerOpenAPIRoute("/tenants/{id}/appointments/staff-public-keys", "GET", { schema: { type: "string", format: "uuid" }, description: "Tenant ID", }, + { + name: "Authorization", + in: "header", + required: true, + schema: { type: "string" }, + description: + "Bearer booking access token from /appointments/verify-challenge or /appointments/bootstrap-verify", + }, ], responses: { "200": { @@ -61,6 +77,22 @@ registerOpenAPIRoute("/tenants/{id}/appointments/staff-public-keys", "GET", { }, }, }, + "401": { + description: "Missing or invalid booking access token", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "403": { + description: "Booking access token does not match requested tenant", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, "500": { description: "Internal server error or no staff keys found", content: { @@ -76,15 +108,34 @@ registerOpenAPIRoute("/tenants/{id}/appointments/staff-public-keys", "GET", { * GET /api/tenants/[id]/appointments/staff-public-keys * * Returns the public keys of all staff members for encryption. - * This is a public endpoint used by clients to encrypt appointment data. + * Requires booking access token from verify-challenge or bootstrap-verify endpoint. */ -export const GET: RequestHandler = async ({ params }) => { +export const GET: RequestHandler = async ({ params, request }) => { try { const tenantId = params.id; if (!tenantId) { throw new ValidationError("Tenant ID is required"); } + const authorizationHeader = request.headers.get("Authorization"); + if (!authorizationHeader?.startsWith("Bearer ")) { + throw new AuthenticationError("Booking access token is required"); + } + + const token = authorizationHeader.substring("Bearer ".length).trim(); + if (!token) { + throw new AuthenticationError("Booking access token is required"); + } + + const tokenPayload = await verifyBookingAccessToken(token); + if (!tokenPayload) { + throw new AuthenticationError("Invalid or expired booking access token"); + } + + if (tokenPayload.tenantId !== tenantId) { + throw new AuthorizationError("Booking access token is not valid for this tenant"); + } + logger.info("Fetching staff public keys", { tenantId }); // Get staff public keys for encryption diff --git a/src/routes/api/tenants/[id]/appointments/staff-public-keys/__tests__/staff-public-keys-api.test.ts b/src/routes/api/tenants/[id]/appointments/staff-public-keys/__tests__/staff-public-keys-api.test.ts new file mode 100644 index 0000000..c879ef1 --- /dev/null +++ b/src/routes/api/tenants/[id]/appointments/staff-public-keys/__tests__/staff-public-keys-api.test.ts @@ -0,0 +1,131 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { RequestEvent } from "@sveltejs/kit"; +import { GET } from "../+server"; + +vi.mock("$lib/logger", () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +vi.mock("$lib/server/services/staff-crypto.service", () => ({ + StaffCryptoService: vi.fn(() => ({ + getStaffPublicKeys: vi.fn(), + })), +})); + +vi.mock("$lib/server/auth/booking-access-token", () => ({ + verifyBookingAccessToken: vi.fn(), +})); + +import { StaffCryptoService } from "$lib/server/services/staff-crypto.service"; +import { verifyBookingAccessToken } from "$lib/server/auth/booking-access-token"; + +describe("Staff Public Keys API", () => { + const tenantId = "123e4567-e89b-12d3-a456-426614174000"; + + const serviceMock = { + getStaffPublicKeys: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + (StaffCryptoService as any).mockImplementation(() => serviceMock); + }); + + function createEvent(authorization?: string): RequestEvent { + return { + params: { id: tenantId }, + request: { + headers: new Headers(authorization ? { Authorization: authorization } : {}), + } as any, + locals: { user: null } as any, + } as RequestEvent; + } + + it("returns 401 when booking access token is missing", async () => { + const response = await GET(createEvent()); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.error).toBe("Booking access token is required"); + }); + + it("returns 401 for invalid booking access token", async () => { + (verifyBookingAccessToken as any).mockResolvedValue(null); + + const response = await GET(createEvent("Bearer invalid")); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.error).toBe("Invalid or expired booking access token"); + }); + + it("returns 403 when token tenant does not match request tenant", async () => { + (verifyBookingAccessToken as any).mockResolvedValue({ + tenantId: "different-tenant", + emailHash: "email-hash", + tunnelId: "tunnel-id", + scope: "appointments:client", + }); + + const response = await GET(createEvent("Bearer valid-token")); + const data = await response.json(); + + expect(response.status).toBe(403); + expect(data.error).toBe("Booking access token is not valid for this tenant"); + }); + + it("returns staff public keys for valid token", async () => { + (verifyBookingAccessToken as any).mockResolvedValue({ + tenantId, + emailHash: "email-hash", + tunnelId: "tunnel-id", + scope: "appointments:client", + }); + + const staffPublicKeys = [ + { + userId: "550e8400-e29b-41d4-a716-446655440111", + publicKey: "base64-public-key", + }, + ]; + + serviceMock.getStaffPublicKeys.mockResolvedValue(staffPublicKeys); + + const response = await GET(createEvent("Bearer valid-token")); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.staffPublicKeys).toEqual(staffPublicKeys); + expect(serviceMock.getStaffPublicKeys).toHaveBeenCalledWith(tenantId); + }); + + it("accepts valid bootstrap-scope booking token", async () => { + (verifyBookingAccessToken as any).mockResolvedValue({ + tenantId, + tunnelId: "tunnel-id", + clientPublicKey: "client-public-key", + scope: "appointments:new-client-bootstrap", + }); + + const staffPublicKeys = [ + { + userId: "550e8400-e29b-41d4-a716-446655440111", + publicKey: "base64-public-key", + }, + ]; + + serviceMock.getStaffPublicKeys.mockResolvedValue(staffPublicKeys); + + const response = await GET(createEvent("Bearer bootstrap-token")); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.staffPublicKeys).toEqual(staffPublicKeys); + }); +}); diff --git a/src/routes/api/tenants/[id]/appointments/verify-challenge/+server.ts b/src/routes/api/tenants/[id]/appointments/verify-challenge/+server.ts index 3b3162e..9e42657 100644 --- a/src/routes/api/tenants/[id]/appointments/verify-challenge/+server.ts +++ b/src/routes/api/tenants/[id]/appointments/verify-challenge/+server.ts @@ -16,6 +16,7 @@ import { } from "$lib/server/utils/errors"; import { registerOpenAPIRoute } from "$lib/server/openapi"; import { challengeThrottleService } from "$lib/server/services/challenge-throttle"; +import { generateBookingAccessToken } from "$lib/server/auth/booking-access-token"; const requestSchema = z.object({ challengeId: z.string(), @@ -84,8 +85,13 @@ registerOpenAPIRoute("/tenants/{id}/appointments/verify-challenge", "POST", { description: "Client tunnel identifier for accessing appointments", example: "550e8400-e29b-41d4-a716-446655440000", }, + bookingAccessToken: { + type: "string", + description: + "Short-lived token for authenticated client booking operations (e.g. fetching staff public keys)", + }, }, - required: ["valid", "encryptedTunnelKey", "tunnelId"], + required: ["valid", "encryptedTunnelKey", "tunnelId", "bookingAccessToken"], }, }, }, @@ -196,6 +202,12 @@ export const POST: RequestHandler = async ({ request, params }) => { tunnelId: tunnel.id, }); + const bookingAccessToken = await generateBookingAccessToken({ + tenantId, + emailHash: storedChallenge.emailHash, + tunnelId: tunnel.id, + }); + // Clear throttle on successful verification await challengeThrottleService.clearThrottle(storedChallenge.emailHash, "pin"); @@ -203,6 +215,7 @@ export const POST: RequestHandler = async ({ request, params }) => { valid: true, encryptedTunnelKey: tunnel.clientEncryptedTunnelKey, tunnelId: tunnel.id, + bookingAccessToken, }; logger.debug("Successfully verified challenge", { diff --git a/src/server-hooks/apiAuthHandle.test.ts b/src/server-hooks/apiAuthHandle.test.ts new file mode 100644 index 0000000..ed21747 --- /dev/null +++ b/src/server-hooks/apiAuthHandle.test.ts @@ -0,0 +1,79 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { RequestEvent } from "@sveltejs/kit"; +import { apiAuthHandle } from "./apiAuthHandle"; + +vi.mock("$lib/server/auth/session-service", () => ({ + SessionService: { + validateTokenWithDB: vi.fn(), + }, +})); + +vi.mock("$lib/server/auth/authorization-service", () => ({ + AuthorizationService: { + hasRole: vi.fn(), + hasAnyRole: vi.fn(), + }, +})); + +import { SessionService } from "$lib/server/auth/session-service"; + +function createEvent(path: string, opts?: { bearer?: string; cookieToken?: string }): RequestEvent { + const headers = new Headers(); + if (opts?.bearer) { + headers.set("authorization", `Bearer ${opts.bearer}`); + } + + return { + url: new URL(`http://localhost${path}`), + request: new Request(`http://localhost${path}`, { headers }), + cookies: { + get: vi.fn((name: string) => { + if (name === "access_token") { + return opts?.cookieToken ?? undefined; + } + return undefined; + }), + } as any, + locals: {}, + } as RequestEvent; +} + +describe("apiAuthHandle", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("skips session token validation for public client appointment route with bearer booking token", async () => { + const resolve = vi.fn(async () => new Response("ok", { status: 200 })); + const event = createEvent( + "/api/tenants/901d0d2a-4bf1-4755-99f2-9e1b7e11486b/appointments/staff-public-keys", + { + bearer: "booking-token", + }, + ); + + const response = await apiAuthHandle({ event, resolve }); + + expect(response.status).toBe(200); + expect(resolve).toHaveBeenCalledOnce(); + expect(SessionService.validateTokenWithDB).not.toHaveBeenCalled(); + }); + + it("still validates session token for non-public route", async () => { + vi.mocked(SessionService.validateTokenWithDB).mockResolvedValue(null); + + const resolve = vi.fn(async () => new Response("ok", { status: 200 })); + const event = createEvent("/api/tenants/901d0d2a-4bf1-4755-99f2-9e1b7e11486b/users", { + bearer: "not-a-session-token", + }); + + const response = await apiAuthHandle({ event, resolve }); + const body = await response.json(); + + expect(response.status).toBe(401); + expect(body.error).toBe("Invalid or expired access token"); + expect(SessionService.validateTokenWithDB).toHaveBeenCalledOnce(); + expect(resolve).not.toHaveBeenCalled(); + }); +}); diff --git a/src/server-hooks/apiAuthHandle.ts b/src/server-hooks/apiAuthHandle.ts index b75bdb0..a3d2456 100644 --- a/src/server-hooks/apiAuthHandle.ts +++ b/src/server-hooks/apiAuthHandle.ts @@ -9,6 +9,21 @@ const logger = new UniversalLogger().setContext("AuthHandle"); const GLOBAL_ADMIN_PATHS = ["/api/admin"]; +const CLIENT_APPOINTMENT_PUBLIC_ROUTE_PATTERNS: RegExp[] = [ + /^\/api\/tenants\/[^/]+\/appointments\/challenge$/, + /^\/api\/tenants\/[^/]+\/appointments\/verify-challenge$/, + /^\/api\/tenants\/[^/]+\/appointments\/bootstrap-challenge$/, + /^\/api\/tenants\/[^/]+\/appointments\/bootstrap-verify$/, + /^\/api\/tenants\/[^/]+\/appointments\/staff-public-keys$/, + /^\/api\/tenants\/[^/]+\/appointments\/create-new-client$/, + /^\/api\/tenants\/[^/]+\/appointments\/add-to-tunnel$/, + /^\/api\/tenants\/[^/]+\/appointments\/my-appointments$/, + /^\/api\/tenants\/[^/]+\/appointments\/[^/]+\/delete-by-client$/, +]; + +const isClientAppointmentPublicRoute = (path: string): boolean => + CLIENT_APPOINTMENT_PUBLIC_ROUTE_PATTERNS.some((pattern) => pattern.test(path)); + export const apiAuthHandle: Handle = async ({ event, resolve }) => { const { url } = event; const path = url.pathname; @@ -29,6 +44,11 @@ export const apiAuthHandle: Handle = async ({ event, resolve }) => { const isGlobalAdminPath = GLOBAL_ADMIN_PATHS.some((gadPath) => path.startsWith(gadPath)); const isAdminPath = false; + const hasSessionCookie = Boolean(event.cookies.get("access_token")); + if (isClientAppointmentPublicRoute(path) && !hasSessionCookie) { + return resolve(event); + } + const accessToken: string | null = getAccessToken(event); let sessionData: { user: SelectUser; diff --git a/tenant-migrations/0013_youthful_grim_reaper.sql b/tenant-migrations/0013_youthful_grim_reaper.sql new file mode 100644 index 0000000..ced14b9 --- /dev/null +++ b/tenant-migrations/0013_youthful_grim_reaper.sql @@ -0,0 +1,11 @@ +CREATE TABLE "booking_access_token" ( + "id" text PRIMARY KEY NOT NULL, + "scope" text NOT NULL, + "tenant_id" uuid NOT NULL, + "email_hash" text, + "tunnel_id" uuid NOT NULL, + "client_public_key" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "expires_at" timestamp NOT NULL, + "consumed" boolean DEFAULT false NOT NULL +); diff --git a/tenant-migrations/meta/0013_snapshot.json b/tenant-migrations/meta/0013_snapshot.json new file mode 100644 index 0000000..82c68f6 --- /dev/null +++ b/tenant-migrations/meta/0013_snapshot.json @@ -0,0 +1,967 @@ +{ + "id": "726f8502-95ab-458f-83e5-9897a8b46a03", + "prevId": "a189259c-e77c-4561-8a6c-c2f2fe5c9eb4", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.agent": { + "name": "agent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "descriptions": { + "name": "descriptions", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "varchar(250000)", + "primaryKey": false, + "notNull": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_absence": { + "name": "agent_absence", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "start_date": { + "name": "start_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "end_date": { + "name": "end_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "absence_type": { + "name": "absence_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_absence_agent_id_agent_id_fk": { + "name": "agent_absence_agent_id_agent_id_fk", + "tableFrom": "agent_absence", + "tableTo": "agent", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.appointment": { + "name": "appointment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tunnel_id": { + "name": "tunnel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "appointment_date": { + "name": "appointment_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "expiry_date": { + "name": "expiry_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "appointment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "encrypted_data": { + "name": "encrypted_data", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "data_key": { + "name": "data_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_payload": { + "name": "encrypted_payload", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "iv": { + "name": "iv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_tag": { + "name": "auth_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "appointment_tunnel_id_client_appointment_tunnel_id_fk": { + "name": "appointment_tunnel_id_client_appointment_tunnel_id_fk", + "tableFrom": "appointment", + "tableTo": "client_appointment_tunnel", + "columnsFrom": ["tunnel_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "appointment_channel_id_channel_id_fk": { + "name": "appointment_channel_id_channel_id_fk", + "tableFrom": "appointment", + "tableTo": "channel", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "appointment_agent_id_agent_id_fk": { + "name": "appointment_agent_id_agent_id_fk", + "tableFrom": "appointment", + "tableTo": "agent", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.appointment_key_share": { + "name": "appointment_key_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "appointment_id": { + "name": "appointment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "appointment_key_share_appointment_id_appointment_id_fk": { + "name": "appointment_key_share_appointment_id_appointment_id_fk", + "tableFrom": "appointment_key_share", + "tableTo": "appointment", + "columnsFrom": ["appointment_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_challenge": { + "name": "auth_challenge", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "challenge": { + "name": "challenge", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_hash": { + "name": "email_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "consumed": { + "name": "consumed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.booking_access_token": { + "name": "booking_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email_hash": { + "name": "email_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tunnel_id": { + "name": "tunnel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "client_public_key": { + "name": "client_public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "consumed": { + "name": "consumed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel": { + "name": "channel", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "names": { + "name": "names", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "paused": { + "name": "paused", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "descriptions": { + "name": "descriptions", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "requires_confirmation": { + "name": "requires_confirmation", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_agent": { + "name": "channel_agent", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "channel_agent_channel_id_channel_id_fk": { + "name": "channel_agent_channel_id_channel_id_fk", + "tableFrom": "channel_agent", + "tableTo": "channel", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "channel_agent_agent_id_agent_id_fk": { + "name": "channel_agent_agent_id_agent_id_fk", + "tableFrom": "channel_agent", + "tableTo": "agent", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_slot_template": { + "name": "channel_slot_template", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slot_template_id": { + "name": "slot_template_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "channel_slot_template_channel_id_channel_id_fk": { + "name": "channel_slot_template_channel_id_channel_id_fk", + "tableFrom": "channel_slot_template", + "tableTo": "channel", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "channel_slot_template_slot_template_id_slotTemplate_id_fk": { + "name": "channel_slot_template_slot_template_id_slotTemplate_id_fk", + "tableFrom": "channel_slot_template", + "tableTo": "slotTemplate", + "columnsFrom": ["slot_template_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_staff": { + "name": "channel_staff", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "staff_id": { + "name": "staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "channel_staff_channel_id_channel_id_fk": { + "name": "channel_staff_channel_id_channel_id_fk", + "tableFrom": "channel_staff", + "tableTo": "channel", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_appointment_tunnel": { + "name": "client_appointment_tunnel", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email_hash": { + "name": "email_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_public_key": { + "name": "client_public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key_share": { + "name": "private_key_share", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_key_share": { + "name": "client_key_share", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "client_appointment_tunnel_email_hash_unique": { + "name": "client_appointment_tunnel_email_hash_unique", + "nullsNotDistinct": false, + "columns": ["email_hash"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_pin_reset_token": { + "name": "client_pin_reset_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token": { + "name": "token", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email_hash": { + "name": "email_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "used": { + "name": "used", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "client_pin_reset_token_token_unique": { + "name": "client_pin_reset_token_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_tunnel_staff_key_share": { + "name": "client_tunnel_staff_key_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tunnel_id": { + "name": "tunnel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "encrypted_tunnel_key": { + "name": "encrypted_tunnel_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "client_tunnel_staff_key_share_tunnel_id_client_appointment_tunnel_id_fk": { + "name": "client_tunnel_staff_key_share_tunnel_id_client_appointment_tunnel_id_fk", + "tableFrom": "client_tunnel_staff_key_share", + "tableTo": "client_appointment_tunnel", + "columnsFrom": ["tunnel_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification": { + "name": "notification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "staff_id": { + "name": "staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'APPOINTMENT_CONFIRMED'" + }, + "meta_data": { + "name": "meta_data", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_read": { + "name": "is_read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slotTemplate": { + "name": "slotTemplate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "weekdays": { + "name": "weekdays", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "from": { + "name": "from", + "type": "time", + "primaryKey": false, + "notNull": true + }, + "to": { + "name": "to", + "type": "time", + "primaryKey": false, + "notNull": true + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.staff_crypto": { + "name": "staff_crypto", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key_share": { + "name": "private_key_share", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "passkey_id": { + "name": "passkey_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.appointment_status": { + "name": "appointment_status", + "schema": "public", + "values": ["NEW", "CONFIRMED", "HELD", "REJECTED", "NO_SHOW"] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": ["APPOINTMENT_CONFIRMED", "APPOINTMENT_CANCELLED", "APPOINTMENT_REQUESTED"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/tenant-migrations/meta/_journal.json b/tenant-migrations/meta/_journal.json index f6f3b09..312ab27 100644 --- a/tenant-migrations/meta/_journal.json +++ b/tenant-migrations/meta/_journal.json @@ -92,6 +92,13 @@ "when": 1769078158060, "tag": "0012_military_puppet_master", "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1773231146366, + "tag": "0013_youthful_grim_reaper", + "breakpoints": true } ] }