@tinytars/vault 0.1.17 → 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
  });
@@ -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 Health Breach Notification Rule 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;
@@ -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,
@@ -68,7 +72,7 @@ export async function updateProviderLinkStatus(db: D1Database, id: string, statu
68
72
  await db.prepare("UPDATE provider_links SET status = ? WHERE id = ?").bind(status, id).run();
69
73
  }
70
74
 
71
- // A patient approving a support request: flip the link active, stamp its time-box + consent.
75
+ // An owner approving a support request: flip the link active, stamp its time-box + consent.
72
76
  export async function grantSupportLink(
73
77
  db: D1Database,
74
78
  id: string,
@@ -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/auth-client.ts CHANGED
@@ -20,6 +20,7 @@ import type {
20
20
  PublicKeyCredentialRequestOptionsJSON,
21
21
  } from "@simplewebauthn/browser";
22
22
  import { bytesToB64 as bytesToBase64, b64ToBytes as base64ToBytes } from "./base64";
23
+ import type { ProviderKind } from "./stores";
23
24
  export { bytesToB64 as bytesToBase64, b64ToBytes as base64ToBytes } from "./base64";
24
25
 
25
26
  export const KDF_ITERATIONS = 200_000;
@@ -42,7 +43,7 @@ export function hexToBytes(hex: string): Uint8Array {
42
43
  *
43
44
  * Nearly every branch below threw `${what} failed: ${res.status}`, discarding a message the
44
45
  * endpoint had already composed for exactly this moment ("that recovery code has expired", "this
45
- * clinician already has access", "no access to this vault") and showing a patient a bare number
46
+ * provider already has access", "no access to this vault") and showing an owner a bare number
46
47
  * instead. These are the paths a person is on during the worst day they will have with this app, and
47
48
  * a status code tells them nothing about whether to retry, wait, or ask someone.
48
49
  *
@@ -327,7 +328,7 @@ export async function resumeSession(): Promise<{
327
328
  vaultId: string | null;
328
329
  r2Key: string | null;
329
330
  rotationPending: boolean;
330
- providerKind: "clinician" | "support" | null;
331
+ providerKind: ProviderKind | null;
331
332
  ownerEnvelope: { wrappedDEK: string; ephemeralPublicKeyJwk: JsonWebKey } | null;
332
333
  } | null> {
333
334
  const res = await fetch("/api/auth/session/resume", { cache: "no-store" });
@@ -350,7 +351,7 @@ export async function addGoogleMethod(privateKey: CryptoKey, currentPassword?: s
350
351
  if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || `add google failed: ${res.status}`);
351
352
  }
352
353
 
353
- export async function getMyAccount(): Promise<{ id: string; email: string | null; emailConfirmed: boolean; displayName: string; providerKind: "clinician" | "support" | null; unitSystem: "metric" | "imperial" | null }> {
354
+ export async function getMyAccount(): Promise<{ id: string; email: string | null; emailConfirmed: boolean; displayName: string; providerKind: ProviderKind | null; unitSystem: "metric" | "imperial" | null }> {
354
355
  const res = await fetch("/api/account", { cache: "no-store" });
355
356
  if (!res.ok) throw await failed(res, "account fetch failed");
356
357
  return res.json();
package/auth-grants.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { wrapDEKForPublicKey } from "./crypto";
2
2
  import { bytesToBase64, failed } from "./auth-client";
3
+ import type { ProviderKind } from "./stores";
3
4
 
4
- // Provider escrow (patient side). A logged-in patient grants a provider access by wrapping
5
+ // Provider escrow (owner side). A logged-in owner grants a provider access by wrapping
5
6
  // their in-memory DEK to the provider's public key client-side and posting the opaque envelope; the
6
7
  // server never sees a plaintext DEK. Revoke deletes the envelope + marks the link revoked.
7
8
 
@@ -9,7 +10,7 @@ export interface ProviderLinkView {
9
10
  linkId: string;
10
11
  providerAccountId: string;
11
12
  displayName: string;
12
- kind: "clinician" | "support";
13
+ kind: ProviderKind;
13
14
  status: "invited" | "active" | "revoked";
14
15
  expiresAt: string | null;
15
16
  publicKeyJwk?: JsonWebKey | null; // present only for a pending (invited) support request
package/auth-recovery.ts CHANGED
@@ -162,7 +162,7 @@ export async function recoverAccount(
162
162
  // Regenerate the recovery code (owner session): re-wrap the in-memory private key under a fresh code +
163
163
  // store the verifier. Returns the new code to display once.
164
164
  // ── Provider-issued recovery ──────────────────────────────────────────────────
165
- // Apple's recovery-contact model: a clinician who already holds the patient's DEK re-wraps it under a
165
+ // Apple's recovery-contact model: a provider who already holds the owner's DEK re-wraps it under a
166
166
  // one-time code and READS THE CODE TO THEM. It is never emailed — see RECOVERY.md I2; the server would
167
167
  // have to be given the code, and it already holds the wrapped DEK.
168
168
 
@@ -191,11 +191,11 @@ export function detectRecoveryKind(raw: string): "code" | "grant" {
191
191
  }
192
192
 
193
193
  /**
194
- * Clinician side. Unwraps the patient's DEK with the clinician's own key — which is what provider
194
+ * Provider side. Unwraps the owner's DEK with the provider's own key — which is what provider
195
195
  * access already is — and re-wraps it under the code. Returns the code to display once.
196
196
  */
197
197
  export async function issueRecoveryCode(
198
- patientAccountId: string,
198
+ ownerAccountId: string,
199
199
  envelope: { wrappedDEK: string; ephemeralPublicKeyJwk: JsonWebKey },
200
200
  providerKey: CryptoKey,
201
201
  ): Promise<{ code: string; expiresAt: string }> {
@@ -207,7 +207,7 @@ export async function issueRecoveryCode(
207
207
  method: "POST",
208
208
  headers: { "content-type": "application/json" },
209
209
  body: JSON.stringify({
210
- patientAccountId,
210
+ ownerAccountId,
211
211
  wrappedDek: bytesToBase64(await wrapDEKWithKek(dek, await deriveKekFromPassword(normalized, salt))),
212
212
  kdfParams: { salt: bytesToHex(salt), iterations: KDF_ITERATIONS },
213
213
  codeAuthHash: await deriveAuthHash(normalized, salt),
@@ -218,12 +218,12 @@ export async function issueRecoveryCode(
218
218
  }
219
219
 
220
220
  /**
221
- * Patient side. Two calls against the same endpoint: the first proves the code and returns the wrapped
221
+ * Owner side. Two calls against the same endpoint: the first proves the code and returns the wrapped
222
222
  * DEK, the second installs a brand-new keypair locked under the new password. The code is proved on
223
223
  * both — the second is not authorised by the first having happened.
224
224
  *
225
225
  * Unlike `recoverAccount`, this MINTS A NEW KEYPAIR rather than re-wrapping the old one, because the
226
- * old private key is exactly what the patient no longer has. That is why the server clears the other
226
+ * old private key is exactly what the owner no longer has. That is why the server clears the other
227
227
  * credentials: they wrap a key nothing references any more.
228
228
  */
229
229
  export async function redeemRecoveryCode(
package/auth-support.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  import { wrapDEKForPublicKey } from "./crypto";
2
2
  import { bytesToBase64, failed } from "./auth-client";
3
3
 
4
- // Support consented-access. A patient approves a pending support request by wrapping their
4
+ // Support consented-access. A vault owner approves a pending support request by wrapping their
5
5
  // in-memory DEK to the support agent's public key (time-boxed); support enters via an audited endpoint.
6
6
 
7
- // Patient side — approve a pending support request (linkId + the agent's publicKeyJwk from GET /api/providers).
7
+ // Owner side — approve a pending support request (linkId + the agent's publicKeyJwk from GET /api/providers).
8
8
  export async function approveSupport(linkId: string, dek: CryptoKey, publicKeyJwk: JsonWebKey, ttlHours: number): Promise<void> {
9
9
  const env = await wrapDEKForPublicKey(dek, publicKeyJwk);
10
10
  const res = await fetch("/api/support/approve", {
@@ -16,30 +16,30 @@ export async function approveSupport(linkId: string, dek: CryptoKey, publicKeyJw
16
16
  }
17
17
 
18
18
  // Support side.
19
- export interface SupportPatient {
20
- patientAccountId: string;
19
+ export interface SupportOwner {
20
+ ownerAccountId: string;
21
21
  displayName: string;
22
22
  expiresAt: string | null;
23
23
  }
24
24
 
25
- export async function requestSupportAccess(patientEmail: string): Promise<void> {
25
+ export async function requestSupportAccess(ownerEmail: string): Promise<void> {
26
26
  const res = await fetch("/api/support/request", {
27
27
  method: "POST",
28
28
  headers: { "content-type": "application/json" },
29
- body: JSON.stringify({ patientEmail }),
29
+ body: JSON.stringify({ ownerEmail }),
30
30
  });
31
31
  if (!res.ok) throw await failed(res, "request failed");
32
32
  }
33
33
 
34
- export async function listSupportPatients(): Promise<SupportPatient[]> {
35
- const res = await fetch("/api/support/patients", { cache: "no-store" });
36
- if (!res.ok) throw await failed(res, "support patients failed");
37
- return ((await res.json()) as { patients: SupportPatient[] }).patients;
34
+ export async function listSupportOwners(): Promise<SupportOwner[]> {
35
+ const res = await fetch("/api/support/owners", { cache: "no-store" });
36
+ if (!res.ok) throw await failed(res, "support owners failed");
37
+ return ((await res.json()) as { owners: SupportOwner[] }).owners;
38
38
  }
39
39
 
40
- // Enter a patient (audited server-side); returns the envelope for client-side DEK unwrap.
41
- export async function enterSupportPatient(patientAccountId: string): Promise<{
42
- patientAccountId: string;
40
+ // Enter an owner's vault (audited server-side); returns the envelope for client-side DEK unwrap.
41
+ export async function enterSupportOwner(ownerAccountId: string): Promise<{
42
+ ownerAccountId: string;
43
43
  displayName: string;
44
44
  email: string | null;
45
45
  vaultId: string;
@@ -49,15 +49,15 @@ export async function enterSupportPatient(patientAccountId: string): Promise<{
49
49
  const res = await fetch("/api/support/access", {
50
50
  method: "POST",
51
51
  headers: { "content-type": "application/json" },
52
- body: JSON.stringify({ patientAccountId }),
52
+ body: JSON.stringify({ ownerAccountId }),
53
53
  });
54
54
  if (!res.ok) throw await failed(res, "support access failed");
55
55
  return res.json();
56
56
  }
57
57
 
58
- // Support→provider roster access. A support agent can request access to a clinician too (same
59
- // /api/support/request, which classifies by the target's kind). The clinician approves (metadata only,
60
- // no DEK), then support sees the clinician's roster and can open the patients who separately consented.
58
+ // Support→provider roster access. A support agent can request access to a primary provider too (same
59
+ // /api/support/request, which classifies by the target's kind). The primary provider approves (metadata
60
+ // only, no DEK), then support sees their roster and can open the owners who separately consented.
61
61
 
62
62
  export interface SupportProvider {
63
63
  linkId: string;
@@ -67,8 +67,8 @@ export interface SupportProvider {
67
67
  expiresAt: string | null;
68
68
  }
69
69
 
70
- export interface SupportRosterPatient {
71
- patientAccountId: string;
70
+ export interface SupportRosterOwner {
71
+ ownerAccountId: string;
72
72
  displayName: string;
73
73
  email: string | null;
74
74
  openable: boolean;
@@ -80,7 +80,7 @@ export interface SupportRequest {
80
80
  targetAccountId: string;
81
81
  displayName: string;
82
82
  email: string | null;
83
- kind: "patient" | "provider";
83
+ kind: "owner" | "provider";
84
84
  }
85
85
 
86
86
  // Provider side — approve a pending support roster request (no DEK; a provider owns no vault).
@@ -99,10 +99,10 @@ export async function listSupportProviders(): Promise<SupportProvider[]> {
99
99
  return ((await res.json()) as { providers: SupportProvider[] }).providers;
100
100
  }
101
101
 
102
- export async function getProviderRoster(providerId: string): Promise<SupportRosterPatient[]> {
102
+ export async function getProviderRoster(providerId: string): Promise<SupportRosterOwner[]> {
103
103
  const res = await fetch(`/api/support/provider-roster?providerId=${encodeURIComponent(providerId)}`, { cache: "no-store" });
104
104
  if (!res.ok) throw await failed(res, "provider roster failed");
105
- return ((await res.json()) as { roster: SupportRosterPatient[] }).roster;
105
+ return ((await res.json()) as { roster: SupportRosterOwner[] }).roster;
106
106
  }
107
107
 
108
108
  export async function listSupportRequests(): Promise<SupportRequest[]> {
package/break-glass.ts CHANGED
@@ -26,7 +26,7 @@ export interface BreakGlassGrantStores {
26
26
 
27
27
  /**
28
28
  * Approves a pending support-role link: validates it's the approver's own pending request, clamps the
29
- * requested TTL, stamps a consent ref, optionally writes an envelope (patient approvals only — a
29
+ * requested TTL, stamps a consent ref, optionally writes an envelope (owner approvals only — a
30
30
  * provider approving a roster request owns nothing encrypted), flips the link active, and audits.
31
31
  */
32
32
  export async function grantBreakGlass(
@@ -43,7 +43,7 @@ export async function grantBreakGlass(
43
43
  }
44
44
  ): Promise<BreakGlassGrantResult> {
45
45
  const link = await stores.links.get(opts.linkId);
46
- if (!link || link.patientAccountId !== opts.approverAccountId || link.role !== "support" || link.status !== "invited") {
46
+ if (!link || link.ownerAccountId !== opts.approverAccountId || link.role !== "support" || link.status !== "invited") {
47
47
  return { ok: false, error: "no_pending_link" };
48
48
  }
49
49
 
@@ -124,10 +124,10 @@ export interface BreakGlassRevokeStores {
124
124
  }
125
125
 
126
126
  /**
127
- * Ends a link early — either side may call this (the patient revoking, or the provider dropping it).
127
+ * Ends a link early — either side may call this (the owner revoking, or the provider dropping it).
128
128
  * Deletes the provider's envelope (if any) so no new read can unwrap the DEK, and marks the link
129
129
  * revoked. Idempotent: a second call re-deletes (no-op) and re-marks revoked (no-op); `auditAction` is
130
- * omitted for link kinds that don't carry a disclosure-audit obligation (clinician links).
130
+ * omitted for link kinds that don't carry a disclosure-audit obligation (primary links).
131
131
  */
132
132
  export async function revokeBreakGlass(
133
133
  stores: BreakGlassRevokeStores,
@@ -8,7 +8,7 @@ export interface EnvelopeAccessSource {
8
8
  getVault(vaultId: string): Promise<VaultRow | null>;
9
9
  }
10
10
  export interface ProviderLinkSource {
11
- getActive(patientAccountId: string, providerAccountId: string): Promise<ProviderLink | null>;
11
+ getActive(ownerAccountId: string, providerAccountId: string): Promise<ProviderLink | null>;
12
12
  }
13
13
 
14
14
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tinytars/vault",
3
- "version": "0.1.17",
3
+ "version": "0.1.18",
4
4
  "description": "Runtime-agnostic key derivation, authenticated envelope encryption, and storage-agnostic access-control contracts for per-user encrypted data.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/stores.ts CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  export type LifecycleStage = "waitlist" | "lead" | "active" | "paying" | "churned";
8
8
  export type AuthMethod = "passkey" | "google" | "password" | "recovery";
9
- export type ProviderKind = "clinician" | "support";
9
+ export type ProviderKind = "primary" | "support";
10
10
  export type LinkStatus = "invited" | "active" | "revoked";
11
11
  export type UnitSystem = "metric" | "imperial";
12
12
 
@@ -74,18 +74,18 @@ export interface EnvelopeInput {
74
74
 
75
75
  export interface ProviderLink {
76
76
  id: string;
77
- patientAccountId: string;
77
+ ownerAccountId: string;
78
78
  providerAccountId: string;
79
79
  role: ProviderKind;
80
80
  status: LinkStatus;
81
81
  consentRef: string | null;
82
82
  grantedBy: string;
83
83
  grantedAt: string;
84
- /** Set on time-boxed support grants; null for clinician links. */
84
+ /** Set on time-boxed support grants; null for primary links. */
85
85
  expiresAt: string | null;
86
86
  }
87
87
 
88
- /** A PHI-access audit-log entry — who touched whose vault, and why. */
88
+ /** A consent-scoped access audit-log entry — who touched whose vault, and why. */
89
89
  export interface AccessEvent {
90
90
  id: string;
91
91
  actorAccountId: string;
@@ -159,7 +159,7 @@ export interface EnvelopeStore {
159
159
 
160
160
  export interface ProviderLinkStore {
161
161
  create(l: {
162
- patientAccountId: string;
162
+ ownerAccountId: string;
163
163
  providerAccountId: string;
164
164
  role: ProviderKind;
165
165
  status?: LinkStatus;
@@ -171,15 +171,16 @@ export interface ProviderLinkStore {
171
171
  updateStatus(id: string, status: LinkStatus): Promise<void>;
172
172
  grantSupport(id: string, opts: { expiresAt: string | null; consentRef?: string | null }): Promise<void>;
173
173
  get(id: string): Promise<ProviderLink | null>;
174
- listForPatient(patientAccountId: string): Promise<ProviderLink[]>;
174
+ listForOwner(ownerAccountId: string): Promise<ProviderLink[]>;
175
175
  listForProvider(providerAccountId: string): Promise<ProviderLink[]>;
176
- /** The active, unexpired link between this patient and provider, or null. */
177
- getActive(patientAccountId: string, providerAccountId: string): Promise<ProviderLink | null>;
176
+ /** The active, unexpired link between this owner and provider, or null. */
177
+ getActive(ownerAccountId: string, providerAccountId: string): Promise<ProviderLink | null>;
178
178
  }
179
179
 
180
180
  /**
181
- * The PHI-access half of an adopter's audit trail only. Lifecycle/CRM events and raw-object
182
- * ownership bookkeeping are app-specific concerns that don't belong in a portable security package.
181
+ * The consent-scoped access-event half of an adopter's audit trail only. Lifecycle/CRM events and
182
+ * raw-object ownership bookkeeping are app-specific concerns that don't belong in a portable
183
+ * security package.
183
184
  */
184
185
  export interface AuditStore {
185
186
  insertAccessEvent(e: { actorAccountId: string; subjectAccountId: string; vaultId?: string | null; action: string; consentRef?: string | null; meta?: unknown; id?: string }): Promise<AccessEvent>;
package/vault-session.ts CHANGED
@@ -6,7 +6,7 @@ import { decryptVaultV2 } from "./crypto";
6
6
  * agree by coincidence.
7
7
  */
8
8
  export interface VaultEntry {
9
- patientAccountId: string;
9
+ ownerAccountId: string;
10
10
  displayName: string;
11
11
  email: string | null;
12
12
  r2Key: string;
@@ -23,7 +23,7 @@ export interface VaultSession {
23
23
  * re-wrap it when adding a login method. Null in a provider or support session.
24
24
  */
25
25
  readonly ownerKey: CryptoKey | null;
26
- /** A provider account's private key, which unwraps each patient's envelope. Null for an owner. */
26
+ /** A provider account's private key, which unwraps each owner's envelope. Null for an owner. */
27
27
  readonly providerKey: CryptoKey | null;
28
28
  /** True only when a vault is genuinely open — derived, never tracked separately. */
29
29
  readonly isOpen: boolean;
@@ -37,8 +37,8 @@ export interface VaultSession {
37
37
  /**
38
38
  * Closes the open vault. Clears the data key and the id together.
39
39
  *
40
- * Does NOT clear the provider key: a provider who leaves one patient is still signed in and still
41
- * needs their own key to open the next. That asymmetry was already the behaviour of
40
+ * Does NOT clear the provider key: a provider who leaves one owner's vault is still signed in and
41
+ * still needs their own key to open the next. That asymmetry was already the behaviour of
42
42
  * `backToRoster()`; stating it here is what stops it being re-derived incorrectly later.
43
43
  */
44
44
  close(): void;
@@ -48,7 +48,7 @@ export interface VaultSession {
48
48
 
49
49
  /**
50
50
  * Decrypts a vault blob and opens the session on it in one step, shared by every path that unlocks
51
- * a vault — the signed-in owner's own, and a provider's drill-in to a patient's. `fetchBlob` stays a
51
+ * a vault — the signed-in owner's own, and a provider's drill-in to an owner's. `fetchBlob` stays a
52
52
  * caller-supplied thunk because fetching it (the R2 route, the ETag it must remember for later saves)
53
53
  * is app-local, not frame-generic.
54
54
  */