Initial Commit
This commit is contained in:
136
packages/api-stack/api/api-secrets-bun/README.md
Normal file
136
packages/api-stack/api/api-secrets-bun/README.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# @suaveplan/api-secrets-bun — SecretsLike adapter backed by Bun.secrets (macOS Keychain / Linux libsecret / Windows Credential Manager). Per-plugin allowlist enforcement, locked-keystore detection, idempotent dispose, OTel api.secrets.reads_total{backend="bun"} counter.
|
||||
|
||||
Stage-8 `SecretsLike` adapter backed by `Bun.secrets` (macOS Keychain,
|
||||
Linux libsecret, Windows Credential Manager) with per-plugin allow-list
|
||||
enforcement, OS-keystore failure-mode classification, and an idempotent
|
||||
lifecycle.
|
||||
|
||||
The right default for local-dev and single-host operator setups: secrets
|
||||
never touch disk in plaintext and rotate via the OS UI.
|
||||
|
||||
Per-plugin allow-list keeps each plugin scoped to its manifest-declared
|
||||
secrets — a leaked plugin cannot enumerate or read any keystore entry
|
||||
beyond what its `manifest.secrets[]` declared. Values never appear in
|
||||
logs or errors — only names.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [adapter](./src/adapter/adapter.md) — The adapter module builds a concrete `SecretsLike` over the `Bun.secrets` API — the cross-platform wrapper Bun ships ar…
|
||||
- [adapter](./src/adapter/adapter.md) — The adapter module builds a concrete `SecretsLike` over the `Bun.secrets` API — the cross-platform wrapper Bun ships ar…
|
||||
- [allowlist](./src/allowlist/allowlist.md) — The allow-list module encapsulates the per-plugin scoping rule that defines the package's security boundary: a plugin c…
|
||||
- [allowlist](./src/allowlist/allowlist.md) — The allow-list module encapsulates the per-plugin scoping rule that defines the package's security boundary: a plugin c…
|
||||
- [descriptor-builder](./src/descriptor-builder/descriptor-builder.md) — This module produces the `SecretDescriptor[]` shape the plugin manifest emits in its `secrets[]` block from a compact a…
|
||||
- [descriptor-builder](./src/descriptor-builder/descriptor-builder.md) — This module produces the `SecretDescriptor[]` shape the plugin manifest emits in its `secrets[]` block from a compact a…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — This module owns the stable `SECRETS_BUN.*` code registry and the `SecretsBunError` subclass thrown by every failure pa…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — This module owns the stable `SECRETS_BUN.*` code registry and the `SecretsBunError` subclass thrown by every failure pa…
|
||||
- [otel](./src/otel/instruments.md) — This module owns the OpenTelemetry instrument bundle the `Bun.secrets`-backed adapter emits and a lazy cache that re-bi…
|
||||
- [instruments](./src/otel/instruments.md) — This module owns the OpenTelemetry instrument bundle the `Bun.secrets`-backed adapter emits and a lazy cache that re-bi…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/api-secrets-bun
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { createBunSecretsAdapter } from "@suaveplan/api-secrets-bun";
|
||||
|
||||
const adapter = createBunSecretsAdapter({
|
||||
service: "billing-plugin",
|
||||
allowlist: ["STRIPE_SECRET_KEY", "JWT_SIGNING_KEY"],
|
||||
});
|
||||
|
||||
const key = await adapter.secrets.require("STRIPE_SECRET_KEY");
|
||||
```
|
||||
|
||||
### Manifest-driven allow-list
|
||||
|
||||
The companion `buildBunSecretDescriptors` builder lets a plugin derive
|
||||
its `manifest.secrets[]` and the adapter's `allowlist` from a single
|
||||
declaration so they cannot drift:
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildBunSecretDescriptors,
|
||||
descriptorAllowlist,
|
||||
createBunSecretsAdapter,
|
||||
} from "@suaveplan/api-secrets-bun";
|
||||
|
||||
const descriptors = buildBunSecretDescriptors([
|
||||
"STRIPE_SECRET_KEY",
|
||||
{ name: "OPTIONAL_WEBHOOK_KEY", required: false },
|
||||
]);
|
||||
|
||||
const adapter = createBunSecretsAdapter({
|
||||
service: "billing-plugin",
|
||||
allowlist: descriptorAllowlist(descriptors),
|
||||
});
|
||||
|
||||
// `descriptors` goes on the plugin manifest's `secrets[]`.
|
||||
```
|
||||
|
||||
## Capability
|
||||
|
||||
This package declares `secrets.bun`. Plugins that specifically require
|
||||
the Bun keystore (e.g. desktop runtimes that bundle Bun) can pin the
|
||||
backend in `requiredCapabilities`; otherwise the host's `host.secrets.backend`
|
||||
config selects this adapter alongside the abstract `secrets` capability.
|
||||
|
||||
## Failure modes
|
||||
|
||||
Distinguishes:
|
||||
|
||||
- `SECRETS_BUN.NOT_FOUND` — permitted name; OS keystore returned `null`.
|
||||
`get()` resolves to `undefined`; `require()` throws.
|
||||
- `SECRETS_BUN.NOT_ALLOWED` — name not in the plugin's allow-list.
|
||||
Raised BEFORE any keystore round-trip.
|
||||
- `SECRETS_BUN.KEYSTORE_LOCKED` — OS keystore is locked. The adapter
|
||||
does NOT fall back silently — operator must unlock.
|
||||
- `SECRETS_BUN.KEYSTORE_UNAVAILABLE` — `Bun.secrets` is missing
|
||||
(running under Node without the shim), the OS keystore daemon is not
|
||||
running, or the adapter has been `dispose()`d.
|
||||
- `SECRETS_BUN.WRITE_FAILED` — `set`/`delete` failed at the OS layer.
|
||||
Always wraps the underlying error as `cause`.
|
||||
- `SECRETS_BUN.INVALID_OPTIONS` — adapter construction failed Zod
|
||||
validation.
|
||||
|
||||
## OpenTelemetry
|
||||
|
||||
Emits:
|
||||
|
||||
- `api.secrets.reads_total{backend="bun"}` — successful reads.
|
||||
- `api.secrets.not_found_total{backend="bun"}` — permitted-but-absent.
|
||||
- `api.secrets.denied_total{backend="bun"}` — allow-list rejections.
|
||||
|
||||
Metric names are backend-agnostic so dashboards roll up across every
|
||||
`api.secrets.*` source via the `backend` label.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
`adapter.dispose()` is idempotent — repeat calls are no-ops. Post-dispose
|
||||
reads / writes throw `KEYSTORE_UNAVAILABLE` rather than leaking the
|
||||
keystore reference.
|
||||
|
||||
## Conformance
|
||||
|
||||
Passes `runSecretsConformance` from `@suaveplan/api-secrets/testing`
|
||||
unchanged — see `src/adapter/adapter.spec-conformance.test.ts`.
|
||||
|
||||
## Modules
|
||||
|
||||
| Module | Subpath | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `adapter` | `./adapter` | `createBunSecretsAdapter` factory + types. |
|
||||
| `allowlist` | `./allowlist` | Per-plugin allow-list with `assertAllowed`. |
|
||||
| `descriptor-builder` | `./descriptor-builder` | Compact → `SecretDescriptor[]`. |
|
||||
| `error-codes` | `./error-codes` | `SECRETS_BUN.*` registry + `SecretsBunError`. |
|
||||
| `otel` | `./otel` | OTel instruments + reset hook. |
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
90
packages/api-stack/api/api-secrets-bun/package.json
Normal file
90
packages/api-stack/api/api-secrets-bun/package.json
Normal file
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"name": "@suaveplan/api-secrets-bun",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Stage-8 SecretsLike adapter backed by Bun.secrets (macOS Keychain / Linux libsecret / Windows Credential Manager). Per-plugin allowlist enforcement, locked-keystore detection, idempotent dispose, OTel api.secrets.reads_total{backend=\"bun\"} counter.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./adapter": {
|
||||
"import": {
|
||||
"types": "./dist/adapter/adapter.d.ts",
|
||||
"default": "./dist/adapter/adapter.js"
|
||||
}
|
||||
},
|
||||
"./allowlist": {
|
||||
"import": {
|
||||
"types": "./dist/allowlist/allowlist.d.ts",
|
||||
"default": "./dist/allowlist/allowlist.js"
|
||||
}
|
||||
},
|
||||
"./descriptor-builder": {
|
||||
"import": {
|
||||
"types": "./dist/descriptor-builder/descriptor-builder.d.ts",
|
||||
"default": "./dist/descriptor-builder/descriptor-builder.js"
|
||||
}
|
||||
},
|
||||
"./error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/error-codes.d.ts",
|
||||
"default": "./dist/error-codes/error-codes.js"
|
||||
}
|
||||
},
|
||||
"./otel": {
|
||||
"import": {
|
||||
"types": "./dist/otel/instruments.d.ts",
|
||||
"default": "./dist/otel/instruments.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lint": "bunx biome check src/ tests/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/ tests/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/ tests/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/ tests/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/api-secrets": "workspace:^",
|
||||
"@suaveplan/api-types": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opentelemetry/sdk-metrics": "1.30.0",
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
121
packages/api-stack/api/api-secrets-bun/src/adapter/adapter.md
Normal file
121
packages/api-stack/api/api-secrets-bun/src/adapter/adapter.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# `adapter`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The adapter module builds a concrete `SecretsLike` over the
|
||||
`Bun.secrets` API — the cross-platform wrapper Bun ships around the OS
|
||||
keystore (macOS Keychain, Linux libsecret, Windows Credential Manager).
|
||||
The host installs the returned `secrets` handle as `ctx.secrets` so
|
||||
plugins read manifest-declared secrets through the same surface every
|
||||
other backend exposes; the only difference is values live in the OS
|
||||
keystore rather than `process.env`, a remote vault, or a worker proxy.
|
||||
|
||||
The adapter also exposes adapter-specific `set` / `deleteSecret`
|
||||
helpers that the abstract `SecretsLike` does not need but are essential
|
||||
for the operator UX of rotating a secret without dropping out to the
|
||||
OS UI. Both helpers go through the same allow-list gate as `get` so a
|
||||
plugin cannot write outside its manifest.
|
||||
|
||||
## Features
|
||||
|
||||
- Per-plugin allow-list gates every `get` / `require` / `set` /
|
||||
`deleteSecret` BEFORE the keystore round-trip.
|
||||
- Distinguishes `NOT_FOUND` (`null` from `Bun.secrets.get`) from
|
||||
`KEYSTORE_LOCKED` and `KEYSTORE_UNAVAILABLE` via a classifier that
|
||||
inspects `error.code` first then falls back to message substrings.
|
||||
- Idempotent `dispose()` for lifecycle dedup; post-dispose reads /
|
||||
writes throw `KEYSTORE_UNAVAILABLE`.
|
||||
- Emits `api.secrets.reads_total{backend="bun"}` on every successful
|
||||
resolve, `api.secrets.not_found_total{backend="bun"}` on every
|
||||
permitted-but-absent read, and `api.secrets.denied_total{backend="bun"}`
|
||||
on every allow-list rejection.
|
||||
- Injectable `keystore` and `classifyError` so tests run against an
|
||||
in-memory keystore on Node without touching the real OS daemon.
|
||||
- Zod-validated options — empty `service` / empty `allowlist` / empty
|
||||
names all raise `SECRETS_BUN.INVALID_OPTIONS` at construction.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createBunSecretsAdapter } from "@suaveplan/api-secrets-bun/adapter";
|
||||
|
||||
const adapter = createBunSecretsAdapter({
|
||||
service: "my-plugin",
|
||||
allowlist: ["STRIPE_SECRET_KEY", "JWT_SIGNING_KEY"],
|
||||
});
|
||||
|
||||
const key = await adapter.secrets.require("STRIPE_SECRET_KEY");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Inject a custom keystore (test harness) and a bespoke classifier (e.g.
|
||||
when wrapping a third-party keystore CLI whose error codes do not match
|
||||
Bun's convention):
|
||||
|
||||
```ts
|
||||
import {
|
||||
createBunSecretsAdapter,
|
||||
type BunSecretsKeystore,
|
||||
} from "@suaveplan/api-secrets-bun/adapter";
|
||||
|
||||
const memory: BunSecretsKeystore = {
|
||||
/* ... in-memory get / set / delete ... */
|
||||
};
|
||||
|
||||
const adapter = createBunSecretsAdapter({
|
||||
service: "my-plugin",
|
||||
allowlist: ["A"],
|
||||
keystore: memory,
|
||||
classifyError: (err) =>
|
||||
typeof err === "object" && err !== null && "stderr" in err &&
|
||||
String((err as { stderr: unknown }).stderr).includes("locked")
|
||||
? "KEYSTORE_LOCKED"
|
||||
: undefined,
|
||||
});
|
||||
|
||||
await adapter.set("A", "v");
|
||||
const v = await adapter.secrets.require("A");
|
||||
|
||||
// Lifecycle: dispose once at plugin teardown.
|
||||
adapter.dispose();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createBunSecretsAdapter(options): BunSecretsAdapter` — entry point.
|
||||
- `BunSecretsAdapter.secrets: SecretsLike` — read-only handle the host
|
||||
installs as `ctx.secrets`.
|
||||
- `BunSecretsAdapter.set(name, value): Promise<void>` — adapter-specific
|
||||
write through the allow-list and keystore.
|
||||
- `BunSecretsAdapter.deleteSecret(name): Promise<void>` — adapter-specific
|
||||
delete.
|
||||
- `BunSecretsAdapter.dispose(): void` — idempotent lifecycle teardown.
|
||||
- `BunSecretsKeystore` — structural view of `Bun.secrets`.
|
||||
- `defaultClassifyError(err): "KEYSTORE_LOCKED" | "KEYSTORE_UNAVAILABLE" | undefined` —
|
||||
default failure-mode classifier.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The default keystore probe reads `globalThis.Bun.secrets` rather than
|
||||
importing the `Bun` global directly, so the package typechecks under
|
||||
plain Node when `@types/bun` is absent. When the probe returns
|
||||
`undefined` (Node, or a Bun runtime missing the `secrets` namespace),
|
||||
the constructor throws `KEYSTORE_UNAVAILABLE` so a misconfigured host
|
||||
fails loudly at start rather than at first read.
|
||||
|
||||
`api.secrets.reads_total` is incremented from inside `get` / `require`
|
||||
on the success path only, AFTER the keystore returns a non-`null` value.
|
||||
This pins the metric to actual reads rather than attempts so dashboards
|
||||
graphing read pressure are not contaminated by allow-list rejections
|
||||
(those live on `denied_total`) or missing entries (those live on
|
||||
`not_found_total`).
|
||||
|
||||
The `dispose` flag is checked first inside every public call so a
|
||||
disposed adapter cannot leak a stale keystore reference; it is also
|
||||
idempotent so the host can call it from both a `SIGTERM` handler and a
|
||||
plugin-unload hook without crashing.
|
||||
@@ -0,0 +1,67 @@
|
||||
// NOTE: import from source not dist — api-secrets's dist bundles vitest
|
||||
// internals (pre-existing build-config bug; tracked upstream). Source
|
||||
// import goes through workspace TS resolution and uses our local vitest.
|
||||
import { runSecretsConformance } from "../../../api-secrets/src/testing/conformance.js";
|
||||
import {
|
||||
SECRETS_BUN_CODES,
|
||||
type SecretsBunError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { type BunSecretsKeystore, createBunSecretsAdapter } from "./adapter.js";
|
||||
|
||||
function inMemoryKeystore(): BunSecretsKeystore {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
async get({ service, name }) {
|
||||
return store.get(`${service}:${name}`) ?? null;
|
||||
},
|
||||
async set({ service, name, value }) {
|
||||
store.set(`${service}:${name}`, value);
|
||||
},
|
||||
async delete({ service, name }) {
|
||||
store.delete(`${service}:${name}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
runSecretsConformance({
|
||||
label: "bun",
|
||||
factory: async (seed) => {
|
||||
const keystore = inMemoryKeystore();
|
||||
const seededNames = Object.keys(seed);
|
||||
// The harness's "MISSING" probe routes through the NOT_FOUND path
|
||||
// rather than NOT_ALLOWED — allow it explicitly.
|
||||
const allowlist =
|
||||
seededNames.length === 0
|
||||
? ["MISSING"]
|
||||
: [...seededNames, "MISSING"];
|
||||
const adapter = createBunSecretsAdapter({
|
||||
service: "conformance",
|
||||
allowlist,
|
||||
keystore,
|
||||
});
|
||||
for (const [k, v] of Object.entries(seed)) {
|
||||
await adapter.set(k, v);
|
||||
}
|
||||
return {
|
||||
secrets: {
|
||||
get: (name) => adapter.secrets.get(name),
|
||||
require: (name) => adapter.secrets.require(name),
|
||||
// The harness verifies `list()` returns exactly the seeded
|
||||
// names. Strip the "MISSING" probe added for NOT_FOUND
|
||||
// routing unless the seed itself contained it.
|
||||
list: () =>
|
||||
adapter.secrets
|
||||
.list()
|
||||
.filter((n) => n !== "MISSING" || n in seed),
|
||||
},
|
||||
teardown: () => {
|
||||
adapter.dispose();
|
||||
},
|
||||
};
|
||||
},
|
||||
isNotFound: (err) =>
|
||||
typeof err === "object" &&
|
||||
err !== null &&
|
||||
"code" in err &&
|
||||
(err as SecretsBunError).code === SECRETS_BUN_CODES.NOT_FOUND,
|
||||
});
|
||||
@@ -0,0 +1,597 @@
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import {
|
||||
SECRETS_BUN_CODES,
|
||||
SecretsBunError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { resetSecretsBunInstruments } from "../otel/instruments.js";
|
||||
import {
|
||||
__setBunGlobalProbeForTest,
|
||||
type BunSecretsKeystore,
|
||||
createBunSecretsAdapter,
|
||||
defaultClassifyError,
|
||||
} from "./adapter.js";
|
||||
|
||||
function inMemoryKeystore(
|
||||
initial: Record<string, string> = {}
|
||||
): BunSecretsKeystore & { store: Map<string, string> } {
|
||||
const store = new Map<string, string>(Object.entries(initial));
|
||||
return {
|
||||
store,
|
||||
async get({ service, name }) {
|
||||
return store.get(`${service}:${name}`) ?? null;
|
||||
},
|
||||
async set({ service, name, value }) {
|
||||
store.set(`${service}:${name}`, value);
|
||||
},
|
||||
async delete({ service, name }) {
|
||||
store.delete(`${service}:${name}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function throwingKeystore(err: unknown): BunSecretsKeystore {
|
||||
return {
|
||||
async get() {
|
||||
throw err;
|
||||
},
|
||||
async set() {
|
||||
throw err;
|
||||
},
|
||||
async delete() {
|
||||
throw err;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let exporter: InMemoryMetricExporter;
|
||||
let provider: MeterProvider;
|
||||
|
||||
beforeEach(() => {
|
||||
metrics.disable();
|
||||
exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
|
||||
const reader = new PeriodicExportingMetricReader({
|
||||
exporter,
|
||||
exportIntervalMillis: 60_000,
|
||||
});
|
||||
provider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(provider);
|
||||
resetSecretsBunInstruments();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await provider.forceFlush();
|
||||
metrics.disable();
|
||||
resetSecretsBunInstruments();
|
||||
});
|
||||
|
||||
async function readsTotal(): Promise<number> {
|
||||
await provider.forceFlush();
|
||||
let total = 0;
|
||||
for (const rm of exporter.getMetrics()) {
|
||||
for (const sm of rm.scopeMetrics) {
|
||||
for (const m of sm.metrics) {
|
||||
if (m.descriptor.name !== "api.secrets.reads_total") continue;
|
||||
for (const dp of m.dataPoints) {
|
||||
total += dp.value as number;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
describe("createBunSecretsAdapter — option validation", () => {
|
||||
it("rejects empty allowlist", { timeout: 5000 }, () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
createBunSecretsAdapter({
|
||||
service: "plugin",
|
||||
allowlist: [],
|
||||
keystore: inMemoryKeystore(),
|
||||
});
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(SecretsBunError);
|
||||
expect((caught as SecretsBunError).code).toBe(
|
||||
SECRETS_BUN_CODES.INVALID_OPTIONS
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects empty service", { timeout: 5000 }, () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
createBunSecretsAdapter({
|
||||
service: "",
|
||||
allowlist: ["A"],
|
||||
keystore: inMemoryKeystore(),
|
||||
});
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect((caught as SecretsBunError).code).toBe(
|
||||
SECRETS_BUN_CODES.INVALID_OPTIONS
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects empty-string allowlist entry", { timeout: 5000 }, () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
createBunSecretsAdapter({
|
||||
service: "plugin",
|
||||
allowlist: [""],
|
||||
keystore: inMemoryKeystore(),
|
||||
});
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect((caught as SecretsBunError).code).toBe(
|
||||
SECRETS_BUN_CODES.INVALID_OPTIONS
|
||||
);
|
||||
});
|
||||
|
||||
it("throws KEYSTORE_UNAVAILABLE when globalThis.Bun is undefined", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
__setBunGlobalProbeForTest(() => undefined);
|
||||
let caught: unknown;
|
||||
try {
|
||||
createBunSecretsAdapter({ service: "p", allowlist: ["A"] });
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect((caught as SecretsBunError).code).toBe(
|
||||
SECRETS_BUN_CODES.KEYSTORE_UNAVAILABLE
|
||||
);
|
||||
} finally {
|
||||
__setBunGlobalProbeForTest(undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws KEYSTORE_UNAVAILABLE when globalThis.Bun is null", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
// Probe returns null — typeof null is "object" so the
|
||||
// primary type guard alone would let it through; the `!== null`
|
||||
// arm is the prevention.
|
||||
__setBunGlobalProbeForTest(() => null);
|
||||
let caught: unknown;
|
||||
try {
|
||||
createBunSecretsAdapter({ service: "p", allowlist: ["A"] });
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect((caught as SecretsBunError).code).toBe(
|
||||
SECRETS_BUN_CODES.KEYSTORE_UNAVAILABLE
|
||||
);
|
||||
} finally {
|
||||
__setBunGlobalProbeForTest(undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws KEYSTORE_UNAVAILABLE when Bun has no secrets namespace", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
__setBunGlobalProbeForTest(() => ({}));
|
||||
let caught: unknown;
|
||||
try {
|
||||
createBunSecretsAdapter({ service: "p", allowlist: ["A"] });
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect((caught as SecretsBunError).code).toBe(
|
||||
SECRETS_BUN_CODES.KEYSTORE_UNAVAILABLE
|
||||
);
|
||||
} finally {
|
||||
__setBunGlobalProbeForTest(undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("exercises the default probe (no override, no keystore supplied)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Ensure the default probe is in effect — restoring is a no-op when
|
||||
// nothing overrode it but pinned here for documentation.
|
||||
__setBunGlobalProbeForTest(undefined);
|
||||
// Under vitest's forked Node pool the Bun global is absent →
|
||||
// the default probe returns undefined → constructor throws.
|
||||
// Under Bun, the global IS present and `secrets` is a real
|
||||
// namespace — supply a keystore explicitly to avoid touching
|
||||
// the real OS keystore.
|
||||
const bunGlobal = (globalThis as { Bun?: { secrets?: unknown } }).Bun;
|
||||
if (bunGlobal?.secrets === undefined) {
|
||||
expect(() =>
|
||||
createBunSecretsAdapter({ service: "p", allowlist: ["A"] })
|
||||
).toThrowError(/KEYSTORE_UNAVAILABLE/);
|
||||
} else {
|
||||
// Real Bun runtime — confirm the default probe sees the
|
||||
// secrets namespace by constructing the adapter and reading
|
||||
// its allowlist (no keystore round-trip).
|
||||
const a = createBunSecretsAdapter({
|
||||
service: "@suaveplan/api-secrets-bun-default-probe-test",
|
||||
allowlist: ["ZZZ_PROBE_ONLY"],
|
||||
});
|
||||
expect(a.allowlist.has("ZZZ_PROBE_ONLY")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses globalThis.Bun.secrets when no keystore supplied", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fake = inMemoryKeystore({ "p:A": "fromBun" });
|
||||
try {
|
||||
__setBunGlobalProbeForTest(() => ({ secrets: fake }));
|
||||
const a = createBunSecretsAdapter({
|
||||
service: "p",
|
||||
allowlist: ["A"],
|
||||
});
|
||||
await expect(a.secrets.get("A")).resolves.toBe("fromBun");
|
||||
} finally {
|
||||
__setBunGlobalProbeForTest(undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createBunSecretsAdapter — get / require", () => {
|
||||
it("get returns stored value AND increments reads_total", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const keystore = inMemoryKeystore({ "p:STRIPE": "sk_live" });
|
||||
const a = createBunSecretsAdapter({
|
||||
service: "p",
|
||||
allowlist: ["STRIPE"],
|
||||
keystore,
|
||||
});
|
||||
await expect(a.secrets.get("STRIPE")).resolves.toBe("sk_live");
|
||||
expect(await readsTotal()).toBe(1);
|
||||
});
|
||||
|
||||
it("get resolves to undefined for permitted-but-absent name", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const a = createBunSecretsAdapter({
|
||||
service: "p",
|
||||
allowlist: ["STRIPE"],
|
||||
keystore: inMemoryKeystore(),
|
||||
});
|
||||
await expect(a.secrets.get("STRIPE")).resolves.toBeUndefined();
|
||||
expect(await readsTotal()).toBe(0);
|
||||
});
|
||||
|
||||
it("require throws NOT_FOUND for permitted-but-absent name", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const a = createBunSecretsAdapter({
|
||||
service: "p",
|
||||
allowlist: ["STRIPE"],
|
||||
keystore: inMemoryKeystore(),
|
||||
});
|
||||
await expect(a.secrets.require("STRIPE")).rejects.toMatchObject({
|
||||
code: SECRETS_BUN_CODES.NOT_FOUND,
|
||||
});
|
||||
});
|
||||
|
||||
it("require returns the stored value", { timeout: 5000 }, async () => {
|
||||
const a = createBunSecretsAdapter({
|
||||
service: "p",
|
||||
allowlist: ["A"],
|
||||
keystore: inMemoryKeystore({ "p:A": "v" }),
|
||||
});
|
||||
await expect(a.secrets.require("A")).resolves.toBe("v");
|
||||
});
|
||||
|
||||
it("list returns the frozen allowlist", { timeout: 5000 }, () => {
|
||||
const a = createBunSecretsAdapter({
|
||||
service: "p",
|
||||
allowlist: ["A", "B"],
|
||||
keystore: inMemoryKeystore(),
|
||||
});
|
||||
const names = [...a.secrets.list()].sort();
|
||||
expect(names).toEqual(["A", "B"]);
|
||||
expect(Object.isFrozen(a.secrets.list())).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createBunSecretsAdapter — allow-list", () => {
|
||||
it("get rejects un-manifested name BEFORE keystore round-trip", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let getCalls = 0;
|
||||
const keystore: BunSecretsKeystore = {
|
||||
async get() {
|
||||
getCalls += 1;
|
||||
return null;
|
||||
},
|
||||
async set() {},
|
||||
async delete() {},
|
||||
};
|
||||
const a = createBunSecretsAdapter({
|
||||
service: "p",
|
||||
allowlist: ["STRIPE"],
|
||||
keystore,
|
||||
});
|
||||
await expect(a.secrets.get("AWS")).rejects.toMatchObject({
|
||||
code: SECRETS_BUN_CODES.NOT_ALLOWED,
|
||||
});
|
||||
expect(getCalls).toBe(0);
|
||||
});
|
||||
|
||||
it("require rejects un-manifested name", { timeout: 5000 }, async () => {
|
||||
const a = createBunSecretsAdapter({
|
||||
service: "p",
|
||||
allowlist: ["STRIPE"],
|
||||
keystore: inMemoryKeystore(),
|
||||
});
|
||||
await expect(a.secrets.require("AWS")).rejects.toMatchObject({
|
||||
code: SECRETS_BUN_CODES.NOT_ALLOWED,
|
||||
});
|
||||
});
|
||||
|
||||
it("set rejects un-manifested name", { timeout: 5000 }, async () => {
|
||||
const a = createBunSecretsAdapter({
|
||||
service: "p",
|
||||
allowlist: ["A"],
|
||||
keystore: inMemoryKeystore(),
|
||||
});
|
||||
await expect(a.set("AWS", "v")).rejects.toMatchObject({
|
||||
code: SECRETS_BUN_CODES.NOT_ALLOWED,
|
||||
});
|
||||
});
|
||||
|
||||
it("deleteSecret rejects un-manifested name", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const a = createBunSecretsAdapter({
|
||||
service: "p",
|
||||
allowlist: ["A"],
|
||||
keystore: inMemoryKeystore(),
|
||||
});
|
||||
await expect(a.deleteSecret("AWS")).rejects.toMatchObject({
|
||||
code: SECRETS_BUN_CODES.NOT_ALLOWED,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("createBunSecretsAdapter — keystore failure modes", () => {
|
||||
it("locked keystore raises SECRETS_BUN_KEYSTORE_LOCKED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const a = createBunSecretsAdapter({
|
||||
service: "p",
|
||||
allowlist: ["A"],
|
||||
keystore: throwingKeystore(
|
||||
Object.assign(new Error("keystore is locked"), {
|
||||
code: "ERR_KEYSTORE_LOCKED",
|
||||
})
|
||||
),
|
||||
});
|
||||
await expect(a.secrets.get("A")).rejects.toMatchObject({
|
||||
code: SECRETS_BUN_CODES.KEYSTORE_LOCKED,
|
||||
});
|
||||
});
|
||||
|
||||
it("locked classifier from message triggers KEYSTORE_LOCKED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const a = createBunSecretsAdapter({
|
||||
service: "p",
|
||||
allowlist: ["A"],
|
||||
keystore: throwingKeystore(new Error("keystore Locked by user")),
|
||||
});
|
||||
await expect(a.secrets.require("A")).rejects.toMatchObject({
|
||||
code: SECRETS_BUN_CODES.KEYSTORE_LOCKED,
|
||||
});
|
||||
});
|
||||
|
||||
it("daemon-not-running raises KEYSTORE_UNAVAILABLE via code", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const a = createBunSecretsAdapter({
|
||||
service: "p",
|
||||
allowlist: ["A"],
|
||||
keystore: throwingKeystore(
|
||||
Object.assign(new Error("nope"), {
|
||||
code: "ERR_KEYSTORE_UNAVAILABLE",
|
||||
})
|
||||
),
|
||||
});
|
||||
await expect(a.secrets.get("A")).rejects.toMatchObject({
|
||||
code: SECRETS_BUN_CODES.KEYSTORE_UNAVAILABLE,
|
||||
});
|
||||
});
|
||||
|
||||
it("daemon-not-running raises KEYSTORE_UNAVAILABLE via message", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const a = createBunSecretsAdapter({
|
||||
service: "p",
|
||||
allowlist: ["A"],
|
||||
keystore: throwingKeystore(
|
||||
new Error("libsecret daemon not running")
|
||||
),
|
||||
});
|
||||
await expect(a.secrets.get("A")).rejects.toMatchObject({
|
||||
code: SECRETS_BUN_CODES.KEYSTORE_UNAVAILABLE,
|
||||
});
|
||||
});
|
||||
|
||||
it("unknown failure wraps as WRITE_FAILED", { timeout: 5000 }, async () => {
|
||||
const a = createBunSecretsAdapter({
|
||||
service: "p",
|
||||
allowlist: ["A"],
|
||||
keystore: throwingKeystore(new Error("acl quota exceeded")),
|
||||
});
|
||||
await expect(a.set("A", "v")).rejects.toMatchObject({
|
||||
code: SECRETS_BUN_CODES.WRITE_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("non-Error throwables go through the unknown branch", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const a = createBunSecretsAdapter({
|
||||
service: "p",
|
||||
allowlist: ["A"],
|
||||
keystore: throwingKeystore("plain string"),
|
||||
});
|
||||
await expect(a.deleteSecret("A")).rejects.toMatchObject({
|
||||
code: SECRETS_BUN_CODES.WRITE_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("custom classifyError overrides the default classifier", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const a = createBunSecretsAdapter({
|
||||
service: "p",
|
||||
allowlist: ["A"],
|
||||
keystore: throwingKeystore(new Error("anything")),
|
||||
classifyError: () => "KEYSTORE_LOCKED",
|
||||
});
|
||||
await expect(a.secrets.get("A")).rejects.toMatchObject({
|
||||
code: SECRETS_BUN_CODES.KEYSTORE_LOCKED,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("createBunSecretsAdapter — set / delete", () => {
|
||||
it("set writes through to the keystore", { timeout: 5000 }, async () => {
|
||||
const keystore = inMemoryKeystore();
|
||||
const a = createBunSecretsAdapter({
|
||||
service: "p",
|
||||
allowlist: ["A"],
|
||||
keystore,
|
||||
});
|
||||
await a.set("A", "v");
|
||||
expect(keystore.store.get("p:A")).toBe("v");
|
||||
});
|
||||
|
||||
it("deleteSecret removes the entry", { timeout: 5000 }, async () => {
|
||||
const keystore = inMemoryKeystore({ "p:A": "v" });
|
||||
const a = createBunSecretsAdapter({
|
||||
service: "p",
|
||||
allowlist: ["A"],
|
||||
keystore,
|
||||
});
|
||||
await a.deleteSecret("A");
|
||||
expect(keystore.store.has("p:A")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createBunSecretsAdapter — lifecycle", () => {
|
||||
it("dispose() is idempotent and gates subsequent reads", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const a = createBunSecretsAdapter({
|
||||
service: "p",
|
||||
allowlist: ["A"],
|
||||
keystore: inMemoryKeystore({ "p:A": "v" }),
|
||||
});
|
||||
a.dispose();
|
||||
a.dispose(); // second call must not throw
|
||||
await expect(a.secrets.get("A")).rejects.toMatchObject({
|
||||
code: SECRETS_BUN_CODES.KEYSTORE_UNAVAILABLE,
|
||||
});
|
||||
await expect(a.secrets.require("A")).rejects.toMatchObject({
|
||||
code: SECRETS_BUN_CODES.KEYSTORE_UNAVAILABLE,
|
||||
});
|
||||
await expect(a.set("A", "x")).rejects.toMatchObject({
|
||||
code: SECRETS_BUN_CODES.KEYSTORE_UNAVAILABLE,
|
||||
});
|
||||
await expect(a.deleteSecret("A")).rejects.toMatchObject({
|
||||
code: SECRETS_BUN_CODES.KEYSTORE_UNAVAILABLE,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultClassifyError", () => {
|
||||
it("returns undefined for non-objects", { timeout: 5000 }, () => {
|
||||
expect(defaultClassifyError(undefined)).toBeUndefined();
|
||||
expect(defaultClassifyError(null)).toBeUndefined();
|
||||
expect(defaultClassifyError(42)).toBeUndefined();
|
||||
expect(defaultClassifyError("locked")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for an unrecognised Error", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(defaultClassifyError(new Error("acl quota"))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("classifies via error.code first", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
defaultClassifyError(
|
||||
Object.assign(new Error("anything"), {
|
||||
code: "ERR_KEYSTORE_LOCKED",
|
||||
})
|
||||
)
|
||||
).toBe("KEYSTORE_LOCKED");
|
||||
expect(
|
||||
defaultClassifyError(
|
||||
Object.assign(new Error("anything"), {
|
||||
code: "ERR_KEYSTORE_UNAVAILABLE",
|
||||
})
|
||||
)
|
||||
).toBe("KEYSTORE_UNAVAILABLE");
|
||||
});
|
||||
|
||||
it("falls through to message when code is a string but unknown", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Hits the false-branch of `code === "ERR_KEYSTORE_UNAVAILABLE"`
|
||||
// by supplying a recognised-string-code that is neither known
|
||||
// value. Falls through to the message-based branch.
|
||||
expect(
|
||||
defaultClassifyError(
|
||||
Object.assign(new Error("acl quota"), {
|
||||
code: "ERR_SOME_OTHER",
|
||||
})
|
||||
)
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores a non-string error.code", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
defaultClassifyError(
|
||||
Object.assign(new Error("acl quota"), { code: 42 })
|
||||
)
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores a non-string error.message", { timeout: 5000 }, () => {
|
||||
const err = Object.create(null) as { message?: unknown };
|
||||
err.message = 42;
|
||||
expect(defaultClassifyError(err)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls through message substrings", { timeout: 5000 }, () => {
|
||||
expect(defaultClassifyError(new Error("Keystore Locked"))).toBe(
|
||||
"KEYSTORE_LOCKED"
|
||||
);
|
||||
expect(defaultClassifyError(new Error("daemon not running"))).toBe(
|
||||
"KEYSTORE_UNAVAILABLE"
|
||||
);
|
||||
expect(defaultClassifyError(new Error("service unavailable"))).toBe(
|
||||
"KEYSTORE_UNAVAILABLE"
|
||||
);
|
||||
expect(defaultClassifyError(new Error("no such service"))).toBe(
|
||||
"KEYSTORE_UNAVAILABLE"
|
||||
);
|
||||
});
|
||||
});
|
||||
295
packages/api-stack/api/api-secrets-bun/src/adapter/adapter.ts
Normal file
295
packages/api-stack/api/api-secrets-bun/src/adapter/adapter.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import type { SecretsLike } from "@suaveplan/api-types/secrets";
|
||||
import z from "zod";
|
||||
import { type Allowlist, createAllowlist } from "../allowlist/allowlist.js";
|
||||
import {
|
||||
SECRETS_BUN_CODES,
|
||||
SecretsBunError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { getSecretsBunInstruments } from "../otel/instruments.js";
|
||||
|
||||
/**
|
||||
* Structural view of `Bun.secrets`. Captured as an interface so tests
|
||||
* inject a deterministic in-memory keystore without touching the real
|
||||
* OS daemon, and so the adapter can be exercised under Node where the
|
||||
* `Bun` global is absent.
|
||||
*/
|
||||
export interface BunSecretsKeystore {
|
||||
get(args: { service: string; name: string }): Promise<string | null>;
|
||||
set(args: { service: string; name: string; value: string }): Promise<void>;
|
||||
delete(args: { service: string; name: string }): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classifier for errors thrown by the underlying `Bun.secrets` daemon.
|
||||
* Returns a {@link SecretsBunError} code or `undefined` to let the
|
||||
* caller wrap the error as `WRITE_FAILED` / `KEYSTORE_UNAVAILABLE` at
|
||||
* its own discretion.
|
||||
*
|
||||
* The default classifier inspects `error.code` (Bun's convention for
|
||||
* OS-keystore failures) and `error.message` for the well-known locked /
|
||||
* unavailable substrings. Operators with bespoke keystore wrappers can
|
||||
* supply their own to retag failure modes.
|
||||
*/
|
||||
export type BunSecretsErrorClassifier = (
|
||||
err: unknown
|
||||
) => "KEYSTORE_LOCKED" | "KEYSTORE_UNAVAILABLE" | undefined;
|
||||
|
||||
/**
|
||||
* Construction options for {@link createBunSecretsAdapter}.
|
||||
*
|
||||
* - `service` — the `Bun.secrets` service namespace. Every plugin gets
|
||||
* its own service so a leaked plugin cannot enumerate sibling
|
||||
* plugins' secrets via the OS UI.
|
||||
* - `allowlist` — per-plugin whitelist of permitted secret names, the
|
||||
* shape `manifest.secrets[].name` returns. Required and non-empty.
|
||||
* - `keystore` — injectable {@link BunSecretsKeystore}. Defaults to
|
||||
* `globalThis.Bun.secrets`. Tests supply an in-memory implementation.
|
||||
* - `classifyError` — optional override for the failure-mode classifier
|
||||
* above. Defaults to {@link defaultClassifyError}.
|
||||
*/
|
||||
export interface CreateBunSecretsAdapterOptions {
|
||||
readonly service: string;
|
||||
readonly allowlist: readonly string[];
|
||||
readonly keystore?: BunSecretsKeystore;
|
||||
readonly classifyError?: BunSecretsErrorClassifier;
|
||||
}
|
||||
|
||||
const optionsSchema = z.object({
|
||||
service: z.string().min(1),
|
||||
allowlist: z.array(z.string().min(1)).min(1),
|
||||
keystore: z.unknown().optional(),
|
||||
classifyError: z.unknown().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Handle returned from {@link createBunSecretsAdapter}. Exposes the
|
||||
* concrete {@link SecretsLike} the host installs as `ctx.secrets`, plus
|
||||
* the adapter-specific `set` / `delete` helpers (the abstract surface
|
||||
* stays read-only by design), the source allow-list, and an idempotent
|
||||
* `dispose()` for lifecycle dedup.
|
||||
*/
|
||||
export interface BunSecretsAdapter {
|
||||
readonly secrets: SecretsLike;
|
||||
readonly allowlist: Allowlist;
|
||||
set(name: string, value: string): Promise<void>;
|
||||
deleteSecret(name: string): Promise<void>;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default failure-mode classifier. Inspects `error.code` first (Bun's
|
||||
* convention for OS-keystore errors) then falls back to a curated set
|
||||
* of well-known message substrings.
|
||||
*/
|
||||
export function defaultClassifyError(
|
||||
err: unknown
|
||||
): "KEYSTORE_LOCKED" | "KEYSTORE_UNAVAILABLE" | undefined {
|
||||
if (typeof err !== "object" || err === null) return undefined;
|
||||
const code = (err as { code?: unknown }).code;
|
||||
if (typeof code === "string") {
|
||||
if (code === "ERR_KEYSTORE_LOCKED") return "KEYSTORE_LOCKED";
|
||||
if (code === "ERR_KEYSTORE_UNAVAILABLE") return "KEYSTORE_UNAVAILABLE";
|
||||
}
|
||||
const message = (err as { message?: unknown }).message;
|
||||
if (typeof message === "string") {
|
||||
const lower = message.toLowerCase();
|
||||
if (lower.includes("locked")) return "KEYSTORE_LOCKED";
|
||||
if (
|
||||
lower.includes("not running") ||
|
||||
lower.includes("unavailable") ||
|
||||
lower.includes("no such service")
|
||||
) {
|
||||
return "KEYSTORE_UNAVAILABLE";
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const defaultBunGlobalProbe = (): unknown =>
|
||||
(globalThis as { Bun?: unknown }).Bun;
|
||||
let bunGlobalProbe: () => unknown = defaultBunGlobalProbe;
|
||||
|
||||
/**
|
||||
* Test-only hook: replace the `globalThis.Bun` probe used by
|
||||
* {@link defaultBunSecrets}. Bun publishes `globalThis.Bun` as
|
||||
* `configurable: false, writable: false`, so direct mutation /
|
||||
* `Object.defineProperty` throws under a real Bun runtime. Tests inject
|
||||
* a probe that returns whatever shape is needed (e.g. `{ secrets: ... }`,
|
||||
* `{}` to force the no-secrets branch, `undefined` for no-Bun). Pass
|
||||
* `undefined` to restore the default `globalThis.Bun` probe. Not part of
|
||||
* the public surface.
|
||||
*/
|
||||
export function __setBunGlobalProbeForTest(
|
||||
probe: (() => unknown) | undefined
|
||||
): void {
|
||||
bunGlobalProbe = probe ?? defaultBunGlobalProbe;
|
||||
}
|
||||
|
||||
function defaultBunSecrets(): BunSecretsKeystore | undefined {
|
||||
const bun = bunGlobalProbe() as
|
||||
| { secrets?: BunSecretsKeystore }
|
||||
| undefined
|
||||
| null;
|
||||
if (typeof bun !== "object" || bun === null) return undefined;
|
||||
return bun.secrets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a {@link BunSecretsAdapter} backed by `Bun.secrets`. The
|
||||
* returned `secrets` handle satisfies `SecretsLike` and gates every
|
||||
* read on the per-plugin allow-list. The adapter:
|
||||
*
|
||||
* 1. validates options with Zod and throws
|
||||
* `SECRETS_BUN.INVALID_OPTIONS` on failure;
|
||||
* 2. probes `globalThis.Bun.secrets` if no `keystore` was supplied and
|
||||
* throws `SECRETS_BUN.KEYSTORE_UNAVAILABLE` when absent;
|
||||
* 3. enforces the allow-list BEFORE any keystore round-trip;
|
||||
* 4. distinguishes `NOT_FOUND` (`null` from `get`) from
|
||||
* `KEYSTORE_LOCKED` / `KEYSTORE_UNAVAILABLE` (classifier-tagged
|
||||
* errors) and never falls back silently;
|
||||
* 5. emits `api.secrets.reads_total{backend="bun"}` on every successful
|
||||
* `get` value and `require`, `api.secrets.not_found_total` on every
|
||||
* permitted-but-absent read, and `api.secrets.denied_total` on every
|
||||
* allow-list rejection;
|
||||
* 6. exposes idempotent `dispose()` for host lifecycle dedup — repeat
|
||||
* calls are no-ops, and post-dispose `get` / `require` throw
|
||||
* `KEYSTORE_UNAVAILABLE`.
|
||||
*/
|
||||
export function createBunSecretsAdapter(
|
||||
options: CreateBunSecretsAdapterOptions
|
||||
): BunSecretsAdapter {
|
||||
const parsed = optionsSchema.safeParse(options);
|
||||
if (!parsed.success) {
|
||||
throw new SecretsBunError(
|
||||
SECRETS_BUN_CODES.INVALID_OPTIONS,
|
||||
"error.secrets_bun.invalid_options",
|
||||
{ context: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
const service = options.service;
|
||||
const allowlist = createAllowlist(options.allowlist);
|
||||
const keystore = options.keystore ?? defaultBunSecrets();
|
||||
if (keystore === undefined) {
|
||||
throw new SecretsBunError(
|
||||
SECRETS_BUN_CODES.KEYSTORE_UNAVAILABLE,
|
||||
"error.secrets_bun.keystore_unavailable",
|
||||
{ context: { reason: "bun_secrets_global_absent" } }
|
||||
);
|
||||
}
|
||||
const classifyError = options.classifyError ?? defaultClassifyError;
|
||||
let disposed = false;
|
||||
|
||||
const instruments = () => getSecretsBunInstruments();
|
||||
|
||||
const guardLifecycle = (): void => {
|
||||
if (disposed) {
|
||||
throw new SecretsBunError(
|
||||
SECRETS_BUN_CODES.KEYSTORE_UNAVAILABLE,
|
||||
"error.secrets_bun.keystore_unavailable",
|
||||
{ context: { reason: "adapter_disposed" } }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const guardAllowed = (name: string): void => {
|
||||
if (!allowlist.has(name)) {
|
||||
instruments().deniedTotal.add(1, { backend: "bun" });
|
||||
allowlist.assertAllowed(name);
|
||||
}
|
||||
};
|
||||
|
||||
const wrapKeystoreError = (err: unknown, name: string): never => {
|
||||
const tag = classifyError(err);
|
||||
if (tag === "KEYSTORE_LOCKED") {
|
||||
throw new SecretsBunError(
|
||||
SECRETS_BUN_CODES.KEYSTORE_LOCKED,
|
||||
"error.secrets_bun.keystore_locked",
|
||||
{ context: { name }, cause: err }
|
||||
);
|
||||
}
|
||||
if (tag === "KEYSTORE_UNAVAILABLE") {
|
||||
throw new SecretsBunError(
|
||||
SECRETS_BUN_CODES.KEYSTORE_UNAVAILABLE,
|
||||
"error.secrets_bun.keystore_unavailable",
|
||||
{ context: { name }, cause: err }
|
||||
);
|
||||
}
|
||||
throw new SecretsBunError(
|
||||
SECRETS_BUN_CODES.WRITE_FAILED,
|
||||
"error.secrets_bun.write_failed",
|
||||
{ context: { name }, cause: err }
|
||||
);
|
||||
};
|
||||
|
||||
const keystoreGet = async (name: string): Promise<string | null> => {
|
||||
try {
|
||||
return await keystore.get({ service, name });
|
||||
} catch (err) {
|
||||
return wrapKeystoreError(err, name);
|
||||
}
|
||||
};
|
||||
|
||||
const get = async (name: string): Promise<string | undefined> => {
|
||||
guardLifecycle();
|
||||
guardAllowed(name);
|
||||
const value = await keystoreGet(name);
|
||||
if (value === null) {
|
||||
instruments().notFoundTotal.add(1, { backend: "bun" });
|
||||
return undefined;
|
||||
}
|
||||
instruments().readsTotal.add(1, { backend: "bun" });
|
||||
return value;
|
||||
};
|
||||
|
||||
const require = async (name: string): Promise<string> => {
|
||||
guardLifecycle();
|
||||
guardAllowed(name);
|
||||
const value = await keystoreGet(name);
|
||||
if (value === null) {
|
||||
instruments().notFoundTotal.add(1, { backend: "bun" });
|
||||
throw new SecretsBunError(
|
||||
SECRETS_BUN_CODES.NOT_FOUND,
|
||||
"error.secrets_bun.not_found",
|
||||
{ context: { name } }
|
||||
);
|
||||
}
|
||||
instruments().readsTotal.add(1, { backend: "bun" });
|
||||
return value;
|
||||
};
|
||||
|
||||
const set = async (name: string, value: string): Promise<void> => {
|
||||
guardLifecycle();
|
||||
guardAllowed(name);
|
||||
try {
|
||||
await keystore.set({ service, name, value });
|
||||
} catch (err) {
|
||||
wrapKeystoreError(err, name);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteSecret = async (name: string): Promise<void> => {
|
||||
guardLifecycle();
|
||||
guardAllowed(name);
|
||||
try {
|
||||
await keystore.delete({ service, name });
|
||||
} catch (err) {
|
||||
wrapKeystoreError(err, name);
|
||||
}
|
||||
};
|
||||
|
||||
const secrets: SecretsLike = {
|
||||
get,
|
||||
require,
|
||||
list: () => allowlist.names,
|
||||
};
|
||||
|
||||
return {
|
||||
secrets,
|
||||
allowlist,
|
||||
set,
|
||||
deleteSecret,
|
||||
dispose(): void {
|
||||
disposed = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
__setBunGlobalProbeForTest,
|
||||
type BunSecretsAdapter,
|
||||
type BunSecretsErrorClassifier,
|
||||
type BunSecretsKeystore,
|
||||
type CreateBunSecretsAdapterOptions,
|
||||
createBunSecretsAdapter,
|
||||
defaultClassifyError,
|
||||
} from "./adapter.js";
|
||||
@@ -0,0 +1,73 @@
|
||||
# `allowlist`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The allow-list module encapsulates the per-plugin scoping rule that
|
||||
defines the package's security boundary: a plugin can only read secrets
|
||||
that its manifest's `secrets[]` block declared, no matter what
|
||||
`Bun.secrets` happens to hold under the host's service namespace. Every
|
||||
`get` / `require` call routes through `assertAllowed` BEFORE any
|
||||
keystore round-trip, so a leaked plugin cannot probe key existence by
|
||||
name even when the OS daemon is reachable.
|
||||
|
||||
The interface is intentionally minimal — a frozen ordered `names` array
|
||||
plus an O(1) `has` probe and the throwing `assertAllowed` guard — so the
|
||||
adapter can compose it without re-implementing the membership semantics.
|
||||
|
||||
## Features
|
||||
|
||||
- Frozen `names` array preserving manifest declaration order; safe to
|
||||
return directly from the adapter's `list()`.
|
||||
- `has(name)` boolean probe for branchy callers (e.g. selective writes).
|
||||
- `assertAllowed(name)` guard that throws `SECRETS_BUN.NOT_ALLOWED`
|
||||
with `context.name` populated for the audit log.
|
||||
- Silent de-duplication so a manifest that imports the same secret
|
||||
twice does not crash at construction.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createAllowlist } from "@suaveplan/api-secrets-bun/allowlist";
|
||||
|
||||
const al = createAllowlist(["STRIPE_SECRET_KEY", "JWT_SIGNING_KEY"]);
|
||||
|
||||
al.assertAllowed("STRIPE_SECRET_KEY"); // no-op
|
||||
al.assertAllowed("AWS_SECRET_KEY"); // throws SECRETS_BUN.NOT_ALLOWED
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Compose the allow-list with the adapter to gate `set` as well as `get`:
|
||||
|
||||
```ts
|
||||
import { createAllowlist } from "@suaveplan/api-secrets-bun/allowlist";
|
||||
|
||||
const al = createAllowlist(manifest.secrets.map((s) => s.name));
|
||||
|
||||
function writeIfAllowed(name: string, value: string): void {
|
||||
al.assertAllowed(name);
|
||||
return Bun.secrets.set({ service: "my-plugin", name, value });
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createAllowlist(names: readonly string[]): Allowlist` — build a
|
||||
frozen allow-list from a manifest-derived name list.
|
||||
- `Allowlist.names: readonly string[]` — frozen ordered view.
|
||||
- `Allowlist.has(name: string): boolean` — membership probe.
|
||||
- `Allowlist.assertAllowed(name: string): void` — throws
|
||||
`SecretsBunError` with code `SECRETS_BUN.NOT_ALLOWED` on miss.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The allow-list owns its `Set<string>` once at construction so per-call
|
||||
lookup is O(1). The `names` array is frozen at the top level only; the
|
||||
strings inside are primitives and already immutable. `assertAllowed`
|
||||
populates `context.name` so the audit trail can route a denied lookup
|
||||
back to the offending plugin without surfacing the value (there is no
|
||||
value yet — the call is rejected before keystore consultation).
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
SECRETS_BUN_CODES,
|
||||
SecretsBunError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { createAllowlist } from "./allowlist.js";
|
||||
|
||||
describe("createAllowlist", () => {
|
||||
it("returns a frozen ordered names array", { timeout: 5000 }, () => {
|
||||
const al = createAllowlist(["A", "B", "C"]);
|
||||
expect(al.names).toEqual(["A", "B", "C"]);
|
||||
expect(Object.isFrozen(al.names)).toBe(true);
|
||||
});
|
||||
|
||||
it("has() probes membership", { timeout: 5000 }, () => {
|
||||
const al = createAllowlist(["A"]);
|
||||
expect(al.has("A")).toBe(true);
|
||||
expect(al.has("B")).toBe(false);
|
||||
});
|
||||
|
||||
it("de-duplicates names while preserving first-seen order", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const al = createAllowlist(["A", "B", "A", "C", "B"]);
|
||||
expect(al.names).toEqual(["A", "B", "C"]);
|
||||
});
|
||||
|
||||
it("assertAllowed is a no-op for permitted names", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const al = createAllowlist(["X"]);
|
||||
expect(() => al.assertAllowed("X")).not.toThrow();
|
||||
});
|
||||
|
||||
it("un-manifested read raises SECRETS_BUN_NOT_ALLOWED", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const al = createAllowlist(["STRIPE"]);
|
||||
let caught: unknown;
|
||||
try {
|
||||
al.assertAllowed("AWS");
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(SecretsBunError);
|
||||
expect((caught as SecretsBunError).code).toBe(
|
||||
SECRETS_BUN_CODES.NOT_ALLOWED
|
||||
);
|
||||
expect((caught as SecretsBunError).context).toEqual({ name: "AWS" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
SECRETS_BUN_CODES,
|
||||
SecretsBunError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
|
||||
/**
|
||||
* Per-plugin allow-list of secret names. Built once at adapter
|
||||
* construction from the plugin's manifest `secrets[]` and consulted
|
||||
* before any `Bun.secrets` round-trip.
|
||||
*
|
||||
* The shape is intentionally minimal — a `Set<string>` lookup + the
|
||||
* frozen ordered array `list()` returns — so the surface is a stable
|
||||
* source of truth for both the read gate and the enumerated names.
|
||||
*/
|
||||
export interface Allowlist {
|
||||
/** Frozen array of permitted names in declaration order. */
|
||||
readonly names: readonly string[];
|
||||
/** O(1) membership probe. */
|
||||
has(name: string): boolean;
|
||||
/**
|
||||
* Throws `SECRETS_BUN.NOT_ALLOWED` when `name` is outside the
|
||||
* allow-list. Returns `void` on a permitted name.
|
||||
*/
|
||||
assertAllowed(name: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an {@link Allowlist} from a manifest `secrets[]` derived list of
|
||||
* names. The input is copied into a frozen array so downstream mutation
|
||||
* cannot widen the surface after construction; the membership set is
|
||||
* built once and reused on every probe.
|
||||
*
|
||||
* Duplicate names are silently de-duplicated — manifest authors who list
|
||||
* the same secret twice (e.g. through two import paths) should not get a
|
||||
* runtime crash, but the surface `list()` reports is the de-duplicated
|
||||
* order-preserved view.
|
||||
*/
|
||||
export function createAllowlist(names: readonly string[]): Allowlist {
|
||||
const seen = new Set<string>();
|
||||
const ordered: string[] = [];
|
||||
for (const raw of names) {
|
||||
if (seen.has(raw)) continue;
|
||||
seen.add(raw);
|
||||
ordered.push(raw);
|
||||
}
|
||||
const frozen = Object.freeze(ordered);
|
||||
const lookup = new Set<string>(ordered);
|
||||
return {
|
||||
names: frozen,
|
||||
has(name: string): boolean {
|
||||
return lookup.has(name);
|
||||
},
|
||||
assertAllowed(name: string): void {
|
||||
if (!lookup.has(name)) {
|
||||
throw new SecretsBunError(
|
||||
SECRETS_BUN_CODES.NOT_ALLOWED,
|
||||
"error.secrets_bun.not_allowed",
|
||||
{ context: { name } }
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { type Allowlist, createAllowlist } from "./allowlist.js";
|
||||
@@ -0,0 +1,93 @@
|
||||
# `descriptor-builder`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module produces the `SecretDescriptor[]` shape the plugin manifest
|
||||
emits in its `secrets[]` block from a compact author-facing list. Pairing
|
||||
the manifest output with `descriptorAllowlist(descriptors)` gives the
|
||||
adapter's `allowlist` option, so a single declaration drives both the
|
||||
manifest the installer reads and the runtime gate the adapter enforces —
|
||||
the two cannot drift.
|
||||
|
||||
The builder accepts either bare strings (shorthand for
|
||||
`{ name, required: true }`) or full row objects, mirroring the sibling
|
||||
env-adapter's `buildEnvSecretDescriptors`. Both shapes return frozen
|
||||
descriptors so consumers can ship the result directly without defensive
|
||||
copies.
|
||||
|
||||
## Features
|
||||
|
||||
- Compact bare-string shorthand (`"NAME"`) for required secrets.
|
||||
- Full row objects for `required: false`, `description`, `format`.
|
||||
- Frozen array + frozen descriptors for safe sharing.
|
||||
- `descriptorAllowlist` extracts the `name` field for the adapter's
|
||||
`allowlist` option.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildBunSecretDescriptors,
|
||||
descriptorAllowlist,
|
||||
} from "@suaveplan/api-secrets-bun/descriptor-builder";
|
||||
|
||||
const descriptors = buildBunSecretDescriptors([
|
||||
"STRIPE_SECRET_KEY",
|
||||
"JWT_SIGNING_KEY",
|
||||
]);
|
||||
|
||||
// Ship `descriptors` on the plugin manifest's `secrets[]`.
|
||||
// Pass `descriptorAllowlist(descriptors)` to `createBunSecretsAdapter`.
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Mix bare strings with optional secrets and richer metadata:
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildBunSecretDescriptors,
|
||||
descriptorAllowlist,
|
||||
createBunSecretsAdapter,
|
||||
} from "@suaveplan/api-secrets-bun";
|
||||
|
||||
const descriptors = buildBunSecretDescriptors([
|
||||
"STRIPE_SECRET_KEY",
|
||||
{
|
||||
name: "OPTIONAL_WEBHOOK_KEY",
|
||||
required: false,
|
||||
description: "Override for inbound webhook signing",
|
||||
format: "base64",
|
||||
},
|
||||
]);
|
||||
|
||||
const adapter = createBunSecretsAdapter({
|
||||
service: "billing",
|
||||
allowlist: descriptorAllowlist(descriptors),
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `buildBunSecretDescriptors(rows): readonly SecretDescriptor[]` —
|
||||
build a frozen descriptor array from compact rows.
|
||||
- `BunSecretDescriptorInput` — full input row type.
|
||||
- `descriptorAllowlist(descs): readonly string[]` — extract names for
|
||||
the adapter's `allowlist`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`required` defaults to `true` so a manifest author who forgets the field
|
||||
fails loud at install (the installer treats every required secret as
|
||||
must-be-present in the OS keystore). Setting it to `false` is the
|
||||
deliberate opt-in for optional secrets — feature-flagged paths that
|
||||
degrade gracefully when the value is absent.
|
||||
|
||||
The descriptor objects are frozen with `Object.freeze` rather than
|
||||
shipped as `readonly` types alone because the manifest sometimes
|
||||
traverses through `JSON.parse` round-trips and downstream code historically
|
||||
mutated descriptors in place; freezing kills that class of bug.
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
buildBunSecretDescriptors,
|
||||
descriptorAllowlist,
|
||||
} from "./descriptor-builder.js";
|
||||
|
||||
describe("buildBunSecretDescriptors", () => {
|
||||
it("treats bare strings as required descriptors", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const descs = buildBunSecretDescriptors(["A", "B"]);
|
||||
expect(descs).toEqual([
|
||||
{ name: "A", required: true },
|
||||
{ name: "B", required: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("forwards required, description, and format", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const descs = buildBunSecretDescriptors([
|
||||
{
|
||||
name: "X",
|
||||
required: false,
|
||||
description: "test",
|
||||
format: "base64",
|
||||
},
|
||||
]);
|
||||
expect(descs[0]).toEqual({
|
||||
name: "X",
|
||||
required: false,
|
||||
description: "test",
|
||||
format: "base64",
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults required to true on rows that omit it", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const descs = buildBunSecretDescriptors([{ name: "X" }]);
|
||||
expect(descs[0]?.required).toBe(true);
|
||||
});
|
||||
|
||||
it("omits undefined description and format", { timeout: 5000 }, () => {
|
||||
const descs = buildBunSecretDescriptors([{ name: "X" }]);
|
||||
expect(Object.keys(descs[0] as object).sort()).toEqual([
|
||||
"name",
|
||||
"required",
|
||||
]);
|
||||
});
|
||||
|
||||
it("freezes the array and every descriptor", { timeout: 5000 }, () => {
|
||||
const descs = buildBunSecretDescriptors(["A"]);
|
||||
expect(Object.isFrozen(descs)).toBe(true);
|
||||
expect(Object.isFrozen(descs[0])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("descriptorAllowlist", () => {
|
||||
it("extracts just the names in order", { timeout: 5000 }, () => {
|
||||
const descs = buildBunSecretDescriptors(["A", "B"]);
|
||||
const names = descriptorAllowlist(descs);
|
||||
expect(names).toEqual(["A", "B"]);
|
||||
expect(Object.isFrozen(names)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { SecretDescriptor } from "@suaveplan/api-secrets/descriptor";
|
||||
|
||||
/**
|
||||
* Input row for {@link buildBunSecretDescriptors}. The `name` becomes the
|
||||
* descriptor's lookup name; `required` defaults to `true` because
|
||||
* OS-keystore deployments treat omitted-but-needed secrets as a
|
||||
* fail-fast install error (the operator has not yet stored the value);
|
||||
* `description` and `format` are forwarded verbatim.
|
||||
*/
|
||||
export interface BunSecretDescriptorInput {
|
||||
readonly name: string;
|
||||
readonly required?: boolean;
|
||||
readonly description?: string;
|
||||
readonly format?: "string" | "base64" | "json";
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `SecretDescriptor[]` for a Bun-keystore-backed plugin from a
|
||||
* compact allow-list. Accepts either bare strings (shorthand for
|
||||
* `{ name, required: true }`) or full input rows. Useful when the
|
||||
* plugin's `allowlist` and manifest `secrets[]` derive from the same
|
||||
* source of truth.
|
||||
*
|
||||
* The returned array is frozen at the top level so callers can ship it
|
||||
* to the manifest without worrying about downstream mutation; the
|
||||
* descriptor objects inside are also frozen.
|
||||
*/
|
||||
export function buildBunSecretDescriptors(
|
||||
rows: ReadonlyArray<string | BunSecretDescriptorInput>
|
||||
): readonly SecretDescriptor[] {
|
||||
const out: SecretDescriptor[] = [];
|
||||
for (const row of rows) {
|
||||
if (typeof row === "string") {
|
||||
out.push(Object.freeze({ name: row, required: true }));
|
||||
continue;
|
||||
}
|
||||
const required = row.required ?? true;
|
||||
const desc: SecretDescriptor = {
|
||||
name: row.name,
|
||||
required,
|
||||
...(row.description === undefined
|
||||
? {}
|
||||
: { description: row.description }),
|
||||
...(row.format === undefined ? {} : { format: row.format }),
|
||||
};
|
||||
out.push(Object.freeze(desc));
|
||||
}
|
||||
return Object.freeze(out);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract just the `name` field from a descriptor list — the shape
|
||||
* {@link createBunSecretsAdapter}'s `allowlist` expects. Pairs with
|
||||
* {@link buildBunSecretDescriptors} so a single declaration drives both
|
||||
* the manifest `secrets[]` and the adapter's allow-list.
|
||||
*/
|
||||
export function descriptorAllowlist(
|
||||
descriptors: readonly SecretDescriptor[]
|
||||
): readonly string[] {
|
||||
return Object.freeze(descriptors.map((d) => d.name));
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type BunSecretDescriptorInput,
|
||||
buildBunSecretDescriptors,
|
||||
descriptorAllowlist,
|
||||
} from "./descriptor-builder.js";
|
||||
@@ -0,0 +1,86 @@
|
||||
# `error-codes`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the stable `SECRETS_BUN.*` code registry and the
|
||||
`SecretsBunError` subclass thrown by every failure path inside the
|
||||
`Bun.secrets`-backed adapter. Splitting these codes from the upstream
|
||||
`SECRETS.*` namespace gives operators a one-glance distinction between
|
||||
the abstract secrets contract (`@suaveplan/api-secrets`), the
|
||||
environment-variable adapter (`SECRETS_ENV.*`), and the OS-keystore
|
||||
adapter (`SECRETS_BUN.*`).
|
||||
|
||||
The registry is frozen at module-load time so no consumer can mutate the
|
||||
codes that downstream telemetry already aggregates on. `SecretsBunError`
|
||||
pins `error.name` to a single canonical string so structured loggers can
|
||||
group rolled-up failure rates across every concrete code.
|
||||
|
||||
## Features
|
||||
|
||||
- Six codes — `NOT_FOUND`, `NOT_ALLOWED`, `KEYSTORE_UNAVAILABLE`,
|
||||
`KEYSTORE_LOCKED`, `WRITE_FAILED`, `INVALID_OPTIONS`.
|
||||
- Frozen registry via `defineCodes` so the surface cannot drift between
|
||||
runs in the same process.
|
||||
- Single `SecretsBunError` subclass for every throwable.
|
||||
- Carries `cause` chains so the underlying `Bun.secrets` error survives
|
||||
the wrap.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
SECRETS_BUN_CODES,
|
||||
SecretsBunError,
|
||||
} from "@suaveplan/api-secrets-bun/error-codes";
|
||||
|
||||
throw new SecretsBunError(
|
||||
SECRETS_BUN_CODES.NOT_ALLOWED,
|
||||
"error.secrets_bun.not_allowed",
|
||||
{ context: { name: "AWS_SECRET_KEY" } }
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Catch and route on `code`:
|
||||
|
||||
```ts
|
||||
import {
|
||||
SECRETS_BUN_CODES,
|
||||
SecretsBunError,
|
||||
} from "@suaveplan/api-secrets-bun/error-codes";
|
||||
|
||||
try {
|
||||
await secrets.require("STRIPE");
|
||||
} catch (err) {
|
||||
if (err instanceof SecretsBunError) {
|
||||
if (err.code === SECRETS_BUN_CODES.KEYSTORE_LOCKED) {
|
||||
// Prompt the operator to unlock the OS keystore.
|
||||
} else if (err.code === SECRETS_BUN_CODES.NOT_FOUND) {
|
||||
// Surface a manifest-mismatch installer hint.
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SECRETS_BUN_CODES` — frozen `{ [name]: "SECRETS_BUN.<name>" }`.
|
||||
- `SecretsBunCode` — TypeScript union of every fully-qualified code.
|
||||
- `SECRETS_BUN_ERROR_NAME` — `"SecretsBunError"`.
|
||||
- `SecretsBunError extends SuaveplanError<SecretsBunCode>` — concrete
|
||||
subclass with `code` typed to `SecretsBunCode`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Codes follow the project convention `<NAMESPACE>.<CASE>` so the dotted
|
||||
prefix is a stable telemetry key for grouping at the metric backend.
|
||||
The `SecretsBunError` constructor sets `this.name` AFTER `super` because
|
||||
`SuaveplanError`'s base assigns its own default name; overriding it
|
||||
unconditionally is the only way to guarantee the canonical name survives
|
||||
inheritance.
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
SECRETS_BUN_CODES,
|
||||
SECRETS_BUN_ERROR_NAME,
|
||||
SecretsBunError,
|
||||
} from "./error-codes.js";
|
||||
|
||||
describe("SECRETS_BUN_CODES registry", () => {
|
||||
it("exposes the six documented codes", { timeout: 5000 }, () => {
|
||||
expect(SECRETS_BUN_CODES).toEqual({
|
||||
NOT_FOUND: "SECRETS_BUN.NOT_FOUND",
|
||||
NOT_ALLOWED: "SECRETS_BUN.NOT_ALLOWED",
|
||||
KEYSTORE_UNAVAILABLE: "SECRETS_BUN.KEYSTORE_UNAVAILABLE",
|
||||
KEYSTORE_LOCKED: "SECRETS_BUN.KEYSTORE_LOCKED",
|
||||
WRITE_FAILED: "SECRETS_BUN.WRITE_FAILED",
|
||||
INVALID_OPTIONS: "SECRETS_BUN.INVALID_OPTIONS",
|
||||
});
|
||||
});
|
||||
|
||||
it("is frozen", { timeout: 5000 }, () => {
|
||||
expect(Object.isFrozen(SECRETS_BUN_CODES)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SecretsBunError", () => {
|
||||
it("pins the canonical name", { timeout: 5000 }, () => {
|
||||
const err = new SecretsBunError(
|
||||
SECRETS_BUN_CODES.NOT_ALLOWED,
|
||||
"error.x"
|
||||
);
|
||||
expect(err.name).toBe(SECRETS_BUN_ERROR_NAME);
|
||||
});
|
||||
|
||||
it("carries the code and context", { timeout: 5000 }, () => {
|
||||
const err = new SecretsBunError(
|
||||
SECRETS_BUN_CODES.NOT_FOUND,
|
||||
"error.x",
|
||||
{ context: { name: "FOO" } }
|
||||
);
|
||||
expect(err.code).toBe("SECRETS_BUN.NOT_FOUND");
|
||||
expect(err.context).toEqual({ name: "FOO" });
|
||||
});
|
||||
|
||||
it("accepts no options argument", { timeout: 5000 }, () => {
|
||||
const err = new SecretsBunError(
|
||||
SECRETS_BUN_CODES.KEYSTORE_LOCKED,
|
||||
"error.y"
|
||||
);
|
||||
expect(err.code).toBe("SECRETS_BUN.KEYSTORE_LOCKED");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
defineCodes,
|
||||
SuaveplanError,
|
||||
type SuaveplanErrorOptions,
|
||||
} from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Frozen registry of error codes thrown by the Bun-keystore-backed
|
||||
* `SecretsLike` adapter. Distinct from upstream `SECRETS.*` (owned by
|
||||
* `@suaveplan/api-secrets`) and from sibling `SECRETS_ENV.*` so operators
|
||||
* can tell at a glance whether the failure originated in the Bun
|
||||
* allow-list enforcement, the OS keystore round-trip, or the env adapter.
|
||||
*
|
||||
* - `NOT_FOUND` — the requested name is permitted by the allow-list but
|
||||
* `Bun.secrets.get` returned `null` (no value under the configured
|
||||
* `service` / `name` pair). `get()` resolves to `undefined`; `require()`
|
||||
* throws this code.
|
||||
* - `NOT_ALLOWED` — the requested name is not in the per-plugin
|
||||
* allow-list. Raised BEFORE the keystore is consulted so an attacker
|
||||
* cannot probe key existence by name. Same semantic as
|
||||
* `SECRETS_ENV.KEY_NOT_PERMITTED` but namespaced for telemetry split.
|
||||
* - `KEYSTORE_UNAVAILABLE` — `Bun.secrets` itself is missing (running
|
||||
* under Node without the Bun shim) or the OS keystore daemon is not
|
||||
* running. Surfaces from both the adapter constructor (when the runtime
|
||||
* probe fails) and from individual `get` / `require` / `set` calls.
|
||||
* - `KEYSTORE_LOCKED` — the OS keystore is locked (macOS Keychain
|
||||
* awaiting user prompt, Linux libsecret awaiting unlock, Windows
|
||||
* Credential Manager requiring smart-card auth). The adapter MUST NOT
|
||||
* fall back silently when this is observed.
|
||||
* - `WRITE_FAILED` — `Bun.secrets.set` / `.delete` failed at the OS
|
||||
* layer for reasons other than `LOCKED` / `UNAVAILABLE` (e.g. quota,
|
||||
* ACL denial). Always wraps the underlying error as `cause`.
|
||||
* - `INVALID_OPTIONS` — adapter construction options failed Zod
|
||||
* validation (empty `allowlist`, empty `service`, etc).
|
||||
*/
|
||||
export const SECRETS_BUN_CODES = defineCodes("SECRETS_BUN", [
|
||||
"NOT_FOUND",
|
||||
"NOT_ALLOWED",
|
||||
"KEYSTORE_UNAVAILABLE",
|
||||
"KEYSTORE_LOCKED",
|
||||
"WRITE_FAILED",
|
||||
"INVALID_OPTIONS",
|
||||
] as const);
|
||||
|
||||
/**
|
||||
* Union of fully-qualified `SECRETS_BUN.*` codes. Use as the `code` field
|
||||
* type on a {@link SecretsBunError}-typed throwable.
|
||||
*/
|
||||
export type SecretsBunCode =
|
||||
(typeof SECRETS_BUN_CODES)[keyof typeof SECRETS_BUN_CODES];
|
||||
|
||||
/**
|
||||
* Stable `error.name` value for every throwable this package produces.
|
||||
* Structured loggers group on `name` for cross-code aggregation.
|
||||
*/
|
||||
export const SECRETS_BUN_ERROR_NAME = "SecretsBunError" as const;
|
||||
|
||||
/**
|
||||
* Concrete `SuaveplanError` subclass for Bun-adapter failures. Mirrors the
|
||||
* shape of `SecretsError` / `SecretsEnvError` so consumers can
|
||||
* `instanceof`-check or code-match without parsing message strings.
|
||||
*/
|
||||
export class SecretsBunError extends SuaveplanError<SecretsBunCode> {
|
||||
public constructor(
|
||||
code: SecretsBunCode,
|
||||
messageKey: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, messageKey, options);
|
||||
this.name = SECRETS_BUN_ERROR_NAME;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
SECRETS_BUN_CODES,
|
||||
SECRETS_BUN_ERROR_NAME,
|
||||
type SecretsBunCode,
|
||||
SecretsBunError,
|
||||
} from "./error-codes.js";
|
||||
35
packages/api-stack/api/api-secrets-bun/src/index.ts
Normal file
35
packages/api-stack/api/api-secrets-bun/src/index.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* `@suaveplan/api-secrets-bun` — root barrel.
|
||||
*
|
||||
* Stage-8 `SecretsLike` adapter backed by `Bun.secrets` (macOS Keychain,
|
||||
* Linux libsecret, Windows Credential Manager). Per-plugin allow-list
|
||||
* enforcement plus an OS-keystore round-trip that distinguishes
|
||||
* locked-keystore from missing-entry failure modes.
|
||||
*/
|
||||
|
||||
export {
|
||||
__setBunGlobalProbeForTest,
|
||||
type BunSecretsAdapter,
|
||||
type BunSecretsErrorClassifier,
|
||||
type BunSecretsKeystore,
|
||||
type CreateBunSecretsAdapterOptions,
|
||||
createBunSecretsAdapter,
|
||||
defaultClassifyError,
|
||||
} from "./adapter/index.js";
|
||||
export { type Allowlist, createAllowlist } from "./allowlist/index.js";
|
||||
export {
|
||||
type BunSecretDescriptorInput,
|
||||
buildBunSecretDescriptors,
|
||||
descriptorAllowlist,
|
||||
} from "./descriptor-builder/index.js";
|
||||
export {
|
||||
SECRETS_BUN_CODES,
|
||||
SECRETS_BUN_ERROR_NAME,
|
||||
type SecretsBunCode,
|
||||
SecretsBunError,
|
||||
} from "./error-codes/index.js";
|
||||
export {
|
||||
getSecretsBunInstruments,
|
||||
resetSecretsBunInstruments,
|
||||
type SecretsBunInstruments,
|
||||
} from "./otel/index.js";
|
||||
5
packages/api-stack/api/api-secrets-bun/src/otel/index.ts
Normal file
5
packages/api-stack/api/api-secrets-bun/src/otel/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
getSecretsBunInstruments,
|
||||
resetSecretsBunInstruments,
|
||||
type SecretsBunInstruments,
|
||||
} from "./instruments.js";
|
||||
@@ -0,0 +1,75 @@
|
||||
# `otel/instruments`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the OpenTelemetry instrument bundle the
|
||||
`Bun.secrets`-backed adapter emits and a lazy cache that re-binds it to
|
||||
the current global `MeterProvider`. Dashboards aggregate the
|
||||
`api.secrets.*` counters across every concrete adapter via the
|
||||
`backend` label, so the metric names are deliberately backend-agnostic
|
||||
and the discriminator lives in label space.
|
||||
|
||||
The cache is process-wide because OTel SDKs treat instruments as
|
||||
identity values: two `createCounter` calls with the same name on the
|
||||
same meter return the same instrument, but re-binding the meter against
|
||||
a freshly-installed provider in tests requires explicit reset.
|
||||
|
||||
## Features
|
||||
|
||||
- `api.secrets.reads_total{backend="bun"}` — counter of successful
|
||||
reads from the adapter.
|
||||
- `api.secrets.not_found_total{backend="bun"}` — counter of permitted
|
||||
names that the keystore reported as absent.
|
||||
- `api.secrets.denied_total{backend="bun"}` — counter of allow-list
|
||||
rejections.
|
||||
- Cached bundle; `resetSecretsBunInstruments()` for tests.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { getSecretsBunInstruments } from "@suaveplan/api-secrets-bun/otel";
|
||||
|
||||
const inst = getSecretsBunInstruments();
|
||||
inst.readsTotal.add(1, { backend: "bun" });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
In a test, install a fresh `MeterProvider` and reset the cache so the
|
||||
next call re-binds to it:
|
||||
|
||||
```ts
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
import { resetSecretsBunInstruments } from "@suaveplan/api-secrets-bun/otel";
|
||||
|
||||
const exporter = new InMemoryMetricExporter(/* ... */);
|
||||
const reader = new PeriodicExportingMetricReader({ exporter });
|
||||
const provider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(provider);
|
||||
resetSecretsBunInstruments();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `getSecretsBunInstruments(): SecretsBunInstruments` — lazy, cached.
|
||||
- `resetSecretsBunInstruments(): void` — drop the cache.
|
||||
- `SecretsBunInstruments` — bundle of three `Counter` instruments.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The meter name is the package name (`@suaveplan/api-secrets-bun`) so the
|
||||
metric source is unambiguous in OTel-Collector pipelines. Labels live on
|
||||
the `add` call rather than the descriptor because OTel's data model
|
||||
expects cardinality discriminators as attributes; the `backend` label
|
||||
takes a single value here but the shape matches the abstract
|
||||
`api-secrets` family where future backends will share the metric name
|
||||
and split on the label.
|
||||
@@ -0,0 +1,73 @@
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import {
|
||||
getSecretsBunInstruments,
|
||||
resetSecretsBunInstruments,
|
||||
} from "./instruments.js";
|
||||
|
||||
let exporter: InMemoryMetricExporter;
|
||||
let provider: MeterProvider;
|
||||
|
||||
describe("api-secrets-bun OTel instruments", () => {
|
||||
beforeEach(() => {
|
||||
metrics.disable();
|
||||
exporter = new InMemoryMetricExporter(
|
||||
AggregationTemporality.CUMULATIVE
|
||||
);
|
||||
const reader = new PeriodicExportingMetricReader({
|
||||
exporter,
|
||||
exportIntervalMillis: 60_000,
|
||||
});
|
||||
provider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(provider);
|
||||
resetSecretsBunInstruments();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await provider.forceFlush();
|
||||
metrics.disable();
|
||||
resetSecretsBunInstruments();
|
||||
});
|
||||
|
||||
it("returns the same cached bundle until reset", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = getSecretsBunInstruments();
|
||||
const b = getSecretsBunInstruments();
|
||||
expect(a).toBe(b);
|
||||
resetSecretsBunInstruments();
|
||||
const c = getSecretsBunInstruments();
|
||||
expect(a).not.toBe(c);
|
||||
});
|
||||
|
||||
it("emits all three instrument names with backend label", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const inst = getSecretsBunInstruments();
|
||||
inst.readsTotal.add(1, { backend: "bun" });
|
||||
inst.notFoundTotal.add(1, { backend: "bun" });
|
||||
inst.deniedTotal.add(1, { backend: "bun" });
|
||||
await provider.forceFlush();
|
||||
const names = new Set<string>();
|
||||
for (const rm of exporter.getMetrics()) {
|
||||
for (const sm of rm.scopeMetrics) {
|
||||
for (const m of sm.metrics) names.add(m.descriptor.name);
|
||||
}
|
||||
}
|
||||
expect(names.has("api.secrets.reads_total")).toBe(true);
|
||||
expect(names.has("api.secrets.not_found_total")).toBe(true);
|
||||
expect(names.has("api.secrets.denied_total")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { type Counter, metrics } from "@opentelemetry/api";
|
||||
|
||||
/**
|
||||
* Bundle of OpenTelemetry instruments emitted by the Bun-keystore
|
||||
* secrets adapter.
|
||||
*
|
||||
* - `readsTotal` — incremented every time `get` resolves to a value or
|
||||
* `require` succeeds. Labels: `{ backend: "bun" }` (always — pinned at
|
||||
* instrument creation time so dashboards aggregate across every
|
||||
* `api.secrets.reads_total` source uniformly).
|
||||
* - `notFoundTotal` — incremented when a permitted name resolves to
|
||||
* `undefined` from the keystore. Labels: `{ backend: "bun" }`.
|
||||
* - `deniedTotal` — incremented every time `assertAllowed` raises
|
||||
* `SECRETS_BUN.NOT_ALLOWED`. Mirrors the metric the env adapter emits
|
||||
* so dashboards roll up cross-backend.
|
||||
*/
|
||||
export interface SecretsBunInstruments {
|
||||
readonly readsTotal: Counter;
|
||||
readonly notFoundTotal: Counter;
|
||||
readonly deniedTotal: Counter;
|
||||
}
|
||||
|
||||
let cached: SecretsBunInstruments | undefined;
|
||||
|
||||
/**
|
||||
* Lazily build (and cache) the secrets instruments bundle against the
|
||||
* current global `MeterProvider`. Call {@link resetSecretsBunInstruments}
|
||||
* in test setup to force a re-bind against a freshly-installed provider.
|
||||
*/
|
||||
export function getSecretsBunInstruments(): SecretsBunInstruments {
|
||||
if (cached !== undefined) return cached;
|
||||
const meter = metrics.getMeter("@suaveplan/api-secrets-bun");
|
||||
cached = {
|
||||
readsTotal: meter.createCounter("api.secrets.reads_total", {
|
||||
description:
|
||||
"Total successful secrets reads, labelled by backend identifier",
|
||||
}),
|
||||
notFoundTotal: meter.createCounter("api.secrets.not_found_total", {
|
||||
description:
|
||||
"Total permitted-but-absent secret lookups, labelled by backend",
|
||||
}),
|
||||
deniedTotal: meter.createCounter("api.secrets.denied_total", {
|
||||
description:
|
||||
"Total allow-list rejections, labelled by backend identifier",
|
||||
}),
|
||||
};
|
||||
return cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the cached instrument bundle so the next
|
||||
* {@link getSecretsBunInstruments} call rebinds against the global
|
||||
* `MeterProvider` in effect at that point. Tests call this after
|
||||
* installing a fresh `MeterProvider`.
|
||||
*/
|
||||
export function resetSecretsBunInstruments(): void {
|
||||
cached = undefined;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("dep-graph", () => {
|
||||
it("runtime deps are exactly the expected set (sorted)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as { dependencies?: Record<string, string> };
|
||||
const runtime = Object.keys(pkg.dependencies ?? {}).sort();
|
||||
expect(runtime).toEqual([
|
||||
"@opentelemetry/api",
|
||||
"@suaveplan/api-secrets",
|
||||
"@suaveplan/api-types",
|
||||
"@suaveplan/error",
|
||||
"tslib",
|
||||
"zod",
|
||||
]);
|
||||
});
|
||||
|
||||
it("zero peerDependencies", { timeout: 5000 }, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as { peerDependencies?: Record<string, string> };
|
||||
expect(Object.keys(pkg.peerDependencies ?? {})).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("zod-import-form", () => {
|
||||
it("every src file uses default-import z", { timeout: 5000 }, () => {
|
||||
function* walk(dir: string): Generator<string> {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const p = join(dir, entry);
|
||||
if (statSync(p).isDirectory()) {
|
||||
yield* walk(p);
|
||||
} else if (p.endsWith(".ts")) {
|
||||
yield p;
|
||||
}
|
||||
}
|
||||
}
|
||||
const srcDir = join(__dirname, "..", "src");
|
||||
const bad: string[] = [];
|
||||
for (const file of walk(srcDir)) {
|
||||
const src = readFileSync(file, "utf8");
|
||||
if (/import\s*\{\s*z\s*\}\s*from\s*["']zod["']/.test(src)) {
|
||||
bad.push(file);
|
||||
}
|
||||
}
|
||||
expect(bad).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
12
packages/api-stack/api/api-secrets-bun/tsconfig.lib.json
Normal file
12
packages/api-stack/api/api-secrets-bun/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
|
||||
}
|
||||
36
packages/api-stack/api/api-secrets-bun/vite.config.ts
Normal file
36
packages/api-stack/api/api-secrets-bun/vite.config.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../../node_modules/.vite/packages/api-secrets-bun",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
/^@suaveplan\//,
|
||||
/^@opentelemetry\//,
|
||||
/^node:/,
|
||||
"tslib",
|
||||
"zod",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
31
packages/api-stack/api/api-secrets-bun/vitest.config.ts
Normal file
31
packages/api-stack/api/api-secrets-bun/vitest.config.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/index.ts",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user