create-safest-tools 0.4.2 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +3 -3
- 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 +7 -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 +114 -29
- 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
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
<link rel="manifest" href="./manifest.webmanifest" />
|
|
12
12
|
<link rel="stylesheet" href="./styles.css" />
|
|
13
13
|
</head>
|
|
14
|
-
<body class="
|
|
14
|
+
<body class="auth-resolving">
|
|
15
15
|
<svg class="svg-defs" aria-hidden="true">
|
|
16
16
|
<defs>
|
|
17
17
|
<symbol id="i-home" viewBox="0 0 24 24"><path d="m3 10 9-7 9 7v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M9 21v-8h6v8"/></symbol>
|
|
@@ -43,7 +43,15 @@
|
|
|
43
43
|
</div>
|
|
44
44
|
|
|
45
45
|
<main id="main">
|
|
46
|
-
<section id="
|
|
46
|
+
<section id="session-loading" class="session-loading" aria-live="polite" aria-busy="true">
|
|
47
|
+
<div class="session-loading-card">
|
|
48
|
+
<span class="session-loading-mark" aria-hidden="true"></span>
|
|
49
|
+
<strong>Opening your workspace</strong>
|
|
50
|
+
<span>Restoring your secure session…</span>
|
|
51
|
+
</div>
|
|
52
|
+
</section>
|
|
53
|
+
|
|
54
|
+
<section id="login" class="login-layout" aria-labelledby="login-title" hidden>
|
|
47
55
|
<div class="login-story">
|
|
48
56
|
<p class="eyebrow light">Safest Resolve</p>
|
|
49
57
|
<h1>Every report gets<br />a real next step.</h1>
|
|
@@ -1107,6 +1107,81 @@ body.report-open .content { margin-right: 0; }
|
|
|
1107
1107
|
/* Authoritative responsive contract for the console shell and dense editors. */
|
|
1108
1108
|
body.login-mode .topbar { display: flex; justify-content: flex-start; }
|
|
1109
1109
|
body.login-mode .top-actions { margin-left: auto; }
|
|
1110
|
+
.session-loading {
|
|
1111
|
+
min-height: calc(100vh - 72px);
|
|
1112
|
+
display: grid;
|
|
1113
|
+
place-items: center;
|
|
1114
|
+
padding: 32px;
|
|
1115
|
+
background: var(--background, #f4f7f3);
|
|
1116
|
+
}
|
|
1117
|
+
.session-loading[hidden] { display: none; }
|
|
1118
|
+
.session-loading-card {
|
|
1119
|
+
display: grid;
|
|
1120
|
+
justify-items: center;
|
|
1121
|
+
gap: 8px;
|
|
1122
|
+
color: var(--text, #183228);
|
|
1123
|
+
text-align: center;
|
|
1124
|
+
}
|
|
1125
|
+
.session-loading-card strong { font-size: 16px; }
|
|
1126
|
+
.session-loading-card > span:last-child { color: var(--muted, #66766f); font-size: 13px; }
|
|
1127
|
+
.session-loading-mark {
|
|
1128
|
+
width: 30px;
|
|
1129
|
+
height: 30px;
|
|
1130
|
+
margin-bottom: 4px;
|
|
1131
|
+
border: 3px solid color-mix(in srgb, var(--primary, #165b43) 18%, transparent);
|
|
1132
|
+
border-top-color: var(--primary, #165b43);
|
|
1133
|
+
border-radius: 50%;
|
|
1134
|
+
animation: session-loading-spin .8s linear infinite;
|
|
1135
|
+
}
|
|
1136
|
+
@keyframes session-loading-spin { to { transform: rotate(360deg); } }
|
|
1137
|
+
@media (prefers-reduced-motion: reduce) { .session-loading-mark { animation: none; } }
|
|
1138
|
+
|
|
1139
|
+
/* Report drawer: readable case record with technical execution kept secondary. */
|
|
1140
|
+
.report-drawer .report-detail-content {
|
|
1141
|
+
display: block;
|
|
1142
|
+
max-width: 1180px;
|
|
1143
|
+
margin-inline: auto;
|
|
1144
|
+
padding: 22px 24px 36px;
|
|
1145
|
+
}
|
|
1146
|
+
.report-drawer .report-detail-content > section,
|
|
1147
|
+
.report-drawer .detail-grid section,
|
|
1148
|
+
.report-drawer .detail-actions form {
|
|
1149
|
+
border-color: #dce5de;
|
|
1150
|
+
border-radius: 12px;
|
|
1151
|
+
background: #fff;
|
|
1152
|
+
}
|
|
1153
|
+
.report-drawer .report-detail-content > section { margin-top: 16px; padding: 20px; }
|
|
1154
|
+
.report-drawer .detail-grid { grid-template-columns: 1fr 1fr; gap: 16px; }
|
|
1155
|
+
.report-drawer .claim-bar,
|
|
1156
|
+
.report-drawer .detail-actions,
|
|
1157
|
+
.report-drawer .surface-error { width: 100%; }
|
|
1158
|
+
.report-drawer .detail-actions { grid-template-columns: minmax(0, .85fr) minmax(0, 1.15fr); align-items: start; gap: 16px; }
|
|
1159
|
+
.case-activity-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 20px; }
|
|
1160
|
+
.case-activity-heading h3 { margin: 0 !important; font-size: 20px; }
|
|
1161
|
+
.case-activity-heading p { margin: 5px 0 0; color: var(--muted); font-size: 12px; line-height: 1.5; }
|
|
1162
|
+
.case-activity-heading > span { flex: 0 0 auto; padding: 5px 9px; border-radius: 999px; color: #315b47; background: #edf5ef; font-size: 11px; font-weight: 750; }
|
|
1163
|
+
.case-activity-section .report-timeline { padding-left: 2px; }
|
|
1164
|
+
.case-activity-section .timeline-event { grid-template-columns: 36px minmax(0, 1fr); gap: 12px; min-height: 76px; }
|
|
1165
|
+
.case-activity-section .timeline-event:not(:last-child)::before { top: 32px; bottom: 0; left: 15px; width: 2px; background: #dfe8e1; }
|
|
1166
|
+
.case-activity-section .timeline-marker { width: 32px; height: 32px; border-width: 2px; font-size: 13px; }
|
|
1167
|
+
.case-activity-section .timeline-event > div { padding: 2px 0 18px; }
|
|
1168
|
+
.case-activity-section .timeline-event strong { color: #172d23; font-size: 13px; line-height: 1.35; }
|
|
1169
|
+
.case-activity-section .timeline-event span { margin-top: 4px; color: #40584c; font-size: 12px; line-height: 1.5; }
|
|
1170
|
+
.case-activity-section .timeline-event small { margin-top: 6px; color: #75827b; font-size: 10px; line-height: 1.4; }
|
|
1171
|
+
.workflow-technical { margin-top: 4px; border-top: 1px solid #e4eae5; }
|
|
1172
|
+
.workflow-technical summary { padding: 15px 2px 5px; color: #40564b; cursor: pointer; font-size: 12px; font-weight: 750; }
|
|
1173
|
+
.workflow-technical summary span { margin-left: 6px; color: var(--muted); font-weight: 550; }
|
|
1174
|
+
.workflow-technical .report-run-list { margin: 10px 0 0; }
|
|
1175
|
+
.technical-event-count { margin: 10px 2px 2px; color: var(--muted); font-size: 10px; }
|
|
1176
|
+
.report-drawer .case-message { max-width: min(82%, 720px); padding: 13px 15px; font-size: 12px; line-height: 1.55; }
|
|
1177
|
+
.report-drawer .case-message small { font-size: 10px; line-height: 1.45; }
|
|
1178
|
+
|
|
1179
|
+
@media (max-width: 800px) {
|
|
1180
|
+
.report-drawer .report-detail-content { padding: 14px 14px 28px; }
|
|
1181
|
+
.report-drawer .detail-grid,
|
|
1182
|
+
.report-drawer .detail-actions { grid-template-columns: 1fr; }
|
|
1183
|
+
.report-drawer .submission-meta { grid-template-columns: 100px minmax(0, 1fr) !important; }
|
|
1184
|
+
}
|
|
1110
1185
|
|
|
1111
1186
|
@media (max-width: 760px) {
|
|
1112
1187
|
body { min-width: 0; overflow-x: clip; }
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"required": ["enabled", "fromAddress"],
|
|
49
49
|
"properties": {
|
|
50
50
|
"enabled": { "type": "boolean" },
|
|
51
|
-
"fromAddress": { "type": "string", "maxLength": 254 },
|
|
51
|
+
"fromAddress": { "type": "string", "maxLength": 254, "description": "Receivable reporter mailbox. Resolve routes signed plus-addressed replies for this exact address to the Worker." },
|
|
52
52
|
"authFromAddress": { "type": "string", "maxLength": 254 }
|
|
53
53
|
}
|
|
54
54
|
},
|
|
@@ -66,6 +66,55 @@ async function fetchWorkersAccountSettings(accountId, token, fetchImpl) {
|
|
|
66
66
|
return { ok: true, settings: body.result };
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
async function cloudflareApi(path, token, fetchImpl, init = {}) {
|
|
70
|
+
const response = await fetchImpl(`https://api.cloudflare.com/client/v4${path}`, {
|
|
71
|
+
...init,
|
|
72
|
+
headers: {
|
|
73
|
+
authorization: `Bearer ${token}`,
|
|
74
|
+
accept: "application/json",
|
|
75
|
+
...(init.body ? { "content-type": "application/json" } : {}),
|
|
76
|
+
...init.headers,
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
const body = await response.json().catch(() => null);
|
|
80
|
+
if (!response.ok || body?.success !== true) {
|
|
81
|
+
const detail = Array.isArray(body?.errors) && body.errors[0]?.message ? `: ${body.errors[0].message}` : "";
|
|
82
|
+
throw new Error(`Cloudflare API ${response.status}${detail}`);
|
|
83
|
+
}
|
|
84
|
+
return body.result;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function emailZone(domain, token, fetchImpl) {
|
|
88
|
+
const labels = domain.toLowerCase().split(".").filter(Boolean);
|
|
89
|
+
for (let index = 0; index <= Math.max(0, labels.length - 2); index += 1) {
|
|
90
|
+
const candidate = labels.slice(index).join(".");
|
|
91
|
+
const zones = await cloudflareApi(`/zones?name=${encodeURIComponent(candidate)}&status=active&per_page=5`, token, fetchImpl);
|
|
92
|
+
if (Array.isArray(zones) && zones.length === 1 && typeof zones[0]?.id === "string") return zones[0];
|
|
93
|
+
}
|
|
94
|
+
throw new Error(`Cloudflare could not find an active zone that owns the reporter email domain ${domain}.`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function ensureReporterReplyRouting({ reporterAddress, wranglerToken, fetchImpl = fetch }) {
|
|
98
|
+
const separator = reporterAddress.lastIndexOf("@");
|
|
99
|
+
if (separator < 1) throw new Error("The reporter email address is invalid.");
|
|
100
|
+
const domain = reporterAddress.slice(separator + 1).toLowerCase();
|
|
101
|
+
const zone = await emailZone(domain, wranglerToken, fetchImpl);
|
|
102
|
+
const settings = await cloudflareApi(`/zones/${encodeURIComponent(zone.id)}/email/routing`, wranglerToken, fetchImpl);
|
|
103
|
+
if (settings?.enabled !== true || settings?.status !== "ready") {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`Cloudflare Email Routing is not ready for ${zone.name}. Enable it before setup so ${reporterAddress} can receive case replies; ` +
|
|
106
|
+
"Resolve will not change a domain's MX records automatically.",
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
if (settings.support_subaddress !== true) {
|
|
110
|
+
await cloudflareApi(`/zones/${encodeURIComponent(zone.id)}/email/routing`, wranglerToken, fetchImpl, {
|
|
111
|
+
method: "PATCH",
|
|
112
|
+
body: JSON.stringify({ support_subaddress: true }),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
return { zoneId: zone.id, zoneName: zone.name, address: reporterAddress, subaddressingEnabled: true };
|
|
116
|
+
}
|
|
117
|
+
|
|
69
118
|
export function isWorkersPaidUsageModel(value) {
|
|
70
119
|
return String(value ?? "").trim().toLowerCase() === "standard";
|
|
71
120
|
}
|
|
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
|
|
|
6
6
|
import { configureAuthentication, requiredOAuthSecrets } from "./reports-auth-onboarding.mjs";
|
|
7
7
|
import {
|
|
8
8
|
chooseCloudflareAccount,
|
|
9
|
+
ensureReporterReplyRouting,
|
|
9
10
|
ensureWranglerAuthentication,
|
|
10
11
|
saveCloudflareAccountSelection,
|
|
11
12
|
verifyWorkersPaid,
|
|
@@ -36,36 +37,83 @@ function parseArguments(argv) {
|
|
|
36
37
|
return result;
|
|
37
38
|
}
|
|
38
39
|
|
|
39
|
-
function runWrangler(args, label) {
|
|
40
|
+
function runWrangler(args, label, { stdin = "inherit" } = {}) {
|
|
40
41
|
return new Promise((resolvePromise, reject) => {
|
|
41
42
|
console.log(`\n${label}`);
|
|
42
|
-
const child = spawn(process.execPath, [wrangler, ...args], {
|
|
43
|
+
const child = spawn(process.execPath, [wrangler, ...args], {
|
|
44
|
+
cwd: projectRoot,
|
|
45
|
+
env: process.env,
|
|
46
|
+
stdio: [stdin, "inherit", "inherit"],
|
|
47
|
+
});
|
|
43
48
|
child.once("error", reject);
|
|
44
49
|
child.once("exit", (code, signal) => code === 0 ? resolvePromise() : reject(new Error(`${label} failed${signal ? ` with signal ${signal}` : ` with exit code ${code}`}`)));
|
|
45
50
|
});
|
|
46
51
|
}
|
|
47
52
|
|
|
53
|
+
function captureWrangler(args, { stdin = "ignore" } = {}) {
|
|
54
|
+
return new Promise((resolvePromise, reject) => {
|
|
55
|
+
const child = spawn(process.execPath, [wrangler, ...args], {
|
|
56
|
+
cwd: projectRoot,
|
|
57
|
+
env: process.env,
|
|
58
|
+
stdio: [stdin, "pipe", "pipe"],
|
|
59
|
+
});
|
|
60
|
+
let stdout = "";
|
|
61
|
+
let stderr = "";
|
|
62
|
+
child.stdout.on("data", (chunk) => { stdout += chunk; });
|
|
63
|
+
child.stderr.on("data", (chunk) => { stderr += chunk; });
|
|
64
|
+
child.once("error", reject);
|
|
65
|
+
child.once("exit", (code, signal) => resolvePromise({ code, signal, stdout, stderr, output: `${stdout}\n${stderr}` }));
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function printWranglerOutput({ stdout, stderr }) {
|
|
70
|
+
if (stdout) process.stdout.write(stdout);
|
|
71
|
+
if (stderr) process.stderr.write(stderr);
|
|
72
|
+
}
|
|
73
|
+
|
|
48
74
|
export function isExistingResourceError(output) {
|
|
49
75
|
return /already exists|already (?:been )?taken|duplicate resource/iu.test(output);
|
|
50
76
|
}
|
|
51
77
|
|
|
52
|
-
function
|
|
53
|
-
return
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
});
|
|
78
|
+
export function isTransientWranglerAuthenticationError(output) {
|
|
79
|
+
return /authentication error[\s\S]{0,200}(?:code\s*:\s*10000|\[code:\s*10000\])/iu.test(output);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function ensureWranglerResource(args, label) {
|
|
83
|
+
console.log(`\n${label}`);
|
|
84
|
+
const result = await captureWrangler(args);
|
|
85
|
+
if (result.code === 0) {
|
|
86
|
+
console.log("Created the exact configured resource.");
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (isExistingResourceError(result.output)) {
|
|
90
|
+
console.log("Already exists; reusing the exact configured resource.");
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
printWranglerOutput(result);
|
|
94
|
+
throw new Error(`${label} failed${result.signal ? ` with signal ${result.signal}` : ` with exit code ${result.code}`}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function deployWithAuthenticationRetry(args, label) {
|
|
98
|
+
console.log(`\n${label}`);
|
|
99
|
+
let result = await captureWrangler(args, { stdin: "inherit" });
|
|
100
|
+
if (result.code === 0) {
|
|
101
|
+
printWranglerOutput(result);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (!isTransientWranglerAuthenticationError(result.output)) {
|
|
105
|
+
printWranglerOutput(result);
|
|
106
|
+
throw new Error(`${label} failed${result.signal ? ` with signal ${result.signal}` : ` with exit code ${result.code}`}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
console.log("Cloudflare rejected the upload token. Refreshing Wrangler authentication and retrying once.");
|
|
110
|
+
await runWrangler(["whoami"], "Refresh Cloudflare authentication");
|
|
111
|
+
console.log(`\n${label} (automatic retry 1 of 1)`);
|
|
112
|
+
result = await captureWrangler(args, { stdin: "inherit" });
|
|
113
|
+
printWranglerOutput(result);
|
|
114
|
+
if (result.code !== 0) {
|
|
115
|
+
throw new Error(`${label} failed after one automatic authentication retry${result.signal ? ` with signal ${result.signal}` : ` with exit code ${result.code}`}`);
|
|
116
|
+
}
|
|
69
117
|
}
|
|
70
118
|
|
|
71
119
|
async function validateSecrets(path, config) {
|
|
@@ -87,6 +135,23 @@ async function hasDatabaseId() {
|
|
|
87
135
|
return typeof config?.d1_databases?.[0]?.database_id === "string" && config.d1_databases[0].database_id.length > 0;
|
|
88
136
|
}
|
|
89
137
|
|
|
138
|
+
async function fileExists(path) {
|
|
139
|
+
try {
|
|
140
|
+
await readFile(path);
|
|
141
|
+
return true;
|
|
142
|
+
} catch (error) {
|
|
143
|
+
if (error?.code === "ENOENT") return false;
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function setupInstallationState({ databaseConfigured, installationRecorded }) {
|
|
149
|
+
if (installationRecorded && !databaseConfigured) return "inconsistent";
|
|
150
|
+
if (installationRecorded) return "installed";
|
|
151
|
+
if (databaseConfigured) return "resume";
|
|
152
|
+
return "fresh";
|
|
153
|
+
}
|
|
154
|
+
|
|
90
155
|
async function confirmation(installationId, yes) {
|
|
91
156
|
if (yes) return;
|
|
92
157
|
if (!process.stdin.isTTY) throw new Error("interactive confirmation is required; use --yes only after reviewing setup:plan");
|
|
@@ -104,6 +169,20 @@ export async function deployReports(argv = process.argv.slice(2)) {
|
|
|
104
169
|
console.log(JSON.stringify({ action: options.upgrade ? "upgrade" : "install", ...plan }, null, 2));
|
|
105
170
|
if (options.planOnly) return { status: "planned", plan };
|
|
106
171
|
if (!plan.ready) throw new Error(plan.blockers.join(" "));
|
|
172
|
+
const installationPath = resolve(projectRoot, ".safest/installation.json");
|
|
173
|
+
const databaseConfigured = await hasDatabaseId();
|
|
174
|
+
const installationRecorded = await fileExists(installationPath);
|
|
175
|
+
const setupState = setupInstallationState({ databaseConfigured, installationRecorded });
|
|
176
|
+
if (options.upgrade && !databaseConfigured) throw new Error("upgrade requires an existing D1 database_id in wrangler.jsonc");
|
|
177
|
+
if (!options.upgrade && setupState === "installed") {
|
|
178
|
+
throw new Error("setup is already complete; use npm run owner:setup for a new owner link or npm run upgrade for an existing installation");
|
|
179
|
+
}
|
|
180
|
+
if (!options.upgrade && setupState === "inconsistent") {
|
|
181
|
+
throw new Error("the installation receipt exists but wrangler.jsonc has no D1 database_id; restore the database configuration before continuing");
|
|
182
|
+
}
|
|
183
|
+
if (!options.upgrade && setupState === "resume") {
|
|
184
|
+
console.log("\nInterrupted installation detected. Reusing the saved authentication choices and exact Cloudflare resources, then continuing setup.");
|
|
185
|
+
}
|
|
107
186
|
const interactive = process.stdin.isTTY && !options.yes;
|
|
108
187
|
const authentication = await ensureWranglerAuthentication({ wrangler, projectRoot, interactive });
|
|
109
188
|
const accountSelection = await chooseCloudflareAccount({
|
|
@@ -126,7 +205,7 @@ export async function deployReports(argv = process.argv.slice(2)) {
|
|
|
126
205
|
await initializeSecrets(secretsPath);
|
|
127
206
|
console.log(`Created ${secretsPath} with generated owner-only secrets.`);
|
|
128
207
|
}
|
|
129
|
-
if (!options.upgrade) {
|
|
208
|
+
if (!options.upgrade && setupState === "fresh") {
|
|
130
209
|
const configured = await configureAuthentication({
|
|
131
210
|
configPath: resolve(projectRoot, options.config),
|
|
132
211
|
secretsPath,
|
|
@@ -138,10 +217,12 @@ export async function deployReports(argv = process.argv.slice(2)) {
|
|
|
138
217
|
}
|
|
139
218
|
await validateSecrets(secretsPath, config);
|
|
140
219
|
await confirmation(config.installationId, options.yes);
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
220
|
+
const reporterRouting = await ensureReporterReplyRouting({
|
|
221
|
+
reporterAddress: config.email.fromAddress,
|
|
222
|
+
wranglerToken: authentication.token,
|
|
223
|
+
});
|
|
224
|
+
console.log(`\nReporter replies will route through ${reporterRouting.address}; plus-addressing is enabled and no catch-all is created.`);
|
|
225
|
+
if (!options.upgrade && setupState === "fresh") {
|
|
145
226
|
await runWrangler(["d1", "create", config.resources.databaseName, "--binding", "DB", "--update-config"], "Create the customer-owned D1 database");
|
|
146
227
|
}
|
|
147
228
|
await ensureWranglerResource(["r2", "bucket", "create", config.resources.profileMediaBucketName], "Ensure the private profile-media R2 bucket exists");
|
|
@@ -151,9 +232,13 @@ export async function deployReports(argv = process.argv.slice(2)) {
|
|
|
151
232
|
await ensureWranglerResource(["queues", "create", config.resources.reportQueueName], "Ensure the report jobs Queue exists");
|
|
152
233
|
await ensureWranglerResource(["queues", "create", config.resources.deliveryQueueName], "Ensure the delivery jobs Queue exists");
|
|
153
234
|
await ensureWranglerResource(["queues", "create", config.resources.operationsDlqName], "Ensure the operations dead-letter Queue exists");
|
|
154
|
-
await runWrangler(
|
|
155
|
-
|
|
156
|
-
|
|
235
|
+
await runWrangler(
|
|
236
|
+
["d1", "migrations", "apply", "DB", "--remote"],
|
|
237
|
+
"Apply forward-only report migrations",
|
|
238
|
+
{ stdin: "ignore" },
|
|
239
|
+
);
|
|
240
|
+
await deployWithAuthenticationRetry(["deploy", "--secrets-file", secretsPath, "--strict"], "Deploy the reports Worker and bindings");
|
|
241
|
+
const ownerSetup = options.upgrade ? null : await issueOwnerSetup({ config });
|
|
157
242
|
await mkdir(dirname(installationPath), { recursive: true, mode: 0o700 });
|
|
158
243
|
await writeFile(installationPath, `${JSON.stringify({
|
|
159
244
|
schemaVersion: 2,
|
|
@@ -162,9 +247,9 @@ export async function deployReports(argv = process.argv.slice(2)) {
|
|
|
162
247
|
resources: config.resources,
|
|
163
248
|
ownerEmail: config.owner.email,
|
|
164
249
|
}, null, 2)}\n`, { mode: 0o600 });
|
|
165
|
-
if (
|
|
250
|
+
if (ownerSetup) printOwnerSetup(ownerSetup);
|
|
166
251
|
console.log("\nDeployment finished. Open the one-time owner setup link, run the authenticated setup feature checks until /ready succeeds, invite an administrator and analyst, then verify one server report and Queue consumption before production traffic.");
|
|
167
|
-
return { status: "deployed", plan };
|
|
252
|
+
return { status: "deployed", plan, resumed: !options.upgrade && setupState === "resume" };
|
|
168
253
|
}
|
|
169
254
|
|
|
170
255
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
@@ -85,6 +85,13 @@ export function parseReportsConfig(value) {
|
|
|
85
85
|
const defaultAuthEmail = emailDomain ? `${emailFromAddress.startsWith("accounts@") ? "auth" : "accounts"}@${emailDomain}` : "";
|
|
86
86
|
const authEmailFromAddress = String(email.authFromAddress ?? defaultAuthEmail).trim().toLowerCase();
|
|
87
87
|
if (email.enabled && !EMAIL.test(emailFromAddress)) throw new ReportsConfigError("email.fromAddress must be a valid onboarded sender when email is enabled");
|
|
88
|
+
const emailLocal = emailFromAddress.slice(0, emailFromAddress.lastIndexOf("@"));
|
|
89
|
+
if (email.enabled && /^no-?reply$/u.test(emailLocal)) {
|
|
90
|
+
throw new ReportsConfigError("email.fromAddress must be a receivable mailbox, not a no-reply address");
|
|
91
|
+
}
|
|
92
|
+
if (email.enabled && (emailLocal.includes("+") || emailLocal.length > 10)) {
|
|
93
|
+
throw new ReportsConfigError("email.fromAddress must use a base mailbox of 10 characters or fewer without plus-addressing so replies can be routed securely");
|
|
94
|
+
}
|
|
88
95
|
if (email.enabled && !EMAIL.test(authEmailFromAddress)) throw new ReportsConfigError("email.authFromAddress must be a valid onboarded sender when email is enabled");
|
|
89
96
|
if (email.enabled && authEmailFromAddress === emailFromAddress) throw new ReportsConfigError("email.authFromAddress must differ from email.fromAddress");
|
|
90
97
|
if (!email.enabled && emailFromAddress) throw new ReportsConfigError("email.fromAddress must be empty when email is disabled");
|
|
@@ -156,6 +163,11 @@ export function buildReportsPlan(config) {
|
|
|
156
163
|
reporter: `REPORTER_EMAIL (${config.email.fromAddress || "not configured"})`,
|
|
157
164
|
authentication: `AUTH_EMAIL (${config.email.authFromAddress || "not configured"})`,
|
|
158
165
|
},
|
|
166
|
+
inboundReporterEmail: {
|
|
167
|
+
address: config.email.fromAddress,
|
|
168
|
+
routingRule: `Exact ${config.email.fromAddress} address routed to this Worker (no catch-all)`,
|
|
169
|
+
subaddressing: "Enabled so signed per-case reply addresses are accepted",
|
|
170
|
+
},
|
|
159
171
|
staticAssets: "public/",
|
|
160
172
|
publicHostname,
|
|
161
173
|
customDomain: customDomain ?? "workers.dev hostname (no custom domain mutation)",
|
|
@@ -184,7 +196,7 @@ export function buildReportsPlan(config) {
|
|
|
184
196
|
},
|
|
185
197
|
order: [
|
|
186
198
|
"Review this plan and the initial infrastructure-owner email.",
|
|
187
|
-
"Onboard the sender domain to Cloudflare Email Service before deployment.",
|
|
199
|
+
"Onboard the sender domain to Cloudflare Email Service and enable Email Routing for the reporter address before deployment.",
|
|
188
200
|
"Initialize local secrets without committing them.",
|
|
189
201
|
"Create D1, four private R2 buckets, and the three Queues in the customer account.",
|
|
190
202
|
"Apply the D1 schema baseline and any later forward-only migrations before accepting traffic.",
|
package/template/src/index.ts
CHANGED
|
@@ -56,6 +56,7 @@ import { sha256, signContextToken, verifyContextToken } from "./report-crypto";
|
|
|
56
56
|
import { createStoredReportContext, loadStoredReportContext, pruneReportContexts } from "./report-context";
|
|
57
57
|
import { parseContextSchema, routingContextFacts, validateContextFacts } from "./report-context-schema";
|
|
58
58
|
import { pendingDeliveryJobs, processDeliveryJob, RetryableDeliveryError } from "./report-delivery";
|
|
59
|
+
import { receiveReporterEmail } from "./report-email-inbound";
|
|
59
60
|
import { ApiError, allowedOrigin, apiErrorResponse, json, methodNotAllowed, readJsonObject, withCors } from "./report-http";
|
|
60
61
|
import {
|
|
61
62
|
cancelHumanTask,
|
|
@@ -613,6 +614,7 @@ async function handleWidgetReport(request: Request, env: Env, ctx: ExecutionCont
|
|
|
613
614
|
});
|
|
614
615
|
if (result.route) queueRoutingJob(env, ctx, result.jobId, result.receipt.reportId);
|
|
615
616
|
queueDeliveryJobs(env, ctx, result.deliveryJobs);
|
|
617
|
+
if (result.created) publishWorkspaceEvent(env, ctx, { type: "reports_changed", reportId: result.receipt.reportId });
|
|
616
618
|
return json(receiptJson(result.receipt), { status: result.created ? 201 : 200 });
|
|
617
619
|
}
|
|
618
620
|
|
|
@@ -649,6 +651,7 @@ async function handleAnonymousWidgetReport(request: Request, env: Env, ctx: Exec
|
|
|
649
651
|
});
|
|
650
652
|
if (result.route) queueRoutingJob(env, ctx, result.jobId, result.receipt.reportId);
|
|
651
653
|
queueDeliveryJobs(env, ctx, result.deliveryJobs);
|
|
654
|
+
if (result.created) publishWorkspaceEvent(env, ctx, { type: "reports_changed", reportId: result.receipt.reportId });
|
|
652
655
|
return json(receiptJson(result.receipt), { status: result.created ? 201 : 200 });
|
|
653
656
|
}
|
|
654
657
|
|
|
@@ -670,6 +673,7 @@ async function handleServerReport(request: Request, env: Env, ctx: ExecutionCont
|
|
|
670
673
|
});
|
|
671
674
|
if (result.route) queueRoutingJob(env, ctx, result.jobId, result.receipt.reportId);
|
|
672
675
|
queueDeliveryJobs(env, ctx, result.deliveryJobs);
|
|
676
|
+
if (result.created) publishWorkspaceEvent(env, ctx, { type: "reports_changed", reportId: result.receipt.reportId });
|
|
673
677
|
return json(receiptJson(result.receipt), { status: result.created ? 201 : 200 });
|
|
674
678
|
}
|
|
675
679
|
|
|
@@ -743,6 +747,7 @@ async function handlePublicReport(request: Request, env: Env, ctx: ExecutionCont
|
|
|
743
747
|
});
|
|
744
748
|
if (result.route) queueRoutingJob(env, ctx, result.jobId, result.receipt.reportId);
|
|
745
749
|
queueDeliveryJobs(env, ctx, result.deliveryJobs);
|
|
750
|
+
if (result.created) publishWorkspaceEvent(env, ctx, { type: "reports_changed", reportId: result.receipt.reportId });
|
|
746
751
|
return json(receiptJson(result.receipt), { status: result.created ? 201 : 200 });
|
|
747
752
|
}
|
|
748
753
|
|
|
@@ -3879,11 +3884,13 @@ async function handleQueue(batch: MessageBatch<unknown>, env: Env, ctx: Executio
|
|
|
3879
3884
|
}
|
|
3880
3885
|
if (isWorkflowDispatchJob(message.body)) {
|
|
3881
3886
|
await processWorkflowDispatchJob(env, ctx, message.body);
|
|
3887
|
+
publishWorkspaceEvent(env, ctx, { type: "reports_changed" });
|
|
3882
3888
|
message.ack();
|
|
3883
3889
|
continue;
|
|
3884
3890
|
}
|
|
3885
3891
|
if (isEvidenceFetchJob(message.body)) {
|
|
3886
3892
|
await processEvidenceFetch(env, message.body);
|
|
3893
|
+
publishWorkspaceEvent(env, ctx, { type: "reports_changed", reportId: message.body.reportId });
|
|
3887
3894
|
message.ack();
|
|
3888
3895
|
continue;
|
|
3889
3896
|
}
|
|
@@ -3907,6 +3914,7 @@ async function handleQueue(batch: MessageBatch<unknown>, env: Env, ctx: Executio
|
|
|
3907
3914
|
await Promise.all(jobs.filter(isAiJob).map((job) => markAiJobEnqueued(env.DB, job.jobId, false)));
|
|
3908
3915
|
}
|
|
3909
3916
|
}
|
|
3917
|
+
publishWorkspaceEvent(env, ctx, { type: "reports_changed", reportId: message.body.reportId });
|
|
3910
3918
|
message.ack();
|
|
3911
3919
|
} catch (error) {
|
|
3912
3920
|
await markJobEnqueueFailed(env.DB, message.body.jobId, "routing_processing_failed");
|
|
@@ -3933,16 +3941,19 @@ async function handleQueue(batch: MessageBatch<unknown>, env: Env, ctx: Executio
|
|
|
3933
3941
|
});
|
|
3934
3942
|
}
|
|
3935
3943
|
}
|
|
3944
|
+
publishWorkspaceEvent(env, ctx, { type: "reports_changed", reportId: message.body.reportId });
|
|
3936
3945
|
message.ack();
|
|
3937
3946
|
continue;
|
|
3938
3947
|
}
|
|
3939
3948
|
if (isDeliveryJob(message.body)) {
|
|
3940
3949
|
await processDeliveryJob(env.DB, message.body, env);
|
|
3950
|
+
publishWorkspaceEvent(env, ctx, { type: "reports_changed" });
|
|
3941
3951
|
message.ack();
|
|
3942
3952
|
continue;
|
|
3943
3953
|
}
|
|
3944
3954
|
if (isActionProposalJob(message.body)) {
|
|
3945
3955
|
await processActionProposalJob(env, message.body);
|
|
3956
|
+
publishWorkspaceEvent(env, ctx, { type: "reports_changed" });
|
|
3946
3957
|
message.ack();
|
|
3947
3958
|
continue;
|
|
3948
3959
|
}
|
|
@@ -4142,6 +4153,11 @@ export default {
|
|
|
4142
4153
|
await handleQueue(batch, env, ctx);
|
|
4143
4154
|
},
|
|
4144
4155
|
|
|
4156
|
+
async email(message: ForwardableEmailMessage, env: Env, ctx: ExecutionContext): Promise<void> {
|
|
4157
|
+
const result = await receiveReporterEmail(message, env);
|
|
4158
|
+
if (result.stored && result.reportId) publishWorkspaceEvent(env, ctx, { type: "reports_changed", reportId: result.reportId });
|
|
4159
|
+
},
|
|
4160
|
+
|
|
4145
4161
|
async scheduled(_controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
|
|
4146
4162
|
ctx.waitUntil((async () => {
|
|
4147
4163
|
await Promise.all([
|
|
@@ -2,7 +2,9 @@ import { canonicalJson } from "./audit";
|
|
|
2
2
|
import type { DeliveryJob } from "./report-types";
|
|
3
3
|
import { decryptReportContact } from "./report-crypto";
|
|
4
4
|
import { BudgetControlError, reserveBudgets, settleBudgets } from "./budget-control";
|
|
5
|
-
import { reporterEmailBinding, reporterEmailFrom, reporterEmailSubjectPrefix } from "./report-email";
|
|
5
|
+
import { reporterEmailBinding, reporterEmailFrom, reporterEmailSubjectPrefix, reporterReplyAddress } from "./report-email";
|
|
6
|
+
import { renderReporterEmail } from "./report-email-template";
|
|
7
|
+
import { loadWorkspaceBrand } from "./workspace-branding";
|
|
6
8
|
|
|
7
9
|
const MAX_ATTEMPTS = 8;
|
|
8
10
|
const RESPONSE_LIMIT = 16 * 1024;
|
|
@@ -41,6 +43,7 @@ interface EmailDeliveryRow {
|
|
|
41
43
|
recipientReference: string | null;
|
|
42
44
|
contactCiphertext: string | null;
|
|
43
45
|
body: string;
|
|
46
|
+
templateVersionId: string | null;
|
|
44
47
|
reportReference: string;
|
|
45
48
|
installationId: string;
|
|
46
49
|
queueId: string | null;
|
|
@@ -227,7 +230,7 @@ async function claimEmail(db: D1Database, id: string): Promise<EmailDeliveryRow
|
|
|
227
230
|
SELECT e.id, m.report_id AS reportId, e.message_id AS messageId,
|
|
228
231
|
e.audience, e.state, e.attempt_count AS attemptCount,
|
|
229
232
|
p.reference AS recipientReference, p.contact_ciphertext AS contactCiphertext,
|
|
230
|
-
m.body, r.public_reference AS reportReference,
|
|
233
|
+
m.body, m.template_version_id AS templateVersionId, r.public_reference AS reportReference,
|
|
231
234
|
r.installation_id AS installationId, r.queue_id AS queueId
|
|
232
235
|
FROM email_deliveries e
|
|
233
236
|
JOIN case_messages m ON m.id = e.message_id
|
|
@@ -486,11 +489,30 @@ async function processEmail(db: D1Database, deliveryId: string, env: Env): Promi
|
|
|
486
489
|
throw new DeliveryConfigurationError("Email sender or recipient configuration is invalid.");
|
|
487
490
|
}
|
|
488
491
|
const prefix = reporterEmailSubjectPrefix(env);
|
|
492
|
+
const [brand, replyTo] = await Promise.all([
|
|
493
|
+
loadWorkspaceBrand(db),
|
|
494
|
+
reporterReplyAddress({
|
|
495
|
+
fromAddress: from,
|
|
496
|
+
publicReference: row.reportReference,
|
|
497
|
+
signingSecret: env.CONTEXT_SIGNING_SECRET,
|
|
498
|
+
expiresAt: Math.floor(Date.now() / 1_000) + Math.max(
|
|
499
|
+
Number.parseInt(env.REPORT_RETENTION_DAYS, 10) || 365,
|
|
500
|
+
Number.parseInt(env.APPEAL_WINDOW_DAYS, 10) || 30,
|
|
501
|
+
) * 86_400,
|
|
502
|
+
}),
|
|
503
|
+
]);
|
|
504
|
+
const rendered = renderReporterEmail({
|
|
505
|
+
brand, body: row.body, reportReference: row.reportReference,
|
|
506
|
+
templateVersionId: row.templateVersionId, subjectPrefix: prefix,
|
|
507
|
+
});
|
|
489
508
|
const result = await binding.send({
|
|
490
509
|
to: email,
|
|
491
|
-
from,
|
|
492
|
-
|
|
493
|
-
|
|
510
|
+
from: { email: from, name: `${brand.organization_name} Safety` },
|
|
511
|
+
replyTo: { email: replyTo, name: `${brand.organization_name} Safety` },
|
|
512
|
+
subject: rendered.subject,
|
|
513
|
+
text: rendered.text,
|
|
514
|
+
html: rendered.html,
|
|
515
|
+
headers: { "X-Safest-Report-Reference": row.reportReference },
|
|
494
516
|
});
|
|
495
517
|
providerMessageId = typeof result.messageId === "string" ? result.messageId.slice(0, 300) : null;
|
|
496
518
|
} catch (error) {
|