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/cosmetics.js
CHANGED
|
@@ -1,534 +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
|
-
* cosmetics.js — the cosmetics pack format and its validator.
|
|
10
|
-
*
|
|
11
|
-
* WHY A FORMAT AT ALL. Spec §3 says the avatar is *data, not art*: a persona is
|
|
12
|
-
* one JSON record and a look is a layered SVG assembled from a part manifest, so
|
|
13
|
-
* "adding a seasonal set" is adding a file. `lib/avatar/packs/*.pack.json` is
|
|
14
|
-
* that file's shape. A pack is deliberately DUMB — ids, labels and the parts that
|
|
15
|
-
* compose a look — because a pack the user can hand-edit is a pack that can be
|
|
16
|
-
* checked, and a pack that could do something is a pack that could be a bug.
|
|
17
|
-
*
|
|
18
|
-
* THE PAYWALL LINE IS A VALIDATOR RULE, NOT A PROMISE (spec §3, §6, invariant 8:
|
|
19
|
-
* *sell cosmetics only — never XP, levels, recall breadth, approval friction or
|
|
20
|
-
* personalization*). `level.js` owns the unlock table; a pack never declares its
|
|
21
|
-
* own price or its own entitlement, it only *references* an unlock id that must
|
|
22
|
-
* already exist there, and `validatePack` refuses the pack unless the unlock's
|
|
23
|
-
* `paid` flag agrees with the pack's tier and its namespace agrees with the
|
|
24
|
-
* pack's kind. `auditPacks` then sweeps the whole pack for anything that smells
|
|
25
|
-
* like capability — `xp`, `recallEntries`, `approvals`, `toolGrants`,
|
|
26
|
-
* `modelFloor`, `price`, `entitlement`, … — recursively, at any depth, under any
|
|
27
|
-
* casing. So a future "paid pack that also raises your recall limit" is not a
|
|
28
|
-
* policy violation somebody has to notice in review: it is a pack that fails to
|
|
29
|
-
* load. That is the same trick `level.js` uses for the approval carve-outs (make
|
|
30
|
-
* the wrong thing unrepresentable) applied to the thing the product sells.
|
|
31
|
-
*
|
|
32
|
-
* Validation is REPAIR-ORIENTED like `persona.js`, with the difference that the
|
|
33
|
-
* hard errors are the point: a pack is an artifact, not the user's own file, so
|
|
34
|
-
* an unrepairable pack is dropped (`ok: false`, `pack: null`) rather than coerced
|
|
35
|
-
* into something that ships. Unknown *item* ids are the one soft case — a pack
|
|
36
|
-
* from a newer build should load with a warning and render the parts it does
|
|
37
|
-
* know, rather than blanking a look the user paid for.
|
|
38
|
-
*
|
|
39
|
-
* Pure apart from `loadPacks`/`shippedPacks`, which read the packs directory and
|
|
40
|
-
* are the only reason `node:fs` is imported at all. No Electron, no settings.
|
|
41
|
-
*/
|
|
42
|
-
|
|
43
|
-
const fs = require('node:fs');
|
|
44
|
-
const path = require('node:path');
|
|
45
|
-
const personaModule = require('./persona.js');
|
|
46
|
-
const level = require('./level.js');
|
|
47
|
-
|
|
48
|
-
/** Bump when the record shape changes. A newer pack is reported, not guessed at. */
|
|
49
|
-
const SCHEMA_VERSION = 1;
|
|
50
|
-
|
|
51
|
-
/** What a pack may be *about*. One pack, one kind — a mixed pack cannot be gated. */
|
|
52
|
-
const KINDS = Object.freeze(['outfit', 'expressions', 'palette', 'voice']);
|
|
53
|
-
|
|
54
|
-
/** Free or paid. `paid` is a claim ABOUT the unlock table, verified below. */
|
|
55
|
-
const TIERS = Object.freeze(['free', 'paid']);
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Pack kind → the unlock-id namespace it is allowed to reference. `expressions`
|
|
59
|
-
* is plural in the persona and singular in the unlock table (`expression.core`);
|
|
60
|
-
* that mismatch is exactly why this table exists rather than a `startsWith`.
|
|
61
|
-
*/
|
|
62
|
-
const KIND_UNLOCK_PREFIX = Object.freeze({
|
|
63
|
-
outfit: 'outfit',
|
|
64
|
-
expressions: 'expression',
|
|
65
|
-
palette: 'palette',
|
|
66
|
-
voice: 'voice',
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
/** Kind → the `persona.PARTS` list an item id must be drawn from. */
|
|
70
|
-
const KIND_PART_LIST = Object.freeze({
|
|
71
|
-
outfit: 'outfit',
|
|
72
|
-
expressions: 'expressionPack',
|
|
73
|
-
palette: 'palette',
|
|
74
|
-
voice: null, // voices are not persona parts — see voice.js / BUILTIN_VOICES
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Persona ids and unlock ids are not the same vocabulary — a seasonal SET is
|
|
79
|
-
* `seasonal-winter` in the persona and `outfit.seasonal` in the level table.
|
|
80
|
-
* This is the canonical alias table; `renderer/avatar/pane.js` carries the same
|
|
81
|
-
* pair for the gating lookup and `test/avatar-cosmetics.test.mjs` asserts the
|
|
82
|
-
* two agree, so the tables cannot drift apart silently.
|
|
83
|
-
*/
|
|
84
|
-
const UNLOCK_ALIASES = Object.freeze({
|
|
85
|
-
'outfit.seasonal-winter': 'outfit.seasonal',
|
|
86
|
-
'outfit.archive-coat': 'outfit.archive',
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Keys a pack may NEVER carry, matched case-insensitively and at ANY depth.
|
|
91
|
-
* Every entry here is a field some other module reads to decide what the user
|
|
92
|
-
* is allowed to do: `capabilities()` in level.js, the tool gate, the model
|
|
93
|
-
* picker, the recall path — plus the money fields, because pricing and
|
|
94
|
-
* entitlement live in the store, never in an artifact the user can edit.
|
|
95
|
-
*/
|
|
96
|
-
const FORBIDDEN_KEYS = Object.freeze([
|
|
97
|
-
'xp', 'xpbonus', 'xpmultiplier', 'level', 'levels', 'unlocklevel', 'minlevel', 'maxlevel',
|
|
98
|
-
'recall', 'recallentries', 'recalltokens', 'memory', 'memorywriteback',
|
|
99
|
-
'approval', 'approvals', 'autoapprove', 'autoapproveclasses', 'approvalfriction',
|
|
100
|
-
'grant', 'grants', 'toolgrant', 'toolgrants', 'capability', 'capabilities',
|
|
101
|
-
'offdevice', 'offdevicegrants', 'modelfloor', 'proactivity', 'queuedrain',
|
|
102
|
-
'importassistant', 'tokens', 'budget', 'pricetokens',
|
|
103
|
-
'price', 'prices', 'amount', 'currency', 'cost', 'sku', 'entitlement', 'entitlements',
|
|
104
|
-
'checkout', 'purchase', 'subscription', 'trial',
|
|
105
|
-
// the clone vectors (non-goal: "no voice cloning of real people")
|
|
106
|
-
'sample', 'samples', 'sampleurl', 'recording', 'recordingurl', 'sourceaudio', 'voiceprint',
|
|
107
|
-
]);
|
|
108
|
-
|
|
109
|
-
/** Second net: any key we did not name but that reads like a capability knob. */
|
|
110
|
-
const FORBIDDEN_KEY_PATTERN = /(xp|level|recall|approval|grant|capabilit|offdevice|proactiv|entitlement|price|clone|voiceprint)/i;
|
|
111
|
-
|
|
112
|
-
/** Structural caps. A pack is a cosmetics artifact, not a distribution channel. */
|
|
113
|
-
const LIMITS = Object.freeze({
|
|
114
|
-
id: 48,
|
|
115
|
-
name: 60,
|
|
116
|
-
label: 48,
|
|
117
|
-
summary: 240,
|
|
118
|
-
author: 60,
|
|
119
|
-
license: 40,
|
|
120
|
-
unlocks: 8,
|
|
121
|
-
items: 24,
|
|
122
|
-
parts: 6,
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
/** Only files matching this suffix are loaded — a stray README.json is not a pack. */
|
|
126
|
-
const PACK_SUFFIX = '.pack.json';
|
|
127
|
-
|
|
128
|
-
const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
129
|
-
|
|
130
|
-
function isPlainObject(value) {
|
|
131
|
-
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/** The unlock row behind an id, or null. The level table is the only authority. */
|
|
135
|
-
function unlockById(id) {
|
|
136
|
-
return level.UNLOCKS.find((u) => u.id === id) || null;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/** `outfit.seasonal-winter` → `outfit.seasonal` for the ones that differ. */
|
|
140
|
-
function unlockIdFor(kind, itemId) {
|
|
141
|
-
return UNLOCK_ALIASES[`${kind}.${itemId}`] || `${kind}.${itemId}`;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Every forbidden key in a pack-shaped value, at any depth.
|
|
146
|
-
*
|
|
147
|
-
* @param {unknown} node
|
|
148
|
-
* @param {string} [at] path prefix, for the error message
|
|
149
|
-
* @returns {string[]} dotted paths, e.g. `["items.2.grants"]`
|
|
150
|
-
*/
|
|
151
|
-
function forbiddenKeys(node, at = '') {
|
|
152
|
-
const hits = [];
|
|
153
|
-
if (Array.isArray(node)) {
|
|
154
|
-
node.forEach((child, i) => hits.push(...forbiddenKeys(child, `${at}${at ? '.' : ''}${i}`)));
|
|
155
|
-
return hits;
|
|
156
|
-
}
|
|
157
|
-
if (!isPlainObject(node)) return hits;
|
|
158
|
-
for (const [key, value] of Object.entries(node)) {
|
|
159
|
-
const here = `${at}${at ? '.' : ''}${key}`;
|
|
160
|
-
const flat = key.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
161
|
-
if (FORBIDDEN_KEYS.includes(flat) || FORBIDDEN_KEY_PATTERN.test(key)) hits.push(here);
|
|
162
|
-
hits.push(...forbiddenKeys(value, here));
|
|
163
|
-
}
|
|
164
|
-
return hits;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
function clean(value, max) {
|
|
168
|
-
if (typeof value !== 'string') return '';
|
|
169
|
-
// Same treatment as persona text: a pack label lands in a UI (and, for an
|
|
170
|
-
// expression pack, eventually in a tooltip), so it stays one clean line.
|
|
171
|
-
return value.replace(/[\u0000-\u001f\u007f\u2028\u2029]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* Validate one pack.
|
|
176
|
-
*
|
|
177
|
-
* @param {unknown} raw parsed JSON (or anything hand-written)
|
|
178
|
-
* @returns {{ ok: boolean, pack: object|null, errors: string[], warnings: string[],
|
|
179
|
-
* forbidden: string[] }}
|
|
180
|
-
* `ok: false` means DO NOT ship it: `pack` is null and `errors` says why. The
|
|
181
|
-
* split between errors and warnings is deliberate — an unknown *item id* is a
|
|
182
|
-
* warning (a newer build's part renders as a label with no art), while
|
|
183
|
-
* anything touching the paywall line is an error.
|
|
184
|
-
*/
|
|
185
|
-
function validatePack(raw) {
|
|
186
|
-
const errors = [];
|
|
187
|
-
const warnings = [];
|
|
188
|
-
|
|
189
|
-
if (!isPlainObject(raw)) {
|
|
190
|
-
return {
|
|
191
|
-
ok: false,
|
|
192
|
-
pack: null,
|
|
193
|
-
errors: ['cosmetics pack must be a JSON object'],
|
|
194
|
-
warnings,
|
|
195
|
-
forbidden: [],
|
|
196
|
-
};
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// --- the line, checked before anything structural ------------------------
|
|
200
|
-
const forbidden = forbiddenKeys(raw);
|
|
201
|
-
for (const where of forbidden) {
|
|
202
|
-
errors.push(
|
|
203
|
-
`cosmetics packs may not carry \`${where}\` — a pack sells LOOKS only, never XP, ` +
|
|
204
|
-
'levels, recall breadth, approval friction or personalization',
|
|
205
|
-
);
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
if (Number.isInteger(raw.schema) && raw.schema > SCHEMA_VERSION) {
|
|
209
|
-
errors.push(`pack schema ${raw.schema} is newer than this build supports (${SCHEMA_VERSION})`);
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// --- identity -----------------------------------------------------------
|
|
213
|
-
const id = clean(raw.id, LIMITS.id);
|
|
214
|
-
if (!id || !SLUG.test(id)) {
|
|
215
|
-
errors.push(`pack id must be a kebab-case slug (got ${JSON.stringify(raw.id)})`);
|
|
216
|
-
}
|
|
217
|
-
const name = clean(raw.name, LIMITS.name);
|
|
218
|
-
if (!name) errors.push('pack needs a name');
|
|
219
|
-
|
|
220
|
-
const kind = KINDS.includes(raw.kind) ? raw.kind : null;
|
|
221
|
-
if (!kind) errors.push(`pack kind must be one of ${KINDS.join(' | ')} (got ${JSON.stringify(raw.kind)})`);
|
|
222
|
-
|
|
223
|
-
const tier = TIERS.includes(raw.tier) ? raw.tier : null;
|
|
224
|
-
if (!tier) errors.push(`pack tier must be "free" or "paid" (got ${JSON.stringify(raw.tier)})`);
|
|
225
|
-
|
|
226
|
-
// --- unlocks: the only thing that makes a pack free or paid --------------
|
|
227
|
-
const rawUnlocks = Array.isArray(raw.unlocks) ? raw.unlocks : [];
|
|
228
|
-
const unlocks = [];
|
|
229
|
-
if (!rawUnlocks.length) {
|
|
230
|
-
errors.push('pack must reference at least one unlock id from lib/avatar/level.js (a pack cannot create one)');
|
|
231
|
-
}
|
|
232
|
-
if (rawUnlocks.length > LIMITS.unlocks) {
|
|
233
|
-
errors.push(`at most ${LIMITS.unlocks} unlocks per pack (got ${rawUnlocks.length})`);
|
|
234
|
-
}
|
|
235
|
-
for (const rawUnlock of rawUnlocks.slice(0, LIMITS.unlocks)) {
|
|
236
|
-
const unlock = unlockById(rawUnlock);
|
|
237
|
-
if (!unlock) {
|
|
238
|
-
errors.push(
|
|
239
|
-
`unknown unlock ${JSON.stringify(rawUnlock)} — the level table (level.js UNLOCKS) is the only ` +
|
|
240
|
-
'authority on what exists and what is paid',
|
|
241
|
-
);
|
|
242
|
-
continue;
|
|
243
|
-
}
|
|
244
|
-
if (kind && unlock.id.split('.')[0] !== KIND_UNLOCK_PREFIX[kind]) {
|
|
245
|
-
errors.push(`${unlock.id} is not a ${kind} unlock — a pack may only gate its own kind`);
|
|
246
|
-
}
|
|
247
|
-
if (tier && unlock.paid !== (tier === 'paid')) {
|
|
248
|
-
errors.push(
|
|
249
|
-
`tier "${tier}" contradicts unlock ${unlock.id} (paid: ${unlock.paid}) — ` +
|
|
250
|
-
'the level table decides what is paid, not the pack',
|
|
251
|
-
);
|
|
252
|
-
}
|
|
253
|
-
if (!unlocks.includes(unlock.id)) unlocks.push(unlock.id);
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
// --- items --------------------------------------------------------------
|
|
257
|
-
const partListName = kind ? KIND_PART_LIST[kind] : null;
|
|
258
|
-
const partList = partListName ? personaModule.PARTS[partListName] : null;
|
|
259
|
-
const rawItems = Array.isArray(raw.items) ? raw.items : [];
|
|
260
|
-
if (!rawItems.length) errors.push('pack needs at least one item');
|
|
261
|
-
if (rawItems.length > LIMITS.items) errors.push(`at most ${LIMITS.items} items per pack (got ${rawItems.length})`);
|
|
262
|
-
|
|
263
|
-
const items = [];
|
|
264
|
-
const seen = new Set();
|
|
265
|
-
for (const rawItem of rawItems.slice(0, LIMITS.items)) {
|
|
266
|
-
if (!isPlainObject(rawItem)) {
|
|
267
|
-
warnings.push('a non-object item was dropped');
|
|
268
|
-
continue;
|
|
269
|
-
}
|
|
270
|
-
const itemId = clean(rawItem.id, LIMITS.id);
|
|
271
|
-
if (!itemId || !SLUG.test(itemId)) {
|
|
272
|
-
warnings.push(`item id must be a kebab-case slug (got ${JSON.stringify(rawItem.id)}) — dropped`);
|
|
273
|
-
continue;
|
|
274
|
-
}
|
|
275
|
-
if (seen.has(itemId)) {
|
|
276
|
-
warnings.push(`duplicate item ${itemId} — dropped`);
|
|
277
|
-
continue;
|
|
278
|
-
}
|
|
279
|
-
seen.add(itemId);
|
|
280
|
-
|
|
281
|
-
let known = true;
|
|
282
|
-
if (partList && !partList.includes(itemId)) {
|
|
283
|
-
// Soft on purpose: a pack authored for a newer manifest should still load,
|
|
284
|
-
// and the pane already renders an id it has no art for as a labelled row.
|
|
285
|
-
known = false;
|
|
286
|
-
warnings.push(`item ${itemId} is not in this build's ${partListName} manifest — it will render without art`);
|
|
287
|
-
}
|
|
288
|
-
if (kind === 'voice') {
|
|
289
|
-
const engine = String(rawItem.engine || '').trim().toLowerCase();
|
|
290
|
-
if (!['local', 'system'].includes(engine)) {
|
|
291
|
-
errors.push(
|
|
292
|
-
`voice item ${itemId} must name engine "local" or "system" (got ${JSON.stringify(rawItem.engine)}) — ` +
|
|
293
|
-
'voice never leaves the device',
|
|
294
|
-
);
|
|
295
|
-
known = false;
|
|
296
|
-
}
|
|
297
|
-
if (rawItem.consent !== 'synthetic') {
|
|
298
|
-
errors.push(
|
|
299
|
-
`voice item ${itemId} must declare consent: "synthetic" — no pack may ship a voice cloned from a real person`,
|
|
300
|
-
);
|
|
301
|
-
known = false;
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
const parts = Array.isArray(rawItem.parts)
|
|
306
|
-
? rawItem.parts
|
|
307
|
-
.map((p) => clean(p, LIMITS.id))
|
|
308
|
-
.filter((p) => Boolean(p))
|
|
309
|
-
.slice(0, LIMITS.parts)
|
|
310
|
-
: [];
|
|
311
|
-
const allParts = new Set(Object.values(personaModule.PARTS).flat().filter((v) => typeof v === 'string'));
|
|
312
|
-
for (const part of parts) {
|
|
313
|
-
if (!allParts.has(part)) warnings.push(`item ${itemId} names part ${part}, which this build has no art for`);
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
items.push(
|
|
317
|
-
Object.freeze({
|
|
318
|
-
id: itemId,
|
|
319
|
-
label: clean(rawItem.label, LIMITS.label) || itemId,
|
|
320
|
-
known,
|
|
321
|
-
parts: Object.freeze(parts),
|
|
322
|
-
unlock: unlockIdFor(kind || 'outfit', itemId),
|
|
323
|
-
// Carried through for voice items only, and carried through *verbatim*
|
|
324
|
-
// rather than dropped: `auditPacks` re-checks `consent` on the loaded
|
|
325
|
-
// object, so the boot audit is a second opinion on the raw pack instead
|
|
326
|
-
// of a re-run of the validator's own conclusion.
|
|
327
|
-
...(kind === 'voice'
|
|
328
|
-
? { engine: clean(rawItem.engine, 16).toLowerCase(), consent: clean(rawItem.consent, 16).toLowerCase() }
|
|
329
|
-
: {}),
|
|
330
|
-
}),
|
|
331
|
-
);
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
// Unknown top-level keys are dropped rather than carried, for the same reason
|
|
335
|
-
// persona.js drops them: an artifact that accumulates junk is an artifact whose
|
|
336
|
-
// contents nobody can reason about.
|
|
337
|
-
const knownKeys = ['schema', 'id', 'name', 'kind', 'tier', 'unlocks', 'items', 'author', 'license', 'summary'];
|
|
338
|
-
for (const key of Object.keys(raw)) {
|
|
339
|
-
if (!knownKeys.includes(key)) warnings.push(`unknown pack key ${JSON.stringify(key)} — ignored`);
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
if (errors.length) return { ok: false, pack: null, errors, warnings, forbidden };
|
|
343
|
-
|
|
344
|
-
return {
|
|
345
|
-
ok: true,
|
|
346
|
-
errors,
|
|
347
|
-
warnings,
|
|
348
|
-
forbidden,
|
|
349
|
-
pack: Object.freeze({
|
|
350
|
-
schema: SCHEMA_VERSION,
|
|
351
|
-
id,
|
|
352
|
-
name,
|
|
353
|
-
kind,
|
|
354
|
-
tier,
|
|
355
|
-
paid: tier === 'paid',
|
|
356
|
-
unlocks: Object.freeze(unlocks),
|
|
357
|
-
items: Object.freeze(items),
|
|
358
|
-
author: clean(raw.author, LIMITS.author),
|
|
359
|
-
license: clean(raw.license, LIMITS.license),
|
|
360
|
-
summary: clean(raw.summary, LIMITS.summary),
|
|
361
|
-
}),
|
|
362
|
-
};
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
/**
|
|
366
|
-
* The paywall line as a function. `validatePack` already refuses a pack whose
|
|
367
|
-
* tier contradicts its unlock, so today this can only fire on a pack built by
|
|
368
|
-
* something other than the validator (a hand-assembled object, a future loader
|
|
369
|
-
* path) — which is exactly why it exists: `store.verifyAtBoot()` runs it over
|
|
370
|
-
* the shipped set at launch, so the line fails loudly rather than being trusted.
|
|
371
|
-
*
|
|
372
|
-
* @returns {string[]} one string per violation, empty when the packs are clean.
|
|
373
|
-
*/
|
|
374
|
-
function auditPacks(packs) {
|
|
375
|
-
const violations = [];
|
|
376
|
-
for (const pack of Array.isArray(packs) ? packs : []) {
|
|
377
|
-
if (!isPlainObject(pack)) continue;
|
|
378
|
-
const label = pack.id || '(unnamed pack)';
|
|
379
|
-
const bad = forbiddenKeys(pack);
|
|
380
|
-
for (const where of bad) violations.push(`${label}: forbidden field ${where}`);
|
|
381
|
-
|
|
382
|
-
const tier = pack.tier === 'paid' ? 'paid' : 'free';
|
|
383
|
-
const unlocks = Array.isArray(pack.unlocks) ? pack.unlocks : [];
|
|
384
|
-
if (!unlocks.length) violations.push(`${label}: no unlock — a pack that grants nothing is not a cosmetic`);
|
|
385
|
-
for (const id of unlocks) {
|
|
386
|
-
const unlock = unlockById(id);
|
|
387
|
-
if (!unlock) {
|
|
388
|
-
violations.push(`${label}: unlock ${id} does not exist in the level table`);
|
|
389
|
-
continue;
|
|
390
|
-
}
|
|
391
|
-
if (unlock.paid !== (tier === 'paid')) {
|
|
392
|
-
violations.push(`${label}: tier ${tier} contradicts unlock ${id} (paid: ${unlock.paid})`);
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
if (pack.kind === 'voice') {
|
|
396
|
-
for (const item of pack.items || []) {
|
|
397
|
-
if (!item || item.consent !== 'synthetic') {
|
|
398
|
-
violations.push(`${label}: voice item ${item && item.id} is not declared synthetic`);
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
return violations;
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
/** Resolve a pack's directory (shipped packs live next to this file). */
|
|
407
|
-
function packsDir(dir) {
|
|
408
|
-
return dir || path.join(__dirname, 'packs');
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
/**
|
|
412
|
-
* Read every `*.pack.json` in a directory and validate it.
|
|
413
|
-
*
|
|
414
|
-
* A broken pack never throws and never takes the others down with it: it comes
|
|
415
|
-
* back in `rejected` with its errors, which is what the free/paid UI needs to
|
|
416
|
-
* tell the user *why* a set did not appear. Missing directory ⇒ no packs, no
|
|
417
|
-
* drama (a build with the directory stripped is not a crash).
|
|
418
|
-
*
|
|
419
|
-
* @returns {{ packs: object[], rejected: Array<{file: string, errors: string[]}>, warnings: string[], dir: string }}
|
|
420
|
-
*/
|
|
421
|
-
function loadPacks(dir) {
|
|
422
|
-
const root = packsDir(dir);
|
|
423
|
-
const out = { packs: [], rejected: [], warnings: [], dir: root };
|
|
424
|
-
let names;
|
|
425
|
-
try {
|
|
426
|
-
names = fs.readdirSync(root).filter((n) => n.endsWith(PACK_SUFFIX)).sort();
|
|
427
|
-
} catch (err) {
|
|
428
|
-
out.warnings.push(`no cosmetics packs at ${root} (${err.code || err.message})`);
|
|
429
|
-
return out;
|
|
430
|
-
}
|
|
431
|
-
for (const file of names) {
|
|
432
|
-
let raw;
|
|
433
|
-
try {
|
|
434
|
-
raw = JSON.parse(fs.readFileSync(path.join(root, file), 'utf8'));
|
|
435
|
-
} catch (err) {
|
|
436
|
-
out.rejected.push({ file, errors: [`not valid JSON: ${err.message}`] });
|
|
437
|
-
continue;
|
|
438
|
-
}
|
|
439
|
-
const result = validatePack(raw);
|
|
440
|
-
for (const warning of result.warnings) out.warnings.push(`${file}: ${warning}`);
|
|
441
|
-
if (!result.ok) {
|
|
442
|
-
out.rejected.push({ file, errors: result.errors });
|
|
443
|
-
continue;
|
|
444
|
-
}
|
|
445
|
-
out.packs.push(result.pack);
|
|
446
|
-
}
|
|
447
|
-
return out;
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
/** Just the shipped packs (throws nothing — see loadPacks). */
|
|
451
|
-
function shippedPacks() {
|
|
452
|
-
return loadPacks().packs;
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
/**
|
|
456
|
-
* Boot audit over the shipped set: every violation `auditPacks` knows about,
|
|
457
|
-
* plus a rejection reason for any pack that will not load at all.
|
|
458
|
-
*/
|
|
459
|
-
function verifyShipped(packs) {
|
|
460
|
-
const violations = [];
|
|
461
|
-
let list = packs;
|
|
462
|
-
if (!Array.isArray(list)) {
|
|
463
|
-
const loaded = loadPacks();
|
|
464
|
-
list = loaded.packs;
|
|
465
|
-
for (const bad of loaded.rejected) {
|
|
466
|
-
violations.push(`${bad.file}: ${bad.errors.join('; ')}`);
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
violations.push(...auditPacks(list));
|
|
470
|
-
return { ok: violations.length === 0, violations, packs: list };
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
/**
|
|
474
|
-
* The free/paid line, joined to the level table — the one structure a docs page,
|
|
475
|
-
* a test and (later) the customization pane can all read instead of each
|
|
476
|
-
* re-deriving it. Every row names the level that unlocks it, because that is the
|
|
477
|
-
* whole claim: **the level earns the cosmetic, money may only ever skip nothing
|
|
478
|
-
* but taste.**
|
|
479
|
-
*
|
|
480
|
-
* @returns {Array<{packId, packName, kind, tier, paid, unlockId, level, label, items: string[]}>}
|
|
481
|
-
*/
|
|
482
|
-
function catalog(packs) {
|
|
483
|
-
const rows = [];
|
|
484
|
-
for (const pack of Array.isArray(packs) ? packs : []) {
|
|
485
|
-
if (!isPlainObject(pack) || !Array.isArray(pack.unlocks)) continue;
|
|
486
|
-
for (const unlockId of pack.unlocks) {
|
|
487
|
-
const unlock = unlockById(unlockId);
|
|
488
|
-
rows.push({
|
|
489
|
-
packId: pack.id,
|
|
490
|
-
packName: pack.name,
|
|
491
|
-
kind: pack.kind,
|
|
492
|
-
tier: pack.tier,
|
|
493
|
-
paid: Boolean(unlock && unlock.paid),
|
|
494
|
-
unlockId,
|
|
495
|
-
level: unlock ? unlock.level : null,
|
|
496
|
-
label: unlock ? unlock.label : unlockId,
|
|
497
|
-
items: (pack.items || []).map((i) => i.id),
|
|
498
|
-
});
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
return rows;
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
/** `{ free: [...], paid: [...] }` — the demonstrable line, in one call. */
|
|
505
|
-
function freePaidLine(packs) {
|
|
506
|
-
const rows = catalog(packs);
|
|
507
|
-
return {
|
|
508
|
-
free: rows.filter((r) => !r.paid),
|
|
509
|
-
paid: rows.filter((r) => r.paid),
|
|
510
|
-
};
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
module.exports = {
|
|
514
|
-
SCHEMA_VERSION,
|
|
515
|
-
KINDS,
|
|
516
|
-
TIERS,
|
|
517
|
-
KIND_UNLOCK_PREFIX,
|
|
518
|
-
KIND_PART_LIST,
|
|
519
|
-
UNLOCK_ALIASES,
|
|
520
|
-
FORBIDDEN_KEYS,
|
|
521
|
-
LIMITS,
|
|
522
|
-
PACK_SUFFIX,
|
|
523
|
-
unlockById,
|
|
524
|
-
unlockIdFor,
|
|
525
|
-
forbiddenKeys,
|
|
526
|
-
validatePack,
|
|
527
|
-
auditPacks,
|
|
528
|
-
packsDir,
|
|
529
|
-
loadPacks,
|
|
530
|
-
shippedPacks,
|
|
531
|
-
verifyShipped,
|
|
532
|
-
catalog,
|
|
533
|
-
freePaidLine,
|
|
534
|
-
};
|
|
3
|
+
"use strict";const fs=require("node:fs"),path=require("node:path"),personaModule=require("./persona.js"),level=require("./level.js"),SCHEMA_VERSION=1,KINDS=Object.freeze(["outfit","expressions","palette","voice"]),TIERS=Object.freeze(["free","paid"]),KIND_UNLOCK_PREFIX=Object.freeze({outfit:"outfit",expressions:"expression",palette:"palette",voice:"voice"}),KIND_PART_LIST=Object.freeze({outfit:"outfit",expressions:"expressionPack",palette:"palette",voice:null}),UNLOCK_ALIASES=Object.freeze({"outfit.seasonal-winter":"outfit.seasonal","outfit.archive-coat":"outfit.archive"}),FORBIDDEN_KEYS=Object.freeze(["xp","xpbonus","xpmultiplier","level","levels","unlocklevel","minlevel","maxlevel","recall","recallentries","recalltokens","memory","memorywriteback","approval","approvals","autoapprove","autoapproveclasses","approvalfriction","grant","grants","toolgrant","toolgrants","capability","capabilities","offdevice","offdevicegrants","modelfloor","proactivity","queuedrain","importassistant","tokens","budget","pricetokens","price","prices","amount","currency","cost","sku","entitlement","entitlements","checkout","purchase","subscription","trial","sample","samples","sampleurl","recording","recordingurl","sourceaudio","voiceprint"]),FORBIDDEN_KEY_PATTERN=/(xp|level|recall|approval|grant|capabilit|offdevice|proactiv|entitlement|price|clone|voiceprint)/i,LIMITS=Object.freeze({id:48,name:60,label:48,summary:240,author:60,license:40,unlocks:8,items:24,parts:6}),PACK_SUFFIX=".pack.json",SLUG=/^[a-z0-9]+(?:-[a-z0-9]+)*$/;function isPlainObject(e){return Boolean(e)&&"object"==typeof e&&!Array.isArray(e)}function unlockById(e){return level.UNLOCKS.find((t=>t.id===e))||null}function unlockIdFor(e,t){return UNLOCK_ALIASES[`${e}.${t}`]||`${e}.${t}`}function forbiddenKeys(e,t=""){const n=[];if(Array.isArray(e))return e.forEach(((e,i)=>n.push(...forbiddenKeys(e,`${t}${t?".":""}${i}`)))),n;if(!isPlainObject(e))return n;for(const[i,s]of Object.entries(e)){const e=`${t}${t?".":""}${i}`,a=i.toLowerCase().replace(/[^a-z0-9]/g,"");(FORBIDDEN_KEYS.includes(a)||FORBIDDEN_KEY_PATTERN.test(i))&&n.push(e),n.push(...forbiddenKeys(s,e))}return n}function clean(e,t){return"string"!=typeof e?"":e.replace(/[\u0000-\u001f\u007f\u2028\u2029]+/g," ").replace(/\s+/g," ").trim().slice(0,t)}function validatePack(e){const t=[],n=[];if(!isPlainObject(e))return{ok:!1,pack:null,errors:["cosmetics pack must be a JSON object"],warnings:n,forbidden:[]};const i=forbiddenKeys(e);for(const e of i)t.push(`cosmetics packs may not carry \`${e}\` — a pack sells LOOKS only, never XP, levels, recall breadth, approval friction or personalization`);Number.isInteger(e.schema)&&e.schema>SCHEMA_VERSION&&t.push(`pack schema ${e.schema} is newer than this build supports (${SCHEMA_VERSION})`);const s=clean(e.id,LIMITS.id);s&&SLUG.test(s)||t.push(`pack id must be a kebab-case slug (got ${JSON.stringify(e.id)})`);const a=clean(e.name,LIMITS.name);a||t.push("pack needs a name");const o=KINDS.includes(e.kind)?e.kind:null;o||t.push(`pack kind must be one of ${KINDS.join(" | ")} (got ${JSON.stringify(e.kind)})`);const r=TIERS.includes(e.tier)?e.tier:null;r||t.push(`pack tier must be "free" or "paid" (got ${JSON.stringify(e.tier)})`);const c=Array.isArray(e.unlocks)?e.unlocks:[],l=[];c.length||t.push("pack must reference at least one unlock id from lib/avatar/level.js (a pack cannot create one)"),c.length>LIMITS.unlocks&&t.push(`at most ${LIMITS.unlocks} unlocks per pack (got ${c.length})`);for(const e of c.slice(0,LIMITS.unlocks)){const n=unlockById(e);n?(o&&n.id.split(".")[0]!==KIND_UNLOCK_PREFIX[o]&&t.push(`${n.id} is not a ${o} unlock — a pack may only gate its own kind`),r&&n.paid!==("paid"===r)&&t.push(`tier "${r}" contradicts unlock ${n.id} (paid: ${n.paid}) — the level table decides what is paid, not the pack`),l.includes(n.id)||l.push(n.id)):t.push(`unknown unlock ${JSON.stringify(e)} — the level table (level.js UNLOCKS) is the only authority on what exists and what is paid`)}const u=o?KIND_PART_LIST[o]:null,p=u?personaModule.PARTS[u]:null,d=Array.isArray(e.items)?e.items:[];d.length||t.push("pack needs at least one item"),d.length>LIMITS.items&&t.push(`at most ${LIMITS.items} items per pack (got ${d.length})`);const f=[],k=new Set;for(const e of d.slice(0,LIMITS.items)){if(!isPlainObject(e)){n.push("a non-object item was dropped");continue}const i=clean(e.id,LIMITS.id);if(!i||!SLUG.test(i)){n.push(`item id must be a kebab-case slug (got ${JSON.stringify(e.id)}) — dropped`);continue}if(k.has(i)){n.push(`duplicate item ${i} — dropped`);continue}k.add(i);let s=!0;if(p&&!p.includes(i)&&(s=!1,n.push(`item ${i} is not in this build's ${u} manifest — it will render without art`)),"voice"===o){const n=String(e.engine||"").trim().toLowerCase();["local","system"].includes(n)||(t.push(`voice item ${i} must name engine "local" or "system" (got ${JSON.stringify(e.engine)}) — voice never leaves the device`),s=!1),"synthetic"!==e.consent&&(t.push(`voice item ${i} must declare consent: "synthetic" — no pack may ship a voice cloned from a real person`),s=!1)}const a=Array.isArray(e.parts)?e.parts.map((e=>clean(e,LIMITS.id))).filter((e=>Boolean(e))).slice(0,LIMITS.parts):[],r=new Set(Object.values(personaModule.PARTS).flat().filter((e=>"string"==typeof e)));for(const e of a)r.has(e)||n.push(`item ${i} names part ${e}, which this build has no art for`);f.push(Object.freeze({id:i,label:clean(e.label,LIMITS.label)||i,known:s,parts:Object.freeze(a),unlock:unlockIdFor(o||"outfit",i),..."voice"===o?{engine:clean(e.engine,16).toLowerCase(),consent:clean(e.consent,16).toLowerCase()}:{}}))}const h=["schema","id","name","kind","tier","unlocks","items","author","license","summary"];for(const t of Object.keys(e))h.includes(t)||n.push(`unknown pack key ${JSON.stringify(t)} — ignored`);return t.length?{ok:!1,pack:null,errors:t,warnings:n,forbidden:i}:{ok:!0,errors:t,warnings:n,forbidden:i,pack:Object.freeze({schema:SCHEMA_VERSION,id:s,name:a,kind:o,tier:r,paid:"paid"===r,unlocks:Object.freeze(l),items:Object.freeze(f),author:clean(e.author,LIMITS.author),license:clean(e.license,LIMITS.license),summary:clean(e.summary,LIMITS.summary)})}}function auditPacks(e){const t=[];for(const n of Array.isArray(e)?e:[]){if(!isPlainObject(n))continue;const e=n.id||"(unnamed pack)",i=forbiddenKeys(n);for(const n of i)t.push(`${e}: forbidden field ${n}`);const s="paid"===n.tier?"paid":"free",a=Array.isArray(n.unlocks)?n.unlocks:[];a.length||t.push(`${e}: no unlock — a pack that grants nothing is not a cosmetic`);for(const n of a){const i=unlockById(n);i?i.paid!==("paid"===s)&&t.push(`${e}: tier ${s} contradicts unlock ${n} (paid: ${i.paid})`):t.push(`${e}: unlock ${n} does not exist in the level table`)}if("voice"===n.kind)for(const i of n.items||[])i&&"synthetic"===i.consent||t.push(`${e}: voice item ${i&&i.id} is not declared synthetic`)}return t}function packsDir(e){return e||path.join(__dirname,"packs")}function loadPacks(e){const t=packsDir(e),n={packs:[],rejected:[],warnings:[],dir:t};let i;try{i=fs.readdirSync(t).filter((e=>e.endsWith(PACK_SUFFIX))).sort()}catch(e){return n.warnings.push(`no cosmetics packs at ${t} (${e.code||e.message})`),n}for(const e of i){let i;try{i=JSON.parse(fs.readFileSync(path.join(t,e),"utf8"))}catch(t){n.rejected.push({file:e,errors:[`not valid JSON: ${t.message}`]});continue}const s=validatePack(i);for(const t of s.warnings)n.warnings.push(`${e}: ${t}`);s.ok?n.packs.push(s.pack):n.rejected.push({file:e,errors:s.errors})}return n}function shippedPacks(){return loadPacks().packs}function verifyShipped(e){const t=[];let n=e;if(!Array.isArray(n)){const e=loadPacks();n=e.packs;for(const n of e.rejected)t.push(`${n.file}: ${n.errors.join("; ")}`)}return t.push(...auditPacks(n)),{ok:0===t.length,violations:t,packs:n}}function catalog(e){const t=[];for(const n of Array.isArray(e)?e:[])if(isPlainObject(n)&&Array.isArray(n.unlocks))for(const e of n.unlocks){const i=unlockById(e);t.push({packId:n.id,packName:n.name,kind:n.kind,tier:n.tier,paid:Boolean(i&&i.paid),unlockId:e,level:i?i.level:null,label:i?i.label:e,items:(n.items||[]).map((e=>e.id))})}return t}function freePaidLine(e){const t=catalog(e);return{free:t.filter((e=>!e.paid)),paid:t.filter((e=>e.paid))}}module.exports={SCHEMA_VERSION:SCHEMA_VERSION,KINDS:KINDS,TIERS:TIERS,KIND_UNLOCK_PREFIX:KIND_UNLOCK_PREFIX,KIND_PART_LIST:KIND_PART_LIST,UNLOCK_ALIASES:UNLOCK_ALIASES,FORBIDDEN_KEYS:FORBIDDEN_KEYS,LIMITS:LIMITS,PACK_SUFFIX:PACK_SUFFIX,unlockById:unlockById,unlockIdFor:unlockIdFor,forbiddenKeys:forbiddenKeys,validatePack:validatePack,auditPacks:auditPacks,packsDir:packsDir,loadPacks:loadPacks,shippedPacks:shippedPacks,verifyShipped:verifyShipped,catalog:catalog,freePaidLine:freePaidLine};
|