okengine 0.21.1 → 0.23.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/AGENTS.md +11 -4
- package/LICENSE +202 -0
- package/NOTICE +2 -0
- package/README.md +15 -9
- package/manifest.v1.schema.json +54 -0
- package/package.json +53 -38
- package/site/content/docs/ai/mcp.mdx +13 -9
- package/site/content/docs/ai/skills.mdx +2 -2
- package/site/content/docs/client/calling.mdx +27 -19
- package/site/content/docs/client/index.mdx +1 -1
- package/site/content/docs/elements/ai/agents.mdx +61 -13
- package/site/content/docs/elements/ai/decide.mdx +536 -0
- package/site/content/docs/elements/ai/events.mdx +373 -0
- package/site/content/docs/elements/ai/index.mdx +9 -3
- package/site/content/docs/elements/ai/meta.json +1 -1
- package/site/content/docs/elements/ai/prompts.mdx +4 -2
- package/site/content/docs/elements/channel/email.mdx +1 -1
- package/site/content/docs/elements/channel/index.mdx +1 -1
- package/site/content/docs/elements/flow/http.mdx +4 -4
- package/site/content/docs/elements/flow/index.mdx +3 -3
- package/site/content/docs/elements/flow/meta.json +1 -1
- package/site/content/docs/elements/gate/auth.mdx +4 -1
- package/site/content/docs/elements/gate/tenancy.mdx +1 -1
- package/site/content/docs/elements/store/index.mdx +1 -1
- package/site/content/docs/elements/store/kv.mdx +5 -5
- package/site/content/docs/elements/vault/rotation.mdx +15 -0
- package/site/content/docs/plugins/otp.mdx +31 -31
- package/site/content/docs/recipes/dragonfly.mdx +5 -5
- package/site/content/docs/recipes/index.mdx +6 -6
- package/site/content/docs/recipes/mailpit.mdx +1 -1
- package/site/content/docs/recipes/meilisearch.mdx +1 -1
- package/site/content/docs/recipes/pgdog.mdx +1 -1
- package/site/content/docs/recipes/postgres.mdx +12 -8
- package/site/content/docs/recipes/redis.mdx +4 -4
- package/site/content/docs/reference/cli.mdx +2 -1
- package/site/content/docs/reference/configuration.mdx +11 -9
- package/site/content/docs/reference/errors.mdx +12 -2
- package/site/content/docs/reference/fx.mdx +35 -21
- package/site/content/docs/reference/idempotency.mdx +192 -0
- package/site/content/docs/reference/index.mdx +6 -1
- package/site/content/docs/reference/meta.json +2 -1
- package/site/content/docs/understand/meta.json +1 -1
- package/site/content/docs/{elements/flow → understand}/routing.mdx +11 -10
- package/site/content/docs/understand/the-architecture.mdx +14 -12
- package/site/content/docs/understand/try-it.mdx +3 -3
- package/src/auth/api-key-sql.test.ts +44 -0
- package/src/auth/api-key-sql.ts +1 -0
- package/src/auth/api-keys.ts +30 -12
- package/src/auth/config.ts +6 -2
- package/src/auth/gate-auth.test.ts +20 -0
- package/src/cli/agents-md.test.ts +25 -0
- package/src/cli/ask-vault-gaps.test.ts +38 -1
- package/src/cli/ask-vault-gaps.ts +35 -2
- package/src/cli/competitor-mention-removal.test.ts +3 -3
- package/src/cli/decide.ts +174 -0
- package/src/cli/decision-lock-watch.test.ts +45 -0
- package/src/cli/decision-lock-watch.ts +51 -0
- package/src/cli/dev-app-runner.ts +34 -8
- package/src/cli/dev-auth-secret.test.ts +49 -0
- package/src/cli/dev-auth-secret.ts +49 -0
- package/src/cli/dev-hot.test.ts +41 -0
- package/src/cli/dev-hot.ts +48 -0
- package/src/cli/dev.test.ts +1 -1
- package/src/cli/dev.ts +24 -6
- package/src/cli/docker-cli.test.ts +1 -1
- package/src/cli/eval.ts +142 -1
- package/src/cli/index.ts +14 -1
- package/src/cli/load-config.images.test.ts +12 -12
- package/src/cli/load-config.ts +2 -2
- package/src/cli/mcp-from-console.ts +48 -0
- package/src/cli/registry.ts +35 -1
- package/src/cli/vault-cmd.test.ts +23 -0
- package/src/cli/vault-cmd.ts +7 -0
- package/src/client/agent.ts +307 -0
- package/src/client/create.ts +18 -1
- package/src/client/transport.test.ts +245 -0
- package/src/client/transport.ts +133 -19
- package/src/client/types.ts +29 -3
- package/src/client-react/index.ts +7 -1
- package/src/client-react/use-agent-run.test.ts +271 -0
- package/src/client-react/use-agent-run.ts +193 -0
- package/src/compiler/ai-approval.test.ts +36 -0
- package/src/compiler/ai-repair.test.ts +22 -0
- package/src/compiler/decisions.extract.test.ts +164 -0
- package/src/compiler/effects-infer.ts +753 -17
- package/src/compiler/extract.test.ts +37 -0
- package/src/compiler/extract.ts +381 -24
- package/src/compiler/fixtures/skyport.expected.json +24 -0
- package/src/compiler/fx-follow.test.ts +164 -0
- package/src/compiler/fx-index.ts +399 -0
- package/src/compiler/response.ts +35 -2
- package/src/config/index.ts +21 -3
- package/src/console/server/ai-runs-flows.ts +269 -0
- package/src/console/server/ai-runs.test.ts +323 -0
- package/src/console/server/ai-runs.ts +562 -0
- package/src/console/server/ai.ts +12 -1
- package/src/console/server/app.ts +10 -1
- package/src/console/server/decisions-flows.ts +157 -0
- package/src/console/server/decisions.test.ts +66 -0
- package/src/console/server/decisions.ts +229 -0
- package/src/console/server/flows-invoke.test.ts +2 -2
- package/src/console/server/flows.ts +50 -2
- package/src/console/server/invoke-user-flow.ts +9 -0
- package/src/console/server/runs-ingest.ts +16 -0
- package/src/console/server/serve.ts +5 -0
- package/src/console/server/state.ts +25 -1
- package/src/console/server/store-stats.test.ts +47 -0
- package/src/console/server/store-stats.ts +92 -18
- package/src/console/ui-next/dist/assets/access-page-DDKkhT9v.js +4 -0
- package/src/console/ui-next/dist/assets/agent-disclosure-62Xts8Xi.js +1 -0
- package/src/console/ui-next/dist/assets/{cache-glyph-92uM5MO7.js → cache-glyph-DyeoKHKA.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button-DtGVPtcs.js → call-pii-button-DvfpLXsU.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-DN7l6zmC.js → collapsible-BY03SeCg.js} +1 -1
- package/src/console/ui-next/dist/assets/confirm-sheet-DZfuJXP4.js +1 -0
- package/src/console/ui-next/dist/assets/copy-inline-button-BTjPVjRT.js +1 -0
- package/src/console/ui-next/dist/assets/decisions-page-CgkKlA56.js +1 -0
- package/src/console/ui-next/dist/assets/detail-header-Dk5LipF6.js +1 -0
- package/src/console/ui-next/dist/assets/dropdown-menu-CRGrj5JU.js +1 -0
- package/src/console/ui-next/dist/assets/duration-tone-Y6HLjyJ5.js +9 -0
- package/src/console/ui-next/dist/assets/element-icons-C6t8Vlml.js +1 -0
- package/src/console/ui-next/dist/assets/explorer-chrome-Dslh2yE8.js +1 -0
- package/src/console/ui-next/dist/assets/explorer-empty-CIVg7Jgb.js +1 -0
- package/src/console/ui-next/dist/assets/flows-page-DLPfNy-_.js +1 -0
- package/src/console/ui-next/dist/assets/{highlighted-json-BlAEVgNW.js → highlighted-json-D7nNzpgB.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-BDf7OAHv.js → http-method-DdL19zzh.js} +1 -1
- package/src/console/ui-next/dist/assets/index-BlrN49Hs.css +2 -0
- package/src/console/ui-next/dist/assets/index-C0lc_s9d.js +58 -0
- package/src/console/ui-next/dist/assets/observability-page-CRlcyLCC.js +8 -0
- package/src/console/ui-next/dist/assets/{replica-lag-B3GLNVfF.js → replica-lag-Bb3FWUu9.js} +7 -7
- package/src/console/ui-next/dist/assets/request-meta-DYBMWhX8.js +1 -0
- package/src/console/ui-next/dist/assets/shortcut-keys-DTE1sjTC.js +1 -0
- package/src/console/ui-next/dist/assets/store-page-CcE-SXC8.js +41 -0
- package/src/console/ui-next/dist/assets/trace-detail-sheet-CKMSEQ4s.js +2 -0
- package/src/console/ui-next/dist/assets/tree-expand-toggle-QOfk0M0H.js +55 -0
- package/src/console/ui-next/dist/assets/units-page-DKC1CDDd.js +1 -0
- package/src/console/ui-next/dist/assets/use-vault-list-BAyQG8Re.js +1 -0
- package/src/console/ui-next/dist/assets/vault-page-B54v4Yit.js +2 -0
- package/src/console/ui-next/dist/index.html +4 -3
- package/src/console/ui-next/seed-parked-approval.ts +126 -0
- package/src/console/ui-next/src/client.ts +224 -1
- package/src/console/ui-next/src/components/explorer/detail-header.tsx +9 -0
- package/src/console/ui-next/src/components/explorer/explorer-start-toggle.tsx +5 -1
- package/src/console/ui-next/src/components/ui/sheet-form.tsx +21 -12
- package/src/console/ui-next/src/features/flows/decisions/decisions-page.tsx +216 -0
- package/src/console/ui-next/src/features/flows/decisions/resolve.test.ts +48 -0
- package/src/console/ui-next/src/features/flows/decisions/resolve.ts +111 -0
- package/src/console/ui-next/src/features/flows/flows-page.tsx +10 -0
- package/src/console/ui-next/src/features/flows/graph/element-map.ts +1 -0
- package/src/console/ui-next/src/features/flows/traces/effect-kind.ts +6 -0
- package/src/console/ui-next/src/features/flows/traces/effect-summary.test.ts +24 -0
- package/src/console/ui-next/src/features/flows/traces/effect-summary.ts +24 -0
- package/src/console/ui-next/src/features/flows/traces/http-status.ts +58 -0
- package/src/console/ui-next/src/features/flows/traces/request-client-card.tsx +355 -0
- package/src/console/ui-next/src/features/flows/traces/request-client.test.ts +114 -0
- package/src/console/ui-next/src/features/flows/traces/request-client.ts +423 -0
- package/src/console/ui-next/src/features/flows/traces/request-input-view.test.ts +52 -6
- package/src/console/ui-next/src/features/flows/traces/request-input-view.ts +85 -19
- package/src/console/ui-next/src/features/flows/traces/trace-detail-sheet.tsx +505 -80
- package/src/console/ui-next/src/features/flows/traces/trace-lanes.test.ts +61 -0
- package/src/console/ui-next/src/features/flows/traces/trace-lanes.ts +78 -0
- package/src/console/ui-next/src/features/flows/traces/trace-nav.test.ts +48 -0
- package/src/console/ui-next/src/features/flows/traces/trace-nav.ts +57 -0
- package/src/console/ui-next/src/features/flows/traces/trace-request-section.tsx +275 -96
- package/src/console/ui-next/src/features/flows/traces/traces-pane.tsx +16 -2
- package/src/console/ui-next/src/features/observability/detail/ai-runs.tsx +369 -0
- package/src/console/ui-next/src/features/observability/observability-page.tsx +31 -1
- package/src/console/ui-next/src/features/observability/state/observability-selection.ts +18 -3
- package/src/console/ui-next/src/features/store/performance/kv-performance-panel.tsx +12 -1
- package/src/console/ui-next/src/features/store/performance/performance-panel.tsx +14 -1
- package/src/console/ui-next/src/features/units/call/call-api-panel.tsx +1 -1
- package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +15 -2
- package/src/console/ui-next/src/features/units/explorer/units-tree.tsx +73 -18
- package/src/console/ui-next/src/features/units/lib/unit-tree.test.ts +21 -0
- package/src/console/ui-next/src/features/units/lib/unit-tree.ts +82 -0
- package/src/console/ui-next/src/features/units/units-page.tsx +25 -7
- package/src/console/ui-next/src/router.tsx +17 -0
- package/src/console/ui-next/ui-next-seed-runs.ts +23 -0
- package/src/console/ui-next/vite-console-kernel-plugin.ts +43 -3
- package/src/console/ui-next/vite.config.ts +9 -0
- package/src/docker/docker.test.ts +13 -13
- package/src/docker/images-config.test.ts +12 -12
- package/src/docker/recipes/dragonfly.ts +3 -2
- package/src/docker/recipes/redis.ts +1 -1
- package/src/docker/stack-id.test.ts +1 -1
- package/src/drivers/ai-anthropic.ts +212 -21
- package/src/drivers/ai-mock.ts +34 -1
- package/src/drivers/ai-openai-compatible.ts +67 -26
- package/src/drivers/ai-preconnect.ts +18 -0
- package/src/drivers/ai-stream.test.ts +62 -0
- package/src/drivers/ai-types.ts +20 -0
- package/src/drivers/journal-postgres.ts +455 -3
- package/src/drivers/meilisearch.ts +2 -0
- package/src/drivers/memory-ddl.test.ts +18 -0
- package/src/drivers/memory.ts +21 -1
- package/src/drivers/pg-rls.test.ts +31 -0
- package/src/drivers/pg-rls.ts +15 -0
- package/src/drivers/postgres.test.ts +9 -0
- package/src/drivers/postgres.ts +49 -11
- package/src/drivers/vault-builtin.test.ts +10 -0
- package/src/drivers/vault-builtin.ts +13 -1
- package/src/drivers/vault-remote-bag.ts +5 -1
- package/src/drivers/vault-types.ts +6 -1
- package/src/elements/ai/agent-event-slot.ts +27 -0
- package/src/elements/ai/approval-http.ts +207 -0
- package/src/elements/ai/approval.test.ts +741 -0
- package/src/elements/ai/approval.ts +228 -0
- package/src/elements/ai/decisions/bind.ts +163 -0
- package/src/elements/ai/decisions/certificate.test.ts +291 -0
- package/src/elements/ai/decisions/certificate.ts +377 -0
- package/src/elements/ai/decisions/certify.ts +314 -0
- package/src/elements/ai/decisions/decide.test.ts +953 -0
- package/src/elements/ai/decisions/e2e.test.ts +399 -0
- package/src/elements/ai/decisions/export.test.ts +120 -0
- package/src/elements/ai/decisions/export.ts +146 -0
- package/src/elements/ai/decisions/fixtures/openrouter-request.json +24 -0
- package/src/elements/ai/decisions/fixtures/openrouter-response.json +22 -0
- package/src/elements/ai/decisions/fixtures/typesafe-request.json +19 -0
- package/src/elements/ai/decisions/fixtures/typesafe-response.json +13 -0
- package/src/elements/ai/decisions/http.test.ts +154 -0
- package/src/elements/ai/decisions/http.ts +240 -0
- package/src/elements/ai/decisions/labels.test.ts +282 -0
- package/src/elements/ai/decisions/labels.ts +257 -0
- package/src/elements/ai/decisions/openrouter.live.test.ts +136 -0
- package/src/elements/ai/decisions/openrouter.ts +36 -0
- package/src/elements/ai/decisions/provider.ts +102 -0
- package/src/elements/ai/decisions/typesafe.ts +36 -0
- package/src/elements/ai/declare.ts +252 -1
- package/src/elements/ai/events.test.ts +379 -0
- package/src/elements/ai/events.ts +138 -0
- package/src/elements/ai/run-events.test.ts +461 -0
- package/src/elements/ai/run-events.ts +522 -0
- package/src/elements/ai/runtime.ts +1186 -105
- package/src/elements/ai/stream-turn.ts +214 -0
- package/src/elements/ai/stream.live.test.ts +76 -0
- package/src/elements/ai/subagent.test.ts +136 -0
- package/src/elements/ai.test.ts +497 -3
- package/src/elements/ai.ts +16 -1
- package/src/elements/clock/durable.ts +27 -1
- package/src/elements/store/emit-drizzle.ts +2 -2
- package/src/elements/store/live-default.test.ts +2 -0
- package/src/elements/store/live-http.test.ts +1 -0
- package/src/elements/store/resource-list-docs.test.ts +2 -2
- package/src/elements/store/resource.test.ts +3 -3
- package/src/elements/store/schema-decl.test.ts +1 -0
- package/src/elements/store/sql-condition.ts +24 -7
- package/src/elements/store/sql-session.test.ts +51 -0
- package/src/elements/store/sql-session.ts +111 -1
- package/src/elements/store/upsert-app.test.ts +1 -1
- package/src/elements/vault/audit.test.ts +137 -0
- package/src/elements/vault/audit.ts +106 -13
- package/src/elements/vault/boot-chain.ts +9 -1
- package/src/elements/vault/builtin-adapter.ts +33 -3
- package/src/i18n/catalogs/ar.ts +9 -0
- package/src/i18n/catalogs/en.ts +9 -0
- package/src/index.ts +2 -0
- package/src/kernel/abort-scope.ts +33 -2
- package/src/kernel/agent-event-store.ts +448 -0
- package/src/kernel/app.ts +294 -19
- package/src/kernel/auto-cache.test.ts +6 -6
- package/src/kernel/boot-bind/ai.ts +3 -0
- package/src/kernel/boot-bind/store.test.ts +26 -0
- package/src/kernel/boot-bind/store.ts +82 -6
- package/src/kernel/boot-bind/vault.ts +1 -0
- package/src/kernel/boot.ts +34 -5
- package/src/kernel/builtin-errors.ts +10 -0
- package/src/kernel/capability.ts +3 -0
- package/src/kernel/client-descriptor.ts +1 -0
- package/src/kernel/concurrency.test.ts +33 -0
- package/src/kernel/decision-label-store.ts +441 -0
- package/src/kernel/effects-stamping.test.ts +2 -2
- package/src/kernel/effects.test.ts +2 -1
- package/src/kernel/effects.ts +13 -1
- package/src/kernel/element-registries.ts +3 -0
- package/src/kernel/errors-compiler.ts +20 -0
- package/src/kernel/errors-text.ts +4 -0
- package/src/kernel/errors.ts +2 -0
- package/src/kernel/external-effects.test.ts +36 -0
- package/src/kernel/flow.test.ts +2 -3
- package/src/kernel/flow.ts +24 -0
- package/src/kernel/fx-decide.ts +761 -0
- package/src/kernel/fx-fetch.ts +5 -1
- package/src/kernel/fx.test.ts +12 -5
- package/src/kernel/fx.ts +316 -29
- package/src/kernel/http-frame.test.ts +94 -0
- package/src/kernel/http-frame.ts +203 -0
- package/src/kernel/idempotency-store.ts +578 -0
- package/src/kernel/idempotency.test.ts +556 -0
- package/src/kernel/idempotency.ts +304 -0
- package/src/kernel/index.ts +1 -0
- package/src/kernel/journal.ts +160 -0
- package/src/kernel/json-result.ts +10 -0
- package/src/kernel/pipeline.test.ts +45 -0
- package/src/kernel/sse-id.ts +32 -0
- package/src/manifest/diff.ts +1 -0
- package/src/manifest/types.ts +37 -2
- package/src/mcp/ai-tools.test.ts +202 -0
- package/src/mcp/authorization.ts +66 -0
- package/src/mcp/session.ts +3 -0
- package/src/mcp/tools.ts +168 -0
- package/src/plugins/auth-delivery.mailpit.integration.test.ts +1 -1
- package/src/plugins/otp.test.ts +65 -1
- package/src/plugins/otp.ts +59 -7
- package/src/release/absolute-regression.test.ts +63 -0
- package/src/release/http-graph.test.ts +27 -0
- package/src/release/http-graph.ts +95 -0
- package/src/release/index.ts +3 -0
- package/src/release/limits.ts +17 -2
- package/src/release/measure.ts +144 -28
- package/src/runs/collect.ts +4 -1
- package/src/runs/parquet.test.ts +27 -1
- package/src/runs/parquet.ts +19 -0
- package/src/runs/types.ts +31 -0
- package/src/runtime/json-code-block.test.ts +106 -47
- package/src/runtime/json-code-block.ts +1209 -46
- package/src/term.test.ts +13 -0
- package/src/term.ts +81 -49
- package/src/test/create-test-app.test.ts +3 -1
- package/src/test/create-test-app.ts +40 -0
- package/src/test/live-signals.test.ts +1 -0
- package/src/test/provisions.integration.test.ts +1 -0
- package/src/test/tenant-isolation.test.ts +1 -0
- package/src/console/ui-next/dist/assets/PlusSignIcon-CwG3nxfu.js +0 -1
- package/src/console/ui-next/dist/assets/access-page-Bgt9bq2r.js +0 -4
- package/src/console/ui-next/dist/assets/agent-disclosure-B43CXZZR.js +0 -1
- package/src/console/ui-next/dist/assets/copy-inline-button-CAYD18cr.js +0 -1
- package/src/console/ui-next/dist/assets/detail-header-DVWNjWjg.js +0 -1
- package/src/console/ui-next/dist/assets/dropdown-menu-4h2LOVXM.js +0 -1
- package/src/console/ui-next/dist/assets/duration-tone-BmIR9FV8.js +0 -9
- package/src/console/ui-next/dist/assets/element-icons-BI8cJgdh.js +0 -1
- package/src/console/ui-next/dist/assets/explorer-empty-CJs5A-wm.js +0 -1
- package/src/console/ui-next/dist/assets/flows-page-BMHs-IzK.js +0 -1
- package/src/console/ui-next/dist/assets/index-BKpaes3n.js +0 -63
- package/src/console/ui-next/dist/assets/index-VxoEz295.css +0 -2
- package/src/console/ui-next/dist/assets/observability-page-WnVLI-0j.js +0 -4
- package/src/console/ui-next/dist/assets/request-meta-DMbnAe3f.js +0 -1
- package/src/console/ui-next/dist/assets/shortcut-keys-3ILd8oGn.js +0 -1
- package/src/console/ui-next/dist/assets/store-page-KvFDingJ.js +0 -41
- package/src/console/ui-next/dist/assets/trace-detail-sheet-Htk8Cm9t.js +0 -2
- package/src/console/ui-next/dist/assets/tree-expand-toggle-CFMPWX4f.js +0 -55
- package/src/console/ui-next/dist/assets/units-page-C4NdNuxP.js +0 -1
- package/src/console/ui-next/dist/assets/use-vault-list-CT4-gajj.js +0 -1
- package/src/console/ui-next/dist/assets/vault-page-CBYl0LW_.js +0 -2
|
@@ -156,7 +156,7 @@ describe("resource HTTP — exact keys + PII with differing TS/SQL names", () =>
|
|
|
156
156
|
|
|
157
157
|
test("app.fetch list/get/create/update: only createdAt + masked email, never raw SQL keys", async () => {
|
|
158
158
|
const app = buildApp();
|
|
159
|
-
const t = await createTestApp(app);
|
|
159
|
+
const t = await createTestApp(app, { capability: "open" });
|
|
160
160
|
|
|
161
161
|
// create → 201
|
|
162
162
|
const created = await app.fetch(
|
|
@@ -234,7 +234,7 @@ describe("resource HTTP — exact keys + PII with differing TS/SQL names", () =>
|
|
|
234
234
|
|
|
235
235
|
test("list cursor meta walks next and previous", async () => {
|
|
236
236
|
const app = buildApp();
|
|
237
|
-
const t = await createTestApp(app);
|
|
237
|
+
const t = await createTestApp(app, { capability: "open" });
|
|
238
238
|
for (const email of ["a@example.com", "b@example.com", "c@example.com"]) {
|
|
239
239
|
const created = await app.fetch(
|
|
240
240
|
new Request("http://localhost/contacts", {
|
|
@@ -317,7 +317,7 @@ describe("resource HTTP — exact keys + PII with differing TS/SQL names", () =>
|
|
|
317
317
|
config: { drivers: { store: { sql: { test: "pglite" } } } },
|
|
318
318
|
});
|
|
319
319
|
|
|
320
|
-
const t = await createTestApp(app);
|
|
320
|
+
const t = await createTestApp(app, { capability: "open" });
|
|
321
321
|
const created = await app.fetch(
|
|
322
322
|
new Request("http://localhost/people", {
|
|
323
323
|
method: "POST",
|
|
@@ -289,6 +289,7 @@ describe("store.schema RLS extras", () => {
|
|
|
289
289
|
"scope_booking_create_insert",
|
|
290
290
|
]);
|
|
291
291
|
const src = emitDrizzleSource([bookings], "postgres");
|
|
292
|
+
expect(src).toContain("pgTable.withRLS");
|
|
292
293
|
expect(src).toContain("pgPolicy");
|
|
293
294
|
expect(src).toContain("oke.gate() = ");
|
|
294
295
|
expect(src).toContain("oke.user()");
|
|
@@ -215,6 +215,8 @@ function compileSqlNode(node: unknown): CompiledNode {
|
|
|
215
215
|
|
|
216
216
|
// Group level: only structural parens and ` and ` / ` or ` separators may
|
|
217
217
|
// appear next to nested conditions — anything else is a loud failure.
|
|
218
|
+
// `1.0.0-rc.5-5935859` glues the parens onto the separator (`"(("`,
|
|
219
|
+
// `") and ("`, `"))`).
|
|
218
220
|
let joiner: "AND" | "OR" | undefined;
|
|
219
221
|
for (const chunk of chunks) {
|
|
220
222
|
if (isDrizzleSql(chunk)) continue;
|
|
@@ -222,14 +224,13 @@ function compileSqlNode(node: unknown): CompiledNode {
|
|
|
222
224
|
if (text === undefined) {
|
|
223
225
|
throw new TypeError("sql where: unsupported mixed condition");
|
|
224
226
|
}
|
|
225
|
-
const
|
|
226
|
-
if (
|
|
227
|
-
if (
|
|
228
|
-
|
|
229
|
-
if (joiner !== undefined && joiner !== next) {
|
|
227
|
+
const sep = groupSeparator(text);
|
|
228
|
+
if (sep === "skip") continue;
|
|
229
|
+
if (sep === "AND" || sep === "OR") {
|
|
230
|
+
if (joiner !== undefined && joiner !== sep) {
|
|
230
231
|
throw new TypeError("sql where: mixed AND/OR at one level — parenthesize explicitly");
|
|
231
232
|
}
|
|
232
|
-
joiner =
|
|
233
|
+
joiner = sep;
|
|
233
234
|
continue;
|
|
234
235
|
}
|
|
235
236
|
throw new TypeError(`sql where: unsupported fragment ${JSON.stringify(text)}`);
|
|
@@ -329,6 +330,22 @@ function compileLeaf(chunks: readonly unknown[]): CompiledNode {
|
|
|
329
330
|
};
|
|
330
331
|
}
|
|
331
332
|
|
|
333
|
+
/**
|
|
334
|
+
* Classify a group-level string chunk.
|
|
335
|
+
*
|
|
336
|
+
* Empty or parenthesis-only text is structural. `and` / `or` may sit inside
|
|
337
|
+
* the parentheses (`") and ("`). Anything else is not a separator.
|
|
338
|
+
*
|
|
339
|
+
* @param text - Raw `StringChunk` text
|
|
340
|
+
*/
|
|
341
|
+
function groupSeparator(text: string): "skip" | "AND" | "OR" | undefined {
|
|
342
|
+
const token = text.trim().toLowerCase().replace(/[()]/g, "").trim();
|
|
343
|
+
if (token === "") return "skip";
|
|
344
|
+
if (token === "and") return "AND";
|
|
345
|
+
if (token === "or") return "OR";
|
|
346
|
+
return undefined;
|
|
347
|
+
}
|
|
348
|
+
|
|
332
349
|
function chunksOf(node: unknown): readonly unknown[] {
|
|
333
350
|
return (node as { queryChunks: unknown[] }).queryChunks;
|
|
334
351
|
}
|
|
@@ -353,7 +370,7 @@ function isStringChunk(chunk: object): boolean {
|
|
|
353
370
|
/**
|
|
354
371
|
* Joined text of a Drizzle `StringChunk`.
|
|
355
372
|
*
|
|
356
|
-
* drizzle-orm `1.0.0-rc.5-
|
|
373
|
+
* drizzle-orm `1.0.0-rc.5-5935859` stores `value` as a `string`. Earlier
|
|
357
374
|
* snapshots (and the synthetic chunks in `resource.ts`) store `string[]`.
|
|
358
375
|
*/
|
|
359
376
|
function chunkText(chunk: unknown): string | undefined {
|
|
@@ -156,6 +156,57 @@ describe("SqlStoreHandle — upsert", () => {
|
|
|
156
156
|
expect(row).toEqual({ id: "welcome", title: "Hello", createdAt: 1 });
|
|
157
157
|
});
|
|
158
158
|
|
|
159
|
+
test("primary-key insert and update each hit the connection once", async () => {
|
|
160
|
+
let queries = 0;
|
|
161
|
+
const counting = {
|
|
162
|
+
driverId: sharedConn.driverId,
|
|
163
|
+
role: sharedConn.role,
|
|
164
|
+
query(sql: string, params?: readonly unknown[]) {
|
|
165
|
+
queries += 1;
|
|
166
|
+
return sharedConn.query(sql, params);
|
|
167
|
+
},
|
|
168
|
+
exec: (sql: string, params?: readonly unknown[]) => sharedConn.exec(sql, params),
|
|
169
|
+
close: () => sharedConn.close(),
|
|
170
|
+
} satisfies SqlConnection;
|
|
171
|
+
const handle = createSqlStoreHandle("sql:app", {
|
|
172
|
+
connection: counting,
|
|
173
|
+
classifications: new Map(),
|
|
174
|
+
routedRole: "primary",
|
|
175
|
+
domainDdl: "off",
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
queries = 0;
|
|
179
|
+
const inserted = await handle.upsert(
|
|
180
|
+
posts,
|
|
181
|
+
{ id: "once" },
|
|
182
|
+
{ id: "once", title: "a", createdAt: 1 },
|
|
183
|
+
);
|
|
184
|
+
expect(inserted.status).toBe("upserted");
|
|
185
|
+
expect(queries).toBe(1);
|
|
186
|
+
|
|
187
|
+
queries = 0;
|
|
188
|
+
const updated = await handle.upsert(
|
|
189
|
+
posts,
|
|
190
|
+
{ id: "once" },
|
|
191
|
+
{ id: "once", title: "b", createdAt: 2 },
|
|
192
|
+
{ onExisting: "update" },
|
|
193
|
+
);
|
|
194
|
+
expect(updated.status).toBe("changed");
|
|
195
|
+
expect(queries).toBe(1);
|
|
196
|
+
|
|
197
|
+
const row = await sharedHandle.findById(posts, "once");
|
|
198
|
+
expect(row).toEqual({ id: "once", title: "b", createdAt: 2 });
|
|
199
|
+
|
|
200
|
+
queries = 0;
|
|
201
|
+
const other = await handle.upsert(
|
|
202
|
+
posts,
|
|
203
|
+
{ title: "missing" },
|
|
204
|
+
{ id: "other", title: "missing", createdAt: 3 },
|
|
205
|
+
);
|
|
206
|
+
expect(other.status).toBe("upserted");
|
|
207
|
+
expect(queries).toBe(2);
|
|
208
|
+
});
|
|
209
|
+
|
|
159
210
|
test("onExisting update changes matched columns", async () => {
|
|
160
211
|
await sharedHandle.upsert(posts, { id: "n1" }, { id: "n1", title: "one", createdAt: 10 });
|
|
161
212
|
const updated = await sharedHandle.upsert(
|
|
@@ -424,7 +424,9 @@ export interface SqlStoreHandle {
|
|
|
424
424
|
exists(table: TableHandle | unknown, idOrWhere: string | WhereMap): Promise<boolean>;
|
|
425
425
|
/**
|
|
426
426
|
* Insert when no row matches `matchOn`; never touches an existing match
|
|
427
|
-
* unless `options.onExisting` is `"update"`.
|
|
427
|
+
* unless `options.onExisting` is `"update"`. A match that is only the
|
|
428
|
+
* primary key is one `INSERT … ON CONFLICT` round trip on postgres and
|
|
429
|
+
* pglite. Memory SQL, and any other predicate, stays a read then a write.
|
|
428
430
|
*
|
|
429
431
|
* @param table - Table
|
|
430
432
|
* @param matchOn - Equality map or Drizzle condition identifying the row
|
|
@@ -685,6 +687,101 @@ export function createSqlStoreHandle(
|
|
|
685
687
|
return coerceCompiledWhere(table, compileWhere(where));
|
|
686
688
|
}
|
|
687
689
|
|
|
690
|
+
/**
|
|
691
|
+
* Primary-key equality only. Any other predicate cannot use `ON CONFLICT`.
|
|
692
|
+
*
|
|
693
|
+
* @param compiled - Match clause
|
|
694
|
+
* @param pkCol - Primary key SQL name
|
|
695
|
+
*/
|
|
696
|
+
function primaryKeyEquality(
|
|
697
|
+
compiled: ReturnType<typeof compileWhere>,
|
|
698
|
+
pkCol: string,
|
|
699
|
+
): { readonly value: unknown } | undefined {
|
|
700
|
+
if (compiled.predicates.length !== 1) return undefined;
|
|
701
|
+
const pred = compiled.predicates[0];
|
|
702
|
+
if (!pred || pred.op !== "=" || pred.column !== pkCol) return undefined;
|
|
703
|
+
return { value: pred.value };
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* One round trip: `INSERT … ON CONFLICT (pk)`.
|
|
708
|
+
*
|
|
709
|
+
* `DO NOTHING` when the existing row must stay. `DO UPDATE` captures the
|
|
710
|
+
* pre-image in a CTE so CDC still sees before and after.
|
|
711
|
+
*
|
|
712
|
+
* @param table - Table
|
|
713
|
+
* @param name - SQL table name
|
|
714
|
+
* @param pkCol - Primary key SQL name
|
|
715
|
+
* @param pkValue - Bound primary key
|
|
716
|
+
* @param values - Insert / update payload
|
|
717
|
+
* @param onExisting - `"update"` rewrites the existing row
|
|
718
|
+
*/
|
|
719
|
+
async function upsertOnPrimaryKey(
|
|
720
|
+
table: TableHandle | unknown,
|
|
721
|
+
name: string,
|
|
722
|
+
pkCol: string,
|
|
723
|
+
pkValue: unknown,
|
|
724
|
+
values: SqlRow,
|
|
725
|
+
onExisting: "update" | undefined,
|
|
726
|
+
): Promise<UpsertResult> {
|
|
727
|
+
const prepared = prepareInsertRow(table, values);
|
|
728
|
+
if (!(pkCol in prepared)) prepared[pkCol] = pkValue;
|
|
729
|
+
const cols = Object.keys(prepared);
|
|
730
|
+
const colList = cols.map(quoteIdent).join(", ");
|
|
731
|
+
const placeholders = cols.map(() => "?").join(", ");
|
|
732
|
+
const params = cols.map((col) => prepared[col]);
|
|
733
|
+
const quotedPk = quoteIdent(pkCol);
|
|
734
|
+
const quotedName = quoteIdent(name);
|
|
735
|
+
const setEntries =
|
|
736
|
+
onExisting === "update"
|
|
737
|
+
? Object.entries(prepareUpdateRow(table, values)).filter(([col]) => col !== pkCol)
|
|
738
|
+
: [];
|
|
739
|
+
if (onExisting !== "update" || setEntries.length === 0) {
|
|
740
|
+
const rows = await query(
|
|
741
|
+
`INSERT INTO ${quotedName} (${colList}) VALUES (${placeholders})` +
|
|
742
|
+
` ON CONFLICT (${quotedPk}) DO NOTHING RETURNING *`,
|
|
743
|
+
params,
|
|
744
|
+
);
|
|
745
|
+
if (rows.length === 0) {
|
|
746
|
+
return { status: onExisting === "update" ? "changed" : "already-existed" };
|
|
747
|
+
}
|
|
748
|
+
notifySqlCdc({
|
|
749
|
+
tableName: name,
|
|
750
|
+
op: "insert",
|
|
751
|
+
before: null,
|
|
752
|
+
after: toJs(table, rows)[0] ?? null,
|
|
753
|
+
});
|
|
754
|
+
return { status: "upserted" };
|
|
755
|
+
}
|
|
756
|
+
const setSql = setEntries
|
|
757
|
+
.map(([col]) => `${quoteIdent(col)} = EXCLUDED.${quoteIdent(col)}`)
|
|
758
|
+
.join(", ");
|
|
759
|
+
// The old row is a CTE, then read from an outer SELECT. INSERT …
|
|
760
|
+
// RETURNING cannot see that CTE on PGlite, so the before image would
|
|
761
|
+
// be null and an update would look like an insert.
|
|
762
|
+
const result = await query(
|
|
763
|
+
`WITH __oke_old AS (` +
|
|
764
|
+
`SELECT * FROM ${quotedName} WHERE ${quotedPk} = ? FOR UPDATE` +
|
|
765
|
+
`), __oke_new AS (` +
|
|
766
|
+
`INSERT INTO ${quotedName} (${colList}) VALUES (${placeholders})` +
|
|
767
|
+
` ON CONFLICT (${quotedPk}) DO UPDATE SET ${setSql}` +
|
|
768
|
+
` RETURNING *` +
|
|
769
|
+
`) SELECT (SELECT row_to_json(__oke_old) FROM __oke_old) AS __oke_before_data,` +
|
|
770
|
+
` (SELECT row_to_json(__oke_new) FROM __oke_new) AS __oke_after_data`,
|
|
771
|
+
[pkValue, ...params],
|
|
772
|
+
);
|
|
773
|
+
const raw = result[0];
|
|
774
|
+
const before = raw ? jsonImageToJs(table, raw.__oke_before_data) : null;
|
|
775
|
+
const after = raw ? jsonImageToJs(table, raw.__oke_after_data) : null;
|
|
776
|
+
notifySqlCdc({
|
|
777
|
+
tableName: name,
|
|
778
|
+
op: before ? "update" : "insert",
|
|
779
|
+
before,
|
|
780
|
+
after,
|
|
781
|
+
});
|
|
782
|
+
return { status: before ? "changed" : "upserted" };
|
|
783
|
+
}
|
|
784
|
+
|
|
688
785
|
/**
|
|
689
786
|
* Postgres `row_to_json` images come back as JSON strings on pglite and
|
|
690
787
|
* objects on Bun.SQL — normalize to the JS row mapping (snake→camel).
|
|
@@ -1026,6 +1123,19 @@ export function createSqlStoreHandle(
|
|
|
1026
1123
|
if (!compiled.clause) {
|
|
1027
1124
|
throw new Error("upsert() requires at least one matchOn predicate");
|
|
1028
1125
|
}
|
|
1126
|
+
const pkCol = resolvePkColumn(table);
|
|
1127
|
+
const pkMatch = primaryKeyEquality(compiled, pkCol);
|
|
1128
|
+
// Memory SQL has no ON CONFLICT. Postgres and PGlite do.
|
|
1129
|
+
if (pkMatch && connection.driverId !== "memory") {
|
|
1130
|
+
return upsertOnPrimaryKey(
|
|
1131
|
+
table,
|
|
1132
|
+
name,
|
|
1133
|
+
pkCol,
|
|
1134
|
+
pkMatch.value,
|
|
1135
|
+
values,
|
|
1136
|
+
upsertOptions?.onExisting,
|
|
1137
|
+
);
|
|
1138
|
+
}
|
|
1029
1139
|
const found = await query(
|
|
1030
1140
|
`SELECT 1 AS "ok" FROM ${quoteIdent(name)} WHERE ${compiled.clause} LIMIT 1`,
|
|
1031
1141
|
compiled.params,
|
|
@@ -65,7 +65,7 @@ describe("fx.store(db).upsert via app.fetch (real boot)", () => {
|
|
|
65
65
|
gate: { policies: [gate.public] },
|
|
66
66
|
}).adopt({ seed });
|
|
67
67
|
Object.assign(app.$options, { env: "test", stores: [db], unguardedHttp: "allow" });
|
|
68
|
-
await createTestApp(app);
|
|
68
|
+
await createTestApp(app, { capability: "open" });
|
|
69
69
|
|
|
70
70
|
async function post(body: unknown) {
|
|
71
71
|
const res = await app.fetch(
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audit sinks — secret-free stdout/webhook payloads, and chain commands
|
|
3
|
+
* that exist only for the `db` sink.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import { createAuditSink, type AuditEntry } from "./audit.ts";
|
|
8
|
+
import { buildVaultBootChain } from "./boot-chain.ts";
|
|
9
|
+
import { createBuiltinVaultAdapter, sqlConnectionAsExec } from "./builtin-adapter.ts";
|
|
10
|
+
import { isVaultError } from "./errors.ts";
|
|
11
|
+
import { createMemoryVaultSql } from "./test-helpers.ts";
|
|
12
|
+
|
|
13
|
+
const ENTRY: AuditEntry = {
|
|
14
|
+
action: "set",
|
|
15
|
+
path: "prod/api",
|
|
16
|
+
actorType: "flow",
|
|
17
|
+
actorId: "orders.place",
|
|
18
|
+
success: true,
|
|
19
|
+
requestId: "req-1",
|
|
20
|
+
at: new Date("2026-01-02T03:04:05.000Z"),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const SECRET = "sk_live_do_not_log_me";
|
|
24
|
+
|
|
25
|
+
describe("webhook audit sink", () => {
|
|
26
|
+
test("missing or non-http webhookUrl fails at construction", () => {
|
|
27
|
+
expect(() => createAuditSink("webhook")).toThrow(/webhookUrl/);
|
|
28
|
+
expect(
|
|
29
|
+
isVaultError(
|
|
30
|
+
catchError(() => createAuditSink("webhook")),
|
|
31
|
+
"MISSING_PEER",
|
|
32
|
+
),
|
|
33
|
+
).toBe(true);
|
|
34
|
+
expect(
|
|
35
|
+
isVaultError(
|
|
36
|
+
catchError(() => createAuditSink("webhook", { webhookUrl: "/relative" })),
|
|
37
|
+
"MISSING_PEER",
|
|
38
|
+
),
|
|
39
|
+
).toBe(true);
|
|
40
|
+
expect(
|
|
41
|
+
isVaultError(
|
|
42
|
+
catchError(() => createAuditSink("webhook", { webhookUrl: "ftp://audit.example/hook" })),
|
|
43
|
+
"MISSING_PEER",
|
|
44
|
+
),
|
|
45
|
+
).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("POSTs the stdout-shaped JSON, refuses redirects, and omits secret values", async () => {
|
|
49
|
+
const bodies: string[] = [];
|
|
50
|
+
const inits: RequestInit[] = [];
|
|
51
|
+
const sink = createAuditSink("webhook", {
|
|
52
|
+
webhookUrl: "https://audit.example/hook",
|
|
53
|
+
fetch: (async (_url, init) => {
|
|
54
|
+
inits.push(init ?? {});
|
|
55
|
+
bodies.push(String(init?.body ?? ""));
|
|
56
|
+
return new Response(null, { status: 204 });
|
|
57
|
+
}) as typeof fetch,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
await sink.append(ENTRY);
|
|
61
|
+
|
|
62
|
+
expect(inits[0]?.method).toBe("POST");
|
|
63
|
+
expect(inits[0]?.redirect).toBe("error");
|
|
64
|
+
expect(inits[0]?.signal).toBeInstanceOf(AbortSignal);
|
|
65
|
+
const parsed = JSON.parse(bodies[0] ?? "{}") as Record<string, unknown>;
|
|
66
|
+
expect(parsed).toEqual({
|
|
67
|
+
sink: "oke.vault.audit",
|
|
68
|
+
action: "set",
|
|
69
|
+
path: "prod/api",
|
|
70
|
+
actorType: "flow",
|
|
71
|
+
actorId: "orders.place",
|
|
72
|
+
success: true,
|
|
73
|
+
errorCode: null,
|
|
74
|
+
errorMessage: null,
|
|
75
|
+
requestId: "req-1",
|
|
76
|
+
createdAt: "2026-01-02T03:04:05.000Z",
|
|
77
|
+
});
|
|
78
|
+
expect(bodies[0]).not.toContain(SECRET);
|
|
79
|
+
expect(parsed).not.toHaveProperty("value");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("a non-2xx response rejects without echoing the body", async () => {
|
|
83
|
+
const sink = createAuditSink("webhook", {
|
|
84
|
+
webhookUrl: "https://audit.example/hook",
|
|
85
|
+
fetch: (async () => new Response(SECRET, { status: 502 })) as unknown as typeof fetch,
|
|
86
|
+
});
|
|
87
|
+
const failure = await sink.append(ENTRY).catch((err: unknown) => err);
|
|
88
|
+
expect(isVaultError(failure, "BACKEND_ERROR")).toBe(true);
|
|
89
|
+
expect((failure as Error).message).not.toContain(SECRET);
|
|
90
|
+
expect((failure as Error).message).toContain("502");
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("vault boot chain", () => {
|
|
95
|
+
test("forwards vault.audit only onto the builtin driver layer", () => {
|
|
96
|
+
const audit = { sink: "webhook" as const, webhookUrl: "https://audit.example/hook" };
|
|
97
|
+
const chain = buildVaultBootChain({ driverId: "vault", audit });
|
|
98
|
+
expect(chain[0]?.driver.id).toBe("vault");
|
|
99
|
+
expect(chain[0]?.options?.audit).toEqual(audit);
|
|
100
|
+
expect(chain.slice(1).every((layer) => layer.options?.audit === undefined)).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("non-db audit commands", () => {
|
|
105
|
+
test("verify refuses a webhook sink before touching the chain", async () => {
|
|
106
|
+
const sql = createMemoryVaultSql();
|
|
107
|
+
try {
|
|
108
|
+
const adapter = createBuiltinVaultAdapter({
|
|
109
|
+
db: sqlConnectionAsExec(sql),
|
|
110
|
+
audit: { sink: "webhook", webhookUrl: "https://audit.example/hook" },
|
|
111
|
+
fetch: (async () => new Response(null, { status: 500 })) as unknown as typeof fetch,
|
|
112
|
+
});
|
|
113
|
+
const failure = await adapter.verifyAudit().catch((err: unknown) => err);
|
|
114
|
+
expect(isVaultError(failure, "UNSUPPORTED")).toBe(true);
|
|
115
|
+
expect((failure as Error).message).toContain('audit.sink "db"');
|
|
116
|
+
|
|
117
|
+
const init = await adapter.initialize();
|
|
118
|
+
expect(init.masterKey.length).toBeGreaterThan(0);
|
|
119
|
+
} finally {
|
|
120
|
+
await sql.close();
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Capture a thrown construction error.
|
|
127
|
+
*
|
|
128
|
+
* @param fn - Throwing call
|
|
129
|
+
*/
|
|
130
|
+
function catchError(fn: () => unknown): unknown {
|
|
131
|
+
try {
|
|
132
|
+
fn();
|
|
133
|
+
return undefined;
|
|
134
|
+
} catch (err) {
|
|
135
|
+
return err;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -9,8 +9,10 @@
|
|
|
9
9
|
|
|
10
10
|
import { VaultError } from "./errors.ts";
|
|
11
11
|
import type { VaultErrorCode } from "./errors.ts";
|
|
12
|
-
import type { VaultActorType } from "./types.ts";
|
|
13
|
-
|
|
12
|
+
import type { VaultActorType, VaultAuditConfig, VaultAuditSinkKind } from "./types.ts";
|
|
13
|
+
|
|
14
|
+
/** Bound on a webhook audit POST so a dead receiver cannot stall a write. */
|
|
15
|
+
const AUDIT_WEBHOOK_TIMEOUT_MS = 5_000;
|
|
14
16
|
|
|
15
17
|
/** Operations recorded in the audit chain. */
|
|
16
18
|
export type AuditAction =
|
|
@@ -110,8 +112,10 @@ export interface AuditWriter {
|
|
|
110
112
|
export interface CreateAuditSinkOptions {
|
|
111
113
|
/** Required for the `db` sink. */
|
|
112
114
|
readonly writer?: AuditWriter;
|
|
113
|
-
/** Required for the `webhook` sink
|
|
115
|
+
/** Required for the `webhook` sink. Absolute `http:` or `https:` URL. */
|
|
114
116
|
readonly webhookUrl?: string;
|
|
117
|
+
/** Fetch implementation for the `webhook` sink (tests). */
|
|
118
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
115
119
|
/** Line writer for the `stdout` sink (tests). */
|
|
116
120
|
readonly write?: (line: string) => void;
|
|
117
121
|
/** Clock for `stdout` timestamps (tests). */
|
|
@@ -169,23 +173,98 @@ export async function computeAuditRowHash(
|
|
|
169
173
|
return Buffer.from(new Uint8Array(digest)).toString("hex");
|
|
170
174
|
}
|
|
171
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Reject a webhook URL that is missing or not an absolute http(s) URL.
|
|
178
|
+
*
|
|
179
|
+
* @param webhookUrl - Configured destination
|
|
180
|
+
* @returns Absolute URL string
|
|
181
|
+
* @throws VaultError `MISSING_PEER` when the URL is absent or not http(s)
|
|
182
|
+
*/
|
|
183
|
+
function requireWebhookUrl(webhookUrl: string | undefined): string {
|
|
184
|
+
const raw = webhookUrl?.trim() ?? "";
|
|
185
|
+
if (raw.length === 0) {
|
|
186
|
+
throw new VaultError("MISSING_PEER", "vault: webhook audit sink requires webhookUrl");
|
|
187
|
+
}
|
|
188
|
+
let parsed: URL;
|
|
189
|
+
try {
|
|
190
|
+
parsed = new URL(raw);
|
|
191
|
+
} catch {
|
|
192
|
+
throw new VaultError(
|
|
193
|
+
"MISSING_PEER",
|
|
194
|
+
"vault: webhook audit sink requires an absolute http(s) webhookUrl",
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
198
|
+
throw new VaultError(
|
|
199
|
+
"MISSING_PEER",
|
|
200
|
+
"vault: webhook audit sink requires an absolute http(s) webhookUrl",
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
return parsed.href;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* JSON body shared by the `stdout` and `webhook` sinks.
|
|
208
|
+
*
|
|
209
|
+
* Only normalized metadata is serialized — an entry never holds a value.
|
|
210
|
+
*
|
|
211
|
+
* @param entry - Operation to record
|
|
212
|
+
* @param now - Clock when the entry has no `at`
|
|
213
|
+
*/
|
|
214
|
+
function auditEventJson(entry: AuditEntry, now: () => Date): string {
|
|
215
|
+
return JSON.stringify({
|
|
216
|
+
sink: "oke.vault.audit",
|
|
217
|
+
...toAuditHashPayload(entry, entry.at ?? now()),
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
172
221
|
/**
|
|
173
222
|
* Build an audit sink.
|
|
174
223
|
*
|
|
175
224
|
* - `db` delegates to an {@link AuditWriter} supplied by storage.
|
|
176
225
|
* - `stdout` writes one secret-free JSON line per operation.
|
|
177
|
-
* - `webhook`
|
|
226
|
+
* - `webhook` POSTs that same JSON. Delivery failure throws; callers that
|
|
227
|
+
* record after a successful operation swallow it.
|
|
228
|
+
*
|
|
229
|
+
* Hash-chain verify and purge apply only to the `db` sink.
|
|
178
230
|
*
|
|
179
231
|
* @param kind - Sink kind from `audit.sink`
|
|
180
232
|
* @param opts - Writer / webhook URL / test seams
|
|
181
|
-
* @throws VaultError `
|
|
233
|
+
* @throws VaultError `MISSING_PEER` when `db` has no writer or `webhook` has no absolute URL
|
|
182
234
|
*/
|
|
183
235
|
export function createAuditSink(
|
|
184
236
|
kind: VaultAuditSinkKind,
|
|
185
237
|
opts: CreateAuditSinkOptions = {},
|
|
186
238
|
): AuditSink {
|
|
187
239
|
if (kind === "webhook") {
|
|
188
|
-
|
|
240
|
+
const url = requireWebhookUrl(opts.webhookUrl);
|
|
241
|
+
const fetchFn = opts.fetch ?? globalThis.fetch;
|
|
242
|
+
const now = opts.now ?? (() => new Date());
|
|
243
|
+
return {
|
|
244
|
+
kind: "webhook",
|
|
245
|
+
async append(entry) {
|
|
246
|
+
const body = auditEventJson(entry, now);
|
|
247
|
+
let response: Response;
|
|
248
|
+
try {
|
|
249
|
+
response = await fetchFn(url, {
|
|
250
|
+
method: "POST",
|
|
251
|
+
headers: { "content-type": "application/json" },
|
|
252
|
+
body,
|
|
253
|
+
redirect: "error",
|
|
254
|
+
signal: AbortSignal.timeout(AUDIT_WEBHOOK_TIMEOUT_MS),
|
|
255
|
+
});
|
|
256
|
+
} catch (err) {
|
|
257
|
+
if (err instanceof VaultError) throw err;
|
|
258
|
+
throw new VaultError("BACKEND_ERROR", "vault: webhook audit sink request failed");
|
|
259
|
+
}
|
|
260
|
+
if (!response.ok) {
|
|
261
|
+
throw new VaultError(
|
|
262
|
+
"BACKEND_ERROR",
|
|
263
|
+
`vault: webhook audit sink failed (${response.status})`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
};
|
|
189
268
|
}
|
|
190
269
|
|
|
191
270
|
if (kind === "db") {
|
|
@@ -206,17 +285,31 @@ export function createAuditSink(
|
|
|
206
285
|
return {
|
|
207
286
|
kind: "stdout",
|
|
208
287
|
async append(entry) {
|
|
209
|
-
|
|
210
|
-
write(
|
|
211
|
-
JSON.stringify({
|
|
212
|
-
sink: "oke.vault.audit",
|
|
213
|
-
...toAuditHashPayload(entry, entry.at ?? now()),
|
|
214
|
-
}),
|
|
215
|
-
);
|
|
288
|
+
write(auditEventJson(entry, now));
|
|
216
289
|
},
|
|
217
290
|
};
|
|
218
291
|
}
|
|
219
292
|
|
|
293
|
+
/**
|
|
294
|
+
* Pick the sink for a built-in adapter from `vault.audit`.
|
|
295
|
+
*
|
|
296
|
+
* `enabled: false` drops every row. Omitted config is the `db` chain.
|
|
297
|
+
*
|
|
298
|
+
* @param config - `vault.audit` block, when present
|
|
299
|
+
* @param opts - SQL writer and optional fetch
|
|
300
|
+
*/
|
|
301
|
+
export function auditSinkForConfig(
|
|
302
|
+
config: VaultAuditConfig | undefined,
|
|
303
|
+
opts: { readonly writer: AuditWriter; readonly fetch?: typeof globalThis.fetch },
|
|
304
|
+
): AuditSink {
|
|
305
|
+
if (config?.enabled === false) return createNullAuditSink();
|
|
306
|
+
return createAuditSink(config?.sink ?? "db", {
|
|
307
|
+
writer: opts.writer,
|
|
308
|
+
...(config?.webhookUrl === undefined ? {} : { webhookUrl: config.webhookUrl }),
|
|
309
|
+
...(opts.fetch === undefined ? {} : { fetch: opts.fetch }),
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
220
313
|
/**
|
|
221
314
|
* Sink that drops every entry (`audit.enabled: false`).
|
|
222
315
|
*
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
import { parseDotenv } from "../../drivers/vault-dotenv-parse.ts";
|
|
23
23
|
import type { VaultDriverId } from "../../drivers/vault-types.ts";
|
|
24
24
|
import { resolveComposeEnvPath } from "./chain.ts";
|
|
25
|
+
import type { VaultAuditConfig } from "./types.ts";
|
|
25
26
|
import type { VaultChainLayer } from "./runtime.ts";
|
|
26
27
|
|
|
27
28
|
/** Options for {@link buildVaultBootChain}. */
|
|
@@ -34,6 +35,10 @@ export interface BuildVaultBootChainOptions {
|
|
|
34
35
|
readonly env?: ConfigEnv;
|
|
35
36
|
/** Seed secrets for the terminal memory / managed layer. */
|
|
36
37
|
readonly seed?: Readonly<Record<string, string>>;
|
|
38
|
+
/**
|
|
39
|
+
* Built-in store audit block. Forwarded only to the `vault` driver layer.
|
|
40
|
+
*/
|
|
41
|
+
readonly audit?: VaultAuditConfig;
|
|
37
42
|
}
|
|
38
43
|
|
|
39
44
|
/**
|
|
@@ -142,7 +147,10 @@ export function buildVaultBootChain(options: BuildVaultBootChainOptions): VaultC
|
|
|
142
147
|
{
|
|
143
148
|
driver: builtinVaultDriver,
|
|
144
149
|
source: "driver",
|
|
145
|
-
options: {
|
|
150
|
+
options: {
|
|
151
|
+
secrets: seed,
|
|
152
|
+
...(options.audit === undefined ? {} : { audit: options.audit }),
|
|
153
|
+
},
|
|
146
154
|
},
|
|
147
155
|
...envLayers,
|
|
148
156
|
];
|