@glance-apps/sync 1.8.0 → 1.11.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.
@@ -14,6 +14,13 @@
14
14
  // POST /sync/:app/device update device cursor, { updated }
15
15
  // GET /salt/:accountId fetch the account root-key salt
16
16
  // PUT /salt/:accountId register a salt (first-write-wins)
17
+ // POST /intents/batch append intent events, { written, maxSeq }
18
+ // GET /intents/list?accountId=&since=&limit= intent events after a cursor, { rows, hasMore }
19
+ //
20
+ // Every client method is gated by a module-scope brake and counted by a
21
+ // module-scope budget meter, and the two write methods keep a bounded
22
+ // per-entity history that warns on loop-shaped traffic. See the diagnostics
23
+ // block below for why those live at module scope and what they cost.
17
24
  //
18
25
  // Auth models (vault Phase 1.4b — the client half of per-account credentials):
19
26
  // a vault runs in one of two modes, discoverable unauthenticated via
@@ -39,6 +46,464 @@ class VaultError extends Error {
39
46
  }
40
47
  }
41
48
 
49
+ // ═══════════════════════════════════════════════════════════════════════════
50
+ // Module-scope diagnostics (1.11.0): the brake, the budget meter, the
51
+ // write-loop detector.
52
+ //
53
+ // WHY MODULE SCOPE, NOT PER CLIENT INSTANCE. All three protect (or observe) a
54
+ // resource that is shared by every client in the process: the server's
55
+ // per-IP request budget. Two clients built by the same bundle are two views
56
+ // of one traffic stream, so the state that models that stream belongs to the
57
+ // module — one brake, one meter, one write history per *bundle realm*. The
58
+ // app is one realm; a plugin that bundles its own copy of this package is a
59
+ // separate realm, which is correct: it is a separate process with its own
60
+ // traffic and its own IP budget slice.
61
+ //
62
+ // WHY BY CONSTRUCTION. The incident these exist for (2026-08-30) was a
63
+ // fire-and-forget delete loop that re-tombstoned rows for hours and saturated
64
+ // the server's per-IP budget. Every request "succeeded" or failed into a
65
+ // swallowed `.catch(() => {})`. A protection a caller has to remember to
66
+ // apply is a protection that is missing on the day it matters, so the brake
67
+ // gates every network method of every client, and the meter and the write
68
+ // history observe them all whether or not anyone reads the stats.
69
+ // ═══════════════════════════════════════════════════════════════════════════
70
+
71
+ // --- The brake ---
72
+ // 30s doubling to a 10-minute ceiling. The ceiling is a deliberate stopping
73
+ // point rather than unbounded growth: a client that has waited ten minutes
74
+ // and is still being limited is in a condition an operator has to fix, and
75
+ // waiting twenty would only make the recovery slower once they do.
76
+ const BRAKE_BASE_MS = 30_000;
77
+ const BRAKE_CEILING_MS = 600_000;
78
+
79
+ // --- The budget meter ---
80
+ // 300/min is half of GLANCEvault's default 600/min per-IP budget: crossing
81
+ // half the budget in a minute is not yet a failure, which is exactly when a
82
+ // warning is still useful. The window is rolling; samples older than a minute
83
+ // are pruned on every record, and the buffer is hard-capped so a runaway loop
84
+ // can never grow it without bound (a dropped sample only under-counts a
85
+ // number that is already far past the threshold).
86
+ const BUDGET_WINDOW_MS = 60_000;
87
+ const DEFAULT_SOFT_LIMIT = 300;
88
+ const BUDGET_SAMPLE_CAP = 20_000;
89
+ const BUDGET_TOP_N = 3;
90
+
91
+ // --- The write-loop detector ---
92
+ // K=4 redundant writes inside M=10 minutes. A write is redundant when it
93
+ // cannot have changed the row's state from what the previous write to the
94
+ // same entityId left it: a polarity flip, a repeat delete, or an
95
+ // identical-content rewrite (see isRedundantWrite for why each counts). K=4
96
+ // means five events — upsert/delete/upsert/delete/upsert, or five deletes of
97
+ // one tombstone — which no human edit pattern produces and every
98
+ // delete/resupply or re-tombstone loop produces within seconds.
99
+ // M=10min is long enough to catch a slow loop running on a 60s sync cycle
100
+ // and short enough that a week of ordinary edits never accumulates into it.
101
+ // Both are configurable via configureVaultDiagnostics for callers whose
102
+ // cadence makes different numbers right.
103
+ //
104
+ // FAILS OPEN BY CONSTRUCTION: the rule is per-entityId and needs consecutive
105
+ // events on the SAME id, so a first sync or a large import — many DIFFERENT
106
+ // ids, one event each — can never trigger it however big it is.
107
+ const DEFAULT_LOOP_TRANSITIONS = 4;
108
+ const DEFAULT_LOOP_WINDOW_MS = 10 * 60_000;
109
+ const WRITE_HISTORY_PER_ID = 8; // >= K+1 events, so the ring always holds a full detection
110
+ const WRITE_HISTORY_IDS = 500; // LRU-capped: the map is bounded regardless of traffic
111
+
112
+ let diagnosticsConfig = {
113
+ softLimitPerMinute: DEFAULT_SOFT_LIMIT,
114
+ loopTransitions: DEFAULT_LOOP_TRANSITIONS,
115
+ loopWindowMs: DEFAULT_LOOP_WINDOW_MS,
116
+ logger: null,
117
+ };
118
+
119
+ const diagLog = (level, message) => {
120
+ const sink = diagnosticsConfig.logger || console;
121
+ const fn = typeof sink[level] === 'function' ? sink[level] : sink.log;
122
+ if (typeof fn === 'function') fn.call(sink, message);
123
+ };
124
+
125
+ const secs = (ms) => Math.round(ms / 1000);
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // The brake
129
+ // ---------------------------------------------------------------------------
130
+
131
+ const brakeState = { until: 0, memoryMs: 0 };
132
+
133
+ /**
134
+ * The gate. Returns a typed error when the brake is engaged, or null.
135
+ *
136
+ * The error is deliberately shaped like a REAL 429 (`status: 429`) so every
137
+ * retry ladder already sitting on top of this client — the DB engine's
138
+ * backoff, an app's bounded retry — treats it as the transient it is with no
139
+ * new code. `code: 'RATE_LIMITED'` and `retryInMs` are additive: a caller
140
+ * that wants to sit the whole cycle out can branch on them.
141
+ */
142
+ const brakeGate = () => {
143
+ const now = Date.now();
144
+ if (brakeState.until <= now) return null;
145
+ const retryInMs = brakeState.until - now;
146
+ const err = new VaultError(
147
+ `vault requests are paused: rate-limited by the server, retry in ~${Math.ceil(retryInMs / 1000)}s`,
148
+ 429
149
+ );
150
+ err.code = 'RATE_LIMITED';
151
+ err.retryInMs = retryInMs;
152
+ return err;
153
+ };
154
+
155
+ /**
156
+ * Arms the brake on a real 429. ONE ARMING PER BURST: a 429 that arrives
157
+ * while the brake is already engaged never compounds the delay, so a
158
+ * concurrent fan-out of ten requests that all meet the limiter costs one
159
+ * escalation step, not ten.
160
+ */
161
+ const brakeArm = (label) => {
162
+ const now = Date.now();
163
+ if (brakeState.until > now) return;
164
+ brakeState.memoryMs = brakeState.memoryMs === 0
165
+ ? BRAKE_BASE_MS
166
+ : Math.min(brakeState.memoryMs * 2, BRAKE_CEILING_MS);
167
+ brakeState.until = now + brakeState.memoryMs;
168
+ // The once-per-incident report, and the only loud line the brake prints.
169
+ // It names the method that met the limiter because that is the single most
170
+ // useful fact when the storm is someone else's loop.
171
+ diagLog('warn', `[vault] BRAKE: rate-limited (429) on ${label} — vault requests paused for ~${secs(brakeState.memoryMs)}s`);
172
+ };
173
+
174
+ /**
175
+ * DECAY, NEVER AMNESTY. A success clears the gate but only HALVES the
176
+ * escalation memory (below the 30s base it drains to zero).
177
+ *
178
+ * This is the lesson from the first brake built for this ecosystem, which
179
+ * reset escalation to zero on ANY success. On a saturated SHARED budget the
180
+ * occasional cheap request slips into a fresh limiter window and returns 200
181
+ * — and each lucky 200 wiped the entire 30s->480s escalation and re-licensed
182
+ * full cadence. It backed off, escalated, forgot, and started over, forever.
183
+ *
184
+ * Halving keeps the storm's level in memory: the next 429 re-arms at 2x what
185
+ * is left (so a lucky success cannot buy back full cadence), while a genuine
186
+ * recovery drains to zero in a few quiet successes and costs nothing.
187
+ */
188
+ const brakeNoteSuccess = () => {
189
+ const wasBraked = brakeState.until > Date.now();
190
+ brakeState.until = 0;
191
+ if (brakeState.memoryMs === 0) return;
192
+ const before = brakeState.memoryMs;
193
+ const halved = Math.floor(before / 2);
194
+ brakeState.memoryMs = halved < BRAKE_BASE_MS ? 0 : halved;
195
+ diagLog(
196
+ 'info',
197
+ `[vault] brake ${wasBraked ? 'released' : 'decay'}: escalation memory ${secs(before)}s -> ${secs(brakeState.memoryMs)}s`
198
+ );
199
+ };
200
+
201
+ /**
202
+ * The brake's state, for callers that would rather sit a whole cycle out
203
+ * pre-flight than fire a call and catch RATE_LIMITED.
204
+ *
205
+ * @returns {{ braked: boolean, until: number|null, memoryMs: number, retryInMs: number }}
206
+ */
207
+ export function vaultBrakeStatus() {
208
+ const now = Date.now();
209
+ const braked = brakeState.until > now;
210
+ return {
211
+ braked,
212
+ until: brakeState.until || null,
213
+ memoryMs: brakeState.memoryMs,
214
+ retryInMs: braked ? brakeState.until - now : 0,
215
+ };
216
+ }
217
+
218
+ /** Convenience read: is the shared brake engaged right now? */
219
+ export function isVaultRateLimited() {
220
+ return brakeState.until > Date.now();
221
+ }
222
+
223
+ // ---------------------------------------------------------------------------
224
+ // The budget meter
225
+ // ---------------------------------------------------------------------------
226
+ //
227
+ // VISIBILITY ONLY — the meter never throttles anything. Its argument is that
228
+ // it needs no theory about WHY a loop is running: it counts requests and
229
+ // names who made them, so the next storm is an attributed event on day one
230
+ // instead of an afternoon of archaeology.
231
+
232
+ const meterState = { samples: [], lastReportAt: 0, dropped: 0 };
233
+
234
+ const meterPrune = (now) => {
235
+ const cutoff = now - BUDGET_WINDOW_MS;
236
+ const s = meterState.samples;
237
+ let i = 0;
238
+ while (i < s.length && s[i].at <= cutoff) i += 1;
239
+ if (i > 0) s.splice(0, i);
240
+ };
241
+
242
+ const meterTally = () => {
243
+ const counts = new Map();
244
+ for (const sample of meterState.samples) {
245
+ counts.set(sample.label, (counts.get(sample.label) || 0) + 1);
246
+ }
247
+ return [...counts.entries()].sort((a, b) => b[1] - a[1]);
248
+ };
249
+
250
+ const meterRecord = (label) => {
251
+ const now = Date.now();
252
+ meterPrune(now);
253
+ if (meterState.samples.length >= BUDGET_SAMPLE_CAP) {
254
+ // Hard cap: a loop cannot grow the buffer without bound. Dropping the
255
+ // oldest samples only under-counts a number already far past the limit.
256
+ const excess = meterState.samples.length - BUDGET_SAMPLE_CAP + 1;
257
+ meterState.samples.splice(0, excess);
258
+ meterState.dropped += excess;
259
+ }
260
+ meterState.samples.push({ at: now, label });
261
+
262
+ const total = meterState.samples.length;
263
+ if (total <= diagnosticsConfig.softLimitPerMinute) return;
264
+ // ONE line per window: after reporting, stay quiet for a full minute. A
265
+ // storm that lasts an hour prints sixty attributable lines, not a million.
266
+ if (meterState.lastReportAt !== 0 && now - meterState.lastReportAt < BUDGET_WINDOW_MS) return;
267
+ meterState.lastReportAt = now;
268
+ const top = meterTally().slice(0, BUDGET_TOP_N).map(([name, n]) => `${name} ${n}`).join(', ');
269
+ diagLog('warn', `[vault] budget: ${total} requests in the last minute (soft limit ${diagnosticsConfig.softLimitPerMinute}) — top: ${top}`);
270
+ };
271
+
272
+ // ---------------------------------------------------------------------------
273
+ // The write-loop detector (success-side visibility)
274
+ // ---------------------------------------------------------------------------
275
+ //
276
+ // Failure visibility exists. What was missing on the day of the incident was
277
+ // the signal for everything "succeeding" pathologically: every request
278
+ // returned 200 and every caller swallowed its promise, so a delete/resupply
279
+ // loop was indistinguishable from healthy traffic until the server's budget
280
+ // ran out. This ring is the client-level answer — diagnostic only, never a
281
+ // brake, and it fails open.
282
+
283
+ const writeHistory = new Map(); // entityId -> { events: [...], warnedAt }
284
+
285
+ // FNV-1a over a bounded prefix. Cheap enough to run on every row of every
286
+ // batch, and only ever used to spot an IDENTICAL rewrite (see
287
+ // isRedundantWrite). Never decoded, only hashed.
288
+ const hashContent = (value) => {
289
+ if (typeof value !== 'string' || value === '') return null;
290
+ const span = value.length > 4096 ? value.slice(0, 4096) : value;
291
+ let h = 0x811c9dc5;
292
+ for (let i = 0; i < span.length; i += 1) {
293
+ h ^= span.charCodeAt(i);
294
+ h = Math.imul(h, 0x01000193);
295
+ }
296
+ return `${value.length}:${(h >>> 0).toString(36)}`;
297
+ };
298
+
299
+ /**
300
+ * Is this consecutive pair of writes to one entityId REDUNDANT — a write that
301
+ * cannot have changed the row's state from what the previous write left it?
302
+ *
303
+ * Three shapes qualify, and the third is the founding incident's:
304
+ *
305
+ * 1. A POLARITY FLIP (upsert -> delete or delete -> upsert). The
306
+ * write-flip / delete-and-resupply loop.
307
+ * 2. An IDENTICAL-CONTENT REWRITE (upsert -> upsert, same content). Note
308
+ * that an AES-GCM envelope carries a fresh random IV, so identical
309
+ * plaintext does NOT produce an identical envelope: this rule fires for
310
+ * plaintext and deterministic payloads, while shape 1 covers encrypted
311
+ * ones.
312
+ * 3. A REPEAT DELETE (delete -> delete). Re-deleting a row that is already
313
+ * a tombstone is redundant by definition — and it is exactly the loop
314
+ * this detector was built for: the Obsidian plugin's cleanup called
315
+ * deleteRow on rows that were already tombstones, the server re-tombstoned
316
+ * each time with a fresh seq, and that pushed the row back above the
317
+ * caller's cursor to be deleted again. Same polarity every time, no
318
+ * content at all, so neither of the first two rules sees it. A detector
319
+ * that misses its own origin story is not a detector.
320
+ *
321
+ * A bounded retry of one failed delete does not reach K on its own; a ladder
322
+ * that retries the same delete K times inside the window is itself worth
323
+ * knowing about, and is the same shape as the incident.
324
+ */
325
+ const isRedundantWrite = (prev, cur) => {
326
+ if (prev.polarity !== cur.polarity) return true;
327
+ if (prev.polarity === 'delete') return true;
328
+ return prev.contentHash != null && prev.contentHash === cur.contentHash;
329
+ };
330
+
331
+ const countTransitions = (events, now) => {
332
+ const cutoff = now - diagnosticsConfig.loopWindowMs;
333
+ const recent = events.filter((e) => e.at >= cutoff);
334
+ let transitions = 0;
335
+ for (let i = 1; i < recent.length; i += 1) {
336
+ if (isRedundantWrite(recent[i - 1], recent[i])) transitions += 1;
337
+ }
338
+ return transitions;
339
+ };
340
+
341
+ const renderHistory = (events) => events
342
+ .map((e) => (e.polarity === 'upsert' ? 'upsert' : 'delete'))
343
+ .join(' -> ');
344
+
345
+ /**
346
+ * Records one write against an entityId and warns — loudly, at most once per
347
+ * id per window — when its recent history looks like a loop.
348
+ *
349
+ * Recorded at INTENT time (before the request), so a loop that is being
350
+ * gated by the brake or rejected by the server is still visible as a loop.
351
+ */
352
+ const noteWrite = (entityId, polarity, envelope) => {
353
+ if (typeof entityId !== 'string' || entityId === '') return;
354
+ const now = Date.now();
355
+ let rec = writeHistory.get(entityId);
356
+ if (rec) writeHistory.delete(entityId); // re-insert keeps Map iteration order LRU
357
+ else rec = { events: [], warnedAt: 0 };
358
+ rec.events.push({
359
+ polarity,
360
+ at: now,
361
+ contentHash: polarity === 'upsert' ? hashContent(envelope) : null,
362
+ });
363
+ if (rec.events.length > WRITE_HISTORY_PER_ID) rec.events.shift();
364
+ writeHistory.set(entityId, rec);
365
+ while (writeHistory.size > WRITE_HISTORY_IDS) {
366
+ writeHistory.delete(writeHistory.keys().next().value);
367
+ }
368
+
369
+ const transitions = countTransitions(rec.events, now);
370
+ if (transitions < diagnosticsConfig.loopTransitions) return;
371
+ if (rec.warnedAt !== 0 && now - rec.warnedAt < diagnosticsConfig.loopWindowMs) return;
372
+ rec.warnedAt = now;
373
+ diagLog(
374
+ 'warn',
375
+ `[vault] WRITE LOOP? entity ${entityId}: ${transitions} redundant writes (polarity flips, repeat deletes or identical rewrites) in the last ${Math.round(diagnosticsConfig.loopWindowMs / 60_000)} min — ${renderHistory(rec.events)}`
376
+ );
377
+ };
378
+
379
+ const writeLoopSuspects = () => {
380
+ const now = Date.now();
381
+ const suspects = [];
382
+ for (const [entityId, rec] of writeHistory) {
383
+ const transitions = countTransitions(rec.events, now);
384
+ if (transitions === 0) continue;
385
+ suspects.push({
386
+ entityId,
387
+ transitions,
388
+ warned: rec.warnedAt !== 0,
389
+ history: rec.events.map((e) => ({ polarity: e.polarity, at: e.at, contentHash: e.contentHash })),
390
+ });
391
+ }
392
+ return suspects.sort((a, b) => b.transitions - a.transitions).slice(0, 20);
393
+ };
394
+
395
+ // ---------------------------------------------------------------------------
396
+ // The shared stats surface
397
+ // ---------------------------------------------------------------------------
398
+
399
+ /**
400
+ * Everything the module-scope diagnostics know, in one read: the brake's
401
+ * state, the last rolling minute of request counts attributed by method, and
402
+ * the entityIds whose recent write history looks loop-shaped.
403
+ *
404
+ * @returns {{ brake: object, requests: object, writeLoopSuspects: Array }}
405
+ */
406
+ export function getVaultStats() {
407
+ const now = Date.now();
408
+ meterPrune(now);
409
+ const byMethod = {};
410
+ for (const [label, n] of meterTally()) byMethod[label] = n;
411
+ return {
412
+ brake: vaultBrakeStatus(),
413
+ requests: {
414
+ lastMinute: meterState.samples.length,
415
+ softLimitPerMinute: diagnosticsConfig.softLimitPerMinute,
416
+ byMethod,
417
+ droppedSamples: meterState.dropped,
418
+ },
419
+ writeLoopSuspects: writeLoopSuspects(),
420
+ };
421
+ }
422
+
423
+ /**
424
+ * Tunes the visibility-only thresholds. The brake's curve is deliberately NOT
425
+ * configurable: it is a protection for someone else's server, and an app that
426
+ * could widen it would eventually widen it.
427
+ *
428
+ * @param {object} [options]
429
+ * @param {number} [options.softLimitPerMinute] - budget-meter warn threshold (default 300)
430
+ * @param {number} [options.loopTransitions] - write-loop K (default 4)
431
+ * @param {number} [options.loopWindowMs] - write-loop M (default 10min)
432
+ * @param {object|null} [options.logger] - console-shaped sink for the diagnostic lines
433
+ */
434
+ export function configureVaultDiagnostics(options = {}) {
435
+ if (typeof options.softLimitPerMinute === 'number' && options.softLimitPerMinute > 0) {
436
+ diagnosticsConfig.softLimitPerMinute = options.softLimitPerMinute;
437
+ }
438
+ if (typeof options.loopTransitions === 'number' && options.loopTransitions > 0) {
439
+ diagnosticsConfig.loopTransitions = options.loopTransitions;
440
+ }
441
+ if (typeof options.loopWindowMs === 'number' && options.loopWindowMs > 0) {
442
+ diagnosticsConfig.loopWindowMs = options.loopWindowMs;
443
+ }
444
+ if ('logger' in options) diagnosticsConfig.logger = options.logger || null;
445
+ return { ...diagnosticsConfig };
446
+ }
447
+
448
+ /**
449
+ * Clears the brake, the meter and the write history, and restores the default
450
+ * thresholds. For tests — module-scope state is per realm and would otherwise
451
+ * carry between cases.
452
+ */
453
+ export function resetVaultDiagnostics() {
454
+ brakeState.until = 0;
455
+ brakeState.memoryMs = 0;
456
+ meterState.samples.length = 0;
457
+ meterState.lastReportAt = 0;
458
+ meterState.dropped = 0;
459
+ writeHistory.clear();
460
+ diagnosticsConfig = {
461
+ softLimitPerMinute: DEFAULT_SOFT_LIMIT,
462
+ loopTransitions: DEFAULT_LOOP_TRANSITIONS,
463
+ loopWindowMs: DEFAULT_LOOP_WINDOW_MS,
464
+ logger: null,
465
+ };
466
+ }
467
+
468
+ // Quota rejection parsing (GLANCEvault Phase 3.2 / client Phase 3.3).
469
+ //
470
+ // The server rejects an over-quota write with 413 (storage-shaped: `storage`,
471
+ // `rows`) or 429 (volume/concurrency-shaped: `intents`, `concurrent-uploads`)
472
+ // and a uniform body:
473
+ // { error: "quota exceeded", quota, limit, used, requested }
474
+ // The three numbers are bytes for `storage` and counts otherwise, and are
475
+ // meant to render "X of Y used" directly.
476
+ //
477
+ // DEFENSIVE BY CONSTRUCTION — this shape is a contract across two repos, so
478
+ // a body must earn the typed classification rather than be assumed into it.
479
+ // ALL of: the exact `error` wording, a non-empty string `quota`, and three
480
+ // finite numbers. Anything short of that (a missing field, a non-JSON body, a
481
+ // 413 with no quota fields at all, an older server that never sends this
482
+ // shape) degrades to the generic VAULT_ERROR the client already handled —
483
+ // never a throw, never a mis-classification.
484
+ //
485
+ // The `quota` DIMENSION is deliberately NOT allowlisted. A newer server may
486
+ // add dimensions, and an unknown one is still a quota condition with the same
487
+ // remedy (wait; it clears when the operator acts, with no client action), so
488
+ // passing the string through verbatim is more truthful than pretending the
489
+ // rejection was a generic failure. Consumers that do not recognise a
490
+ // dimension render it generically. See the PR for this decision.
491
+ //
492
+ // The legacy SSE per-account cap (429 {"error":"too many connections for
493
+ // account"}) predates this shape and is NOT a quota rejection: its wording
494
+ // fails the `error` check on the first line, so it degrades generically with
495
+ // no special-casing. Nothing in this package speaks SSE regardless.
496
+ const parseQuotaBody = (status, body, bodyError) => {
497
+ if (status !== 413 && status !== 429) return null;
498
+ if (bodyError !== 'quota exceeded') return null;
499
+ if (!body || typeof body.quota !== 'string' || body.quota === '') return null;
500
+ const { limit, used, requested } = body;
501
+ if (typeof limit !== 'number' || !Number.isFinite(limit)) return null;
502
+ if (typeof used !== 'number' || !Number.isFinite(used)) return null;
503
+ if (typeof requested !== 'number' || !Number.isFinite(requested)) return null;
504
+ return { quota: body.quota, limit, used, requested };
505
+ };
506
+
42
507
  // Shared by createVaultClient and the standalone pre-credential helpers below.
