179 lines
10 KiB
Markdown
179 lines
10 KiB
Markdown
<!-- markdownlint-disable MD024 -->
|
|
|
|
# Capability Spec Delta: api-plugin-vault (add-api-plugin-vault)
|
|
|
|
**Package**: `@suaveplan/api-plugin-vault`
|
|
**Status**: PROPOSED
|
|
|
|
## 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"`
|
|
|
|
<!-- markdownlint-enable MD024 -->
|