@glance-apps/sync 1.6.1 → 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.
- package/CHANGELOG.md +133 -0
- package/package.json +1 -1
- package/src/dbEngine.js +154 -6
- package/src/index.js +15 -2
- package/src/vaultClient.js +158 -9
- package/src/vaultConnect.js +391 -0
- package/types/index.d.ts +116 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,139 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.8.0] - 2026-07-31
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Recovery from a halted credential (GLANCEvault Phase 2.2).** The 1.7.0
|
|
15
|
+
credential halt was built with no exit; this release adds the one exit:
|
|
16
|
+
**`recoverVaultSyncEngine(config)`**, user-initiated re-enrollment with the
|
|
17
|
+
bootstrap secret. Since server Phase 2.1, enrolling the same byte-exact
|
|
18
|
+
`(accountId, deviceId)` revokes every still-active predecessor credential
|
|
19
|
+
in the same transaction, so recovery is a rotation — the dead credential is
|
|
20
|
+
superseded, not accumulated alongside.
|
|
21
|
+
- **Halt-gated, structurally.** The call refuses with `NOT_HALTED` unless
|
|
22
|
+
the device is credential-halted, so it cannot be wired into a startup or
|
|
23
|
+
retry path as an on-demand rotator. Combined with the package holding no
|
|
24
|
+
secret (one must be supplied fresh, and it is confined to the call
|
|
25
|
+
exactly as in `connectVaultSyncEngine` — never stored, logged, or
|
|
26
|
+
retained), user initiation is structural, not procedural. **No code path
|
|
27
|
+
recovers automatically**, including transport failures, startup, or a
|
|
28
|
+
halt discovered at launch.
|
|
29
|
+
- **Per-account only, no fallback.** Unlike connect, recovery never falls
|
|
30
|
+
back on discovery failure: an unreachable `/healthz` is
|
|
31
|
+
`VAULT_UNREACHABLE` and a non-per-account mode is
|
|
32
|
+
`RECOVERY_UNSUPPORTED`; both leave the device halted and untouched.
|
|
33
|
+
Shared mode has no credentials, no halt, and no reachable recovery path.
|
|
34
|
+
- **Order of operations**, each failure leaving the device halted rather
|
|
35
|
+
than ambiguous: halt gate → deviceId resolution → mode guard → storage
|
|
36
|
+
canary (now restores the slot's prior contents, so a failed recovery
|
|
37
|
+
keeps the stale record byte-identical) → enroll → persist + read-back
|
|
38
|
+
verify (overwriting the stale record — it does not survive recovery) →
|
|
39
|
+
**clear the halt last** → return a fresh engine. The old engine's client
|
|
40
|
+
closes over the dead credential; apps must swap to the returned engine.
|
|
41
|
+
- **deviceId: the stale record's value wins.** Rotation only lands if
|
|
42
|
+
enrollment uses the identity the dead credential is bound to, and the
|
|
43
|
+
stored record is ground truth for that. An explicit `config.deviceId`
|
|
44
|
+
that differs is surfaced as `DEVICE_ID_CONFLICT`, never resolved
|
|
45
|
+
silently (either choice orphans a credential or a cursor). With no
|
|
46
|
+
readable record: explicit value, else the persisted package-owned id.
|
|
47
|
+
Never minted fresh.
|
|
48
|
+
- **Halt-set identity rule** (the stale-engine hazard fix). The halt key is
|
|
49
|
+
shared by every engine instance on a device, so an old instance still
|
|
50
|
+
holding the superseded credential could previously re-halt the device
|
|
51
|
+
after recovery. Now, on `CREDENTIAL_INVALID`, the engine halts **unless**
|
|
52
|
+
the stored credential record exists, is readable, and holds a *different*
|
|
53
|
+
credential than the engine's bearer — definitive proof the instance was
|
|
54
|
+
superseded. **The rule fails toward halting**: a missing or unreadable
|
|
55
|
+
record still halts (the naive "no record → no halt" would retry a dead
|
|
56
|
+
credential forever, exactly what the halt exists to prevent). A superseded
|
|
57
|
+
instance goes **inert in memory** instead — surfaced once via
|
|
58
|
+
`onError('CREDENTIAL_INVALID', isHardStop true)`, then silent, zero
|
|
59
|
+
network, never touching the shared halt key — killing both the bricking
|
|
60
|
+
and the pointless server load. New state query: `isSuperseded()`.
|
|
61
|
+
- The 1.4b minted-but-lost-persistence residual is now self-healing: a
|
|
62
|
+
credential minted and then lost to a persistence failure is superseded by
|
|
63
|
+
the next successful enrollment for the same `(accountId, deviceId)`.
|
|
64
|
+
|
|
65
|
+
## [1.7.0] - 2026-07-31
|
|
66
|
+
|
|
67
|
+
### Added
|
|
68
|
+
|
|
69
|
+
- **Per-account vault auth, client half (GLANCEvault Phase 1.4b).** A
|
|
70
|
+
GLANCEvault server can run `authMode: 'per-account'`, where each device
|
|
71
|
+
authenticates with its own enrolled credential instead of the instance-wide
|
|
72
|
+
shared device token. The package owns the whole client-side flow — apps hand
|
|
73
|
+
it a bootstrap secret (or the shared token) and get a working engine back,
|
|
74
|
+
without implementing discovery, branching, credential persistence, or
|
|
75
|
+
secret discard themselves. Shared mode remains the default path and is
|
|
76
|
+
byte-for-byte unchanged.
|
|
77
|
+
- **`connectVaultSyncEngine(config)`** — the packaged connect flow.
|
|
78
|
+
Discovers the server's auth mode from `/healthz`, then branches:
|
|
79
|
+
`shared` → builds the engine from `config.vaultToken` exactly as before;
|
|
80
|
+
`per-account` → uses the credential persisted under
|
|
81
|
+
`{prefix}-vault-credential`, enrolling with `config.enrollmentSecret`
|
|
82
|
+
only when no credential is stored for this exact (server, account). The
|
|
83
|
+
credential is persisted durably (write + read-back verify) **before**
|
|
84
|
+
enrollment is treated as complete, and a storage canary runs **before**
|
|
85
|
+
minting so broken storage cannot orphan one server row per launch. The
|
|
86
|
+
bootstrap secret is never written to storage, never logged, never placed
|
|
87
|
+
on the engine config, and not retained past the call. **No code path
|
|
88
|
+
re-enrolls automatically** — once a credential is stored it is always
|
|
89
|
+
reused, even after rejection (recovery is Phase 2.2), so revocation
|
|
90
|
+
(vault Phase 2.1) cannot be silently undone by a client loop. Discovery
|
|
91
|
+
failure (server unreachable, unexpected body) falls back to the last
|
|
92
|
+
known auth state — stored credential, else `vaultToken` — which is
|
|
93
|
+
exactly the pre-1.4b behavior for existing installs; an unrecognized
|
|
94
|
+
future mode string does the same rather than guessing. A `/healthz`
|
|
95
|
+
without `authMode` (a pre-1.4a server) is ordinary shared mode, not an
|
|
96
|
+
error. Typed failures: `VAULT_TOKEN_REQUIRED`,
|
|
97
|
+
`ENROLLMENT_SECRET_REQUIRED`, `CREDENTIAL_PERSIST_FAILED`,
|
|
98
|
+
`VAULT_UNREACHABLE`, plus the enrollment errors below.
|
|
99
|
+
- **`getOrCreateDeviceId(storageKeyPrefix)`** — the package now owns the
|
|
100
|
+
stable device identity: generated once (`crypto.randomUUID`, with a
|
|
101
|
+
`getRandomValues` fallback for old WebViews) and persisted under
|
|
102
|
+
`{prefix}-device-id`; used for both the device cursor and enrollment. An
|
|
103
|
+
explicit `config.deviceId` still wins. This also fixes a latent bug: the
|
|
104
|
+
engine's device-cursor update silently no-oped (`{updated:false}`) when
|
|
105
|
+
no `deviceId` was configured, so such installs never reported cursors —
|
|
106
|
+
on upgrade they get a generated id and start reporting for the first
|
|
107
|
+
time (server-side effect: a `devices` row appears and tombstone GC gains
|
|
108
|
+
an accurate cursor; pure improvement, no data risk).
|
|
109
|
+
- **Substrate exports** (used by the flow, available standalone):
|
|
110
|
+
`fetchVaultHealth({ vaultUrl, fetchImpl? })` — unauthenticated
|
|
111
|
+
`GET /healthz`, `authMode` normalized to `'shared'` when the server
|
|
112
|
+
predates the field; and `enrollVaultDevice({ vaultUrl, enrollmentSecret,
|
|
113
|
+
accountId, deviceId, fetchImpl? })` — `POST /enroll`, secret in the body
|
|
114
|
+
only, values sent byte-exact, typed `ENROLLMENT_REJECTED` (401) /
|
|
115
|
+
`ENROLLMENT_UNSUPPORTED` (404, shared-mode server) failures, and
|
|
116
|
+
`*_REQUIRED` codes thrown before touching the wire on missing fields.
|
|
117
|
+
- **`CREDENTIAL_INVALID` and the credential halt.** `createVaultClient`
|
|
118
|
+
now reads error bodies best-effort: a 401 whose body says
|
|
119
|
+
`invalid credential` (the server's chosen signal for "this device must
|
|
120
|
+
re-enroll") surfaces as code `CREDENTIAL_INVALID`; a missing, non-JSON,
|
|
121
|
+
or unrecognized body degrades to the generic `VAULT_ERROR`, so client
|
|
122
|
+
correctness never depends on server wording. On `CREDENTIAL_INVALID` the
|
|
123
|
+
DB engine persists a stop under `{prefix}-db-sync-credential-halt`,
|
|
124
|
+
surfaces `onError(message, 'CREDENTIAL_INVALID', /* isHardStop */ true)`,
|
|
125
|
+
and makes **no further network calls** — across restarts. Nothing in
|
|
126
|
+
this version clears the halt (that is Phase 2.2's recovery flow);
|
|
127
|
+
`isCredentialHalted()` / `getCredentialHalt()` expose the state.
|
|
128
|
+
Shared-mode 401s (`invalid device token`) keep today's retryable
|
|
129
|
+
behavior. The key verifier passes `CREDENTIAL_INVALID` through instead
|
|
130
|
+
of relabeling it `VERIFIER_UNSUPPORTED`, so `allowUnverified` can never
|
|
131
|
+
wave a rejected device through.
|
|
132
|
+
- `createVaultClient` is otherwise unchanged on the wire; `vaultToken`
|
|
133
|
+
accepts either the shared device token or an enrolled credential — the
|
|
134
|
+
credential IS the Bearer token.
|
|
135
|
+
|
|
136
|
+
### Security notes
|
|
137
|
+
|
|
138
|
+
- The persisted credential lives in localStorage, exactly as exposed as the
|
|
139
|
+
shared device token and the file tier's provider passwords are today:
|
|
140
|
+
parity, not a regression. Secure-storage migration is explicitly out of
|
|
141
|
+
scope for this phase.
|
|
142
|
+
|
|
10
143
|
## [1.6.1] - 2026-07-19
|
|
11
144
|
|
|
12
145
|
### Fixed
|
package/package.json
CHANGED
package/src/dbEngine.js
CHANGED
|
@@ -36,6 +36,46 @@ const ts = (v) => {
|
|
|
36
36
|
return Number.isNaN(t) ? 0 : t;
|
|
37
37
|
};
|
|
38
38
|
|
|
39
|
+
// randomUUID is missing in some non-secure-context WebViews; getRandomValues
|
|
40
|
+
// is everywhere the rest of this package's crypto already runs.
|
|
41
|
+
const randomUUID = () => {
|
|
42
|
+
if (typeof globalThis.crypto?.randomUUID === 'function') return globalThis.crypto.randomUUID();
|
|
43
|
+
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
|
|
44
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
45
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
46
|
+
const hex = [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
47
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The package-owned stable device identifier (vault Phase 1.4b). Generated
|
|
52
|
+
* once per storageKeyPrefix and persisted under `{prefix}-device-id`; used
|
|
53
|
+
* for both the device cursor and per-account enrollment, so the identity a
|
|
54
|
+
* credential is bound to is the same one the server's tombstone-GC cursor
|
|
55
|
+
* tracks. An explicit config.deviceId still wins for callers that already
|
|
56
|
+
* manage their own.
|
|
57
|
+
*
|
|
58
|
+
* Existing installs that never passed a deviceId get one generated on first
|
|
59
|
+
* launch of this version — which also means their device cursor starts
|
|
60
|
+
* updating for the first time (updateDeviceCursor previously no-oped
|
|
61
|
+
* silently without a deviceId).
|
|
62
|
+
*/
|
|
63
|
+
export const getOrCreateDeviceId = (storageKeyPrefix) => {
|
|
64
|
+
if (!storageKeyPrefix) throw new Error('getOrCreateDeviceId: storageKeyPrefix is required');
|
|
65
|
+
const key = `${storageKeyPrefix}-device-id`;
|
|
66
|
+
let id = localStorage.getItem(key);
|
|
67
|
+
if (!id) {
|
|
68
|
+
id = randomUUID();
|
|
69
|
+
localStorage.setItem(key, id);
|
|
70
|
+
}
|
|
71
|
+
return id;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// Storage keys shared between this engine and the connect/recovery flow in
|
|
75
|
+
// vaultConnect.js. Defined once here so the two modules cannot drift.
|
|
76
|
+
export const vaultCredentialKey = (storageKeyPrefix) => `${storageKeyPrefix}-vault-credential`;
|
|
77
|
+
export const credentialHaltKey = (storageKeyPrefix) => `${storageKeyPrefix}-db-sync-credential-halt`;
|
|
78
|
+
|
|
39
79
|
/**
|
|
40
80
|
* Creates the database sync engine.
|
|
41
81
|
*
|
|
@@ -46,7 +86,7 @@ const ts = (v) => {
|
|
|
46
86
|
* @param {string} config.vaultUrl
|
|
47
87
|
* @param {string} config.vaultToken
|
|
48
88
|
* @param {string} config.accountId
|
|
49
|
-
* @param {string} [config.deviceId]
|
|
89
|
+
* @param {string} [config.deviceId] - stable device identity; defaults to the persisted package-owned id (getOrCreateDeviceId)
|
|
50
90
|
* @param {string} config.cryptoDBName - device storage name for the root key
|
|
51
91
|
* @param {Function|null} [config.nativeGetSyncKey]
|
|
52
92
|
* @param {Function|null} [config.nativeStoreSyncKey]
|
|
@@ -61,7 +101,7 @@ const ts = (v) => {
|
|
|
61
101
|
*
|
|
62
102
|
* Event callbacks (optional):
|
|
63
103
|
* @param {Function} [config.onStatusChange]
|
|
64
|
-
* @param {Function} [config.onError] - (message, code, isHardStop) called with code 'KEY_MISMATCH' on a wrong key,
|
|
104
|
+
* @param {Function} [config.onError] - (message, code, isHardStop) called with code 'KEY_MISMATCH' on a wrong key, 'VERIFIER_UNSUPPORTED' when the server can't host the verifier, or 'CREDENTIAL_INVALID' (isHardStop true) when the server rejects this device's credential — the cycle then halts until Phase 2.2 recovery exists
|
|
65
105
|
* @param {Function} [config.onRowsSkipped] - (count, entityIds) called when a cycle skipped undecryptable rows
|
|
66
106
|
* @param {boolean} [config.allowUnverified=false] - operator escape hatch: downgrade VERIFIER_UNSUPPORTED to a logged warning and proceed (unsafe; off by default)
|
|
67
107
|
*/
|
|
@@ -104,6 +144,9 @@ export const createDbSyncEngine = (config) => {
|
|
|
104
144
|
if (typeof applyRemoteDelete !== 'function') throw new Error('createDbSyncEngine: applyRemoteDelete is required');
|
|
105
145
|
|
|
106
146
|
const app = vaultApp || appId;
|
|
147
|
+
// Package-owned device identity: an explicit config.deviceId wins, else the
|
|
148
|
+
// persisted per-prefix id (generated on first use).
|
|
149
|
+
const resolvedDeviceId = deviceId || getOrCreateDeviceId(storageKeyPrefix);
|
|
107
150
|
const lastModifiedOf = typeof getEntityLastModified === 'function'
|
|
108
151
|
? getEntityLastModified
|
|
109
152
|
: (entity) => entity && entity.lastModified;
|
|
@@ -135,9 +178,25 @@ export const createDbSyncEngine = (config) => {
|
|
|
135
178
|
// on pull so one bad row cannot wedge the cycle, and re-attempted each cycle
|
|
136
179
|
// so a row recovers if the correct key later becomes available.
|
|
137
180
|
const KEY_QUARANTINE = `${storageKeyPrefix}-db-sync-quarantine`;
|
|
181
|
+
// KEY_CRED_HALT persists the credential-rejected stop state (vault Phase
|
|
182
|
+
// 1.4b), as { message, at }. Once the server has said 401 "invalid
|
|
183
|
+
// credential", retrying is pure hammering — the only way out is
|
|
184
|
+
// user-initiated re-enrollment (recoverVaultSyncEngine, Phase 2.2), which
|
|
185
|
+
// is the ONLY code that clears this key, and only after a verified
|
|
186
|
+
// successful enrollment. Shared-mode auth failures (401 "invalid device
|
|
187
|
+
// token") do NOT set it — those are operator config problems that a config
|
|
188
|
+
// fix resolves, so they keep the ordinary retry behavior.
|
|
189
|
+
const KEY_CRED_HALT = credentialHaltKey(storageKeyPrefix);
|
|
190
|
+
const KEY_CREDENTIAL = vaultCredentialKey(storageKeyPrefix);
|
|
138
191
|
|
|
139
192
|
// ── In-memory guards ──────────────────────────────────────────────────────
|
|
140
193
|
let syncing = false;
|
|
194
|
+
// Set when this instance's bearer is proven superseded (see
|
|
195
|
+
// isBearerSuperseded): the instance goes inert IN MEMORY ONLY — it never
|
|
196
|
+
// touches the shared halt key, and it stops generating network traffic. A
|
|
197
|
+
// stale reference the app forgot after recovery dies quietly instead of
|
|
198
|
+
// hammering the server with a dead credential.
|
|
199
|
+
let supersededInert = false;
|
|
141
200
|
// Per-session cache: once the derived root key is proven against the account's
|
|
142
201
|
// existing data (Part A), we never re-verify for the life of this engine.
|
|
143
202
|
let verified = false;
|
|
@@ -219,6 +278,46 @@ export const createDbSyncEngine = (config) => {
|
|
|
219
278
|
writeQuarantine(getQuarantine().filter((q) => q.entityId !== entityId));
|
|
220
279
|
};
|
|
221
280
|
|
|
281
|
+
// Credential-rejected stop state. Read/written only here; deliberately no
|
|
282
|
+
// clear function on the engine (recovery is Phase 2.2).
|
|
283
|
+
const getCredentialHalt = () => {
|
|
284
|
+
const raw = localStorage.getItem(KEY_CRED_HALT);
|
|
285
|
+
if (!raw) return null;
|
|
286
|
+
try {
|
|
287
|
+
return JSON.parse(raw);
|
|
288
|
+
} catch {
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
const setCredentialHalt = (message) => {
|
|
293
|
+
localStorage.setItem(KEY_CRED_HALT, JSON.stringify({ message, at: new Date().toISOString() }));
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
// The halt-set identity rule (Phase 2.2). The halt key is shared by every
|
|
297
|
+
// engine instance on this device, so a STALE instance — one still holding a
|
|
298
|
+
// credential that recovery has since replaced — must not be able to re-set
|
|
299
|
+
// the halt and brick the recovered engine. A superseded bearer is provable:
|
|
300
|
+
// the stored credential record exists, is readable, and holds a DIFFERENT
|
|
301
|
+
// credential than this engine speaks.
|
|
302
|
+
//
|
|
303
|
+
// THE RULE FAILS TOWARD HALTING, deliberately: halt when the record is
|
|
304
|
+
// missing or unreadable, or when it matches this engine's bearer. Skip the
|
|
305
|
+
// halt ONLY on that provable-superseded case. The naive simplification
|
|
306
|
+
// ("no record -> no halt") would leave an engine with a dead credential and
|
|
307
|
+
// a wiped record retrying forever — exactly what the halt exists to
|
|
308
|
+
// prevent. Do not simplify this condition.
|
|
309
|
+
const isBearerSuperseded = () => {
|
|
310
|
+
if (typeof vaultToken !== 'string' || vaultToken === '') return false;
|
|
311
|
+
const raw = localStorage.getItem(KEY_CREDENTIAL);
|
|
312
|
+
if (!raw) return false;
|
|
313
|
+
try {
|
|
314
|
+
const rec = JSON.parse(raw);
|
|
315
|
+
return !!(rec && typeof rec.credential === 'string' && rec.credential !== vaultToken);
|
|
316
|
+
} catch {
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
|
|
222
321
|
// markDirty: called by the app on every local write (and at creation time for
|
|
223
322
|
// insert-only types). Idempotent and synchronous so it can run inside the
|
|
224
323
|
// app's own write path.
|
|
@@ -253,9 +352,14 @@ export const createDbSyncEngine = (config) => {
|
|
|
253
352
|
// typed, retryable code instead of being mislabeled "update your server".
|
|
254
353
|
const isClientNotReady = (err) =>
|
|
255
354
|
err && (err.code === 'ACCOUNT_ID_REQUIRED' || err.code === 'PASSPHRASE_REQUIRED');
|
|
355
|
+
// Likewise a rejected credential (401 "invalid credential") is an auth
|
|
356
|
+
// outcome, not a capability one: pass it through so the cycle's halt
|
|
357
|
+
// handling sees it — and so allowUnverified can never wave a revoked
|
|
358
|
+
// device through.
|
|
359
|
+
const isPassThrough = (err) => isClientNotReady(err) || (err && err.code === 'CREDENTIAL_INVALID');
|
|
256
360
|
|
|
257
361
|
const verifierUnsupported = (cause) => {
|
|
258
|
-
if (
|
|
362
|
+
if (isPassThrough(cause)) throw cause;
|
|
259
363
|
if (allowUnverified) {
|
|
260
364
|
// eslint-disable-next-line no-console
|
|
261
365
|
console.warn(`[${appId}] db sync proceeding UNVERIFIED: the server cannot host the key verifier (${KEYCHECK_ENTITY_ID}).`, cause);
|
|
@@ -564,11 +668,10 @@ export const createDbSyncEngine = (config) => {
|
|
|
564
668
|
// progress is the conservative value for server-side tombstone GC, since it
|
|
565
669
|
// never lets the server reclaim a tombstone this device has not yet seen.
|
|
566
670
|
const updateDeviceCursor = async () => {
|
|
567
|
-
if (!deviceId) return { updated: false };
|
|
568
671
|
try {
|
|
569
672
|
return await vault.device(app, {
|
|
570
673
|
accountId,
|
|
571
|
-
deviceId,
|
|
674
|
+
deviceId: resolvedDeviceId,
|
|
572
675
|
lastSeenSeq: getHighWaterMark(),
|
|
573
676
|
});
|
|
574
677
|
} catch (err) {
|
|
@@ -582,6 +685,22 @@ export const createDbSyncEngine = (config) => {
|
|
|
582
685
|
const dbSyncCycle = async () => {
|
|
583
686
|
const cfg = getConfig();
|
|
584
687
|
if (cfg && cfg.enabled === false) return;
|
|
688
|
+
// A superseded stale instance is dead silently: no network, and no
|
|
689
|
+
// onError either — its failure was surfaced once when detected, and
|
|
690
|
+
// re-surfacing would fight the recovered engine's own status/error
|
|
691
|
+
// signals through the app's shared callbacks.
|
|
692
|
+
if (supersededInert) {
|
|
693
|
+
return { applied: 0, skipped: 0, skippedEntityIds: [], superseded: true };
|
|
694
|
+
}
|
|
695
|
+
// Standing credential-rejected stop: no network calls at all. The state is
|
|
696
|
+
// re-surfaced on every attempted cycle so a UI constructed after the halt
|
|
697
|
+
// (e.g. next app launch) still learns about it through its own onError.
|
|
698
|
+
const halt = getCredentialHalt();
|
|
699
|
+
if (halt) {
|
|
700
|
+
onError?.(halt.message, 'CREDENTIAL_INVALID', true);
|
|
701
|
+
onStatusChange?.('error');
|
|
702
|
+
return { applied: 0, skipped: 0, skippedEntityIds: [], halted: true };
|
|
703
|
+
}
|
|
585
704
|
if (syncing) return;
|
|
586
705
|
syncing = true;
|
|
587
706
|
onStatusChange?.('uploading');
|
|
@@ -607,7 +726,28 @@ export const createDbSyncEngine = (config) => {
|
|
|
607
726
|
// eslint-disable-next-line no-console
|
|
608
727
|
console.error(`[${appId}] db sync cycle error:`, err);
|
|
609
728
|
const code = err && err.code ? err.code : 'NETWORK_ERROR';
|
|
610
|
-
|
|
729
|
+
if (code === 'CREDENTIAL_INVALID') {
|
|
730
|
+
// The server rejected this engine's credential. Under NO circumstances
|
|
731
|
+
// does the engine re-enroll here: enrollment is non-idempotent, so an
|
|
732
|
+
// automatic retry would mint a server row per failure and would let a
|
|
733
|
+
// revoked device silently re-admit itself. Recovery is user-initiated
|
|
734
|
+
// only (recoverVaultSyncEngine).
|
|
735
|
+
if (isBearerSuperseded()) {
|
|
736
|
+
// This instance was replaced by recovery and the app kept a stale
|
|
737
|
+
// reference. Go inert in memory — surfaced once, then silent — and
|
|
738
|
+
// leave the shared halt key alone so the recovered engine keeps
|
|
739
|
+
// syncing.
|
|
740
|
+
supersededInert = true;
|
|
741
|
+
onError?.(err?.message || String(err), 'CREDENTIAL_INVALID', true);
|
|
742
|
+
} else {
|
|
743
|
+
// Persist the stop so no future cycle retries (a rejected
|
|
744
|
+
// credential never heals by retry) and surface it as a hard stop.
|
|
745
|
+
setCredentialHalt(err?.message || String(err));
|
|
746
|
+
onError?.(err?.message || String(err), 'CREDENTIAL_INVALID', true);
|
|
747
|
+
}
|
|
748
|
+
} else {
|
|
749
|
+
onError?.(err?.message || String(err), code, false);
|
|
750
|
+
}
|
|
611
751
|
onStatusChange?.('error');
|
|
612
752
|
return { applied: 0, skipped: 0, skippedEntityIds: [] };
|
|
613
753
|
} finally {
|
|
@@ -650,6 +790,14 @@ export const createDbSyncEngine = (config) => {
|
|
|
650
790
|
isSyncing: () => syncing,
|
|
651
791
|
isKeyVerified: () => verified,
|
|
652
792
|
hasEncryptionReady: hasDbRootKey,
|
|
793
|
+
// Credential-rejected stop state. Cleared only by recoverVaultSyncEngine
|
|
794
|
+
// after a verified successful re-enrollment — never by the engine.
|
|
795
|
+
isCredentialHalted: () => getCredentialHalt() !== null,
|
|
796
|
+
getCredentialHalt,
|
|
797
|
+
// True once this instance proved its bearer was superseded by recovery
|
|
798
|
+
// and went inert (in-memory only; a fresh instance is unaffected).
|
|
799
|
+
isSuperseded: () => supersededInert,
|
|
800
|
+
deviceId: resolvedDeviceId,
|
|
653
801
|
|
|
654
802
|
// Sub-modules
|
|
655
803
|
vault,
|
package/src/index.js
CHANGED
|
@@ -40,8 +40,21 @@ export {
|
|
|
40
40
|
} from './engine.js';
|
|
41
41
|
|
|
42
42
|
// Phase 3: database transport (selected via transportMode: 'database').
|
|
43
|
-
export { createDbSyncEngine } from './dbEngine.js';
|
|
44
|
-
export {
|
|
43
|
+
export { createDbSyncEngine, getOrCreateDeviceId } from './dbEngine.js';
|
|
44
|
+
export {
|
|
45
|
+
createVaultClient,
|
|
46
|
+
// Per-account auth substrate (vault Phase 1.4b): unauthenticated auth-mode
|
|
47
|
+
// discovery and the bootstrap-secret -> per-device-credential exchange.
|
|
48
|
+
fetchVaultHealth,
|
|
49
|
+
enrollVaultDevice,
|
|
50
|
+
} from './vaultClient.js';
|
|
51
|
+
// The packaged connect flow: discover -> branch -> enroll if needed ->
|
|
52
|
+
// persist credential -> build engine. Apps hand it a secret or token and get
|
|
53
|
+
// a working engine back; the bootstrap secret never touches storage.
|
|
54
|
+
// recoverVaultSyncEngine (Phase 2.2) is the halt-gated, user-initiated exit
|
|
55
|
+
// from a rejected credential: re-enrollment that rotates the dead credential
|
|
56
|
+
// away server-side.
|
|
57
|
+
export { connectVaultSyncEngine, recoverVaultSyncEngine } from './vaultConnect.js';
|
|
45
58
|
export {
|
|
46
59
|
setupDbRootKey,
|
|
47
60
|
initDbRootKey,
|
package/src/vaultClient.js
CHANGED
|
@@ -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
|
-
|
|
171
|
+
const base = requireBaseUrl(vaultUrl, 'createVaultClient');
|
|
42
172
|
if (!vaultToken) throw new Error('createVaultClient: vaultToken is required');
|
|
43
|
-
const doFetch = fetchImpl
|
|
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
|
|
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
|
+
}
|
package/types/index.d.ts
CHANGED
|
@@ -27,7 +27,12 @@ export type SyncErrorCode =
|
|
|
27
27
|
| 'VERIFIER_UNSUPPORTED'
|
|
28
28
|
// DB transport: a row-scoped call (incl. the key verifier) was made before the
|
|
29
29
|
// accountId was populated — retryable once the account id is available.
|
|
30
|
-
| 'ACCOUNT_ID_REQUIRED'
|
|
30
|
+
| 'ACCOUNT_ID_REQUIRED'
|
|
31
|
+
// DB transport, per-account auth: the server rejected this device's
|
|
32
|
+
// credential (401 "invalid credential"). Surfaced with isHardStop === true;
|
|
33
|
+
// the engine persists a halt and stops retrying. Recovery (re-enrollment)
|
|
34
|
+
// is Phase 2.2 — nothing in this version clears the halt.
|
|
35
|
+
| 'CREDENTIAL_INVALID';
|
|
31
36
|
|
|
32
37
|
export type SyncStatus = 'idle' | 'uploading' | 'downloading' | 'success' | 'error';
|
|
33
38
|
|
|
@@ -421,10 +426,53 @@ export interface VaultClient {
|
|
|
421
426
|
|
|
422
427
|
export function createVaultClient(config: {
|
|
423
428
|
vaultUrl: string;
|
|
429
|
+
/** Shared device token, or the device's enrolled credential in per-account mode. */
|
|
424
430
|
vaultToken: string;
|
|
425
431
|
fetchImpl?: typeof fetch;
|
|
426
432
|
}): VaultClient;
|
|
427
433
|
|
|
434
|
+
// Per-account auth (vault Phase 1.4b).
|
|
435
|
+
|
|
436
|
+
/** Which trust model a GLANCEvault server runs. Discoverable via fetchVaultHealth. */
|
|
437
|
+
export type VaultAuthMode = 'shared' | 'per-account';
|
|
438
|
+
|
|
439
|
+
export interface VaultHealth {
|
|
440
|
+
status: string;
|
|
441
|
+
version: string;
|
|
442
|
+
schemaVersion: number;
|
|
443
|
+
/** Normalized to 'shared' when the server predates the field. */
|
|
444
|
+
authMode: VaultAuthMode;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** Unauthenticated GET /healthz. Safe to call before any token exists. */
|
|
448
|
+
export function fetchVaultHealth(config: {
|
|
449
|
+
vaultUrl: string;
|
|
450
|
+
fetchImpl?: typeof fetch;
|
|
451
|
+
}): Promise<VaultHealth>;
|
|
452
|
+
|
|
453
|
+
export interface VaultEnrollment {
|
|
454
|
+
credentialId: string;
|
|
455
|
+
/** Returned once, never retrievable again. Persist it, then discard the bootstrap secret. */
|
|
456
|
+
credential: string;
|
|
457
|
+
accountId: string;
|
|
458
|
+
deviceId: string;
|
|
459
|
+
createdAt: string;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* POST /enroll — exchanges the bootstrap secret for this device's own credential.
|
|
464
|
+
* Rejects with err.code 'ENROLLMENT_REJECTED' (401, secret not accepted),
|
|
465
|
+
* 'ENROLLMENT_UNSUPPORTED' (404, server runs shared mode or predates enrollment),
|
|
466
|
+
* or 'VAULT_ERROR' with err.status for other failures.
|
|
467
|
+
*/
|
|
468
|
+
export function enrollVaultDevice(config: {
|
|
469
|
+
vaultUrl: string;
|
|
470
|
+
enrollmentSecret: string;
|
|
471
|
+
accountId: string;
|
|
472
|
+
deviceId: string;
|
|
473
|
+
fetchImpl?: typeof fetch;
|
|
474
|
+
}): Promise<VaultEnrollment>;
|
|
475
|
+
|
|
428
476
|
export interface DbSyncEngineConfig {
|
|
429
477
|
// Identity / local state
|
|
430
478
|
storageKeyPrefix: string;
|
|
@@ -510,7 +558,74 @@ export interface DbSyncEngine {
|
|
|
510
558
|
isKeyVerified(): boolean;
|
|
511
559
|
hasEncryptionReady(): boolean;
|
|
512
560
|
|
|
561
|
+
/** True while the persisted credential-rejected stop is set (CREDENTIAL_INVALID). */
|
|
562
|
+
isCredentialHalted(): boolean;
|
|
563
|
+
/** The persisted stop record, or null. Cleared only by recoverVaultSyncEngine after a verified successful re-enrollment. */
|
|
564
|
+
getCredentialHalt(): { message: string; at: string } | null;
|
|
565
|
+
/** True once this instance proved its bearer was superseded by recovery and went inert (in-memory only). */
|
|
566
|
+
isSuperseded(): boolean;
|
|
567
|
+
/** The resolved stable device identity this engine runs as. */
|
|
568
|
+
deviceId: string;
|
|
569
|
+
|
|
513
570
|
vault: VaultClient;
|
|
514
571
|
}
|
|
515
572
|
|
|
516
573
|
export function createDbSyncEngine(config: DbSyncEngineConfig): DbSyncEngine;
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Package-owned stable device identifier: generated once (crypto.randomUUID)
|
|
577
|
+
* and persisted under `{prefix}-device-id`. Used for both the device cursor
|
|
578
|
+
* and per-account enrollment. An explicit config.deviceId overrides it.
|
|
579
|
+
*/
|
|
580
|
+
export function getOrCreateDeviceId(storageKeyPrefix: string): string;
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* The packaged vault connect flow: discovers the server's auth mode, resolves
|
|
584
|
+
* the Bearer value (enrolling with the bootstrap secret when the server is
|
|
585
|
+
* per-account and no credential is stored yet), persists the credential under
|
|
586
|
+
* `{prefix}-vault-credential`, and returns a ready engine. The bootstrap
|
|
587
|
+
* secret is never written to storage, never logged, and never retained past
|
|
588
|
+
* the call. No path re-enrolls automatically once a credential is stored.
|
|
589
|
+
*
|
|
590
|
+
* Typed failures: VAULT_TOKEN_REQUIRED, ENROLLMENT_SECRET_REQUIRED,
|
|
591
|
+
* CREDENTIAL_PERSIST_FAILED, VAULT_UNREACHABLE, plus enrollVaultDevice's
|
|
592
|
+
* ENROLLMENT_REJECTED / ENROLLMENT_UNSUPPORTED.
|
|
593
|
+
*/
|
|
594
|
+
export function connectVaultSyncEngine(
|
|
595
|
+
config: Omit<DbSyncEngineConfig, 'vaultToken'> & {
|
|
596
|
+
vaultToken?: string;
|
|
597
|
+
enrollmentSecret?: string;
|
|
598
|
+
}
|
|
599
|
+
): Promise<{
|
|
600
|
+
engine: DbSyncEngine;
|
|
601
|
+
/** null when discovery failed and the flow fell back to the last known auth state. */
|
|
602
|
+
authMode: VaultAuthMode | null;
|
|
603
|
+
/** True only when this call minted a credential. */
|
|
604
|
+
enrolled: boolean;
|
|
605
|
+
deviceId: string;
|
|
606
|
+
}>;
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* The exit from the credential halt (Phase 2.2): USER-INITIATED re-enrollment
|
|
610
|
+
* with the bootstrap secret. Refuses unless the device is halted (NOT_HALTED)
|
|
611
|
+
* and unless the server runs per-account auth (RECOVERY_UNSUPPORTED /
|
|
612
|
+
* VAULT_UNREACHABLE — recovery never falls back on discovery failure). The
|
|
613
|
+
* stale record's deviceId is used for enrollment so the server (2.1) revokes
|
|
614
|
+
* every still-active predecessor; an explicit config.deviceId differing from
|
|
615
|
+
* it is DEVICE_ID_CONFLICT. Order: canary -> enroll -> persist + verify
|
|
616
|
+
* (overwriting the stale record) -> clear the halt LAST -> fresh engine.
|
|
617
|
+
* Every failure leaves the device halted with its state intact. The secret is
|
|
618
|
+
* confined to the call, exactly as in connectVaultSyncEngine. Call only from
|
|
619
|
+
* a deliberate user action; no code path in this package invokes it.
|
|
620
|
+
*/
|
|
621
|
+
export function recoverVaultSyncEngine(
|
|
622
|
+
config: Omit<DbSyncEngineConfig, 'vaultToken'> & {
|
|
623
|
+
vaultToken?: string;
|
|
624
|
+
enrollmentSecret: string;
|
|
625
|
+
}
|
|
626
|
+
): Promise<{
|
|
627
|
+
engine: DbSyncEngine;
|
|
628
|
+
authMode: 'per-account';
|
|
629
|
+
enrolled: true;
|
|
630
|
+
deviceId: string;
|
|
631
|
+
}>;
|