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
|
@@ -256,6 +256,19 @@ td small { margin-top: 3px; color: var(--muted); }
|
|
|
256
256
|
.detail-grid dl { margin: 0; display: grid; grid-template-columns: 115px 1fr; gap: 8px; font-size: 10px; }
|
|
257
257
|
.detail-grid dt { color: var(--muted); }
|
|
258
258
|
.detail-grid dd { margin: 0; overflow-wrap: anywhere; font-weight: 750; }
|
|
259
|
+
.report-submission { grid-column: 1 / -1; }
|
|
260
|
+
.submission-meta { margin-bottom: 14px !important; grid-template-columns: 115px minmax(0, 1fr) 115px minmax(0, 1fr) !important; }
|
|
261
|
+
.answer-list { display: grid; gap: 8px; }
|
|
262
|
+
.answer-record { padding: 11px 12px; display: grid; grid-template-columns: minmax(150px, .8fr) minmax(0, 1.4fr); gap: 16px; align-items: start; border: 1px solid #e5e9e4; border-radius: 8px; background: white; font-size: 11px; line-height: 1.5; }
|
|
263
|
+
.answer-record strong, .answer-record small { display: block; }
|
|
264
|
+
.answer-record strong { font-size: 11px; }
|
|
265
|
+
.answer-record small { margin-top: 3px; color: var(--muted); font-size: 9px; }
|
|
266
|
+
.answer-record > :last-child { min-width: 0; overflow-wrap: anywhere; }
|
|
267
|
+
.answer-text { white-space: pre-wrap; }
|
|
268
|
+
.answer-empty { color: var(--muted); font-style: italic; }
|
|
269
|
+
.form-help { margin: -5px 0 12px; color: var(--muted); font-size: 10px; line-height: 1.45; }
|
|
270
|
+
.mutation-status { margin: 0; padding: 11px 12px; border: 1px solid #d4e7d9; border-radius: 8px; color: #23543b; background: #eff8f1; font-size: 10px; line-height: 1.45; }
|
|
271
|
+
.mutation-status:empty { display: none; }
|
|
259
272
|
.evidence-list { display: grid; gap: 7px; }
|
|
260
273
|
.evidence-item { padding: 8px; border-radius: 7px; color: #405249; background: white; font-size: 9px; overflow-wrap: anywhere; }
|
|
261
274
|
.report-detail-content > section { margin-top: 14px; }
|
|
@@ -845,8 +858,8 @@ body.report-open .content { margin-right: 0; }
|
|
|
845
858
|
.sign-out-button:hover { background: #f7f9f6; }
|
|
846
859
|
|
|
847
860
|
.workspace { min-height: calc(100vh - var(--product-topbar)); grid-template-columns: var(--product-sidebar) minmax(0, 1fr); background: var(--canvas); }
|
|
848
|
-
.sidebar { top: var(--product-topbar); height: calc(100vh - var(--product-topbar)); padding: 16px 12px 14px; background: var(--forest-dark); }
|
|
849
|
-
.sidebar nav { margin-top: 0; display: grid; gap: 3px; }
|
|
861
|
+
.sidebar { top: var(--product-topbar); height: calc(100vh - var(--product-topbar)); height: calc(100dvh - var(--product-topbar)); min-height: 0; padding: 16px 12px 14px; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; scrollbar-width: thin; background: var(--forest-dark); }
|
|
862
|
+
.sidebar nav { min-height: min-content; margin-top: 0; padding-bottom: 10px; display: grid; gap: 3px; }
|
|
850
863
|
.sidebar nav > small { margin: 11px 10px 5px; color: #7fa596; font-size: 9px; font-weight: 800; letter-spacing: .11em; text-transform: uppercase; }
|
|
851
864
|
.sidebar nav > small:first-child { margin-top: 9px; }
|
|
852
865
|
.sidebar nav a { min-height: 40px; padding: 0 10px; display: grid; grid-template-columns: 20px minmax(0, 1fr) auto; gap: 10px; color: #c5d7cf; font-size: 13px; font-weight: 560; }
|
|
@@ -1490,6 +1503,8 @@ body.login-mode .top-actions { margin-left: auto; }
|
|
|
1490
1503
|
.configuration-dialog .detail-head, .registry-dialog .detail-head { padding: 14px; }
|
|
1491
1504
|
.configuration-dialog .editor-form, .registry-dialog .editor-form { padding: 14px; }
|
|
1492
1505
|
.configuration-tabs { scrollbar-width: thin; }
|
|
1506
|
+
.submission-meta { grid-template-columns: 100px minmax(0, 1fr) !important; }
|
|
1507
|
+
.answer-record { grid-template-columns: 1fr; gap: 5px; }
|
|
1493
1508
|
.configuration-tabs button { flex: 0 0 auto; }
|
|
1494
1509
|
}
|
|
1495
1510
|
|
|
@@ -48,7 +48,8 @@
|
|
|
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 },
|
|
52
|
+
"authFromAddress": { "type": "string", "maxLength": 254 }
|
|
52
53
|
}
|
|
53
54
|
},
|
|
54
55
|
"auth": {
|
|
@@ -58,13 +58,14 @@ function providerConfigured(provider, selected, secrets) {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
export async function confirmEmailSending(config, ui = clack) {
|
|
61
|
-
const
|
|
62
|
-
const
|
|
61
|
+
const reporterSender = config.email?.fromAddress ?? "";
|
|
62
|
+
const authSender = config.email?.authFromAddress ?? "";
|
|
63
|
+
const domains = [...new Set([reporterSender, authSender].filter(Boolean).map((sender) => sender.slice(sender.lastIndexOf("@") + 1)))];
|
|
63
64
|
ui.note(
|
|
64
|
-
`
|
|
65
|
+
`Reporter updates: ${reporterSender}\nAccount and invitation mail: ${authSender}\nDomain${domains.length === 1 ? "" : "s"}: ${domains.join(", ")}\n\n` +
|
|
65
66
|
`1. In Cloudflare, open Compute → Email Service → Email Sending.\n` +
|
|
66
|
-
`2. Onboard ${
|
|
67
|
-
`3. Return here.
|
|
67
|
+
`2. Onboard ${domains.join(" and ")} and wait for the sending DNS records to show configured.\n` +
|
|
68
|
+
`3. Return here. Resolve keeps participant mail separate from account and invitation mail.`,
|
|
68
69
|
"Email delivery",
|
|
69
70
|
);
|
|
70
71
|
const action = await ui.select({
|
|
@@ -77,12 +77,18 @@ export function parseReportsConfig(value) {
|
|
|
77
77
|
if (!EMAIL.test(ownerEmail)) throw new ReportsConfigError("owner.email is invalid");
|
|
78
78
|
const retention = object(input.retention, "retention");
|
|
79
79
|
exactKeys(retention, new Set(["reportsDays", "messagesDays", "appealWindowDays"]), "retention");
|
|
80
|
-
const email = input.email === undefined ? { enabled: false, fromAddress: "" } : object(input.email, "email");
|
|
81
|
-
exactKeys(email, new Set(["enabled", "fromAddress"]), "email");
|
|
80
|
+
const email = input.email === undefined ? { enabled: false, fromAddress: "", authFromAddress: "" } : object(input.email, "email");
|
|
81
|
+
exactKeys(email, new Set(["enabled", "fromAddress", "authFromAddress"]), "email");
|
|
82
82
|
if (typeof email.enabled !== "boolean") throw new ReportsConfigError("email.enabled must be boolean");
|
|
83
83
|
const emailFromAddress = String(email.fromAddress ?? "").trim().toLowerCase();
|
|
84
|
+
const emailDomain = emailFromAddress.includes("@") ? emailFromAddress.slice(emailFromAddress.lastIndexOf("@") + 1) : "";
|
|
85
|
+
const defaultAuthEmail = emailDomain ? `${emailFromAddress.startsWith("accounts@") ? "auth" : "accounts"}@${emailDomain}` : "";
|
|
86
|
+
const authEmailFromAddress = String(email.authFromAddress ?? defaultAuthEmail).trim().toLowerCase();
|
|
84
87
|
if (email.enabled && !EMAIL.test(emailFromAddress)) throw new ReportsConfigError("email.fromAddress must be a valid onboarded sender when email is enabled");
|
|
88
|
+
if (email.enabled && !EMAIL.test(authEmailFromAddress)) throw new ReportsConfigError("email.authFromAddress must be a valid onboarded sender when email is enabled");
|
|
89
|
+
if (email.enabled && authEmailFromAddress === emailFromAddress) throw new ReportsConfigError("email.authFromAddress must differ from email.fromAddress");
|
|
85
90
|
if (!email.enabled && emailFromAddress) throw new ReportsConfigError("email.fromAddress must be empty when email is disabled");
|
|
91
|
+
if (!email.enabled && email.authFromAddress) throw new ReportsConfigError("email.authFromAddress must be empty when email is disabled");
|
|
86
92
|
const auth = input.auth === undefined ? { password: true, providers: [] } : object(input.auth, "auth");
|
|
87
93
|
exactKeys(auth, new Set(["password", "providers"]), "auth");
|
|
88
94
|
if (auth.password !== true) throw new ReportsConfigError("auth.password must be true because password recovery is a required sign-in method");
|
|
@@ -112,7 +118,7 @@ export function parseReportsConfig(value) {
|
|
|
112
118
|
publicBaseUrl: origin(input.publicBaseUrl, "publicBaseUrl"),
|
|
113
119
|
allowedOrigins: stringList(input.allowedOrigins, "allowedOrigins", 20, origin),
|
|
114
120
|
owner: { email: ownerEmail },
|
|
115
|
-
email: { enabled: email.enabled, fromAddress: emailFromAddress },
|
|
121
|
+
email: { enabled: email.enabled, fromAddress: emailFromAddress, authFromAddress: authEmailFromAddress },
|
|
116
122
|
auth: { password: true, providers: authProviders },
|
|
117
123
|
retention: {
|
|
118
124
|
reportsDays: integer(retention.reportsDays, "retention.reportsDays", 30, 3_650),
|
|
@@ -146,7 +152,10 @@ export function buildReportsPlan(config) {
|
|
|
146
152
|
queues: [config.resources.reportQueueName, config.resources.deliveryQueueName, config.resources.operationsDlqName],
|
|
147
153
|
durableObject: "WorkspacePresence (SQLite-backed, declared by Worker migration)",
|
|
148
154
|
workersAiBinding: "AI",
|
|
149
|
-
|
|
155
|
+
emailServiceBindings: {
|
|
156
|
+
reporter: `REPORTER_EMAIL (${config.email.fromAddress || "not configured"})`,
|
|
157
|
+
authentication: `AUTH_EMAIL (${config.email.authFromAddress || "not configured"})`,
|
|
158
|
+
},
|
|
150
159
|
staticAssets: "public/",
|
|
151
160
|
publicHostname,
|
|
152
161
|
customDomain: customDomain ?? "workers.dev hostname (no custom domain mutation)",
|
package/template/src/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { canonicalJson } from "./audit";
|
|
2
|
+
import { reporterEmailConfigured } from "./report-email";
|
|
2
3
|
import { decodeApiCursor, encodeApiCursor } from "./api-cursor";
|
|
3
4
|
import {
|
|
4
5
|
assertBackupTransferAllowed,
|
|
@@ -409,15 +410,17 @@ function publishWorkspaceEvent(
|
|
|
409
410
|
|
|
410
411
|
function deliveryOptions(env: Env) {
|
|
411
412
|
return {
|
|
413
|
+
emailEnabled: reporterEmailConfigured(env),
|
|
414
|
+
contactEncryptionKey: env.CREDENTIAL_ENCRYPTION_KEY,
|
|
415
|
+
recipientHashPepper: env.RATE_LIMIT_PEPPER,
|
|
412
416
|
notificationEnabled: Boolean(env.NOTIFICATION_WEBHOOK_URL?.trim() && env.NOTIFICATION_WEBHOOK_SECRET?.trim()),
|
|
413
417
|
};
|
|
414
418
|
}
|
|
415
419
|
|
|
416
420
|
function intakeDeliveryOptions(env: Env) {
|
|
417
|
-
const emailBinding = (env as unknown as { EMAIL?: unknown }).EMAIL;
|
|
418
421
|
return {
|
|
419
422
|
contactEncryptionKey: env.CREDENTIAL_ENCRYPTION_KEY,
|
|
420
|
-
emailEnabled:
|
|
423
|
+
emailEnabled: reporterEmailConfigured(env),
|
|
421
424
|
notificationEnabled: Boolean(env.NOTIFICATION_WEBHOOK_URL?.trim() && env.NOTIFICATION_WEBHOOK_SECRET?.trim()),
|
|
422
425
|
};
|
|
423
426
|
}
|
|
@@ -2592,7 +2595,10 @@ async function handleAdminAiQualitySample(
|
|
|
2592
2595
|
const sample = await loadAiQualitySample(env.DB, sampleId);
|
|
2593
2596
|
if (!sample) throw new ApiError(404, "quality_sample_not_found", "The quality sample does not exist.");
|
|
2594
2597
|
await assertOperatorReportAccess(env.DB, session, String(sample.reportId));
|
|
2595
|
-
const report = await loadReportDetail(env.DB, String(sample.reportId)
|
|
2598
|
+
const report = await loadReportDetail(env.DB, String(sample.reportId), {
|
|
2599
|
+
contactEncryptionKey: env.CREDENTIAL_ENCRYPTION_KEY,
|
|
2600
|
+
includeReporterEmail: session.permissions.messageReporters,
|
|
2601
|
+
});
|
|
2596
2602
|
if (!report) throw new ApiError(404, "report_not_found", "The sampled report does not exist.");
|
|
2597
2603
|
return json({ schema_version: "1", sample: projectOperatorFields(session, "quality_sample", sample), report: projectOperatorFields(session, "report", report) });
|
|
2598
2604
|
}
|
|
@@ -2840,7 +2846,10 @@ async function handleAdminReportDetail(request: Request, env: Env, reportId: str
|
|
|
2840
2846
|
if (request.method !== "GET") return methodNotAllowed(["GET"]);
|
|
2841
2847
|
const session = await requireOperator(request, env, "readReports");
|
|
2842
2848
|
await assertOperatorReportAccess(env.DB, session, reportId);
|
|
2843
|
-
const report = await loadReportDetail(env.DB, reportId
|
|
2849
|
+
const report = await loadReportDetail(env.DB, reportId, {
|
|
2850
|
+
contactEncryptionKey: env.CREDENTIAL_ENCRYPTION_KEY,
|
|
2851
|
+
includeReporterEmail: session.permissions.messageReporters,
|
|
2852
|
+
});
|
|
2844
2853
|
if (!report) throw new ApiError(404, "report_not_found", "The report does not exist.");
|
|
2845
2854
|
return json({ schema_version: "1", report: projectOperatorFields(session, "report", report) });
|
|
2846
2855
|
}
|
|
@@ -2879,7 +2888,13 @@ async function handleAdminReportMessage(
|
|
|
2879
2888
|
);
|
|
2880
2889
|
queueDeliveryJobs(env, ctx, result.jobs);
|
|
2881
2890
|
publishWorkspaceEvent(env, ctx, { type: "reports_changed", reportId });
|
|
2882
|
-
return json({
|
|
2891
|
+
return json({
|
|
2892
|
+
schema_version: "1",
|
|
2893
|
+
message_id: result.messageId,
|
|
2894
|
+
delivery_state: result.deliveryState,
|
|
2895
|
+
delivery_channel: result.deliveryChannel,
|
|
2896
|
+
idempotent_replay: result.idempotentReplay,
|
|
2897
|
+
}, {
|
|
2883
2898
|
status: result.idempotentReplay ? 200 : 201,
|
|
2884
2899
|
});
|
|
2885
2900
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { canonicalJson } from "./audit";
|
|
2
2
|
import { sha256 } from "./report-crypto";
|
|
3
3
|
import { ApiError } from "./report-http";
|
|
4
|
+
import { reporterEmailConfigured } from "./report-email";
|
|
4
5
|
import type { OperatorSession } from "./report-types";
|
|
5
6
|
import { WORKFLOW_COMPILER_VERSION } from "./workflow-compiler";
|
|
6
7
|
import { parseWorkspaceBrand } from "./workspace-branding";
|
|
@@ -280,7 +281,7 @@ export async function verifyFeature(env: Env, feature: string, actorId: string):
|
|
|
280
281
|
else if (feature === "workers_ai") { ready = Boolean((env as unknown as { AI?: unknown }).AI); }
|
|
281
282
|
else if (feature === "object_storage") { ready = Boolean(env.PROFILE_MEDIA && env.WORKFLOW_ARTIFACTS && env.EVIDENCE_OBJECTS && env.EXPORTS); capability = { buckets: ready ? 4 : 0 }; }
|
|
282
283
|
else if (feature === "queues") { ready = Boolean(env.REPORT_JOBS && env.DELIVERY_JOBS && env.CONTROL_JOBS); capability = { producers: ready ? 3 : 0 }; }
|
|
283
|
-
else { ready =
|
|
284
|
+
else { ready = reporterEmailConfigured(env); capability = { optional: true, channel: "reporter_email" }; }
|
|
284
285
|
} catch { errorCode = "feature_probe_failed"; }
|
|
285
286
|
const state = ready ? "ready" : feature === "email" ? "disabled" : "setup_required";
|
|
286
287
|
const timestamp = now();
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { canonicalJson } from "./audit";
|
|
2
2
|
import { evaluateAiFailurePause } from "./report-ai-quality";
|
|
3
|
+
import { selectParticipantDelivery } from "./report-email";
|
|
3
4
|
import type { AiJob, DeliveryJob, ReportState } from "./report-types";
|
|
4
5
|
import type { AiMode, ValidatedAiResult } from "./report-ai-validation";
|
|
5
6
|
import { validateAiResult } from "./report-ai-validation";
|
|
@@ -58,6 +59,7 @@ interface AiContextRow extends AiConfigRow {
|
|
|
58
59
|
job_state: "pending" | "enqueued" | "processing" | "complete" | "failed" | "dead_letter";
|
|
59
60
|
attempt_count: number;
|
|
60
61
|
report_id: string;
|
|
62
|
+
installation_id: string;
|
|
61
63
|
public_reference: string;
|
|
62
64
|
source: string;
|
|
63
65
|
report_state: ReportState;
|
|
@@ -67,6 +69,8 @@ interface AiContextRow extends AiConfigRow {
|
|
|
67
69
|
target_reference: string;
|
|
68
70
|
signed_facts_json: string;
|
|
69
71
|
reporter_reference: string | null;
|
|
72
|
+
reporter_contact_mode: string;
|
|
73
|
+
reporter_contact_ciphertext: string | null;
|
|
70
74
|
queue_ai_instructions: string | null;
|
|
71
75
|
}
|
|
72
76
|
|
|
@@ -224,10 +228,12 @@ function randomSample(rate: number): boolean {
|
|
|
224
228
|
|
|
225
229
|
async function aiContext(db: D1Database, jobId: string): Promise<AiContextRow | null> {
|
|
226
230
|
return db.prepare(`
|
|
227
|
-
SELECT j.id AS job_id, j.state AS job_state, j.attempt_count, r.id AS report_id,
|
|
231
|
+
SELECT j.id AS job_id, j.state AS job_state, j.attempt_count, r.id AS report_id, r.installation_id,
|
|
228
232
|
r.public_reference, r.source, r.state AS report_state, r.reason_code, r.form_version_id,
|
|
229
233
|
t.target_type, t.target_reference, t.signed_facts_json,
|
|
230
|
-
p.reference AS reporter_reference,
|
|
234
|
+
p.reference AS reporter_reference, p.contact_mode AS reporter_contact_mode,
|
|
235
|
+
p.contact_ciphertext AS reporter_contact_ciphertext,
|
|
236
|
+
qv.ai_instructions AS queue_ai_instructions, c.*
|
|
231
237
|
FROM background_jobs j
|
|
232
238
|
JOIN reports r ON r.id = j.report_id
|
|
233
239
|
JOIN report_targets t ON t.report_id = r.id
|
|
@@ -431,6 +437,9 @@ async function providerFailure(db: D1Database, context: AiContextRow, runId: str
|
|
|
431
437
|
}
|
|
432
438
|
|
|
433
439
|
export interface AiProcessOptions {
|
|
440
|
+
emailEnabled: boolean;
|
|
441
|
+
contactEncryptionKey: string;
|
|
442
|
+
recipientHashPepper: string;
|
|
434
443
|
notificationEnabled: boolean;
|
|
435
444
|
}
|
|
436
445
|
|
|
@@ -643,6 +652,14 @@ export async function processAiJob(
|
|
|
643
652
|
const messageId = crypto.randomUUID();
|
|
644
653
|
const noticeId = crypto.randomUUID();
|
|
645
654
|
const notificationId = crypto.randomUUID();
|
|
655
|
+
const participantDelivery = await selectParticipantDelivery({
|
|
656
|
+
reportId: context.report_id,
|
|
657
|
+
audience: "reporter",
|
|
658
|
+
contactMode: context.reporter_contact_mode,
|
|
659
|
+
contactCiphertext: context.reporter_contact_ciphertext,
|
|
660
|
+
reference: context.reporter_reference,
|
|
661
|
+
...options,
|
|
662
|
+
});
|
|
646
663
|
let templateId: string;
|
|
647
664
|
let variables: Record<string, string>;
|
|
648
665
|
let nextState: ReportState;
|
|
@@ -721,7 +738,7 @@ export async function processAiJob(
|
|
|
721
738
|
) VALUES (?1, ?2, 'reporter', 'outbound', 'ai', ?3, ?4, ?5, 1, ?6, ?7, ?8)
|
|
722
739
|
`).bind(
|
|
723
740
|
messageId, context.report_id, context.id, renderedBody, templateId,
|
|
724
|
-
|
|
741
|
+
participantDelivery.state, finishedAt, `ai-message:${storedRun.id}`,
|
|
725
742
|
),
|
|
726
743
|
usageStatement(db, context.id, nextState === "resolved_by_ai" ? { resolved: 1 } : { waiting: 1 }, finishedAt),
|
|
727
744
|
auditEvent(db, {
|
|
@@ -760,10 +777,18 @@ export async function processAiJob(
|
|
|
760
777
|
) VALUES (?1, ?2, ?3, 'reporter', ?4, ?5, ?6, ?7, ?7, ?8, ?9)
|
|
761
778
|
`).bind(
|
|
762
779
|
noticeId, context.report_id, nextState === "resolved_by_ai" ? decisionId : null, templateId, renderedBody,
|
|
763
|
-
|
|
780
|
+
participantDelivery.state, finishedAt, `ai-notice:${storedRun.id}`, messageId,
|
|
764
781
|
));
|
|
765
782
|
const jobs: DeliveryJob[] = [];
|
|
766
|
-
if (
|
|
783
|
+
if (participantDelivery.channel === "email" && participantDelivery.recipientHash) {
|
|
784
|
+
statements.push(db.prepare(`
|
|
785
|
+
INSERT INTO email_deliveries (
|
|
786
|
+
id, installation_id, message_id, audience, recipient_hash,
|
|
787
|
+
provider, state, attempt_count, next_attempt_at, created_at, updated_at
|
|
788
|
+
) VALUES (?1, ?2, ?3, 'reporter', ?4, 'cloudflare_email_service', 'pending', 0, ?5, ?5, ?5)
|
|
789
|
+
`).bind(notificationId, context.installation_id, messageId, participantDelivery.recipientHash, finishedAt));
|
|
790
|
+
jobs.push({ version: 1, jobId: `email:${notificationId}`, type: "deliver_email", outboxId: notificationId });
|
|
791
|
+
} else if (participantDelivery.channel === "customer_webhook") {
|
|
767
792
|
statements.push(db.prepare(`
|
|
768
793
|
INSERT INTO notification_outbox (
|
|
769
794
|
id, notice_id, idempotency_key, channel, payload_json, status, next_attempt_at, created_at, updated_at
|
|
@@ -2,6 +2,7 @@ import { betterAuth } from "better-auth";
|
|
|
2
2
|
import { getMigrations } from "better-auth/db/migration";
|
|
3
3
|
import { genericOAuth } from "better-auth/plugins";
|
|
4
4
|
import type { GenericOAuthConfig } from "better-auth/plugins/generic-oauth";
|
|
5
|
+
import { authenticationEmailBinding } from "./report-email";
|
|
5
6
|
|
|
6
7
|
const INVITATION_COOKIE = "safest_invitation";
|
|
7
8
|
const INVITATION_MAX_AGE_SECONDS = 7 * 24 * 60 * 60;
|
|
@@ -9,16 +10,6 @@ const OWNER_SETUP_COOKIE = "safest_owner_setup";
|
|
|
9
10
|
const OWNER_SETUP_RATE_LIMIT = 12;
|
|
10
11
|
const OWNER_SETUP_WINDOW_SECONDS = 10 * 60;
|
|
11
12
|
|
|
12
|
-
type AuthEmailBinding = {
|
|
13
|
-
send(message: {
|
|
14
|
-
from: { email: string; name?: string };
|
|
15
|
-
to: string | string[];
|
|
16
|
-
subject: string;
|
|
17
|
-
text: string;
|
|
18
|
-
html: string;
|
|
19
|
-
}): Promise<unknown>;
|
|
20
|
-
};
|
|
21
|
-
|
|
22
13
|
type BetterAuthEnv = Env & {
|
|
23
14
|
BETTER_AUTH_SECRET?: string;
|
|
24
15
|
AUTH_EMAIL_FROM?: string;
|
|
@@ -28,7 +19,6 @@ type BetterAuthEnv = Env & {
|
|
|
28
19
|
AUTH_GITHUB_CLIENT_SECRET?: string;
|
|
29
20
|
AUTH_CLOUDFLARE_CLIENT_ID?: string;
|
|
30
21
|
AUTH_CLOUDFLARE_CLIENT_SECRET?: string;
|
|
31
|
-
EMAIL?: AuthEmailBinding;
|
|
32
22
|
};
|
|
33
23
|
|
|
34
24
|
type BetterAuthSession = {
|
|
@@ -274,11 +264,12 @@ async function sendAuthEmail(
|
|
|
274
264
|
env: BetterAuthEnv,
|
|
275
265
|
message: { to: string; subject: string; heading: string; body: string; action: string; url: string },
|
|
276
266
|
): Promise<void> {
|
|
277
|
-
|
|
267
|
+
const email = authenticationEmailBinding(env);
|
|
268
|
+
if (localDevelopment(env) && (!email || !env.AUTH_EMAIL_FROM)) {
|
|
278
269
|
console.info(`Local authentication email (${message.subject}) for ${message.to}: ${message.url}`);
|
|
279
270
|
return;
|
|
280
271
|
}
|
|
281
|
-
if (!
|
|
272
|
+
if (!email || !env.AUTH_EMAIL_FROM) {
|
|
282
273
|
throw new Error("Cloudflare Email Service and AUTH_EMAIL_FROM are required for authentication email.");
|
|
283
274
|
}
|
|
284
275
|
const footer = "If you did not request this message, you can safely ignore it.";
|
|
@@ -288,7 +279,7 @@ async function sendAuthEmail(
|
|
|
288
279
|
`<p><a href="${escapeHtml(message.url)}" style="display:inline-block;padding:10px 16px;` +
|
|
289
280
|
`background:#171717;color:#fff;text-decoration:none;border-radius:6px">${escapeHtml(message.action)}</a></p>` +
|
|
290
281
|
`<p style="font-size:12px;color:#666">${escapeHtml(footer)}</p></body></html>`;
|
|
291
|
-
await
|
|
282
|
+
await email.send({
|
|
292
283
|
from: { name: "Safest Resolve", email: env.AUTH_EMAIL_FROM },
|
|
293
284
|
to: message.to,
|
|
294
285
|
subject: message.subject,
|
|
@@ -2,6 +2,7 @@ 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
6
|
|
|
6
7
|
const MAX_ATTEMPTS = 8;
|
|
7
8
|
const RESPONSE_LIMIT = 16 * 1024;
|
|
@@ -449,8 +450,8 @@ async function processEmail(db: D1Database, deliveryId: string, env: Env): Promi
|
|
|
449
450
|
const email = row.contactCiphertext
|
|
450
451
|
? (await decryptReportContact(env.CREDENTIAL_ENCRYPTION_KEY, row.contactCiphertext, row.reportId, row.audience)).trim().toLowerCase()
|
|
451
452
|
: row.recipientReference?.trim().toLowerCase() ?? "";
|
|
452
|
-
const from = env
|
|
453
|
-
const binding = (env
|
|
453
|
+
const from = reporterEmailFrom(env);
|
|
454
|
+
const binding = reporterEmailBinding(env);
|
|
454
455
|
let reservations;
|
|
455
456
|
try {
|
|
456
457
|
reservations = await reserveBudgets(db, {
|
|
@@ -484,7 +485,7 @@ async function processEmail(db: D1Database, deliveryId: string, env: Env): Promi
|
|
|
484
485
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(email) || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(from)) {
|
|
485
486
|
throw new DeliveryConfigurationError("Email sender or recipient configuration is invalid.");
|
|
486
487
|
}
|
|
487
|
-
const prefix = env
|
|
488
|
+
const prefix = reporterEmailSubjectPrefix(env);
|
|
488
489
|
const result = await binding.send({
|
|
489
490
|
to: email,
|
|
490
491
|
from,
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
export type AuthenticationEmailBinding = {
|
|
2
|
+
send(message: {
|
|
3
|
+
from: { email: string; name?: string };
|
|
4
|
+
to: string | string[];
|
|
5
|
+
subject: string;
|
|
6
|
+
text: string;
|
|
7
|
+
html: string;
|
|
8
|
+
}): Promise<unknown>;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type ReporterEmailBinding = {
|
|
12
|
+
send(message: {
|
|
13
|
+
from: string;
|
|
14
|
+
to: string;
|
|
15
|
+
subject: string;
|
|
16
|
+
text: string;
|
|
17
|
+
}): Promise<{ messageId?: string }>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type EmailEnvironment = {
|
|
21
|
+
AUTH_EMAIL?: AuthenticationEmailBinding;
|
|
22
|
+
REPORTER_EMAIL?: ReporterEmailBinding;
|
|
23
|
+
/** Compatibility with installations created before the email channels were separated. */
|
|
24
|
+
EMAIL?: AuthenticationEmailBinding & ReporterEmailBinding;
|
|
25
|
+
AUTH_EMAIL_FROM?: string;
|
|
26
|
+
REPORTER_EMAIL_FROM?: string;
|
|
27
|
+
REPORTER_EMAIL_SUBJECT_PREFIX?: string;
|
|
28
|
+
/** Compatibility with installations created before the email channels were separated. */
|
|
29
|
+
EMAIL_FROM_ADDRESS?: string;
|
|
30
|
+
EMAIL_SUBJECT_PREFIX?: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function emailEnvironment(env: unknown): EmailEnvironment {
|
|
34
|
+
return env as EmailEnvironment;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function authenticationEmailBinding(env: unknown): AuthenticationEmailBinding | undefined {
|
|
38
|
+
const bindings = emailEnvironment(env);
|
|
39
|
+
return bindings.AUTH_EMAIL ?? bindings.EMAIL;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function reporterEmailBinding(env: unknown): ReporterEmailBinding | undefined {
|
|
43
|
+
const bindings = emailEnvironment(env);
|
|
44
|
+
return bindings.REPORTER_EMAIL ?? bindings.EMAIL;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function reporterEmailFrom(env: unknown): string {
|
|
48
|
+
const bindings = emailEnvironment(env);
|
|
49
|
+
return (bindings.REPORTER_EMAIL_FROM ?? bindings.EMAIL_FROM_ADDRESS ?? "").trim().toLowerCase();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function reporterEmailSubjectPrefix(env: unknown): string {
|
|
53
|
+
const bindings = emailEnvironment(env);
|
|
54
|
+
return (bindings.REPORTER_EMAIL_SUBJECT_PREFIX ?? bindings.EMAIL_SUBJECT_PREFIX ?? "").trim().slice(0, 120)
|
|
55
|
+
|| "Safest report update";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function reporterEmailConfigured(env: unknown): boolean {
|
|
59
|
+
return Boolean(reporterEmailBinding(env) && reporterEmailFrom(env));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type ParticipantDelivery = {
|
|
63
|
+
channel: "email" | "customer_webhook" | null;
|
|
64
|
+
state: "queued" | "suppressed";
|
|
65
|
+
recipientHash?: string;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export async function selectParticipantDelivery(input: {
|
|
69
|
+
reportId: string;
|
|
70
|
+
audience: ParticipantAudience;
|
|
71
|
+
contactMode: string;
|
|
72
|
+
contactCiphertext: string | null;
|
|
73
|
+
reference: string | null;
|
|
74
|
+
emailEnabled: boolean;
|
|
75
|
+
notificationEnabled: boolean;
|
|
76
|
+
contactEncryptionKey: string;
|
|
77
|
+
recipientHashPepper: string;
|
|
78
|
+
}): Promise<ParticipantDelivery> {
|
|
79
|
+
if (input.emailEnabled && input.contactMode === "customer_notification" && input.contactCiphertext) {
|
|
80
|
+
try {
|
|
81
|
+
const email = (await decryptReportContact(
|
|
82
|
+
input.contactEncryptionKey,
|
|
83
|
+
input.contactCiphertext,
|
|
84
|
+
input.reportId,
|
|
85
|
+
input.audience,
|
|
86
|
+
)).trim().toLowerCase();
|
|
87
|
+
if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(email)) {
|
|
88
|
+
return {
|
|
89
|
+
channel: "email",
|
|
90
|
+
state: "queued",
|
|
91
|
+
recipientHash: `sha256:${await sha256(`email-recipient-v1:${input.recipientHashPepper}:${email}`)}`,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
} catch {
|
|
95
|
+
// A damaged encrypted contact can still fall back to a customer-owned
|
|
96
|
+
// notification webhook when the report has an eligible participant.
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (input.notificationEnabled && input.contactMode === "customer_notification"
|
|
100
|
+
&& (input.reference || input.contactCiphertext)) {
|
|
101
|
+
return { channel: "customer_webhook", state: "queued" };
|
|
102
|
+
}
|
|
103
|
+
return { channel: null, state: "suppressed" };
|
|
104
|
+
}
|
|
105
|
+
import { decryptReportContact, sha256 } from "./report-crypto";
|
|
106
|
+
import type { ParticipantAudience } from "./report-types";
|