@tinytars/frame 0.1.26 → 0.1.27

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.
@@ -21,6 +21,20 @@ export interface AccountInfo {
21
21
  emailConfirmed: boolean;
22
22
  }
23
23
 
24
+ const defaultApi = {
25
+ getMyAccount,
26
+ listMethods,
27
+ updateProfile,
28
+ addPasswordMethod,
29
+ addPasskeyMethod,
30
+ addGoogleMethod,
31
+ removeMethod,
32
+ // Wrapped, because a bare `fetch` invoked as `api.fetch` throws "Illegal invocation" in browsers.
33
+ fetch: (input: RequestInfo | URL, init?: RequestInit) => fetch(input, init),
34
+ };
35
+
36
+ export type AccountMethodsApi = typeof defaultApi;
37
+
24
38
  export interface AccountMethodsDeps {
25
39
  /**
26
40
  * The in-memory account private key — `session.ownerKey ?? session.providerKey`, read fresh because
@@ -33,6 +47,8 @@ export interface AccountMethodsDeps {
33
47
  applyUnitSystem: (u: "metric" | "imperial") => void;
34
48
  /** Vault principals + access events. App's, because App owns the recovery controller they feed. */
35
49
  loadOwnerBlocks: () => Promise<void>;
50
+ /** The server calls, overridable so a test can pass fakes. Defaults to `@tinytars/vault`. */
51
+ api?: Partial<AccountMethodsApi>;
36
52
  }
37
53
 
