Move docs/ package mirror into document-navigator plugin
Relocate the ~6k-file docs tree from the repo root into plugins/document-navigator/docs/ so the plugin is self-contained and portable. docs/superpowers/ (specs and plans) stays at the repo root. Moved via cp+rm rather than git mv because directory rename() fails with EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the identical-content files at commit time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user