@tinytars/frame 0.1.0
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/AccountMenu.svelte +210 -0
- package/AttachPicker.svelte +36 -0
- package/Diagnostics.svelte +188 -0
- package/ExportTab.svelte +86 -0
- package/LeafActionMenu.svelte +168 -0
- package/LeafCard.svelte +72 -0
- package/LoginScreen.svelte +124 -0
- package/Onboarding.svelte +122 -0
- package/VisibilitySettings.svelte +62 -0
- package/account-methods.svelte.ts +321 -0
- package/anchored-menu.svelte.ts +208 -0
- package/attach-controller.ts +27 -0
- package/brand.ts +8 -0
- package/menu-items.ts +17 -0
- package/menu-registry.svelte.ts +18 -0
- package/package.json +65 -0
- package/recovery-controller.svelte.ts +301 -0
- package/roster-session.svelte.ts +263 -0
- package/support-access.svelte.ts +229 -0
- package/vault-principals.svelte.ts +245 -0
- package/vault-session.svelte.ts +77 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// The support console: which lists a support agent sees, what they have asked for, and whose record
|
|
2
|
+
// they are about to open.
|
|
3
|
+
//
|
|
4
|
+
// App's third controller extraction. The server side of support access is well covered
|
|
5
|
+
// (support-access-function.test.ts, support-provider-roster-function.test.ts, and
|
|
6
|
+
// auth-client-support.test.ts); what had no test is the browser's answer to "which session is this
|
|
7
|
+
// person in, and whose roster are they looking at" — a six-field state machine that decides whether
|
|
8
|
+
// a patient's name is even offered.
|
|
9
|
+
//
|
|
10
|
+
// Same getter-parameterized factory shape as vault-principals.svelte.ts and
|
|
11
|
+
// recovery-controller.svelte.ts. The audited drill-in stops at the envelope: unwrapping it, decrypting
|
|
12
|
+
// the vault and moving the app to that patient is App's, because App owns the vault, the DEK and the
|
|
13
|
+
// navigation. What is here is the part that decides WHETHER to ask for the envelope at all.
|
|
14
|
+
|
|
15
|
+
import type { VaultSession, VaultEntry } from "./vault-session.svelte";
|
|
16
|
+
import {
|
|
17
|
+
listSupportPatients,
|
|
18
|
+
listSupportProviders,
|
|
19
|
+
listSupportRequests,
|
|
20
|
+
requestSupportAccess,
|
|
21
|
+
cancelSupportRequest,
|
|
22
|
+
getProviderRoster,
|
|
23
|
+
enterSupportPatient,
|
|
24
|
+
type SupportPatient,
|
|
25
|
+
type SupportProvider,
|
|
26
|
+
type SupportRosterPatient,
|
|
27
|
+
type SupportRequest,
|
|
28
|
+
} from "@tinytars/vault/auth-support";
|
|
29
|
+
|
|
30
|
+
/** What the audited /api/support/access hands back — the envelope plus who it belongs to. */
|
|
31
|
+
export interface SupportEntry extends VaultEntry {
|
|
32
|
+
vaultId: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface SupportProviderView {
|
|
36
|
+
providerAccountId: string;
|
|
37
|
+
displayName: string;
|
|
38
|
+
roster: SupportRosterPatient[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SupportAccessDeps {
|
|
42
|
+
/** `providerKey` unwraps the envelope; without it there is nothing to drill into. */
|
|
43
|
+
session: VaultSession;
|
|
44
|
+
/**
|
|
45
|
+
* App's shared error line, not a panel of this console's own. Preserved from the extraction: the
|
|
46
|
+
* support console IS the whole screen in this session, so its errors have always been the shell's.
|
|
47
|
+
*/
|
|
48
|
+
reportError: (message: string | null) => void;
|
|
49
|
+
/**
|
|
50
|
+
* Unwrap, decrypt, and move the app to this patient. App owns all three. The provider key is passed
|
|
51
|
+
* rather than re-read: it was checked here, and handing it over is what makes that check load-bearing
|
|
52
|
+
* instead of leaving the host to assert a non-null it cannot prove.
|
|
53
|
+
*/
|
|
54
|
+
openPatientVault: (entry: VaultEntry, providerKey: CryptoKey) => Promise<void>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface SupportAccess {
|
|
58
|
+
/** Whether this account is a support agent, and so gets the console instead of the roster. */
|
|
59
|
+
readonly isSupportSession: boolean;
|
|
60
|
+
readonly patients: SupportPatient[];
|
|
61
|
+
readonly providers: SupportProvider[];
|
|
62
|
+
/** Outstanding (invited) requests, so an ask is visibly pending rather than silently nothing. */
|
|
63
|
+
readonly requests: SupportRequest[];
|
|
64
|
+
/** The clinician roster currently drilled into, or null at the console top level. */
|
|
65
|
+
readonly providerView: SupportProviderView | null;
|
|
66
|
+
/** Bound to the request-access input. */
|
|
67
|
+
requestEmail: string;
|
|
68
|
+
|
|
69
|
+
/** Enter the support console and load everything it shows. */
|
|
70
|
+
beginSession(): Promise<void>;
|
|
71
|
+
loadPatients(): Promise<void>;
|
|
72
|
+
loadProviders(): Promise<void>;
|
|
73
|
+
loadRequests(): Promise<void>;
|
|
74
|
+
/** Ask a patient for access by email. */
|
|
75
|
+
requestAccess(): Promise<void>;
|
|
76
|
+
cancelRequest(linkId: string): Promise<void>;
|
|
77
|
+
openProvider(p: SupportProvider): Promise<void>;
|
|
78
|
+
closeProvider(): void;
|
|
79
|
+
/** The audited drill-in. Hands the envelope to the host and never touches it here. */
|
|
80
|
+
enterPatient(patientAccountId: string): Promise<void>;
|
|
81
|
+
/** Clears the console on sign-out. */
|
|
82
|
+
reset(): void;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function createSupportAccess(deps: SupportAccessDeps): SupportAccess {
|
|
86
|
+
let isSupportSession = $state(false);
|
|
87
|
+
let patients = $state<SupportPatient[]>([]);
|
|
88
|
+
let providers = $state<SupportProvider[]>([]);
|
|
89
|
+
let requests = $state<SupportRequest[]>([]);
|
|
90
|
+
let providerView = $state<SupportProviderView | null>(null);
|
|
91
|
+
let requestEmail = $state("");
|
|
92
|
+
|
|
93
|
+
/** Every list load reports to the same line and none of them is worth aborting the console over. */
|
|
94
|
+
async function load(body: () => Promise<void>): Promise<void> {
|
|
95
|
+
try {
|
|
96
|
+
await body();
|
|
97
|
+
} catch (e) {
|
|
98
|
+
deps.reportError((e as Error).message);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Re-reads the drilled-into roster if one is open. A request or a cancellation changes the `pending`
|
|
104
|
+
* and `openable` flags on the very rows being looked at, and without this the row keeps offering the
|
|
105
|
+
* button that was just pressed.
|
|
106
|
+
*/
|
|
107
|
+
async function refreshProviderView(): Promise<void> {
|
|
108
|
+
if (!providerView) return;
|
|
109
|
+
providerView = { ...providerView, roster: await getProviderRoster(providerView.providerAccountId) };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function loadPatients() {
|
|
113
|
+
await load(async () => {
|
|
114
|
+
patients = await listSupportPatients();
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function loadProviders() {
|
|
119
|
+
await load(async () => {
|
|
120
|
+
providers = await listSupportProviders();
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function loadRequests() {
|
|
125
|
+
await load(async () => {
|
|
126
|
+
requests = await listSupportRequests();
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
get isSupportSession() {
|
|
132
|
+
return isSupportSession;
|
|
133
|
+
},
|
|
134
|
+
get patients() {
|
|
135
|
+
return patients;
|
|
136
|
+
},
|
|
137
|
+
get providers() {
|
|
138
|
+
return providers;
|
|
139
|
+
},
|
|
140
|
+
get requests() {
|
|
141
|
+
return requests;
|
|
142
|
+
},
|
|
143
|
+
get providerView() {
|
|
144
|
+
return providerView;
|
|
145
|
+
},
|
|
146
|
+
get requestEmail() {
|
|
147
|
+
return requestEmail;
|
|
148
|
+
},
|
|
149
|
+
set requestEmail(v: string) {
|
|
150
|
+
requestEmail = v;
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
async beginSession() {
|
|
154
|
+
isSupportSession = true;
|
|
155
|
+
await loadPatients();
|
|
156
|
+
await loadProviders();
|
|
157
|
+
await loadRequests();
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
loadPatients,
|
|
161
|
+
loadProviders,
|
|
162
|
+
loadRequests,
|
|
163
|
+
|
|
164
|
+
async requestAccess() {
|
|
165
|
+
const em = requestEmail.trim();
|
|
166
|
+
if (!em) return;
|
|
167
|
+
deps.reportError(null);
|
|
168
|
+
try {
|
|
169
|
+
await requestSupportAccess(em);
|
|
170
|
+
requestEmail = "";
|
|
171
|
+
// Invited until approved — show it as pending, and refresh the active lists too.
|
|
172
|
+
await loadRequests();
|
|
173
|
+
await loadPatients();
|
|
174
|
+
await loadProviders();
|
|
175
|
+
await refreshProviderView();
|
|
176
|
+
} catch (e) {
|
|
177
|
+
deps.reportError((e as Error).message);
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
async cancelRequest(linkId: string) {
|
|
182
|
+
deps.reportError(null);
|
|
183
|
+
try {
|
|
184
|
+
await cancelSupportRequest(linkId);
|
|
185
|
+
await loadRequests();
|
|
186
|
+
await refreshProviderView();
|
|
187
|
+
} catch (e) {
|
|
188
|
+
deps.reportError((e as Error).message);
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
async openProvider(p: SupportProvider) {
|
|
193
|
+
deps.reportError(null);
|
|
194
|
+
try {
|
|
195
|
+
providerView = { providerAccountId: p.providerAccountId, displayName: p.displayName, roster: await getProviderRoster(p.providerAccountId) };
|
|
196
|
+
} catch (e) {
|
|
197
|
+
deps.reportError((e as Error).message);
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
closeProvider() {
|
|
202
|
+
providerView = null;
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
async enterPatient(patientAccountId: string) {
|
|
206
|
+
const providerKey = deps.session.providerKey;
|
|
207
|
+
if (!providerKey) return;
|
|
208
|
+
deps.reportError(null);
|
|
209
|
+
try {
|
|
210
|
+
await deps.openPatientVault(await enterSupportPatient(patientAccountId), providerKey);
|
|
211
|
+
} catch (e) {
|
|
212
|
+
deps.reportError((e as Error).message);
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
reset() {
|
|
217
|
+
// All six, where App used to clear two. The drill-in view was the one that mattered:
|
|
218
|
+
// nothing reloads it on the next sign-in the way the three lists are reloaded, so a support
|
|
219
|
+
// agent signing out and another signing in on the same browser opened onto the previous
|
|
220
|
+
// agent's clinician roster.
|
|
221
|
+
isSupportSession = false;
|
|
222
|
+
patients = [];
|
|
223
|
+
providers = [];
|
|
224
|
+
requests = [];
|
|
225
|
+
providerView = null;
|
|
226
|
+
requestEmail = "";
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// Who can open this vault, and the re-key that changes the answer.
|
|
2
|
+
//
|
|
3
|
+
// The first of App's five controller extractions. This cluster was ~110 lines of the
|
|
4
|
+
// component: six `$state` declarations, three `$derived` filters and seven async handlers, including
|
|
5
|
+
// `rotateVaultKey` — the single most consequential operation in the app, whose only test was an e2e
|
|
6
|
+
// that by construction cannot interrupt it.
|
|
7
|
+
//
|
|
8
|
+
// Same getter-parameterized factory shape used consistently across this package's controllers: the
|
|
9
|
+
// host passes thunks rather than values, so this module never captures a stale vault. What stays with
|
|
10
|
+
// App is what App owns — the decrypted record and the sink that writes it.
|
|
11
|
+
|
|
12
|
+
import type { VaultSession } from "./vault-session.svelte";
|
|
13
|
+
import { generateDEK, wrapDEKForPublicKey } from "@tinytars/vault/crypto";
|
|
14
|
+
import { bytesToB64 } from "@tinytars/vault/base64";
|
|
15
|
+
import { listMyProviders, lookupProvider, grantProvider, revokeProvider, type ProviderLinkView } from "@tinytars/vault/auth-grants";
|
|
16
|
+
import { approveSupport, approveSupportAsProvider } from "@tinytars/vault/auth-support";
|
|
17
|
+
import { getVaultPrincipals, stageVaultRotation, rotateVault } from "@tinytars/vault/auth-recovery";
|
|
18
|
+
|
|
19
|
+
export interface VaultPrincipalsDeps<V> {
|
|
20
|
+
/** The decrypted record, read fresh — never captured. Still App's, along with the sink below. */
|
|
21
|
+
getVault: () => V | null;
|
|
22
|
+
/** The unlocked-session key material. Stable identity, its own module. */
|
|
23
|
+
session: VaultSession;
|
|
24
|
+
/** Re-encrypts and writes the vault under a given id and key. App owns the sink. */
|
|
25
|
+
saveVault: (vault: V, r2Id: string, dek: CryptoKey) => Promise<unknown>;
|
|
26
|
+
/**
|
|
27
|
+
* The provider-side pair (approve/revoke a support agent's roster grant) reports through App's
|
|
28
|
+
* shared error line rather than this panel's, because it renders in the clinician console where
|
|
29
|
+
* the Access modal is not mounted. Behaviour preserved from the extraction, not a new idea.
|
|
30
|
+
*/
|
|
31
|
+
reportError: (message: string | null) => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface VaultPrincipals {
|
|
35
|
+
readonly open: boolean;
|
|
36
|
+
readonly providers: ProviderLinkView[];
|
|
37
|
+
readonly busy: boolean;
|
|
38
|
+
readonly error: string | null;
|
|
39
|
+
/** Bound to the add-provider input. */
|
|
40
|
+
newProviderEmail: string;
|
|
41
|
+
/** Bound to the approval TTL select. */
|
|
42
|
+
ttlHours: number;
|
|
43
|
+
|
|
44
|
+
/** Support agents who have asked and not yet been approved. */
|
|
45
|
+
readonly pendingSupport: ProviderLinkView[];
|
|
46
|
+
/** Everything that is not a pending support request — clinicians plus approved support. */
|
|
47
|
+
readonly activeAccess: ProviderLinkView[];
|
|
48
|
+
/** Approved support agents, as the clinician console lists them. */
|
|
49
|
+
readonly activeSupport: ProviderLinkView[];
|
|
50
|
+
|
|
51
|
+
openPanel(): Promise<void>;
|
|
52
|
+
closePanel(): void;
|
|
53
|
+
/** Reloads the list without opening the panel. Non-fatal: an empty section beats a blocked boot. */
|
|
54
|
+
refreshQuietly(): Promise<void>;
|
|
55
|
+
addProvider(): Promise<void>;
|
|
56
|
+
revoke(p: ProviderLinkView): Promise<void>;
|
|
57
|
+
approve(p: ProviderLinkView): Promise<void>;
|
|
58
|
+
approveAsProvider(p: ProviderLinkView): Promise<void>;
|
|
59
|
+
revokeAsProvider(p: ProviderLinkView): Promise<void>;
|
|
60
|
+
/**
|
|
61
|
+
* Mints a fresh DEK, re-encrypts the vault under it, re-wraps it to every remaining principal and
|
|
62
|
+
* swaps. Exposed because a pending rotation from a prior session is resumed at boot.
|
|
63
|
+
*/
|
|
64
|
+
rotateVaultKey(): Promise<void>;
|
|
65
|
+
/** Clears panel state on sign-out. */
|
|
66
|
+
reset(): void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function createVaultPrincipals<V>(deps: VaultPrincipalsDeps<V>): VaultPrincipals {
|
|
70
|
+
let open = $state(false);
|
|
71
|
+
let providers = $state<ProviderLinkView[]>([]);
|
|
72
|
+
let newProviderEmail = $state("");
|
|
73
|
+
let busy = $state(false);
|
|
74
|
+
let error = $state<string | null>(null);
|
|
75
|
+
let ttlHours = $state(72);
|
|
76
|
+
|
|
77
|
+
const pendingSupport = $derived(providers.filter((p) => p.kind === "support" && p.status === "invited"));
|
|
78
|
+
const activeAccess = $derived(providers.filter((p) => !(p.kind === "support" && p.status === "invited")));
|
|
79
|
+
const activeSupport = $derived(providers.filter((p) => p.kind === "support" && p.status === "active"));
|
|
80
|
+
|
|
81
|
+
/** The busy/error/reload envelope every handler here shares. */
|
|
82
|
+
async function run(body: () => Promise<void>, report: (m: string | null) => void): Promise<void> {
|
|
83
|
+
busy = true;
|
|
84
|
+
report(null);
|
|
85
|
+
try {
|
|
86
|
+
await body();
|
|
87
|
+
providers = await listMyProviders();
|
|
88
|
+
} catch (e) {
|
|
89
|
+
report((e as Error).message);
|
|
90
|
+
} finally {
|
|
91
|
+
busy = false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const toPanel = (m: string | null) => (error = m);
|
|
96
|
+
|
|
97
|
+
async function rotateVaultKey(): Promise<void> {
|
|
98
|
+
const vault = deps.getVault();
|
|
99
|
+
const { session } = deps;
|
|
100
|
+
if (!vault || !session.dek || !session.r2Id) return;
|
|
101
|
+
const principals = await getVaultPrincipals();
|
|
102
|
+
const newDek = await generateDEK();
|
|
103
|
+
// The blob goes to a NEW object, reserved here, and the envelope commit below doubles as the
|
|
104
|
+
// pointer swap. This used to re-encrypt in place and commit the matching envelopes four round
|
|
105
|
+
// trips later; anything that interrupted that window — a dropped connection, a closed lid
|
|
106
|
+
// mid-revoke — left every principal holding an envelope for a key the ciphertext no longer used.
|
|
107
|
+
// An interruption before the commit now leaves the old blob and the old envelopes still agreeing,
|
|
108
|
+
// and the abandoned object is ciphertext under a key nobody kept.
|
|
109
|
+
const newVaultId = await stageVaultRotation(principals.vaultId);
|
|
110
|
+
await deps.saveVault(vault, newVaultId, newDek);
|
|
111
|
+
const targets = [
|
|
112
|
+
{ accountId: principals.selfAccountId, publicKeyJwk: principals.selfPublicKeyJwk },
|
|
113
|
+
// A patient who removed org recovery must not have it silently restored by the next re-key.
|
|
114
|
+
...(principals.orgRecoveryRevokedAt ? [] : [{ accountId: principals.orgAccountId, publicKeyJwk: principals.orgPublicKeyJwk }]),
|
|
115
|
+
...principals.providers,
|
|
116
|
+
];
|
|
117
|
+
const envelopes = await Promise.all(
|
|
118
|
+
targets.map(async (t) => {
|
|
119
|
+
const e = await wrapDEKForPublicKey(newDek, t.publicKeyJwk);
|
|
120
|
+
return { principalAccountId: t.accountId, wrappedDEK: bytesToB64(e.wrappedDEK), ephemeralPublicKeyJwk: e.ephemeralPublicKeyJwk };
|
|
121
|
+
}),
|
|
122
|
+
);
|
|
123
|
+
await rotateVault({ vaultId: principals.vaultId, newVaultId, envelopes });
|
|
124
|
+
session.open(newVaultId, newDek);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
get open() {
|
|
129
|
+
return open;
|
|
130
|
+
},
|
|
131
|
+
get providers() {
|
|
132
|
+
return providers;
|
|
133
|
+
},
|
|
134
|
+
get busy() {
|
|
135
|
+
return busy;
|
|
136
|
+
},
|
|
137
|
+
get error() {
|
|
138
|
+
return error;
|
|
139
|
+
},
|
|
140
|
+
get newProviderEmail() {
|
|
141
|
+
return newProviderEmail;
|
|
142
|
+
},
|
|
143
|
+
set newProviderEmail(v: string) {
|
|
144
|
+
newProviderEmail = v;
|
|
145
|
+
},
|
|
146
|
+
get ttlHours() {
|
|
147
|
+
return ttlHours;
|
|
148
|
+
},
|
|
149
|
+
set ttlHours(v: number) {
|
|
150
|
+
ttlHours = v;
|
|
151
|
+
},
|
|
152
|
+
get pendingSupport() {
|
|
153
|
+
return pendingSupport;
|
|
154
|
+
},
|
|
155
|
+
get activeAccess() {
|
|
156
|
+
return activeAccess;
|
|
157
|
+
},
|
|
158
|
+
get activeSupport() {
|
|
159
|
+
return activeSupport;
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
async openPanel() {
|
|
163
|
+
open = true;
|
|
164
|
+
error = null;
|
|
165
|
+
try {
|
|
166
|
+
providers = await listMyProviders();
|
|
167
|
+
} catch (e) {
|
|
168
|
+
error = (e as Error).message;
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
closePanel() {
|
|
173
|
+
open = false;
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
async refreshQuietly() {
|
|
177
|
+
try {
|
|
178
|
+
providers = await listMyProviders();
|
|
179
|
+
} catch {
|
|
180
|
+
/* non-fatal; the section just stays empty */
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
async addProvider() {
|
|
185
|
+
const emailInput = newProviderEmail.trim();
|
|
186
|
+
const dek = deps.session.dek;
|
|
187
|
+
if (!emailInput || !dek) return;
|
|
188
|
+
busy = true;
|
|
189
|
+
error = null;
|
|
190
|
+
try {
|
|
191
|
+
const provider = await lookupProvider(emailInput);
|
|
192
|
+
// Not an exception, and deliberately not a reload either: a typo is an ordinary outcome, and
|
|
193
|
+
// re-listing for it would be a round trip that changes nothing.
|
|
194
|
+
if (!provider) {
|
|
195
|
+
error = "No provider found with that email.";
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
await grantProvider(dek, provider);
|
|
199
|
+
newProviderEmail = "";
|
|
200
|
+
providers = await listMyProviders();
|
|
201
|
+
} catch (e) {
|
|
202
|
+
error = (e as Error).message;
|
|
203
|
+
} finally {
|
|
204
|
+
busy = false;
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
async revoke(p: ProviderLinkView) {
|
|
209
|
+
await run(async () => {
|
|
210
|
+
await revokeProvider(p.linkId);
|
|
211
|
+
// True forward-secret revocation for SUPPORT: re-key the vault so a support agent
|
|
212
|
+
// who cached the DEK can no longer decrypt it. Clinician revoke stays delete-only. Denying a
|
|
213
|
+
// pending (never-active) support request needs no rotation — support never held the DEK.
|
|
214
|
+
if (p.kind === "support" && p.status === "active") await rotateVaultKey();
|
|
215
|
+
}, toPanel);
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
async approve(p: ProviderLinkView) {
|
|
219
|
+
if (!deps.session.dek || !p.publicKeyJwk) return;
|
|
220
|
+
const dek = deps.session.dek;
|
|
221
|
+
const jwk = p.publicKeyJwk;
|
|
222
|
+
await run(() => approveSupport(p.linkId, dek, jwk, ttlHours).then(() => undefined), toPanel);
|
|
223
|
+
},
|
|
224
|
+
|
|
225
|
+
async approveAsProvider(p: ProviderLinkView) {
|
|
226
|
+
await run(() => approveSupportAsProvider(p.linkId, ttlHours).then(() => undefined), deps.reportError);
|
|
227
|
+
},
|
|
228
|
+
|
|
229
|
+
async revokeAsProvider(p: ProviderLinkView) {
|
|
230
|
+
// No vault rotation, unlike the patient-side revoke: a provider owns nothing encrypted, and
|
|
231
|
+
// support only ever held per-patient DEKs via separate patient grants, which are the patients'
|
|
232
|
+
// to rotate.
|
|
233
|
+
await run(() => revokeProvider(p.linkId).then(() => undefined), deps.reportError);
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
rotateVaultKey,
|
|
237
|
+
|
|
238
|
+
reset() {
|
|
239
|
+
open = false;
|
|
240
|
+
providers = [];
|
|
241
|
+
newProviderEmail = "";
|
|
242
|
+
error = null;
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { VaultSession } from "@tinytars/vault/vault-session";
|
|
2
|
+
|
|
3
|
+
export type { VaultEntry, VaultSession } from "@tinytars/vault/vault-session";
|
|
4
|
+
export { openVault } from "@tinytars/vault/vault-session";
|
|
5
|
+
|
|
6
|
+
// The unlocked-session key material, as one object with one transition each way.
|
|
7
|
+
//
|
|
8
|
+
// This consolidation was done as its own careful, tested commit rather than a drive-by change
|
|
9
|
+
// folded into a larger move — the key material it manages is live PHI-handling code, and a change
|
|
10
|
+
// to it needs its own verification pass regardless of what else is happening around it.
|
|
11
|
+
//
|
|
12
|
+
// The argument is not only tidiness. Until now `dek`, `vaultR2Id` and the two private keys were four
|
|
13
|
+
// separate `$state` declarations, set in four separate assignments and cleared in four more. Nothing
|
|
14
|
+
// made them move together, so a HALF-OPEN session was representable: `backToRoster()` clears the DEK
|
|
15
|
+
// and the r2 id, and any future path that forgot one would leave a live data key in memory for a
|
|
16
|
+
// vault the user believes they have closed. Here that state cannot be expressed — `close()` clears
|
|
17
|
+
// everything, and `isOpen` is derived rather than tracked.
|
|
18
|
+
//
|
|
19
|
+
// What is deliberately NOT here: `vault` itself, the decrypted record. It has 86 references in
|
|
20
|
+
// App and is mutated by every save, so moving it is a much larger edit than moving the key
|
|
21
|
+
// material, and a larger edit on PHI-handling code than this one commit should carry. The key
|
|
22
|
+
// material is the part whose lifetime is a security property; the plaintext record's lifetime is the
|
|
23
|
+
// same as the key's by construction, because it cannot be re-read without one.
|
|
24
|
+
//
|
|
25
|
+
// `VaultEntry`/`VaultSession` (the shapes) and `openVault` (rune-free logic) live in
|
|
26
|
+
// packages/security/vault-session.ts — this file imports them back and adds only the one thing that
|
|
27
|
+
// cannot move there: the $state-backed implementation, which is Svelte-coupled by construction.
|
|
28
|
+
|
|
29
|
+
export function createVaultSession(): VaultSession {
|
|
30
|
+
let dek = $state<CryptoKey | null>(null);
|
|
31
|
+
let r2Id = $state<string | null>(null);
|
|
32
|
+
let ownerKey = $state<CryptoKey | null>(null);
|
|
33
|
+
let providerKey = $state<CryptoKey | null>(null);
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
get dek() {
|
|
37
|
+
return dek;
|
|
38
|
+
},
|
|
39
|
+
get r2Id() {
|
|
40
|
+
return r2Id;
|
|
41
|
+
},
|
|
42
|
+
get ownerKey() {
|
|
43
|
+
return ownerKey;
|
|
44
|
+
},
|
|
45
|
+
get providerKey() {
|
|
46
|
+
return providerKey;
|
|
47
|
+
},
|
|
48
|
+
get isOpen() {
|
|
49
|
+
// Both, though the API makes it impossible for them to disagree — `open` sets the pair and
|
|
50
|
+
// `close` clears the pair. That redundancy is deliberate defence in depth and is deliberately
|
|
51
|
+
// NOT testable: a mutation to `r2Id !== null` alone passes every test in vault-session.test.ts,
|
|
52
|
+
// because reaching the state it would misreport requires bypassing this object. Noted rather
|
|
53
|
+
// than covered by a contrived test.
|
|
54
|
+
return dek !== null && r2Id !== null;
|
|
55
|
+
},
|
|
56
|
+
open(nextId: string, nextDek: CryptoKey) {
|
|
57
|
+
r2Id = nextId;
|
|
58
|
+
dek = nextDek;
|
|
59
|
+
},
|
|
60
|
+
setOwnerKey(key: CryptoKey | null) {
|
|
61
|
+
ownerKey = key;
|
|
62
|
+
},
|
|
63
|
+
setProviderKey(key: CryptoKey | null) {
|
|
64
|
+
providerKey = key;
|
|
65
|
+
},
|
|
66
|
+
close() {
|
|
67
|
+
dek = null;
|
|
68
|
+
r2Id = null;
|
|
69
|
+
},
|
|
70
|
+
signOut() {
|
|
71
|
+
dek = null;
|
|
72
|
+
r2Id = null;
|
|
73
|
+
ownerKey = null;
|
|
74
|
+
providerKey = null;
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|