@tinytars/vault 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +271 -0
- package/adapters/conformance.ts +224 -0
- package/adapters/d1/accounts.ts +160 -0
- package/adapters/d1/audit.ts +58 -0
- package/adapters/d1/credentials.ts +154 -0
- package/adapters/d1/index.ts +179 -0
- package/adapters/d1/providers.ts +117 -0
- package/adapters/d1/types.ts +22 -0
- package/adapters/d1/vault.ts +209 -0
- package/adapters/memory.ts +342 -0
- package/adapters/pages-http.ts +13 -0
- package/adapters/r2.ts +50 -0
- package/blob-store.ts +27 -0
- package/break-glass.ts +158 -0
- package/bytes.ts +18 -0
- package/crypto.ts +276 -0
- package/env.d.ts +9 -0
- package/envelope-access.ts +44 -0
- package/kdf.ts +73 -0
- package/key-store.ts +67 -0
- package/package.json +52 -0
- package/stores.ts +190 -0
- package/vault-sink.ts +147 -0
|
@@ -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
|
+
}
|