From 78dfd9317a0be0897e6e4d73afe670c07a75460f Mon Sep 17 00:00:00 2001 From: Hendrik Belitz Date: Fri, 8 May 2026 10:47:08 +0200 Subject: [PATCH] Mitigate account takeover in user registration process Co-authored-by: Copilot --- src/lib/client/appointment-crypto.ts | 2 + src/lib/server/db/tenant-schema.ts | 41 +- src/lib/utils/passkey.ts | 14 + .../setup-passkey/setup-passkey-form.svelte | 9 +- .../[id]/staff/[staffId]/crypto/+server.ts | 83 +- tenant-migrations/0015_strange_warbird.sql | 1 + tenant-migrations/meta/0015_snapshot.json | 1053 +++++++++++++++++ tenant-migrations/meta/_journal.json | 9 +- 8 files changed, 1184 insertions(+), 28 deletions(-) create mode 100644 tenant-migrations/0015_strange_warbird.sql create mode 100644 tenant-migrations/meta/0015_snapshot.json diff --git a/src/lib/client/appointment-crypto.ts b/src/lib/client/appointment-crypto.ts index 546e3b9..143c825 100644 --- a/src/lib/client/appointment-crypto.ts +++ b/src/lib/client/appointment-crypto.ts @@ -994,6 +994,7 @@ export class UnifiedAppointmentCrypto { staffId: string, passkeyId: string, prfOutput: ArrayBuffer, + email: string, keyPair: { publicKey: Uint8Array; privateKey: Uint8Array }, ): Promise { // Derive passkey-based shard from PRF output @@ -1013,6 +1014,7 @@ export class UnifiedAppointmentCrypto { passkeyId, publicKey: this.uint8ArrayToBase64(keyPair.publicKey), privateKeyShare: this.uint8ArrayToBase64(dbShard), + email, }), }); diff --git a/src/lib/server/db/tenant-schema.ts b/src/lib/server/db/tenant-schema.ts index 2f345bb..090885d 100644 --- a/src/lib/server/db/tenant-schema.ts +++ b/src/lib/server/db/tenant-schema.ts @@ -9,6 +9,7 @@ import { text, time, timestamp, + uniqueIndex, uuid, varchar, } from "drizzle-orm/pg-core"; @@ -289,24 +290,28 @@ export type SelectNotification = InferSelectModel; * Enables end-to-end encryption for appointments in tenant database * @table staffCrypto */ -export const staffCrypto = pgTable("staff_crypto", { - /** Primary key - unique identifier */ - id: uuid("id").primaryKey().defaultRandom(), - /** Foreign key to central user table */ - userId: uuid("user_id").notNull(), - /** ML-KEM-768 (Kyber) public key for this staff member (Base64 encoded) */ - publicKey: text("public_key").notNull(), - /** Database-stored shard of the private key (Base64 encoded) */ - privateKeyShare: text("private_key_share").notNull(), - /** Associated passkey ID for key derivation */ - passkeyId: text("passkey_id").notNull(), - /** Timestamp when the key was created */ - createdAt: timestamp("created_at").defaultNow().notNull(), - /** Timestamp when the key was last updated */ - updatedAt: timestamp("updated_at").defaultNow().notNull(), - /** Whether this key is currently active */ - isActive: boolean("is_active").default(true).notNull(), -}); +export const staffCrypto = pgTable( + "staff_crypto", + { + /** Primary key - unique identifier */ + id: uuid("id").primaryKey().defaultRandom(), + /** Foreign key to central user table */ + userId: uuid("user_id").notNull(), + /** ML-KEM-768 (Kyber) public key for this staff member (Base64 encoded) */ + publicKey: text("public_key").notNull(), + /** Database-stored shard of the private key (Base64 encoded) */ + privateKeyShare: text("private_key_share").notNull(), + /** Associated passkey ID for key derivation */ + passkeyId: text("passkey_id").notNull(), + /** Timestamp when the key was created */ + createdAt: timestamp("created_at").defaultNow().notNull(), + /** Timestamp when the key was last updated */ + updatedAt: timestamp("updated_at").defaultNow().notNull(), + /** Whether this key is currently active */ + isActive: boolean("is_active").default(true).notNull(), + }, + (table) => [uniqueIndex("staff_crypto_ua_idx").on(table.userId, table.isActive)], +); /** * ClientAppointmentTunnels table - represents encrypted appointment tunnels for clients diff --git a/src/lib/utils/passkey.ts b/src/lib/utils/passkey.ts index 5222e8f..04be6c3 100644 --- a/src/lib/utils/passkey.ts +++ b/src/lib/utils/passkey.ts @@ -1,5 +1,11 @@ import logger from "$lib/logger"; +type WebAuthnAllowCredential = { + id: string; + type: "public-key"; + transports?: AuthenticatorTransport[]; +}; + export const arrayBufferToBase64 = (buffer: ArrayBuffer): string => { try { const bytes = new Uint8Array(buffer); @@ -81,6 +87,7 @@ export const fetchChallenge = async (email: string) => { return { id: data.rpId, challenge: data.challenge, + allowCredentials: data.allowCredentials, }; }; @@ -252,11 +259,13 @@ export const getCredential = async ({ challenge, email, enablePRF = false, + allowCredentials, }: { id: string; challenge: string; email: string; enablePRF?: boolean; + allowCredentials?: WebAuthnAllowCredential[]; }): Promise => { // Build WebAuthn options manually to support PRF const challengeBuffer = base64UrlToArrayBuffer(challenge); @@ -265,6 +274,11 @@ export const getCredential = async ({ challenge: challengeBuffer, rpId: id, userVerification: "preferred", + allowCredentials: allowCredentials?.map((credential) => ({ + id: base64UrlToArrayBuffer(credential.id), + type: credential.type, + transports: credential.transports, + })), }; // Add PRF extension if enabled diff --git a/src/routes/(pages)/confirm/setup-passkey/setup-passkey-form.svelte b/src/routes/(pages)/confirm/setup-passkey/setup-passkey-form.svelte index 58fadcb..6cfac63 100644 --- a/src/routes/(pages)/confirm/setup-passkey/setup-passkey-form.svelte +++ b/src/routes/(pages)/confirm/setup-passkey/setup-passkey-form.svelte @@ -196,7 +196,14 @@ if (tenantId && passkeyId && prfOutput && kyberKeyPair) { const crypto = new UnifiedAppointmentCrypto(); return await crypto - .storeStaffKeyPair(tenantId, $formData.userId, passkeyId, prfOutput, kyberKeyPair) + .storeStaffKeyPair( + tenantId, + $formData.userId, + passkeyId, + prfOutput, + $formData.email, + kyberKeyPair, + ) .then(() => { toast.success(m["setupPasskey.successKeyPairSaved"]()); }) diff --git a/src/routes/api/tenants/[id]/staff/[staffId]/crypto/+server.ts b/src/routes/api/tenants/[id]/staff/[staffId]/crypto/+server.ts index 11edfef..6544383 100644 --- a/src/routes/api/tenants/[id]/staff/[staffId]/crypto/+server.ts +++ b/src/routes/api/tenants/[id]/staff/[staffId]/crypto/+server.ts @@ -12,9 +12,43 @@ import type { RequestHandler } from "@sveltejs/kit"; import { StaffCryptoService } from "$lib/server/services/staff-crypto.service"; import { logger } from "$lib/logger"; import { checkPermission } from "$lib/server/utils/permissions"; -import { BackendError, ValidationError, InternalError, logError } from "$lib/server/utils/errors"; +import { + BackendError, + ValidationError, + InternalError, + logError, + AuthorizationError, +} from "$lib/server/utils/errors"; import { z } from "zod"; import { registerOpenAPIRoute } from "$lib/server/openapi"; +import { centralDb } from "$lib/server/db"; +import { user } from "$lib/server/db/central-schema"; +import { eq } from "drizzle-orm"; + +const ML_KEM_768_PUBLIC_KEY_BYTES = 1184; +const ML_KEM_768_PRIVATE_KEY_BYTES = 2400; +const passkeyIdBase64UrlPattern = /^[A-Za-z0-9_-]+$/; +const base64Pattern = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; + +const isBase64String = (value: string): boolean => { + return value.length > 0 && value.length % 4 === 0 && base64Pattern.test(value); +}; + +const hasDecodedByteLength = (value: string, expectedBytes: number): boolean => { + try { + return Buffer.from(value, "base64").length === expectedBytes; + } catch { + return false; + } +}; + +const normalizeEmail = (value?: string | null): string | undefined => { + if (!value) { + return undefined; + } + + return value.trim().toLowerCase(); +}; // Register OpenAPI documentation registerOpenAPIRoute("/tenants/{id}/staff/{staffId}/crypto", "POST", { @@ -111,10 +145,26 @@ registerOpenAPIRoute("/tenants/{id}/staff/{staffId}/crypto", "POST", { }); const requestSchema = z.object({ - passkeyId: z.string().min(1, "Passkey ID is required"), - publicKey: z.string().min(1, "Public key is required"), - privateKeyShare: z.string().min(1, "Private key share is required"), - email: z.string().email("Valid email is required").optional(), // Required for cookie validation + passkeyId: z + .string() + .min(16, "Passkey ID is required") + .max(1024, "Passkey ID is too long") + .regex(passkeyIdBase64UrlPattern, "Passkey ID must be base64url encoded"), + publicKey: z + .string() + .refine((value) => isBase64String(value), "Public key must be valid Base64") + .refine( + (value) => hasDecodedByteLength(value, ML_KEM_768_PUBLIC_KEY_BYTES), + `Public key must decode to ${ML_KEM_768_PUBLIC_KEY_BYTES} bytes (ML-KEM-768)`, + ), + privateKeyShare: z + .string() + .refine((value) => isBase64String(value), "Private key share must be valid Base64") + .refine( + (value) => hasDecodedByteLength(value, ML_KEM_768_PRIVATE_KEY_BYTES), + `Private key share must decode to ${ML_KEM_768_PRIVATE_KEY_BYTES} bytes (ML-KEM-768)`, + ), + email: z.email("Valid email is required").optional(), // Required for cookie validation }); export const POST: RequestHandler = async ({ params, locals, request, cookies }) => { @@ -140,8 +190,9 @@ export const POST: RequestHandler = async ({ params, locals, request, cookies }) // Authentication: Accept either active session OR registration cookie const isAuthenticated = locals.user && locals.user.id === staffId; - const registrationEmail = cookies.get("webauthn-registration-email"); - const isRegistration = registrationEmail === email; + const registrationEmail = normalizeEmail(cookies.get("webauthn-registration-email")); + const requestEmail = normalizeEmail(email); + const isRegistration = !!registrationEmail && !!requestEmail && registrationEmail === requestEmail; if (!isAuthenticated && !isRegistration) { log.warn("Unauthorized crypto key storage attempt", { @@ -149,13 +200,29 @@ export const POST: RequestHandler = async ({ params, locals, request, cookies }) staffId, requesterId: locals.user?.id, hasRegistrationCookie: !!registrationEmail, - emailsMatch: registrationEmail === email, + emailsMatch: registrationEmail === requestEmail, }); + throw new AuthorizationError(); } // For authenticated users, verify tenant access if (isAuthenticated) { checkPermission(locals, tenantId, false); + } else { + const staffUser = await centralDb + .select({ id: user.id }) + .from(user) + .where(eq(user.id, staffId)) + .limit(1); + + if (staffUser.length === 0) { + log.warn("Rejected registration crypto key storage for unknown staff user", { + tenantId, + staffId, + hasRegistrationCookie: !!registrationEmail, + }); + throw new AuthorizationError(); + } } log.debug("Storing staff crypto keys", { tenantId, staffId, passkeyId }); diff --git a/tenant-migrations/0015_strange_warbird.sql b/tenant-migrations/0015_strange_warbird.sql new file mode 100644 index 0000000..75b879e --- /dev/null +++ b/tenant-migrations/0015_strange_warbird.sql @@ -0,0 +1 @@ +CREATE UNIQUE INDEX "staff_crypto_ua_idx" ON "staff_crypto" USING btree ("user_id","is_active"); \ No newline at end of file diff --git a/tenant-migrations/meta/0015_snapshot.json b/tenant-migrations/meta/0015_snapshot.json new file mode 100644 index 0000000..41e2adc --- /dev/null +++ b/tenant-migrations/meta/0015_snapshot.json @@ -0,0 +1,1053 @@ +{ + "id": "3cdabf94-a461-4a66-a9a2-de9158b45984", + "prevId": "7e551bff-abaf-48b5-bb08-0ba14cb3292d", + "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 + }, + "timezone": { + "name": "timezone", + "type": "text", + "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": { + "staff_crypto_ua_idx": { + "name": "staff_crypto_ua_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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": {} + } +} \ No newline at end of file diff --git a/tenant-migrations/meta/_journal.json b/tenant-migrations/meta/_journal.json index fa1c97d..7c21a67 100644 --- a/tenant-migrations/meta/_journal.json +++ b/tenant-migrations/meta/_journal.json @@ -106,6 +106,13 @@ "when": 1775654963847, "tag": "0014_fluffy_ezekiel", "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1778225646617, + "tag": "0015_strange_warbird", + "breakpoints": true } ] -} +} \ No newline at end of file