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,244 @@
|
|
|
1
|
+
import { ApiError } from "./report-http";
|
|
2
|
+
import type { JsonObject } from "./workflow-platform-types";
|
|
3
|
+
|
|
4
|
+
function object(value: unknown, name: string): Record<string, unknown> {
|
|
5
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
6
|
+
throw new ApiError(400, "invalid_ai_configuration", `${name} must be an object.`);
|
|
7
|
+
}
|
|
8
|
+
return value as Record<string, unknown>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function exact(value: Record<string, unknown>, allowed: string[], name: string): void {
|
|
12
|
+
const unexpected = Object.keys(value).find((key) => !allowed.includes(key));
|
|
13
|
+
if (unexpected) throw new ApiError(400, "invalid_ai_configuration", `${name} contains unsupported field ${unexpected}.`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function text(value: unknown, name: string, minimum: number, maximum: number, pattern?: RegExp): string {
|
|
17
|
+
if (typeof value !== "string" || value.trim().length < minimum || value.trim().length > maximum || (pattern && !pattern.test(value.trim()))) {
|
|
18
|
+
throw new ApiError(400, "invalid_ai_configuration", `${name} is invalid.`);
|
|
19
|
+
}
|
|
20
|
+
return value.trim();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function jsonObject(value: unknown, name: string): JsonObject {
|
|
24
|
+
const result = object(value, name);
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(JSON.stringify(result)) as JsonObject;
|
|
27
|
+
} catch {
|
|
28
|
+
throw new ApiError(400, "invalid_ai_configuration", `${name} must be JSON serializable.`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function schema(value: unknown, name: string): JsonObject {
|
|
33
|
+
const result = jsonObject(value, name);
|
|
34
|
+
if (result.type !== "object") throw new ApiError(400, "invalid_ai_configuration", `${name} must be an object JSON Schema.`);
|
|
35
|
+
assertSupportedSchema(result, name, 0);
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function assertSupportedSchema(value: JsonObject, name: string, depth: number): void {
|
|
40
|
+
if (depth > 8) throw new ApiError(400, "invalid_ai_configuration", `${name} exceeds the schema nesting limit.`);
|
|
41
|
+
const supported = new Set(["type", "properties", "required", "additionalProperties", "items", "enum", "minLength", "maxLength", "minimum", "maximum", "maxItems"]);
|
|
42
|
+
if (Object.keys(value).some((key) => !supported.has(key))) throw new ApiError(400, "invalid_ai_configuration", `${name} uses unsupported JSON Schema features.`);
|
|
43
|
+
const types = new Set(["object", "array", "string", "number", "integer", "boolean", "null"]);
|
|
44
|
+
if (typeof value.type !== "string" || !types.has(value.type)) throw new ApiError(400, "invalid_ai_configuration", `${name}.type is unsupported.`);
|
|
45
|
+
if (value.properties !== undefined) {
|
|
46
|
+
if (!value.properties || typeof value.properties !== "object" || Array.isArray(value.properties) || Object.keys(value.properties).length > 100) {
|
|
47
|
+
throw new ApiError(400, "invalid_ai_configuration", `${name}.properties is invalid.`);
|
|
48
|
+
}
|
|
49
|
+
for (const [key, child] of Object.entries(value.properties)) {
|
|
50
|
+
if (!/^[A-Za-z][A-Za-z0-9_-]{0,99}$/u.test(key) || !child || typeof child !== "object" || Array.isArray(child)) {
|
|
51
|
+
throw new ApiError(400, "invalid_ai_configuration", `${name}.properties contains an invalid entry.`);
|
|
52
|
+
}
|
|
53
|
+
assertSupportedSchema(child as JsonObject, `${name}.properties.${key}`, depth + 1);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (value.items !== undefined) {
|
|
57
|
+
if (!value.items || typeof value.items !== "object" || Array.isArray(value.items)) throw new ApiError(400, "invalid_ai_configuration", `${name}.items is invalid.`);
|
|
58
|
+
assertSupportedSchema(value.items as JsonObject, `${name}.items`, depth + 1);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const keyPattern = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
|
|
63
|
+
const idPattern = /^[A-Za-z0-9._:-]{1,200}$/u;
|
|
64
|
+
|
|
65
|
+
export interface PromptPublicationInput {
|
|
66
|
+
key: string;
|
|
67
|
+
name: string;
|
|
68
|
+
templateText: string;
|
|
69
|
+
variableSchema: JsonObject;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function parsePromptPublication(value: unknown): PromptPublicationInput {
|
|
73
|
+
const input = object(value, "prompt");
|
|
74
|
+
exact(input, ["key", "name", "template_text", "variable_schema"], "prompt");
|
|
75
|
+
const templateText = text(input.template_text, "template_text", 20, 20_000);
|
|
76
|
+
if (/\{\{\{?|<%|\$\{|javascript:/iu.test(templateText)) {
|
|
77
|
+
throw new ApiError(400, "invalid_ai_configuration", "template_text contains an unsupported interpolation form.");
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
key: text(input.key, "key", 2, 80, keyPattern),
|
|
81
|
+
name: text(input.name, "name", 2, 120),
|
|
82
|
+
templateText,
|
|
83
|
+
variableSchema: schema(input.variable_schema ?? { type: "object" }, "variable_schema"),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface ModelAliasVersionInput {
|
|
88
|
+
provider: "workers_ai" | "openai_compatible" | "anthropic" | "google" | "custom_connection";
|
|
89
|
+
model: string;
|
|
90
|
+
connectionVersionId: string | null;
|
|
91
|
+
fallbackAliasVersionId: string | null;
|
|
92
|
+
inputCostMicrousdPerMillion: number;
|
|
93
|
+
outputCostMicrousdPerMillion: number;
|
|
94
|
+
capability: JsonObject;
|
|
95
|
+
dataPolicy: JsonObject;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface ModelAliasCreateInput {
|
|
99
|
+
key: string;
|
|
100
|
+
name: string;
|
|
101
|
+
version: ModelAliasVersionInput;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function nonnegativeInteger(value: unknown, name: string, maximum: number): number {
|
|
105
|
+
if (!Number.isInteger(value) || Number(value) < 0 || Number(value) > maximum) {
|
|
106
|
+
throw new ApiError(400, "invalid_ai_configuration", `${name} must be a non-negative integer at most ${maximum}.`);
|
|
107
|
+
}
|
|
108
|
+
return Number(value);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function parseModelAliasVersion(value: unknown): ModelAliasVersionInput {
|
|
112
|
+
const input = object(value, "model alias version");
|
|
113
|
+
exact(input, [
|
|
114
|
+
"provider", "model", "connection_version_id", "fallback_alias_version_id",
|
|
115
|
+
"input_cost_microusd_per_million", "output_cost_microusd_per_million",
|
|
116
|
+
"capability", "data_policy",
|
|
117
|
+
], "model alias version");
|
|
118
|
+
const providers = new Set(["workers_ai", "openai_compatible", "anthropic", "google", "custom_connection"]);
|
|
119
|
+
if (typeof input.provider !== "string" || !providers.has(input.provider)) {
|
|
120
|
+
throw new ApiError(400, "invalid_ai_configuration", "provider is unsupported.");
|
|
121
|
+
}
|
|
122
|
+
const provider = input.provider as ModelAliasVersionInput["provider"];
|
|
123
|
+
const model = text(input.model, "model", 2, 200, /^[A-Za-z0-9@._:/-]+$/u);
|
|
124
|
+
const connectionVersionId = input.connection_version_id === null || input.connection_version_id === undefined
|
|
125
|
+
? null : text(input.connection_version_id, "connection_version_id", 1, 200, idPattern);
|
|
126
|
+
if (provider === "workers_ai" && (!model.startsWith("@cf/") || connectionVersionId)) {
|
|
127
|
+
throw new ApiError(400, "invalid_ai_configuration", "Workers AI aliases require an @cf model and no connection.");
|
|
128
|
+
}
|
|
129
|
+
if (provider !== "workers_ai" && !connectionVersionId) {
|
|
130
|
+
throw new ApiError(400, "invalid_ai_configuration", "External model aliases require a connection_version_id.");
|
|
131
|
+
}
|
|
132
|
+
const fallbackAliasVersionId = input.fallback_alias_version_id === null || input.fallback_alias_version_id === undefined
|
|
133
|
+
? null : text(input.fallback_alias_version_id, "fallback_alias_version_id", 1, 200, idPattern);
|
|
134
|
+
return {
|
|
135
|
+
provider,
|
|
136
|
+
model,
|
|
137
|
+
connectionVersionId,
|
|
138
|
+
fallbackAliasVersionId,
|
|
139
|
+
inputCostMicrousdPerMillion: nonnegativeInteger(input.input_cost_microusd_per_million ?? 0, "input_cost_microusd_per_million", 1_000_000_000),
|
|
140
|
+
outputCostMicrousdPerMillion: nonnegativeInteger(input.output_cost_microusd_per_million ?? 0, "output_cost_microusd_per_million", 1_000_000_000),
|
|
141
|
+
capability: jsonObject(input.capability ?? {}, "capability"),
|
|
142
|
+
dataPolicy: jsonObject(input.data_policy ?? {}, "data_policy"),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function parseModelAliasCreate(value: unknown): ModelAliasCreateInput {
|
|
147
|
+
const input = object(value, "model alias");
|
|
148
|
+
exact(input, ["key", "name", "version"], "model alias");
|
|
149
|
+
return {
|
|
150
|
+
key: text(input.key, "key", 2, 80, keyPattern),
|
|
151
|
+
name: text(input.name, "name", 2, 120),
|
|
152
|
+
version: parseModelAliasVersion(input.version),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface AgentVersionInput {
|
|
157
|
+
promptVersionId: string;
|
|
158
|
+
modelAliasVersionId: string;
|
|
159
|
+
toolManifest: string[];
|
|
160
|
+
memoryPolicy: JsonObject;
|
|
161
|
+
maximumTurns: number;
|
|
162
|
+
approvalPolicy: JsonObject;
|
|
163
|
+
inputSchema: JsonObject;
|
|
164
|
+
outputSchema: JsonObject;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface AgentCreateInput {
|
|
168
|
+
key: string;
|
|
169
|
+
name: string;
|
|
170
|
+
description: string;
|
|
171
|
+
kind: "routing" | "specialist" | "assistant" | "shadow";
|
|
172
|
+
version: AgentVersionInput;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function parseAgentVersion(value: unknown): AgentVersionInput {
|
|
176
|
+
const input = object(value, "agent version");
|
|
177
|
+
exact(input, [
|
|
178
|
+
"prompt_version_id", "model_alias_version_id", "tool_manifest", "memory_policy",
|
|
179
|
+
"maximum_turns", "approval_policy", "input_schema", "output_schema",
|
|
180
|
+
], "agent version");
|
|
181
|
+
if (!Array.isArray(input.tool_manifest) || input.tool_manifest.length > 8
|
|
182
|
+
|| input.tool_manifest.some((tool) => typeof tool !== "string" || !["read_report_context", "read_findings"].includes(tool))
|
|
183
|
+
|| new Set(input.tool_manifest).size !== input.tool_manifest.length) {
|
|
184
|
+
throw new ApiError(400, "invalid_ai_configuration", "tool_manifest contains unsupported or duplicate tools.");
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
promptVersionId: text(input.prompt_version_id, "prompt_version_id", 1, 200, idPattern),
|
|
188
|
+
modelAliasVersionId: text(input.model_alias_version_id, "model_alias_version_id", 1, 200, idPattern),
|
|
189
|
+
toolManifest: input.tool_manifest as string[],
|
|
190
|
+
memoryPolicy: jsonObject(input.memory_policy ?? {}, "memory_policy"),
|
|
191
|
+
maximumTurns: nonnegativeInteger(input.maximum_turns ?? 1, "maximum_turns", 8) || 1,
|
|
192
|
+
approvalPolicy: jsonObject(input.approval_policy ?? {}, "approval_policy"),
|
|
193
|
+
inputSchema: schema(input.input_schema, "input_schema"),
|
|
194
|
+
outputSchema: schema(input.output_schema, "output_schema"),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function parseAgentCreate(value: unknown): AgentCreateInput {
|
|
199
|
+
const input = object(value, "agent");
|
|
200
|
+
exact(input, ["key", "name", "description", "kind", "version"], "agent");
|
|
201
|
+
if (typeof input.kind !== "string" || !["routing", "specialist", "assistant", "shadow"].includes(input.kind)) {
|
|
202
|
+
throw new ApiError(400, "invalid_ai_configuration", "kind is unsupported.");
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
key: text(input.key, "key", 2, 80, keyPattern),
|
|
206
|
+
name: text(input.name, "name", 2, 120),
|
|
207
|
+
description: typeof input.description === "string" ? input.description.trim().slice(0, 2_000) : "",
|
|
208
|
+
kind: input.kind as AgentCreateInput["kind"],
|
|
209
|
+
version: parseAgentVersion(input.version),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export interface BudgetPublicationInput {
|
|
214
|
+
scopeType: "installation" | "queue" | "workflow" | "component" | "connection" | "agent" | "assistant" | "shadow";
|
|
215
|
+
scopeId: string;
|
|
216
|
+
metric: "reports" | "calls" | "concurrent_calls" | "input_units" | "output_units" | "cost_microusd" | "emails" | "actions" | "evidence_fetches" | "storage_bytes";
|
|
217
|
+
limitValue: number;
|
|
218
|
+
windowSeconds: number;
|
|
219
|
+
behavior: "block" | "fallback" | "defer" | "sample" | "pause";
|
|
220
|
+
fallbackReference: string | null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function parseBudgetPublication(value: unknown): BudgetPublicationInput {
|
|
224
|
+
const input = object(value, "budget");
|
|
225
|
+
exact(input, ["scope_type", "scope_id", "metric", "limit_value", "window_seconds", "behavior", "fallback_reference"], "budget");
|
|
226
|
+
const scopes = ["installation", "queue", "workflow", "component", "connection", "agent", "assistant", "shadow"] as const;
|
|
227
|
+
const metrics = ["reports", "calls", "concurrent_calls", "input_units", "output_units", "cost_microusd", "emails", "actions", "evidence_fetches", "storage_bytes"] as const;
|
|
228
|
+
const behaviors = ["block", "fallback", "defer", "sample", "pause"] as const;
|
|
229
|
+
if (typeof input.scope_type !== "string" || !scopes.includes(input.scope_type as typeof scopes[number])) throw new ApiError(400, "invalid_budget", "scope_type is unsupported.");
|
|
230
|
+
if (typeof input.metric !== "string" || !metrics.includes(input.metric as typeof metrics[number])) throw new ApiError(400, "invalid_budget", "metric is unsupported.");
|
|
231
|
+
if (typeof input.behavior !== "string" || !behaviors.includes(input.behavior as typeof behaviors[number])) throw new ApiError(400, "invalid_budget", "behavior is unsupported.");
|
|
232
|
+
const fallbackReference = input.fallback_reference === null || input.fallback_reference === undefined
|
|
233
|
+
? null : text(input.fallback_reference, "fallback_reference", 1, 200, idPattern);
|
|
234
|
+
if (input.behavior === "fallback" && !fallbackReference) throw new ApiError(400, "invalid_budget", "Fallback behavior requires fallback_reference.");
|
|
235
|
+
return {
|
|
236
|
+
scopeType: input.scope_type as BudgetPublicationInput["scopeType"],
|
|
237
|
+
scopeId: text(input.scope_id, "scope_id", 1, 200, idPattern),
|
|
238
|
+
metric: input.metric as BudgetPublicationInput["metric"],
|
|
239
|
+
limitValue: nonnegativeInteger(input.limit_value, "limit_value", Number.MAX_SAFE_INTEGER),
|
|
240
|
+
windowSeconds: nonnegativeInteger(input.window_seconds, "window_seconds", 2_678_400) || 1,
|
|
241
|
+
behavior: input.behavior as BudgetPublicationInput["behavior"],
|
|
242
|
+
fallbackReference,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { canonicalJson } from "./audit";
|
|
2
|
+
import type {
|
|
3
|
+
AgentCreateInput,
|
|
4
|
+
AgentVersionInput,
|
|
5
|
+
BudgetPublicationInput,
|
|
6
|
+
ModelAliasCreateInput,
|
|
7
|
+
ModelAliasVersionInput,
|
|
8
|
+
PromptPublicationInput,
|
|
9
|
+
} from "./ai-registry-validation";
|
|
10
|
+
import { sha256 } from "./report-crypto";
|
|
11
|
+
import { ApiError } from "./report-http";
|
|
12
|
+
import type { OperatorSession } from "./report-types";
|
|
13
|
+
|
|
14
|
+
interface MutationRow { action: string; actor_id: string; response_json: string }
|
|
15
|
+
|
|
16
|
+
function now(): string { return new Date().toISOString(); }
|
|
17
|
+
|
|
18
|
+
function parsed<T>(value: string, fallback: T): T {
|
|
19
|
+
try { return JSON.parse(value) as T; } catch { return fallback; }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function replay(db: D1Database, key: string, action: string, actorId: string): Promise<Record<string, unknown> | null> {
|
|
23
|
+
const row = await db.prepare(`SELECT action, actor_id, response_json FROM ai_registry_mutations WHERE idempotency_key = ?1 LIMIT 1`)
|
|
24
|
+
.bind(key).first<MutationRow>();
|
|
25
|
+
if (!row) return null;
|
|
26
|
+
if (row.action !== action || row.actor_id !== actorId) throw new ApiError(409, "idempotency_key_reused", "This Idempotency-Key was used for another AI registry mutation.");
|
|
27
|
+
return parsed(row.response_json, {});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function audit(db: D1Database, session: OperatorSession, key: string, action: string, targetType: string, targetId: string, details: unknown, timestamp: string): D1PreparedStatement {
|
|
31
|
+
return db.prepare(`
|
|
32
|
+
INSERT OR IGNORE INTO audit_events (
|
|
33
|
+
id, idempotency_key, action, actor_type, actor_id,
|
|
34
|
+
target_type, target_id, details_json, created_at
|
|
35
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
|
36
|
+
`).bind(crypto.randomUUID(), key, action, session.actor.type, session.actor.id, targetType, targetId, canonicalJson(details), timestamp);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function mutation(db: D1Database, key: string, action: string, actorId: string, targetId: string, response: unknown, timestamp: string): D1PreparedStatement {
|
|
40
|
+
return db.prepare(`
|
|
41
|
+
INSERT INTO ai_registry_mutations (idempotency_key, action, actor_id, target_id, response_json, created_at)
|
|
42
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
|
43
|
+
`).bind(key, action, actorId, targetId, canonicalJson(response), timestamp);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function listPrompts(db: D1Database): Promise<Record<string, unknown>[]> {
|
|
47
|
+
const result = await db.prepare(`
|
|
48
|
+
SELECT id, prompt_key AS key, version, name, prompt_digest AS promptDigest,
|
|
49
|
+
variable_schema_json AS variableSchemaJson, status, published_at AS publishedAt,
|
|
50
|
+
retired_at AS retiredAt, created_by AS createdBy
|
|
51
|
+
FROM prompt_versions WHERE installation_id = 'default'
|
|
52
|
+
ORDER BY prompt_key, version DESC, id DESC
|
|
53
|
+
`).all<Record<string, unknown>>();
|
|
54
|
+
return result.results.map((row) => ({
|
|
55
|
+
...row,
|
|
56
|
+
variableSchema: parsed(String(row.variableSchemaJson ?? "{}"), {}),
|
|
57
|
+
variableSchemaJson: undefined,
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function publishPrompt(
|
|
62
|
+
db: D1Database,
|
|
63
|
+
input: PromptPublicationInput,
|
|
64
|
+
session: OperatorSession,
|
|
65
|
+
idempotencyKey: string,
|
|
66
|
+
): Promise<{ promptVersionId: string; version: number; promptDigest: string; idempotentReplay: boolean }> {
|
|
67
|
+
const existing = await replay(db, idempotencyKey, "prompt.publish", session.actor.id);
|
|
68
|
+
if (existing) return {
|
|
69
|
+
promptVersionId: String(existing.promptVersionId), version: Number(existing.version),
|
|
70
|
+
promptDigest: String(existing.promptDigest), idempotentReplay: true,
|
|
71
|
+
};
|
|
72
|
+
const latest = await db.prepare(`SELECT COALESCE(MAX(version), 0) AS version FROM prompt_versions WHERE installation_id = 'default' AND prompt_key = ?1`)
|
|
73
|
+
.bind(input.key).first<{ version: number }>();
|
|
74
|
+
const version = Number(latest?.version ?? 0) + 1;
|
|
75
|
+
const promptVersionId = crypto.randomUUID();
|
|
76
|
+
const promptDigest = `sha256:${await sha256(canonicalJson({ template: input.templateText, schema: input.variableSchema }))}`;
|
|
77
|
+
const timestamp = now();
|
|
78
|
+
const response = { promptVersionId, version, promptDigest };
|
|
79
|
+
await db.batch([
|
|
80
|
+
db.prepare(`
|
|
81
|
+
INSERT INTO prompt_versions (
|
|
82
|
+
id, installation_id, prompt_key, version, name, template_text,
|
|
83
|
+
variable_schema_json, prompt_digest, status, published_at, created_by, created_at
|
|
84
|
+
) VALUES (?1, 'default', ?2, ?3, ?4, ?5, ?6, ?7, 'published', ?8, ?9, ?8)
|
|
85
|
+
`).bind(promptVersionId, input.key, version, input.name, input.templateText, canonicalJson(input.variableSchema), promptDigest, timestamp, session.actor.id),
|
|
86
|
+
mutation(db, idempotencyKey, "prompt.publish", session.actor.id, promptVersionId, response, timestamp),
|
|
87
|
+
audit(db, session, `prompt:${idempotencyKey}`, "prompt.version_published", "prompt_version", promptVersionId, { key: input.key, version, promptDigest }, timestamp),
|
|
88
|
+
]);
|
|
89
|
+
return { ...response, idempotentReplay: false };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function modelVersionInsert(db: D1Database, aliasId: string, versionId: string, version: number, input: ModelAliasVersionInput, actorId: string, timestamp: string): D1PreparedStatement {
|
|
93
|
+
return db.prepare(`
|
|
94
|
+
INSERT INTO model_alias_versions (
|
|
95
|
+
id, alias_id, version, provider, model, connection_version_id,
|
|
96
|
+
fallback_alias_version_id, input_cost_microusd_per_million,
|
|
97
|
+
output_cost_microusd_per_million, capability_json, data_policy_json,
|
|
98
|
+
published_at, created_by, created_at
|
|
99
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?12)
|
|
100
|
+
`).bind(
|
|
101
|
+
versionId, aliasId, version, input.provider, input.model, input.connectionVersionId,
|
|
102
|
+
input.fallbackAliasVersionId, input.inputCostMicrousdPerMillion,
|
|
103
|
+
input.outputCostMicrousdPerMillion, canonicalJson(input.capability),
|
|
104
|
+
canonicalJson(input.dataPolicy), timestamp, actorId,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function validateModelDependencies(db: D1Database, input: ModelAliasVersionInput, aliasId?: string): Promise<void> {
|
|
109
|
+
if (input.connectionVersionId) {
|
|
110
|
+
const connection = await db.prepare(`
|
|
111
|
+
SELECT v.id FROM connection_versions v JOIN connection_definitions d ON d.id = v.connection_id
|
|
112
|
+
WHERE v.id = ?1 AND v.retired_at IS NULL AND d.status = 'active' LIMIT 1
|
|
113
|
+
`).bind(input.connectionVersionId).first();
|
|
114
|
+
if (!connection) throw new ApiError(400, "model_connection_unavailable", "The model connection version is not active.");
|
|
115
|
+
}
|
|
116
|
+
if (input.fallbackAliasVersionId) {
|
|
117
|
+
const fallback = await db.prepare(`
|
|
118
|
+
SELECT v.alias_id AS aliasId FROM model_alias_versions v JOIN model_aliases a ON a.id = v.alias_id
|
|
119
|
+
WHERE v.id = ?1 AND v.retired_at IS NULL AND a.status = 'active' LIMIT 1
|
|
120
|
+
`).bind(input.fallbackAliasVersionId).first<{ aliasId: string }>();
|
|
121
|
+
if (!fallback || fallback.aliasId === aliasId) throw new ApiError(400, "model_fallback_invalid", "The fallback model alias version is unavailable or circular.");
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function listModelAliases(db: D1Database): Promise<Record<string, unknown>[]> {
|
|
126
|
+
const result = await db.prepare(`
|
|
127
|
+
SELECT a.id, a.alias_key AS key, a.name, a.status, a.active_version_id AS activeVersionId,
|
|
128
|
+
a.created_by AS createdBy, a.created_at AS createdAt, a.updated_at AS updatedAt,
|
|
129
|
+
v.version, v.provider, v.model, v.connection_version_id AS connectionVersionId,
|
|
130
|
+
v.fallback_alias_version_id AS fallbackAliasVersionId,
|
|
131
|
+
v.input_cost_microusd_per_million AS inputCostMicrousdPerMillion,
|
|
132
|
+
v.output_cost_microusd_per_million AS outputCostMicrousdPerMillion,
|
|
133
|
+
v.capability_json AS capabilityJson, v.data_policy_json AS dataPolicyJson,
|
|
134
|
+
v.published_at AS publishedAt
|
|
135
|
+
FROM model_aliases a LEFT JOIN model_alias_versions v ON v.id = a.active_version_id
|
|
136
|
+
WHERE a.installation_id = 'default' ORDER BY a.name, a.id
|
|
137
|
+
`).all<Record<string, unknown>>();
|
|
138
|
+
return result.results.map((row) => ({ ...row, capability: parsed(String(row.capabilityJson ?? "{}"), {}), dataPolicy: parsed(String(row.dataPolicyJson ?? "{}"), {}), capabilityJson: undefined, dataPolicyJson: undefined }));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function createModelAlias(db: D1Database, input: ModelAliasCreateInput, session: OperatorSession, idempotencyKey: string): Promise<{ aliasId: string; versionId: string; version: number; idempotentReplay: boolean }> {
|
|
142
|
+
const existing = await replay(db, idempotencyKey, "model_alias.create", session.actor.id);
|
|
143
|
+
if (existing) return { aliasId: String(existing.aliasId), versionId: String(existing.versionId), version: Number(existing.version), idempotentReplay: true };
|
|
144
|
+
if (await db.prepare(`SELECT id FROM model_aliases WHERE installation_id = 'default' AND alias_key = ?1`).bind(input.key).first()) throw new ApiError(409, "model_alias_key_exists", "A model alias with this key already exists.");
|
|
145
|
+
await validateModelDependencies(db, input.version);
|
|
146
|
+
const aliasId = crypto.randomUUID();
|
|
147
|
+
const versionId = crypto.randomUUID();
|
|
148
|
+
const timestamp = now();
|
|
149
|
+
const response = { aliasId, versionId, version: 1 };
|
|
150
|
+
await db.batch([
|
|
151
|
+
db.prepare(`INSERT INTO model_aliases (id, installation_id, alias_key, name, status, active_version_id, created_by, created_at, updated_at) VALUES (?1, 'default', ?2, ?3, 'active', ?4, ?5, ?6, ?6)`)
|
|
152
|
+
.bind(aliasId, input.key, input.name, versionId, session.actor.id, timestamp),
|
|
153
|
+
modelVersionInsert(db, aliasId, versionId, 1, input.version, session.actor.id, timestamp),
|
|
154
|
+
mutation(db, idempotencyKey, "model_alias.create", session.actor.id, aliasId, response, timestamp),
|
|
155
|
+
audit(db, session, `model-alias:${idempotencyKey}`, "model_alias.created", "model_alias", aliasId, { key: input.key, versionId }, timestamp),
|
|
156
|
+
]);
|
|
157
|
+
return { ...response, idempotentReplay: false };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function publishModelAliasVersion(db: D1Database, aliasId: string, input: ModelAliasVersionInput, session: OperatorSession, idempotencyKey: string): Promise<{ aliasId: string; versionId: string; version: number; idempotentReplay: boolean }> {
|
|
161
|
+
const existing = await replay(db, idempotencyKey, "model_alias.version.publish", session.actor.id);
|
|
162
|
+
if (existing) return { aliasId: String(existing.aliasId), versionId: String(existing.versionId), version: Number(existing.version), idempotentReplay: true };
|
|
163
|
+
const alias = await db.prepare(`SELECT status FROM model_aliases WHERE id = ?1 AND installation_id = 'default' LIMIT 1`).bind(aliasId).first<{ status: string }>();
|
|
164
|
+
if (!alias) throw new ApiError(404, "model_alias_not_found", "The model alias does not exist.");
|
|
165
|
+
if (alias.status === "archived") throw new ApiError(409, "model_alias_archived", "Archived model aliases cannot receive versions.");
|
|
166
|
+
await validateModelDependencies(db, input, aliasId);
|
|
167
|
+
const latest = await db.prepare(`SELECT COALESCE(MAX(version), 0) AS version FROM model_alias_versions WHERE alias_id = ?1`).bind(aliasId).first<{ version: number }>();
|
|
168
|
+
const version = Number(latest?.version ?? 0) + 1;
|
|
169
|
+
const versionId = crypto.randomUUID();
|
|
170
|
+
const timestamp = now();
|
|
171
|
+
const response = { aliasId, versionId, version };
|
|
172
|
+
await db.batch([
|
|
173
|
+
modelVersionInsert(db, aliasId, versionId, version, input, session.actor.id, timestamp),
|
|
174
|
+
db.prepare(`UPDATE model_aliases SET active_version_id = ?2, status = 'active', updated_at = ?3 WHERE id = ?1`).bind(aliasId, versionId, timestamp),
|
|
175
|
+
mutation(db, idempotencyKey, "model_alias.version.publish", session.actor.id, versionId, response, timestamp),
|
|
176
|
+
audit(db, session, `model-alias-version:${idempotencyKey}`, "model_alias.version_published", "model_alias", aliasId, { versionId, version }, timestamp),
|
|
177
|
+
]);
|
|
178
|
+
return { ...response, idempotentReplay: false };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function validateAgentDependencies(db: D1Database, input: AgentVersionInput): Promise<void> {
|
|
182
|
+
const [prompt, model] = await Promise.all([
|
|
183
|
+
db.prepare(`SELECT id FROM prompt_versions WHERE id = ?1 AND installation_id = 'default' AND status = 'published' LIMIT 1`).bind(input.promptVersionId).first(),
|
|
184
|
+
db.prepare(`SELECT v.id FROM model_alias_versions v JOIN model_aliases a ON a.id = v.alias_id WHERE v.id = ?1 AND v.retired_at IS NULL AND a.status = 'active' LIMIT 1`).bind(input.modelAliasVersionId).first(),
|
|
185
|
+
]);
|
|
186
|
+
if (!prompt) throw new ApiError(400, "agent_prompt_unavailable", "The prompt version is not published.");
|
|
187
|
+
if (!model) throw new ApiError(400, "agent_model_unavailable", "The model alias version is not active.");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function agentVersionInsert(db: D1Database, agentId: string, versionId: string, version: number, input: AgentVersionInput, actorId: string, timestamp: string): D1PreparedStatement {
|
|
191
|
+
return db.prepare(`
|
|
192
|
+
INSERT INTO agent_versions (
|
|
193
|
+
id, agent_id, version, prompt_version_id, model_alias_version_id,
|
|
194
|
+
tool_manifest_json, memory_policy_json, maximum_turns, approval_policy_json,
|
|
195
|
+
input_schema_json, output_schema_json, published_at, created_by, created_at
|
|
196
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?12)
|
|
197
|
+
`).bind(
|
|
198
|
+
versionId, agentId, version, input.promptVersionId, input.modelAliasVersionId,
|
|
199
|
+
canonicalJson(input.toolManifest), canonicalJson(input.memoryPolicy), input.maximumTurns,
|
|
200
|
+
canonicalJson(input.approvalPolicy), canonicalJson(input.inputSchema),
|
|
201
|
+
canonicalJson(input.outputSchema), timestamp, actorId,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export async function listAgents(db: D1Database): Promise<Record<string, unknown>[]> {
|
|
206
|
+
const result = await db.prepare(`
|
|
207
|
+
SELECT a.id, a.agent_key AS key, a.name, a.description, a.agent_kind AS kind,
|
|
208
|
+
a.status, a.active_version_id AS activeVersionId, a.created_by AS createdBy,
|
|
209
|
+
a.created_at AS createdAt, a.updated_at AS updatedAt,
|
|
210
|
+
v.version, v.prompt_version_id AS promptVersionId,
|
|
211
|
+
v.model_alias_version_id AS modelAliasVersionId, v.tool_manifest_json AS toolManifestJson,
|
|
212
|
+
v.maximum_turns AS maximumTurns, v.published_at AS publishedAt
|
|
213
|
+
FROM agent_definitions a LEFT JOIN agent_versions v ON v.id = a.active_version_id
|
|
214
|
+
WHERE a.installation_id = 'default' ORDER BY a.name, a.id
|
|
215
|
+
`).all<Record<string, unknown>>();
|
|
216
|
+
return result.results.map((row) => ({ ...row, toolManifest: parsed(String(row.toolManifestJson ?? "[]"), []), toolManifestJson: undefined }));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export async function createAgent(db: D1Database, input: AgentCreateInput, session: OperatorSession, idempotencyKey: string): Promise<{ agentId: string; versionId: string; version: number; idempotentReplay: boolean }> {
|
|
220
|
+
const existing = await replay(db, idempotencyKey, "agent.create", session.actor.id);
|
|
221
|
+
if (existing) return { agentId: String(existing.agentId), versionId: String(existing.versionId), version: Number(existing.version), idempotentReplay: true };
|
|
222
|
+
if (await db.prepare(`SELECT id FROM agent_definitions WHERE installation_id = 'default' AND agent_key = ?1`).bind(input.key).first()) throw new ApiError(409, "agent_key_exists", "An agent with this key already exists.");
|
|
223
|
+
await validateAgentDependencies(db, input.version);
|
|
224
|
+
const agentId = crypto.randomUUID();
|
|
225
|
+
const versionId = crypto.randomUUID();
|
|
226
|
+
const timestamp = now();
|
|
227
|
+
const response = { agentId, versionId, version: 1 };
|
|
228
|
+
await db.batch([
|
|
229
|
+
db.prepare(`INSERT INTO agent_definitions (id, installation_id, agent_key, name, description, agent_kind, status, active_version_id, created_by, created_at, updated_at) VALUES (?1, 'default', ?2, ?3, ?4, ?5, 'active', ?6, ?7, ?8, ?8)`)
|
|
230
|
+
.bind(agentId, input.key, input.name, input.description, input.kind, versionId, session.actor.id, timestamp),
|
|
231
|
+
agentVersionInsert(db, agentId, versionId, 1, input.version, session.actor.id, timestamp),
|
|
232
|
+
mutation(db, idempotencyKey, "agent.create", session.actor.id, agentId, response, timestamp),
|
|
233
|
+
audit(db, session, `agent:${idempotencyKey}`, "agent.created", "agent", agentId, { key: input.key, kind: input.kind, versionId }, timestamp),
|
|
234
|
+
]);
|
|
235
|
+
return { ...response, idempotentReplay: false };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export async function publishAgentVersion(db: D1Database, agentId: string, input: AgentVersionInput, session: OperatorSession, idempotencyKey: string): Promise<{ agentId: string; versionId: string; version: number; idempotentReplay: boolean }> {
|
|
239
|
+
const existing = await replay(db, idempotencyKey, "agent.version.publish", session.actor.id);
|
|
240
|
+
if (existing) return { agentId: String(existing.agentId), versionId: String(existing.versionId), version: Number(existing.version), idempotentReplay: true };
|
|
241
|
+
const agent = await db.prepare(`SELECT status FROM agent_definitions WHERE id = ?1 AND installation_id = 'default' LIMIT 1`).bind(agentId).first<{ status: string }>();
|
|
242
|
+
if (!agent) throw new ApiError(404, "agent_not_found", "The agent does not exist.");
|
|
243
|
+
if (agent.status === "archived") throw new ApiError(409, "agent_archived", "Archived agents cannot receive versions.");
|
|
244
|
+
await validateAgentDependencies(db, input);
|
|
245
|
+
const latest = await db.prepare(`SELECT COALESCE(MAX(version), 0) AS version FROM agent_versions WHERE agent_id = ?1`).bind(agentId).first<{ version: number }>();
|
|
246
|
+
const version = Number(latest?.version ?? 0) + 1;
|
|
247
|
+
const versionId = crypto.randomUUID();
|
|
248
|
+
const timestamp = now();
|
|
249
|
+
const response = { agentId, versionId, version };
|
|
250
|
+
await db.batch([
|
|
251
|
+
agentVersionInsert(db, agentId, versionId, version, input, session.actor.id, timestamp),
|
|
252
|
+
db.prepare(`UPDATE agent_definitions SET active_version_id = ?2, status = 'active', updated_at = ?3 WHERE id = ?1`).bind(agentId, versionId, timestamp),
|
|
253
|
+
mutation(db, idempotencyKey, "agent.version.publish", session.actor.id, versionId, response, timestamp),
|
|
254
|
+
audit(db, session, `agent-version:${idempotencyKey}`, "agent.version_published", "agent", agentId, { versionId, version }, timestamp),
|
|
255
|
+
]);
|
|
256
|
+
return { ...response, idempotentReplay: false };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export async function listBudgets(db: D1Database): Promise<Record<string, unknown>[]> {
|
|
260
|
+
const result = await db.prepare(`
|
|
261
|
+
SELECT p.id, p.scope_type AS scopeType, p.scope_id AS scopeId, p.metric,
|
|
262
|
+
p.limit_value AS limitValue, p.window_seconds AS windowSeconds, p.behavior,
|
|
263
|
+
p.fallback_reference AS fallbackReference, p.published_at AS publishedAt,
|
|
264
|
+
p.retired_at AS retiredAt, p.created_by AS createdBy,
|
|
265
|
+
COALESCE((SELECT SUM(u.reserved_value) FROM budget_usage u WHERE u.policy_id = p.id), 0) AS reservedValue,
|
|
266
|
+
COALESCE((SELECT SUM(u.consumed_value) FROM budget_usage u WHERE u.policy_id = p.id), 0) AS consumedValue
|
|
267
|
+
FROM budget_policies p WHERE p.installation_id = 'default'
|
|
268
|
+
ORDER BY p.published_at DESC, p.id DESC
|
|
269
|
+
`).all<Record<string, unknown>>();
|
|
270
|
+
return result.results;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export async function publishBudget(db: D1Database, input: BudgetPublicationInput, session: OperatorSession, idempotencyKey: string): Promise<{ budgetPolicyId: string; idempotentReplay: boolean }> {
|
|
274
|
+
const existing = await replay(db, idempotencyKey, "budget.publish", session.actor.id);
|
|
275
|
+
if (existing) return { budgetPolicyId: String(existing.budgetPolicyId), idempotentReplay: true };
|
|
276
|
+
if (input.scopeType === "installation" && input.scopeId !== "default") throw new ApiError(400, "budget_scope_invalid", "Installation budgets must use the default installation ID.");
|
|
277
|
+
const budgetPolicyId = crypto.randomUUID();
|
|
278
|
+
const timestamp = now();
|
|
279
|
+
const response = { budgetPolicyId };
|
|
280
|
+
await db.batch([
|
|
281
|
+
db.prepare(`
|
|
282
|
+
INSERT INTO budget_policies (
|
|
283
|
+
id, installation_id, scope_type, scope_id, metric, limit_value,
|
|
284
|
+
window_seconds, behavior, fallback_reference, published_at, created_by, created_at
|
|
285
|
+
) VALUES (?1, 'default', ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?9)
|
|
286
|
+
`).bind(budgetPolicyId, input.scopeType, input.scopeId, input.metric, input.limitValue, input.windowSeconds, input.behavior, input.fallbackReference, timestamp, session.actor.id),
|
|
287
|
+
mutation(db, idempotencyKey, "budget.publish", session.actor.id, budgetPolicyId, response, timestamp),
|
|
288
|
+
audit(db, session, `budget:${idempotencyKey}`, "budget.published", "budget_policy", budgetPolicyId, input, timestamp),
|
|
289
|
+
]);
|
|
290
|
+
return { budgetPolicyId, idempotentReplay: false };
|
|
291
|
+
}
|
|
292
|
+
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { canonicalJson } from "./audit";
|
|
2
|
+
import { ApiError } from "./report-http";
|
|
3
|
+
|
|
4
|
+
const encoder = new TextEncoder();
|
|
5
|
+
const decoder = new TextDecoder();
|
|
6
|
+
|
|
7
|
+
interface CursorEnvelope {
|
|
8
|
+
v: 1;
|
|
9
|
+
scope: string;
|
|
10
|
+
position: Record<string, string | number | boolean | null>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function base64UrlEncode(bytes: Uint8Array): string {
|
|
14
|
+
let binary = "";
|
|
15
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
16
|
+
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/u, "");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function base64UrlDecode(value: string): Uint8Array {
|
|
20
|
+
if (!/^[A-Za-z0-9_-]+$/u.test(value)) throw new Error("invalid_base64url");
|
|
21
|
+
const padded = value.replaceAll("-", "+").replaceAll("_", "/").padEnd(Math.ceil(value.length / 4) * 4, "=");
|
|
22
|
+
const binary = atob(padded);
|
|
23
|
+
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function cursorKey(secret: string, usage: "sign" | "verify"): Promise<CryptoKey> {
|
|
27
|
+
if (encoder.encode(secret).byteLength < 32) throw new Error("Cursor signing secrets must contain at least 32 bytes.");
|
|
28
|
+
return crypto.subtle.importKey(
|
|
29
|
+
"raw",
|
|
30
|
+
encoder.encode(secret),
|
|
31
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
32
|
+
false,
|
|
33
|
+
[usage],
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function invalidCursor(): ApiError {
|
|
38
|
+
return new ApiError(400, "cursor_invalid", "cursor is invalid or does not belong to this query.");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function encodeApiCursor(
|
|
42
|
+
secret: string,
|
|
43
|
+
scope: string,
|
|
44
|
+
position: Record<string, string | number | boolean | null>,
|
|
45
|
+
): Promise<string> {
|
|
46
|
+
if (!scope || scope.length > 500) throw new Error("Cursor scope is invalid.");
|
|
47
|
+
const payload = base64UrlEncode(encoder.encode(canonicalJson({ v: 1, scope, position })));
|
|
48
|
+
const key = await cursorKey(secret, "sign");
|
|
49
|
+
const signature = new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(payload)));
|
|
50
|
+
return `${payload}.${base64UrlEncode(signature)}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function decodeApiCursor<T extends Record<string, string | number | boolean | null>>(
|
|
54
|
+
secret: string,
|
|
55
|
+
scope: string,
|
|
56
|
+
cursor: string | null,
|
|
57
|
+
): Promise<T | null> {
|
|
58
|
+
if (!cursor) return null;
|
|
59
|
+
if (cursor.length > 4_096) throw invalidCursor();
|
|
60
|
+
try {
|
|
61
|
+
const segments = cursor.split(".");
|
|
62
|
+
if (segments.length !== 2) throw new Error("invalid_segments");
|
|
63
|
+
const [payload, signature] = segments as [string, string];
|
|
64
|
+
const key = await cursorKey(secret, "verify");
|
|
65
|
+
const verified = await crypto.subtle.verify(
|
|
66
|
+
"HMAC",
|
|
67
|
+
key,
|
|
68
|
+
base64UrlDecode(signature),
|
|
69
|
+
encoder.encode(payload),
|
|
70
|
+
);
|
|
71
|
+
if (!verified) throw new Error("invalid_signature");
|
|
72
|
+
const decoded = JSON.parse(decoder.decode(base64UrlDecode(payload))) as unknown;
|
|
73
|
+
if (!decoded || typeof decoded !== "object" || Array.isArray(decoded)) throw new Error("invalid_envelope");
|
|
74
|
+
const envelope = decoded as Partial<CursorEnvelope>;
|
|
75
|
+
if (envelope.v !== 1 || envelope.scope !== scope || !envelope.position
|
|
76
|
+
|| typeof envelope.position !== "object" || Array.isArray(envelope.position)) {
|
|
77
|
+
throw new Error("invalid_envelope");
|
|
78
|
+
}
|
|
79
|
+
return envelope.position as T;
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (error instanceof ApiError) throw error;
|
|
82
|
+
throw invalidCursor();
|
|
83
|
+
}
|
|
84
|
+
}
|