@tinytars/vault 0.1.16 → 0.1.18

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/README.md CHANGED
@@ -7,46 +7,60 @@ A storage operator who can read what it stores hasn't encrypted the data — it'
7
7
  and most hand-rolled "encrypted vault" designs end up exactly there, one convenience shortcut at a
8
8
  time. This package is the alternative already built: a shared key-derivation primitive, an
9
9
  authenticated envelope format with multi-principal access via wrapped data-encryption keys, and
10
- storage-agnostic contracts for the account/vault/grant data model those envelopes sit on top of.
10
+ storage-agnostic contracts for the account/vault/grant data model those envelopes sit on top of
11
+ domain-neutral by design, not health-specific code that happened to get open-sourced.
11
12
 
12
- Built for and extracted from a health-records app that needed patient-controlled encryption plus
13
- revocable provider access; published because the primitives don't have anything health-specific
14
- in them. Maintained by the [Tiny Tars Foundation](https://tinytars.foundation), a 501(c)(3).
13
+ Maintained by the [Tiny Tars Foundation](https://tinytars.foundation), a 501(c)(3).
15
14
 
16
15
  ## Why
17
16
 
18
- ### Built for a HIPAA-adjacent app
19
-
20
- The pattern such an app needs is exactly what ships here, not something you'd assemble from
21
- parts. `crypto.ts` gives you a zero-knowledge vault the storage operator holds ciphertext and
22
- never a key or usable plaintext. `envelope-access.ts` gives you consent-based sharing access to
23
- a vault is a per-principal, revocable wrapped-key grant, not a shared secret or a role flag.
24
- `break-glass.ts` gives you the third piece that's easy to get wrong by hand: a time-boxed grant
25
- with the TTL clamp, self-expiry, and audit trail built in, so "temporary access" is actually
26
- temporary instead of a support ticket someone forgets to close. Put together, that's the
27
- "encrypted vault + consent-based sharing + time-boxed break-glass access" shape any HIPAA-adjacent
28
- app ends up needing — and this package ships all three, not a subset with the rest left as an
29
- exercise. None of that makes the package itself HIPAA-compliant; it's a primitive an adopter
30
- builds compliant handling on top of, not a compliance product in its own right.
31
-
32
- ### Not health-specific
33
-
34
- The storage contracts in `stores.ts` are generic data-access interfaces; the payload `crypto.ts`
35
- encrypts is an arbitrary JSON value, not a medical-record shape. Even `ProviderLinkStore`'s
36
- "provider"/"patient" field names are just this package's first adopter's vocabulary
37
- `ARCHITECTURE.md` says to read it generically as "grantee linked to vault owner," and nothing in
38
- the access-policy logic cares what a principal represents. The same three pieces above fit a
39
- client's encrypted files shared with revocable access for outside counsel, or a household's
40
- financial records shared temporarily with an accountant at tax time — any case with an owner, a
41
- resource only they can decrypt by default, and a need to grant and later cut off someone else's
42
- access to it.
17
+ ### The healthcare deployment this was built for
18
+
19
+ This package was extracted from a health-records app, and that origin is worth stating plainly
20
+ rather than hiding: it's a real, fully worked use case, not a footnote. Map its vocabulary onto
21
+ this package's generic one and the fit is exact. The vault **owner** is a patient; a **provider**
22
+ link is a treating clinician's standing access to that patient's record; a **support** link is a
23
+ care-team member's time-boxed access, handled by `break-glass.ts`'s grant/check/revoke lifecycle
24
+ so a temporary exception doesn't quietly become a permanent one; the audit log in
25
+ `adapters/d1/audit.ts` is shaped to satisfy the FTC Health Breach Notification Rule's
26
+ disclosure-logging requirement who accessed whose record and why, so a breach can be scoped to
27
+ affected individuals.
28
+
29
+ `crypto.ts` gives that app a zero-knowledge vault the storage operator holds ciphertext and
30
+ never a key or usable plaintext, which matters when the payload is a medical record.
31
+ `envelope-access.ts` gives it consent-based sharing — access is a per-principal, revocable
32
+ wrapped-key grant, not a shared secret or a role flag, which is what "the patient controls who
33
+ sees their record" actually requires at the implementation level. `break-glass.ts` gives it the
34
+ piece hand-rolled HIPAA-adjacent systems get wrong most often: a time-boxed grant with the TTL
35
+ clamp, self-expiry, and audit trail built in. None of this makes the package itself HIPAA-
36
+ compliant it's a primitive an adopter builds compliant handling on top of, not a compliance
37
+ product in its own right but the shape it ships is exactly the shape that adopter needs, not
38
+ something assembled from unrelated parts after the fact.
39
+
40
+ ### Beyond health
41
+
42
+ The same three primitives — an encrypted resource only its owner can read by default, a revocable
43
+ per-principal grant, and a time-boxed version of that grant — solve an identical problem anywhere
44
+ one party owns sensitive data and needs to hand a second party temporary or standing access to it,
45
+ under a record of who saw what and when. None of `stores.ts`'s contracts, `crypto.ts`'s envelope
46
+ format, or `break-glass.ts`'s lifecycle reference anything about the payload's shape or domain.
47
+ Concretely, beyond the healthcare case above:
48
+
49
+ - **Outside counsel** reviewing a client's encrypted case files, access granted for the matter's
50
+ duration and revoked when it closes.
51
+ - **An accountant** getting temporary access to a household's financial records at tax time,
52
+ through the same time-boxed grant `break-glass.ts` gives a clinician.
53
+ - **A corporate IT admin** granted standing access to an employee's HR file for the audit trail
54
+ it creates, or temporary access during an offboarding review.
55
+ - **A SaaS support agent** getting time-boxed access to a customer's account data to debug a
56
+ ticket, auto-expiring instead of depending on someone remembering to revoke it.
43
57
 
44
58
  Claims like these are only worth as much as the threat model backing them. **Read
45
59
  `THREAT_MODEL.md` before you adopt this** — it documents what's in scope, what isn't, and two
46
60
  deliberate design tradeoffs (extractable keys, no forward secrecy on revoke) that look like bugs
47
- if you haven't read them first. Notice, while you're there, that it's written in fully generic
48
- terms too — no "patient," no "provider," no health-specific language anywhere in it. That's not
49
- an oversight; it's the same evidence the argument above rests on, stated a second way.
61
+ if you haven't read them first. It's written in the same domain-neutral vocabulary as the rest of
62
+ this package — no health-specific language anywhere in it. That's not an oversight; it's the same
63
+ evidence the argument above rests on, stated a second way.
50
64
 
51
65
  ## What it does
52
66
 
@@ -170,7 +184,7 @@ const { publicKeyJwk, privateKey } = await generateAccountKeypair();
170
184
 
171
185
  // Encrypt a payload (any JSON-serializable value) under a fresh, random DEK.
172
186
  const dek = await generateDEK();
173
- const envelope = await encryptVaultV2({ note: "patient-controlled data" }, dek);
187
+ const envelope = await encryptVaultV2({ note: "owner-controlled data" }, dek);
174
188
 
175
189
  // Grant this account access by wrapping the DEK for its public key.
176
190
  const wrapped = await wrapDEKForPublicKey(dek, publicKeyJwk);
@@ -179,7 +193,7 @@ const wrapped = await wrapDEKForPublicKey(dek, publicKeyJwk);
179
193
  const recoveredDek = await unwrapDEKWithPrivateKey(wrapped.wrappedDEK, wrapped.ephemeralPublicKeyJwk, privateKey);
180
194
  const plaintext = await decryptVaultV2(envelope, recoveredDek);
181
195
 
182
- console.log(plaintext); // { note: "patient-controlled data" }
196
+ console.log(plaintext); // { note: "owner-controlled data" }
183
197
  ```
184
198
 
185
199
  This package ships TypeScript source directly (no compiled `dist/`) — the subpath imports above
@@ -203,7 +217,7 @@ import type { Envelope, ProviderLink, VaultRow } from "@tinytars/vault/stores";
203
217
 
204
218
  const envelopes = new Map<string, Envelope>(); // key: `${vaultId}:${principalAccountId}`
205
219
  const vaults = new Map<string, VaultRow>();
206
- const providerLinks = new Map<string, ProviderLink>(); // key: `${patientAccountId}:${providerAccountId}`
220
+ const providerLinks = new Map<string, ProviderLink>(); // key: `${ownerAccountId}:${providerAccountId}`
207
221
 
208
222
  const envelopeSource: EnvelopeAccessSource = {
209
223
  async getEnvelopeRow(vaultId, principalAccountId) {
@@ -215,8 +229,8 @@ const envelopeSource: EnvelopeAccessSource = {
215
229
  };
216
230
 
217
231
  const providerLinkSource: ProviderLinkSource = {
218
- async getActive(patientAccountId, providerAccountId) {
219
- return providerLinks.get(`${patientAccountId}:${providerAccountId}`) ?? null;
232
+ async getActive(ownerAccountId, providerAccountId) {
233
+ return providerLinks.get(`${ownerAccountId}:${providerAccountId}`) ?? null;
220
234
  },
221
235
  };
222
236
 
@@ -267,8 +281,8 @@ is entirely your own auth middleware's job. See `THREAT_MODEL.md`'s "trust bound
267
281
  | `adapters/conformance.ts` | Shared vitest contract suites for each `stores.ts` interface, run against every adapter above so "storage-agnostic" is proven, not asserted |
268
282
  | `auth-client.ts` | Password/passkey/Google signup, login, session resume, account-settings method management — the browser-side orchestration wiring `crypto.ts` to a specific `/api/auth/*`/`/api/account/*` API. Reference client, not a portable primitive |
269
283
  | `auth-recovery.ts` | Recovery-code issuance/redemption (owner and provider-issued), DEK rotation, access-event log fetch — same reference-client caveat as `auth-client.ts` |
270
- | `auth-support.ts` | Audited support-agent access: patient approves a pending request, support enters via an audited endpoint; support→provider roster access |
271
- | `auth-grants.ts` | Provider/clinician grant CRUD from the patient side: lookup, grant, revoke |
284
+ | `auth-support.ts` | Audited support-agent access: owner approves a pending request, support enters via an audited endpoint; support→provider roster access |
285
+ | `auth-grants.ts` | Provider grant CRUD from the owner side: lookup, grant, revoke |
272
286
  | `org-recovery.ts` | Backfills the org-recovery envelope for accounts that predate or missed it at signup — best-effort, never blocks an unlock |
273
287
  | `vault-session.ts` | `VaultEntry`/`VaultSession` types plus `openVault()` — the decrypt-and-open-session step every unlock path shares |
274
288
  | `base64.ts` | Byte ↔ base64 codec used throughout the client layer |
@@ -164,14 +164,14 @@ export function runProviderLinkStoreConformance(label: string, factory: () => Pr
164
164
  describe(`ProviderLinkStore conformance (${label})`, () => {
165
165
  it("creates a link, defaulting status to invited", async () => {
166
166
  const store = await factory();
167
- const link = await store.create({ patientAccountId: "p1", providerAccountId: "d1", role: "clinician", grantedBy: "p1" });
167
+ const link = await store.create({ ownerAccountId: "p1", providerAccountId: "d1", role: "primary", grantedBy: "p1" });
168
168
  expect(link.status).toBe("invited");
169
169
  expect(await store.get(link.id)).toEqual(link);
170
170
  });
171
171
 
172
172
  it("getActive is null for an invited (non-active) link", async () => {
173
173
  const store = await factory();
174
- const link = await store.create({ patientAccountId: "p1", providerAccountId: "d1", role: "clinician", grantedBy: "p1" });
174
+ const link = await store.create({ ownerAccountId: "p1", providerAccountId: "d1", role: "primary", grantedBy: "p1" });
175
175
  expect(await store.getActive("p1", "d1")).toBeNull();
176
176
  await store.updateStatus(link.id, "active");
177
177
  expect((await store.getActive("p1", "d1"))?.id).toBe(link.id);
@@ -179,14 +179,14 @@ export function runProviderLinkStoreConformance(label: string, factory: () => Pr
179
179
 
180
180
  it("getActive is null once expiresAt is in the past", async () => {
181
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" });
182
+ const link = await store.create({ ownerAccountId: "p1", providerAccountId: "d1", role: "support", grantedBy: "p1", status: "active", expiresAt: "2000-01-01T00:00:00.000Z" });
183
183
  expect(link.status).toBe("active");
184
184
  expect(await store.getActive("p1", "d1")).toBeNull();
185
185
  });
186
186
 
187
187
  it("grantSupport activates and time-boxes a link", async () => {
188
188
  const store = await factory();
189
- const link = await store.create({ patientAccountId: "p1", providerAccountId: "d1", role: "support", grantedBy: "p1" });
189
+ const link = await store.create({ ownerAccountId: "p1", providerAccountId: "d1", role: "support", grantedBy: "p1" });
190
190
  const expiresAt = new Date(Date.now() + 3600_000).toISOString();
191
191
  await store.grantSupport(link.id, { expiresAt, consentRef: "consent-1" });
192
192
  const after = await store.get(link.id);
@@ -194,12 +194,12 @@ export function runProviderLinkStoreConformance(label: string, factory: () => Pr
194
194
  expect(after?.expiresAt).toBe(expiresAt);
195
195
  });
196
196
 
197
- it("listForPatient / listForProvider scope correctly", async () => {
197
+ it("listForOwner / listForProvider scope correctly", async () => {
198
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);
199
+ await store.create({ ownerAccountId: "p1", providerAccountId: "d1", role: "primary", grantedBy: "p1" });
200
+ await store.create({ ownerAccountId: "p1", providerAccountId: "d2", role: "primary", grantedBy: "p1" });
201
+ await store.create({ ownerAccountId: "p2", providerAccountId: "d1", role: "primary", grantedBy: "p2" });
202
+ expect(await store.listForOwner("p1")).toHaveLength(2);
203
203
  expect(await store.listForProvider("d1")).toHaveLength(2);
204
204
  });
205
205
  });
@@ -70,7 +70,7 @@ export async function getAccountByEmail(db: D1Database, email: string): Promise<
70
70
  /**
71
71
  * The instant before which this account's session cookies are no longer accepted, or null.
72
72
  *
73
- * Read on every authenticated request, by the adopter's own session-check middleware. One indexed lookup by primary
73
+ * Read on every authenticated request (see `requireSession`). One indexed lookup by primary
74
74
  * key is what buys revocability: the cookie is self-contained, so without a server-side fact to
75
75
  * check against, nothing short of rotating SESSION_SECRET for the entire deployment can invalidate one.
76
76
  */
@@ -2,12 +2,15 @@ import type { D1Database } from "./types";
2
2
  import type { AccessEvent } from "../../stores";
3
3
  export type { AccessEvent };
4
4
 
5
- // AuditStore's two members only — PHI-access events. Lifecycle/CRM events and raw-object ownership
6
- // bookkeeping are app-specific concerns that stay in the app's identity-audit.ts (see stores.ts's
7
- // own docstring on AuditStore).
5
+ // AuditStore's two members only — consent-scoped vault-access events. Lifecycle/CRM events and
6
+ // raw-object ownership bookkeeping are app-specific concerns that stay in the app's
7
+ // identity-audit.ts (see stores.ts's own docstring on AuditStore).
8
8
 
9
- // FTC-HBNR (§I) PHI-access/disclosure audit log. Records WHO (actor) accessed WHOSE (subject)
10
- // vault and WHY (action + consent_ref), so a breach can be scoped to affected individuals. NO PHI.
9
+ // A generic actor/subject/action audit log for consent-scoped vault access e.g., satisfies FTC
10
+ // Health Breach Notification Rule PHI-disclosure logging for healthcare deployments, or analogous
11
+ // breach-notification and access-audit obligations in other regulated domains. Records WHO
12
+ // (actor) accessed WHOSE (subject) vault and WHY (action + consent_ref), so a breach can be
13
+ // scoped to affected individuals. NO PHI.
11
14
  interface AccessEventRow {
12
15
  id: string;
13
16
  actor_account_id: string;
@@ -99,8 +99,8 @@ export async function getCredential(db: D1Database, accountId: string, method: A
99
99
  return row ? mapCredential(row) : null;
100
100
  }
101
101
 
102
- // The account's key-bearing methods (password/passkey/recovery), for the account-settings screen and
103
- // the "don't orphan the vault key on remove" invariant. The credentials table is the source of truth (each
102
+ // The account's key-bearing methods (password/passkey/recovery), for the Account screen and the
103
+ // "don't orphan the vault key on remove" invariant. The credentials table is the source of truth (each
104
104
  // row independently wraps the same private key); identities lacks a recovery row.
105
105
  export async function listCredentials(db: D1Database, accountId: string): Promise<{ method: AuthMethod; createdAt: string }[]> {
106
106
  const { results } = await db
@@ -157,14 +157,14 @@ export class D1ProviderLinkStore implements ProviderLinkStore {
157
157
  get(id: string) {
158
158
  return providers.getProviderLink(this.db, id);
159
159
  }
160
- listForPatient(patientAccountId: string) {
161
- return providers.listProvidersForPatient(this.db, patientAccountId);
160
+ listForOwner(ownerAccountId: string) {
161
+ return providers.listProvidersForOwner(this.db, ownerAccountId);
162
162
  }
163
163
  listForProvider(providerAccountId: string) {
164
- return providers.listPatientsForProvider(this.db, providerAccountId);
164
+ return providers.listOwnersForProvider(this.db, providerAccountId);
165
165
  }
166
- getActive(patientAccountId: string, providerAccountId: string) {
167
- return providers.getActiveProviderLink(this.db, patientAccountId, providerAccountId);
166
+ getActive(ownerAccountId: string, providerAccountId: string) {
167
+ return providers.getActiveProviderLink(this.db, ownerAccountId, providerAccountId);
168
168
  }
169
169
  }
170
170
 
@@ -13,10 +13,14 @@ interface ProviderLinkRow {
13
13
  granted_at: string;
14
14
  expires_at: string | null;
15
15
  }
16
+ // The `patient_account_id` column name is a legacy leftover from before this package's TS-facing
17
+ // vocabulary was genericized to "owner" — renaming a live D1 column is a schema migration against
18
+ // real production data, which that vocabulary cleanup had no reason to force. This mapping function
19
+ // is the boundary that absorbs the difference; the TS-facing shape has never said "patient".
16
20
  function mapProviderLink(r: ProviderLinkRow): ProviderLink {
17
21
  return {
18
22
  id: r.id,
19
- patientAccountId: r.patient_account_id,
23
+ ownerAccountId: r.patient_account_id,
20
24
  providerAccountId: r.provider_account_id,
21
25
  role: r.role,
22
26
  status: r.status,
@@ -30,7 +34,7 @@ function mapProviderLink(r: ProviderLinkRow): ProviderLink {
30
34
  export async function createProviderLink(
31
35
  db: D1Database,
32
36
  l: {
33
- patientAccountId: string;
37
+ ownerAccountId: string;
34
38
  providerAccountId: string;
35
39
  role: ProviderKind;
36
40
  status?: LinkStatus;
@@ -49,11 +53,11 @@ export async function createProviderLink(
49
53
  .prepare(
50
54
  "INSERT INTO provider_links (id, patient_account_id, provider_account_id, role, status, consent_ref, granted_by, granted_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
51
55
  )
52
- .bind(id, l.patientAccountId, l.providerAccountId, l.role, status, consentRef, l.grantedBy, grantedAt, expiresAt)
56
+ .bind(id, l.ownerAccountId, l.providerAccountId, l.role, status, consentRef, l.grantedBy, grantedAt, expiresAt)
53
57
  .run();
54
58
  return {
55
59
  id,
56
- patientAccountId: l.patientAccountId,
60
+ ownerAccountId: l.ownerAccountId,
57
61
  providerAccountId: l.providerAccountId,
58
62
  role: l.role,
59
63
  status,
@@ -85,15 +89,15 @@ export async function getProviderLink(db: D1Database, id: string): Promise<Provi
85
89
  return row ? mapProviderLink(row) : null;
86
90
  }
87
91
 
88
- export async function listProvidersForPatient(db: D1Database, patientAccountId: string): Promise<ProviderLink[]> {
92
+ export async function listProvidersForOwner(db: D1Database, ownerAccountId: string): Promise<ProviderLink[]> {
89
93
  const { results } = await db
90
94
  .prepare("SELECT * FROM provider_links WHERE patient_account_id = ?")
91
- .bind(patientAccountId)
95
+ .bind(ownerAccountId)
92
96
  .all<ProviderLinkRow>();
93
97
  return results.map(mapProviderLink);
94
98
  }
95
99
 
96
- export async function listPatientsForProvider(db: D1Database, providerAccountId: string): Promise<ProviderLink[]> {
100
+ export async function listOwnersForProvider(db: D1Database, providerAccountId: string): Promise<ProviderLink[]> {
97
101
  const { results } = await db
98
102
  .prepare("SELECT * FROM provider_links WHERE provider_account_id = ?")
99
103
  .bind(providerAccountId)
@@ -103,15 +107,15 @@ export async function listPatientsForProvider(db: D1Database, providerAccountId:
103
107
 
104
108
  export async function getActiveProviderLink(
105
109
  db: D1Database,
106
- patientAccountId: string,
110
+ ownerAccountId: string,
107
111
  providerAccountId: string
108
112
  ): Promise<ProviderLink | null> {
109
113
  const link = await db
110
114
  .prepare("SELECT * FROM provider_links WHERE patient_account_id = ? AND provider_account_id = ?")
111
- .bind(patientAccountId, providerAccountId)
115
+ .bind(ownerAccountId, providerAccountId)
112
116
  .first<ProviderLinkRow>();
113
117
  if (!link || link.status !== "active") return null;
114
- // `expires_at` is set on time-boxed support grants and null for clinician links.
118
+ // `expires_at` is set on time-boxed support grants and null for primary links.
115
119
  if (link.expires_at && Date.parse(link.expires_at) <= Date.now()) return null;
116
120
  return mapProviderLink(link);
117
121
  }
@@ -266,10 +266,10 @@ export class MemoryEnvelopeStore implements EnvelopeStore {
266
266
  export class MemoryProviderLinkStore implements ProviderLinkStore {
267
267
  private links = new Map<string, ProviderLink>();
268
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> {
269
+ async create(l: { ownerAccountId: string; providerAccountId: string; role: ProviderKind; status?: LinkStatus; consentRef?: string | null; grantedBy: string; expiresAt?: string | null; id?: string }): Promise<ProviderLink> {
270
270
  const link: ProviderLink = {
271
271
  id: l.id ?? crypto.randomUUID(),
272
- patientAccountId: l.patientAccountId,
272
+ ownerAccountId: l.ownerAccountId,
273
273
  providerAccountId: l.providerAccountId,
274
274
  role: l.role,
275
275
  status: l.status ?? "invited",
@@ -300,17 +300,17 @@ export class MemoryProviderLinkStore implements ProviderLinkStore {
300
300
  return l ? { ...l } : null;
301
301
  }
302
302
 
303
- async listForPatient(patientAccountId: string): Promise<ProviderLink[]> {
304
- return [...this.links.values()].filter((l) => l.patientAccountId === patientAccountId).map((l) => ({ ...l }));
303
+ async listForOwner(ownerAccountId: string): Promise<ProviderLink[]> {
304
+ return [...this.links.values()].filter((l) => l.ownerAccountId === ownerAccountId).map((l) => ({ ...l }));
305
305
  }
306
306
 
307
307
  async listForProvider(providerAccountId: string): Promise<ProviderLink[]> {
308
308
  return [...this.links.values()].filter((l) => l.providerAccountId === providerAccountId).map((l) => ({ ...l }));
309
309
  }
310
310
 
311
- async getActive(patientAccountId: string, providerAccountId: string): Promise<ProviderLink | null> {
311
+ async getActive(ownerAccountId: string, providerAccountId: string): Promise<ProviderLink | null> {
312
312
  const link = [...this.links.values()].find(
313
- (l) => l.patientAccountId === patientAccountId && l.providerAccountId === providerAccountId
313
+ (l) => l.ownerAccountId === ownerAccountId && l.providerAccountId === providerAccountId
314
314
  );
315
315
  if (!link || link.status !== "active") return null;
316
316
  if (link.expiresAt && Date.parse(link.expiresAt) <= Date.now()) return null;
package/adapters/r2.ts CHANGED
@@ -1,14 +1,13 @@
1
1
  import type { BlobStore, BlobConditional, StoredBlob } from "../blob-store";
2
2
 
3
3
  // Minimal structural type for the R2 binding — no @cloudflare/workers-types dependency, and
4
- // trivially mockable in tests. An adopter's own Pages Function route should import R2Bucket/
5
- // R2BlobStore from here instead of declaring its own copy.
4
+ // trivially mockable in tests.
6
5
  export interface R2ObjectBody {
7
6
  body: ReadableStream;
8
7
  /** The version token. Handed to the browser on GET and sent back as If-Match on PUT. */
9
8
  etag: string;
10
9
  }
11
- /** A precondition on a write. Verify this against real `workerd` in an adopter's own test suite — see ARCHITECTURE.md's "Adapters" section. */
10
+ /** A precondition on a write. Real workerd conditional-write semantics are verified in an adopter's own test suite, not here — see CHANGELOG.md. */
12
11
  export interface R2Conditional {
13
12
  etagMatches?: string;
14
13
  etagDoesNotMatch?: string;
@@ -17,8 +16,8 @@ export interface R2Bucket {
17
16
  get(key: string): Promise<R2ObjectBody | null>;
18
17
  /**
19
18
  * 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.
19
+ * Null-on-failure rather than a throw is observed behaviour against workerd, not an assumption —
20
+ * an adopter's own test suite is where this gets pinned; see CHANGELOG.md.
22
21
  */
23
22
  put(key: string, value: Uint8Array, options?: { onlyIf?: R2Conditional }): Promise<{ etag: string } | null>;
24
23
  delete(key: string): Promise<void>;