create-safest-tools 0.3.1 → 0.4.1

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 (71) hide show
  1. package/README.md +8 -4
  2. package/package.json +1 -1
  3. package/src/cli.mjs +52 -7
  4. package/src/config.mjs +26 -5
  5. package/src/scaffold.mjs +4 -1
  6. package/src/update.mjs +295 -0
  7. package/template/README.md +7 -5
  8. package/template/console/reports/ReportDrawer.tsx +51 -6
  9. package/template/console/reports/controller.ts +11 -5
  10. package/template/console/reports/store.ts +1 -1
  11. package/template/console/reports/types.ts +23 -0
  12. package/template/console/shell/WorkspaceShell.tsx +0 -4
  13. package/template/migrations/0001_safest_resolve_baseline.sql +2446 -0
  14. package/template/package.json +4 -3
  15. package/template/public/console/auth-shell.js +4333 -4243
  16. package/template/public/styles.css +19 -17
  17. package/template/reports.config.example.json +2 -1
  18. package/template/reports.schema.json +2 -1
  19. package/template/scripts/reports-auth-onboarding.mjs +6 -5
  20. package/template/scripts/reports-plan.mjs +14 -5
  21. package/template/src/index.ts +12 -3
  22. package/template/src/installation-admin.ts +2 -1
  23. package/template/src/report-ai.ts +30 -5
  24. package/template/src/report-better-auth.ts +5 -14
  25. package/template/src/report-delivery.ts +4 -3
  26. package/template/src/report-email.ts +106 -0
  27. package/template/src/report-workflow.ts +103 -21
  28. package/template/src/workflow-effects.ts +4 -3
  29. package/template/worker-configuration.d.ts +5 -3
  30. package/template/migrations/0001_reports_foundation.sql +0 -444
  31. package/template/migrations/0002_human_report_loop.sql +0 -65
  32. package/template/migrations/0003_delivery_reliability.sql +0 -18
  33. package/template/migrations/0004_public_intake.sql +0 -14
  34. package/template/migrations/0005_operations_visibility.sql +0 -24
  35. package/template/migrations/0006_ai_governance.sql +0 -198
  36. package/template/migrations/0007_ai_release_gates.sql +0 -6
  37. package/template/migrations/0008_retention_analytics_exports.sql +0 -52
  38. package/template/migrations/0009_retention_derived_copies.sql +0 -14
  39. package/template/migrations/0010_ai_quality_controls.sql +0 -26
  40. package/template/migrations/0011_analyst_presence.sql +0 -28
  41. package/template/migrations/0012_queue_policies.sql +0 -87
  42. package/template/migrations/0013_routing_agents.sql +0 -63
  43. package/template/migrations/0014_webhook_enrichments.sql +0 -122
  44. package/template/migrations/0015_queue_owned_ai.sql +0 -55
  45. package/template/migrations/0016_operator_accounts.sql +0 -67
  46. package/template/migrations/0017_operator_profiles_and_recovery.sql +0 -33
  47. package/template/migrations/0018_platform_configuration.sql +0 -382
  48. package/template/migrations/0019_workflow_authoring_runtime.sql +0 -372
  49. package/template/migrations/0020_tasks_findings_assistant_budgets.sql +0 -427
  50. package/template/migrations/0021_abuse_evidence_operations.sql +0 -324
  51. package/template/migrations/0022_workflow_dispatch_operations.sql +0 -42
  52. package/template/migrations/0023_component_connection_execution.sql +0 -75
  53. package/template/migrations/0024_access_runtime_integrity.sql +0 -72
  54. package/template/migrations/0025_installation_timezone.sql +0 -11
  55. package/template/migrations/0026_ai_and_egress_execution_controls.sql +0 -49
  56. package/template/migrations/0027_prompt_and_ai_registry.sql +0 -37
  57. package/template/migrations/0028_step_attempt_ai_provenance.sql +0 -13
  58. package/template/migrations/0029_evidence_fetch_transport.sql +0 -5
  59. package/template/migrations/0030_evidence_dlq_incidents.sql +0 -45
  60. package/template/migrations/0031_shadow_quality_integrity.sql +0 -7
  61. package/template/migrations/0032_action_delivery_outbox.sql +0 -55
  62. package/template/migrations/0033_configuration_and_assistant_drafts.sql +0 -43
  63. package/template/migrations/0034_installation_integrations.sql +0 -31
  64. package/template/migrations/0035_workspace_governance.sql +0 -21
  65. package/template/migrations/0036_published_routing_baseline.sql +0 -15
  66. package/template/migrations/0037_builtin_phishing_specialist.sql +0 -71
  67. package/template/migrations/0038_remove_deprecated_enrichment_runtime.sql +0 -228
  68. package/template/migrations/0039_secure_reporting_channels.sql +0 -45
  69. package/template/migrations/0040_notification_only_reporting.sql +0 -24
  70. package/template/migrations/0041_better_auth_credentials.sql +0 -17
  71. package/template/migrations/0042_owner_setup_tokens.sql +0 -29
