create-safest-tools 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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 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.
21
+ The `--email-from` address is a real, receivable mailbox used for reporter receipts, follow-ups, outcomes, and replies. Use a short base address such as `reports@example.com` on a domain onboarded under Cloudflare Email Service → Email Sending, then enable Email Routing for that domain. Setup creates an exact Worker route for this address, enables plus-addressing for signed case replies, and does not enable catch-all mail. 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
 
@@ -44,7 +44,7 @@ npm run setup:plan
44
44
  npm run setup
45
45
  ```
46
46
 
47
- `setup:plan` lists exact resource names and exits without modifying Cloudflare. `setup` opens Wrangler's Cloudflare login when needed, lets you choose the owning account, and verifies Workers Paid from the account's Workers usage model. Standard accounts need no separate billing token; only legacy or ambiguous account models use the temporary Billing Read fallback. It then guides Google, GitHub, and Cloudflare OAuth configuration with exact callback URLs and masked secret input. Nothing is provisioned until the exact `DEPLOY <installation-id>` confirmation.
47
+ `setup:plan` lists exact resource names and the reporter email route, then exits without modifying Cloudflare. `setup` opens Wrangler's Cloudflare login when needed, lets you choose the owning account, and verifies Workers Paid from the account's Workers usage model. Standard accounts need no separate billing token; only legacy or ambiguous account models use the temporary Billing Read fallback. It then guides Google, GitHub, and Cloudflare OAuth configuration with exact callback URLs and masked secret input. Nothing is provisioned until the exact `DEPLOY <installation-id>` confirmation. After confirmation it verifies Email Routing, enables subaddressing, and deploys only the exact reporter-address route.
48
48
 
49
49
  A new installation applies one current D1 schema baseline. Later releases add only forward-compatible upgrade migrations.
50
50
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-safest-tools",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
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,7 @@ 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 Reporter-update sender on an onboarded Email Sending domain
32
+ --email-from address Receivable reporter mailbox on the onboarded email domain
33
33
  --auth-email-from addr Account/invitation sender (default: accounts@same-domain)
34
34
  --yes Do not prompt for omitted optional values
35
35
  --skip-install Create files without running npm install or setup:plan
@@ -111,7 +111,7 @@ async function completeInteractive(options, input) {
111
111
  options.publicBaseUrl ||= await ask(input, "Public reports origin", "https://reports.example.com");
112
112
  if (!options.allowedOrigins.length) options.allowedOrigins.push(await ask(input, "Application origin allowed to embed the report form", "https://app.example.com"));
113
113
  options.ownerEmail ||= await ask(input, "Infrastructure owner email");
114
- options.emailFromAddress ||= await ask(input, "Reporter-update sender on a Cloudflare Email Sending domain");
114
+ options.emailFromAddress ||= await ask(input, "Receivable reporter mailbox (for example reports@example.com)");
115
115
  return options;
116
116
  }
117
117
 
package/src/config.mjs CHANGED
@@ -30,10 +30,21 @@ function defaultAuthEmail(reporterEmail) {
30
30
  return `${reporterEmail.slice(0, at).toLowerCase() === "accounts" ? "auth" : "accounts"}@${domain}`;
31
31
  }
32
32
 
33
+ function validateReporterMailbox(address, field) {
34
+ const local = address.slice(0, address.lastIndexOf("@"));
35
+ if (/^no-?reply$/u.test(local)) {
36
+ throw new Error(`${field} must be a receivable mailbox, not a no-reply address`);
37
+ }
38
+ if (local.includes("+") || local.length > 10) {
39
+ throw new Error(`${field} must use a base mailbox of 10 characters or fewer without plus-addressing so secure case reply addresses fit`);
40
+ }
41
+ }
42
+
33
43
  function emailAddresses(config) {
34
44
  const reporter = optionalEmail(config.email?.fromAddress, "email.fromAddress");
35
45
  const auth = optionalEmail(config.email?.authFromAddress, "email.authFromAddress") || defaultAuthEmail(reporter);
36
46
  if (reporter && auth === reporter) throw new Error("authentication and reporter email senders must be different addresses");
47
+ validateReporterMailbox(reporter, "email.fromAddress");
37
48
  return { reporter, auth };
38
49
  }
39
50
 
@@ -51,6 +62,7 @@ export function buildConfiguration(options) {
51
62
  if (!ownerEmail) throw new Error("--owner-email is required");
52
63
  const emailFromAddress = optionalEmail(options.emailFromAddress, "email sender");
53
64
  if (!emailFromAddress) throw new Error("--email-from is required and its domain must be onboarded to Cloudflare Email Sending");
65
+ validateReporterMailbox(emailFromAddress, "--email-from");
54
66
  const authEmailFromAddress = optionalEmail(options.authEmailFromAddress, "authentication email sender")
55
67
  || defaultAuthEmail(emailFromAddress);
56
68
  if (authEmailFromAddress === emailFromAddress) throw new Error("--auth-email-from must differ from --email-from");
@@ -153,6 +165,7 @@ export function buildWranglerConfiguration(config) {
153
165
  if (!usesWorkersDev) {
154
166
  wrangler.routes = [{ pattern: publicHostname, custom_domain: true }];
155
167
  }
168
+ wrangler.addresses = [email.reporter];
156
169
  wrangler.send_email = [
157
170
  { name: "AUTH_EMAIL", allowed_sender_addresses: [email.auth] },
158
171
  { name: "REPORTER_EMAIL", allowed_sender_addresses: [email.reporter] },
@@ -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. Reporter updates and account mail use separate sender addresses and sender-restricted bindings.
12
+ 2. Under Cloudflare Email Service → Email Sending, onboard the configured sender domain. Enable Email Routing for that domain and use a real reporter mailbox such as `reports@example.com`; setup creates only that exact Worker route, enables plus-addressing for signed case replies, and never enables catch-all routing. 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 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.
23
+ Cloudflare Email Service and a receivable `--email-from reports@example.com` address on an onboarded Email Sending domain are required for reporter receipts, follow-ups, outcomes, and replies. Reply addresses contain a signed, expiring case token; the Worker also verifies the original reporter address before storing a message. 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
@@ -39,6 +39,14 @@ function AnswerValue({ answer }: { answer: ReportAnswer }) {
39
39
  : <span className="answer-text">{value}</span>;
40
40
  }
41
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
+
42
50
  function deliveryLabel(state: string): string {
43
51
  if (state === "suppressed") return "Not sent";
44
52
  if (state === "queued" || state === "pending" || state === "sending" || state === "delivering") return "Queued";
@@ -56,13 +64,37 @@ function deliveryExplanation(state: string): string {
56
64
  function timelineDetail(event: TimelineEvent): string {
57
65
  const details = event.details ?? {};
58
66
  const value = (key: string): string => typeof details[key] === "string" || typeof details[key] === "number" ? String(details[key]) : "";
59
- if (event.eventType === "state_transition") return `${readable(value("from_state") || "received")} → ${readable(value("to_state") || event.code)}`;
67
+ if (event.eventType === "state_transition") {
68
+ const from = value("from_state");
69
+ const to = value("to_state") || event.code;
70
+ if (!from && to === "received") return "The report was safely recorded and queued for routing.";
71
+ return `${readable(from)} → ${readable(to)}`;
72
+ }
60
73
  if (event.eventType === "finding") return value("summary") || readable(event.code);
61
74
  if (event.eventType === "action") return `${readable(value("action_code") || "action")} · ${readable(event.code)}`;
62
75
  if (event.eventType === "message") return `${readable(value("audience") || "reporter")} · ${readable(value("delivery_state") || event.code)}`;
63
76
  return `${readable(event.code)}${value("error_code") ? ` · ${readable(value("error_code"))}` : ""}`;
64
77
  }
65
78
 
79
+ function timelineTitle(event: TimelineEvent): string {
80
+ const details = event.details ?? {};
81
+ const value = (key: string): string => typeof details[key] === "string" ? String(details[key]) : "";
82
+ if (event.eventType === "state_transition") {
83
+ const from = value("from_state");
84
+ const to = value("to_state") || event.code;
85
+ if (!from && to === "received") return "Report received";
86
+ if (to === "human_review") return event.code === "reporter_reply_received" ? "Reporter reply needs review" : "Routed for human review";
87
+ if (to === "awaiting_reporter") return "Waiting for the reporter";
88
+ if (to.startsWith("resolved")) return "Report resolved";
89
+ return readable(event.code || "Case status changed");
90
+ }
91
+ if (event.eventType === "message") return event.code === "inbound" ? "Reporter replied" : "Update sent to reporter";
92
+ if (event.eventType === "decision") return "Decision recorded";
93
+ if (event.eventType === "finding") return "Evidence finding recorded";
94
+ if (event.eventType === "action") return "Application action updated";
95
+ return readable(event.eventType || "Case activity");
96
+ }
97
+
66
98
  function timelineMarker(eventType: string): string {
67
99
  if (eventType === "finding") return "⬡";
68
100
  if (eventType === "workflow_run") return "⌘";
@@ -72,6 +104,14 @@ function timelineMarker(eventType: string): string {
72
104
  return "•";
73
105
  }
74
106
 
107
+ const decisionOptions = [
108
+ { value: "violation_confirmed", label: "Violation confirmed" },
109
+ { value: "no_violation_found", label: "No violation found" },
110
+ { value: "insufficient_information", label: "Insufficient information" },
111
+ { value: "out_of_scope", label: "Out of scope" },
112
+ { value: "duplicate", label: "Duplicate report" },
113
+ ] as const;
114
+
75
115
  function DetailContent({ report, actorId, generatedAt, actionCodes, claimReports, messageReporters, decideReports, pendingAction, messageMutationVersion, statusMessage, error }: {
76
116
  report: ReportDetailRecord;
77
117
  actorId: string;
@@ -90,6 +130,14 @@ function DetailContent({ report, actorId, generatedAt, actionCodes, claimReports
90
130
  const messages = report.messages ?? [];
91
131
  const workflowRuns = report.workflowRuns ?? [];
92
132
  const timeline = report.timeline ?? [];
133
+ const caseTimeline = timeline.filter((item) => {
134
+ if (["workflow_run", "component_run"].includes(item.eventType)) return false;
135
+ if (item.eventType !== "state_transition") return true;
136
+ const from = item.details?.from_state;
137
+ const to = item.details?.to_state;
138
+ return !from || from !== to;
139
+ });
140
+ const technicalTimeline = timeline.filter((item) => ["workflow_run", "component_run"].includes(item.eventType));
93
141
  const findings = report.findings ?? [];
94
142
  const decisions = report.decisions ?? [];
95
143
  const aiRuns = report.aiRuns ?? [];
@@ -147,6 +195,7 @@ function DetailContent({ report, actorId, generatedAt, actionCodes, claimReports
147
195
  <Fact label="Channel">{readable(report.source || "unknown")}</Fact>
148
196
  <Fact label="Submitted">{dateTime(report.submittedAt || report.receivedAt)}</Fact>
149
197
  <Fact label="Language">{report.locale || "—"}</Fact>
198
+ <Fact label="Reporter email">{reporterEmailValue(report)}</Fact>
150
199
  </dl><div className="answer-list">
151
200
  {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>}
152
201
  </div></section>
@@ -167,7 +216,7 @@ function DetailContent({ report, actorId, generatedAt, actionCodes, claimReports
167
216
  </div></section>
168
217
  </div>
169
218
 
170
- {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}
219
+ {workflowRuns.length || timeline.length ? <section className="case-activity-section"><div className="case-activity-heading"><div><h3>Case activity</h3><p>A clear record of reporter communication, review, and decisions.</p></div><span>{caseTimeline.length} event{caseTimeline.length === 1 ? "" : "s"}</span></div><div className="report-timeline">{caseTimeline.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>{timelineTitle(item)}</strong><span>{timelineDetail(item)}</span><small>{dateTime(item.createdAt)} · {relativeTime(item.createdAt)} · {readable(item.actorType || "system")}</small></div></article>)}</div>{workflowRuns.length || technicalTimeline.length ? <details className="workflow-technical"><summary>Technical workflow details <span>{workflowRuns.length} workflow run{workflowRuns.length === 1 ? "" : "s"}</span></summary><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>{technicalTimeline.length ? <p className="technical-event-count">{technicalTimeline.length} low-level execution event{technicalTimeline.length === 1 ? "" : "s"} recorded in the audit trail.</p> : null}</details> : null}</section> : null}
171
220
 
172
221
  <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>
173
222
 
@@ -183,7 +232,7 @@ function DetailContent({ report, actorId, generatedAt, actionCodes, claimReports
183
232
 
184
233
  <div className="detail-actions">
185
234
  <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>
186
- <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>
235
+ <form onSubmit={recordDecision}><h3>Record a human decision</h3><label>Decision outcome<select value={decisionCode} onChange={(event) => setDecisionCode(event.currentTarget.value)} disabled={!canDecide || pendingAction === "decision"} required>{decisionOptions.map((option) => <option value={option.value} key={option.value}>{option.label}</option>)}</select></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>
187
236
  </div>
188
237
  <p className="mutation-status" role="status">{statusMessage}</p>
189
238
  <p className="error" role="alert">{error}</p>
@@ -73,6 +73,9 @@ let presenceSessionId = crypto.randomUUID();
73
73
  let presenceSocket: WebSocket | null = null;
74
74
  let presenceReconnectTimer: ReturnType<typeof setTimeout> | null = null;
75
75
  let presenceHeartbeatTimer: ReturnType<typeof setInterval> | null = null;
76
+ let reconciliationTimer: ReturnType<typeof setInterval> | null = null;
77
+ let realtimeRefreshTimer: ReturnType<typeof setTimeout> | null = null;
78
+ let presenceReconnectAttempts = 0;
76
79
  let presenceRunning = false;
77
80
  let started = false;
78
81
 
@@ -379,6 +382,19 @@ async function heartbeatPresence(): Promise<void> {
379
382
  }
380
383
  }
381
384
 
385
+ function scheduleRealtimeRefresh(reportId?: string | null, immediate = false): void {
386
+ if (!getShellSnapshot().session || document.visibilityState === "hidden") return;
387
+ if (realtimeRefreshTimer) clearTimeout(realtimeRefreshTimer);
388
+ realtimeRefreshTimer = setTimeout(() => {
389
+ realtimeRefreshTimer = null;
390
+ const open = getReportWorkspaceSnapshot().detail.report;
391
+ void Promise.all([
392
+ loadReports(),
393
+ open && (!reportId || open.id === reportId) ? openReport(open.id) : Promise.resolve(),
394
+ ]);
395
+ }, immediate ? 0 : 250);
396
+ }
397
+
382
398
  function sendPresenceActivity(): void {
383
399
  if (!getShellSnapshot().session) return;
384
400
  const current = currentPresenceActivity();
@@ -399,7 +415,11 @@ function openPresenceSocket(): void {
399
415
  url.searchParams.set("session_id", presenceSessionId);
400
416
  const socket = new WebSocket(url);
401
417
  presenceSocket = socket;
402
- socket.addEventListener("open", sendPresenceActivity);
418
+ socket.addEventListener("open", () => {
419
+ presenceReconnectAttempts = 0;
420
+ sendPresenceActivity();
421
+ scheduleRealtimeRefresh(null, true);
422
+ });
403
423
  socket.addEventListener("message", (event) => {
404
424
  try {
405
425
  const message = JSON.parse(String(event.data)) as { type?: string; analysts?: RawPresenceAnalyst[]; report_id?: string };
@@ -408,9 +428,7 @@ function openPresenceSocket(): void {
408
428
  publish();
409
429
  }
410
430
  if (message.type === "claim_changed" || message.type === "reports_changed") {
411
- void loadReports();
412
- const report = getReportWorkspaceSnapshot().detail.report;
413
- if (report && (!message.report_id || report.id === message.report_id)) void openReport(report.id);
431
+ scheduleRealtimeRefresh(message.report_id);
414
432
  }
415
433
  } catch {
416
434
  // Ignore messages outside the bounded presence protocol.
@@ -421,7 +439,10 @@ function openPresenceSocket(): void {
421
439
  if (presenceRunning && getShellSnapshot().session) {
422
440
  void heartbeatPresence();
423
441
  if (presenceReconnectTimer) clearTimeout(presenceReconnectTimer);
424
- presenceReconnectTimer = setTimeout(openPresenceSocket, 3_000);
442
+ presenceReconnectAttempts += 1;
443
+ const baseDelay = Math.min(30_000, 1_000 * (2 ** Math.min(presenceReconnectAttempts, 5)));
444
+ const jitteredDelay = Math.round(baseDelay * (.8 + Math.random() * .4));
445
+ presenceReconnectTimer = setTimeout(openPresenceSocket, jitteredDelay);
425
446
  }
426
447
  });
427
448
  socket.addEventListener("error", () => socket.close());
@@ -430,9 +451,14 @@ function openPresenceSocket(): void {
430
451
  function stopPresence(): void {
431
452
  presenceRunning = false;
432
453
  if (presenceHeartbeatTimer) clearInterval(presenceHeartbeatTimer);
454
+ if (reconciliationTimer) clearInterval(reconciliationTimer);
455
+ if (realtimeRefreshTimer) clearTimeout(realtimeRefreshTimer);
433
456
  if (presenceReconnectTimer) clearTimeout(presenceReconnectTimer);
434
457
  presenceHeartbeatTimer = null;
458
+ reconciliationTimer = null;
459
+ realtimeRefreshTimer = null;
435
460
  presenceReconnectTimer = null;
461
+ presenceReconnectAttempts = 0;
436
462
  const socket = presenceSocket;
437
463
  presenceSocket = null;
438
464
  if (socket && socket.readyState < WebSocket.CLOSING) socket.close(1000, "workspace closed");
@@ -458,6 +484,7 @@ async function startPresence(): Promise<void> {
458
484
  void heartbeatPresence();
459
485
  }
460
486
  }, 15_000);
487
+ reconciliationTimer = setInterval(() => scheduleRealtimeRefresh(), 30_000);
461
488
  openPresenceSocket();
462
489
  }
463
490
 
@@ -520,4 +547,8 @@ export function startReportController(): void {
520
547
  window.addEventListener("keydown", (event) => {
521
548
  if (event.key === "Escape" && getReportWorkspaceSnapshot().detail.reportId) closeReport();
522
549
  });
550
+ window.addEventListener("focus", () => scheduleRealtimeRefresh(null, true));
551
+ document.addEventListener("visibilitychange", () => {
552
+ if (document.visibilityState === "visible") scheduleRealtimeRefresh(null, true);
553
+ });
523
554
  }
@@ -183,6 +183,9 @@ export interface ReportDetailRecord {
183
183
  customerUrl?: string;
184
184
  ownerReference?: string;
185
185
  reporterReference?: string;
186
+ reporterEmail?: string | null;
187
+ reporterContactMode?: string;
188
+ reporterContactStatus?: "available" | "not_provided" | "redacted" | "restricted" | "unavailable";
186
189
  policyTitle?: string;
187
190
  policyVersionId?: string;
188
191
  queuePolicyVersionId?: string;
@@ -1,4 +1,5 @@
1
1
  import { authEvents } from "../auth/events";
2
+ import { accountRouteFromHash } from "../auth/account-route";
2
3
  import { errorMessage, hasSessionCredentials, requestJson } from "../lib/http";
3
4
  import { shellEvents } from "./events";
4
5
  import { visibleNavigationGroups } from "./navigation";
@@ -26,7 +27,7 @@ let brand: CustomerBrand = { organization_name: "Safest Resolve", logo_url: "",
26
27
  let expired = false;
27
28
  let started = false;
28
29
 
29
- function element(id: "login" | "workspace"): HTMLElement | null {
30
+ function element(id: "login" | "workspace" | "session-loading"): HTMLElement | null {
30
31
  return document.querySelector<HTMLElement>(`#${id}`);
