fix(security): percent-encode every path-position id in lib/api.ts; session pages gate URL-derived id on GUID before any fetch

A crafted /sessions?id=x%2F..%2F..%2Fglobal%2Fy%23 deep link let the victim's browser resolve
dot-segments into a different API route and truncate builder suffixes via '#', turning UI
actions into attacker-chosen authenticated same-origin requests. Hydration now also requires
the returned sessionId to match the requested one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-08-29 23:45:34 +02:00
parent c41c2327ab
commit dbb154bae9
6 changed files with 153 additions and 54 deletions
@@ -30,7 +30,8 @@ export default function DiagnosisPage() {
function DiagnosisContent() {
const searchParams = useSearchParams();
const router = useRouter();
const sessionId = searchParams?.get("id") as string;
// Deep-link value is percent-decoded by useSearchParams; only a GUID may reach the API builders.
const sessionId = (isGuid(searchParams?.get("id")) ? searchParams!.get("id")!.trim() : "") as string;
const [session, setSession] = useState<Session | null>(null);
const [sessionTenantId, setSessionTenantId] = useState<string | null>(null);
@@ -108,7 +108,10 @@ export function useSessionDetail({
const response = await authenticatedFetch(endpoint, getAccessToken);
if (response.ok) {
const data = await response.json();
const foundSession = data.session ?? data.sessions?.find((s: Session) => s.sessionId === sessionId);
// Accept only a session whose id matches the one we asked for — a misrouted or
// unexpected 200 must never hydrate the page (defense in depth behind the GUID gate).
const candidate: Session | undefined = data.session ?? data.sessions?.find((s: Session) => s.sessionId === sessionId);
const foundSession = candidate?.sessionId?.toLowerCase() === sessionId.toLowerCase() ? candidate : undefined;
if (foundSession) {
setSession(foundSession);
setSessionTenantId(foundSession.tenantId);
@@ -16,6 +16,7 @@ import { useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { ProtectedRoute } from '@/components/ProtectedRoute';
import { useAuth } from '@/contexts/AuthContext';
import { isGuid } from '@/utils/inputValidation';
import { api } from '@/lib/api';
import { authenticatedFetch } from '@/lib/authenticatedFetch';
import { extractContinuation, MAX_EAGER_PAGES } from '@/lib/paginationLink';
@@ -91,7 +92,8 @@ export default function NetworkTimelinePage() {
function Content() {
const searchParams = useSearchParams();
const sessionId = searchParams?.get('id') ?? null;
// Deep-link value is percent-decoded by useSearchParams; only a GUID may reach the API builders.
const sessionId = isGuid(searchParams?.get('id')) ? searchParams!.get('id')!.trim() : null;
const tenantIdOverride = searchParams?.get('tenantId') ?? undefined;
const { session, events, loading, error } = useSessionData(sessionId, tenantIdOverride);
@@ -3,6 +3,7 @@
import { diagnosisUrl, inspectorUrl } from "@/lib/routes";
import { Suspense, useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { useSignalR } from "../../contexts/SignalRContext";
import { useTenant } from "../../contexts/TenantContext";
import { useAuth } from "../../contexts/AuthContext";
@@ -15,6 +16,7 @@ import ScriptExecutions from '../../components/ScriptExecutions';
import { useLatestVersions } from '@/lib/useLatestVersions';
import { useScriptDisplayNames } from '@/lib/scriptDisplayNames';
import { api } from "@/lib/api";
import { isGuid } from "@/utils/inputValidation";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { useSessionAnalysis } from "./hooks/useSessionAnalysis";
@@ -59,7 +61,11 @@ function SessionDetailContent() {
const searchParams = useSearchParams();
// Query-string identity (`?id=`) — path params are gone with the static export;
// legacy /sessions/{id} URLs are rewritten here by LegacyPathRedirect.
const sessionId = searchParams?.get("id") as string;
// The raw value is attacker-deliverable (deep link) and useSearchParams returns it
// percent-DECODED, so anything but a GUID must never reach an API URL builder: an empty
// sessionId keeps every data hook inert and the page renders the invalid-id branch below.
const rawSessionId = searchParams?.get("id") ?? "";
const sessionId = isGuid(rawSessionId) ? rawSessionId.trim() : "";
// Explicit target tenant from the fleet drill-in (`?tenantId=`). Drives the cross-tenant reads for a
// delegated ("MSP") admin viewing a managed tenant's session, and flips the page into read-only mode.
const tenantIdOverride = searchParams?.get("tenantId") || undefined;
@@ -424,6 +430,19 @@ function SessionDetailContent() {
// so `loading` never flips and we'd render this branch forever — bypassing the
// auth gate and hanging on "Loading session details..." without ever triggering
// the MSAL login redirect. Wrapping here lets ProtectedRoute drive re-auth.
if (!sessionId) {
return (
<ProtectedRoute>
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-gray-600">
{rawSessionId ? "Invalid session id." : "No session id given."}{" "}
<Link href="/dashboard" className="text-blue-600 hover:underline">Back to dashboard</Link>
</div>
</div>
</ProtectedRoute>
);
}
if (loading && !session && events.length === 0) {
return (
<ProtectedRoute>
@@ -0,0 +1,74 @@
import { describe, it, expect, vi } from "vitest";
// API_BASE_URL is read at import time of lib/api — stub it before the import.
vi.mock("@/utils/config", () => ({ API_BASE_URL: "https://test.example" }));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const apiPromise = import("../api") as Promise<{ api: any }>;
/**
* Path-position ids must be percent-encoded so an attacker-controlled id
* (deep link `/sessions?id=...`, decoded by useSearchParams) cannot rewrite the
* route: '/' and '..' would otherwise be resolved by the URL parser into a
* different API path, '#' would truncate the builder's suffix and query string.
*/
const HOSTILE = "x/../../global/y#";
const ENCODED = "x%2F..%2F..%2Fglobal%2Fy%23";
describe("api path ids are percent-encoded", () => {
it("sessions.* keeps a hostile id inside the sessions/{id} segment", async () => {
const { api } = await apiPromise;
const builders: Array<(id: string) => string> = [
(id) => api.sessions.get(id),
(id) => api.sessions.events(id),
(id) => api.sessions.delete(id, "t"),
(id) => api.sessions.analysis(id),
(id) => api.sessions.vulnerabilityReport(id),
(id) => api.sessions.timeAttribution(id),
(id) => api.sessions.markFailed(id),
(id) => api.sessions.markSucceeded(id),
(id) => api.sessions.queueAction(id),
(id) => api.sessions.report(id),
(id) => api.sessions.annotations(id),
(id) => api.sessions.annotation(id, "lane"),
(id) => api.inspector.signals(id),
(id) => api.inspector.decisionGraph(id),
(id) => api.inspector.reducerVerification(id),
];
for (const build of builders) {
const url = build(HOSTILE);
expect(url.startsWith(`https://test.example/api/sessions/${ENCODED}`)).toBe(true);
expect(new URL(url).pathname.startsWith(`/api/sessions/${ENCODED}`)).toBe(true);
}
});
it("markFailed keeps its suffix after a '#'-bearing id", async () => {
const { api } = await apiPromise;
expect(api.sessions.markFailed(HOSTILE, "t")).toBe(
`https://test.example/api/sessions/${ENCODED}/mark-failed?tenantId=t`,
);
});
it("annotation lane and gather rule id are encoded too", async () => {
const { api } = await apiPromise;
expect(api.sessions.annotation("s", "a/b")).toBe("https://test.example/api/sessions/s/annotations/a%2Fb");
expect(api.rules.gatherRule(HOSTILE)).toBe(`https://test.example/api/rules/gather/${ENCODED}`);
});
it("no path builder lets an id escape its segment", async () => {
const { api } = await apiPromise;
type Builder = (...args: string[]) => string;
const walk = (node: unknown): Builder[] =>
typeof node === "function"
? [node as Builder]
: Object.values(node as Record<string, unknown>).flatMap(walk);
for (const fn of walk(api)) {
if (fn.length === 0) continue;
const url = fn(HOSTILE, HOSTILE);
// The WHATWG parser resolves a raw "x/../../global/y" into "/api/global/y";
// an encoded id survives verbatim and the fragment marker never appears.
expect(new URL(url).pathname, url).not.toMatch(/\/global\/y/);
expect(url).not.toContain("#");
}
});
});
+50 -50
View File
@@ -33,44 +33,44 @@ export const api = {
continuation: opts?.continuation,
})}`,
get: (sessionId: string, tenantId?: string) =>
`${API_BASE_URL}/api/sessions/${sessionId}${qs({ tenantId })}`,
`${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}${qs({ tenantId })}`,
events: (
sessionId: string,
tenantId?: string,
opts?: { pageSize?: number; continuation?: string },
) =>
`${API_BASE_URL}/api/sessions/${sessionId}/events${qs({
`${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/events${qs({
tenantId,
pageSize: opts?.pageSize?.toString(),
continuation: opts?.continuation,
})}`,
delete: (sessionId: string, tenantId: string) =>
`${API_BASE_URL}/api/sessions/${sessionId}${qs({ tenantId })}`,
`${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}${qs({ tenantId })}`,
analysis: (sessionId: string, tenantId?: string, reanalyze?: boolean) =>
`${API_BASE_URL}/api/sessions/${sessionId}/analysis${qs({ tenantId, ...(reanalyze ? { reanalyze: "true" } : {}) })}`,
`${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/analysis${qs({ tenantId, ...(reanalyze ? { reanalyze: "true" } : {}) })}`,
vulnerabilityReport: (sessionId: string, tenantId?: string, rescan?: boolean) =>
`${API_BASE_URL}/api/sessions/${sessionId}/vulnerability-report${qs({ tenantId, ...(rescan ? { rescan: "true" } : {}) })}`,
`${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/vulnerability-report${qs({ tenantId, ...(rescan ? { rescan: "true" } : {}) })}`,
// F1 time attribution: pre-computed breakdown row (null for pre-feature / non-terminal /
// Incomplete sessions — the lane is simply omitted, nothing is re-derived client-side).
timeAttribution: (sessionId: string, tenantId?: string) =>
`${API_BASE_URL}/api/sessions/${sessionId}/time-attribution${qs({ tenantId })}`,
`${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/time-attribution${qs({ tenantId })}`,
markFailed: (sessionId: string, tenantId?: string) =>
`${API_BASE_URL}/api/sessions/${sessionId}/mark-failed${qs({ tenantId })}`,
`${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/mark-failed${qs({ tenantId })}`,
markSucceeded: (sessionId: string, tenantId?: string) =>
`${API_BASE_URL}/api/sessions/${sessionId}/mark-succeeded${qs({ tenantId })}`,
`${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/mark-succeeded${qs({ tenantId })}`,
// POST { type, reason } — queues a ServerAction delivered with the agent's next ingest
// response (e.g. request_diagnostics for the "Collect Logs" button).
queueAction: (sessionId: string, tenantId?: string) =>
`${API_BASE_URL}/api/sessions/${sessionId}/actions${qs({ tenantId })}`,
`${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/actions${qs({ tenantId })}`,
report: (sessionId: string, tenantId?: string) =>
`${API_BASE_URL}/api/sessions/${sessionId}/report${qs({ tenantId })}`,
`${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/report${qs({ tenantId })}`,
// GET — all annotation lanes visible to the caller (backend hides the globaladmin
// lane from callers without global scope).
annotations: (sessionId: string, tenantId?: string) =>
`${API_BASE_URL}/api/sessions/${sessionId}/annotations${qs({ tenantId })}`,
`${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/annotations${qs({ tenantId })}`,
// PUT { verdict, note } — upserts one lane; both fields null/empty clears it.
annotation: (sessionId: string, lane: string, tenantId?: string) =>
`${API_BASE_URL}/api/sessions/${sessionId}/annotations/${lane}${qs({ tenantId })}`,
`${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/annotations/${encodeURIComponent(lane)}${qs({ tenantId })}`,
quickSearch: (q: string) =>
`${API_BASE_URL}/api/search/quick${qs({ q })}`,
// Server-side free-text search (dashboard search box) — substring across the same
@@ -92,11 +92,11 @@ export const api = {
// ── Inspector v1 (global admin only — Plan §M6) ───────────────────────────
inspector: {
signals: (sessionId: string, opts?: { tenantId?: string; maxResults?: number }) =>
`${API_BASE_URL}/api/sessions/${sessionId}/signals${qs({ tenantId: opts?.tenantId, maxResults: opts?.maxResults?.toString() })}`,
`${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/signals${qs({ tenantId: opts?.tenantId, maxResults: opts?.maxResults?.toString() })}`,
decisionGraph: (sessionId: string, tenantId?: string) =>
`${API_BASE_URL}/api/sessions/${sessionId}/decision-graph${qs({ tenantId })}`,
`${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/decision-graph${qs({ tenantId })}`,
reducerVerification: (sessionId: string, tenantId?: string) =>
`${API_BASE_URL}/api/sessions/${sessionId}/reducer-verification${qs({ tenantId })}`,
`${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/reducer-verification${qs({ tenantId })}`,
},
// ── Global Sessions (global admin) ────────────────────────────────────────
@@ -131,31 +131,31 @@ export const api = {
// ── Config ────────────────────────────────────────────────────────────────
config: {
all: () => `${API_BASE_URL}/api/config/all`,
tenant: (tenantId: string) => `${API_BASE_URL}/api/config/${tenantId}`,
tenant: (tenantId: string) => `${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}`,
// Same PUT, labelled: the session-detail Collect Logs quick-config dialog. The backend
// maps the intent to write source "portal-collect-logs" and emits the dedicated
// CollectLogsQuickConfigEnabled ops event instead of the generic DiagnosticsUploadEnabled.
tenantCollectLogsQuickConfig: (tenantId: string) => `${API_BASE_URL}/api/config/${tenantId}?intent=collect-logs`,
tenantCollectLogsQuickConfig: (tenantId: string) => `${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}?intent=collect-logs`,
/**
* PATCH — transactional field-level config write (TenantAdminOrGA, own row):
* { fields: { <wireFieldName>: value | null, ... }, reason?: string }. The backend
* verifies exactly those fields changed and rolls back on drift; every write snapshots
* to ConfigurationBackups first. The Settings sections save through this per section.
*/
fields: (tenantId: string) => `${API_BASE_URL}/api/config/${tenantId}/fields`,
featureFlags: (tenantId: string) => `${API_BASE_URL}/api/config/${tenantId}/feature-flags`,
fields: (tenantId: string) => `${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}/fields`,
featureFlags: (tenantId: string) => `${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}/feature-flags`,
/** PATCH — GA-only plan/trial mutation: { planTier?, trialExpiresUtc?: ISO | null }. */
plan: (tenantId: string) => `${API_BASE_URL}/api/config/${tenantId}/plan`,
plan: (tenantId: string) => `${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}/plan`,
/** POST — tenant-admin self-service 30-day Pro trial (once per tenant, 409 after). */
trial: (tenantId: string) => `${API_BASE_URL}/api/config/${tenantId}/trial`,
trial: (tenantId: string) => `${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}/trial`,
autopilotConsentUrl: (tenantId: string, redirectUri: string) =>
`${API_BASE_URL}/api/config/${tenantId}/autopilot-device-validation/consent-url${qs({ redirectUri })}`,
`${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}/autopilot-device-validation/consent-url${qs({ redirectUri })}`,
autopilotConsentStatus: (tenantId: string) =>
`${API_BASE_URL}/api/config/${tenantId}/autopilot-device-validation/consent-status`,
`${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}/autopilot-device-validation/consent-status`,
autopilotConsentFailure: (tenantId: string) =>
`${API_BASE_URL}/api/config/${tenantId}/autopilot-device-validation/consent-failure`,
`${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}/autopilot-device-validation/consent-failure`,
autopilotConsentSuccess: (tenantId: string) =>
`${API_BASE_URL}/api/config/${tenantId}/autopilot-device-validation/consent-success`,
`${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}/autopilot-device-validation/consent-success`,
/**
* Probe whether the app's core validation permission is already effectively granted in
* the tenant (pre-approved by someone with consent rights), so a rights-less admin can be
@@ -163,16 +163,16 @@ export const api = {
* { accessPresent, isTransient, requiredPermission }.
*/
autopilotAccessCheck: (tenantId: string) =>
`${API_BASE_URL}/api/config/${tenantId}/autopilot-device-validation/access-check`,
`${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}/autopilot-device-validation/access-check`,
/**
* POST — dual app-reg homing flip: { target: "primary" | "legacy", force?: boolean }.
* Tenant admins: target "primary" only, no force, kill-switch flag on, consent probe must
* pass. Global Admins: both directions, force skips the probe.
*/
appHoming: (tenantId: string) =>
`${API_BASE_URL}/api/config/${tenantId}/app-homing`,
`${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}/app-homing`,
testNotification: (tenantId: string) =>
`${API_BASE_URL}/api/config/${tenantId}/test-notification`,
`${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}/test-notification`,
latestVersions: (opts?: { refresh?: boolean }) =>
`${API_BASE_URL}/api/config/latest-versions${qs({ refresh: opts?.refresh ? "true" : undefined })}`,
},
@@ -181,13 +181,13 @@ export const api = {
graphPermissions: {
/** GET status of optional Graph add-on permissions for the tenant (admin-only). */
status: (tenantId: string) =>
`${API_BASE_URL}/api/tenants/${tenantId}/graph-permissions/status`,
`${API_BASE_URL}/api/tenants/${encodeURIComponent(tenantId)}/graph-permissions/status`,
/** POST to invalidate the backend's cached roles after a grant script run. */
refresh: (tenantId: string) =>
`${API_BASE_URL}/api/tenants/${tenantId}/graph-permissions/refresh`,
`${API_BASE_URL}/api/tenants/${encodeURIComponent(tenantId)}/graph-permissions/refresh`,
/** POST endpoint to resolve Intune script display names; body carries the refs array. */
scriptDisplayNames: (tenantId: string) =>
`${API_BASE_URL}/api/tenants/${tenantId}/scripts/display-names`,
`${API_BASE_URL}/api/tenants/${encodeURIComponent(tenantId)}/scripts/display-names`,
},
// ── Global Config (global admin) ──────────────────────────────────────────
@@ -201,17 +201,17 @@ export const api = {
// ── Tenants ───────────────────────────────────────────────────────────────
tenants: {
admins: (tenantId: string) =>
`${API_BASE_URL}/api/tenants/${tenantId}/admins`,
`${API_BASE_URL}/api/tenants/${encodeURIComponent(tenantId)}/admins`,
admin: (tenantId: string, adminUpn: string) =>
`${API_BASE_URL}/api/tenants/${tenantId}/admins/${encodeURIComponent(adminUpn)}`,
`${API_BASE_URL}/api/tenants/${encodeURIComponent(tenantId)}/admins/${encodeURIComponent(adminUpn)}`,
adminAction: (tenantId: string, adminUpn: string, action: string) =>
`${API_BASE_URL}/api/tenants/${tenantId}/admins/${encodeURIComponent(adminUpn)}/${action}`,
`${API_BASE_URL}/api/tenants/${encodeURIComponent(tenantId)}/admins/${encodeURIComponent(adminUpn)}/${encodeURIComponent(action)}`,
adminPermissions: (tenantId: string, adminUpn: string) =>
`${API_BASE_URL}/api/tenants/${tenantId}/admins/${encodeURIComponent(adminUpn)}/permissions`,
`${API_BASE_URL}/api/tenants/${encodeURIComponent(tenantId)}/admins/${encodeURIComponent(adminUpn)}/permissions`,
offboard: (tenantId: string) =>
`${API_BASE_URL}/api/tenants/${tenantId}/offboard`,
`${API_BASE_URL}/api/tenants/${encodeURIComponent(tenantId)}/offboard`,
offboardFeedback: (tenantId: string) =>
`${API_BASE_URL}/api/tenants/${tenantId}/offboard/feedback`,
`${API_BASE_URL}/api/tenants/${encodeURIComponent(tenantId)}/offboard/feedback`,
},
// ── Devices ───────────────────────────────────────────────────────────────
@@ -248,7 +248,7 @@ export const api = {
gather: (tenantId?: string) =>
`${API_BASE_URL}/api/rules/gather${qs({ tenantId })}`,
gatherRule: (ruleId: string, tenantId?: string) =>
`${API_BASE_URL}/api/rules/gather/${ruleId}${qs({ tenantId })}`,
`${API_BASE_URL}/api/rules/gather/${encodeURIComponent(ruleId)}${qs({ tenantId })}`,
globalGather: (tenantId?: string) =>
`${API_BASE_URL}/api/global/rules/gather${qs({ tenantId })}`,
// Cross-tenant edit/toggle/delete of an existing gather rule (Global Admin override) — see globalAnalyzeRule.
@@ -406,7 +406,7 @@ export const api = {
lookup: (tenantId: string, search: string) =>
`${API_BASE_URL}/api/progress/sessions/lookup${qs({ tenantId, search })}`,
sessionEvents: (sessionId: string, tenantId: string, serial: string) =>
`${API_BASE_URL}/api/progress/sessions/${sessionId}/events${qs({ tenantId, serial })}`,
`${API_BASE_URL}/api/progress/sessions/${encodeURIComponent(sessionId)}/events${qs({ tenantId, serial })}`,
},
// ── Bootstrap ─────────────────────────────────────────────────────────────
@@ -414,7 +414,7 @@ export const api = {
sessions: (tenantId?: string) =>
`${API_BASE_URL}/api/bootstrap/sessions${qs({ tenantId })}`,
session: (code: string, tenantId: string) =>
`${API_BASE_URL}/api/bootstrap/sessions/${code}${qs({ tenantId })}`,
`${API_BASE_URL}/api/bootstrap/sessions/${encodeURIComponent(code)}${qs({ tenantId })}`,
},
// ── Annotations (tenant-scoped overview list) ─────────────────────────────
@@ -467,7 +467,7 @@ export const api = {
downloadUrl: (blobName: string) =>
`${API_BASE_URL}/api/global/session-reports/download-url${qs({ blobName })}`,
note: (reportId: string) =>
`${API_BASE_URL}/api/global/session-reports/${reportId}/note`,
`${API_BASE_URL}/api/global/session-reports/${encodeURIComponent(reportId)}/note`,
},
// ── Diag Files Reports (no session context) ───────────────────────────────
@@ -553,11 +553,11 @@ export const api = {
// ── Notifications ─────────────────────────────────────────────────────────
notifications: {
list: () => `${API_BASE_URL}/api/global/notifications`,
dismiss: (id: string) => `${API_BASE_URL}/api/global/notifications/${id}/dismiss`,
dismiss: (id: string) => `${API_BASE_URL}/api/global/notifications/${encodeURIComponent(id)}/dismiss`,
dismissAll: () => `${API_BASE_URL}/api/global/notifications/dismiss-all`,
// Tenant-scoped persistent notifications (bell). TenantId resolved server-side from JWT.
tenantList: () => `${API_BASE_URL}/api/notifications`,
tenantDismiss: (id: string) => `${API_BASE_URL}/api/notifications/${id}/dismiss`,
tenantDismiss: (id: string) => `${API_BASE_URL}/api/notifications/${encodeURIComponent(id)}/dismiss`,
tenantDismissAll: () => `${API_BASE_URL}/api/notifications/dismiss-all`,
},
@@ -608,12 +608,12 @@ export const api = {
preview: {
whitelist: () => `${API_BASE_URL}/api/preview/whitelist`,
whitelistTenant: (tenantId: string) =>
`${API_BASE_URL}/api/preview/whitelist/${tenantId}`,
`${API_BASE_URL}/api/preview/whitelist/${encodeURIComponent(tenantId)}`,
sendWelcomeEmail: (tenantId: string) =>
`${API_BASE_URL}/api/preview/send-welcome-email/${tenantId}`,
`${API_BASE_URL}/api/preview/send-welcome-email/${encodeURIComponent(tenantId)}`,
notificationEmail: () => `${API_BASE_URL}/api/preview/notification-email`,
notificationEmailTenant: (tenantId: string) =>
`${API_BASE_URL}/api/preview/notification-email/${tenantId}`,
`${API_BASE_URL}/api/preview/notification-email/${encodeURIComponent(tenantId)}`,
/** GA/Reader: every stored notification address, keyed by lowercased tenant id. */
notificationEmails: () => `${API_BASE_URL}/api/preview/notification-emails`,
},
@@ -721,13 +721,13 @@ export const api = {
// ── Email Templates (GA) ──────────────────────────────────────────────────
emailTemplates: {
get: (kind: "welcome" | "farewell") =>
`${API_BASE_URL}/api/global/email-templates/${kind}`,
`${API_BASE_URL}/api/global/email-templates/${encodeURIComponent(kind)}`,
save: (kind: "welcome" | "farewell") =>
`${API_BASE_URL}/api/global/email-templates/${kind}`,
`${API_BASE_URL}/api/global/email-templates/${encodeURIComponent(kind)}`,
reset: (kind: "welcome" | "farewell") =>
`${API_BASE_URL}/api/global/email-templates/${kind}`,
`${API_BASE_URL}/api/global/email-templates/${encodeURIComponent(kind)}`,
sendTest: (kind: "welcome" | "farewell") =>
`${API_BASE_URL}/api/global/email-templates/${kind}/test`,
`${API_BASE_URL}/api/global/email-templates/${encodeURIComponent(kind)}/test`,
},
// ── Maintenance ───────────────────────────────────────────────────────────