Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View 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

View 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"
]
}

View 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.

View File

@@ -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,
});

View File

@@ -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"
);
});
});

View 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;
},
};
}

View File

@@ -0,0 +1,9 @@
export {
__setBunGlobalProbeForTest,
type BunSecretsAdapter,
type BunSecretsErrorClassifier,
type BunSecretsKeystore,
type CreateBunSecretsAdapterOptions,
createBunSecretsAdapter,
defaultClassifyError,
} from "./adapter.js";

View File

@@ -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).

View File

@@ -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" });
});
});

View File

@@ -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 } }
);
}
},
};
}

View File

@@ -0,0 +1 @@
export { type Allowlist, createAllowlist } from "./allowlist.js";

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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));
}

View File

@@ -0,0 +1,5 @@
export {
type BunSecretDescriptorInput,
buildBunSecretDescriptors,
descriptorAllowlist,
} from "./descriptor-builder.js";

View File

@@ -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.

View File

@@ -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");
});
});

View File

@@ -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;
}
}

View File

@@ -0,0 +1,6 @@
export {
SECRETS_BUN_CODES,
SECRETS_BUN_ERROR_NAME,
type SecretsBunCode,
SecretsBunError,
} from "./error-codes.js";

View 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";

View File

@@ -0,0 +1,5 @@
export {
getSecretsBunInstruments,
resetSecretsBunInstruments,
type SecretsBunInstruments,
} from "./instruments.js";

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -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([]);
});
});

View File

@@ -0,0 +1 @@
export {};

View 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"]
}

View 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",
],
},
},
});

View 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,
},
});