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.
Files changed (240) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +71 -0
  3. package/bin/create-safest-tools.mjs +12 -0
  4. package/package.json +34 -0
  5. package/src/cli.mjs +128 -0
  6. package/src/config.mjs +151 -0
  7. package/src/scaffold.mjs +30 -0
  8. package/template/LICENSE +201 -0
  9. package/template/README.md +35 -0
  10. package/template/console/README.md +28 -0
  11. package/template/console/analytics/AnalyticsWorkspace.tsx +55 -0
  12. package/template/console/analytics/controller.ts +133 -0
  13. package/template/console/analytics/events.ts +33 -0
  14. package/template/console/analytics/store.ts +33 -0
  15. package/template/console/analytics/types.ts +38 -0
  16. package/template/console/appeals/AppealWorkspace.tsx +73 -0
  17. package/template/console/appeals/controller.ts +125 -0
  18. package/template/console/appeals/events.ts +33 -0
  19. package/template/console/appeals/store.ts +35 -0
  20. package/template/console/appeals/types.ts +37 -0
  21. package/template/console/assistant/AssistantWorkspace.tsx +133 -0
  22. package/template/console/assistant/types.ts +34 -0
  23. package/template/console/auth/AccountJourney.tsx +69 -0
  24. package/template/console/auth/InvitationAcceptance.tsx +145 -0
  25. package/template/console/auth/OAuthButtons.tsx +78 -0
  26. package/template/console/auth/OperatorLogin.tsx +110 -0
  27. package/template/console/auth/PasswordRecovery.tsx +132 -0
  28. package/template/console/auth/account-route.ts +15 -0
  29. package/template/console/auth/browser-navigation.ts +7 -0
  30. package/template/console/auth/events.ts +15 -0
  31. package/template/console/command/CommandCentre.tsx +119 -0
  32. package/template/console/command/controller.ts +267 -0
  33. package/template/console/command/events.ts +19 -0
  34. package/template/console/command/store.ts +36 -0
  35. package/template/console/command/types.ts +61 -0
  36. package/template/console/components/AnalystIdentity.tsx +25 -0
  37. package/template/console/components/PageHeader.tsx +27 -0
  38. package/template/console/configuration/ConfigurationDialog.tsx +200 -0
  39. package/template/console/configuration/ConfigurationWorkspace.tsx +66 -0
  40. package/template/console/configuration/FormBuilder.tsx +143 -0
  41. package/template/console/configuration/api.ts +40 -0
  42. package/template/console/configuration/events.ts +14 -0
  43. package/template/console/configuration/types.ts +79 -0
  44. package/template/console/lib/format.ts +49 -0
  45. package/template/console/lib/http.ts +96 -0
  46. package/template/console/main.tsx +225 -0
  47. package/template/console/operations/OperationsWorkspace.tsx +126 -0
  48. package/template/console/operations/controller.ts +237 -0
  49. package/template/console/operations/events.ts +37 -0
  50. package/template/console/operations/store.ts +32 -0
  51. package/template/console/operations/types.ts +107 -0
  52. package/template/console/people/PeopleWorkspace.tsx +81 -0
  53. package/template/console/people/types.ts +25 -0
  54. package/template/console/profile/ProfileWorkspace.tsx +136 -0
  55. package/template/console/profile/events.ts +9 -0
  56. package/template/console/profile/types.ts +6 -0
  57. package/template/console/quality/QualityWorkspace.tsx +87 -0
  58. package/template/console/quality/controller.ts +154 -0
  59. package/template/console/quality/events.ts +34 -0
  60. package/template/console/quality/store.ts +36 -0
  61. package/template/console/quality/types.ts +70 -0
  62. package/template/console/queues/QueueEditor.tsx +173 -0
  63. package/template/console/queues/QueueWorkspace.tsx +109 -0
  64. package/template/console/queues/controller.ts +194 -0
  65. package/template/console/queues/events.ts +34 -0
  66. package/template/console/queues/store.ts +37 -0
  67. package/template/console/queues/types.ts +102 -0
  68. package/template/console/registry/RegistryDialog.tsx +198 -0
  69. package/template/console/registry/RegistryWorkspace.tsx +108 -0
  70. package/template/console/registry/events.ts +14 -0
  71. package/template/console/registry/types.ts +52 -0
  72. package/template/console/reports/ReportDrawer.tsx +159 -0
  73. package/template/console/reports/ReportWorkspace.tsx +90 -0
  74. package/template/console/reports/controller.ts +517 -0
  75. package/template/console/reports/events.ts +42 -0
  76. package/template/console/reports/store.ts +39 -0
  77. package/template/console/reports/types.ts +230 -0
  78. package/template/console/settings/BrandEditor.tsx +126 -0
  79. package/template/console/settings/ChannelDialog.tsx +241 -0
  80. package/template/console/settings/SettingsWorkspace.tsx +155 -0
  81. package/template/console/settings/events.ts +19 -0
  82. package/template/console/settings/handoff.ts +22 -0
  83. package/template/console/settings/types.ts +93 -0
  84. package/template/console/shell/WorkspaceShell.tsx +160 -0
  85. package/template/console/shell/controller.ts +182 -0
  86. package/template/console/shell/events.ts +25 -0
  87. package/template/console/shell/navigation.ts +61 -0
  88. package/template/console/shell/store.ts +56 -0
  89. package/template/console/shell/types.ts +86 -0
  90. package/template/console/workflows/CreateWorkflowDialog.tsx +87 -0
  91. package/template/console/workflows/WorkflowCanvas.tsx +42 -0
  92. package/template/console/workflows/WorkflowDialogs.tsx +6 -0
  93. package/template/console/workflows/WorkflowStudio.tsx +331 -0
  94. package/template/console/workflows/WorkflowWorkspace.tsx +82 -0
  95. package/template/console/workflows/events.ts +17 -0
  96. package/template/console/workflows/graph.ts +156 -0
  97. package/template/console/workflows/templates.ts +70 -0
  98. package/template/console/workflows/types.ts +201 -0
  99. package/template/gitignore.template +18 -0
  100. package/template/migrations/0001_reports_foundation.sql +444 -0
  101. package/template/migrations/0002_human_report_loop.sql +65 -0
  102. package/template/migrations/0003_delivery_reliability.sql +18 -0
  103. package/template/migrations/0004_public_intake.sql +14 -0
  104. package/template/migrations/0005_operations_visibility.sql +24 -0
  105. package/template/migrations/0006_ai_governance.sql +198 -0
  106. package/template/migrations/0007_ai_release_gates.sql +6 -0
  107. package/template/migrations/0008_retention_analytics_exports.sql +52 -0
  108. package/template/migrations/0009_retention_derived_copies.sql +14 -0
  109. package/template/migrations/0010_ai_quality_controls.sql +26 -0
  110. package/template/migrations/0011_analyst_presence.sql +28 -0
  111. package/template/migrations/0012_queue_policies.sql +87 -0
  112. package/template/migrations/0013_routing_agents.sql +63 -0
  113. package/template/migrations/0014_webhook_enrichments.sql +122 -0
  114. package/template/migrations/0015_queue_owned_ai.sql +55 -0
  115. package/template/migrations/0016_operator_accounts.sql +67 -0
  116. package/template/migrations/0017_operator_profiles_and_recovery.sql +33 -0
  117. package/template/migrations/0018_platform_configuration.sql +382 -0
  118. package/template/migrations/0019_workflow_authoring_runtime.sql +372 -0
  119. package/template/migrations/0020_tasks_findings_assistant_budgets.sql +427 -0
  120. package/template/migrations/0021_abuse_evidence_operations.sql +324 -0
  121. package/template/migrations/0022_workflow_dispatch_operations.sql +42 -0
  122. package/template/migrations/0023_component_connection_execution.sql +75 -0
  123. package/template/migrations/0024_access_runtime_integrity.sql +72 -0
  124. package/template/migrations/0025_installation_timezone.sql +11 -0
  125. package/template/migrations/0026_ai_and_egress_execution_controls.sql +49 -0
  126. package/template/migrations/0027_prompt_and_ai_registry.sql +37 -0
  127. package/template/migrations/0028_step_attempt_ai_provenance.sql +13 -0
  128. package/template/migrations/0029_evidence_fetch_transport.sql +5 -0
  129. package/template/migrations/0030_evidence_dlq_incidents.sql +45 -0
  130. package/template/migrations/0031_shadow_quality_integrity.sql +7 -0
  131. package/template/migrations/0032_action_delivery_outbox.sql +55 -0
  132. package/template/migrations/0033_configuration_and_assistant_drafts.sql +43 -0
  133. package/template/migrations/0034_installation_integrations.sql +31 -0
  134. package/template/migrations/0035_workspace_governance.sql +21 -0
  135. package/template/migrations/0036_published_routing_baseline.sql +15 -0
  136. package/template/migrations/0037_builtin_phishing_specialist.sql +71 -0
  137. package/template/migrations/0038_remove_deprecated_enrichment_runtime.sql +228 -0
  138. package/template/migrations/0039_secure_reporting_channels.sql +45 -0
  139. package/template/migrations/0040_notification_only_reporting.sql +24 -0
  140. package/template/migrations/0041_better_auth_credentials.sql +17 -0
  141. package/template/package.json +47 -0
  142. package/template/public/_headers +27 -0
  143. package/template/public/app-icon-192.png +0 -0
  144. package/template/public/app-icon-512.png +0 -0
  145. package/template/public/brand-icon.svg +7 -0
  146. package/template/public/brand-tokens.css +80 -0
  147. package/template/public/console/auth-shell.js +19340 -0
  148. package/template/public/customer-brand.js +58 -0
  149. package/template/public/embed/embed.css +139 -0
  150. package/template/public/embed/embed.js +408 -0
  151. package/template/public/embed/index.html +91 -0
  152. package/template/public/favicon.svg +7 -0
  153. package/template/public/fonts/Manrope-Variable.ttf +0 -0
  154. package/template/public/fonts/Newsreader-Italic-Variable.ttf +0 -0
  155. package/template/public/fonts/Newsreader-Variable.ttf +0 -0
  156. package/template/public/index.html +123 -0
  157. package/template/public/logo-primary.svg +7 -0
  158. package/template/public/logo-reversed.svg +7 -0
  159. package/template/public/manifest.webmanifest +21 -0
  160. package/template/public/public-report.js +184 -0
  161. package/template/public/report/index.html +39 -0
  162. package/template/public/report/public-report.css +20 -0
  163. package/template/public/social-card.png +0 -0
  164. package/template/public/styles.css +1521 -0
  165. package/template/public/widget.css +80 -0
  166. package/template/public/widget.js +220 -0
  167. package/template/reports.config.example.json +38 -0
  168. package/template/reports.schema.json +89 -0
  169. package/template/scripts/reports-auth-onboarding.mjs +180 -0
  170. package/template/scripts/reports-backup.mjs +276 -0
  171. package/template/scripts/reports-cloudflare-preflight.mjs +152 -0
  172. package/template/scripts/reports-deploy.mjs +173 -0
  173. package/template/scripts/reports-plan.mjs +226 -0
  174. package/template/scripts/reports-restore.mjs +180 -0
  175. package/template/scripts/reports-secrets.mjs +73 -0
  176. package/template/scripts/reports-uninstall-plan.mjs +32 -0
  177. package/template/src/agent-executor.ts +330 -0
  178. package/template/src/ai-observability.ts +163 -0
  179. package/template/src/ai-registry-validation.ts +244 -0
  180. package/template/src/ai-registry.ts +292 -0
  181. package/template/src/api-cursor.ts +84 -0
  182. package/template/src/assistant.ts +554 -0
  183. package/template/src/audit.ts +39 -0
  184. package/template/src/backup-service.ts +269 -0
  185. package/template/src/budget-control.ts +127 -0
  186. package/template/src/component-executor.ts +845 -0
  187. package/template/src/configuration-registry.ts +550 -0
  188. package/template/src/connection-egress.ts +430 -0
  189. package/template/src/connection-oauth.ts +368 -0
  190. package/template/src/evidence-service.ts +335 -0
  191. package/template/src/human-tasks.ts +316 -0
  192. package/template/src/index.ts +4114 -0
  193. package/template/src/installation-admin.ts +301 -0
  194. package/template/src/intake-abuse.ts +156 -0
  195. package/template/src/platform-registry-validation.ts +367 -0
  196. package/template/src/platform-registry.ts +753 -0
  197. package/template/src/report-admin.ts +342 -0
  198. package/template/src/report-ai-quality.ts +390 -0
  199. package/template/src/report-ai-validation.ts +80 -0
  200. package/template/src/report-ai.ts +857 -0
  201. package/template/src/report-auth.ts +292 -0
  202. package/template/src/report-better-auth.ts +583 -0
  203. package/template/src/report-context-schema.ts +132 -0
  204. package/template/src/report-context.ts +126 -0
  205. package/template/src/report-crypto.ts +142 -0
  206. package/template/src/report-delivery.ts +579 -0
  207. package/template/src/report-form-validation.ts +51 -0
  208. package/template/src/report-governance.ts +510 -0
  209. package/template/src/report-http.ts +90 -0
  210. package/template/src/report-operations.ts +685 -0
  211. package/template/src/report-operator-accounts.ts +810 -0
  212. package/template/src/report-presence.ts +398 -0
  213. package/template/src/report-queue-validation.ts +309 -0
  214. package/template/src/report-queues.ts +478 -0
  215. package/template/src/report-repository.ts +972 -0
  216. package/template/src/report-router-agent.ts +328 -0
  217. package/template/src/report-router-validation.ts +81 -0
  218. package/template/src/report-routing.ts +178 -0
  219. package/template/src/report-turnstile.ts +142 -0
  220. package/template/src/report-types.ts +224 -0
  221. package/template/src/report-validation.ts +279 -0
  222. package/template/src/report-workflow-validation.ts +119 -0
  223. package/template/src/report-workflow.ts +886 -0
  224. package/template/src/shadow-quality.ts +278 -0
  225. package/template/src/workflow-actions.ts +782 -0
  226. package/template/src/workflow-compiler.ts +230 -0
  227. package/template/src/workflow-dynamic-runtime.ts +613 -0
  228. package/template/src/workflow-effects.ts +316 -0
  229. package/template/src/workflow-expressions.ts +191 -0
  230. package/template/src/workflow-platform-types.ts +121 -0
  231. package/template/src/workflow-platform-validation.ts +540 -0
  232. package/template/src/workflow-repository.ts +916 -0
  233. package/template/src/workflow-runs.ts +686 -0
  234. package/template/src/workflow-simulator.ts +211 -0
  235. package/template/src/workspace-branding.ts +128 -0
  236. package/template/src/workspace-governance.ts +279 -0
  237. package/template/tsconfig.console.json +23 -0
  238. package/template/tsconfig.json +22 -0
  239. package/template/vite.console.config.ts +20 -0
  240. package/template/worker-configuration.d.ts +65 -0
