Packages

Kernel

The typed CaissonError hierarchy, the credit-gate and tenancy-denial error shapes, and the one standards gate.

@caisson/kernel is the shared spine every package imports. It defines a typed CaissonError hierarchy (each failure has a stable, documented shape instead of an ad-hoc string) and hosts the one standards/conformance gate every package ships through.

Two entry points

As of 0.7.0 the kernel ships two entry points, so it can be imported from a browser bundle:

  • @caisson/kernel — browser-safe. The error model, strict-schema helpers, canonical serialization, the money and version types, the timeout-bounded fetch, the event sink, and the redaction helpers. Reaches no Node built-in.
  • @caisson/kernel/node — everything above plus the parts that need Node: constant-time secret comparison, audit-chain hashing, migration assembly, and the SSRF guard.

/node is a strict superset, so server code that needs one of the moved functions changes a single import path and keeps the rest of its import list unchanged. Nothing changed about what any of these functions do. Each block below names the entry point its symbols come from; where a block spans both, it says so per symbol.

Upgrading from 0.6.x: if you import safeEqualFixed, safeEqualVariable, verifyAllowlisted, verifyBearer, contentHash, hashChainLink, chainEntry, buildChain, verifyChain, anchorChain, assembleMigrations, assembleMigrationsWithPinnedPrefix, isPrivateAddress, assertSafePublicUrl, assertResolvedHostPublic, assertSafePublicUrlResolved, or ssrfGuardedFetch, point that import at @caisson/kernel/node. Type-only imports need no change.

The contract

Two error shapes are load-bearing across the base:

  • The credit gate raises a 402 with a documented body, not a bare 500.
  • A tenancy denial returns 404, not 403: refusing to confirm a row exists is the policy. There is no existence leak to probe.
import { InsufficientCreditsError, NotFoundError } from "@caisson/kernel";

// 402 — the credit gate. Documented body, never a bare 500.
throw new InsufficientCreditsError(1, 0); // (required, balance)

// Tenancy denial → 404. We never confirm the row exists.
throw new NotFoundError();

The 402 carries a stable, machine-readable body so an agent can react to it:

// HTTP 402
{
  "error": {
    "code": "insufficient_credits",
    "message": "Insufficient credits",
    "details": { "required": 1, "balance": 0 },
  },
}

A package becomes part of the base only by passing the one gate:

$ bun run check   # build · lint · test — every package, one gate

API reference

The rest of @caisson/kernel's exports, grouped by concern, pure functions, typed errors, and Zod schemas; none of it touches the network or a database on its own.

Errors

CaissonError is the abstract base every subclass extends, a stable code, a mapped httpStatus, and details allowlisted per subclass, never a raw stack or SQL string.

ClassHTTPcode
ValidationError400validation_error
AuthnError401unauthenticated
AuthzError403forbidden
EntitlementError403not_entitled
InsufficientCreditsError402insufficient_credits
NotFoundError404not_found
TenancyError404not_found
ConflictError409conflict
GuardrailError422guardrail_blocked
RateLimitError429rate_limited
ConfigError500config_error
InternalError500internal_error

GuardrailError carries { stage, category } metadata only. ConflictError is what a Postgres unique-constraint violation (23505) maps to.

function isCaissonError(err: unknown): err is CaissonError;
function isUniqueViolation(err: unknown): boolean; // Postgres SQLSTATE 23505
function toErrorResponse(err: unknown): { status: number; body: ErrorEnvelope }; // any thrown value → a client-safe response

Security & redaction

The constant-time compare and SSRF helpers below are imported from @caisson/kernel/node, not the main entry — they reach node:crypto and node:dns, so they live behind the Node-only entry point that keeps @caisson/kernel itself browser-safe. @caisson/kernel/node re-exports the main entry in full, so a server module that needs one of them changes a single import path and keeps the rest of its import list. The redaction helpers further down stay on @caisson/kernel.

// from "@caisson/kernel/node"
function safeEqualFixed(a: string, b: string): boolean; // fixed-length secrets (tokens, HMACs)
function safeEqualVariable(a: string, b: string): boolean; // variable-length — hashed before compare
function verifyAllowlisted(
  candidate: string,
  allowed: readonly string[],
  normalize?: (s: string) => string,
): boolean;
function verifyBearer(
  authorizationHeader: string | null | undefined,
  expected: string,
): void; // throws AuthnError

Never === on a secret. safeEqualVariable hashes both sides to 32 bytes first, a raw variable-length timingSafeEqual throws on a length mismatch, itself a timing leak. verifyBearer fails closed on a blank expected secret.

// from "@caisson/kernel/node"
function isPrivateAddress(hostname: string): boolean;
function assertSafePublicUrl(raw: string): URL; // sync: https-only, no credentials, literal-host check
function assertResolvedHostPublic(hostname: string): Promise<void>; // async DNS re-check
function assertSafePublicUrlResolved(raw: string): Promise<void>; // both checks, in order
const ssrfGuardedFetch: (
  input: string | URL | Request,
  init?: RequestInit,
  timeout?: FetchTimeoutOptions,
) => Promise<Response>;

A public hostname that resolves to a private/link-local/metadata address is rejected, not just a literal private IP. The other four throw ValidationError on a violation (isPrivateAddress is a plain boolean predicate, never throws); ssrfGuardedFetch fetches with redirect: "error" so a 3xx can't bypass the check post-verification.

function scrubForEgress(text: string): string; // redacts credential-shaped spans
function looksLikeSecret(text: string): boolean;
function scrubDeep(value: unknown): unknown; // recursive — drops whole subtrees by KEY name
type SystemMode = "active" | "read_only";
function assertNotReadOnly(mode: SystemMode, action?: string): void; // throws ConflictError (409)