38
54
  export interface AccountMethods {
@@ -86,6 +102,7 @@ export interface AccountMethods {
86
102
  }
87
103
 
88
104
  export function createAccountMethods(deps: AccountMethodsDeps): AccountMethods {
105
+ const api = { ...defaultApi, ...deps.api };
89
106
  let open = $state(false);
90
107
  let busy = $state(false);
91
108
  let error = $state<string | null>(null);
@@ -113,7 +130,7 @@ export function createAccountMethods(deps: AccountMethodsDeps): AccountMethods {
113
130
  }
114
131
 
115
132
  async function refresh(): Promise<void> {
116
- const [account, list] = await Promise.all([getMyAccount(), listMethods()]);
133
+ const [account, list] = await Promise.all([api.getMyAccount(), api.listMethods()]);
117
134
  info = { email: account.email, displayName: account.displayName, emailConfirmed: account.emailConfirmed };
118
135
  methods = list;
119
136
  deps.applyUnitSystem(account.unitSystem ?? "imperial");
@@ -202,7 +219,7 @@ export function createAccountMethods(deps: AccountMethodsDeps): AccountMethods {
202
219
  open = true;
203
220
  error = null;
204
221
  try {
205
- const [account, list] = await Promise.all([getMyAccount(), listMethods()]);
222
+ const [account, list] = await Promise.all([api.getMyAccount(), api.listMethods()]);
206
223
  info = { email: account.email, displayName: account.displayName, emailConfirmed: account.emailConfirmed };
207
224
  methods = list;
208
225
  editEmail = account.email ?? "";
@@ -225,7 +242,7 @@ export function createAccountMethods(deps: AccountMethodsDeps): AccountMethods {
225
242
  // worth a red line across a panel the person opened to do something else.
226
243
  async resendVerification() {
227
244
  try {
228
- await fetch("/api/account/email/send-verification", { method: "POST" });
245
+ await api.fetch("/api/account/email/send-verification", { method: "POST" });
229
246
  emailVerifyNote = "sent";
230
247
  } catch {
231
248
  /* best-effort */
@@ -234,7 +251,7 @@ export function createAccountMethods(deps: AccountMethodsDeps): AccountMethods {
234
251
 
235
252
  async saveProfile() {
236
253
  error = null;
237
- await mutate(() => updateProfile({ email: editEmail.trim() || undefined, displayName: editDisplayName.trim() || undefined }));
254
+ await mutate(() => api.updateProfile({ email: editEmail.trim() || undefined, displayName: editDisplayName.trim() || undefined }));
238
255
  },
239
256
 
240
257
  async addPassword() {
@@ -244,7 +261,7 @@ export function createAccountMethods(deps: AccountMethodsDeps): AccountMethods {
244
261
  if (!pk) return;
245
262
  const password = newPassword;
246
263
  await mutate(async () => {
247
- await addPasswordMethod(pk, password);
264
+ await api.addPasswordMethod(pk, password);
248
265
  // Cleared inside the try: a rejected password (too short, step-up refused) must stay in the
249
266
  // field, or the retry is a re-entry of something the person already typed once.
250
267
  newPassword = "";
@@ -255,7 +272,7 @@ export function createAccountMethods(deps: AccountMethodsDeps): AccountMethods {
255
272
  error = null;
256
273
  const pk = ensureExtractableKey();
257
274
  if (!pk) return;
258
- await mutate(() => addPasskeyMethod(pk));
275
+ await mutate(() => api.addPasskeyMethod(pk));
259
276
  },
260
277
 
261
278
  async remove(method: RemovableMethod) {
@@ -265,7 +282,7 @@ export function createAccountMethods(deps: AccountMethodsDeps): AccountMethods {
265
282
  error = blocked;
266
283
  return;
267
284
  }
268
- await mutate(() => removeMethod(method));
285
+ await mutate(() => api.removeMethod(method));
269
286
  },
270
287
 
271
288
  // OAuth runs in a POPUP so this SPA (and the in-memory key the server needs to wrap)
@@ -294,7 +311,7 @@ export function createAccountMethods(deps: AccountMethodsDeps): AccountMethods {
294
311
  : "Google sign-in failed.";
295
312
  return;
296
313
  }
297
- await mutate(() => addGoogleMethod(pk));
314
+ await mutate(() => api.addGoogleMethod(pk));
298
315
  };
299
316
  window.addEventListener("message", onMessage);
300
317
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tinytars/frame",
3
- "version": "0.1.26",
3
+ "version": "0.1.27",
4
4
  "description": "Domain-neutral Svelte app-shell: session/auth controllers, account chrome, and menu/card/modal primitives built on @tinytars/vault.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -23,6 +23,7 @@
23
23
  },
24
24
  "files": [
25
25
  "*.ts",
26
+ "!*.test.ts",
26
27
  "*.svelte",
27
28
  "*.css"
28
29
  ],
@@ -74,7 +75,8 @@
74
75
  "./VisibilitySettings.svelte": "./VisibilitySettings.svelte"
75
76
  },
76
77
  "scripts": {
77
- "typecheck": "svelte-check --tsconfig ./tsconfig.json --threshold error"
78
+ "typecheck": "svelte-check --tsconfig ./tsconfig.json --threshold error",
79
+ "test": "vitest run"
78
80
  },
79
81
  "dependencies": {
80
82
  "@tinytars/vault": "^0.1.18",
@@ -36,6 +36,20 @@ export interface EnteredAccount {
36
36
  rotationPending?: boolean;
37
37
  }
38
38
 
39
+ const defaultApi = {
40
+ getAccountKey,
41
+ putAccountKey,
42
+ clearAccountKey,
43
+ resumeSession,
44
+ bootstrapGoogleSession,
45
+ getMyAccount,
46
+ revokeProvider,
47
+ // Wrapped, because a bare `fetch` invoked as `api.fetch` throws "Illegal invocation" in browsers.
48
+ fetch: (input: RequestInfo | URL, init?: RequestInit) => fetch(input, init),
49
+ };
50
+
51
+ export type RosterSessionApi = typeof defaultApi;
52
+
39
53
  export interface RosterSessionDeps {
40
54
  session: VaultSession;
41
55
  reportError: (message: string | null) => void;
@@ -54,6 +68,8 @@ export interface RosterSessionDeps {
54
68
  closeVault: () => void;
55
69
  /** The roster-removal confirmation. Injected so the dialog stays the host's. */
56
70
  confirmRemoval: (label: string) => boolean;
71
+ /** The server and key-store calls, overridable so a test can pass fakes. Defaults to `@tinytars/vault`. */
72
+ api?: Partial<RosterSessionApi>;
57
73
  }
58
74
 
59
75
  export interface RosterSession {
@@ -90,13 +106,14 @@ export interface RosterSession {
90
106
  }
91
107
 
92
108
  export function createRosterSession(deps: RosterSessionDeps): RosterSession {
109
+ const api = { ...defaultApi, ...deps.api };
93
110
  let isProvider = $state(false);
94
111
  let patients = $state<RosterPatient[]>([]);
95
112
  let enteredPatient = $state<{ email: string | null; displayName: string } | null>(null);
96
113
  let resuming = $state(false);
97
114
 
98
115
  async function loadPatients(): Promise<void> {
99
- const res = await fetch("/api/providers/patients", { cache: "no-store" });
116
+ const res = await api.fetch("/api/providers/patients", { cache: "no-store" });
100
117
  // A failed fetch leaves the previous roster standing rather than blanking it. A provider whose
101
118
  // network blipped keeps the list they were working from; an empty roster would read as "you have
102
119
  // no patients", which is a different and wrong statement.
@@ -114,7 +131,7 @@ export function createRosterSession(deps: RosterSessionDeps): RosterSession {
114
131
  }
115
132
  deps.session.setProviderKey(r.privateKey);
116
133
  isProvider = true;
117
- const acct = await getMyAccount();
134
+ const acct = await api.getMyAccount();
118
135
  if (acct.providerKind === "support") await deps.beginSupportSession();
119
136
  else {
120
137
  await loadPatients();
@@ -131,15 +148,15 @@ export function createRosterSession(deps: RosterSessionDeps): RosterSession {
131
148
  async function resumeFromStoredKey(): Promise<boolean> {
132
149
  let storedKey: CryptoKey | null = null;
133
150
  try {
134
- storedKey = await getAccountKey();
151
+ storedKey = await api.getAccountKey();
135
152
  } catch {
136
153
  return false;
137
154
  }
138
155
  if (!storedKey) return false;
139
- const r = await resumeSession();
156
+ const r = await api.resumeSession();
140
157
  if (!r) {
141
158
  try {
142
- await clearAccountKey();
159
+ await api.clearAccountKey();
143
160
  } catch {
144
161
  /* best-effort */
145
162
  }
@@ -155,7 +172,7 @@ export function createRosterSession(deps: RosterSessionDeps): RosterSession {
155
172
  /** Google: no client-held key (server custody). A non-Google session 401s and stays locked. */
156
173
  async function resumeGoogle(): Promise<boolean> {
157
174
  try {
158
- await enterAccount(await bootstrapGoogleSession());
175
+ await enterAccount(await api.bootstrapGoogleSession());
159
176
  return true;
160
177
  } catch {
161
178
  return false;
@@ -197,7 +214,7 @@ export function createRosterSession(deps: RosterSessionDeps): RosterSession {
197
214
 
198
215
  async persistSessionKey(privateKey: CryptoKey) {
199
216
  try {
200
- await putAccountKey(privateKey);
217
+ await api.putAccountKey(privateKey);
201
218
  // Only once the key is genuinely stored. Setting the marker first would promise a resume that
202
219
  // private browsing cannot deliver, and the next load would probe, fail, and clear it anyway.
203
220
  localStorage.setItem(RESUME_MARKER, "key");
@@ -212,7 +229,7 @@ export function createRosterSession(deps: RosterSessionDeps): RosterSession {
212
229
  if (!deps.confirmRemoval(p.displayName)) return;
213
230
  deps.reportError(null);
214
231
  try {
215
- await revokeProvider(p.linkId);
232
+ await api.revokeProvider(p.linkId);
216
233
  await loadPatients();
217
234
  } catch (e) {
218
235
  deps.reportError((e as Error).message);
@@ -16,6 +16,20 @@ import { listMyProviders, lookupProvider, grantProvider, revokeProvider, type Pr
16
16
  import { approveSupport, approveSupportAsProvider } from "@tinytars/vault/auth-support";
17
17
  import { getVaultPrincipals, stageVaultRotation, rotateVault } from "@tinytars/vault/auth-recovery";
18
18
 
19
+ const defaultApi = {
20
+ listMyProviders,
21
+ lookupProvider,
22
+ grantProvider,
23
+ revokeProvider,
24
+ approveSupport,
25
+ approveSupportAsProvider,
26
+ getVaultPrincipals,
27
+ stageVaultRotation,
28
+ rotateVault,
29
+ };
30
+
31
+ export type VaultPrincipalsApi = typeof defaultApi;
32
+
19
33
  export interface VaultPrincipalsDeps<V> {
20
34
  /** The decrypted record, read fresh — never captured. Still App's, along with the sink below. */
21
35
  getVault: () => V | null;
@@ -29,6 +43,8 @@ export interface VaultPrincipalsDeps<V> {
29
43
  * the Access modal is not mounted. Behaviour preserved from the extraction, not a new idea.
30
44
  */
31
45
  reportError: (message: string | null) => void;
46
+ /** The server calls, overridable so a test can pass fakes. Defaults to `@tinytars/vault`. */
47
+ api?: Partial<VaultPrincipalsApi>;
32
48
  }
33
49
 
34
50
  export interface VaultPrincipals {
@@ -67,6 +83,7 @@ export interface VaultPrincipals {
67
83
  }
68
84
 
69
85
  export function createVaultPrincipals<V>(deps: VaultPrincipalsDeps<V>): VaultPrincipals {
86
+ const api = { ...defaultApi, ...deps.api };
70
87
  let open = $state(false);
71
88
  let providers = $state<ProviderLinkView[]>([]);
72
89
  let newProviderEmail = $state("");
@@ -84,7 +101,7 @@ export function createVaultPrincipals<V>(deps: VaultPrincipalsDeps<V>): VaultPri
84
101
  report(null);
85
102
  try {
86
103
  await body();
87
- providers = await listMyProviders();
104
+ providers = await api.listMyProviders();
88
105
  } catch (e) {
89
106
  report((e as Error).message);
90
107
  } finally {
@@ -98,7 +115,7 @@ export function createVaultPrincipals<V>(deps: VaultPrincipalsDeps<V>): VaultPri
98
115
  const vault = deps.getVault();
99
116
  const { session } = deps;
100
117
  if (!vault || !session.dek || !session.r2Id) return;
101
- const principals = await getVaultPrincipals();
118
+ const principals = await api.getVaultPrincipals();
102
119
  const newDek = await generateDEK();
103
120
  // The blob goes to a NEW object, reserved here, and the envelope commit below doubles as the
104
121
  // pointer swap. This used to re-encrypt in place and commit the matching envelopes four round
@@ -106,7 +123,7 @@ export function createVaultPrincipals<V>(deps: VaultPrincipalsDeps<V>): VaultPri
106
123
  // mid-revoke — left every principal holding an envelope for a key the ciphertext no longer used.
107
124
  // An interruption before the commit now leaves the old blob and the old envelopes still agreeing,
108
125
  // and the abandoned object is ciphertext under a key nobody kept.
109
- const newVaultId = await stageVaultRotation(principals.vaultId);
126
+ const newVaultId = await api.stageVaultRotation(principals.vaultId);
110
127
  await deps.saveVault(vault, newVaultId, newDek);
111
128
  const targets = [
112
129
  { accountId: principals.selfAccountId, publicKeyJwk: principals.selfPublicKeyJwk },
@@ -120,7 +137,7 @@ export function createVaultPrincipals<V>(deps: VaultPrincipalsDeps<V>): VaultPri
120
137
  return { principalAccountId: t.accountId, wrappedDEK: bytesToB64(e.wrappedDEK), ephemeralPublicKeyJwk: e.ephemeralPublicKeyJwk };
121
138
  }),
122
139
  );
123
- await rotateVault({ vaultId: principals.vaultId, newVaultId, envelopes });
140
+ await api.rotateVault({ vaultId: principals.vaultId, newVaultId, envelopes });
124
141
  session.open(newVaultId, newDek);
125
142
  }
126
143
 
@@ -163,7 +180,7 @@ export function createVaultPrincipals<V>(deps: VaultPrincipalsDeps<V>): VaultPri
163
180
  open = true;
164
181
  error = null;
165
182
  try {
166
- providers = await listMyProviders();
183
+ providers = await api.listMyProviders();
167
184
  } catch (e) {
168
185
  error = (e as Error).message;
169
186
  }
@@ -175,7 +192,7 @@ export function createVaultPrincipals<V>(deps: VaultPrincipalsDeps<V>): VaultPri
175
192
 
176
193
  async refreshQuietly() {
177
194
  try {
178
- providers = await listMyProviders();
195
+ providers = await api.listMyProviders();
179
196
  } catch {
180
197
  /* non-fatal; the section just stays empty */
181
198
  }
@@ -188,16 +205,16 @@ export function createVaultPrincipals<V>(deps: VaultPrincipalsDeps<V>): VaultPri
188
205
  busy = true;
189
206
  error = null;
190
207
  try {
191
- const provider = await lookupProvider(emailInput);
208
+ const provider = await api.lookupProvider(emailInput);
192
209
  // Not an exception, and deliberately not a reload either: a typo is an ordinary outcome, and
193
210
  // re-listing for it would be a round trip that changes nothing.
194
211
  if (!provider) {
195
212
  error = "No provider found with that email.";
196
213
  return;
197
214
  }
198
- await grantProvider(dek, provider);
215
+ await api.grantProvider(dek, provider);
199
216
  newProviderEmail = "";
200
- providers = await listMyProviders();
217
+ providers = await api.listMyProviders();
201
218
  } catch (e) {
202
219
  error = (e as Error).message;
203
220
  } finally {
@@ -207,7 +224,7 @@ export function createVaultPrincipals<V>(deps: VaultPrincipalsDeps<V>): VaultPri
207
224
 
208
225
  async revoke(p: ProviderLinkView) {
209
226
  await run(async () => {
210
- await revokeProvider(p.linkId);
227
+ await api.revokeProvider(p.linkId);
211
228
  // True forward-secret revocation for SUPPORT: re-key the vault so a support agent
212
229
  // who cached the DEK can no longer decrypt it. Clinician revoke stays delete-only. Denying a
213
230
  // pending (never-active) support request needs no rotation — support never held the DEK.
@@ -219,18 +236,18 @@ export function createVaultPrincipals<V>(deps: VaultPrincipalsDeps<V>): VaultPri
219
236
  if (!deps.session.dek || !p.publicKeyJwk) return;
220
237
  const dek = deps.session.dek;
221
238
  const jwk = p.publicKeyJwk;
222
- await run(() => approveSupport(p.linkId, dek, jwk, ttlHours).then(() => undefined), toPanel);
239
+ await run(() => api.approveSupport(p.linkId, dek, jwk, ttlHours).then(() => undefined), toPanel);
223
240
  },
224
241
 
225
242
  async approveAsProvider(p: ProviderLinkView) {
226
- await run(() => approveSupportAsProvider(p.linkId, ttlHours).then(() => undefined), deps.reportError);
243
+ await run(() => api.approveSupportAsProvider(p.linkId, ttlHours).then(() => undefined), deps.reportError);
227
244
  },
228
245
 
229
246
  async revokeAsProvider(p: ProviderLinkView) {
230
247
  // No vault rotation, unlike the patient-side revoke: a provider owns nothing encrypted, and
231
248
  // support only ever held per-patient DEKs via separate patient grants, which are the patients'
232
249
  // to rotate.
233
- await run(() => revokeProvider(p.linkId).then(() => undefined), deps.reportError);
250
+ await run(() => api.revokeProvider(p.linkId).then(() => undefined), deps.reportError);
234
251
  },
235
252
 
236
253
  rotateVaultKey,
package/filter.test.ts DELETED
@@ -1,42 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import { filterTokens, matchesTokens, onlyIndexed } from "./filter";
3
-
4
- describe("filterTokens", () => {
5
- it("lowercases, splits on whitespace, drops empties", () => {
6
- expect(filterTokens("Rosuvastatin 10mg")).toEqual(["rosuvastatin", "10mg"]);
7
- expect(filterTokens(" Vitamin D ")).toEqual(["vitamin", "d"]);
8
- expect(filterTokens("")).toEqual([]);
9
- expect(filterTokens(" ")).toEqual([]);
10
- });
11
- });
12
-
13
- describe("matchesTokens", () => {
14
- it("empty query matches everything", () => {
15
- expect(matchesTokens("anything", [])).toBe(true);
16
- expect(matchesTokens("", [])).toBe(true);
17
- });
18
-
19
- it("is case-insensitive substring match", () => {
20
- expect(matchesTokens("Rosuvastatin drug", filterTokens("ROSU"))).toBe(true);
21
- expect(matchesTokens("Rosuvastatin drug", filterTokens("statin"))).toBe(true);
22
- });
23
-
24
- it("requires every token to match (AND)", () => {
25
- expect(matchesTokens("Rosuvastatin 10mg drug", filterTokens("rosuvastatin drug"))).toBe(true);
26
- expect(matchesTokens("Rosuvastatin 10mg drug", filterTokens("rosuvastatin supplement"))).toBe(false);
27
- });
28
- });
29
-
30
- describe("onlyIndexed", () => {
31
- it("passes through untouched when only is undefined", () => {
32
- expect(onlyIndexed(["a", "b", "c"], undefined)).toEqual(["a", "b", "c"]);
33
- });
34
-
35
- it("reduces to just the one index", () => {
36
- expect(onlyIndexed(["a", "b", "c"], 1)).toEqual(["b"]);
37
- });
38
-
39
- it("returns empty for an out-of-range index", () => {
40
- expect(onlyIndexed(["a", "b", "c"], 5)).toEqual([]);
41
- });
42
- });
package/time-ago.test.ts DELETED
@@ -1,32 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import { timeAgo } from "./time-ago";
3
-
4
- describe("timeAgo", () => {
5
- const NOW = new Date("2026-07-05T12:00:00Z").getTime();
6
- const ago = (ms: number) => new Date(NOW - ms).toISOString();
7
- const s = 1000, m = 60 * s, h = 60 * m, d = 24 * h;
8
-
9
- it("collapses the last few seconds to 'just now'", () => {
10
- expect(timeAgo(ago(5 * s), NOW)).toBe("just now");
11
- expect(timeAgo(ago(44 * s), NOW)).toBe("just now");
12
- });
13
-
14
- it("reports minutes, hours, and days with correct pluralization", () => {
15
- expect(timeAgo(ago(1 * m), NOW)).toBe("1 minute ago");
16
- expect(timeAgo(ago(5 * m), NOW)).toBe("5 minutes ago");
17
- expect(timeAgo(ago(1 * h), NOW)).toBe("1 hour ago");
18
- expect(timeAgo(ago(3 * h), NOW)).toBe("3 hours ago");
19
- expect(timeAgo(ago(1 * d), NOW)).toBe("1 day ago");
20
- expect(timeAgo(ago(2 * d), NOW)).toBe("2 days ago");
21
- });
22
-
23
- it("rolls up to months and years for older timestamps", () => {
24
- expect(timeAgo(ago(45 * d), NOW)).toBe("2 months ago");
25
- expect(timeAgo(ago(400 * d), NOW)).toBe("1 year ago");
26
- });
27
-
28
- it("degrades a future or malformed timestamp instead of throwing", () => {
29
- expect(timeAgo(ago(-1 * h), NOW)).toBe("just now"); // clock skew → future
30
- expect(timeAgo("not-a-date", NOW)).toBe("unknown");
31
- });
32
- });