@chatpanel/events 0.33.1 → 0.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/backup-envelope.js +221 -0
- package/curate.js +509 -0
- package/distance.js +124 -0
- package/entitlement.js +298 -0
- package/entity.js +354 -0
- package/flowchart.js +350 -97
- package/index.js +78 -0
- package/knowledge-derive.js +267 -0
- package/knowledge.js +221 -0
- package/library.js +265 -0
- package/omni.js +125 -0
- package/package.json +43 -11
- package/promotion.js +171 -0
- package/redaction-tokens.js +61 -0
- package/ref.js +4 -1
- package/subject-kinds.js +5 -0
- package/subject-name.js +96 -0
- package/sync-plan.js +170 -0
- package/synthesis.js +123 -0
- package/theme.js +155 -0
- package/voice-intents.js +5 -23
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// THE BACKUP ENVELOPE — one wire format, one implementation.
|
|
2
|
+
//
|
|
3
|
+
// A ChatPanel backup is the only LOSSLESS channel the corpus has between clients: the
|
|
4
|
+
// extension writes one, the gateway reads one, and a desktop client has to do both. Today
|
|
5
|
+
// that format is implemented twice — the extension's `crypto-backup.js` (both directions)
|
|
6
|
+
// and the gateway's `backup-decrypt.js` (decrypt only, re-derived from the format's shape).
|
|
7
|
+
// Two implementations of a CRYPTO format is the worst kind of duplication: a divergence does
|
|
8
|
+
// not show up as a wrong pixel, it shows up as a backup nobody can open.
|
|
9
|
+
//
|
|
10
|
+
// Envelope, unchanged and frozen:
|
|
11
|
+
// { type:'chatpanel-backup-encrypted', version, kdf:{iterations,salt},
|
|
12
|
+
// cipher:'AES-GCM', compression:'brotli'|'gzip'|'none'|absent, iv, ct }
|
|
13
|
+
//
|
|
14
|
+
// COMPRESSION IS INJECTED, crypto is not. WebCrypto is on every runtime we target
|
|
15
|
+
// (browser, Node 19+, Electron), so it is imported as a global. Compression is not: the
|
|
16
|
+
// browser has CompressionStream, Node has zlib, and neither can be reached from the other.
|
|
17
|
+
// So the caller passes a `codec` — which is the same shape as the `page-capability.js`
|
|
18
|
+
// factory and the `loop.js` clock, and for the same reason.
|
|
19
|
+
//
|
|
20
|
+
// COMPRESS THEN ENCRYPT, never the reverse. Ciphertext is indistinguishable from random and
|
|
21
|
+
// does not compress, so the order is load-bearing rather than stylistic. The envelope
|
|
22
|
+
// records which codec was used so a gzip backup written in 2025 still opens forever.
|
|
23
|
+
|
|
24
|
+
export class BackupError extends Error {
|
|
25
|
+
constructor(message) { super(message); this.name = 'BackupError'; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const ENCRYPTED_TYPE = 'chatpanel-backup-encrypted';
|
|
29
|
+
|
|
30
|
+
/** PBKDF2-SHA256 rounds. Recorded IN the envelope so this can be raised without breaking old files. */
|
|
31
|
+
export const KDF_ITERATIONS = 250_000;
|
|
32
|
+
|
|
33
|
+
export const COMPRESSIONS = Object.freeze(['brotli', 'gzip', 'none']);
|
|
34
|
+
|
|
35
|
+
// --------------------------------------------------------------------------
|
|
36
|
+
// Bytes ↔ base64, without Buffer or atob assumptions
|
|
37
|
+
// --------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
const CHUNK = 0x8000;
|
|
40
|
+
|
|
41
|
+
export function toB64(bytes) {
|
|
42
|
+
if (typeof Buffer !== 'undefined') return Buffer.from(bytes).toString('base64');
|
|
43
|
+
let bin = '';
|
|
44
|
+
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
45
|
+
bin += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK));
|
|
46
|
+
}
|
|
47
|
+
return btoa(bin);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function fromB64(s) {
|
|
51
|
+
const str = String(s || '');
|
|
52
|
+
if (typeof Buffer !== 'undefined') return new Uint8Array(Buffer.from(str, 'base64'));
|
|
53
|
+
const bin = atob(str);
|
|
54
|
+
const out = new Uint8Array(bin.length);
|
|
55
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// --------------------------------------------------------------------------
|
|
60
|
+
// Codecs
|
|
61
|
+
// --------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The no-op codec. A backup written with it is still encrypted — it is just larger.
|
|
65
|
+
* Useful in tests and on a runtime that offers no compression at all.
|
|
66
|
+
*/
|
|
67
|
+
export const identityCodec = Object.freeze({
|
|
68
|
+
name: 'none',
|
|
69
|
+
async compress(bytes) { return bytes; },
|
|
70
|
+
async decompress(bytes) { return bytes; },
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The browser/Electron-renderer codec, built from CompressionStream.
|
|
75
|
+
* Returns `null` when the runtime lacks the format, so a caller can fall back rather than
|
|
76
|
+
* discover the gap at write time.
|
|
77
|
+
*/
|
|
78
|
+
export function streamCodec(format = 'gzip') {
|
|
79
|
+
if (typeof CompressionStream === 'undefined' || typeof DecompressionStream === 'undefined') return null;
|
|
80
|
+
try {
|
|
81
|
+
new CompressionStream(format);
|
|
82
|
+
new DecompressionStream(format);
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
const run = async (bytes, Stream) => {
|
|
87
|
+
const stream = new Blob([bytes]).stream().pipeThrough(new Stream(format));
|
|
88
|
+
return new Uint8Array(await new Response(stream).arrayBuffer());
|
|
89
|
+
};
|
|
90
|
+
return Object.freeze({
|
|
91
|
+
name: format,
|
|
92
|
+
compress: (b) => run(b, CompressionStream),
|
|
93
|
+
decompress: (b) => run(b, DecompressionStream),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Build a codec from Node's zlib without importing it here — the caller passes the module,
|
|
99
|
+
* so this file stays dependency-free and loadable in a browser.
|
|
100
|
+
*
|
|
101
|
+
* import zlib from 'node:zlib';
|
|
102
|
+
* const codec = nodeCodec(zlib, 'brotli');
|
|
103
|
+
*/
|
|
104
|
+
export function nodeCodec(zlib, format = 'brotli') {
|
|
105
|
+
if (!zlib) return null;
|
|
106
|
+
const pair = format === 'brotli'
|
|
107
|
+
? [zlib.brotliCompressSync, zlib.brotliDecompressSync]
|
|
108
|
+
: format === 'gzip' ? [zlib.gzipSync, zlib.gunzipSync] : null;
|
|
109
|
+
if (!pair || !pair[0] || !pair[1]) return null;
|
|
110
|
+
const [enc, dec] = pair;
|
|
111
|
+
return Object.freeze({
|
|
112
|
+
name: format,
|
|
113
|
+
async compress(bytes) { return new Uint8Array(enc(Buffer.from(bytes))); },
|
|
114
|
+
async decompress(bytes) { return new Uint8Array(dec(Buffer.from(bytes))); },
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Pick the best codec a runtime offers, preferring the smallest output. */
|
|
119
|
+
export function bestCodec({ zlib = null } = {}) {
|
|
120
|
+
if (zlib) return nodeCodec(zlib, 'brotli') || nodeCodec(zlib, 'gzip') || identityCodec;
|
|
121
|
+
return streamCodec('brotli') || streamCodec('gzip') || identityCodec;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// --------------------------------------------------------------------------
|
|
125
|
+
// Key derivation
|
|
126
|
+
// --------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
async function deriveKey(passphrase, salt, iterations, usages, subtle) {
|
|
129
|
+
const crypto_ = subtle || globalThis.crypto?.subtle;
|
|
130
|
+
if (!crypto_) throw new BackupError('no WebCrypto available on this runtime');
|
|
131
|
+
const base = await crypto_.importKey(
|
|
132
|
+
'raw', new TextEncoder().encode(String(passphrase)), 'PBKDF2', false, ['deriveKey'],
|
|
133
|
+
);
|
|
134
|
+
return crypto_.deriveKey(
|
|
135
|
+
{ name: 'PBKDF2', salt, iterations, hash: 'SHA-256' },
|
|
136
|
+
base,
|
|
137
|
+
{ name: 'AES-GCM', length: 256 },
|
|
138
|
+
false,
|
|
139
|
+
usages,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// --------------------------------------------------------------------------
|
|
144
|
+
// The two directions
|
|
145
|
+
// --------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
export function isEncryptedBackup(obj) {
|
|
148
|
+
return !!obj && typeof obj === 'object' && obj.type === ENCRYPTED_TYPE;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* data + passphrase → envelope.
|
|
153
|
+
*
|
|
154
|
+
* `version` is the ENVELOPE's version, not the payload's — the backup body carries its own.
|
|
155
|
+
*/
|
|
156
|
+
export async function encryptBackup(data, passphrase, {
|
|
157
|
+
codec = null, iterations = KDF_ITERATIONS, subtle = null, random = null, version = 3,
|
|
158
|
+
} = {}) {
|
|
159
|
+
if (!passphrase) throw new BackupError('a passphrase is required to encrypt a backup');
|
|
160
|
+
const c = codec || bestCodec();
|
|
161
|
+
const rand = random || ((n) => globalThis.crypto.getRandomValues(new Uint8Array(n)));
|
|
162
|
+
|
|
163
|
+
const plain = new TextEncoder().encode(JSON.stringify(data));
|
|
164
|
+
const packed = await c.compress(plain);
|
|
165
|
+
|
|
166
|
+
const salt = rand(16);
|
|
167
|
+
const iv = rand(12);
|
|
168
|
+
const key = await deriveKey(passphrase, salt, iterations, ['encrypt'], subtle);
|
|
169
|
+
const cryptoSubtle = subtle || globalThis.crypto.subtle;
|
|
170
|
+
const ct = new Uint8Array(await cryptoSubtle.encrypt({ name: 'AES-GCM', iv }, key, packed));
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
type: ENCRYPTED_TYPE,
|
|
174
|
+
version,
|
|
175
|
+
kdf: { iterations, salt: toB64(salt) },
|
|
176
|
+
cipher: 'AES-GCM',
|
|
177
|
+
compression: c.name,
|
|
178
|
+
iv: toB64(iv),
|
|
179
|
+
ct: toB64(ct),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* envelope + passphrase → the original data object.
|
|
185
|
+
*
|
|
186
|
+
* A wrong passphrase and a tampered file are the SAME failure here, and deliberately so:
|
|
187
|
+
* AES-GCM's auth tag catches both, and telling them apart would be an oracle.
|
|
188
|
+
*/
|
|
189
|
+
export async function decryptBackup(envelope, passphrase, { codec = null, subtle = null } = {}) {
|
|
190
|
+
if (!isEncryptedBackup(envelope)) throw new BackupError('not an encrypted ChatPanel backup');
|
|
191
|
+
if (!passphrase) throw new BackupError('a passphrase is required to decrypt this backup');
|
|
192
|
+
|
|
193
|
+
const salt = fromB64(envelope.kdf?.salt);
|
|
194
|
+
const iterations = Number(envelope.kdf?.iterations) || KDF_ITERATIONS;
|
|
195
|
+
const key = await deriveKey(passphrase, salt, iterations, ['decrypt'], subtle);
|
|
196
|
+
const cryptoSubtle = subtle || globalThis.crypto?.subtle;
|
|
197
|
+
if (!cryptoSubtle) throw new BackupError('no WebCrypto available on this runtime');
|
|
198
|
+
|
|
199
|
+
let payload;
|
|
200
|
+
try {
|
|
201
|
+
payload = new Uint8Array(await cryptoSubtle.decrypt(
|
|
202
|
+
{ name: 'AES-GCM', iv: fromB64(envelope.iv) }, key, fromB64(envelope.ct),
|
|
203
|
+
));
|
|
204
|
+
} catch {
|
|
205
|
+
throw new BackupError('wrong passphrase, or the backup file is corrupted');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// v1 wrote plaintext, v2 gzip, v3 may use brotli. The envelope says which; an absent
|
|
209
|
+
// field means the oldest behaviour, which is why `none` and absent must agree.
|
|
210
|
+
const want = envelope.compression || 'none';
|
|
211
|
+
if (want !== 'none') {
|
|
212
|
+
const c = codec || bestCodec();
|
|
213
|
+
if (c.name !== want) {
|
|
214
|
+
// Being explicit beats returning garbage: a gzip envelope handed a brotli-only codec
|
|
215
|
+
// fails here with a readable reason instead of a JSON parse error twenty lines later.
|
|
216
|
+
throw new BackupError(`this backup is ${want}-compressed; supply a matching codec`);
|
|
217
|
+
}
|
|
218
|
+
payload = await c.decompress(payload);
|
|
219
|
+
}
|
|
220
|
+
return JSON.parse(new TextDecoder().decode(payload));
|
|
221
|
+
}
|