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,335 @@
|
|
|
1
|
+
import { canonicalJson } from "./audit";
|
|
2
|
+
import { ConnectionExecutionError, executeConnectionJson } from "./connection-egress";
|
|
3
|
+
import { sha256 } from "./report-crypto";
|
|
4
|
+
import { ApiError } from "./report-http";
|
|
5
|
+
import type { EvidenceFetchJob, OperatorSession } from "./report-types";
|
|
6
|
+
import { BudgetControlError, reserveBudgets, settleBudgets } from "./budget-control";
|
|
7
|
+
|
|
8
|
+
interface FetchRequestRow {
|
|
9
|
+
id: string;
|
|
10
|
+
installationId: string;
|
|
11
|
+
reportId: string;
|
|
12
|
+
evidenceReferenceId: string;
|
|
13
|
+
connectionVersionId: string;
|
|
14
|
+
requestedFieldsJson: string;
|
|
15
|
+
state: string;
|
|
16
|
+
operationId: string;
|
|
17
|
+
attemptCount: number;
|
|
18
|
+
requestMethod: string;
|
|
19
|
+
requestPath: string;
|
|
20
|
+
reference: string;
|
|
21
|
+
queueId: string | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class RetryableEvidenceError extends Error {
|
|
25
|
+
constructor(readonly delaySeconds: number) { super("evidence_fetch_retryable"); this.name = "RetryableEvidenceError"; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function now(): string { return new Date().toISOString(); }
|
|
29
|
+
|
|
30
|
+
function fields(value: unknown): string[] {
|
|
31
|
+
if (!Array.isArray(value) || value.length < 1 || value.length > 50) throw new ApiError(400, "evidence_fields_invalid", "requested_fields must contain 1 to 50 fields.");
|
|
32
|
+
const result = value.map((entry) => typeof entry === "string" ? entry.trim() : "");
|
|
33
|
+
if (result.some((entry) => !/^[A-Za-z][A-Za-z0-9_.-]{0,99}$/u.test(entry)) || new Set(result).size !== result.length) throw new ApiError(400, "evidence_fields_invalid", "requested_fields contains invalid or duplicate entries.");
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function method(value: unknown): string {
|
|
38
|
+
const result = typeof value === "string" ? value.toUpperCase() : "POST";
|
|
39
|
+
if (!["GET", "POST"].includes(result)) throw new ApiError(400, "evidence_method_invalid", "Evidence fetches support GET or POST.");
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function path(value: unknown): string {
|
|
44
|
+
if (typeof value !== "string" || !value.startsWith("/") || value.length > 2_048 || value.includes("..") || value.includes("?") || value.includes("#")) throw new ApiError(400, "evidence_path_invalid", "request_path must be a safe absolute path.");
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function listReportEvidence(db: D1Database, reportId: string): Promise<Record<string, unknown>> {
|
|
49
|
+
const [references, snapshots, requests] = await Promise.all([
|
|
50
|
+
db.prepare(`SELECT id, evidence_kind AS kind, reference, availability, retention_class AS retentionClass, created_at AS createdAt FROM evidence_references WHERE report_id = ?1 ORDER BY created_at, id`).bind(reportId).all<Record<string, unknown>>(),
|
|
51
|
+
db.prepare(`SELECT id, evidence_reference_id AS evidenceReferenceId, content_type AS contentType, size_bytes AS sizeBytes, digest, retention_class AS retentionClass, source_observed_at AS sourceObservedAt, created_at AS createdAt, deleted_at AS deletedAt FROM evidence_snapshots WHERE report_id = ?1 ORDER BY created_at DESC, id DESC`).bind(reportId).all<Record<string, unknown>>(),
|
|
52
|
+
db.prepare(`SELECT id, evidence_reference_id AS evidenceReferenceId, connection_version_id AS connectionVersionId, requested_fields_json AS requestedFieldsJson, state, attempt_count AS attemptCount, last_error_code AS lastErrorCode, created_at AS createdAt, updated_at AS updatedAt FROM evidence_fetch_requests WHERE report_id = ?1 ORDER BY created_at DESC, id DESC`).bind(reportId).all<Record<string, unknown>>(),
|
|
53
|
+
]);
|
|
54
|
+
return {
|
|
55
|
+
references: references.results,
|
|
56
|
+
snapshots: snapshots.results,
|
|
57
|
+
fetchRequests: requests.results.map((row) => ({ ...row, requestedFields: JSON.parse(String(row.requestedFieldsJson ?? "[]")) as unknown, requestedFieldsJson: undefined })),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function requestEvidenceFetch(
|
|
62
|
+
db: D1Database,
|
|
63
|
+
reportId: string,
|
|
64
|
+
value: Record<string, unknown>,
|
|
65
|
+
session: OperatorSession,
|
|
66
|
+
idempotencyKey: string,
|
|
67
|
+
): Promise<{ requestId: string; job: EvidenceFetchJob; idempotentReplay: boolean }> {
|
|
68
|
+
const evidenceReferenceId = typeof value.evidence_reference_id === "string" ? value.evidence_reference_id : "";
|
|
69
|
+
const connectionVersionId = typeof value.connection_version_id === "string" ? value.connection_version_id : "";
|
|
70
|
+
if (!evidenceReferenceId || !connectionVersionId) throw new ApiError(400, "evidence_fetch_invalid", "evidence_reference_id and connection_version_id are required.");
|
|
71
|
+
const requestedFields = fields(value.requested_fields);
|
|
72
|
+
const requestMethod = method(value.method);
|
|
73
|
+
const requestPath = path(value.request_path);
|
|
74
|
+
const freshness = value.freshness_requirement_seconds === null || value.freshness_requirement_seconds === undefined
|
|
75
|
+
? null : Number(value.freshness_requirement_seconds);
|
|
76
|
+
if (freshness !== null && (!Number.isInteger(freshness) || freshness < 0 || freshness > 31_536_000)) throw new ApiError(400, "evidence_freshness_invalid", "freshness_requirement_seconds is invalid.");
|
|
77
|
+
const operationId = `evidence-fetch:${idempotencyKey}`;
|
|
78
|
+
const existing = await db.prepare(`SELECT id FROM evidence_fetch_requests WHERE operation_id = ?1 LIMIT 1`).bind(operationId).first<{ id: string }>();
|
|
79
|
+
if (existing) return { requestId: existing.id, job: { version: 1, jobId: `evidence:${existing.id}`, type: "fetch_evidence", reportId, requestId: existing.id }, idempotentReplay: true };
|
|
80
|
+
const [reference, connection] = await Promise.all([
|
|
81
|
+
db.prepare(`SELECT id FROM evidence_references WHERE id = ?1 AND report_id = ?2 AND availability = 'available' LIMIT 1`).bind(evidenceReferenceId, reportId).first(),
|
|
82
|
+
db.prepare(`SELECT v.id FROM connection_versions v JOIN connection_definitions d ON d.id = v.connection_id WHERE v.id = ?1 AND v.retired_at IS NULL AND d.status = 'active' LIMIT 1`).bind(connectionVersionId).first(),
|
|
83
|
+
]);
|
|
84
|
+
if (!reference) throw new ApiError(404, "evidence_reference_not_found", "The evidence reference is not available for this report.");
|
|
85
|
+
if (!connection) throw new ApiError(409, "evidence_connection_unavailable", "The evidence connection version is not active.");
|
|
86
|
+
const requestId = crypto.randomUUID();
|
|
87
|
+
const timestamp = now();
|
|
88
|
+
await db.batch([
|
|
89
|
+
db.prepare(`
|
|
90
|
+
INSERT INTO evidence_fetch_requests (
|
|
91
|
+
id, installation_id, report_id, evidence_reference_id,
|
|
92
|
+
connection_version_id, requested_fields_json, freshness_requirement_seconds,
|
|
93
|
+
state, operation_id, next_attempt_at, attempt_count,
|
|
94
|
+
request_method, request_path, created_at, updated_at
|
|
95
|
+
) VALUES (?1, 'default', ?2, ?3, ?4, ?5, ?6, 'pending', ?7, ?8, 0, ?9, ?10, ?8, ?8)
|
|
96
|
+
`).bind(requestId, reportId, evidenceReferenceId, connectionVersionId, canonicalJson(requestedFields), freshness, operationId, timestamp, requestMethod, requestPath),
|
|
97
|
+
db.prepare(`INSERT OR IGNORE INTO audit_events (id, idempotency_key, action, actor_type, actor_id, target_type, target_id, details_json, created_at) VALUES (?1, ?2, 'evidence.fetch_requested', ?3, ?4, 'evidence_fetch_request', ?5, ?6, ?7)`)
|
|
98
|
+
.bind(crypto.randomUUID(), `evidence-fetch:${idempotencyKey}`, session.actor.type, session.actor.id, requestId, canonicalJson({ reportId, evidenceReferenceId, connectionVersionId, requestedFields }), timestamp),
|
|
99
|
+
]);
|
|
100
|
+
return { requestId, job: { version: 1, jobId: `evidence:${requestId}`, type: "fetch_evidence", reportId, requestId }, idempotentReplay: false };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function selectedData(value: Record<string, unknown>, requested: string[]): Record<string, unknown> {
|
|
104
|
+
const result: Record<string, unknown> = {};
|
|
105
|
+
for (const field of requested) {
|
|
106
|
+
let current: unknown = value;
|
|
107
|
+
for (const segment of field.split(".")) {
|
|
108
|
+
if (["__proto__", "prototype", "constructor"].includes(segment)
|
|
109
|
+
|| !current || typeof current !== "object" || Array.isArray(current)
|
|
110
|
+
|| !Object.prototype.hasOwnProperty.call(current, segment)) {
|
|
111
|
+
current = undefined;
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
current = (current as Record<string, unknown>)[segment];
|
|
115
|
+
}
|
|
116
|
+
if (current !== undefined) result[field] = current;
|
|
117
|
+
}
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function processEvidenceFetch(env: Env, job: EvidenceFetchJob): Promise<void> {
|
|
122
|
+
const claimed = await env.DB.prepare(`
|
|
123
|
+
UPDATE evidence_fetch_requests SET state = 'fetching', attempt_count = attempt_count + 1,
|
|
124
|
+
updated_at = ?2 WHERE id = ?1 AND state = 'pending' AND next_attempt_at <= ?2
|
|
125
|
+
RETURNING id
|
|
126
|
+
`).bind(job.requestId, now()).first();
|
|
127
|
+
const row = await env.DB.prepare(`
|
|
128
|
+
SELECT r.id, r.installation_id AS installationId, r.report_id AS reportId,
|
|
129
|
+
r.evidence_reference_id AS evidenceReferenceId,
|
|
130
|
+
r.connection_version_id AS connectionVersionId,
|
|
131
|
+
r.requested_fields_json AS requestedFieldsJson, r.state,
|
|
132
|
+
r.operation_id AS operationId, r.attempt_count AS attemptCount,
|
|
133
|
+
r.request_method AS requestMethod, r.request_path AS requestPath,
|
|
134
|
+
e.reference, report.queue_id AS queueId
|
|
135
|
+
FROM evidence_fetch_requests r JOIN evidence_references e ON e.id = r.evidence_reference_id
|
|
136
|
+
JOIN reports report ON report.id = r.report_id
|
|
137
|
+
WHERE r.id = ?1 AND r.report_id = ?2 LIMIT 1
|
|
138
|
+
`).bind(job.requestId, job.reportId).first<FetchRequestRow>();
|
|
139
|
+
if (!row) throw new ApiError(404, "evidence_fetch_not_found", "The evidence fetch request does not exist.");
|
|
140
|
+
if (!claimed) {
|
|
141
|
+
if (row.state === "available") return;
|
|
142
|
+
throw new RetryableEvidenceError(10);
|
|
143
|
+
}
|
|
144
|
+
const requested = JSON.parse(row.requestedFieldsJson) as string[];
|
|
145
|
+
const startedAt = now();
|
|
146
|
+
let reservations;
|
|
147
|
+
try {
|
|
148
|
+
reservations = await reserveBudgets(env.DB, {
|
|
149
|
+
installationId: row.installationId,
|
|
150
|
+
scopes: [
|
|
151
|
+
{ type: "connection", id: row.connectionVersionId },
|
|
152
|
+
...(row.queueId ? [{ type: "queue", id: row.queueId }] : []),
|
|
153
|
+
],
|
|
154
|
+
metric: "evidence_fetches", amount: 1, operationType: "evidence_fetch",
|
|
155
|
+
operationId: row.operationId, attemptId: String(row.attemptCount),
|
|
156
|
+
});
|
|
157
|
+
} catch (error) {
|
|
158
|
+
if (!(error instanceof BudgetControlError)) throw error;
|
|
159
|
+
const timestamp = now();
|
|
160
|
+
await env.DB.prepare(`UPDATE evidence_fetch_requests SET state = ?2, next_attempt_at = ?3, last_error_code = ?4, updated_at = ?5 WHERE id = ?1`)
|
|
161
|
+
.bind(row.id, error.retryable ? "pending" : "failed", new Date(Date.now() + Math.max(1, error.delaySeconds) * 1_000).toISOString(), error.code, timestamp).run();
|
|
162
|
+
if (error.retryable) throw new RetryableEvidenceError(error.delaySeconds);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
const result = await executeConnectionJson(env, {
|
|
167
|
+
connectionVersionId: row.connectionVersionId,
|
|
168
|
+
operationId: row.operationId,
|
|
169
|
+
attemptNumber: row.attemptCount,
|
|
170
|
+
method: row.requestMethod,
|
|
171
|
+
path: row.requestPath,
|
|
172
|
+
body: {
|
|
173
|
+
schema_version: "1", operation_id: row.operationId,
|
|
174
|
+
evidence_reference: row.reference, requested_fields: requested,
|
|
175
|
+
},
|
|
176
|
+
headers: { "x-safest-purpose": "controlled-evidence-fetch" },
|
|
177
|
+
});
|
|
178
|
+
const data = selectedData(result.data, requested);
|
|
179
|
+
const body = canonicalJson({ schema_version: "1", data });
|
|
180
|
+
const digest = `sha256:${await sha256(body)}`;
|
|
181
|
+
const snapshotId = crypto.randomUUID();
|
|
182
|
+
const objectKey = `evidence/default/${row.reportId}/${row.id}/${digest.slice(7)}.json`;
|
|
183
|
+
await env.EVIDENCE_OBJECTS.put(objectKey, body, { httpMetadata: { contentType: "application/json" }, customMetadata: { digest, reportId: row.reportId, requestId: row.id } });
|
|
184
|
+
const finishedAt = now();
|
|
185
|
+
await env.DB.batch([
|
|
186
|
+
env.DB.prepare(`INSERT INTO evidence_fetch_attempts (id, request_id, attempt_number, request_digest, response_status, response_digest, response_class, started_at, finished_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'success', ?7, ?8)`)
|
|
187
|
+
.bind(crypto.randomUUID(), row.id, row.attemptCount, `sha256:${await sha256(canonicalJson({ reference: row.reference, requested }))}`, result.status, result.responseDigest, startedAt, finishedAt),
|
|
188
|
+
env.DB.prepare(`INSERT INTO evidence_snapshots (id, installation_id, report_id, evidence_reference_id, fetch_request_id, r2_object_key, content_type, size_bytes, digest, retention_class, access_policy_json, created_by_type, created_by_id, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'application/json', ?7, ?8, 'evidence', ?9, 'system', 'evidence-fetcher', ?10)`)
|
|
189
|
+
.bind(snapshotId, row.installationId, row.reportId, row.evidenceReferenceId, row.id, objectKey, new TextEncoder().encode(body).byteLength, digest, canonicalJson({ permission: "readSensitiveEvidence", fields: requested }), finishedAt),
|
|
190
|
+
env.DB.prepare(`UPDATE evidence_fetch_requests SET state = 'available', last_error_code = NULL, updated_at = ?2 WHERE id = ?1`).bind(row.id, finishedAt),
|
|
191
|
+
env.DB.prepare(`INSERT OR IGNORE INTO audit_events (id, idempotency_key, action, actor_type, actor_id, target_type, target_id, details_json, created_at) VALUES (?1, ?2, 'evidence.fetch_succeeded', 'system', 'evidence-fetcher', 'evidence_snapshot', ?3, ?4, ?5)`)
|
|
192
|
+
.bind(crypto.randomUUID(), `evidence-fetch-succeeded:${row.id}`, snapshotId, canonicalJson({ reportId: row.reportId, digest, fields: requested }), finishedAt),
|
|
193
|
+
]);
|
|
194
|
+
await settleBudgets(env.DB, reservations, true);
|
|
195
|
+
} catch (error) {
|
|
196
|
+
const failure = error instanceof ConnectionExecutionError ? error : new ConnectionExecutionError("evidence_storage_error", true, 15);
|
|
197
|
+
const finishedAt = now();
|
|
198
|
+
const retryAt = new Date(Date.now() + failure.delaySeconds * 1_000).toISOString();
|
|
199
|
+
await env.DB.batch([
|
|
200
|
+
env.DB.prepare(`INSERT OR IGNORE INTO evidence_fetch_attempts (id, request_id, attempt_number, request_digest, response_class, error_code, started_at, finished_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)`)
|
|
201
|
+
.bind(crypto.randomUUID(), row.id, row.attemptCount, `sha256:${await sha256(canonicalJson({ reference: row.reference, requested }))}`, failure.retryable ? "retryable" : "permanent", failure.code, startedAt, finishedAt),
|
|
202
|
+
env.DB.prepare(`UPDATE evidence_fetch_requests SET state = ?2, next_attempt_at = ?3, last_error_code = ?4, updated_at = ?5 WHERE id = ?1`)
|
|
203
|
+
.bind(row.id, failure.retryable ? "pending" : "failed", retryAt, failure.code, finishedAt),
|
|
204
|
+
]);
|
|
205
|
+
await settleBudgets(env.DB, reservations, true);
|
|
206
|
+
if (failure.retryable) throw new RetryableEvidenceError(failure.delaySeconds);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export async function pendingEvidenceFetchJobs(db: D1Database, limit = 50): Promise<EvidenceFetchJob[]> {
|
|
211
|
+
const result = await db.prepare(`SELECT id, report_id AS reportId FROM evidence_fetch_requests WHERE state = 'pending' AND next_attempt_at <= ?1 ORDER BY next_attempt_at, id LIMIT ?2`)
|
|
212
|
+
.bind(now(), Math.max(1, Math.min(100, limit))).all<{ id: string; reportId: string }>();
|
|
213
|
+
return result.results.map((row) => ({ version: 1, jobId: `evidence:${row.id}`, type: "fetch_evidence", reportId: row.reportId, requestId: row.id }));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export async function acceptEvidenceResponse(
|
|
217
|
+
env: Env,
|
|
218
|
+
value: Record<string, unknown>,
|
|
219
|
+
integrationId: string,
|
|
220
|
+
): Promise<{ requestId: string; snapshotId: string | null; state: string; idempotentReplay: boolean }> {
|
|
221
|
+
const requestId = typeof value.request_id === "string" ? value.request_id : "";
|
|
222
|
+
const operationId = typeof value.operation_id === "string" ? value.operation_id : "";
|
|
223
|
+
const status = value.status;
|
|
224
|
+
if (!requestId || requestId.length > 100 || !operationId || operationId.length > 300) {
|
|
225
|
+
throw new ApiError(400, "evidence_response_invalid", "request_id and operation_id are required.");
|
|
226
|
+
}
|
|
227
|
+
if (status !== "available" && status !== "unavailable" && status !== "failed") {
|
|
228
|
+
throw new ApiError(400, "evidence_response_invalid", "status must be available, unavailable, or failed.");
|
|
229
|
+
}
|
|
230
|
+
const row = await env.DB.prepare(`
|
|
231
|
+
SELECT r.id, r.installation_id AS installationId, r.report_id AS reportId,
|
|
232
|
+
r.evidence_reference_id AS evidenceReferenceId, r.requested_fields_json AS requestedFieldsJson,
|
|
233
|
+
r.state, r.operation_id AS operationId, r.attempt_count AS attemptCount,
|
|
234
|
+
s.id AS snapshotId
|
|
235
|
+
FROM evidence_fetch_requests r LEFT JOIN evidence_snapshots s ON s.fetch_request_id = r.id AND s.deleted_at IS NULL
|
|
236
|
+
WHERE r.id = ?1 LIMIT 1
|
|
237
|
+
`).bind(requestId).first<{
|
|
238
|
+
id: string; installationId: string; reportId: string; evidenceReferenceId: string;
|
|
239
|
+
requestedFieldsJson: string; state: string; operationId: string; attemptCount: number; snapshotId: string | null;
|
|
240
|
+
}>();
|
|
241
|
+
if (!row || row.operationId !== operationId) throw new ApiError(404, "evidence_fetch_not_found", "The evidence fetch request does not exist.");
|
|
242
|
+
if (row.state === "available" && row.snapshotId) return { requestId, snapshotId: row.snapshotId, state: "available", idempotentReplay: true };
|
|
243
|
+
if ((row.state === "unavailable" || row.state === "failed") && row.state === status) {
|
|
244
|
+
return { requestId, snapshotId: null, state: row.state, idempotentReplay: true };
|
|
245
|
+
}
|
|
246
|
+
if (!["pending", "fetching", "failed"].includes(row.state)) throw new ApiError(409, "evidence_response_terminal", "The evidence request is already terminal.");
|
|
247
|
+
const timestamp = now();
|
|
248
|
+
if (status !== "available") {
|
|
249
|
+
const errorCode = typeof value.error_code === "string" && value.error_code.length <= 200
|
|
250
|
+
? value.error_code : status === "unavailable" ? "evidence_unavailable" : "evidence_callback_failed";
|
|
251
|
+
await env.DB.batch([
|
|
252
|
+
env.DB.prepare(`UPDATE evidence_fetch_requests SET state = ?2, last_error_code = ?3, updated_at = ?4 WHERE id = ?1`)
|
|
253
|
+
.bind(requestId, status, errorCode, timestamp),
|
|
254
|
+
env.DB.prepare(`INSERT OR IGNORE INTO audit_events (id, idempotency_key, action, actor_type, actor_id, target_type, target_id, details_json, created_at) VALUES (?1, ?2, 'evidence.callback_received', 'service_identity', ?3, 'evidence_fetch_request', ?4, ?5, ?6)`)
|
|
255
|
+
.bind(crypto.randomUUID(), `evidence-callback:${operationId}`, integrationId, requestId, canonicalJson({ status, errorCode }), timestamp),
|
|
256
|
+
]);
|
|
257
|
+
return { requestId, snapshotId: null, state: status, idempotentReplay: false };
|
|
258
|
+
}
|
|
259
|
+
if (!value.data || typeof value.data !== "object" || Array.isArray(value.data)) {
|
|
260
|
+
throw new ApiError(400, "evidence_response_data_required", "data must be an object for an available response.");
|
|
261
|
+
}
|
|
262
|
+
const requested = JSON.parse(row.requestedFieldsJson) as string[];
|
|
263
|
+
const data = selectedData(value.data as Record<string, unknown>, requested);
|
|
264
|
+
const body = canonicalJson({ schema_version: "1", data });
|
|
265
|
+
const bytes = new TextEncoder().encode(body);
|
|
266
|
+
if (bytes.byteLength > 1_048_576) throw new ApiError(413, "evidence_response_too_large", "The evidence response exceeds one MiB.");
|
|
267
|
+
const digest = `sha256:${await sha256(body)}`;
|
|
268
|
+
const snapshotId = crypto.randomUUID();
|
|
269
|
+
const objectKey = `evidence/${row.installationId}/${row.reportId}/${row.id}/${digest.slice(7)}.json`;
|
|
270
|
+
await env.EVIDENCE_OBJECTS.put(objectKey, body, {
|
|
271
|
+
httpMetadata: { contentType: "application/json" },
|
|
272
|
+
customMetadata: { digest, reportId: row.reportId, requestId: row.id },
|
|
273
|
+
});
|
|
274
|
+
try {
|
|
275
|
+
await env.DB.batch([
|
|
276
|
+
env.DB.prepare(`INSERT INTO evidence_snapshots (id, installation_id, report_id, evidence_reference_id, fetch_request_id, r2_object_key, content_type, size_bytes, digest, retention_class, access_policy_json, created_by_type, created_by_id, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'application/json', ?7, ?8, 'evidence', ?9, 'service_identity', ?10, ?11)`)
|
|
277
|
+
.bind(snapshotId, row.installationId, row.reportId, row.evidenceReferenceId, row.id, objectKey, bytes.byteLength, digest, canonicalJson({ permission: "readSensitiveEvidence", fields: requested }), integrationId, timestamp),
|
|
278
|
+
env.DB.prepare(`UPDATE evidence_fetch_requests SET state = 'available', last_error_code = NULL, updated_at = ?2 WHERE id = ?1`).bind(requestId, timestamp),
|
|
279
|
+
env.DB.prepare(`INSERT OR IGNORE INTO audit_events (id, idempotency_key, action, actor_type, actor_id, target_type, target_id, details_json, created_at) VALUES (?1, ?2, 'evidence.callback_received', 'service_identity', ?3, 'evidence_snapshot', ?4, ?5, ?6)`)
|
|
280
|
+
.bind(crypto.randomUUID(), `evidence-callback:${operationId}`, integrationId, snapshotId, canonicalJson({ requestId, reportId: row.reportId, digest, fields: requested }), timestamp),
|
|
281
|
+
]);
|
|
282
|
+
} catch (error) {
|
|
283
|
+
await env.EVIDENCE_OBJECTS.delete(objectKey);
|
|
284
|
+
throw error;
|
|
285
|
+
}
|
|
286
|
+
return { requestId, snapshotId, state: "available", idempotentReplay: false };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export async function createEvidenceSnapshot(
|
|
290
|
+
env: Env,
|
|
291
|
+
reportId: string,
|
|
292
|
+
value: Record<string, unknown>,
|
|
293
|
+
session: OperatorSession,
|
|
294
|
+
idempotencyKey: string,
|
|
295
|
+
): Promise<{ snapshotId: string; digest: string; idempotentReplay: boolean }> {
|
|
296
|
+
const referenceId = typeof value.evidence_reference_id === "string" ? value.evidence_reference_id : "";
|
|
297
|
+
const existing = await env.DB.prepare(`SELECT target_id AS snapshotId, details_json AS detailsJson FROM audit_events WHERE idempotency_key = ?1 AND action = 'evidence.snapshot_created' LIMIT 1`)
|
|
298
|
+
.bind(`evidence-snapshot:${idempotencyKey}`).first<{ snapshotId: string; detailsJson: string }>();
|
|
299
|
+
if (existing) return { snapshotId: existing.snapshotId, digest: String((JSON.parse(existing.detailsJson) as Record<string, unknown>).digest), idempotentReplay: true };
|
|
300
|
+
const reference = await env.DB.prepare(`SELECT id FROM evidence_references WHERE id = ?1 AND report_id = ?2 LIMIT 1`).bind(referenceId, reportId).first();
|
|
301
|
+
if (!reference) throw new ApiError(404, "evidence_reference_not_found", "The evidence reference does not belong to this report.");
|
|
302
|
+
const data = value.data;
|
|
303
|
+
if (data === undefined) throw new ApiError(400, "evidence_snapshot_data_required", "data is required.");
|
|
304
|
+
const body = canonicalJson({ schema_version: "1", data });
|
|
305
|
+
if (new TextEncoder().encode(body).byteLength > 1_048_576) throw new ApiError(413, "evidence_snapshot_too_large", "Evidence snapshots are limited to 1 MiB through this endpoint.");
|
|
306
|
+
const digest = `sha256:${await sha256(body)}`;
|
|
307
|
+
const snapshotId = crypto.randomUUID();
|
|
308
|
+
const objectKey = `evidence/default/${reportId}/manual/${snapshotId}.json`;
|
|
309
|
+
const timestamp = now();
|
|
310
|
+
await env.EVIDENCE_OBJECTS.put(objectKey, body, { httpMetadata: { contentType: "application/json" }, customMetadata: { digest, reportId } });
|
|
311
|
+
try {
|
|
312
|
+
await env.DB.batch([
|
|
313
|
+
env.DB.prepare(`INSERT INTO evidence_snapshots (id, installation_id, report_id, evidence_reference_id, r2_object_key, content_type, size_bytes, digest, retention_class, access_policy_json, source_observed_at, created_by_type, created_by_id, created_at) VALUES (?1, 'default', ?2, ?3, ?4, 'application/json', ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)`)
|
|
314
|
+
.bind(snapshotId, reportId, referenceId, objectKey, new TextEncoder().encode(body).byteLength, digest, typeof value.retention_class === "string" ? value.retention_class.slice(0, 100) : "evidence", canonicalJson({ permission: "readSensitiveEvidence" }), typeof value.source_observed_at === "string" ? value.source_observed_at : null, session.actor.type, session.actor.id, timestamp),
|
|
315
|
+
env.DB.prepare(`INSERT INTO audit_events (id, idempotency_key, action, actor_type, actor_id, target_type, target_id, details_json, created_at) VALUES (?1, ?2, 'evidence.snapshot_created', ?3, ?4, 'evidence_snapshot', ?5, ?6, ?7)`)
|
|
316
|
+
.bind(crypto.randomUUID(), `evidence-snapshot:${idempotencyKey}`, session.actor.type, session.actor.id, snapshotId, canonicalJson({ reportId, digest, referenceId }), timestamp),
|
|
317
|
+
]);
|
|
318
|
+
} catch (error) {
|
|
319
|
+
await env.EVIDENCE_OBJECTS.delete(objectKey);
|
|
320
|
+
throw error;
|
|
321
|
+
}
|
|
322
|
+
return { snapshotId, digest, idempotentReplay: false };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export async function readEvidenceContent(env: Env, snapshotId: string, session: OperatorSession, requestId: string): Promise<Response> {
|
|
326
|
+
const snapshot = await env.DB.prepare(`SELECT id, report_id AS reportId, evidence_reference_id AS evidenceReferenceId, r2_object_key AS objectKey, content_type AS contentType, digest, deleted_at AS deletedAt FROM evidence_snapshots WHERE id = ?1 LIMIT 1`)
|
|
327
|
+
.bind(snapshotId).first<{ id: string; reportId: string; evidenceReferenceId: string | null; objectKey: string; contentType: string; digest: string; deletedAt: string | null }>();
|
|
328
|
+
if (!snapshot || snapshot.deletedAt) throw new ApiError(404, "evidence_snapshot_not_found", "The evidence snapshot is unavailable.");
|
|
329
|
+
const object = await env.EVIDENCE_OBJECTS.get(snapshot.objectKey);
|
|
330
|
+
if (!object) throw new ApiError(503, "evidence_object_missing", "The evidence object is temporarily unavailable.");
|
|
331
|
+
const timestamp = now();
|
|
332
|
+
await env.DB.prepare(`INSERT INTO evidence_access_events (id, installation_id, report_id, evidence_reference_id, snapshot_id, actor_type, actor_id, purpose_code, request_id, accessed_at) VALUES (?1, 'default', ?2, ?3, ?4, ?5, ?6, 'operator_review', ?7, ?8)`)
|
|
333
|
+
.bind(crypto.randomUUID(), snapshot.reportId, snapshot.evidenceReferenceId, snapshot.id, session.actor.type, session.actor.id, requestId, timestamp).run();
|
|
334
|
+
return new Response(object.body, { headers: { "content-type": snapshot.contentType, "etag": `"${snapshot.digest}"`, "cache-control": "private, no-store", "x-content-type-options": "nosniff" } });
|
|
335
|
+
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { canonicalJson } from "./audit";
|
|
2
|
+
import { sha256 } from "./report-crypto";
|
|
3
|
+
import { ApiError } from "./report-http";
|
|
4
|
+
import { assertOperatorQueueAccess } from "./report-auth";
|
|
5
|
+
import type { OperatorSession } from "./report-types";
|
|
6
|
+
import { validateJsonSchemaValue } from "./workflow-expressions";
|
|
7
|
+
import type { JsonObject, JsonValue } from "./workflow-platform-types";
|
|
8
|
+
import { sendWorkflowEvent } from "./workflow-runs";
|
|
9
|
+
|
|
10
|
+
function now(): string {
|
|
11
|
+
return new Date().toISOString();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function jsonObject(value: unknown): JsonObject {
|
|
15
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new ApiError(400, "human_task_response_invalid", "response must be an object.");
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(canonicalJson(value)) as JsonObject;
|
|
18
|
+
} catch {
|
|
19
|
+
throw new ApiError(400, "human_task_response_invalid", "response must contain bounded JSON data.");
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function parsedObject(value: string): JsonObject {
|
|
24
|
+
try {
|
|
25
|
+
return jsonObject(JSON.parse(value));
|
|
26
|
+
} catch {
|
|
27
|
+
return {};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function listHumanTasks(
|
|
32
|
+
db: D1Database,
|
|
33
|
+
input: { state?: string; queueId?: string; operatorId?: string; allowedQueueIds?: string[] | null; limit: number },
|
|
34
|
+
): Promise<Record<string, unknown>[]> {
|
|
35
|
+
const timestamp = now();
|
|
36
|
+
await db.batch([
|
|
37
|
+
db.prepare(`
|
|
38
|
+
UPDATE human_task_claims SET released_at = ?1, release_reason = 'claim_expired'
|
|
39
|
+
WHERE released_at IS NULL AND expires_at <= ?1
|
|
40
|
+
`).bind(timestamp),
|
|
41
|
+
db.prepare(`
|
|
42
|
+
UPDATE human_tasks SET state = 'open', assigned_operator_id = NULL, updated_at = ?1
|
|
43
|
+
WHERE state = 'claimed' AND NOT EXISTS (
|
|
44
|
+
SELECT 1 FROM human_task_claims c WHERE c.task_id = human_tasks.id
|
|
45
|
+
AND c.released_at IS NULL AND c.expires_at > ?1
|
|
46
|
+
)
|
|
47
|
+
`).bind(timestamp),
|
|
48
|
+
]);
|
|
49
|
+
const rows = await db.prepare(`
|
|
50
|
+
SELECT t.id, t.report_id AS reportId, t.appeal_id AS appealId,
|
|
51
|
+
t.run_id AS runId, t.step_run_id AS stepRunId, t.task_type AS taskType,
|
|
52
|
+
t.state, t.queue_id AS queueId, t.assigned_operator_id AS assignedOperatorId,
|
|
53
|
+
t.context_json AS contextJson, t.response_schema_json AS responseSchemaJson,
|
|
54
|
+
t.action_proposal_id AS actionProposalId, t.due_at AS dueAt,
|
|
55
|
+
t.escalation_at AS escalationAt, t.created_at AS createdAt,
|
|
56
|
+
t.updated_at AS updatedAt, c.id AS claimId, c.expires_at AS claimExpiresAt
|
|
57
|
+
FROM human_tasks t LEFT JOIN human_task_claims c
|
|
58
|
+
ON c.task_id = t.id AND c.released_at IS NULL AND c.expires_at > ?1
|
|
59
|
+
WHERE (?2 IS NULL OR t.state = ?2) AND (?3 IS NULL OR t.queue_id = ?3)
|
|
60
|
+
AND (?4 IS NULL OR t.assigned_operator_id = ?4)
|
|
61
|
+
AND (?5 = 1 OR t.queue_id IN (SELECT value FROM json_each(?6)))
|
|
62
|
+
ORDER BY CASE WHEN t.due_at IS NULL THEN 1 ELSE 0 END, t.due_at, t.created_at, t.id
|
|
63
|
+
LIMIT ?7
|
|
64
|
+
`).bind(
|
|
65
|
+
timestamp, input.state ?? null, input.queueId ?? null, input.operatorId ?? null,
|
|
66
|
+
input.allowedQueueIds === null || input.allowedQueueIds === undefined ? 1 : 0,
|
|
67
|
+
canonicalJson(input.allowedQueueIds ?? []), Math.max(1, Math.min(input.limit, 100)),
|
|
68
|
+
).all<Record<string, unknown>>();
|
|
69
|
+
return rows.results.map((row) => ({
|
|
70
|
+
...row,
|
|
71
|
+
context: parsedObject(String(row.contextJson ?? "")),
|
|
72
|
+
responseSchema: parsedObject(String(row.responseSchemaJson ?? "")),
|
|
73
|
+
contextJson: undefined,
|
|
74
|
+
responseSchemaJson: undefined,
|
|
75
|
+
}));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function loadHumanTask(db: D1Database, taskId: string): Promise<Record<string, unknown> | null> {
|
|
79
|
+
const task = await db.prepare(`
|
|
80
|
+
SELECT t.id, t.report_id AS reportId, t.appeal_id AS appealId,
|
|
81
|
+
t.run_id AS runId, t.step_run_id AS stepRunId, t.task_type AS taskType,
|
|
82
|
+
t.state, t.queue_id AS queueId, t.assigned_operator_id AS assignedOperatorId,
|
|
83
|
+
t.context_json AS contextJson, t.response_schema_json AS responseSchemaJson,
|
|
84
|
+
t.action_proposal_id AS actionProposalId, t.due_at AS dueAt,
|
|
85
|
+
t.escalation_at AS escalationAt, t.created_at AS createdAt,
|
|
86
|
+
t.updated_at AS updatedAt, t.completed_at AS completedAt,
|
|
87
|
+
c.id AS claimId, c.operator_id AS claimOperatorId,
|
|
88
|
+
c.claimed_at AS claimedAt, c.expires_at AS claimExpiresAt
|
|
89
|
+
FROM human_tasks t LEFT JOIN human_task_claims c
|
|
90
|
+
ON c.task_id = t.id AND c.released_at IS NULL AND c.expires_at > ?2
|
|
91
|
+
WHERE t.id = ?1 LIMIT 1
|
|
92
|
+
`).bind(taskId, now()).first<Record<string, unknown>>();
|
|
93
|
+
if (!task) return null;
|
|
94
|
+
const responses = await db.prepare(`
|
|
95
|
+
SELECT id, operator_id AS operatorId, response_json AS responseJson,
|
|
96
|
+
response_digest AS responseDigest, submitted_at AS submittedAt
|
|
97
|
+
FROM human_task_responses WHERE task_id = ?1 ORDER BY submitted_at, id
|
|
98
|
+
`).bind(taskId).all<Record<string, unknown>>();
|
|
99
|
+
return {
|
|
100
|
+
...task,
|
|
101
|
+
context: parsedObject(String(task.contextJson ?? "")),
|
|
102
|
+
responseSchema: parsedObject(String(task.responseSchemaJson ?? "")),
|
|
103
|
+
contextJson: undefined,
|
|
104
|
+
responseSchemaJson: undefined,
|
|
105
|
+
responses: responses.results.map((response) => ({
|
|
106
|
+
...response,
|
|
107
|
+
response: parsedObject(String(response.responseJson ?? "")),
|
|
108
|
+
responseJson: undefined,
|
|
109
|
+
})),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function reassignHumanTask(
|
|
114
|
+
db: D1Database,
|
|
115
|
+
taskId: string,
|
|
116
|
+
queueId: string,
|
|
117
|
+
reason: string,
|
|
118
|
+
session: OperatorSession,
|
|
119
|
+
idempotencyKey: string,
|
|
120
|
+
): Promise<{ taskId: string; queueId: string; state: string; idempotentReplay: boolean }> {
|
|
121
|
+
const auditKey = `human-task-reassign:${idempotencyKey}`;
|
|
122
|
+
const replay = await db.prepare(`SELECT target_id AS targetId FROM audit_events WHERE idempotency_key = ?1 LIMIT 1`)
|
|
123
|
+
.bind(auditKey).first<{ targetId: string }>();
|
|
124
|
+
if (replay) {
|
|
125
|
+
if (replay.targetId !== taskId) throw new ApiError(409, "idempotency_key_reused", "This Idempotency-Key was already used for another task.");
|
|
126
|
+
const task = await db.prepare(`SELECT queue_id AS queueId, state FROM human_tasks WHERE id = ?1 LIMIT 1`).bind(taskId).first<{ queueId: string; state: string }>();
|
|
127
|
+
if (!task) throw new ApiError(404, "human_task_not_found", "The task does not exist.");
|
|
128
|
+
return { taskId, queueId: task.queueId, state: task.state, idempotentReplay: true };
|
|
129
|
+
}
|
|
130
|
+
const [task, queue] = await Promise.all([
|
|
131
|
+
db.prepare(`SELECT state, queue_id AS queueId FROM human_tasks WHERE id = ?1 LIMIT 1`).bind(taskId).first<{ state: string; queueId: string | null }>(),
|
|
132
|
+
db.prepare(`SELECT id FROM queue_definitions WHERE id = ?1 AND status = 'active' LIMIT 1`).bind(queueId).first<{ id: string }>(),
|
|
133
|
+
]);
|
|
134
|
+
if (!task) throw new ApiError(404, "human_task_not_found", "The task does not exist.");
|
|
135
|
+
if (!queue) throw new ApiError(409, "human_task_queue_unavailable", "The destination queue is not active.");
|
|
136
|
+
if (!["open", "claimed"].includes(task.state)) throw new ApiError(409, "human_task_not_reassignable", "Only an open or claimed task can be reassigned.");
|
|
137
|
+
const timestamp = now();
|
|
138
|
+
await db.batch([
|
|
139
|
+
db.prepare(`UPDATE human_task_claims SET released_at = ?2, release_reason = 'task_reassigned' WHERE task_id = ?1 AND released_at IS NULL`)
|
|
140
|
+
.bind(taskId, timestamp),
|
|
141
|
+
db.prepare(`UPDATE human_tasks SET state = 'open', queue_id = ?2, assigned_operator_id = NULL, updated_at = ?3 WHERE id = ?1`)
|
|
142
|
+
.bind(taskId, queueId, timestamp),
|
|
143
|
+
db.prepare(`
|
|
144
|
+
INSERT INTO audit_events (id, idempotency_key, action, actor_type, actor_id, target_type, target_id, details_json, created_at)
|
|
145
|
+
VALUES (?1, ?2, 'human_task.reassigned', ?3, ?4, 'human_task', ?5, ?6, ?7)
|
|
146
|
+
`).bind(crypto.randomUUID(), auditKey, session.actor.type, session.actor.id, taskId, canonicalJson({ previousQueueId: task.queueId, queueId, reason }), timestamp),
|
|
147
|
+
]);
|
|
148
|
+
return { taskId, queueId, state: "open", idempotentReplay: false };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function cancelHumanTask(
|
|
152
|
+
db: D1Database,
|
|
153
|
+
taskId: string,
|
|
154
|
+
reason: string,
|
|
155
|
+
session: OperatorSession,
|
|
156
|
+
idempotencyKey: string,
|
|
157
|
+
): Promise<{ taskId: string; runId: string; state: "cancelled"; idempotentReplay: boolean }> {
|
|
158
|
+
const auditKey = `human-task-cancel:${idempotencyKey}`;
|
|
159
|
+
const task = await db.prepare(`SELECT run_id AS runId, state FROM human_tasks WHERE id = ?1 LIMIT 1`)
|
|
160
|
+
.bind(taskId).first<{ runId: string; state: string }>();
|
|
161
|
+
if (!task) throw new ApiError(404, "human_task_not_found", "The task does not exist.");
|
|
162
|
+
const replay = await db.prepare(`SELECT target_id AS targetId FROM audit_events WHERE idempotency_key = ?1 LIMIT 1`)
|
|
163
|
+
.bind(auditKey).first<{ targetId: string }>();
|
|
164
|
+
if (replay) {
|
|
165
|
+
if (replay.targetId !== taskId) throw new ApiError(409, "idempotency_key_reused", "This Idempotency-Key was already used for another task.");
|
|
166
|
+
return { taskId, runId: task.runId, state: "cancelled", idempotentReplay: true };
|
|
167
|
+
}
|
|
168
|
+
if (!["open", "claimed"].includes(task.state)) throw new ApiError(409, "human_task_not_cancellable", "Only an open or claimed task can be cancelled.");
|
|
169
|
+
const timestamp = now();
|
|
170
|
+
await db.batch([
|
|
171
|
+
db.prepare(`UPDATE human_task_claims SET released_at = ?2, release_reason = 'task_cancelled' WHERE task_id = ?1 AND released_at IS NULL`)
|
|
172
|
+
.bind(taskId, timestamp),
|
|
173
|
+
db.prepare(`UPDATE human_tasks SET state = 'cancelled', assigned_operator_id = NULL, updated_at = ?2 WHERE id = ?1`)
|
|
174
|
+
.bind(taskId, timestamp),
|
|
175
|
+
db.prepare(`
|
|
176
|
+
INSERT INTO audit_events (id, idempotency_key, action, actor_type, actor_id, target_type, target_id, details_json, created_at)
|
|
177
|
+
VALUES (?1, ?2, 'human_task.cancelled', ?3, ?4, 'human_task', ?5, ?6, ?7)
|
|
178
|
+
`).bind(crypto.randomUUID(), auditKey, session.actor.type, session.actor.id, taskId, canonicalJson({ reason, runId: task.runId }), timestamp),
|
|
179
|
+
]);
|
|
180
|
+
return { taskId, runId: task.runId, state: "cancelled", idempotentReplay: false };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function claimHumanTask(
|
|
184
|
+
db: D1Database,
|
|
185
|
+
taskId: string,
|
|
186
|
+
session: OperatorSession,
|
|
187
|
+
ttlMinutes: number,
|
|
188
|
+
): Promise<{ claimId: string; expiresAt: string }> {
|
|
189
|
+
const timestamp = now();
|
|
190
|
+
const expiresAt = new Date(Date.now() + Math.max(1, Math.min(ttlMinutes, 1_440)) * 60_000).toISOString();
|
|
191
|
+
await db.prepare(`
|
|
192
|
+
UPDATE human_task_claims SET released_at = ?2, release_reason = 'claim_expired'
|
|
193
|
+
WHERE task_id = ?1 AND released_at IS NULL AND expires_at <= ?2
|
|
194
|
+
`).bind(taskId, timestamp).run();
|
|
195
|
+
const task = await db.prepare(`SELECT state, queue_id AS queueId FROM human_tasks WHERE id = ?1 LIMIT 1`).bind(taskId).first<{ state: string; queueId: string | null }>();
|
|
196
|
+
if (!task) throw new ApiError(404, "human_task_not_found", "The task does not exist.");
|
|
197
|
+
assertOperatorQueueAccess(session, task.queueId);
|
|
198
|
+
if (!['open', 'claimed'].includes(task.state)) throw new ApiError(409, "human_task_not_claimable", "The task is not open for claiming.");
|
|
199
|
+
const active = await db.prepare(`SELECT id, operator_id AS operatorId, expires_at AS expiresAt FROM human_task_claims WHERE task_id = ?1 AND released_at IS NULL LIMIT 1`)
|
|
200
|
+
.bind(taskId).first<{ id: string; operatorId: string; expiresAt: string }>();
|
|
201
|
+
if (active) {
|
|
202
|
+
if (active.operatorId === session.actor.id) return { claimId: active.id, expiresAt: active.expiresAt };
|
|
203
|
+
throw new ApiError(409, "human_task_already_claimed", "The task is already claimed by another operator.");
|
|
204
|
+
}
|
|
205
|
+
const claimId = crypto.randomUUID();
|
|
206
|
+
await db.batch([
|
|
207
|
+
db.prepare(`INSERT INTO human_task_claims (id, task_id, operator_id, claimed_at, expires_at) VALUES (?1, ?2, ?3, ?4, ?5)`)
|
|
208
|
+
.bind(claimId, taskId, session.actor.id, timestamp, expiresAt),
|
|
209
|
+
db.prepare(`UPDATE human_tasks SET state = 'claimed', assigned_operator_id = ?2, updated_at = ?3 WHERE id = ?1 AND state IN ('open','claimed')`)
|
|
210
|
+
.bind(taskId, session.actor.id, timestamp),
|
|
211
|
+
]);
|
|
212
|
+
return { claimId, expiresAt };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export async function releaseHumanTaskClaim(
|
|
216
|
+
db: D1Database,
|
|
217
|
+
taskId: string,
|
|
218
|
+
session: OperatorSession,
|
|
219
|
+
): Promise<void> {
|
|
220
|
+
const timestamp = now();
|
|
221
|
+
const released = await db.prepare(`
|
|
222
|
+
UPDATE human_task_claims SET released_at = ?3, release_reason = 'operator_released'
|
|
223
|
+
WHERE task_id = ?1 AND operator_id = ?2 AND released_at IS NULL
|
|
224
|
+
`).bind(taskId, session.actor.id, timestamp).run();
|
|
225
|
+
if (released.meta.changes !== 1) throw new ApiError(409, "human_task_claim_missing", "You do not hold the active claim for this task.");
|
|
226
|
+
await db.prepare(`UPDATE human_tasks SET state = 'open', assigned_operator_id = NULL, updated_at = ?2 WHERE id = ?1 AND state = 'claimed'`)
|
|
227
|
+
.bind(taskId, timestamp).run();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export async function respondToHumanTask(
|
|
231
|
+
env: Env,
|
|
232
|
+
taskId: string,
|
|
233
|
+
responseValue: unknown,
|
|
234
|
+
session: OperatorSession,
|
|
235
|
+
idempotencyKey: string,
|
|
236
|
+
): Promise<{ responseId: string; eventId: string; eventDelivered: boolean; idempotentReplay: boolean }> {
|
|
237
|
+
const existing = await env.DB.prepare(`
|
|
238
|
+
SELECT r.id, t.run_id AS runId, t.action_proposal_id AS actionProposalId FROM human_task_responses r
|
|
239
|
+
JOIN human_tasks t ON t.id = r.task_id
|
|
240
|
+
WHERE r.operator_id = ?1 AND r.idempotency_key = ?2 LIMIT 1
|
|
241
|
+
`).bind(session.actor.id, idempotencyKey).first<{ id: string; runId: string; actionProposalId: string | null }>();
|
|
242
|
+
if (existing) {
|
|
243
|
+
const event = await sendWorkflowEvent(env, existing.runId, {
|
|
244
|
+
eventType: existing.actionProposalId ? `action.${existing.actionProposalId}.resolved` : `task.${taskId}.completed`, eventKey: `task-response:${existing.id}`,
|
|
245
|
+
sourceType: "operator", sourceId: session.actor.id, payload: { task_id: taskId, response_id: existing.id },
|
|
246
|
+
});
|
|
247
|
+
return { responseId: existing.id, eventId: event.eventId, eventDelivered: event.delivered, idempotentReplay: true };
|
|
248
|
+
}
|
|
249
|
+
const timestamp = now();
|
|
250
|
+
const task = await env.DB.prepare(`
|
|
251
|
+
SELECT t.run_id AS runId, t.state, t.queue_id AS queueId, t.response_schema_json AS responseSchemaJson,
|
|
252
|
+
t.action_proposal_id AS actionProposalId, p.payload_digest AS actionPayloadDigest,
|
|
253
|
+
p.state AS actionState, c.id AS claimId, c.operator_id AS operatorId,
|
|
254
|
+
c.expires_at AS expiresAt
|
|
255
|
+
FROM human_tasks t LEFT JOIN human_task_claims c ON c.task_id = t.id AND c.released_at IS NULL
|
|
256
|
+
LEFT JOIN action_proposals p ON p.id = t.action_proposal_id
|
|
257
|
+
WHERE t.id = ?1 LIMIT 1
|
|
258
|
+
`).bind(taskId).first<{
|
|
259
|
+
runId: string; state: string; responseSchemaJson: string; actionProposalId: string | null;
|
|
260
|
+
actionPayloadDigest: string | null; actionState: string | null; claimId: string | null;
|
|
261
|
+
operatorId: string | null; expiresAt: string | null; queueId: string | null;
|
|
262
|
+
}>();
|
|
263
|
+
if (!task) throw new ApiError(404, "human_task_not_found", "The task does not exist.");
|
|
264
|
+
assertOperatorQueueAccess(session, task.queueId);
|
|
265
|
+
if (task.state !== "claimed" || task.operatorId !== session.actor.id || !task.claimId || !task.expiresAt || task.expiresAt <= timestamp) {
|
|
266
|
+
throw new ApiError(409, "human_task_claim_required", "A current claim held by this operator is required.");
|
|
267
|
+
}
|
|
268
|
+
const response = jsonObject(responseValue);
|
|
269
|
+
const errors = validateJsonSchemaValue(response as JsonValue, parsedObject(task.responseSchemaJson));
|
|
270
|
+
if (errors.length) throw new ApiError(422, "human_task_response_schema_invalid", "The task response does not match its published schema.", { errors });
|
|
271
|
+
const responseJson = canonicalJson(response);
|
|
272
|
+
const responseId = crypto.randomUUID();
|
|
273
|
+
const statements: D1PreparedStatement[] = [
|
|
274
|
+
env.DB.prepare(`
|
|
275
|
+
INSERT INTO human_task_responses (
|
|
276
|
+
id, task_id, claim_id, operator_id, response_json, response_digest,
|
|
277
|
+
idempotency_key, submitted_at
|
|
278
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
|
279
|
+
`).bind(responseId, taskId, task.claimId, session.actor.id, responseJson, `sha256:${await sha256(responseJson)}`, idempotencyKey, timestamp),
|
|
280
|
+
env.DB.prepare(`UPDATE human_task_claims SET released_at = ?2, release_reason = 'task_completed' WHERE id = ?1 AND released_at IS NULL`)
|
|
281
|
+
.bind(task.claimId, timestamp),
|
|
282
|
+
env.DB.prepare(`UPDATE human_tasks SET state = 'completed', completed_at = ?2, updated_at = ?2 WHERE id = ?1 AND state = 'claimed'`)
|
|
283
|
+
.bind(taskId, timestamp),
|
|
284
|
+
];
|
|
285
|
+
if (task.actionProposalId) {
|
|
286
|
+
if (!session.permissions.approveActions) throw new ApiError(403, "action_approval_permission_denied", "Your role cannot approve or reject actions.");
|
|
287
|
+
const decision = response.decision;
|
|
288
|
+
if ((decision !== "approved" && decision !== "rejected") || !task.actionPayloadDigest || task.actionState !== "approval_required") {
|
|
289
|
+
throw new ApiError(409, "action_approval_invalid", "The linked action proposal is not awaiting this exact approval.");
|
|
290
|
+
}
|
|
291
|
+
statements.push(
|
|
292
|
+
env.DB.prepare(`
|
|
293
|
+
INSERT INTO action_approvals (
|
|
294
|
+
id, proposal_id, task_id, approver_id, payload_digest,
|
|
295
|
+
decision, reason, idempotency_key, created_at
|
|
296
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
|
297
|
+
`).bind(
|
|
298
|
+
crypto.randomUUID(), task.actionProposalId, taskId, session.actor.id,
|
|
299
|
+
task.actionPayloadDigest, decision,
|
|
300
|
+
typeof response.reason === "string" ? response.reason : null,
|
|
301
|
+
idempotencyKey, timestamp,
|
|
302
|
+
),
|
|
303
|
+
env.DB.prepare(`
|
|
304
|
+
UPDATE action_proposals SET state = ?2, updated_at = ?3
|
|
305
|
+
WHERE id = ?1 AND state = 'approval_required'
|
|
306
|
+
`).bind(task.actionProposalId, decision, timestamp),
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
await env.DB.batch(statements);
|
|
310
|
+
const event = await sendWorkflowEvent(env, task.runId, {
|
|
311
|
+
eventType: task.actionProposalId ? `action.${task.actionProposalId}.resolved` : `task.${taskId}.completed`, eventKey: `task-response:${responseId}`,
|
|
312
|
+
sourceType: "operator", sourceId: session.actor.id,
|
|
313
|
+
payload: { task_id: taskId, response_id: responseId, response },
|
|
314
|
+
});
|
|
315
|
+
return { responseId, eventId: event.eventId, eventDelivered: event.delivered, idempotentReplay: false };
|
|
316
|
+
}
|