@glance-apps/sync 1.6.0 → 1.8.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.
@@ -15,6 +15,16 @@
15
15
  // GET /salt/:accountId fetch the account root-key salt
16
16
  // PUT /salt/:accountId register a salt (first-write-wins)
17
17
  //
18
+ // Auth models (vault Phase 1.4b — the client half of per-account credentials):
19
+ // a vault runs in one of two modes, discoverable unauthenticated via
20
+ // fetchVaultHealth. In "shared" mode every device presents the instance-wide
21
+ // device token as the Bearer value. In "per-account" mode each device first
22
+ // exchanges the admin-configured bootstrap secret for its own credential at
23
+ // POST /enroll (enrollVaultDevice below) and presents THAT as the Bearer
24
+ // value — the wire shape of every scoped call is identical in both modes, so
25
+ // createVaultClient is mode-agnostic: callers pass the shared token or the
26
+ // per-device credential as vaultToken and nothing else changes.
27
+ //
18
28
  // Salts cross the wire as base64 in a { salt } field. The client converts to and
19
29
  // from Uint8Array at the boundary so callers always deal in bytes.
20
30
 
@@ -29,23 +39,138 @@ class VaultError extends Error {
29
39
  }
30
40
  }
31
41
 
42
+ // Shared by createVaultClient and the standalone pre-credential helpers below.
43
+ const resolveFetch = (fetchImpl, context) => {
44
+ const doFetch = fetchImpl || globalThis.fetch;
45
+ if (typeof doFetch !== 'function') {
46
+ throw new Error(`${context}: no fetch implementation available`);
47
+ }
48
+ return doFetch;
49
+ };
50
+
51
+ const requireBaseUrl = (vaultUrl, context) => {
52
+ if (typeof vaultUrl !== 'string' || vaultUrl.trim() === '') {
53
+ throw new Error(`${context}: vaultUrl is required`);
54
+ }
55
+ return vaultUrl.replace(/\/+$/, '');
56
+ };
57
+
58
+ /**
59
+ * Fetches the vault's public health document. Unauthenticated — /healthz is
60
+ * the one endpoint that never requires a token, in either auth mode, so this
61
+ * is safe to call before the user has pasted anything.
62
+ *
63
+ * `authMode` is normalized to 'shared' when the field is absent: servers that
64
+ * predate it are all shared-token servers, so a client can branch on
65
+ * `health.authMode === 'per-account'` against any server version.
66
+ *
67
+ * @param {object} config
68
+ * @param {string} config.vaultUrl - base URL of the GLANCEvault server
69
+ * @param {Function} [config.fetchImpl] - fetch implementation (defaults to global fetch)
70
+ * @returns {Promise<{ status: string, version: string, schemaVersion: number, authMode: 'shared'|'per-account' }>}
71
+ */
72
+ export async function fetchVaultHealth({ vaultUrl, fetchImpl } = {}) {
73
+ const base = requireBaseUrl(vaultUrl, 'fetchVaultHealth');
74
+ const doFetch = resolveFetch(fetchImpl, 'fetchVaultHealth');
75
+ const res = await doFetch(`${base}/healthz`, { method: 'GET', headers: {} });
76
+ if (!res.ok) {
77
+ throw new VaultError(`health check failed: ${res.status}`, res.status);
78
+ }
79
+ const body = await res.json();
80
+ return {
81
+ ...body,
82
+ authMode: typeof body.authMode === 'string' ? body.authMode : 'shared',
83
+ };
84
+ }
85
+
86
+ /**
87
+ * Enrolls this device with a per-account vault: exchanges the admin-configured
88
+ * bootstrap secret for the device's own credential (POST /enroll). The secret
89
+ * rides in the request body, never the query string (query strings appear in
90
+ * proxy logs), and no Authorization header is sent — the secret IS this
91
+ * route's authentication.
92
+ *
93
+ * The returned `credential` appears in the response ONCE and is never
94
+ * retrievable again (the server stores only a hash). Callers must persist it
95
+ * and then DISCARD the bootstrap secret: nothing ever asks for the secret
96
+ * again, and a device that loses its credential simply re-enrolls — every
97
+ * successful call mints a fresh credential. Pass the credential as
98
+ * `vaultToken` to createVaultClient / the DB engine; the wire shape of scoped
99
+ * calls is unchanged.
100
+ *
101
+ * Typed failures:
102
+ * - `ENROLLMENT_REJECTED` (401): the server did not accept the secret.
103
+ * - `ENROLLMENT_UNSUPPORTED` (404): the server does not offer enrollment —
104
+ * it runs shared auth mode (the route is registration-gated off) or
105
+ * predates per-account auth. Check fetchVaultHealth().authMode first to
106
+ * distinguish this up front.
107
+ * - `VAULT_ERROR` with `status` for any other non-2xx response.
108
+ *
109
+ * @param {object} config
110
+ * @param {string} config.vaultUrl - base URL of the GLANCEvault server
111
+ * @param {string} config.enrollmentSecret - the admin-configured bootstrap secret
112
+ * @param {string} config.accountId - account to bind the credential to
113
+ * @param {string} config.deviceId - stable identifier for this device
114
+ * @param {Function} [config.fetchImpl] - fetch implementation (defaults to global fetch)
115
+ * @returns {Promise<{ credentialId: string, credential: string, accountId: string, deviceId: string, createdAt: string }>}
116
+ */
117
+ export async function enrollVaultDevice({ vaultUrl, enrollmentSecret, accountId, deviceId, fetchImpl } = {}) {
118
+ const base = requireBaseUrl(vaultUrl, 'enrollVaultDevice');
119
+ const doFetch = resolveFetch(fetchImpl, 'enrollVaultDevice');
120
+
121
+ // Fail typed and off the wire on missing fields, like requireAccountId: the
122
+ // server rejects them with a 400 anyway, but a client-side throw is clearer
123
+ // and never puts a malformed enrollment on the network. Values are sent
124
+ // byte-exact — validation only rejects missing/whitespace-only input, it
125
+ // never trims what goes on the wire (matching the server's semantics).
126
+ const requireField = (value, name, code) => {
127
+ if (typeof value !== 'string' || value.trim() === '') {
128
+ const err = new Error(`enrollVaultDevice: ${name} is required but was missing or empty.`);
129
+ err.code = code;
130
+ throw err;
131
+ }
132
+ };
133
+ requireField(enrollmentSecret, 'enrollmentSecret', 'ENROLLMENT_SECRET_REQUIRED');
134
+ requireField(accountId, 'accountId', 'ACCOUNT_ID_REQUIRED');
135
+ requireField(deviceId, 'deviceId', 'DEVICE_ID_REQUIRED');
136
+
137
+ const res = await doFetch(`${base}/enroll`, {
138
+ method: 'POST',
139
+ headers: { 'Content-Type': 'application/json' },
140
+ body: JSON.stringify({ enrollmentSecret, accountId, deviceId }),
141
+ });
142
+
143
+ if (res.status === 401) {
144
+ const err = new VaultError('enroll failed: enrollment secret rejected', 401);
145
+ err.code = 'ENROLLMENT_REJECTED';
146
+ throw err;
147
+ }
148
+ if (res.status === 404) {
149
+ // Express's default 404: the /enroll route is only registered in
150
+ // per-account mode, so this server cannot enroll anyone.
151
+ const err = new VaultError('enroll failed: this server does not offer enrollment (shared auth mode?)', 404);
152
+ err.code = 'ENROLLMENT_UNSUPPORTED';
153
+ throw err;
154
+ }
155
+ if (!res.ok) {
156
+ throw new VaultError(`enroll failed: ${res.status}`, res.status);
157
+ }
158
+ return res.json();
159
+ }
160
+
32
161
  /**
33
162
  * Creates a vault client bound to a server URL and token.
34
163
  *
35
164
  * @param {object} config
36
165
  * @param {string} config.vaultUrl - base URL of the GLANCEvault server
37
- * @param {string} config.vaultToken - Bearer token
166
+ * @param {string} config.vaultToken - Bearer token: the shared device token
167
+ * (shared mode) or this device's enrolled credential (per-account mode)
38
168
  * @param {Function} [config.fetchImpl] - fetch implementation (defaults to global fetch)
39
169
  */
