@tinytars/vault 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tiny Tars Foundation
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,271 @@
1
+ # `@tinytars/vault`
2
+
3
+ [![Community Health](https://img.shields.io/badge/dynamic/json?url=https://api.github.com/repos/tinytars/vault/community/profile&query=$.health_percentage&suffix=%25&label=community%20health)](https://github.com/tinytars/vault/community)
4
+
5
+ A storage operator who can read what it stores hasn't encrypted the data — it's obfuscated it,
6
+ and most hand-rolled "encrypted vault" designs end up exactly there, one convenience shortcut at a
7
+ time. This package is the alternative already built: a shared key-derivation primitive, an
8
+ authenticated envelope format with multi-principal access via wrapped data-encryption keys, and
9
+ storage-agnostic contracts for the account/vault/grant data model those envelopes sit on top of.
10
+
11
+ Built for and extracted from a health-records app that needed patient-controlled encryption plus
12
+ revocable provider access; published because the primitives don't have anything health-specific
13
+ in them. Maintained by the [Tiny Tars Foundation](https://tinytars.foundation), a 501(c)(3).
14
+
15
+ ## Why
16
+
17
+ ### Built for a HIPAA-adjacent app
18
+
19
+ The pattern such an app needs is exactly what ships here, not something you'd assemble from
20
+ parts. `crypto.ts` gives you a zero-knowledge vault — the storage operator holds ciphertext and
21
+ never a key or usable plaintext. `envelope-access.ts` gives you consent-based sharing — access to
22
+ a vault is a per-principal, revocable wrapped-key grant, not a shared secret or a role flag.
23
+ `break-glass.ts` gives you the third piece that's easy to get wrong by hand: a time-boxed grant
24
+ with the TTL clamp, self-expiry, and audit trail built in, so "temporary access" is actually
25
+ temporary instead of a support ticket someone forgets to close. Put together, that's the
26
+ "encrypted vault + consent-based sharing + time-boxed break-glass access" shape any HIPAA-adjacent
27
+ app ends up needing — and this package ships all three, not a subset with the rest left as an
28
+ exercise. None of that makes the package itself HIPAA-compliant; it's a primitive an adopter
29
+ builds compliant handling on top of, not a compliance product in its own right.
30
+
31
+ ### Not health-specific
32
+
33
+ The storage contracts in `stores.ts` are generic data-access interfaces; the payload `crypto.ts`
34
+ encrypts is an arbitrary JSON value, not a medical-record shape. Even `ProviderLinkStore`'s
35
+ "provider"/"patient" field names are just this package's first adopter's vocabulary —
36
+ `ARCHITECTURE.md` says to read it generically as "grantee linked to vault owner," and nothing in
37
+ the access-policy logic cares what a principal represents. The same three pieces above fit a
38
+ client's encrypted files shared with revocable access for outside counsel, or a household's
39
+ financial records shared temporarily with an accountant at tax time — any case with an owner, a
40
+ resource only they can decrypt by default, and a need to grant and later cut off someone else's
41
+ access to it.
42
+
43
+ Claims like these are only worth as much as the threat model backing them. **Read
44
+ `THREAT_MODEL.md` before you adopt this** — it documents what's in scope, what isn't, and two
45
+ deliberate design tradeoffs (extractable keys, no forward secrecy on revoke) that look like bugs
46
+ if you haven't read them first. Notice, while you're there, that it's written in fully generic
47
+ terms too — no "patient," no "provider," no health-specific language anywhere in it. That's not
48
+ an oversight; it's the same evidence the argument above rests on, stated a second way.
49
+
50
+ ## What it does
51
+
52
+ Five pieces, each independently usable — nothing here requires adopting all five.
53
+
54
+ ```
55
+ your app
56
+ |
57
+ | imports core modules only
58
+ v
59
+ --------------------------------------------------------------
60
+ @tinytars/vault (core) -- zero Cloudflare/platform imports
61
+
62
+ crypto.ts + kdf.ts zero-knowledge envelope encryption
63
+ envelope-access.ts consent-based access sharing
64
+ break-glass.ts time-boxed access grants
65
+ stores.ts storage-agnostic contracts
66
+ --------------------------------------------------------------
67
+ | implements stores.ts's five contracts
68
+ v
69
+ --------------------------------------------------------------
70
+ adapters/* (optional, subpath exports)
71
+ d1 . r2 . memory . pages-http . conformance
72
+ --------------------------------------------------------------
73
+ ```
74
+
75
+ ### Zero-knowledge vault encryption
76
+
77
+ `crypto.ts` + `kdf.ts`: an authenticated envelope format (HD1) where the storage operator holds
78
+ ciphertext and never a key or usable plaintext. Two versions — v1 is passphrase-only; v2 separates
79
+ encrypting the data from granting access to it, via per-principal wrapped data-encryption keys.
80
+ Full byte layout and the tradeoffs behind it:
81
+ [`ARCHITECTURE.md` § Envelope format](ARCHITECTURE.md#envelope-format-cryptots).
82
+
83
+ ### Consent-based access sharing
84
+
85
+ `envelope-access.ts`: access to a vault is a per-principal, revocable wrapped-key grant, composed
86
+ from storage-agnostic contracts rather than baked into either one — and it closes a specific bug
87
+ class along the way, expiry checked lazily per-route instead of once, centrally. This is one of
88
+ two forms a grant takes — see [`ARCHITECTURE.md` § Principal
89
+ model](ARCHITECTURE.md#principal-model) for both. Details:
90
+ [`ARCHITECTURE.md` § Access policy](ARCHITECTURE.md#access-policy-envelope-accessts).
91
+
92
+ ### Time-boxed break-glass access
93
+
94
+ `break-glass.ts`: temporary access that's actually temporary — a TTL clamp, self-expiry on next
95
+ check rather than on a timer, idempotent revoke, and an audit trail, so an exception doesn't
96
+ quietly become a standing grant nobody remembers to close. It's the time-boxed form of the same
97
+ grant described above, not a separate mechanism — see [`ARCHITECTURE.md` § Principal
98
+ model](ARCHITECTURE.md#principal-model). Details:
99
+ [`ARCHITECTURE.md` § Time-boxed access grants](ARCHITECTURE.md#time-boxed-access-grants-break-glassts).
100
+
101
+ ### Storage-agnostic contracts
102
+
103
+ `stores.ts` + `blob-store.ts`: five plain data-access interfaces — accounts, credentials,
104
+ envelopes, grants, audit log — plus a generic conditional-write blob interface, none of them
105
+ carrying crypto or policy. An adapter (D1, Postgres, an in-memory map) implements these against
106
+ whatever it stores rows in; the crypto and access-policy layers above never know which one is
107
+ underneath. Details:
108
+ [`ARCHITECTURE.md` § Storage contracts](ARCHITECTURE.md#storage-contracts-storests).
109
+
110
+ ### Platform-independent adapters
111
+
112
+ `adapters/*`: everything above — `crypto.ts`, `kdf.ts`, `stores.ts`, `envelope-access.ts`,
113
+ `break-glass.ts`, and the rest — has zero Cloudflare (or any other platform) imports. An adopter
114
+ who never imports `@tinytars/vault/adapters/*` never links against Cloudflare's types at all —
115
+ that's the actual mechanism behind "platform-independent," not a claim about intent. Five adapters
116
+ ship as optional subpath exports for adopters who do want one: `adapters/d1` and `adapters/r2`
117
+ (Cloudflare), `adapters/memory` (the portability proof — the same conformance suite that passes
118
+ against the D1 adapter passes against it, unmodified), and `adapters/pages-http` (a five-line
119
+ wrapper, not a rewrite, for Cloudflare Pages Functions' request shape). Full picture:
120
+ [`ARCHITECTURE.md` § Adapters](ARCHITECTURE.md#adapters).
121
+
122
+ ## Install
123
+
124
+ ```
125
+ npm install @tinytars/vault
126
+ ```
127
+
128
+ Requires an environment with `SubtleCrypto` (`globalThis.crypto.subtle`) — every modern browser,
129
+ Node 19+, Cloudflare Workers, Deno.
130
+
131
+ ## Quick start: encrypt, wrap, unwrap (Node, no Cloudflare)
132
+
133
+ This runs in plain Node 19+ — no Workers runtime, no Hono required for the crypto itself, no
134
+ `node:crypto` import either (`generateAccountKeypair`/`encryptVaultV2`/etc. read
135
+ `globalThis.crypto.subtle`, which Node provides natively). It shows the full v2 flow: generate an
136
+ account keypair, encrypt a payload under a fresh DEK, wrap that DEK for the account, then unwrap
137
+ and decrypt.
138
+
139
+ ```ts
140
+ import {
141
+ generateAccountKeypair,
142
+ generateDEK,
143
+ encryptVaultV2,
144
+ wrapDEKForPublicKey,
145
+ unwrapDEKWithPrivateKey,
146
+ decryptVaultV2,
147
+ } from "@tinytars/vault/crypto";
148
+
149
+ // One-time: the account's long-term keypair. In a real app the private key
150
+ // is wrapped under a KEK (see ARCHITECTURE.md) and never held like this.
151
+ const { publicKeyJwk, privateKey } = await generateAccountKeypair();
152
+
153
+ // Encrypt a payload (any JSON-serializable value) under a fresh, random DEK.
154
+ const dek = await generateDEK();
155
+ const envelope = await encryptVaultV2({ note: "patient-controlled data" }, dek);
156
+
157
+ // Grant this account access by wrapping the DEK for its public key.
158
+ const wrapped = await wrapDEKForPublicKey(dek, publicKeyJwk);
159
+
160
+ // Later, as that account: unwrap the DEK and decrypt the envelope.
161
+ const recoveredDek = await unwrapDEKWithPrivateKey(wrapped.wrappedDEK, wrapped.ephemeralPublicKeyJwk, privateKey);
162
+ const plaintext = await decryptVaultV2(envelope, recoveredDek);
163
+
164
+ console.log(plaintext); // { note: "patient-controlled data" }
165
+ ```
166
+
167
+ This package ships TypeScript source directly (no compiled `dist/`) — the subpath imports above
168
+ resolve via `package.json`'s `exports` map to the `.ts` files, which works out of the box with a
169
+ bundler or TS-aware runtime (Vite, esbuild, `tsx`, Deno). Plain `node file.js` without a TS loader
170
+ won't resolve them; a compiled build is a tracked follow-up, not yet done.
171
+
172
+ ## Quick start: a minimal Hono route backed by an in-memory store
173
+
174
+ `resolveEnvelopeAccess` only needs the two narrow slices of your storage it actually reads —
175
+ `EnvelopeAccessSource` (2 methods) and `ProviderLinkSource` (1 method), both exported from
176
+ `envelope-access.ts` — not the full `EnvelopeStore`/`ProviderLinkStore` contracts from `stores.ts`.
177
+ Swap the in-memory `Map`s below for a real adapter (a D1 client, Postgres, whatever you have) and
178
+ the route is unchanged.
179
+
180
+ ```ts
181
+ import { Hono } from "hono";
182
+ import { resolveEnvelopeAccess } from "@tinytars/vault/envelope-access";
183
+ import type { EnvelopeAccessSource, ProviderLinkSource } from "@tinytars/vault/envelope-access";
184
+ import type { Envelope, ProviderLink, VaultRow } from "@tinytars/vault/stores";
185
+
186
+ const envelopes = new Map<string, Envelope>(); // key: `${vaultId}:${principalAccountId}`
187
+ const vaults = new Map<string, VaultRow>();
188
+ const providerLinks = new Map<string, ProviderLink>(); // key: `${patientAccountId}:${providerAccountId}`
189
+
190
+ const envelopeSource: EnvelopeAccessSource = {
191
+ async getEnvelopeRow(vaultId, principalAccountId) {
192
+ return envelopes.get(`${vaultId}:${principalAccountId}`) ?? null;
193
+ },
194
+ async getVault(vaultId) {
195
+ return vaults.get(vaultId) ?? null;
196
+ },
197
+ };
198
+
199
+ const providerLinkSource: ProviderLinkSource = {
200
+ async getActive(patientAccountId, providerAccountId) {
201
+ return providerLinks.get(`${patientAccountId}:${providerAccountId}`) ?? null;
202
+ },
203
+ };
204
+
205
+ // No "org recovery account" concept in this example — pass a value nothing will ever match.
206
+ const ORG_ACCOUNT_ID = "__none__";
207
+
208
+ const app = new Hono();
209
+
210
+ app.get("/vaults/:id", async (c) => {
211
+ const principalAccountId = c.get("accountId"); // set by your own auth middleware
212
+ const vaultId = c.req.param("id");
213
+
214
+ const envelope = await resolveEnvelopeAccess(
215
+ envelopeSource,
216
+ providerLinkSource,
217
+ vaultId,
218
+ principalAccountId,
219
+ ORG_ACCOUNT_ID,
220
+ );
221
+ if (!envelope) return c.json({ error: "forbidden" }, 403);
222
+
223
+ return c.json({ envelope });
224
+ });
225
+
226
+ export default app;
227
+ ```
228
+
229
+ `resolveEnvelopeAccess` never touches HTTP or session state itself — `c.get("accountId")` above
230
+ is entirely your own auth middleware's job. See `THREAT_MODEL.md`'s "trust boundaries" section.
231
+
232
+ ## What's here
233
+
234
+ | Module | What it does |
235
+ |---|---|
236
+ | `kdf.ts` | Shared PBKDF2-SHA256 key derivation, runtime-agnostic |
237
+ | `bytes.ts` | One helper: safely turn a `Uint8Array` view into the `ArrayBuffer` WebCrypto wants |
238
+ | `crypto.ts` | HD1 envelope format (v1 passphrase-only, v2 DEK + multi-principal wrap), account keypairs, KEK wrapping, recovery-code grants |
239
+ | `key-store.ts` | Browser-only: persists an account's private key in IndexedDB as non-extractable |
240
+ | `vault-sink.ts` | `VaultSink` interface + a conflict-safe (ETag/If-Match) HTTP `PUT` implementation for saving an encrypted blob |
241
+ | `stores.ts` | Five storage-agnostic contracts: `AccountStore`, `CredentialStore`, `EnvelopeStore`, `ProviderLinkStore`, `AuditStore` |
242
+ | `envelope-access.ts` | `resolveEnvelopeAccess` — the one composed access-policy function built on `stores.ts` |
243
+ | `break-glass.ts` | `grantBreakGlass`/`checkBreakGlass`/`revokeBreakGlass` — time-boxed grants: TTL clamp, expiry self-revoke, idempotent revoke, all audited |
244
+ | `blob-store.ts` | `BlobStore` — a generic, conditional-write ("only if unchanged") interface for storing an opaque encrypted blob by key. Server-side counterpart to `vault-sink.ts`'s browser-side `VaultSink` |
245
+ | `adapters/d1/` | `D1AccountStore`/`D1CredentialStore`/`D1EnvelopeStore`/`D1ProviderLinkStore`/`D1AuditStore` — a Cloudflare D1 implementation of all five `stores.ts` contracts |
246
+ | `adapters/r2.ts` | `R2BlobStore` — a Cloudflare R2 implementation of `BlobStore` |
247
+ | `adapters/memory.ts` | In-memory implementations of all five `stores.ts` contracts — the reference adapter that proves the interfaces are actually storage-agnostic, not just Cloudflare-shaped |
248
+ | `adapters/pages-http.ts` | `pagesHandler()` — wraps a portable `(request, deps) => Promise<Response>` handler into Cloudflare Pages Functions' `onRequestX({request, env, params})` shape |
249
+ | `adapters/conformance.ts` | Shared vitest contract suites for each `stores.ts` interface, run against every adapter above so "storage-agnostic" is proven, not asserted |
250
+
251
+ Full design, including the exact envelope byte layout and why extractable keys are a deliberate
252
+ choice, is in `ARCHITECTURE.md` — see **What it does** above for the featureset summary and the
253
+ platform-independence mechanism behind the adapters.
254
+
255
+ ## Contributing
256
+
257
+ See `CONTRIBUTING.md`. Any change to `kdf.ts` or an envelope format needs a new
258
+ `tests/kdf.test.ts` (or `tests/crypto.test.ts`) case — those are the two things every consumer's
259
+ data durability depends on.
260
+
261
+ ## Tests
262
+
263
+ ```
264
+ npm install
265
+ npm test # vitest, 85 tests across 9 files, real WebCrypto — no mocked crypto
266
+ npm run typecheck
267
+ ```
268
+
269
+ ## License
270
+
271
+ MIT — see `LICENSE`.
@@ -0,0 +1,224 @@
1
+ // Shared contract tests for each stores.ts interface. A caller registers these against any factory —
2
+ // run the same suite against the D1 adapter and the memory adapter and a pass on both is the actual
3
+ // evidence the "storage-agnostic" claim holds, not just an assertion made by interface shape.
4
+ //
5
+ // Each function calls vitest's describe/it itself; a caller imports and invokes it from inside its own
6
+ // test file (vitest collects tests registered this way during the collection phase — the call need not
7
+ // be lexically inside a `describe` block).
8
+
9
+ import { describe, it, expect } from "vitest";
10
+ import type { AccountStore, AuditStore, CredentialStore, EnvelopeStore, ProviderLinkStore } from "../stores";
11
+
12
+ export function runAccountStoreConformance(label: string, factory: () => AccountStore | Promise<AccountStore>) {
13
+ describe(`AccountStore conformance (${label})`, () => {
14
+ it("creates and reads an account back by id and by email", async () => {
15
+ const store = await factory();
16
+ const created = await store.create({ id: "acct-1", displayName: "Ada", email: "ada@example.com" });
17
+ expect(created.emailConfirmed).toBe(false);
18
+ expect(created.lifecycleStage).toBe("active");
19
+
20
+ const byId = await store.get("acct-1");
21
+ expect(byId).toEqual(created);
22
+
23
+ const byEmail = await store.getByEmail("ada@example.com");
24
+ expect(byEmail).toEqual(created);
25
+ });
26
+
27
+ it("returns null for an account that does not exist", async () => {
28
+ const store = await factory();
29
+ expect(await store.get("nope")).toBeNull();
30
+ expect(await store.getByEmail("nope@example.com")).toBeNull();
31
+ });
32
+
33
+ it("sessionsValidFrom is +Infinity for a deleted or never-existing account", async () => {
34
+ const store = await factory();
35
+ expect(await store.sessionsValidFrom("nope")).toBe(Number.POSITIVE_INFINITY);
36
+ });
37
+
38
+ it("sessionsValidFrom is null until revokeSessions is called, then a recent timestamp", async () => {
39
+ const store = await factory();
40
+ await store.create({ id: "acct-2", displayName: "Bea" });
41
+ expect(await store.sessionsValidFrom("acct-2")).toBeNull();
42
+
43
+ const before = Math.floor(Date.now() / 1000);
44
+ await store.revokeSessions("acct-2");
45
+ const validFrom = await store.sessionsValidFrom("acct-2");
46
+ expect(validFrom).toBeGreaterThanOrEqual(before);
47
+ });
48
+
49
+ it("updateProfile only touches the fields provided", async () => {
50
+ const store = await factory();
51
+ await store.create({ id: "acct-3", displayName: "Cy", email: "cy@example.com" });
52
+ await store.updateProfile("acct-3", { displayName: "Cy Renamed" });
53
+ const after = await store.get("acct-3");
54
+ expect(after?.displayName).toBe("Cy Renamed");
55
+ expect(after?.email).toBe("cy@example.com");
56
+ });
57
+
58
+ it("tombstone nulls personal fields and stamps deletedAt", async () => {
59
+ const store = await factory();
60
+ await store.create({ id: "acct-4", displayName: "Dee", email: "dee@example.com" });
61
+ await store.tombstone("acct-4", "2026-01-01T00:00:00.000Z");
62
+ const after = await store.get("acct-4");
63
+ expect(after?.email).toBeNull();
64
+ expect(after?.displayName).toBe("");
65
+ expect(after?.lifecycleStage).toBe("churned");
66
+ expect(after?.deletedAt).toBe("2026-01-01T00:00:00.000Z");
67
+ });
68
+ });
69
+ }
70
+
71
+ export function runCredentialStoreConformance(label: string, factory: () => CredentialStore | Promise<CredentialStore>) {
72
+ describe(`CredentialStore conformance (${label})`, () => {
73
+ it("adds an identity and finds it by provider subject", async () => {
74
+ const store = await factory();
75
+ await store.addIdentity({ accountId: "acct-1", method: "google", providerSubject: "sub-1" });
76
+ const found = await store.getIdentityByProviderSubject("google", "sub-1");
77
+ expect(found?.accountId).toBe("acct-1");
78
+ });
79
+
80
+ it("returns null for an unknown identity lookup", async () => {
81
+ const store = await factory();
82
+ expect(await store.getIdentityByProviderSubject("google", "nope")).toBeNull();
83
+ expect(await store.getIdentityByCredentialId("nope")).toBeNull();
84
+ });
85
+
86
+ it("round-trips a credential and omits it from listCredentials' PHI-free view once deleted", async () => {
87
+ const store = await factory();
88
+ await store.putCredential({ accountId: "acct-1", method: "password", wrappedPrivateKey: new Uint8Array([1, 2, 3]), kdfParams: { n: 1 } });
89
+ const cred = await store.getCredential("acct-1", "password");
90
+ expect(cred?.wrappedPrivateKey).toEqual(new Uint8Array([1, 2, 3]));
91
+ expect((await store.listCredentials("acct-1")).map((c) => c.method)).toContain("password");
92
+
93
+ await store.deleteCredential("acct-1", "password");
94
+ expect(await store.getCredential("acct-1", "password")).toBeNull();
95
+ });
96
+
97
+ it("updatePasskeyCounter merges into the existing kdfParams", async () => {
98
+ const store = await factory();
99
+ await store.putCredential({ accountId: "acct-1", method: "passkey", wrappedPrivateKey: new Uint8Array(), kdfParams: { alg: "es256" } });
100
+ await store.updatePasskeyCounter("acct-1", 7);
101
+ const cred = await store.getCredential("acct-1", "passkey");
102
+ expect(cred?.kdfParams).toMatchObject({ alg: "es256", counter: 7 });
103
+ });
104
+
105
+ it("round-trips a public key", async () => {
106
+ const store = await factory();
107
+ await store.putPublicKey({ accountId: "acct-1", publicKeyJwk: { kty: "EC" } });
108
+ expect((await store.getPublicKey("acct-1"))?.publicKeyJwk).toEqual({ kty: "EC" });
109
+ expect(await store.getPublicKey("nope")).toBeNull();
110
+ });
111
+ });
112
+ }
113
+
114
+ export function runEnvelopeStoreConformance(label: string, factory: () => EnvelopeStore | Promise<EnvelopeStore>) {
115
+ describe(`EnvelopeStore conformance (${label})`, () => {
116
+ it("creates a vault with no rotation/recovery flags set", async () => {
117
+ const store = await factory();
118
+ const vault = await store.createVault({ vaultId: "vault-1", ownerAccountId: "acct-1", r2Key: "data-1.enc", hd1Version: 1 });
119
+ expect(vault.rotationPending).toBe(false);
120
+ expect(vault.orgRecoveryRevokedAt).toBeNull();
121
+ expect(await store.getVault("vault-1")).toEqual(vault);
122
+ expect(await store.getVaultByR2Key("data-1.enc")).toEqual(vault);
123
+ });
124
+
125
+ it("returns null for a vault that does not exist", async () => {
126
+ const store = await factory();
127
+ expect(await store.getVault("nope")).toBeNull();
128
+ expect(await store.getVaultByR2Key("nope")).toBeNull();
129
+ expect(await store.getVaultByStagingR2Key("nope")).toBeNull();
130
+ });
131
+
132
+ it("getEnvelopeRow answers existence only, with no access policy applied", async () => {
133
+ const store = await factory();
134
+ await store.createVault({ vaultId: "vault-1", ownerAccountId: "acct-1", r2Key: "data-1.enc", hd1Version: 1 });
135
+ expect(await store.getEnvelopeRow("vault-1", "acct-1")).toBeNull();
136
+ await store.putEnvelope({ vaultId: "vault-1", principalAccountId: "acct-1", wrappedDek: new Uint8Array([9]), ephemeralPublicKeyJwk: {}, createdBy: "acct-1" });
137
+ const row = await store.getEnvelopeRow("vault-1", "acct-1");
138
+ expect(row?.wrappedDek).toEqual(new Uint8Array([9]));
139
+ });
140
+
141
+ it("replaceEnvelopes atomically swaps the whole set", async () => {
142
+ const store = await factory();
143
+ await store.createVault({ vaultId: "vault-1", ownerAccountId: "acct-1", r2Key: "data-1.enc", hd1Version: 1 });
144
+ await store.putEnvelope({ vaultId: "vault-1", principalAccountId: "acct-1", wrappedDek: new Uint8Array([1]), ephemeralPublicKeyJwk: {}, createdBy: "acct-1" });
145
+ await store.replaceEnvelopes("vault-1", [{ principalAccountId: "acct-2", wrappedDek: new Uint8Array([2]), ephemeralPublicKeyJwk: {} }], "acct-1");
146
+ expect(await store.getEnvelopeRow("vault-1", "acct-1")).toBeNull();
147
+ expect((await store.getEnvelopeRow("vault-1", "acct-2"))?.wrappedDek).toEqual(new Uint8Array([2]));
148
+ });
149
+
150
+ it("commitRotation swaps envelopes and repoints the vault at the new key", async () => {
151
+ const store = await factory();
152
+ await store.createVault({ vaultId: "vault-1", ownerAccountId: "acct-1", r2Key: "old.enc", hd1Version: 1 });
153
+ await store.setRotationStaging("vault-1", "new.enc");
154
+ await store.commitRotation("vault-1", "new.enc", [{ principalAccountId: "acct-1", wrappedDek: new Uint8Array([3]), ephemeralPublicKeyJwk: {} }], "acct-1");
155
+ const vault = await store.getVault("vault-1");
156
+ expect(vault?.r2Key).toBe("new.enc");
157
+ expect(vault?.rotationStagingR2Key).toBeNull();
158
+ expect(vault?.rotationPending).toBe(false);
159
+ });
160
+ });
161
+ }
162
+
163
+ export function runProviderLinkStoreConformance(label: string, factory: () => ProviderLinkStore | Promise<ProviderLinkStore>) {
164
+ describe(`ProviderLinkStore conformance (${label})`, () => {
165
+ it("creates a link, defaulting status to invited", async () => {
166
+ const store = await factory();
167
+ const link = await store.create({ patientAccountId: "p1", providerAccountId: "d1", role: "clinician", grantedBy: "p1" });
168
+ expect(link.status).toBe("invited");
169
+ expect(await store.get(link.id)).toEqual(link);
170
+ });
171
+
172
+ it("getActive is null for an invited (non-active) link", async () => {
173
+ const store = await factory();
174
+ const link = await store.create({ patientAccountId: "p1", providerAccountId: "d1", role: "clinician", grantedBy: "p1" });
175
+ expect(await store.getActive("p1", "d1")).toBeNull();
176
+ await store.updateStatus(link.id, "active");
177
+ expect((await store.getActive("p1", "d1"))?.id).toBe(link.id);
178
+ });
179
+
180
+ it("getActive is null once expiresAt is in the past", async () => {
181
+ const store = await factory();
182
+ const link = await store.create({ patientAccountId: "p1", providerAccountId: "d1", role: "support", grantedBy: "p1", status: "active", expiresAt: "2000-01-01T00:00:00.000Z" });
183
+ expect(link.status).toBe("active");
184
+ expect(await store.getActive("p1", "d1")).toBeNull();
185
+ });
186
+
187
+ it("grantSupport activates and time-boxes a link", async () => {
188
+ const store = await factory();
189
+ const link = await store.create({ patientAccountId: "p1", providerAccountId: "d1", role: "support", grantedBy: "p1" });
190
+ const expiresAt = new Date(Date.now() + 3600_000).toISOString();
191
+ await store.grantSupport(link.id, { expiresAt, consentRef: "consent-1" });
192
+ const after = await store.get(link.id);
193
+ expect(after?.status).toBe("active");
194
+ expect(after?.expiresAt).toBe(expiresAt);
195
+ });
196
+
197
+ it("listForPatient / listForProvider scope correctly", async () => {
198
+ const store = await factory();
199
+ await store.create({ patientAccountId: "p1", providerAccountId: "d1", role: "clinician", grantedBy: "p1" });
200
+ await store.create({ patientAccountId: "p1", providerAccountId: "d2", role: "clinician", grantedBy: "p1" });
201
+ await store.create({ patientAccountId: "p2", providerAccountId: "d1", role: "clinician", grantedBy: "p2" });
202
+ expect(await store.listForPatient("p1")).toHaveLength(2);
203
+ expect(await store.listForProvider("d1")).toHaveLength(2);
204
+ });
205
+ });
206
+ }
207
+
208
+ export function runAuditStoreConformance(label: string, factory: () => AuditStore | Promise<AuditStore>) {
209
+ describe(`AuditStore conformance (${label})`, () => {
210
+ it("records an access event and lists it for the subject", async () => {
211
+ const store = await factory();
212
+ const event = await store.insertAccessEvent({ actorAccountId: "d1", subjectAccountId: "p1", action: "view" });
213
+ expect(event.consentRef).toBeNull();
214
+ expect(await store.listAccessEventsForSubject("p1")).toEqual([event]);
215
+ });
216
+
217
+ it("scopes listAccessEventsForSubject to the given subject only", async () => {
218
+ const store = await factory();
219
+ await store.insertAccessEvent({ actorAccountId: "d1", subjectAccountId: "p1", action: "view" });
220
+ await store.insertAccessEvent({ actorAccountId: "d1", subjectAccountId: "p2", action: "view" });
221
+ expect(await store.listAccessEventsForSubject("p1")).toHaveLength(1);
222
+ });
223
+ });
224
+ }