@glance-apps/sync 1.6.1 → 1.10.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 +343 -0
- package/package.json +1 -1
- package/src/dbEngine.js +450 -11
- package/src/index.js +15 -2
- package/src/vaultClient.js +208 -9
- package/src/vaultConnect.js +391 -0
- package/types/index.d.ts +211 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,349 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.10.0] - 2026-08-01
|
|
11
|
+
|
|
12
|
+
**Should you take this release?** Yes, for any deployment using the database
|
|
13
|
+
(GLANCEvault) transport. Until now the DB sync engine had **no backoff of any
|
|
14
|
+
kind**: on a persistent failure it re-sent the same request on every cycle,
|
|
15
|
+
forever, at whatever rate the app polls. A server that was down, a device
|
|
16
|
+
offline, a wrong token — all of them produced an unbounded request loop. This
|
|
17
|
+
release adds the backoff the file tier has always had, and fixes two related
|
|
18
|
+
defects found alongside it. The file-tier (WebDAV) engine is untouched.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- **The DB engine no longer hammers a failing server.** Both directions now
|
|
23
|
+
back off: 30s doubling to a 15-minute cap for pushes and a 5-minute cap for
|
|
24
|
+
pulls, with a flat one hour on an authentication failure — the file tier's
|
|
25
|
+
numbers, escalation curve and reset rule. Any success resets the window and
|
|
26
|
+
its strike count. A window only ever **delays**: the next ordinary cycle
|
|
27
|
+
after it expires probes again, with no user action and no restart, and
|
|
28
|
+
dirty rows are never dropped — backoff changes *when* a push is retried,
|
|
29
|
+
never *whether* it is.
|
|
30
|
+
- Unlike the file tier's, the DB engine's backoff is **self-enforcing**: the
|
|
31
|
+
engine skips its own work when called too soon. (The file tier's is
|
|
32
|
+
advisory — nothing reads `uploadBackoffUntil` except its getter, so
|
|
33
|
+
honouring it is the app's job. That is exactly why the hammering was live
|
|
34
|
+
in every published version.) `getUploadBackoffUntil()` and
|
|
35
|
+
`getDownloadBackoffUntil()` are exposed with the same names and meaning as
|
|
36
|
+
the file tier's so one scheduler can treat both tiers alike, but honouring
|
|
37
|
+
them is now an optimisation rather than a duty.
|
|
38
|
+
- **A failed pull no longer aborts the cycle.** 1.9.0 contained push failures
|
|
39
|
+
but left pull failures throwing past the device-cursor report. A pull error
|
|
40
|
+
is now captured the same way, so the cursor still reports on a cycle whose
|
|
41
|
+
pull failed.
|
|
42
|
+
- **The pull cursor is persisted per page.** It was written only after the
|
|
43
|
+
whole pagination loop, so a failure on page 3 discarded the progress of
|
|
44
|
+
pages 1 and 2 — a large backlog on a flaky connection could re-download the
|
|
45
|
+
same pages indefinitely and never converge. Every row in a page reaches a
|
|
46
|
+
terminal outcome (applied, quarantined, or skipped as engine-reserved)
|
|
47
|
+
before the page ends, so the cursor may safely advance past each completed
|
|
48
|
+
page.
|
|
49
|
+
- **A rejected credential surfacing from the push or pull now halts.** Once
|
|
50
|
+
the key is verified in-session, `ensureRootKey` makes no network call, so a
|
|
51
|
+
mid-session revocation is first seen by the push or the pull — and 1.9.0's
|
|
52
|
+
error capture routed those away from the halt path. They now reach exactly
|
|
53
|
+
the same halt logic as before; the halt itself, its identity rule, and the
|
|
54
|
+
`invalid credential` body match are unchanged.
|
|
55
|
+
|
|
56
|
+
### Changed
|
|
57
|
+
|
|
58
|
+
- **Over-quota suppression is now one case of the backoff ladder**, not a
|
|
59
|
+
parallel mechanism. Its behaviour is unchanged — same curve, same
|
|
60
|
+
in-memory lifetime, still surfacing `QUOTA_EXCEEDED` with its descriptor on
|
|
61
|
+
every suppressed cycle so apps can render "X of Y used" — but
|
|
62
|
+
`getQuotaState()` and `isQuotaSuppressed()` are now derived from the push
|
|
63
|
+
window rather than a separate variable.
|
|
64
|
+
- **A shared-mode 401 is now delayed rather than retried immediately.** It
|
|
65
|
+
opens the one-hour auth window instead of being re-sent every cycle. It is
|
|
66
|
+
still not terminal and still not the credential halt: it self-resumes.
|
|
67
|
+
- New: `getBackoffState()` returns `{ push, pull }` windows with `until`,
|
|
68
|
+
`reason` (`'quota' | 'auth' | 'transport'`), `code`, `strikes` and `since`.
|
|
69
|
+
The `reason` is what lets an app distinguish a one-hour auth backoff from a
|
|
70
|
+
thirty-second transport one without inferring it from the timestamp.
|
|
71
|
+
- The cycle result gains `pullFailed`, `pullSkipped` and `pullErrorCode`,
|
|
72
|
+
mirroring the push fields added in 1.9.0.
|
|
73
|
+
|
|
74
|
+
### Notes
|
|
75
|
+
|
|
76
|
+
- **Classes that are never delayed**, deliberately: `PASSPHRASE_REQUIRED` and
|
|
77
|
+
`ACCOUNT_ID_REQUIRED` fire before any network call and clear the instant the
|
|
78
|
+
app supplies the value, so a window would make the app feel broken for 30
|
|
79
|
+
seconds after a user types their passphrase; and `CREDENTIAL_INVALID` is
|
|
80
|
+
owned by the (terminal) credential halt, so stacking a window on it would be
|
|
81
|
+
meaningless at best. `QUOTA_EXCEEDED` opens exactly one window, its own.
|
|
82
|
+
- **For app integration — do not render backoff from the last `onError`
|
|
83
|
+
message.** On a cycle suppressed by a quiet (transport or auth) window, the
|
|
84
|
+
cycle's usual `onError(null, null, false)` reset still fires and nothing is
|
|
85
|
+
re-surfaced, so an app that remembers the last error string will see it
|
|
86
|
+
*cleared* while `onStatusChange` reads `'error'`. This is deliberate (quiet
|
|
87
|
+
windows report once; repeating every cycle is the noise this release
|
|
88
|
+
removes). Render standing backoff from `getBackoffState()` — `reason` plus
|
|
89
|
+
`until` gives "retrying in Xs" — and treat `onError` as an event stream, not
|
|
90
|
+
a status store. Quota windows are the exception and keep re-surfacing
|
|
91
|
+
`QUOTA_EXCEEDED` with the descriptor each cycle.
|
|
92
|
+
- The cycle result now reports a superseded stale instance truthfully:
|
|
93
|
+
`{ superseded: true }` rather than `{ halted: true }`, matching
|
|
94
|
+
`isSuperseded()` / `isCredentialHalted()` whichever path the credential
|
|
95
|
+
rejection arrived by.
|
|
96
|
+
- A healthy client is unaffected: no window is ever opened, no added latency,
|
|
97
|
+
and an identical request pattern.
|
|
98
|
+
|
|
99
|
+
## [1.9.0] - 2026-08-01
|
|
100
|
+
|
|
101
|
+
**Should you take this release?** Yes if you run, or might run, a GLANCEvault
|
|
102
|
+
server with a row cap configured (`GLANCEVAULT_QUOTA_*`, server Phase 3.2).
|
|
103
|
+
Also yes for the sync-reliability fix below, which applies to **every**
|
|
104
|
+
deployment including shared mode and unconfigured servers: before this
|
|
105
|
+
release, a failed push also cost you the pull, so a device whose writes were
|
|
106
|
+
failing silently stopped receiving other devices' changes too. Nothing here
|
|
107
|
+
changes behavior against a server with no quota configured, which is and will
|
|
108
|
+
remain the common case.
|
|
109
|
+
|
|
110
|
+
### Fixed
|
|
111
|
+
|
|
112
|
+
- **A failed push no longer prevents the pull.** The DB sync cycle threw
|
|
113
|
+
straight from a push failure to its error handler, so `pullRemoteChanges`
|
|
114
|
+
and the device-cursor report never ran. A device that could not write — for
|
|
115
|
+
*any* reason: a transport blip, a 500, a server-side cap — also stopped
|
|
116
|
+
**reading**, and silently fell behind its peers. The push error is now
|
|
117
|
+
captured, the pull and cursor run regardless, and the error is reported
|
|
118
|
+
after. Applies to every transport failure, not just the quota case that
|
|
119
|
+
exposed it. Behavior on a fully successful cycle is unchanged.
|
|
120
|
+
- A cycle whose push failed but whose pull succeeded now returns
|
|
121
|
+
`{ applied, skipped, skippedEntityIds, pushFailed: true, pushErrorCode,
|
|
122
|
+
quota }` — the pull's own numbers are reported as usual, and `onError`
|
|
123
|
+
fires with the push error's code and `isHardStop: false`. `lastSynced` is
|
|
124
|
+
deliberately **not** advanced (the device is not fully in step), and the
|
|
125
|
+
dirty rows are retained for the next cycle exactly as before.
|
|
126
|
+
- **A quota rejection is no longer mislabelled "update your server".** The key
|
|
127
|
+
verifier's establishing write is a net-new entity, so a fresh device on an
|
|
128
|
+
account at the row cap gets a 413 there — and every error on that path was
|
|
129
|
+
relabelled `VERIFIER_UNSUPPORTED` ("Your GLANCEvault server needs to be
|
|
130
|
+
updated to support key verification"), sending the user to fix a server
|
|
131
|
+
that is working correctly. `QUOTA_EXCEEDED` now passes through that
|
|
132
|
+
classifier, the same way `CREDENTIAL_INVALID` and the client-readiness
|
|
133
|
+
codes already did.
|
|
134
|
+
|
|
135
|
+
### Added
|
|
136
|
+
|
|
137
|
+
- **Over-quota handling for the reachable dimension (server Phase 3.2 /
|
|
138
|
+
client Phase 3.3).** A 413 or 429 carrying the server's quota body
|
|
139
|
+
(`{ error: "quota exceeded", quota, limit, used, requested }`) is now typed
|
|
140
|
+
as `QUOTA_EXCEEDED` and carries the parsed descriptor, so consumers no
|
|
141
|
+
longer have to substring-match an error message to tell a quota rejection
|
|
142
|
+
from any other failure.
|
|
143
|
+
- **Never a halt.** A quota condition is the opposite of a rejected
|
|
144
|
+
credential: it clears when the operator raises the limit or reclaim runs
|
|
145
|
+
— with **no client action at all** — so the engine keeps running.
|
|
146
|
+
`isHardStop` is always `false`, nothing is persisted, and there is no
|
|
147
|
+
recovery entry point and nothing to clear.
|
|
148
|
+
- **Bounded, self-resuming suppression** instead of retrying a doomed write
|
|
149
|
+
every cycle: after a rejection the engine skips the *write* for 30s,
|
|
150
|
+
doubling on repeat to a 15-minute cap (the file tier's backoff shape),
|
|
151
|
+
while pulls and cursor reports continue untouched. The next ordinary
|
|
152
|
+
cycle after the window probes again; a write that gets through clears the
|
|
153
|
+
state by itself. Raising the server limit is picked up with no restart,
|
|
154
|
+
no re-enrollment, and no user action.
|
|
155
|
+
- New state queries: `getQuotaState()` (the descriptor plus `since` /
|
|
156
|
+
`retryAt`, in memory only) and `isQuotaSuppressed()`.
|
|
157
|
+
- **Defensive parse.** A body earns the typed classification only with the
|
|
158
|
+
exact wording, a non-empty `quota` string, and three finite numbers.
|
|
159
|
+
A missing field, a non-JSON body, a 413 with no quota fields, the legacy
|
|
160
|
+
SSE `{"error":"too many connections for account"}` 429, or an older
|
|
161
|
+
server that never sends this shape all degrade to the generic
|
|
162
|
+
`VAULT_ERROR` handled before — never a throw, never a mis-classification.
|
|
163
|
+
An **unrecognised** `quota` dimension from a newer server is passed
|
|
164
|
+
through verbatim and still treated as a quota condition, since the remedy
|
|
165
|
+
is the same; render an unfamiliar dimension generically.
|
|
166
|
+
|
|
167
|
+
### Notes
|
|
168
|
+
|
|
169
|
+
- **Scope: `rows` is the only quota dimension this package can reach.** The
|
|
170
|
+
server enforces `storage` and `concurrent-uploads` at the blob path, and
|
|
171
|
+
`intents` at the intents route; this package has no blob path (media is
|
|
172
|
+
stripped at the adapter boundary) and no intents path (that is
|
|
173
|
+
`@glance-apps/intents`), and no SSE client. So "text syncs while media
|
|
174
|
+
fails" is not something this package can exercise. Handling for those
|
|
175
|
+
dimensions belongs with the media transport work that would call them, and
|
|
176
|
+
was deliberately not written here.
|
|
177
|
+
- **Known gap, not fixed here:** the DB engine has no backoff of any kind
|
|
178
|
+
outside the quota window above. On a persistent write failure the same
|
|
179
|
+
dirty rows are re-pushed on every cycle indefinitely, where the file tier
|
|
180
|
+
has exponential backoff with an auth-failure escalation. This is a real
|
|
181
|
+
defect with a live blast radius and it wants its own change rather than a
|
|
182
|
+
corner of this one.
|
|
183
|
+
|
|
184
|
+
## [1.8.0] - 2026-07-31
|
|
185
|
+
|
|
186
|
+
### Added
|
|
187
|
+
|
|
188
|
+
- **Recovery from a halted credential (GLANCEvault Phase 2.2).** The 1.7.0
|
|
189
|
+
credential halt was built with no exit; this release adds the one exit:
|
|
190
|
+
**`recoverVaultSyncEngine(config)`**, user-initiated re-enrollment with the
|
|
191
|
+
bootstrap secret. Since server Phase 2.1, enrolling the same byte-exact
|
|
192
|
+
`(accountId, deviceId)` revokes every still-active predecessor credential
|
|
193
|
+
in the same transaction, so recovery is a rotation — the dead credential is
|
|
194
|
+
superseded, not accumulated alongside.
|
|
195
|
+
- **Halt-gated, structurally.** The call refuses with `NOT_HALTED` unless
|
|
196
|
+
the device is credential-halted, so it cannot be wired into a startup or
|
|
197
|
+
retry path as an on-demand rotator. Combined with the package holding no
|
|
198
|
+
secret (one must be supplied fresh, and it is confined to the call
|
|
199
|
+
exactly as in `connectVaultSyncEngine` — never stored, logged, or
|
|
200
|
+
retained), user initiation is structural, not procedural. **No code path
|
|
201
|
+
recovers automatically**, including transport failures, startup, or a
|
|
202
|
+
halt discovered at launch.
|
|
203
|
+
- **Per-account only, no fallback.** Unlike connect, recovery never falls
|
|
204
|
+
back on discovery failure: an unreachable `/healthz` is
|
|
205
|
+
`VAULT_UNREACHABLE` and a non-per-account mode is
|
|
206
|
+
`RECOVERY_UNSUPPORTED`; both leave the device halted and untouched.
|
|
207
|
+
Shared mode has no credentials, no halt, and no reachable recovery path.
|
|
208
|
+
- **Order of operations**, each failure leaving the device halted rather
|
|
209
|
+
than ambiguous: halt gate → deviceId resolution → mode guard → storage
|
|
210
|
+
canary (now restores the slot's prior contents, so a failed recovery
|
|
211
|
+
keeps the stale record byte-identical) → enroll → persist + read-back
|
|
212
|
+
verify (overwriting the stale record — it does not survive recovery) →
|
|
213
|
+
**clear the halt last** → return a fresh engine. The old engine's client
|
|
214
|
+
closes over the dead credential; apps must swap to the returned engine.
|
|
215
|
+
- **deviceId: the stale record's value wins.** Rotation only lands if
|
|
216
|
+
enrollment uses the identity the dead credential is bound to, and the
|
|
217
|
+
stored record is ground truth for that. An explicit `config.deviceId`
|
|
218
|
+
that differs is surfaced as `DEVICE_ID_CONFLICT`, never resolved
|
|
219
|
+
silently (either choice orphans a credential or a cursor). With no
|
|
220
|
+
readable record: explicit value, else the persisted package-owned id.
|
|
221
|
+
Never minted fresh.
|
|
222
|
+
- **Halt-set identity rule** (the stale-engine hazard fix). The halt key is
|
|
223
|
+
shared by every engine instance on a device, so an old instance still
|
|
224
|
+
holding the superseded credential could previously re-halt the device
|
|
225
|
+
after recovery. Now, on `CREDENTIAL_INVALID`, the engine halts **unless**
|
|
226
|
+
the stored credential record exists, is readable, and holds a *different*
|
|
227
|
+
credential than the engine's bearer — definitive proof the instance was
|
|
228
|
+
superseded. **The rule fails toward halting**: a missing or unreadable
|
|
229
|
+
record still halts (the naive "no record → no halt" would retry a dead
|
|
230
|
+
credential forever, exactly what the halt exists to prevent). A superseded
|
|
231
|
+
instance goes **inert in memory** instead — surfaced once via
|
|
232
|
+
`onError('CREDENTIAL_INVALID', isHardStop true)`, then silent, zero
|
|
233
|
+
network, never touching the shared halt key — killing both the bricking
|
|
234
|
+
and the pointless server load. New state query: `isSuperseded()`.
|
|
235
|
+
- The 1.4b minted-but-lost-persistence residual is now self-healing: a
|
|
236
|
+
credential minted and then lost to a persistence failure is superseded by
|
|
237
|
+
the next successful enrollment for the same `(accountId, deviceId)`.
|
|
238
|
+
|
|
239
|
+
### Fixed
|
|
240
|
+
|
|
241
|
+
- **Device cursors now report on installs that never configured a
|
|
242
|
+
`deviceId`, automatically on upgrade.** Such installs have never updated
|
|
243
|
+
their device cursor: the cursor step silently no-opped (`{updated:
|
|
244
|
+
false}`) whenever no `deviceId` was passed, so the server's tombstone GC
|
|
245
|
+
had no cursor for those devices. On upgrade, a device id is generated and
|
|
246
|
+
persisted (`{prefix}-device-id`) at engine construction, and cursor
|
|
247
|
+
reporting begins on the first completed sync cycle — **no app changes
|
|
248
|
+
required**; this applies to plain `createDbSyncEngine` construction, not
|
|
249
|
+
only the new connect flow, and identically in shared and per-account
|
|
250
|
+
mode. Installs that already pass an explicit `deviceId` are unaffected
|
|
251
|
+
and keep using it. Operator note: an install this fixes has no prior
|
|
252
|
+
cursor history, so the newly generated id strands nothing.
|
|
253
|
+
(This shipped in 1.7.0's device-identity work but 1.7.0 was never
|
|
254
|
+
published; it reaches consumers with this release.)
|
|
255
|
+
|
|
256
|
+
### Upgrade notes
|
|
257
|
+
|
|
258
|
+
Two behavior changes arrive with the fix above, without opt-in:
|
|
259
|
+
|
|
260
|
+
- **Engine construction now touches localStorage.** `getOrCreateDeviceId`
|
|
261
|
+
reads and writes `{prefix}-device-id` at construction time; previously
|
|
262
|
+
construction deferred all localStorage access to cycle time. In an
|
|
263
|
+
environment without a `localStorage` global, construction now fails where
|
|
264
|
+
it previously succeeded and failed later at first sync. Theoretical for
|
|
265
|
+
browser and WebView hosts (all three GLANCE apps), but if construction
|
|
266
|
+
throws before any network activity, look here first.
|
|
267
|
+
- **Injected `vaultClient` consumers see new `device()` calls.** An engine
|
|
268
|
+
constructed with `config.vaultClient` and no `deviceId` previously made
|
|
269
|
+
zero `device()` calls; it now makes one per cycle. A mock or bridge
|
|
270
|
+
client without a `device()` method will throw inside the cursor step —
|
|
271
|
+
swallowed and warned, never fatal — but the console warning is new and
|
|
272
|
+
recurring. Affects tests with mock clients and native shells that inject
|
|
273
|
+
a bridge client.
|
|
274
|
+
|
|
275
|
+
## [1.7.0] - 2026-07-31
|
|
276
|
+
|
|
277
|
+
### Added
|
|
278
|
+
|
|
279
|
+
- **Per-account vault auth, client half (GLANCEvault Phase 1.4b).** A
|
|
280
|
+
GLANCEvault server can run `authMode: 'per-account'`, where each device
|
|
281
|
+
authenticates with its own enrolled credential instead of the instance-wide
|
|
282
|
+
shared device token. The package owns the whole client-side flow — apps hand
|
|
283
|
+
it a bootstrap secret (or the shared token) and get a working engine back,
|
|
284
|
+
without implementing discovery, branching, credential persistence, or
|
|
285
|
+
secret discard themselves. Shared mode remains the default path and is
|
|
286
|
+
byte-for-byte unchanged.
|
|
287
|
+
- **`connectVaultSyncEngine(config)`** — the packaged connect flow.
|
|
288
|
+
Discovers the server's auth mode from `/healthz`, then branches:
|
|
289
|
+
`shared` → builds the engine from `config.vaultToken` exactly as before;
|
|
290
|
+
`per-account` → uses the credential persisted under
|
|
291
|
+
`{prefix}-vault-credential`, enrolling with `config.enrollmentSecret`
|
|
292
|
+
only when no credential is stored for this exact (server, account). The
|
|
293
|
+
credential is persisted durably (write + read-back verify) **before**
|
|
294
|
+
enrollment is treated as complete, and a storage canary runs **before**
|
|
295
|
+
minting so broken storage cannot orphan one server row per launch. The
|
|
296
|
+
bootstrap secret is never written to storage, never logged, never placed
|
|
297
|
+
on the engine config, and not retained past the call. **No code path
|
|
298
|
+
re-enrolls automatically** — once a credential is stored it is always
|
|
299
|
+
reused, even after rejection (recovery is Phase 2.2), so revocation
|
|
300
|
+
(vault Phase 2.1) cannot be silently undone by a client loop. Discovery
|
|
301
|
+
failure (server unreachable, unexpected body) falls back to the last
|
|
302
|
+
known auth state — stored credential, else `vaultToken` — which is
|
|
303
|
+
exactly the pre-1.4b behavior for existing installs; an unrecognized
|
|
304
|
+
future mode string does the same rather than guessing. A `/healthz`
|
|
305
|
+
without `authMode` (a pre-1.4a server) is ordinary shared mode, not an
|
|
306
|
+
error. Typed failures: `VAULT_TOKEN_REQUIRED`,
|
|
307
|
+
`ENROLLMENT_SECRET_REQUIRED`, `CREDENTIAL_PERSIST_FAILED`,
|
|
308
|
+
`VAULT_UNREACHABLE`, plus the enrollment errors below.
|
|
309
|
+
- **`getOrCreateDeviceId(storageKeyPrefix)`** — the package now owns the
|
|
310
|
+
stable device identity: generated once (`crypto.randomUUID`, with a
|
|
311
|
+
`getRandomValues` fallback for old WebViews) and persisted under
|
|
312
|
+
`{prefix}-device-id`; used for both the device cursor and enrollment. An
|
|
313
|
+
explicit `config.deviceId` still wins. This also fixes a latent bug: the
|
|
314
|
+
engine's device-cursor update silently no-oped (`{updated:false}`) when
|
|
315
|
+
no `deviceId` was configured, so such installs never reported cursors —
|
|
316
|
+
on upgrade they get a generated id and start reporting for the first
|
|
317
|
+
time (server-side effect: a `devices` row appears and tombstone GC gains
|
|
318
|
+
an accurate cursor; pure improvement, no data risk).
|
|
319
|
+
- **Substrate exports** (used by the flow, available standalone):
|
|
320
|
+
`fetchVaultHealth({ vaultUrl, fetchImpl? })` — unauthenticated
|
|
321
|
+
`GET /healthz`, `authMode` normalized to `'shared'` when the server
|
|
322
|
+
predates the field; and `enrollVaultDevice({ vaultUrl, enrollmentSecret,
|
|
323
|
+
accountId, deviceId, fetchImpl? })` — `POST /enroll`, secret in the body
|
|
324
|
+
only, values sent byte-exact, typed `ENROLLMENT_REJECTED` (401) /
|
|
325
|
+
`ENROLLMENT_UNSUPPORTED` (404, shared-mode server) failures, and
|
|
326
|
+
`*_REQUIRED` codes thrown before touching the wire on missing fields.
|
|
327
|
+
- **`CREDENTIAL_INVALID` and the credential halt.** `createVaultClient`
|
|
328
|
+
now reads error bodies best-effort: a 401 whose body says
|
|
329
|
+
`invalid credential` (the server's chosen signal for "this device must
|
|
330
|
+
re-enroll") surfaces as code `CREDENTIAL_INVALID`; a missing, non-JSON,
|
|
331
|
+
or unrecognized body degrades to the generic `VAULT_ERROR`, so client
|
|
332
|
+
correctness never depends on server wording. On `CREDENTIAL_INVALID` the
|
|
333
|
+
DB engine persists a stop under `{prefix}-db-sync-credential-halt`,
|
|
334
|
+
surfaces `onError(message, 'CREDENTIAL_INVALID', /* isHardStop */ true)`,
|
|
335
|
+
and makes **no further network calls** — across restarts. Nothing in
|
|
336
|
+
this version clears the halt (that is Phase 2.2's recovery flow);
|
|
337
|
+
`isCredentialHalted()` / `getCredentialHalt()` expose the state.
|
|
338
|
+
Shared-mode 401s (`invalid device token`) keep today's retryable
|
|
339
|
+
behavior. The key verifier passes `CREDENTIAL_INVALID` through instead
|
|
340
|
+
of relabeling it `VERIFIER_UNSUPPORTED`, so `allowUnverified` can never
|
|
341
|
+
wave a rejected device through.
|
|
342
|
+
- `createVaultClient` is otherwise unchanged on the wire; `vaultToken`
|
|
343
|
+
accepts either the shared device token or an enrolled credential — the
|
|
344
|
+
credential IS the Bearer token.
|
|
345
|
+
|
|
346
|
+
### Security notes
|
|
347
|
+
|
|
348
|
+
- The persisted credential lives in localStorage, exactly as exposed as the
|
|
349
|
+
shared device token and the file tier's provider passwords are today:
|
|
350
|
+
parity, not a regression. Secure-storage migration is explicitly out of
|
|
351
|
+
scope for this phase.
|
|
352
|
+
|
|
10
353
|
## [1.6.1] - 2026-07-19
|
|
11
354
|
|
|
12
355
|
### Fixed
|