okengine 0.10.2 → 0.11.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/package.json +76 -10
- package/site/content/docs/ai/mcp.mdx +0 -1
- package/site/content/docs/deployment/docker-swarm.mdx +48 -111
- package/site/content/docs/deployment/docker.mdx +43 -36
- package/site/content/docs/deployment/index.mdx +9 -9
- package/site/content/docs/deployment/kubernetes.mdx +4 -4
- package/site/content/docs/elements/ai.mdx +7 -13
- package/site/content/docs/elements/channel.mdx +9 -13
- package/site/content/docs/elements/clock.mdx +8 -14
- package/site/content/docs/elements/flow.mdx +46 -54
- package/site/content/docs/elements/gate.mdx +6 -11
- package/site/content/docs/elements/signal.mdx +2 -8
- package/site/content/docs/elements/store.mdx +79 -93
- package/site/content/docs/elements/vault.mdx +89 -76
- package/site/content/docs/get-started/basic-usage.mdx +8 -14
- package/site/content/docs/get-started/installation.mdx +36 -36
- package/site/content/docs/get-started/introduction.mdx +11 -0
- package/site/content/docs/get-started/why.mdx +3 -4
- package/site/content/docs/index.mdx +0 -1
- package/site/content/docs/meta.json +0 -1
- package/site/content/docs/plugins/anonymous.mdx +1 -1
- package/site/content/docs/plugins/magic-link.mdx +11 -11
- package/site/content/docs/plugins/otp.mdx +2 -2
- package/site/content/docs/plugins/passkey.mdx +1 -1
- package/site/content/docs/plugins/two-factor.mdx +1 -1
- package/site/content/docs/plugins/username.mdx +1 -1
- package/site/content/docs/recipes/caddy.mdx +1 -1
- package/site/content/docs/recipes/cockroachdb.mdx +2 -2
- package/site/content/docs/recipes/dragonfly.mdx +1 -1
- package/site/content/docs/recipes/index.mdx +1 -2
- package/site/content/docs/recipes/llama-cpp.mdx +21 -23
- package/site/content/docs/recipes/mailpit.mdx +11 -11
- package/site/content/docs/recipes/meilisearch.mdx +10 -10
- package/site/content/docs/recipes/meta.json +0 -1
- package/site/content/docs/recipes/ollama.mdx +1 -1
- package/site/content/docs/recipes/pgdog.mdx +12 -9
- package/site/content/docs/recipes/postgres.mdx +3 -3
- package/site/content/docs/recipes/redis.mdx +2 -2
- package/site/content/docs/recipes/rustfs.mdx +10 -7
- package/site/content/docs/recipes/sglang.mdx +1 -1
- package/site/content/docs/recipes/supabase-docker.mdx +1 -1
- package/site/content/docs/recipes/timescale.mdx +2 -2
- package/site/content/docs/recipes/valkey.mdx +1 -1
- package/site/content/docs/recipes/vllm.mdx +1 -1
- package/site/content/docs/recipes/yugabytedb.mdx +2 -2
- package/site/content/docs/reference/cli.md +61 -38
- package/site/content/docs/reference/configuration.mdx +145 -70
- package/site/content/docs/reference/environment-variables.mdx +36 -28
- package/site/content/docs/reference/errors.mdx +28 -29
- package/site/content/docs/reference/fx.mdx +14 -7
- package/site/content/docs/reference/i18n.mdx +17 -6
- package/site/content/docs/reference/index.mdx +5 -0
- package/site/content/docs/reference/meta.json +1 -0
- package/site/content/docs/reference/migrating-environments.mdx +158 -0
- package/site/content/docs/reference/plugins.mdx +2 -2
- package/src/auth/gate-auth.test.ts +12 -10
- package/src/cli/ai-setup/ai-setup.test.ts +30 -16
- package/src/cli/ai-setup/apply.ts +111 -15
- package/src/cli/ai-setup/catalog.ts +3 -3
- package/src/cli/ai-setup/index.ts +1 -1
- package/src/cli/ai-setup/prompts.ts +1 -1
- package/src/cli/ai-setup/recommend.test.ts +1 -1
- package/src/cli/ask-seed.test.ts +3 -3
- package/src/cli/build.ts +8 -9
- package/src/cli/db-auto-push.test.ts +6 -3
- package/src/cli/db-auto-push.ts +7 -5
- package/src/cli/db-seed.ts +7 -8
- package/src/cli/db.test.ts +70 -71
- package/src/cli/db.ts +86 -12
- package/src/cli/dev-controller.test.ts +35 -0
- package/src/cli/dev-controller.ts +298 -0
- package/src/cli/dev-controls.ts +11 -65
- package/src/cli/dev-db-push.test.ts +47 -15
- package/src/cli/dev-schema-sync.test.ts +11 -11
- package/src/cli/dev-schema-sync.ts +10 -10
- package/src/cli/dev-session-lock.test.ts +106 -0
- package/src/cli/dev-session-lock.ts +209 -0
- package/src/cli/dev.test.ts +246 -114
- package/src/cli/dev.ts +153 -205
- package/src/cli/doc-staleness.test.ts +3 -6
- package/src/cli/docker-clean.test.ts +33 -1
- package/src/cli/docker-clean.ts +28 -33
- package/src/cli/docker-cli.test.ts +12 -11
- package/src/cli/docker.ts +95 -20
- package/src/cli/doctor.test.ts +40 -0
- package/src/cli/doctor.ts +142 -9
- package/src/cli/drizzle-env.test.ts +11 -13
- package/src/cli/drizzle-env.ts +18 -11
- package/src/cli/ensure-drizzle-config.ts +11 -14
- package/src/cli/hero-meta.test.ts +15 -15
- package/src/cli/hero-meta.ts +10 -9
- package/src/cli/index.ts +36 -6
- package/src/cli/load-config.images.test.ts +44 -10
- package/src/cli/load-config.source.test.ts +50 -0
- package/src/cli/load-config.ts +35 -29
- package/src/cli/meilisearch-local.ts +1 -2
- package/src/cli/mode.ts +7 -58
- package/src/cli/registry.ts +175 -19
- package/src/cli/resolve-dev-sql-env.test.ts +8 -16
- package/src/cli/resolve-dev-sql-env.ts +13 -15
- package/src/cli/safe-defaults.test.ts +29 -11
- package/src/cli/schema.ts +65 -16
- package/src/cli/test.ts +58 -0
- package/src/cli/tui/App.tsx +252 -0
- package/src/cli/tui/DevLive.tsx +116 -0
- package/src/cli/tui/components/ConfirmDialog.tsx +48 -0
- package/src/cli/tui/components/HeaderBar.tsx +67 -0
- package/src/cli/tui/components/LogStream.tsx +47 -0
- package/src/cli/tui/components/PanelCard.tsx +39 -0
- package/src/cli/tui/components/SelectRow.tsx +37 -0
- package/src/cli/tui/components/SlashPalette.tsx +172 -0
- package/src/cli/tui/components/Spinner.tsx +32 -0
- package/src/cli/tui/components/StatusBar.tsx +51 -0
- package/src/cli/tui/components/StatusDot.tsx +37 -0
- package/src/cli/tui/components/TabBar.tsx +49 -0
- package/src/cli/tui/components/Wordmark.tsx +43 -0
- package/src/cli/tui/format.ts +38 -0
- package/src/cli/tui/keys.test.ts +18 -0
- package/src/cli/tui/keys.ts +26 -0
- package/src/cli/tui/launch.test.ts +18 -0
- package/src/cli/tui/launch.ts +57 -0
- package/src/cli/tui/panels/Dashboard.tsx +201 -0
- package/src/cli/tui/panels/Database.tsx +198 -0
- package/src/cli/tui/panels/Dev.tsx +257 -0
- package/src/cli/tui/panels/Docker.tsx +357 -0
- package/src/cli/tui/panels/Navigator.test.ts +22 -0
- package/src/cli/tui/panels/Navigator.tsx +213 -0
- package/src/cli/tui/slash-catalog.test.ts +31 -0
- package/src/cli/tui/slash-catalog.ts +184 -0
- package/src/cli/tui/slash-run.ts +54 -0
- package/src/cli/tui/state/ports.ts +95 -0
- package/src/cli/tui/theme.ts +24 -0
- package/src/cli/vault-cmd.test.ts +402 -0
- package/src/cli/vault-cmd.ts +727 -69
- package/src/cli/vault-secure-input.test.ts +56 -0
- package/src/cli/vault-secure-input.ts +124 -0
- package/src/compiler/effects-infer.ts +7 -0
- package/src/compiler/extract.ts +12 -0
- package/src/compiler/fixtures/skyport/oke.config.ts +7 -5
- package/src/compiler/fixtures/skyport/src/flows/payments/index.ts +1 -1
- package/src/compiler/fixtures/skyport.expected.json +1 -1
- package/src/compiler/generate-adopt.ts +27 -1
- package/src/compiler-entry.ts +7 -0
- package/src/config/define-config.test.ts +111 -47
- package/src/config/driver-defaults.test.ts +115 -0
- package/src/config/driver-defaults.ts +156 -0
- package/src/config/index.ts +246 -131
- package/src/config/resolve-driver.test.ts +14 -19
- package/src/console/server/app.ts +4 -2
- package/src/console/server/console.test.ts +58 -1
- package/src/console/server/flows.ts +22 -2
- package/src/console/server/index.ts +3 -0
- package/src/console/server/operator-db.test.ts +55 -13
- package/src/console/server/operator-db.ts +305 -263
- package/src/console/server/serve.ts +16 -7
- package/src/console/server/state.ts +31 -9
- package/src/console/server/vault.test.ts +120 -5
- package/src/console/server/vault.ts +208 -12
- package/src/console/ui/dist/assets/{index-D-9KFce5.js → index--Y0Uaz-m.js} +2 -2
- package/src/console/ui/dist/assets/panel-vault-BkZdJPX8.js +1 -0
- package/src/console/ui/dist/assets/style-C3L349iZ.css +3 -0
- package/src/console/ui/dist/index.html +2 -2
- package/src/console/ui/shell/client.ts +16 -0
- package/src/console/ui/shell/panels/vault/VaultPanel.tsx +54 -1
- package/src/console/ui/vault/VaultA11yView.tsx +33 -1
- package/src/console/ui/vault/backend.test.ts +75 -0
- package/src/console/ui/vault/backend.ts +117 -0
- package/src/console/ui/vault/fixture.ts +21 -2
- package/src/console/ui/vault/index.ts +11 -1
- package/src/console/ui/vault/types.ts +26 -0
- package/src/docker/compose.ts +164 -61
- package/src/docker/derive.ts +106 -12
- package/src/docker/docker.test.ts +156 -110
- package/src/docker/helpers.ts +98 -0
- package/src/docker/images-config.test.ts +163 -0
- package/src/docker/index.ts +30 -5
- package/src/docker/recipes/index.ts +0 -3
- package/src/docker/recipes/llama-cpp.ts +4 -4
- package/src/docker/recipes/pgdog.ts +9 -2
- package/src/docker/resources.test.ts +71 -0
- package/src/docker/resources.ts +176 -0
- package/src/docker/stack-id.test.ts +11 -4
- package/src/docker/stack-id.ts +42 -32
- package/src/docker/stack.integration.test.ts +4 -15
- package/src/docker/types.ts +35 -3
- package/src/drivers/ai-ollama.integration.test.ts +2 -2
- package/src/drivers/conformance.test.ts +11 -12
- package/src/drivers/drizzle-dialect.test.ts +0 -4
- package/src/drivers/drizzle-dialect.ts +7 -13
- package/src/drivers/index.ts +24 -7
- package/src/drivers/journal-postgres.test.ts +3 -1
- package/src/drivers/journal-postgres.ts +4 -2
- package/src/drivers/meilisearch.ts +1 -1
- package/src/drivers/pglite.ts +28 -5
- package/src/drivers/types.ts +9 -11
- package/src/drivers/vault-aws-secrets-manager.test.ts +159 -0
- package/src/drivers/vault-aws-secrets-manager.ts +333 -0
- package/src/drivers/vault-builtin.test.ts +110 -0
- package/src/drivers/vault-builtin.ts +184 -0
- package/src/drivers/vault-driver-removal.test.ts +13 -0
- package/src/drivers/vault-managed.test.ts +95 -0
- package/src/drivers/vault-managed.ts +101 -25
- package/src/drivers/vault-types.ts +25 -5
- package/src/elements/ai/runtime.ts +1 -1
- package/src/elements/clock/chaos.test.ts +4 -1
- package/src/elements/clock/durable.ts +57 -4
- package/src/elements/clock.test.ts +13 -14
- package/src/elements/gate/constants.ts +18 -0
- package/src/elements/gate.test.ts +1 -1
- package/src/elements/gate.ts +2 -2
- package/src/elements/store/domain-ddl.test.ts +5 -12
- package/src/elements/store/emit-drizzle.ts +44 -19
- package/src/elements/store/index-boot.test.ts +15 -116
- package/src/elements/store/load-plugin-tables.ts +1 -1
- package/src/elements/store/resource-list-docs.fixture.ts +12 -6
- package/src/elements/store/resource-list-docs.test.ts +64 -44
- package/src/elements/store/resource.test.ts +17 -10
- package/src/elements/store/runtime.ts +9 -12
- package/src/elements/store/schema-decl.test.ts +16 -32
- package/src/elements/store/seed.test.ts +1 -2
- package/src/elements/store/seed.ts +7 -8
- package/src/elements/store/sql-condition.test.ts +2 -2
- package/src/elements/store/sql-session.test.ts +8 -10
- package/src/elements/store/sql-session.ts +10 -2
- package/src/elements/store.test.ts +13 -13
- package/src/elements/vault/audit.ts +233 -0
- package/src/elements/vault/boot-chain.test.ts +78 -0
- package/src/elements/vault/boot-chain.ts +43 -39
- package/src/elements/vault/builtin-adapter.test.ts +479 -0
- package/src/elements/vault/builtin-adapter.ts +1184 -0
- package/src/elements/vault/crypto.test.ts +237 -0
- package/src/elements/vault/crypto.ts +431 -0
- package/src/elements/vault/declare.ts +164 -3
- package/src/elements/vault/errors.ts +56 -0
- package/src/elements/vault/kms-unseal.test.ts +76 -0
- package/src/elements/vault/kms-unseal.ts +181 -0
- package/src/elements/vault/path.ts +90 -0
- package/src/elements/vault/resilience.test.ts +102 -0
- package/src/elements/vault/resilience.ts +107 -0
- package/src/elements/vault/runtime.ts +36 -4
- package/src/elements/vault/security-checklist.test.ts +130 -0
- package/src/elements/vault/storage.ts +448 -0
- package/src/elements/vault/types.ts +288 -0
- package/src/elements/vault/unseal.ts +150 -0
- package/src/elements/vault.test.ts +72 -4
- package/src/elements/vault.ts +91 -2
- package/src/full.ts +248 -0
- package/src/http.ts +41 -0
- package/src/i18n/interpolate-simple.ts +37 -0
- package/src/i18n/messages.ts +51 -4
- package/src/i18n-entry.ts +7 -0
- package/src/index.ts +35 -145
- package/src/journal-entry.ts +20 -0
- package/src/kernel/adopt-barrel-fresh.test.ts +25 -10
- package/src/kernel/app.ts +40 -42
- package/src/kernel/auto-registry.test.ts +2 -2
- package/src/kernel/boot-bind/ai.test.ts +5 -5
- package/src/kernel/boot-bind/ai.ts +2 -1
- package/src/kernel/boot-bind/channel.test.ts +6 -6
- package/src/kernel/boot-bind/channel.ts +5 -4
- package/src/kernel/boot-bind/clock.ts +5 -5
- package/src/kernel/boot-bind/gate.ts +6 -7
- package/src/kernel/boot-bind/honor-config.test.ts +42 -42
- package/src/kernel/boot-bind/journal.ts +5 -5
- package/src/kernel/boot-bind/signal.ts +3 -1
- package/src/kernel/boot-bind/store.test.ts +55 -37
- package/src/kernel/boot-bind/store.ts +24 -34
- package/src/kernel/boot-bind/vault.ts +19 -4
- package/src/kernel/boot.test.ts +12 -4
- package/src/kernel/boot.ts +31 -19
- package/src/kernel/budget-entry.ts +1 -1
- package/src/kernel/compensate.test.ts +288 -1
- package/src/kernel/compensate.ts +126 -0
- package/src/kernel/concurrency.test.ts +10 -9
- package/src/kernel/effects-stamping.test.ts +27 -2
- package/src/kernel/element-registries.ts +5 -3
- package/src/kernel/flow.ts +4 -2
- package/src/kernel/fx.test.ts +119 -3
- package/src/kernel/fx.ts +215 -40
- package/src/kernel/index.ts +14 -0
- package/src/kernel/journal.ts +130 -7
- package/src/kernel/pipeline.test.ts +20 -6
- package/src/kernel/redacted.test.ts +35 -0
- package/src/kernel/redacted.ts +24 -2
- package/src/kernel/router/create-default.ts +16 -0
- package/src/kernel/router/create-edge.ts +18 -0
- package/src/kernel/router/create.ts +23 -0
- package/src/kernel/router/index.ts +23 -0
- package/src/kernel/router/linear.ts +88 -0
- package/src/kernel/router/regexp.ts +188 -0
- package/src/kernel/router/smart.ts +120 -0
- package/src/kernel/router/trie.ts +108 -0
- package/src/kernel/router/types.ts +56 -0
- package/src/kernel/router.ts +18 -532
- package/src/kernel-entry.ts +43 -0
- package/src/manifest/fixtures/skyport.excerpt.json +1 -1
- package/src/manifest/fixtures/skyport.manifest.json +1 -1
- package/src/plugins/config-source.test.ts +7 -7
- package/src/release/build-lib.ts +58 -0
- package/src/release/exports.test.ts +4 -2
- package/src/release/exports.ts +30 -4
- package/src/release/measure.ts +101 -22
- package/src/runs/duckdb.ts +7 -2
- package/src/shared/optional-peer.ts +41 -0
- package/src/term.test.ts +3 -44
- package/src/test/create-test-app.ts +12 -0
- package/src/test/provisions.integration.test.ts +1 -1
- package/src/test/reset-element-registries.ts +2 -0
- package/src/upgrade/codemods.test.ts +7 -20
- package/src/upgrade/codemods.ts +93 -60
- package/site/content/docs/console/access.mdx +0 -29
- package/site/content/docs/console/ai.mdx +0 -35
- package/site/content/docs/console/architecture.mdx +0 -35
- package/site/content/docs/console/channels.mdx +0 -37
- package/site/content/docs/console/clock.mdx +0 -51
- package/site/content/docs/console/flows.mdx +0 -31
- package/site/content/docs/console/gates.mdx +0 -73
- package/site/content/docs/console/index.mdx +0 -54
- package/site/content/docs/console/manifest-diff.mdx +0 -34
- package/site/content/docs/console/meta.json +0 -24
- package/site/content/docs/console/overview.mdx +0 -40
- package/site/content/docs/console/plugins.mdx +0 -41
- package/site/content/docs/console/privacy.mdx +0 -32
- package/site/content/docs/console/runs.mdx +0 -50
- package/site/content/docs/console/signals.mdx +0 -40
- package/site/content/docs/console/store.mdx +0 -32
- package/site/content/docs/console/tenancy.mdx +0 -32
- package/site/content/docs/console/traces.mdx +0 -38
- package/site/content/docs/console/vault.mdx +0 -41
- package/site/content/docs/recipes/openbao.mdx +0 -141
- package/src/cli/ask-dev-mode.ts +0 -40
- package/src/cli/dev-mode.test.ts +0 -45
- package/src/cli/dev-mode.ts +0 -78
- package/src/cli/openbao-bootstrap.test.ts +0 -150
- package/src/cli/openbao-bootstrap.ts +0 -321
- package/src/cli/openbao-restart.integration.test.ts +0 -145
- package/src/console/ui/dist/assets/panel-vault-Za1GyJfM.js +0 -1
- package/src/console/ui/dist/assets/style-C8MxEWPd.css +0 -3
- package/src/docker/recipes/openbao.ts +0 -47
- package/src/drivers/libsql.ts +0 -179
- package/src/drivers/sqlite.ts +0 -44
- package/src/drivers/vault-openbao.test.ts +0 -97
- package/src/drivers/vault-openbao.ts +0 -143
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Physics: secrets · config · environment.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { secretRegistry } from "../../kernel/element-registries.ts";
|
|
7
|
+
import { requiredEnvRegistry, secretRegistry } from "../../kernel/element-registries.ts";
|
|
8
8
|
|
|
9
9
|
/** Options for {@link vault} / {@link vault.secret} / {@link vault.config}. */
|
|
10
10
|
export interface VaultSecretOptions {
|
|
@@ -105,6 +105,163 @@ function declareConfig(name: string, options: VaultSecretOptions = {}): VaultSec
|
|
|
105
105
|
};
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Synchronous environment-variable helpers on `vault.env`.
|
|
110
|
+
*
|
|
111
|
+
* Plain process configuration — no boot chain, no driver, no redaction.
|
|
112
|
+
* Reach for {@link vault.secret} when the value is a contract the runtime
|
|
113
|
+
* must resolve, fingerprint, and keep out of logs.
|
|
114
|
+
*/
|
|
115
|
+
export interface VaultEnvApi {
|
|
116
|
+
/**
|
|
117
|
+
* Raw value, or `undefined` when unset / empty.
|
|
118
|
+
*
|
|
119
|
+
* @param name - Environment variable name
|
|
120
|
+
*/
|
|
121
|
+
(name: string): string | undefined;
|
|
122
|
+
/**
|
|
123
|
+
* Value that must exist. Registers `name` so boot reports every missing
|
|
124
|
+
* variable at once alongside secret gaps.
|
|
125
|
+
*
|
|
126
|
+
* @param name - Environment variable name
|
|
127
|
+
* @throws TypeError when the variable is unset or empty
|
|
128
|
+
*/
|
|
129
|
+
required(name: string): string;
|
|
130
|
+
/**
|
|
131
|
+
* Integer value.
|
|
132
|
+
*
|
|
133
|
+
* @param name - Environment variable name
|
|
134
|
+
* @param defaultValue - Used when the variable is unset or empty
|
|
135
|
+
* @throws TypeError when missing without a default, or not an integer
|
|
136
|
+
*/
|
|
137
|
+
int(name: string, defaultValue?: number): number;
|
|
138
|
+
/**
|
|
139
|
+
* Boolean value (`1`/`true`/`yes`/`on` · `0`/`false`/`no`/`off`).
|
|
140
|
+
*
|
|
141
|
+
* @param name - Environment variable name
|
|
142
|
+
* @param defaultValue - Used when the variable is unset or empty
|
|
143
|
+
* @throws TypeError when missing without a default, or not boolean-shaped
|
|
144
|
+
*/
|
|
145
|
+
bool(name: string, defaultValue?: boolean): boolean;
|
|
146
|
+
/**
|
|
147
|
+
* JSON-parsed value, or `undefined` when unset / empty.
|
|
148
|
+
*
|
|
149
|
+
* @param name - Environment variable name
|
|
150
|
+
* @throws TypeError when the value is not valid JSON
|
|
151
|
+
*/
|
|
152
|
+
json<T = unknown>(name: string): T | undefined;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Read one environment variable, treating empty strings as unset.
|
|
157
|
+
*
|
|
158
|
+
* @param name - Environment variable name
|
|
159
|
+
*/
|
|
160
|
+
export function readEnv(name: string): string | undefined {
|
|
161
|
+
const value =
|
|
162
|
+
(typeof Bun !== "undefined" ? Bun.env[name] : undefined) ?? process.env[name] ?? undefined;
|
|
163
|
+
return value === undefined || value.length === 0 ? undefined : value;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Snapshot of every name passed to {@link vault.env.required} since the last
|
|
168
|
+
* reset. Boot turns the missing ones into {@link VaultGap}s.
|
|
169
|
+
*/
|
|
170
|
+
export function listRequiredEnvNames(): readonly string[] {
|
|
171
|
+
return requiredEnvRegistry.slice();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Clear the required-env registry (tests / fresh app adopt).
|
|
176
|
+
*
|
|
177
|
+
* @internal
|
|
178
|
+
*/
|
|
179
|
+
export function resetRequiredEnvNames(): void {
|
|
180
|
+
requiredEnvRegistry.length = 0;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* @param name - Environment variable name
|
|
185
|
+
*/
|
|
186
|
+
function envValue(name: string): string | undefined {
|
|
187
|
+
if (!name) throw new TypeError("vault.env: name is required");
|
|
188
|
+
return readEnv(name);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* @param name - Environment variable name
|
|
193
|
+
*/
|
|
194
|
+
function envRequired(name: string): string {
|
|
195
|
+
if (!name) throw new TypeError("vault.env.required: name is required");
|
|
196
|
+
if (!requiredEnvRegistry.includes(name)) requiredEnvRegistry.push(name);
|
|
197
|
+
const value = readEnv(name);
|
|
198
|
+
if (value === undefined) {
|
|
199
|
+
throw new TypeError(`vault.env.required: ${name} is not set`);
|
|
200
|
+
}
|
|
201
|
+
return value;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* @param name - Environment variable name
|
|
206
|
+
* @param defaultValue - Used when the variable is unset
|
|
207
|
+
*/
|
|
208
|
+
function envInt(name: string, defaultValue?: number): number {
|
|
209
|
+
const raw = envValue(name);
|
|
210
|
+
if (raw === undefined) {
|
|
211
|
+
if (defaultValue === undefined) {
|
|
212
|
+
throw new TypeError(`vault.env.int: ${name} is not set and has no default`);
|
|
213
|
+
}
|
|
214
|
+
return defaultValue;
|
|
215
|
+
}
|
|
216
|
+
const parsed = Number(raw);
|
|
217
|
+
if (!Number.isInteger(parsed)) {
|
|
218
|
+
throw new TypeError(`vault.env.int: ${name} is not an integer`);
|
|
219
|
+
}
|
|
220
|
+
return parsed;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const TRUE_VALUES = new Set(["1", "true", "yes", "on"]);
|
|
224
|
+
const FALSE_VALUES = new Set(["0", "false", "no", "off"]);
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* @param name - Environment variable name
|
|
228
|
+
* @param defaultValue - Used when the variable is unset
|
|
229
|
+
*/
|
|
230
|
+
function envBool(name: string, defaultValue?: boolean): boolean {
|
|
231
|
+
const raw = envValue(name);
|
|
232
|
+
if (raw === undefined) {
|
|
233
|
+
if (defaultValue === undefined) {
|
|
234
|
+
throw new TypeError(`vault.env.bool: ${name} is not set and has no default`);
|
|
235
|
+
}
|
|
236
|
+
return defaultValue;
|
|
237
|
+
}
|
|
238
|
+
const normalized = raw.trim().toLowerCase();
|
|
239
|
+
if (TRUE_VALUES.has(normalized)) return true;
|
|
240
|
+
if (FALSE_VALUES.has(normalized)) return false;
|
|
241
|
+
throw new TypeError(`vault.env.bool: ${name} is not a boolean (got "${raw}")`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* @param name - Environment variable name
|
|
246
|
+
*/
|
|
247
|
+
function envJson<T = unknown>(name: string): T | undefined {
|
|
248
|
+
const raw = envValue(name);
|
|
249
|
+
if (raw === undefined) return undefined;
|
|
250
|
+
try {
|
|
251
|
+
return JSON.parse(raw) as T;
|
|
252
|
+
} catch {
|
|
253
|
+
throw new TypeError(`vault.env.json: ${name} is not valid JSON`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Sync env helpers exposed as {@link vault.env}. */
|
|
258
|
+
export const vaultEnvApi: VaultEnvApi = Object.assign(envValue, {
|
|
259
|
+
required: envRequired,
|
|
260
|
+
int: envInt,
|
|
261
|
+
bool: envBool,
|
|
262
|
+
json: envJson,
|
|
263
|
+
});
|
|
264
|
+
|
|
108
265
|
/**
|
|
109
266
|
* Marker prefix for {@link vault.fromDocker} local fallbacks.
|
|
110
267
|
* Resolved from `docker/.env.docker` by `oke dev --docker` / vault boot.
|
|
@@ -144,18 +301,22 @@ export function fromDockerRole(value: string): string {
|
|
|
144
301
|
}
|
|
145
302
|
|
|
146
303
|
/**
|
|
147
|
-
* Vault element — `vault("NAME", opts)` · `vault.secret` · `vault.config
|
|
304
|
+
* Vault element — `vault("NAME", opts)` · `vault.secret` · `vault.config`
|
|
305
|
+
* · `vault.env`.
|
|
148
306
|
*
|
|
149
307
|
* A declaration is a contract, not a value. Values resolve at boot through
|
|
150
|
-
* the configured driver chain.
|
|
308
|
+
* the configured driver chain. `vault.env` is the escape hatch for plain
|
|
309
|
+
* process configuration that needs no contract.
|
|
151
310
|
*/
|
|
152
311
|
export const vault: {
|
|
153
312
|
(name: string, options?: VaultSecretOptions): VaultSecretDecl;
|
|
154
313
|
secret(name: string, options?: VaultSecretOptions): VaultSecretDecl;
|
|
155
314
|
config(name: string, options?: VaultSecretOptions): VaultSecretDecl;
|
|
156
315
|
fromDocker(role: string): string;
|
|
316
|
+
env: VaultEnvApi;
|
|
157
317
|
} = Object.assign(declareSecret, {
|
|
158
318
|
secret: declareSecret,
|
|
159
319
|
config: declareConfig,
|
|
160
320
|
fromDocker,
|
|
321
|
+
env: vaultEnvApi,
|
|
161
322
|
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vault error taxonomy — one class, one closed code union.
|
|
3
|
+
*
|
|
4
|
+
* `VaultError` deliberately never carries a `cause`. An underlying driver or
|
|
5
|
+
* Web Crypto error can hold key material, ciphertext, or plaintext in its
|
|
6
|
+
* message or attached buffers; re-throwing it would leak that material into
|
|
7
|
+
* logs and traces. Callers translate foreign errors into a code + a message
|
|
8
|
+
* they authored themselves.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Closed set of Vault failure reasons. */
|
|
12
|
+
export type VaultErrorCode =
|
|
13
|
+
| "SEALED"
|
|
14
|
+
| "NOT_INITIALIZED"
|
|
15
|
+
| "SECRET_NOT_FOUND"
|
|
16
|
+
| "INVALID_KEY"
|
|
17
|
+
| "ALREADY_INITIALIZED"
|
|
18
|
+
| "READONLY"
|
|
19
|
+
| "EXPIRED"
|
|
20
|
+
| "PERMISSION_DENIED"
|
|
21
|
+
| "BACKEND_ERROR"
|
|
22
|
+
| "INVALID_PATH"
|
|
23
|
+
| "UNSUPPORTED"
|
|
24
|
+
| "MISSING_PEER";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Vault failure with a machine-readable {@link VaultErrorCode}.
|
|
28
|
+
*
|
|
29
|
+
* Never attach a `cause`: upstream crypto/driver errors may hold secret
|
|
30
|
+
* material. Author a safe message instead.
|
|
31
|
+
*/
|
|
32
|
+
export class VaultError extends Error {
|
|
33
|
+
/** Machine-readable reason. */
|
|
34
|
+
readonly code: VaultErrorCode;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param code - Machine-readable reason
|
|
38
|
+
* @param message - Safe, secret-free description
|
|
39
|
+
*/
|
|
40
|
+
constructor(code: VaultErrorCode, message: string) {
|
|
41
|
+
super(message);
|
|
42
|
+
this.name = "VaultError";
|
|
43
|
+
this.code = code;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Whether a value is a {@link VaultError} (optionally of a specific code).
|
|
49
|
+
*
|
|
50
|
+
* @param value - Candidate
|
|
51
|
+
* @param code - Narrow to a single code
|
|
52
|
+
*/
|
|
53
|
+
export function isVaultError(value: unknown, code?: VaultErrorCode): value is VaultError {
|
|
54
|
+
if (!(value instanceof VaultError)) return false;
|
|
55
|
+
return code === undefined || value.code === code;
|
|
56
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KMS unseal — injected seams, key hygiene, and the missing-peer message.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { deriveVerifyHash, generateMasterKey, MASTER_KEY_BYTES } from "./crypto.ts";
|
|
7
|
+
import { isVaultError } from "./errors.ts";
|
|
8
|
+
import { createAwsKmsUnsealer, wrapMasterWithAwsKms } from "./kms-unseal.ts";
|
|
9
|
+
|
|
10
|
+
const WRAPPED = Uint8Array.from([0xde, 0xad, 0xbe, 0xef]);
|
|
11
|
+
|
|
12
|
+
describe("aws kms unseal", () => {
|
|
13
|
+
test("an injected decrypt yields a working unsealer", async () => {
|
|
14
|
+
const masterKey = generateMasterKey();
|
|
15
|
+
const unsealer = await createAwsKmsUnsealer({
|
|
16
|
+
keyId: "alias/oke-vault",
|
|
17
|
+
wrappedMaster: WRAPPED,
|
|
18
|
+
decryptFn: async (ciphertext) => {
|
|
19
|
+
expect([...ciphertext]).toEqual([...WRAPPED]);
|
|
20
|
+
return Uint8Array.from(masterKey);
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
expect(unsealer.sealed).toBe(false);
|
|
25
|
+
expect(await unsealer.verifyHash()).toBe(await deriveVerifyHash(masterKey));
|
|
26
|
+
unsealer.seal();
|
|
27
|
+
expect(unsealer.sealed).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("the plaintext handed back by kms is zeroed after import", async () => {
|
|
31
|
+
const masterKey = generateMasterKey();
|
|
32
|
+
const plaintext = Uint8Array.from(masterKey);
|
|
33
|
+
const unsealer = await createAwsKmsUnsealer({
|
|
34
|
+
keyId: "alias/oke-vault",
|
|
35
|
+
wrappedMaster: WRAPPED,
|
|
36
|
+
decryptFn: async () => plaintext,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
expect([...plaintext]).toEqual(new Array<number>(MASTER_KEY_BYTES).fill(0));
|
|
40
|
+
// The unsealer kept its own copy, so it still derives the real hash.
|
|
41
|
+
expect(await unsealer.verifyHash()).toBe(await deriveVerifyHash(masterKey));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("a wrong-length plaintext is rejected", async () => {
|
|
45
|
+
const failure = await createAwsKmsUnsealer({
|
|
46
|
+
keyId: "alias/oke-vault",
|
|
47
|
+
wrappedMaster: WRAPPED,
|
|
48
|
+
decryptFn: async () => new Uint8Array(16),
|
|
49
|
+
}).catch((e: unknown) => e);
|
|
50
|
+
|
|
51
|
+
expect(isVaultError(failure, "INVALID_KEY")).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("wrapMasterWithAwsKms uses the injected encrypt and checks key length", async () => {
|
|
55
|
+
const masterKey = generateMasterKey();
|
|
56
|
+
const blob = await wrapMasterWithAwsKms("alias/oke-vault", masterKey, {
|
|
57
|
+
encryptFn: async (plaintext) => Uint8Array.from([1, ...plaintext]),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
expect(blob).toHaveLength(MASTER_KEY_BYTES + 1);
|
|
61
|
+
const failure = await wrapMasterWithAwsKms("alias/oke-vault", new Uint8Array(8), {
|
|
62
|
+
encryptFn: async (plaintext) => plaintext,
|
|
63
|
+
}).catch((e: unknown) => e);
|
|
64
|
+
expect(isVaultError(failure, "INVALID_KEY")).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("without the optional peer the failure names the install command", async () => {
|
|
68
|
+
const failure = await createAwsKmsUnsealer({
|
|
69
|
+
keyId: "alias/oke-vault",
|
|
70
|
+
wrappedMaster: WRAPPED,
|
|
71
|
+
}).catch((e: unknown) => e);
|
|
72
|
+
|
|
73
|
+
expect(isVaultError(failure, "MISSING_PEER")).toBe(true);
|
|
74
|
+
expect((failure as Error).message).toContain("bun add @aws-sdk/client-kms");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AWS KMS unseal path — the master key never exists outside KMS at rest.
|
|
3
|
+
*
|
|
4
|
+
* At init the plaintext master key is sent to KMS `Encrypt` once; the
|
|
5
|
+
* returned ciphertext blob is what gets stored (`oke_vault_master.wrapped_master`).
|
|
6
|
+
* At boot the blob goes back to KMS `Decrypt` and the plaintext is handed
|
|
7
|
+
* straight to an {@link Unsealer}, then zeroed — it is never written to disk,
|
|
8
|
+
* an environment variable, or a log.
|
|
9
|
+
*
|
|
10
|
+
* `@aws-sdk/client-kms` is an optional peer, imported dynamically so a
|
|
11
|
+
* project that unseals from `env` or a file never pays for it. Tests inject
|
|
12
|
+
* `decryptFn` / `encryptFn` and skip the SDK entirely.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { MASTER_KEY_BYTES, zeroBytes } from "./crypto.ts";
|
|
16
|
+
import { VaultError } from "./errors.ts";
|
|
17
|
+
import { createEnvUnsealer, type Unsealer } from "./unseal.ts";
|
|
18
|
+
|
|
19
|
+
/** Package name of the optional peer, resolved at call time. */
|
|
20
|
+
const KMS_PACKAGE = "@aws-sdk/client-kms";
|
|
21
|
+
|
|
22
|
+
/** Options for {@link createAwsKmsUnsealer}. */
|
|
23
|
+
export interface CreateAwsKmsUnsealerOptions {
|
|
24
|
+
/** KMS key id, alias, or ARN that wrapped the master key. */
|
|
25
|
+
readonly keyId: string;
|
|
26
|
+
/** Ciphertext blob returned by {@link wrapMasterWithAwsKms}. */
|
|
27
|
+
readonly wrappedMaster: Uint8Array;
|
|
28
|
+
/** AWS region. Falls back to the SDK's own resolution chain. */
|
|
29
|
+
readonly region?: string;
|
|
30
|
+
/** Decrypt seam for tests — skips the SDK import entirely. */
|
|
31
|
+
readonly decryptFn?: (ciphertext: Uint8Array) => Promise<Uint8Array>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Options for {@link wrapMasterWithAwsKms}. */
|
|
35
|
+
export interface WrapMasterWithAwsKmsOptions {
|
|
36
|
+
/** AWS region. Falls back to the SDK's own resolution chain. */
|
|
37
|
+
readonly region?: string;
|
|
38
|
+
/** Encrypt seam for tests — skips the SDK import entirely. */
|
|
39
|
+
readonly encryptFn?: (plaintext: Uint8Array) => Promise<Uint8Array>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** KMS response fields this module reads. */
|
|
43
|
+
interface KmsResult {
|
|
44
|
+
readonly Plaintext?: Uint8Array;
|
|
45
|
+
readonly CiphertextBlob?: Uint8Array;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Structural client interface — the SDK's `KMSClient` satisfies it. */
|
|
49
|
+
interface KmsClientLike {
|
|
50
|
+
send(command: unknown): Promise<KmsResult>;
|
|
51
|
+
destroy?(): void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Constructors pulled off the dynamically imported module. */
|
|
55
|
+
interface KmsBindings {
|
|
56
|
+
readonly KMSClient: new (config: { region?: string }) => KmsClientLike;
|
|
57
|
+
readonly DecryptCommand: new (input: { KeyId: string; CiphertextBlob: Uint8Array }) => unknown;
|
|
58
|
+
readonly EncryptCommand: new (input: { KeyId: string; Plaintext: Uint8Array }) => unknown;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Unseal from a KMS-wrapped master key.
|
|
63
|
+
*
|
|
64
|
+
* @param opts - Key id, wrapped blob, region, and the test decrypt seam
|
|
65
|
+
* @throws VaultError `MISSING_PEER` when `@aws-sdk/client-kms` is absent
|
|
66
|
+
* @throws VaultError `INVALID_KEY` when KMS returns something other than 32 bytes
|
|
67
|
+
*/
|
|
68
|
+
export async function createAwsKmsUnsealer(opts: CreateAwsKmsUnsealerOptions): Promise<Unsealer> {
|
|
69
|
+
const decrypt = opts.decryptFn ?? (await bindDecrypt(opts.keyId, opts.region));
|
|
70
|
+
const plaintext = await decrypt(opts.wrappedMaster);
|
|
71
|
+
try {
|
|
72
|
+
if (plaintext.byteLength !== MASTER_KEY_BYTES) {
|
|
73
|
+
throw new VaultError(
|
|
74
|
+
"INVALID_KEY",
|
|
75
|
+
`vault: kms master key must be ${MASTER_KEY_BYTES} bytes`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
return createEnvUnsealer(plaintext);
|
|
79
|
+
} finally {
|
|
80
|
+
zeroBytes(plaintext);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Wrap a freshly generated master key with KMS for storage at init.
|
|
86
|
+
*
|
|
87
|
+
* The caller still owns `masterKey` and must zero it afterwards.
|
|
88
|
+
*
|
|
89
|
+
* @param keyId - KMS key id, alias, or ARN
|
|
90
|
+
* @param masterKey - Raw 32-byte master key
|
|
91
|
+
* @param opts - Region and the test encrypt seam
|
|
92
|
+
* @throws VaultError `MISSING_PEER` when `@aws-sdk/client-kms` is absent
|
|
93
|
+
*/
|
|
94
|
+
export async function wrapMasterWithAwsKms(
|
|
95
|
+
keyId: string,
|
|
96
|
+
masterKey: Uint8Array,
|
|
97
|
+
opts: WrapMasterWithAwsKmsOptions = {},
|
|
98
|
+
): Promise<Uint8Array> {
|
|
99
|
+
if (masterKey.byteLength !== MASTER_KEY_BYTES) {
|
|
100
|
+
throw new VaultError("INVALID_KEY", `vault: master key must be ${MASTER_KEY_BYTES} bytes`);
|
|
101
|
+
}
|
|
102
|
+
if (opts.encryptFn) return opts.encryptFn(masterKey);
|
|
103
|
+
|
|
104
|
+
const { KMSClient, EncryptCommand } = await loadKms();
|
|
105
|
+
const client = new KMSClient(opts.region === undefined ? {} : { region: opts.region });
|
|
106
|
+
try {
|
|
107
|
+
const result = await client.send(new EncryptCommand({ KeyId: keyId, Plaintext: masterKey }));
|
|
108
|
+
const blob = result.CiphertextBlob;
|
|
109
|
+
if (!blob || blob.byteLength === 0) {
|
|
110
|
+
throw new VaultError("BACKEND_ERROR", "vault: kms returned an empty ciphertext blob");
|
|
111
|
+
}
|
|
112
|
+
return new Uint8Array(blob);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if (error instanceof VaultError) throw error;
|
|
115
|
+
// Never surface the SDK error: it can echo request payloads.
|
|
116
|
+
throw new VaultError("BACKEND_ERROR", "vault: kms encrypt failed");
|
|
117
|
+
} finally {
|
|
118
|
+
client.destroy?.();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Build a decrypt closure backed by a real KMS client.
|
|
124
|
+
*
|
|
125
|
+
* @param keyId - KMS key id, alias, or ARN
|
|
126
|
+
* @param region - AWS region
|
|
127
|
+
*/
|
|
128
|
+
async function bindDecrypt(
|
|
129
|
+
keyId: string,
|
|
130
|
+
region: string | undefined,
|
|
131
|
+
): Promise<(ciphertext: Uint8Array) => Promise<Uint8Array>> {
|
|
132
|
+
const { KMSClient, DecryptCommand } = await loadKms();
|
|
133
|
+
return async (ciphertext: Uint8Array) => {
|
|
134
|
+
const client = new KMSClient(region === undefined ? {} : { region });
|
|
135
|
+
try {
|
|
136
|
+
const result = await client.send(
|
|
137
|
+
new DecryptCommand({ KeyId: keyId, CiphertextBlob: ciphertext }),
|
|
138
|
+
);
|
|
139
|
+
const plaintext = result.Plaintext;
|
|
140
|
+
if (!plaintext || plaintext.byteLength === 0) {
|
|
141
|
+
throw new VaultError("BACKEND_ERROR", "vault: kms returned an empty plaintext");
|
|
142
|
+
}
|
|
143
|
+
return new Uint8Array(plaintext);
|
|
144
|
+
} catch (error) {
|
|
145
|
+
if (error instanceof VaultError) throw error;
|
|
146
|
+
// Never surface the SDK error: it can echo key material or ciphertext.
|
|
147
|
+
throw new VaultError("BACKEND_ERROR", "vault: kms decrypt failed");
|
|
148
|
+
} finally {
|
|
149
|
+
client.destroy?.();
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Import the optional `@aws-sdk/client-kms` peer.
|
|
156
|
+
*
|
|
157
|
+
* @throws VaultError `MISSING_PEER` with the exact install command
|
|
158
|
+
*/
|
|
159
|
+
async function loadKms(): Promise<KmsBindings> {
|
|
160
|
+
// Indirect specifier: the peer is optional, so it must not become a
|
|
161
|
+
// static dependency of the module graph.
|
|
162
|
+
const specifier: string = KMS_PACKAGE;
|
|
163
|
+
let mod: unknown;
|
|
164
|
+
try {
|
|
165
|
+
mod = await import(specifier);
|
|
166
|
+
} catch {
|
|
167
|
+
throw new VaultError("MISSING_PEER", `Install peer: bun add ${KMS_PACKAGE}`);
|
|
168
|
+
}
|
|
169
|
+
const bindings = mod as Partial<KmsBindings>;
|
|
170
|
+
if (!bindings.KMSClient || !bindings.DecryptCommand || !bindings.EncryptCommand) {
|
|
171
|
+
throw new VaultError(
|
|
172
|
+
"MISSING_PEER",
|
|
173
|
+
`vault: \`${KMS_PACKAGE}\` is installed but does not export KMSClient / DecryptCommand / EncryptCommand`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
KMSClient: bindings.KMSClient,
|
|
178
|
+
DecryptCommand: bindings.DecryptCommand,
|
|
179
|
+
EncryptCommand: bindings.EncryptCommand,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vault path canonicalization.
|
|
3
|
+
*
|
|
4
|
+
* A path is a slash-separated key with **no** leading or trailing slash:
|
|
5
|
+
* `prod/api/stripe`. Canonicalization runs before every read, write, and
|
|
6
|
+
* audit row so the same logical secret always hashes into the same AAD.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { VaultError } from "./errors.ts";
|
|
10
|
+
|
|
11
|
+
/** Maximum canonical path length in UTF-16 code units. */
|
|
12
|
+
export const MAX_VAULT_PATH_LENGTH = 512;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Normalize and validate a Vault path.
|
|
16
|
+
*
|
|
17
|
+
* Trims surrounding whitespace, strips leading/trailing slashes, and
|
|
18
|
+
* collapses duplicate separators. Rejects empty paths, `..` traversal,
|
|
19
|
+
* NUL bytes, backslashes, and anything longer than
|
|
20
|
+
* {@link MAX_VAULT_PATH_LENGTH}.
|
|
21
|
+
*
|
|
22
|
+
* @param input - Raw path from a caller
|
|
23
|
+
* @throws VaultError `INVALID_PATH` when the path cannot be canonicalized
|
|
24
|
+
*/
|
|
25
|
+
export function canonicalizePath(input: string): string {
|
|
26
|
+
if (typeof input !== "string") {
|
|
27
|
+
throw new VaultError("INVALID_PATH", "vault: path must be a string");
|
|
28
|
+
}
|
|
29
|
+
const trimmed = input.trim();
|
|
30
|
+
if (trimmed.length === 0) {
|
|
31
|
+
throw new VaultError("INVALID_PATH", "vault: path must not be empty");
|
|
32
|
+
}
|
|
33
|
+
if (trimmed.length > MAX_VAULT_PATH_LENGTH) {
|
|
34
|
+
throw new VaultError("INVALID_PATH", `vault: path exceeds ${MAX_VAULT_PATH_LENGTH} characters`);
|
|
35
|
+
}
|
|
36
|
+
if (trimmed.includes("\0")) {
|
|
37
|
+
throw new VaultError("INVALID_PATH", "vault: path must not contain NUL bytes");
|
|
38
|
+
}
|
|
39
|
+
if (trimmed.includes("\\")) {
|
|
40
|
+
throw new VaultError("INVALID_PATH", "vault: path must not contain backslashes");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const segments: string[] = [];
|
|
44
|
+
for (const raw of trimmed.split("/")) {
|
|
45
|
+
const segment = raw.trim();
|
|
46
|
+
if (segment.length === 0) continue;
|
|
47
|
+
if (segment === ".") {
|
|
48
|
+
throw new VaultError("INVALID_PATH", 'vault: path must not contain "." segments');
|
|
49
|
+
}
|
|
50
|
+
if (segment === "..") {
|
|
51
|
+
throw new VaultError("INVALID_PATH", 'vault: path must not contain ".." segments');
|
|
52
|
+
}
|
|
53
|
+
segments.push(segment);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (segments.length === 0) {
|
|
57
|
+
throw new VaultError("INVALID_PATH", "vault: path must contain at least one segment");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const canonical = segments.join("/");
|
|
61
|
+
if (canonical.length > MAX_VAULT_PATH_LENGTH) {
|
|
62
|
+
throw new VaultError("INVALID_PATH", `vault: path exceeds ${MAX_VAULT_PATH_LENGTH} characters`);
|
|
63
|
+
}
|
|
64
|
+
return canonical;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Canonicalize a prefix filter for list queries.
|
|
69
|
+
*
|
|
70
|
+
* Unlike {@link canonicalizePath}, an empty prefix is legal and means
|
|
71
|
+
* "every path".
|
|
72
|
+
*
|
|
73
|
+
* @param input - Raw prefix, or `undefined` for no filter
|
|
74
|
+
*/
|
|
75
|
+
export function canonicalizePrefix(input: string | undefined): string | undefined {
|
|
76
|
+
if (input === undefined) return undefined;
|
|
77
|
+
const trimmed = input.trim();
|
|
78
|
+
if (trimmed.length === 0 || trimmed === "/") return undefined;
|
|
79
|
+
return canonicalizePath(trimmed);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Whether a canonical path sits under a canonical prefix.
|
|
84
|
+
*
|
|
85
|
+
* @param path - Canonical path
|
|
86
|
+
* @param prefix - Canonical prefix
|
|
87
|
+
*/
|
|
88
|
+
export function pathHasPrefix(path: string, prefix: string): boolean {
|
|
89
|
+
return path === prefix || path.startsWith(`${prefix}/`);
|
|
90
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for Vault SQL resilience (retry / no-retry).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import {
|
|
7
|
+
createResilientSqlExec,
|
|
8
|
+
DEFAULT_RETRY,
|
|
9
|
+
isRetryableError,
|
|
10
|
+
withResilience,
|
|
11
|
+
type RetryConfig,
|
|
12
|
+
} from "./resilience.ts";
|
|
13
|
+
import type { SqlExec } from "./storage.ts";
|
|
14
|
+
|
|
15
|
+
const FAST: RetryConfig = {
|
|
16
|
+
...DEFAULT_RETRY,
|
|
17
|
+
baseDelayMs: 1,
|
|
18
|
+
maxDelayMs: 2,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
describe("isRetryableError", () => {
|
|
22
|
+
test("matches known connection codes", () => {
|
|
23
|
+
expect(isRetryableError({ code: "ECONNRESET" })).toBe(true);
|
|
24
|
+
expect(isRetryableError({ code: "08006" })).toBe(true);
|
|
25
|
+
expect(isRetryableError(new Error("connect ETIMEDOUT"))).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("rejects business / unknown errors", () => {
|
|
29
|
+
expect(isRetryableError(new Error("INVALID_PATH"))).toBe(false);
|
|
30
|
+
expect(isRetryableError({ code: "42P01" })).toBe(false);
|
|
31
|
+
expect(isRetryableError(null)).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("withResilience", () => {
|
|
36
|
+
test("retries retryable failures then succeeds", async () => {
|
|
37
|
+
let attempts = 0;
|
|
38
|
+
const value = await withResilience(async () => {
|
|
39
|
+
attempts += 1;
|
|
40
|
+
if (attempts < 3) {
|
|
41
|
+
const err = new Error("connection reset");
|
|
42
|
+
(err as { code?: string }).code = "ECONNRESET";
|
|
43
|
+
throw err;
|
|
44
|
+
}
|
|
45
|
+
return "ok";
|
|
46
|
+
}, FAST);
|
|
47
|
+
expect(value).toBe("ok");
|
|
48
|
+
expect(attempts).toBe(3);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("exhausts retries then fails", async () => {
|
|
52
|
+
let attempts = 0;
|
|
53
|
+
await expect(
|
|
54
|
+
withResilience(
|
|
55
|
+
async () => {
|
|
56
|
+
attempts += 1;
|
|
57
|
+
const err = new Error("gone");
|
|
58
|
+
(err as { code?: string }).code = "57P01";
|
|
59
|
+
throw err;
|
|
60
|
+
},
|
|
61
|
+
{ ...FAST, maxRetries: 3 },
|
|
62
|
+
),
|
|
63
|
+
).rejects.toMatchObject({ code: "57P01" });
|
|
64
|
+
// First try + 3 retries.
|
|
65
|
+
expect(attempts).toBe(4);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("non-retryable errors fail immediately", async () => {
|
|
69
|
+
let attempts = 0;
|
|
70
|
+
await expect(
|
|
71
|
+
withResilience(async () => {
|
|
72
|
+
attempts += 1;
|
|
73
|
+
throw new Error("INVALID_PATH");
|
|
74
|
+
}, FAST),
|
|
75
|
+
).rejects.toThrow("INVALID_PATH");
|
|
76
|
+
expect(attempts).toBe(1);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("createResilientSqlExec", () => {
|
|
81
|
+
test("wraps query with the same retry policy", async () => {
|
|
82
|
+
let attempts = 0;
|
|
83
|
+
const raw: SqlExec = {
|
|
84
|
+
async query<T>() {
|
|
85
|
+
attempts += 1;
|
|
86
|
+
if (attempts === 1) {
|
|
87
|
+
const err = new Error("blip");
|
|
88
|
+
(err as { code?: string }).code = "ETIMEDOUT";
|
|
89
|
+
throw err;
|
|
90
|
+
}
|
|
91
|
+
return [{ id: 1 }] as T[];
|
|
92
|
+
},
|
|
93
|
+
async execute() {
|
|
94
|
+
/* unused */
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
const db = createResilientSqlExec(raw, FAST);
|
|
98
|
+
const rows = await db.query<{ id: number }>("SELECT 1");
|
|
99
|
+
expect(rows).toEqual([{ id: 1 }]);
|
|
100
|
+
expect(attempts).toBe(2);
|
|
101
|
+
});
|
|
102
|
+
});
|