Files
2026-05-27 23:29:53 +01:00

10 KiB

Capability Spec Delta: api-plugin-vault (add-api-plugin-vault)

Package: @suaveplan/api-plugin-vault Status: ACTIVE

ADDED Requirements

Requirement: createVaultPlugin returns a Zod-validated descriptor with auth dependency

The package SHALL export createVaultPlugin(options): PluginDescriptor<VaultPluginConfig> Zod-validating options via VaultPluginOptionsSchema. The descriptor SHALL declare name: "vault" and dependencies: ["auth"]. Invalid options raise VaultPluginError(VAULT_PLUGIN_INVALID_INPUT) with cause instanceof ZodError.

Scenario: descriptor declares auth dependency

  • WHEN createVaultPlugin({ storage }) is called with a valid storage
  • THEN the descriptor's dependencies contains "auth"

Scenario: missing storage raises VAULT_PLUGIN_INVALID_INPUT

  • WHEN createVaultPlugin({} as never) is called
  • THEN VaultPluginError(VAULT_PLUGIN_INVALID_INPUT) is thrown

Verified by: src/plugin/plugin.test.ts::"descriptor declares auth dependency" AND src/plugin/plugin.test.ts::"missing storage raises VAULT_PLUGIN_INVALID_INPUT"


Requirement: onInit applies VAULT_OWNERSHIP_SCHEMA (plug-and-play point 1)

onInit(ctx) SHALL resolve migrationRunner (preferred) or db (fallback) from ServiceRegistry. When applySchemaOnInit === true (default), VAULT_OWNERSHIP_SCHEMA is applied; both vault_item and vault_share tables created with their indexes. Missing services raise VaultPluginError(VAULT_PLUGIN_SERVICE_NOT_AVAILABLE).

Scenario: schema creates vault_item and vault_share tables

  • WHEN the plugin initializes against a real SurrealDB testcontainer (with the auth schema applied first)
  • THEN INFO FOR DB lists both vault_item and vault_share tables AND the expected indexes exist

Scenario: applySchemaOnInit: false skips the step

  • WHEN createVaultPlugin({ storage, applySchemaOnInit: false }) initializes
  • THEN the schema apply step is skipped

Verified by: src/plugin/on-init.test.ts::"applies VAULT_OWNERSHIP_SCHEMA via migrationRunner" AND src/plugin/on-init.test.ts::"creates vault_item + vault_share tables" AND src/plugin/on-init.test.ts::"applySchemaOnInit: false skips apply"


Requirement: 5 routes registered with RouteBuilder + Zod end-to-end (plug-and-play points 2 + 3)

onInit SHALL call definePluginRoutes(buildVaultRoutes(vaultService, authService), { app, registry, pluginName: "vault" }) registering: GET /vault/items, POST /vault/items, GET /vault/items/:id, PATCH /vault/items/:id, DELETE /vault/items/:id. Every route uses requireAuth() upstream. Every body/query/response is a Zod schema from ./schemas/vault-item. All routes scope to c.get("user")!.id — items from other users SHALL appear as VAULT_PLUGIN_ITEM_NOT_FOUND to prevent enumeration.

Scenario: list returns only the principal's items

  • WHEN principal A owns 3 items and principal B owns 2; A is authenticated; GET /vault/items
  • THEN the response contains 3 items, all ownerId === A.id

Scenario: GET /vault/items/:id of foreign item returns ITEM_NOT_FOUND

  • WHEN principal A is authenticated and GETs an item owned by principal B
  • THEN the response is 404 with VAULT_PLUGIN_ITEM_NOT_FOUND (NOT 403 — prevents enumeration)

Scenario: PATCH /vault/items/:id rejects unknown fields

  • WHEN authenticated PATCH with { title: "X", evil: "Y" }
  • THEN the response is 400 from UpdateVaultItemBodySchema Zod strict-mode validation

Scenario: DELETE /vault/items/:id of foreign item returns FORBIDDEN

  • WHEN principal A DELETEs an item that exists but is owned by B
  • THEN the response is 404 (not 403, same anti-enumeration as GET)

Verified by: src/routes/list.test.ts::"returns only principal's items" AND src/routes/get.test.ts::"foreign item returns ITEM_NOT_FOUND" AND src/routes/patch.test.ts::"rejects unknown fields" AND src/routes/delete.test.ts::"foreign item returns 404" AND tests/integration/openapi-doc.test.ts::"served /openapi.json includes 5 /vault/items routes"


Requirement: Item size enforced via maxItemSizeBytes

POST /vault/items and PATCH /vault/items/:id SHALL reject bodies whose encrypted payload exceeds options.maxItemSizeBytes (default 10 MiB) with VaultPluginError(VAULT_PLUGIN_ITEM_TOO_LARGE) (413).

Scenario: item over limit raises ITEM_TOO_LARGE

  • WHEN maxItemSizeBytes: 1024 and an authenticated POST sends a body larger than 1 KiB
  • THEN the response is 413 with VAULT_PLUGIN_ITEM_TOO_LARGE

Verified by: src/routes/create.test.ts::"oversize body raises ITEM_TOO_LARGE"


Requirement: vaultService registered with typed slot

onInit registers ctx.registerService("vaultService", { openForPrincipal, listItemsForOwner, close }). ServiceRegistry augmentation adds vaultService: VaultService with full type inference.

Scenario: openForPrincipal returns VaultHandle

  • WHEN vaultService.openForPrincipal(principal, password) is awaited with a valid password
  • THEN the result is a VaultHandle opaque type (no leak of internal vault state)

