aegis-desktop 1.0.13 → 1.0.15

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.
Files changed (96) hide show
  1. package/SOURCE-OFFER.txt +9 -0
  2. package/bin/aegis.js +1 -32
  3. package/lib/account-key.js +1 -56
  4. package/lib/auto.js +1 -430
  5. package/lib/avatar/companion.js +1 -698
  6. package/lib/avatar/cosmetics.js +1 -532
  7. package/lib/avatar/events.js +1 -344
  8. package/lib/avatar/holder-turn.js +1 -350
  9. package/lib/avatar/holders.js +1 -612
  10. package/lib/avatar/identity.js +1 -641
  11. package/lib/avatar/level.js +1 -368
  12. package/lib/avatar/mirror.js +1 -524
  13. package/lib/avatar/persona.js +1 -388
  14. package/lib/avatar/profile.js +1 -977
  15. package/lib/avatar/register.js +1 -307
  16. package/lib/avatar/store.js +1 -472
  17. package/lib/avatar/tiers.js +1 -38
  18. package/lib/avatar/transfer.js +1 -415
  19. package/lib/avatar/turn.js +1 -216
  20. package/lib/avatar/voice.js +1 -328
  21. package/lib/avatar/xp.js +1 -404
  22. package/lib/deep-link.js +1 -120
  23. package/lib/fingerprint.js +2 -347
  24. package/lib/google-connect.js +1 -169
  25. package/lib/local/agents.js +1 -105
  26. package/lib/local/autonomous.js +1 -1045
  27. package/lib/local/claude-pro.js +1 -993
  28. package/lib/local/context.js +1 -95
  29. package/lib/local/engine.js +1 -3228
  30. package/lib/local/entitlement.js +1 -654
  31. package/lib/local/git-scope.js +1 -228
  32. package/lib/local/local.js +1 -2135
  33. package/lib/local/prompt.js +1 -275
  34. package/lib/local/queue.js +1 -560
  35. package/lib/local/session-rounds.js +1 -208
  36. package/lib/local/shell.js +1 -680
  37. package/lib/local/telemetry.js +1 -470
  38. package/lib/local/tools.js +1 -1238
  39. package/lib/local/turn-guard.js +1 -300
  40. package/lib/local/worktree-lock.js +1 -235
  41. package/lib/provider-key-mirror.js +1 -252
  42. package/lib/quick-launcher.js +1 -84
  43. package/lib/settings.js +1 -890
  44. package/lib/sync/memory-queue.js +1 -60
  45. package/lib/sync/persist-gate.js +1 -148
  46. package/lib/sync/sessions.js +1 -106
  47. package/lib/window-state.js +1 -66
  48. package/main.js +3 -4390
  49. package/package.json +3 -5
  50. package/preload.js +3 -628
  51. package/renderer/app.js +1 -6560
  52. package/renderer/avatar/assemble.js +1 -335
  53. package/renderer/avatar/avatar.js +1 -380
  54. package/renderer/avatar/cards.js +1 -141
  55. package/renderer/avatar/hud.js +1 -206
  56. package/renderer/avatar/machine.js +1 -213
  57. package/renderer/avatar/pane.js +1 -439
  58. package/renderer/avatar/parts.js +1 -437
  59. package/renderer/budget.js +1 -168
  60. package/renderer/companion.js +1 -223
  61. package/renderer/diff.js +1 -359
  62. package/renderer/diffview.js +1 -336
  63. package/renderer/fingerprint.js +3 -720
  64. package/renderer/markdown.js +1 -168
  65. package/renderer/provider-toolbar.js +1 -134
  66. package/renderer/providers.js +1 -259
  67. package/renderer/quick.js +1 -191
  68. package/renderer/stream-policy.js +1 -306
  69. package/renderer/transcript-view.js +1 -323
  70. package/renderer/usage.js +1 -1239
  71. package/vendor/aegis.js +1 -2054
  72. package/vendor/attest.js +1 -442
  73. package/vendor/authorship-key.js +1 -410
  74. package/vendor/auto-provision.js +1 -134
  75. package/vendor/brain-catalog.js +1 -268
  76. package/vendor/byok-catalog.js +1 -230
  77. package/vendor/credentials.js +1 -494
  78. package/vendor/device-fingerprint.js +1 -141
  79. package/vendor/drive-connect.js +1 -85
  80. package/vendor/drive-upload.js +1 -174
  81. package/vendor/env-file.js +1 -1273
  82. package/vendor/fingerprint.js +1 -1064
  83. package/vendor/foreign-memory.js +1 -669
  84. package/vendor/google-signin.js +1 -125
  85. package/vendor/hooks.js +1 -430
  86. package/vendor/model-discovery.js +1 -330
  87. package/vendor/platform.js +1 -937
  88. package/vendor/prefs.js +1 -351
  89. package/vendor/presence-constants.js +1 -107
  90. package/vendor/presence.js +1 -438
  91. package/vendor/record-vault.js +3 -1055
  92. package/vendor/session-store.js +1 -548
  93. package/vendor/topup.js +1 -297
  94. package/vendor/touch.js +1 -458
  95. package/vendor/update.js +1 -704
  96. package/vendor/user-models.js +1 -381
