create-safest-tools 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +71 -0
- package/bin/create-safest-tools.mjs +12 -0
- package/package.json +34 -0
- package/src/cli.mjs +128 -0
- package/src/config.mjs +151 -0
- package/src/scaffold.mjs +30 -0
- package/template/LICENSE +201 -0
- package/template/README.md +35 -0
- package/template/console/README.md +28 -0
- package/template/console/analytics/AnalyticsWorkspace.tsx +55 -0
- package/template/console/analytics/controller.ts +133 -0
- package/template/console/analytics/events.ts +33 -0
- package/template/console/analytics/store.ts +33 -0
- package/template/console/analytics/types.ts +38 -0
- package/template/console/appeals/AppealWorkspace.tsx +73 -0
- package/template/console/appeals/controller.ts +125 -0
- package/template/console/appeals/events.ts +33 -0
- package/template/console/appeals/store.ts +35 -0
- package/template/console/appeals/types.ts +37 -0
- package/template/console/assistant/AssistantWorkspace.tsx +133 -0
- package/template/console/assistant/types.ts +34 -0
- package/template/console/auth/AccountJourney.tsx +69 -0
- package/template/console/auth/InvitationAcceptance.tsx +145 -0
- package/template/console/auth/OAuthButtons.tsx +78 -0
- package/template/console/auth/OperatorLogin.tsx +110 -0
- package/template/console/auth/PasswordRecovery.tsx +132 -0
- package/template/console/auth/account-route.ts +15 -0
- package/template/console/auth/browser-navigation.ts +7 -0
- package/template/console/auth/events.ts +15 -0
- package/template/console/command/CommandCentre.tsx +119 -0
- package/template/console/command/controller.ts +267 -0
- package/template/console/command/events.ts +19 -0
- package/template/console/command/store.ts +36 -0
- package/template/console/command/types.ts +61 -0
- package/template/console/components/AnalystIdentity.tsx +25 -0
- package/template/console/components/PageHeader.tsx +27 -0
- package/template/console/configuration/ConfigurationDialog.tsx +200 -0
- package/template/console/configuration/ConfigurationWorkspace.tsx +66 -0
- package/template/console/configuration/FormBuilder.tsx +143 -0
- package/template/console/configuration/api.ts +40 -0
- package/template/console/configuration/events.ts +14 -0
- package/template/console/configuration/types.ts +79 -0
- package/template/console/lib/format.ts +49 -0
- package/template/console/lib/http.ts +96 -0
- package/template/console/main.tsx +225 -0
- package/template/console/operations/OperationsWorkspace.tsx +126 -0
- package/template/console/operations/controller.ts +237 -0
- package/template/console/operations/events.ts +37 -0
- package/template/console/operations/store.ts +32 -0
- package/template/console/operations/types.ts +107 -0
- package/template/console/people/PeopleWorkspace.tsx +81 -0
- package/template/console/people/types.ts +25 -0
- package/template/console/profile/ProfileWorkspace.tsx +136 -0
- package/template/console/profile/events.ts +9 -0
- package/template/console/profile/types.ts +6 -0
- package/template/console/quality/QualityWorkspace.tsx +87 -0
- package/template/console/quality/controller.ts +154 -0
- package/template/console/quality/events.ts +34 -0
- package/template/console/quality/store.ts +36 -0
- package/template/console/quality/types.ts +70 -0
- package/template/console/queues/QueueEditor.tsx +173 -0
- package/template/console/queues/QueueWorkspace.tsx +109 -0
- package/template/console/queues/controller.ts +194 -0
- package/template/console/queues/events.ts +34 -0
- package/template/console/queues/store.ts +37 -0
- package/template/console/queues/types.ts +102 -0
- package/template/console/registry/RegistryDialog.tsx +198 -0
- package/template/console/registry/RegistryWorkspace.tsx +108 -0
- package/template/console/registry/events.ts +14 -0
- package/template/console/registry/types.ts +52 -0
- package/template/console/reports/ReportDrawer.tsx +159 -0
- package/template/console/reports/ReportWorkspace.tsx +90 -0
- package/template/console/reports/controller.ts +517 -0
- package/template/console/reports/events.ts +42 -0
- package/template/console/reports/store.ts +39 -0
- package/template/console/reports/types.ts +230 -0
- package/template/console/settings/BrandEditor.tsx +126 -0
- package/template/console/settings/ChannelDialog.tsx +241 -0
- package/template/console/settings/SettingsWorkspace.tsx +155 -0
- package/template/console/settings/events.ts +19 -0
- package/template/console/settings/handoff.ts +22 -0
- package/template/console/settings/types.ts +93 -0
- package/template/console/shell/WorkspaceShell.tsx +160 -0
- package/template/console/shell/controller.ts +182 -0
- package/template/console/shell/events.ts +25 -0
- package/template/console/shell/navigation.ts +61 -0
- package/template/console/shell/store.ts +56 -0
- package/template/console/shell/types.ts +86 -0
- package/template/console/workflows/CreateWorkflowDialog.tsx +87 -0
- package/template/console/workflows/WorkflowCanvas.tsx +42 -0
- package/template/console/workflows/WorkflowDialogs.tsx +6 -0
- package/template/console/workflows/WorkflowStudio.tsx +331 -0
- package/template/console/workflows/WorkflowWorkspace.tsx +82 -0
- package/template/console/workflows/events.ts +17 -0
- package/template/console/workflows/graph.ts +156 -0
- package/template/console/workflows/templates.ts +70 -0
- package/template/console/workflows/types.ts +201 -0
- package/template/gitignore.template +18 -0
- package/template/migrations/0001_reports_foundation.sql +444 -0
- package/template/migrations/0002_human_report_loop.sql +65 -0
- package/template/migrations/0003_delivery_reliability.sql +18 -0
- package/template/migrations/0004_public_intake.sql +14 -0
- package/template/migrations/0005_operations_visibility.sql +24 -0
- package/template/migrations/0006_ai_governance.sql +198 -0
- package/template/migrations/0007_ai_release_gates.sql +6 -0
- package/template/migrations/0008_retention_analytics_exports.sql +52 -0
- package/template/migrations/0009_retention_derived_copies.sql +14 -0
- package/template/migrations/0010_ai_quality_controls.sql +26 -0
- package/template/migrations/0011_analyst_presence.sql +28 -0
- package/template/migrations/0012_queue_policies.sql +87 -0
- package/template/migrations/0013_routing_agents.sql +63 -0
- package/template/migrations/0014_webhook_enrichments.sql +122 -0
- package/template/migrations/0015_queue_owned_ai.sql +55 -0
- package/template/migrations/0016_operator_accounts.sql +67 -0
- package/template/migrations/0017_operator_profiles_and_recovery.sql +33 -0
- package/template/migrations/0018_platform_configuration.sql +382 -0
- package/template/migrations/0019_workflow_authoring_runtime.sql +372 -0
- package/template/migrations/0020_tasks_findings_assistant_budgets.sql +427 -0
- package/template/migrations/0021_abuse_evidence_operations.sql +324 -0
- package/template/migrations/0022_workflow_dispatch_operations.sql +42 -0
- package/template/migrations/0023_component_connection_execution.sql +75 -0
- package/template/migrations/0024_access_runtime_integrity.sql +72 -0
- package/template/migrations/0025_installation_timezone.sql +11 -0
- package/template/migrations/0026_ai_and_egress_execution_controls.sql +49 -0
- package/template/migrations/0027_prompt_and_ai_registry.sql +37 -0
- package/template/migrations/0028_step_attempt_ai_provenance.sql +13 -0
- package/template/migrations/0029_evidence_fetch_transport.sql +5 -0
- package/template/migrations/0030_evidence_dlq_incidents.sql +45 -0
- package/template/migrations/0031_shadow_quality_integrity.sql +7 -0
- package/template/migrations/0032_action_delivery_outbox.sql +55 -0
- package/template/migrations/0033_configuration_and_assistant_drafts.sql +43 -0
- package/template/migrations/0034_installation_integrations.sql +31 -0
- package/template/migrations/0035_workspace_governance.sql +21 -0
- package/template/migrations/0036_published_routing_baseline.sql +15 -0
- package/template/migrations/0037_builtin_phishing_specialist.sql +71 -0
- package/template/migrations/0038_remove_deprecated_enrichment_runtime.sql +228 -0
- package/template/migrations/0039_secure_reporting_channels.sql +45 -0
- package/template/migrations/0040_notification_only_reporting.sql +24 -0
- package/template/migrations/0041_better_auth_credentials.sql +17 -0
- package/template/package.json +47 -0
- package/template/public/_headers +27 -0
- package/template/public/app-icon-192.png +0 -0
- package/template/public/app-icon-512.png +0 -0
- package/template/public/brand-icon.svg +7 -0
- package/template/public/brand-tokens.css +80 -0
- package/template/public/console/auth-shell.js +19340 -0
- package/template/public/customer-brand.js +58 -0
- package/template/public/embed/embed.css +139 -0
- package/template/public/embed/embed.js +408 -0
- package/template/public/embed/index.html +91 -0
- package/template/public/favicon.svg +7 -0
- package/template/public/fonts/Manrope-Variable.ttf +0 -0
- package/template/public/fonts/Newsreader-Italic-Variable.ttf +0 -0
- package/template/public/fonts/Newsreader-Variable.ttf +0 -0
- package/template/public/index.html +123 -0
- package/template/public/logo-primary.svg +7 -0
- package/template/public/logo-reversed.svg +7 -0
- package/template/public/manifest.webmanifest +21 -0
- package/template/public/public-report.js +184 -0
- package/template/public/report/index.html +39 -0
- package/template/public/report/public-report.css +20 -0
- package/template/public/social-card.png +0 -0
- package/template/public/styles.css +1521 -0
- package/template/public/widget.css +80 -0
- package/template/public/widget.js +220 -0
- package/template/reports.config.example.json +38 -0
- package/template/reports.schema.json +89 -0
- package/template/scripts/reports-auth-onboarding.mjs +180 -0
- package/template/scripts/reports-backup.mjs +276 -0
- package/template/scripts/reports-cloudflare-preflight.mjs +152 -0
- package/template/scripts/reports-deploy.mjs +173 -0
- package/template/scripts/reports-plan.mjs +226 -0
- package/template/scripts/reports-restore.mjs +180 -0
- package/template/scripts/reports-secrets.mjs +73 -0
- package/template/scripts/reports-uninstall-plan.mjs +32 -0
- package/template/src/agent-executor.ts +330 -0
- package/template/src/ai-observability.ts +163 -0
- package/template/src/ai-registry-validation.ts +244 -0
- package/template/src/ai-registry.ts +292 -0
- package/template/src/api-cursor.ts +84 -0
- package/template/src/assistant.ts +554 -0
- package/template/src/audit.ts +39 -0
- package/template/src/backup-service.ts +269 -0
- package/template/src/budget-control.ts +127 -0
- package/template/src/component-executor.ts +845 -0
- package/template/src/configuration-registry.ts +550 -0
- package/template/src/connection-egress.ts +430 -0
- package/template/src/connection-oauth.ts +368 -0
- package/template/src/evidence-service.ts +335 -0
- package/template/src/human-tasks.ts +316 -0
- package/template/src/index.ts +4114 -0
- package/template/src/installation-admin.ts +301 -0
- package/template/src/intake-abuse.ts +156 -0
- package/template/src/platform-registry-validation.ts +367 -0
- package/template/src/platform-registry.ts +753 -0
- package/template/src/report-admin.ts +342 -0
- package/template/src/report-ai-quality.ts +390 -0
- package/template/src/report-ai-validation.ts +80 -0
- package/template/src/report-ai.ts +857 -0
- package/template/src/report-auth.ts +292 -0
- package/template/src/report-better-auth.ts +583 -0
- package/template/src/report-context-schema.ts +132 -0
- package/template/src/report-context.ts +126 -0
- package/template/src/report-crypto.ts +142 -0
- package/template/src/report-delivery.ts +579 -0
- package/template/src/report-form-validation.ts +51 -0
- package/template/src/report-governance.ts +510 -0
- package/template/src/report-http.ts +90 -0
- package/template/src/report-operations.ts +685 -0
- package/template/src/report-operator-accounts.ts +810 -0
- package/template/src/report-presence.ts +398 -0
- package/template/src/report-queue-validation.ts +309 -0
- package/template/src/report-queues.ts +478 -0
- package/template/src/report-repository.ts +972 -0
- package/template/src/report-router-agent.ts +328 -0
- package/template/src/report-router-validation.ts +81 -0
- package/template/src/report-routing.ts +178 -0
- package/template/src/report-turnstile.ts +142 -0
- package/template/src/report-types.ts +224 -0
- package/template/src/report-validation.ts +279 -0
- package/template/src/report-workflow-validation.ts +119 -0
- package/template/src/report-workflow.ts +886 -0
- package/template/src/shadow-quality.ts +278 -0
- package/template/src/workflow-actions.ts +782 -0
- package/template/src/workflow-compiler.ts +230 -0
- package/template/src/workflow-dynamic-runtime.ts +613 -0
- package/template/src/workflow-effects.ts +316 -0
- package/template/src/workflow-expressions.ts +191 -0
- package/template/src/workflow-platform-types.ts +121 -0
- package/template/src/workflow-platform-validation.ts +540 -0
- package/template/src/workflow-repository.ts +916 -0
- package/template/src/workflow-runs.ts +686 -0
- package/template/src/workflow-simulator.ts +211 -0
- package/template/src/workspace-branding.ts +128 -0
- package/template/src/workspace-governance.ts +279 -0
- package/template/tsconfig.console.json +23 -0
- package/template/tsconfig.json +22 -0
- package/template/vite.console.config.ts +20 -0
- package/template/worker-configuration.d.ts +65 -0
|
@@ -0,0 +1,810 @@
|
|
|
1
|
+
import { canonicalJson } from "./audit";
|
|
2
|
+
import { ApiError } from "./report-http";
|
|
3
|
+
import type { OperatorRole, OperatorSession } from "./report-types";
|
|
4
|
+
|
|
5
|
+
const encoder = new TextEncoder();
|
|
6
|
+
const SESSION_HOURS = 12;
|
|
7
|
+
const PROFILE_PICTURE_MAX_BYTES = 2 * 1024 * 1024;
|
|
8
|
+
const assignableRoles = new Set<OperatorRole>(["administrator", "workflow_author", "reviewer", "appeal_reviewer", "quality_reviewer", "operations", "auditor"]);
|
|
9
|
+
|
|
10
|
+
interface OperatorUserRow {
|
|
11
|
+
id: string;
|
|
12
|
+
email: string;
|
|
13
|
+
display_name: string;
|
|
14
|
+
avatar_url: string | null;
|
|
15
|
+
avatar_object_key: string | null;
|
|
16
|
+
avatar_uploaded_at: string | null;
|
|
17
|
+
roles_json: string;
|
|
18
|
+
status: "active" | "suspended";
|
|
19
|
+
accepted_at: string | null;
|
|
20
|
+
last_login_at: string | null;
|
|
21
|
+
created_at: string;
|
|
22
|
+
updated_at: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface AccountSessionRow extends OperatorUserRow {
|
|
26
|
+
session_id: string;
|
|
27
|
+
csrf_hash: string;
|
|
28
|
+
expires_at: string;
|
|
29
|
+
last_seen_at: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface InvitationRow {
|
|
33
|
+
id: string;
|
|
34
|
+
email: string;
|
|
35
|
+
role: OperatorRole;
|
|
36
|
+
status: "pending" | "accepted" | "revoked";
|
|
37
|
+
invited_by: string;
|
|
38
|
+
accepted_by: string | null;
|
|
39
|
+
expires_at: string;
|
|
40
|
+
accepted_at: string | null;
|
|
41
|
+
revoked_at: string | null;
|
|
42
|
+
created_at: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface OperatorInvitationDelivery {
|
|
46
|
+
id: string;
|
|
47
|
+
email: string;
|
|
48
|
+
role: OperatorRole;
|
|
49
|
+
status: "pending";
|
|
50
|
+
expiresAt: string;
|
|
51
|
+
inviteUrl: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface IssuedOperatorSession {
|
|
55
|
+
session: OperatorSession;
|
|
56
|
+
sessionToken: string;
|
|
57
|
+
csrfToken: string;
|
|
58
|
+
expiresAt: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function bytesToBase64Url(bytes: Uint8Array): string {
|
|
62
|
+
let binary = "";
|
|
63
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
64
|
+
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/u, "");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function base64UrlToBytes(value: string): Uint8Array {
|
|
68
|
+
const normalized = value.replaceAll("-", "+").replaceAll("_", "/");
|
|
69
|
+
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
|
|
70
|
+
const binary = atob(padded);
|
|
71
|
+
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function randomToken(byteLength = 32): string {
|
|
75
|
+
const bytes = new Uint8Array(byteLength);
|
|
76
|
+
crypto.getRandomValues(bytes);
|
|
77
|
+
return bytesToBase64Url(bytes);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function sha256(value: string): Promise<string> {
|
|
81
|
+
return bytesToBase64Url(new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(value))));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function constantTimeEqual(left: Uint8Array, right: Uint8Array): boolean {
|
|
85
|
+
if (left.byteLength !== right.byteLength) return false;
|
|
86
|
+
let difference = 0;
|
|
87
|
+
for (let index = 0; index < left.byteLength; index += 1) difference |= left[index]! ^ right[index]!;
|
|
88
|
+
return difference === 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function cleanEmail(value: unknown): string {
|
|
92
|
+
if (typeof value !== "string") throw new ApiError(400, "invalid_email", "Enter a valid email address.", { field: "email" });
|
|
93
|
+
const email = value.trim().toLowerCase();
|
|
94
|
+
if (email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(email)) {
|
|
95
|
+
throw new ApiError(400, "invalid_email", "Enter a valid email address.", { field: "email" });
|
|
96
|
+
}
|
|
97
|
+
return email;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function cleanDisplayName(value: unknown, fallback?: string): string {
|
|
101
|
+
const displayName = typeof value === "string" ? value.trim() : "";
|
|
102
|
+
const result = displayName || fallback || "";
|
|
103
|
+
if (!result || result.length > 100 || /[\u0000-\u001f\u007f]/u.test(result)) {
|
|
104
|
+
throw new ApiError(400, "invalid_display_name", "Display name must contain 1 to 100 characters.", { field: "display_name" });
|
|
105
|
+
}
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function cleanAvatarUrl(value: unknown): string | null {
|
|
110
|
+
if (value === undefined || value === null || value === "") return null;
|
|
111
|
+
if (typeof value !== "string" || value.length > 2_048) throw new ApiError(400, "invalid_avatar_url", "Profile picture URL is invalid.");
|
|
112
|
+
try {
|
|
113
|
+
const url = new URL(value);
|
|
114
|
+
if (url.protocol !== "https:" || url.username || url.password) throw new Error("invalid");
|
|
115
|
+
return url.href;
|
|
116
|
+
} catch {
|
|
117
|
+
throw new ApiError(400, "invalid_avatar_url", "Profile picture URL must be an HTTPS URL.");
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function parseRoles(value: string): OperatorRole[] {
|
|
122
|
+
try {
|
|
123
|
+
const parsed: unknown = JSON.parse(value);
|
|
124
|
+
if (!Array.isArray(parsed)) return [];
|
|
125
|
+
return parsed.filter((role): role is OperatorRole => typeof role === "string"
|
|
126
|
+
&& ["owner", "administrator", "workflow_author", "reviewer", "appeal_reviewer", "quality_reviewer", "operations", "auditor"].includes(role));
|
|
127
|
+
} catch {
|
|
128
|
+
return [];
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function permissionsForRoles(roles: OperatorRole[]): OperatorSession["permissions"] {
|
|
133
|
+
const owner = roles.includes("owner");
|
|
134
|
+
const administrator = roles.includes("administrator");
|
|
135
|
+
const reviewer = roles.includes("reviewer");
|
|
136
|
+
const appealReviewer = roles.includes("appeal_reviewer");
|
|
137
|
+
const workflowAuthor = roles.includes("workflow_author");
|
|
138
|
+
const qualityReviewer = roles.includes("quality_reviewer");
|
|
139
|
+
const operations = roles.includes("operations");
|
|
140
|
+
const auditor = roles.includes("auditor");
|
|
141
|
+
const configure = owner || administrator;
|
|
142
|
+
const read = configure || reviewer || appealReviewer || workflowAuthor || qualityReviewer || operations || auditor;
|
|
143
|
+
return {
|
|
144
|
+
readReports: read,
|
|
145
|
+
readEvidence: read,
|
|
146
|
+
readSensitiveEvidence: configure || reviewer || appealReviewer,
|
|
147
|
+
manageEvidence: configure,
|
|
148
|
+
claimReports: configure || reviewer,
|
|
149
|
+
reviewTasks: configure || reviewer || appealReviewer || qualityReviewer || operations,
|
|
150
|
+
assignTasks: configure,
|
|
151
|
+
messageReporters: configure || reviewer || appealReviewer,
|
|
152
|
+
messageParticipants: configure || reviewer || appealReviewer,
|
|
153
|
+
decideReports: configure || reviewer,
|
|
154
|
+
reassignReports: configure || reviewer,
|
|
155
|
+
approveActions: configure || reviewer,
|
|
156
|
+
operateActions: configure || operations,
|
|
157
|
+
reviewAppeals: configure || appealReviewer,
|
|
158
|
+
decideAppeals: configure || appealReviewer,
|
|
159
|
+
reviewQuality: configure || reviewer || qualityReviewer,
|
|
160
|
+
runShadow: configure || workflowAuthor || qualityReviewer,
|
|
161
|
+
runEnrichments: configure || workflowAuthor || reviewer || qualityReviewer,
|
|
162
|
+
manageForms: configure,
|
|
163
|
+
managePolicies: configure,
|
|
164
|
+
manageTemplates: configure,
|
|
165
|
+
manageQueues: configure,
|
|
166
|
+
manageRouting: configure,
|
|
167
|
+
manageWorkflowPins: configure,
|
|
168
|
+
manageConnections: configure,
|
|
169
|
+
publishConnections: configure,
|
|
170
|
+
manageComponents: configure || workflowAuthor,
|
|
171
|
+
publishComponents: configure,
|
|
172
|
+
manageWorkflows: configure || workflowAuthor,
|
|
173
|
+
publishWorkflows: configure,
|
|
174
|
+
manageAi: configure,
|
|
175
|
+
manageBudgets: configure,
|
|
176
|
+
useAssistant: read,
|
|
177
|
+
readRuns: read,
|
|
178
|
+
operateRuns: configure || operations,
|
|
179
|
+
searchWorkspace: read,
|
|
180
|
+
readAnalytics: read,
|
|
181
|
+
readAi: read,
|
|
182
|
+
readOperations: configure || operations || auditor,
|
|
183
|
+
operateSystem: configure || operations,
|
|
184
|
+
manageConfiguration: configure,
|
|
185
|
+
managePeople: configure,
|
|
186
|
+
manageInstallation: owner,
|
|
187
|
+
viewAudit: configure || auditor,
|
|
188
|
+
exportData: configure || auditor,
|
|
189
|
+
manageGovernance: configure,
|
|
190
|
+
manageIntegrations: configure,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function operatorAvatarUrl(user: OperatorUserRow): string | undefined {
|
|
195
|
+
if (user.avatar_object_key) {
|
|
196
|
+
const version = user.avatar_uploaded_at ?? user.updated_at;
|
|
197
|
+
return `/v1/profile-pictures/${encodeURIComponent(user.id)}?v=${encodeURIComponent(version)}`;
|
|
198
|
+
}
|
|
199
|
+
return user.avatar_url ?? undefined;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function operatorSession(
|
|
203
|
+
user: OperatorUserRow,
|
|
204
|
+
authorization?: { roles: OperatorRole[]; queueScope: string[] | null; fieldScope: Record<string, string[]> | null },
|
|
205
|
+
authMethod: OperatorSession["authMethod"] = "cloudflare_access",
|
|
206
|
+
): OperatorSession {
|
|
207
|
+
const roles = authorization?.roles ?? parseRoles(user.roles_json);
|
|
208
|
+
const avatarUrl = operatorAvatarUrl(user);
|
|
209
|
+
return {
|
|
210
|
+
actor: { type: "operator_user", id: user.id },
|
|
211
|
+
authMethod,
|
|
212
|
+
verified: true,
|
|
213
|
+
displayName: user.display_name,
|
|
214
|
+
email: user.email,
|
|
215
|
+
...(avatarUrl ? { avatarUrl } : {}),
|
|
216
|
+
roles,
|
|
217
|
+
permissions: permissionsForRoles(roles),
|
|
218
|
+
queueScope: authorization?.queueScope ?? null,
|
|
219
|
+
fieldScope: authorization?.fieldScope ?? null,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function operatorAuthorization(db: D1Database, user: OperatorUserRow): Promise<{
|
|
224
|
+
roles: OperatorRole[];
|
|
225
|
+
queueScope: string[] | null;
|
|
226
|
+
fieldScope: Record<string, string[]> | null;
|
|
227
|
+
}> {
|
|
228
|
+
const baseRoles = parseRoles(user.roles_json);
|
|
229
|
+
if (baseRoles.includes("owner")) return { roles: baseRoles, queueScope: null, fieldScope: null };
|
|
230
|
+
const bindings = await db.prepare(`
|
|
231
|
+
SELECT role, queue_scope_json AS queueScopeJson, field_scope_json AS fieldScopeJson
|
|
232
|
+
FROM role_bindings
|
|
233
|
+
WHERE installation_id = 'default' AND principal_type = 'operator_user'
|
|
234
|
+
AND principal_id = ?1 AND revoked_at IS NULL
|
|
235
|
+
ORDER BY created_at, id
|
|
236
|
+
`).bind(user.id).all<{ role: string; queueScopeJson: string; fieldScopeJson: string }>();
|
|
237
|
+
if (!bindings.results.length) return { roles: baseRoles, queueScope: null, fieldScope: null };
|
|
238
|
+
const roles = [...new Set(bindings.results.map((binding) => binding.role)
|
|
239
|
+
.filter((role): role is OperatorRole => ["administrator", "workflow_author", "reviewer", "appeal_reviewer", "quality_reviewer", "operations", "auditor"].includes(role)))];
|
|
240
|
+
const queueIds = new Set<string>();
|
|
241
|
+
let globalQueues = false;
|
|
242
|
+
const mergedFields: Record<string, string[]> = {};
|
|
243
|
+
let globalFields = false;
|
|
244
|
+
for (const binding of bindings.results) {
|
|
245
|
+
try {
|
|
246
|
+
const parsed = JSON.parse(binding.queueScopeJson) as unknown;
|
|
247
|
+
if (!Array.isArray(parsed) || parsed.length === 0) globalQueues = true;
|
|
248
|
+
else for (const queueId of parsed) if (typeof queueId === "string") queueIds.add(queueId);
|
|
249
|
+
} catch {
|
|
250
|
+
globalQueues = true;
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
const parsed = JSON.parse(binding.fieldScopeJson) as unknown;
|
|
254
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || Object.keys(parsed).length === 0) globalFields = true;
|
|
255
|
+
else for (const [resource, fields] of Object.entries(parsed as Record<string, unknown>)) {
|
|
256
|
+
if (Array.isArray(fields)) mergedFields[resource] = [...new Set([...(mergedFields[resource] ?? []), ...fields.filter((field): field is string => typeof field === "string")])];
|
|
257
|
+
}
|
|
258
|
+
} catch {
|
|
259
|
+
globalFields = true;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return {
|
|
263
|
+
roles: roles.length ? roles : baseRoles,
|
|
264
|
+
queueScope: globalQueues ? null : [...queueIds].sort(),
|
|
265
|
+
fieldScope: globalFields ? null : mergedFields,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Resolves a Better Auth identity into Safest Resolve's product authorization model.
|
|
271
|
+
* A first sign-in may consume the signed invitation admitted by report-better-auth;
|
|
272
|
+
* returning users are resolved by their immutable, verified email address.
|
|
273
|
+
*/
|
|
274
|
+
export async function resolveBetterAuthOperator(
|
|
275
|
+
db: D1Database,
|
|
276
|
+
identity: { email: string; name: string; image?: string | null },
|
|
277
|
+
invitationId: string | null,
|
|
278
|
+
): Promise<OperatorSession | null> {
|
|
279
|
+
const email = cleanEmail(identity.email);
|
|
280
|
+
let user = await db.prepare(`SELECT * FROM operator_users WHERE email = ?1 LIMIT 1`)
|
|
281
|
+
.bind(email).first<OperatorUserRow>();
|
|
282
|
+
if (user) {
|
|
283
|
+
if (user.status !== "active") return null;
|
|
284
|
+
return operatorSession(user, await operatorAuthorization(db, user), "better_auth");
|
|
285
|
+
}
|
|
286
|
+
if (!invitationId) return null;
|
|
287
|
+
const timestamp = new Date().toISOString();
|
|
288
|
+
const invitation = await db.prepare(`
|
|
289
|
+
SELECT id, email, role, status, invited_by, accepted_by, expires_at, accepted_at, revoked_at, created_at
|
|
290
|
+
FROM operator_invitations
|
|
291
|
+
WHERE id = ?1 AND status = 'pending' AND expires_at > ?2
|
|
292
|
+
LIMIT 1
|
|
293
|
+
`).bind(invitationId, timestamp).first<InvitationRow>();
|
|
294
|
+
if (!invitation || invitation.email.trim().toLowerCase() !== email) return null;
|
|
295
|
+
const displayName = cleanDisplayName(identity.name, email.split("@", 1)[0]);
|
|
296
|
+
const avatarUrl = cleanAvatarUrl(identity.image);
|
|
297
|
+
user = {
|
|
298
|
+
id: crypto.randomUUID(),
|
|
299
|
+
email,
|
|
300
|
+
display_name: displayName,
|
|
301
|
+
avatar_url: avatarUrl,
|
|
302
|
+
avatar_object_key: null,
|
|
303
|
+
avatar_uploaded_at: null,
|
|
304
|
+
roles_json: canonicalJson([invitation.role]),
|
|
305
|
+
status: "active",
|
|
306
|
+
accepted_at: timestamp,
|
|
307
|
+
last_login_at: timestamp,
|
|
308
|
+
created_at: timestamp,
|
|
309
|
+
updated_at: timestamp,
|
|
310
|
+
};
|
|
311
|
+
try {
|
|
312
|
+
await db.batch([
|
|
313
|
+
db.prepare(`
|
|
314
|
+
INSERT INTO operator_users (
|
|
315
|
+
id, email, display_name, avatar_url, roles_json, status,
|
|
316
|
+
invited_by, accepted_at, last_login_at, created_at, updated_at
|
|
317
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, 'active', ?6, ?7, ?7, ?7, ?7)
|
|
318
|
+
`).bind(
|
|
319
|
+
user.id, user.email, user.display_name, user.avatar_url, user.roles_json,
|
|
320
|
+
invitation.invited_by, timestamp,
|
|
321
|
+
),
|
|
322
|
+
db.prepare(`
|
|
323
|
+
UPDATE operator_invitations
|
|
324
|
+
SET status = 'accepted', accepted_by = ?2, accepted_at = ?3
|
|
325
|
+
WHERE id = ?1 AND status = 'pending' AND expires_at > ?3
|
|
326
|
+
`).bind(invitation.id, user.id, timestamp),
|
|
327
|
+
db.prepare(`
|
|
328
|
+
INSERT INTO role_bindings (
|
|
329
|
+
id, installation_id, principal_type, principal_id, role,
|
|
330
|
+
queue_scope_json, field_scope_json, created_by, created_at
|
|
331
|
+
) VALUES (?1, 'default', 'operator_user', ?2, ?3, '[]', '{}', ?4, ?5)
|
|
332
|
+
`).bind(crypto.randomUUID(), user.id, invitation.role, invitation.invited_by, timestamp),
|
|
333
|
+
auditEvent(db, "operator.invitation_accepted", user.id, "operator_user", user.id, {
|
|
334
|
+
invitation_id: invitation.id,
|
|
335
|
+
role: invitation.role,
|
|
336
|
+
auth_method: "better_auth",
|
|
337
|
+
}, timestamp),
|
|
338
|
+
]);
|
|
339
|
+
} catch {
|
|
340
|
+
const raced = await db.prepare(`SELECT * FROM operator_users WHERE email = ?1 AND status = 'active' LIMIT 1`)
|
|
341
|
+
.bind(email).first<OperatorUserRow>();
|
|
342
|
+
if (!raced) return null;
|
|
343
|
+
user = raced;
|
|
344
|
+
}
|
|
345
|
+
return operatorSession(user, await operatorAuthorization(db, user), "better_auth");
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function cookieMap(request: Request): Map<string, string> {
|
|
349
|
+
const result = new Map<string, string>();
|
|
350
|
+
for (const part of (request.headers.get("cookie") ?? "").split(";")) {
|
|
351
|
+
const separator = part.indexOf("=");
|
|
352
|
+
if (separator > 0) result.set(part.slice(0, separator).trim(), part.slice(separator + 1).trim());
|
|
353
|
+
}
|
|
354
|
+
return result;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function secureRequest(request: Request): boolean {
|
|
358
|
+
return new URL(request.url).protocol === "https:";
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function sessionCookieName(request: Request): string {
|
|
362
|
+
return secureRequest(request) ? "__Host-safest_infrastructure_session" : "safest_infrastructure_session";
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function csrfCookieName(request: Request): string {
|
|
366
|
+
return secureRequest(request) ? "__Host-safest_csrf" : "safest_csrf";
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function cookie(name: string, value: string, request: Request, maxAge: number, httpOnly: boolean): string {
|
|
370
|
+
return `${name}=${value}; Path=/; Max-Age=${maxAge}; SameSite=Strict${httpOnly ? "; HttpOnly" : ""}${secureRequest(request) ? "; Secure" : ""}`;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export function appendOperatorSessionCookies(response: Response, issued: IssuedOperatorSession, request: Request): Response {
|
|
374
|
+
const headers = new Headers(response.headers);
|
|
375
|
+
const maxAge = Math.max(0, Math.floor((Date.parse(issued.expiresAt) - Date.now()) / 1_000));
|
|
376
|
+
headers.append("set-cookie", cookie(sessionCookieName(request), issued.sessionToken, request, maxAge, true));
|
|
377
|
+
headers.append("set-cookie", cookie(csrfCookieName(request), issued.csrfToken, request, maxAge, false));
|
|
378
|
+
return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export function clearOperatorSessionCookies(response: Response, request: Request): Response {
|
|
382
|
+
const headers = new Headers(response.headers);
|
|
383
|
+
headers.append("set-cookie", cookie(sessionCookieName(request), "", request, 0, true));
|
|
384
|
+
headers.append("set-cookie", cookie(csrfCookieName(request), "", request, 0, false));
|
|
385
|
+
return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function issueSession(db: D1Database, user: OperatorUserRow): Promise<IssuedOperatorSession> {
|
|
389
|
+
const createdAt = new Date().toISOString();
|
|
390
|
+
const expiresAt = new Date(Date.parse(createdAt) + SESSION_HOURS * 3_600_000).toISOString();
|
|
391
|
+
const sessionToken = randomToken();
|
|
392
|
+
const csrfToken = randomToken();
|
|
393
|
+
await db.prepare(`
|
|
394
|
+
INSERT INTO infrastructure_owner_sessions (id, user_id, token_hash, csrf_hash, expires_at, last_seen_at, created_at)
|
|
395
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6)
|
|
396
|
+
`).bind(crypto.randomUUID(), user.id, await sha256(sessionToken), await sha256(csrfToken), expiresAt, createdAt).run();
|
|
397
|
+
return { session: operatorSession(user), sessionToken, csrfToken, expiresAt };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export async function resolveOperatorAccountSession(
|
|
401
|
+
request: Request,
|
|
402
|
+
db: D1Database,
|
|
403
|
+
): Promise<{ session: OperatorSession; csrfHash: string; sessionId: string } | null> {
|
|
404
|
+
const rawToken = cookieMap(request).get(sessionCookieName(request));
|
|
405
|
+
if (!rawToken || rawToken.length > 200) return null;
|
|
406
|
+
const now = new Date().toISOString();
|
|
407
|
+
const row = await db.prepare(`
|
|
408
|
+
SELECT u.*, s.id AS session_id, s.csrf_hash, s.expires_at, s.last_seen_at
|
|
409
|
+
FROM infrastructure_owner_sessions s JOIN operator_users u ON u.id = s.user_id
|
|
410
|
+
WHERE s.token_hash = ?1 AND s.revoked_at IS NULL AND s.expires_at > ?2 AND u.status = 'active'
|
|
411
|
+
`).bind(await sha256(rawToken), now).first<AccountSessionRow>();
|
|
412
|
+
if (!row) return null;
|
|
413
|
+
if (Date.parse(row.last_seen_at) < Date.now() - 15 * 60_000) {
|
|
414
|
+
await db.prepare(`UPDATE infrastructure_owner_sessions SET last_seen_at = ?2 WHERE id = ?1 AND revoked_at IS NULL`).bind(row.session_id, now).run();
|
|
415
|
+
}
|
|
416
|
+
return { session: operatorSession(row, await operatorAuthorization(db, row)), csrfHash: row.csrf_hash, sessionId: row.session_id };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export async function verifyOperatorCsrf(request: Request, csrfHash: string): Promise<void> {
|
|
420
|
+
if (["GET", "HEAD", "OPTIONS"].includes(request.method)) return;
|
|
421
|
+
const cookies = cookieMap(request);
|
|
422
|
+
const cookieToken = cookies.get(csrfCookieName(request));
|
|
423
|
+
const headerToken = request.headers.get("x-csrf-token");
|
|
424
|
+
if (!cookieToken || !headerToken || cookieToken !== headerToken || !constantTimeEqual(
|
|
425
|
+
base64UrlToBytes(await sha256(headerToken)),
|
|
426
|
+
base64UrlToBytes(csrfHash),
|
|
427
|
+
)) throw new ApiError(403, "csrf_validation_failed", "Refresh the page and try again.");
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export async function bootstrapOwner(
|
|
431
|
+
db: D1Database,
|
|
432
|
+
identity: { email: string; displayName: string; avatarUrl?: string },
|
|
433
|
+
): Promise<IssuedOperatorSession> {
|
|
434
|
+
const email = cleanEmail(identity.email);
|
|
435
|
+
const displayName = cleanDisplayName(identity.displayName, email.split("@", 1)[0]);
|
|
436
|
+
const avatarUrl = cleanAvatarUrl(identity.avatarUrl);
|
|
437
|
+
const timestamp = new Date().toISOString();
|
|
438
|
+
let user = await db.prepare(`SELECT * FROM operator_users WHERE email = ?1`).bind(email).first<OperatorUserRow>();
|
|
439
|
+
if (user) {
|
|
440
|
+
await db.prepare(`
|
|
441
|
+
UPDATE operator_users SET display_name = ?2, avatar_url = ?3, roles_json = '["owner"]', status = 'active', updated_at = ?4
|
|
442
|
+
WHERE id = ?1
|
|
443
|
+
`).bind(user.id, displayName, avatarUrl, timestamp).run();
|
|
444
|
+
user = { ...user, display_name: displayName, avatar_url: avatarUrl, roles_json: '["owner"]', status: "active", updated_at: timestamp };
|
|
445
|
+
} else {
|
|
446
|
+
user = {
|
|
447
|
+
id: crypto.randomUUID(), email, display_name: displayName, avatar_url: avatarUrl,
|
|
448
|
+
avatar_object_key: null, avatar_uploaded_at: null,
|
|
449
|
+
roles_json: '["owner"]', status: "active", accepted_at: timestamp,
|
|
450
|
+
last_login_at: timestamp, created_at: timestamp, updated_at: timestamp,
|
|
451
|
+
};
|
|
452
|
+
await db.prepare(`
|
|
453
|
+
INSERT INTO operator_users (
|
|
454
|
+
id, email, display_name, avatar_url, roles_json, status, accepted_at, last_login_at, created_at, updated_at
|
|
455
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, 'active', ?6, ?6, ?6, ?6)
|
|
456
|
+
`).bind(user.id, user.email, user.display_name, user.avatar_url, user.roles_json, timestamp).run();
|
|
457
|
+
}
|
|
458
|
+
await db.prepare(`UPDATE operator_users SET last_login_at = ?2 WHERE id = ?1`).bind(user.id, timestamp).run();
|
|
459
|
+
await auditEvent(db, "operator.owner_bootstrap", user.id, "cloudflare_access", user.id, { email }, timestamp).run();
|
|
460
|
+
return issueSession(db, user);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function auditEvent(
|
|
464
|
+
db: D1Database,
|
|
465
|
+
action: string,
|
|
466
|
+
actorId: string,
|
|
467
|
+
actorType: OperatorSession["actor"]["type"] | "system",
|
|
468
|
+
targetId: string,
|
|
469
|
+
details: unknown,
|
|
470
|
+
createdAt: string,
|
|
471
|
+
): D1PreparedStatement {
|
|
472
|
+
return db.prepare(`
|
|
473
|
+
INSERT INTO audit_events (
|
|
474
|
+
id, idempotency_key, action, actor_type, actor_id, target_type, target_id, details_json, created_at
|
|
475
|
+
) VALUES (?1, ?1, ?2, ?3, ?4, 'operator_user', ?5, ?6, ?7)
|
|
476
|
+
`).bind(crypto.randomUUID(), action, actorType, actorId, targetId, canonicalJson(details), createdAt);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function roleFromInput(value: unknown): OperatorRole {
|
|
480
|
+
if (typeof value !== "string" || !assignableRoles.has(value as OperatorRole)) {
|
|
481
|
+
throw new ApiError(400, "invalid_operator_role", "Choose a valid team role.", { field: "role" });
|
|
482
|
+
}
|
|
483
|
+
return value as OperatorRole;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
export async function createOperatorInvitation(
|
|
487
|
+
db: D1Database,
|
|
488
|
+
actor: OperatorSession,
|
|
489
|
+
input: Record<string, unknown>,
|
|
490
|
+
publicBaseUrl: string,
|
|
491
|
+
): Promise<OperatorInvitationDelivery> {
|
|
492
|
+
const email = cleanEmail(input.email);
|
|
493
|
+
const role = roleFromInput(input.role);
|
|
494
|
+
if (role === "administrator" && !actor.roles.includes("owner")) {
|
|
495
|
+
throw new ApiError(403, "owner_required", "Only the infrastructure owner can invite an administrator.");
|
|
496
|
+
}
|
|
497
|
+
if (!actor.roles.includes("owner") && !actor.roles.includes("administrator")) {
|
|
498
|
+
throw new ApiError(403, "operator_permission_denied", "Your role cannot invite team members.");
|
|
499
|
+
}
|
|
500
|
+
const existing = await db.prepare(`SELECT id, status FROM operator_users WHERE email = ?1`).bind(email)
|
|
501
|
+
.first<{ id: string; status: "active" | "suspended" }>();
|
|
502
|
+
if (existing) {
|
|
503
|
+
throw new ApiError(
|
|
504
|
+
409,
|
|
505
|
+
"operator_already_exists",
|
|
506
|
+
existing.status === "suspended"
|
|
507
|
+
? "This person already has a suspended Safest account. Reactivate that account instead of inviting them again."
|
|
508
|
+
: "This person already has an active Safest account.",
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
const timestamp = new Date().toISOString();
|
|
512
|
+
const expiresAt = new Date(Date.parse(timestamp) + 7 * 86_400_000).toISOString();
|
|
513
|
+
const rawToken = randomToken();
|
|
514
|
+
const id = crypto.randomUUID();
|
|
515
|
+
await db.batch([
|
|
516
|
+
db.prepare(`UPDATE operator_invitations SET status = 'revoked', revoked_at = ?2 WHERE email = ?1 AND status = 'pending'`).bind(email, timestamp),
|
|
517
|
+
db.prepare(`
|
|
518
|
+
INSERT INTO operator_invitations (id, email, role, token_hash, invited_by, expires_at, created_at)
|
|
519
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
|
520
|
+
`).bind(id, email, role, await sha256(rawToken), actor.actor.id, expiresAt, timestamp),
|
|
521
|
+
auditEvent(db, "operator.invited", actor.actor.id, actor.actor.type, id, { email, role, expires_at: expiresAt }, timestamp),
|
|
522
|
+
]);
|
|
523
|
+
return {
|
|
524
|
+
id, email, role, status: "pending", expiresAt,
|
|
525
|
+
inviteUrl: `${new URL(publicBaseUrl).origin}/#invite=${encodeURIComponent(rawToken)}`,
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
export async function listOperatorPeople(db: D1Database): Promise<Record<string, unknown>> {
|
|
530
|
+
const [users, invitations] = await Promise.all([
|
|
531
|
+
db.prepare(`
|
|
532
|
+
SELECT id, email, display_name, avatar_url, avatar_object_key, avatar_uploaded_at,
|
|
533
|
+
roles_json, status, accepted_at, last_login_at, created_at, updated_at
|
|
534
|
+
FROM operator_users ORDER BY CASE WHEN roles_json LIKE '%owner%' THEN 0 WHEN roles_json LIKE '%administrator%' THEN 1 ELSE 2 END, display_name, email
|
|
535
|
+
`).all<OperatorUserRow>(),
|
|
536
|
+
db.prepare(`
|
|
537
|
+
SELECT id, email, role, status, invited_by, accepted_by, expires_at, accepted_at, revoked_at, created_at
|
|
538
|
+
FROM operator_invitations ORDER BY created_at DESC, id DESC LIMIT 100
|
|
539
|
+
`).all<InvitationRow>(),
|
|
540
|
+
]);
|
|
541
|
+
return {
|
|
542
|
+
users: users.results.map((user) => ({
|
|
543
|
+
id: user.id, email: user.email, displayName: user.display_name, avatarUrl: operatorAvatarUrl(user),
|
|
544
|
+
roles: parseRoles(user.roles_json), status: user.status, acceptedAt: user.accepted_at,
|
|
545
|
+
lastLoginAt: user.last_login_at, createdAt: user.created_at,
|
|
546
|
+
})),
|
|
547
|
+
invitations: invitations.results.map((invitation) => ({
|
|
548
|
+
id: invitation.id, email: invitation.email, role: invitation.role,
|
|
549
|
+
status: invitation.status === "pending" && invitation.expires_at <= new Date().toISOString() ? "expired" : invitation.status,
|
|
550
|
+
invitedBy: invitation.invited_by, expiresAt: invitation.expires_at, acceptedAt: invitation.accepted_at,
|
|
551
|
+
createdAt: invitation.created_at,
|
|
552
|
+
})),
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
export async function revokeOperatorInvitation(db: D1Database, actor: OperatorSession, invitationId: string): Promise<void> {
|
|
557
|
+
if (!/^[0-9a-f-]{36}$/iu.test(invitationId)) throw new ApiError(400, "invalid_invitation", "Invitation ID is invalid.");
|
|
558
|
+
const timestamp = new Date().toISOString();
|
|
559
|
+
const result = await db.prepare(`
|
|
560
|
+
UPDATE operator_invitations SET status = 'revoked', revoked_at = ?2
|
|
561
|
+
WHERE id = ?1 AND status = 'pending'
|
|
562
|
+
`).bind(invitationId, timestamp).run();
|
|
563
|
+
if (!result.meta.changes) throw new ApiError(404, "invitation_not_found", "The pending invitation was not found.");
|
|
564
|
+
await auditEvent(db, "operator.invitation_revoked", actor.actor.id, actor.actor.type, invitationId, {}, timestamp).run();
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
export async function updateOperatorUser(
|
|
568
|
+
db: D1Database,
|
|
569
|
+
actor: OperatorSession,
|
|
570
|
+
userId: string,
|
|
571
|
+
input: Record<string, unknown>,
|
|
572
|
+
): Promise<Record<string, unknown>> {
|
|
573
|
+
const user = await db.prepare(`
|
|
574
|
+
SELECT id, roles_json AS rolesJson, status,
|
|
575
|
+
(SELECT queue_scope_json FROM role_bindings
|
|
576
|
+
WHERE principal_type = 'operator_user' AND principal_id = operator_users.id AND revoked_at IS NULL
|
|
577
|
+
ORDER BY created_at DESC, id DESC LIMIT 1) AS queueScopeJson
|
|
578
|
+
FROM operator_users WHERE id = ?1 LIMIT 1
|
|
579
|
+
`).bind(userId).first<{ id: string; rolesJson: string; status: "active" | "suspended"; queueScopeJson: string | null }>();
|
|
580
|
+
if (!user) throw new ApiError(404, "operator_not_found", "The operator does not exist.");
|
|
581
|
+
const existingRoles = parseRoles(user.rolesJson);
|
|
582
|
+
const rolesValue = input.roles ?? existingRoles;
|
|
583
|
+
if (!Array.isArray(rolesValue) || !rolesValue.length || rolesValue.length > 8) {
|
|
584
|
+
throw new ApiError(400, "operator_roles_invalid", "roles must contain one or more unique roles.");
|
|
585
|
+
}
|
|
586
|
+
const allRoles = new Set<OperatorRole>(["owner", ...assignableRoles]);
|
|
587
|
+
const roles = [...new Set(rolesValue.map((role) => {
|
|
588
|
+
if (typeof role !== "string" || !allRoles.has(role as OperatorRole)) throw new ApiError(400, "operator_roles_invalid", "roles contains an unsupported role.");
|
|
589
|
+
return role as OperatorRole;
|
|
590
|
+
}))];
|
|
591
|
+
const statusValue = input.status ?? user.status;
|
|
592
|
+
if (statusValue !== "active" && statusValue !== "suspended") throw new ApiError(400, "operator_status_invalid", "status must be active or suspended.");
|
|
593
|
+
let existingQueueScopes: unknown = [];
|
|
594
|
+
try { existingQueueScopes = JSON.parse(user.queueScopeJson ?? "[]") as unknown; } catch { existingQueueScopes = []; }
|
|
595
|
+
const queueScopeValue = input.queue_scopes ?? existingQueueScopes;
|
|
596
|
+
if (!Array.isArray(queueScopeValue) || queueScopeValue.length > 200 || queueScopeValue.some((value) => typeof value !== "string" || value.length < 1 || value.length > 100)) {
|
|
597
|
+
throw new ApiError(400, "operator_queue_scope_invalid", "queue_scopes must be a bounded array of queue IDs.");
|
|
598
|
+
}
|
|
599
|
+
const queueScopes = [...new Set(queueScopeValue as string[])];
|
|
600
|
+
const changingOwner = existingRoles.includes("owner") && (statusValue !== "active" || !roles.includes("owner"));
|
|
601
|
+
if ((roles.includes("owner") || changingOwner) && !actor.roles.includes("owner")) {
|
|
602
|
+
throw new ApiError(403, "owner_role_requires_owner", "Only an owner can grant or remove the owner role.");
|
|
603
|
+
}
|
|
604
|
+
if (changingOwner) {
|
|
605
|
+
const otherOwners = await db.prepare(`
|
|
606
|
+
SELECT COUNT(*) AS count FROM operator_users
|
|
607
|
+
WHERE id != ?1 AND status = 'active' AND roles_json LIKE '%"owner"%'
|
|
608
|
+
`).bind(userId).first<{ count: number }>();
|
|
609
|
+
if (Number(otherOwners?.count ?? 0) < 1) throw new ApiError(409, "last_owner_required", "The last active owner cannot be suspended or demoted.");
|
|
610
|
+
}
|
|
611
|
+
const timestamp = new Date().toISOString();
|
|
612
|
+
const statements: D1PreparedStatement[] = [
|
|
613
|
+
db.prepare(`UPDATE operator_users SET roles_json = ?2, status = ?3, updated_at = ?4 WHERE id = ?1`)
|
|
614
|
+
.bind(userId, canonicalJson(roles), statusValue, timestamp),
|
|
615
|
+
db.prepare(`UPDATE role_bindings SET revoked_at = ?2 WHERE principal_type = 'operator_user' AND principal_id = ?1 AND revoked_at IS NULL`)
|
|
616
|
+
.bind(userId, timestamp),
|
|
617
|
+
];
|
|
618
|
+
for (const role of roles) {
|
|
619
|
+
statements.push(db.prepare(`
|
|
620
|
+
INSERT INTO role_bindings (
|
|
621
|
+
id, installation_id, principal_type, principal_id, role,
|
|
622
|
+
queue_scope_json, field_scope_json, created_by, created_at
|
|
623
|
+
) VALUES (?1, 'default', 'operator_user', ?2, ?3, ?4, '{}', ?5, ?6)
|
|
624
|
+
`).bind(crypto.randomUUID(), userId, role, canonicalJson(queueScopes), actor.actor.id, timestamp));
|
|
625
|
+
}
|
|
626
|
+
if (statusValue === "suspended") {
|
|
627
|
+
statements.push(db.prepare(`UPDATE infrastructure_owner_sessions SET revoked_at = ?2 WHERE user_id = ?1 AND revoked_at IS NULL`).bind(userId, timestamp));
|
|
628
|
+
}
|
|
629
|
+
statements.push(auditEvent(db, "operator.access_updated", actor.actor.id, actor.actor.type, userId, {
|
|
630
|
+
roles, status: statusValue, queueScopes,
|
|
631
|
+
}, timestamp));
|
|
632
|
+
await db.batch(statements);
|
|
633
|
+
return { id: userId, roles, status: statusValue, queueScopes };
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
export async function revokeOperatorSessions(db: D1Database, actor: OperatorSession, userId: string): Promise<number> {
|
|
637
|
+
const user = await db.prepare(`SELECT id FROM operator_users WHERE id = ?1 LIMIT 1`).bind(userId).first();
|
|
638
|
+
if (!user) throw new ApiError(404, "operator_not_found", "The operator does not exist.");
|
|
639
|
+
const timestamp = new Date().toISOString();
|
|
640
|
+
const result = await db.prepare(`UPDATE infrastructure_owner_sessions SET revoked_at = ?2 WHERE user_id = ?1 AND revoked_at IS NULL`)
|
|
641
|
+
.bind(userId, timestamp).run();
|
|
642
|
+
await auditEvent(db, "operator.sessions_revoked", actor.actor.id, actor.actor.type, userId, { count: result.meta.changes }, timestamp).run();
|
|
643
|
+
return Number(result.meta.changes ?? 0);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function requireAccountActor(session: OperatorSession): string {
|
|
647
|
+
if (session.actor.type !== "operator_user") {
|
|
648
|
+
throw new ApiError(403, "account_session_required", "Sign in with your Safest account to manage your profile.");
|
|
649
|
+
}
|
|
650
|
+
return session.actor.id;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
async function loadOperatorUser(db: D1Database, userId: string): Promise<OperatorUserRow> {
|
|
654
|
+
const user = await db.prepare(`SELECT * FROM operator_users WHERE id = ?1 AND status = 'active' LIMIT 1`)
|
|
655
|
+
.bind(userId).first<OperatorUserRow>();
|
|
656
|
+
if (!user) throw new ApiError(404, "operator_not_found", "The Safest account was not found.");
|
|
657
|
+
return user;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
export async function updateOperatorProfile(
|
|
661
|
+
db: D1Database,
|
|
662
|
+
session: OperatorSession,
|
|
663
|
+
input: Record<string, unknown>,
|
|
664
|
+
): Promise<OperatorSession> {
|
|
665
|
+
const userId = requireAccountActor(session);
|
|
666
|
+
const displayName = cleanDisplayName(input.display_name);
|
|
667
|
+
const timestamp = new Date().toISOString();
|
|
668
|
+
const results = await db.batch([
|
|
669
|
+
db.prepare(`UPDATE operator_users SET display_name = ?2, updated_at = ?3 WHERE id = ?1 AND status = 'active'`)
|
|
670
|
+
.bind(userId, displayName, timestamp),
|
|
671
|
+
auditEvent(db, "operator.profile_updated", userId, "operator_user", userId, { display_name: displayName }, timestamp),
|
|
672
|
+
]);
|
|
673
|
+
if (!(results[0]?.meta.changes ?? 0)) throw new ApiError(404, "operator_not_found", "The Safest account was not found.");
|
|
674
|
+
return operatorSession(await loadOperatorUser(db, userId));
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function profilePictureExtension(contentType: string): string | null {
|
|
678
|
+
return ({ "image/jpeg": "jpg", "image/png": "png", "image/webp": "webp" } as Record<string, string>)[contentType] ?? null;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
export function detectProfilePictureContentType(bytes: Uint8Array): "image/jpeg" | "image/png" | "image/webp" | null {
|
|
682
|
+
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return "image/jpeg";
|
|
683
|
+
if (bytes.length >= 8 && [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
|
|
684
|
+
.every((value, index) => bytes[index] === value)) return "image/png";
|
|
685
|
+
if (bytes.length >= 12 && new TextDecoder().decode(bytes.slice(0, 4)) === "RIFF"
|
|
686
|
+
&& new TextDecoder().decode(bytes.slice(8, 12)) === "WEBP") return "image/webp";
|
|
687
|
+
return null;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
async function boundedProfilePicture(request: Request): Promise<{ bytes: Uint8Array; contentType: string; extension: string }> {
|
|
691
|
+
const declaredLength = Number.parseInt(request.headers.get("content-length") ?? "0", 10);
|
|
692
|
+
if (Number.isFinite(declaredLength) && declaredLength > PROFILE_PICTURE_MAX_BYTES) {
|
|
693
|
+
throw new ApiError(413, "profile_picture_too_large", "Profile pictures must be 2 MB or smaller.");
|
|
694
|
+
}
|
|
695
|
+
const declaredType = request.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() ?? "";
|
|
696
|
+
const extension = profilePictureExtension(declaredType);
|
|
697
|
+
if (!extension) throw new ApiError(415, "unsupported_profile_picture", "Use a JPEG, PNG, or WebP profile picture.");
|
|
698
|
+
const bytes = new Uint8Array(await request.arrayBuffer());
|
|
699
|
+
if (!bytes.length || bytes.length > PROFILE_PICTURE_MAX_BYTES) {
|
|
700
|
+
throw new ApiError(bytes.length ? 413 : 400, bytes.length ? "profile_picture_too_large" : "profile_picture_empty",
|
|
701
|
+
bytes.length ? "Profile pictures must be 2 MB or smaller." : "Choose a profile picture to upload.");
|
|
702
|
+
}
|
|
703
|
+
if (detectProfilePictureContentType(bytes) !== declaredType) {
|
|
704
|
+
throw new ApiError(400, "profile_picture_type_mismatch", "The selected file does not match its image type.");
|
|
705
|
+
}
|
|
706
|
+
return { bytes, contentType: declaredType, extension };
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
export async function uploadOperatorProfilePicture(
|
|
710
|
+
db: D1Database,
|
|
711
|
+
bucket: R2Bucket,
|
|
712
|
+
session: OperatorSession,
|
|
713
|
+
request: Request,
|
|
714
|
+
): Promise<{ session: OperatorSession; oldObjectKey: string | null }> {
|
|
715
|
+
const userId = requireAccountActor(session);
|
|
716
|
+
const upload = await boundedProfilePicture(request);
|
|
717
|
+
const current = await loadOperatorUser(db, userId);
|
|
718
|
+
const objectKey = `operator-profile-pictures/${userId}/${crypto.randomUUID()}.${upload.extension}`;
|
|
719
|
+
const timestamp = new Date().toISOString();
|
|
720
|
+
await bucket.put(objectKey, upload.bytes, {
|
|
721
|
+
httpMetadata: { contentType: upload.contentType, cacheControl: "private, max-age=300" },
|
|
722
|
+
customMetadata: { operatorUserId: userId },
|
|
723
|
+
});
|
|
724
|
+
try {
|
|
725
|
+
await db.batch([
|
|
726
|
+
db.prepare(`
|
|
727
|
+
UPDATE operator_users
|
|
728
|
+
SET avatar_object_key = ?2, avatar_uploaded_at = ?3, avatar_url = NULL, updated_at = ?3
|
|
729
|
+
WHERE id = ?1 AND status = 'active'
|
|
730
|
+
`).bind(userId, objectKey, timestamp),
|
|
731
|
+
auditEvent(db, "operator.profile_picture_updated", userId, "operator_user", userId, {
|
|
732
|
+
content_type: upload.contentType,
|
|
733
|
+
size_bytes: upload.bytes.length,
|
|
734
|
+
}, timestamp),
|
|
735
|
+
]);
|
|
736
|
+
} catch (error) {
|
|
737
|
+
await bucket.delete(objectKey);
|
|
738
|
+
throw error;
|
|
739
|
+
}
|
|
740
|
+
return {
|
|
741
|
+
session: operatorSession({
|
|
742
|
+
...current,
|
|
743
|
+
avatar_url: null,
|
|
744
|
+
avatar_object_key: objectKey,
|
|
745
|
+
avatar_uploaded_at: timestamp,
|
|
746
|
+
updated_at: timestamp,
|
|
747
|
+
}),
|
|
748
|
+
oldObjectKey: current.avatar_object_key,
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
export async function deleteOperatorProfilePicture(
|
|
753
|
+
db: D1Database,
|
|
754
|
+
session: OperatorSession,
|
|
755
|
+
): Promise<{ session: OperatorSession; oldObjectKey: string | null }> {
|
|
756
|
+
const userId = requireAccountActor(session);
|
|
757
|
+
const current = await loadOperatorUser(db, userId);
|
|
758
|
+
const timestamp = new Date().toISOString();
|
|
759
|
+
await db.batch([
|
|
760
|
+
db.prepare(`
|
|
761
|
+
UPDATE operator_users
|
|
762
|
+
SET avatar_object_key = NULL, avatar_uploaded_at = NULL, avatar_url = NULL, updated_at = ?2
|
|
763
|
+
WHERE id = ?1 AND status = 'active'
|
|
764
|
+
`).bind(userId, timestamp),
|
|
765
|
+
auditEvent(db, "operator.profile_picture_removed", userId, "operator_user", userId, {}, timestamp),
|
|
766
|
+
]);
|
|
767
|
+
return {
|
|
768
|
+
session: operatorSession({
|
|
769
|
+
...current,
|
|
770
|
+
avatar_url: null,
|
|
771
|
+
avatar_object_key: null,
|
|
772
|
+
avatar_uploaded_at: null,
|
|
773
|
+
updated_at: timestamp,
|
|
774
|
+
}),
|
|
775
|
+
oldObjectKey: current.avatar_object_key,
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
export async function operatorProfilePictureResponse(
|
|
780
|
+
db: D1Database,
|
|
781
|
+
bucket: R2Bucket,
|
|
782
|
+
userId: string,
|
|
783
|
+
headOnly = false,
|
|
784
|
+
): Promise<Response> {
|
|
785
|
+
if (!/^[0-9a-f-]{36}$/iu.test(userId)) throw new ApiError(404, "profile_picture_not_found", "The profile picture was not found.");
|
|
786
|
+
const row = await db.prepare(`SELECT avatar_object_key FROM operator_users WHERE id = ?1 AND status = 'active' LIMIT 1`)
|
|
787
|
+
.bind(userId).first<{ avatar_object_key: string | null }>();
|
|
788
|
+
if (!row?.avatar_object_key) throw new ApiError(404, "profile_picture_not_found", "The profile picture was not found.");
|
|
789
|
+
const object = headOnly ? await bucket.head(row.avatar_object_key) : await bucket.get(row.avatar_object_key);
|
|
790
|
+
if (!object) throw new ApiError(404, "profile_picture_not_found", "The profile picture was not found.");
|
|
791
|
+
const headers = new Headers();
|
|
792
|
+
object.writeHttpMetadata(headers);
|
|
793
|
+
headers.set("etag", object.httpEtag);
|
|
794
|
+
headers.set("content-length", String(object.size));
|
|
795
|
+
headers.set("cache-control", "private, max-age=300");
|
|
796
|
+
headers.set("cross-origin-resource-policy", "same-origin");
|
|
797
|
+
headers.set("x-content-type-options", "nosniff");
|
|
798
|
+
return new Response(headOnly ? null : (object as R2ObjectBody).body, { headers });
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
export async function logoutOperator(request: Request, db: D1Database): Promise<void> {
|
|
802
|
+
const account = await resolveOperatorAccountSession(request, db);
|
|
803
|
+
if (!account) return;
|
|
804
|
+
await verifyOperatorCsrf(request, account.csrfHash);
|
|
805
|
+
const timestamp = new Date().toISOString();
|
|
806
|
+
await db.batch([
|
|
807
|
+
db.prepare(`UPDATE infrastructure_owner_sessions SET revoked_at = ?2 WHERE id = ?1 AND revoked_at IS NULL`).bind(account.sessionId, timestamp),
|
|
808
|
+
auditEvent(db, "operator.logout", account.session.actor.id, account.session.actor.type, account.session.actor.id, {}, timestamp),
|
|
809
|
+
]);
|
|
810
|
+
}
|