@@ -0,0 +1,583 @@
1
+ import { betterAuth } from "better-auth";
2
+ import { getMigrations } from "better-auth/db/migration";
3
+ import { genericOAuth } from "better-auth/plugins";
4
+ import type { GenericOAuthConfig } from "better-auth/plugins/generic-oauth";
5
+
6
+ const INVITATION_COOKIE = "safest_invitation";
7
+ const INVITATION_MAX_AGE_SECONDS = 7 * 24 * 60 * 60;
8
+
9
+ type AuthEmailBinding = {
10
+ send(message: {
11
+ from: { email: string; name?: string };
12
+ to: string | string[];
13
+ subject: string;
14
+ text: string;
15
+ html: string;
16
+ }): Promise<unknown>;
17
+ };
18
+
19
+ type BetterAuthEnv = Env & {
20
+ BETTER_AUTH_SECRET?: string;
21
+ AUTH_EMAIL_FROM?: string;
22
+ AUTH_GOOGLE_CLIENT_ID?: string;
23
+ AUTH_GOOGLE_CLIENT_SECRET?: string;
24
+ AUTH_GITHUB_CLIENT_ID?: string;
25
+ AUTH_GITHUB_CLIENT_SECRET?: string;
26
+ AUTH_CLOUDFLARE_CLIENT_ID?: string;
27
+ AUTH_CLOUDFLARE_CLIENT_SECRET?: string;
28
+ EMAIL?: AuthEmailBinding;
29
+ };
30
+
31
+ type BetterAuthSession = {
32
+ session: { id: string; userId: string; expiresAt: Date };
33
+ user: {
34
+ id: string;
35
+ name: string;
36
+ email: string;
37
+ emailVerified: boolean;
38
+ image?: string | null;
39
+ };
40
+ };
41
+
42
+ type AuthProviderInfo = {
43
+ providerId: "google" | "github" | "cloudflare";
44
+ displayName: "Google" | "GitHub" | "Cloudflare";
45
+ };
46
+
47
+ type InvitationRow = {
48
+ id: string;
49
+ email: string;
50
+ role: string;
51
+ status: string;
52
+ expires_at: string;
53
+ };
54
+
55
+ const encoder = new TextEncoder();
56
+
57
+ function bytesToBase64Url(bytes: Uint8Array): string {
58
+ let binary = "";
59
+ for (const byte of bytes) binary += String.fromCharCode(byte);
60
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/u, "");
61
+ }
62
+
63
+ async function sha256(value: string): Promise<string> {
64
+ const digest = await crypto.subtle.digest("SHA-256", encoder.encode(value));
65
+ return bytesToBase64Url(new Uint8Array(digest));
66
+ }
67
+
68
+ async function hmac(value: string, secret: string): Promise<string> {
69
+ const key = await crypto.subtle.importKey(
70
+ "raw",
71
+ encoder.encode(secret),
72
+ { name: "HMAC", hash: "SHA-256" },
73
+ false,
74
+ ["sign"],
75
+ );
76
+ return bytesToBase64Url(new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(value))));
77
+ }
78
+
79
+ function constantTimeEqual(left: string, right: string): boolean {
80
+ const leftBytes = encoder.encode(left);
81
+ const rightBytes = encoder.encode(right);
82
+ if (leftBytes.byteLength !== rightBytes.byteLength) return false;
83
+ let difference = 0;
84
+ for (let index = 0; index < leftBytes.byteLength; index += 1) {
85
+ difference |= leftBytes[index]! ^ rightBytes[index]!;
86
+ }
87
+ return difference === 0;
88
+ }
89
+
90
+ function parseCookies(header: string | null): Map<string, string> {
91
+ const result = new Map<string, string>();
92
+ for (const part of (header ?? "").split(";")) {
93
+ const separator = part.indexOf("=");
94
+ if (separator <= 0) continue;
95
+ result.set(part.slice(0, separator).trim(), part.slice(separator + 1).trim());
96
+ }
97
+ return result;
98
+ }
99
+
100
+ function localDevelopment(env: BetterAuthEnv): boolean {
101
+ try {
102
+ const hostname = new URL(env.PUBLIC_BASE_URL).hostname;
103
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
104
+ } catch {
105
+ return false;
106
+ }
107
+ }
108
+
109
+ function authSecret(env: BetterAuthEnv): string {
110
+ if (env.BETTER_AUTH_SECRET && env.BETTER_AUTH_SECRET.length >= 32) return env.BETTER_AUTH_SECRET;
111
+ if (localDevelopment(env)) return "development-only-better-auth-secret-change-me";
112
+ throw new Error("BETTER_AUTH_SECRET must be configured with at least 32 characters.");
113
+ }
114
+
115
+ async function invitationCookieValue(id: string, env: BetterAuthEnv): Promise<string> {
116
+ return `${id}.${await hmac(`${INVITATION_COOKIE}:${id}`, authSecret(env))}`;
117
+ }
118
+
119
+ export async function invitationIdFromRequest(
120
+ request: Request | undefined,
121
+ env: BetterAuthEnv,
122
+ ): Promise<string | null> {
123
+ const value = parseCookies(request?.headers.get("cookie") ?? null).get(INVITATION_COOKIE);
124
+ if (!value) return null;
125
+ const separator = value.lastIndexOf(".");
126
+ if (separator <= 0) return null;
127
+ const id = value.slice(0, separator);
128
+ const signature = value.slice(separator + 1);
129
+ if (!/^[0-9a-f-]{36}$/iu.test(id)) return null;
130
+ const expected = await hmac(`${INVITATION_COOKIE}:${id}`, authSecret(env));
131
+ return constantTimeEqual(signature, expected) ? id : null;
132
+ }
133
+
134
+ async function findPendingInvitationById(
135
+ env: BetterAuthEnv,
136
+ id: string,
137
+ ): Promise<InvitationRow | null> {
138
+ return env.DB.prepare(`
139
+ SELECT id, email, role, status, expires_at
140
+ FROM operator_invitations
141
+ WHERE id = ?1 AND status = 'pending' AND expires_at > ?2
142
+ LIMIT 1
143
+ `).bind(id, new Date().toISOString()).first<InvitationRow>();
144
+ }
145
+
146
+ async function findPendingInvitationByToken(
147
+ env: BetterAuthEnv,
148
+ token: string,
149
+ ): Promise<InvitationRow | null> {
150
+ if (token.length < 32 || token.length > 200) return null;
151
+ return env.DB.prepare(`
152
+ SELECT id, email, role, status, expires_at
153
+ FROM operator_invitations
154
+ WHERE token_hash = ?1 AND status = 'pending' AND expires_at > ?2
155
+ LIMIT 1
156
+ `).bind(await sha256(token), new Date().toISOString()).first<InvitationRow>();
157
+ }
158
+
159
+ export async function invitationAllowsEmail(
160
+ request: Request,
161
+ env: BetterAuthEnv,
162
+ emailValue: unknown,
163
+ ): Promise<boolean> {
164
+ const email = typeof emailValue === "string" ? emailValue.trim().toLowerCase() : "";
165
+ if (!email) return false;
166
+ const invitationId = await invitationIdFromRequest(request, env);
167
+ const invitation = invitationId ? await findPendingInvitationById(env, invitationId) : null;
168
+ return invitation?.email.trim().toLowerCase() === email;
169
+ }
170
+
171
+ async function identityMayCreateUser(
172
+ env: BetterAuthEnv,
173
+ emailValue: unknown,
174
+ request: Request | undefined,
175
+ ): Promise<void | { error: string; errorDescription: string }> {
176
+ const email = typeof emailValue === "string" ? emailValue.trim().toLowerCase() : "";
177
+ if (!email) return { error: "invalid_email", errorDescription: "A verified email address is required." };
178
+ const existing = await env.DB.prepare(
179
+ "SELECT id FROM operator_users WHERE email = ?1 AND status = 'active' LIMIT 1",
180
+ ).bind(email).first();
181
+ if (existing) return;
182
+ const invitationId = await invitationIdFromRequest(request, env);
183
+ const invitation = invitationId ? await findPendingInvitationById(env, invitationId) : null;
184
+ if (!invitation) {
185
+ return { error: "invitation_required", errorDescription: "A valid Safest Resolve invitation is required." };
186
+ }
187
+ if (invitation.email.trim().toLowerCase() !== email) {
188
+ return {
189
+ error: "invitation_email_mismatch",
190
+ errorDescription: "Use the email address to which this invitation was sent.",
191
+ };
192
+ }
193
+ }
194
+
195
+ async function identityMaySignIn(
196
+ env: BetterAuthEnv,
197
+ emailValue: unknown,
198
+ request: Request | undefined,
199
+ ): Promise<void | { error: string; errorDescription: string }> {
200
+ const email = typeof emailValue === "string" ? emailValue.trim().toLowerCase() : "";
201
+ const operator = email ? await env.DB.prepare(
202
+ "SELECT status FROM operator_users WHERE email = ?1 LIMIT 1",
203
+ ).bind(email).first<{ status: string }>() : null;
204
+ if (operator?.status === "active") return;
205
+ if (request && await invitationAllowsEmail(request, env, email)) return;
206
+ return { error: "account_unavailable", errorDescription: "This Safest Resolve account is unavailable." };
207
+ }
208
+
209
+ function escapeHtml(value: string): string {
210
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;")
211
+ .replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
212
+ }
213
+
214
+ async function sendAuthEmail(
215
+ env: BetterAuthEnv,
216
+ message: { to: string; subject: string; heading: string; body: string; action: string; url: string },
217
+ ): Promise<void> {
218
+ if (localDevelopment(env) && (!env.EMAIL || !env.AUTH_EMAIL_FROM)) {
219
+ console.info(`Local authentication email (${message.subject}) for ${message.to}: ${message.url}`);
220
+ return;
221
+ }
222
+ if (!env.EMAIL || !env.AUTH_EMAIL_FROM) {
223
+ throw new Error("Cloudflare Email Service and AUTH_EMAIL_FROM are required for authentication email.");
224
+ }
225
+ const footer = "If you did not request this message, you can safely ignore it.";
226
+ const html = `<!doctype html><html><body style="font-family:system-ui,sans-serif;color:#171717">` +
227
+ `<h1 style="font-size:20px">${escapeHtml(message.heading)}</h1>` +
228
+ `<p>${escapeHtml(message.body)}</p>` +
229
+ `<p><a href="${escapeHtml(message.url)}" style="display:inline-block;padding:10px 16px;` +
230
+ `background:#171717;color:#fff;text-decoration:none;border-radius:6px">${escapeHtml(message.action)}</a></p>` +
231
+ `<p style="font-size:12px;color:#666">${escapeHtml(footer)}</p></body></html>`;
232
+ await env.EMAIL.send({
233
+ from: { name: "Safest Resolve", email: env.AUTH_EMAIL_FROM },
234
+ to: message.to,
235
+ subject: message.subject,
236
+ text: `${message.heading}\n\n${message.body}\n\n${message.action}: ${message.url}\n\n${footer}`,
237
+ html,
238
+ });
239
+ }
240
+
241
+ function deliverAuthEmail(
242
+ env: BetterAuthEnv,
243
+ ctx: ExecutionContext | undefined,
244
+ message: Parameters<typeof sendAuthEmail>[1],
245
+ ): Promise<void> {
246
+ const delivery = sendAuthEmail(env, message).catch((error) => {
247
+ console.error("Authentication email delivery failed", error instanceof Error ? error.message : typeof error);
248
+ });
249
+ if (ctx) {
250
+ ctx.waitUntil(delivery);
251
+ return Promise.resolve();
252
+ }
253
+ return delivery;
254
+ }
255
+
256
+ function cloudflareProvider(env: BetterAuthEnv): GenericOAuthConfig | null {
257
+ if (!env.AUTH_CLOUDFLARE_CLIENT_ID || !env.AUTH_CLOUDFLARE_CLIENT_SECRET) return null;
258
+ return {
259
+ providerId: "cloudflare",
260
+ name: "Cloudflare",
261
+ clientId: env.AUTH_CLOUDFLARE_CLIENT_ID,
262
+ clientSecret: env.AUTH_CLOUDFLARE_CLIENT_SECRET,
263
+ authorizationUrl: "https://dash.cloudflare.com/oauth2/auth",
264
+ tokenUrl: "https://dash.cloudflare.com/oauth2/token",
265
+ tokenEndpointAuth: { method: "client_secret_basic" },
266
+ accountIssuer: "https://dash.cloudflare.com",
267
+ scopes: ["user-details.read"],
268
+ pkce: true,
269
+ requireEmailVerification: true,
270
+ accountSubject: ({ profile }) => String(profile.id),
271
+ async getUserInfo(tokens) {
272
+ if (!tokens.accessToken) return null;
273
+ const response = await fetch("https://api.cloudflare.com/client/v4/user", {
274
+ headers: { authorization: `Bearer ${tokens.accessToken}`, accept: "application/json" },
275
+ });
276
+ if (!response.ok) {
277
+ response.body?.cancel();
278
+ return null;
279
+ }
280
+ const envelope = await response.json() as {
281
+ success?: boolean;
282
+ result?: { id?: string; email?: string; first_name?: string; last_name?: string };
283
+ };
284
+ const profile = envelope.success ? envelope.result : undefined;
285
+ if (!profile?.id || !profile.email) return null;
286
+ const name = [profile.first_name, profile.last_name].filter(Boolean).join(" ").trim();
287
+ return {
288
+ id: profile.id,
289
+ email: profile.email,
290
+ emailVerified: true,
291
+ name: name || profile.email.split("@", 1)[0],
292
+ };
293
+ },
294
+ mapProfileToUser(profile) {
295
+ return {
296
+ email: typeof profile.email === "string" ? profile.email : null,
297
+ name: typeof profile.name === "string" ? profile.name : "Cloudflare user",
298
+ emailVerified: true,
299
+ };
300
+ },
301
+ };
302
+ }
303
+
304
+ export function configuredAuthProviders(env: BetterAuthEnv): AuthProviderInfo[] {
305
+ const providers: AuthProviderInfo[] = [];
306
+ if (env.AUTH_GOOGLE_CLIENT_ID && env.AUTH_GOOGLE_CLIENT_SECRET) {
307
+ providers.push({ providerId: "google", displayName: "Google" });
308
+ }
309
+ if (env.AUTH_GITHUB_CLIENT_ID && env.AUTH_GITHUB_CLIENT_SECRET) {
310
+ providers.push({ providerId: "github", displayName: "GitHub" });
311
+ }
312
+ if (env.AUTH_CLOUDFLARE_CLIENT_ID && env.AUTH_CLOUDFLARE_CLIENT_SECRET) {
313
+ providers.push({ providerId: "cloudflare", displayName: "Cloudflare" });
314
+ }
315
+ return providers;
316
+ }
317
+
318
+ export async function sendOperatorInvitationEmail(
319
+ env: BetterAuthEnv,
320
+ invitation: { email: string; inviteUrl: string; role: string; expiresAt: string },
321
+ ): Promise<void> {
322
+ const role = invitation.role.replaceAll("_", " ");
323
+ await sendAuthEmail(env, {
324
+ to: invitation.email,
325
+ subject: "You are invited to Safest Resolve",
326
+ heading: "Join Safest Resolve",
327
+ body: `You were invited as ${role}. This single-use invitation expires ${new Date(invitation.expiresAt).toUTCString()}.`,
328
+ action: "Accept invitation",
329
+ url: invitation.inviteUrl,
330
+ });
331
+ }
332
+
333
+ function createBetterAuth(env: BetterAuthEnv, ctx?: ExecutionContext) {
334
+ const secret = authSecret(env);
335
+ const baseURL = new URL(env.PUBLIC_BASE_URL).origin;
336
+ const development = localDevelopment(env);
337
+ const cloudflare = cloudflareProvider(env);
338
+ const plugins = [genericOAuth({ config: cloudflare ? [cloudflare] : [] })];
339
+ return betterAuth({
340
+ appName: "Safest Resolve",
341
+ baseURL,
342
+ basePath: "/api/auth",
343
+ trustedOrigins: development ? [baseURL, "http://localhost:*", "http://127.0.0.1:*"] : [baseURL],
344
+ secret,
345
+ database: env.DB,
346
+ telemetry: { enabled: false },
347
+ user: {
348
+ modelName: "auth_user",
349
+ async validateUserInfo({ user, source }, endpoint) {
350
+ if (source.action === "create-user") return identityMayCreateUser(env, user.email, endpoint.request);
351
+ if (source.action === "link-account" || source.action === "sign-in") {
352
+ return identityMaySignIn(env, user.email, endpoint.request);
353
+ }
354
+ },
355
+ },
356
+ session: {
357
+ modelName: "auth_session",
358
+ expiresIn: 12 * 60 * 60,
359
+ updateAge: 60 * 60,
360
+ },
361
+ account: {
362
+ modelName: "auth_account",
363
+ encryptOAuthTokens: true,
364
+ accountLinking: {
365
+ enabled: true,
366
+ allowDifferentEmails: false,
367
+ trustedProviders: ["google", "github", "cloudflare"],
368
+ },
369
+ },
370
+ verification: { modelName: "auth_verification", storeIdentifier: "hashed" },
371
+ emailAndPassword: {
372
+ enabled: true,
373
+ minPasswordLength: 12,
374
+ maxPasswordLength: 128,
375
+ requireEmailVerification: !development,
376
+ autoSignIn: development,
377
+ revokeSessionsOnPasswordReset: true,
378
+ resetPasswordTokenExpiresIn: 30 * 60,
379
+ async sendResetPassword({ user, token }) {
380
+ const resetUrl = `${baseURL}/#reset=${encodeURIComponent(token)}`;
381
+ await deliverAuthEmail(env, ctx, {
382
+ to: user.email,
383
+ subject: "Reset your Safest Resolve password",
384
+ heading: "Reset your password",
385
+ body: "Use this one-time link within 30 minutes. Resetting your password signs out your other sessions.",
386
+ action: "Reset password",
387
+ url: resetUrl,
388
+ });
389
+ },
390
+ },
391
+ emailVerification: {
392
+ sendOnSignUp: !development,
393
+ sendOnSignIn: !development,
394
+ autoSignInAfterVerification: true,
395
+ expiresIn: 24 * 60 * 60,
396
+ async sendVerificationEmail({ user, url }) {
397
+ await deliverAuthEmail(env, ctx, {
398
+ to: user.email,
399
+ subject: "Verify your Safest Resolve email",
400
+ heading: "Verify your email",
401
+ body: "Confirm this address before signing in. This link expires in 24 hours and can be used once.",
402
+ action: "Verify email",
403
+ url,
404
+ });
405
+ },
406
+ },
407
+ socialProviders: {
408
+ ...(env.AUTH_GOOGLE_CLIENT_ID && env.AUTH_GOOGLE_CLIENT_SECRET ? {
409
+ google: {
410
+ clientId: env.AUTH_GOOGLE_CLIENT_ID,
411
+ clientSecret: env.AUTH_GOOGLE_CLIENT_SECRET,
412
+ requireEmailVerification: true,
413
+ },
414
+ } : {}),
415
+ ...(env.AUTH_GITHUB_CLIENT_ID && env.AUTH_GITHUB_CLIENT_SECRET ? {
416
+ github: {
417
+ clientId: env.AUTH_GITHUB_CLIENT_ID,
418
+ clientSecret: env.AUTH_GITHUB_CLIENT_SECRET,
419
+ requireEmailVerification: true,
420
+ },
421
+ } : {}),
422
+ },
423
+ rateLimit: { enabled: true, storage: "database", modelName: "auth_rate_limit" },
424
+ advanced: {
425
+ database: { generateId: "uuid", joins: true },
426
+ ipAddress: { ipAddressHeaders: ["cf-connecting-ip"] },
427
+ useSecureCookies: !development,
428
+ cookiePrefix: "safest_auth",
429
+ ...(ctx ? { backgroundTasks: { handler: (promise: Promise<unknown>) => ctx.waitUntil(promise) } } : {}),
430
+ },
431
+ plugins,
432
+ onAPIError: {
433
+ onError(error) {
434
+ console.error("Better Auth request failed", error instanceof Error ? error.message : typeof error);
435
+ },
436
+ },
437
+ });
438
+ }
439
+
440
+ const migrationPromises = new WeakMap<object, Promise<void>>();
441
+
442
+ function ensureBetterAuthMigrations(
443
+ auth: ReturnType<typeof createBetterAuth>,
444
+ database: D1Database,
445
+ ): Promise<void> {
446
+ const key = database as unknown as object;
447
+ const existing = migrationPromises.get(key);
448
+ if (existing) return existing;
449
+ const migration = getMigrations(auth.options).then(async ({ runMigrations }) => {
450
+ await runMigrations();
451
+ }).catch((error) => {
452
+ migrationPromises.delete(key);
453
+ throw error;
454
+ });
455
+ migrationPromises.set(key, migration);
456
+ return migration;
457
+ }
458
+
459
+ export async function handleBetterAuthRequest(
460
+ request: Request,
461
+ env: BetterAuthEnv,
462
+ ctx: ExecutionContext,
463
+ ): Promise<Response> {
464
+ const auth = createBetterAuth(env, ctx);
465
+ await ensureBetterAuthMigrations(auth, env.DB);
466
+ return auth.handler(request);
467
+ }
468
+
469
+ export async function callBetterAuth(
470
+ request: Request,
471
+ env: BetterAuthEnv,
472
+ ctx: ExecutionContext,
473
+ path: string,
474
+ body: Record<string, unknown>,
475
+ cookie?: string,
476
+ ): Promise<Response> {
477
+ const url = new URL(`/api/auth${path}`, request.url);
478
+ const headers = new Headers(request.headers);
479
+ headers.set("content-type", "application/json");
480
+ headers.delete("content-length");
481
+ if (cookie) headers.set("cookie", cookie);
482
+ const internal = new Request(url, {
483
+ method: "POST",
484
+ headers,
485
+ body: JSON.stringify(body),
486
+ });
487
+ return handleBetterAuthRequest(internal, env, ctx);
488
+ }
489
+
490
+ export async function resolveBetterAuthSession(
491
+ request: Request,
492
+ env: BetterAuthEnv,
493
+ ): Promise<BetterAuthSession | null> {
494
+ const auth = createBetterAuth(env);
495
+ await ensureBetterAuthMigrations(auth, env.DB);
496
+ const session = await auth.api.getSession({ headers: request.headers });
497
+ return session as BetterAuthSession | null;
498
+ }
499
+
500
+ export async function revokeBetterAuthSessionsForOperator(
501
+ env: BetterAuthEnv,
502
+ operatorUserId: string,
503
+ ): Promise<number> {
504
+ const auth = createBetterAuth(env);
505
+ await ensureBetterAuthMigrations(auth, env.DB);
506
+ const result = await env.DB.prepare(`
507
+ DELETE FROM auth_session
508
+ WHERE userId IN (
509
+ SELECT auth_user.id
510
+ FROM auth_user JOIN operator_users ON operator_users.email = auth_user.email COLLATE NOCASE
511
+ WHERE operator_users.id = ?1
512
+ )
513
+ `).bind(operatorUserId).run();
514
+ return Number(result.meta.changes ?? 0);
515
+ }
516
+
517
+ export async function betterAuthCsrfToken(
518
+ env: BetterAuthEnv,
519
+ sessionId: string,
520
+ ): Promise<string> {
521
+ return hmac(`safest-csrf:${sessionId}`, authSecret(env));
522
+ }
523
+
524
+ export async function betterAuthCsrfHash(token: string): Promise<string> {
525
+ return sha256(token);
526
+ }
527
+
528
+ export function appendBetterAuthCsrfCookie(
529
+ response: Response,
530
+ request: Request,
531
+ token: string,
532
+ expiresAt: Date,
533
+ ): Response {
534
+ const secure = new URL(request.url).protocol === "https:";
535
+ const name = secure ? "__Host-safest_csrf" : "safest_csrf";
536
+ const maxAge = Math.max(0, Math.floor((expiresAt.valueOf() - Date.now()) / 1_000));
537
+ const headers = new Headers(response.headers);
538
+ headers.append(
539
+ "set-cookie",
540
+ `${name}=${token}; Path=/; Max-Age=${maxAge}; SameSite=Strict${secure ? "; Secure" : ""}`,
541
+ );
542
+ headers.append(
543
+ "set-cookie",
544
+ `${INVITATION_COOKIE}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax${secure ? "; Secure" : ""}`,
545
+ );
546
+ return new Response(response.body, {
547
+ status: response.status,
548
+ statusText: response.statusText,
549
+ headers,
550
+ });
551
+ }
552
+
553
+ export async function inspectInvitation(
554
+ request: Request,
555
+ env: BetterAuthEnv,
556
+ ): Promise<Response> {
557
+ if (request.method !== "POST") return new Response("Method Not Allowed", { status: 405, headers: { allow: "POST" } });
558
+ const body = await request.json().catch(() => null) as { token?: unknown } | null;
559
+ const token = typeof body?.token === "string" ? body.token : "";
560
+ const invitation = await findPendingInvitationByToken(env, token);
561
+ if (!invitation) {
562
+ return Response.json({ error: { code: "invalid_invitation", message: "This invitation link is invalid or expired." } }, { status: 400 });
563
+ }
564
+ const secure = new URL(request.url).protocol === "https:";
565
+ const maxAge = Math.min(
566
+ INVITATION_MAX_AGE_SECONDS,
567
+ Math.max(0, Math.floor((Date.parse(invitation.expires_at) - Date.now()) / 1_000)),
568
+ );
569
+ const headers = new Headers({ "cache-control": "no-store" });
570
+ headers.append(
571
+ "set-cookie",
572
+ `${INVITATION_COOKIE}=${await invitationCookieValue(invitation.id, env)}; Path=/; ` +
573
+ `Max-Age=${maxAge}; HttpOnly; SameSite=Lax${secure ? "; Secure" : ""}`,
574
+ );
575
+ return Response.json({
576
+ schema_version: "1",
577
+ invitation: {
578
+ email: invitation.email,
579
+ role: invitation.role,
580
+ expires_at: invitation.expires_at,
581
+ },
582
+ }, { headers });
583
+ }