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,79 @@
# `allowlist`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Compiled per-plugin allow-list of secret names. The vault-backed
adapter calls `assertAllowed` BEFORE consulting the backend so a
non-permitted name never even discovers whether a corresponding
secret exists, and the watcher calls `isAllowed` as a filter on every
forwarded `secret.updated` event. Decoupling the allow-list into its
own module lets the adapter and watcher share the same compiled set
without re-running Zod option validation per call site.
## Features
- `createAllowlist({ names })` — Zod-validated factory returning a
frozen `Allowlist` (`isAllowed`, `assertAllowed`, `names`).
- O(1) lookups via internal `Set<string>` — `assertAllowed` is on
the hot read path of every `get` / `require` call.
- Deduplicated + sorted public `names` array, frozen so a consumer
cannot mutate the surface back upstream.
- Empty input is permitted: a plugin manifest that declares no
secrets compiles to an allow-list that rejects every read at the
gate (strict-mode behaviour by design).
## Usage: Basic
```ts
import { createAllowlist } from "@suaveplan/api-secrets-vault/allowlist";
const allow = createAllowlist({ names: ["STRIPE", "JWT_SIGNING"] });
allow.assertAllowed("STRIPE"); // ok
allow.assertAllowed("AWS"); // throws SECRETS_VAULT.NOT_ALLOWED
```
## Usage: Advanced
Use the predicate form as a watcher filter:
```ts
import { createAllowlist } from "@suaveplan/api-secrets-vault/allowlist";
const allow = createAllowlist({ names: ["STRIPE"] });
backend.onSecretUpdated((payload) => {
if (!allow.isAllowed(payload.name)) return;
bus.emit("secret.changed", { name: payload.name });
});
```
## API Reference
- `interface CreateAllowlistOptions { readonly names: readonly string[] }`
- `createAllowlist(options): Allowlist`
- `interface Allowlist`
- `readonly names: readonly string[]`
- `isAllowed(name: string): boolean`
- `assertAllowed(name: string): void`
— throws `SecretsVaultError(SECRETS_VAULT.NOT_ALLOWED)`
- Construction failures raise
`SecretsVaultError(SECRETS_VAULT.INVALID_OPTIONS)` carrying the raw
Zod issue list on `error.context.issues`.
## Implementation Notes
The `names` snapshot is built via `new Set(names).sort()` so callers
who accidentally pass duplicates get a clean public list, and the
sort makes the snapshot's iteration order deterministic — important
for tests that snapshot the allow-list shape, and for cache keys
keyed on the allow-list ordering.
The Zod schema rejects empty-string entries because a zero-length
allow-list entry would collide with the conformance harness's
"missing-name" probe and silently grant access to a "" key in the
backend. Real plugin manifests never declare a "" secret, so the
check is free strictness.

View File

