@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,160 @@
1
+ import type { D1Database } from "./types";
2
+ import type { Account, LifecycleStage, ProviderKind, UnitSystem } from "../../stores";
3
+ export type { Account };
4
+
5
+ interface AccountRow {
6
+ id: string;
7
+ email: string | null;
8
+ email_confirmed: number;
9
+ display_name: string;
10
+ lifecycle_stage: LifecycleStage;
11
+ provider_kind: ProviderKind | null;
12
+ unit_system: UnitSystem | null;
13
+ created_at: string;
14
+ deleted_at?: string | null;
15
+ email_changed_at?: string | null;
16
+ }
17
+ function mapAccount(r: AccountRow): Account {
18
+ return {
19
+ id: r.id,
20
+ email: r.email,
21
+ emailConfirmed: r.email_confirmed === 1,
22
+ displayName: r.display_name,
23
+ lifecycleStage: r.lifecycle_stage,
24
+ providerKind: r.provider_kind,
25
+ unitSystem: r.unit_system,
26
+ createdAt: r.created_at,
27
+ deletedAt: r.deleted_at ?? null,
28
+ emailChangedAt: r.email_changed_at ?? null,
29
+ };
30
+ }
31
+
32
+ export async function createAccount(
33
+ db: D1Database,
34
+ a: { id: string; displayName: string; email?: string | null; lifecycleStage?: LifecycleStage; providerKind?: ProviderKind | null }
35
+ ): Promise<Account> {
36
+ const createdAt = new Date().toISOString();
37
+ const email = a.email ?? null;
38
+ const lifecycleStage = a.lifecycleStage ?? "active";
39
+ const providerKind = a.providerKind ?? null;
40
+ await db
41
+ .prepare(
42
+ "INSERT INTO accounts (id, email, email_confirmed, display_name, lifecycle_stage, provider_kind, created_at) VALUES (?, ?, 0, ?, ?, ?, ?)"
43
+ )
44
+ .bind(a.id, email, a.displayName, lifecycleStage, providerKind, createdAt)
45
+ .run();
46
+ return {
47
+ id: a.id,
48
+ email,
49
+ emailConfirmed: false,
50
+ displayName: a.displayName,
51
+ lifecycleStage,
52
+ providerKind,
53
+ unitSystem: null,
54
+ createdAt,
55
+ deletedAt: null,
56
+ emailChangedAt: null,
57
+ };
58
+ }
59
+
60
+ export async function getAccount(db: D1Database, id: string): Promise<Account | null> {
61
+ const row = await db.prepare("SELECT * FROM accounts WHERE id = ?").bind(id).first<AccountRow>();
62
+ return row ? mapAccount(row) : null;
63
+ }
64
+
65
+ export async function getAccountByEmail(db: D1Database, email: string): Promise<Account | null> {
66
+ const row = await db.prepare("SELECT * FROM accounts WHERE email = ?").bind(email).first<AccountRow>();
67
+ return row ? mapAccount(row) : null;
68
+ }
69
+
70
+ /**
71
+ * The instant before which this account's session cookies are no longer accepted, or null.
72
+ *
73
+ * W71 — read on every authenticated request (see `requireSession`). One indexed lookup by primary
74
+ * key is what buys revocability: the cookie is self-contained, so without a server-side fact to
75
+ * check against, nothing short of rotating SESSION_SECRET for the entire deployment can invalidate one.
76
+ */
77
+ export async function sessionsValidFrom(db: D1Database, accountId: string): Promise<number | null> {
78
+ const row = await db.prepare("SELECT sessions_valid_from FROM accounts WHERE id = ?").bind(accountId).first<{ sessions_valid_from: string | null }>();
79
+ // A cookie for an account that no longer exists is not merely un-revoked, it is unusable. Returning
80
+ // Infinity rather than null makes deletion revoke by construction instead of by omission.
81
+ if (!row) return Number.POSITIVE_INFINITY;
82
+ return row.sessions_valid_from ? Math.floor(Date.parse(row.sessions_valid_from) / 1000) : null;
83
+ }
84
+
85
+ /**
86
+ * Invalidates every session cookie already issued for this account.
87
+ *
88
+ * Called on logout and after any change to how the account is authenticated — a replaced password, a
89
+ * removed passkey, a changed email. Those were the mutations that made a stolen cookie permanent:
90
+ * they changed the credential and left every existing session running.
91
+ *
92
+ * Truncated to the second, and the rule is `iat >= validFrom`, so a cookie minted in the SAME second
93
+ * as the revocation survives. That window is deliberate and cannot be closed without a per-session
94
+ * id: the cookie's `iat` has one-second resolution, so a cookie minted just before the revocation and
95
+ * one minted just after — by the login that immediately follows a logout — are indistinguishable.
96
+ * Rounding the other way would reject the new session instead, which trades a one-second exposure for
97
+ * a user who cannot log back in.
98
+ */
99
+ export async function revokeSessions(db: D1Database, accountId: string): Promise<void> {
100
+ await db
101
+ .prepare("UPDATE accounts SET sessions_valid_from = ? WHERE id = ?")
102
+ .bind(new Date(Math.floor(Date.now() / 1000) * 1000).toISOString(), accountId)
103
+ .run();
104
+ }
105
+
106
+ export async function setEmailConfirmed(db: D1Database, id: string, confirmed: boolean): Promise<void> {
107
+ await db.prepare("UPDATE accounts SET email_confirmed = ? WHERE id = ?").bind(confirmed ? 1 : 0, id).run();
108
+ }
109
+
110
+ export async function setLifecycleStage(db: D1Database, id: string, stage: LifecycleStage): Promise<void> {
111
+ await db.prepare("UPDATE accounts SET lifecycle_stage = ? WHERE id = ?").bind(stage, id).run();
112
+ }
113
+
114
+ export async function updateAccountProfile(
115
+ db: D1Database,
116
+ id: string,
117
+ updates: { email?: string | null; displayName?: string; unitSystem?: UnitSystem | null }
118
+ ): Promise<void> {
119
+ const sets: string[] = [];
120
+ const values: unknown[] = [];
121
+ if (updates.email !== undefined) {
122
+ sets.push("email = ?");
123
+ values.push(updates.email);
124
+ }
125
+ if (updates.displayName !== undefined) {
126
+ sets.push("display_name = ?");
127
+ values.push(updates.displayName);
128
+ }
129
+ if (updates.unitSystem !== undefined) {
130
+ sets.push("unit_system = ?");
131
+ values.push(updates.unitSystem);
132
+ }
133
+ if (sets.length === 0) return;
134
+ values.push(id);
135
+ await db.prepare(`UPDATE accounts SET ${sets.join(", ")} WHERE id = ?`).bind(...values).run();
136
+ }
137
+
138
+ /**
139
+ * Nulls every personal field and stamps `deleted_at`, leaving an opaque row behind. See the migration
140
+ * for why the row survives at all.
141
+ */
142
+ export async function tombstoneAccount(db: D1Database, accountId: string, at: string): Promise<void> {
143
+ await db
144
+ .prepare(
145
+ "UPDATE accounts SET email = NULL, email_confirmed = 0, display_name = '', lifecycle_stage = 'churned', provider_kind = NULL, unit_system = NULL, deleted_at = ? WHERE id = ?",
146
+ )
147
+ .bind(at, accountId)
148
+ .run();
149
+ }
150
+
151
+ /**
152
+ * W73 — stamps when the account's email address changed.
153
+ *
154
+ * Read by the recovery routes, which refuse to issue a grant while the address is still new: a stolen
155
+ * cookie that repoints the mailbox should not be able to convert that into a recovery code minutes
156
+ * later. See RECOVERY.md and migrations/0009.
157
+ */
158
+ export async function markEmailChanged(db: D1Database, accountId: string, at: string): Promise<void> {
159
+ await db.prepare("UPDATE accounts SET email_changed_at = ? WHERE id = ?").bind(at, accountId).run();
160
+ }
@@ -0,0 +1,58 @@
1
+ import type { D1Database } from "./types";
2
+ import type { AccessEvent } from "../../stores";
3
+ export type { AccessEvent };
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).
8
+
9
+ // W44 P4b — 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.
11
+ interface AccessEventRow {
12
+ id: string;
13
+ actor_account_id: string;
14
+ subject_account_id: string;
15
+ vault_id: string | null;
16
+ action: string;
17
+ consent_ref: string | null;
18
+ meta: string;
19
+ created_at: string;
20
+ }
21
+ function mapAccessEvent(r: AccessEventRow): AccessEvent {
22
+ return {
23
+ id: r.id,
24
+ actorAccountId: r.actor_account_id,
25
+ subjectAccountId: r.subject_account_id,
26
+ vaultId: r.vault_id,
27
+ action: r.action,
28
+ consentRef: r.consent_ref,
29
+ meta: JSON.parse(r.meta),
30
+ createdAt: r.created_at,
31
+ };
32
+ }
33
+
34
+ export async function insertAccessEvent(
35
+ db: D1Database,
36
+ e: { actorAccountId: string; subjectAccountId: string; vaultId?: string | null; action: string; consentRef?: string | null; meta?: unknown; id?: string }
37
+ ): Promise<AccessEvent> {
38
+ const id = e.id ?? crypto.randomUUID();
39
+ const vaultId = e.vaultId ?? null;
40
+ const consentRef = e.consentRef ?? null;
41
+ const meta = e.meta ?? {};
42
+ const createdAt = new Date().toISOString();
43
+ await db
44
+ .prepare(
45
+ "INSERT INTO phi_access_events (id, actor_account_id, subject_account_id, vault_id, action, consent_ref, meta, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
46
+ )
47
+ .bind(id, e.actorAccountId, e.subjectAccountId, vaultId, e.action, consentRef, JSON.stringify(meta), createdAt)
48
+ .run();
49
+ return { id, actorAccountId: e.actorAccountId, subjectAccountId: e.subjectAccountId, vaultId, action: e.action, consentRef, meta, createdAt };
50
+ }
51
+
52
+ export async function listAccessEventsForSubject(db: D1Database, subjectAccountId: string): Promise<AccessEvent[]> {
53
+ const { results } = await db
54
+ .prepare("SELECT * FROM phi_access_events WHERE subject_account_id = ? ORDER BY created_at")
55
+ .bind(subjectAccountId)
56
+ .all<AccessEventRow>();
57
+ return results.map(mapAccessEvent);
58
+ }
@@ -0,0 +1,154 @@
1
+ import type { D1Database } from "./types";
2
+ import { toBytes } from "./types";
3
+ import type { AuthMethod, Identity, Credential, PublicKey } from "../../stores";
4
+ export type { Identity, Credential, PublicKey };
5
+
6
+ interface IdentityRow {
7
+ id: string;
8
+ account_id: string;
9
+ method: AuthMethod;
10
+ provider_subject: string | null;
11
+ credential_id: string | null;
12
+ created_at: string;
13
+ }
14
+ function mapIdentity(r: IdentityRow): Identity {
15
+ return {
16
+ id: r.id,
17
+ accountId: r.account_id,
18
+ method: r.method,
19
+ providerSubject: r.provider_subject,
20
+ credentialId: r.credential_id,
21
+ createdAt: r.created_at,
22
+ };
23
+ }
24
+
25
+ export async function addIdentity(
26
+ db: D1Database,
27
+ i: { accountId: string; method: AuthMethod; providerSubject?: string | null; credentialId?: string | null; id?: string }
28
+ ): Promise<Identity> {
29
+ const id = i.id ?? crypto.randomUUID();
30
+ const createdAt = new Date().toISOString();
31
+ const providerSubject = i.providerSubject ?? null;
32
+ const credentialId = i.credentialId ?? null;
33
+ await db
34
+ .prepare(
35
+ "INSERT INTO identities (id, account_id, method, provider_subject, credential_id, created_at) VALUES (?, ?, ?, ?, ?, ?)"
36
+ )
37
+ .bind(id, i.accountId, i.method, providerSubject, credentialId, createdAt)
38
+ .run();
39
+ return { id, accountId: i.accountId, method: i.method, providerSubject, credentialId, createdAt };
40
+ }
41
+
42
+ export async function getIdentityByProviderSubject(
43
+ db: D1Database,
44
+ method: AuthMethod,
45
+ subject: string
46
+ ): Promise<Identity | null> {
47
+ const row = await db
48
+ .prepare("SELECT * FROM identities WHERE method = ? AND provider_subject = ?")
49
+ .bind(method, subject)
50
+ .first<IdentityRow>();
51
+ return row ? mapIdentity(row) : null;
52
+ }
53
+
54
+ export async function getIdentityByCredentialId(db: D1Database, credentialId: string): Promise<Identity | null> {
55
+ const row = await db.prepare("SELECT * FROM identities WHERE credential_id = ?").bind(credentialId).first<IdentityRow>();
56
+ return row ? mapIdentity(row) : null;
57
+ }
58
+
59
+ export async function listIdentities(db: D1Database, accountId: string): Promise<Identity[]> {
60
+ const { results } = await db.prepare("SELECT * FROM identities WHERE account_id = ?").bind(accountId).all<IdentityRow>();
61
+ return results.map(mapIdentity);
62
+ }
63
+
64
+ interface CredentialRow {
65
+ account_id: string;
66
+ method: AuthMethod;
67
+ wrapped_private_key: unknown;
68
+ kdf_params: string;
69
+ created_at: string;
70
+ }
71
+ function mapCredential(r: CredentialRow): Credential {
72
+ return {
73
+ accountId: r.account_id,
74
+ method: r.method,
75
+ wrappedPrivateKey: toBytes(r.wrapped_private_key),
76
+ kdfParams: JSON.parse(r.kdf_params),
77
+ createdAt: r.created_at,
78
+ };
79
+ }
80
+
81
+ export async function putCredential(
82
+ db: D1Database,
83
+ c: { accountId: string; method: AuthMethod; wrappedPrivateKey: Uint8Array; kdfParams: unknown }
84
+ ): Promise<void> {
85
+ const createdAt = new Date().toISOString();
86
+ await db
87
+ .prepare(
88
+ "INSERT OR REPLACE INTO credentials (account_id, method, wrapped_private_key, kdf_params, created_at) VALUES (?, ?, ?, ?, ?)"
89
+ )
90
+ .bind(c.accountId, c.method, c.wrappedPrivateKey, JSON.stringify(c.kdfParams), createdAt)
91
+ .run();
92
+ }
93
+
94
+ export async function getCredential(db: D1Database, accountId: string, method: AuthMethod): Promise<Credential | null> {
95
+ const row = await db
96
+ .prepare("SELECT * FROM credentials WHERE account_id = ? AND method = ?")
97
+ .bind(accountId, method)
98
+ .first<CredentialRow>();
99
+ return row ? mapCredential(row) : null;
100
+ }
101
+
102
+ // W44 P8 — 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
+ // row independently wraps the same private key); identities lacks a recovery row.
105
+ export async function listCredentials(db: D1Database, accountId: string): Promise<{ method: AuthMethod; createdAt: string }[]> {
106
+ const { results } = await db
107
+ .prepare("SELECT method, created_at FROM credentials WHERE account_id = ?")
108
+ .bind(accountId)
109
+ .all<{ method: AuthMethod; created_at: string }>();
110
+ return results.map((r) => ({ method: r.method, createdAt: r.created_at }));
111
+ }
112
+
113
+ export async function deleteCredential(db: D1Database, accountId: string, method: AuthMethod): Promise<void> {
114
+ await db.prepare("DELETE FROM credentials WHERE account_id = ? AND method = ?").bind(accountId, method).run();
115
+ }
116
+
117
+ export async function deleteIdentity(db: D1Database, accountId: string, method: AuthMethod): Promise<void> {
118
+ await db.prepare("DELETE FROM identities WHERE account_id = ? AND method = ?").bind(accountId, method).run();
119
+ }
120
+
121
+ // W44 P3 — bump the passkey authenticator's signature counter after a successful login
122
+ // (replay-attack detection). Merges into the existing kdf_params rather than a raw column
123
+ // update so the wrapped key row stays a single INSERT OR REPLACE-shaped record.
124
+ export async function updatePasskeyCounter(db: D1Database, accountId: string, counter: number): Promise<void> {
125
+ const cred = await getCredential(db, accountId, "passkey");
126
+ if (!cred) return;
127
+ const kdfParams = { ...(cred.kdfParams as Record<string, unknown>), counter };
128
+ await db
129
+ .prepare("UPDATE credentials SET kdf_params = ? WHERE account_id = ? AND method = 'passkey'")
130
+ .bind(JSON.stringify(kdfParams), accountId)
131
+ .run();
132
+ }
133
+
134
+ interface PublicKeyRow {
135
+ account_id: string;
136
+ public_key_jwk: string;
137
+ created_at: string;
138
+ }
139
+ function mapPublicKey(r: PublicKeyRow): PublicKey {
140
+ return { accountId: r.account_id, publicKeyJwk: JSON.parse(r.public_key_jwk), createdAt: r.created_at };
141
+ }
142
+
143
+ export async function putPublicKey(db: D1Database, p: { accountId: string; publicKeyJwk: unknown }): Promise<void> {
144
+ const createdAt = new Date().toISOString();
145
+ await db
146
+ .prepare("INSERT OR REPLACE INTO public_keys (account_id, public_key_jwk, created_at) VALUES (?, ?, ?)")
147
+ .bind(p.accountId, JSON.stringify(p.publicKeyJwk), createdAt)
148
+ .run();
149
+ }
150
+
151
+ export async function getPublicKey(db: D1Database, accountId: string): Promise<PublicKey | null> {
152
+ const row = await db.prepare("SELECT * FROM public_keys WHERE account_id = ?").bind(accountId).first<PublicKeyRow>();
153
+ return row ? mapPublicKey(row) : null;
154
+ }
@@ -0,0 +1,179 @@
1
+ // D1 implementations of the storage-agnostic contracts in ../../stores.ts. Each method is a
2
+ // one-line delegation to the corresponding module in this directory — this file wires the portable
3
+ // interfaces to a D1 schema, and carries no logic of its own.
4
+
5
+ import type { D1Database } from "./types";
6
+ import type {
7
+ AccountStore,
8
+ CredentialStore,
9
+ EnvelopeStore,
10
+ ProviderLinkStore,
11
+ AuditStore,
12
+ EnvelopeInput,
13
+ } from "../../stores";
14
+
15
+ import * as accounts from "./accounts";
16
+ import * as credentials from "./credentials";
17
+ import * as vault from "./vault";
18
+ import * as providers from "./providers";
19
+ import * as audit from "./audit";
20
+
21
+ export type { D1Database, D1PreparedStatement, toBytes } from "./types";
22
+
23
+ export class D1AccountStore implements AccountStore {
24
+ constructor(private db: D1Database) {}
25
+ create(a: Parameters<AccountStore["create"]>[0]) {
26
+ return accounts.createAccount(this.db, a);
27
+ }
28
+ get(id: string) {
29
+ return accounts.getAccount(this.db, id);
30
+ }
31
+ getByEmail(email: string) {
32
+ return accounts.getAccountByEmail(this.db, email);
33
+ }
34
+ sessionsValidFrom(accountId: string) {
35
+ return accounts.sessionsValidFrom(this.db, accountId);
36
+ }
37
+ revokeSessions(accountId: string) {
38
+ return accounts.revokeSessions(this.db, accountId);
39
+ }
40
+ setEmailConfirmed(id: string, confirmed: boolean) {
41
+ return accounts.setEmailConfirmed(this.db, id, confirmed);
42
+ }
43
+ setLifecycleStage(id: string, stage: Parameters<AccountStore["setLifecycleStage"]>[1]) {
44
+ return accounts.setLifecycleStage(this.db, id, stage);
45
+ }
46
+ updateProfile(id: string, updates: Parameters<AccountStore["updateProfile"]>[1]) {
47
+ return accounts.updateAccountProfile(this.db, id, updates);
48
+ }
49
+ tombstone(accountId: string, at: string) {
50
+ return accounts.tombstoneAccount(this.db, accountId, at);
51
+ }
52
+ markEmailChanged(accountId: string, at: string) {
53
+ return accounts.markEmailChanged(this.db, accountId, at);
54
+ }
55
+ }
56
+
57
+ export class D1CredentialStore implements CredentialStore {
58
+ constructor(private db: D1Database) {}
59
+ addIdentity(i: Parameters<CredentialStore["addIdentity"]>[0]) {
60
+ return credentials.addIdentity(this.db, i);
61
+ }
62
+ getIdentityByProviderSubject(method: Parameters<CredentialStore["getIdentityByProviderSubject"]>[0], subject: string) {
63
+ return credentials.getIdentityByProviderSubject(this.db, method, subject);
64
+ }
65
+ getIdentityByCredentialId(credentialId: string) {
66
+ return credentials.getIdentityByCredentialId(this.db, credentialId);
67
+ }
68
+ listIdentities(accountId: string) {
69
+ return credentials.listIdentities(this.db, accountId);
70
+ }
71
+ deleteIdentity(accountId: string, method: Parameters<CredentialStore["deleteIdentity"]>[1]) {
72
+ return credentials.deleteIdentity(this.db, accountId, method);
73
+ }
74
+ putCredential(c: Parameters<CredentialStore["putCredential"]>[0]) {
75
+ return credentials.putCredential(this.db, c);
76
+ }
77
+ getCredential(accountId: string, method: Parameters<CredentialStore["getCredential"]>[1]) {
78
+ return credentials.getCredential(this.db, accountId, method);
79
+ }
80
+ listCredentials(accountId: string) {
81
+ return credentials.listCredentials(this.db, accountId);
82
+ }
83
+ deleteCredential(accountId: string, method: Parameters<CredentialStore["deleteCredential"]>[1]) {
84
+ return credentials.deleteCredential(this.db, accountId, method);
85
+ }
86
+ updatePasskeyCounter(accountId: string, counter: number) {
87
+ return credentials.updatePasskeyCounter(this.db, accountId, counter);
88
+ }
89
+ putPublicKey(p: Parameters<CredentialStore["putPublicKey"]>[0]) {
90
+ return credentials.putPublicKey(this.db, p);
91
+ }
92
+ getPublicKey(accountId: string) {
93
+ return credentials.getPublicKey(this.db, accountId);
94
+ }
95
+ }
96
+
97
+ export class D1EnvelopeStore implements EnvelopeStore {
98
+ constructor(private db: D1Database) {}
99
+ createVault(v: Parameters<EnvelopeStore["createVault"]>[0]) {
100
+ return vault.createVault(this.db, v);
101
+ }
102
+ getVault(vaultId: string) {
103
+ return vault.getVault(this.db, vaultId);
104
+ }
105
+ getVaultByR2Key(r2Key: string) {
106
+ return vault.getVaultByR2Key(this.db, r2Key);
107
+ }
108
+ getVaultByStagingR2Key(r2Key: string) {
109
+ return vault.getVaultByStagingR2Key(this.db, r2Key);
110
+ }
111
+ listVaultsForOwner(ownerAccountId: string) {
112
+ return vault.listVaultsForOwner(this.db, ownerAccountId);
113
+ }
114
+ setRotationPending(vaultId: string, pending: boolean) {
115
+ return vault.setRotationPending(this.db, vaultId, pending);
116
+ }
117
+ setRotationStaging(vaultId: string, r2Key: string | null) {
118
+ return vault.setRotationStaging(this.db, vaultId, r2Key);
119
+ }
120
+ setOrgRecoveryRevoked(vaultId: string, at: string | null) {
121
+ return vault.setOrgRecoveryRevoked(this.db, vaultId, at);
122
+ }
123
+ putEnvelope(e: Parameters<EnvelopeStore["putEnvelope"]>[0]) {
124
+ return vault.putEnvelope(this.db, e);
125
+ }
126
+ replaceEnvelopes(vaultId: string, envelopes: EnvelopeInput[], createdBy: string) {
127
+ return vault.replaceEnvelopes(this.db, vaultId, envelopes, createdBy);
128
+ }
129
+ commitRotation(vaultId: string, newR2Key: string, envelopes: EnvelopeInput[], createdBy: string) {
130
+ return vault.commitRotation(this.db, vaultId, newR2Key, envelopes, createdBy);
131
+ }
132
+ getEnvelopeRow(vaultId: string, principalAccountId: string) {
133
+ return vault.getEnvelopeRow(this.db, vaultId, principalAccountId);
134
+ }
135
+ listEnvelopesForVault(vaultId: string) {
136
+ return vault.listEnvelopesForVault(this.db, vaultId);
137
+ }
138
+ listEnvelopesForPrincipal(principalAccountId: string) {
139
+ return vault.listEnvelopesForPrincipal(this.db, principalAccountId);
140
+ }
141
+ deleteEnvelope(vaultId: string, principalAccountId: string) {
142
+ return vault.deleteEnvelope(this.db, vaultId, principalAccountId);
143
+ }
144
+ }
145
+
146
+ export class D1ProviderLinkStore implements ProviderLinkStore {
147
+ constructor(private db: D1Database) {}
148
+ create(l: Parameters<ProviderLinkStore["create"]>[0]) {
149
+ return providers.createProviderLink(this.db, l);
150
+ }
151
+ updateStatus(id: string, status: Parameters<ProviderLinkStore["updateStatus"]>[1]) {
152
+ return providers.updateProviderLinkStatus(this.db, id, status);
153
+ }
154
+ grantSupport(id: string, opts: Parameters<ProviderLinkStore["grantSupport"]>[1]) {
155
+ return providers.grantSupportLink(this.db, id, opts);
156
+ }
157
+ get(id: string) {
158
+ return providers.getProviderLink(this.db, id);
159
+ }
160
+ listForPatient(patientAccountId: string) {
161
+ return providers.listProvidersForPatient(this.db, patientAccountId);
162
+ }
163
+ listForProvider(providerAccountId: string) {
164
+ return providers.listPatientsForProvider(this.db, providerAccountId);
165
+ }
166
+ getActive(patientAccountId: string, providerAccountId: string) {
167
+ return providers.getActiveProviderLink(this.db, patientAccountId, providerAccountId);
168
+ }
169
+ }
170
+
171
+ export class D1AuditStore implements AuditStore {
172
+ constructor(private db: D1Database) {}
173
+ insertAccessEvent(e: Parameters<AuditStore["insertAccessEvent"]>[0]) {
174
+ return audit.insertAccessEvent(this.db, e);
175
+ }
176
+ listAccessEventsForSubject(subjectAccountId: string) {
177
+ return audit.listAccessEventsForSubject(this.db, subjectAccountId);
178
+ }
179
+ }
@@ -0,0 +1,117 @@
1
+ import type { D1Database } from "./types";
2
+ import type { ProviderKind, LinkStatus, ProviderLink } from "../../stores";
3
+ export type { ProviderLink };
4
+
5
+ interface ProviderLinkRow {
6
+ id: string;
7
+ patient_account_id: string;
8
+ provider_account_id: string;
9
+ role: ProviderKind;
10
+ status: LinkStatus;
11
+ consent_ref: string | null;
12
+ granted_by: string;
13
+ granted_at: string;
14
+ expires_at: string | null;
15
+ }
16
+ function mapProviderLink(r: ProviderLinkRow): ProviderLink {
17
+ return {
18
+ id: r.id,
19
+ patientAccountId: r.patient_account_id,
20
+ providerAccountId: r.provider_account_id,
21
+ role: r.role,
22
+ status: r.status,
23
+ consentRef: r.consent_ref,
24
+ grantedBy: r.granted_by,
25
+ grantedAt: r.granted_at,
26
+ expiresAt: r.expires_at ?? null,
27
+ };
28
+ }
29
+
30
+ export async function createProviderLink(
31
+ db: D1Database,
32
+ l: {
33
+ patientAccountId: string;
34
+ providerAccountId: string;
35
+ role: ProviderKind;
36
+ status?: LinkStatus;
37
+ consentRef?: string | null;
38
+ grantedBy: string;
39
+ expiresAt?: string | null;
40
+ id?: string;
41
+ }
42
+ ): Promise<ProviderLink> {
43
+ const id = l.id ?? crypto.randomUUID();
44
+ const status = l.status ?? "invited";
45
+ const consentRef = l.consentRef ?? null;
46
+ const expiresAt = l.expiresAt ?? null;
47
+ const grantedAt = new Date().toISOString();
48
+ await db
49
+ .prepare(
50
+ "INSERT INTO provider_links (id, patient_account_id, provider_account_id, role, status, consent_ref, granted_by, granted_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
51
+ )
52
+ .bind(id, l.patientAccountId, l.providerAccountId, l.role, status, consentRef, l.grantedBy, grantedAt, expiresAt)
53
+ .run();
54
+ return {
55
+ id,
56
+ patientAccountId: l.patientAccountId,
57
+ providerAccountId: l.providerAccountId,
58
+ role: l.role,
59
+ status,
60
+ consentRef,
61
+ grantedBy: l.grantedBy,
62
+ grantedAt,
63
+ expiresAt,
64
+ };
65
+ }
66
+
67
+ export async function updateProviderLinkStatus(db: D1Database, id: string, status: LinkStatus): Promise<void> {
68
+ await db.prepare("UPDATE provider_links SET status = ? WHERE id = ?").bind(status, id).run();
69
+ }
70
+
71
+ // W44 P4b — a patient approving a support request: flip the link active, stamp its time-box + consent.
72
+ export async function grantSupportLink(
73
+ db: D1Database,
74
+ id: string,
75
+ opts: { expiresAt: string | null; consentRef?: string | null }
76
+ ): Promise<void> {
77
+ await db
78
+ .prepare("UPDATE provider_links SET status = 'active', expires_at = ?, consent_ref = ? WHERE id = ?")
79
+ .bind(opts.expiresAt, opts.consentRef ?? null, id)
80
+ .run();
81
+ }
82
+
83
+ export async function getProviderLink(db: D1Database, id: string): Promise<ProviderLink | null> {
84
+ const row = await db.prepare("SELECT * FROM provider_links WHERE id = ?").bind(id).first<ProviderLinkRow>();
85
+ return row ? mapProviderLink(row) : null;
86
+ }
87
+
88
+ export async function listProvidersForPatient(db: D1Database, patientAccountId: string): Promise<ProviderLink[]> {
89
+ const { results } = await db
90
+ .prepare("SELECT * FROM provider_links WHERE patient_account_id = ?")
91
+ .bind(patientAccountId)
92
+ .all<ProviderLinkRow>();
93
+ return results.map(mapProviderLink);
94
+ }
95
+
96
+ export async function listPatientsForProvider(db: D1Database, providerAccountId: string): Promise<ProviderLink[]> {
97
+ const { results } = await db
98
+ .prepare("SELECT * FROM provider_links WHERE provider_account_id = ?")
99
+ .bind(providerAccountId)
100
+ .all<ProviderLinkRow>();
101
+ return results.map(mapProviderLink);
102
+ }
103
+
104
+ export async function getActiveProviderLink(
105
+ db: D1Database,
106
+ patientAccountId: string,
107
+ providerAccountId: string
108
+ ): Promise<ProviderLink | null> {
109
+ const link = await db
110
+ .prepare("SELECT * FROM provider_links WHERE patient_account_id = ? AND provider_account_id = ?")
111
+ .bind(patientAccountId, providerAccountId)
112
+ .first<ProviderLinkRow>();
113
+ if (!link || link.status !== "active") return null;
114
+ // `expires_at` is set on time-boxed support grants and null for clinician links.
115
+ if (link.expires_at && Date.parse(link.expires_at) <= Date.now()) return null;
116
+ return mapProviderLink(link);
117
+ }
@@ -0,0 +1,22 @@
1
+ // Structural D1 types only, so this module has no dependency on @cloudflare/workers-types — any
2
+ // D1-compatible database (Cloudflare's own, or a test double) satisfies this by shape.
3
+
4
+ export interface D1Database {
5
+ prepare(query: string): D1PreparedStatement;
6
+ /**
7
+ * D1 runs a batch as a single implicit transaction, which `vault.ts`'s `replaceEnvelopes`/
8
+ * `commitRotation` need and nothing else here does. The shape is pinned against real workerd by
9
+ * the adopting app's own tests rather than trusted from a comment.
10
+ */
11
+ batch(statements: D1PreparedStatement[]): Promise<unknown[]>;
12
+ }
13
+ export interface D1PreparedStatement {
14
+ bind(...values: unknown[]): D1PreparedStatement;
15
+ first<T = unknown>(): Promise<T | null>;
16
+ all<T = unknown>(): Promise<{ results: T[] }>;
17
+ run(): Promise<unknown>;
18
+ }
19
+
20
+ export function toBytes(blob: unknown): Uint8Array {
21
+ return blob instanceof Uint8Array ? blob : new Uint8Array(blob as ArrayBuffer);
22
+ }