create-safest-tools 0.4.0 → 0.4.2

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/README.md CHANGED
@@ -18,7 +18,7 @@ npx create-safest-tools safest-resolve \
18
18
  --email-from reports@example.com
19
19
  ```
20
20
 
21
- The `--email-from` address must belong to a domain onboarded under Cloudflare Email Service → Email Sending. Domain onboarding permits delivery to arbitrary invited users; invitations, email verification, and forgot-password delivery use this address.
21
+ The `--email-from` address is used only for reporter receipts, follow-ups, and outcomes, and must belong to a domain onboarded under Cloudflare Email Service → Email Sending. Account verification, invitations, and password recovery use a distinct `accounts@` address on the same domain by default; pass `--auth-email-from` to choose another address.
22
22
 
23
23
  The command creates a local project and prints a read-only infrastructure plan. It does not change Cloudflare unless `--deploy` is supplied or the generated project’s `npm run setup` command is run and explicitly confirmed.
24
24
 
@@ -68,7 +68,7 @@ npm run restore:plan -- .safest/backups/<backup-directory> --target-config repor
68
68
  npm run uninstall:plan
69
69
  ```
70
70
 
71
- The updater hashes Safest-owned project files and refuses to overwrite local modifications. It preserves `reports.config.json`, owner-only secrets, backups, the Cloudflare account selection, and the D1 database ID; creates a local rollback snapshot; takes a verified D1/R2 backup; installs and validates the release; then applies forward-only migrations and deploys. `update . --plan` is read-only.
71
+ The updater hashes Safest-owned project files and refuses to overwrite local modifications. It preserves `reports.config.json`, owner-only secrets, backups, Cloudflare resource IDs, the Turnstile site key, customer webhook URLs, and the action allowlist; creates a local rollback snapshot; takes a verified D1/R2 backup; installs and validates the release; then applies forward-only migrations and deploys. `update . --plan` is read-only.
72
72
 
73
73
  Backups contain the D1 export and every object in the four private R2 buckets, with stable inventories and SHA-256 verification. They intentionally exclude pending Queue messages, live Workflow engine state, Worker secrets, external provider state, and ephemeral presence. Restore verification is local; restore planning requires a separate target installation and never mutates Cloudflare.
74
74
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-safest-tools",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Create customer-owned abuse-reporting infrastructure on Cloudflare",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
package/src/cli.mjs CHANGED
@@ -29,7 +29,8 @@ Options:
29
29
  --origin origin Allowed embedding/application origin; repeat as needed
30
30
  --owner-email email Initial infrastructure owner bound to one-time setup
31
31
  --admin-email email Deprecated alias for --owner-email
32
- --email-from address Address on an onboarded Email Sending domain (required)
32
+ --email-from address Reporter-update sender on an onboarded Email Sending domain
33
+ --auth-email-from addr Account/invitation sender (default: accounts@same-domain)
33
34
  --yes Do not prompt for omitted optional values
34
35
  --skip-install Create files without running npm install or setup:plan
35
36
  --deploy Run setup after scaffolding (still requires exact confirmation)
