create-safest-tools 0.4.2 → 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 +2 -2
- package/package.json +1 -1
- package/src/cli.mjs +2 -2
- package/src/config.mjs +13 -0
- package/template/README.md +2 -2
- package/template/console/reports/ReportDrawer.tsx +43 -3
- package/template/console/reports/controller.ts +36 -5
- package/template/console/shell/controller.ts +8 -3
- package/template/package.json +3 -2
- package/template/public/console/auth-shell.js +4335 -4250
- package/template/public/index.html +10 -2
- package/template/public/styles.css +75 -0
- package/template/reports.schema.json +1 -1
- package/template/scripts/reports-cloudflare-preflight.mjs +49 -0
- package/template/scripts/reports-deploy.mjs +6 -0
- package/template/scripts/reports-plan.mjs +13 -1
- package/template/src/index.ts +16 -0
- package/template/src/report-delivery.ts +27 -5
- package/template/src/report-email-inbound.ts +169 -0
- package/template/src/report-email-template.ts +53 -0
- package/template/src/report-email.ts +71 -2
- package/template/src/report-presence.ts +9 -5
- package/template/src/report-repository.ts +1 -1
- package/template/src/report-workflow-validation.ts +15 -1
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
|
|
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
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
|
|
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, "
|
|
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] },
|
package/template/README.md
CHANGED
|
@@ -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
|
|
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
|
|
@@ -64,13 +64,37 @@ function deliveryExplanation(state: string): string {
|
|
|
64
64
|
function timelineDetail(event: TimelineEvent): string {
|
|
65
65
|
const details = event.details ?? {};
|
|
66
66
|
const value = (key: string): string => typeof details[key] === "string" || typeof details[key] === "number" ? String(details[key]) : "";
|
|
67
|
-
if (event.eventType === "state_transition")
|
|
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
|
+
}
|
|
68
73
|
if (event.eventType === "finding") return value("summary") || readable(event.code);
|
|
69
74
|
if (event.eventType === "action") return `${readable(value("action_code") || "action")} · ${readable(event.code)}`;
|
|
70
75
|
if (event.eventType === "message") return `${readable(value("audience") || "reporter")} · ${readable(value("delivery_state") || event.code)}`;
|
|
71
76
|
return `${readable(event.code)}${value("error_code") ? ` · ${readable(value("error_code"))}` : ""}`;
|
|
72
77
|
}
|
|
73
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
|
+
|
|
74
98
|
function timelineMarker(eventType: string): string {
|
|
75
99
|
if (eventType === "finding") return "⬡";
|
|
76
100
|
if (eventType === "workflow_run") return "⌘";
|
|
@@ -80,6 +104,14 @@ function timelineMarker(eventType: string): string {
|
|
|
80
104
|
return "•";
|
|
81
105
|
}
|
|
82
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
|
+
|
|
83
115
|
function DetailContent({ report, actorId, generatedAt, actionCodes, claimReports, messageReporters, decideReports, pendingAction, messageMutationVersion, statusMessage, error }: {
|
|
84
116
|
report: ReportDetailRecord;
|
|
85
117
|
actorId: string;
|
|
@@ -98,6 +130,14 @@ function DetailContent({ report, actorId, generatedAt, actionCodes, claimReports
|
|
|
98
130
|
const messages = report.messages ?? [];
|
|
99
131
|
const workflowRuns = report.workflowRuns ?? [];
|
|
100
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));
|
|
101
141
|
const findings = report.findings ?? [];
|
|
102
142
|
const decisions = report.decisions ?? [];
|
|
103
143
|
const aiRuns = report.aiRuns ?? [];
|
|
@@ -176,7 +216,7 @@ function DetailContent({ report, actorId, generatedAt, actionCodes, claimReports
|
|
|
176
216
|
</div></section>
|
|
177
217
|
</div>
|
|
178
218
|
|
|
179
|
-
{workflowRuns.length || timeline.length ? <section><h3>
|
|
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}
|
|
180
220
|
|
|
181
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>
|
|
182
222
|
|
|
@@ -192,7 +232,7 @@ function DetailContent({ report, actorId, generatedAt, actionCodes, claimReports
|
|
|
192
232
|
|
|
193
233
|
<div className="detail-actions">
|
|
194
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>
|
|
195
|
-
<form onSubmit={recordDecision}><h3>Record a human decision</h3><label>Decision
|
|
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>
|
|
196
236
|
</div>
|
|
197
237
|
<p className="mutation-status" role="status">{statusMessage}</p>
|
|
198
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",
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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
|
-
|
|
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 {
|
package/template/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safest-resolve-installation",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"safestToolsVersion": "0.
|
|
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
|
},
|