Notification shows who canceled an appointment (#317)

* Notification shows who canceled an appointment -wip

* Added solution arcitecture for decrypting notification payload

* Use encrypted tunnel key to decrypt encrpyted notification payload

* Fix tests

---------

Co-authored-by: Karl Ludwig Weise <ludwig@ludwigweise.de>
This commit is contained in:
Karl Ludwig Weise
2026-08-13 10:54:01 +02:00
committed by GitHub
co-authored by Karl Ludwig Weise
parent de83771843
commit 9e5a5ab722
15 changed files with 780 additions and 78 deletions
+5 -6
View File
@@ -366,6 +366,7 @@
"delete": "Löschen",
"select": "Auswählen",
"close": "Schließen",
"unkown": "Unbekannt",
"dashboard": {
"title": "Übersicht",
"hello": "Hallo",
@@ -490,6 +491,7 @@
},
"channels": {
"title": "Kanäle",
"singular": "Kanal",
"empty": {
"title": "Bisher keine Kanäle",
"description": "Kanäle sind Ihre Möglichkeit, verschiedene Arten von Terminen zu sammeln"
@@ -1130,16 +1132,13 @@
"empty": "Keine Benachrichtigungen vorhanden",
"types": {
"APPOINTMENT_REQUESTED": {
"title": "Neue Terminanfrage",
"description": "Ein neuer Termin wurde in {channel} angefragt."
"title": "Neue Terminanfrage"
},
"APPOINTMENT_CONFIRMED": {
"title": "Termin bestätigt",
"description": "Terminanfrage in {channel} wurde bestätigt."
"title": "Termin bestätigt"
},
"APPOINTMENT_CANCELLED": {
"title": "Termin abgesagt",
"description": "Der Klient hat den Termin in {channel} abgesagt."
"title": "Termin abgesagt"
}
}
},
+6 -7
View File
@@ -373,6 +373,8 @@
"pause": "Deactivate",
"delete": "Delete",
"select": "Select",
"close": "Close",
"unkown": "Unkown",
"dashboard": {
"title": "Dashboard",
"hello": "Hello",
@@ -432,7 +434,6 @@
}
}
},
"close": "Close",
"agents": {
"title": "Agents",
"empty": {
@@ -498,6 +499,7 @@
},
"channels": {
"title": "Channels",
"singular": "Channel",
"empty": {
"title": "No channels yet",
"description": "Channels are your way to collect different types of appointments"
@@ -1138,16 +1140,13 @@
"empty": "No notifications available",
"types": {
"APPOINTMENT_REQUESTED": {
"title": "New appointment request",
"description": "A new appointment has been requested in {channel}."
"title": "New appointment request"
},
"APPOINTMENT_CONFIRMED": {
"title": "Appointment confirmed",
"description": "Appointment in {channel} was confirmed."
"title": "Appointment confirmed"
},
"APPOINTMENT_CANCELLED": {
"title": "Appointment cancelled",
"description": "Client cancelled appointment in {channel}."
"title": "Appointment cancelled"
}
}
},
+40 -5
View File
@@ -49,7 +49,13 @@
import type { BootstrapChallengeResponse, BootstrapVerifyResponse } from "$lib/types/appointment";
import { OptimizedArgon2 } from "$lib/crypto/hashing";
import { AESCrypto, BufferUtils, KyberCrypto, ShamirSecretSharing } from "$lib/crypto/utils";
import {
AESCrypto,
BufferUtils,
encryptedDataToString,
KyberCrypto,
ShamirSecretSharing,
} from "$lib/crypto/utils";
import type { ClientTunnelResponse } from "$lib/server/services/appointment-service";
import { pinThrottleStore } from "$lib/stores/pin-throttle";
@@ -64,7 +70,7 @@ interface StaffKeyPair {
privateKey: Uint8Array;
}
interface EncryptedData {
export interface EncryptedData {
encryptedPayload: string;
iv: string;
authTag: string;
@@ -90,6 +96,7 @@ interface StaffPublicKey {
export interface DecryptedAppointment {
id: string;
channelId?: string;
appointmentDate: string;
status: string;
name: string;
@@ -103,6 +110,7 @@ interface MyAppointmentsResponse {
id: string;
appointmentDate: string;
status: string;
channelId: string;
encryptedData: EncryptedData;
}>;
}
@@ -246,7 +254,9 @@ export class UnifiedAppointmentCrypto {
async cancelAppointmentByClient(opts: {
tenant: string;
appointment: string;
name: string;
email: string;
phone?: string;
}): Promise<void> {
try {
if (this.pin === null) {
@@ -305,6 +315,13 @@ export class UnifiedAppointmentCrypto {
emailHash: this.emailHash,
challengeId: challengeData.challengeId,
challengeResponse: decryptedChallenge,
encryptedPayload: encryptedDataToString(
await this.encryptData({
name: opts.name,
email: opts.email,
phone: opts.phone,
}),
),
}),
},
);
@@ -709,6 +726,7 @@ export class UnifiedAppointmentCrypto {
id: encryptedAppt.id,
appointmentDate: encryptedAppt.appointmentDate,
status: encryptedAppt.status,
channelId: encryptedAppt.channelId,
...decryptedData,
});
} catch (error) {
@@ -787,6 +805,16 @@ export class UnifiedAppointmentCrypto {
encryptedAppointment: EncryptedData;
staffKeyShare: string;
}): Promise<AppointmentData> {
return this.decryptStaff<AppointmentData>({
data: encryptedData.encryptedAppointment,
staffKeyShare: encryptedData.staffKeyShare,
});
}
/**
* Decrypt appointment data for staff members
*/
async decryptStaff<T>(encryptedData: { data: EncryptedData; staffKeyShare: string }): Promise<T> {
if (!this.staffAuthenticated || !this.staffKeyPair) {
throw new Error("Staff not authenticated");
}
@@ -848,9 +876,9 @@ export class UnifiedAppointmentCrypto {
);
// 5. Now decrypt the actual appointment data
const appointmentIv = this.hexToUint8Array(encryptedData.encryptedAppointment.iv);
const ciphertext = this.hexToUint8Array(encryptedData.encryptedAppointment.encryptedPayload);
const authTag = this.hexToUint8Array(encryptedData.encryptedAppointment.authTag);
const appointmentIv = this.hexToUint8Array(encryptedData.data.iv);
const ciphertext = this.hexToUint8Array(encryptedData.data.encryptedPayload);
const authTag = this.hexToUint8Array(encryptedData.data.authTag);
// Combine ciphertext and auth tag for Web Crypto API
const encrypted = new Uint8Array(ciphertext.length + authTag.length);
@@ -1137,6 +1165,13 @@ export class UnifiedAppointmentCrypto {
data: AppointmentData | AppointmentDataByStaff,
tunnelKey?: CryptoKey,
): Promise<EncryptedData> {
return this.encryptData<AppointmentData | AppointmentDataByStaff>(data, tunnelKey);
}
/**
* Encrypt data with the tunnel key
*/
private async encryptData<T>(data: T, tunnelKey?: CryptoKey): Promise<EncryptedData> {
const usedTunnelKey = tunnelKey ?? this.tunnelKey;
if (!usedTunnelKey) throw new Error("No tunnel key available");
+26
View File
@@ -0,0 +1,26 @@
import logger from "$lib/logger";
import type { TStaffKeyShare } from "$lib/types/staff";
export const getStaffKeyShares = async (
tenantId: string,
tunnelId: string,
): Promise<TStaffKeyShare[]> => {
const resp = await fetch(
`/api/tenants/${tenantId}/appointments/tunnels/${tunnelId}/staff-key-shares`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
},
},
);
let data;
try {
data = await resp.json();
} catch (error) {
logger.error("Failed to parse staff key shares response", { tenantId, tunnelId, error });
}
return data?.keyShares || [];
};
@@ -3,11 +3,15 @@
import { resolve } from "$app/paths";
import { page } from "$app/state";
import { m } from "$i18n/messages";
import { getStaffKeyShares } from "$lib/client/crypto";
import { Button } from "$lib/components/ui/button";
import { Text } from "$lib/components/ui/typography";
import { ROUTES } from "$lib/const/routes";
import { stringToEncryptedData } from "$lib/crypto/utils";
import { auth } from "$lib/stores/auth";
import { channels as channelsStore } from "$lib/stores/channels";
import { staffCrypto } from "$lib/stores/staff-crypto";
import { tenants } from "$lib/stores/tenants";
import type { TAppointment } from "$lib/types/appointments";
import type { TNotification } from "$lib/types/notification";
import { toDisplayDateTime } from "$lib/utils/datetime";
@@ -26,7 +30,9 @@
const queryClient = useQueryClient();
const channels = $derived($channelsStore.channels);
const tenant = $derived($tenants.currentTenant);
let appointment: TAppointment | undefined = $state();
let decrypted: { name?: string; email?: string; phone?: string } | undefined = $state();
const getAppointment = async (id: string) => {
try {
@@ -46,16 +52,57 @@
}
};
const decryptPayload = async () => {
if (item.metaData?.encryptedPayload) {
if (!$staffCrypto.isAuthenticated || !$staffCrypto.crypto) {
const maxWaitTime = 5000; // 5 seconds
const startTime = Date.now();
while (!$staffCrypto.isAuthenticated && Date.now() - startTime < maxWaitTime) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
if (!$staffCrypto.isAuthenticated || !$staffCrypto.crypto) {
console.error("Staff crypto not initialized after waiting");
return;
}
}
const data = stringToEncryptedData(item.metaData?.encryptedPayload);
if (data) {
if (!item.metaData?.tunnelId) {
console.error("Missing tunnelId in notification metadata");
return;
}
if (tenant === null) {
console.error("Missing tenant to decrypt encrypted notification payload");
}
const staffKeyShares = await getStaffKeyShares(tenant!.id, item.metaData.tunnelId);
// TODO: When multiple staffKeyShares are supported, select the one currently in use
const staffKeyShare = staffKeyShares[0];
decrypted = await $staffCrypto.crypto.decryptStaff({
data,
staffKeyShare: staffKeyShare.encryptedTunnelKey,
});
}
}
};
onMount(() => {
if (item.metaData?.appointmentId) {
getAppointment(item.metaData.appointmentId);
decryptPayload();
}
});
</script>
<Button
variant="ghost"
class=" flex h-auto w-full shrink flex-col items-start gap-1 rounded-none px-3 last:rounded-b-md focus:ring-inset"
class="flex h-auto w-full shrink flex-col items-start gap-1 rounded-none px-3 last:rounded-b-md focus:ring-inset"
onclick={() => {
if (appointment) {
closePopover();
@@ -96,6 +143,21 @@
timeZoneName: "short",
})}
</Text>
{:else if item.metaData?.appointmentDate}
<Text
style="xs"
class="text-muted-foreground -mb-1 flex pr-8 text-start font-light whitespace-break-spaces"
>
{toDisplayDateTime(new Date(item.metaData?.appointmentDate), {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
timeZone: getLocalTimeZone(),
timeZoneName: "short",
})}
</Text>
{/if}
<Text style="md" class="flex pr-8 text-start whitespace-break-spaces">
{#if item.type === "APPOINTMENT_REQUESTED"}
@@ -106,23 +168,29 @@
{m["notifications.types.APPOINTMENT_CANCELLED.title"]()}
{/if}
</Text>
{#if appointment}
<Text style="sm" class="text-muted-foreground flex w-full text-start whitespace-break-spaces">
{#if appointment?.channelId}
{@const channel = channels.find((c) => c.id === appointment?.channelId)}
{@const channelName = getCurrentTranlslation(channel?.names)}
{#if channelName}
{#if item.type === "APPOINTMENT_REQUESTED"}
{m["notifications.types.APPOINTMENT_REQUESTED.description"]({
channel: channelName,
})}
{@const possibleChannelIds = [item.metaData?.channelId, appointment?.channelId]}
{@const channel = channels.find((c) => possibleChannelIds.includes(c.id))}
{#if channel}
{@const channelName = getCurrentTranlslation(channel.names)}
<Text style="xs" class="text-muted-foreground flex w-full text-start whitespace-break-spaces">
{m["channels.singular"]()}: {channelName}
</Text>
{/if}
{:else if item.type === "APPOINTMENT_CONFIRMED"}
{m["notifications.types.APPOINTMENT_CONFIRMED.description"]({
channel: channelName,
})}
{#if decrypted?.name}
<Text style="xs" class="text-muted-foreground flex w-full text-start whitespace-break-spaces">
{m["form.name"]()}: {decrypted.name || m["unkown"]()}
</Text>
{/if}
{#if decrypted?.email}
<Text style="xs" class="text-muted-foreground flex w-full text-start whitespace-break-spaces">
{m["form.email"]()}:
<a href={`mailto:${decrypted.email}`} class="underline">{decrypted.email}</a>
</Text>
{/if}
{#if decrypted?.phone}
<Text style="xs" class="text-muted-foreground flex w-full text-start whitespace-break-spaces">
{m["form.phone"]()}:
<a href={`tel:${decrypted.phone}`} class="underline">{decrypted.phone}</a>
</Text>
{/if}
</Button>
@@ -2,7 +2,7 @@
import { Text } from "$lib/components/ui/typography";
import { languageSwitchLocales } from "$lib/const/locales";
import { toDisplayDateTime } from "$lib/utils/datetime";
import { Calendar, Languages, Mail, Phone, User, UserStar } from "@lucide/svelte";
import { Calendar, Languages, Mail, Phone, Split, User, UserStar } from "@lucide/svelte";
import type { AppointmentDetailItems } from ".";
import CopyButton from "./copy-button.svelte";
@@ -18,7 +18,12 @@
{#each items as item (item.type)}
{#if item.value}
<div class="flex items-center gap-2">
{#if item.type === "agent"}
{#if item.type === "channel"}
<Split class="size-4 shrink-0" />
<Text style="sm">
{item.value}
</Text>
{:else if item.type === "agent"}
<UserStar class="size-4 shrink-0" />
<Text style="sm">
{item.value}
@@ -29,4 +29,8 @@ export type AppointmentDetailItem =
| {
type: "agent";
value: string | undefined;
}
| {
type: "channel";
value: string | undefined;
};
+41
View File
@@ -1,5 +1,6 @@
import { ml_kem768 } from "@noble/post-quantum/ml-kem";
import { randomBytes } from "@noble/hashes/utils";
import type { EncryptedData } from "$lib/client/appointment-crypto";
/**
* Type alias for cryptographic buffer operations
@@ -588,3 +589,43 @@ export class ShamirSecretSharing {
return length;
}
}
export const encryptedDataToString = (data: EncryptedData): string => {
const encoder = new TextEncoder();
const bytes = encoder.encode(JSON.stringify(data));
const binString = Array.from(bytes, (byte) => String.fromCodePoint(byte)).join("");
const base64 = btoa(binString);
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
};
export const stringToEncryptedData = (str: string): EncryptedData | null => {
try {
const base64 = str.replace(/-/g, "+").replace(/_/g, "/");
const binString = atob(base64);
const bytes = Uint8Array.from(binString, (char) => char.codePointAt(0)!);
const decoder = new TextDecoder();
const parsed = JSON.parse(decoder.decode(bytes));
if (
typeof parsed.encryptedPayload === "string" &&
typeof parsed.iv === "string" &&
typeof parsed.authTag === "string"
) {
return {
encryptedPayload: parsed.encryptedPayload,
iv: parsed.iv,
authTag: parsed.authTag,
};
} else {
console.error("Hydrated encrypted data does not match expected object type");
return null;
}
} catch (error) {
console.error("Failed to hydrate encrypted data from string", error);
return null;
}
};
+5 -20
View File
@@ -554,26 +554,6 @@ export class AppointmentService {
);
}
// Create notifications for all staff members in the channel
const notificationService = await NotificationService.forTenant(this.tenantId);
// Create notifications (async, don't wait)
notificationService
.createNotification({
channelId,
type: "APPOINTMENT_CANCELLED",
metaData: {
appointmentId,
},
})
.catch((error) => {
log.error("Failed to create channel notifications", {
appointmentId,
channelId,
error: String(error),
});
});
log.info("Appointment deleted by staff successfully", {
appointmentId,
channelId,
@@ -1251,6 +1231,7 @@ export class AppointmentService {
emailHash: string,
challengeId: string,
challengeResponse: string,
encryptedPayload?: string,
): Promise<void> {
const log = logger.setContext("AppointmentService");
log.debug("Client deleting appointment with authentication", {
@@ -1367,6 +1348,10 @@ export class AppointmentService {
type: "APPOINTMENT_CANCELLED",
metaData: {
appointmentId,
channelId: appointment.channelId,
appointmentDate: appointment.appointmentDate,
tunnelId: appointment.tunnelId,
encryptedPayload,
},
});
+5
View File
@@ -1,3 +1,8 @@
import type { SelectUser } from "$lib/server/db/central-schema";
export type TStaff = Pick<SelectUser, "id" | "name" | "role" | "email">;
export type TStaffKeyShare = {
tunnelId: string;
encryptedTunnelKey: string;
};
@@ -3,21 +3,24 @@
import { m } from "$i18n/messages.js";
import type { DecryptedAppointment } from "$lib/client/appointment-crypto";
import * as Alert from "$lib/components/ui/alert";
import { AppointmentDetails } from "$lib/components/ui/appointment-details";
import { Badge } from "$lib/components/ui/badge";
import { Button } from "$lib/components/ui/button";
import * as Item from "$lib/components/ui/item";
import { LocalizedText } from "$lib/components/ui/public";
import { openDialog, ResponsiveDialog } from "$lib/components/ui/responsive-dialog";
import { Skeleton } from "$lib/components/ui/skeleton";
import { Headline, Text } from "$lib/components/ui/typography";
import { Headline } from "$lib/components/ui/typography";
import { ROUTES } from "$lib/const/routes";
import { publicStore } from "$lib/stores/public";
import { toDisplayDateTime, utcToLocalWithoutDST } from "$lib/utils/datetime";
import { getLocalTimeZone } from "@internationalized/date";
import { Calendar, CircleAlert } from "@lucide/svelte";
import { toDisplayDateTime } from "$lib/utils/datetime";
import { getCurrentTranlslation } from "$lib/utils/localizations";
import { CircleAlert } from "@lucide/svelte";
import { onMount } from "svelte";
import { toast } from "svelte-sonner";
import { appointmentStatusToBadge } from "./utils";
let channels = $derived($publicStore.channels);
let tenant = $derived($publicStore.tenant);
let step: "loading" | "list" | "error" = $state("loading");
let appointments: DecryptedAppointment[] = $state([]);
@@ -43,6 +46,8 @@
?.cancelAppointmentByClient({
appointment: cancelling.id,
tenant: tenant.id,
name: cancelling.name,
phone: cancelling.phone,
email: cancelling.email,
})
.then(() => {
@@ -72,10 +77,13 @@
{#if appointments.length > 0}
<div class="flex flex-col gap-1">
{#each appointments as item (item)}
{@const channel = channels?.find((it) => it.id === item.channelId)}
<Item.Root variant="outline">
<Item.Content>
<Item.Title>
<!-- TODO: Add channel and agent; ids needed -->
{#if channel}
<LocalizedText translations={channel.names} />
{/if}
{@const badge = appointmentStatusToBadge(item.status)}
{#if badge}
<Badge>{badge.label.toLocaleUpperCase()}</Badge>
@@ -118,26 +126,25 @@
</div>
{#if cancelling}
{@const channel = channels?.find((it) => it.id === cancelling?.channelId)}
<ResponsiveDialog
id="cancel-appointment"
title={m["clients.appointments.cancel.title"]()}
description={m["clients.appointments.cancel.description"]()}
triggerHidden={true}
>
<div class="flex gap-2 p-1">
<Calendar class="size-4 " />
<Text style="sm">
{toDisplayDateTime(utcToLocalWithoutDST(new Date(cancelling.appointmentDate)), {
year: "numeric",
month: "long",
day: "numeric",
weekday: "short",
hour: "2-digit",
minute: "2-digit",
timeZone: getLocalTimeZone().toString(),
})}
</Text>
</div>
<AppointmentDetails
items={[
{
type: "channel",
value: channel && getCurrentTranlslation(channel.names),
},
{
type: "date",
value: new Date(cancelling.appointmentDate),
},
]}
/>
<Button
variant="destructive"
@@ -17,6 +17,7 @@ const requestSchema = z.object({
emailHash: z.string().min(1),
challengeId: z.string().min(1),
challengeResponse: z.string().min(1),
encryptedPayload: z.string().optional(),
});
// Register OpenAPI documentation for DELETE
@@ -131,7 +132,8 @@ export const DELETE: RequestHandler = async ({ request, params }) => {
}
const body = await request.json();
const { emailHash, challengeId, challengeResponse } = requestSchema.parse(body);
const { emailHash, challengeId, challengeResponse, encryptedPayload } =
requestSchema.parse(body);
log.debug("Client deleting appointment", {
tenantId,
@@ -146,6 +148,7 @@ export const DELETE: RequestHandler = async ({ request, params }) => {
emailHash,
challengeId,
challengeResponse,
encryptedPayload,
);
log.debug("Appointment deleted successfully by client", {
@@ -26,6 +26,7 @@ const mockAppointmentId = "appointment-456";
const mockEmailHash = "email-hash-abc123";
const mockChallengeId = "challenge-789";
const mockChallengeResponse = "decrypted-challenge";
const mockEncryptedPayload = "encrypted-payload-string";
describe("DELETE /api/tenants/[id]/appointments/[appointmentId]/delete-by-client", () => {
beforeEach(() => {
@@ -40,6 +41,7 @@ describe("DELETE /api/tenants/[id]/appointments/[appointmentId]/delete-by-client
emailHash: mockEmailHash,
challengeId: mockChallengeId,
challengeResponse: mockChallengeResponse,
encryptedPayload: mockEncryptedPayload,
},
),
} as any,
@@ -66,6 +68,7 @@ describe("DELETE /api/tenants/[id]/appointments/[appointmentId]/delete-by-client
mockEmailHash,
mockChallengeId,
mockChallengeResponse,
mockEncryptedPayload,
);
});
@@ -0,0 +1,191 @@
import { logger } from "$lib/logger";
import { centralDb, getTenantDb } from "$lib/server/db";
import { user } from "$lib/server/db/central-schema";
import { clientTunnelStaffKeyShare } from "$lib/server/db/tenant-schema";
import { registerOpenAPIRoute } from "$lib/server/openapi";
import {
AuthenticationError,
AuthorizationError,
BackendError,
InternalError,
logError,
ValidationError,
} from "$lib/server/utils/errors";
import { checkPermission } from "$lib/server/utils/permissions";
import type { RequestHandler } from "@sveltejs/kit";
import { json } from "@sveltejs/kit";
import { and, eq } from "drizzle-orm";
// Register OpenAPI documentation for GET
registerOpenAPIRoute("/tenants/{id}/appointments/tunnels/{tunnelId}/staff-key-shares", "GET", {
summary: "Get staff key shares for a specific tunnel",
description:
"Get staff key shares for a specific tunnel. This is used when a staff member needs access to decrypt a specific client tunnel.",
tags: ["Appointments", "Tunnels", "Staff"],
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Tenant ID",
},
{
name: "tunnelId",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Tunnel ID",
},
],
responses: {
"200": {
description: "Staff key shares retrieved successfully",
content: {
"application/json": {
schema: {
type: "object",
properties: {
keyShares: {
type: "array",
items: {
type: "object",
properties: {
encryptedTunnelKey: { type: "string", description: "Encrypted tunnel key" },
tunnelId: { type: "string", format: "uuid", description: "Tunnel ID" },
},
required: ["encryptedTunnelKey", "tunnelId"],
},
description: "Retrieved key shares",
},
},
required: ["keyShares"],
},
},
},
},
"401": {
description: "Authentication required",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"403": {
description: "Forbidden",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"500": {
description: "Internal server error",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
},
});
export const GET: RequestHandler = async ({ params, locals }) => {
const log = logger.setContext("API.GetStaffKeyShares");
const tenantId = params.id;
const tunnelId = params.tunnelId;
const userId = locals.user?.id;
if (!tenantId) {
throw new ValidationError("Tenant ID is required");
}
if (!tunnelId) {
throw new ValidationError("Tunnel ID is required");
}
if (!userId) {
throw new AuthenticationError("User ID is required");
}
checkPermission(locals, tenantId);
try {
log.debug("Getting staff key shares for tunnel", {
tenantId,
tunnelId,
userId: locals.user?.id,
});
const staffUser = await centralDb
.select({
id: user.id,
tenantId: user.tenantId,
isActive: user.isActive,
role: user.role,
confirmationState: user.confirmationState,
})
.from(user)
.where(eq(user.id, userId))
.limit(1);
if (staffUser.length === 0) {
throw new AuthenticationError("User not found");
}
if (staffUser[0].tenantId !== tenantId) {
throw new AuthorizationError("Staff user does not belong to this tenant");
}
if (!staffUser[0].isActive) {
throw new AuthorizationError("Staff user is inactive");
}
const db = await getTenantDb(tenantId);
const tunnelAccess = await db
.select({
tunnelId: clientTunnelStaffKeyShare.tunnelId,
})
.from(clientTunnelStaffKeyShare)
.where(
and(
eq(clientTunnelStaffKeyShare.userId, userId),
eq(clientTunnelStaffKeyShare.tunnelId, tunnelId),
),
)
.limit(1);
if (tunnelAccess.length === 0) {
throw new AuthorizationError(
"User does not have access to this tunnel or tunnel does not exist",
);
}
const keyShares = await db
.select({
tunnelId: clientTunnelStaffKeyShare.tunnelId,
encryptedTunnelKey: clientTunnelStaffKeyShare.encryptedTunnelKey,
})
.from(clientTunnelStaffKeyShare)
.where(
and(
eq(clientTunnelStaffKeyShare.userId, userId),
eq(clientTunnelStaffKeyShare.tunnelId, tunnelId),
),
);
return json({
keyShares,
});
} catch (error) {
logError(log)("Error getting staff key shares", error, locals.user?.id, tenantId);
if (error instanceof BackendError) {
return error.toJson();
}
return new InternalError().toJson();
}
};
@@ -0,0 +1,331 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockCheckPermission = vi.hoisted(() => vi.fn());
const mockLogger = vi.hoisted(() => ({
setContext: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}));
const mockCentralDb = vi.hoisted(() => ({
select: vi.fn(),
}));
const mockGetTenantDb = vi.hoisted(() => vi.fn());
vi.mock("$lib/logger", () => ({
logger: {
...mockLogger,
setContext: vi.fn().mockImplementation(() => mockLogger),
},
}));
vi.mock("$lib/server/openapi", () => ({
registerOpenAPIRoute: vi.fn(),
}));
vi.mock("$lib/server/db", () => ({
centralDb: mockCentralDb,
getTenantDb: mockGetTenantDb,
}));
vi.mock("$lib/server/db/central-schema", () => ({
user: {
id: "id",
tenantId: "tenantId",
isActive: "isActive",
role: "role",
confirmationState: "confirmationState",
},
}));
vi.mock("$lib/server/db/tenant-schema", () => ({
clientTunnelStaffKeyShare: {
userId: "userId",
tunnelId: "tunnelId",
encryptedTunnelKey: "encryptedTunnelKey",
},
}));
vi.mock("$lib/server/utils/permissions", () => ({
checkPermission: mockCheckPermission,
}));
import { GET } from "../+server";
const mockTenantId = "123e4567-e89b-12d3-a456-426614174000";
const mockStaffUserId = "456e7890-e89b-12d3-a456-426614174001";
const mockTunnelId = "789e0123-e89b-12d3-a456-426614174002";
const createSelectChain = (result: unknown) => ({
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockResolvedValue(result),
});
const createTenantDbResult = (rows: unknown[]) => ({
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue(rows),
then: (resolve: (value: unknown[]) => unknown) => resolve(rows),
}),
}),
}),
});
const buildEvent = (locals: Record<string, unknown>, tenantId?: string, tunnelId?: string) =>
({
params: { id: tenantId, tunnelId },
locals,
}) as any;
describe("Get Staff Key Shares API", () => {
beforeEach(() => {
vi.clearAllMocks();
mockCheckPermission.mockImplementation(() => undefined);
mockLogger.setContext.mockImplementation(() => mockLogger);
});
it("returns the staff key shares for the current staff user and tunnel", async () => {
const mockStaffUser = {
id: mockStaffUserId,
tenantId: mockTenantId,
isActive: true,
role: "STAFF",
confirmationState: "ACCESS_GRANTED",
};
mockCentralDb.select.mockReturnValue(createSelectChain([mockStaffUser]));
mockGetTenantDb.mockResolvedValue(
createTenantDbResult([
{
tunnelId: mockTunnelId,
encryptedTunnelKey: "encrypted-key-share",
},
]),
);
const response = await GET(
buildEvent(
{
user: { id: mockStaffUserId, tenantId: mockTenantId, role: "STAFF" },
},
mockTenantId,
mockTunnelId,
),
);
expect(response.status).toBe(200);
const body = await response.json();
expect(body).toEqual({
keyShares: [{ tunnelId: mockTunnelId, encryptedTunnelKey: "encrypted-key-share" }],
});
expect(mockCheckPermission).toHaveBeenCalledWith(
{ user: { id: mockStaffUserId, tenantId: mockTenantId, role: "STAFF" } },
mockTenantId,
);
expect(mockGetTenantDb).toHaveBeenCalledWith(mockTenantId);
});
it("rejects when the staff user does not have access to the requested tunnel", async () => {
const mockStaffUser = {
id: mockStaffUserId,
tenantId: mockTenantId,
isActive: true,
role: "STAFF",
confirmationState: "ACCESS_GRANTED",
};
mockCentralDb.select.mockReturnValue(createSelectChain([mockStaffUser]));
mockGetTenantDb.mockResolvedValue(createTenantDbResult([]));
const response = await GET(
buildEvent(
{
user: { id: mockStaffUserId, tenantId: mockTenantId, role: "STAFF" },
},
mockTenantId,
mockTunnelId,
),
);
expect(response.status).toBe(403);
const body = await response.json();
expect(body).toEqual({
error: "User does not have access to this tunnel or tunnel does not exist",
message: "User does not have access to this tunnel or tunnel does not exist",
});
});
it("rejects when tenant id is missing", async () => {
await expect(
GET(
buildEvent(
{
user: { id: mockStaffUserId, tenantId: mockTenantId, role: "STAFF" },
},
undefined,
),
),
).rejects.toThrow("Tenant ID is required");
});
it("rejects when tunnel id is missing", async () => {
await expect(
GET(
buildEvent(
{
user: { id: mockStaffUserId, tenantId: mockTenantId, role: "STAFF" },
},
mockTenantId,
undefined,
),
),
).rejects.toThrow("Tunnel ID is required");
});
it("rejects when the current user is not in locals", async () => {
await expect(
GET(
buildEvent(
{
user: null,
},
mockTenantId,
mockTunnelId,
),
),
).rejects.toThrow("User ID is required");
});
it("rejects when the staff user record is not found in the central database", async () => {
mockCentralDb.select.mockReturnValue(createSelectChain([]));
const response = await GET(
buildEvent(
{
user: { id: mockStaffUserId, tenantId: mockTenantId, role: "STAFF" },
},
mockTenantId,
mockTunnelId,
),
);
expect(response.status).toBe(401);
const body = await response.json();
expect(body).toEqual({ error: "User not found", message: "User not found" });
});
it("rejects when the staff user belongs to a different tenant", async () => {
mockCentralDb.select.mockReturnValue(
createSelectChain([
{
id: mockStaffUserId,
tenantId: "different-tenant",
isActive: true,
role: "STAFF",
confirmationState: "ACCESS_GRANTED",
},
]),
);
const response = await GET(
buildEvent(
{
user: { id: mockStaffUserId, tenantId: mockTenantId, role: "STAFF" },
},
mockTenantId,
mockTunnelId,
),
);
expect(response.status).toBe(403);
const body = await response.json();
expect(body).toEqual({
error: "Staff user does not belong to this tenant",
message: "Staff user does not belong to this tenant",
});
});
it("rejects when the staff user is inactive", async () => {
mockCentralDb.select.mockReturnValue(
createSelectChain([
{
id: mockStaffUserId,
tenantId: mockTenantId,
isActive: false,
role: "STAFF",
confirmationState: "ACCESS_GRANTED",
},
]),
);
const response = await GET(
buildEvent(
{
user: { id: mockStaffUserId, tenantId: mockTenantId, role: "STAFF" },
},
mockTenantId,
mockTunnelId,
),
);
expect(response.status).toBe(403);
const body = await response.json();
expect(body).toEqual({ error: "Staff user is inactive", message: "Staff user is inactive" });
});
it("returns a 500 response when the tenant database query fails unexpectedly", async () => {
mockCentralDb.select.mockReturnValue(
createSelectChain([
{
id: mockStaffUserId,
tenantId: mockTenantId,
isActive: true,
role: "STAFF",
confirmationState: "ACCESS_GRANTED",
},
]),
);
mockGetTenantDb.mockRejectedValue(new Error("database unavailable"));
const response = await GET(
buildEvent(
{
user: { id: mockStaffUserId, tenantId: mockTenantId, role: "STAFF" },
},
mockTenantId,
mockTunnelId,
),
);
expect(response.status).toBe(500);
const body = await response.json();
expect(body).toEqual({ error: "Internal server error", message: "Internal server error" });
});
it("propagates permission failures before any database access occurs", async () => {
mockCheckPermission.mockImplementation(() => {
throw new Error("permission denied");
});
await expect(
GET(
buildEvent(
{
user: { id: mockStaffUserId, tenantId: mockTenantId, role: "STAFF" },
},
mockTenantId,
mockTunnelId,
),
),
).rejects.toThrow("permission denied");
expect(mockCentralDb.select).not.toHaveBeenCalled();
});
});