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.
- package/SOURCE-OFFER.txt +9 -0
- package/bin/aegis.js +1 -32
- package/lib/account-key.js +1 -56
- package/lib/auto.js +1 -430
- package/lib/avatar/companion.js +1 -698
- package/lib/avatar/cosmetics.js +1 -532
- package/lib/avatar/events.js +1 -344
- package/lib/avatar/holder-turn.js +1 -350
- package/lib/avatar/holders.js +1 -612
- package/lib/avatar/identity.js +1 -641
- package/lib/avatar/level.js +1 -368
- package/lib/avatar/mirror.js +1 -524
- package/lib/avatar/persona.js +1 -388
- package/lib/avatar/profile.js +1 -977
- package/lib/avatar/register.js +1 -307
- package/lib/avatar/store.js +1 -472
- package/lib/avatar/tiers.js +1 -38
- package/lib/avatar/transfer.js +1 -415
- package/lib/avatar/turn.js +1 -216
- package/lib/avatar/voice.js +1 -328
- package/lib/avatar/xp.js +1 -404
- package/lib/deep-link.js +1 -120
- package/lib/fingerprint.js +2 -347
- package/lib/google-connect.js +1 -169
- package/lib/local/agents.js +1 -105
- package/lib/local/autonomous.js +1 -1045
- package/lib/local/claude-pro.js +1 -993
- package/lib/local/context.js +1 -95
- package/lib/local/engine.js +1 -3228
- package/lib/local/entitlement.js +1 -654
- package/lib/local/git-scope.js +1 -228
- package/lib/local/local.js +1 -2135
- package/lib/local/prompt.js +1 -275
- package/lib/local/queue.js +1 -560
- package/lib/local/session-rounds.js +1 -208
- package/lib/local/shell.js +1 -680
- package/lib/local/telemetry.js +1 -470
- package/lib/local/tools.js +1 -1238
- package/lib/local/turn-guard.js +1 -300
- package/lib/local/worktree-lock.js +1 -235
- package/lib/provider-key-mirror.js +1 -252
- package/lib/quick-launcher.js +1 -84
- package/lib/settings.js +1 -890
- package/lib/sync/memory-queue.js +1 -60
- package/lib/sync/persist-gate.js +1 -148
- package/lib/sync/sessions.js +1 -106
- package/lib/window-state.js +1 -66
- package/main.js +3 -4390
- package/package.json +3 -5
- package/preload.js +3 -628
- package/renderer/app.js +1 -6560
- package/renderer/avatar/assemble.js +1 -335
- package/renderer/avatar/avatar.js +1 -380
- package/renderer/avatar/cards.js +1 -141
- package/renderer/avatar/hud.js +1 -206
- package/renderer/avatar/machine.js +1 -213
- package/renderer/avatar/pane.js +1 -439
- package/renderer/avatar/parts.js +1 -437
- package/renderer/budget.js +1 -168
- package/renderer/companion.js +1 -223
- package/renderer/diff.js +1 -359
- package/renderer/diffview.js +1 -336
- package/renderer/fingerprint.js +3 -720
- package/renderer/markdown.js +1 -168
- package/renderer/provider-toolbar.js +1 -134
- package/renderer/providers.js +1 -259
- package/renderer/quick.js +1 -191
- package/renderer/stream-policy.js +1 -306
- package/renderer/transcript-view.js +1 -323
- package/renderer/usage.js +1 -1239
- package/vendor/aegis.js +1 -2054
- package/vendor/attest.js +1 -442
- package/vendor/authorship-key.js +1 -410
- package/vendor/auto-provision.js +1 -134
- package/vendor/brain-catalog.js +1 -268
- package/vendor/byok-catalog.js +1 -230
- package/vendor/credentials.js +1 -494
- package/vendor/device-fingerprint.js +1 -141
- package/vendor/drive-connect.js +1 -85
- package/vendor/drive-upload.js +1 -174
- package/vendor/env-file.js +1 -1273
- package/vendor/fingerprint.js +1 -1064
- package/vendor/foreign-memory.js +1 -669
- package/vendor/google-signin.js +1 -125
- package/vendor/hooks.js +1 -430
- package/vendor/model-discovery.js +1 -330
- package/vendor/platform.js +1 -937
- package/vendor/prefs.js +1 -351
- package/vendor/presence-constants.js +1 -107
- package/vendor/presence.js +1 -438
- package/vendor/record-vault.js +3 -1055
- package/vendor/session-store.js +1 -548
- package/vendor/topup.js +1 -297
- package/vendor/touch.js +1 -458
- package/vendor/update.js +1 -704
- package/vendor/user-models.js +1 -381
package/lib/avatar/mirror.js
CHANGED
|
@@ -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
|
-
|
|
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};
|