@tinytars/vault 0.1.16 → 0.1.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -39
- package/adapters/conformance.ts +9 -9
- package/adapters/d1/accounts.ts +1 -1
- package/adapters/d1/audit.ts +8 -5
- package/adapters/d1/credentials.ts +2 -2
- package/adapters/d1/index.ts +5 -5
- package/adapters/d1/providers.ts +14 -10
- package/adapters/memory.ts +6 -6
- package/adapters/r2.ts +4 -5
- package/auth-client.ts +423 -0
- package/auth-grants.ts +56 -0
- package/auth-recovery.ts +360 -0
- package/auth-support.ts +118 -0
- package/base64.ts +15 -0
- package/break-glass.ts +9 -10
- package/crypto.ts +20 -19
- package/envelope-access.ts +1 -1
- package/kdf.ts +12 -20
- package/key-store.ts +4 -3
- package/org-recovery.ts +19 -0
- package/package.json +6 -3
- package/stores.ts +14 -15
- package/vault-session.ts +59 -0
- package/vault-sink.ts +25 -27
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
|
-
|
|
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
|
-
###
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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.
|
|
48
|
-
|
|
49
|
-
|
|
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: "
|
|
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: "
|
|
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: `${
|
|
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(
|
|
219
|
-
return providerLinks.get(`${
|
|
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:
|
|
271
|
-
| `auth-grants.ts` | Provider
|
|
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 |
|
package/adapters/conformance.ts
CHANGED
|
@@ -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({
|
|
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({
|
|
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({
|
|
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({
|
|
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("
|
|
197
|
+
it("listForOwner / listForProvider scope correctly", async () => {
|
|
198
198
|
const store = await factory();
|
|
199
|
-
await store.create({
|
|
200
|
-
await store.create({
|
|
201
|
-
await store.create({
|
|
202
|
-
expect(await store.
|
|
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
|
});
|
package/adapters/d1/accounts.ts
CHANGED
|
@@ -70,7 +70,7 @@ export async function getAccountByEmail(db: D1Database, email: string): Promise<
|
|
|
70
70
|
/**
|
|
71
71
|
* The instant before which this account's session cookies are no longer accepted, or null.
|
|
72
72
|
*
|
|
73
|
-
* Read on every authenticated request
|
|
73
|
+
* Read on every authenticated request (see `requireSession`). One indexed lookup by primary
|
|
74
74
|
* key is what buys revocability: the cookie is self-contained, so without a server-side fact to
|
|
75
75
|
* check against, nothing short of rotating SESSION_SECRET for the entire deployment can invalidate one.
|
|
76
76
|
*/
|
package/adapters/d1/audit.ts
CHANGED
|
@@ -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 —
|
|
6
|
-
// bookkeeping are app-specific concerns that stay in the app'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
|
-
//
|
|
10
|
-
//
|
|
9
|
+
// A generic actor/subject/action audit log for consent-scoped vault access — e.g., satisfies FTC
|
|
10
|
+
// Health Breach Notification Rule PHI-disclosure logging for healthcare deployments, or analogous
|
|
11
|
+
// breach-notification and access-audit obligations in other regulated domains. Records WHO
|
|
12
|
+
// (actor) accessed WHOSE (subject) vault and WHY (action + consent_ref), so a breach can be
|
|
13
|
+
// scoped to affected individuals. NO PHI.
|
|
11
14
|
interface AccessEventRow {
|
|
12
15
|
id: string;
|
|
13
16
|
actor_account_id: string;
|
|
@@ -99,8 +99,8 @@ export async function getCredential(db: D1Database, accountId: string, method: A
|
|
|
99
99
|
return row ? mapCredential(row) : null;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
// The account's key-bearing methods (password/passkey/recovery), for the
|
|
103
|
-
//
|
|
102
|
+
// The account's key-bearing methods (password/passkey/recovery), for the Account screen and the
|
|
103
|
+
// "don't orphan the vault key on remove" invariant. The credentials table is the source of truth (each
|
|
104
104
|
// row independently wraps the same private key); identities lacks a recovery row.
|
|
105
105
|
export async function listCredentials(db: D1Database, accountId: string): Promise<{ method: AuthMethod; createdAt: string }[]> {
|
|
106
106
|
const { results } = await db
|
package/adapters/d1/index.ts
CHANGED
|
@@ -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
|
-
|
|
161
|
-
return providers.
|
|
160
|
+
listForOwner(ownerAccountId: string) {
|
|
161
|
+
return providers.listProvidersForOwner(this.db, ownerAccountId);
|
|
162
162
|
}
|
|
163
163
|
listForProvider(providerAccountId: string) {
|
|
164
|
-
return providers.
|
|
164
|
+
return providers.listOwnersForProvider(this.db, providerAccountId);
|
|
165
165
|
}
|
|
166
|
-
getActive(
|
|
167
|
-
return providers.getActiveProviderLink(this.db,
|
|
166
|
+
getActive(ownerAccountId: string, providerAccountId: string) {
|
|
167
|
+
return providers.getActiveProviderLink(this.db, ownerAccountId, providerAccountId);
|
|
168
168
|
}
|
|
169
169
|
}
|
|
170
170
|
|
package/adapters/d1/providers.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
60
|
+
ownerAccountId: l.ownerAccountId,
|
|
57
61
|
providerAccountId: l.providerAccountId,
|
|
58
62
|
role: l.role,
|
|
59
63
|
status,
|
|
@@ -85,15 +89,15 @@ export async function getProviderLink(db: D1Database, id: string): Promise<Provi
|
|
|
85
89
|
return row ? mapProviderLink(row) : null;
|
|
86
90
|
}
|
|
87
91
|
|
|
88
|
-
export async function
|
|
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(
|
|
95
|
+
.bind(ownerAccountId)
|
|
92
96
|
.all<ProviderLinkRow>();
|
|
93
97
|
return results.map(mapProviderLink);
|
|
94
98
|
}
|
|
95
99
|
|
|
96
|
-
export async function
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
}
|
package/adapters/memory.ts
CHANGED
|
@@ -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: {
|
|
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
|
-
|
|
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
|
|
304
|
-
return [...this.links.values()].filter((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(
|
|
311
|
+
async getActive(ownerAccountId: string, providerAccountId: string): Promise<ProviderLink | null> {
|
|
312
312
|
const link = [...this.links.values()].find(
|
|
313
|
-
(l) => l.
|
|
313
|
+
(l) => l.ownerAccountId === ownerAccountId && l.providerAccountId === providerAccountId
|
|
314
314
|
);
|
|
315
315
|
if (!link || link.status !== "active") return null;
|
|
316
316
|
if (link.expiresAt && Date.parse(link.expiresAt) <= Date.now()) return null;
|
package/adapters/r2.ts
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
import type { BlobStore, BlobConditional, StoredBlob } from "../blob-store";
|
|
2
2
|
|
|
3
3
|
// Minimal structural type for the R2 binding — no @cloudflare/workers-types dependency, and
|
|
4
|
-
// trivially mockable in tests.
|
|
5
|
-
// R2BlobStore from here instead of declaring its own copy.
|
|
4
|
+
// trivially mockable in tests.
|
|
6
5
|
export interface R2ObjectBody {
|
|
7
6
|
body: ReadableStream;
|
|
8
7
|
/** The version token. Handed to the browser on GET and sent back as If-Match on PUT. */
|
|
9
8
|
etag: string;
|
|
10
9
|
}
|
|
11
|
-
/** A precondition on a write.
|
|
10
|
+
/** A precondition on a write. Real workerd conditional-write semantics are verified in an adopter's own test suite, not here — see CHANGELOG.md. */
|
|
12
11
|
export interface R2Conditional {
|
|
13
12
|
etagMatches?: string;
|
|
14
13
|
etagDoesNotMatch?: string;
|
|
@@ -17,8 +16,8 @@ export interface R2Bucket {
|
|
|
17
16
|
get(key: string): Promise<R2ObjectBody | null>;
|
|
18
17
|
/**
|
|
19
18
|
* Returns the stored object (carrying its NEW etag), or `null` when an `onlyIf` precondition fails.
|
|
20
|
-
* Null-on-failure rather than a throw is observed behaviour, not an assumption —
|
|
21
|
-
*
|
|
19
|
+
* Null-on-failure rather than a throw is observed behaviour against workerd, not an assumption —
|
|
20
|
+
* an adopter's own test suite is where this gets pinned; see CHANGELOG.md.
|
|
22
21
|
*/
|
|
23
22
|
put(key: string, value: Uint8Array, options?: { onlyIf?: R2Conditional }): Promise<{ etag: string } | null>;
|
|
24
23
|
delete(key: string): Promise<void>;
|