@@ -1,526 +1,3 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-or-later
2
2
  // Copyright (C) 2026 Niklas Borneklint — AEGIS Code (aegiscode) / Aegiscloud.
3
- // AEGIS Code (aegiscode) — source: https://github.com/aegisinfo/aegiscode-plugin
4
- // Conveying a modified copy, or running one as a network service, owes the
5
- // corresponding source under this licence (§5/§13) — see LICENSING.md.
6
- 'use strict';
7
-
8
- /**
9
- * mirror.js — the per-holder cloud mirror.
10
- *
11
- * Earlier work put the ledger on disk, made a profile foldable from it and
12
- * gave every row a holder. All three are local. This file is the half
13
- * that leaves the machine, and it exists to make four claims true — each of
14
- * which is a test in `desktop/test/avatar-mirror.test.mjs`, not a paragraph:
15
- *
16
- * 1. **The mirror is keyed by holder id, never by fingerprint.**
17
- * `sessionFor()` builds `avatar:ledger:<holderId>` /
18
- * `avatar:profile:<holderId>` and *refuses* anything that is not a holder
19
- * id (`h_<12 hex>`). A fingerprint is a property of a credential and a key
20
- * rotation would orphan every mirrored row under it; §6 says the reserved
21
- * sessions are named by holder for exactly that reason. The refusal is by
22
- * shape, so passing `sha256(key).slice(0,12)` here throws rather than
23
- * quietly creating a second, key-shaped namespace.
24
- *
25
- * 2. **Reconciliation is union-by-row-id, and therefore order-independent.**
26
- * `reconcile()` unions two sides by a content-addressed row id, deduplicates
27
- * a row both machines already have, and sorts by `(t, id)`. Two machines
28
- * that appended in opposite orders converge on the *same bytes*, and
29
- * `derive()`'s order independence makes that the
30
- * same level. The ordering test feeds both interleavings and
31
- * asserts one level; the idempotence leg asserts feeding the union back in
32
- * cannot double a level, which is what would actually hurt.
33
- *
34
- * 3. **A profile is never mirrored as a blob.** Only *inputs* travel — ledger
35
- * rows and memory entries — and facets are refolded locally by
36
- * `profile.fold()`. A synced conclusion cannot be provenance-checked on the
37
- * machine that receives it: it would arrive citing entry ids that machine
38
- * has never seen, which is precisely the "why does it know this" surface
39
- * going dark. `readMirror()` therefore classifies a `facets`-shaped payload
40
- * as a blob, reports it, and drops it; `refold()` folds only from rows that
41
- * are *present locally*, so a facet can never cite an absent entry.
42
- *
43
- * 4. **Forget is real, and it is scoped.** `queueDelete()` writes one durable
44
- * request naming the holder's two reserved sessions; `drainDeleteQueue()`
45
- * hands each session to the caller's delete function and only marks the row
46
- * done when that function resolves. The queue lives at the avatar root, not
47
- * inside the holder's directory, so it survives the directory removal that
48
- * `transfer.forgetHolder()` performs — a deleted holder whose cloud copy
49
- * could not be deleted would otherwise be unrecoverable, and §7.3 counts
50
- * the cloud delete as part of the promise (*"forget is real"*).
51
- *
52
- * Pure Node (fs + path + crypto), no Electron, injectable clock and delete
53
- * function, so the whole phase runs under plain `node --test`.
54
- */
55
-
56
- const fs = require('node:fs');
57
- const path = require('node:path');
58
- const crypto = require('node:crypto');
59
-
60
- const identity = require('./identity.js');
61
- const profileModule = require('./profile.js');
62
- const xp = require('./xp.js');
63
-
64
- /** `<userData>/avatar/cloud-deletes.jsonl` — outside every holder directory. */
65
- const DELETE_FILE = 'cloud-deletes.jsonl';
66
- /** Envelope version written into every mirrored memory row. */
67
- const MIRROR_VERSION = 1;
68
- const MIRROR_TAG = 'avatar:mirror';
69
- /** The two kinds that may travel. Anything else is a blob and is refused. */
70
- const MIRROR_KINDS = Object.freeze(['ledger', 'entry']);
71
- const BLOB_KINDS = Object.freeze(['profile', 'facets', 'summary']);
72
- /** Row ids are content-addressed: `L` + 16 hex of the canonical row. */
73
- const ROW_ID_CHARS = 16;
74
-
75
- // ------------------------------------------------------------- sessions ----
76
-
77
- function holderIdShape(value) {
78
- const id = value == null ? '' : String(value).trim().toLowerCase();
79
- return identity.HOLDER_ID_SHAPE.test(id) ? id : null;
80
- }
81
-
82
- /**
83
- * The reserved mirror session for one holder and one kind (§6).
84
- *
85
- * Refuses a fingerprint *by shape*: this is the whole point of the section. The
86
- * session is named by the thing that survives a key rotation.
87
- */
88
- function sessionFor(holderId, kind = 'ledger') {
89
- const id = holderIdShape(holderId);
90
- if (!id) {
91
- throw new Error(
92
- 'mirror: reserved sessions are named by holder id (h_ + 8-16 hex), never by a fingerprint or a key'
93
- );
94
- }
95
- const suffix = kind === 'profile' ? 'profile' : 'ledger';
96
- return `avatar:${suffix}:${id}`;
97
- }
98
-
99
- /** Both reserved sessions, in a stable order. */
100
- function scopeFor(holderId) {
101
- return {
102
- holderId: holderIdShape(holderId),
103
- ledger: sessionFor(holderId, 'ledger'),
104
- profile: sessionFor(holderId, 'profile'),
105
- sessions: [sessionFor(holderId, 'ledger'), sessionFor(holderId, 'profile')],
106
- };
107
- }
108
-
109
- /** Which holder (if any) a mirrored session names. `null` for a foreign string. */
110
- function holderOfSession(session) {
111
- const text = session == null ? '' : String(session);
112
- const match = /^avatar:(ledger|profile):(.+)$/.exec(text);
113
- if (!match) return null;
114
- const id = holderIdShape(match[2]);
115
- if (!id) return null;
116
- return { holderId: id, kind: match[1] };
117
- }
118
-
119
- // ----------------------------------------------------------------- rows ----
120
-
121
- /**
122
- * The canonical form of a ledger row: the fields that carry meaning, sorted by
123
- * key, so a row that arrives with its keys in a different order still hashes
124
- * to the same id.
125
- */
126
- function canonical(row) {
127
- if (!row || typeof row !== 'object') return '';
128
- const keys = Object.keys(row)
129
- .filter((key) => row[key] !== undefined && row[key] !== null && key !== 'xp')
130
- .sort();
131
- const out = {};
132
- for (const key of keys) out[key] = row[key];
133
- return JSON.stringify(out);
134
- }
135
-
136
- /**
137
- * A row's mirror identity.
138
- *
139
- * aegis1 has no ledger-row id, and inventing a random one would make the same
140
- * event mirror twice from two machines — the one failure mode that *would*
141
- * inflate a level. So identity is the row's own content: the same event written
142
- * independently on two machines reconciles to one row, and a genuinely different
143
- * event (different `t`, `kind`, `ref` or `turn`) keeps its own id.
144
- */
145
- function rowId(row) {
146
- const explicit = row && (row.id != null ? row.id : row.entryId);
147
- if (explicit != null && String(explicit) !== '') return String(explicit);
148
- return `L${crypto.createHash('sha1').update(canonical(row)).digest('hex').slice(0, ROW_ID_CHARS)}`;
149
- }
150
-
151
- /**
152
- * Union two sides by row id. Order-independent by construction: the output is
153
- * sorted by `(t, id)` and, when both sides carry the same id with different
154
- * payloads, the surviving copy is chosen by a deterministic rule (more fields
155
- * wins, then lexicographically smaller canonical form) rather than by whichever
156
- * argument came first.
157
- *
158
- * @returns {{ rows: object[], ids: string[], added: number, shared: number,
159
- * conflicts: string[], dropped: number }}
160
- */
161
- function reconcile(local, remote) {
162
- const left = Array.isArray(local) ? local : [];
163
- const right = Array.isArray(remote) ? remote : [];
164
- const byId = new Map();
165
- const seen = new Map();
166
- const sides = new Map();
167
- const conflicts = [];
168
- let shared = 0;
169
- let dropped = 0;
170
-
171
- const put = (row, side) => {
172
- if (!row || typeof row !== 'object') {
173
- dropped += 1;
174
- return;
175
- }
176
- const id = rowId(row);
177
- const prior = byId.get(id);
178
- const both = sides.get(id) || new Set();
179
- both.add(side);
180
- sides.set(id, both);
181
- if (prior === undefined) {
182
- byId.set(id, row);
183
- seen.set(id, canonical(row));
184
- return;
185
- }
186
- shared += 1;
187
- if (seen.get(id) === canonical(row)) return;
188
- // Same id, different payload. Deterministic winner, never "last write wins":
189
- // both machines compute the same answer from the same pair.
190
- const a = canonical(prior);
191
- const b = canonical(row);
192
- const richer = Object.keys(row).length - Object.keys(prior).length;
193
- const winner = richer > 0 || (richer === 0 && b < a) ? row : prior;
194
- byId.set(id, winner);
195
- seen.set(id, canonical(winner));
196
- if (!conflicts.includes(id)) conflicts.push(id);
197
- };
198
-
199
- for (const row of left) put(row, 'local');
200
- for (const row of right) put(row, 'remote');
201
-
202
- const rows = Array.from(byId.values()).sort((a, b) => {
203
- const ta = Number(a && a.t) || 0;
204
- const tb = Number(b && b.t) || 0;
205
- if (ta !== tb) return ta - tb;
206
- return rowId(a) < rowId(b) ? -1 : rowId(a) > rowId(b) ? 1 : 0;
207
- });
208
-
209
- let added = 0;
210
- for (const set of sides.values()) if (set.size === 1) added += 1;
211
-
212
- return {
213
- rows,
214
- ids: rows.map(rowId),
215
- added,
216
- shared,
217
- conflicts,
218
- dropped,
219
- };
220
- }
221
-
222
- /** The level a set of rows reads as. Same call `xp.js` makes, nothing more. */
223
- function levelFor(rows) {
224
- try {
225
- const evaluated = xp.evaluate(Array.isArray(rows) ? rows : []);
226
- return evaluated && Number.isFinite(evaluated.level) ? evaluated.level : 1;
227
- } catch (err) {
228
- return 1;
229
- }
230
- }
231
-
232
- /** Is this row safe to send? A row carrying key material is not (§2). */
233
- function hasKeyMaterial(row) {
234
- for (const value of Object.values(row && typeof row === 'object' ? row : {})) {
235
- if (typeof value === 'string' && identity.looksLikeRawKey && identity.looksLikeRawKey(value)) return true;
236
- }
237
- return false;
238
- }
239
-
240
- // ------------------------------------------------------------- envelope ----
241
-
242
- /**
243
- * One mirrored row = one memory entry in the holder's reserved session, with the
244
- * payload as its `content`. The envelope is explicit (`{ mirror: kind, row }`)
245
- * so `readMirror()` can tell an input from a conclusion without guessing, and
246
- * so a `facets` payload is *recognisably* a blob rather than an unknown row.
247
- */
248
- function encodeRow(kind, row, holderId) {
249
- const wanted = MIRROR_KINDS.includes(kind) ? kind : null;
250
- if (!wanted || !row || typeof row !== 'object') return null;
251
- if (hasKeyMaterial(row)) return null;
252
- const session = sessionFor(holderId, wanted === 'ledger' ? 'ledger' : 'profile');
253
- return {
254
- id: wanted === 'ledger' ? rowId(row) : String(rowId(row)),
255
- content: JSON.stringify({ v: MIRROR_VERSION, mirror: wanted, row }),
256
- session,
257
- source: 'aegis-desktop-mirror',
258
- tags: [MIRROR_TAG, wanted],
259
- };
260
- }
261
-
262
- /** The rows to push for one holder: its ledger, and the entries a fold needs. */
263
- function pushPlan(state, opts = {}) {
264
- const holderId = holderIdShape(state && state.holderId) || holderIdShape(opts.holderId);
265
- if (!holderId) return { ok: false, reason: 'pushPlan: expected a holder id', rows: [], sessions: [] };
266
- const ledgerRows = Array.isArray(state && state.ledger) ? state.ledger : [];
267
- const entries = Array.isArray(opts.entries) ? opts.entries : [];
268
- const rows = [];
269
- let refused = 0;
270
- for (const row of ledgerRows) {
271
- const encoded = encodeRow('ledger', row, holderId);
272
- if (encoded) rows.push(encoded);
273
- else refused += 1;
274
- }
275
- for (const entry of entries) {
276
- // Only entries already stamped for this holder travel: an unstamped row
277
- // belongs to whoever is asking, and mirroring it would hand it to both.
278
- const encoded = encodeRow('entry', profileModule.belongsTo(entry, holderId) ? entry : null, holderId);
279
- if (encoded) rows.push(encoded);
280
- else refused += 1;
281
- }
282
- return { ok: true, holderId, rows, refused, sessions: scopeFor(holderId).sessions };
283
- }
284
-
285
- /**
286
- * Decode one mirrored memory row for one holder. Returns a *classification*, not
287
- * a throw: `blob` (a synced conclusion — refused), `foreign` (another holder's
288
- * session — dropped, never read), or a decoded `ledger`/`entry` row.
289
- */
290
- function decodeRow(memoryRow, holderId) {
291
- const id = holderIdShape(holderId);
292
- const row = memoryRow && typeof memoryRow === 'object' ? memoryRow : null;
293
- if (!row) return { ok: false, kind: 'invalid', reason: 'not a row' };
294
- const named = holderOfSession(row.session);
295
- if (!named) return { ok: false, kind: 'foreign', reason: `not a reserved mirror session: ${String(row.session)}` };
296
- if (named.holderId !== id) {
297
- return { ok: false, kind: 'foreign', reason: `session names ${named.holderId}, not ${id}` };
298
- }
299
- let payload = null;
300
- try {
301
- payload = JSON.parse(String(row.content == null ? '' : row.content));
302
- } catch (err) {
303
- return { ok: false, kind: 'invalid', reason: 'content is not JSON' };
304
- }
305
- if (!payload || typeof payload !== 'object') return { ok: false, kind: 'invalid', reason: 'empty envelope' };
306
- const kind = String(payload.mirror || payload.kind || '');
307
- const body = payload.row && typeof payload.row === 'object' ? payload.row : payload;
308
- if (BLOB_KINDS.includes(kind) || Array.isArray(body.facets) || body.profile) {
309
- // The §6 refusal, stated in code: a folded profile that arrived over the wire
310
- // is a conclusion we cannot provenance-check, so it is never applied.
311
- return { ok: false, kind: 'blob', reason: `refusing a synced ${kind || 'profile'} blob — facets refold locally`, session: named };
312
- }
313
- if (!MIRROR_KINDS.includes(kind)) return { ok: false, kind: 'invalid', reason: `unknown mirror kind "${kind}"` };
314
- if (hasKeyMaterial(body)) return { ok: false, kind: 'invalid', reason: 'row carries key material' };
315
- return { ok: true, kind, row: body, session: named };
316
- }
317
-
318
- /**
319
- * Read a batch of mirrored memory rows for one holder: what arrived, what was
320
- * refused, and what belonged to someone else.
321
- */
322
- function readMirror(memoryRows, holderId) {
323
- const list = Array.isArray(memoryRows) ? memoryRows : [];
324
- const out = { holderId: holderIdShape(holderId), ledger: [], entries: [], blobs: [], foreign: [], invalid: [] };
325
- for (const memoryRow of list) {
326
- const decoded = decodeRow(memoryRow, holderId);
327
- if (decoded.ok) (decoded.kind === 'ledger' ? out.ledger : out.entries).push(decoded.row);
328
- else if (decoded.kind === 'blob') out.blobs.push(decoded.reason);
329
- else if (decoded.kind === 'foreign') out.foreign.push(decoded.reason);
330
- else out.invalid.push(decoded.reason);
331
- }
332
- return out;
333
- }
334
-
335
- // --------------------------------------------------------------- refold ----
336
-
337
- /**
338
- * Refold one holder's profile from rows that are *present locally*.
339
- *
340
- * `remote` here is a mirror read (or an arbitrary payload); nothing in it is
341
- * trusted as a conclusion — `payload.profile`/`payload.facets` are dropped
342
- * explicitly and recorded in `refused`. The fold itself is `profile.fold()`
343
- * with `holder` supplied, so a facet can only cite an entry id that survived
344
- * the holder filter *and* exists in this
345
- * input (the `citable` set inside the fold).
346
- */
347
- function refold(input = {}) {
348
- const holderId = holderIdShape(input.holder);
349
- const localEntries = Array.isArray(input.entries) ? input.entries : [];
350
- const localLedger = Array.isArray(input.ledger) ? input.ledger : [];
351
- const remote = input.remote && typeof input.remote === 'object' ? input.remote : null;
352
-
353
- const refused = [];
354
- if (remote) {
355
- for (const key of ['profile', 'facets', 'summary']) {
356
- if (remote[key] !== undefined) refused.push(`dropped remote ${key}`);
357
- }
358
- if (Array.isArray(remote.blobs)) for (const why of remote.blobs) refused.push(why);
359
- }
360
-
361
- const departed = remote && Array.isArray(remote.ledger) ? remote.ledger : [];
362
- const arrived = remote && Array.isArray(remote.entries) ? remote.entries : [];
363
- const ledger = reconcile(localLedger, departed).rows;
364
- const byLocalId = new Set(localEntries.map((entry) => String(entry && entry.id)));
365
- const entries = localEntries.concat(
366
- arrived.filter((entry) => entry && entry.id != null && !byLocalId.has(String(entry.id)))
367
- );
368
-
369
- const profile = profileModule.fold({
370
- holder: holderId,
371
- entries,
372
- ledger,
373
- corrections: input.corrections,
374
- now: input.now,
375
- });
376
-
377
- const known = new Set(entries.map((entry) => String(entry && entry.id)));
378
- const orphaned = [];
379
- for (const facet of profile.facets) {
380
- for (const source of facet.sources) if (!known.has(String(source))) orphaned.push(`${facet.id}:${source}`);
381
- }
382
-
383
- return {
384
- holderId,
385
- profile,
386
- ledger,
387
- entries,
388
- refused,
389
- orphaned,
390
- level: levelFor(ledger),
391
- };
392
- }
393
-
394
- // ----------------------------------------------------------- delete queue ---
395
-
396
- function deleteQueuePath(dir) {
397
- return path.join(String(dir), 'avatar', DELETE_FILE);
398
- }
399
-
400
- function readDeleteQueue(dir) {
401
- let raw = '';
402
- try {
403
- raw = fs.readFileSync(deleteQueuePath(dir), 'utf8');
404
- } catch (err) {
405
- return [];
406
- }
407
- const rows = [];
408
- for (const line of raw.split('\n')) {
409
- const trimmed = line.trim();
410
- if (!trimmed) continue;
411
- try {
412
- const row = JSON.parse(trimmed);
413
- if (row && typeof row === 'object') rows.push(row);
414
- } catch (err) {
415
- // A torn append loses a delete request, never the whole queue.
416
- }
417
- }
418
- return rows;
419
- }
420
-
421
- function writeDeleteQueue(dir, rows) {
422
- const file = deleteQueuePath(dir);
423
- fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
424
- const tmp = `${file}.tmp`;
425
- fs.writeFileSync(
426
- tmp,
427
- rows.map((row) => JSON.stringify(row)).join('\n').concat(rows.length ? '\n' : ''),
428
- { mode: 0o600 }
429
- );
430
- fs.renameSync(tmp, file);
431
- return rows;
432
- }
433
-
434
- /**
435
- * Queue a scoped cloud delete for one holder: its two reserved sessions, by
436
- * holder id, and nothing else. Idempotent — a second forget request for the same
437
- * holder does not add a second row, because the delete is per session and the
438
- * queue is a set of outstanding requests, not a log.
439
- *
440
- * The queue deliberately lives above the holder directory: `transfer.forget()`
441
- * removes that directory, and the request must outlive it.
442
- */
443
- function queueDelete(dir, holderId, opts = {}) {
444
- const scope = scopeFor(holderId);
445
- if (!scope.holderId) return { ok: false, reason: 'queueDelete: expected a holder id', rows: [] };
446
- const rows = readDeleteQueue(dir);
447
- const existing = rows.find((row) => row && row.holderId === scope.holderId && !row.done);
448
- if (existing) {
449
- return { ok: true, duplicate: true, holderId: scope.holderId, sessions: scope.sessions.slice(), rows };
450
- }
451
- const row = {
452
- schema: 1,
453
- id: `del-${scope.holderId}-${typeof opts.t === 'number' ? opts.t : 0}`,
454
- holderId: scope.holderId,
455
- sessions: scope.sessions.slice(),
456
- reason: String(opts.reason || 'holder-forgotten'),
457
- t: typeof opts.t === 'number' ? opts.t : 0,
458
- done: false,
459
- };
460
- const next = rows.concat([row]);
461
- writeDeleteQueue(dir, next);
462
- return { ok: true, duplicate: false, holderId: scope.holderId, sessions: scope.sessions.slice(), row, rows: next };
463
- }
464
-
465
- /**
466
- * Drain the queue: call `send(session)` for every outstanding session and only
467
- * mark the request done when it resolves without throwing. A failure leaves the
468
- * row pending — an offline machine must not silently forget what it owes the
469
- * cloud, which is the same rule as `lib/sync/memory-queue.js`.
470
- */
471
- async function drainDeleteQueue(dir, send) {
472
- const rows = readDeleteQueue(dir);
473
- const fn = typeof send === 'function' ? send : async () => {};
474
- let deleted = 0;
475
- let failed = 0;
476
- for (const row of rows) {
477
- if (!row || row.done) continue;
478
- const sessions = Array.isArray(row.sessions) ? row.sessions : [];
479
- let all = true;
480
- const errors = [];
481
- for (const session of sessions) {
482
- try {
483
- await fn(session, row.holderId);
484
- deleted += 1;
485
- } catch (err) {
486
- all = false;
487
- failed += 1;
488
- errors.push(`${session}: ${(err && err.message) || 'failed'}`);
489
- }
490
- }
491
- if (all) {
492
- row.done = true;
493
- row.doneAt = typeof row.t === 'number' ? row.t : 0;
494
- row.deleted = sessions.slice();
495
- } else {
496
- row.errors = errors;
497
- }
498
- }
499
- writeDeleteQueue(dir, rows);
500
- return { ok: failed === 0, deleted, failed, rows };
501
- }
502
-
503
- module.exports = {
504
- DELETE_FILE,
505
- MIRROR_VERSION,
506
- MIRROR_TAG,
507
- MIRROR_KINDS,
508
- BLOB_KINDS,
509
- sessionFor,
510
- scopeFor,
511
- holderOfSession,
512
- canonical,
513
- rowId,
514
- reconcile,
515
- levelFor,
516
- encodeRow,
517
- decodeRow,
518
- pushPlan,
519
- readMirror,
520
- refold,
521
- deleteQueuePath,
522
- readDeleteQueue,
523
- writeDeleteQueue,
524
- queueDelete,
525
- drainDeleteQueue,
526
- };
3
+ "use strict";const fs=require("node:fs"),path=require("node:path"),crypto=require("node:crypto"),identity=require("./identity.js"),profileModule=require("./profile.js"),xp=require("./xp.js"),DELETE_FILE="cloud-deletes.jsonl",MIRROR_VERSION=1,MIRROR_TAG="avatar:mirror",MIRROR_KINDS=Object.freeze(["ledger","entry"]),BLOB_KINDS=Object.freeze(["profile","facets","summary"]),ROW_ID_CHARS=16;function holderIdShape(e){const r=null==e?"":String(e).trim().toLowerCase();return identity.HOLDER_ID_SHAPE.test(r)?r:null}function sessionFor(e,r="ledger"){const o=holderIdShape(e);if(!o)throw new Error("mirror: reserved sessions are named by holder id (h_ + 8-16 hex), never by a fingerprint or a key");return`avatar:${"profile"===r?"profile":"ledger"}:${o}`}function scopeFor(e){return{holderId:holderIdShape(e),ledger:sessionFor(e,"ledger"),profile:sessionFor(e,"profile"),sessions:[sessionFor(e,"ledger"),sessionFor(e,"profile")]}}function holderOfSession(e){const r=null==e?"":String(e),o=/^avatar:(ledger|profile):(.+)$/.exec(r);if(!o)return null;const n=holderIdShape(o[2]);return n?{holderId:n,kind:o[1]}:null}function canonical(e){if(!e||"object"!=typeof e)return"";const r=Object.keys(e).filter((r=>void 0!==e[r]&&null!==e[r]&&"xp"!==r)).sort(),o={};for(const n of r)o[n]=e[n];return JSON.stringify(o)}function rowId(e){const r=e&&(null!=e.id?e.id:e.entryId);return null!=r&&""!==String(r)?String(r):`L${crypto.createHash("sha1").update(canonical(e)).digest("hex").slice(0,ROW_ID_CHARS)}`}function reconcile(e,r){const o=Array.isArray(e)?e:[],n=Array.isArray(r)?r:[],t=new Map,s=new Map,i=new Map,l=[];let d=0,c=0;const a=(e,r)=>{if(!e||"object"!=typeof e)return void(c+=1);const o=rowId(e),n=t.get(o),a=i.get(o)||new Set;if(a.add(r),i.set(o,a),void 0===n)return t.set(o,e),void s.set(o,canonical(e));if(d+=1,s.get(o)===canonical(e))return;const u=canonical(n),f=canonical(e),p=Object.keys(e).length-Object.keys(n).length,h=p>0||0===p&&f<u?e:n;t.set(o,h),s.set(o,canonical(h)),l.includes(o)||l.push(o)};for(const e of o)a(e,"local");for(const e of n)a(e,"remote");const u=Array.from(t.values()).sort(((e,r)=>{const o=Number(e&&e.t)||0,n=Number(r&&r.t)||0;return o!==n?o-n:rowId(e)<rowId(r)?-1:rowId(e)>rowId(r)?1:0}));let f=0;for(const e of i.values())1===e.size&&(f+=1);return{rows:u,ids:u.map(rowId),added:f,shared:d,conflicts:l,dropped:c}}function levelFor(e){try{const r=xp.evaluate(Array.isArray(e)?e:[]);return r&&Number.isFinite(r.level)?r.level:1}catch(e){return 1}}function hasKeyMaterial(e){for(const r of Object.values(e&&"object"==typeof e?e:{}))if("string"==typeof r&&identity.looksLikeRawKey&&identity.looksLikeRawKey(r))return!0;return!1}function encodeRow(e,r,o){const n=MIRROR_KINDS.includes(e)?e:null;if(!n||!r||"object"!=typeof r)return null;if(hasKeyMaterial(r))return null;const t=sessionFor(o,"ledger"===n?"ledger":"profile");return{id:"ledger"===n?rowId(r):String(rowId(r)),content:JSON.stringify({v:MIRROR_VERSION,mirror:n,row:r}),session:t,source:"aegis-desktop-mirror",tags:[MIRROR_TAG,n]}}function pushPlan(e,r={}){const o=holderIdShape(e&&e.holderId)||holderIdShape(r.holderId);if(!o)return{ok:!1,reason:"pushPlan: expected a holder id",rows:[],sessions:[]};const n=Array.isArray(e&&e.ledger)?e.ledger:[],t=Array.isArray(r.entries)?r.entries:[],s=[];let i=0;for(const e of n){const r=encodeRow("ledger",e,o);r?s.push(r):i+=1}for(const e of t){const r=encodeRow("entry",profileModule.belongsTo(e,o)?e:null,o);r?s.push(r):i+=1}return{ok:!0,holderId:o,rows:s,refused:i,sessions:scopeFor(o).sessions}}function decodeRow(e,r){const o=holderIdShape(r),n=e&&"object"==typeof e?e:null;if(!n)return{ok:!1,kind:"invalid",reason:"not a row"};const t=holderOfSession(n.session);if(!t)return{ok:!1,kind:"foreign",reason:`not a reserved mirror session: ${String(n.session)}`};if(t.holderId!==o)return{ok:!1,kind:"foreign",reason:`session names ${t.holderId}, not ${o}`};let s=null;try{s=JSON.parse(String(null==n.content?"":n.content))}catch(e){return{ok:!1,kind:"invalid",reason:"content is not JSON"}}if(!s||"object"!=typeof s)return{ok:!1,kind:"invalid",reason:"empty envelope"};const i=String(s.mirror||s.kind||""),l=s.row&&"object"==typeof s.row?s.row:s;return BLOB_KINDS.includes(i)||Array.isArray(l.facets)||l.profile?{ok:!1,kind:"blob",reason:`refusing a synced ${i||"profile"} blob — facets refold locally`,session:t}:MIRROR_KINDS.includes(i)?hasKeyMaterial(l)?{ok:!1,kind:"invalid",reason:"row carries key material"}:{ok:!0,kind:i,row:l,session:t}:{ok:!1,kind:"invalid",reason:`unknown mirror kind "${i}"`}}function readMirror(e,r){const o=Array.isArray(e)?e:[],n={holderId:holderIdShape(r),ledger:[],entries:[],blobs:[],foreign:[],invalid:[]};for(const e of o){const o=decodeRow(e,r);o.ok?("ledger"===o.kind?n.ledger:n.entries).push(o.row):"blob"===o.kind?n.blobs.push(o.reason):"foreign"===o.kind?n.foreign.push(o.reason):n.invalid.push(o.reason)}return n}function refold(e={}){const r=holderIdShape(e.holder),o=Array.isArray(e.entries)?e.entries:[],n=Array.isArray(e.ledger)?e.ledger:[],t=e.remote&&"object"==typeof e.remote?e.remote:null,s=[];if(t){for(const e of["profile","facets","summary"])void 0!==t[e]&&s.push(`dropped remote ${e}`);if(Array.isArray(t.blobs))for(const e of t.blobs)s.push(e)}const i=t&&Array.isArray(t.ledger)?t.ledger:[],l=t&&Array.isArray(t.entries)?t.entries:[],d=reconcile(n,i).rows,c=new Set(o.map((e=>String(e&&e.id)))),a=o.concat(l.filter((e=>e&&null!=e.id&&!c.has(String(e.id))))),u=profileModule.fold({holder:r,entries:a,ledger:d,corrections:e.corrections,now:e.now}),f=new Set(a.map((e=>String(e&&e.id)))),p=[];for(const e of u.facets)for(const r of e.sources)f.has(String(r))||p.push(`${e.id}:${r}`);return{holderId:r,profile:u,ledger:d,entries:a,refused:s,orphaned:p,level:levelFor(d)}}function deleteQueuePath(e){return path.join(String(e),"avatar",DELETE_FILE)}function readDeleteQueue(e){let r="";try{r=fs.readFileSync(deleteQueuePath(e),"utf8")}catch(e){return[]}const o=[];for(const e of r.split("\n")){const r=e.trim();if(r)try{const e=JSON.parse(r);e&&"object"==typeof e&&o.push(e)}catch(e){}}return o}function writeDeleteQueue(e,r){const o=deleteQueuePath(e);fs.mkdirSync(path.dirname(o),{recursive:!0,mode:448});const n=`${o}.tmp`;return fs.writeFileSync(n,r.map((e=>JSON.stringify(e))).join("\n").concat(r.length?"\n":""),{mode:384}),fs.renameSync(n,o),r}function queueDelete(e,r,o={}){const n=scopeFor(r);if(!n.holderId)return{ok:!1,reason:"queueDelete: expected a holder id",rows:[]};const t=readDeleteQueue(e),s=t.find((e=>e&&e.holderId===n.holderId&&!e.done));if(s)return{ok:!0,duplicate:!0,holderId:n.holderId,sessions:n.sessions.slice(),rows:t};const i={schema:1,id:`del-${n.holderId}-${"number"==typeof o.t?o.t:0}`,holderId:n.holderId,sessions:n.sessions.slice(),reason:String(o.reason||"holder-forgotten"),t:"number"==typeof o.t?o.t:0,done:!1},l=t.concat([i]);return writeDeleteQueue(e,l),{ok:!0,duplicate:!1,holderId:n.holderId,sessions:n.sessions.slice(),row:i,rows:l}}async function drainDeleteQueue(e,r){const o=readDeleteQueue(e),n="function"==typeof r?r:async()=>{};let t=0,s=0;for(const e of o){if(!e||e.done)continue;const r=Array.isArray(e.sessions)?e.sessions:[];let o=!0;const i=[];for(const l of r)try{await n(l,e.holderId),t+=1}catch(e){o=!1,s+=1,i.push(`${l}: ${e&&e.message||"failed"}`)}o?(e.done=!0,e.doneAt="number"==typeof e.t?e.t:0,e.deleted=r.slice()):e.errors=i}return writeDeleteQueue(e,o),{ok:0===s,deleted:t,failed:s,rows:o}}module.exports={DELETE_FILE:DELETE_FILE,MIRROR_VERSION:MIRROR_VERSION,MIRROR_TAG:MIRROR_TAG,MIRROR_KINDS:MIRROR_KINDS,BLOB_KINDS:BLOB_KINDS,sessionFor:sessionFor,scopeFor:scopeFor,holderOfSession:holderOfSession,canonical:canonical,rowId:rowId,reconcile:reconcile,levelFor:levelFor,encodeRow:encodeRow,decodeRow:decodeRow,pushPlan:pushPlan,readMirror:readMirror,refold:refold,deleteQueuePath:deleteQueuePath,readDeleteQueue:readDeleteQueue,writeDeleteQueue:writeDeleteQueue,queueDelete:queueDelete,drainDeleteQueue:drainDeleteQueue};