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,430 @@
|
|
|
1
|
+
import { canonicalJson } from "./audit";
|
|
2
|
+
import { resolveOAuthAccessToken } from "./connection-oauth";
|
|
3
|
+
import { sha256 } from "./report-crypto";
|
|
4
|
+
import { ApiError } from "./report-http";
|
|
5
|
+
import { assertSafeConnectionDestination } from "./platform-registry-validation";
|
|
6
|
+
|
|
7
|
+
interface ConnectionExecutionRow {
|
|
8
|
+
id: string;
|
|
9
|
+
connectionId: string;
|
|
10
|
+
definitionStatus: string;
|
|
11
|
+
baseUrl: string | null;
|
|
12
|
+
serviceBindingName: string | null;
|
|
13
|
+
allowedHostsJson: string;
|
|
14
|
+
allowedMethodsJson: string;
|
|
15
|
+
allowedPathPrefixesJson: string;
|
|
16
|
+
requestLimitBytes: number;
|
|
17
|
+
responseLimitBytes: number;
|
|
18
|
+
responseContentTypesJson: string;
|
|
19
|
+
timeoutMs: number;
|
|
20
|
+
concurrencyLimit: number;
|
|
21
|
+
requestsPerWindow: number;
|
|
22
|
+
windowSeconds: number;
|
|
23
|
+
credentialStrategy: string;
|
|
24
|
+
secretReference: string | null;
|
|
25
|
+
signingStrategyJson: string;
|
|
26
|
+
healthState: string | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ConnectionExecutionInput {
|
|
30
|
+
connectionVersionId: string;
|
|
31
|
+
componentRunId?: string;
|
|
32
|
+
actionProposalId?: string;
|
|
33
|
+
operationId: string;
|
|
34
|
+
attemptNumber: number;
|
|
35
|
+
method: string;
|
|
36
|
+
path: string;
|
|
37
|
+
body: unknown;
|
|
38
|
+
headers?: Record<string, string>;
|
|
39
|
+
allowPaused?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ConnectionExecutionResult {
|
|
43
|
+
status: number;
|
|
44
|
+
data: Record<string, unknown>;
|
|
45
|
+
responseDigest: string;
|
|
46
|
+
connectionAttemptId: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class ConnectionExecutionError extends Error {
|
|
50
|
+
constructor(
|
|
51
|
+
readonly code: string,
|
|
52
|
+
readonly retryable: boolean,
|
|
53
|
+
readonly delaySeconds = 5,
|
|
54
|
+
) {
|
|
55
|
+
super(code);
|
|
56
|
+
this.name = "ConnectionExecutionError";
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function parsedJson<T>(value: string, fallback: T): T {
|
|
61
|
+
try {
|
|
62
|
+
return JSON.parse(value) as T;
|
|
63
|
+
} catch {
|
|
64
|
+
return fallback;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function secretValue(env: Env, reference: string | null): string | null {
|
|
69
|
+
if (!reference || !/^(?:CONNECTION|OAUTH|ACTION|NOTIFICATION|EMAIL|AI)_[A-Z0-9_]{2,96}$/u.test(reference)) return null;
|
|
70
|
+
const value = (env as unknown as Record<string, unknown>)[reference];
|
|
71
|
+
return typeof value === "string" && value.length >= 16 ? value : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function hmac(secret: string, value: string): Promise<string> {
|
|
75
|
+
const key = await crypto.subtle.importKey(
|
|
76
|
+
"raw",
|
|
77
|
+
new TextEncoder().encode(secret),
|
|
78
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
79
|
+
false,
|
|
80
|
+
["sign"],
|
|
81
|
+
);
|
|
82
|
+
const digest = new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(value)));
|
|
83
|
+
return [...digest].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function boundedBody(response: Response, maximum: number): Promise<Uint8Array> {
|
|
87
|
+
const contentLength = Number.parseInt(response.headers.get("content-length") ?? "", 10);
|
|
88
|
+
if (Number.isFinite(contentLength) && contentLength > maximum) throw new ConnectionExecutionError("connection_response_too_large", false);
|
|
89
|
+
if (!response.body) return new Uint8Array();
|
|
90
|
+
const reader = response.body.getReader();
|
|
91
|
+
const chunks: Uint8Array[] = [];
|
|
92
|
+
let size = 0;
|
|
93
|
+
try {
|
|
94
|
+
while (true) {
|
|
95
|
+
const { done, value } = await reader.read();
|
|
96
|
+
if (done) break;
|
|
97
|
+
size += value.byteLength;
|
|
98
|
+
if (size > maximum) {
|
|
99
|
+
await reader.cancel("response limit exceeded");
|
|
100
|
+
throw new ConnectionExecutionError("connection_response_too_large", false);
|
|
101
|
+
}
|
|
102
|
+
chunks.push(value);
|
|
103
|
+
}
|
|
104
|
+
} finally {
|
|
105
|
+
reader.releaseLock();
|
|
106
|
+
}
|
|
107
|
+
const result = new Uint8Array(size);
|
|
108
|
+
let offset = 0;
|
|
109
|
+
for (const chunk of chunks) {
|
|
110
|
+
result.set(chunk, offset);
|
|
111
|
+
offset += chunk.byteLength;
|
|
112
|
+
}
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function recordAttempt(
|
|
117
|
+
db: D1Database,
|
|
118
|
+
input: {
|
|
119
|
+
id: string;
|
|
120
|
+
connection: ConnectionExecutionRow;
|
|
121
|
+
execution: ConnectionExecutionInput;
|
|
122
|
+
destinationOrigin: string;
|
|
123
|
+
destinationPath: string;
|
|
124
|
+
requestDigest: string;
|
|
125
|
+
responseStatus: number | null;
|
|
126
|
+
responseDigest: string | null;
|
|
127
|
+
responseClass: "success" | "retryable" | "permanent" | "timeout" | "circuit_open" | "denied" | "invalid";
|
|
128
|
+
errorCode: string | null;
|
|
129
|
+
startedAt: string;
|
|
130
|
+
finishedAt: string;
|
|
131
|
+
},
|
|
132
|
+
): Promise<void> {
|
|
133
|
+
const succeeded = input.responseClass === "success";
|
|
134
|
+
const affectsHealth = ["success", "retryable", "timeout"].includes(input.responseClass);
|
|
135
|
+
await db.batch([
|
|
136
|
+
db.prepare(`
|
|
137
|
+
INSERT INTO connection_attempts (
|
|
138
|
+
id, installation_id, connection_version_id, component_run_id,
|
|
139
|
+
action_proposal_id, operation_id, attempt_number, method,
|
|
140
|
+
destination_origin, destination_path_hash, request_digest,
|
|
141
|
+
response_status, response_digest, response_class, error_code,
|
|
142
|
+
started_at, finished_at
|
|
143
|
+
) VALUES (?1, 'default', ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)
|
|
144
|
+
ON CONFLICT(operation_id, attempt_number) DO NOTHING
|
|
145
|
+
`).bind(
|
|
146
|
+
input.id, input.connection.id, input.execution.componentRunId ?? null,
|
|
147
|
+
input.execution.actionProposalId ?? null, input.execution.operationId,
|
|
148
|
+
input.execution.attemptNumber, input.execution.method, input.destinationOrigin,
|
|
149
|
+
`sha256:${await sha256(input.destinationPath)}`, input.requestDigest,
|
|
150
|
+
input.responseStatus, input.responseDigest, input.responseClass,
|
|
151
|
+
input.errorCode, input.startedAt, input.finishedAt,
|
|
152
|
+
),
|
|
153
|
+
db.prepare(`
|
|
154
|
+
UPDATE connection_health SET
|
|
155
|
+
state = CASE
|
|
156
|
+
WHEN ?6 = 0 THEN state
|
|
157
|
+
WHEN ?2 = 1 THEN 'healthy'
|
|
158
|
+
WHEN consecutive_failures + 1 >= 5 THEN 'open_circuit'
|
|
159
|
+
ELSE 'degraded'
|
|
160
|
+
END,
|
|
161
|
+
consecutive_failures = CASE WHEN ?6 = 0 THEN consecutive_failures WHEN ?2 = 1 THEN 0 ELSE consecutive_failures + 1 END,
|
|
162
|
+
opened_at = CASE WHEN ?6 = 0 THEN opened_at WHEN ?2 = 0 AND consecutive_failures + 1 >= 5 THEN COALESCE(opened_at, ?3) ELSE NULL END,
|
|
163
|
+
next_probe_at = CASE WHEN ?6 = 0 THEN next_probe_at WHEN ?2 = 0 AND consecutive_failures + 1 >= 5 THEN ?4 ELSE NULL END,
|
|
164
|
+
last_success_at = CASE WHEN ?6 = 1 AND ?2 = 1 THEN ?3 ELSE last_success_at END,
|
|
165
|
+
last_error_code = CASE WHEN ?6 = 0 THEN last_error_code WHEN ?2 = 1 THEN NULL ELSE ?5 END,
|
|
166
|
+
revision = revision + 1, updated_at = ?3
|
|
167
|
+
WHERE connection_version_id = ?1
|
|
168
|
+
`).bind(
|
|
169
|
+
input.connection.id, succeeded ? 1 : 0, input.finishedAt,
|
|
170
|
+
new Date(Date.now() + 60_000).toISOString(), input.errorCode, affectsHealth ? 1 : 0,
|
|
171
|
+
),
|
|
172
|
+
]);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function acquireExecutionLease(
|
|
176
|
+
db: D1Database,
|
|
177
|
+
connection: ConnectionExecutionRow,
|
|
178
|
+
input: ConnectionExecutionInput,
|
|
179
|
+
startedAt: string,
|
|
180
|
+
): Promise<string> {
|
|
181
|
+
const leaseId = crypto.randomUUID();
|
|
182
|
+
const expiresAt = new Date(Date.now() + connection.timeoutMs + 10_000).toISOString();
|
|
183
|
+
const windowStart = new Date(Date.now() - connection.windowSeconds * 1_000).toISOString();
|
|
184
|
+
await db.prepare(`DELETE FROM connection_execution_leases WHERE expires_at <= ?1`).bind(startedAt).run();
|
|
185
|
+
const inserted = await db.prepare(`
|
|
186
|
+
INSERT INTO connection_execution_leases (
|
|
187
|
+
id, connection_version_id, operation_id, attempt_number, expires_at, created_at
|
|
188
|
+
)
|
|
189
|
+
SELECT ?1, ?2, ?3, ?4, ?5, ?6
|
|
190
|
+
WHERE (SELECT COUNT(*) FROM connection_execution_leases
|
|
191
|
+
WHERE connection_version_id = ?2 AND expires_at > ?6) < ?7
|
|
192
|
+
AND (SELECT COUNT(*) FROM connection_attempts
|
|
193
|
+
WHERE connection_version_id = ?2 AND started_at >= ?8) < ?9
|
|
194
|
+
ON CONFLICT(operation_id, attempt_number) DO NOTHING
|
|
195
|
+
`).bind(
|
|
196
|
+
leaseId, connection.id, input.operationId, input.attemptNumber,
|
|
197
|
+
expiresAt, startedAt, connection.concurrencyLimit, windowStart,
|
|
198
|
+
connection.requestsPerWindow,
|
|
199
|
+
).run();
|
|
200
|
+
if (inserted.meta.changes === 1) return leaseId;
|
|
201
|
+
const existing = await db.prepare(`
|
|
202
|
+
SELECT id FROM connection_execution_leases
|
|
203
|
+
WHERE operation_id = ?1 AND attempt_number = ?2 AND expires_at > ?3 LIMIT 1
|
|
204
|
+
`).bind(input.operationId, input.attemptNumber, startedAt).first<{ id: string }>();
|
|
205
|
+
if (existing) throw new ConnectionExecutionError("connection_attempt_in_progress", true, 2);
|
|
206
|
+
const active = await db.prepare(`
|
|
207
|
+
SELECT COUNT(*) AS count FROM connection_execution_leases
|
|
208
|
+
WHERE connection_version_id = ?1 AND expires_at > ?2
|
|
209
|
+
`).bind(connection.id, startedAt).first<{ count: number }>();
|
|
210
|
+
if (Number(active?.count ?? 0) >= connection.concurrencyLimit) {
|
|
211
|
+
throw new ConnectionExecutionError("connection_concurrency_exhausted", true, 5);
|
|
212
|
+
}
|
|
213
|
+
throw new ConnectionExecutionError("connection_rate_limited", true, Math.max(1, connection.windowSeconds));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function loadConnection(db: D1Database, id: string, allowPaused = false): Promise<ConnectionExecutionRow> {
|
|
217
|
+
const row = await db.prepare(`
|
|
218
|
+
SELECT v.id, v.connection_id AS connectionId, d.status AS definitionStatus,
|
|
219
|
+
v.base_url AS baseUrl, v.service_binding_name AS serviceBindingName,
|
|
220
|
+
v.allowed_hosts_json AS allowedHostsJson,
|
|
221
|
+
v.allowed_methods_json AS allowedMethodsJson,
|
|
222
|
+
v.allowed_path_prefixes_json AS allowedPathPrefixesJson,
|
|
223
|
+
v.request_limit_bytes AS requestLimitBytes,
|
|
224
|
+
v.response_limit_bytes AS responseLimitBytes,
|
|
225
|
+
v.response_content_types_json AS responseContentTypesJson,
|
|
226
|
+
v.timeout_ms AS timeoutMs, v.concurrency_limit AS concurrencyLimit,
|
|
227
|
+
v.requests_per_window AS requestsPerWindow, v.window_seconds AS windowSeconds,
|
|
228
|
+
v.credential_strategy AS credentialStrategy,
|
|
229
|
+
v.secret_reference AS secretReference, v.signing_strategy_json AS signingStrategyJson,
|
|
230
|
+
h.state AS healthState
|
|
231
|
+
FROM connection_versions v JOIN connection_definitions d ON d.id = v.connection_id
|
|
232
|
+
LEFT JOIN connection_health h ON h.connection_version_id = v.id
|
|
233
|
+
WHERE v.id = ?1 AND v.retired_at IS NULL LIMIT 1
|
|
234
|
+
`).bind(id).first<ConnectionExecutionRow>();
|
|
235
|
+
if (!row || (row.definitionStatus !== "active" && !(allowPaused && row.definitionStatus === "paused"))) throw new ConnectionExecutionError("connection_unavailable", false);
|
|
236
|
+
if (row.healthState === "paused" && !allowPaused) throw new ConnectionExecutionError("connection_paused", false);
|
|
237
|
+
if (row.healthState === "open_circuit") throw new ConnectionExecutionError("connection_circuit_open", true, 60);
|
|
238
|
+
return row;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export async function executeConnectionJson(
|
|
242
|
+
env: Env,
|
|
243
|
+
input: ConnectionExecutionInput,
|
|
244
|
+
): Promise<ConnectionExecutionResult> {
|
|
245
|
+
const connection = await loadConnection(env.DB, input.connectionVersionId, input.allowPaused === true);
|
|
246
|
+
const method = input.method.toUpperCase();
|
|
247
|
+
const allowedMethods = parsedJson<string[]>(connection.allowedMethodsJson, []);
|
|
248
|
+
if (!allowedMethods.includes(method)) throw new ConnectionExecutionError("connection_method_denied", false);
|
|
249
|
+
if (!input.path.startsWith("/") || input.path.includes("..") || input.path.includes("#")) throw new ConnectionExecutionError("connection_path_denied", false);
|
|
250
|
+
const body = canonicalJson(input.body);
|
|
251
|
+
const bodyBytes = new TextEncoder().encode(body);
|
|
252
|
+
if (bodyBytes.byteLength > connection.requestLimitBytes) throw new ConnectionExecutionError("connection_request_too_large", false);
|
|
253
|
+
const requestDigest = `sha256:${await sha256(body)}`;
|
|
254
|
+
const attemptId = crypto.randomUUID();
|
|
255
|
+
const startedAt = new Date().toISOString();
|
|
256
|
+
let destinationOrigin: string;
|
|
257
|
+
let destinationPath: string;
|
|
258
|
+
let requestUrl: string;
|
|
259
|
+
let fetcher: Pick<Fetcher, "fetch"> = globalThis;
|
|
260
|
+
if (connection.baseUrl) {
|
|
261
|
+
const url = new URL(`${connection.baseUrl.replace(/\/$/u, "")}/${input.path.replace(/^\//u, "")}`);
|
|
262
|
+
try {
|
|
263
|
+
assertSafeConnectionDestination(
|
|
264
|
+
url,
|
|
265
|
+
parsedJson(connection.allowedHostsJson, []),
|
|
266
|
+
parsedJson(connection.allowedPathPrefixesJson, []),
|
|
267
|
+
);
|
|
268
|
+
} catch {
|
|
269
|
+
throw new ConnectionExecutionError("connection_destination_denied", false);
|
|
270
|
+
}
|
|
271
|
+
destinationOrigin = url.origin;
|
|
272
|
+
destinationPath = url.pathname;
|
|
273
|
+
requestUrl = url.href;
|
|
274
|
+
} else if (connection.serviceBindingName) {
|
|
275
|
+
if (!/^[A-Z][A-Z0-9_]{1,99}$/u.test(connection.serviceBindingName)) throw new ConnectionExecutionError("connection_service_binding_invalid", false);
|
|
276
|
+
const candidate = (env as unknown as Record<string, unknown>)[connection.serviceBindingName];
|
|
277
|
+
if (!candidate || typeof candidate !== "object" || !("fetch" in candidate) || typeof candidate.fetch !== "function") throw new ConnectionExecutionError("connection_service_binding_missing", false);
|
|
278
|
+
fetcher = candidate as Pick<Fetcher, "fetch">;
|
|
279
|
+
destinationOrigin = `service://${connection.serviceBindingName}`;
|
|
280
|
+
destinationPath = input.path;
|
|
281
|
+
requestUrl = `https://service.invalid${input.path}`;
|
|
282
|
+
} else {
|
|
283
|
+
throw new ConnectionExecutionError("connection_destination_missing", false);
|
|
284
|
+
}
|
|
285
|
+
const headers = new Headers({ "content-type": "application/json", "accept": "application/json" });
|
|
286
|
+
for (const [name, value] of Object.entries(input.headers ?? {})) {
|
|
287
|
+
const lower = name.toLowerCase();
|
|
288
|
+
if (!["accept", "content-type", "x-safest-purpose"].includes(lower) || /[\r\n]/u.test(value)) throw new ConnectionExecutionError("connection_header_denied", false);
|
|
289
|
+
headers.set(name, value);
|
|
290
|
+
}
|
|
291
|
+
const secret = secretValue(env, connection.secretReference);
|
|
292
|
+
const timestamp = new Date().toISOString();
|
|
293
|
+
const nonce = crypto.randomUUID();
|
|
294
|
+
if (connection.credentialStrategy === "bearer") {
|
|
295
|
+
if (!secret) throw new ConnectionExecutionError("connection_auth_required", false);
|
|
296
|
+
headers.set("authorization", `Bearer ${secret}`);
|
|
297
|
+
} else if (connection.credentialStrategy === "static_header") {
|
|
298
|
+
if (!secret) throw new ConnectionExecutionError("connection_auth_required", false);
|
|
299
|
+
const strategy = parsedJson<Record<string, unknown>>(connection.signingStrategyJson, {});
|
|
300
|
+
const headerName = typeof strategy.header_name === "string" ? strategy.header_name : "x-api-key";
|
|
301
|
+
if (!/^[A-Za-z][A-Za-z0-9-]{0,63}$/u.test(headerName)) throw new ConnectionExecutionError("connection_auth_configuration_invalid", false);
|
|
302
|
+
headers.set(headerName, secret);
|
|
303
|
+
} else if (connection.credentialStrategy === "hmac") {
|
|
304
|
+
if (!secret) throw new ConnectionExecutionError("connection_auth_required", false);
|
|
305
|
+
headers.set("safest-timestamp", timestamp);
|
|
306
|
+
headers.set("safest-nonce", nonce);
|
|
307
|
+
headers.set("safest-operation-id", input.operationId);
|
|
308
|
+
headers.set("safest-signature", await hmac(secret, `${method}\n${destinationPath}\n${timestamp}\n${nonce}\n${requestDigest}`));
|
|
309
|
+
} else if (connection.credentialStrategy === "oauth2_authorization_code") {
|
|
310
|
+
try {
|
|
311
|
+
const credential = await resolveOAuthAccessToken(env, connection.id);
|
|
312
|
+
headers.set("authorization", `${credential.tokenType} ${credential.token}`);
|
|
313
|
+
} catch (error) {
|
|
314
|
+
if (error instanceof ApiError) {
|
|
315
|
+
const retryable = ["oauth_provider_unavailable", "oauth_token_exchange_failed"].includes(error.code);
|
|
316
|
+
throw new ConnectionExecutionError(error.code, retryable, retryable ? 30 : 5);
|
|
317
|
+
}
|
|
318
|
+
throw error;
|
|
319
|
+
}
|
|
320
|
+
} else if (["oauth2_client_credentials", "mutual_tls"].includes(connection.credentialStrategy)) {
|
|
321
|
+
throw new ConnectionExecutionError("connection_auth_required", false);
|
|
322
|
+
}
|
|
323
|
+
let leaseId: string;
|
|
324
|
+
try {
|
|
325
|
+
leaseId = await acquireExecutionLease(env.DB, connection, input, startedAt);
|
|
326
|
+
} catch (error) {
|
|
327
|
+
if (error instanceof ConnectionExecutionError) {
|
|
328
|
+
await recordAttempt(env.DB, {
|
|
329
|
+
id: attemptId,
|
|
330
|
+
connection,
|
|
331
|
+
execution: input,
|
|
332
|
+
destinationOrigin,
|
|
333
|
+
destinationPath,
|
|
334
|
+
requestDigest,
|
|
335
|
+
responseStatus: null,
|
|
336
|
+
responseDigest: null,
|
|
337
|
+
responseClass: "denied",
|
|
338
|
+
errorCode: error.code,
|
|
339
|
+
startedAt,
|
|
340
|
+
finishedAt: new Date().toISOString(),
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
throw error;
|
|
344
|
+
}
|
|
345
|
+
const controller = new AbortController();
|
|
346
|
+
const timeout = setTimeout(() => controller.abort("connection timeout"), connection.timeoutMs);
|
|
347
|
+
let attemptRecorded = false;
|
|
348
|
+
try {
|
|
349
|
+
const response = await fetcher.fetch(requestUrl, {
|
|
350
|
+
method,
|
|
351
|
+
headers,
|
|
352
|
+
body: ["GET", "HEAD"].includes(method) ? undefined : body,
|
|
353
|
+
redirect: "manual",
|
|
354
|
+
signal: controller.signal,
|
|
355
|
+
});
|
|
356
|
+
if (response.status >= 300 && response.status < 400) throw new ConnectionExecutionError("connection_redirect_denied", false);
|
|
357
|
+
const contentType = (response.headers.get("content-type") ?? "").split(";", 1)[0]!.trim().toLowerCase();
|
|
358
|
+
const allowedContentTypes = parsedJson<string[]>(connection.responseContentTypesJson, []);
|
|
359
|
+
if (!allowedContentTypes.includes(contentType)) throw new ConnectionExecutionError("connection_content_type_invalid", false);
|
|
360
|
+
const bytes = await boundedBody(response, connection.responseLimitBytes);
|
|
361
|
+
const responseText = new TextDecoder().decode(bytes);
|
|
362
|
+
const responseDigest = `sha256:${await sha256(responseText)}`;
|
|
363
|
+
let data: unknown;
|
|
364
|
+
try {
|
|
365
|
+
data = responseText ? JSON.parse(responseText) : {};
|
|
366
|
+
} catch {
|
|
367
|
+
throw new ConnectionExecutionError("connection_json_invalid", false);
|
|
368
|
+
}
|
|
369
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) throw new ConnectionExecutionError("connection_json_invalid", false);
|
|
370
|
+
const responseClass = response.ok ? "success" : [408, 425, 429].includes(response.status) || response.status >= 500 ? "retryable" : "permanent";
|
|
371
|
+
const finishedAt = new Date().toISOString();
|
|
372
|
+
await recordAttempt(env.DB, {
|
|
373
|
+
id: attemptId,
|
|
374
|
+
connection,
|
|
375
|
+
execution: input,
|
|
376
|
+
destinationOrigin,
|
|
377
|
+
destinationPath,
|
|
378
|
+
requestDigest,
|
|
379
|
+
responseStatus: response.status,
|
|
380
|
+
responseDigest,
|
|
381
|
+
responseClass,
|
|
382
|
+
errorCode: response.ok ? null : `http_${response.status}`,
|
|
383
|
+
startedAt,
|
|
384
|
+
finishedAt,
|
|
385
|
+
});
|
|
386
|
+
attemptRecorded = true;
|
|
387
|
+
if (!response.ok) throw new ConnectionExecutionError(`connection_http_${response.status}`, responseClass === "retryable", response.status === 429 ? 30 : 5);
|
|
388
|
+
return { status: response.status, data: data as Record<string, unknown>, responseDigest, connectionAttemptId: attemptId };
|
|
389
|
+
} catch (error) {
|
|
390
|
+
if (error instanceof ConnectionExecutionError) {
|
|
391
|
+
if (!attemptRecorded) {
|
|
392
|
+
await recordAttempt(env.DB, {
|
|
393
|
+
id: attemptId,
|
|
394
|
+
connection,
|
|
395
|
+
execution: input,
|
|
396
|
+
destinationOrigin,
|
|
397
|
+
destinationPath,
|
|
398
|
+
requestDigest,
|
|
399
|
+
responseStatus: null,
|
|
400
|
+
responseDigest: null,
|
|
401
|
+
responseClass: error.code === "connection_circuit_open" ? "circuit_open" : error.retryable ? "retryable" : "invalid",
|
|
402
|
+
errorCode: error.code,
|
|
403
|
+
startedAt,
|
|
404
|
+
finishedAt: new Date().toISOString(),
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
throw error;
|
|
408
|
+
}
|
|
409
|
+
const timedOut = controller.signal.aborted;
|
|
410
|
+
const code = timedOut ? "connection_timeout" : "connection_transport_error";
|
|
411
|
+
await recordAttempt(env.DB, {
|
|
412
|
+
id: attemptId,
|
|
413
|
+
connection,
|
|
414
|
+
execution: input,
|
|
415
|
+
destinationOrigin,
|
|
416
|
+
destinationPath,
|
|
417
|
+
requestDigest,
|
|
418
|
+
responseStatus: null,
|
|
419
|
+
responseDigest: null,
|
|
420
|
+
responseClass: timedOut ? "timeout" : "retryable",
|
|
421
|
+
errorCode: code,
|
|
422
|
+
startedAt,
|
|
423
|
+
finishedAt: new Date().toISOString(),
|
|
424
|
+
});
|
|
425
|
+
throw new ConnectionExecutionError(code, true, 5);
|
|
426
|
+
} finally {
|
|
427
|
+
clearTimeout(timeout);
|
|
428
|
+
await env.DB.prepare(`DELETE FROM connection_execution_leases WHERE id = ?1`).bind(leaseId).run();
|
|
429
|
+
}
|
|
430
|
+
}
|