@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 +21 -0
- package/README.md +271 -0
- package/adapters/conformance.ts +224 -0
- package/adapters/d1/accounts.ts +160 -0
- package/adapters/d1/audit.ts +58 -0
- package/adapters/d1/credentials.ts +154 -0
- package/adapters/d1/index.ts +179 -0
- package/adapters/d1/providers.ts +117 -0
- package/adapters/d1/types.ts +22 -0
- package/adapters/d1/vault.ts +209 -0
- package/adapters/memory.ts +342 -0
- package/adapters/pages-http.ts +13 -0
- package/adapters/r2.ts +50 -0
- package/blob-store.ts +27 -0
- package/break-glass.ts +158 -0
- package/bytes.ts +18 -0
- package/crypto.ts +276 -0
- package/env.d.ts +9 -0
- package/envelope-access.ts +44 -0
- package/kdf.ts +73 -0
- package/key-store.ts +67 -0
- package/package.json +52 -0
- package/stores.ts +190 -0
- package/vault-sink.ts +147 -0
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
|
+
[](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
|
+
}
|