@tinytars/frame 0.1.25 → 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.
- package/account-methods.svelte.ts +25 -8
- package/package.json +4 -2
- package/roster-session.svelte.ts +25 -8
- package/vault-principals.svelte.ts +30 -13
- package/filter.test.ts +0 -42
- package/time-ago.test.ts +0 -32
|
@@ -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.
|
|
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",
|
package/roster-session.svelte.ts
CHANGED
|
@@ -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
|
-
});
|