@@ -123,9 +123,6 @@ input:focus, select:focus { border-color: #68a380; box-shadow: 0 0 0 3px rgba(34
123
123
  .presence-dot.online { background: #55a271; }
124
124
  .presence-dot.viewing { background: #4d83a0; }
125
125
  .presence-dot.editing { background: #cf8a38; box-shadow: 0 0 0 3px rgba(207,138,56,.13); }
126
- .principle { margin-top: auto; padding: 14px; border-radius: 10px; color: #496054; background: #edf6f0; }
127
- .principle strong { font-size: 10px; }
128
- .principle p { margin: 7px 0 0; font-size: 9px; line-height: 1.5; }
129
126
  .content { min-width: 0; padding: clamp(25px, 4vw, 48px); }
130
127
  .page-head { max-width: 1400px; margin: 0 auto 24px; display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; }
131
128
  .page-head p:last-child { margin: 0; color: var(--muted); font-size: 12px; }
@@ -259,6 +256,19 @@ td small { margin-top: 3px; color: var(--muted); }
259
256
  .detail-grid dl { margin: 0; display: grid; grid-template-columns: 115px 1fr; gap: 8px; font-size: 10px; }
260
257
  .detail-grid dt { color: var(--muted); }
261
258
  .detail-grid dd { margin: 0; overflow-wrap: anywhere; font-weight: 750; }
259
+ .report-submission { grid-column: 1 / -1; }
260
+ .submission-meta { margin-bottom: 14px !important; grid-template-columns: 115px minmax(0, 1fr) 115px minmax(0, 1fr) !important; }
261
+ .answer-list { display: grid; gap: 8px; }
262
+ .answer-record { padding: 11px 12px; display: grid; grid-template-columns: minmax(150px, .8fr) minmax(0, 1.4fr); gap: 16px; align-items: start; border: 1px solid #e5e9e4; border-radius: 8px; background: white; font-size: 11px; line-height: 1.5; }
263
+ .answer-record strong, .answer-record small { display: block; }
264
+ .answer-record strong { font-size: 11px; }
265
+ .answer-record small { margin-top: 3px; color: var(--muted); font-size: 9px; }
266
+ .answer-record > :last-child { min-width: 0; overflow-wrap: anywhere; }
267
+ .answer-text { white-space: pre-wrap; }
268
+ .answer-empty { color: var(--muted); font-style: italic; }
269
+ .form-help { margin: -5px 0 12px; color: var(--muted); font-size: 10px; line-height: 1.45; }
270
+ .mutation-status { margin: 0; padding: 11px 12px; border: 1px solid #d4e7d9; border-radius: 8px; color: #23543b; background: #eff8f1; font-size: 10px; line-height: 1.45; }
271
+ .mutation-status:empty { display: none; }
262
272
  .evidence-list { display: grid; gap: 7px; }
263
273
  .evidence-item { padding: 8px; border-radius: 7px; color: #405249; background: white; font-size: 9px; overflow-wrap: anywhere; }
264
274
  .report-detail-content > section { margin-top: 14px; }
@@ -327,7 +337,6 @@ td small { margin-top: 3px; color: var(--muted); }
327
337
  .workspace { grid-template-columns: 1fr; }
328
338
  .sidebar { position: static; width: auto; height: auto; border-right: 0; border-bottom: 1px solid var(--line); }
329
339
  .sidebar nav { grid-template-columns: 1fr 1fr; }
330
- .principle { display: none; }
331
340
  .metrics { grid-template-columns: 1fr 1fr; }
332
341
  .operation-metrics { grid-template-columns: 1fr 1fr; }
333
342
  .analytics-metrics { grid-template-columns: 1fr 1fr; }
@@ -388,8 +397,6 @@ td small { margin-top: 3px; color: var(--muted); }
388
397
  .sidebar nav a { min-height: 36px; color: #bdd0c8; font-size: 10px; }
389
398
  .sidebar nav a:hover { color: white; background: rgba(255,255,255,.07); }
390
399
  .sidebar nav a.active { color: #123b2b; background: #dafa72; box-shadow: 0 7px 18px rgba(0,0,0,.12); }
391
- .principle { color: #bcd2c8; background: rgba(255,255,255,.06); }
392
- .principle strong { color: white; }
393
400
  .content { min-width: 0; padding: 0; transition: margin-right .2s ease; }
394
401
  .queue-view { min-height: calc(100vh - 66px); }
395
402
  .page-head { max-width: none; min-height: 78px; margin: 0; padding: 15px 22px; align-items: center; border-bottom: 1px solid #dedfd8; background: #fbfaf7; }
@@ -498,7 +505,7 @@ body.report-open .content { margin-right: 408px; }
498
505
  @media (max-width: 900px) {
499
506
  .workspace { width: 100%; min-width: 0; grid-template-columns: minmax(0, 1fr); overflow-x: clip; }
500
507
  .sidebar { position: static; width: 100%; min-width: 0; max-width: 100vw; height: auto; padding: 10px; overflow: hidden; }
501
- .sidebar .workspace-card, .sidebar .principle { display: none; }
508
+ .sidebar .workspace-card { display: none; }
502
509
  .sidebar nav { width: 100%; min-width: 0; max-width: 100%; margin: 0; display: flex; overflow-x: auto; overscroll-behavior-x: contain; }
503
510
  .sidebar nav, .active-analyst-list { scrollbar-width: none; }
504
511
  .sidebar nav::-webkit-scrollbar, .active-analyst-list::-webkit-scrollbar { display: none; }
@@ -851,8 +858,8 @@ body.report-open .content { margin-right: 0; }
851
858
  .sign-out-button:hover { background: #f7f9f6; }
852
859
 
853
860
  .workspace { min-height: calc(100vh - var(--product-topbar)); grid-template-columns: var(--product-sidebar) minmax(0, 1fr); background: var(--canvas); }
854
- .sidebar { top: var(--product-topbar); height: calc(100vh - var(--product-topbar)); padding: 16px 12px 14px; background: var(--forest-dark); }
855
- .sidebar nav { margin-top: 0; display: grid; gap: 3px; }
861
+ .sidebar { top: var(--product-topbar); height: calc(100vh - var(--product-topbar)); height: calc(100dvh - var(--product-topbar)); min-height: 0; padding: 16px 12px 14px; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; scrollbar-width: thin; background: var(--forest-dark); }
862
+ .sidebar nav { min-height: min-content; margin-top: 0; padding-bottom: 10px; display: grid; gap: 3px; }
856
863
  .sidebar nav > small { margin: 11px 10px 5px; color: #7fa596; font-size: 9px; font-weight: 800; letter-spacing: .11em; text-transform: uppercase; }
857
864
  .sidebar nav > small:first-child { margin-top: 9px; }
858
865
  .sidebar nav a { min-height: 40px; padding: 0 10px; display: grid; grid-template-columns: 20px minmax(0, 1fr) auto; gap: 10px; color: #c5d7cf; font-size: 13px; font-weight: 560; }
@@ -862,10 +869,6 @@ body.report-open .content { margin-right: 0; }
862
869
  .sidebar nav a.active { color: #102f24; background: var(--safest-lime-400); box-shadow: none; font-weight: 750; }
863
870
  .sidebar .nav-count { min-width: 22px; margin-left: 0; color: #dceae3; background: rgba(255,255,255,.10); font-size: 9px; }
864
871
  .sidebar nav a.active .nav-count { color: white; background: var(--forest-dark); }
865
- .principle { margin-top: auto; padding: 12px 10px; display: flex; align-items: flex-start; gap: 9px; color: #8eb0a2; background: rgba(255,255,255,.055); }
866
- .principle-dot { width: 8px; height: 8px; margin-top: 3px; flex: 0 0 8px; border-radius: 50%; background: #55b27b; box-shadow: 0 0 0 5px rgba(85,178,123,.12); }
867
- .principle strong { display: block; color: #eef6f1; font-size: 11px; }
868
- .principle p { margin-top: 3px; color: #8eb0a2; font-size: 10px; line-height: 1.45; }
869
872
 
870
873
  .content { background: var(--canvas); }
871
874
  .command-view { max-width: 1184px; margin: 0 auto; padding: 28px; }
@@ -947,9 +950,8 @@ body.report-open .content { margin-right: 0; }
947
950
  .topbar { grid-template-columns: 190px minmax(210px, 1fr) auto; }
948
951
  .top-workspace { display: none; }
949
952
  .sidebar { padding-inline: 10px; }
950
- .sidebar nav > small, .sidebar nav a span, .sidebar nav a b, .principle div { display: none; }
953
+ .sidebar nav > small, .sidebar nav a span, .sidebar nav a b { display: none; }
951
954
  .sidebar nav a { grid-template-columns: 1fr; justify-items: center; padding-inline: 0; }
952
- .principle { justify-content: center; }
953
955
  .command-grid { grid-template-columns: 1fr; }
954
956
  .queue-load-list { grid-template-columns: 1fr 1fr; }
955
957
  }
@@ -969,8 +971,6 @@ body.report-open .content { margin-right: 0; }
969
971
  .sidebar nav a { min-width: 0; display: grid; grid-template-columns: 20px minmax(0, 1fr) auto; justify-items: stretch; padding: 0 10px; }
970
972
  .sidebar nav a span { display: block; white-space: normal; }
971
973
  .sidebar nav a b { display: inline-block; }
972
- .principle { display: flex; }
973
- .principle div { display: block; }
974
974
  .nav-scrim { position: fixed; z-index: 65; inset: var(--product-topbar) 0 0; width: 100%; height: auto; display: block; border: 0; border-radius: 0; background: rgba(5,28,20,.33); backdrop-filter: blur(1px); }
975
975
  .nav-scrim[hidden] { display: none; }
976
976
  .content { padding: 0; }
@@ -1503,6 +1503,8 @@ body.login-mode .top-actions { margin-left: auto; }
1503
1503
  .configuration-dialog .detail-head, .registry-dialog .detail-head { padding: 14px; }
1504
1504
  .configuration-dialog .editor-form, .registry-dialog .editor-form { padding: 14px; }
1505
1505
  .configuration-tabs { scrollbar-width: thin; }
1506
+ .submission-meta { grid-template-columns: 100px minmax(0, 1fr) !important; }
1507
+ .answer-record { grid-template-columns: 1fr; gap: 5px; }
1506
1508
  .configuration-tabs button { flex: 0 0 auto; }
1507
1509
  }
1508
1510
 
@@ -21,7 +21,8 @@
21
21
  "owner": { "email": "infrastructure@example.com" },
22
22
  "email": {
23
23
  "enabled": true,
24
- "fromAddress": "reports@example.com"
24
+ "fromAddress": "reports@example.com",
25
+ "authFromAddress": "accounts@example.com"
25
26
  },
26
27
  "auth": {
27
28
  "password": true,
@@ -48,7 +48,8 @@
48
48
  "required": ["enabled", "fromAddress"],
49
49
  "properties": {
50
50
  "enabled": { "type": "boolean" },
51
- "fromAddress": { "type": "string", "maxLength": 254 }
51
+ "fromAddress": { "type": "string", "maxLength": 254 },
52
+ "authFromAddress": { "type": "string", "maxLength": 254 }
52
53
  }
53
54
  },
54
55
  "auth": {
@@ -58,13 +58,14 @@ function providerConfigured(provider, selected, secrets) {
58
58
  }
59
59
 
60
60
  export async function confirmEmailSending(config, ui = clack) {
61
- const sender = config.email?.fromAddress ?? "";
62
- const domain = sender.includes("@") ? sender.slice(sender.lastIndexOf("@") + 1) : sender;
61
+ const reporterSender = config.email?.fromAddress ?? "";
62
+ const authSender = config.email?.authFromAddress ?? "";
63
+ const domains = [...new Set([reporterSender, authSender].filter(Boolean).map((sender) => sender.slice(sender.lastIndexOf("@") + 1)))];
63
64
  ui.note(
64
- `Sender: ${sender}\nDomain: ${domain}\n\n` +
65
+ `Reporter updates: ${reporterSender}\nAccount and invitation mail: ${authSender}\nDomain${domains.length === 1 ? "" : "s"}: ${domains.join(", ")}\n\n` +
65
66
  `1. In Cloudflare, open Compute → Email Service → Email Sending.\n` +
66
- `2. Onboard ${domain} and wait for its sending DNS records to show configured.\n` +
67
- `3. Return here. Domain onboarding is required to email arbitrary invited users.`,
67
+ `2. Onboard ${domains.join(" and ")} and wait for the sending DNS records to show configured.\n` +
68
+ `3. Return here. Resolve keeps participant mail separate from account and invitation mail.`,
68
69
  "Email delivery",
69
70
  );
70
71
  const action = await ui.select({
@@ -77,12 +77,18 @@ export function parseReportsConfig(value) {
77
77
  if (!EMAIL.test(ownerEmail)) throw new ReportsConfigError("owner.email is invalid");
78
78
  const retention = object(input.retention, "retention");
79
79
  exactKeys(retention, new Set(["reportsDays", "messagesDays", "appealWindowDays"]), "retention");
80
- const email = input.email === undefined ? { enabled: false, fromAddress: "" } : object(input.email, "email");
81
- exactKeys(email, new Set(["enabled", "fromAddress"]), "email");
80
+ const email = input.email === undefined ? { enabled: false, fromAddress: "", authFromAddress: "" } : object(input.email, "email");
81
+ exactKeys(email, new Set(["enabled", "fromAddress", "authFromAddress"]), "email");
82
82
  if (typeof email.enabled !== "boolean") throw new ReportsConfigError("email.enabled must be boolean");
83
83
  const emailFromAddress = String(email.fromAddress ?? "").trim().toLowerCase();
84
+ const emailDomain = emailFromAddress.includes("@") ? emailFromAddress.slice(emailFromAddress.lastIndexOf("@") + 1) : "";
85
+ const defaultAuthEmail = emailDomain ? `${emailFromAddress.startsWith("accounts@") ? "auth" : "accounts"}@${emailDomain}` : "";
86
+ const authEmailFromAddress = String(email.authFromAddress ?? defaultAuthEmail).trim().toLowerCase();
84
87
  if (email.enabled && !EMAIL.test(emailFromAddress)) throw new ReportsConfigError("email.fromAddress must be a valid onboarded sender when email is enabled");
88
+ if (email.enabled && !EMAIL.test(authEmailFromAddress)) throw new ReportsConfigError("email.authFromAddress must be a valid onboarded sender when email is enabled");
89
+ if (email.enabled && authEmailFromAddress === emailFromAddress) throw new ReportsConfigError("email.authFromAddress must differ from email.fromAddress");
85
90
  if (!email.enabled && emailFromAddress) throw new ReportsConfigError("email.fromAddress must be empty when email is disabled");
91
+ if (!email.enabled && email.authFromAddress) throw new ReportsConfigError("email.authFromAddress must be empty when email is disabled");
86
92
  const auth = input.auth === undefined ? { password: true, providers: [] } : object(input.auth, "auth");
87
93
  exactKeys(auth, new Set(["password", "providers"]), "auth");
88
94
  if (auth.password !== true) throw new ReportsConfigError("auth.password must be true because password recovery is a required sign-in method");
@@ -112,7 +118,7 @@ export function parseReportsConfig(value) {
112
118
  publicBaseUrl: origin(input.publicBaseUrl, "publicBaseUrl"),
113
119
  allowedOrigins: stringList(input.allowedOrigins, "allowedOrigins", 20, origin),
114
120
  owner: { email: ownerEmail },
115
- email: { enabled: email.enabled, fromAddress: emailFromAddress },
121
+ email: { enabled: email.enabled, fromAddress: emailFromAddress, authFromAddress: authEmailFromAddress },
116
122
  auth: { password: true, providers: authProviders },
117
123
  retention: {
118
124
  reportsDays: integer(retention.reportsDays, "retention.reportsDays", 30, 3_650),
@@ -146,7 +152,10 @@ export function buildReportsPlan(config) {
146
152
  queues: [config.resources.reportQueueName, config.resources.deliveryQueueName, config.resources.operationsDlqName],
147
153
  durableObject: "WorkspacePresence (SQLite-backed, declared by Worker migration)",
148
154
  workersAiBinding: "AI",
149
- emailServiceBinding: `EMAIL (${config.email.fromAddress || "not configured"})`,
155
+ emailServiceBindings: {
156
+ reporter: `REPORTER_EMAIL (${config.email.fromAddress || "not configured"})`,
157
+ authentication: `AUTH_EMAIL (${config.email.authFromAddress || "not configured"})`,
158
+ },
150
159
  staticAssets: "public/",
151
160
  publicHostname,
152
161
  customDomain: customDomain ?? "workers.dev hostname (no custom domain mutation)",
@@ -178,7 +187,7 @@ export function buildReportsPlan(config) {
178
187
  "Onboard the sender domain to Cloudflare Email Service before deployment.",
179
188
  "Initialize local secrets without committing them.",
180
189
  "Create D1, four private R2 buckets, and the three Queues in the customer account.",
181
- "Apply all report migrations before accepting traffic.",
190
+ "Apply the D1 schema baseline and any later forward-only migrations before accepting traffic.",
182
191
  `Deploy the Worker, assets, Worker Loader, Dynamic Workflow, presence Durable Object, Queue consumers, cron, Workers AI binding${customDomain ? `, and ${customDomain} Custom Domain` : ""}.`,
183
192
  "Open the generated one-time owner setup link, create the owner account, verify required setup features and authenticated /ready, invite an administrator and analyst, then verify report intake, routing, and delivery recovery.",
184
193
  ],
@@ -1,4 +1,5 @@
1
1
  import { canonicalJson } from "./audit";
2
+ import { reporterEmailConfigured } from "./report-email";
2
3
  import { decodeApiCursor, encodeApiCursor } from "./api-cursor";
3
4
  import {
4
5
  assertBackupTransferAllowed,
@@ -409,15 +410,17 @@ function publishWorkspaceEvent(
409
410
 
410
411
  function deliveryOptions(env: Env) {
411
412
  return {
413
+ emailEnabled: reporterEmailConfigured(env),
414
+ contactEncryptionKey: env.CREDENTIAL_ENCRYPTION_KEY,
415
+ recipientHashPepper: env.RATE_LIMIT_PEPPER,
412
416
  notificationEnabled: Boolean(env.NOTIFICATION_WEBHOOK_URL?.trim() && env.NOTIFICATION_WEBHOOK_SECRET?.trim()),
413
417
  };
414
418
  }
415
419
 
416
420
  function intakeDeliveryOptions(env: Env) {
417
- const emailBinding = (env as unknown as { EMAIL?: unknown }).EMAIL;
418
421
  return {
419
422
  contactEncryptionKey: env.CREDENTIAL_ENCRYPTION_KEY,
420
- emailEnabled: Boolean(emailBinding && env.EMAIL_FROM_ADDRESS?.trim()),
423
+ emailEnabled: reporterEmailConfigured(env),
421
424
  notificationEnabled: Boolean(env.NOTIFICATION_WEBHOOK_URL?.trim() && env.NOTIFICATION_WEBHOOK_SECRET?.trim()),
422
425
  };
423
426
  }
@@ -2879,7 +2882,13 @@ async function handleAdminReportMessage(
2879
2882
  );
2880
2883
  queueDeliveryJobs(env, ctx, result.jobs);
2881
2884
  publishWorkspaceEvent(env, ctx, { type: "reports_changed", reportId });
2882
- return json({ schema_version: "1", message_id: result.messageId, idempotent_replay: result.idempotentReplay }, {
2885
+ return json({
2886
+ schema_version: "1",
2887
+ message_id: result.messageId,
2888
+ delivery_state: result.deliveryState,
2889
+ delivery_channel: result.deliveryChannel,
2890
+ idempotent_replay: result.idempotentReplay,
2891
+ }, {
2883
2892
  status: result.idempotentReplay ? 200 : 201,
2884
2893
  });
2885
2894
  }
@@ -1,6 +1,7 @@
1
1
  import { canonicalJson } from "./audit";
2
2
  import { sha256 } from "./report-crypto";
3
3
  import { ApiError } from "./report-http";
4
+ import { reporterEmailConfigured } from "./report-email";
4
5
  import type { OperatorSession } from "./report-types";
5
6
  import { WORKFLOW_COMPILER_VERSION } from "./workflow-compiler";
6
7
  import { parseWorkspaceBrand } from "./workspace-branding";
@@ -280,7 +281,7 @@ export async function verifyFeature(env: Env, feature: string, actorId: string):
280
281
  else if (feature === "workers_ai") { ready = Boolean((env as unknown as { AI?: unknown }).AI); }
281
282
  else if (feature === "object_storage") { ready = Boolean(env.PROFILE_MEDIA && env.WORKFLOW_ARTIFACTS && env.EVIDENCE_OBJECTS && env.EXPORTS); capability = { buckets: ready ? 4 : 0 }; }
282
283
  else if (feature === "queues") { ready = Boolean(env.REPORT_JOBS && env.DELIVERY_JOBS && env.CONTROL_JOBS); capability = { producers: ready ? 3 : 0 }; }
283
- else { ready = Boolean((env as unknown as { EMAIL?: unknown }).EMAIL && env.EMAIL_FROM_ADDRESS); capability = { optional: true }; }
284
+ else { ready = reporterEmailConfigured(env); capability = { optional: true, channel: "reporter_email" }; }
284
285
  } catch { errorCode = "feature_probe_failed"; }
285
286
  const state = ready ? "ready" : feature === "email" ? "disabled" : "setup_required";
286
287
  const timestamp = now();
@@ -1,5 +1,6 @@
1
1
  import { canonicalJson } from "./audit";
2
2
  import { evaluateAiFailurePause } from "./report-ai-quality";
3
+ import { selectParticipantDelivery } from "./report-email";
3
4
  import type { AiJob, DeliveryJob, ReportState } from "./report-types";
4
5
  import type { AiMode, ValidatedAiResult } from "./report-ai-validation";
5
6
  import { validateAiResult } from "./report-ai-validation";
@@ -58,6 +59,7 @@ interface AiContextRow extends AiConfigRow {
58
59
  job_state: "pending" | "enqueued" | "processing" | "complete" | "failed" | "dead_letter";
59
60
  attempt_count: number;
60
61
  report_id: string;
62
+ installation_id: string;
61
63
  public_reference: string;
62
64
  source: string;
63
65
  report_state: ReportState;
@@ -67,6 +69,8 @@ interface AiContextRow extends AiConfigRow {
67
69
  target_reference: string;
68
70
  signed_facts_json: string;
69
71
  reporter_reference: string | null;
72
+ reporter_contact_mode: string;
73
+ reporter_contact_ciphertext: string | null;
70
74
  queue_ai_instructions: string | null;
71
75
  }
72
76
 
@@ -224,10 +228,12 @@ function randomSample(rate: number): boolean {
224
228
 
225
229
  async function aiContext(db: D1Database, jobId: string): Promise<AiContextRow | null> {
226
230
  return db.prepare(`
227
- SELECT j.id AS job_id, j.state AS job_state, j.attempt_count, r.id AS report_id,
231
+ SELECT j.id AS job_id, j.state AS job_state, j.attempt_count, r.id AS report_id, r.installation_id,
228
232
  r.public_reference, r.source, r.state AS report_state, r.reason_code, r.form_version_id,
229
233
  t.target_type, t.target_reference, t.signed_facts_json,
230
- p.reference AS reporter_reference, qv.ai_instructions AS queue_ai_instructions, c.*
234
+ p.reference AS reporter_reference, p.contact_mode AS reporter_contact_mode,
235
+ p.contact_ciphertext AS reporter_contact_ciphertext,
236
+ qv.ai_instructions AS queue_ai_instructions, c.*
231
237
  FROM background_jobs j
232
238
  JOIN reports r ON r.id = j.report_id
233
239
  JOIN report_targets t ON t.report_id = r.id
@@ -431,6 +437,9 @@ async function providerFailure(db: D1Database, context: AiContextRow, runId: str
431
437
  }
432
438
 
433
439
  export interface AiProcessOptions {
440
+ emailEnabled: boolean;
441
+ contactEncryptionKey: string;
442
+ recipientHashPepper: string;
434
443
  notificationEnabled: boolean;
435
444
  }
436
445
 
@@ -643,6 +652,14 @@ export async function processAiJob(
643
652
  const messageId = crypto.randomUUID();
644
653
  const noticeId = crypto.randomUUID();
645
654
  const notificationId = crypto.randomUUID();
655
+ const participantDelivery = await selectParticipantDelivery({
656
+ reportId: context.report_id,
657
+ audience: "reporter",
658
+ contactMode: context.reporter_contact_mode,
659
+ contactCiphertext: context.reporter_contact_ciphertext,
660
+ reference: context.reporter_reference,
661
+ ...options,
662
+ });
646
663
  let templateId: string;
647
664
  let variables: Record<string, string>;
648
665
  let nextState: ReportState;
@@ -721,7 +738,7 @@ export async function processAiJob(
721
738
  ) VALUES (?1, ?2, 'reporter', 'outbound', 'ai', ?3, ?4, ?5, 1, ?6, ?7, ?8)
722
739
  `).bind(
723
740
  messageId, context.report_id, context.id, renderedBody, templateId,
724
- options.notificationEnabled ? "queued" : "suppressed", finishedAt, `ai-message:${storedRun.id}`,
741
+ participantDelivery.state, finishedAt, `ai-message:${storedRun.id}`,
725
742
  ),
726
743
  usageStatement(db, context.id, nextState === "resolved_by_ai" ? { resolved: 1 } : { waiting: 1 }, finishedAt),
727
744
  auditEvent(db, {
@@ -760,10 +777,18 @@ export async function processAiJob(
760
777
  ) VALUES (?1, ?2, ?3, 'reporter', ?4, ?5, ?6, ?7, ?7, ?8, ?9)
761
778
  `).bind(
762
779
  noticeId, context.report_id, nextState === "resolved_by_ai" ? decisionId : null, templateId, renderedBody,
763
- options.notificationEnabled ? "queued" : "suppressed", finishedAt, `ai-notice:${storedRun.id}`, messageId,
780
+ participantDelivery.state, finishedAt, `ai-notice:${storedRun.id}`, messageId,
764
781
  ));
765
782
  const jobs: DeliveryJob[] = [];
766
- if (options.notificationEnabled) {
783
+ if (participantDelivery.channel === "email" && participantDelivery.recipientHash) {
784
+ statements.push(db.prepare(`
785
+ INSERT INTO email_deliveries (
786
+ id, installation_id, message_id, audience, recipient_hash,
787
+ provider, state, attempt_count, next_attempt_at, created_at, updated_at
788
+ ) VALUES (?1, ?2, ?3, 'reporter', ?4, 'cloudflare_email_service', 'pending', 0, ?5, ?5, ?5)
789
+ `).bind(notificationId, context.installation_id, messageId, participantDelivery.recipientHash, finishedAt));
790
+ jobs.push({ version: 1, jobId: `email:${notificationId}`, type: "deliver_email", outboxId: notificationId });
791
+ } else if (participantDelivery.channel === "customer_webhook") {
767
792
  statements.push(db.prepare(`
768
793
  INSERT INTO notification_outbox (
769
794
  id, notice_id, idempotency_key, channel, payload_json, status, next_attempt_at, created_at, updated_at
@@ -2,6 +2,7 @@ import { betterAuth } from "better-auth";
2
2
  import { getMigrations } from "better-auth/db/migration";
3
3
  import { genericOAuth } from "better-auth/plugins";
4
4
  import type { GenericOAuthConfig } from "better-auth/plugins/generic-oauth";
5
+ import { authenticationEmailBinding } from "./report-email";
5
6
 
6
7
  const INVITATION_COOKIE = "safest_invitation";
7
8
  const INVITATION_MAX_AGE_SECONDS = 7 * 24 * 60 * 60;
@@ -9,16 +10,6 @@ const OWNER_SETUP_COOKIE = "safest_owner_setup";
9
10
  const OWNER_SETUP_RATE_LIMIT = 12;
10
11
  const OWNER_SETUP_WINDOW_SECONDS = 10 * 60;
11
12
 
12
- type AuthEmailBinding = {
13
- send(message: {
14
- from: { email: string; name?: string };
15
- to: string | string[];
16
- subject: string;
17
- text: string;
18
- html: string;
19
- }): Promise<unknown>;
20
- };
21
-
22
13
  type BetterAuthEnv = Env & {
23
14
  BETTER_AUTH_SECRET?: string;
24
15
  AUTH_EMAIL_FROM?: string;
@@ -28,7 +19,6 @@ type BetterAuthEnv = Env & {
28
19
  AUTH_GITHUB_CLIENT_SECRET?: string;
29
20
  AUTH_CLOUDFLARE_CLIENT_ID?: string;
30
21
  AUTH_CLOUDFLARE_CLIENT_SECRET?: string;
31
- EMAIL?: AuthEmailBinding;
32
22
  };
33
23
 
34
24
  type BetterAuthSession = {
@@ -274,11 +264,12 @@ async function sendAuthEmail(
274
264
  env: BetterAuthEnv,
275
265
  message: { to: string; subject: string; heading: string; body: string; action: string; url: string },
276
266
  ): Promise<void> {
277
- if (localDevelopment(env) && (!env.EMAIL || !env.AUTH_EMAIL_FROM)) {
267
+ const email = authenticationEmailBinding(env);
268
+ if (localDevelopment(env) && (!email || !env.AUTH_EMAIL_FROM)) {
278
269
  console.info(`Local authentication email (${message.subject}) for ${message.to}: ${message.url}`);
279
270
  return;
280
271
  }
281
- if (!env.EMAIL || !env.AUTH_EMAIL_FROM) {
272
+ if (!email || !env.AUTH_EMAIL_FROM) {
282
273
  throw new Error("Cloudflare Email Service and AUTH_EMAIL_FROM are required for authentication email.");
283
274
  }
284
275
  const footer = "If you did not request this message, you can safely ignore it.";
@@ -288,7 +279,7 @@ async function sendAuthEmail(
288
279
  `<p><a href="${escapeHtml(message.url)}" style="display:inline-block;padding:10px 16px;` +
289
280
  `background:#171717;color:#fff;text-decoration:none;border-radius:6px">${escapeHtml(message.action)}</a></p>` +
290
281
  `<p style="font-size:12px;color:#666">${escapeHtml(footer)}</p></body></html>`;
291
- await env.EMAIL.send({
282
+ await email.send({
292
283
  from: { name: "Safest Resolve", email: env.AUTH_EMAIL_FROM },
293
284
  to: message.to,
294
285
  subject: message.subject,
@@ -2,6 +2,7 @@ import { canonicalJson } from "./audit";
2
2
  import type { DeliveryJob } from "./report-types";
3
3
  import { decryptReportContact } from "./report-crypto";
4
4
  import { BudgetControlError, reserveBudgets, settleBudgets } from "./budget-control";
5
+ import { reporterEmailBinding, reporterEmailFrom, reporterEmailSubjectPrefix } from "./report-email";
5
6
 
6
7
  const MAX_ATTEMPTS = 8;
7
8
  const RESPONSE_LIMIT = 16 * 1024;
@@ -449,8 +450,8 @@ async function processEmail(db: D1Database, deliveryId: string, env: Env): Promi
449
450
  const email = row.contactCiphertext
450
451
  ? (await decryptReportContact(env.CREDENTIAL_ENCRYPTION_KEY, row.contactCiphertext, row.reportId, row.audience)).trim().toLowerCase()
451
452
  : row.recipientReference?.trim().toLowerCase() ?? "";
452
- const from = env.EMAIL_FROM_ADDRESS?.trim().toLowerCase() ?? "";
453
- const binding = (env as unknown as { EMAIL?: { send(message: Record<string, unknown>): Promise<{ messageId?: string }> } }).EMAIL;
453
+ const from = reporterEmailFrom(env);
454
+ const binding = reporterEmailBinding(env);
454
455
  let reservations;
455
456
  try {
456
457
  reservations = await reserveBudgets(db, {
@@ -484,7 +485,7 @@ async function processEmail(db: D1Database, deliveryId: string, env: Env): Promi
484
485
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(email) || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(from)) {
485
486
  throw new DeliveryConfigurationError("Email sender or recipient configuration is invalid.");
486
487
  }
487
- const prefix = env.EMAIL_SUBJECT_PREFIX?.trim().slice(0, 120) || "Safest report update";
488
+ const prefix = reporterEmailSubjectPrefix(env);
488
489
  const result = await binding.send({
489
490
  to: email,
490
491
  from,
@@ -0,0 +1,106 @@
1
+ export type AuthenticationEmailBinding = {
2
+ send(message: {
3
+ from: { email: string; name?: string };
4
+ to: string | string[];
5
+ subject: string;
6
+ text: string;
7
+ html: string;
8
+ }): Promise<unknown>;
9
+ };
10
+
11
+ export type ReporterEmailBinding = {
12
+ send(message: {
13
+ from: string;
14
+ to: string;
15
+ subject: string;
16
+ text: string;
17
+ }): Promise<{ messageId?: string }>;
18
+ };
19
+
20
+ type EmailEnvironment = {
21
+ AUTH_EMAIL?: AuthenticationEmailBinding;
22
+ REPORTER_EMAIL?: ReporterEmailBinding;
23
+ /** Compatibility with installations created before the email channels were separated. */
24
+ EMAIL?: AuthenticationEmailBinding & ReporterEmailBinding;
25
+ AUTH_EMAIL_FROM?: string;
26
+ REPORTER_EMAIL_FROM?: string;
27
+ REPORTER_EMAIL_SUBJECT_PREFIX?: string;
28
+ /** Compatibility with installations created before the email channels were separated. */
29
+ EMAIL_FROM_ADDRESS?: string;
30
+ EMAIL_SUBJECT_PREFIX?: string;
31
+ };
32
+
33
+ function emailEnvironment(env: unknown): EmailEnvironment {
34
+ return env as EmailEnvironment;
35
+ }
36
+
37
+ export function authenticationEmailBinding(env: unknown): AuthenticationEmailBinding | undefined {
38
+ const bindings = emailEnvironment(env);
39
+ return bindings.AUTH_EMAIL ?? bindings.EMAIL;
40
+ }
41
+
42
+ export function reporterEmailBinding(env: unknown): ReporterEmailBinding | undefined {
43
+ const bindings = emailEnvironment(env);
44
+ return bindings.REPORTER_EMAIL ?? bindings.EMAIL;
45
+ }
46
+
47
+ export function reporterEmailFrom(env: unknown): string {
48
+ const bindings = emailEnvironment(env);
49
+ return (bindings.REPORTER_EMAIL_FROM ?? bindings.EMAIL_FROM_ADDRESS ?? "").trim().toLowerCase();
50
+ }
51
+
52
+ export function reporterEmailSubjectPrefix(env: unknown): string {
53
+ const bindings = emailEnvironment(env);
54
+ return (bindings.REPORTER_EMAIL_SUBJECT_PREFIX ?? bindings.EMAIL_SUBJECT_PREFIX ?? "").trim().slice(0, 120)
55
+ || "Safest report update";
56
+ }
57
+
58
+ export function reporterEmailConfigured(env: unknown): boolean {
59
+ return Boolean(reporterEmailBinding(env) && reporterEmailFrom(env));
60
+ }
61
+
62
+ export type ParticipantDelivery = {
63
+ channel: "email" | "customer_webhook" | null;
64
+ state: "queued" | "suppressed";
65
+ recipientHash?: string;
66
+ };
67
+
68
+ export async function selectParticipantDelivery(input: {
69
+ reportId: string;
70
+ audience: ParticipantAudience;
71
+ contactMode: string;
72
+ contactCiphertext: string | null;
73
+ reference: string | null;
74
+ emailEnabled: boolean;
75
+ notificationEnabled: boolean;
76
+ contactEncryptionKey: string;
77
+ recipientHashPepper: string;
78
+ }): Promise<ParticipantDelivery> {
79
+ if (input.emailEnabled && input.contactMode === "customer_notification" && input.contactCiphertext) {
80
+ try {
81
+ const email = (await decryptReportContact(
82
+ input.contactEncryptionKey,
83
+ input.contactCiphertext,
84
+ input.reportId,
85
+ input.audience,
86
+ )).trim().toLowerCase();
87
+ if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(email)) {
88
+ return {
89
+ channel: "email",
90
+ state: "queued",
91
+ recipientHash: `sha256:${await sha256(`email-recipient-v1:${input.recipientHashPepper}:${email}`)}`,
92
+ };
93
+ }
94
+ } catch {
95
+ // A damaged encrypted contact can still fall back to a customer-owned
96
+ // notification webhook when the report has an eligible participant.
97
+ }
98
+ }
99
+ if (input.notificationEnabled && input.contactMode === "customer_notification"
100
+ && (input.reference || input.contactCiphertext)) {
101
+ return { channel: "customer_webhook", state: "queued" };
102
+ }
103
+ return { channel: null, state: "suppressed" };
104
+ }
105
+ import { decryptReportContact, sha256 } from "./report-crypto";
106
+ import type { ParticipantAudience } from "./report-types";