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/identity.js
CHANGED
|
@@ -1,643 +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
|
-
* identity.js — whose memory it is.
|
|
10
|
-
*
|
|
11
|
-
* Memory is the value; value is meaningless
|
|
12
|
-
* unattributed. This module answers exactly one question — *which holder is at
|
|
13
|
-
* the keyboard* — from signals the caller already has, and it answers with a
|
|
14
|
-
* **proposal plus provenance**, never with a merge.
|
|
15
|
-
*
|
|
16
|
-
* The precedence (identity plan §2) is deliberate and ordered by how much the
|
|
17
|
-
* signal actually proves:
|
|
18
|
-
*
|
|
19
|
-
* explicit `AEGIS_AVATAR_HOLDER` / persona `identity.holderId`.
|
|
20
|
-
* Deliberate beats inferred — CI and power users need it.
|
|
21
|
-
* account a cloud account id. Survives key rotation.
|
|
22
|
-
* keyFingerprint `sha256(key).slice(0, FINGERPRINT_LENGTH)`, computed in
|
|
23
|
-
* main. The only signal a fresh install has.
|
|
24
|
-
* local a minted `h_<12 hex>`: single-user offline machines.
|
|
25
|
-
*
|
|
26
|
-
* Four rules, and each one is an assertion in `desktop/test/avatar-identity
|
|
27
|
-
* .test.mjs` rather than a sentence in a doc:
|
|
28
|
-
*
|
|
29
|
-
* 1. **A fingerprint is not an identity.** It says "same credential", not
|
|
30
|
-
* "same person" — two holders on one box can legitimately share a key. So a
|
|
31
|
-
* fingerprint match that disagrees with the registry's active holder
|
|
32
|
-
* returns `conflict: { a, b, reason }` and *both rows keep existing*. Nothing
|
|
33
|
-
* here ever moves data between holders.
|
|
34
|
-
* 2. **No key material, anywhere.** `fingerprint()` is the only thing that sees
|
|
35
|
-
* a key, it is one-way and truncated, and `resolve()` accepts a fingerprint
|
|
36
|
-
* *by shape* (12 lowercase hex) or not at all: a raw key, or an untruncated
|
|
37
|
-
* 64-char digest, is rejected and warned about rather than trusted. Nothing
|
|
38
|
-
* returned by this module can carry a long high-entropy hex run, so a caller
|
|
39
|
-
* cannot accidentally log one.
|
|
40
|
-
* 3. **The OS user name is a hint, never an id.** `neo` on two machines is two
|
|
41
|
-
* relationships and `neo` on a shared box is not a person, so `osUser` can
|
|
42
|
-
* only ever influence `displayName`.
|
|
43
|
-
* 4. **Resolution is pure and never writes.** `holders.json` is validated,
|
|
44
|
-
* repaired and copied; the input registry is never mutated, and the fields
|
|
45
|
-
* `proposal`/`conflict`/`needsMint` exist precisely so main writes the file
|
|
46
|
-
* with the user's confirmation instead of this module guessing.
|
|
47
|
-
*
|
|
48
|
-
* Pure: no fs, no Electron, no crypto import — the caller supplies `hash` so
|
|
49
|
-
* this file is testable in plain Node exactly like `lib/sync/*`.
|
|
50
|
-
*/
|
|
51
|
-
|
|
52
|
-
const persona = require('./persona.js');
|
|
53
|
-
|
|
54
|
-
/** Bump when the registry record shape changes; `migrate` walks it forward. */
|
|
55
|
-
const SCHEMA_VERSION = 1;
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* `sha256(apiKey).slice(0, 12)` (identity plan §2). Short on purpose: it is an
|
|
59
|
-
* index for a registry row, not an authenticator.
|
|
60
|
-
*/
|
|
61
|
-
const FINGERPRINT_LENGTH = 12;
|
|
62
|
-
|
|
63
|
-
/** A registry is an index of at most this many holders. */
|
|
64
|
-
const MAX_HOLDERS = 32;
|
|
65
|
-
const MAX_DISPLAY_NAME = 40;
|
|
66
|
-
const MAX_ACCOUNT_ID = 64;
|
|
67
|
-
|
|
68
|
-
/** Minted ids: `h_` + 12 lowercase hex, matching the plan's examples. */
|
|
69
|
-
const HOLDER_ID_RE = /^h_[0-9a-f]{12}$/;
|
|
70
|
-
/**
|
|
71
|
-
* Ids accepted *from the outside* (env, persona, registry file): wider than we
|
|
72
|
-
* mint, still narrow. Capped at 16 hex so no field this module returns can
|
|
73
|
-
* contain a 20+ character hex run (rule 2) even when the caller passes one.
|
|
74
|
-
*/
|
|
75
|
-
const HOLDER_ID_SHAPE = /^h_[0-9a-f]{8,16}$/;
|
|
76
|
-
const FINGERPRINT_RE = /^[0-9a-f]{12}$/;
|
|
77
|
-
/** Account ids are opaque server-side strings; this is a shape check only. */
|
|
78
|
-
const ACCOUNT_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{3,63}$/;
|
|
79
|
-
/** Anything that looks like key material rather than a name or an id. */
|
|
80
|
-
const KEY_SHAPE = /^(?:sk|pk|gsk|xox[baprs]|ghp|glpat|aegis)[-_][A-Za-z0-9_-]{6,}/i;
|
|
81
|
-
const LONG_HEX = /[0-9a-f]{20,}/i;
|
|
82
|
-
const BEARER = /^Bearer\s+\S+/i;
|
|
83
|
-
|
|
84
|
-
/** The neutral fallback. Never the OS user name, never a guess. */
|
|
85
|
-
const DEFAULT_DISPLAY_NAME = 'You';
|
|
86
|
-
|
|
87
|
-
// ---------------------------------------------------------------------------
|
|
88
|
-
// hashing helpers — the caller owns the hash
|
|
89
|
-
// ---------------------------------------------------------------------------
|
|
90
|
-
|
|
91
|
-
/** Accept a hex string or a byte array (Buffer/Uint8Array) and return hex. */
|
|
92
|
-
function toHex(value) {
|
|
93
|
-
if (typeof value === 'string') {
|
|
94
|
-
const flat = value.trim().toLowerCase().replace(/\s+/g, '');
|
|
95
|
-
return /^[0-9a-f]+$/.test(flat) ? flat : null;
|
|
96
|
-
}
|
|
97
|
-
if (value instanceof Uint8Array || Array.isArray(value)) {
|
|
98
|
-
let out = '';
|
|
99
|
-
for (const byte of value) {
|
|
100
|
-
const b = Number(byte);
|
|
101
|
-
if (!Number.isInteger(b) || b < 0 || b > 255) return null;
|
|
102
|
-
out += b.toString(16).padStart(2, '0');
|
|
103
|
-
}
|
|
104
|
-
return out || null;
|
|
105
|
-
}
|
|
106
|
-
return null;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* One-way, truncated digest of a credential. **The only function in the tree
|
|
111
|
-
* that sees a raw key.**
|
|
112
|
-
*
|
|
113
|
-
* @param {(input: string) => string|Uint8Array} hash caller's digest (e.g.
|
|
114
|
-
* `(s) => crypto.createHash('sha256').update(s).digest('hex')`)
|
|
115
|
-
* @param {string} key the credential
|
|
116
|
-
* @returns {string|null} 12 lowercase hex, or `null` for anything unusable —
|
|
117
|
-
* `null` rather than a throw because a missing key is the fresh-install case,
|
|
118
|
-
* not an error.
|
|
119
|
-
*/
|
|
120
|
-
function fingerprint(hash, key) {
|
|
121
|
-
if (typeof hash !== 'function') return null;
|
|
122
|
-
if (typeof key !== 'string') return null;
|
|
123
|
-
const trimmed = key.trim();
|
|
124
|
-
if (!trimmed) return null;
|
|
125
|
-
let digest;
|
|
126
|
-
try {
|
|
127
|
-
digest = hash(trimmed);
|
|
128
|
-
} catch {
|
|
129
|
-
return null;
|
|
130
|
-
}
|
|
131
|
-
const hex = toHex(digest);
|
|
132
|
-
if (!hex || hex.length < FINGERPRINT_LENGTH) return null;
|
|
133
|
-
return hex.slice(0, FINGERPRINT_LENGTH);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/** Shape check only — this says "a fingerprint could look like this". */
|
|
137
|
-
function isFingerprint(value) {
|
|
138
|
-
return typeof value === 'string' && FINGERPRINT_RE.test(value.trim().toLowerCase());
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Does this look like a secret rather than an identifier? Used to refuse an
|
|
143
|
-
* account id, a display hint, or a fingerprint that is really a key.
|
|
144
|
-
*/
|
|
145
|
-
function looksLikeRawKey(value) {
|
|
146
|
-
if (typeof value !== 'string') return false;
|
|
147
|
-
const flat = value.trim();
|
|
148
|
-
if (!flat) return false;
|
|
149
|
-
return KEY_SHAPE.test(flat) || BEARER.test(flat) || LONG_HEX.test(flat);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* Deterministic local id minting: `h_` + 12 hex of `hash(seed)`.
|
|
154
|
-
*
|
|
155
|
-
* Deterministic on purpose — the same machine seed produces the same id on a
|
|
156
|
-
* reinstall, which is what keeps "the ledger survives a reinstall"
|
|
157
|
-
* true for an install that never had a cloud key.
|
|
158
|
-
*
|
|
159
|
-
* @returns {string|null} `null` when there is nothing trustworthy to mint from;
|
|
160
|
-
* the caller then falls back to random bytes.
|
|
161
|
-
*/
|
|
162
|
-
function mintHolderId(hash, seed) {
|
|
163
|
-
if (typeof hash !== 'function') return null;
|
|
164
|
-
if (typeof seed !== 'string' || !seed.trim()) return null;
|
|
165
|
-
let digest;
|
|
166
|
-
try {
|
|
167
|
-
digest = hash(seed.trim());
|
|
168
|
-
} catch {
|
|
169
|
-
return null;
|
|
170
|
-
}
|
|
171
|
-
const hex = toHex(digest);
|
|
172
|
-
if (!hex || hex.length < FINGERPRINT_LENGTH) return null;
|
|
173
|
-
const id = `h_${hex.slice(0, FINGERPRINT_LENGTH)}`;
|
|
174
|
-
return HOLDER_ID_RE.test(id) ? id : null;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// ---------------------------------------------------------------------------
|
|
178
|
-
// the registry — `holders.json`, an index and not the data
|
|
179
|
-
// ---------------------------------------------------------------------------
|
|
180
|
-
|
|
181
|
-
/** A display name is a single line of UI text, capped, and never a digest. */
|
|
182
|
-
function cleanDisplay(value, fallback = DEFAULT_DISPLAY_NAME) {
|
|
183
|
-
if (typeof value !== 'string') return fallback;
|
|
184
|
-
// A hint that looks like a credential is not shown as one.
|
|
185
|
-
if (looksLikeRawKey(value)) return fallback;
|
|
186
|
-
const flat = persona.cleanText(value, MAX_DISPLAY_NAME);
|
|
187
|
-
return flat || fallback;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function numOrNull(value) {
|
|
191
|
-
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* A short label that is safe to put in a warning: a value we could not accept is
|
|
196
|
-
* never echoed back, because the caller may have handed us a credential in the
|
|
197
|
-
* wrong slot (rule 2). `warnings` travels into main's log, so echoing it would
|
|
198
|
-
* be the leak this module exists to prevent.
|
|
199
|
-
*/
|
|
200
|
-
function safeLabel(value) {
|
|
201
|
-
if (typeof value !== 'string' || !value.trim()) return '(empty)';
|
|
202
|
-
const flat = value.trim();
|
|
203
|
-
if (flat.length > 24 || looksLikeRawKey(flat)) return '(withheld)';
|
|
204
|
-
return flat;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/** A valid, mappable holder id from an outside source, or `null`. */
|
|
208
|
-
function holderIdShape(value) {
|
|
209
|
-
if (typeof value !== 'string') return null;
|
|
210
|
-
const id = value.trim().toLowerCase();
|
|
211
|
-
return HOLDER_ID_SHAPE.test(id) ? id : null;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
function emptyRegistry() {
|
|
215
|
-
return { schema: SCHEMA_VERSION, active: null, holders: [] };
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/**
|
|
219
|
-
* Walk an old record forward. With one schema version this is trivial, but the
|
|
220
|
-
* mechanism exists now so that adding v2 is a table entry rather than a
|
|
221
|
-
* discovery that shipped registries cannot be read.
|
|
222
|
-
*/
|
|
223
|
-
const MIGRATIONS = Object.freeze({
|
|
224
|
-
// 0 → 1: pre-schema registries are partial records; normalisation fills them.
|
|
225
|
-
0: (raw) => Object.assign({}, raw),
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
function migrate(raw) {
|
|
229
|
-
if (!raw || typeof raw !== 'object') return { registry: emptyRegistry(), migrated: false, fromSchema: 0 };
|
|
230
|
-
let current = raw;
|
|
231
|
-
let version = Number.isInteger(current.schema) ? current.schema : 0;
|
|
232
|
-
let migrated = false;
|
|
233
|
-
while (version < SCHEMA_VERSION) {
|
|
234
|
-
const step = MIGRATIONS[version];
|
|
235
|
-
current = step ? step(current) : current;
|
|
236
|
-
version += 1;
|
|
237
|
-
migrated = true;
|
|
238
|
-
}
|
|
239
|
-
return { registry: current, migrated, fromSchema: Number.isInteger(raw.schema) ? raw.schema : 0 };
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
/**
|
|
243
|
-
* Validate and repair a registry.
|
|
244
|
-
*
|
|
245
|
-
* Repair-oriented like `persona.js`: the only hard errors are "not an object"
|
|
246
|
-
* and "schema from the future", because this is a file the user can hand-edit
|
|
247
|
-
* and a bad row must not take the app down. Everything else is coerced, dropped
|
|
248
|
-
* or repaired and reported in `warnings`.
|
|
249
|
-
*
|
|
250
|
-
* @returns {{ ok: boolean, registry: object, errors: string[], warnings: string[], migrated: boolean }}
|
|
251
|
-
*/
|
|
252
|
-
function validate(raw) {
|
|
253
|
-
const errors = [];
|
|
254
|
-
const warnings = [];
|
|
255
|
-
|
|
256
|
-
if (raw === undefined || raw === null) {
|
|
257
|
-
return { ok: true, registry: emptyRegistry(), errors, warnings: ['no holders registry yet — starting empty'], migrated: false };
|
|
258
|
-
}
|
|
259
|
-
if (typeof raw !== 'object' || Array.isArray(raw)) {
|
|
260
|
-
return { ok: false, registry: emptyRegistry(), errors: ['holders.json must be a JSON object'], warnings, migrated: false };
|
|
261
|
-
}
|
|
262
|
-
if (Number.isInteger(raw.schema) && raw.schema > SCHEMA_VERSION) {
|
|
263
|
-
return {
|
|
264
|
-
ok: false,
|
|
265
|
-
registry: emptyRegistry(),
|
|
266
|
-
errors: [`holders schema ${raw.schema} is newer than this build supports (${SCHEMA_VERSION})`],
|
|
267
|
-
warnings,
|
|
268
|
-
migrated: false,
|
|
269
|
-
};
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
const { registry: migratedRegistry, migrated } = migrate(raw);
|
|
273
|
-
const src = migratedRegistry;
|
|
274
|
-
|
|
275
|
-
const rows = [];
|
|
276
|
-
const seen = new Set();
|
|
277
|
-
const rawHolders = Array.isArray(src.holders) ? src.holders : [];
|
|
278
|
-
if (src.holders !== undefined && !Array.isArray(src.holders)) {
|
|
279
|
-
warnings.push('holders must be an array — ignored');
|
|
280
|
-
}
|
|
281
|
-
for (const rawRow of rawHolders) {
|
|
282
|
-
if (rows.length >= MAX_HOLDERS) {
|
|
283
|
-
warnings.push(`at most ${MAX_HOLDERS} holders — extra rows dropped`);
|
|
284
|
-
break;
|
|
285
|
-
}
|
|
286
|
-
if (!rawRow || typeof rawRow !== 'object' || Array.isArray(rawRow)) {
|
|
287
|
-
warnings.push('a holder row was not an object — dropped');
|
|
288
|
-
continue;
|
|
289
|
-
}
|
|
290
|
-
const id = holderIdShape(rawRow.id);
|
|
291
|
-
if (!id) {
|
|
292
|
-
warnings.push(`holder id ${safeLabel(rawRow.id)} is not h_ plus 8-16 hex — row dropped`);
|
|
293
|
-
continue;
|
|
294
|
-
}
|
|
295
|
-
if (seen.has(id)) {
|
|
296
|
-
warnings.push(`duplicate holder id ${id} — the later row was dropped`);
|
|
297
|
-
continue;
|
|
298
|
-
}
|
|
299
|
-
seen.add(id);
|
|
300
|
-
|
|
301
|
-
let fingerprintValue = typeof rawRow.fingerprint === 'string' ? rawRow.fingerprint.trim().toLowerCase() : null;
|
|
302
|
-
if (fingerprintValue && !FINGERPRINT_RE.test(fingerprintValue)) {
|
|
303
|
-
// The row survives; the bad fingerprint does not. A malformed digest is a
|
|
304
|
-
// repair, not a reason to lose the holder's display name and timestamps.
|
|
305
|
-
warnings.push(`holder ${id} fingerprint is not ${FINGERPRINT_LENGTH} lowercase hex — dropped from the row`);
|
|
306
|
-
fingerprintValue = null;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
let accountId = null;
|
|
310
|
-
if (rawRow.accountId !== undefined && rawRow.accountId !== null) {
|
|
311
|
-
const candidate = typeof rawRow.accountId === 'string' ? rawRow.accountId.trim() : '';
|
|
312
|
-
if (candidate && ACCOUNT_ID_RE.test(candidate) && !looksLikeRawKey(candidate)) accountId = candidate;
|
|
313
|
-
else warnings.push(`holder ${id} accountId was rejected by shape`);
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
rows.push({
|
|
317
|
-
id,
|
|
318
|
-
displayName: cleanDisplay(rawRow.displayName, id),
|
|
319
|
-
fingerprint: fingerprintValue,
|
|
320
|
-
accountId,
|
|
321
|
-
personaName: cleanDisplay(rawRow.personaName, null),
|
|
322
|
-
createdAt: numOrNull(rawRow.createdAt),
|
|
323
|
-
lastSeenAt: numOrNull(rawRow.lastSeenAt),
|
|
324
|
-
});
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
// Two rows on one fingerprint is exactly the case that makes "a fingerprint
|
|
328
|
-
// is not an identity" load-bearing (§3/§6): it is legal, and resolve() reports
|
|
329
|
-
// it rather than merging the rows.
|
|
330
|
-
const byFingerprint = new Map();
|
|
331
|
-
for (const row of rows) {
|
|
332
|
-
if (!row.fingerprint) continue;
|
|
333
|
-
if (byFingerprint.has(row.fingerprint)) {
|
|
334
|
-
warnings.push(`holders ${byFingerprint.get(row.fingerprint)} and ${row.id} share one key fingerprint — resolve() proposes, never merges`);
|
|
335
|
-
} else {
|
|
336
|
-
byFingerprint.set(row.fingerprint, row.id);
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
let active = typeof src.active === 'string' ? holderIdShape(src.active) : null;
|
|
341
|
-
if (src.active !== undefined && src.active !== null && src.active !== '' && !active) {
|
|
342
|
-
warnings.push(`active ${safeLabel(src.active)} is not a holder id — repaired`);
|
|
343
|
-
}
|
|
344
|
-
if (active && !rows.some((row) => row.id === active)) {
|
|
345
|
-
warnings.push(`active holder ${active} is not in the registry — repaired`);
|
|
346
|
-
active = null;
|
|
347
|
-
}
|
|
348
|
-
if (!active && rows.length) active = rows[0].id;
|
|
349
|
-
|
|
350
|
-
for (const key of Object.keys(src)) {
|
|
351
|
-
if (!['schema', 'active', 'holders'].includes(key)) {
|
|
352
|
-
warnings.push(`unknown registry key ${safeLabel(key)} — ignored`);
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
return { ok: true, errors, warnings, migrated, registry: { schema: SCHEMA_VERSION, active, holders: rows } };
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
/** Convenience: validate and hand back just the record. */
|
|
360
|
-
function load(raw) {
|
|
361
|
-
return validate(raw).registry;
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
/** Shallow-merge a patch onto a valid registry, then re-validate. */
|
|
365
|
-
function update(current, patch) {
|
|
366
|
-
const base = validate(current).registry;
|
|
367
|
-
if (!patch || typeof patch !== 'object') return validate(base);
|
|
368
|
-
const merged = {
|
|
369
|
-
schema: SCHEMA_VERSION,
|
|
370
|
-
active: patch.active !== undefined ? patch.active : base.active,
|
|
371
|
-
holders: patch.holders !== undefined ? patch.holders : base.holders,
|
|
372
|
-
};
|
|
373
|
-
return validate(merged);
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
function findHolder(registry, id) {
|
|
377
|
-
const wanted = holderIdShape(id);
|
|
378
|
-
if (!wanted) return null;
|
|
379
|
-
return load(registry).holders.find((row) => row.id === wanted) || null;
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
function findByFingerprint(registry, value) {
|
|
383
|
-
if (!isFingerprint(value)) return null;
|
|
384
|
-
const wanted = String(value).trim().toLowerCase();
|
|
385
|
-
return load(registry).holders.find((row) => row.fingerprint === wanted) || null;
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
/** Every row on one fingerprint, id-ascending. Usually 0 or 1; 2 is legal. */
|
|
389
|
-
function allByFingerprint(registry, value) {
|
|
390
|
-
if (!isFingerprint(value)) return [];
|
|
391
|
-
const wanted = String(value).trim().toLowerCase();
|
|
392
|
-
return load(registry)
|
|
393
|
-
.holders.filter((row) => row.fingerprint === wanted)
|
|
394
|
-
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
function findByAccount(registry, value) {
|
|
398
|
-
if (typeof value !== 'string' || !value.trim()) return null;
|
|
399
|
-
const wanted = value.trim();
|
|
400
|
-
return load(registry).holders.find((row) => row.accountId === wanted) || null;
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
function hasFingerprint(registry, value) {
|
|
404
|
-
return Boolean(findByFingerprint(registry, value));
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
/**
|
|
408
|
-
* Append a holder row. An invalid row comes back as a warning on an unchanged
|
|
409
|
-
* registry rather than a throw — the same contract as `persona.validate`.
|
|
410
|
-
* The first valid row also becomes `active`, because a registry with one holder
|
|
411
|
-
* and no active holder is a state main would have to guess its way out of.
|
|
412
|
-
*/
|
|
413
|
-
function addHolder(current, holder) {
|
|
414
|
-
const base = validate(current).registry;
|
|
415
|
-
if (base.holders.length >= MAX_HOLDERS) {
|
|
416
|
-
return update(base, {});
|
|
417
|
-
}
|
|
418
|
-
const next = update(base, { holders: base.holders.concat([holder]) });
|
|
419
|
-
if (!next.registry.active && next.registry.holders.length === base.holders.length + 1) {
|
|
420
|
-
return update(next.registry, { active: next.registry.holders[next.registry.holders.length - 1].id });
|
|
421
|
-
}
|
|
422
|
-
return next;
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
function setActive(current, id) {
|
|
426
|
-
const base = validate(current).registry;
|
|
427
|
-
const wanted = holderIdShape(id);
|
|
428
|
-
if (wanted && base.holders.some((row) => row.id === wanted)) return update(base, { active: wanted });
|
|
429
|
-
return validate(base);
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
/** «forget»: drop one row, and re-point `active` if it was that row. */
|
|
433
|
-
function removeHolder(current, id) {
|
|
434
|
-
const base = validate(current).registry;
|
|
435
|
-
const wanted = holderIdShape(id);
|
|
436
|
-
if (!wanted || !base.holders.some((row) => row.id === wanted)) return validate(base);
|
|
437
|
-
const holders = base.holders.filter((row) => row.id !== wanted);
|
|
438
|
-
return update(base, { holders, active: base.active === wanted ? null : base.active });
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
/** Record a sighting. `t` is caller-supplied so this stays clock-free. */
|
|
442
|
-
function touch(current, id, t) {
|
|
443
|
-
const base = validate(current).registry;
|
|
444
|
-
const wanted = holderIdShape(id);
|
|
445
|
-
const when = numOrNull(t);
|
|
446
|
-
if (!wanted || when === null || !base.holders.some((row) => row.id === wanted)) return validate(base);
|
|
447
|
-
const holders = base.holders.map((row) => (
|
|
448
|
-
row.id === wanted ? Object.assign({}, row, { lastSeenAt: Math.max(row.lastSeenAt ?? when, when) }) : row
|
|
449
|
-
));
|
|
450
|
-
return update(base, { holders });
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
// ---------------------------------------------------------------------------
|
|
454
|
-
// resolution
|
|
455
|
-
// ---------------------------------------------------------------------------
|
|
456
|
-
|
|
457
|
-
/** Per-rung confidence: how much the signal actually proves (identity plan §2). */
|
|
458
|
-
const CONFIDENCE = Object.freeze({
|
|
459
|
-
explicit: 1,
|
|
460
|
-
account: 0.9,
|
|
461
|
-
keyFingerprint: 0.6,
|
|
462
|
-
local: 0.4,
|
|
463
|
-
});
|
|
464
|
-
|
|
465
|
-
/**
|
|
466
|
-
* Decide which holder is at the keyboard.
|
|
467
|
-
*
|
|
468
|
-
* @param {object} signals
|
|
469
|
-
* @param {string} [signals.explicit] `AEGIS_AVATAR_HOLDER` or persona `identity.holderId`
|
|
470
|
-
* @param {string} [signals.account] cloud account id, when the server gives one
|
|
471
|
-
* @param {string} [signals.keyFingerprint] output of `fingerprint()` — never a key
|
|
472
|
-
* @param {string} [signals.local] the minted id main keeps in `__avatar` settings
|
|
473
|
-
* @param {string} [signals.osUser] display hint ONLY (rule 3)
|
|
474
|
-
* @param {string} [signals.machineSeed] stable per-machine entropy for `mintHolderId`
|
|
475
|
-
* @param {(s: string) => string|Uint8Array} [signals.hash] the caller's digest
|
|
476
|
-
* @param {object} [registry] parsed (or unparsed) `holders.json`
|
|
477
|
-
* @returns {{ holderId: string|null, source: string|null, confidence: number,
|
|
478
|
-
* displayName: string, conflict: {a: string, b: string, reason: string}|null,
|
|
479
|
-
* known: boolean, needsMint: boolean, proposal: object|null, warnings: string[] }}
|
|
480
|
-
* `conflict.a` is what you would be switching away from (the registry's
|
|
481
|
-
* active holder, or the losing signal's holder) and `conflict.b` is the
|
|
482
|
-
* proposal. Returned so main can show a "who is at the keyboard?" card
|
|
483
|
-
* instead of switching silently.
|
|
484
|
-
*/
|
|
485
|
-
function resolve(signals, registry) {
|
|
486
|
-
const raw = signals && typeof signals === 'object' ? signals : {};
|
|
487
|
-
const reg = load(registry);
|
|
488
|
-
const active = reg.active;
|
|
489
|
-
const warnings = [];
|
|
490
|
-
|
|
491
|
-
// --- signal intake: every rung is shape-checked before it is believed ----
|
|
492
|
-
const explicit = holderIdShape(raw.explicit);
|
|
493
|
-
if (raw.explicit !== undefined && raw.explicit !== null && raw.explicit !== '' && !explicit) {
|
|
494
|
-
warnings.push('explicit holder id ignored: expected h_ plus 8-16 hex');
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
let account = null;
|
|
498
|
-
if (raw.account !== undefined && raw.account !== null && raw.account !== '') {
|
|
499
|
-
const candidate = typeof raw.account === 'string' ? raw.account.trim() : '';
|
|
500
|
-
if (candidate && ACCOUNT_ID_RE.test(candidate) && !looksLikeRawKey(candidate)) account = candidate;
|
|
501
|
-
else warnings.push('account signal ignored: rejected by shape');
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
let keyFingerprint = null;
|
|
505
|
-
if (raw.keyFingerprint !== undefined && raw.keyFingerprint !== null && raw.keyFingerprint !== '') {
|
|
506
|
-
// Rule 2, the load-bearing one: an already-computed fingerprint or nothing.
|
|
507
|
-
if (isFingerprint(raw.keyFingerprint)) keyFingerprint = String(raw.keyFingerprint).trim().toLowerCase();
|
|
508
|
-
else warnings.push('keyFingerprint ignored: expected the 12-hex output of fingerprint(), not a key or a full digest');
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
const local = holderIdShape(raw.local);
|
|
512
|
-
if (raw.local !== undefined && raw.local !== null && raw.local !== '' && !local) {
|
|
513
|
-
warnings.push('local holder id ignored: expected h_ plus 8-16 hex');
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
// Rule 3: the OS user name reaches `displayName` and nothing else.
|
|
517
|
-
const hint = cleanDisplay(raw.osUser, null);
|
|
518
|
-
const hash = typeof raw.hash === 'function' ? raw.hash : null;
|
|
519
|
-
const seed = typeof raw.machineSeed === 'string' && raw.machineSeed.trim() ? raw.machineSeed : null;
|
|
520
|
-
|
|
521
|
-
function finish(parts) {
|
|
522
|
-
const holderId = parts.holderId || null;
|
|
523
|
-
const row = holderId ? reg.holders.find((r) => r.id === holderId) || null : null;
|
|
524
|
-
// A fingerprint that matches no registry row proves nothing about who this
|
|
525
|
-
// is, so it can never *select* a holder. It can only be proposed for
|
|
526
|
-
// binding onto the holder that WAS resolved, which main writes only after
|
|
527
|
-
// the user confirms — this module never writes anything. Built
|
|
528
|
-
// here rather than in the last rung so every rung reports it identically:
|
|
529
|
-
// an explicit persona import with a freshly rotated key is the same
|
|
530
|
-
// question as a fresh install with one.
|
|
531
|
-
const proposal = holderId && keyFingerprint && !hasFingerprint(reg, keyFingerprint)
|
|
532
|
-
? { kind: 'bind-fingerprint', holderId, fingerprint: keyFingerprint }
|
|
533
|
-
: null;
|
|
534
|
-
return {
|
|
535
|
-
holderId,
|
|
536
|
-
source: holderId ? parts.source : null,
|
|
537
|
-
confidence: holderId ? parts.confidence : 0,
|
|
538
|
-
displayName: row ? row.displayName : hint || DEFAULT_DISPLAY_NAME,
|
|
539
|
-
conflict: parts.conflict || null,
|
|
540
|
-
known: Boolean(row),
|
|
541
|
-
needsMint: Boolean(parts.needsMint),
|
|
542
|
-
proposal,
|
|
543
|
-
warnings,
|
|
544
|
-
};
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
// --- rung 1: deliberate beats inferred --------------------------------
|
|
548
|
-
if (explicit) {
|
|
549
|
-
return finish({ holderId: explicit, source: 'explicit', confidence: CONFIDENCE.explicit });
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
const byAccount = account ? findByAccount(reg, account) : null;
|
|
553
|
-
const fpRows = keyFingerprint ? allByFingerprint(reg, keyFingerprint) : [];
|
|
554
|
-
const byFingerprint = fpRows.length ? (fpRows.find((row) => row.id === active) || fpRows[0]) : null;
|
|
555
|
-
|
|
556
|
-
// --- rung 2: account (survives key rotation) ---------------------------
|
|
557
|
-
if (byAccount && byFingerprint && byAccount.id !== byFingerprint.id) {
|
|
558
|
-
// Both strong signals landed on existing holders, and they disagree. The
|
|
559
|
-
// account is the stronger claim; the disagreement is reported, not hidden.
|
|
560
|
-
return finish({
|
|
561
|
-
holderId: byAccount.id,
|
|
562
|
-
source: 'account',
|
|
563
|
-
confidence: CONFIDENCE.account,
|
|
564
|
-
conflict: { a: byFingerprint.id, b: byAccount.id, reason: 'account-vs-fingerprint' },
|
|
565
|
-
});
|
|
566
|
-
}
|
|
567
|
-
if (byFingerprint && fpRows.length > 1) {
|
|
568
|
-
// One credential, two rows: legal (a shared BYOK key) and never merged.
|
|
569
|
-
const chosen = fpRows.find((row) => row.id === active) || fpRows[0];
|
|
570
|
-
const other = fpRows.find((row) => row.id !== chosen.id);
|
|
571
|
-
return finish({
|
|
572
|
-
holderId: chosen.id,
|
|
573
|
-
source: 'keyFingerprint',
|
|
574
|
-
confidence: CONFIDENCE.keyFingerprint,
|
|
575
|
-
conflict: { a: other.id, b: chosen.id, reason: 'duplicate-fingerprint' },
|
|
576
|
-
});
|
|
577
|
-
}
|
|
578
|
-
if (byAccount) {
|
|
579
|
-
return finish({
|
|
580
|
-
holderId: byAccount.id,
|
|
581
|
-
source: 'account',
|
|
582
|
-
confidence: CONFIDENCE.account,
|
|
583
|
-
conflict: active && active !== byAccount.id ? { a: active, b: byAccount.id, reason: 'account' } : null,
|
|
584
|
-
});
|
|
585
|
-
}
|
|
586
|
-
if (byFingerprint) {
|
|
587
|
-
return finish({
|
|
588
|
-
holderId: byFingerprint.id,
|
|
589
|
-
source: 'keyFingerprint',
|
|
590
|
-
confidence: CONFIDENCE.keyFingerprint,
|
|
591
|
-
// A fingerprint match PROPOSES a rebind. Both rows survive; main asks.
|
|
592
|
-
conflict: active && active !== byFingerprint.id ? { a: active, b: byFingerprint.id, reason: 'key-fingerprint' } : null,
|
|
593
|
-
});
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
// --- rung 4: local ------------------------------------------------------
|
|
597
|
-
let holderId = local || holderIdShape(active);
|
|
598
|
-
let needsMint = false;
|
|
599
|
-
if (!holderId) {
|
|
600
|
-
holderId = hash && seed ? mintHolderId(hash, seed) : null;
|
|
601
|
-
if (!holderId) {
|
|
602
|
-
needsMint = true;
|
|
603
|
-
warnings.push('no holder signal is available — main must mint a local holder id');
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
// A fingerprint that matches no row cannot select a holder here; `finish()`
|
|
608
|
-
// turns it into a bind proposal for whichever holder did resolve.
|
|
609
|
-
return finish({ holderId, source: 'local', confidence: CONFIDENCE.local, needsMint });
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
module.exports = {
|
|
613
|
-
SCHEMA_VERSION,
|
|
614
|
-
FINGERPRINT_LENGTH,
|
|
615
|
-
MAX_HOLDERS,
|
|
616
|
-
MAX_DISPLAY_NAME,
|
|
617
|
-
DEFAULT_DISPLAY_NAME,
|
|
618
|
-
CONFIDENCE,
|
|
619
|
-
HOLDER_ID_RE,
|
|
620
|
-
HOLDER_ID_SHAPE,
|
|
621
|
-
FINGERPRINT_RE,
|
|
622
|
-
fingerprint,
|
|
623
|
-
mintHolderId,
|
|
624
|
-
isFingerprint,
|
|
625
|
-
looksLikeRawKey,
|
|
626
|
-
safeLabel,
|
|
627
|
-
cleanDisplay,
|
|
628
|
-
emptyRegistry,
|
|
629
|
-
migrate,
|
|
630
|
-
validate,
|
|
631
|
-
load,
|
|
632
|
-
update,
|
|
633
|
-
findHolder,
|
|
634
|
-
findByFingerprint,
|
|
635
|
-
allByFingerprint,
|
|
636
|
-
findByAccount,
|
|
637
|
-
hasFingerprint,
|
|
638
|
-
addHolder,
|
|
639
|
-
setActive,
|
|
640
|
-
removeHolder,
|
|
641
|
-
touch,
|
|
642
|
-
resolve,
|
|
643
|
-
};
|
|
3
|
+
"use strict";const persona=require("./persona.js"),SCHEMA_VERSION=1,FINGERPRINT_LENGTH=12,MAX_HOLDERS=32,MAX_DISPLAY_NAME=40,MAX_ACCOUNT_ID=64,HOLDER_ID_RE=/^h_[0-9a-f]{12}$/,HOLDER_ID_SHAPE=/^h_[0-9a-f]{8,16}$/,FINGERPRINT_RE=/^[0-9a-f]{12}$/,ACCOUNT_ID_RE=/^[A-Za-z0-9][A-Za-z0-9._:-]{3,63}$/,KEY_SHAPE=/^(?:sk|pk|gsk|xox[baprs]|ghp|glpat|aegis)[-_][A-Za-z0-9_-]{6,}/i,LONG_HEX=/[0-9a-f]{20,}/i,BEARER=/^Bearer\s+\S+/i,DEFAULT_DISPLAY_NAME="You";function toHex(e){if("string"==typeof e){const r=e.trim().toLowerCase().replace(/\s+/g,"");return/^[0-9a-f]+$/.test(r)?r:null}if(e instanceof Uint8Array||Array.isArray(e)){let r="";for(const t of e){const e=Number(t);if(!Number.isInteger(e)||e<0||e>255)return null;r+=e.toString(16).padStart(2,"0")}return r||null}return null}function fingerprint(e,r){if("function"!=typeof e)return null;if("string"!=typeof r)return null;const t=r.trim();if(!t)return null;let n;try{n=e(t)}catch{return null}const i=toHex(n);return!i||i.length<FINGERPRINT_LENGTH?null:i.slice(0,FINGERPRINT_LENGTH)}function isFingerprint(e){return"string"==typeof e&&FINGERPRINT_RE.test(e.trim().toLowerCase())}function looksLikeRawKey(e){if("string"!=typeof e)return!1;const r=e.trim();return!!r&&(KEY_SHAPE.test(r)||BEARER.test(r)||LONG_HEX.test(r))}function mintHolderId(e,r){if("function"!=typeof e)return null;if("string"!=typeof r||!r.trim())return null;let t;try{t=e(r.trim())}catch{return null}const n=toHex(t);if(!n||n.length<FINGERPRINT_LENGTH)return null;const i=`h_${n.slice(0,FINGERPRINT_LENGTH)}`;return HOLDER_ID_RE.test(i)?i:null}function cleanDisplay(e,r=DEFAULT_DISPLAY_NAME){return"string"!=typeof e||looksLikeRawKey(e)?r:persona.cleanText(e,MAX_DISPLAY_NAME)||r}function numOrNull(e){return"number"==typeof e&&Number.isFinite(e)?e:null}function safeLabel(e){if("string"!=typeof e||!e.trim())return"(empty)";const r=e.trim();return r.length>24||looksLikeRawKey(r)?"(withheld)":r}function holderIdShape(e){if("string"!=typeof e)return null;const r=e.trim().toLowerCase();return HOLDER_ID_SHAPE.test(r)?r:null}function emptyRegistry(){return{schema:SCHEMA_VERSION,active:null,holders:[]}}const MIGRATIONS=Object.freeze({0:e=>Object.assign({},e)});function migrate(e){if(!e||"object"!=typeof e)return{registry:emptyRegistry(),migrated:!1,fromSchema:0};let r=e,t=Number.isInteger(r.schema)?r.schema:0,n=!1;for(;t<SCHEMA_VERSION;){const e=MIGRATIONS[t];r=e?e(r):r,t+=1,n=!0}return{registry:r,migrated:n,fromSchema:Number.isInteger(e.schema)?e.schema:0}}function validate(e){const r=[],t=[];if(null==e)return{ok:!0,registry:emptyRegistry(),errors:r,warnings:["no holders registry yet — starting empty"],migrated:!1};if("object"!=typeof e||Array.isArray(e))return{ok:!1,registry:emptyRegistry(),errors:["holders.json must be a JSON object"],warnings:t,migrated:!1};if(Number.isInteger(e.schema)&&e.schema>SCHEMA_VERSION)return{ok:!1,registry:emptyRegistry(),errors:[`holders schema ${e.schema} is newer than this build supports (${SCHEMA_VERSION})`],warnings:t,migrated:!1};const{registry:n,migrated:i}=migrate(e),o=n,l=[],s=new Set,a=Array.isArray(o.holders)?o.holders:[];void 0===o.holders||Array.isArray(o.holders)||t.push("holders must be an array — ignored");for(const e of a){if(l.length>=MAX_HOLDERS){t.push(`at most ${MAX_HOLDERS} holders — extra rows dropped`);break}if(!e||"object"!=typeof e||Array.isArray(e)){t.push("a holder row was not an object — dropped");continue}const r=holderIdShape(e.id);if(!r){t.push(`holder id ${safeLabel(e.id)} is not h_ plus 8-16 hex — row dropped`);continue}if(s.has(r)){t.push(`duplicate holder id ${r} — the later row was dropped`);continue}s.add(r);let n="string"==typeof e.fingerprint?e.fingerprint.trim().toLowerCase():null;n&&!FINGERPRINT_RE.test(n)&&(t.push(`holder ${r} fingerprint is not ${FINGERPRINT_LENGTH} lowercase hex — dropped from the row`),n=null);let i=null;if(void 0!==e.accountId&&null!==e.accountId){const n="string"==typeof e.accountId?e.accountId.trim():"";n&&ACCOUNT_ID_RE.test(n)&&!looksLikeRawKey(n)?i=n:t.push(`holder ${r} accountId was rejected by shape`)}l.push({id:r,displayName:cleanDisplay(e.displayName,r),fingerprint:n,accountId:i,personaName:cleanDisplay(e.personaName,null),createdAt:numOrNull(e.createdAt),lastSeenAt:numOrNull(e.lastSeenAt)})}const d=new Map;for(const e of l)e.fingerprint&&(d.has(e.fingerprint)?t.push(`holders ${d.get(e.fingerprint)} and ${e.id} share one key fingerprint — resolve() proposes, never merges`):d.set(e.fingerprint,e.id));let c="string"==typeof o.active?holderIdShape(o.active):null;void 0===o.active||null===o.active||""===o.active||c||t.push(`active ${safeLabel(o.active)} is not a holder id — repaired`),c&&!l.some((e=>e.id===c))&&(t.push(`active holder ${c} is not in the registry — repaired`),c=null),!c&&l.length&&(c=l[0].id);for(const e of Object.keys(o))["schema","active","holders"].includes(e)||t.push(`unknown registry key ${safeLabel(e)} — ignored`);return{ok:!0,errors:r,warnings:t,migrated:i,registry:{schema:SCHEMA_VERSION,active:c,holders:l}}}function load(e){return validate(e).registry}function update(e,r){const t=validate(e).registry;return validate(r&&"object"==typeof r?{schema:SCHEMA_VERSION,active:void 0!==r.active?r.active:t.active,holders:void 0!==r.holders?r.holders:t.holders}:t)}function findHolder(e,r){const t=holderIdShape(r);return t&&load(e).holders.find((e=>e.id===t))||null}function findByFingerprint(e,r){if(!isFingerprint(r))return null;const t=String(r).trim().toLowerCase();return load(e).holders.find((e=>e.fingerprint===t))||null}function allByFingerprint(e,r){if(!isFingerprint(r))return[];const t=String(r).trim().toLowerCase();return load(e).holders.filter((e=>e.fingerprint===t)).sort(((e,r)=>e.id<r.id?-1:e.id>r.id?1:0))}function findByAccount(e,r){if("string"!=typeof r||!r.trim())return null;const t=r.trim();return load(e).holders.find((e=>e.accountId===t))||null}function hasFingerprint(e,r){return Boolean(findByFingerprint(e,r))}function addHolder(e,r){const t=validate(e).registry;if(t.holders.length>=MAX_HOLDERS)return update(t,{});const n=update(t,{holders:t.holders.concat([r])});return n.registry.active||n.registry.holders.length!==t.holders.length+1?n:update(n.registry,{active:n.registry.holders[n.registry.holders.length-1].id})}function setActive(e,r){const t=validate(e).registry,n=holderIdShape(r);return n&&t.holders.some((e=>e.id===n))?update(t,{active:n}):validate(t)}function removeHolder(e,r){const t=validate(e).registry,n=holderIdShape(r);if(!n||!t.holders.some((e=>e.id===n)))return validate(t);const i=t.holders.filter((e=>e.id!==n));return update(t,{holders:i,active:t.active===n?null:t.active})}function touch(e,r,t){const n=validate(e).registry,i=holderIdShape(r),o=numOrNull(t);if(!i||null===o||!n.holders.some((e=>e.id===i)))return validate(n);const l=n.holders.map((e=>e.id===i?Object.assign({},e,{lastSeenAt:Math.max(e.lastSeenAt??o,o)}):e));return update(n,{holders:l})}const CONFIDENCE=Object.freeze({explicit:1,account:.9,keyFingerprint:.6,local:.4});function resolve(e,r){const t=e&&"object"==typeof e?e:{},n=load(r),i=n.active,o=[],l=holderIdShape(t.explicit);void 0===t.explicit||null===t.explicit||""===t.explicit||l||o.push("explicit holder id ignored: expected h_ plus 8-16 hex");let s=null;if(void 0!==t.account&&null!==t.account&&""!==t.account){const e="string"==typeof t.account?t.account.trim():"";e&&ACCOUNT_ID_RE.test(e)&&!looksLikeRawKey(e)?s=e:o.push("account signal ignored: rejected by shape")}let a=null;void 0!==t.keyFingerprint&&null!==t.keyFingerprint&&""!==t.keyFingerprint&&(isFingerprint(t.keyFingerprint)?a=String(t.keyFingerprint).trim().toLowerCase():o.push("keyFingerprint ignored: expected the 12-hex output of fingerprint(), not a key or a full digest"));const d=holderIdShape(t.local);void 0===t.local||null===t.local||""===t.local||d||o.push("local holder id ignored: expected h_ plus 8-16 hex");const c=cleanDisplay(t.osUser,null),u="function"==typeof t.hash?t.hash:null,p="string"==typeof t.machineSeed&&t.machineSeed.trim()?t.machineSeed:null;function f(e){const r=e.holderId||null,t=r&&n.holders.find((e=>e.id===r))||null,i=r&&a&&!hasFingerprint(n,a)?{kind:"bind-fingerprint",holderId:r,fingerprint:a}:null;return{holderId:r,source:r?e.source:null,confidence:r?e.confidence:0,displayName:t?t.displayName:c||DEFAULT_DISPLAY_NAME,conflict:e.conflict||null,known:Boolean(t),needsMint:Boolean(e.needsMint),proposal:i,warnings:o}}if(l)return f({holderId:l,source:"explicit",confidence:CONFIDENCE.explicit});const h=s?findByAccount(n,s):null,g=a?allByFingerprint(n,a):[],y=g.length?g.find((e=>e.id===i))||g[0]:null;if(h&&y&&h.id!==y.id)return f({holderId:h.id,source:"account",confidence:CONFIDENCE.account,conflict:{a:y.id,b:h.id,reason:"account-vs-fingerprint"}});if(y&&g.length>1){const e=g.find((e=>e.id===i))||g[0],r=g.find((r=>r.id!==e.id));return f({holderId:e.id,source:"keyFingerprint",confidence:CONFIDENCE.keyFingerprint,conflict:{a:r.id,b:e.id,reason:"duplicate-fingerprint"}})}if(h)return f({holderId:h.id,source:"account",confidence:CONFIDENCE.account,conflict:i&&i!==h.id?{a:i,b:h.id,reason:"account"}:null});if(y)return f({holderId:y.id,source:"keyFingerprint",confidence:CONFIDENCE.keyFingerprint,conflict:i&&i!==y.id?{a:i,b:y.id,reason:"key-fingerprint"}:null});let E=d||holderIdShape(i),I=!1;return E||(E=u&&p?mintHolderId(u,p):null,E||(I=!0,o.push("no holder signal is available — main must mint a local holder id"))),f({holderId:E,source:"local",confidence:CONFIDENCE.local,needsMint:I})}module.exports={SCHEMA_VERSION:SCHEMA_VERSION,FINGERPRINT_LENGTH:FINGERPRINT_LENGTH,MAX_HOLDERS:MAX_HOLDERS,MAX_DISPLAY_NAME:MAX_DISPLAY_NAME,DEFAULT_DISPLAY_NAME:DEFAULT_DISPLAY_NAME,CONFIDENCE:CONFIDENCE,HOLDER_ID_RE:HOLDER_ID_RE,HOLDER_ID_SHAPE:HOLDER_ID_SHAPE,FINGERPRINT_RE:FINGERPRINT_RE,fingerprint:fingerprint,mintHolderId:mintHolderId,isFingerprint:isFingerprint,looksLikeRawKey:looksLikeRawKey,safeLabel:safeLabel,cleanDisplay:cleanDisplay,emptyRegistry:emptyRegistry,migrate:migrate,validate:validate,load:load,update:update,findHolder:findHolder,findByFingerprint:findByFingerprint,allByFingerprint:allByFingerprint,findByAccount:findByAccount,hasFingerprint:hasFingerprint,addHolder:addHolder,setActive:setActive,removeHolder:removeHolder,touch:touch,resolve:resolve};
|