@@ -54,7 +55,7 @@ export function parseArguments(argv) {
54
55
  const result = {
55
56
  command: commandName,
56
57
  directory: null, installationName: null, publicBaseUrl: null, allowedOrigins: [],
57
- ownerEmail: null, emailFromAddress: null, yes: false, skipInstall: false,
58
+ ownerEmail: null, emailFromAddress: null, authEmailFromAddress: null, yes: false, skipInstall: false,
58
59
  deploy: false, dryRun: false, plan: false, help: false, version: false,
59
60
  };
60
61
  for (let index = commandName === "update" ? 1 : 0; index < argv.length; index += 1) {
@@ -69,7 +70,7 @@ export function parseArguments(argv) {
69
70
  else if (argument === "--plan") result.plan = true;
70
71
  else if (argument === "--help" || argument === "-h") result.help = true;
71
72
  else if (argument === "--version" || argument === "-v") result.version = true;
72
- else if (["--name", "--public-url", "--origin", "--owner-email", "--admin-email", "--email-from"].includes(argument)) {
73
+ else if (["--name", "--public-url", "--origin", "--owner-email", "--admin-email", "--email-from", "--auth-email-from"].includes(argument)) {
73
74
  const value = valueAfter(argv, index, argument);
74
75
  index += 1;
75
76
  if (argument === "--name") result.installationName = value;
@@ -79,13 +80,14 @@ export function parseArguments(argv) {
79
80
  if (result.ownerEmail && result.ownerEmail !== value) throw new Error("provide only one infrastructure owner email");
80
81
  result.ownerEmail = value;
81
82
  }
82
- else result.emailFromAddress = value;
83
+ else if (argument === "--email-from") result.emailFromAddress = value;
84
+ else result.authEmailFromAddress = value;
83
85
  } else throw new Error(`unknown option: ${argument}`);
84
86
  }
85
87
  if (result.deploy && result.skipInstall) throw new Error("--deploy cannot be used with --skip-install");
86
88
  if (result.command === "create" && result.plan) throw new Error("--plan is available only with the update command");
87
89
  if (result.command === "update" && (result.installationName || result.publicBaseUrl || result.allowedOrigins.length
88
- || result.ownerEmail || result.emailFromAddress || result.skipInstall || result.deploy || result.dryRun)) {
90
+ || result.ownerEmail || result.emailFromAddress || result.authEmailFromAddress || result.skipInstall || result.deploy || result.dryRun)) {
89
91
  throw new Error("update accepts only a project directory, --plan, and --yes");
90
92
  }
91
93
  return result;
@@ -109,7 +111,7 @@ async function completeInteractive(options, input) {
109
111
  options.publicBaseUrl ||= await ask(input, "Public reports origin", "https://reports.example.com");
110
112
  if (!options.allowedOrigins.length) options.allowedOrigins.push(await ask(input, "Application origin allowed to embed the report form", "https://app.example.com"));
111
113
  options.ownerEmail ||= await ask(input, "Infrastructure owner email");
112
- options.emailFromAddress ||= await ask(input, "Sender address on a Cloudflare Email Sending domain");
114
+ options.emailFromAddress ||= await ask(input, "Reporter-update sender on a Cloudflare Email Sending domain");
113
115
  return options;
114
116
  }
115
117
 
package/src/config.mjs CHANGED
@@ -23,6 +23,20 @@ function optionalEmail(value, field) {
23
23
  return result;
24
24
  }
25
25
 
26
+ function defaultAuthEmail(reporterEmail) {
27
+ const at = reporterEmail.lastIndexOf("@");
28
+ if (at < 1) return "";
29
+ const domain = reporterEmail.slice(at + 1);
30
+ return `${reporterEmail.slice(0, at).toLowerCase() === "accounts" ? "auth" : "accounts"}@${domain}`;
31
+ }
32
+
33
+ function emailAddresses(config) {
34
+ const reporter = optionalEmail(config.email?.fromAddress, "email.fromAddress");
35
+ const auth = optionalEmail(config.email?.authFromAddress, "email.authFromAddress") || defaultAuthEmail(reporter);
36
+ if (reporter && auth === reporter) throw new Error("authentication and reporter email senders must be different addresses");
37
+ return { reporter, auth };
38
+ }
39
+
26
40
  function resource(base, suffix) {
27
41
  const prefix = base.slice(0, 63 - suffix.length - 1).replace(/-+$/u, "");
28
42
  return `${prefix}-${suffix}`;
@@ -37,6 +51,9 @@ export function buildConfiguration(options) {
37
51
  if (!ownerEmail) throw new Error("--owner-email is required");
38
52
  const emailFromAddress = optionalEmail(options.emailFromAddress, "email sender");
39
53
  if (!emailFromAddress) throw new Error("--email-from is required and its domain must be onboarded to Cloudflare Email Sending");
54
+ const authEmailFromAddress = optionalEmail(options.authEmailFromAddress, "authentication email sender")
55
+ || defaultAuthEmail(emailFromAddress);
56
+ if (authEmailFromAddress === emailFromAddress) throw new Error("--auth-email-from must differ from --email-from");
40
57
  const resources = {
41
58
  workerName: resource(installationId, "worker"),
42
59
  databaseName: resource(installationId, "db"),
@@ -61,7 +78,7 @@ export function buildConfiguration(options) {
61
78
  publicBaseUrl,
62
79
  allowedOrigins,
63
80
  owner: { email: ownerEmail },
64
- email: { enabled: true, fromAddress: emailFromAddress },
81
+ email: { enabled: true, fromAddress: emailFromAddress, authFromAddress: authEmailFromAddress },
65
82
  auth: { password: true, providers: [] },
66
83
  retention: { reportsDays: 365, messagesDays: 180, appealWindowDays: 30 },
67
84
  };
@@ -70,6 +87,7 @@ export function buildConfiguration(options) {
70
87
  export function buildWranglerConfiguration(config) {
71
88
  const publicHostname = new URL(config.publicBaseUrl).hostname.toLowerCase();
72
89
  const usesWorkersDev = publicHostname.endsWith(".workers.dev");
90
+ const email = emailAddresses(config);
73
91
  const wrangler = {
74
92
  $schema: "node_modules/wrangler/config-schema.json",
75
93
  name: config.resources.workerName,
@@ -126,15 +144,18 @@ export function buildWranglerConfiguration(config) {
126
144
  ACTION_WEBHOOK_URL: "",
127
145
  NOTIFICATION_WEBHOOK_URL: "",
128
146
  OPERATOR_NOTIFICATION_WEBHOOK_URL: "",
129
- AUTH_EMAIL_FROM: config.email.fromAddress,
130
- EMAIL_FROM_ADDRESS: config.email.fromAddress,
131
- EMAIL_SUBJECT_PREFIX: "Safest report update",
147
+ AUTH_EMAIL_FROM: email.auth,
148
+ REPORTER_EMAIL_FROM: email.reporter,
149
+ REPORTER_EMAIL_SUBJECT_PREFIX: "Safest report update",
132
150
  },
133
151
  triggers: { crons: ["*/5 * * * *"] },
134
152
  };
135
153
  if (!usesWorkersDev) {
136
154
  wrangler.routes = [{ pattern: publicHostname, custom_domain: true }];
137
155
  }
138
- wrangler.send_email = [{ name: "EMAIL" }];
156
+ wrangler.send_email = [
157
+ { name: "AUTH_EMAIL", allowed_sender_addresses: [email.auth] },
158
+ { name: "REPORTER_EMAIL", allowed_sender_addresses: [email.reporter] },
159
+ ];
139
160
  return wrangler;
140
161
  }
package/src/update.mjs CHANGED
@@ -7,6 +7,13 @@ import { buildWranglerConfiguration } from "./config.mjs";
7
7
  const MANIFEST_PATH = ".safest-managed.json";
8
8
  const MANIFEST_SCHEMA_VERSION = 1;
9
9
  const REBUILDABLE_PATHS = new Set(["public/console/auth-shell.js"]);
10
+ const CUSTOMER_RUNTIME_VAR_KEYS = new Set([
11
+ "ALLOWED_ACTION_CODES",
12
+ "TURNSTILE_SITE_KEY",
13
+ "ACTION_WEBHOOK_URL",
14
+ "NOTIFICATION_WEBHOOK_URL",
15
+ "OPERATOR_NOTIFICATION_WEBHOOK_URL",
16
+ ]);
10
17
 
11
18
  function stableValue(value) {
12
19
  if (Array.isArray(value)) return value.map(stableValue);
@@ -58,6 +65,7 @@ function normalizedWranglerConfiguration(configuration) {
58
65
  delete database.database_id;
59
66
  delete database.preview_database_id;
60
67
  }
68
+ for (const key of CUSTOMER_RUNTIME_VAR_KEYS) delete normalized.vars?.[key];
61
69
  return normalized;
62
70
  }
63
71
 
@@ -65,6 +73,25 @@ function wranglerHash(configuration) {
65
73
  return sha256(stableJson(normalizedWranglerConfiguration(configuration)));
66
74
  }
67
75
 
76
+ function legacyWranglerHash(configuration) {
77
+ const normalized = structuredClone(configuration);
78
+ delete normalized.account_id;
79
+ for (const database of normalized.d1_databases ?? []) {
80
+ delete database.database_id;
81
+ delete database.preview_database_id;
82
+ }
83
+ return sha256(stableJson(normalized));
84
+ }
85
+
86
+ function wranglerMatchesManifest(configuration, expectedHash) {
87
+ if (wranglerHash(configuration) === expectedHash || legacyWranglerHash(configuration) === expectedHash) return true;
88
+ const preUpdateDefaults = structuredClone(configuration);
89
+ for (const key of CUSTOMER_RUNTIME_VAR_KEYS) {
90
+ if (key in (preUpdateDefaults.vars ?? {})) preUpdateDefaults.vars[key] = "";
91
+ }
92
+ return legacyWranglerHash(preUpdateDefaults) === expectedHash;
93
+ }
94
+
68
95
  function mergeWranglerRuntimeState(desired, current) {
69
96
  const result = structuredClone(desired);
70
97
  if (typeof current.account_id === "string" && current.account_id) result.account_id = current.account_id;
@@ -75,6 +102,9 @@ function mergeWranglerRuntimeState(desired, current) {
75
102
  if (typeof installed[field] === "string" && installed[field]) database[field] = installed[field];
76
103
  }
77
104
  }
105
+ for (const key of CUSTOMER_RUNTIME_VAR_KEYS) {
106
+ if (typeof current.vars?.[key] === "string") result.vars[key] = current.vars[key];
107
+ }
78
108
  return result;
79
109
  }
80
110
 
@@ -172,7 +202,7 @@ export async function planProjectUpdate({ directory, templateDirectory }) {
172
202
  if (currentHash !== null && currentHash !== targetHash) conflicts.push(path);
173
203
  added.push(path);
174
204
  }
175
- if (wranglerHash(currentWrangler) !== manifest.wranglerHash) conflicts.push("wrangler.jsonc");
205
+ if (!wranglerMatchesManifest(currentWrangler, manifest.wranglerHash)) conflicts.push("wrangler.jsonc");
176
206
 
177
207
  const desiredWrangler = mergeWranglerRuntimeState(buildWranglerConfiguration(reportsConfiguration), currentWrangler);
178
208
  const deployed = (currentWrangler.d1_databases ?? []).some(({ database_id: id }) => typeof id === "string" && id.length > 0);
@@ -186,7 +216,7 @@ export async function planProjectUpdate({ directory, templateDirectory }) {
186
216
  ready: uniqueConflicts.length === 0,
187
217
  changes: { added: added.sort(), updated: updated.sort(), removed: removed.sort() },
188
218
  conflicts: uniqueConflicts,
189
- preserves: ["reports.config.json", ".safest/secrets.env", ".safest/backups/", "Cloudflare account_id", "D1 database_id"],
219
+ preserves: ["reports.config.json", ".safest/secrets.env", ".safest/backups/", "Cloudflare account_id", "D1 database_id", "Turnstile site key", "customer webhook URLs and action allowlist"],
190
220
  manifest,
191
221
  targetFiles,
192
222
  desiredWrangler,
@@ -9,7 +9,7 @@ This project deploys one Worker, one D1 database, four private R2 buckets, a Dyn
9
9
  Before deployment:
10
10
 
11
11
  1. Review `reports.config.json` and `npm run setup:plan`.
12
- 2. Under Cloudflare Email Service → Email Sending, onboard the configured sender domain. Invitations and password recovery to arbitrary recipients depend on domain onboarding.
12
+ 2. Under Cloudflare Email Service → Email Sending, onboard the configured sender domain. Reporter updates and account mail use separate sender addresses and sender-restricted bindings.
13
13
  3. Run `npm run setup`. The guided setup signs in through Wrangler and verifies Workers Paid from the account's Workers usage model before provisioning. Standard accounts need no separate billing token; only legacy or ambiguous models use the temporary Billing Read fallback. Setup then creates owner-only local secrets and walks through optional Google, GitHub, and Cloudflare OAuth credentials with exact callback URLs.
14
14
  4. Type the exact installation confirmation when setup requests it. After deployment, setup prints a 256-bit, single-use owner link that expires after 15 minutes and is never written to disk.
15
15
  5. Open the owner link, create the Safest owner account, then invite administrators and analysts from People. Nobody needs a Cloudflare account to sign in. Names and workspace roles are always displayed separately.
@@ -20,7 +20,7 @@ If the setup link expires, run `npm run owner:setup`. If the owner loses access
20
20
 
21
21
  Administrators build and preview questions in **Configuration → Intake forms**, set the logo, colours, fonts, and shape in **Settings → Branding**, then choose a private hosted page, signed-in product widget, or anonymous product widget in **Settings → Reporting channels**. Hosted pages and anonymous widgets need no API key. Signed-in widgets use one small customer-backend endpoint; verified reporter, target, and registered trusted facts stay server-side while the browser receives only a short-lived opaque token. Every public submission is verified with Turnstile inside a Resolve-owned isolated frame.
22
22
 
23
- Cloudflare Email Service and an `--email-from reports@example.com` address on an onboarded Email Sending domain are required for invitations, email verification, and password recovery. Participant notifications may still use a configured signed customer notification webhook.
23
+ Cloudflare Email Service and an `--email-from reports@example.com` address on an onboarded Email Sending domain are required for reporter receipts, follow-ups, and outcomes. Invitations, email verification, and password recovery use a separate `accounts@` sender by default; `--auth-email-from` overrides it. A configured signed customer notification webhook remains the fallback for reference-only participants.
24
24
 
25
25
  ```bash
26
26
  npm run backup:plan
@@ -31,7 +31,7 @@ npx create-safest-tools@latest update . --plan
31
31
  npx create-safest-tools@latest update .
32
32
  ```
33
33
 
34
- The updater preserves customer configuration, secrets, backups, and Cloudflare resource IDs. It blocks on locally modified Safest-owned files, creates a local rollback snapshot, takes a verified D1/R2 backup, validates the new release, then applies forward-only migrations and deploys. `update . --plan` is read-only.
34
+ The updater preserves customer configuration, secrets, backups, Cloudflare resource IDs, Turnstile configuration, customer webhook URLs, and the action allowlist. It blocks on locally modified Safest-owned files, creates a local rollback snapshot, takes a verified D1/R2 backup, validates the new release, then applies forward-only migrations and deploys. `update . --plan` is read-only.
35
35
 
36
36
  `npm run uninstall:plan` is deliberately read-only. It lists only the resources named by this installation and preserves D1 and all private R2 buckets by default.
37
37
 
@@ -9,7 +9,7 @@ import {
9
9
  dispatchReporterMessage,
10
10
  } from "./events";
11
11
  import { getReportWorkspaceSnapshot, subscribeToReportWorkspace } from "./store";
12
- import type { ReportDetailRecord, TimelineEvent } from "./types";
12
+ import type { ReportAnswer, ReportDetailRecord, TimelineEvent } from "./types";
13
13
 
14
14
  function Fact({ label, children }: { label: string; children: React.ReactNode }) {
15
15
  return <><dt>{label}</dt><dd>{children || "—"}</dd></>;
@@ -19,6 +19,48 @@ function EmptyLine({ children }: { children: React.ReactNode }) {
19
19
  return <p className="detail-empty">{children}</p>;
20
20
  }
21
21
 
22
+ function answerOptionLabel(answer: ReportAnswer, value: string): string {
23
+ return answer.options?.find((option) => option.value === value)?.label || readable(value);
24
+ }
25
+
26
+ function AnswerValue({ answer }: { answer: ReportAnswer }) {
27
+ if (!answer.answered) return <span className="answer-empty">Not answered</span>;
28
+ if (typeof answer.value === "boolean") return <span>{answer.value ? "Yes" : "No"}</span>;
29
+ if (Array.isArray(answer.value)) {
30
+ const values = answer.value.map((value) => answerOptionLabel(answer, String(value)));
31
+ return <span>{values.length ? values.join(", ") : "None selected"}</span>;
32
+ }
33
+ if (answer.value === null || answer.value === undefined || answer.value === "") return <span className="answer-empty">Not answered</span>;
34
+ const value = String(answer.value);
35
+ if (answer.type === "select") return <span>{answerOptionLabel(answer, value)}</span>;
36
+ const url = answer.type === "url" ? safeCustomerUrl(value) : null;
37
+ return url
38
+ ? <a className="text-button" href={url} target="_blank" rel="noopener noreferrer" referrerPolicy="no-referrer">{value} ↗</a>
39
+ : <span className="answer-text">{value}</span>;
40
+ }
41
+
42
+ function reporterEmailValue(report: ReportDetailRecord): string {
43
+ if (report.reporterEmail) return report.reporterEmail;
44
+ if (report.reporterContactStatus === "redacted") return "Redacted by the retention policy";
45
+ if (report.reporterContactStatus === "restricted") return "Restricted for this role";
46
+ if (report.reporterContactStatus === "unavailable") return "Temporarily unavailable";
47
+ return "Not provided";
48
+ }
49
+
50
+ function deliveryLabel(state: string): string {
51
+ if (state === "suppressed") return "Not sent";
52
+ if (state === "queued" || state === "pending" || state === "sending" || state === "delivering") return "Queued";
53
+ return readable(state);
54
+ }
55
+
56
+ function deliveryExplanation(state: string): string {
57
+ if (state === "suppressed") return "No eligible participant delivery channel was available when this message was created.";
58
+ if (state === "queued" || state === "pending" || state === "sending" || state === "delivering") return "Waiting for the delivery worker.";
59
+ if (state === "delivered") return "Successfully handed to the configured delivery provider.";
60
+ if (state === "failed" || state === "rejected" || state === "bounced") return "Delivery needs attention in Operations.";
61
+ return "";
62
+ }
63
+
22
64
  function timelineDetail(event: TimelineEvent): string {
23
65
  const details = event.details ?? {};
24
66
  const value = (key: string): string => typeof details[key] === "string" || typeof details[key] === "number" ? String(details[key]) : "";
@@ -38,7 +80,7 @@ function timelineMarker(eventType: string): string {
38
80
  return "•";
39
81
  }
40
82
 
41
- function DetailContent({ report, actorId, generatedAt, actionCodes, claimReports, messageReporters, decideReports, pendingAction, messageMutationVersion, error }: {
83
+ function DetailContent({ report, actorId, generatedAt, actionCodes, claimReports, messageReporters, decideReports, pendingAction, messageMutationVersion, statusMessage, error }: {
42
84
  report: ReportDetailRecord;
43
85
  actorId: string;
44
86
  generatedAt: string;
@@ -48,9 +90,11 @@ function DetailContent({ report, actorId, generatedAt, actionCodes, claimReports
48
90
  decideReports: boolean;
49
91
  pendingAction: "claim" | "release" | "message" | "decision" | null;
50
92
  messageMutationVersion: number;
93
+ statusMessage: string;
51
94
  error: string;
52
95
  }) {
53
96
  const evidence = report.evidence ?? [];
97
+ const answers = report.answers ?? [];
54
98
  const messages = report.messages ?? [];
55
99
  const workflowRuns = report.workflowRuns ?? [];
56
100
  const timeline = report.timeline ?? [];
@@ -106,6 +150,15 @@ function DetailContent({ report, actorId, generatedAt, actionCodes, claimReports
106
150
  return (
107
151
  <>
108
152
  <div className="detail-grid">
153
+ <section className="report-submission"><h3>Reporter submission</h3><dl className="submission-meta">
154
+ <Fact label="Form">{report.formTitle || report.formVersionId || "Unknown form"}</Fact>
155
+ <Fact label="Channel">{readable(report.source || "unknown")}</Fact>
156
+ <Fact label="Submitted">{dateTime(report.submittedAt || report.receivedAt)}</Fact>
157
+ <Fact label="Language">{report.locale || "—"}</Fact>
158
+ <Fact label="Reporter email">{reporterEmailValue(report)}</Fact>
159
+ </dl><div className="answer-list">
160
+ {answers.length ? answers.map((answer) => <article className="answer-record" key={answer.fieldKey}><div><strong>{answer.label || readable(answer.fieldKey)}</strong>{answer.required ? <small>Required question</small> : null}</div><AnswerValue answer={answer} /></article>) : <EmptyLine>This form did not contain additional questions.</EmptyLine>}
161
+ </div></section>
109
162
  <section><h3>Allegation and target</h3><dl>
110
163
  <Fact label="Allegation">{readable(report.reasonCode)}</Fact>
111
164
  <Fact label="Target type">{readable(report.targetType)}</Fact>
@@ -125,22 +178,23 @@ function DetailContent({ report, actorId, generatedAt, actionCodes, claimReports
125
178
 
126
179
  {workflowRuns.length || timeline.length ? <section><h3>Workflow &amp; case timeline</h3><div className="report-run-list">{workflowRuns.map((run) => <article className="report-run-record" key={run.id}><div><strong>{run.workflowName || run.workflowKey || `Workflow ${String(run.workflowVersionId || run.id).slice(0, 12)}`}</strong><small>{readable(run.runKind || "primary")} · {readable(run.authorityMode || "assist")} · immutable version {run.workflowVersion || String(run.workflowVersionId || "").slice(0, 12)}</small></div><span className={`badge${["failed", "repair_required"].includes(run.state) ? " urgent" : ""}`}>{readable(run.state)}</span></article>)}</div><div className="report-timeline">{timeline.map((item, index) => <article className="timeline-event" key={item.id ?? `${item.eventType}-${item.createdAt}-${index}`}><span className={`timeline-marker ${item.eventType || "event"}`}>{timelineMarker(item.eventType)}</span><div><strong>{readable(item.eventType || "activity")}</strong><span>{timelineDetail(item)}</span><small>{relativeTime(item.createdAt)} · {readable(item.actorType || "system")}</small></div></article>)}</div></section> : null}
127
180
 
128
- <section><h3>Case messages</h3><div className="message-list">{messages.length ? messages.map((item, index) => <article className={`case-message ${item.direction}`} key={item.id ?? `${item.createdAt}-${index}`}><div>{item.body}</div><small>{readable(item.senderType)} · {dateTime(item.createdAt)} · {readable(item.deliveryState)}</small></article>) : <EmptyLine>No case messages yet.</EmptyLine>}</div></section>
181
+ <section><h3>Case messages</h3><div className="message-list">{messages.length ? messages.map((item, index) => <article className={`case-message ${item.direction}`} key={item.id ?? `${item.createdAt}-${index}`}><div>{item.body}</div><small>{readable(item.senderType)} · {dateTime(item.createdAt)} · {deliveryLabel(item.deliveryState)}{deliveryExplanation(item.deliveryState) ? ` — ${deliveryExplanation(item.deliveryState)}` : ""}</small></article>) : <EmptyLine>No case messages yet.</EmptyLine>}</div></section>
129
182
 
130
183
  {findings.length ? <section><h3>Component findings</h3><div className="finding-result-list">{findings.map((finding, index) => <article key={finding.id ?? `${finding.findingType}-${index}`}><div><strong>{finding.findingType}</strong><small>{finding.summary}{finding.componentVersionId ? ` · component ${finding.componentVersionId}` : ""}{finding.expiresAt ? ` · expires ${dateTime(finding.expiresAt)}` : ""}</small>{finding.data ? <pre>{JSON.stringify(finding.data, null, 2)}</pre> : null}</div><span className={`badge${finding.freshness !== "available" ? " urgent" : ""}`}>{readable(finding.freshness || finding.status)}</span></article>)}</div></section> : null}
131
184
 
132
185
  {decisions.length ? <section><h3>Decisions &amp; rationale</h3><div className="decision-record-list">{decisions.map((decision, index) => <article className="decision-record" key={decision.id ?? `${decision.createdAt}-${index}`}><div><strong>{readable(decision.decisionCode)}</strong><span className="badge">{decision.policyCode || "Policy decision"}</span></div><p>{decision.rationale || "No rationale recorded."}</p><small>{readable(decision.makerType)} · {dateTime(decision.createdAt)} · {(decision.evidenceReferences || []).length} evidence reference(s)</small></article>)}</div></section> : null}
133
186
 
134
- {deliveries.length ? <section><h3>Decision and delivery</h3><div className="delivery-list">{deliveries.map((delivery) => <article key={delivery.id}><div><strong>{delivery.title}</strong><small>{delivery.detail}</small></div><span className={`badge${["failed", "rejected"].includes(delivery.state) ? " urgent" : ""}`}>{readable(delivery.state)}</span></article>)}</div></section> : null}
187
+ {deliveries.length ? <section><h3>Decision and delivery</h3><div className="delivery-list">{deliveries.map((delivery) => <article key={delivery.id}><div><strong>{delivery.title}</strong><small>{delivery.detail}{deliveryExplanation(delivery.state) ? ` · ${deliveryExplanation(delivery.state)}` : ""}</small></div><span className={`badge${["failed", "rejected", "bounced", "suppressed"].includes(delivery.state) ? " urgent" : ""}`}>{deliveryLabel(delivery.state)}</span></article>)}</div></section> : null}
135
188
 
136
189
  {aiRuns.length ? <section><h3>Bounded AI record</h3><div className="ai-run-list">{aiRuns.map((run, index) => <article key={run.id ?? `${run.model}-${index}`}><div><strong>{readable(run.mode)} · {readable(run.status)}</strong><small>{run.model} · {run.promptVersion} · {run.configVersionId}</small></div><p>{run.summary || readable(run.errorCode || "No validated result")}</p><small>{readable(run.outcome || "no outcome")} · {run.policyCode || "no policy"} · {run.estimatedCostMicrousd || 0} µUSD{run.uncertain ? " · escalated for uncertainty" : ""}</small></article>)}</div></section> : null}
137
190
 
138
191
  <div className="claim-bar"><p>{activeClaim && report.claim ? <><AnalystAvatar analyst={report.claim.analyst ?? { displayName: "Another reviewer" }} size="small" />{mine ? "You hold" : `${report.claim.analyst?.displayName || "Another reviewer"} holds`} this report until {new Date(report.claim.expiresAt).toLocaleTimeString()}.</> : "This report must be claimed before a reviewer changes it."}</p><div>{mine ? <button className="secondary" type="button" disabled={changingClaim} onClick={dispatchReleaseReport}>{pendingAction === "release" ? "Releasing…" : "Release"}</button> : null}{claimable && !activeClaim ? <button className="primary" type="button" disabled={changingClaim} onClick={dispatchClaimReport}>{pendingAction === "claim" ? "Claiming…" : "Claim report"}</button> : null}</div></div>
139
192
 
140
193
  <div className="detail-actions">
141
- <form onSubmit={sendMessage}><h3>Ask the reporter</h3><label>Message<textarea maxLength={4000} required value={message} onChange={(event) => setMessage(event.currentTarget.value)} disabled={!canMessage || pendingAction === "message"} /></label><label className="checkbox"><input type="checkbox" checked={awaitReporter} onChange={(event) => setAwaitReporter(event.currentTarget.checked)} disabled={!canMessage || pendingAction === "message"} />Move to awaiting reporter</label><button className="secondary" type="submit" disabled={!canMessage || pendingAction === "message"}>{pendingAction === "message" ? "Sending…" : "Send participant update"}</button></form>
194
+ <form onSubmit={sendMessage}><h3>Ask the reporter</h3><p className="form-help">The update is emailed when the reporter supplied an address. Otherwise, Resolve uses the configured signed customer notification channel.</p><label>Message<textarea maxLength={4000} required value={message} onChange={(event) => setMessage(event.currentTarget.value)} disabled={!canMessage || pendingAction === "message"} /></label><label className="checkbox"><input type="checkbox" checked={awaitReporter} onChange={(event) => setAwaitReporter(event.currentTarget.checked)} disabled={!canMessage || pendingAction === "message"} />Move to awaiting reporter</label><button className="secondary" type="submit" disabled={!canMessage || pendingAction === "message"}>{pendingAction === "message" ? "Queueing…" : "Send participant update"}</button></form>
142
195
  <form onSubmit={recordDecision}><h3>Record a human decision</h3><label>Decision code<input value={decisionCode} onChange={(event) => setDecisionCode(event.currentTarget.value)} disabled={!canDecide || pendingAction === "decision"} required /></label><label>Policy code<select value={policyCode} onChange={(event) => setPolicyCode(event.currentTarget.value)} disabled={!canDecide || pendingAction === "decision"} required>{rules.map((rule) => <option value={rule.code} key={rule.code}>{rule.title ? `${rule.code} · ${rule.title}` : rule.code}</option>)}</select></label><label>Internal rationale<textarea maxLength={8000} value={rationale} onChange={(event) => setRationale(event.currentTarget.value)} disabled={!canDecide || pendingAction === "decision"} required /></label><label>Reporter outcome<textarea maxLength={4000} value={reporterNotice} onChange={(event) => setReporterNotice(event.currentTarget.value)} disabled={!canDecide || pendingAction === "decision"} required /></label><label>Affected-user notice <small>Optional; sent through a separate audience channel.</small><textarea maxLength={4000} value={affectedNotice} onChange={(event) => setAffectedNotice(event.currentTarget.value)} disabled={!canDecide || pendingAction === "decision"} /></label>{actionCodes.length ? <label>Application action <small>Optional. Only deployment-allowlisted actions are shown.</small><select value={actionCode} onChange={(event) => setActionCode(event.currentTarget.value)} disabled={!canDecide || pendingAction === "decision"}><option value="">No application action</option>{actionCodes.map((code) => <option value={code} key={code}>{readable(code)}</option>)}</select></label> : null}<button className="primary" type="submit" disabled={!canDecide || pendingAction === "decision"}>{pendingAction === "decision" ? "Recording…" : "Complete human review"}</button></form>
143
196
  </div>
197
+ <p className="mutation-status" role="status">{statusMessage}</p>
144
198
  <p className="error" role="alert">{error}</p>
145
199
  </>
146
200
  );
@@ -153,7 +207,7 @@ export function ReportDrawer() {
153
207
  return (
154
208
  <dialog className="detail-dialog report-drawer" open={open} aria-labelledby="report-detail-title" onCancel={(event) => { event.preventDefault(); dispatchCloseReport(); }} data-react-slice="report-drawer">
155
209
  <div className="detail-head"><div><h2 id="report-detail-title">{detail.report?.reference || "Report"}</h2><p>{detail.report ? `${readable(detail.report.state)} · ${readable(detail.report.queueId || "unassigned")}` : "Loading report…"}</p></div><button className="icon-button" type="button" aria-label="Close report detail" onClick={dispatchCloseReport}>×</button></div>
156
- <div className="report-detail-content">{detail.loading && !detail.report ? <div className="skeleton-block" role="status" aria-label="Loading report detail" /> : detail.report ? <DetailContent report={detail.report} actorId={snapshot.actorId} generatedAt={snapshot.generatedAt} actionCodes={snapshot.actionCodes} claimReports={snapshot.permissions.claimReports} messageReporters={snapshot.permissions.messageReporters} decideReports={snapshot.permissions.decideReports} pendingAction={detail.pendingAction} messageMutationVersion={detail.messageMutationVersion} error={detail.error} /> : detail.error ? <p className="error surface-error" role="alert">{detail.error}</p> : null}</div>
210
+ <div className="report-detail-content">{detail.loading && !detail.report ? <div className="skeleton-block" role="status" aria-label="Loading report detail" /> : detail.report ? <DetailContent report={detail.report} actorId={snapshot.actorId} generatedAt={snapshot.generatedAt} actionCodes={snapshot.actionCodes} claimReports={snapshot.permissions.claimReports} messageReporters={snapshot.permissions.messageReporters} decideReports={snapshot.permissions.decideReports} pendingAction={detail.pendingAction} messageMutationVersion={detail.messageMutationVersion} statusMessage={detail.statusMessage} error={detail.error} /> : detail.error ? <p className="error surface-error" role="alert">{detail.error}</p> : null}</div>
157
211
  </dialog>
158
212
  );
159
213
  }
@@ -62,7 +62,7 @@ const initialSnapshot = (): ReportWorkspaceSnapshot => ({
62
62
  presence: [],
63
63
  actionCodes: [],
64
64
  permissions: { claimReports: false, messageReporters: false, decideReports: false, readRuns: false },
65
- detail: { reportId: null, loading: false, error: "", pendingAction: null, messageMutationVersion: 0, report: null },
65
+ detail: { reportId: null, loading: false, error: "", statusMessage: "", pendingAction: null, messageMutationVersion: 0, report: null },
66
66
  });
67
67
 
68
68
  let actorId = "";
@@ -223,6 +223,7 @@ async function openReport(reportId: string): Promise<void> {
223
223
  reportId,
224
224
  loading: true,
225
225
  error: "",
226
+ statusMessage: snapshot.detail.reportId === reportId ? snapshot.detail.statusMessage : "",
226
227
  report: snapshot.detail.report?.id === reportId ? snapshot.detail.report : null,
227
228
  },
228
229
  });
@@ -262,7 +263,7 @@ function closeReport(): void {
262
263
  detailRequestVersion += 1;
263
264
  const messageMutationVersion = getReportWorkspaceSnapshot().detail.messageMutationVersion;
264
265
  document.body.classList.remove("report-open");
265
- publish({ detail: { reportId: null, loading: false, error: "", pendingAction: null, messageMutationVersion, report: null } });
266
+ publish({ detail: { reportId: null, loading: false, error: "", statusMessage: "", pendingAction: null, messageMutationVersion, report: null } });
266
267
  sendPresenceActivity();
267
268
  }
268
269
 
@@ -301,15 +302,20 @@ async function sendMessage(input: ReporterMessageInput): Promise<void> {
301
302
  const reportId = snapshot.detail.report?.id;
302
303
  if (!reportId || !snapshot.permissions.messageReporters || snapshot.detail.pendingAction) return;
303
304
  const idempotencyKey = crypto.randomUUID();
304
- publish({ detail: { ...snapshot.detail, error: "", pendingAction: "message" } });
305
+ publish({ detail: { ...snapshot.detail, error: "", statusMessage: "", pendingAction: "message" } });
305
306
  try {
306
- await requestJson(`/v1/admin/reports/${encodeURIComponent(reportId)}/messages`, {
307
+ const response = await requestJson<{ delivery_state?: string; delivery_channel?: string | null }>(`/v1/admin/reports/${encodeURIComponent(reportId)}/messages`, {
307
308
  method: "POST",
308
309
  headers: { "content-type": "application/json", "idempotency-key": idempotencyKey },
309
310
  body: JSON.stringify({ idempotency_key: idempotencyKey, body: input.body, await_reporter: input.awaitReporter }),
310
311
  });
311
312
  const version = getReportWorkspaceSnapshot().detail.messageMutationVersion + 1;
312
- publish({ detail: { ...getReportWorkspaceSnapshot().detail, messageMutationVersion: version } });
313
+ const statusMessage = response.delivery_state === "queued"
314
+ ? response.delivery_channel === "email"
315
+ ? "Participant update queued for email delivery."
316
+ : "Participant update queued for the configured customer notification channel."
317
+ : "The update was saved but not sent because this report has no available participant delivery channel.";
318
+ publish({ detail: { ...getReportWorkspaceSnapshot().detail, messageMutationVersion: version, statusMessage } });
313
319
  await Promise.all([openReport(reportId), loadReports()]);
314
320
  } catch (cause) {
315
321
  publish({ detail: { ...getReportWorkspaceSnapshot().detail, error: errorMessage(cause, "The message could not be created.") } });
@@ -15,7 +15,7 @@ let snapshot: ReportWorkspaceSnapshot = {
15
15
  presence: [],
16
16
  actionCodes: [],
17
17
  permissions: { claimReports: false, messageReporters: false, decideReports: false, readRuns: false },
18
- detail: { reportId: null, loading: false, error: "", pendingAction: null, messageMutationVersion: 0, report: null },
18
+ detail: { reportId: null, loading: false, error: "", statusMessage: "", pendingAction: null, messageMutationVersion: 0, report: null },
19
19
  };
20
20
 
21
21
  window.addEventListener(reportEvents.state, (event) => {
@@ -151,23 +151,48 @@ export interface ReportClaim {
151
151
  analyst?: AnalystIdentityData;
152
152
  }
153
153
 
154
+ export interface ReportAnswerOption {
155
+ value: string;
156
+ label: string;
157
+ }
158
+
159
+ export interface ReportAnswer {
160
+ fieldKey: string;
161
+ label: string;
162
+ type: string;
163
+ required: boolean;
164
+ answered: boolean;
165
+ value: unknown;
166
+ options?: ReportAnswerOption[];
167
+ }
168
+
154
169
  export interface ReportDetailRecord {
155
170
  id: string;
156
171
  reference: string;
157
172
  state: string;
158
173
  queueId?: string;
159
174
  reasonCode: string;
175
+ source?: string;
176
+ locale?: string;
177
+ submittedAt?: string;
178
+ formVersionId?: string;
179
+ formTitle?: string;
180
+ consentNotice?: string;
160
181
  targetType: string;
161
182
  targetReference: string;
162
183
  customerUrl?: string;
163
184
  ownerReference?: string;
164
185
  reporterReference?: string;
186
+ reporterEmail?: string | null;
187
+ reporterContactMode?: string;
188
+ reporterContactStatus?: "available" | "not_provided" | "redacted" | "restricted" | "unavailable";
165
189
  policyTitle?: string;
166
190
  policyVersionId?: string;
167
191
  queuePolicyVersionId?: string;
168
192
  reviewerInstructions?: string;
169
193
  receivedAt: string;
170
194
  policyRules?: PolicyRule[];
195
+ answers?: ReportAnswer[];
171
196
  evidence?: EvidenceReference[];
172
197
  messages?: CaseMessage[];
173
198
  workflowRuns?: WorkflowRunRecord[];
@@ -189,6 +214,7 @@ export interface ReportDetailSnapshot {
189
214
  error: string;
190
215
  pendingAction: ReportPendingAction;
191
216
  messageMutationVersion: number;
217
+ statusMessage: string;
192
218
  report: ReportDetailRecord | null;
193
219
  }
194
220
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "safest-resolve-installation",
3
- "version": "0.4.0",
4
- "safestToolsVersion": "0.4.0-resolve",
3
+ "version": "0.4.2",
4
+ "safestToolsVersion": "0.4.2-resolve",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
7
7
  "type": "module",