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,132 @@
|
|
|
1
|
+
import { ApiError } from "./report-http";
|
|
2
|
+
|
|
3
|
+
export type ContextFieldType = "string" | "number" | "boolean";
|
|
4
|
+
export type ContextFieldSensitivity = "standard" | "sensitive";
|
|
5
|
+
|
|
6
|
+
export interface ContextFieldDefinition {
|
|
7
|
+
key: string;
|
|
8
|
+
label: string;
|
|
9
|
+
type: ContextFieldType;
|
|
10
|
+
required: boolean;
|
|
11
|
+
sensitivity: ContextFieldSensitivity;
|
|
12
|
+
ruleEligible: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ContextSchema {
|
|
16
|
+
version: 1;
|
|
17
|
+
allowUnknown: boolean;
|
|
18
|
+
fields: ContextFieldDefinition[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const FIELD_KEY = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/u;
|
|
22
|
+
const TYPES = new Set<ContextFieldType>(["string", "number", "boolean"]);
|
|
23
|
+
const SENSITIVITIES = new Set<ContextFieldSensitivity>(["standard", "sensitive"]);
|
|
24
|
+
|
|
25
|
+
function object(value: unknown, code: string): Record<string, unknown> {
|
|
26
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
27
|
+
throw new ApiError(400, code, "The trusted-context schema must be an object.");
|
|
28
|
+
}
|
|
29
|
+
return value as Record<string, unknown>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function exactFields(value: Record<string, unknown>, allowed: string[], code: string): void {
|
|
33
|
+
const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
|
|
34
|
+
if (unknown.length) throw new ApiError(400, code, "The trusted-context schema contains unsupported fields.", { fields: unknown.slice(0, 10) });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function parseContextSchema(value: unknown, options: { allowLegacy?: boolean } = {}): ContextSchema {
|
|
38
|
+
if (value === undefined && options.allowLegacy) return { version: 1, allowUnknown: true, fields: [] };
|
|
39
|
+
const input = object(value ?? { version: 1, fields: [] }, "context_schema_invalid");
|
|
40
|
+
exactFields(input, ["version", "allow_unknown", "fields"], "context_schema_invalid");
|
|
41
|
+
if (input.version !== 1) throw new ApiError(400, "context_schema_version_invalid", "The trusted-context schema version must be 1.");
|
|
42
|
+
if (input.allow_unknown !== undefined && typeof input.allow_unknown !== "boolean") {
|
|
43
|
+
throw new ApiError(400, "context_schema_invalid", "allow_unknown must be a boolean.");
|
|
44
|
+
}
|
|
45
|
+
if (!Array.isArray(input.fields) || input.fields.length > 50) {
|
|
46
|
+
throw new ApiError(400, "context_schema_invalid", "The trusted-context schema may contain at most 50 fields.");
|
|
47
|
+
}
|
|
48
|
+
const fields = input.fields.map((raw, index): ContextFieldDefinition => {
|
|
49
|
+
const field = object(raw, "context_schema_field_invalid");
|
|
50
|
+
exactFields(field, ["key", "label", "type", "required", "sensitivity", "rule_eligible"], "context_schema_field_invalid");
|
|
51
|
+
if (typeof field.key !== "string" || !FIELD_KEY.test(field.key)) {
|
|
52
|
+
throw new ApiError(400, "context_schema_field_invalid", `Trusted-context field ${index + 1} has an invalid key.`);
|
|
53
|
+
}
|
|
54
|
+
if (typeof field.label !== "string" || !field.label.trim() || field.label.trim().length > 120) {
|
|
55
|
+
throw new ApiError(400, "context_schema_field_invalid", `Trusted-context field ${field.key} needs a label of 1 to 120 characters.`);
|
|
56
|
+
}
|
|
57
|
+
if (typeof field.type !== "string" || !TYPES.has(field.type as ContextFieldType)) {
|
|
58
|
+
throw new ApiError(400, "context_schema_field_invalid", `Trusted-context field ${field.key} has an unsupported type.`);
|
|
59
|
+
}
|
|
60
|
+
if (field.required !== undefined && typeof field.required !== "boolean") {
|
|
61
|
+
throw new ApiError(400, "context_schema_field_invalid", `Trusted-context field ${field.key} has an invalid required value.`);
|
|
62
|
+
}
|
|
63
|
+
const sensitivity = field.sensitivity ?? "standard";
|
|
64
|
+
if (typeof sensitivity !== "string" || !SENSITIVITIES.has(sensitivity as ContextFieldSensitivity)) {
|
|
65
|
+
throw new ApiError(400, "context_schema_field_invalid", `Trusted-context field ${field.key} has an unsupported sensitivity.`);
|
|
66
|
+
}
|
|
67
|
+
if (field.rule_eligible !== undefined && typeof field.rule_eligible !== "boolean") {
|
|
68
|
+
throw new ApiError(400, "context_schema_field_invalid", `Trusted-context field ${field.key} has an invalid rule_eligible value.`);
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
key: field.key,
|
|
72
|
+
label: field.label.trim(),
|
|
73
|
+
type: field.type as ContextFieldType,
|
|
74
|
+
required: field.required === true,
|
|
75
|
+
sensitivity: sensitivity as ContextFieldSensitivity,
|
|
76
|
+
ruleEligible: field.rule_eligible !== false,
|
|
77
|
+
};
|
|
78
|
+
});
|
|
79
|
+
if (new Set(fields.map((field) => field.key)).size !== fields.length) {
|
|
80
|
+
throw new ApiError(400, "context_schema_field_duplicate", "Trusted-context field keys must be unique.");
|
|
81
|
+
}
|
|
82
|
+
return { version: 1, allowUnknown: input.allow_unknown === true, fields };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function contextSchemaJson(schema: ContextSchema): Record<string, unknown> {
|
|
86
|
+
return {
|
|
87
|
+
version: schema.version,
|
|
88
|
+
allow_unknown: schema.allowUnknown,
|
|
89
|
+
fields: schema.fields.map((field) => ({
|
|
90
|
+
key: field.key,
|
|
91
|
+
label: field.label,
|
|
92
|
+
type: field.type,
|
|
93
|
+
required: field.required,
|
|
94
|
+
sensitivity: field.sensitivity,
|
|
95
|
+
rule_eligible: field.ruleEligible,
|
|
96
|
+
})),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function validateContextFacts(
|
|
101
|
+
facts: Record<string, string | number | boolean | null>,
|
|
102
|
+
schema: ContextSchema,
|
|
103
|
+
): Record<string, string | number | boolean | null> {
|
|
104
|
+
const definitions = new Map(schema.fields.map((field) => [field.key, field]));
|
|
105
|
+
for (const field of schema.fields) {
|
|
106
|
+
if (field.required && (facts[field.key] === undefined || facts[field.key] === null)) {
|
|
107
|
+
throw new ApiError(400, "trusted_context_required", `The customer backend did not provide ${field.label}.`, { field: `trusted_facts.${field.key}` });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
for (const [key, value] of Object.entries(facts)) {
|
|
111
|
+
const field = definitions.get(key);
|
|
112
|
+
if (!field) {
|
|
113
|
+
if (!schema.allowUnknown) throw new ApiError(400, "trusted_context_unknown", `trusted_facts.${key} is not registered for this reporting channel.`, { field: `trusted_facts.${key}` });
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (value !== null && typeof value !== field.type) {
|
|
117
|
+
throw new ApiError(400, "trusted_context_type", `trusted_facts.${key} must be ${field.type}.`, { field: `trusted_facts.${key}` });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return facts;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function routingContextFacts(
|
|
124
|
+
facts: Record<string, string | number | boolean | null>,
|
|
125
|
+
schema: ContextSchema,
|
|
126
|
+
): Record<string, string | number | boolean | null> {
|
|
127
|
+
const definitions = new Map(schema.fields.map((field) => [field.key, field]));
|
|
128
|
+
return Object.fromEntries(Object.entries(facts).filter(([key]) => {
|
|
129
|
+
const definition = definitions.get(key);
|
|
130
|
+
return definition ? definition.ruleEligible : schema.allowUnknown;
|
|
131
|
+
}));
|
|
132
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { canonicalJson } from "./audit";
|
|
2
|
+
import { ApiError } from "./report-http";
|
|
3
|
+
import type { ContextMintInput } from "./report-validation";
|
|
4
|
+
import type { StoredReportContext } from "./report-types";
|
|
5
|
+
|
|
6
|
+
interface ContextRow {
|
|
7
|
+
id: string;
|
|
8
|
+
integration_id: string;
|
|
9
|
+
form_version_id: string;
|
|
10
|
+
origin: string;
|
|
11
|
+
reporter_reference: string | null;
|
|
12
|
+
target_type: string;
|
|
13
|
+
target_reference: string;
|
|
14
|
+
target_owner_reference: string | null;
|
|
15
|
+
target_customer_url: string | null;
|
|
16
|
+
trusted_facts_json: string;
|
|
17
|
+
routing_facts_json: string;
|
|
18
|
+
context_schema_version: number;
|
|
19
|
+
assertion_source: "customer_backend" | "resolve_hosted";
|
|
20
|
+
created_at: string;
|
|
21
|
+
expires_at: string;
|
|
22
|
+
used_at: string | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function decodeFacts(value: string): Record<string, string | number | boolean | null> {
|
|
26
|
+
try {
|
|
27
|
+
const parsed: unknown = JSON.parse(value);
|
|
28
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
29
|
+
&& Object.values(parsed).every((entry) => entry === null || ["string", "number", "boolean"].includes(typeof entry))) {
|
|
30
|
+
return parsed as Record<string, string | number | boolean | null>;
|
|
31
|
+
}
|
|
32
|
+
} catch { /* handled below */ }
|
|
33
|
+
throw new ApiError(503, "report_context_corrupt", "The trusted report context could not be read.");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function project(row: ContextRow): StoredReportContext {
|
|
37
|
+
return {
|
|
38
|
+
id: row.id,
|
|
39
|
+
integrationId: row.integration_id,
|
|
40
|
+
formVersionId: row.form_version_id,
|
|
41
|
+
origin: row.origin,
|
|
42
|
+
reporter: { ...(row.reporter_reference ? { reference: row.reporter_reference } : {}) },
|
|
43
|
+
target: {
|
|
44
|
+
type: row.target_type,
|
|
45
|
+
reference: row.target_reference,
|
|
46
|
+
...(row.target_owner_reference ? { ownerReference: row.target_owner_reference } : {}),
|
|
47
|
+
...(row.target_customer_url ? { customerUrl: row.target_customer_url } : {}),
|
|
48
|
+
},
|
|
49
|
+
trustedFacts: decodeFacts(row.trusted_facts_json),
|
|
50
|
+
routingFacts: decodeFacts(row.routing_facts_json),
|
|
51
|
+
contextSchemaVersion: row.context_schema_version,
|
|
52
|
+
assertionSource: row.assertion_source,
|
|
53
|
+
createdAt: row.created_at,
|
|
54
|
+
expiresAt: row.expires_at,
|
|
55
|
+
usedAt: row.used_at,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function createStoredReportContext(
|
|
60
|
+
db: D1Database,
|
|
61
|
+
input: ContextMintInput & { origin: string },
|
|
62
|
+
options: {
|
|
63
|
+
integrationId: string;
|
|
64
|
+
formVersionId: string;
|
|
65
|
+
contextSchemaVersion: number;
|
|
66
|
+
routingFacts: Record<string, string | number | boolean | null>;
|
|
67
|
+
expiresAt: string;
|
|
68
|
+
timestamp?: string;
|
|
69
|
+
},
|
|
70
|
+
): Promise<StoredReportContext> {
|
|
71
|
+
const id = crypto.randomUUID();
|
|
72
|
+
const timestamp = options.timestamp ?? new Date().toISOString();
|
|
73
|
+
await db.prepare(`
|
|
74
|
+
INSERT INTO report_contexts (
|
|
75
|
+
id, installation_id, integration_id, form_version_id, origin,
|
|
76
|
+
reporter_reference, target_type, target_reference, target_owner_reference,
|
|
77
|
+
target_customer_url, trusted_facts_json, routing_facts_json, context_schema_version,
|
|
78
|
+
assertion_source, created_at, expires_at
|
|
79
|
+
) VALUES (?1, 'default', ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 'customer_backend', ?13, ?14)
|
|
80
|
+
`).bind(
|
|
81
|
+
id, options.integrationId, options.formVersionId, input.origin,
|
|
82
|
+
input.reporter.reference ?? null, input.target.type, input.target.reference,
|
|
83
|
+
input.target.ownerReference ?? null, input.target.customerUrl ?? null,
|
|
84
|
+
canonicalJson(input.trustedFacts), canonicalJson(options.routingFacts), options.contextSchemaVersion, timestamp, options.expiresAt,
|
|
85
|
+
).run();
|
|
86
|
+
return {
|
|
87
|
+
id,
|
|
88
|
+
integrationId: options.integrationId,
|
|
89
|
+
formVersionId: options.formVersionId,
|
|
90
|
+
origin: input.origin,
|
|
91
|
+
reporter: input.reporter,
|
|
92
|
+
target: input.target,
|
|
93
|
+
trustedFacts: input.trustedFacts,
|
|
94
|
+
routingFacts: options.routingFacts,
|
|
95
|
+
contextSchemaVersion: options.contextSchemaVersion,
|
|
96
|
+
assertionSource: "customer_backend",
|
|
97
|
+
createdAt: timestamp,
|
|
98
|
+
expiresAt: options.expiresAt,
|
|
99
|
+
usedAt: null,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function loadStoredReportContext(
|
|
104
|
+
db: D1Database,
|
|
105
|
+
contextId: string,
|
|
106
|
+
now = new Date().toISOString(),
|
|
107
|
+
): Promise<StoredReportContext> {
|
|
108
|
+
const row = await db.prepare(`
|
|
109
|
+
SELECT id, integration_id, form_version_id, origin, reporter_reference,
|
|
110
|
+
target_type, target_reference, target_owner_reference, target_customer_url,
|
|
111
|
+
trusted_facts_json, routing_facts_json, context_schema_version, assertion_source,
|
|
112
|
+
created_at, expires_at, used_at
|
|
113
|
+
FROM report_contexts WHERE id = ?1 LIMIT 1
|
|
114
|
+
`).bind(contextId).first<ContextRow>();
|
|
115
|
+
if (!row || row.expires_at <= now) throw new ApiError(401, "report_context_expired", "This report window expired. Close it and try again.");
|
|
116
|
+
return project(row);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function pruneReportContexts(db: D1Database, now = new Date().toISOString()): Promise<number> {
|
|
120
|
+
const usedRetentionCutoff = new Date(Date.parse(now) - 86_400_000).toISOString();
|
|
121
|
+
const result = await db.prepare(`
|
|
122
|
+
DELETE FROM report_contexts
|
|
123
|
+
WHERE expires_at < ?1 AND (report_id IS NULL OR used_at < ?2)
|
|
124
|
+
`).bind(now, usedRetentionCutoff).run();
|
|
125
|
+
return result.meta.changes ?? 0;
|
|
126
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { ApiError } from "./report-http";
|
|
2
|
+
import type { ContextClaims, ParticipantAudience } from "./report-types";
|
|
3
|
+
|
|
4
|
+
const encoder = new TextEncoder();
|
|
5
|
+
const decoder = new TextDecoder();
|
|
6
|
+
const contextHeader = { alg: "HS256", typ: "SECT", v: 2 } as const;
|
|
7
|
+
|
|
8
|
+
function base64UrlEncode(bytes: Uint8Array): string {
|
|
9
|
+
let binary = "";
|
|
10
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
11
|
+
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/u, "");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function base64UrlDecode(value: string): Uint8Array {
|
|
15
|
+
if (!/^[A-Za-z0-9_-]+$/u.test(value)) throw new ApiError(401, "invalid_context_token", "The signed context token is invalid.");
|
|
16
|
+
const padded = value.replaceAll("-", "+").replaceAll("_", "/").padEnd(Math.ceil(value.length / 4) * 4, "=");
|
|
17
|
+
try {
|
|
18
|
+
const binary = atob(padded);
|
|
19
|
+
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
20
|
+
} catch {
|
|
21
|
+
throw new ApiError(401, "invalid_context_token", "The signed context token is invalid.");
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function hmac(secret: string, message: string): Promise<Uint8Array> {
|
|
26
|
+
if (encoder.encode(secret).byteLength < 32) throw new Error("Signing secrets must contain at least 32 bytes.");
|
|
27
|
+
const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
28
|
+
return new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(message)));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function constantTimeEqual(left: Uint8Array, right: Uint8Array): boolean {
|
|
32
|
+
let difference = left.byteLength ^ right.byteLength;
|
|
33
|
+
const length = Math.max(left.byteLength, right.byteLength);
|
|
34
|
+
for (let index = 0; index < length; index += 1) difference |= (left[index] ?? 0) ^ (right[index] ?? 0);
|
|
35
|
+
return difference === 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function signContextToken(claims: ContextClaims, secret: string): Promise<string> {
|
|
39
|
+
const header = base64UrlEncode(encoder.encode(JSON.stringify(contextHeader)));
|
|
40
|
+
const payload = base64UrlEncode(encoder.encode(JSON.stringify(claims)));
|
|
41
|
+
const signingInput = `${header}.${payload}`;
|
|
42
|
+
return `${signingInput}.${base64UrlEncode(await hmac(secret, signingInput))}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function verifyContextToken(
|
|
46
|
+
token: string,
|
|
47
|
+
secret: string,
|
|
48
|
+
expectedIssuer: string,
|
|
49
|
+
now = Math.floor(Date.now() / 1000),
|
|
50
|
+
): Promise<ContextClaims> {
|
|
51
|
+
if (token.length > 16 * 1024) throw new ApiError(401, "invalid_context_token", "The signed context token is invalid.");
|
|
52
|
+
const segments = token.split(".");
|
|
53
|
+
if (segments.length !== 3) throw new ApiError(401, "invalid_context_token", "The signed context token is invalid.");
|
|
54
|
+
const [header, payload, signature] = segments as [string, string, string];
|
|
55
|
+
const expected = await hmac(secret, `${header}.${payload}`);
|
|
56
|
+
if (!constantTimeEqual(base64UrlDecode(signature), expected)) {
|
|
57
|
+
throw new ApiError(401, "invalid_context_signature", "The signed context token signature is invalid.");
|
|
58
|
+
}
|
|
59
|
+
let parsedHeader: unknown;
|
|
60
|
+
let parsedClaims: unknown;
|
|
61
|
+
try {
|
|
62
|
+
parsedHeader = JSON.parse(decoder.decode(base64UrlDecode(header)));
|
|
63
|
+
parsedClaims = JSON.parse(decoder.decode(base64UrlDecode(payload)));
|
|
64
|
+
} catch {
|
|
65
|
+
throw new ApiError(401, "invalid_context_token", "The signed context token is invalid.");
|
|
66
|
+
}
|
|
67
|
+
if (!parsedHeader || typeof parsedHeader !== "object" || Array.isArray(parsedHeader)
|
|
68
|
+
|| (parsedHeader as Record<string, unknown>).alg !== "HS256"
|
|
69
|
+
|| (parsedHeader as Record<string, unknown>).typ !== "SECT"
|
|
70
|
+
|| (parsedHeader as Record<string, unknown>).v !== 2) {
|
|
71
|
+
throw new ApiError(401, "invalid_context_header", "The signed context token header is invalid.");
|
|
72
|
+
}
|
|
73
|
+
if (!parsedClaims || typeof parsedClaims !== "object" || Array.isArray(parsedClaims)) {
|
|
74
|
+
throw new ApiError(401, "invalid_context_claims", "The signed context token claims are invalid.");
|
|
75
|
+
}
|
|
76
|
+
const claims = parsedClaims as Partial<ContextClaims>;
|
|
77
|
+
if (claims.version !== 2 || claims.aud !== "safest-resolve-reports" || claims.iss !== expectedIssuer
|
|
78
|
+
|| typeof claims.integrationId !== "string" || typeof claims.contextId !== "string"
|
|
79
|
+
|| typeof claims.formVersionId !== "string" || typeof claims.jti !== "string"
|
|
80
|
+
|| typeof claims.iat !== "number" || typeof claims.exp !== "number"
|
|
81
|
+
|| claims.iat > now + 30 || claims.exp <= now || claims.exp - claims.iat > 900
|
|
82
|
+
|| typeof claims.origin !== "string") {
|
|
83
|
+
throw new ApiError(401, "invalid_context_claims", "The signed context token claims are invalid or expired.");
|
|
84
|
+
}
|
|
85
|
+
return claims as ContextClaims;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function sha256(value: string): Promise<string> {
|
|
89
|
+
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(value)));
|
|
90
|
+
return [...digest].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function reportContactKey(secret: string): Promise<CryptoKey> {
|
|
94
|
+
if (encoder.encode(secret).byteLength < 32) {
|
|
95
|
+
throw new ApiError(503, "contact_encryption_unavailable", "Reporter contact encryption is not configured.");
|
|
96
|
+
}
|
|
97
|
+
return crypto.subtle.importKey(
|
|
98
|
+
"raw",
|
|
99
|
+
await crypto.subtle.digest("SHA-256", encoder.encode(secret)),
|
|
100
|
+
{ name: "AES-GCM" },
|
|
101
|
+
false,
|
|
102
|
+
["encrypt", "decrypt"],
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function encryptReportContact(secret: string, email: string, reportId: string, audience: ParticipantAudience): Promise<string> {
|
|
107
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
108
|
+
const context = encoder.encode(`report-contact:v1:${reportId}:${audience}`);
|
|
109
|
+
const ciphertext = new Uint8Array(await crypto.subtle.encrypt(
|
|
110
|
+
{ name: "AES-GCM", iv, additionalData: context },
|
|
111
|
+
await reportContactKey(secret),
|
|
112
|
+
encoder.encode(email),
|
|
113
|
+
));
|
|
114
|
+
return `v1.${base64UrlEncode(iv)}.${base64UrlEncode(ciphertext)}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function decryptReportContact(secret: string, value: string, reportId: string, audience: ParticipantAudience): Promise<string> {
|
|
118
|
+
const parts = value.split(".");
|
|
119
|
+
if (parts.length !== 3 || parts[0] !== "v1") {
|
|
120
|
+
throw new ApiError(503, "contact_decryption_failed", "Stored reporter contact details could not be decrypted.");
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
const plaintext = await crypto.subtle.decrypt(
|
|
124
|
+
{ name: "AES-GCM", iv: base64UrlDecode(parts[1]!), additionalData: encoder.encode(`report-contact:v1:${reportId}:${audience}`) },
|
|
125
|
+
await reportContactKey(secret),
|
|
126
|
+
base64UrlDecode(parts[2]!),
|
|
127
|
+
);
|
|
128
|
+
return decoder.decode(plaintext);
|
|
129
|
+
} catch {
|
|
130
|
+
throw new ApiError(503, "contact_decryption_failed", "Stored reporter contact details could not be decrypted.");
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function secretsEqual(supplied: string, expected: string): Promise<boolean> {
|
|
135
|
+
const [left, right] = await Promise.all([sha256(supplied), sha256(expected)]);
|
|
136
|
+
return constantTimeEqual(encoder.encode(left), encoder.encode(right));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function randomPublicReference(): string {
|
|
140
|
+
const bytes = crypto.getRandomValues(new Uint8Array(12));
|
|
141
|
+
return `RPT-${base64UrlEncode(bytes).toUpperCase()}`;
|
|
142
|
+
}
|