31
32
  }
32
33
 
@@ -39,6 +40,7 @@ function readable(value: string): string {
39
40
  }
40
41
 
41
42
  function setWorkspaceMode(authenticated: boolean): void {
43
+ document.body.classList.remove("auth-resolving");
42
44
  document.body.classList.toggle("workspace-mode", authenticated);
43
45
  document.body.classList.toggle("login-mode", !authenticated);
44
46
  document.body.classList.remove("mobile-navigation-open", "report-open");
@@ -48,8 +50,10 @@ function setWorkspaceMode(authenticated: boolean): void {
48
50
  function showWorkspace(authenticated: boolean): void {
49
51
  const login = element("login");
50
52
  const workspace = element("workspace");
53
+ const loading = element("session-loading");
51
54
  if (login) login.hidden = authenticated;
52
55
  if (workspace) workspace.hidden = !authenticated;
56
+ if (loading) loading.hidden = true;
53
57
  setWorkspaceMode(authenticated);
54
58
  if (!authenticated) setDocumentTitle("command");
55
59
  }
@@ -128,14 +132,15 @@ async function signOut(): Promise<void> {
128
132
  }
129
133
 
130
134
  async function initialize(): Promise<void> {
135
+ const accountRoute = accountRouteFromHash(window.location.hash);
136
+ if (accountRoute.view !== "login") showWorkspace(false);
131
137
  try {
132
138
  brand = await window.SafestBrand?.loadBrand("/v1/brand", { title: "Reports and workflows" }) || brand;
133
139
  } catch {
134
140
  brand = window.SafestBrand?.defaults || brand;
135
141
  }
136
142
  await checkHealth();
137
- const parameters = new URLSearchParams(window.location.hash.slice(1));
138
- if (parameters.has("invite") || parameters.has("reset")) return;
143
+ if (accountRoute.view !== "login") return;
139
144
  if (sessionStorage.getItem("safest-reports-force-login") !== "true") {
140
145
  await openWorkspace(undefined, !hasSessionCredentials());
141
146
  } else {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "safest-resolve-installation",
3
- "version": "0.4.1",
4
- "safestToolsVersion": "0.4.1-resolve",
3
+ "version": "0.5.0",
4
+ "safestToolsVersion": "0.5.0-resolve",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
7
7
  "type": "module",
@@ -31,6 +31,7 @@
31
31
  "@clack/prompts": "^1.7.0",
32
32
  "@cloudflare/dynamic-workflows": "^0.1.1",
33
33
  "better-auth": "^1.7.2",
34
+ "postal-mime": "^3.0.0",
34
35
  "react": "^19.2.8",
35
36
  "react-dom": "^19.2.8"
36
37
  },