first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,156 @@
# @suaveplan/vault - Vault orchestration layer for the Vaulted password-manager ecosystem: drive-backed encrypted SQLite container, lifecycle state machine (open → unlock → lock), per-item field encryption, attachments / shares / sync delegation, and OpaqueKeyHandle boundary enforcement.
## Summary
Vault orchestration layer for the Vaulted password-manager ecosystem. Provides a drive-backed encrypted SQLite-style container, a strict `locked` ↔ `unlocked` lifecycle state machine, per-item field encryption for concealed values, attachment / share / sync delegation behind injectable adapter interfaces, and `OpaqueKeyHandle` boundary enforcement so raw key bytes never leave `@suaveplan/crypto-vault`.
<!-- begin:auto-docs -->
## Documentation tree
- [attachments](./src/attachments/attachments.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [attachments](./src/attachments/attachments.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [container](./src/container/container.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [container](./src/container/container.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [encryption](./src/encryption/field-encryption.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [field-encryption](./src/encryption/field-encryption.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [codes](./src/errors/codes.md) — `codes.ts` defines the stable error-code registry for `@suaveplan/vault`. Every failure raised by the orchestration lay…
- [vault-error](./src/errors/vault-error.md) — `vault-error.ts` defines `VaultError`, the concrete `SuaveplanError` subclass thrown by every public path in `@suavepla…
- [items](./src/items/items.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->- migrations — <!-- begin:auto-docs --> _(no submodule…
- [migrations](./src/items/migrations/migrations.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [migrations](./src/items/migrations/migrations.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [helpers](./src/items/helpers.md) — `items-helpers.ts` extracts the pure transformation helpers used by the items repository: row-to-item projection (with…
- [items](./src/items/items.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->- migrations — <!-- begin:auto-docs --> _(no submodule…
- [manifest](./src/manifest/manifest.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [manifest](./src/manifest/manifest.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [otel](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [instruments](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [search](./src/search/search.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [search](./src/search/search.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [shares](./src/shares/shares.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [shares](./src/shares/shares.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [sync](./src/sync/sync.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [sync](./src/sync/sync.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [adapters](./src/types/adapters.md) — `adapters.ts` defines the Phase-1 adapter contracts the vault orchestration layer uses to talk to blob storage, share-l…
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [vault](./src/vault/vault.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - wiring — `wiring.ts` extracts the per-unlock module…
- [lifecycle](./src/vault/lifecycle.md) — `lifecycle.ts` extracts the two side-effecting helpers that drive the unlock path: `deriveVaultKeyBuffer` (running 2SKD…
- [vault](./src/vault/vault.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - wiring — `wiring.ts` extracts the per-unlock module…
- [wiring](./src/vault/wiring.md) — `wiring.ts` extracts the per-unlock module factories used by `Vault`: `buildItems`, `buildSearch`, `buildAttachments`,…
<!-- end:auto-docs --> - [attachments](./src/attachments/attachments.md) — <!-- begin:auto-docs --> _(no submodules)_- [container](./src/container/container.md) — <!-- begin:auto-docs --> _(no submodules)_- [container](./src/container/container.md) — <!-- begin:auto-docs --> _(no submodules)_- [encryption](./src/encryption/field-encryption.md) — <!-- begin:auto-docs --> _(no submodules)_- [field-encryption](./src/encryption/field-encryption.md) — <!-- begin:auto-docs --> _(no submodules)_- [codes](./src/errors/codes.md) — `codes.ts` defines the stable error-code registry for `@suaveplan/vault`. Every failure raised by the orchestration lay…
- [vault-error](./src/errors/vault-error.md) — `vault-error.ts` defines `VaultError`, the concrete `SuaveplanError` subclass thrown by every public path in `@suavepla…
- [items](./src/items/items.md) — <!-- begin:auto-docs --> _(no submodules)_- [migrations](./src/items/migrations/migrations.md) — <!-- begin:auto-docs --> _(no submodules)_- [migrations](./src/items/migrations/migrations.md) — <!-- begin:auto-docs --> _(no submodules)_- [helpers](./src/items/helpers.md) — `items-helpers.ts` extracts the pure transformation helpers used by the items repository: row-to-item projection (with…
- [items](./src/items/items.md) — <!-- begin:auto-docs --> _(no submodules)_- [manifest](./src/manifest/manifest.md) — <!-- begin:auto-docs --> _(no submodules)_- [manifest](./src/manifest/manifest.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [search](./src/search/search.md) — <!-- begin:auto-docs --> _(no submodules)_- [search](./src/search/search.md) — <!-- begin:auto-docs --> _(no submodules)_- [shares](./src/shares/shares.md) — <!-- begin:auto-docs --> _(no submodules)_- [shares](./src/shares/shares.md) — <!-- begin:auto-docs --> _(no submodules)_- [sync](./src/sync/sync.md) — <!-- begin:auto-docs --> _(no submodules)_- [sync](./src/sync/sync.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [adapters](./src/types/adapters.md) — `adapters.ts` defines the Phase-1 adapter contracts the vault orchestration layer uses to talk to blob storage, share-l…
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [vault](./src/vault/vault.md) — <!-- begin:auto-docs --> _(no submodules)_- wiring — `wiring.ts` extracts the per-unlock module…
- [lifecycle](./src/vault/lifecycle.md) — `lifecycle.ts` extracts the two side-effecting helpers that drive the unlock path: `deriveVaultKeyBuffer` (running 2SKD…
- [vault](./src/vault/vault.md) — <!-- begin:auto-docs --> _(no submodules)_- wiring — `wiring.ts` extracts the per-unlock module…
- [wiring](./src/vault/wiring.md) — `wiring.ts` extracts the per-unlock module factories used by `Vault`: `buildItems`, `buildSearch`, `buildAttachments`,…
- [attachments](./src/attachments/attachments.md) — <!-- begin:auto-docs --> _(no submodules)_- [container](./src/container/container.md) — <!-- begin:auto-docs --> _(no submodules)_- [container](./src/container/container.md) — <!-- begin:auto-docs --> _(no submodules)_- [encryption](./src/encryption/field-encryption.md) — <!-- begin:auto-docs --> _(no submodules)_- [field-encryption](./src/encryption/field-encryption.md) — <!-- begin:auto-docs --> _(no submodules)_- [codes](./src/errors/codes.md) — `codes.ts` defines the stable error-code registry for `@suaveplan/vault`. Every failure raised by the orchestration lay…
- [vault-error](./src/errors/vault-error.md) — `vault-error.ts` defines `VaultError`, the concrete `SuaveplanError` subclass thrown by every public path in `@suavepla…
- [items](./src/items/items.md) — <!-- begin:auto-docs --> _(no submodules)_- migrations — <!-- begin:auto…
- [migrations](./src/items/migrations/migrations.md) — <!-- begin:auto-docs --> _(no submodules)_- [migrations](./src/items/migrations/migrations.md) — <!-- begin:auto-docs --> _(no submodules)_- [helpers](./src/items/helpers.md) — `items-helpers.ts` extracts the pure transformation helpers used by the items repository: row-to-item projection (with…
- [items](./src/items/items.md) — <!-- begin:auto-docs --> _(no submodules)_- migrations — <!-- begin:auto…
- [manifest](./src/manifest/manifest.md) — <!-- begin:auto-docs --> _(no submodules)_- [manifest](./src/manifest/manifest.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [search](./src/search/search.md) — <!-- begin:auto-docs --> _(no submodules)_- [search](./src/search/search.md) — <!-- begin:auto-docs --> _(no submodules)_- [shares](./src/shares/shares.md) — <!-- begin:auto-docs --> _(no submodules)_- [shares](./src/shares/shares.md) — <!-- begin:auto-docs --> _(no submodules)_- [sync](./src/sync/sync.md) — <!-- begin:auto-docs --> _(no submodules)_- [sync](./src/sync/sync.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [adapters](./src/types/adapters.md) — `adapters.ts` defines the Phase-1 adapter contracts the vault orchestration layer uses to talk to blob storage, share-l…
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [vault](./src/vault/vault.md) — <!-- begin:auto-docs --> _(no submodules)_- wiring — `wiring.ts` extracts the per-unlock module…
- [lifecycle](./src/vault/lifecycle.md) — `lifecycle.ts` extracts the two side-effecting helpers that drive the unlock path: `deriveVaultKeyBuffer` (running 2SKD…
- [vault](./src/vault/vault.md) — <!-- begin:auto-docs --> _(no submodules)_- wiring — `wiring.ts` extracts the per-unlock module…
- [wiring](./src/vault/wiring.md) — `wiring.ts` extracts the per-unlock module factories used by `Vault`: `buildItems`, `buildSearch`, `buildAttachments`,…
- [attachments](./src/attachments/attachments.md) — <!-- begin:auto-docs --> _(no submodules)_- [container](./src/container/container.md) — <!-- begin:auto-docs --> _(no submodules)_- [container](./src/container/container.md) — <!-- begin:auto-docs --> _(no submodules)_- [encryption](./src/encryption/field-encryption.md) — <!-- begin:auto-docs --> _(no submodules)_- [field-encryption](./src/encryption/field-encryption.md) — <!-- begin:auto-docs --> _(no submodules)_- [codes](./src/errors/codes.md) — `codes.ts` defines the stable error-code registry for `@suaveplan/vault`. Every failure raised by the orchestration lay…
- [vault-error](./src/errors/vault-error.md) — `vault-error.ts` defines `VaultError`, the concrete `SuaveplanError` subclass thrown by every public path in `@suavepla…
- [items](./src/items/items.md) — <!-- begin:auto-docs --> _(no submodules)_- migrations — <!-- begin:auto-docs --> _(no submodul…
- [migrations](./src/items/migrations/migrations.md) — <!-- begin:auto-docs --> _(no submodules)_- [migrations](./src/items/migrations/migrations.md) — <!-- begin:auto-docs --> _(no submodules)_- [helpers](./src/items/helpers.md) — `items-helpers.ts` extracts the pure transformation helpers used by the items repository: row-to-item projection (with…
- [items](./src/items/items.md) — <!-- begin:auto-docs --> _(no submodules)_- migrations — <!-- begin:auto-docs --> _(no submodul…
- [manifest](./src/manifest/manifest.md) — <!-- begin:auto-docs --> _(no submodules)_- [manifest](./src/manifest/manifest.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [search](./src/search/search.md) — <!-- begin:auto-docs --> _(no submodules)_- [search](./src/search/search.md) — <!-- begin:auto-docs --> _(no submodules)_- [shares](./src/shares/shares.md) — <!-- begin:auto-docs --> _(no submodules)_- [shares](./src/shares/shares.md) — <!-- begin:auto-docs --> _(no submodules)_- [sync](./src/sync/sync.md) — <!-- begin:auto-docs --> _(no submodules)_- [sync](./src/sync/sync.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [adapters](./src/types/adapters.md) — `adapters.ts` defines the Phase-1 adapter contracts the vault orchestration layer uses to talk to blob storage, share-l…
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [vault](./src/vault/vault.md) — <!-- begin:auto-docs --> _(no submodules)_- wiring — `wiring.ts` extracts the per-unlock module…
- [lifecycle](./src/vault/lifecycle.md) — `lifecycle.ts` extracts the two side-effecting helpers that drive the unlock path: `deriveVaultKeyBuffer` (running 2SKD…
- [vault](./src/vault/vault.md) — <!-- begin:auto-docs --> _(no submodules)_- wiring — `wiring.ts` extracts the per-unlock module…
- [wiring](./src/vault/wiring.md) — `wiring.ts` extracts the per-unlock module factories used by `Vault`: `buildItems`, `buildSearch`, `buildAttachments`,…
- [attachments](./src/attachments/attachments.md) — <!-- begin:auto-docs --> _(no submodules)_- [container](./src/container/container.md) — <!-- begin:auto-docs --> _(no submodules)_ - [container](./src/container/container.md) — <!-- begin:auto-docs --> _(no submodules)_- [encryption](./src/encryption/field-encryption.md) — <!-- begin:auto-docs --> _(no submodules)_ - [field-encryption](./src/encryption/field-encryption.md) — <!-- begin:auto-docs --> _(no submodules)_- [codes](./src/errors/codes.md) — `codes.ts` defines the stable error-code registry for `@suaveplan/vault`. Every failure raised by the orchestration lay…
- [vault-error](./src/errors/vault-error.md) — `vault-error.ts` defines `VaultError`, the concrete `SuaveplanError` subclass thrown by every public path in `@suavepla…
- [items](./src/items/items.md) — <!-- begin:auto-docs --> _(no submodules)_ - [migrations](./src/items/migrations/migrations.md) — <!-- begin:auto-docs --> _(no submodules)_ - [migrations](./src/items/migrations/migrations.md) — <!-- begin:auto-docs --> _(no submodules)_ - [helpers](./src/items/helpers.md) — `items-helpers.ts` extracts the pure transformation helpers used by the items repository: row-to-item projection (with…
- [items](./src/items/items.md) — <!-- begin:auto-docs --> _(no submodules)_- [manifest](./src/manifest/manifest.md) — <!-- begin:auto-docs --> _(no submodules)_ - [manifest](./src/manifest/manifest.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_ - [instruments](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ - [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [search](./src/search/search.md) — <!-- begin:auto-docs --> _(no submodules)_ - [search](./src/search/search.md) — <!-- begin:auto-docs --> _(no submodules)_- [shares](./src/shares/shares.md) — <!-- begin:auto-docs --> _(no submodules)_ - [shares](./src/shares/shares.md) — <!-- begin:auto-docs --> _(no submodules)_- [sync](./src/sync/sync.md) — <!-- begin:auto-docs --> _(no submodules)_ - [sync](./src/sync/sync.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ - [adapters](./src/types/adapters.md) — `adapters.ts` defines the Phase-1 adapter contracts the vault orchestration layer uses to talk to blob storage, share-l…
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [vault](./src/vault/vault.md) — <!-- begin:auto-docs --> _(no submodules)_ - [lifecycle](./src/vault/lifecycle.md) — `lifecycle.ts` extracts the two side-effecting helpers that drive the unlock path: `deriveVaultKeyBuffer` (running 2SKD…
- [vault](./src/vault/vault.md) — <!-- begin:auto-docs --> _(no submodules)_ - [wiring](./src/vault/wiring.md) — `wiring.ts` extracts the per-unlock module factories used by `Vault`: `buildItems`, `buildSearch`, `buildAttachments`,…
## Install
```bash
bun add @suaveplan/vault
```
## Quick start
```ts
import { Vault } from "@suaveplan/vault";
const vault = Vault.open({
vaultId: "personal",
email: "user@example.com",
containerPath: "/personal.vault",
attachmentsPrefix: "/personal/attachments",
argon2id: { memoryKB: 65536, iterations: 3, parallelism: 4, outputLength: 32 },
argon2idSalt: argon2idSaltBytes,
drive,
driveSharing,
sync,
});
await vault.unlock(password, secretKey);
const item = vault.items.create({
title: "GitHub",
category: "login",
fields: { username: "alice", url: "https://github.com" },
concealedFields: { password: "p@ssw0rd" },
tags: ["work"],
favorite: true,
});
const fetched = vault.items.get(item.id);
const matches = vault.search.search({ query: "github", category: "login" });
await vault.lock(); // zeroizes all key material
```
## Modules
- [`vault/vault`](./src/vault/vault.md) — orchestration entry point, lifecycle state machine.
- [`vault/lifecycle`](./src/vault/lifecycle.md) — 2SKD + key-hierarchy + drive load/create helpers.
- [`vault/wiring`](./src/vault/wiring.md) — module factory helpers.
- [`manifest/manifest`](./src/manifest/manifest.md) — bitpacker-packed container manifest header.
- [`container/container`](./src/container/container.md) — drive-backed encrypted blob layer.
- [`items/items`](./src/items/items.md) — in-memory items repository.
- [`items/helpers`](./src/items/helpers.md) — pure row projection / patch helpers.
- [`items/migrations/migrations`](./src/items/migrations/migrations.md) — forward-only schema migrations.
- [`encryption/field-encryption`](./src/encryption/field-encryption.md) — per-item field encryption.
- [`search/search`](./src/search/search.md) — concealed-field-safe search.
- [`attachments/attachments`](./src/attachments/attachments.md) — drive-backed attachment storage.
- [`shares/shares`](./src/shares/shares.md) — share-link orchestration.
- [`sync/sync`](./src/sync/sync.md) — sync delegation orchestrator.
- [`errors/codes`](./src/errors/codes.md) — stable `VAULT.*` error code registry.
- [`errors/vault-error`](./src/errors/vault-error.md) — `VaultError` class.
- [`otel/instruments`](./src/otel/instruments.md) — OTel histogram + counter instruments.
- [`schemas/schemas`](./src/schemas/schemas.md) — Zod schemas for every public boundary.
- [`types/types`](./src/types/types.md) — orchestration-level types.
- [`types/adapters`](./src/types/adapters.md) — Phase-1 adapter interfaces (DriveLike / DriveSharingLike / VaultSyncLike).
## Security model
- **2SKD**: `Argon2id(HMAC-SHA256(secretKey, password ‖ 0x00 ‖ email), salt, params)` produces the AUK, which is HKDF-derived through `personal-keyset → vault-key → item-key`. Intermediate buffers are zeroized eagerly.
- **OpaqueKeyHandle boundary**: the vault key is wrapped in a `SecureBuffer` and accessed only through `@suaveplan/crypto-vault`'s `unwrap` — never as raw bytes across module boundaries.
- **Defence in depth**: even if the drive-level AEAD is compromised, concealed fields (passwords, PINs, secure notes) remain encrypted with per-item keys inside the SQLite-style container.
- **Manifest binding**: the bitpacker-packed manifest header is bound as AAD to the container's AEAD, so any tampering invalidates the authentication tag.
- **`zeroizeAll()` on lock**: every live `SecureBuffer` is overwritten with zeros and the references released.
## Performance
- Unlock is dominated by Argon2id (≥ 64 MiB / 3 iterations / parallelism configurable). Default parameters ship at OWASP minimums.
- Items, search, attachments, and share operations are all in-memory after unlock; flushes are debounced (default 500 ms; configurable via `flushDebounceMs`).
- Drive I/O happens twice per unlock at most: once on read, once for the freshly-encrypted blob if creating a new vault. Ongoing operations only touch drive on flush + lock.
## Phased delivery
This is the Phase-1 landing. Adapter interfaces (`DriveLike`, `DriveSharingLike`, `VaultSyncLike`) are defined inline; production wiring binds the real `@suaveplan/drive`, `@suaveplan/drive-sharing`, and `@suaveplan/vault-sync` packages without changes to this surface.

View File

@@ -0,0 +1,102 @@
# vault attachments
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`attachments.ts` implements drive-backed AEAD-encrypted attachment storage. Each attachment is encrypted under a per-attachment key derived from the vault key + a synthetic `attachment/<id>` derivation label, with the AEAD AAD bound to the owning `(itemId, filename)` so an attacker who reorders blobs in drive cannot pass authentication.
The repository wires `DriveLike.read` / `write` / `delete` for blob I/O and keeps attachment metadata (id / itemId / filename / mimeType / size / createdAt) in a caller-provided in-memory `Map<string, Attachment>` that lives inside the vault's container state. Metadata mutations notify the orchestrator via `onMutation` so the next debounced flush re-encrypts the container blob and writes it back.
## Features
- `upload(input) → Attachment` — Zod-validated metadata, AEAD-encrypted body, atomic drive write.
- `download(id) → Uint8Array` — drive read + AEAD decryption; throws `ATTACHMENT.NOT_FOUND` on missing metadata.
- `delete(id)` — drive delete + metadata removal; throws `ATTACHMENT.NOT_FOUND` on missing metadata.
- `list()` — snapshot of every attachment metadata record.
- Hard cap on plaintext size (default 100 MiB; configurable via `maxAttachmentBytes`); over-cap uploads throw `ATTACHMENT.TOO_LARGE`.
- Drive failures throw `DRIVE.FAILURE` with the underlying error chained as `cause`.
- Per-attachment key zeroization in a `finally` block after every encrypt / decrypt.
- OTel histogram + counter per upload / download / delete with `op` and `status` labels.
## Usage: Basic Example
```ts
import { VaultAttachments } from "@suaveplan/vault/attachments/attachments";
const attachments = new VaultAttachments({
drive,
attachmentsPrefix: "/vaults/personal/attachments",
vaultKey,
clock: Date.now,
rng: undefined,
metadata: state.attachments,
onMutation: () => scheduleFlush(),
});
const meta = await attachments.upload({
itemId: "abc",
filename: "passport.pdf",
mimeType: "application/pdf",
data: new Uint8Array(fileBytes),
});
const recovered = await attachments.download(meta.id);
```
## Usage: Advanced Example
```ts
import {
VaultAttachments,
encodeAttachmentMetadata,
decodeAttachmentMetadata,
} from "@suaveplan/vault/attachments/attachments";
// Custom 1 MiB cap for a memory-constrained device.
const small = new VaultAttachments({
drive,
attachmentsPrefix: "/embedded",
vaultKey,
clock,
rng,
maxAttachmentBytes: 1024 * 1024,
metadata,
onMutation,
});
// Persist attachment metadata in a sidecar list outside the container.
const meta = await small.upload({
itemId,
filename: "small.bin",
mimeType: "application/octet-stream",
data,
});
const encoded = encodeAttachmentMetadata(meta);
const decoded = decodeAttachmentMetadata(encoded);
// `decoded` round-trips equal to `meta`.
```
## API Reference
- `class VaultAttachments`.
- `constructor(options: AttachmentsOptions)`.
- `upload(input: UploadAttachmentInput): Promise<Attachment>` — throws `ATTACHMENT.TOO_LARGE` / `ITEM.VALIDATION_FAILED` / `DRIVE.FAILURE`.
- `download(id: string): Promise<Uint8Array>` — throws `ATTACHMENT.NOT_FOUND` / `DRIVE.FAILURE` / `DECRYPT_FAILED`.
- `delete(id: string): Promise<void>` — throws `ATTACHMENT.NOT_FOUND` / `DRIVE.FAILURE`.
- `list(): Attachment[]`.
- `interface AttachmentsOptions` — `{ drive, attachmentsPrefix, vaultKey, clock, rng, maxAttachmentBytes?, metadata, onMutation }`.
- `interface UploadAttachmentInput` — `{ itemId, filename, mimeType, data }`.
- `DEFAULT_MAX_ATTACHMENT_BYTES: 100 MiB`.
- `encodeAttachmentMetadata(meta): string` — base64 JSON helper.
- `decodeAttachmentMetadata(encoded): Attachment` — inverse, validates via `AttachmentSchema`.
## Implementation Notes
- The blob layout is `[24-byte nonce][ciphertext + 16-byte Poly1305 tag]`. No per-attachment manifest header — the metadata record carries `(id, itemId, filename, mimeType, size)`, and the container manifest's vault-id binding still applies indirectly through the container blob's own AEAD authentication.
- The per-attachment key is derived via `deriveItemKey(vaultKey, "attachment/<id>")`. Using a synthetic key path prevents collisions with item keys derived from the same vault key under genuine item ids.
- The AEAD AAD is `"vault/v1/attachment/<itemId>/<filename>"`. A renamed attachment (different filename) re-encrypted without updating the metadata would fail authentication on download — which is the desired behaviour: metadata and blob must stay in sync.
- Drive `write` is contracted as atomic; a partial write would otherwise leave a half-encrypted blob that fails AEAD authentication on the next read. Adapters that cannot satisfy atomicity (e.g. unsynced filesystem writes) must implement their own staged-tmp + rename.
- Over-cap uploads throw `ATTACHMENT.TOO_LARGE` BEFORE any drive I/O — this prevents an attacker from filling drive storage with rejected bytes.
- The OTel `outcome` counter uses a fixed allowlist (`success | not_found | rejected`); other failure modes (e.g. AEAD auth) are surfaced through the thrown `VaultError` rather than as a counter status, to keep the metric cardinality bounded.

View File

@@ -0,0 +1,76 @@
# vault container
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`container.ts` is the drive-backed encrypted blob layer that sits between the orchestration `Vault` class and the underlying `DriveLike` adapter. It handles the on-disk format — `[manifest header][AEAD nonce][AEAD ciphertext+tag]` — by combining `manifest/manifest.ts` (header packing) with `@suaveplan/crypto-cipher`'s XChaCha20-Poly1305 (body authentication).
The container is the single boundary at which vault state crosses between in-memory plaintext and on-disk ciphertext. `loadContainer` reads the blob, parses the manifest, and AEAD-decrypts the body using the manifest as AAD; `saveContainer` reverses the process and writes back atomically. Tampering with any byte of the manifest or ciphertext fails the AEAD tag check, raising `VAULT.DECRYPT_FAILED`.
## Features
- `createEmptyContainer(input)` — build a fresh state + manifest for first-time vault creation.
- `loadContainer(drive, path, vaultKey)` — read encrypted blob from drive and decrypt it.
- `saveContainer(input)` — refresh the manifest's `lastModifiedAt`, encrypt, and atomically write the blob.
- `encryptContainer(input)` / `decryptContainer(blob, vaultKey)` — pure byte-level helpers without drive I/O (used by tests and integration).
- `serialiseState(state)` / `deserialiseState(bytes)` — round-trip the in-memory item / history / ledger state through UTF-8 JSON.
- All drive-side failures map to `VAULT.DRIVE.FAILURE`; AEAD / encoding failures map to `VAULT.DECRYPT_FAILED`.
## Usage: Basic Example
```ts
import { createEmptyContainer, encryptContainer, decryptContainer } from "@suaveplan/vault/container/container";
const { manifest, state } = createEmptyContainer({
vaultId: "personal",
argon2idMemoryKB: 65536,
argon2idIterations: 3,
argon2idParallelism: 4,
now: Date.now(),
});
const blob = encryptContainer({ manifest, state, vaultKey });
const round = decryptContainer(blob, vaultKey);
// round.manifest.formatVersion === manifest.formatVersion
// round.state.schemaVersion === state.schemaVersion
```
## Usage: Advanced Example
```ts
import { loadContainer, saveContainer } from "@suaveplan/vault/container/container";
// On unlock: read + decrypt + return state.
const { manifest, state } = await loadContainer(drive, "/personal.vault", vaultKey);
// ... mutate state via items module ...
// On flush: refresh manifest + encrypt + atomic write.
const updated = await saveContainer({
drive,
path: "/personal.vault",
manifest,
state,
vaultKey,
now: Date.now(),
});
```
## API Reference
- `createEmptyContainer(input): ContainerLoadResult` — pristine state at head schema.
- `serialiseState(state): Uint8Array` / `deserialiseState(bytes): VaultContainerState` — JSON round-trip.
- `encryptContainer({ manifest, state, vaultKey, rng? }): Uint8Array` — pack header, encrypt body, concatenate.
- `decryptContainer(blob, vaultKey): ContainerLoadResult` — slice, unpack, decrypt, deserialise.
- `loadContainer(drive, path, vaultKey): Promise<ContainerLoadResult>` — drive read + decrypt.
- `saveContainer({ drive, path, manifest, state, vaultKey, now, rng? }): Promise<VaultManifest>` — refresh manifest, encrypt, drive write; returns the refreshed manifest.
## Implementation Notes
- The blob layout is fixed: header at offset 0 (size `MANIFEST_BYTE_SIZE`), nonce at offset `MANIFEST_BYTE_SIZE` (24 bytes), ciphertext after that. Any change to the layout requires a manifest format-version bump.
- The AEAD AAD is the **packed manifest header bytes**, not the parsed manifest object — this binds the ciphertext to the exact byte sequence the reader will use to choose the AEAD algorithm and KDF parameters. An attacker who flips a single bit in the header sees an authentication failure rather than a silent algorithm downgrade.
- Drive `write` is contracted to be atomic in `DriveLike`; the container layer relies on this for crash safety. A partial write would corrupt the blob; truthfully-atomic adapters (POSIX `rename`, S3 `PutObject`, IndexedDB transactions) all satisfy the contract.
- `serialiseState` collects items as an array (sorted into Map order on the way out) and re-builds the Map on `deserialiseState`. This keeps the on-disk JSON shape stable across runtimes that may not preserve Map iteration order through structured-clone.
- The minimum blob length check in `decryptContainer` (`MANIFEST_BYTE_SIZE + NONCE_LENGTH + 16`) is the smallest possible valid blob: header + nonce + 16-byte Poly1305 tag with empty plaintext. Anything shorter is malformed by definition and fails fast with `VAULT.DECRYPT_FAILED`.

View File

@@ -0,0 +1,74 @@
# vault field encryption
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`field-encryption.ts` implements per-item, per-field XChaCha20-Poly1305 AEAD encryption for vault items' concealed fields (passwords, PINs, secure notes). It is the **defence-in-depth** layer that lives **inside** the SQLite container: even if the drive-level AEAD is broken (which by construction it cannot be), every concealed field remains ciphertext until lazily decrypted on `vault.items.get`.
The module derives a per-item key from the vault key via `@suaveplan/crypto-vault`'s `deriveItemKey(vaultKey, itemId)`, encrypts the plaintext with XChaCha20-Poly1305, binds the AEAD AAD to the `(itemId, fieldName)` pair so a swapped envelope (e.g. moving a password between items) fails authentication, and zeroizes the per-item key and plaintext buffer immediately after the operation.
## Features
- `encryptField(options) → EncryptedField` — encrypt a single named field value.
- `decryptField(options) → string` — decrypt a single envelope; throws `VAULT.DECRYPT_FAILED` on auth failure or malformed encoding.
- `encryptConcealedFields(vaultKey, itemId, concealed, rng?) → Record<string, EncryptedField>` — bulk-encrypt every field in a record.
- `decryptConcealedFields(vaultKey, itemId, encrypted) → Record<string, string>` — bulk-decrypt every envelope.
- AAD-binding to `(itemId, fieldName)` prevents envelope-swapping attacks.
- Per-item key + plaintext zeroization after every operation.
## Usage: Basic Example
```ts
import { encryptField, decryptField } from "@suaveplan/vault/encryption/field-encryption";
const envelope = encryptField({
vaultKey,
itemId: "abc-123",
fieldName: "password",
value: "p@ssw0rd",
});
const recovered = decryptField({
vaultKey,
itemId: "abc-123",
fieldName: "password",
envelope,
});
// recovered === "p@ssw0rd"
```
## Usage: Advanced Example
```ts
import {
encryptConcealedFields,
decryptConcealedFields,
} from "@suaveplan/vault/encryption/field-encryption";
// Encrypt every concealed field in one call before persisting.
const concealed = { password: "p@ssw0rd", pin: "1234" };
const envelopes = encryptConcealedFields(vaultKey, itemId, concealed);
// Persist `envelopes` as JSON inside the SQLite row.
// On read:
const decrypted = decryptConcealedFields(vaultKey, itemId, envelopes);
// decrypted.password === "p@ssw0rd"
```
## API Reference
- `interface EncryptedField` — `{ algorithm: "xchacha20-poly1305", nonce: base64, ciphertext: base64 }`.
- `encryptField(options: EncryptFieldOptions): EncryptedField`.
- `decryptField(options: DecryptFieldOptions): string`.
- throws `VaultError(VAULT.DECRYPT_FAILED)` on bad algorithm / encoding / authentication.
- `encryptConcealedFields(vaultKey, itemId, concealed, rng?): Record<string, EncryptedField>`.
- `decryptConcealedFields(vaultKey, itemId, encrypted): Record<string, string>`.
## Implementation Notes
- The AEAD AAD is `"vault/v1/item/<itemId>/field/<fieldName>"`. The `v1` literal prefix lets future versions bump without colliding with old envelopes; consumers that try to decrypt a v1 envelope under a hypothetical v2 AAD will get `VAULT.DECRYPT_FAILED`.
- The 24-byte XChaCha20-Poly1305 nonce is sampled from the injected `rng` (or `crypto.getRandomValues` by default) per-encryption. The 192-bit nonce space lets nonce reuse remain birthday-bound at ~2⁸⁰ messages — well beyond any vault's lifetime.
- Per-item keys are derived deterministically from `(vaultKey, itemId)`. They are zeroized via `zeroize(itemKey)` in a `finally` block after every encrypt or decrypt — this minimises the wall-clock window during which a key is reachable in heap memory.
- The plaintext buffer (`TEXT_ENCODER.encode(value)`) is also zeroized after encryption. Strings, by contrast, are immutable in JS and cannot be cleared from heap; callers that want full lifecycle hygiene must source plaintexts from `Uint8Array` and overwrite their own buffers.
- A failure of `xchacha20poly1305.decrypt` is mapped to `VAULT.DECRYPT_FAILED` with an `{ reason: "auth" }` context tag. Encoding failures (bad base64) are mapped to the same code with `{ reason: "encoding" }` so callers can branch on the failure mode without exposing implementation details.

View File

@@ -0,0 +1,55 @@
# vault error codes
## Purpose
`codes.ts` defines the stable error-code registry for `@suaveplan/vault`. Every failure raised by the orchestration layer — locked-state rejections, bad-credential unlocks, manifest tampering, item validation, attachment / share / sync failures, and opaque-handle violations — carries one of these literal codes. The codes are frozen at module-load via `defineCodes("VAULT", [...])` so consumer code can assert on the exact strings without depending on JavaScript identifier ordering or import-time side effects.
The codes form the discriminated-union narrowing surface for `VaultError`. Together with the `VaultError` class they form the complete error contract: callers `import { VAULT_CODES, VaultError, type VaultErrorCode } from "@suaveplan/vault/errors"`, branch on `err.code`, and let TypeScript exhaustively check the switch.
## Features
- Two-level namespacing — `VAULT.LOCKED` for top-level lifecycle errors, `VAULT.ITEM.NOT_FOUND` for module-scoped errors. The dot-separated form is searchable and groups related failures (`ITEM.*`, `ATTACHMENT.*`, `SHARE.*`, `SYNC.*`).
- Every code is a literal string in the `VaultErrorCode` union — no enum cost, no value coupling.
- Hard-frozen registry — `defineCodes` returns a frozen object so consumers cannot accidentally mutate or extend the registry at runtime.
## Usage: Basic Example
```ts
import { VAULT_CODES, VaultError } from "@suaveplan/vault/errors";
throw new VaultError(VAULT_CODES.LOCKED, "error.vault.locked");
```
## Usage: Advanced Example
```ts
import { VAULT_CODES, VaultError, type VaultErrorCode } from "@suaveplan/vault/errors";
function describe(err: VaultError): string {
const code: VaultErrorCode = err.code;
switch (code) {
case VAULT_CODES.LOCKED:
return "Vault is locked — call unlock() first.";
case VAULT_CODES.INVALID_CREDENTIALS:
return "Wrong password or secret key.";
case VAULT_CODES["ITEM.NOT_FOUND"]:
return "Item does not exist in this vault.";
case VAULT_CODES["ITEM.VERSION_CONFLICT"]:
return "Item was modified concurrently — refetch and retry.";
// ... exhaustive
default:
return "Unknown vault error.";
}
}
```
## API Reference
- `VAULT_CODES` — frozen `Record<string, "VAULT.X.Y">` literal registry.
- `VaultErrorCode` — union of every value in `VAULT_CODES`.
## Implementation Notes
- The registry is intentionally fixed in source — adding a code is a public-API change requiring an OpenSpec proposal and a coordinated release with consumers.
- `defineCodes("VAULT", [...])` automatically prefixes each entry with `VAULT.`; the source array contains the unqualified suffix so visual scanning stays compact.
- Sub-namespaces (`ITEM`, `ATTACHMENT`, `SHARE`, `SYNC`) match the file layout under `src/` so locating a code's emission site is mechanical.

View File

@@ -0,0 +1,52 @@
# VaultError
## Purpose
`vault-error.ts` defines `VaultError`, the concrete `SuaveplanError` subclass thrown by every public path in `@suaveplan/vault`. It binds the runtime instance to a `VaultErrorCode` from the stable code registry and tags every instance with a unique brand symbol so duck-typed peers cannot impersonate a `VaultError` across module or worker boundaries.
This package follows the same shape as `@suaveplan/vault-sync`'s `VaultSyncError` and `@suaveplan/crypto-vault`'s `CryptoVaultError`: subclass `SuaveplanError` once, parameterise on the package's code-union, and add a brand-symbol type guard. Consumer code in the password-manager UI, browser extension, and desktop shell can pattern-match on `err instanceof VaultError` and switch over `err.code` without worrying about colliding with a foreign error class.
## Features
- Subclass of `SuaveplanError<VaultErrorCode>` — inherits structured fields (`code`, `messageKey`, `cause`, `context`).
- Unique-symbol brand attached at construction — survives `instanceof` failures across realms.
- `VaultError.is(value)` static type guard — safe across iframe / worker boundaries.
- Forwards `SuaveplanErrorOptions` to the base class so `cause`, `context`, and i18n metadata flow through unchanged.
## Usage: Basic Example
```ts
import { VAULT_CODES, VaultError } from "@suaveplan/vault/errors";
throw new VaultError(VAULT_CODES.LOCKED, "error.vault.locked");
```
## Usage: Advanced Example
```ts
import { VAULT_CODES, VaultError } from "@suaveplan/vault/errors";
try {
await vault.items.create({ title: "" });
} catch (err) {
if (VaultError.is(err) && err.code === VAULT_CODES["ITEM.VALIDATION_FAILED"]) {
// Surface the missing-field reason from `err.context` to the form.
showValidationErrors(err.context);
return;
}
throw err;
}
```
## API Reference
- `class VaultError extends SuaveplanError<VaultErrorCode>` — concrete error class.
- `constructor(code: VaultErrorCode, messageKey: string, options?: SuaveplanErrorOptions)` — instantiate with one of the codes in `VAULT_CODES` and an i18n message key.
- `static is(value: unknown): value is VaultError` — brand-aware type guard.
## Implementation Notes
- The brand symbol is a `unique symbol` declared module-locally; it cannot be reproduced from outside this file even with full source access.
- The constructor sets the brand property after calling `super()` so the inherited `code`, `messageKey`, and `context` fields are populated normally.
- The static `is(value)` guard checks both `instanceof VaultError` and the brand property — this defends against subclasses or polyfills that satisfy `instanceof` but were not minted by this package.
- Throwing `VaultError` is the only way the package signals a recoverable failure to callers; lifecycle, item, attachment, share, sync, and opaque-handle paths all use this class.

View File

@@ -0,0 +1,57 @@
# vault items helpers
## Purpose
`items-helpers.ts` extracts the pure transformation helpers used by the items repository: row-to-item projection (with on-the-fly concealed-field decryption), `applyPatch` to merge a partial `ItemFields` into an existing row, and `generateItemId` to produce a hex-encoded random 16-byte id. Keeping these helpers in their own file lets the main `items.ts` stay under the 300-line cap while preserving readability.
The helpers are **pure** with respect to the vault state — they take inputs (rows / vault key / rng) and return new values without touching the container's `Map<string, VaultItemRow>`. The `VaultItems` class wires them together, applies them inside the `state.items.set(...)` mutation, and emits the OTel outcome counters.
## Features
- `rowToItem(row, vaultKey)` — project a row back to a `VaultItem`, lazily decrypting concealed envelopes.
- `applyPatch(row, patch, vaultKey, rng)` — merge a `Partial<ItemFields>` into an existing row; re-encrypts concealed-field updates under the per-item key.
- `generateItemId(rng)` — 32-char hex string from 16 random bytes; falls back to `crypto.getRandomValues` when no `rng` is injected.
- All helpers are stateless and side-effect-free except for the per-call key derivation done inside `encryptConcealedFields` / `decryptConcealedFields`.
## Usage: Basic Example
```ts
import { rowToItem } from "@suaveplan/vault/items/items";
const row = state.items.get(id);
if (row !== undefined) {
const item = rowToItem(row, vaultKey);
console.log(item.concealedFields.password);
}
```
## Usage: Advanced Example
```ts
import { applyPatch, generateItemId } from "@suaveplan/vault/items/items";
const row = state.items.get(id);
if (row === undefined) throw new Error("missing");
const patched = applyPatch(
row,
{ title: "Renamed", concealedFields: { password: "new" } },
vaultKey,
rng
);
state.items.set(id, { ...patched, version: row.version + 1 });
const fresh = generateItemId(rng); // hex-encoded id
```
## API Reference
- `rowToItem(row: VaultItemRow, vaultKey: Uint8Array): VaultItem`.
- `applyPatch(row: VaultItemRow, patch: Partial<ItemFields>, vaultKey: Uint8Array, rng: Rng | undefined): VaultItemRow`.
- `generateItemId(rng: Rng | undefined): string`.
## Implementation Notes
- `rowToItem` re-decrypts concealed envelopes on every call. Callers that read the same row repeatedly are responsible for caching the result; the items repository chooses lazy decryption to keep the typical-read path cheap when only metadata is touched.
- `applyPatch` returns a fresh row object. Callers must not mutate the returned row before storing it; the items repository takes ownership.
- `generateItemId` uses 128 bits of entropy — well above the birthday-collision bound for any vault size. The hex encoding is chosen for stable JSON serialisation across runtimes.
- The injected `rng` flows through `encryptConcealedFields` so per-field nonces are sourced from a deterministic stream in tests.

View File

@@ -0,0 +1,99 @@
# vault items
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
- [migrations](./migrations/migrations.md) — <!-- begin:auto-docs --> _(no submodules)_- [helpers](./helpers.md) — `items-helpers.ts` extracts the pure transformation helpers used by the items repository: row-to-item projection (with…
- [items](./items.md) — <!-- begin:auto-docs --> _(no submodules)_- migrations — <!-- begin:auto-docs --> _(no submodule…
- [migrations](./migrations/migrations.md) — <!-- begin:auto-docs --> _(no submodules)_- [helpers](./helpers.md) — `items-helpers.ts` extracts the pure transformation helpers used by the items repository: row-to-item projection (with…
- [items](./items.md) — <!-- begin:auto-docs --> _(no submodules)_- [migrations](./migrations/migrations.md) — <!-- begin:auto-docs --> _(no submodules)_- [helpers](./helpers.md) — `items-helpers.ts` extracts the pure transformation helpers used by the items repository: row-to-item projection (with…
- [items](./items.md) — <!-- begin:auto-docs --> _(no submodules)_- migrations — <!-- begin:auto…
- [migrations](./migrations/migrations.md) — <!-- begin:auto-docs --> _(no submodules)_- [helpers](./helpers.md) — `items-helpers.ts` extracts the pure transformation helpers used by the items repository: row-to-item projection (with…
- [items](./items.md) — <!-- begin:auto-docs --> _(no submodules)_- migrations — <!-- begin:auto-docs --> _(no submodul…
- [migrations](./migrations/migrations.md) — <!-- begin:auto-docs --> _(no submodules)_- [helpers](./helpers.md) — `items-helpers.ts` extracts the pure transformation helpers used by the items repository: row-to-item projection (with…
- [items](./items.md) — <!-- begin:auto-docs --> _(no submodules)_
## Purpose
`items.ts` exposes the `VaultItems` class, the in-memory CRUD repository over the vault container's item map. It is the boundary between the public `vault.items` surface and the underlying state — every public method validates input via Zod, mutates the container `Map<string, VaultItemRow>`, appends an audit entry to the container history, encrypts concealed-field values per item, and notifies the orchestrator that a flush is due.
The repository operates on the in-memory state loaded from the encrypted container blob. It does NOT touch drive directly; persistence is the orchestrator's job (debounced flush + on lock). Concealed-field values (passwords / PINs / secure notes) are encrypted with per-item keys derived from the vault key, so even a leaked container snapshot keeps concealed values as ciphertext until decrypted on demand by `rowToItem`.
## Features
- `create(fields) → VaultItem` — Zod-validated insert with concealed-field encryption.
- `get(id) → VaultItem | null` — lazy decryption; returns null for missing or tombstoned ids.
- `update(id, patch, expectedVersion?) → VaultItem` — optimistic concurrency via `version`; throws `VAULT.ITEM.VERSION_CONFLICT` on stale writes.
- `delete(id)` — tombstone (not physical delete) so sync can propagate the deletion.
- `list(options?) → VaultItem[]` — filters by category / favorite, with offset / limit pagination.
- `allRows()` — snapshot of non-deleted rows for the search index.
- Every operation records a histogram sample and an outcome counter; OTel attributes are limited to `{ op, status }` — no item ids, no field names, no plaintext.
- Mutations call `onMutation()` so the orchestrator can debounce flushes.
## Usage: Basic Example
```ts
import { VaultItems } from "@suaveplan/vault/items/items";
const items = new VaultItems({
state,
vaultKey,
clock: Date.now,
rng: undefined,
onMutation: () => scheduleFlush(),
});
const created = items.create({
title: "GitHub",
category: "login",
fields: { username: "alice", url: "https://github.com" },
concealedFields: { password: "p@ssw0rd" },
tags: ["work"],
favorite: true,
});
```
## Usage: Advanced Example
```ts
import { VaultItems } from "@suaveplan/vault/items/items";
import { VaultError, VAULT_CODES } from "@suaveplan/vault/errors";
// Optimistic concurrency: capture version, retry on conflict.
function updateWithRetry(items: VaultItems, id: string, patch: Partial<ItemFields>) {
for (let attempt = 0; attempt < 3; attempt++) {
const current = items.get(id);
if (current === null) throw new Error("missing");
try {
return items.update(id, patch, current.version);
} catch (err) {
if (
VaultError.is(err) &&
err.code === VAULT_CODES["ITEM.VERSION_CONFLICT"] &&
attempt < 2
) continue;
throw err;
}
}
}
```
## API Reference
- `class VaultItems` — public surface for `vault.items`.
- `constructor(options: ItemsOptions)`.
- `create(fields: ItemFields): VaultItem` — throws `ITEM.VALIDATION_FAILED`.
- `get(id: string): VaultItem | null`.
- `update(id: string, patch: Partial<ItemFields>, expectedVersion?: number): VaultItem` — throws `ITEM.NOT_FOUND` / `ITEM.VERSION_CONFLICT` / `ITEM.VALIDATION_FAILED`.
- `delete(id: string): void` — throws `ITEM.NOT_FOUND`.
- `list(options?: ListOptions): VaultItem[]` — throws `ITEM.VALIDATION_FAILED`.
- `allRows(): VaultItemRow[]` — read-only snapshot for search.
- `interface ItemsOptions` — `{ state, vaultKey, clock, rng, onMutation }`.
## Implementation Notes
- The repository is **stateful** — it holds references to the container state map, the vault key, and the orchestrator's mutation callback. The vault layer creates a new `VaultItems` on every unlock; the state map is replaced when the container reloads from drive.
- `update` always increments `version` by 1 per successful call. Concurrent writers using `expectedVersion` will see exactly one winner; the others receive `VAULT.ITEM.VERSION_CONFLICT`.
- `delete` is a tombstone — the row stays in the map with `deleted: true` so the sync layer can propagate the deletion. A future garbage-collection pass (out of scope for this version) would prune tombstones older than a retention window.
- The history log is append-only: every successful `create` / `update` / `delete` appends one row with `(itemId, version, timestamp, operation)`. This feeds the audit-trail and CRDT merge paths.
- `list` walks the map insertion order — the same order in which rows were created or last updated. Callers that need stable cross-process ordering should sort by `createdAt` or `updatedAt` after fetch.
- The OTel `outcome` counter uses a fixed allowlist (`success | not_found | conflict | rejected`) — adding a new value requires an OpenSpec change in lockstep with `getVaultInstruments`.

View File

@@ -0,0 +1,80 @@
# vault item migrations
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`migrations.ts` is the forward-only schema-migration ledger for the in-memory vault container. The container holds a `schemaVersion`, an item map, a history log, and a ledger of applied migrations. Migrations are described by `{ version, name, up(state) }`; running them advances the container state from one schema version to the next while recording the step in the ledger.
The model is deliberately schema-light: the container state is plain JS data structures, not a SQL database. This keeps the package vitest-runnable (no `bun:sqlite` dependency in test) while preserving the migration semantics consumers expect — strictly-increasing versions, append-only ledger, deterministic forward replay. Future schema changes append a new entry to `VAULT_MIGRATIONS` with the next version number; the manifest's `schemaVersion` field carries the current head so older clients can detect they need to upgrade.
## Features
- `VaultMigration` interface — `{ version, name, up(state) }`.
- `VAULT_MIGRATIONS` — frozen array of every migration shipped with the current package version.
- `emptyContainerState()` — produce a pristine state at version 0.
- `runMigrations(state, migrations?, now?)` — apply every step whose version exceeds the state's, recording a `MigrationLedgerEntry` per step.
- `headSchemaVersion(migrations?)` — return the highest version in the migration set.
- Strictly-monotonic ledger — once applied, an entry never changes; later migrations are appended.
## Usage: Basic Example
```ts
import {
emptyContainerState,
runMigrations,
VAULT_MIGRATIONS,
} from "@suaveplan/vault/items/migrations/migrations";
const state = emptyContainerState();
const result = runMigrations(state);
// result.fromVersion === 0
// result.toVersion === headSchemaVersion()
```
## Usage: Advanced Example
```ts
import {
runMigrations,
type VaultMigration,
type VaultContainerState,
} from "@suaveplan/vault/items/migrations/migrations";
// In-place migration: rename `tagsJson` to `tagListJson` (illustrative only).
const customMigration: VaultMigration = {
version: 2,
name: "rename-tagsJson-to-tagListJson",
up(state) {
for (const row of state.items.values()) {
// Mutate state in place — migrations are forward-only.
(row as unknown as Record<string, unknown>).tagListJson =
row.tagsJson;
}
},
};
const fakeClock = () => 1_700_000_000_000;
runMigrations(state, [...VAULT_MIGRATIONS, customMigration], fakeClock);
```
## API Reference
- `interface VaultMigration` — `{ version: number, name: string, up(state): void }`.
- `interface MigrationLedgerEntry` — `{ version, name, appliedAt }`.
- `interface VaultContainerState` — `{ schemaVersion, items: Map, history: [], ledger: [] }`.
- `interface VaultItemRow` — persisted row shape (see source for full field list).
- `interface VaultHistoryRow` — `{ itemId, version, timestamp, operation }`.
- `VAULT_MIGRATIONS: readonly VaultMigration[]` — frozen migration set.
- `emptyContainerState(): VaultContainerState`.
- `runMigrations(state, migrations?, now?): MigrationResult`.
- `headSchemaVersion(migrations?): number`.
## Implementation Notes
- Migrations sort by `version` before application so callers can declare them in any order; the runtime ensures strictly-increasing application.
- The `up` callback mutates state in place. Callers that want transactional semantics across multiple migrations should snapshot state before invoking `runMigrations` and roll back on failure.
- The ledger is append-only — every successfully-applied migration adds one entry. Duplicate applications are prevented by the `m.version <= state.schemaVersion` check.
- The shipped `VAULT_MIGRATIONS` set is intentionally sparse: the suaveplan "schema" is plain JS structures, so the v1 migration is a no-op marker. Future versions add real transformations (e.g. adding a new field to every row, splitting a JSON blob into two columns).
- `runMigrations` accepts an optional injectable `now` to keep ledger timestamps deterministic in tests; production passes `Date.now`.

View File

@@ -0,0 +1,84 @@
# vault manifest
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`manifest.ts` defines the bitpacker-packed manifest header that lives at the start of every encrypted vault container. The header carries the format version, KDF identifier, AEAD algorithm identifier, Argon2id parameters, vault-id hash, schema version, and creation / modification timestamps. The packed bytes are bound into the AEAD additional-authenticated-data (AAD) so any tampering with the manifest invalidates the ciphertext authentication tag.
The manifest layer wraps `@suaveplan/bitpacker`'s `defineBitSchema` so the on-disk format is compact (50 bytes total: 18 bytes of packed numerics + 32 bytes of SHA-256 vault-id hash), versioned, and forward-tolerant. The leading `formatVersion: u16` lets future readers decide whether they can decode the rest of the blob; mismatched versions raise `VAULT.MANIFEST_VERSION_UNSUPPORTED`.
## Features
- `packManifest(manifest) → Uint8Array` — validate via Zod, then pack to canonical 50-byte header.
- `unpackManifest(bytes) → VaultManifest` — decode + validate; rejects too-short buffers, schema violations, and unsupported versions.
- `createManifest(input) → VaultManifest` — build a fresh manifest for a new vault from Argon2id parameters and the current wall-clock time.
- `touchManifest(manifest, now) → VaultManifest` — update `lastModifiedAt` on flush.
- `hashVaultId(vaultId) → Uint8Array(32)` — stable SHA-256 hash used as the manifest's `vaultIdHash` field.
- Constants: `MANIFEST_FORMAT_VERSION`, `MANIFEST_KDF_ARGON2ID_2SKD`, `MANIFEST_AEAD_XCHACHA20_POLY1305`, `MANIFEST_BYTE_SIZE`.
## Usage: Basic Example
```ts
import { createManifest, packManifest, unpackManifest } from "@suaveplan/vault/manifest/manifest";
const manifest = createManifest({
vaultId: "personal",
argon2idMemoryKB: 65536,
argon2idIterations: 3,
argon2idParallelism: 4,
schemaVersion: 1,
now: Date.now(),
});
const bytes = packManifest(manifest); // 50-byte header
const decoded = unpackManifest(bytes);
```
## Usage: Advanced Example
```ts
import {
MANIFEST_BYTE_SIZE,
packManifest,
touchManifest,
unpackManifest,
} from "@suaveplan/vault/manifest/manifest";
// On flush: re-pack the manifest with a new lastModifiedAt and bind it as AAD.
function flush(manifest, sqliteBytes, now, vaultKey) {
const refreshed = touchManifest(manifest, now);
const header = packManifest(refreshed);
const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LENGTH));
const ciphertext = encrypt(vaultKey, nonce, sqliteBytes, header); // AAD = header
const out = new Uint8Array(MANIFEST_BYTE_SIZE + nonce.length + ciphertext.length);
out.set(header, 0);
out.set(nonce, MANIFEST_BYTE_SIZE);
out.set(ciphertext, MANIFEST_BYTE_SIZE + nonce.length);
return out;
}
```
## API Reference
- `MANIFEST_FORMAT_VERSION: 1` — current packed format version.
- `MANIFEST_KDF_ARGON2ID_2SKD: 1` — KDF identifier (Argon2id with 2SKD pre-mix).
- `MANIFEST_AEAD_XCHACHA20_POLY1305: 1` — AEAD algorithm identifier.
- `MANIFEST_BYTE_SIZE: number` — total byte size of every packed manifest.
- `hashVaultId(vaultId: string): Uint8Array` — 32-byte SHA-256 hash.
- `packManifest(manifest: VaultManifest): Uint8Array` — pack to canonical header.
- throws `VaultError(VAULT.MANIFEST_INVALID)` on schema violation.
- `unpackManifest(bytes: Uint8Array): VaultManifest` — decode + validate.
- throws `VaultError(VAULT.MANIFEST_INVALID)` on too-short buffer or schema violation.
- throws `VaultError(VAULT.MANIFEST_VERSION_UNSUPPORTED)` on unknown `formatVersion`.
- `createManifest(input): VaultManifest` — build a fresh manifest.
- `touchManifest(manifest, now): VaultManifest` — bump `lastModifiedAt`.
## Implementation Notes
- The bitpacker schema uses `u53` (53 bits) for `createdAt` / `lastModifiedAt`. JS numbers are double-precision floats; values up to `Number.MAX_SAFE_INTEGER` (2⁵³ − 1) round-trip losslessly. ms-precision timestamps fit comfortably for the next ~285 million years.
- `vaultIdHash` is a **fixed 32-byte SHA-256** rather than the raw `vaultId` string. This keeps the on-disk format constant-size, lets the AEAD AAD bind to a specific vault without leaking the `vaultId`, and prevents an attacker from learning vault names from header inspection.
- `unpackManifest` ignores trailing bytes after `MANIFEST_BYTE_SIZE` so the same routine can be called against the full container blob without slicing first.
- The `formatVersion` check at unpack time is the forward-compatibility guard — readers from a future format version are rejected explicitly rather than silently misinterpreting bytes.
- All numeric fields have explicit bit-width caps in `MANIFEST_FIELDS` matching the corresponding Zod schema in `schemas/schemas.ts`. The two layers must stay in sync — touching one without the other is a defect.

View File

@@ -0,0 +1,71 @@
# vault OTel instruments
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`instruments.ts` is the single source of truth for OpenTelemetry instruments emitted by `@suaveplan/vault`. It exports a frozen `VaultInstruments` interface plus lazy-singleton accessors (`getVaultInstruments`, `getVaultTracer`) and test-only overrides (`setVaultMeterProvider`, `setVaultTracerProvider`, `resetVaultInstruments`, `resetVaultTracer`).
Every histogram, counter, and span is **safe-by-construction**: no password, no secret key, no AUK, no item content, no vault identifier, no item id, and no key material is permitted in any attribute. Only operation labels, status enums (`status` ∈ documented allowlist), durations in ms, and counts cross the OTel boundary. This guarantees that enabling vault telemetry can never leak vault contents into a tracing backend, even when the backend is misconfigured or compromised.
## Features
- `unlockDurationMs` / `lockDurationMs` / `flushDurationMs` — lifecycle latency histograms with no per-vault attributes.
- `itemOpDurationMs` / `attachmentOpDurationMs` / `shareOpDurationMs` / `syncOpDurationMs` — operation latency histograms keyed by operation kind.
- `lifecycleOutcomeTotal` / `itemOutcomeTotal` / `attachmentOutcomeTotal` / `shareOutcomeTotal` / `syncOutcomeTotal` — outcome counters with `status` allowlist enforced at the call site.
- Lazy-singleton accessors so no OTel cost is paid until the first use.
- `set*Provider` overrides for deterministic in-memory test providers; `reset*` to clear cached state between tests.
## Usage: Basic Example
```ts
import { getVaultInstruments } from "@suaveplan/vault/otel/instruments";
const instruments = getVaultInstruments();
const start = Date.now();
await vault.unlock(password, secretKey);
instruments.unlockDurationMs.record(Date.now() - start);
instruments.lifecycleOutcomeTotal.add(1, { status: "unlocked" });
```
## Usage: Advanced Example
```ts
import { metrics, type MeterProvider } from "@opentelemetry/api";
import {
getVaultInstruments,
resetVaultInstruments,
setVaultMeterProvider,
} from "@suaveplan/vault/otel/instruments";
// Inject an in-memory test provider for assertion-driven tests.
const testProvider: MeterProvider = createInMemoryMeterProvider();
setVaultMeterProvider(testProvider);
try {
const instruments = getVaultInstruments();
instruments.itemOpDurationMs.record(12, { op: "create" });
instruments.itemOutcomeTotal.add(1, { op: "create", status: "success" });
// ... assert against testProvider's recorded data points ...
} finally {
resetVaultInstruments();
setVaultMeterProvider(undefined);
}
```
## API Reference
- `interface VaultInstruments` — frozen shape of the instrument set.
- `getVaultInstruments(): VaultInstruments` — lazy accessor for the cached instrument set.
- `setVaultMeterProvider(provider: MeterProvider | undefined): void` — override the meter provider for tests.
- `resetVaultInstruments(): void` — drop the cached instrument set.
- `getVaultTracer(): Tracer` — lazy accessor for the cached tracer.
- `setVaultTracerProvider(provider: TracerProvider | undefined): void` — override the tracer provider for tests.
- `resetVaultTracer(): void` — drop the cached tracer.
## Implementation Notes
- Histograms are recorded in milliseconds against the OTel `unit: "ms"` convention; consumers can configure histogram buckets globally via the SDK without per-instrument code.
- Counters expose a documented `status` allowlist for each surface (lifecycle: `opened | unlocked | locked | denied`; items: `success | not_found | conflict | rejected`; attachments: `success | not_found | rejected`; shares: `success | not_found | revoked | rejected`; sync: `success | failed | rejected`). Call sites pass a string from this list — adding a new value requires an OpenSpec change.
- Resetting a provider mid-process drops the cached singleton so subsequent `getVaultInstruments()` calls re-acquire from the new provider — used in test isolation only.
- The global OTel API returns no-op instruments when no provider is registered; production code can call `getVaultInstruments()` unconditionally without performance impact when telemetry is disabled.

View File

@@ -0,0 +1,65 @@
# vault Zod schemas
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`schemas.ts` defines Zod schemas for every public boundary type in `@suaveplan/vault`: `ItemFields`, `VaultItem`, `SearchQuery`, `ListOptions`, `Attachment`, `ShareLink`, and the bitpacker-packed `VaultManifest`. Each schema is `strict` — unknown keys are rejected at validation time so that callers can never sneak extra fields through that the orchestration layer would silently drop or misroute into the SQLite schema.
The schemas serve three roles: (1) public-API input validation (`vault.items.create` validates `ItemFields` before encryption); (2) deserialisation guard (loading a snapshot from drive validates each item before re-hydration); and (3) manifest sanity-check before bitpack/unpack. The latter doubles as defence-in-depth: even if a corrupt blob gets past AEAD authentication (it cannot, by construction, but never trust a single layer), the schema fails the load with a typed `VAULT.MANIFEST_INVALID`.
## Features
- `ItemFieldsSchema` / `PartialItemFieldsSchema` / `VaultItemSchema` — strict item shapes with bounded length budgets.
- `SearchQuerySchema` / `ListOptionsSchema` — query options with size caps to prevent pathological FTS5 queries.
- `AttachmentSchema` / `ShareLinkSchema` — record envelopes with bounded filenames / URLs.
- `ManifestArgon2idSchema` / `VaultManifestSchema` — pre-bitpacking sanity-check for the manifest struct, including a 32-byte `vaultIdHash` length refinement.
## Usage: Basic Example
```ts
import { ItemFieldsSchema } from "@suaveplan/vault/schemas/schemas";
const parsed = ItemFieldsSchema.safeParse(input);
if (!parsed.success) {
throw new VaultError(VAULT_CODES["ITEM.VALIDATION_FAILED"], "...");
}
```
## Usage: Advanced Example
```ts
import { VaultManifestSchema } from "@suaveplan/vault/schemas/schemas";
function loadManifest(decoded: unknown) {
const result = VaultManifestSchema.safeParse(decoded);
if (!result.success) {
throw new VaultError(
VAULT_CODES.MANIFEST_INVALID,
"error.vault.manifest.invalid",
{ context: { issues: result.error.issues.length } }
);
}
return result.data;
}
```
## API Reference
- `ItemFieldsSchema` — strict `{ title, category, fields, concealedFields, tags, favorite }`.
- `PartialItemFieldsSchema` — `ItemFieldsSchema.partial()` for updates.
- `VaultItemSchema` — `ItemFieldsSchema` extended with `id`, `createdAt`, `updatedAt`, `version`, `deleted`.
- `SearchQuerySchema` — `{ query?, category?, tags?, favorite?, limit? }` with size caps.
- `ListOptionsSchema` — `{ category?, favorite?, limit?, offset? }`.
- `AttachmentSchema` — `{ id, itemId, filename, mimeType, size, createdAt }`.
- `ShareLinkSchema` — `{ id, itemId, url, expiresAt, maxViews, createdAt }`.
- `ManifestArgon2idSchema` — `{ memoryKB, iterations, parallelism }` with bit-width caps matching the bitpacker manifest schema.
- `VaultManifestSchema` — full manifest validation with 32-byte `vaultIdHash` refinement.
## Implementation Notes
- All schemas use `.strict()` to block unknown keys; this is critical for the manifest where forward-compatibility is handled by the bitpacker `_version` strategy, not by silent passthrough.
- Bit-width caps on numeric manifest fields (e.g. `argon2idMemoryKB ≤ 0xFFFFFFFF`) match the `u32` / `u16` / `u8` widths used by the bitpacker schema in `manifest/manifest.ts`.
- The `VaultManifestSchema.vaultIdHash` refinement asserts 32 bytes — the SHA-256 hash size. The vault layer derives this hash deterministically from `vaultId`; mismatches at load time indicate a corrupt or tampered blob.
- Search query `limit` is hard-capped at 1000 so a misbehaving caller cannot trigger an O(N) FTS5 fetch that monopolises the in-memory SQLite connection.

View File

@@ -0,0 +1,70 @@
# vault search
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`search.ts` implements free-text search across vault items with category / favorite / tag filters. The result projection deliberately strips `concealedFields`, so search results NEVER contain plaintext passwords / PINs / secure notes — even if a caller forgets to do its own filtering.
The implementation is a linear scan over the non-deleted rows; with the schema-enforced caps (1000-row `limit`, 256-char `query`, 32-tag filter) this remains O(N × Q) bounded well under millisecond latency for typical vaults. Future versions may swap in an FTS5 in-memory index without changing the public surface.
## Features
- `search(query) → SearchResult[]` — Zod-validated free-text query with optional `category` / `tags` / `favorite` filters and result limit.
- Scoring weights — title = 4, plaintext fields = 2, tags = 1; ties break by insertion order via stable sort.
- Tag filter is intersection (every tag in `query.tags` must appear on the row).
- Empty `query` string returns every matching row (filtered) — useful for filter-only listing.
- Concealed-field plaintext is structurally excluded from the result type — the projection uses `Omit<VaultItem, "concealedFields">`.
- Every search records a histogram sample and an `op: "search"` outcome counter.
## Usage: Basic Example
```ts
import { VaultSearch } from "@suaveplan/vault/search/search";
const search = new VaultSearch({
rows: () => items.allRows(),
vaultKey,
clock: Date.now,
});
const results = search.search({ query: "github", category: "login" });
for (const item of results) {
console.log(item.title, item.fields.username);
// item.concealedFields is `never` here — type-checked exclusion.
}
```
## Usage: Advanced Example
```ts
import { VaultSearch } from "@suaveplan/vault/search/search";
const search = new VaultSearch({ rows, vaultKey, clock });
// Tag intersection — only items with BOTH "work" AND "dev".
const workDev = search.search({ tags: ["work", "dev"], limit: 50 });
// Favorite-only listing with no text filter.
const favorites = search.search({ favorite: true });
// Empty query, category filter, custom limit.
const allLogins = search.search({ category: "login", limit: 20 });
```
## API Reference
- `class VaultSearch`.
- `constructor(options: SearchOptions)`.
- `search(query: SearchQuery): SearchResult[]` — throws `ITEM.VALIDATION_FAILED` on schema violation.
- `interface SearchOptions` — `{ rows: () => VaultItemRow[], vaultKey, clock }`.
- `type SearchResult = Omit<VaultItem, "concealedFields">` — search projection without concealed plaintext.
## Implementation Notes
- The result projection strips `concealedFields` via destructuring; this guarantees no concealed-field plaintext can flow to a search caller, even if a future code path adds new concealed fields. The `SearchResult` type alias propagates the exclusion to TypeScript consumers.
- Scoring is deterministic and field-weighted; titles dominate so a query like `"github"` ranks `{ title: "GitHub" }` ahead of an item that merely mentions GitHub in a username field. Ties retain insertion order via the stable `Array.prototype.sort`.
- The `rows` callback is invoked on every search so the search index always reflects the latest state. Building a persistent index across multiple searches is intentionally deferred to a future version.
- The injected `vaultKey` is needed by the projection to decrypt nothing — concealed-field values are stripped before decryption — but `rowToItem` still derives the per-item key during projection. This keeps the projection pure and avoids special-casing search inside `rowToItem`.
- The OTel counter uses `op: "search"` — search is treated as a read-side item op for telemetry purposes, sharing the items histogram + counter to keep the metric surface narrow.

View File

@@ -0,0 +1,89 @@
# vault shares
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`shares.ts` implements the share-link orchestration for `vault.shares.createLink` and `vault.shares.revokeLink`. The repository wraps the per-share key envelope under a KEK derived from the vault key + share id, then delegates the actual link creation / revocation / status check to a pluggable `DriveSharingLike` adapter (production wires this to `@suaveplan/drive-sharing`; tests use a stub).
The wrapped envelope keeps share-key plaintext out of `drive-sharing`'s reach: the partition store sees only ciphertext + a 24-byte nonce. Even a fully-compromised drive-sharing service cannot recover the shared item's plaintext without the vault key.
## Features
- `createLink(input) → ShareLink` — wrap the item plaintext, register the link, return metadata.
- `revokeLink(id)` — delegate to drive-sharing; remove local metadata.
- `isActive(id) → boolean` — fast local-metadata short-circuit + drive-sharing canonical check.
- `list()` — snapshot of share metadata.
- Drive failures throw `DRIVE.FAILURE` with the underlying error chained.
- Missing-link reads / revokes throw `SHARE.NOT_FOUND`.
- Per-share KEK derived via `deriveItemKey(vaultKey, "share/<itemId>")` and zeroized after wrap.
- AEAD AAD bound to `"vault/v1/share/<itemId>"` so a wrapped envelope cannot be replayed against a different item.
- OTel histogram + counter per `createLink` / `revokeLink` with `op` and `status` labels.
## Usage: Basic Example
```ts
import { VaultShares } from "@suaveplan/vault/shares/shares";
const shares = new VaultShares({
driveSharing,
partitionId: "vault-abc",
vaultKey,
clock: Date.now,
rng: undefined,
metadata: state.shares,
onMutation: () => scheduleFlush(),
});
const link = await shares.createLink({
itemId: "i1",
itemPlaintext: TextEncoder().encode(JSON.stringify(item)),
expiresAt: Date.now() + 86_400_000,
maxViews: 5,
});
console.log(link.url);
```
## Usage: Advanced Example
```ts
import { VaultShares } from "@suaveplan/vault/shares/shares";
import { VaultError, VAULT_CODES } from "@suaveplan/vault/errors";
// Revoke + verify pattern.
async function safeRevoke(shares: VaultShares, id: string) {
try {
await shares.revokeLink(id);
} catch (err) {
if (
VaultError.is(err) &&
err.code === VAULT_CODES["SHARE.NOT_FOUND"]
) {
return false; // already gone
}
throw err;
}
const stillActive = await shares.isActive(id);
return !stillActive;
}
```
## API Reference
- `class VaultShares`.
- `constructor(options: SharesOptions)`.
- `createLink(input: CreateLinkInput): Promise<ShareLink>` — throws `DRIVE.FAILURE` / `ITEM.VALIDATION_FAILED`.
- `revokeLink(id: string): Promise<void>` — throws `SHARE.NOT_FOUND` / `DRIVE.FAILURE`.
- `isActive(id: string): Promise<boolean>`.
- `list(): ShareLink[]`.
- `interface SharesOptions` — `{ driveSharing, partitionId, vaultKey, clock, rng, metadata, onMutation }`.
- `interface CreateLinkInput` — `{ itemId, itemPlaintext, expiresAt?, maxViews? }`.
## Implementation Notes
- The KEK is derived per-share via `deriveItemKey(vaultKey, "share/<itemId>")`. Using a synthetic key path keeps share KEKs distinct from item keys derived from the same vault key under genuine item ids.
- `wrappedKey` returned by `createLink` to drive-sharing is opaque ciphertext bytes — the adapter never sees the unwrapped per-share key. Even if the adapter is fully compromised, the share recipient's vault-key derivation is required to unwrap the envelope.
- `revokeLink` delegates to `DriveSharingLike.revokeLink` first, then removes local metadata only on success. A drive-sharing failure leaves the local metadata intact so a retry hits the same code path.
- `isActive` short-circuits on missing local metadata before calling the adapter; this avoids spurious network round-trips for already-revoked links and keeps `revokedLink === false` consistent with `metadata.delete`.
- The OTel `outcome` counter uses a fixed allowlist (`success | not_found | rejected`); `revoked` is not an outcome here — once a link is revoked it is gone from local metadata and any further calls fall under `not_found`.

View File

@@ -0,0 +1,74 @@
# vault sync
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`sync.ts` exposes the `VaultSync` orchestrator that delegates `pull` / `push` / `sync` operations to a `VaultSyncLike` adapter (production wires this to `@suaveplan/vault-sync`'s `SyncEngine`). The orchestrator's responsibilities are: (1) project the in-memory item rows into the wire-shaped `SyncSnapshot`, (2) hydrate the delegate with the latest local snapshot before every operation, (3) translate the delegate's `SyncDelegationResult` into the public `SyncResult` envelope, and (4) wrap any delegate failure in a `VAULT.SYNC.FAILED` `VaultError` so consumers see a stable error code regardless of the underlying transport.
This keeps the sync engine generic — it has no knowledge of the vault's encryption layer, item schema, or container format. The orchestrator handles all the impedance matching at the boundary.
## Features
- `pull() → SyncResult` — pull remote changes via the delegate.
- `push() → SyncResult` — push local changes via the delegate.
- `sync() → SyncResult` — bidirectional sync via the delegate.
- `currentSnapshot()` — read-through to the delegate's view.
- Snapshot is built on-demand from `rows()` — the orchestrator never caches a stale snapshot.
- All operations emit a histogram sample and an `op`-tagged outcome counter; failures map to `VAULT.SYNC.FAILED` with the underlying error chained as `cause`.
## Usage: Basic Example
```ts
import { VaultSync } from "@suaveplan/vault/sync/sync";
const sync = new VaultSync({
delegate: vaultSyncEngine,
rows: () => items.allRows(),
clock: Date.now,
});
const result = await sync.sync();
console.log(`merged ${result.merged.length} items`);
```
## Usage: Advanced Example
```ts
import { VaultSync } from "@suaveplan/vault/sync/sync";
import { VaultError, VAULT_CODES } from "@suaveplan/vault/errors";
async function syncWithBackoff(sync: VaultSync, attempts = 3) {
for (let i = 0; i < attempts; i++) {
try {
return await sync.sync();
} catch (err) {
if (
!VaultError.is(err) ||
err.code !== VAULT_CODES["SYNC.FAILED"] ||
i === attempts - 1
) throw err;
await new Promise((r) => setTimeout(r, 100 * 2 ** i));
}
}
throw new Error("unreachable");
}
```
## API Reference
- `class VaultSync`.
- `constructor(options: SyncOptions)`.
- `pull(): Promise<SyncResult>` — throws `SYNC.FAILED`.
- `push(): Promise<SyncResult>` — throws `SYNC.FAILED`.
- `sync(): Promise<SyncResult>` — throws `SYNC.FAILED`.
- `currentSnapshot(): SyncSnapshot`.
- `interface SyncOptions` — `{ delegate, rows, clock }`.
## Implementation Notes
- `buildSnapshot` projects only the wire-essential fields (`id`, `updatedAt`, `version`, `deleted`, `fieldsJson`, `concealedFieldsJson`, `tagsJson`). It deliberately does not decrypt concealed fields — sync sees ciphertext only, end-to-end. The remote service stores ciphertext, the merge layer compares it as opaque bytes, and concealed-field plaintext never leaves the vault layer.
- The orchestrator hydrates the delegate before every operation. This keeps the snapshot correct even if the caller mutates items between successive `pull` / `push` calls.
- `VAULT.SYNC.FAILED` is the canonical wrapping for any error raised from the delegate. Callers can inspect `err.cause` for the underlying reason (transport timeout, merge conflict, payload-too-large, etc.).
- The OTel `outcome` counter uses a fixed allowlist (`success | failed | rejected`); the orchestrator emits `success` and `failed`. The `rejected` slot is reserved for the lifecycle reject path (e.g. `vault.sync.pull` called while locked) which is enforced one layer up in `Vault`.

View File

@@ -0,0 +1,91 @@
# vault adapter interfaces
## Purpose
`adapters.ts` defines the Phase-1 adapter contracts the vault orchestration layer uses to talk to blob storage, share-link partitions, and the sync engine. Each contract is a TypeScript `interface` — not a runtime import — so the vault package compiles and tests in isolation without forcing every consumer to pull `@suaveplan/drive`, `@suaveplan/drive-sharing`, and `@suaveplan/vault-sync` transitively.
The interfaces are deliberately narrower than the underlying packages' full surfaces: vault only needs `read` / `write` / `delete` / `exists` from drive (no streaming, no plugins, no middleware), `createLink` / `revokeLink` / `isActive` from drive-sharing, and `hydrate` / `pull` / `push` / `sync` / `currentSnapshot` from sync. Production wiring lives in a thin adapter outside this package; tests inject plain object literals.
## Features
- `DriveLike` — minimal blob-storage surface (4 methods).
- `DriveSharingLike` — share-link surface plus the wrapped-key envelope schema.
- `VaultSyncLike` — sync delegation surface mirroring the `SyncEngine` public API.
- All adapter operations are `Promise`-returning so consumers can route them through any async transport.
## Usage: Basic Example
```ts
import type { DriveLike } from "@suaveplan/vault/types/adapters";
const memory = new Map<string, Uint8Array>();
const drive: DriveLike = {
async read(path) {
const blob = memory.get(path);
if (blob === undefined) throw new Error("not found");
return blob;
},
async write(path, data) {
memory.set(path, data);
},
async delete(path) {
return memory.delete(path);
},
async exists(path) {
return memory.has(path);
},
};
```
## Usage: Advanced Example
```ts
import type {
DriveSharingLike,
CreateShareLinkInput,
CreatedShareLink,
} from "@suaveplan/vault/types/adapters";
class HttpsDriveSharing implements DriveSharingLike {
public constructor(private readonly endpoint: string) {}
public async createLink(input: CreateShareLinkInput): Promise<CreatedShareLink> {
const res = await fetch(`${this.endpoint}/links`, {
method: "POST",
body: JSON.stringify({
partitionId: input.partitionId,
itemId: input.itemId,
expiresAt: input.expiresAt,
maxViews: input.maxViews,
wrappedKeyHex: Buffer.from(input.wrappedKey).toString("hex"),
}),
});
return res.json() as Promise<CreatedShareLink>;
}
public async revokeLink(id: string): Promise<void> {
await fetch(`${this.endpoint}/links/${id}`, { method: "DELETE" });
}
public async isActive(id: string): Promise<boolean> {
const res = await fetch(`${this.endpoint}/links/${id}`);
return res.ok;
}
}
```
## API Reference
- `interface DriveLike` — `{ read, write, delete, exists }`.
- `interface DriveSharingLike` — `{ createLink, revokeLink, isActive }`.
- `interface CreateShareLinkInput` — `{ partitionId, itemId, expiresAt, maxViews, wrappedKey }`.
- `interface CreatedShareLink` — `{ id, url }`.
- `interface SyncSnapshot` — `{ items, snapshotAt }`.
- `interface SyncSnapshotItem` — `{ id, updatedAt, version, deleted, fieldsJson, concealedFieldsJson, tagsJson }`.
- `interface SyncDelegationResult` — `{ merged, conflicts, deleted, durationMs }`.
- `interface VaultSyncLike` — `{ hydrate, pull, push, sync, currentSnapshot }`.
## Implementation Notes
- `DriveLike.write` is contracted as atomic (either replace fully or fail); the vault layer relies on this property when flushing the encrypted SQLite container so a partial write does not corrupt the manifest header.
- `wrappedKey` in `CreateShareLinkInput` is opaque bytes — vault wraps the per-share key with an out-of-band KEK before passing it to drive-sharing. Drive-sharing never sees plaintext key material.
- `SyncSnapshotItem` carries pre-serialised JSON for `fields` / `concealedFields` / `tags`; this keeps the wire shape stable while allowing vault to evolve its in-memory item representation without breaking sync.
- All methods return `Promise` so adapter implementations can be backed by any transport — local filesystem, memory store, IndexedDB, HTTP, gRPC, etc.

View File

@@ -0,0 +1,81 @@
# vault types
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`types.ts` declares the orchestration-level types consumed by every vault module: the lifecycle state union, vault configuration shape, item / attachment / share / sync result shapes, search-query options, and the manifest-header struct. Keeping these in a dedicated `.ts` file decouples them from the runtime modules — sibling modules import only from here, and tests can stub adapters without pulling in the full vault implementation.
The types are mirrors of the upstream Vaulted password-manager domain shapes adjusted for suaveplan: `ItemFields` strictly separates `fields` (searchable plaintext) from `concealedFields` (passwords / PINs / secure notes that are field-level encrypted). `VaultManifest` matches the bitpacker-packed header layout in `manifest/manifest.ts`.
## Features
- `VaultState` — `"locked" | "unlocked"` lifecycle discriminator.
- `VaultConfig` — full open-time configuration including injectable `DriveLike` / `DriveSharingLike` / `VaultSyncLike` adapters, Argon2id parameters, and clock / RNG injection.
- `ItemFields` / `VaultItem` — pre-encryption and persisted forms. `VaultItem` adds `id`, `createdAt`, `updatedAt`, `version`, and `deleted`.
- `SearchQuery` / `ListOptions` — read-side query options.
- `Attachment` / `ShareLink` / `SyncResult` — operation result envelopes.
- `VaultManifest` — bitpacker-packed header struct embedded at the start of the encrypted vault blob.
## Usage: Basic Example
```ts
import type { VaultConfig, VaultItem } from "@suaveplan/vault/types/types";
const config: VaultConfig = {
vaultId: "personal",
email: "user@example.com",
containerPath: "/vault.bin",
attachmentsPrefix: "/attachments",
argon2id: { memoryKB: 65536, iterations: 3, parallelism: 4, outputLength: 32 },
argon2idSalt: crypto.getRandomValues(new Uint8Array(16)),
drive,
driveSharing,
sync,
};
```
## Usage: Advanced Example
```ts
import type { ItemFields, VaultItem, SearchQuery } from "@suaveplan/vault/types/types";
function buildLogin(domain: string, username: string, password: string): ItemFields {
return {
title: domain,
category: "login",
fields: { username, url: `https://${domain}` },
concealedFields: { password },
tags: ["work"],
favorite: false,
};
}
const query: SearchQuery = {
query: "github",
category: "login",
tags: ["work"],
limit: 10,
};
```
## API Reference
- `type VaultState = "locked" | "unlocked"`
- `interface VaultConfig` — see source.
- `interface ItemFields` — pre-encryption item shape.
- `interface VaultItem extends ItemFields` — persisted shape with `id`, `createdAt`, `updatedAt`, `version`, `deleted`.
- `interface SearchQuery` — `{ query?, category?, tags?, favorite?, limit? }`.
- `interface ListOptions` — `{ category?, favorite?, limit?, offset? }`.
- `interface Attachment` — `{ id, itemId, filename, mimeType, size, createdAt }`.
- `interface ShareLink` — `{ id, itemId, url, expiresAt, maxViews, createdAt }`.
- `interface SyncResult` — `{ merged, conflicts, deleted, durationMs }`.
- `interface VaultManifest` — bitpacker header struct.
## Implementation Notes
- `concealedFields` is a plaintext map at the API boundary; the vault layer encrypts each value with the per-item key before persistence and decrypts lazily on read. This keeps the public surface ergonomic while preserving the defence-in-depth boundary inside the SQLite blob.
- `argon2idSalt` is a `Uint8Array` (not `string` / hex) because the manifest layer packs it directly as bytes; conversion to / from hex is the caller's responsibility.
- `VaultManifest.vaultIdHash` is a 32-byte SHA-256 of the `vaultId` — embedded in the manifest so the AAD bound to the AEAD ciphertext includes a stable, non-secret tag identifying the vault.
- `formatVersion`, `kdfId`, `aeadAlgorithm`, `schemaVersion` are integers; the manifest layer constrains them to specific bit widths for forward compatibility.

View File

@@ -0,0 +1,77 @@
# vault lifecycle helpers
## Purpose
`lifecycle.ts` extracts the two side-effecting helpers that drive the unlock path: `deriveVaultKeyBuffer` (running 2SKD + key-hierarchy derivation to produce a `SecureBuffer` holding the vault key) and `loadOrCreateContainer` (reading the encrypted blob from drive, or — if missing — creating a fresh container and flushing it back). Splitting them into a sibling file keeps `vault.ts` under the suaveplan 300-line cap while preserving a single-class orchestrator.
The helpers are deliberately self-contained: they take inputs, return outputs, and rely only on the public surfaces of `crypto-vault`, `crypto-utils`, and the local `container/` and `items/migrations/` modules. They do not touch the lifecycle state machine; the caller (the `Vault` class) is responsible for ordering the calls correctly and zeroizing the returned buffer on lock.
## Features
- `deriveVaultKeyBuffer({ password, secretKey, email, salt, params, vaultId, rng? })` — full 2SKD + personal-keyset + vault-key derivation, returning a `SecureBuffer`.
- `loadOrCreateContainer({ config, buffer, now, rng? })` — drive read + decrypt, or pristine create + flush; runs head migrations.
- Intermediate AUK / personal keyset / vault-key buffers are zeroized before return — only the wrapped `SecureBuffer` survives.
- Migrations are applied in lockstep with the load so the returned state is at the head schema version.
## Usage: Basic Example
```ts
import { deriveVaultKeyBuffer, loadOrCreateContainer } from "@suaveplan/vault/vault/lifecycle";
const buffer = await deriveVaultKeyBuffer({
password,
secretKey,
email: "user@example.com",
salt: argon2idSalt,
params: { memoryKB: 65536, iterations: 3, parallelism: 4, outputLength: 32 },
vaultId: "personal",
});
const { manifest, state } = await loadOrCreateContainer({
config,
buffer,
now: () => Date.now(),
});
```
## Usage: Advanced Example
```ts
import { deriveVaultKeyBuffer, loadOrCreateContainer } from "@suaveplan/vault/vault/lifecycle";
import { wrap } from "@suaveplan/crypto-vault";
// Inject a deterministic RNG for testing.
const buffer = await deriveVaultKeyBuffer({
password: "p",
secretKey: "s",
email: "u@e.com",
salt: new Uint8Array(16),
params,
vaultId: "v",
rng: deterministicRng,
});
// Wrap as an opaque handle before crossing into the orchestrator.
const handle = wrap(buffer);
const result = await loadOrCreateContainer({
config,
buffer,
now: () => 1_700_000_000_000,
rng: deterministicRng,
});
```
## API Reference
- `deriveVaultKeyBuffer(input: DeriveVaultKeyInput): Promise<SecureBuffer>`.
- `loadOrCreateContainer(input: LoadOrCreateInput): Promise<LoadOrCreateResult>`.
- `interface DeriveVaultKeyInput` — `{ password, secretKey, email, salt, params, vaultId, rng? }`.
- `interface LoadOrCreateInput` — `{ config, buffer, now, rng? }`.
- `interface LoadOrCreateResult` — `{ manifest, state }`.
## Implementation Notes
- The AUK derived inside `deriveVaultKeyBuffer` is zeroized immediately after the personal keyset is produced. The personal keyset's three keys are also zeroized after deriving the vault key. Only the vault key survives, wrapped in a fresh `SecureBuffer`.
- `loadOrCreateContainer` always returns a state at the head schema version. New migrations added to `VAULT_MIGRATIONS` are applied transparently on the next unlock — old containers self-upgrade without explicit caller intervention.
- The `now` callback is invoked at most twice during a fresh-container creation (once for `createEmptyContainer`, once for `saveContainer`); production passes `Date.now`. Tests can pin a fixed timestamp through this hook.
- A first-time unlock (drive blob missing) writes the freshly-encrypted blob back to drive before returning. This guarantees a successful unlock leaves drive in a valid state — the next read will decrypt against the same salt and Argon2id parameters.

View File

@@ -0,0 +1,103 @@
# Vault
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs --> - wiring — `wiring.ts` extracts the per-unlock module…
- [wiring](./wiring.md) — `wiring.ts` extracts the per-unlock module factories used by `Vault`: `buildItems`, `buildSearch`, `buildAttachments`,…
- wiring — `wiring.ts` extracts the per-unlock module…
- [wiring](./wiring.md) — `wiring.ts` extracts the per-unlock module factories used by `Vault`: `buildItems`, `buildSearch`, `buildAttachments`,…
- wiring — `wiring.ts` extracts the per-unlock module…
- [wiring](./wiring.md) — `wiring.ts` extracts the per-unlock module factories used by `Vault`: `buildItems`, `buildSearch`, `buildAttachments`,…
- wiring — `wiring.ts` extracts the per-unlock module…
- [wiring](./wiring.md) — `wiring.ts` extracts the per-unlock module factories used by `Vault`: `buildItems`, `buildSearch`, `buildAttachments`,…
- [wiring](./wiring.md) — `wiring.ts` extracts the per-unlock module factories used by `Vault`: `buildItems`, `buildSearch`, `buildAttachments`,…
## Purpose
`vault.ts` exposes the `Vault` class — the public orchestration entry point for `@suaveplan/vault`. It owns the lifecycle state machine (`locked` ↔ `unlocked`), the in-memory container state once unlocked, the `OpaqueKeyHandle` to the vault key (sourced from `@suaveplan/crypto-vault`), and the per-unlock module facades (`VaultItems`, `VaultSearch`, `VaultAttachments`, `VaultShares`, `VaultSync`).
Every public read/write surface routes through `requireUnlocked` so a locked vault rejects every CRUD / sync call with `VAULT.LOCKED`. Mutations notify the flush scheduler; `lock()` drains pending flushes, zeroizes every key buffer via `crypto-vault`'s `zeroizeAll`, and resets the orchestrator into a clean locked state.
## Features
- `Vault.open(config)` — construct a locked vault. Does not touch drive.
- `unlock(password, secretKey)` — derive vault key via 2SKD + key hierarchy, decrypt the drive blob (or create+flush a fresh one on first unlock), run pending migrations, and instantiate module facades.
- `lock()` — drain pending flushes, zeroize keys, reset state. Idempotent.
- `isLocked` / `currentState` — readonly state inspection.
- `items` / `search` / `attachments` / `shares` / `sync` getters — return module facades; throw `VAULT.LOCKED` when locked.
- `currentManifest()` — read the active manifest snapshot.
- `flush()` — force a pending flush immediately (skips the debounce window).
- `VAULT_HEAD_SCHEMA_VERSION` — head schema version constant for client compatibility checks.
## Usage: Basic Example
```ts
import { Vault } from "@suaveplan/vault/vault/vault";
const vault = Vault.open({
vaultId: "personal",
email: "user@example.com",
containerPath: "/personal.vault",
attachmentsPrefix: "/personal/attachments",
argon2id: { memoryKB: 65536, iterations: 3, parallelism: 4, outputLength: 32 },
argon2idSalt: salt,
drive,
driveSharing,
sync: syncEngine,
});
await vault.unlock(password, secretKey);
const item = vault.items.create({ title: "GitHub", category: "login", fields: { username: "u" }, concealedFields: { password: "p" }, tags: [], favorite: false });
await vault.lock();
```
## Usage: Advanced Example
```ts
import { Vault } from "@suaveplan/vault/vault/vault";
import { VaultError, VAULT_CODES } from "@suaveplan/vault/errors";
const vault = Vault.open(config);
try {
await vault.unlock(password, secretKey);
} catch (err) {
if (VaultError.is(err) && err.code === VAULT_CODES.INVALID_CREDENTIALS) {
return promptForPassword();
}
throw err;
}
vault.items.create(item1);
vault.items.create(item2);
await vault.flush(); // force flush before lock
await vault.lock(); // zeroizes keys
// Locked vault rejects every operation.
expect(() => vault.items.list()).toThrow();
```
## API Reference
- `class Vault`.
- `static open(config: VaultConfig): Vault` — construct locked.
- `unlock(password, secretKey): Promise<void>` — throws `ALREADY_OPEN` / `INVALID_CREDENTIALS` / propagates `MANIFEST_INVALID`.
- `lock(): Promise<void>` — idempotent.
- `flush(): Promise<void>` — force flush; throws `LOCKED` if locked.
- `get isLocked: boolean` / `get currentState: VaultState`.
- `get items: VaultItems` / `get search: VaultSearch` / `get attachments: VaultAttachments` / `get shares: VaultShares` / `get sync: VaultSync` — all throw `LOCKED` when locked.
- `currentManifest(): VaultManifest` — throws `LOCKED` when locked.
- `VAULT_HEAD_SCHEMA_VERSION: number` — current head schema version.
## Implementation Notes
- The vault key never leaves the `SecureBuffer` envelope wrapped in an `OpaqueKeyHandle`. Every per-call access goes through `unwrap(handle)` and obtains the same backing buffer; on `lock()`, `zeroizeAll()` from `crypto-vault` walks the live-buffer registry and overwrites every key with zero bytes.
- Mutation callbacks from items / attachments / shares schedule a debounced flush (default 500 ms; configurable via `VaultConfig.flushDebounceMs`). The flush re-encrypts the container blob (manifest as AAD) and writes it back atomically through `DriveLike.write`.
- `unlock` is **not** idempotent — calling it twice while unlocked throws `VAULT.ALREADY_OPEN`. This is deliberate: a re-unlock with stale credentials must fail loudly rather than silently re-derive against a different vault key.
- `lock()` is idempotent. A second call on an already-locked vault is a no-op (records a `locked` lifecycle outcome and returns).
- Failure during the unlock decryption path closes the `SecureBuffer`, drops the handle, and throws `VAULT.INVALID_CREDENTIALS` — even if the underlying error was a manifest issue, the wrong vault key was the cause from the user's perspective. `VaultError`s thrown from inside the load (e.g. a corrupt manifest) propagate unchanged so callers can distinguish corruption from credential mismatch.
- The OTel `lifecycleOutcomeTotal` counter uses a fixed allowlist (`opened | unlocked | locked | denied`); adding new statuses requires an OpenSpec change in lockstep with `getVaultInstruments`.

View File

@@ -0,0 +1,73 @@
# vault wiring
## Purpose
`wiring.ts` extracts the per-unlock module factories used by `Vault`: `buildItems`, `buildSearch`, `buildAttachments`, `buildShares`, `buildSync`, plus the `RuntimeIndex` and `WiringInputs` shapes. Splitting these helpers into a sibling file keeps `vault.ts` under the suaveplan 300-line cap while preserving an idiomatic single-file orchestrator surface — `vault.ts` deals with the lifecycle state machine, and `wiring.ts` deals with module construction.
The factories are pure: they read live state via the `vaultKey()` callback (which reaches through the `OpaqueKeyHandle`) and instantiate the corresponding module class with the same flush-mutation callback every time. The orchestrator calls them once per public-getter access; module instances are short-lived to keep stale-state hazards minimal.
## Features
- `buildItems(inputs)` — `VaultItems` over the current container state.
- `buildSearch(inputs)` — `VaultSearch` whose `rows` callback reads live state.
- `buildAttachments(inputs)` — `VaultAttachments` bound to drive + runtime metadata.
- `buildShares(inputs)` — `VaultShares` bound to drive-sharing + runtime metadata.
- `buildSync(inputs)` — `VaultSync` bound to the configured sync delegate.
- `freshRuntimeIndex()` — allocate empty maps for attachments + shares metadata.
- `RuntimeIndex` / `WiringInputs` shapes for module-internal threading.
## Usage: Basic Example
```ts
import { buildItems, freshRuntimeIndex } from "@suaveplan/vault/vault/wiring";
const inputs = {
config,
state,
runtime: freshRuntimeIndex(),
vaultKey: () => buffer.bytes,
clock: Date.now,
rng: undefined,
onMutation: () => scheduleFlush(),
};
const items = buildItems(inputs);
items.create({ ... });
```
## Usage: Advanced Example
```ts
import {
buildAttachments,
buildShares,
buildSync,
} from "@suaveplan/vault/vault/wiring";
// Wire every module in lockstep with the same vaultKey + flush hook.
const attachments = buildAttachments(inputs);
const shares = buildShares(inputs);
const sync = buildSync(inputs);
await attachments.upload({ itemId, filename, mimeType, data });
await shares.createLink({ itemId, itemPlaintext, expiresAt: null, maxViews: null });
await sync.sync();
```
## API Reference
- `interface RuntimeIndex` — `{ attachments: Map, shares: Map }`.
- `interface WiringInputs` — `{ config, state, runtime, vaultKey, clock, rng, onMutation }`.
- `buildItems(inputs: WiringInputs): VaultItems`.
- `buildSearch(inputs: WiringInputs): VaultSearch`.
- `buildAttachments(inputs: WiringInputs): VaultAttachments`.
- `buildShares(inputs: WiringInputs): VaultShares`.
- `buildSync(inputs: WiringInputs): VaultSync`.
- `freshRuntimeIndex(): RuntimeIndex`.
## Implementation Notes
- The `vaultKey` callback returns a fresh `Uint8Array` view of the underlying `SecureBuffer`. Because `Vault` resolves the buffer on every access via `unwrap(handle)`, even a transient mid-operation re-key will be picked up on the next module access without rebuilding the orchestrator.
- `WiringInputs.onMutation` is the same flush-debouncing closure for every module — every successful create / update / delete / upload / share-create / share-revoke triggers the same scheduler in `vault.ts`.
- Module instances are deliberately built fresh on every getter access in `vault.ts`. This keeps the lifecycle simple — a future lock / unlock cycle replaces `state` and `runtime` and the next getter access sees the new objects automatically.
- The factory functions are pure with respect to the inputs: the same inputs always produce a module observing the same state, so consumers can safely cache the returned module within a single tick if the per-call cost matters.