scrubForEgress catches PEM blocks, URL userinfo passwords, key: value secret assignments, and bare AWS/GitHub/OpenAI/JWT token shapes. scrubDeep drops any subtree whose key names a PHI/PII field or a secret. assertNotReadOnly is a no-op unless mode is "read_only".

Config, validation, fetch

function loadConfig<T extends z.ZodTypeAny>(
  schema: T,
  source?: EnvSource,
): z.infer<T>; // throws ConfigError
function strictObject<T extends z.ZodRawShape>(
  shape: T,
): z.ZodObject<T, "strict">;
function parseStrict<T extends z.ZodTypeAny>(
  schema: T,
  input: unknown,
): z.infer<T>; // throws ValidationError
function fetchWithTimeout(
  input: string | URL | Request,
  init?: RequestInit,
  opts?: { timeoutMs?: number },
): Promise<Response>; // default 10s; also at "@caisson/kernel/fetch"

loadConfig validates process.env at boot, naming the failing keys, never their values. fetchWithTimeout merges any caller signal rather than replacing it, so a stream-cancellation abort still tears down the connection.

Audit chain & versioning

This block is split across both entry points. canonicalize is pure and stays on @caisson/kernel; everything below it hashes, so it lives on @caisson/kernel/node. The AuditChainEntry and AuditChainAnchor types stay on the main entry too — a type-only import is erased at build time and never pulls Node into a bundle.

// from "@caisson/kernel"
function canonicalize(value: JsonValue): string; // deterministic: sorted keys

// from "@caisson/kernel/node"
function contentHash(value: JsonValue): string; // sha256 hex of canonicalize(value)
function hashChainLink(prevHash: string | null, payload: JsonValue): string;
function chainEntry(
  prev: AuditChainEntry | null,
  payload: JsonValue,
): AuditChainEntry;
function buildChain(payloads: readonly JsonValue[]): AuditChainEntry[];
function verifyChain(
  entries: readonly AuditChainEntry[],
  anchor?: AuditChainAnchor,
): { valid: boolean; brokenAt: number | null };
function anchorChain(entries: readonly AuditChainEntry[]): AuditChainAnchor;

An append-only, tamper-evident hash chain. verifyChain returns the first broken index on tamper, insert, reorder, or a middle drop; without an anchor a tail truncation or wholesale rewrite still verifies clean, so pass the { length, tipHash } anchorChain mints.

function validateVersionSet(versions: readonly VersionRecord[]): {
  byId: Map<string, VersionRecord>;
  successorOf: Map<string, string>;
};
function isCurrent(versions: readonly VersionRecord[], id: string): boolean;
function currentVersions(versions: readonly VersionRecord[]): VersionRecord[];
function versionChain(
  versions: readonly VersionRecord[],
  id: string,
): VersionRecord[];

A version ({ id, supersedesId }) is current iff nothing supersedes it. All four throw a plain Error on a malformed set: a duplicate id, a dangling supersedesId, a fork, or a cycle.

Event sink & observability schemas

interface EventSink {
  emit(event: OpsEvent): void | Promise<void>;
}
function redactEvent(event: OpsEvent): OpsEvent;
class InMemoryEventSink implements EventSink {
  get events(): readonly OpsEvent[];
  clear(): void;
}
class NoopEventSink implements EventSink {} // drops everything — the fail-open default
class OtelPostgresEventSink implements EventSink {
  constructor(opts: { endpoint: string; timeoutMs?: number; send?: OtlpSend });
}

redactEvent strips secrets, SQL statements, and stack traces from attributes, applied once at the sink. OtelPostgresEventSink posts one OTLP/HTTP log record per event and throws InternalError on a non-OK response. Five .strict() schemas the buyer dashboard reads:

SchemaShape
opsEventSchemaboundary schema for an externally-supplied OpsEvent
evidencePackSchemaa WORM-anchored compliance evidence bundle
usageMeteringSchemaone metered unit against a feature tag
evalResultSchemaone eval-suite run outcome
guardrailBlockSchemaone guardrail block, stage/category/policy only, never the flagged content

Migration assembly

// from "@caisson/kernel/node"
function assembleMigrations(
  packages: readonly PackageMigrations[],
): MigrationAssembly;

MigrationAssembly and PackageMigrations are types, so they import from @caisson/kernel unchanged; only the function itself moved, because it hashes.

Pure and deterministic: orders packages by declared dependsOn, renumbers every migration into one global NNNN_*.sql sequence, and folds a schemaVersion checksum over the merged ledger. Throws on a duplicate slug, a dependency cycle, or a bad filename.

Money & credits

function asCents(value: number): Cents; // non-negative integer, else ValidationError
function asCredits(value: number): Credits;
function asMicroUsd(value: number): MicroUsd;
function asMicroUsdPerCredit(value: number): MicroUsdPerCredit; // must be a positive integer
function unwrapMoney(
  value: Cents | Credits | MicroUsd | MicroUsdPerCredit,
): number;

Branded number types, zero runtime cost, a cents value can no longer pass silently where credits are expected. unwrapMoney is an identity no-op marking where a value exits to SQL.

const CREDIT_CONVERSION: CreditConversion; // 1 credit = 1000 micro-USD = $0.001
function parseCreditConversion(input: unknown): CreditConversion;
function centsToCredits(cents: number, conversion?: CreditConversion): Credits; // throws ValidationError
function centsToCreditsProvenance(
  cents: number,
  conversion?: CreditConversion,
): RoundedMoney<Cents, Credits>; // { raw: Cents; mode: "up" | "down"; result: Credits }

centsToCredits always rounds down, never over-crediting a buyer. centsToCreditsProvenance returns the same result plus a { raw, mode } audit record a ledger write can persist; for this conversion mode is always "down" at runtime, though the declared type admits both directions.