43
508
  const resolveFetch = (fetchImpl, context) => {
44
509
  const doFetch = fetchImpl || globalThis.fetch;
@@ -161,16 +626,25 @@ export async function enrollVaultDevice({ vaultUrl, enrollmentSecret, accountId,
161
626
  /**
162
627
  * Creates a vault client bound to a server URL and token.
163
628
  *
629
+ * Every network method of the returned client passes through the module-scope
630
+ * brake and budget meter (see the diagnostics block at the top of this file).
631
+ * That is deliberate: protection a caller has to remember to apply is
632
+ * protection that is missing on the day it matters.
633
+ *
164
634
  * @param {object} config
165
635
  * @param {string} config.vaultUrl - base URL of the GLANCEvault server
166
636
  * @param {string} config.vaultToken - Bearer token: the shared device token
167
637
  * (shared mode) or this device's enrolled credential (per-account mode)
168
638
  * @param {Function} [config.fetchImpl] - fetch implementation (defaults to global fetch)
639
+ * @param {boolean} [config.brake=true] - set false to opt this client out of the
640
+ * shared brake (it neither gates on nor arms it). An escape hatch for tests
641
+ * that need the wire on every call; production callers should leave it on.
169
642
  */
170
- export function createVaultClient({ vaultUrl, vaultToken, fetchImpl } = {}) {
643
+ export function createVaultClient({ vaultUrl, vaultToken, fetchImpl, brake = true } = {}) {
171
644
  const base = requireBaseUrl(vaultUrl, 'createVaultClient');
172
645
  if (!vaultToken) throw new Error('createVaultClient: vaultToken is required');
173
646
  const doFetch = resolveFetch(fetchImpl, 'createVaultClient');
647
+ const brakeEnabled = brake !== false;
174
648
  const authHeaders = (extra = {}) => ({
175
649
  Authorization: `Bearer ${vaultToken}`,
176
650
  ...extra,
@@ -191,6 +665,29 @@ export function createVaultClient({ vaultUrl, vaultToken, fetchImpl } = {}) {
191
665
  return res;
192
666
  };
193
667
 
668
+ /**
669
+ * The one door every network method goes through: gate, meter, send, and
670
+ * report a 2xx back to the brake.
671
+ *
672
+ * `label` is the METHOD NAME (batch, intentsList, deleteRow, ...) — the
673
+ * brake's arming line and the meter's attribution both name it, so the
674
+ * report says who met the limiter rather than just that someone did.
675
+ *
676
+ * A gated call throws BEFORE the fetch and is deliberately silent and
677
+ * unmetered: it never reaches the wire, so it is not budget the server
678
+ * spent, and the arming line already reported the incident once.
679
+ */
680
+ const send = async (label, method, path, opts) => {
681
+ if (brakeEnabled) {
682
+ const gated = brakeGate();
683
+ if (gated) throw gated;
684
+ }
685
+ meterRecord(label);
686
+ const res = await request(method, path, opts);
687
+ if (brakeEnabled && res && res.ok) brakeNoteSuccess();
688
+ return res;
689
+ };
690
+
194
691
  // Error-body inspection is BEST-EFFORT and deliberately narrow. The server
195
692
  // made the 401 body wording the only signal separating shared mode's
196
693
  // "invalid device token" (server misconfigured / wrong token) from
@@ -199,25 +696,48 @@ export function createVaultClient({ vaultUrl, vaultToken, fetchImpl } = {}) {
199
696
  // exact wording upgrades the code to CREDENTIAL_INVALID; a missing,
200
697
  // non-JSON, or unrecognized body degrades to the generic VAULT_ERROR, so
201
698
  // the client's correctness never depends on server wording.
202
- const errorFromResponse = async (res, context) => {
699
+ const errorFromResponse = async (res, context, label) => {
700
+ let body = null;
203
701
  let bodyError = null;
204
702
  try {
205
- const body = await res.json();
703
+ body = await res.json();
206
704
  if (body && typeof body.error === 'string') bodyError = body.error;
207
705
  } catch {
208
706
  // Empty or non-JSON error body: keep the generic classification.
209
707
  }
708
+ // A 429 arms the shared brake — but only a RATE-LIMITER 429, not an
709
+ // over-quota one. The server uses 429 for both, and they call for
710
+ // opposite handling: a limiter hit means "you are asking too often, stop
711
+ // for a while" (exactly what the brake is), while a quota rejection means
712
+ // "this account is full", clears only when an operator acts, and already
713
+ // has its own self-resuming window in the DB engine. Gating on a quota
714
+ // 429 would mask QUOTA_EXCEEDED and its descriptor behind RATE_LIMITED
715
+ // and leave the engine unable to render "X of Y used". So the arming
716
+ // decision waits until the body has been parsed and the quota shape ruled
717
+ // out — see parseQuotaBody for how narrowly that shape is recognised.
718
+ const quotaShaped = parseQuotaBody(res.status, body, bodyError);
719
+ if (res.status === 429 && !quotaShaped && brakeEnabled) brakeArm(label || context);
210
720
  if (res.status === 401 && bodyError === 'invalid credential') {
211
721
  const err = new VaultError(`${context} failed: 401 — the server rejected this device's credential`, 401);
212
722
  err.code = 'CREDENTIAL_INVALID';
213
723
  return err;
214
724
  }
725
+ if (quotaShaped) {
726
+ const quota = quotaShaped;
727
+ const err = new VaultError(
728
+ `${context} failed: ${res.status} — ${quota.quota} quota exceeded (${quota.used} of ${quota.limit}, requested ${quota.requested})`,
729
+ res.status
730
+ );
731
+ err.code = 'QUOTA_EXCEEDED';
732
+ err.quota = quota;
733
+ return err;
734
+ }
215
735
  return new VaultError(`${context} failed: ${res.status}`, res.status);
216
736
  };
217
737
 
218
- const jsonOrThrow = async (res, context) => {
738
+ const jsonOrThrow = async (res, context, label) => {
219
739
  if (!res.ok) {
220
- throw await errorFromResponse(res, context);
740
+ throw await errorFromResponse(res, context, label);
221
741
  }
222
742
  return res.json();
223
743
  };
@@ -243,10 +763,17 @@ export function createVaultClient({ vaultUrl, vaultToken, fetchImpl } = {}) {
243
763
  */
244
764
  async batch(app, { accountId, rows }) {
245
765
  requireAccountId(accountId, 'batch upsert');
246
- const res = await request('POST', `/sync/${encodeURIComponent(app)}/batch`, {
766
+ // Recorded at intent time, before the wire: a loop that is being gated
767
+ // by the brake or rejected by the server is still a loop worth naming.
768
+ if (Array.isArray(rows)) {
769
+ for (const row of rows) {
770
+ if (row) noteWrite(row.entityId, 'upsert', row.envelope);
771
+ }
772
+ }
773
+ const res = await send('batch', 'POST', `/sync/${encodeURIComponent(app)}/batch`, {
247
774
  body: { accountId, rows },
248
775
  });
249
- return jsonOrThrow(res, 'batch upsert');
776
+ return jsonOrThrow(res, 'batch upsert', 'batch');
250
777
  },
251
778
 
252
779
  /**
@@ -255,10 +782,10 @@ export function createVaultClient({ vaultUrl, vaultToken, fetchImpl } = {}) {
255
782
  */
256
783
  async list(app, { accountId, since }) {
257
784
  requireAccountId(accountId, 'list');
258
- const res = await request('GET', `/sync/${encodeURIComponent(app)}/list`, {
785
+ const res = await send('list', 'GET', `/sync/${encodeURIComponent(app)}/list`, {
259
786
  query: { accountId, since: String(since) },
260
787
  });
261
- return jsonOrThrow(res, 'list');
788
+ return jsonOrThrow(res, 'list', 'list');
262
789
  },
263
790
 
264
791
  /**
@@ -266,11 +793,11 @@ export function createVaultClient({ vaultUrl, vaultToken, fetchImpl } = {}) {
266
793
  */
267
794
  async getRow(app, entityId, accountId) {
268
795
  requireAccountId(accountId, 'get row');
269
- const res = await request('GET', `/sync/${encodeURIComponent(app)}/${encodeURIComponent(entityId)}`, {
796
+ const res = await send('getRow', 'GET', `/sync/${encodeURIComponent(app)}/${encodeURIComponent(entityId)}`, {
270
797
  query: { accountId },
271
798
  });
272
799
  if (res.status === 404) return null;
273
- return jsonOrThrow(res, 'get row');
800
+ return jsonOrThrow(res, 'get row', 'getRow');
274
801
  },
275
802
 
276
803
  /**
@@ -284,13 +811,14 @@ export function createVaultClient({ vaultUrl, vaultToken, fetchImpl } = {}) {
284
811
  */
285
812
  async deleteRow(app, entityId, accountId, opts = {}) {
286
813
  requireAccountId(accountId, 'delete row');
814
+ noteWrite(entityId, 'delete');
287
815
  const query = { accountId };
288
816
  if (opts && opts.deletedAt != null) query.deletedAt = String(opts.deletedAt);
289
- const res = await request('DELETE', `/sync/${encodeURIComponent(app)}/${encodeURIComponent(entityId)}`, {
817
+ const res = await send('deleteRow', 'DELETE', `/sync/${encodeURIComponent(app)}/${encodeURIComponent(entityId)}`, {
290
818
  query,
291
819
  });
292
820
  if (res.status === 404) return null;
293
- return jsonOrThrow(res, 'delete row');
821
+ return jsonOrThrow(res, 'delete row', 'deleteRow');
294
822
  },
295
823
 
296
824
  /**
@@ -300,10 +828,10 @@ export function createVaultClient({ vaultUrl, vaultToken, fetchImpl } = {}) {
300
828
  */
301
829
  async device(app, { accountId, deviceId, lastSeenSeq }) {
302
830
  requireAccountId(accountId, 'device cursor');
303
- const res = await request('POST', `/sync/${encodeURIComponent(app)}/device`, {
831
+ const res = await send('device', 'POST', `/sync/${encodeURIComponent(app)}/device`, {
304
832
  body: { accountId, deviceId, lastSeenSeq },
305
833
  });
306
- return jsonOrThrow(res, 'device cursor');
834
+ return jsonOrThrow(res, 'device cursor', 'device');
307
835
  },
308
836
 
309
837
  /**
@@ -311,9 +839,9 @@ export function createVaultClient({ vaultUrl, vaultToken, fetchImpl } = {}) {
311
839
  * salt is registered yet (404).
312
840
  */
313
841
  async getSalt(accountId) {
314
- const res = await request('GET', `/salt/${encodeURIComponent(accountId)}`);
842
+ const res = await send('getSalt', 'GET', `/salt/${encodeURIComponent(accountId)}`);
315
843
  if (res.status === 404) return null;
316
- const body = await jsonOrThrow(res, 'get salt');
844
+ const body = await jsonOrThrow(res, 'get salt', 'getSalt');
317
845
  return body && body.salt ? fromBase64(body.salt) : null;
318
846
  },
319
847
 
@@ -327,11 +855,70 @@ export function createVaultClient({ vaultUrl, vaultToken, fetchImpl } = {}) {
327
855
  * @returns {Promise<Uint8Array>}
328
856
  */
329
857
  async putSalt(accountId, salt) {
330
- const res = await request('PUT', `/salt/${encodeURIComponent(accountId)}`, {
858
+ const res = await send('putSalt', 'PUT', `/salt/${encodeURIComponent(accountId)}`, {
331
859
  body: { salt: toBase64(salt) },
332
860
  });
333
- const body = await jsonOrThrow(res, 'put salt');
861
+ const body = await jsonOrThrow(res, 'put salt', 'putSalt');
334
862
  return body && body.salt ? fromBase64(body.salt) : salt;
335
863
  },
864
+
865
+ // ───────────────────────── intents transport (1.11.0) ────────────────────
866
+ //
867
+ // The client half of GLANCEvault's /intents/* surface, so apps can drop
868
+ // their hand-rolled raw-fetch transports and inherit this client's auth,
869
+ // error classification, brake and meter. PURE TRANSPORT: the envelope is
870
+ // OPAQUE here and is never decoded, inspected or validated — the codec
871
+ // (and the per-envelope key derivation it needs) stays in
872
+ // @glance-apps/intents on the app side, which is why this package can
873
+ // carry the transport without carrying the crypto.
874
+ //
875
+ // The wire contract below is what the server already serves; nothing in
876
+ // this release changes it.
877
+
878
+ /**
879
+ * Appends intent events. INSERT-ONLY: the server ignores an eventId it
880
+ * has already stored, so a re-sent batch after an ambiguous failure is a
881
+ * no-op rather than a duplicate — retries are safe by construction.
882
+ *
883
+ * POST {vaultUrl}/intents/batch
884
+ * body { accountId, events: [{ eventId, envelope, expiresAt }] }
885
+ *
886
+ * @param {string} accountId
887
+ * @param {Array<{ eventId: string, envelope: string, expiresAt: string }>} events
888
+ * envelope is an opaque base64 string; expiresAt is an ISO timestamp.
889
+ * @returns {Promise<{ written: number, maxSeq: number }>}
890
+ */
891
+ async intentsBatch(accountId, events) {
892
+ requireAccountId(accountId, 'intents batch');
893
+ if (!Array.isArray(events)) {
894
+ const err = new Error('intents batch: events must be an array of { eventId, envelope, expiresAt }.');
895
+ err.code = 'EVENTS_REQUIRED';
896
+ throw err;
897
+ }
898
+ const res = await send('intentsBatch', 'POST', '/intents/batch', {
899
+ body: { accountId, events },
900
+ });
901
+ return jsonOrThrow(res, 'intents batch', 'intentsBatch');
902
+ },
903
+
904
+ /**
905
+ * Fetches intent events with seq > since, ascending, one page per call.
906
+ * The server returns only non-expired rows and pages at 500.
907
+ *
908
+ * GET {vaultUrl}/intents/list?accountId=&since=&limit=
909
+ *
910
+ * @param {string} accountId
911
+ * @param {object} [opts]
912
+ * @param {number} [opts.since=0] - exclusive seq cursor
913
+ * @param {number} [opts.limit] - page size; omitted lets the server choose
914
+ * @returns {Promise<{ rows: Array<{ eventId: string, envelope: string, seq: number, expiresAt: string, serverMtime: string }>, hasMore: boolean }>}
915
+ */
916
+ async intentsList(accountId, { since = 0, limit } = {}) {
917
+ requireAccountId(accountId, 'intents list');
918
+ const query = { accountId, since: String(since) };
919
+ if (limit != null) query.limit = String(limit);
920
+ const res = await send('intentsList', 'GET', '/intents/list', { query });
921
+ return jsonOrThrow(res, 'intents list', 'intentsList');
922
+ },
336
923
  };
337
924
  }