Scenario: bad password raises UNLOCK_FAILED

  • WHEN openForPrincipal is called with the wrong password
  • THEN VaultPluginError(VAULT_PLUGIN_UNLOCK_FAILED) is thrown

Verified by: src/services/vault-service.test.ts::"openForPrincipal returns VaultHandle" AND src/services/vault-service.test.ts::"bad password raises UNLOCK_FAILED" AND src/services/vault-service.spec-conformance.test.ts::"ServiceRegistry slot typed as VaultService"


Requirement: Unlock password sourced via configurable extractor

The package SHALL accept options.unlockExtractor?: (c: Context) => string | null (default headerExtractor("X-Vault-Unlock")). When the extractor returns null on a route that requires unlock, the response SHALL be VaultPluginError(VAULT_PLUGIN_UNLOCK_HEADER_MISSING) (400). The plugin SHALL NEVER log or store the extracted password.

Scenario: default extractor reads X-Vault-Unlock header

  • WHEN an authenticated POST /vault/items includes X-Vault-Unlock: hunter2
  • THEN the plugin uses "hunter2" to open the vault for the principal

Scenario: missing extractor result raises UNLOCK_HEADER_MISSING

  • WHEN an authenticated POST /vault/items omits the unlock header
  • THEN the response is 400 with VAULT_PLUGIN_UNLOCK_HEADER_MISSING

Scenario: password never logged

  • WHEN a logger captures every line emitted during a POST /vault/items run
  • THEN none of the captured lines contain the extracted password substring

Verified by: src/plugin/unlock.test.ts::"default extractor reads X-Vault-Unlock" AND src/plugin/unlock.test.ts::"missing header raises UNLOCK_HEADER_MISSING" AND src/plugin/unlock.test.ts::"password never logged"


Requirement: Zod schemas exported at ./schemas/* (plug-and-play point 2)

The package SHALL export schemas at ./schemas/vault-item (VaultItemSchema, VaultItemSummarySchema, VaultItemResponseSchema, VaultItemListResponseSchema, CreateVaultItemBodySchema, UpdateVaultItemBodySchema strict mode) and ./schemas/options (VaultPluginOptionsSchema). Every schema has a corresponding z.infer type exported alongside.

Scenario: UpdateVaultItemBodySchema is strict

  • WHEN UpdateVaultItemBodySchema.safeParse({ title: "X", evil: "Y" }) is called
  • THEN success === false

Scenario: CreateVaultItemBodySchema requires title and kind

  • WHEN CreateVaultItemBodySchema.safeParse({}) is called
  • THEN success === false (missing title and kind)

Verified by: src/schemas/vault-item.test.ts::"UpdateVaultItemBodySchema is strict" AND src/schemas/vault-item.test.ts::"CreateVaultItemBodySchema requires title and kind"


Requirement: Every VAULT_PLUGIN_* code is thrown from a production path

VAULT_PLUGIN_CODES SHALL define exactly 9 codes (VAULT_PLUGIN_INVALID_INPUT, VAULT_PLUGIN_ITEM_NOT_FOUND, VAULT_PLUGIN_FORBIDDEN, VAULT_PLUGIN_UNLOCK_FAILED, VAULT_PLUGIN_ITEM_TOO_LARGE, VAULT_PLUGIN_SERVICE_NOT_AVAILABLE, VAULT_PLUGIN_SCHEMA_APPLY_FAILED, VAULT_PLUGIN_ROUTE_PREFIX_COLLISION, VAULT_PLUGIN_UNLOCK_HEADER_MISSING). Every code SHALL be raised by ≥1 production call site.

Verified by: src/api-plugin-vault.spec-conformance.test.ts::"every VAULT_PLUGIN_* code is thrown from a production path"


Requirement: OpenTelemetry counters + histogram emit per operation

The package SHALL register counters vault.items.created_total{outcome}, vault.items.read_total{outcome}, vault.items.updated_total{outcome}, vault.items.deleted_total{outcome} and histogram vault.item.size_bytes (on create + update).

Scenario: created_total emits success

  • WHEN a successful POST /vault/items runs against a real in-memory MeterProvider
  • THEN vault.items.created_total{outcome: "success"} has 1 observation AND vault.item.size_bytes has 1 observation

Verified by: src/otel/instruments.test.ts::"created_total emits success" AND src/otel/instruments.test.ts::"size_bytes histogram emits on create + update"


Requirement: Server-tier package shape + full integration test

Package SHALL run in Bun + Node. package.json SHALL declare subpath exports per feature module. Every implementation .ts SHALL have a sibling .md ≥200 words. No export * in feature barrels. 100/100/100/100 coverage. Integration test SHALL exercise the full plugin alongside api-plugin-auth + a real @suaveplan/vault instance with in-memory DriveLike against a SurrealDB testcontainer: sign-up via auth → POST /vault/items → GET /vault/items → PATCH → DELETE.

Scenario: full integration roundtrip

  • WHEN tests/integration/full-boot.test.ts builds a real app with auth + this plugin, signs up, then POSTs/GETs/PATCHes/DELETEs a vault item with X-Vault-Unlock header
  • THEN every step succeeds AND a subsequent GET /vault/items returns an empty list

Verified by: tests/integration/full-boot.test.ts::"sign-up → POST → GET → PATCH → DELETE /vault/items roundtrip against real SurrealDB" AND tests/integration/openapi-doc.test.ts::"served doc lists 5 /vault/items routes" AND tests/integration/cross-user-isolation.test.ts::"user A cannot see / modify user B's items" AND tests/dep-graph.test.ts::"every subpath export resolves" AND tests/dep-graph.test.ts::"every impl .ts has a sibling .md"