create-safest-tools 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +71 -0
- package/bin/create-safest-tools.mjs +12 -0
- package/package.json +34 -0
- package/src/cli.mjs +128 -0
- package/src/config.mjs +151 -0
- package/src/scaffold.mjs +30 -0
- package/template/LICENSE +201 -0
- package/template/README.md +35 -0
- package/template/console/README.md +28 -0
- package/template/console/analytics/AnalyticsWorkspace.tsx +55 -0
- package/template/console/analytics/controller.ts +133 -0
- package/template/console/analytics/events.ts +33 -0
- package/template/console/analytics/store.ts +33 -0
- package/template/console/analytics/types.ts +38 -0
- package/template/console/appeals/AppealWorkspace.tsx +73 -0
- package/template/console/appeals/controller.ts +125 -0
- package/template/console/appeals/events.ts +33 -0
- package/template/console/appeals/store.ts +35 -0
- package/template/console/appeals/types.ts +37 -0
- package/template/console/assistant/AssistantWorkspace.tsx +133 -0
- package/template/console/assistant/types.ts +34 -0
- package/template/console/auth/AccountJourney.tsx +69 -0
- package/template/console/auth/InvitationAcceptance.tsx +145 -0
- package/template/console/auth/OAuthButtons.tsx +78 -0
- package/template/console/auth/OperatorLogin.tsx +110 -0
- package/template/console/auth/PasswordRecovery.tsx +132 -0
- package/template/console/auth/account-route.ts +15 -0
- package/template/console/auth/browser-navigation.ts +7 -0
- package/template/console/auth/events.ts +15 -0
- package/template/console/command/CommandCentre.tsx +119 -0
- package/template/console/command/controller.ts +267 -0
- package/template/console/command/events.ts +19 -0
- package/template/console/command/store.ts +36 -0
- package/template/console/command/types.ts +61 -0
- package/template/console/components/AnalystIdentity.tsx +25 -0
- package/template/console/components/PageHeader.tsx +27 -0
- package/template/console/configuration/ConfigurationDialog.tsx +200 -0
- package/template/console/configuration/ConfigurationWorkspace.tsx +66 -0
- package/template/console/configuration/FormBuilder.tsx +143 -0
- package/template/console/configuration/api.ts +40 -0
- package/template/console/configuration/events.ts +14 -0
- package/template/console/configuration/types.ts +79 -0
- package/template/console/lib/format.ts +49 -0
- package/template/console/lib/http.ts +96 -0
- package/template/console/main.tsx +225 -0
- package/template/console/operations/OperationsWorkspace.tsx +126 -0
- package/template/console/operations/controller.ts +237 -0
- package/template/console/operations/events.ts +37 -0
- package/template/console/operations/store.ts +32 -0
- package/template/console/operations/types.ts +107 -0
- package/template/console/people/PeopleWorkspace.tsx +81 -0
- package/template/console/people/types.ts +25 -0
- package/template/console/profile/ProfileWorkspace.tsx +136 -0
- package/template/console/profile/events.ts +9 -0
- package/template/console/profile/types.ts +6 -0
- package/template/console/quality/QualityWorkspace.tsx +87 -0
- package/template/console/quality/controller.ts +154 -0
- package/template/console/quality/events.ts +34 -0
- package/template/console/quality/store.ts +36 -0
- package/template/console/quality/types.ts +70 -0
- package/template/console/queues/QueueEditor.tsx +173 -0
- package/template/console/queues/QueueWorkspace.tsx +109 -0
- package/template/console/queues/controller.ts +194 -0
- package/template/console/queues/events.ts +34 -0
- package/template/console/queues/store.ts +37 -0
- package/template/console/queues/types.ts +102 -0
- package/template/console/registry/RegistryDialog.tsx +198 -0
- package/template/console/registry/RegistryWorkspace.tsx +108 -0
- package/template/console/registry/events.ts +14 -0
- package/template/console/registry/types.ts +52 -0
- package/template/console/reports/ReportDrawer.tsx +159 -0
- package/template/console/reports/ReportWorkspace.tsx +90 -0
- package/template/console/reports/controller.ts +517 -0
- package/template/console/reports/events.ts +42 -0
- package/template/console/reports/store.ts +39 -0
- package/template/console/reports/types.ts +230 -0
- package/template/console/settings/BrandEditor.tsx +126 -0
- package/template/console/settings/ChannelDialog.tsx +241 -0
- package/template/console/settings/SettingsWorkspace.tsx +155 -0
- package/template/console/settings/events.ts +19 -0
- package/template/console/settings/handoff.ts +22 -0
- package/template/console/settings/types.ts +93 -0
- package/template/console/shell/WorkspaceShell.tsx +160 -0
- package/template/console/shell/controller.ts +182 -0
- package/template/console/shell/events.ts +25 -0
- package/template/console/shell/navigation.ts +61 -0
- package/template/console/shell/store.ts +56 -0
- package/template/console/shell/types.ts +86 -0
- package/template/console/workflows/CreateWorkflowDialog.tsx +87 -0
- package/template/console/workflows/WorkflowCanvas.tsx +42 -0
- package/template/console/workflows/WorkflowDialogs.tsx +6 -0
- package/template/console/workflows/WorkflowStudio.tsx +331 -0
- package/template/console/workflows/WorkflowWorkspace.tsx +82 -0
- package/template/console/workflows/events.ts +17 -0
- package/template/console/workflows/graph.ts +156 -0
- package/template/console/workflows/templates.ts +70 -0
- package/template/console/workflows/types.ts +201 -0
- package/template/gitignore.template +18 -0
- package/template/migrations/0001_reports_foundation.sql +444 -0
- package/template/migrations/0002_human_report_loop.sql +65 -0
- package/template/migrations/0003_delivery_reliability.sql +18 -0
- package/template/migrations/0004_public_intake.sql +14 -0
- package/template/migrations/0005_operations_visibility.sql +24 -0
- package/template/migrations/0006_ai_governance.sql +198 -0
- package/template/migrations/0007_ai_release_gates.sql +6 -0
- package/template/migrations/0008_retention_analytics_exports.sql +52 -0
- package/template/migrations/0009_retention_derived_copies.sql +14 -0
- package/template/migrations/0010_ai_quality_controls.sql +26 -0
- package/template/migrations/0011_analyst_presence.sql +28 -0
- package/template/migrations/0012_queue_policies.sql +87 -0
- package/template/migrations/0013_routing_agents.sql +63 -0
- package/template/migrations/0014_webhook_enrichments.sql +122 -0
- package/template/migrations/0015_queue_owned_ai.sql +55 -0
- package/template/migrations/0016_operator_accounts.sql +67 -0
- package/template/migrations/0017_operator_profiles_and_recovery.sql +33 -0
- package/template/migrations/0018_platform_configuration.sql +382 -0
- package/template/migrations/0019_workflow_authoring_runtime.sql +372 -0
- package/template/migrations/0020_tasks_findings_assistant_budgets.sql +427 -0
- package/template/migrations/0021_abuse_evidence_operations.sql +324 -0
- package/template/migrations/0022_workflow_dispatch_operations.sql +42 -0
- package/template/migrations/0023_component_connection_execution.sql +75 -0
- package/template/migrations/0024_access_runtime_integrity.sql +72 -0
- package/template/migrations/0025_installation_timezone.sql +11 -0
- package/template/migrations/0026_ai_and_egress_execution_controls.sql +49 -0
- package/template/migrations/0027_prompt_and_ai_registry.sql +37 -0
- package/template/migrations/0028_step_attempt_ai_provenance.sql +13 -0
- package/template/migrations/0029_evidence_fetch_transport.sql +5 -0
- package/template/migrations/0030_evidence_dlq_incidents.sql +45 -0
- package/template/migrations/0031_shadow_quality_integrity.sql +7 -0
- package/template/migrations/0032_action_delivery_outbox.sql +55 -0
- package/template/migrations/0033_configuration_and_assistant_drafts.sql +43 -0
- package/template/migrations/0034_installation_integrations.sql +31 -0
- package/template/migrations/0035_workspace_governance.sql +21 -0
- package/template/migrations/0036_published_routing_baseline.sql +15 -0
- package/template/migrations/0037_builtin_phishing_specialist.sql +71 -0
- package/template/migrations/0038_remove_deprecated_enrichment_runtime.sql +228 -0
- package/template/migrations/0039_secure_reporting_channels.sql +45 -0
- package/template/migrations/0040_notification_only_reporting.sql +24 -0
- package/template/migrations/0041_better_auth_credentials.sql +17 -0
- package/template/package.json +47 -0
- package/template/public/_headers +27 -0
- package/template/public/app-icon-192.png +0 -0
- package/template/public/app-icon-512.png +0 -0
- package/template/public/brand-icon.svg +7 -0
- package/template/public/brand-tokens.css +80 -0
- package/template/public/console/auth-shell.js +19340 -0
- package/template/public/customer-brand.js +58 -0
- package/template/public/embed/embed.css +139 -0
- package/template/public/embed/embed.js +408 -0
- package/template/public/embed/index.html +91 -0
- package/template/public/favicon.svg +7 -0
- package/template/public/fonts/Manrope-Variable.ttf +0 -0
- package/template/public/fonts/Newsreader-Italic-Variable.ttf +0 -0
- package/template/public/fonts/Newsreader-Variable.ttf +0 -0
- package/template/public/index.html +123 -0
- package/template/public/logo-primary.svg +7 -0
- package/template/public/logo-reversed.svg +7 -0
- package/template/public/manifest.webmanifest +21 -0
- package/template/public/public-report.js +184 -0
- package/template/public/report/index.html +39 -0
- package/template/public/report/public-report.css +20 -0
- package/template/public/social-card.png +0 -0
- package/template/public/styles.css +1521 -0
- package/template/public/widget.css +80 -0
- package/template/public/widget.js +220 -0
- package/template/reports.config.example.json +38 -0
- package/template/reports.schema.json +89 -0
- package/template/scripts/reports-auth-onboarding.mjs +180 -0
- package/template/scripts/reports-backup.mjs +276 -0
- package/template/scripts/reports-cloudflare-preflight.mjs +152 -0
- package/template/scripts/reports-deploy.mjs +173 -0
- package/template/scripts/reports-plan.mjs +226 -0
- package/template/scripts/reports-restore.mjs +180 -0
- package/template/scripts/reports-secrets.mjs +73 -0
- package/template/scripts/reports-uninstall-plan.mjs +32 -0
- package/template/src/agent-executor.ts +330 -0
- package/template/src/ai-observability.ts +163 -0
- package/template/src/ai-registry-validation.ts +244 -0
- package/template/src/ai-registry.ts +292 -0
- package/template/src/api-cursor.ts +84 -0
- package/template/src/assistant.ts +554 -0
- package/template/src/audit.ts +39 -0
- package/template/src/backup-service.ts +269 -0
- package/template/src/budget-control.ts +127 -0
- package/template/src/component-executor.ts +845 -0
- package/template/src/configuration-registry.ts +550 -0
- package/template/src/connection-egress.ts +430 -0
- package/template/src/connection-oauth.ts +368 -0
- package/template/src/evidence-service.ts +335 -0
- package/template/src/human-tasks.ts +316 -0
- package/template/src/index.ts +4114 -0
- package/template/src/installation-admin.ts +301 -0
- package/template/src/intake-abuse.ts +156 -0
- package/template/src/platform-registry-validation.ts +367 -0
- package/template/src/platform-registry.ts +753 -0
- package/template/src/report-admin.ts +342 -0
- package/template/src/report-ai-quality.ts +390 -0
- package/template/src/report-ai-validation.ts +80 -0
- package/template/src/report-ai.ts +857 -0
- package/template/src/report-auth.ts +292 -0
- package/template/src/report-better-auth.ts +583 -0
- package/template/src/report-context-schema.ts +132 -0
- package/template/src/report-context.ts +126 -0
- package/template/src/report-crypto.ts +142 -0
- package/template/src/report-delivery.ts +579 -0
- package/template/src/report-form-validation.ts +51 -0
- package/template/src/report-governance.ts +510 -0
- package/template/src/report-http.ts +90 -0
- package/template/src/report-operations.ts +685 -0
- package/template/src/report-operator-accounts.ts +810 -0
- package/template/src/report-presence.ts +398 -0
- package/template/src/report-queue-validation.ts +309 -0
- package/template/src/report-queues.ts +478 -0
- package/template/src/report-repository.ts +972 -0
- package/template/src/report-router-agent.ts +328 -0
- package/template/src/report-router-validation.ts +81 -0
- package/template/src/report-routing.ts +178 -0
- package/template/src/report-turnstile.ts +142 -0
- package/template/src/report-types.ts +224 -0
- package/template/src/report-validation.ts +279 -0
- package/template/src/report-workflow-validation.ts +119 -0
- package/template/src/report-workflow.ts +886 -0
- package/template/src/shadow-quality.ts +278 -0
- package/template/src/workflow-actions.ts +782 -0
- package/template/src/workflow-compiler.ts +230 -0
- package/template/src/workflow-dynamic-runtime.ts +613 -0
- package/template/src/workflow-effects.ts +316 -0
- package/template/src/workflow-expressions.ts +191 -0
- package/template/src/workflow-platform-types.ts +121 -0
- package/template/src/workflow-platform-validation.ts +540 -0
- package/template/src/workflow-repository.ts +916 -0
- package/template/src/workflow-runs.ts +686 -0
- package/template/src/workflow-simulator.ts +211 -0
- package/template/src/workspace-branding.ts +128 -0
- package/template/src/workspace-governance.ts +279 -0
- package/template/tsconfig.console.json +23 -0
- package/template/tsconfig.json +22 -0
- package/template/vite.console.config.ts +20 -0
- package/template/worker-configuration.d.ts +65 -0
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
import { canonicalJson } from "./audit";
|
|
2
|
+
import type { DeliveryJob } from "./report-types";
|
|
3
|
+
import { decryptReportContact } from "./report-crypto";
|
|
4
|
+
import { BudgetControlError, reserveBudgets, settleBudgets } from "./budget-control";
|
|
5
|
+
|
|
6
|
+
const MAX_ATTEMPTS = 8;
|
|
7
|
+
const RESPONSE_LIMIT = 16 * 1024;
|
|
8
|
+
const REQUEST_TIMEOUT_MS = 7_500;
|
|
9
|
+
const RETRY_DELAYS = [10, 30, 120, 600, 1_800, 3_600, 7_200, 21_600] as const;
|
|
10
|
+
|
|
11
|
+
interface ActionOutboxRow {
|
|
12
|
+
id: string;
|
|
13
|
+
report_id: string;
|
|
14
|
+
idempotency_key: string;
|
|
15
|
+
payload_json: string;
|
|
16
|
+
status: string;
|
|
17
|
+
attempt_count: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface NotificationOutboxRow {
|
|
21
|
+
id: string;
|
|
22
|
+
report_id: string;
|
|
23
|
+
notice_id: string;
|
|
24
|
+
idempotency_key: string;
|
|
25
|
+
payload_json: string;
|
|
26
|
+
status: string;
|
|
27
|
+
attempt_count: number;
|
|
28
|
+
audience: "reporter" | "affected_user";
|
|
29
|
+
recipient_reference: string | null;
|
|
30
|
+
contact_ciphertext: string | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface EmailDeliveryRow {
|
|
34
|
+
id: string;
|
|
35
|
+
reportId: string;
|
|
36
|
+
messageId: string;
|
|
37
|
+
audience: "reporter" | "affected_user";
|
|
38
|
+
state: string;
|
|
39
|
+
attemptCount: number;
|
|
40
|
+
recipientReference: string | null;
|
|
41
|
+
contactCiphertext: string | null;
|
|
42
|
+
body: string;
|
|
43
|
+
reportReference: string;
|
|
44
|
+
installationId: string;
|
|
45
|
+
queueId: string | null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class RetryableDeliveryError extends Error {
|
|
49
|
+
constructor(readonly delaySeconds: number) {
|
|
50
|
+
super("Delivery failed temporarily and was scheduled for retry.");
|
|
51
|
+
this.name = "RetryableDeliveryError";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class DeliveryConfigurationError extends Error {
|
|
56
|
+
constructor(message: string) {
|
|
57
|
+
super(message);
|
|
58
|
+
this.name = "DeliveryConfigurationError";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function now(): string {
|
|
63
|
+
return new Date().toISOString();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function plusSeconds(timestamp: string, seconds: number): string {
|
|
67
|
+
return new Date(Date.parse(timestamp) + seconds * 1_000).toISOString();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function delay(attempt: number): number {
|
|
71
|
+
return RETRY_DELAYS[Math.min(RETRY_DELAYS.length - 1, Math.max(0, attempt - 1))] ?? 21_600;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function webhookUrl(raw: string | undefined, variable: string): string {
|
|
75
|
+
try {
|
|
76
|
+
const url = new URL(raw ?? "");
|
|
77
|
+
if (url.protocol !== "https:" || url.username || url.password || url.hash) throw new Error("unsafe webhook URL");
|
|
78
|
+
return url.toString();
|
|
79
|
+
} catch {
|
|
80
|
+
throw new DeliveryConfigurationError(`${variable} is not configured as a safe HTTPS URL.`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function signature(secret: string, timestamp: string, payload: string): Promise<string> {
|
|
85
|
+
if (new TextEncoder().encode(secret).byteLength < 32) {
|
|
86
|
+
throw new DeliveryConfigurationError("Webhook signing secrets must contain at least 32 bytes.");
|
|
87
|
+
}
|
|
88
|
+
const key = await crypto.subtle.importKey(
|
|
89
|
+
"raw",
|
|
90
|
+
new TextEncoder().encode(secret),
|
|
91
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
92
|
+
false,
|
|
93
|
+
["sign"],
|
|
94
|
+
);
|
|
95
|
+
const digest = new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${timestamp}.${payload}`)));
|
|
96
|
+
return `v1=${[...digest].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function boundedResponse(response: Response): Promise<Record<string, unknown> | null> {
|
|
100
|
+
const declared = Number.parseInt(response.headers.get("content-length") ?? "0", 10);
|
|
101
|
+
if (declared > RESPONSE_LIMIT) {
|
|
102
|
+
await response.body?.cancel().catch(() => {});
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
if (!response.body) return {};
|
|
106
|
+
const reader = response.body.getReader();
|
|
107
|
+
const chunks: Uint8Array[] = [];
|
|
108
|
+
let length = 0;
|
|
109
|
+
while (true) {
|
|
110
|
+
const result = await reader.read();
|
|
111
|
+
if (result.done) break;
|
|
112
|
+
length += result.value.byteLength;
|
|
113
|
+
if (length > RESPONSE_LIMIT) {
|
|
114
|
+
await reader.cancel().catch(() => {});
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
chunks.push(result.value);
|
|
118
|
+
}
|
|
119
|
+
const bytes = new Uint8Array(length);
|
|
120
|
+
let offset = 0;
|
|
121
|
+
for (const chunk of chunks) {
|
|
122
|
+
bytes.set(chunk, offset);
|
|
123
|
+
offset += chunk.byteLength;
|
|
124
|
+
}
|
|
125
|
+
if (!bytes.length) return {};
|
|
126
|
+
try {
|
|
127
|
+
const value: unknown = JSON.parse(new TextDecoder().decode(bytes));
|
|
128
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
|
|
129
|
+
} catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function sendWebhook(input: {
|
|
135
|
+
url: string;
|
|
136
|
+
urlVariable?: string;
|
|
137
|
+
secret: string;
|
|
138
|
+
event: string;
|
|
139
|
+
idempotencyKey: string;
|
|
140
|
+
payload: string;
|
|
141
|
+
}, fetchImpl: typeof fetch = fetch): Promise<{ status: number; body: Record<string, unknown> | null }> {
|
|
142
|
+
const url = webhookUrl(input.url, input.urlVariable ?? "webhook URL");
|
|
143
|
+
const timestamp = Math.floor(Date.now() / 1_000).toString();
|
|
144
|
+
const controller = new AbortController();
|
|
145
|
+
const timeout = setTimeout(() => controller.abort("webhook_timeout"), REQUEST_TIMEOUT_MS);
|
|
146
|
+
try {
|
|
147
|
+
const response = await fetchImpl(url, {
|
|
148
|
+
method: "POST",
|
|
149
|
+
headers: {
|
|
150
|
+
"content-type": "application/json",
|
|
151
|
+
"user-agent": "Safest-Resolve/1",
|
|
152
|
+
"x-safest-event": input.event,
|
|
153
|
+
"x-safest-idempotency-key": input.idempotencyKey,
|
|
154
|
+
"x-safest-signature": await signature(input.secret, timestamp, input.payload),
|
|
155
|
+
"x-safest-timestamp": timestamp,
|
|
156
|
+
},
|
|
157
|
+
body: input.payload,
|
|
158
|
+
signal: controller.signal,
|
|
159
|
+
});
|
|
160
|
+
return { status: response.status, body: await boundedResponse(response) };
|
|
161
|
+
} finally {
|
|
162
|
+
clearTimeout(timeout);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function responseClass(status: number): "success" | "temporary_failure" | "permanent_failure" {
|
|
167
|
+
if (status >= 200 && status < 300) return "success";
|
|
168
|
+
if (status === 408 || status === 409 || status === 425 || status === 429 || status >= 500) return "temporary_failure";
|
|
169
|
+
return "permanent_failure";
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function claimAction(db: D1Database, id: string): Promise<ActionOutboxRow | null> {
|
|
173
|
+
const claimedAt = now();
|
|
174
|
+
const result = await db.prepare(`
|
|
175
|
+
UPDATE decision_action_outbox
|
|
176
|
+
SET status = 'delivering', attempt_count = attempt_count + 1,
|
|
177
|
+
lease_expires_at = ?2, updated_at = ?1
|
|
178
|
+
WHERE id = ?3 AND terminal = 0 AND attempt_count < ?4
|
|
179
|
+
AND (
|
|
180
|
+
status = 'pending'
|
|
181
|
+
OR (status = 'failed' AND next_attempt_at <= ?1)
|
|
182
|
+
OR (status = 'delivering' AND lease_expires_at <= ?1)
|
|
183
|
+
)
|
|
184
|
+
`).bind(claimedAt, plusSeconds(claimedAt, 60), id, MAX_ATTEMPTS).run();
|
|
185
|
+
if ((result.meta.changes ?? 0) < 1) return null;
|
|
186
|
+
return db.prepare(`
|
|
187
|
+
SELECT id, report_id, idempotency_key, payload_json, status, attempt_count
|
|
188
|
+
FROM decision_action_outbox WHERE id = ?1 LIMIT 1
|
|
189
|
+
`).bind(id).first<ActionOutboxRow>();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function claimNotification(db: D1Database, id: string): Promise<NotificationOutboxRow | null> {
|
|
193
|
+
const claimedAt = now();
|
|
194
|
+
const result = await db.prepare(`
|
|
195
|
+
UPDATE notification_outbox
|
|
196
|
+
SET status = 'delivering', attempt_count = attempt_count + 1,
|
|
197
|
+
lease_expires_at = ?2, updated_at = ?1
|
|
198
|
+
WHERE id = ?3 AND terminal = 0 AND attempt_count < ?4
|
|
199
|
+
AND (
|
|
200
|
+
status = 'pending'
|
|
201
|
+
OR (status = 'failed' AND next_attempt_at <= ?1)
|
|
202
|
+
OR (status = 'delivering' AND lease_expires_at <= ?1)
|
|
203
|
+
)
|
|
204
|
+
`).bind(claimedAt, plusSeconds(claimedAt, 60), id, MAX_ATTEMPTS).run();
|
|
205
|
+
if ((result.meta.changes ?? 0) < 1) return null;
|
|
206
|
+
return db.prepare(`
|
|
207
|
+
SELECT o.id, n.report_id, o.notice_id, o.idempotency_key, o.payload_json, o.status, o.attempt_count,
|
|
208
|
+
n.audience, p.reference AS recipient_reference, p.contact_ciphertext
|
|
209
|
+
FROM notification_outbox o JOIN notices n ON n.id = o.notice_id
|
|
210
|
+
LEFT JOIN report_participants p ON p.report_id = n.report_id AND p.audience = n.audience
|
|
211
|
+
WHERE o.id = ?1 LIMIT 1
|
|
212
|
+
`).bind(id).first<NotificationOutboxRow>();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function claimEmail(db: D1Database, id: string): Promise<EmailDeliveryRow | null> {
|
|
216
|
+
const claimedAt = now();
|
|
217
|
+
const staleAt = plusSeconds(claimedAt, -60);
|
|
218
|
+
const result = await db.prepare(`
|
|
219
|
+
UPDATE email_deliveries SET state = 'sending', attempt_count = attempt_count + 1,
|
|
220
|
+
updated_at = ?2 WHERE id = ?1 AND attempt_count < ?3
|
|
221
|
+
AND (state = 'pending' OR (state = 'failed' AND next_attempt_at <= ?2)
|
|
222
|
+
OR (state = 'sending' AND updated_at <= ?4))
|
|
223
|
+
`).bind(id, claimedAt, MAX_ATTEMPTS, staleAt).run();
|
|
224
|
+
if ((result.meta.changes ?? 0) < 1) return null;
|
|
225
|
+
return db.prepare(`
|
|
226
|
+
SELECT e.id, m.report_id AS reportId, e.message_id AS messageId,
|
|
227
|
+
e.audience, e.state, e.attempt_count AS attemptCount,
|
|
228
|
+
p.reference AS recipientReference, p.contact_ciphertext AS contactCiphertext,
|
|
229
|
+
m.body, r.public_reference AS reportReference,
|
|
230
|
+
r.installation_id AS installationId, r.queue_id AS queueId
|
|
231
|
+
FROM email_deliveries e
|
|
232
|
+
JOIN case_messages m ON m.id = e.message_id
|
|
233
|
+
JOIN reports r ON r.id = m.report_id
|
|
234
|
+
LEFT JOIN report_participants p ON p.report_id = m.report_id AND p.audience = e.audience
|
|
235
|
+
WHERE e.id = ?1 LIMIT 1
|
|
236
|
+
`).bind(id).first<EmailDeliveryRow>();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function deliveryAudit(
|
|
240
|
+
db: D1Database,
|
|
241
|
+
input: { key: string; action: string; targetType: string; targetId: string; reportId: string; details: unknown; createdAt: string },
|
|
242
|
+
): D1PreparedStatement {
|
|
243
|
+
return db.prepare(`
|
|
244
|
+
INSERT OR IGNORE INTO audit_events (
|
|
245
|
+
id, idempotency_key, action, actor_type, actor_id, target_type, target_id, details_json, created_at
|
|
246
|
+
) VALUES (?1, ?2, ?3, 'system', 'delivery-worker', ?4, ?5, ?6, ?7)
|
|
247
|
+
`).bind(
|
|
248
|
+
crypto.randomUUID(), input.key, input.action, input.targetType, input.targetId,
|
|
249
|
+
canonicalJson({ reportId: input.reportId, ...input.details as Record<string, unknown> }), input.createdAt,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function processAction(db: D1Database, outboxId: string, env: Env): Promise<void> {
|
|
254
|
+
const row = await claimAction(db, outboxId);
|
|
255
|
+
if (!row) return;
|
|
256
|
+
const startedAt = now();
|
|
257
|
+
let status: number | null = null;
|
|
258
|
+
let body: Record<string, unknown> | null = null;
|
|
259
|
+
let errorCode: string | null = null;
|
|
260
|
+
try {
|
|
261
|
+
const response = await sendWebhook({
|
|
262
|
+
url: env.ACTION_WEBHOOK_URL,
|
|
263
|
+
urlVariable: "ACTION_WEBHOOK_URL",
|
|
264
|
+
secret: env.ACTION_WEBHOOK_SECRET,
|
|
265
|
+
event: "application.action.requested",
|
|
266
|
+
idempotencyKey: row.idempotency_key,
|
|
267
|
+
payload: row.payload_json,
|
|
268
|
+
});
|
|
269
|
+
status = response.status;
|
|
270
|
+
body = response.body;
|
|
271
|
+
} catch (error) {
|
|
272
|
+
errorCode = error instanceof DeliveryConfigurationError
|
|
273
|
+
? "webhook_configuration_error"
|
|
274
|
+
: error instanceof DOMException && error.name === "AbortError" ? "webhook_timeout" : "webhook_network_error";
|
|
275
|
+
}
|
|
276
|
+
const finishedAt = now();
|
|
277
|
+
const attemptId = crypto.randomUUID();
|
|
278
|
+
if (status !== null && responseClass(status) === "success") {
|
|
279
|
+
const result = body?.status;
|
|
280
|
+
if (result === "accepted" || result === "already_applied" || result === "rejected") {
|
|
281
|
+
await db.batch([
|
|
282
|
+
db.prepare(`
|
|
283
|
+
UPDATE decision_action_outbox SET status = ?2, terminal = 1, lease_expires_at = NULL,
|
|
284
|
+
last_error_code = NULL, updated_at = ?3 WHERE id = ?1
|
|
285
|
+
`).bind(row.id, result, finishedAt),
|
|
286
|
+
db.prepare(`
|
|
287
|
+
INSERT INTO decision_action_attempts (
|
|
288
|
+
id, action_id, attempt_number, response_class, response_status, started_at, finished_at
|
|
289
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
|
290
|
+
`).bind(attemptId, row.id, row.attempt_count, result, status, startedAt, finishedAt),
|
|
291
|
+
deliveryAudit(db, {
|
|
292
|
+
key: `action-delivery:${row.id}:${result}`,
|
|
293
|
+
action: "application_action.delivery_completed",
|
|
294
|
+
targetType: "application_action",
|
|
295
|
+
targetId: row.id,
|
|
296
|
+
reportId: row.report_id,
|
|
297
|
+
details: { result, responseStatus: status, attemptCount: row.attempt_count },
|
|
298
|
+
createdAt: finishedAt,
|
|
299
|
+
}),
|
|
300
|
+
]);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
errorCode = "invalid_action_response";
|
|
304
|
+
} else if (status !== null) {
|
|
305
|
+
errorCode = `webhook_http_${status}`;
|
|
306
|
+
}
|
|
307
|
+
const configurationFailure = errorCode === "webhook_configuration_error";
|
|
308
|
+
const temporary = !configurationFailure
|
|
309
|
+
&& (status === null || responseClass(status) === "temporary_failure" || errorCode === "invalid_action_response");
|
|
310
|
+
const terminal = !temporary || row.attempt_count >= MAX_ATTEMPTS;
|
|
311
|
+
const retryDelay = delay(row.attempt_count);
|
|
312
|
+
const statements = [
|
|
313
|
+
db.prepare(`
|
|
314
|
+
UPDATE decision_action_outbox
|
|
315
|
+
SET status = ?2, terminal = ?3, next_attempt_at = ?4, lease_expires_at = NULL,
|
|
316
|
+
last_error_code = ?5, updated_at = ?6
|
|
317
|
+
WHERE id = ?1
|
|
318
|
+
`).bind(
|
|
319
|
+
row.id,
|
|
320
|
+
terminal && status !== null && responseClass(status) === "permanent_failure" ? "rejected" : "failed",
|
|
321
|
+
terminal ? 1 : 0,
|
|
322
|
+
plusSeconds(finishedAt, retryDelay), errorCode, finishedAt,
|
|
323
|
+
),
|
|
324
|
+
db.prepare(`
|
|
325
|
+
INSERT INTO decision_action_attempts (
|
|
326
|
+
id, action_id, attempt_number, response_class, response_status, error_code, started_at, finished_at
|
|
327
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
|
328
|
+
`).bind(attemptId, row.id, row.attempt_count, terminal ? "permanent_failure" : "temporary_failure", status, errorCode, startedAt, finishedAt),
|
|
329
|
+
];
|
|
330
|
+
if (terminal) statements.push(deliveryAudit(db, {
|
|
331
|
+
key: `action-delivery:${row.id}:failed`,
|
|
332
|
+
action: "application_action.delivery_failed",
|
|
333
|
+
targetType: "application_action",
|
|
334
|
+
targetId: row.id,
|
|
335
|
+
reportId: row.report_id,
|
|
336
|
+
details: { errorCode, responseStatus: status, attemptCount: row.attempt_count },
|
|
337
|
+
createdAt: finishedAt,
|
|
338
|
+
}));
|
|
339
|
+
await db.batch(statements);
|
|
340
|
+
if (!terminal) throw new RetryableDeliveryError(retryDelay);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function updateNoticeDelivery(db: D1Database, noticeId: string, state: "delivered" | "failed", timestamp: string): Promise<void> {
|
|
344
|
+
await db.batch([
|
|
345
|
+
db.prepare(`UPDATE notices SET delivery_state = ?2, updated_at = ?3 WHERE id = ?1`).bind(noticeId, state, timestamp),
|
|
346
|
+
db.prepare(`
|
|
347
|
+
UPDATE case_messages SET delivery_state = ?2
|
|
348
|
+
WHERE id = (SELECT message_id FROM notices WHERE id = ?1)
|
|
349
|
+
`).bind(noticeId, state),
|
|
350
|
+
]);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function processNotification(db: D1Database, outboxId: string, env: Env): Promise<void> {
|
|
354
|
+
const row = await claimNotification(db, outboxId);
|
|
355
|
+
if (!row) return;
|
|
356
|
+
const startedAt = now();
|
|
357
|
+
let status: number | null = null;
|
|
358
|
+
let errorCode: string | null = null;
|
|
359
|
+
try {
|
|
360
|
+
let payload: Record<string, unknown>;
|
|
361
|
+
try {
|
|
362
|
+
const parsed: unknown = JSON.parse(row.payload_json);
|
|
363
|
+
payload = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, unknown> : {};
|
|
364
|
+
} catch {
|
|
365
|
+
payload = {};
|
|
366
|
+
}
|
|
367
|
+
const recipientEmail = row.contact_ciphertext
|
|
368
|
+
? (await decryptReportContact(env.CREDENTIAL_ENCRYPTION_KEY, row.contact_ciphertext, row.report_id, row.audience)).trim().toLowerCase()
|
|
369
|
+
: null;
|
|
370
|
+
const deliveryPayload = canonicalJson({
|
|
371
|
+
...payload,
|
|
372
|
+
...(row.recipient_reference ? { recipient_reference: row.recipient_reference } : {}),
|
|
373
|
+
...(recipientEmail ? { recipient_email: recipientEmail } : {}),
|
|
374
|
+
});
|
|
375
|
+
const response = await sendWebhook({
|
|
376
|
+
url: env.NOTIFICATION_WEBHOOK_URL,
|
|
377
|
+
urlVariable: "NOTIFICATION_WEBHOOK_URL",
|
|
378
|
+
secret: env.NOTIFICATION_WEBHOOK_SECRET,
|
|
379
|
+
event: "participant.notification.requested",
|
|
380
|
+
idempotencyKey: row.idempotency_key,
|
|
381
|
+
payload: deliveryPayload,
|
|
382
|
+
});
|
|
383
|
+
status = response.status;
|
|
384
|
+
} catch (error) {
|
|
385
|
+
errorCode = error instanceof DeliveryConfigurationError
|
|
386
|
+
? "webhook_configuration_error"
|
|
387
|
+
: error instanceof DOMException && error.name === "AbortError" ? "webhook_timeout" : "webhook_network_error";
|
|
388
|
+
}
|
|
389
|
+
const finishedAt = now();
|
|
390
|
+
const attemptId = crypto.randomUUID();
|
|
391
|
+
if (status !== null && responseClass(status) === "success") {
|
|
392
|
+
await db.batch([
|
|
393
|
+
db.prepare(`
|
|
394
|
+
UPDATE notification_outbox SET status = 'delivered', terminal = 1,
|
|
395
|
+
lease_expires_at = NULL, last_error_code = NULL, updated_at = ?2 WHERE id = ?1
|
|
396
|
+
`).bind(row.id, finishedAt),
|
|
397
|
+
db.prepare(`
|
|
398
|
+
INSERT INTO notification_attempts (
|
|
399
|
+
id, notification_id, attempt_number, response_class, response_status, started_at, finished_at
|
|
400
|
+
) VALUES (?1, ?2, ?3, 'delivered', ?4, ?5, ?6)
|
|
401
|
+
`).bind(attemptId, row.id, row.attempt_count, status, startedAt, finishedAt),
|
|
402
|
+
deliveryAudit(db, {
|
|
403
|
+
key: `notification-delivery:${row.id}:delivered`,
|
|
404
|
+
action: "notification.delivered",
|
|
405
|
+
targetType: "notification",
|
|
406
|
+
targetId: row.id,
|
|
407
|
+
reportId: row.report_id,
|
|
408
|
+
details: { responseStatus: status, attemptCount: row.attempt_count },
|
|
409
|
+
createdAt: finishedAt,
|
|
410
|
+
}),
|
|
411
|
+
]);
|
|
412
|
+
await updateNoticeDelivery(db, row.notice_id, "delivered", finishedAt);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
if (status !== null) errorCode = `webhook_http_${status}`;
|
|
416
|
+
const temporary = errorCode !== "webhook_configuration_error"
|
|
417
|
+
&& (status === null || responseClass(status) === "temporary_failure");
|
|
418
|
+
const terminal = !temporary || row.attempt_count >= MAX_ATTEMPTS;
|
|
419
|
+
const retryDelay = delay(row.attempt_count);
|
|
420
|
+
const statements = [
|
|
421
|
+
db.prepare(`
|
|
422
|
+
UPDATE notification_outbox SET status = 'failed', terminal = ?2,
|
|
423
|
+
next_attempt_at = ?3, lease_expires_at = NULL, last_error_code = ?4, updated_at = ?5
|
|
424
|
+
WHERE id = ?1
|
|
425
|
+
`).bind(row.id, terminal ? 1 : 0, plusSeconds(finishedAt, retryDelay), errorCode, finishedAt),
|
|
426
|
+
db.prepare(`
|
|
427
|
+
INSERT INTO notification_attempts (
|
|
428
|
+
id, notification_id, attempt_number, response_class, response_status, error_code, started_at, finished_at
|
|
429
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
|
430
|
+
`).bind(attemptId, row.id, row.attempt_count, terminal ? "permanent_failure" : "temporary_failure", status, errorCode, startedAt, finishedAt),
|
|
431
|
+
];
|
|
432
|
+
if (terminal) statements.push(deliveryAudit(db, {
|
|
433
|
+
key: `notification-delivery:${row.id}:failed`,
|
|
434
|
+
action: "notification.delivery_failed",
|
|
435
|
+
targetType: "notification",
|
|
436
|
+
targetId: row.id,
|
|
437
|
+
reportId: row.report_id,
|
|
438
|
+
details: { errorCode, responseStatus: status, attemptCount: row.attempt_count },
|
|
439
|
+
createdAt: finishedAt,
|
|
440
|
+
}));
|
|
441
|
+
await db.batch(statements);
|
|
442
|
+
if (terminal) await updateNoticeDelivery(db, row.notice_id, "failed", finishedAt);
|
|
443
|
+
if (!terminal) throw new RetryableDeliveryError(retryDelay);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async function processEmail(db: D1Database, deliveryId: string, env: Env): Promise<void> {
|
|
447
|
+
const row = await claimEmail(db, deliveryId);
|
|
448
|
+
if (!row) return;
|
|
449
|
+
const email = row.contactCiphertext
|
|
450
|
+
? (await decryptReportContact(env.CREDENTIAL_ENCRYPTION_KEY, row.contactCiphertext, row.reportId, row.audience)).trim().toLowerCase()
|
|
451
|
+
: row.recipientReference?.trim().toLowerCase() ?? "";
|
|
452
|
+
const from = env.EMAIL_FROM_ADDRESS?.trim().toLowerCase() ?? "";
|
|
453
|
+
const binding = (env as unknown as { EMAIL?: { send(message: Record<string, unknown>): Promise<{ messageId?: string }> } }).EMAIL;
|
|
454
|
+
let reservations;
|
|
455
|
+
try {
|
|
456
|
+
reservations = await reserveBudgets(db, {
|
|
457
|
+
installationId: row.installationId,
|
|
458
|
+
scopes: [...(row.queueId ? [{ type: "queue", id: row.queueId }] : [])],
|
|
459
|
+
metric: "emails", amount: 1, operationType: "email_delivery",
|
|
460
|
+
operationId: row.id, attemptId: String(row.attemptCount),
|
|
461
|
+
});
|
|
462
|
+
} catch (error) {
|
|
463
|
+
if (!(error instanceof BudgetControlError)) throw error;
|
|
464
|
+
const timestamp = now();
|
|
465
|
+
if (error.retryable) {
|
|
466
|
+
await db.prepare(`UPDATE email_deliveries SET state = 'failed', last_error_code = 'email_budget_deferred', next_attempt_at = ?2, updated_at = ?3 WHERE id = ?1`)
|
|
467
|
+
.bind(row.id, plusSeconds(timestamp, error.delaySeconds), timestamp).run();
|
|
468
|
+
throw new RetryableDeliveryError(error.delaySeconds);
|
|
469
|
+
}
|
|
470
|
+
await db.batch([
|
|
471
|
+
db.prepare(`UPDATE email_deliveries SET state = 'suppressed', last_error_code = 'email_budget_exhausted', next_attempt_at = NULL, updated_at = ?2 WHERE id = ?1`).bind(row.id, timestamp),
|
|
472
|
+
deliveryAudit(db, {
|
|
473
|
+
key: `email-delivery:${row.id}:budget-suppressed`, action: "email.budget_exhausted",
|
|
474
|
+
targetType: "email_delivery", targetId: row.id, reportId: row.reportId,
|
|
475
|
+
details: { fallback: "customer_webhook", fallbackReference: error.fallbackReference }, createdAt: timestamp,
|
|
476
|
+
}),
|
|
477
|
+
]);
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
let providerMessageId: string | null = null;
|
|
481
|
+
let errorCode: string | null = null;
|
|
482
|
+
try {
|
|
483
|
+
if (!binding) throw new DeliveryConfigurationError("The Cloudflare Email Service binding is unavailable.");
|
|
484
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(email) || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(from)) {
|
|
485
|
+
throw new DeliveryConfigurationError("Email sender or recipient configuration is invalid.");
|
|
486
|
+
}
|
|
487
|
+
const prefix = env.EMAIL_SUBJECT_PREFIX?.trim().slice(0, 120) || "Safest report update";
|
|
488
|
+
const result = await binding.send({
|
|
489
|
+
to: email,
|
|
490
|
+
from,
|
|
491
|
+
subject: `${prefix} — ${row.reportReference}`,
|
|
492
|
+
text: `${row.body}\n\nReport reference: ${row.reportReference}`,
|
|
493
|
+
});
|
|
494
|
+
providerMessageId = typeof result.messageId === "string" ? result.messageId.slice(0, 300) : null;
|
|
495
|
+
} catch (error) {
|
|
496
|
+
errorCode = error instanceof DeliveryConfigurationError
|
|
497
|
+
? "email_configuration_error"
|
|
498
|
+
: typeof error === "object" && error !== null && "code" in error && typeof error.code === "string"
|
|
499
|
+
? `email_${error.code}`.slice(0, 100) : "email_provider_error";
|
|
500
|
+
}
|
|
501
|
+
const finishedAt = now();
|
|
502
|
+
if (!errorCode) {
|
|
503
|
+
await settleBudgets(db, reservations, true);
|
|
504
|
+
await db.batch([
|
|
505
|
+
db.prepare(`UPDATE email_deliveries SET state = 'delivered', provider_message_id = ?2, last_error_code = NULL, next_attempt_at = NULL, delivered_at = ?3, updated_at = ?3 WHERE id = ?1`)
|
|
506
|
+
.bind(row.id, providerMessageId, finishedAt),
|
|
507
|
+
db.prepare(`UPDATE case_messages SET delivery_state = 'delivered' WHERE id = ?1`).bind(row.messageId),
|
|
508
|
+
db.prepare(`UPDATE notices SET delivery_state = 'delivered', updated_at = ?2 WHERE message_id = ?1`).bind(row.messageId, finishedAt),
|
|
509
|
+
deliveryAudit(db, {
|
|
510
|
+
key: `email-delivery:${row.id}:delivered`, action: "email.delivered",
|
|
511
|
+
targetType: "email_delivery", targetId: row.id, reportId: row.reportId,
|
|
512
|
+
details: { provider: "cloudflare_email_service", attemptCount: row.attemptCount }, createdAt: finishedAt,
|
|
513
|
+
}),
|
|
514
|
+
]);
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
const terminal = errorCode === "email_configuration_error" || row.attemptCount >= MAX_ATTEMPTS;
|
|
518
|
+
await settleBudgets(db, reservations, errorCode !== "email_configuration_error");
|
|
519
|
+
const retryDelay = delay(row.attemptCount);
|
|
520
|
+
const statements: D1PreparedStatement[] = [
|
|
521
|
+
db.prepare(`UPDATE email_deliveries SET state = 'failed', last_error_code = ?2, next_attempt_at = ?3, updated_at = ?4 WHERE id = ?1`)
|
|
522
|
+
.bind(row.id, errorCode, plusSeconds(finishedAt, retryDelay), finishedAt),
|
|
523
|
+
];
|
|
524
|
+
if (terminal) {
|
|
525
|
+
statements.push(
|
|
526
|
+
db.prepare(`UPDATE case_messages SET delivery_state = 'failed' WHERE id = ?1 AND delivery_state != 'delivered'`).bind(row.messageId),
|
|
527
|
+
db.prepare(`UPDATE notices SET delivery_state = 'failed', updated_at = ?2 WHERE message_id = ?1 AND delivery_state != 'delivered'`).bind(row.messageId, finishedAt),
|
|
528
|
+
deliveryAudit(db, {
|
|
529
|
+
key: `email-delivery:${row.id}:failed`, action: "email.delivery_failed",
|
|
530
|
+
targetType: "email_delivery", targetId: row.id, reportId: row.reportId,
|
|
531
|
+
details: { errorCode, attemptCount: row.attemptCount }, createdAt: finishedAt,
|
|
532
|
+
}),
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
await db.batch(statements);
|
|
536
|
+
if (!terminal) throw new RetryableDeliveryError(retryDelay);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
export async function processDeliveryJob(db: D1Database, job: DeliveryJob, env: Env): Promise<void> {
|
|
540
|
+
if (job.type === "deliver_action") return processAction(db, job.outboxId, env);
|
|
541
|
+
if (job.type === "deliver_notification") return processNotification(db, job.outboxId, env);
|
|
542
|
+
return processEmail(db, job.outboxId, env);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
export async function pendingDeliveryJobs(db: D1Database, limit = 50): Promise<DeliveryJob[]> {
|
|
546
|
+
const at = now();
|
|
547
|
+
const boundedLimit = Math.max(1, Math.min(100, limit));
|
|
548
|
+
const [actions, notifications, emails] = await Promise.all([
|
|
549
|
+
db.prepare(`
|
|
550
|
+
SELECT id FROM decision_action_outbox
|
|
551
|
+
WHERE terminal = 0 AND attempt_count < ?2 AND (
|
|
552
|
+
(status IN ('pending', 'failed') AND next_attempt_at <= ?1)
|
|
553
|
+
OR (status = 'delivering' AND lease_expires_at <= ?1)
|
|
554
|
+
)
|
|
555
|
+
ORDER BY next_attempt_at, id LIMIT ?3
|
|
556
|
+
`).bind(at, MAX_ATTEMPTS, boundedLimit).all<{ id: string }>(),
|
|
557
|
+
db.prepare(`
|
|
558
|
+
SELECT id FROM notification_outbox
|
|
559
|
+
WHERE terminal = 0 AND attempt_count < ?2 AND (
|
|
560
|
+
(status IN ('pending', 'failed') AND next_attempt_at <= ?1)
|
|
561
|
+
OR (status = 'delivering' AND lease_expires_at <= ?1)
|
|
562
|
+
)
|
|
563
|
+
ORDER BY next_attempt_at, id LIMIT ?3
|
|
564
|
+
`).bind(at, MAX_ATTEMPTS, boundedLimit).all<{ id: string }>(),
|
|
565
|
+
db.prepare(`
|
|
566
|
+
SELECT id FROM email_deliveries
|
|
567
|
+
WHERE attempt_count < ?2 AND (
|
|
568
|
+
(state IN ('pending', 'failed') AND COALESCE(next_attempt_at, created_at) <= ?1)
|
|
569
|
+
OR (state = 'sending' AND updated_at <= ?3)
|
|
570
|
+
)
|
|
571
|
+
ORDER BY COALESCE(next_attempt_at, created_at), id LIMIT ?4
|
|
572
|
+
`).bind(at, MAX_ATTEMPTS, plusSeconds(at, -60), boundedLimit).all<{ id: string }>(),
|
|
573
|
+
]);
|
|
574
|
+
return [
|
|
575
|
+
...actions.results.map((row): DeliveryJob => ({ version: 1, jobId: `action:${row.id}`, type: "deliver_action", outboxId: row.id })),
|
|
576
|
+
...notifications.results.map((row): DeliveryJob => ({ version: 1, jobId: `notification:${row.id}`, type: "deliver_notification", outboxId: row.id })),
|
|
577
|
+
...emails.results.map((row): DeliveryJob => ({ version: 1, jobId: `email:${row.id}`, type: "deliver_email", outboxId: row.id })),
|
|
578
|
+
].slice(0, boundedLimit);
|
|
579
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { ApiError } from "./report-http";
|
|
2
|
+
|
|
3
|
+
export function validateAnswersAgainstFormFields(
|
|
4
|
+
answers: Record<string, string | boolean | number | string[]>,
|
|
5
|
+
definitionsValue: unknown[],
|
|
6
|
+
): void {
|
|
7
|
+
const definitions = definitionsValue
|
|
8
|
+
.filter((value): value is Record<string, unknown> => Boolean(value && typeof value === "object" && !Array.isArray(value)))
|
|
9
|
+
.filter((value) => typeof value.key === "string");
|
|
10
|
+
const byKey = new Map(definitions.map((field) => [String(field.key), field]));
|
|
11
|
+
const unsupported = Object.keys(answers).filter((key) => !byKey.has(key));
|
|
12
|
+
if (unsupported.length) {
|
|
13
|
+
throw new ApiError(400, "answer_field_not_allowed", "The report contains answers that are not part of this form.", { fields: unsupported.slice(0, 10) });
|
|
14
|
+
}
|
|
15
|
+
for (const field of definitions) {
|
|
16
|
+
const key = String(field.key);
|
|
17
|
+
const value = answers[key];
|
|
18
|
+
const missing = value === undefined || value === null || value === "" || Array.isArray(value) && value.length === 0;
|
|
19
|
+
if (field.required === true && missing) {
|
|
20
|
+
throw new ApiError(400, "required_answer_missing", `The ${key} answer is required.`, { field: `answers.${key}` });
|
|
21
|
+
}
|
|
22
|
+
if (missing) continue;
|
|
23
|
+
const type = String(field.type);
|
|
24
|
+
const answerField = `answers.${key}`;
|
|
25
|
+
if (type === "short_text" || type === "long_text" || type === "url") {
|
|
26
|
+
if (typeof value !== "string") throw new ApiError(400, "answer_type_invalid", `${answerField} must be text.`, { field: answerField });
|
|
27
|
+
const defaultMaximum = type === "short_text" ? 512 : type === "url" ? 2_048 : 4_000;
|
|
28
|
+
const maximum = typeof field.max_length === "number" ? Math.min(defaultMaximum, field.max_length) : defaultMaximum;
|
|
29
|
+
if (value.length > maximum) throw new ApiError(400, "answer_length_invalid", `${answerField} exceeds its ${maximum}-character limit.`, { field: answerField, maximum });
|
|
30
|
+
if (type === "url") {
|
|
31
|
+
try {
|
|
32
|
+
const url = new URL(value);
|
|
33
|
+
const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
|
34
|
+
if ((url.protocol !== "https:" && !(local && url.protocol === "http:")) || url.username || url.password) throw new Error("unsafe URL");
|
|
35
|
+
} catch { throw new ApiError(400, "answer_url_invalid", `${answerField} must be a valid HTTPS URL.`, { field: answerField }); }
|
|
36
|
+
}
|
|
37
|
+
} else if (type === "number") {
|
|
38
|
+
if (typeof value !== "number" || !Number.isFinite(value)) throw new ApiError(400, "answer_type_invalid", `${answerField} must be a number.`, { field: answerField });
|
|
39
|
+
if (typeof field.min_value === "number" && value < field.min_value) throw new ApiError(400, "answer_range_invalid", `${answerField} is below its minimum.`, { field: answerField, minimum: field.min_value });
|
|
40
|
+
if (typeof field.max_value === "number" && value > field.max_value) throw new ApiError(400, "answer_range_invalid", `${answerField} is above its maximum.`, { field: answerField, maximum: field.max_value });
|
|
41
|
+
} else if (type === "boolean") {
|
|
42
|
+
if (typeof value !== "boolean") throw new ApiError(400, "answer_type_invalid", `${answerField} must be yes or no.`, { field: answerField });
|
|
43
|
+
} else if (type === "select" || type === "multi_select") {
|
|
44
|
+
const allowed = new Set(Array.isArray(field.options) ? field.options.map((option) => option && typeof option === "object" && !Array.isArray(option) ? (option as Record<string, unknown>).value : null).filter((option): option is string => typeof option === "string") : []);
|
|
45
|
+
if (type === "select" && (typeof value !== "string" || !allowed.has(value))) throw new ApiError(400, "answer_choice_invalid", `${answerField} contains an unavailable choice.`, { field: answerField });
|
|
46
|
+
if (type === "multi_select" && (!Array.isArray(value) || value.length > 20 || value.some((choice) => !allowed.has(choice)))) throw new ApiError(400, "answer_choice_invalid", `${answerField} contains unavailable choices.`, { field: answerField });
|
|
47
|
+
} else {
|
|
48
|
+
throw new ApiError(400, "form_field_type_invalid", "The published form contains an unsupported field type.", { field: answerField });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|