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
dependenciescontains"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 DBlists bothvault_itemandvault_sharetables 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
UpdateVaultItemBodySchemaZod 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: 1024and 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
VaultHandleopaque type (no leak of internal vault state)
Scenario: bad password raises UNLOCK_FAILED
- WHEN
openForPrincipalis 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(missingtitleandkind)
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 ANDvault.item.size_byteshas 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.tsbuilds a real app with auth + this plugin, signs up, then POSTs/GETs/PATCHes/DELETEs a vault item withX-Vault-Unlockheader - 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"