@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.
- package/CHANGELOG.md +175 -1
- package/README.md +1 -0
- package/package.json +1 -1
- package/src/dbEngine.js +154 -6
- package/src/engine.js +29 -3
- package/src/index.js +16 -3
- package/src/providers.js +34 -1
- package/src/vaultClient.js +158 -9
- package/src/vaultConnect.js +391 -0
- package/types/index.d.ts +124 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,179 @@ 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
|
+
|
|
143
|
+
## [1.6.1] - 2026-07-19
|
|
144
|
+
|
|
145
|
+
### Fixed
|
|
146
|
+
|
|
147
|
+
- **File engine: persistent PRECONDITION_FAILED (412) sync loop on servers
|
|
148
|
+
that mangle ETags** (lastGLANCE issue #232). The optimistic-concurrency
|
|
149
|
+
cycle (GET capturing an ETag, merge, PUT with If-Match) can never converge
|
|
150
|
+
against a server whose GET responses carry a mangled validator: Apache
|
|
151
|
+
mod_deflate rewrites the ETag on gzip-encoded responses to `"xyz-gzip"`
|
|
152
|
+
(Android's HttpURLConnection silently requests gzip, so native apps always
|
|
153
|
+
hit this on Apache-fronted servers such as standard Nextcloud installs), and
|
|
154
|
+
nginx's gzip filter downgrades strong ETags to weak (`W/"xyz"`), which
|
|
155
|
+
If-Match's strong comparison rejects. Either way every PUT 412s, the
|
|
156
|
+
single conditional retry fetches another mangled ETag and 412s again, and
|
|
157
|
+
sync wedges until the user hand-deletes the sync file from the server. Two
|
|
158
|
+
complementary fixes:
|
|
159
|
+
- **ETag normalization at capture time.** A new exported pure helper,
|
|
160
|
+
`normalizeEtag(raw)`, strips the weak-validator prefix (`W/"abc"` becomes
|
|
161
|
+
`"abc"`) and the known content-coding suffixes Apache appends inside the
|
|
162
|
+
quotes (`"abc-gzip"` / `"abc-br"` become `"abc"`), preserving surrounding
|
|
163
|
+
quotes and passing null/undefined through. `parseDownloadResponse` now
|
|
164
|
+
normalizes every ETag it captures, so If-Match round-trips the entity's
|
|
165
|
+
real validator. Clean ETags are unchanged, so servers that never mangle
|
|
166
|
+
see identical behavior.
|
|
167
|
+
- **Termination guarantee: a second consecutive 412 falls back to an
|
|
168
|
+
unconditional upload.** If the conditional retry after a 412 itself fails
|
|
169
|
+
with PRECONDITION_FAILED (any other error keeps the existing
|
|
170
|
+
backoff-and-surface behavior), the engine runs one final
|
|
171
|
+
download-merge-upload cycle whose PUT omits If-Match entirely
|
|
172
|
+
(last-writer-wins). The uploaded payload is the merge of local state with
|
|
173
|
+
the remote fetched milliseconds earlier in the same cycle, so the
|
|
174
|
+
lost-update window is tiny - far safer than a permanently wedged sync
|
|
175
|
+
that pushes users toward deleting the sync file. If the fallback cycle
|
|
176
|
+
itself fails, the error is surfaced exactly as the old retry-failure path
|
|
177
|
+
did (exponential backoff, `onError`, status `'error'`).
|
|
178
|
+
|
|
179
|
+
### Added
|
|
180
|
+
|
|
181
|
+
- `normalizeEtag(raw)` exported from the package root for reuse by apps.
|
|
182
|
+
|
|
10
183
|
## [1.6.0] - 2026-07-10
|
|
11
184
|
|
|
12
185
|
### Fixed
|
|
@@ -374,7 +547,8 @@ entire sync infrastructure can be shared across the GLANCE app family.
|
|
|
374
547
|
- TypeScript declarations in `types/index.d.ts`.
|
|
375
548
|
- WebDAV CORS proxy handler in `api/webdav-proxy.js`.
|
|
376
549
|
|
|
377
|
-
[Unreleased]: https://github.com/glance-apps/glance-sync/compare/v1.6.
|
|
550
|
+
[Unreleased]: https://github.com/glance-apps/glance-sync/compare/v1.6.1...HEAD
|
|
551
|
+
[1.6.1]: https://github.com/glance-apps/glance-sync/compare/v1.6.0...v1.6.1
|
|
378
552
|
[1.6.0]: https://github.com/glance-apps/glance-sync/compare/v1.5.3...v1.6.0
|
|
379
553
|
[1.5.3]: https://github.com/glance-apps/glance-sync/compare/v1.5.2...v1.5.3
|
|
380
554
|
[1.5.2]: https://github.com/glance-apps/glance-sync/compare/v1.5.1...v1.5.2
|
package/README.md
CHANGED
|
@@ -156,6 +156,7 @@ Pass `encryptionEnabled: true` in the sync config object stored via `engine.setC
|
|
|
156
156
|
|--------|-------------|
|
|
157
157
|
| `webdavFetch(config)` | Creates a transport-selecting WebDAV fetcher. Prefers Android HTTP bridge → Electron proxy → CORS proxy, in that order. |
|
|
158
158
|
| `createProviders(config)` | Creates cloud sync provider objects (`nextcloud`, `koofr`, `webdav`). |
|
|
159
|
+
| `normalizeEtag(raw)` | Normalizes a raw ETag header value for If-Match use: strips a weak-validator prefix (`W/"abc"` → `"abc"`) and the content-coding suffixes some servers append inside the quotes (`"abc-gzip"` / `"abc-br"` → `"abc"`). Quotes are preserved; `null`/`undefined` pass through unchanged. |
|
|
159
160
|
|
|
160
161
|
### Auto-backup
|
|
161
162
|
|
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/engine.js
CHANGED
|
@@ -314,7 +314,9 @@ export const createSyncEngine = (config) => {
|
|
|
314
314
|
|
|
315
315
|
// Inner helper: download, merge, apply, upload (if anything changed).
|
|
316
316
|
// Returns true if a reload was triggered (caller should bail).
|
|
317
|
-
|
|
317
|
+
// skipIfMatch forces an unconditional upload (no If-Match) — the
|
|
318
|
+
// last-writer-wins fallback used after two consecutive 412s.
|
|
319
|
+
const doCycle = async (overrideEtag, { skipIfMatch = false } = {}) => {
|
|
318
320
|
const downloaded = await provider.download(cfg);
|
|
319
321
|
if (!downloaded) {
|
|
320
322
|
// Empty remote — seed it with local state.
|
|
@@ -365,7 +367,7 @@ export const createSyncEngine = (config) => {
|
|
|
365
367
|
await upload({
|
|
366
368
|
prebuiltPayload: mergedPayload,
|
|
367
369
|
skipLockCheck: true,
|
|
368
|
-
etag: etag || overrideEtag || null,
|
|
370
|
+
etag: skipIfMatch ? null : (etag || overrideEtag || null),
|
|
369
371
|
});
|
|
370
372
|
|
|
371
373
|
if (localChanged) {
|
|
@@ -425,6 +427,7 @@ export const createSyncEngine = (config) => {
|
|
|
425
427
|
// jitter (1–3 s) to reduce collision probability, then retry once.
|
|
426
428
|
const jitterMs = 1000 + Math.random() * 2000;
|
|
427
429
|
await new Promise(r => setTimeout(r, jitterMs));
|
|
430
|
+
let finalErr = null;
|
|
428
431
|
try {
|
|
429
432
|
await doCycle(null);
|
|
430
433
|
downloadErrorCount = 0;
|
|
@@ -432,12 +435,35 @@ export const createSyncEngine = (config) => {
|
|
|
432
435
|
} catch (retryErr) {
|
|
433
436
|
// eslint-disable-next-line no-console
|
|
434
437
|
console.error(`[${appId}] cloud sync retry after 412 failed:`, retryErr);
|
|
438
|
+
if (classifyError(retryErr).code === 'PRECONDITION_FAILED') {
|
|
439
|
+
// Two consecutive 412s with fresh ETags is not a write race — it's
|
|
440
|
+
// a server whose ETags can't round-trip through If-Match (e.g. a
|
|
441
|
+
// proxy mangling them in a way normalizeEtag doesn't cover), and a
|
|
442
|
+
// conditional retry can never succeed. Run one last cycle whose
|
|
443
|
+
// upload omits If-Match (last-writer-wins). The payload is the
|
|
444
|
+
// merge of local state with the remote fetched milliseconds
|
|
445
|
+
// earlier in the same cycle, so the lost-update window is tiny —
|
|
446
|
+
// far safer than a permanently wedged sync.
|
|
447
|
+
try {
|
|
448
|
+
await doCycle(null, { skipIfMatch: true });
|
|
449
|
+
downloadErrorCount = 0;
|
|
450
|
+
downloadBackoffUntil = 0;
|
|
451
|
+
} catch (fallbackErr) {
|
|
452
|
+
// eslint-disable-next-line no-console
|
|
453
|
+
console.error(`[${appId}] cloud sync unconditional fallback after 412 failed:`, fallbackErr);
|
|
454
|
+
finalErr = fallbackErr;
|
|
455
|
+
}
|
|
456
|
+
} else {
|
|
457
|
+
finalErr = retryErr;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
if (finalErr) {
|
|
435
461
|
// Apply the same exponential backoff used for other transient errors so
|
|
436
462
|
// the engine backs off rather than hammering the server every poll cycle.
|
|
437
463
|
downloadErrorCount += 1;
|
|
438
464
|
const ms = Math.min(30 * Math.pow(2, downloadErrorCount - 1), MAX_DOWNLOAD_BACKOFF_S) * 1000;
|
|
439
465
|
downloadBackoffUntil = Date.now() + ms;
|
|
440
|
-
onError?.(formatErrorMessage(classifyError(
|
|
466
|
+
onError?.(formatErrorMessage(classifyError(finalErr)), classifyError(finalErr).code, false);
|
|
441
467
|
onStatusChange?.('error');
|
|
442
468
|
scheduleAutoRevert('error', ERROR_HOLD_MS);
|
|
443
469
|
}
|
package/src/index.js
CHANGED
|
@@ -24,7 +24,7 @@ export {
|
|
|
24
24
|
|
|
25
25
|
// webdavFetch and createProviders are internal to the engine (Step 6) but
|
|
26
26
|
// exported here so shims can wire them before the engine exists.
|
|
27
|
-
export { webdavFetch, createProviders } from './providers.js';
|
|
27
|
+
export { webdavFetch, createProviders, normalizeEtag } from './providers.js';
|
|
28
28
|
|
|
29
29
|
export {
|
|
30
30
|
createAutoBackupDB,
|
|
@@ -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/providers.js
CHANGED
|
@@ -89,6 +89,39 @@ export const webdavFetch = (config) => async (method, targetUrl, authHeaders, bo
|
|
|
89
89
|
});
|
|
90
90
|
};
|
|
91
91
|
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// ETag normalization
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Normalizes an ETag as captured from a GET response so it can be echoed back
|
|
98
|
+
* in If-Match. Proxies and content-encoding filters mangle ETags in ways that
|
|
99
|
+
* make the stored entity's validator unreachable:
|
|
100
|
+
* - Apache mod_deflate/mod_brotli append `-gzip`/`-br` inside the quotes
|
|
101
|
+
* (`"abc"` becomes `"abc-gzip"` on gzip-encoded responses).
|
|
102
|
+
* - nginx's gzip filter downgrades strong ETags to weak (`W/"abc"`), and
|
|
103
|
+
* If-Match requires strong comparison.
|
|
104
|
+
* Either way, a PUT with the mangled value 412s forever. Stripping the weak
|
|
105
|
+
* prefix and the known coding suffixes recovers the entity's real validator.
|
|
106
|
+
*
|
|
107
|
+
* Surrounding quotes are preserved; null/undefined pass through unchanged.
|
|
108
|
+
*
|
|
109
|
+
* @param {string|null|undefined} raw - raw ETag header value
|
|
110
|
+
* @returns {string|null|undefined} normalized ETag
|
|
111
|
+
*/
|
|
112
|
+
export function normalizeEtag(raw) {
|
|
113
|
+
if (raw == null) return raw;
|
|
114
|
+
let etag = raw;
|
|
115
|
+
if (etag.startsWith('W/')) etag = etag.slice(2);
|
|
116
|
+
if (etag.length >= 2 && etag.startsWith('"') && etag.endsWith('"')) {
|
|
117
|
+
const inner = etag.slice(1, -1).replace(/-(gzip|br)$/, '');
|
|
118
|
+
etag = `"${inner}"`;
|
|
119
|
+
} else {
|
|
120
|
+
etag = etag.replace(/-(gzip|br)$/, '');
|
|
121
|
+
}
|
|
122
|
+
return etag;
|
|
123
|
+
}
|
|
124
|
+
|
|
92
125
|
// ---------------------------------------------------------------------------
|
|
93
126
|
// Internal helpers
|
|
94
127
|
// ---------------------------------------------------------------------------
|
|
@@ -96,7 +129,7 @@ export const webdavFetch = (config) => async (method, targetUrl, authHeaders, bo
|
|
|
96
129
|
async function parseDownloadResponse(res, engineConfig) {
|
|
97
130
|
const parsed = await res.json();
|
|
98
131
|
const payload = isEncryptedEnvelope(parsed) ? await decryptData(parsed, engineConfig) : parsed;
|
|
99
|
-
const etag = res.headers.get('etag') || null;
|
|
132
|
+
const etag = normalizeEtag(res.headers.get('etag')) || null;
|
|
100
133
|
return { payload, etag };
|
|
101
134
|
}
|
|
102
135
|
|