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,301 @@
|
|
|
1
|
+
import { canonicalJson } from "./audit";
|
|
2
|
+
import { sha256 } from "./report-crypto";
|
|
3
|
+
import { ApiError } from "./report-http";
|
|
4
|
+
import type { OperatorSession } from "./report-types";
|
|
5
|
+
import { WORKFLOW_COMPILER_VERSION } from "./workflow-compiler";
|
|
6
|
+
import { parseWorkspaceBrand } from "./workspace-branding";
|
|
7
|
+
import { contextSchemaJson, parseContextSchema } from "./report-context-schema";
|
|
8
|
+
|
|
9
|
+
const integrationKeyPattern = /^[a-z][a-z0-9-]{1,79}$/u;
|
|
10
|
+
const allowedScopes = new Set(["submit_reports", "mint_context_tokens", "evidence_callback", "action_callback"]);
|
|
11
|
+
const channelModes = new Set(["contextual", "anonymous"] as const);
|
|
12
|
+
|
|
13
|
+
function safeChannelMode(value: unknown): "contextual" | "anonymous" {
|
|
14
|
+
const mode = value ?? "contextual";
|
|
15
|
+
if (typeof mode !== "string" || !channelModes.has(mode as "contextual" | "anonymous")) {
|
|
16
|
+
throw new ApiError(400, "integration_mode_invalid", "channel_mode must be contextual or anonymous.");
|
|
17
|
+
}
|
|
18
|
+
return mode as "contextual" | "anonymous";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function now(): string { return new Date().toISOString(); }
|
|
22
|
+
|
|
23
|
+
function randomSecret(): string {
|
|
24
|
+
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
|
25
|
+
let binary = "";
|
|
26
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
27
|
+
return btoa(binary).replace(/\+/gu, "-").replace(/\//gu, "_").replace(/=+$/gu, "");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function safeOrigins(value: unknown): string[] {
|
|
31
|
+
if (!Array.isArray(value) || value.length > 100) throw new ApiError(400, "integration_origins_invalid", "allowed_origins must contain at most 100 HTTPS origins.");
|
|
32
|
+
const origins = value.map((entry) => {
|
|
33
|
+
if (typeof entry !== "string") throw new ApiError(400, "integration_origins_invalid", "Each allowed origin must be a string.");
|
|
34
|
+
let url: URL;
|
|
35
|
+
try { url = new URL(entry); } catch { throw new ApiError(400, "integration_origins_invalid", "Each allowed origin must be a valid origin."); }
|
|
36
|
+
const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
|
37
|
+
if ((url.protocol !== "https:" && !(local && url.protocol === "http:")) || url.username || url.password || url.pathname !== "/" || url.search || url.hash) {
|
|
38
|
+
throw new ApiError(400, "integration_origins_invalid", "Origins must be HTTPS origins without paths; localhost HTTP is allowed for development.");
|
|
39
|
+
}
|
|
40
|
+
return url.origin;
|
|
41
|
+
});
|
|
42
|
+
if (new Set(origins).size !== origins.length) throw new ApiError(400, "integration_origins_invalid", "allowed_origins contains duplicates.");
|
|
43
|
+
return origins;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function safeScopes(value: unknown): string[] {
|
|
47
|
+
if (!Array.isArray(value) || value.length < 1 || value.length > allowedScopes.size) throw new ApiError(400, "integration_scopes_invalid", "scopes must be a non-empty list.");
|
|
48
|
+
const scopes = value.map((entry) => {
|
|
49
|
+
if (typeof entry !== "string" || !allowedScopes.has(entry)) throw new ApiError(400, "integration_scopes_invalid", "An integration scope is unsupported.");
|
|
50
|
+
return entry;
|
|
51
|
+
});
|
|
52
|
+
if (new Set(scopes).size !== scopes.length) throw new ApiError(400, "integration_scopes_invalid", "scopes contains duplicates.");
|
|
53
|
+
return scopes;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function loadInstallation(db: D1Database): Promise<Record<string, unknown>> {
|
|
57
|
+
const installation = await db.prepare(`SELECT id, display_name AS displayName, default_locale AS defaultLocale, anonymous_reporting_enabled AS anonymousReportingEnabled, timezone, settings_json AS settingsJson, created_at AS createdAt, updated_at AS updatedAt FROM installations WHERE id = 'default' LIMIT 1`).first<Record<string, unknown>>();
|
|
58
|
+
if (!installation) throw new ApiError(503, "installation_missing", "The installation has not been initialized.");
|
|
59
|
+
const features = await db.prepare(`SELECT feature_key AS featureKey, state, capability_json AS capabilityJson, checked_at AS checkedAt, error_code AS errorCode, revision FROM installation_features WHERE installation_id = 'default' ORDER BY feature_key`).all<Record<string, unknown>>();
|
|
60
|
+
return {
|
|
61
|
+
...installation,
|
|
62
|
+
anonymousReportingEnabled: installation.anonymousReportingEnabled === 1,
|
|
63
|
+
settings: JSON.parse(String(installation.settingsJson ?? "{}")),
|
|
64
|
+
settingsJson: undefined,
|
|
65
|
+
features: features.results.map((feature) => ({ ...feature, capability: JSON.parse(String(feature.capabilityJson ?? "{}")), capabilityJson: undefined })),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function updateInstallation(
|
|
70
|
+
db: D1Database,
|
|
71
|
+
value: Record<string, unknown>,
|
|
72
|
+
session: OperatorSession,
|
|
73
|
+
idempotencyKey: string,
|
|
74
|
+
): Promise<Record<string, unknown>> {
|
|
75
|
+
const allowed = new Set(["display_name", "default_locale", "timezone", "anonymous_reporting_enabled", "settings"]);
|
|
76
|
+
if (Object.keys(value).some((key) => !allowed.has(key))) throw new ApiError(400, "installation_update_invalid", "The installation update contains unsupported fields.");
|
|
77
|
+
if (value.display_name !== undefined && (typeof value.display_name !== "string" || value.display_name.trim().length < 2 || value.display_name.length > 120)) throw new ApiError(400, "installation_name_invalid", "display_name must contain 2 to 120 characters.");
|
|
78
|
+
if (value.default_locale !== undefined && (typeof value.default_locale !== "string" || !/^[a-z]{2,3}(?:-[A-Z]{2})?$/u.test(value.default_locale))) throw new ApiError(400, "installation_locale_invalid", "default_locale is invalid.");
|
|
79
|
+
if (value.timezone !== undefined) {
|
|
80
|
+
if (typeof value.timezone !== "string" || value.timezone.length > 100) throw new ApiError(400, "installation_timezone_invalid", "timezone is invalid.");
|
|
81
|
+
try { new Intl.DateTimeFormat("en", { timeZone: value.timezone }).format(); } catch { throw new ApiError(400, "installation_timezone_invalid", "timezone is invalid."); }
|
|
82
|
+
}
|
|
83
|
+
if (value.anonymous_reporting_enabled !== undefined && typeof value.anonymous_reporting_enabled !== "boolean") throw new ApiError(400, "installation_anonymous_invalid", "anonymous_reporting_enabled must be boolean.");
|
|
84
|
+
if (value.settings !== undefined && (!value.settings || typeof value.settings !== "object" || Array.isArray(value.settings))) throw new ApiError(400, "installation_settings_invalid", "settings must be an object.");
|
|
85
|
+
const current = await db.prepare(`SELECT display_name AS displayName, default_locale AS defaultLocale, timezone, anonymous_reporting_enabled AS anonymousReportingEnabled, settings_json AS settingsJson FROM installations WHERE id = 'default'`).first<Record<string, unknown>>();
|
|
86
|
+
if (!current) throw new ApiError(503, "installation_missing", "The installation has not been initialized.");
|
|
87
|
+
const settingsValue = value.settings ?? JSON.parse(String(current.settingsJson));
|
|
88
|
+
const settings = settingsValue as Record<string, unknown>;
|
|
89
|
+
if (settings.brand !== undefined) {
|
|
90
|
+
settings.brand = parseWorkspaceBrand(settings.brand, typeof value.display_name === "string" ? value.display_name.trim() : String(current.displayName));
|
|
91
|
+
}
|
|
92
|
+
const timestamp = now();
|
|
93
|
+
await db.batch([
|
|
94
|
+
db.prepare(`UPDATE installations SET display_name = ?1, default_locale = ?2, timezone = ?3, anonymous_reporting_enabled = ?4, settings_json = ?5, updated_at = ?6 WHERE id = 'default'`).bind(
|
|
95
|
+
typeof value.display_name === "string" ? value.display_name.trim() : current.displayName,
|
|
96
|
+
typeof value.default_locale === "string" ? value.default_locale : current.defaultLocale,
|
|
97
|
+
typeof value.timezone === "string" ? value.timezone : current.timezone,
|
|
98
|
+
typeof value.anonymous_reporting_enabled === "boolean" ? (value.anonymous_reporting_enabled ? 1 : 0) : current.anonymousReportingEnabled,
|
|
99
|
+
canonicalJson(settings), timestamp,
|
|
100
|
+
),
|
|
101
|
+
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, 'installation.updated', ?3, ?4, 'installation', 'default', ?5, ?6)`)
|
|
102
|
+
.bind(crypto.randomUUID(), `installation-update:${idempotencyKey}`, session.actor.type, session.actor.id, canonicalJson({ changedFields: Object.keys(value) }), timestamp),
|
|
103
|
+
]);
|
|
104
|
+
return loadInstallation(db);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function listIntegrations(db: D1Database): Promise<Record<string, unknown>[]> {
|
|
108
|
+
const rows = await db.prepare(`
|
|
109
|
+
SELECT i.id, i.name, i.allowed_origins_json AS allowedOriginsJson,
|
|
110
|
+
i.scopes_json AS scopesJson, i.channel_mode AS channelMode,
|
|
111
|
+
i.form_version_id AS formVersionId, i.context_schema_json AS contextSchemaJson,
|
|
112
|
+
i.enabled, i.created_at AS createdAt, i.updated_at AS updatedAt,
|
|
113
|
+
(SELECT COUNT(*) FROM integration_api_keys k WHERE k.integration_id = i.id AND k.status = 'active' AND (k.expires_at IS NULL OR k.expires_at > ?1)) AS activeKeyCount,
|
|
114
|
+
(SELECT MAX(k.created_at) FROM integration_api_keys k WHERE k.integration_id = i.id) AS lastKeyCreatedAt
|
|
115
|
+
FROM application_integrations i WHERE i.installation_id = 'default' ORDER BY i.name, i.id
|
|
116
|
+
`).bind(now()).all<Record<string, unknown>>();
|
|
117
|
+
return rows.results.map((row) => ({
|
|
118
|
+
...row,
|
|
119
|
+
enabled: row.enabled === 1,
|
|
120
|
+
allowedOrigins: JSON.parse(String(row.allowedOriginsJson)),
|
|
121
|
+
scopes: JSON.parse(String(row.scopesJson)),
|
|
122
|
+
contextSchema: JSON.parse(String(row.contextSchemaJson)),
|
|
123
|
+
allowedOriginsJson: undefined,
|
|
124
|
+
scopesJson: undefined,
|
|
125
|
+
contextSchemaJson: undefined,
|
|
126
|
+
}));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function loadIntegrationAdmin(db: D1Database, id: string): Promise<Record<string, unknown> | null> {
|
|
130
|
+
const integration = (await listIntegrations(db)).find((entry) => entry.id === id);
|
|
131
|
+
if (!integration) return null;
|
|
132
|
+
const keys = await db.prepare(`SELECT id, key_id AS keyId, status, valid_from AS validFrom, expires_at AS expiresAt, created_by AS createdBy, created_at AS createdAt, revoked_by AS revokedBy, revoked_at AS revokedAt FROM integration_api_keys WHERE integration_id = ?1 ORDER BY created_at DESC, id DESC`).bind(id).all<Record<string, unknown>>();
|
|
133
|
+
return { ...integration, keys: keys.results };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function createIntegration(
|
|
137
|
+
db: D1Database,
|
|
138
|
+
value: Record<string, unknown>,
|
|
139
|
+
session: OperatorSession,
|
|
140
|
+
idempotencyKey: string,
|
|
141
|
+
): Promise<{ integrationId: string; keyId: string | null; apiKey: string | null; idempotentReplay: boolean }> {
|
|
142
|
+
const replay = await db.prepare(`SELECT target_id AS targetId FROM audit_events WHERE idempotency_key = ?1 AND action = 'integration.created' LIMIT 1`).bind(`integration-create:${idempotencyKey}`).first<{ targetId: string }>();
|
|
143
|
+
if (replay) throw new ApiError(409, "integration_key_already_issued", "The integration was created, but its one-time API key cannot be shown again. Rotate the key to issue a replacement.");
|
|
144
|
+
if (typeof value.integration_key !== "string" || !integrationKeyPattern.test(value.integration_key)) throw new ApiError(400, "integration_key_invalid", "integration_key must use lowercase letters, digits, and hyphens.");
|
|
145
|
+
if (typeof value.name !== "string" || value.name.trim().length < 2 || value.name.length > 120) throw new ApiError(400, "integration_name_invalid", "name must contain 2 to 120 characters.");
|
|
146
|
+
const origins = safeOrigins(value.allowed_origins ?? []);
|
|
147
|
+
const scopes = safeScopes(value.scopes ?? ["submit_reports", "mint_context_tokens"]);
|
|
148
|
+
const channelMode = safeChannelMode(value.channel_mode);
|
|
149
|
+
const formVersionId = typeof value.form_version_id === "string" && value.form_version_id.trim()
|
|
150
|
+
? value.form_version_id.trim()
|
|
151
|
+
: null;
|
|
152
|
+
if (formVersionId && formVersionId.length > 100) throw new ApiError(400, "integration_form_invalid", "form_version_id is too long.");
|
|
153
|
+
if (formVersionId && !await db.prepare(`SELECT id FROM report_form_versions WHERE id = ?1 AND published_at IS NOT NULL AND retired_at IS NULL LIMIT 1`).bind(formVersionId).first()) {
|
|
154
|
+
throw new ApiError(400, "integration_form_unpublished", "Choose a published report form for this channel.");
|
|
155
|
+
}
|
|
156
|
+
const contextSchema = parseContextSchema(value.context_schema ?? { version: 1, allow_unknown: false, fields: [] });
|
|
157
|
+
if (channelMode === "anonymous" && scopes.includes("mint_context_tokens")) {
|
|
158
|
+
throw new ApiError(400, "integration_scope_denied", "Anonymous channels cannot mint authenticated context.");
|
|
159
|
+
}
|
|
160
|
+
const integrationId = crypto.randomUUID();
|
|
161
|
+
const issueApiKey = channelMode === "contextual";
|
|
162
|
+
const keyId = issueApiKey ? `runtime-${crypto.randomUUID()}` : null;
|
|
163
|
+
const apiKey = issueApiKey ? `safest_${randomSecret()}` : null;
|
|
164
|
+
const timestamp = now();
|
|
165
|
+
const statements = [
|
|
166
|
+
db.prepare(`INSERT INTO application_integrations (
|
|
167
|
+
id, installation_id, name, key_id, allowed_origins_json, enabled,
|
|
168
|
+
created_at, updated_at, scopes_json, channel_mode, form_version_id, context_schema_json
|
|
169
|
+
) VALUES (?1, 'default', ?2, ?3, ?4, 1, ?5, ?5, ?6, ?7, ?8, ?9)`)
|
|
170
|
+
.bind(
|
|
171
|
+
integrationId, value.name.trim(), `managed:${value.integration_key}`,
|
|
172
|
+
canonicalJson(origins), timestamp, canonicalJson(scopes), channelMode,
|
|
173
|
+
formVersionId, canonicalJson(contextSchemaJson(contextSchema)),
|
|
174
|
+
),
|
|
175
|
+
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, 'integration.created', ?3, ?4, 'application_integration', ?5, ?6, ?7)`)
|
|
176
|
+
.bind(crypto.randomUUID(), `integration-create:${idempotencyKey}`, session.actor.type, session.actor.id, integrationId, canonicalJson({ keyId, scopes, allowedOrigins: origins, channelMode, formVersionId, contextSchema: contextSchemaJson(contextSchema) }), timestamp),
|
|
177
|
+
];
|
|
178
|
+
if (issueApiKey && keyId && apiKey) {
|
|
179
|
+
statements.splice(1, 0, db.prepare(`INSERT INTO integration_api_keys (id, integration_id, key_id, key_hash, status, valid_from, created_by, created_at) VALUES (?1, ?2, ?3, ?4, 'active', ?5, ?6, ?5)`)
|
|
180
|
+
.bind(crypto.randomUUID(), integrationId, keyId, await sha256(`integration-key-v1:${apiKey}`), timestamp, session.actor.id));
|
|
181
|
+
}
|
|
182
|
+
await db.batch(statements);
|
|
183
|
+
return { integrationId, keyId, apiKey, idempotentReplay: false };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function updateIntegration(
|
|
187
|
+
db: D1Database,
|
|
188
|
+
id: string,
|
|
189
|
+
value: Record<string, unknown>,
|
|
190
|
+
session: OperatorSession,
|
|
191
|
+
idempotencyKey: string,
|
|
192
|
+
): Promise<Record<string, unknown>> {
|
|
193
|
+
const allowed = new Set(["name", "allowed_origins", "scopes", "enabled", "channel_mode", "form_version_id", "context_schema"]);
|
|
194
|
+
if (Object.keys(value).some((key) => !allowed.has(key))) throw new ApiError(400, "integration_update_invalid", "The integration update contains unsupported fields.");
|
|
195
|
+
const current = await db.prepare(`SELECT name, allowed_origins_json AS originsJson, scopes_json AS scopesJson,
|
|
196
|
+
channel_mode AS channelMode, form_version_id AS formVersionId, context_schema_json AS contextSchemaJson, enabled
|
|
197
|
+
FROM application_integrations WHERE id = ?1 LIMIT 1`).bind(id).first<{
|
|
198
|
+
name: string; originsJson: string; scopesJson: string; channelMode: "contextual" | "anonymous";
|
|
199
|
+
formVersionId: string | null; contextSchemaJson: string; enabled: number;
|
|
200
|
+
}>();
|
|
201
|
+
if (!current) throw new ApiError(404, "integration_not_found", "The integration does not exist.");
|
|
202
|
+
if (value.name !== undefined && (typeof value.name !== "string" || value.name.trim().length < 2 || value.name.length > 120)) throw new ApiError(400, "integration_name_invalid", "name is invalid.");
|
|
203
|
+
if (value.enabled !== undefined && typeof value.enabled !== "boolean") throw new ApiError(400, "integration_enabled_invalid", "enabled must be boolean.");
|
|
204
|
+
const origins = value.allowed_origins === undefined ? JSON.parse(current.originsJson) : safeOrigins(value.allowed_origins);
|
|
205
|
+
const scopes = value.scopes === undefined ? JSON.parse(current.scopesJson) : safeScopes(value.scopes);
|
|
206
|
+
const channelMode = value.channel_mode === undefined ? current.channelMode : safeChannelMode(value.channel_mode);
|
|
207
|
+
const formVersionId = value.form_version_id === undefined ? current.formVersionId
|
|
208
|
+
: value.form_version_id === null || value.form_version_id === "" ? null
|
|
209
|
+
: typeof value.form_version_id === "string" ? value.form_version_id.trim() : "";
|
|
210
|
+
if (formVersionId && (formVersionId.length > 100 || !await db.prepare(`SELECT id FROM report_form_versions WHERE id = ?1 AND published_at IS NOT NULL AND retired_at IS NULL LIMIT 1`).bind(formVersionId).first())) {
|
|
211
|
+
throw new ApiError(400, "integration_form_unpublished", "Choose a published report form for this channel.");
|
|
212
|
+
}
|
|
213
|
+
const contextSchema = value.context_schema === undefined
|
|
214
|
+
? parseContextSchema(JSON.parse(current.contextSchemaJson), { allowLegacy: true })
|
|
215
|
+
: parseContextSchema(value.context_schema);
|
|
216
|
+
if (channelMode === "anonymous" && scopes.includes("mint_context_tokens")) {
|
|
217
|
+
throw new ApiError(400, "integration_scope_denied", "Anonymous channels cannot mint authenticated context.");
|
|
218
|
+
}
|
|
219
|
+
const timestamp = now();
|
|
220
|
+
await db.batch([
|
|
221
|
+
db.prepare(`UPDATE application_integrations SET name = ?2, allowed_origins_json = ?3, scopes_json = ?4,
|
|
222
|
+
enabled = ?5, channel_mode = ?6, form_version_id = ?7, context_schema_json = ?8, updated_at = ?9
|
|
223
|
+
WHERE id = ?1`).bind(
|
|
224
|
+
id, typeof value.name === "string" ? value.name.trim() : current.name,
|
|
225
|
+
canonicalJson(origins), canonicalJson(scopes),
|
|
226
|
+
typeof value.enabled === "boolean" ? (value.enabled ? 1 : 0) : current.enabled,
|
|
227
|
+
channelMode, formVersionId, canonicalJson(contextSchemaJson(contextSchema)), timestamp,
|
|
228
|
+
),
|
|
229
|
+
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, 'integration.updated', ?3, ?4, 'application_integration', ?5, ?6, ?7)`).bind(crypto.randomUUID(), `integration-update:${idempotencyKey}`, session.actor.type, session.actor.id, id, canonicalJson({ changedFields: Object.keys(value) }), timestamp),
|
|
230
|
+
]);
|
|
231
|
+
return (await loadIntegrationAdmin(db, id))!;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export async function rotateIntegrationKey(
|
|
235
|
+
db: D1Database,
|
|
236
|
+
id: string,
|
|
237
|
+
overlapSeconds: number,
|
|
238
|
+
session: OperatorSession,
|
|
239
|
+
idempotencyKey: string,
|
|
240
|
+
): Promise<{ integrationId: string; keyId: string; apiKey: string }> {
|
|
241
|
+
if (!Number.isInteger(overlapSeconds) || overlapSeconds < 0 || overlapSeconds > 604_800) throw new ApiError(400, "integration_overlap_invalid", "overlap_seconds must be from 0 to 604800.");
|
|
242
|
+
if (!await db.prepare(`SELECT id FROM application_integrations WHERE id = ?1 LIMIT 1`).bind(id).first()) throw new ApiError(404, "integration_not_found", "The integration does not exist.");
|
|
243
|
+
if (await db.prepare(`SELECT id FROM audit_events WHERE idempotency_key = ?1 LIMIT 1`).bind(`integration-rotate:${idempotencyKey}`).first()) throw new ApiError(409, "integration_key_already_issued", "The replacement key was already issued and cannot be shown again. Rotate again with a new Idempotency-Key.");
|
|
244
|
+
const apiKey = `safest_${randomSecret()}`;
|
|
245
|
+
const keyId = `runtime-${crypto.randomUUID()}`;
|
|
246
|
+
const timestamp = now();
|
|
247
|
+
const oldExpiry = new Date(Date.now() + overlapSeconds * 1_000).toISOString();
|
|
248
|
+
await db.batch([
|
|
249
|
+
db.prepare(`UPDATE integration_api_keys SET expires_at = COALESCE(expires_at, ?2) WHERE integration_id = ?1 AND status = 'active'`).bind(id, oldExpiry),
|
|
250
|
+
db.prepare(`INSERT INTO integration_api_keys (id, integration_id, key_id, key_hash, status, valid_from, created_by, created_at) VALUES (?1, ?2, ?3, ?4, 'active', ?5, ?6, ?5)`).bind(crypto.randomUUID(), id, keyId, await sha256(`integration-key-v1:${apiKey}`), timestamp, session.actor.id),
|
|
251
|
+
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, 'integration.key_rotated', ?3, ?4, 'application_integration', ?5, ?6, ?7)`).bind(crypto.randomUUID(), `integration-rotate:${idempotencyKey}`, session.actor.type, session.actor.id, id, canonicalJson({ keyId, overlapSeconds }), timestamp),
|
|
252
|
+
]);
|
|
253
|
+
return { integrationId: id, keyId, apiKey };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export async function revokeIntegrationKeys(
|
|
257
|
+
db: D1Database,
|
|
258
|
+
id: string,
|
|
259
|
+
keyId: string | null,
|
|
260
|
+
session: OperatorSession,
|
|
261
|
+
idempotencyKey: string,
|
|
262
|
+
): Promise<number> {
|
|
263
|
+
const timestamp = now();
|
|
264
|
+
const changed = await db.prepare(`UPDATE integration_api_keys SET status = 'revoked', revoked_by = ?3, revoked_at = ?4 WHERE integration_id = ?1 AND (?2 IS NULL OR key_id = ?2) AND status = 'active'`)
|
|
265
|
+
.bind(id, keyId, session.actor.id, timestamp).run();
|
|
266
|
+
await 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, 'integration.key_revoked', ?3, ?4, 'application_integration', ?5, ?6, ?7)`)
|
|
267
|
+
.bind(crypto.randomUUID(), `integration-revoke:${idempotencyKey}`, session.actor.type, session.actor.id, id, canonicalJson({ keyId, count: changed.meta.changes }), timestamp).run();
|
|
268
|
+
return changed.meta.changes ?? 0;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export async function verifyFeature(env: Env, feature: string, actorId: string): Promise<Record<string, unknown>> {
|
|
272
|
+
const supported = new Set(["database", "workflow_runtime", "workers_ai", "object_storage", "queues", "email"]);
|
|
273
|
+
if (!supported.has(feature)) throw new ApiError(404, "feature_not_found", "The setup feature does not exist.");
|
|
274
|
+
let ready = false;
|
|
275
|
+
let capability: Record<string, unknown> = {};
|
|
276
|
+
let errorCode: string | null = null;
|
|
277
|
+
try {
|
|
278
|
+
if (feature === "database") { await env.DB.prepare("SELECT 1").first(); ready = true; }
|
|
279
|
+
else if (feature === "workflow_runtime") { ready = Boolean(env.WORKFLOWS && env.LOADER && env.WORKFLOW_ARTIFACTS); capability = { dynamicWorkflows: ready }; }
|
|
280
|
+
else if (feature === "workers_ai") { ready = Boolean((env as unknown as { AI?: unknown }).AI); }
|
|
281
|
+
else if (feature === "object_storage") { ready = Boolean(env.PROFILE_MEDIA && env.WORKFLOW_ARTIFACTS && env.EVIDENCE_OBJECTS && env.EXPORTS); capability = { buckets: ready ? 4 : 0 }; }
|
|
282
|
+
else if (feature === "queues") { ready = Boolean(env.REPORT_JOBS && env.DELIVERY_JOBS && env.CONTROL_JOBS); capability = { producers: ready ? 3 : 0 }; }
|
|
283
|
+
else { ready = Boolean((env as unknown as { EMAIL?: unknown }).EMAIL && env.EMAIL_FROM_ADDRESS); capability = { optional: true }; }
|
|
284
|
+
} catch { errorCode = "feature_probe_failed"; }
|
|
285
|
+
const state = ready ? "ready" : feature === "email" ? "disabled" : "setup_required";
|
|
286
|
+
const timestamp = now();
|
|
287
|
+
await env.DB.prepare(`INSERT INTO installation_features (installation_id, feature_key, state, capability_json, checked_at, error_code, revision) VALUES ('default', ?1, ?2, ?3, ?4, ?5, 1) ON CONFLICT(installation_id, feature_key) DO UPDATE SET state = excluded.state, capability_json = excluded.capability_json, checked_at = excluded.checked_at, error_code = excluded.error_code, revision = installation_features.revision + 1`)
|
|
288
|
+
.bind(feature, state, canonicalJson({ ...capability, checkedBy: actorId }), timestamp, errorCode).run();
|
|
289
|
+
return { feature, state, capability, checkedAt: timestamp, errorCode };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export async function metaVersion(db: D1Database): Promise<Record<string, unknown>> {
|
|
293
|
+
const migration = await db.prepare(`SELECT name FROM d1_migrations ORDER BY id DESC LIMIT 1`).first<{ name: string }>().catch(() => null);
|
|
294
|
+
return {
|
|
295
|
+
application: "0.1.0",
|
|
296
|
+
apiSchema: "1",
|
|
297
|
+
databaseMigration: migration?.name ?? "unknown",
|
|
298
|
+
workflowCompiler: WORKFLOW_COMPILER_VERSION,
|
|
299
|
+
workflowRuntimeProtocol: "1",
|
|
300
|
+
};
|
|
301
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { canonicalJson } from "./audit";
|
|
2
|
+
import { sha256 } from "./report-crypto";
|
|
3
|
+
import { ApiError } from "./report-http";
|
|
4
|
+
import type { ReportSubmission } from "./report-types";
|
|
5
|
+
|
|
6
|
+
export type IntakeDisposition = "accept" | "group" | "quarantine";
|
|
7
|
+
|
|
8
|
+
export interface IntakeProtectionPlan {
|
|
9
|
+
decisionId: string;
|
|
10
|
+
requestId: string;
|
|
11
|
+
disposition: IntakeDisposition;
|
|
12
|
+
reasonCodes: string[];
|
|
13
|
+
networkKeyHash: string | null;
|
|
14
|
+
contactKeyHash: string | null;
|
|
15
|
+
targetKeyHash: string;
|
|
16
|
+
campaignFingerprint: string;
|
|
17
|
+
duplicateGroupId: string;
|
|
18
|
+
canonicalReportId: string | null;
|
|
19
|
+
existingMemberCount: number;
|
|
20
|
+
rateSummary: Record<string, number | string | boolean>;
|
|
21
|
+
surgeState: "normal" | "elevated" | "surge" | "recovery";
|
|
22
|
+
activateSurge: boolean;
|
|
23
|
+
route: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface IntakeCountRow { count: number }
|
|
27
|
+
|
|
28
|
+
function normalized(value: string): string {
|
|
29
|
+
return value.normalize("NFKC").trim().toLowerCase().replace(/\s+/gu, " ").slice(0, 2_048);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function networkPrefix(value: string | null): string | null {
|
|
33
|
+
if (!value) return null;
|
|
34
|
+
const ip = value.trim().toLowerCase();
|
|
35
|
+
if (/^\d{1,3}(?:\.\d{1,3}){3}$/u.test(ip)) return ip.split(".").slice(0, 3).join(".") + ".0/24";
|
|
36
|
+
if (/^[0-9a-f:]+$/u.test(ip) && ip.includes(":")) return ip.split(":").slice(0, 4).join(":") + "::/56";
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function protectedHash(pepper: string, namespace: string, value: string): Promise<string> {
|
|
41
|
+
return `sha256:${await sha256(`intake-${namespace}-v1:${pepper}:${value}`)}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function safeAnswerFingerprint(answers: ReportSubmission["answers"]): Record<string, unknown> {
|
|
45
|
+
return Object.fromEntries(Object.entries(answers).sort(([left], [right]) => left.localeCompare(right)).slice(0, 50).map(([key, value]) => [key, value]));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function planIntakeProtection(
|
|
49
|
+
db: D1Database,
|
|
50
|
+
submission: ReportSubmission,
|
|
51
|
+
input: {
|
|
52
|
+
pepper: string;
|
|
53
|
+
networkAddress?: string | null;
|
|
54
|
+
turnstileOutcome?: string | null;
|
|
55
|
+
surgeThresholdPerFiveMinutes: number;
|
|
56
|
+
},
|
|
57
|
+
): Promise<IntakeProtectionPlan> {
|
|
58
|
+
if (new TextEncoder().encode(input.pepper).byteLength < 32) {
|
|
59
|
+
throw new ApiError(503, "intake_protection_misconfigured", "Report intake protection is not configured.");
|
|
60
|
+
}
|
|
61
|
+
const targetKey = `${normalized(submission.target.type)}:${normalized(submission.target.reference)}`;
|
|
62
|
+
const targetKeyHash = await protectedHash(input.pepper, "target", targetKey);
|
|
63
|
+
const contact = submission.reporter.email ?? submission.reporter.reference;
|
|
64
|
+
const contactKeyHash = contact
|
|
65
|
+
? await protectedHash(input.pepper, "contact", normalized(contact)) : null;
|
|
66
|
+
const prefix = networkPrefix(input.networkAddress ?? null);
|
|
67
|
+
const networkKeyHash = prefix ? await protectedHash(input.pepper, "network", prefix) : null;
|
|
68
|
+
const fingerprintMaterial = canonicalJson({
|
|
69
|
+
target: targetKey,
|
|
70
|
+
reason: normalized(submission.reasonCode),
|
|
71
|
+
answers: safeAnswerFingerprint(submission.answers),
|
|
72
|
+
evidence: [...new Set(submission.evidence.references.map(normalized))].sort(),
|
|
73
|
+
trusted_facts: submission.trustedFacts,
|
|
74
|
+
});
|
|
75
|
+
const campaignFingerprint = await protectedHash(input.pepper, "fingerprint", fingerprintMaterial);
|
|
76
|
+
const duplicateGroupId = `duplicate-v1:${campaignFingerprint.slice("sha256:".length)}`;
|
|
77
|
+
const requestId = await protectedHash(input.pepper, "request", `${submission.integrationId}:${submission.idempotencyKey}`);
|
|
78
|
+
const fiveMinutesAgo = new Date(Date.now() - 5 * 60_000).toISOString();
|
|
79
|
+
const tenMinutesAgo = new Date(Date.now() - 10 * 60_000).toISOString();
|
|
80
|
+
const [overall, network, target, campaign, group, surge] = await Promise.all([
|
|
81
|
+
db.prepare(`SELECT COUNT(*) AS count FROM intake_decisions WHERE installation_id = 'default' AND source = 'public_page' AND created_at >= ?1`).bind(fiveMinutesAgo).first<IntakeCountRow>(),
|
|
82
|
+
networkKeyHash ? db.prepare(`SELECT COUNT(*) AS count FROM intake_decisions WHERE installation_id = 'default' AND network_key_hash = ?1 AND created_at >= ?2`).bind(networkKeyHash, tenMinutesAgo).first<IntakeCountRow>() : Promise.resolve(null),
|
|
83
|
+
db.prepare(`SELECT COUNT(*) AS count FROM intake_decisions WHERE installation_id = 'default' AND target_key_hash = ?1 AND created_at >= ?2`).bind(targetKeyHash, tenMinutesAgo).first<IntakeCountRow>(),
|
|
84
|
+
db.prepare(`SELECT COUNT(*) AS count FROM intake_decisions WHERE installation_id = 'default' AND campaign_fingerprint = ?1 AND created_at >= ?2`).bind(campaignFingerprint, tenMinutesAgo).first<IntakeCountRow>(),
|
|
85
|
+
db.prepare(`SELECT canonical_report_id AS canonicalReportId, member_count AS memberCount FROM duplicate_groups WHERE id = ?1 LIMIT 1`).bind(duplicateGroupId).first<{ canonicalReportId: string | null; memberCount: number }>(),
|
|
86
|
+
db.prepare(`SELECT state FROM abuse_surge_states WHERE installation_id = 'default' LIMIT 1`).first<{ state: "normal" | "elevated" | "surge" | "recovery" }>(),
|
|
87
|
+
]);
|
|
88
|
+
const overallCount = Number(overall?.count ?? 0);
|
|
89
|
+
const networkCount = Number(network?.count ?? 0);
|
|
90
|
+
const targetCount = Number(target?.count ?? 0);
|
|
91
|
+
const campaignCount = Number(campaign?.count ?? 0);
|
|
92
|
+
const threshold = Math.max(25, Math.min(100_000, input.surgeThresholdPerFiveMinutes));
|
|
93
|
+
const activateSurge = submission.source === "public_page" && overallCount >= threshold;
|
|
94
|
+
const surgeState = activateSurge ? "surge" : surge?.state ?? "normal";
|
|
95
|
+
const reasonCodes: string[] = [];
|
|
96
|
+
let disposition: IntakeDisposition = group?.memberCount ? "group" : "accept";
|
|
97
|
+
if (group?.memberCount) reasonCodes.push("duplicate_fingerprint_match");
|
|
98
|
+
if (submission.source === "public_page") {
|
|
99
|
+
if (surgeState === "surge" && (campaignCount >= 1 || targetCount >= 5)) reasonCodes.push("surge_duplicate_quarantine");
|
|
100
|
+
if (campaignCount >= 5) reasonCodes.push("campaign_velocity_quarantine");
|
|
101
|
+
if (targetCount >= 20) reasonCodes.push("target_velocity_quarantine");
|
|
102
|
+
if (networkCount >= 20) reasonCodes.push("network_velocity_quarantine");
|
|
103
|
+
if (reasonCodes.some((code) => code.endsWith("_quarantine"))) disposition = "quarantine";
|
|
104
|
+
}
|
|
105
|
+
if (!reasonCodes.length) reasonCodes.push("intake_policy_passed");
|
|
106
|
+
return {
|
|
107
|
+
decisionId: crypto.randomUUID(),
|
|
108
|
+
requestId,
|
|
109
|
+
disposition,
|
|
110
|
+
reasonCodes,
|
|
111
|
+
networkKeyHash,
|
|
112
|
+
contactKeyHash,
|
|
113
|
+
targetKeyHash,
|
|
114
|
+
campaignFingerprint,
|
|
115
|
+
duplicateGroupId,
|
|
116
|
+
canonicalReportId: group?.canonicalReportId ?? null,
|
|
117
|
+
existingMemberCount: Number(group?.memberCount ?? 0),
|
|
118
|
+
rateSummary: {
|
|
119
|
+
public_five_minute_count: overallCount,
|
|
120
|
+
network_ten_minute_count: networkCount,
|
|
121
|
+
target_ten_minute_count: targetCount,
|
|
122
|
+
campaign_ten_minute_count: campaignCount,
|
|
123
|
+
surge_threshold: threshold,
|
|
124
|
+
turnstile_verified: input.turnstileOutcome === "verified",
|
|
125
|
+
},
|
|
126
|
+
surgeState,
|
|
127
|
+
activateSurge,
|
|
128
|
+
route: disposition !== "quarantine",
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function maintainIntakeProtection(db: D1Database): Promise<void> {
|
|
133
|
+
const timestamp = new Date().toISOString();
|
|
134
|
+
const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60_000).toISOString();
|
|
135
|
+
const sevenDaysAgo = new Date(Date.now() - 7 * 86_400_000).toISOString();
|
|
136
|
+
const tenMinutesAgo = new Date(Date.now() - 10 * 60_000).toISOString();
|
|
137
|
+
await db.batch([
|
|
138
|
+
db.prepare(`DELETE FROM intake_rate_limits WHERE updated_at < ?1`).bind(twoHoursAgo),
|
|
139
|
+
db.prepare(`
|
|
140
|
+
UPDATE intake_decisions SET network_key_hash = NULL, contact_key_hash = NULL
|
|
141
|
+
WHERE created_at < ?1 AND (network_key_hash IS NOT NULL OR contact_key_hash IS NOT NULL)
|
|
142
|
+
`).bind(sevenDaysAgo),
|
|
143
|
+
db.prepare(`
|
|
144
|
+
UPDATE abuse_surge_states SET state = 'recovery', revision = revision + 1,
|
|
145
|
+
updated_by = 'system:intake-guard', updated_at = ?1
|
|
146
|
+
WHERE installation_id = 'default' AND state = 'surge' AND recover_after <= ?1
|
|
147
|
+
AND (SELECT COUNT(*) FROM intake_decisions WHERE source = 'public_page' AND created_at >= ?2) < 25
|
|
148
|
+
`).bind(timestamp, tenMinutesAgo),
|
|
149
|
+
db.prepare(`
|
|
150
|
+
UPDATE abuse_surge_states SET state = 'normal', reason_codes_json = '[]',
|
|
151
|
+
activated_at = NULL, recover_after = NULL, revision = revision + 1,
|
|
152
|
+
updated_by = 'system:intake-guard', updated_at = ?1
|
|
153
|
+
WHERE installation_id = 'default' AND state = 'recovery' AND updated_at < ?2
|
|
154
|
+
`).bind(timestamp, tenMinutesAgo),
|
|
155
|
+
]);
|
|
156
|
+
}
|