40
170
  export function createVaultClient({ vaultUrl, vaultToken, fetchImpl } = {}) {
41
- if (!vaultUrl) throw new Error('createVaultClient: vaultUrl is required');
171
+ const base = requireBaseUrl(vaultUrl, 'createVaultClient');
42
172
  if (!vaultToken) throw new Error('createVaultClient: vaultToken is required');
43
- const doFetch = fetchImpl || globalThis.fetch;
44
- if (typeof doFetch !== 'function') {
45
- throw new Error('createVaultClient: no fetch implementation available');
46
- }
47
-
48
- const base = vaultUrl.replace(/\/+$/, '');
173
+ const doFetch = resolveFetch(fetchImpl, 'createVaultClient');
49
174
  const authHeaders = (extra = {}) => ({
50
175
  Authorization: `Bearer ${vaultToken}`,
51
176
  ...extra,
@@ -66,9 +191,33 @@ export function createVaultClient({ vaultUrl, vaultToken, fetchImpl } = {}) {
66
191
  return res;
67
192
  };
68
193
 
194
+ // Error-body inspection is BEST-EFFORT and deliberately narrow. The server
195
+ // made the 401 body wording the only signal separating shared mode's
196
+ // "invalid device token" (server misconfigured / wrong token) from
197
+ // per-account mode's "invalid credential" (this device must re-enroll —
198
+ // a Phase 2.2 recovery flow; nothing in this package re-enrolls). Only that
199
+ // exact wording upgrades the code to CREDENTIAL_INVALID; a missing,
200
+ // non-JSON, or unrecognized body degrades to the generic VAULT_ERROR, so
201
+ // the client's correctness never depends on server wording.
202
+ const errorFromResponse = async (res, context) => {
203
+ let bodyError = null;
204
+ try {
205
+ const body = await res.json();
206
+ if (body && typeof body.error === 'string') bodyError = body.error;
207
+ } catch {
208
+ // Empty or non-JSON error body: keep the generic classification.
209
+ }
210
+ if (res.status === 401 && bodyError === 'invalid credential') {
211
+ const err = new VaultError(`${context} failed: 401 — the server rejected this device's credential`, 401);
212
+ err.code = 'CREDENTIAL_INVALID';
213
+ return err;
214
+ }
215
+ return new VaultError(`${context} failed: ${res.status}`, res.status);
216
+ };
217
+
69
218
  const jsonOrThrow = async (res, context) => {
70
219
  if (!res.ok) {
71
- throw new VaultError(`${context} failed: ${res.status}`, res.status);
220
+ throw await errorFromResponse(res, context);
72
221
  }
73
222
  return res.json();
74
223
  };
@@ -0,0 +1,391 @@
1
+ // The packaged vault connect flow (vault Phase 1.4b).
2
+ //
3
+ // This is the front door for building a DB sync engine against a GLANCEvault
4
+ // server without the app implementing auth-mode discovery, enrollment,
5
+ // credential persistence, or bootstrap-secret discard itself. The app hands
6
+ // over whatever it has — the shared device token, a bootstrap secret, or
7
+ // nothing beyond a previously connected state — and gets a working engine
8
+ // back:
9
+ //
10
+ // 1. DISCOVER GET /healthz (unauthenticated) → 'shared' | 'per-account'.
11
+ // A missing authMode field means shared (every server that
12
+ // predates the field is a shared-token server), so version
13
+ // skew is an ordinary case, not an error.
14
+ // 2. BRANCH shared → use config.vaultToken, exactly as before 1.4b.
15
+ // per-account → use the persisted credential; enroll with the
16
+ // bootstrap secret only when no credential is stored yet.
17
+ // 3. ENROLL POST /enroll, then persist the credential DURABLY before
18
+ // treating enrollment as complete (enrollment is
19
+ // non-idempotent — every call mints a server row).
20
+ // 4. BUILD createDbSyncEngine with the resolved Bearer value.
21
+ //
22
+ // SECRET LIFETIME: the bootstrap secret exists inside this module only as the
23
+ // `enrollmentSecret` binding for the duration of one connectVaultSyncEngine
24
+ // call. It is sent in the enroll request body and nowhere else: never written
25
+ // to any storage, never placed on the engine config, never logged, never
26
+ // retained. When the call returns, the package holds no reference. (The app
27
+ // still owns the copy it passed in — dropping that is the app's one remaining
28
+ // obligation.)
29
+ //
30
+ // NO AUTOMATIC RE-ENROLLMENT, on any path. Once a credential is stored for
31
+ // this (server, account), this flow always uses it and never enrolls again —
32
+ // even when the credential has been rejected and the engine is halted
33
+ // (CREDENTIAL_INVALID). Re-enrolling on failure would mint a credential row
34
+ // per attempt and would let a revoked device silently re-admit itself, which
35
+ // is exactly what revocation (vault Phase 2.1) must be able to prevent.
36
+ // Recovery from a rejected credential is Phase 2.2.
37
+
38
+ import { fetchVaultHealth, enrollVaultDevice } from './vaultClient.js';
39
+ import {
40
+ createDbSyncEngine,
41
+ getOrCreateDeviceId,
42
+ vaultCredentialKey,
43
+ credentialHaltKey,
44
+ } from './dbEngine.js';
45
+
46
+ const typedError = (message, code) => {
47
+ const err = new Error(message);
48
+ err.code = code;
49
+ return err;
50
+ };
51
+
52
+ // Storage canary: prove the credential slot is writable BEFORE minting.
53
+ // Enrollment is non-idempotent, so if device storage cannot hold the
54
+ // credential we must find out before the server creates a row, not after.
55
+ // (A crash between mint and persist can still orphan one row; since 2.1 the
56
+ // next successful enrollment with the same (accountId, deviceId) supersedes
57
+ // it, so even that is no longer a live orphan. Broken storage must still not
58
+ // orphan one per attempt.)
59
+ const ensureCredentialSlotWritable = (key, context) => {
60
+ // The slot's prior contents are RESTORED, not discarded: during recovery
61
+ // the slot holds the stale record, and a failed enrollment after the canary
62
+ // must leave that record exactly as it was (a failed recovery leaves the
63
+ // device halted with its state intact, never ambiguous).
64
+ const prior = localStorage.getItem(key);
65
+ const canary = JSON.stringify({ canary: true });
66
+ try {
67
+ localStorage.setItem(key, canary);
68
+ if (localStorage.getItem(key) !== canary) throw new Error('read-back mismatch');
69
+ if (prior === null) localStorage.removeItem(key);
70
+ else localStorage.setItem(key, prior);
71
+ } catch {
72
+ throw typedError(
73
+ `${context}: device storage cannot persist a credential; refusing to enroll.`,
74
+ 'CREDENTIAL_PERSIST_FAILED'
75
+ );
76
+ }
77
+ };
78
+
79
+ // Persist durably, then read back and verify, BEFORE enrollment is treated as
80
+ // complete. Overwrites whatever was in the slot — the credential slot holds
81
+ // exactly one record, so a stale credential never survives alongside a new
82
+ // one. Only the credential and its metadata are written — never the secret.
83
+ const persistCredentialRecord = (key, record, context) => {
84
+ try {
85
+ localStorage.setItem(key, JSON.stringify(record));
86
+ const readBack = JSON.parse(localStorage.getItem(key));
87
+ if (!readBack || readBack.credential !== record.credential) throw new Error('read-back mismatch');
88
+ } catch {
89
+ throw typedError(
90
+ `${context}: enrolled but could not persist the credential; not treating enrollment as complete.`,
91
+ 'CREDENTIAL_PERSIST_FAILED'
92
+ );
93
+ }
94
+ };
95
+
96
+ // Byte-exact everywhere: validation only rejects missing/whitespace-only
97
+ // values, it never trims or normalizes what is stored or sent. Whitespace-
98
+ // distinct account ids are distinct live accounts on the server.
99
+ const requireString = (value, name, code) => {
100
+ if (typeof value !== 'string' || value.trim() === '') {
101
+ throw typedError(`connectVaultSyncEngine: ${name} is required but was missing or empty.`, code);
102
+ }
103
+ };
104
+
105
+ /**
106
+ * Discovers the server's auth mode, resolves the Bearer value for this device
107
+ * (enrolling if the server is per-account and no credential is stored yet),
108
+ * and returns a ready sync engine.
109
+ *
110
+ * Config is DbSyncEngineConfig plus:
111
+ * @param {string} [config.enrollmentSecret] - bootstrap secret for first-time
112
+ * enrollment against a per-account server. Ignored (and not needed) once a
113
+ * credential is stored. See SECRET LIFETIME above.
114
+ * @param {string} [config.vaultToken] - the shared device token. Required
115
+ * when the server runs shared mode; ignored against a per-account server
116
+ * (the shared token authenticates nothing there).
117
+ *
118
+ * Typed failures:
119
+ * - VAULT_TOKEN_REQUIRED: server is shared-mode and no vaultToken was given.
120
+ * - ENROLLMENT_SECRET_REQUIRED: server is per-account, no credential is
121
+ * stored, and no enrollmentSecret was given.
122
+ * - CREDENTIAL_PERSIST_FAILED: device storage rejected the credential write.
123
+ * When storage is detectably broken this is raised BEFORE enrolling, so a
124
+ * client with broken storage does not mint a credential row per launch.
125
+ * - VAULT_UNREACHABLE: discovery failed and there is no stored credential
126
+ * and no vaultToken to fall back on.
127
+ * - Plus everything enrollVaultDevice can throw (ENROLLMENT_REJECTED, ...).
128
+ *
129
+ * @returns {Promise<{ engine: object, authMode: 'shared'|'per-account'|null,
130
+ * enrolled: boolean, deviceId: string }>}
131
+ * authMode is null when discovery failed and the flow fell back to the last
132
+ * known state; `enrolled` is true only when this call minted a credential.
133
+ */
134
+ export async function connectVaultSyncEngine(config = {}) {
135
+ // The secret is destructured OFF the config here so the object handed to
136
+ // createDbSyncEngine below can never carry it.
137
+ const { enrollmentSecret, ...engineConfig } = config;
138
+ const { storageKeyPrefix, vaultUrl, vaultToken, accountId, fetchImpl } = engineConfig;
139
+
140
+ requireString(storageKeyPrefix, 'storageKeyPrefix', 'STORAGE_KEY_PREFIX_REQUIRED');
141
+ requireString(vaultUrl, 'vaultUrl', 'VAULT_URL_REQUIRED');
142
+ requireString(accountId, 'accountId', 'ACCOUNT_ID_REQUIRED');
143
+
144
+ // Same normalization the vault client applies to every request URL, so the
145
+ // stored credential's server binding survives a trailing-slash difference.
146
+ const base = vaultUrl.replace(/\/+$/, '');
147
+ const deviceId = engineConfig.deviceId || getOrCreateDeviceId(storageKeyPrefix);
148
+ const KEY_CREDENTIAL = vaultCredentialKey(storageKeyPrefix);
149
+
150
+ // A stored credential is only usable for the exact (server, account) it was
151
+ // minted for: the server binds it to an account and 403s any other claim.
152
+ const readStoredCredential = () => {
153
+ const raw = localStorage.getItem(KEY_CREDENTIAL);
154
+ if (!raw) return null;
155
+ try {
156
+ const rec = JSON.parse(raw);
157
+ return rec && rec.credential && rec.vaultUrl === base && rec.accountId === accountId ? rec : null;
158
+ } catch {
159
+ return null;
160
+ }
161
+ };
162
+
163
+ // 1. Discover. Failure (unreachable, non-JSON, non-2xx) is not fatal here:
164
+ // an offline launch must behave exactly as pre-1.4b — build the engine from
165
+ // whatever auth state exists and let the first sync surface any error.
166
+ let authMode = null;
167
+ try {
168
+ const health = await fetchVaultHealth({ vaultUrl: base, fetchImpl });
169
+ // Only the two known modes are acted on; a future unknown mode string
170
+ // falls through to the last-known-state path rather than being guessed at.
171
+ if (health.authMode === 'shared' || health.authMode === 'per-account') {
172
+ authMode = health.authMode;
173
+ }
174
+ } catch {
175
+ authMode = null;
176
+ }
177
+
178
+ const stored = readStoredCredential();
179
+ let token;
180
+ let enrolled = false;
181
+
182
+ if (authMode === 'per-account') {
183
+ if (stored) {
184
+ token = stored.credential;
185
+ } else {
186
+ // First contact with a per-account server: enrollment needs the secret.
187
+ // The shared token is deliberately NOT a fallback — it authenticates
188
+ // nothing on this server and sending it just produces a confusing 401.
189
+ if (typeof enrollmentSecret !== 'string' || enrollmentSecret.trim() === '') {
190
+ throw typedError(
191
+ 'connectVaultSyncEngine: this server requires per-device enrollment and no credential is stored — pass enrollmentSecret.',
192
+ 'ENROLLMENT_SECRET_REQUIRED'
193
+ );
194
+ }
195
+
196
+ ensureCredentialSlotWritable(KEY_CREDENTIAL, 'connectVaultSyncEngine');
197
+
198
+ const enrollment = await enrollVaultDevice({
199
+ vaultUrl: base,
200
+ enrollmentSecret,
201
+ accountId,
202
+ deviceId,
203
+ fetchImpl,
204
+ });
205
+
206
+ persistCredentialRecord(KEY_CREDENTIAL, {
207
+ credentialId: enrollment.credentialId,
208
+ credential: enrollment.credential,
209
+ accountId,
210
+ deviceId,
211
+ vaultUrl: base,
212
+ createdAt: enrollment.createdAt,
213
+ }, 'connectVaultSyncEngine');
214
+
215
+ token = enrollment.credential;
216
+ enrolled = true;
217
+ }
218
+ } else if (authMode === 'shared') {
219
+ // The default path, unchanged from today. A stored credential from an
220
+ // earlier per-account life of this server is ignored (never sent to a
221
+ // shared-mode server) but kept in storage in case the operator switches
222
+ // back.
223
+ if (typeof vaultToken !== 'string' || vaultToken.trim() === '') {
224
+ throw typedError(
225
+ 'connectVaultSyncEngine: this server uses a shared device token — pass vaultToken.',
226
+ 'VAULT_TOKEN_REQUIRED'
227
+ );
228
+ }
229
+ token = vaultToken;
230
+ } else {
231
+ // Discovery failed: fall back to the last known auth state, which is
232
+ // byte-for-byte the pre-1.4b behavior for existing installs (build with
233
+ // the token you have; the first sync surfaces any real problem).
234
+ token = stored ? stored.credential : vaultToken;
235
+ if (typeof token !== 'string' || token.trim() === '') {
236
+ throw typedError(
237
+ `connectVaultSyncEngine: could not reach ${base}/healthz and no stored credential or vaultToken exists to fall back on.`,
238
+ 'VAULT_UNREACHABLE'
239
+ );
240
+ }
241
+ }
242
+
243
+ const engine = createDbSyncEngine({ ...engineConfig, deviceId, vaultToken: token });
244
+ return { engine, authMode, enrolled, deviceId };
245
+ }
246
+
247
+ /**
248
+ * The exit from the credential halt (Phase 2.2): USER-INITIATED re-enrollment
249
+ * with the bootstrap secret, against a per-account server. Call this only
250
+ * from a deliberate UI action in which the user supplied the secret — never
251
+ * from a startup path, a retry path, or an error handler. Two structural
252
+ * guards enforce that: the package holds no secret (recovery cannot run
253
+ * without one being supplied fresh), and the call REFUSES unless the device
254
+ * is actually halted, so it cannot be wired in as an on-demand rotator.
255
+ *
256
+ * Order of operations — each failure leaves the device HALTED, never
257
+ * ambiguous, and the halt is cleared only after a verified success:
258
+ * 1. halt gate (NOT_HALTED if the device isn't halted)
259
+ * 2. deviceId resolution (stale record's deviceId is ground truth — see below)
260
+ * 3. mode guard (per-account only; NO fallback: VAULT_UNREACHABLE /
261
+ * RECOVERY_UNSUPPORTED leave everything untouched)
262
+ * 4. storage canary (CREDENTIAL_PERSIST_FAILED before minting)
263
+ * 5. enroll (ENROLLMENT_REJECTED etc. leave the halt + stale
264
+ * record intact)
265
+ * 6. persist + verify (overwrites the stale record — it does not survive
266
+ * recovery; CREDENTIAL_PERSIST_FAILED leaves the
267
+ * halt intact)
268
+ * 7. clear the halt (the LAST state change)
269
+ * 8. build a fresh engine
270
+ *
271
+ * DEVICE IDENTITY: the stale record's deviceId wins. Recovery's defining
272
+ * property is rotation — the server (Phase 2.1) revokes every still-active
273
+ * predecessor for the same byte-exact (accountId, deviceId) inside the
274
+ * enrollment transaction — and rotation only lands if we enroll under the
275
+ * identity the dead credential is actually bound to, for which the stored
276
+ * record is ground truth. An explicit config.deviceId that DIFFERS from the
277
+ * record is a conflict surfaced as DEVICE_ID_CONFLICT, not resolved
278
+ * silently: either choice would orphan something (a live predecessor or the
279
+ * device's cursor), so the caller decides. With no readable record, the
280
+ * explicit config value, then the persisted package-owned id, fill in.
281
+ *
282
+ * The engine returned is FRESH — the old engine's client closes over the
283
+ * dead credential and must be discarded by the app. If a stale reference
284
+ * survives anyway, its next 401 finds the stored record differs from its
285
+ * bearer and it goes inert in memory instead of re-halting the device (see
286
+ * isBearerSuperseded in dbEngine.js).
287
+ *
288
+ * SECRET LIFETIME: identical to connectVaultSyncEngine — the secret exists
289
+ * as this call's argument, rides once in the enroll request body, and is
290
+ * never stored, logged, placed on the engine config, or retained.
291
+ *
292
+ * @returns {Promise<{ engine: object, authMode: 'per-account',
293
+ * enrolled: true, deviceId: string }>} — same shape as
294
+ * connectVaultSyncEngine, so apps can swap their engine reference uniformly.
295
+ */
296
+ export async function recoverVaultSyncEngine(config = {}) {
297
+ const { enrollmentSecret, ...engineConfig } = config;
298
+ const { storageKeyPrefix, vaultUrl, accountId, fetchImpl } = engineConfig;
299
+
300
+ requireString(storageKeyPrefix, 'storageKeyPrefix', 'STORAGE_KEY_PREFIX_REQUIRED');
301
+ requireString(vaultUrl, 'vaultUrl', 'VAULT_URL_REQUIRED');
302
+ requireString(accountId, 'accountId', 'ACCOUNT_ID_REQUIRED');
303
+ if (typeof enrollmentSecret !== 'string' || enrollmentSecret.trim() === '') {
304
+ throw typedError(
305
+ 'recoverVaultSyncEngine: recovery requires the bootstrap secret to be supplied (it is never stored).',
306
+ 'ENROLLMENT_SECRET_REQUIRED'
307
+ );
308
+ }
309
+
310
+ const base = vaultUrl.replace(/\/+$/, '');
311
+ const KEY_CREDENTIAL = vaultCredentialKey(storageKeyPrefix);
312
+ const KEY_HALT = credentialHaltKey(storageKeyPrefix);
313
+
314
+ // 1. Halt gate. Recovery is the exit from the halt and nothing else; a
315
+ // device that isn't halted has nothing to recover from, and refusing here
316
+ // is what makes "no routine code path can re-enroll" structural.
317
+ if (localStorage.getItem(KEY_HALT) === null) {
318
+ throw typedError(
319
+ 'recoverVaultSyncEngine: this device is not credential-halted; recovery only runs from the halted state.',
320
+ 'NOT_HALTED'
321
+ );
322
+ }
323
+
324
+ // 2. Device identity, from the stale record when one is readable.
325
+ let staleRecord = null;
326
+ try {
327
+ const raw = localStorage.getItem(KEY_CREDENTIAL);
328
+ staleRecord = raw ? JSON.parse(raw) : null;
329
+ } catch {
330
+ staleRecord = null;
331
+ }
332
+ const recordDeviceId =
333
+ staleRecord && typeof staleRecord.deviceId === 'string' && staleRecord.deviceId !== ''
334
+ ? staleRecord.deviceId
335
+ : null;
336
+ if (recordDeviceId && typeof engineConfig.deviceId === 'string' && engineConfig.deviceId !== recordDeviceId) {
337
+ throw typedError(
338
+ `recoverVaultSyncEngine: config.deviceId ("${engineConfig.deviceId}") differs from the deviceId the stale credential is bound to ("${recordDeviceId}"). Enrolling under either would orphan the other's credential or cursor — resolve the conflict explicitly.`,
339
+ 'DEVICE_ID_CONFLICT'
340
+ );
341
+ }
342
+ const deviceId = recordDeviceId || engineConfig.deviceId || getOrCreateDeviceId(storageKeyPrefix);
343
+
344
+ // 3. Mode guard. Unlike connect, recovery NEVER falls back on discovery
345
+ // failure — it exists only to enroll, and enrolling blind could hit a
346
+ // shared-mode server. Shared mode has no credentials and therefore no
347
+ // recovery; this refusal is what keeps it structurally unreachable.
348
+ let health;
349
+ try {
350
+ health = await fetchVaultHealth({ vaultUrl: base, fetchImpl });
351
+ } catch {
352
+ throw typedError(
353
+ `recoverVaultSyncEngine: could not reach ${base}/healthz; the device stays halted.`,
354
+ 'VAULT_UNREACHABLE'
355
+ );
356
+ }
357
+ if (health.authMode !== 'per-account') {
358
+ throw typedError(
359
+ `recoverVaultSyncEngine: this server reports auth mode "${health.authMode}", not per-account — there is no credential to recover; the device stays halted.`,
360
+ 'RECOVERY_UNSUPPORTED'
361
+ );
362
+ }
363
+
364
+ // 4-6. Canary, enroll (the rotation: same byte-exact accountId + deviceId
365
+ // revokes every still-active predecessor server-side), persist + verify.
366
+ ensureCredentialSlotWritable(KEY_CREDENTIAL, 'recoverVaultSyncEngine');
367
+
368
+ const enrollment = await enrollVaultDevice({
369
+ vaultUrl: base,
370
+ enrollmentSecret,
371
+ accountId,
372
+ deviceId,
373
+ fetchImpl,
374
+ });
375
+
376
+ persistCredentialRecord(KEY_CREDENTIAL, {
377
+ credentialId: enrollment.credentialId,
378
+ credential: enrollment.credential,
379
+ accountId,
380
+ deviceId,
381
+ vaultUrl: base,
382
+ createdAt: enrollment.createdAt,
383
+ }, 'recoverVaultSyncEngine');
384
+
385
+ // 7. Clear the halt — the last state change, after verified persistence.
386
+ localStorage.removeItem(KEY_HALT);
387
+
388
+ // 8. Fresh engine on the new credential.
389
+ const engine = createDbSyncEngine({ ...engineConfig, deviceId, vaultToken: enrollment.credential });
390
+ return { engine, authMode: 'per-account', enrolled: true, deviceId };
391
+ }