@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.
@@ -0,0 +1,209 @@
1
+ import type { D1Database } from "./types";
2
+ import { toBytes } from "./types";
3
+ import type { VaultRow, Envelope } from "../../stores";
4
+ export type { VaultRow, Envelope };
5
+
6
+ // CRUD only. `getEnvelope()` — the version of this read that applies access POLICY (provider-link
7
+ // state, the org-recovery principal) — is app-specific and stays in the app's identity-vault.ts,
8
+ // built on top of `getEnvelopeRow`/`getVault` below. See stores.ts's own docstring on why policy
9
+ // doesn't belong in a portable adapter.
10
+
11
+ interface VaultRowRaw {
12
+ vault_id: string;
13
+ owner_account_id: string;
14
+ r2_key: string;
15
+ hd1_version: number;
16
+ rotation_pending?: number;
17
+ org_recovery_revoked_at?: string | null;
18
+ rotation_staging_r2_key?: string | null;
19
+ }
20
+ function mapVault(r: VaultRowRaw): VaultRow {
21
+ return {
22
+ vaultId: r.vault_id,
23
+ ownerAccountId: r.owner_account_id,
24
+ r2Key: r.r2_key,
25
+ hd1Version: r.hd1_version,
26
+ rotationPending: r.rotation_pending === 1,
27
+ orgRecoveryRevokedAt: r.org_recovery_revoked_at ?? null,
28
+ rotationStagingR2Key: r.rotation_staging_r2_key ?? null,
29
+ };
30
+ }
31
+
32
+ export async function setRotationPending(db: D1Database, vaultId: string, pending: boolean): Promise<void> {
33
+ await db.prepare("UPDATE vaults SET rotation_pending = ? WHERE vault_id = ?").bind(pending ? 1 : 0, vaultId).run();
34
+ }
35
+
36
+ export async function setOrgRecoveryRevoked(db: D1Database, vaultId: string, at: string | null): Promise<void> {
37
+ await db.prepare("UPDATE vaults SET org_recovery_revoked_at = ? WHERE vault_id = ?").bind(at, vaultId).run();
38
+ }
39
+
40
+ export async function createVault(
41
+ db: D1Database,
42
+ v: { vaultId: string; ownerAccountId: string; r2Key: string; hd1Version: number }
43
+ ): Promise<VaultRow> {
44
+ await db
45
+ .prepare("INSERT INTO vaults (vault_id, owner_account_id, r2_key, hd1_version) VALUES (?, ?, ?, ?)")
46
+ .bind(v.vaultId, v.ownerAccountId, v.r2Key, v.hd1Version)
47
+ .run();
48
+ return { ...v, rotationPending: false, orgRecoveryRevokedAt: null, rotationStagingR2Key: null };
49
+ }
50
+
51
+ export async function getVault(db: D1Database, vaultId: string): Promise<VaultRow | null> {
52
+ const row = await db.prepare("SELECT * FROM vaults WHERE vault_id = ?").bind(vaultId).first<VaultRowRaw>();
53
+ return row ? mapVault(row) : null;
54
+ }
55
+
56
+ /** W75 — the vault whose in-flight rotation has reserved this key. See `commitRotation`. */
57
+ export async function getVaultByStagingR2Key(db: D1Database, r2Key: string): Promise<VaultRow | null> {
58
+ const row = await db.prepare("SELECT * FROM vaults WHERE rotation_staging_r2_key = ?").bind(r2Key).first<VaultRowRaw>();
59
+ return row ? mapVault(row) : null;
60
+ }
61
+
62
+ export async function setRotationStaging(db: D1Database, vaultId: string, r2Key: string | null): Promise<void> {
63
+ await db.prepare("UPDATE vaults SET rotation_staging_r2_key = ? WHERE vault_id = ?").bind(r2Key, vaultId).run();
64
+ }
65
+
66
+ export async function getVaultByR2Key(db: D1Database, r2Key: string): Promise<VaultRow | null> {
67
+ const row = await db.prepare("SELECT * FROM vaults WHERE r2_key = ?").bind(r2Key).first<VaultRowRaw>();
68
+ return row ? mapVault(row) : null;
69
+ }
70
+
71
+ export async function listVaultsForOwner(db: D1Database, ownerAccountId: string): Promise<VaultRow[]> {
72
+ const { results } = await db.prepare("SELECT * FROM vaults WHERE owner_account_id = ?").bind(ownerAccountId).all<VaultRowRaw>();
73
+ return results.map(mapVault);
74
+ }
75
+
76
+ interface EnvelopeRow {
77
+ vault_id: string;
78
+ principal_account_id: string;
79
+ wrapped_dek: unknown;
80
+ ephemeral_public_key_jwk: string;
81
+ created_by: string;
82
+ created_at: string;
83
+ }
84
+ function mapEnvelope(r: EnvelopeRow): Envelope {
85
+ return {
86
+ vaultId: r.vault_id,
87
+ principalAccountId: r.principal_account_id,
88
+ wrappedDek: toBytes(r.wrapped_dek),
89
+ ephemeralPublicKeyJwk: JSON.parse(r.ephemeral_public_key_jwk),
90
+ createdBy: r.created_by,
91
+ createdAt: r.created_at,
92
+ };
93
+ }
94
+
95
+ export async function putEnvelope(
96
+ db: D1Database,
97
+ e: { vaultId: string; principalAccountId: string; wrappedDek: Uint8Array; ephemeralPublicKeyJwk: unknown; createdBy: string }
98
+ ): Promise<void> {
99
+ const createdAt = new Date().toISOString();
100
+ await db
101
+ .prepare(
102
+ "INSERT OR REPLACE INTO vault_envelopes (vault_id, principal_account_id, wrapped_dek, ephemeral_public_key_jwk, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?)"
103
+ )
104
+ .bind(e.vaultId, e.principalAccountId, e.wrappedDek, JSON.stringify(e.ephemeralPublicKeyJwk), e.createdBy, createdAt)
105
+ .run();
106
+ }
107
+
108
+ /**
109
+ * W71 — swap a vault's whole envelope set atomically.
110
+ *
111
+ * The rotation route used to delete every envelope in a loop and then build the replacements one at a
112
+ * time, decoding each `wrappedDEK` as it went. Two ways that ended in an unrecoverable vault: a
113
+ * malformed base64 string threw AFTER the deletes had committed, and a D1 failure partway through the
114
+ * second loop left the set half-written. Either way the R2 blob stays encrypted under the new DEK
115
+ * while no envelope anywhere can unwrap it — including the org recovery envelope, so there is no
116
+ * second door. Nobody finds out until the next unlock fails.
117
+ *
118
+ * One batch, so D1 either applies the whole swap or none of it. Callers must still validate the
119
+ * envelopes before calling: a decode that throws here would throw before the batch is submitted,
120
+ * which is safe, but the caller owes the user a 400 rather than a 500.
121
+ */
122
+ export async function replaceEnvelopes(
123
+ db: D1Database,
124
+ vaultId: string,
125
+ envelopes: { principalAccountId: string; wrappedDek: Uint8Array; ephemeralPublicKeyJwk: unknown }[],
126
+ createdBy: string,
127
+ ): Promise<void> {
128
+ const createdAt = new Date().toISOString();
129
+ await db.batch([
130
+ db.prepare("DELETE FROM vault_envelopes WHERE vault_id = ?").bind(vaultId),
131
+ ...envelopes.map((e) =>
132
+ db
133
+ .prepare(
134
+ "INSERT OR REPLACE INTO vault_envelopes (vault_id, principal_account_id, wrapped_dek, ephemeral_public_key_jwk, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?)"
135
+ )
136
+ .bind(vaultId, e.principalAccountId, e.wrappedDek, JSON.stringify(e.ephemeralPublicKeyJwk), createdBy, createdAt)
137
+ ),
138
+ ]);
139
+ }
140
+
141
+ /**
142
+ * W75 — the whole re-key, committed as one D1 batch: swap the envelope set AND repoint the vault at
143
+ * the freshly-written object, in one implicit transaction.
144
+ *
145
+ * The atomicity is the point. The rotation used to re-encrypt IN PLACE — new DEK into the same r2
146
+ * key, envelopes updated several network round trips later. Between those two writes the ciphertext
147
+ * was readable only by a key held in one tab's memory, and a dropped connection there locked every
148
+ * principal out of the record permanently. Writing to a new key makes this UPDATE the only moment
149
+ * anything becomes true: before it, the old blob and the old envelopes still agree.
150
+ *
151
+ * The old object is deliberately left in R2 rather than deleted here. It is unreferenced and
152
+ * undecryptable-by-design after the swap, and deleting it would put an irreversible step back inside
153
+ * the window this function exists to close.
154
+ */
155
+ export async function commitRotation(
156
+ db: D1Database,
157
+ vaultId: string,
158
+ newR2Key: string,
159
+ envelopes: { principalAccountId: string; wrappedDek: Uint8Array; ephemeralPublicKeyJwk: unknown }[],
160
+ createdBy: string,
161
+ ): Promise<void> {
162
+ const createdAt = new Date().toISOString();
163
+ await db.batch([
164
+ db.prepare("DELETE FROM vault_envelopes WHERE vault_id = ?").bind(vaultId),
165
+ ...envelopes.map((e) =>
166
+ db
167
+ .prepare(
168
+ "INSERT OR REPLACE INTO vault_envelopes (vault_id, principal_account_id, wrapped_dek, ephemeral_public_key_jwk, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?)"
169
+ )
170
+ .bind(vaultId, e.principalAccountId, e.wrappedDek, JSON.stringify(e.ephemeralPublicKeyJwk), createdBy, createdAt)
171
+ ),
172
+ db
173
+ .prepare("UPDATE vaults SET r2_key = ?, rotation_staging_r2_key = NULL, rotation_pending = 0 WHERE vault_id = ?")
174
+ .bind(newR2Key, vaultId),
175
+ ]);
176
+ }
177
+
178
+ /**
179
+ * The envelope row as stored, with NO access check. Use the app's `getEnvelope` unless you
180
+ * specifically need the raw row (listing, rotation, administration) — this one answers "does a row
181
+ * exist", which is not the same question as "may this principal open the vault".
182
+ */
183
+ export async function getEnvelopeRow(db: D1Database, vaultId: string, principalAccountId: string): Promise<Envelope | null> {
184
+ const row = await db
185
+ .prepare("SELECT * FROM vault_envelopes WHERE vault_id = ? AND principal_account_id = ?")
186
+ .bind(vaultId, principalAccountId)
187
+ .first<EnvelopeRow>();
188
+ return row ? mapEnvelope(row) : null;
189
+ }
190
+
191
+ export async function listEnvelopesForVault(db: D1Database, vaultId: string): Promise<Envelope[]> {
192
+ const { results } = await db.prepare("SELECT * FROM vault_envelopes WHERE vault_id = ?").bind(vaultId).all<EnvelopeRow>();
193
+ return results.map(mapEnvelope);
194
+ }
195
+
196
+ export async function listEnvelopesForPrincipal(db: D1Database, principalAccountId: string): Promise<Envelope[]> {
197
+ const { results } = await db
198
+ .prepare("SELECT * FROM vault_envelopes WHERE principal_account_id = ?")
199
+ .bind(principalAccountId)
200
+ .all<EnvelopeRow>();
201
+ return results.map(mapEnvelope);
202
+ }
203
+
204
+ export async function deleteEnvelope(db: D1Database, vaultId: string, principalAccountId: string): Promise<void> {
205
+ await db
206
+ .prepare("DELETE FROM vault_envelopes WHERE vault_id = ? AND principal_account_id = ?")
207
+ .bind(vaultId, principalAccountId)
208
+ .run();
209
+ }
@@ -0,0 +1,342 @@
1
+ // In-memory implementations of every stores.ts interface. This is the portability proof: the same
2
+ // conformance suite (./conformance.ts) runs unchanged against these and against the D1 adapter, so
3
+ // "storage-agnostic" is demonstrated rather than merely asserted by interface shape.
4
+
5
+ import type {
6
+ Account,
7
+ AccountStore,
8
+ AuditStore,
9
+ AccessEvent,
10
+ AuthMethod,
11
+ Credential,
12
+ CredentialStore,
13
+ Envelope,
14
+ EnvelopeInput,
15
+ EnvelopeStore,
16
+ Identity,
17
+ LifecycleStage,
18
+ LinkStatus,
19
+ ProviderKind,
20
+ ProviderLink,
21
+ ProviderLinkStore,
22
+ PublicKey,
23
+ UnitSystem,
24
+ VaultRow,
25
+ } from "../stores";
26
+
27
+ export class MemoryAccountStore implements AccountStore {
28
+ private byId = new Map<string, Account>();
29
+ private sessionsValidFromMs = new Map<string, number>();
30
+
31
+ async create(a: { id: string; displayName: string; email?: string | null; lifecycleStage?: LifecycleStage; providerKind?: ProviderKind | null }): Promise<Account> {
32
+ const account: Account = {
33
+ id: a.id,
34
+ email: a.email ?? null,
35
+ emailConfirmed: false,
36
+ displayName: a.displayName,
37
+ lifecycleStage: a.lifecycleStage ?? "active",
38
+ providerKind: a.providerKind ?? null,
39
+ unitSystem: null,
40
+ createdAt: new Date().toISOString(),
41
+ deletedAt: null,
42
+ emailChangedAt: null,
43
+ };
44
+ this.byId.set(a.id, account);
45
+ return { ...account };
46
+ }
47
+
48
+ async get(id: string): Promise<Account | null> {
49
+ const a = this.byId.get(id);
50
+ return a ? { ...a } : null;
51
+ }
52
+
53
+ async getByEmail(email: string): Promise<Account | null> {
54
+ for (const a of this.byId.values()) if (a.email === email) return { ...a };
55
+ return null;
56
+ }
57
+
58
+ async sessionsValidFrom(accountId: string): Promise<number | null> {
59
+ if (!this.byId.has(accountId)) return Number.POSITIVE_INFINITY;
60
+ return this.sessionsValidFromMs.get(accountId) ?? null;
61
+ }
62
+
63
+ async revokeSessions(accountId: string): Promise<void> {
64
+ this.sessionsValidFromMs.set(accountId, Math.floor(Date.now() / 1000));
65
+ }
66
+
67
+ async setEmailConfirmed(id: string, confirmed: boolean): Promise<void> {
68
+ const a = this.byId.get(id);
69
+ if (a) a.emailConfirmed = confirmed;
70
+ }
71
+
72
+ async setLifecycleStage(id: string, stage: LifecycleStage): Promise<void> {
73
+ const a = this.byId.get(id);
74
+ if (a) a.lifecycleStage = stage;
75
+ }
76
+
77
+ async updateProfile(id: string, updates: { email?: string | null; displayName?: string; unitSystem?: UnitSystem | null }): Promise<void> {
78
+ const a = this.byId.get(id);
79
+ if (!a) return;
80
+ if (updates.email !== undefined) a.email = updates.email;
81
+ if (updates.displayName !== undefined) a.displayName = updates.displayName;
82
+ if (updates.unitSystem !== undefined) a.unitSystem = updates.unitSystem;
83
+ }
84
+
85
+ async tombstone(accountId: string, at: string): Promise<void> {
86
+ const a = this.byId.get(accountId);
87
+ if (!a) return;
88
+ a.email = null;
89
+ a.emailConfirmed = false;
90
+ a.displayName = "";
91
+ a.lifecycleStage = "churned";
92
+ a.providerKind = null;
93
+ a.unitSystem = null;
94
+ a.deletedAt = at;
95
+ }
96
+
97
+ async markEmailChanged(accountId: string, at: string): Promise<void> {
98
+ const a = this.byId.get(accountId);
99
+ if (a) a.emailChangedAt = at;
100
+ }
101
+ }
102
+
103
+ export class MemoryCredentialStore implements CredentialStore {
104
+ private identities: Identity[] = [];
105
+ private credentials = new Map<string, Credential>(); // key: `${accountId}:${method}`
106
+ private publicKeys = new Map<string, PublicKey>();
107
+
108
+ private credKey(accountId: string, method: AuthMethod) {
109
+ return `${accountId}:${method}`;
110
+ }
111
+
112
+ async addIdentity(i: { accountId: string; method: AuthMethod; providerSubject?: string | null; credentialId?: string | null; id?: string }): Promise<Identity> {
113
+ const identity: Identity = {
114
+ id: i.id ?? crypto.randomUUID(),
115
+ accountId: i.accountId,
116
+ method: i.method,
117
+ providerSubject: i.providerSubject ?? null,
118
+ credentialId: i.credentialId ?? null,
119
+ createdAt: new Date().toISOString(),
120
+ };
121
+ this.identities.push(identity);
122
+ return { ...identity };
123
+ }
124
+
125
+ async getIdentityByProviderSubject(method: AuthMethod, subject: string): Promise<Identity | null> {
126
+ const found = this.identities.find((i) => i.method === method && i.providerSubject === subject);
127
+ return found ? { ...found } : null;
128
+ }
129
+
130
+ async getIdentityByCredentialId(credentialId: string): Promise<Identity | null> {
131
+ const found = this.identities.find((i) => i.credentialId === credentialId);
132
+ return found ? { ...found } : null;
133
+ }
134
+
135
+ async listIdentities(accountId: string): Promise<Identity[]> {
136
+ return this.identities.filter((i) => i.accountId === accountId).map((i) => ({ ...i }));
137
+ }
138
+
139
+ async deleteIdentity(accountId: string, method: AuthMethod): Promise<void> {
140
+ this.identities = this.identities.filter((i) => !(i.accountId === accountId && i.method === method));
141
+ }
142
+
143
+ async putCredential(c: { accountId: string; method: AuthMethod; wrappedPrivateKey: Uint8Array; kdfParams: unknown }): Promise<void> {
144
+ this.credentials.set(this.credKey(c.accountId, c.method), { ...c, createdAt: new Date().toISOString() });
145
+ }
146
+
147
+ async getCredential(accountId: string, method: AuthMethod): Promise<Credential | null> {
148
+ const c = this.credentials.get(this.credKey(accountId, method));
149
+ return c ? { ...c } : null;
150
+ }
151
+
152
+ async listCredentials(accountId: string): Promise<{ method: AuthMethod; createdAt: string }[]> {
153
+ return [...this.credentials.values()]
154
+ .filter((c) => c.accountId === accountId)
155
+ .map((c) => ({ method: c.method, createdAt: c.createdAt }));
156
+ }
157
+
158
+ async deleteCredential(accountId: string, method: AuthMethod): Promise<void> {
159
+ this.credentials.delete(this.credKey(accountId, method));
160
+ }
161
+
162
+ async updatePasskeyCounter(accountId: string, counter: number): Promise<void> {
163
+ const c = this.credentials.get(this.credKey(accountId, "passkey"));
164
+ if (!c) return;
165
+ c.kdfParams = { ...(c.kdfParams as Record<string, unknown>), counter };
166
+ }
167
+
168
+ async putPublicKey(p: { accountId: string; publicKeyJwk: unknown }): Promise<void> {
169
+ this.publicKeys.set(p.accountId, { ...p, createdAt: new Date().toISOString() });
170
+ }
171
+
172
+ async getPublicKey(accountId: string): Promise<PublicKey | null> {
173
+ const p = this.publicKeys.get(accountId);
174
+ return p ? { ...p } : null;
175
+ }
176
+ }
177
+
178
+ export class MemoryEnvelopeStore implements EnvelopeStore {
179
+ private vaults = new Map<string, VaultRow>();
180
+ private envelopes = new Map<string, Envelope>(); // key: `${vaultId}:${principalAccountId}`
181
+
182
+ private envKey(vaultId: string, principalAccountId: string) {
183
+ return `${vaultId}:${principalAccountId}`;
184
+ }
185
+
186
+ async createVault(v: { vaultId: string; ownerAccountId: string; r2Key: string; hd1Version: number }): Promise<VaultRow> {
187
+ const row: VaultRow = { ...v, rotationPending: false, orgRecoveryRevokedAt: null, rotationStagingR2Key: null };
188
+ this.vaults.set(v.vaultId, row);
189
+ return { ...row };
190
+ }
191
+
192
+ async getVault(vaultId: string): Promise<VaultRow | null> {
193
+ const v = this.vaults.get(vaultId);
194
+ return v ? { ...v } : null;
195
+ }
196
+
197
+ async getVaultByR2Key(r2Key: string): Promise<VaultRow | null> {
198
+ for (const v of this.vaults.values()) if (v.r2Key === r2Key) return { ...v };
199
+ return null;
200
+ }
201
+
202
+ async getVaultByStagingR2Key(r2Key: string): Promise<VaultRow | null> {
203
+ for (const v of this.vaults.values()) if (v.rotationStagingR2Key === r2Key) return { ...v };
204
+ return null;
205
+ }
206
+
207
+ async listVaultsForOwner(ownerAccountId: string): Promise<VaultRow[]> {
208
+ return [...this.vaults.values()].filter((v) => v.ownerAccountId === ownerAccountId).map((v) => ({ ...v }));
209
+ }
210
+
211
+ async setRotationPending(vaultId: string, pending: boolean): Promise<void> {
212
+ const v = this.vaults.get(vaultId);
213
+ if (v) v.rotationPending = pending;
214
+ }
215
+
216
+ async setRotationStaging(vaultId: string, r2Key: string | null): Promise<void> {
217
+ const v = this.vaults.get(vaultId);
218
+ if (v) v.rotationStagingR2Key = r2Key;
219
+ }
220
+
221
+ async setOrgRecoveryRevoked(vaultId: string, at: string | null): Promise<void> {
222
+ const v = this.vaults.get(vaultId);
223
+ if (v) v.orgRecoveryRevokedAt = at;
224
+ }
225
+
226
+ async putEnvelope(e: { vaultId: string; principalAccountId: string; wrappedDek: Uint8Array; ephemeralPublicKeyJwk: unknown; createdBy: string }): Promise<void> {
227
+ this.envelopes.set(this.envKey(e.vaultId, e.principalAccountId), { ...e, createdAt: new Date().toISOString() });
228
+ }
229
+
230
+ async replaceEnvelopes(vaultId: string, envelopes: EnvelopeInput[], createdBy: string): Promise<void> {
231
+ for (const key of [...this.envelopes.keys()]) if (key.startsWith(`${vaultId}:`)) this.envelopes.delete(key);
232
+ const createdAt = new Date().toISOString();
233
+ for (const e of envelopes) {
234
+ this.envelopes.set(this.envKey(vaultId, e.principalAccountId), { vaultId, createdBy, createdAt, ...e });
235
+ }
236
+ }
237
+
238
+ async commitRotation(vaultId: string, newR2Key: string, envelopes: EnvelopeInput[], createdBy: string): Promise<void> {
239
+ await this.replaceEnvelopes(vaultId, envelopes, createdBy);
240
+ const v = this.vaults.get(vaultId);
241
+ if (v) {
242
+ v.r2Key = newR2Key;
243
+ v.rotationStagingR2Key = null;
244
+ v.rotationPending = false;
245
+ }
246
+ }
247
+
248
+ async getEnvelopeRow(vaultId: string, principalAccountId: string): Promise<Envelope | null> {
249
+ const e = this.envelopes.get(this.envKey(vaultId, principalAccountId));
250
+ return e ? { ...e } : null;
251
+ }
252
+
253
+ async listEnvelopesForVault(vaultId: string): Promise<Envelope[]> {
254
+ return [...this.envelopes.values()].filter((e) => e.vaultId === vaultId).map((e) => ({ ...e }));
255
+ }
256
+
257
+ async listEnvelopesForPrincipal(principalAccountId: string): Promise<Envelope[]> {
258
+ return [...this.envelopes.values()].filter((e) => e.principalAccountId === principalAccountId).map((e) => ({ ...e }));
259
+ }
260
+
261
+ async deleteEnvelope(vaultId: string, principalAccountId: string): Promise<void> {
262
+ this.envelopes.delete(this.envKey(vaultId, principalAccountId));
263
+ }
264
+ }
265
+
266
+ export class MemoryProviderLinkStore implements ProviderLinkStore {
267
+ private links = new Map<string, ProviderLink>();
268
+
269
+ async create(l: { patientAccountId: string; providerAccountId: string; role: ProviderKind; status?: LinkStatus; consentRef?: string | null; grantedBy: string; expiresAt?: string | null; id?: string }): Promise<ProviderLink> {
270
+ const link: ProviderLink = {
271
+ id: l.id ?? crypto.randomUUID(),
272
+ patientAccountId: l.patientAccountId,
273
+ providerAccountId: l.providerAccountId,
274
+ role: l.role,
275
+ status: l.status ?? "invited",
276
+ consentRef: l.consentRef ?? null,
277
+ grantedBy: l.grantedBy,
278
+ grantedAt: new Date().toISOString(),
279
+ expiresAt: l.expiresAt ?? null,
280
+ };
281
+ this.links.set(link.id, link);
282
+ return { ...link };
283
+ }
284
+
285
+ async updateStatus(id: string, status: LinkStatus): Promise<void> {
286
+ const l = this.links.get(id);
287
+ if (l) l.status = status;
288
+ }
289
+
290
+ async grantSupport(id: string, opts: { expiresAt: string | null; consentRef?: string | null }): Promise<void> {
291
+ const l = this.links.get(id);
292
+ if (!l) return;
293
+ l.status = "active";
294
+ l.expiresAt = opts.expiresAt;
295
+ l.consentRef = opts.consentRef ?? null;
296
+ }
297
+
298
+ async get(id: string): Promise<ProviderLink | null> {
299
+ const l = this.links.get(id);
300
+ return l ? { ...l } : null;
301
+ }
302
+
303
+ async listForPatient(patientAccountId: string): Promise<ProviderLink[]> {
304
+ return [...this.links.values()].filter((l) => l.patientAccountId === patientAccountId).map((l) => ({ ...l }));
305
+ }
306
+
307
+ async listForProvider(providerAccountId: string): Promise<ProviderLink[]> {
308
+ return [...this.links.values()].filter((l) => l.providerAccountId === providerAccountId).map((l) => ({ ...l }));
309
+ }
310
+
311
+ async getActive(patientAccountId: string, providerAccountId: string): Promise<ProviderLink | null> {
312
+ const link = [...this.links.values()].find(
313
+ (l) => l.patientAccountId === patientAccountId && l.providerAccountId === providerAccountId
314
+ );
315
+ if (!link || link.status !== "active") return null;
316
+ if (link.expiresAt && Date.parse(link.expiresAt) <= Date.now()) return null;
317
+ return { ...link };
318
+ }
319
+ }
320
+
321
+ export class MemoryAuditStore implements AuditStore {
322
+ private events: AccessEvent[] = [];
323
+
324
+ async insertAccessEvent(e: { actorAccountId: string; subjectAccountId: string; vaultId?: string | null; action: string; consentRef?: string | null; meta?: unknown; id?: string }): Promise<AccessEvent> {
325
+ const event: AccessEvent = {
326
+ id: e.id ?? crypto.randomUUID(),
327
+ actorAccountId: e.actorAccountId,
328
+ subjectAccountId: e.subjectAccountId,
329
+ vaultId: e.vaultId ?? null,
330
+ action: e.action,
331
+ consentRef: e.consentRef ?? null,
332
+ meta: e.meta ?? {},
333
+ createdAt: new Date().toISOString(),
334
+ };
335
+ this.events.push(event);
336
+ return { ...event };
337
+ }
338
+
339
+ async listAccessEventsForSubject(subjectAccountId: string): Promise<AccessEvent[]> {
340
+ return this.events.filter((e) => e.subjectAccountId === subjectAccountId).map((e) => ({ ...e }));
341
+ }
342
+ }
@@ -0,0 +1,13 @@
1
+ // Wraps a portable (request, deps) => Promise<Response> handler into Cloudflare Pages Functions'
2
+ // onRequestX({request, env, params}) shape. The portable handler never sees `env` or `context` — only
3
+ // the plain `Deps` object `buildDeps` constructs from them — so the same handler runs unmodified under
4
+ // any host that can produce a Deps value and a Request.
5
+
6
+ export type PortableHandler<Deps> = (request: Request, deps: Deps) => Promise<Response>;
7
+
8
+ export function pagesHandler<Deps, Env = unknown, Params = unknown>(
9
+ handler: PortableHandler<Deps>,
10
+ buildDeps: (context: { request: Request; env: Env; params: Params }) => Deps
11
+ ): (context: { request: Request; env: Env; params: Params }) => Promise<Response> {
12
+ return (context) => handler(context.request, buildDeps(context));
13
+ }
package/adapters/r2.ts ADDED
@@ -0,0 +1,50 @@
1
+ import type { BlobStore, BlobConditional, StoredBlob } from "../blob-store";
2
+
3
+ // Minimal structural type for the R2 binding — no @cloudflare/workers-types dependency, and
4
+ // trivially mockable in tests. Moved from apps/health-dash-web/functions/api/vault/[id].ts, which
5
+ // now imports R2Bucket/R2BlobStore from here instead of declaring its own copy.
6
+ export interface R2ObjectBody {
7
+ body: ReadableStream;
8
+ /** W70 — the version token. Handed to the browser on GET and sent back as If-Match on PUT. */
9
+ etag: string;
10
+ }
11
+ /** A precondition on a write. Verified against real workerd in tests/unit/r2-conditional-put.test.ts. */
12
+ export interface R2Conditional {
13
+ etagMatches?: string;
14
+ etagDoesNotMatch?: string;
15
+ }
16
+ export interface R2Bucket {
17
+ get(key: string): Promise<R2ObjectBody | null>;
18
+ /**
19
+ * Returns the stored object (carrying its NEW etag), or `null` when an `onlyIf` precondition fails.
20
+ * Null-on-failure rather than a throw is observed behaviour, not an assumption — see
21
+ * tests/unit/r2-conditional-put.test.ts, which pins it against workerd.
22
+ */
23
+ put(key: string, value: Uint8Array, options?: { onlyIf?: R2Conditional }): Promise<{ etag: string } | null>;
24
+ delete(key: string): Promise<void>;
25
+ }
26
+
27
+ /** `BlobStore`'s conditional field names, mapped onto R2's own (`onlyIf.etagMatches`/`etagDoesNotMatch`). */
28
+ function toOnlyIf(conditional?: BlobConditional): R2Conditional | undefined {
29
+ if (!conditional) return undefined;
30
+ if (conditional.ifMatch) return { etagMatches: conditional.ifMatch };
31
+ if (conditional.ifNoneMatch === "*") return { etagDoesNotMatch: "*" };
32
+ return undefined;
33
+ }
34
+
35
+ export class R2BlobStore implements BlobStore {
36
+ constructor(private bucket: R2Bucket) {}
37
+
38
+ async get(key: string): Promise<StoredBlob | null> {
39
+ return this.bucket.get(key);
40
+ }
41
+
42
+ async put(key: string, value: Uint8Array, conditional?: BlobConditional): Promise<{ etag: string } | null> {
43
+ const onlyIf = toOnlyIf(conditional);
44
+ return onlyIf ? this.bucket.put(key, value, { onlyIf }) : this.bucket.put(key, value);
45
+ }
46
+
47
+ async delete(key: string): Promise<void> {
48
+ await this.bucket.delete(key);
49
+ }
50
+ }
package/blob-store.ts ADDED
@@ -0,0 +1,27 @@
1
+ // A BlobStore persists an opaque byte blob under a string key, with an optional optimistic-
2
+ // concurrency conditional on write. Server-side counterpart to vault-sink.ts's browser-side
3
+ // VaultSink — that one calls an HTTP endpoint from the browser; this one is the interface the
4
+ // endpoint's own handler stores through, so the handler need not assume any particular backend.
5
+ export interface StoredBlob {
6
+ body: ReadableStream;
7
+ /** The version token of the object as stored. Handed back on GET, sent back as `ifMatch` on PUT. */
8
+ etag: string;
9
+ }
10
+
11
+ export interface BlobConditional {
12
+ /** Only write if the current object's etag matches this value. */
13
+ ifMatch?: string;
14
+ /** Only write if no object currently exists at this key. `"*"` is the only supported value. */
15
+ ifNoneMatch?: "*";
16
+ }
17
+
18
+ export interface BlobStore {
19
+ get(key: string): Promise<StoredBlob | null>;
20
+ /**
21
+ * Returns the written object's new etag, or `null` when a conditional fails. Null-on-failure
22
+ * rather than a thrown error mirrors R2's own observed contract (see adapters/r2.ts) — an
23
+ * implementation is a drop-in replacement only if it fails the same way.
24
+ */
25
+ put(key: string, value: Uint8Array, conditional?: BlobConditional): Promise<{ etag: string } | null>;
26
+ delete(key: string): Promise<void>;
27
+ }