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 +2 -2
- package/package.json +1 -1
- package/src/cli.mjs +8 -6
- package/src/config.mjs +26 -5
- package/src/update.mjs +32 -2
- package/template/README.md +3 -3
- package/template/console/reports/ReportDrawer.tsx +60 -6
- package/template/console/reports/controller.ts +11 -5
- package/template/console/reports/store.ts +1 -1
- package/template/console/reports/types.ts +26 -0
- package/template/package.json +2 -2
- package/template/public/console/auth-shell.js +4360 -4257
- package/template/public/styles.css +17 -2
- package/template/reports.config.example.json +2 -1
- package/template/reports.schema.json +2 -1
- package/template/scripts/reports-auth-onboarding.mjs +6 -5
- package/template/scripts/reports-plan.mjs +13 -4
- package/template/src/index.ts +20 -5
- package/template/src/installation-admin.ts +2 -1
- package/template/src/report-ai.ts +30 -5
- package/template/src/report-better-auth.ts +5 -14
- package/template/src/report-delivery.ts +4 -3
- package/template/src/report-email.ts +106 -0
- package/template/src/report-workflow.ts +139 -23
- package/template/src/workflow-effects.ts +4 -3
- package/template/worker-configuration.d.ts +6 -4
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.
|
|
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
|
|
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
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
|
|
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, "
|
|
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:
|
|
130
|
-
|
|
131
|
-
|
|
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 = [
|
|
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 (
|
|
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,
|
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.
|
|
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
|
|
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,
|
|
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 & 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)} · {
|
|
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 & 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" : ""}`}>{
|
|
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" ? "
|
|
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
|
-
|
|
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
|
|
package/template/package.json
CHANGED