@@ -0,0 +1,82 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
SECRETS_VAULT_CODES,
SecretsVaultError,
} from "../error-codes/error-codes.js";
import { createAllowlist } from "./allowlist.js";
describe("createAllowlist", () => {
it("rejects non-string names with INVALID_OPTIONS", {
timeout: 5000,
}, () => {
let caught: unknown;
try {
const bad = 123 as unknown;
createAllowlist({ names: [bad as string] });
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(SecretsVaultError);
expect((caught as SecretsVaultError).code).toBe(
SECRETS_VAULT_CODES.INVALID_OPTIONS
);
});
it("rejects empty-string entries", { timeout: 5000 }, () => {
let caught: unknown;
try {
createAllowlist({ names: ["A", ""] });
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(SecretsVaultError);
});
it("accepts an empty list", { timeout: 5000 }, () => {
const allow = createAllowlist({ names: [] });
expect(allow.names).toEqual([]);
expect(allow.isAllowed("anything")).toBe(false);
});
it("deduplicates and sorts names", { timeout: 5000 }, () => {
const allow = createAllowlist({ names: ["B", "A", "B", "A"] });
expect(allow.names).toEqual(["A", "B"]);
});
it("isAllowed is true for in-list names", { timeout: 5000 }, () => {
const allow = createAllowlist({ names: ["STRIPE"] });
expect(allow.isAllowed("STRIPE")).toBe(true);
expect(allow.isAllowed("AWS")).toBe(false);
});
it("assertAllowed raises NOT_ALLOWED with the name in context", {
timeout: 5000,
}, () => {
const allow = createAllowlist({ names: ["STRIPE"] });
let caught: unknown;
try {
allow.assertAllowed("AWS");
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(SecretsVaultError);
expect((caught as SecretsVaultError).code).toBe(
SECRETS_VAULT_CODES.NOT_ALLOWED
);
expect((caught as SecretsVaultError).context).toEqual({
name: "AWS",
});
});
it("un-manifested read raises SECRETS_VAULT_NOT_ALLOWED", {
timeout: 5000,
}, () => {
const allow = createAllowlist({ names: ["STRIPE"] });
expect(() => allow.assertAllowed("AWS")).toThrow(SecretsVaultError);
});
it("names array is frozen", { timeout: 5000 }, () => {
const allow = createAllowlist({ names: ["A"] });
expect(Object.isFrozen(allow.names)).toBe(true);
});
});

View File

@@ -0,0 +1,78 @@
import z from "zod";
import {
SECRETS_VAULT_CODES,
SecretsVaultError,
} from "../error-codes/error-codes.js";
/**
* Construction options for {@link createAllowlist}. `names` is the
* per-plugin whitelist drawn from the plugin manifest's `secrets[]`
* declarations. The set MAY be empty — a manifest that declares no
* secrets ends up with an allow-list that rejects every read at the
* gate, which is the correct strict-mode behaviour.
*/
export interface CreateAllowlistOptions {
readonly names: readonly string[];
}
const optionsSchema = z.object({
names: z.array(z.string().min(1)),
});
/**
* Compiled per-plugin allow-list. `assertAllowed` throws
* `SECRETS_VAULT.NOT_ALLOWED` for any name not in the set;
* `isAllowed` is the predicate form for filter pipelines
* (e.g. the watcher's allow-list-filtered fan-out).
*/
export interface Allowlist {
/** Frozen sorted snapshot of allowed names. */
readonly names: readonly string[];
/** Predicate form — returns `true` when the name is allowed. */
isAllowed(name: string): boolean;
/** Throwing form — raises `SECRETS_VAULT.NOT_ALLOWED` when not
* allowed. Used at the adapter's `get` / `require` gate before
* the vault is consulted. */
assertAllowed(name: string): void;
}
/**
* Build a per-plugin {@link Allowlist} from a list of permitted
* names. The constructor:
*
* 1. validates `options` with Zod (rejects non-string / empty-name
* entries) and raises `SECRETS_VAULT.INVALID_OPTIONS` on
* failure;
* 2. deduplicates and freezes the input list so a later mutation
* of the caller's array cannot expand the allowed set;
* 3. compiles the names into a `Set<string>` for O(1) lookups —
* `assertAllowed` is on the hot read path so the set form
* matters under realistic plugin sizes.
*/
export function createAllowlist(options: CreateAllowlistOptions): Allowlist {
const parsed = optionsSchema.safeParse(options);
if (!parsed.success) {
throw new SecretsVaultError(
SECRETS_VAULT_CODES.INVALID_OPTIONS,
"error.secrets_vault.invalid_options",
{ context: { issues: parsed.error.issues } }
);
}
const names = Object.freeze([...new Set(parsed.data.names)].sort());
const allowSet = new Set<string>(names);
return {
names,
isAllowed(name: string): boolean {
return allowSet.has(name);
},
assertAllowed(name: string): void {
if (!allowSet.has(name)) {
throw new SecretsVaultError(
SECRETS_VAULT_CODES.NOT_ALLOWED,
"error.secrets_vault.not_allowed",
{ context: { name } }
);
}
},
};
}

View File

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