@livedesk/hub 0.1.59 → 0.1.64

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.
@@ -0,0 +1,365 @@
1
+ export const PWA_RUNTIME_DIAGNOSTIC_STORE_SCHEMA = 'livedesk.pwa-runtime-diagnostics.v1';
2
+ export const PWA_RUNTIME_DIAGNOSTIC_STORE_MAX_BATCH_EVENTS = 32;
3
+ export const PWA_RUNTIME_DIAGNOSTIC_STORE_MAX_BATCH_UTF8_BYTES = 24 * 1024;
4
+ export const PWA_RUNTIME_DIAGNOSTIC_STORE_MAX_EVENT_UTF8_BYTES = 4 * 1024;
5
+ export const PWA_RUNTIME_DIAGNOSTIC_STORE_MAX_EVENTS_PER_OWNER = 128;
6
+ export const PWA_RUNTIME_DIAGNOSTIC_STORE_MAX_TOTAL_EVENTS = 1_024;
7
+ export const PWA_RUNTIME_DIAGNOSTIC_STORE_MAX_OWNERS = 64;
8
+ export const PWA_RUNTIME_DIAGNOSTIC_STORE_INSTANCE_ID_MIN_LENGTH = 16;
9
+ export const PWA_RUNTIME_DIAGNOSTIC_STORE_INSTANCE_ID_MAX_LENGTH = 64;
10
+
11
+ const ALLOWED_EVENT_TYPES = new Set([
12
+ 'client.started',
13
+ 'client.stopped',
14
+ 'session.changed',
15
+ 'visibility.changed',
16
+ 'frame.owner.changed',
17
+ 'frame.transport.changed',
18
+ 'frame.recovery.changed',
19
+ 'stream.state.changed',
20
+ 'control.state.changed',
21
+ 'audio.state.changed',
22
+ 'hub.request.failed'
23
+ ]);
24
+ const SENSITIVE_KEY = /token|auth(?:orization)?|bearer|cookie|secret|password|credential|api[-_]?key|access[-_]?key|private[-_]?key|connection[-_]?string|pair/i;
25
+ // Fixed prefixes plus flat character classes keep value-only credential
26
+ // detection linear while covering the provider formats most likely to appear
27
+ // in otherwise unlabeled runtime output.
28
+ const KNOWN_SECRET_VALUE = /\b(?:sk-(?:proj-|ant-)?[A-Za-z0-9_-]{8,}|gh[pousr]_[A-Za-z0-9]{8,}|github_pat_[A-Za-z0-9_]{8,}|(?:AKIA|ASIA)[A-Z0-9]{16}|AIza[A-Za-z0-9_-]{35}|npm_[A-Za-z0-9]{8,}|xox[a-z]-[A-Za-z0-9-]{8,}|(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{8,})\b/g;
29
+ const encoder = new TextEncoder();
30
+
31
+ function utf8Bytes(value) {
32
+ return encoder.encode(String(value ?? '')).byteLength;
33
+ }
34
+
35
+ function truncateUtf8(value, maxBytes) {
36
+ const source = String(value ?? '');
37
+ if (utf8Bytes(source) <= maxBytes) return source;
38
+ let result = '';
39
+ let used = 0;
40
+ for (const character of source) {
41
+ const size = utf8Bytes(character);
42
+ if (used + size > maxBytes) break;
43
+ result += character;
44
+ used += size;
45
+ }
46
+ return result;
47
+ }
48
+
49
+ function redactText(value, maxBytes = 1_000) {
50
+ return truncateUtf8(String(value ?? '')
51
+ .replace(/-----BEGIN ([A-Z0-9 ]*PRIVATE KEY)-----[\s\S]*?-----END \1-----/gi, '[redacted private key]')
52
+ .replace(/\b((?:Proxy-)?Authorization)\s*:\s*[^\r\n]*/gi, '$1: [redacted]')
53
+ .replace(/\b((?:Set-Cookie|Cookie))\s*:\s*[^\r\n]*/gi, '$1: [redacted]')
54
+ .replace(/[\u0000-\u001f\u007f]/g, ' ')
55
+ .replace(/("[a-z0-9_-]*(?:token|authorization|cookie|secret|password|credential|api[-_]?key|access[-_]?key|private[-_]?key|connection[-_]?string|pair)[a-z0-9_-]*"\s*:\s*")([^"]*)(")/gi, '$1[redacted]$3')
56
+ .replace(/\b([a-z0-9_-]*(?:token|authorization|cookie|secret|password|credential|api[-_]?key|access[-_]?key|private[-_]?key|connection[-_]?string|pair)[a-z0-9_-]*)\s*[:=]\s*([^\s,;&]+)/gi, '$1=[redacted]')
57
+ .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [redacted]')
58
+ .replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, '[redacted]')
59
+ .replace(KNOWN_SECRET_VALUE, '[redacted]')
60
+ .trim(), maxBytes);
61
+ }
62
+
63
+ function redactValue(value, depth = 0) {
64
+ if (depth > 3) return '[truncated]';
65
+ if (typeof value === 'string') return redactText(value, 1_000);
66
+ if (typeof value === 'number') return Number.isFinite(value) ? value : 'unknown';
67
+ if (typeof value === 'boolean' || value === null) return value;
68
+ if (Array.isArray(value)) return value.slice(0, 16).map(item => redactValue(item, depth + 1));
69
+ if (!value || typeof value !== 'object') return redactText(value, 200) || 'unknown';
70
+ return Object.fromEntries(Object.entries(value).slice(0, 16).map(([rawKey, child]) => {
71
+ const key = redactText(rawKey, 80) || 'unknown';
72
+ return [key, SENSITIVE_KEY.test(key) ? '[redacted]' : redactValue(child, depth + 1)];
73
+ }));
74
+ }
75
+
76
+ function boundedInteger(value, fallback, minimum, maximum) {
77
+ const numeric = Number(value);
78
+ if (!Number.isFinite(numeric)) return fallback;
79
+ return Math.max(minimum, Math.min(maximum, Math.floor(numeric)));
80
+ }
81
+
82
+ function normalizeOwner(value = {}) {
83
+ return Object.freeze({
84
+ clientId: redactText(value.clientId, 160) || 'unknown',
85
+ sessionId: redactText(value.sessionId, 160) || 'unknown'
86
+ });
87
+ }
88
+
89
+ function normalizeInstanceId(value) {
90
+ if (typeof value !== 'string') return null;
91
+ if (
92
+ value.length < PWA_RUNTIME_DIAGNOSTIC_STORE_INSTANCE_ID_MIN_LENGTH
93
+ || value.length > PWA_RUNTIME_DIAGNOSTIC_STORE_INSTANCE_ID_MAX_LENGTH
94
+ || !/^[A-Za-z0-9_-]+$/.test(value)
95
+ ) {
96
+ return null;
97
+ }
98
+ return value;
99
+ }
100
+
101
+ function ownerKey(owner) {
102
+ return `${owner.clientId}\u0000${owner.sessionId}`;
103
+ }
104
+
105
+ function claimedOwnerMismatch(claimedOwner, authoritativeOwner) {
106
+ return (claimedOwner.clientId !== 'unknown' && claimedOwner.clientId !== authoritativeOwner.clientId)
107
+ || (claimedOwner.sessionId !== 'unknown' && claimedOwner.sessionId !== authoritativeOwner.sessionId);
108
+ }
109
+
110
+ function normalizeEvent(input, authoritativeOwner, authoritativeInstanceId, receivedAt) {
111
+ const type = redactText(input?.type, 80);
112
+ if (!ALLOWED_EVENT_TYPES.has(type)) {
113
+ return { ok: false, error: 'event-type-not-allowed' };
114
+ }
115
+ const numericSequence = Number(input?.sequence);
116
+ if (!Number.isSafeInteger(numericSequence) || numericSequence < 1) {
117
+ return { ok: false, error: 'invalid-event-sequence' };
118
+ }
119
+ const claimedInstanceId = normalizeInstanceId(input?.instanceId);
120
+ if (!claimedInstanceId) {
121
+ return { ok: false, error: 'invalid-event-instance-id' };
122
+ }
123
+ if (claimedInstanceId !== authoritativeInstanceId) {
124
+ return { ok: false, error: 'event-instance-mismatch' };
125
+ }
126
+ const claimedOwner = normalizeOwner(input?.owner);
127
+ if (claimedOwnerMismatch(claimedOwner, authoritativeOwner)) {
128
+ return { ok: false, error: 'event-owner-mismatch' };
129
+ }
130
+ const missingEvidence = Array.isArray(input?.missingEvidence)
131
+ ? input.missingEvidence.slice(0, 8).map(item => redactText(item, 80)).filter(Boolean)
132
+ : [];
133
+ const component = redactText(input?.component, 80) || 'unknown';
134
+ const state = redactText(input?.state, 120) || 'unknown';
135
+ const reason = redactText(input?.reason, 500) || 'unknown';
136
+ const sourceTimestamp = redactText(input?.sourceTimestamp, 80) || 'unknown';
137
+ if (component === 'unknown' && !missingEvidence.includes('component')) missingEvidence.push('component');
138
+ if (state === 'unknown' && !missingEvidence.includes('state')) missingEvidence.push('state');
139
+ if (reason === 'unknown' && !missingEvidence.includes('reason')) missingEvidence.push('reason');
140
+ if (sourceTimestamp === 'unknown' && !missingEvidence.includes('sourceTimestamp')) missingEvidence.push('sourceTimestamp');
141
+ if (authoritativeOwner.clientId === 'unknown' && !missingEvidence.includes('owner.clientId')) {
142
+ missingEvidence.push('owner.clientId');
143
+ }
144
+ if (authoritativeOwner.sessionId === 'unknown' && !missingEvidence.includes('owner.sessionId')) {
145
+ missingEvidence.push('owner.sessionId');
146
+ }
147
+ let event = {
148
+ instanceId: authoritativeInstanceId,
149
+ sequence: numericSequence,
150
+ sourceTimestamp,
151
+ receivedAt,
152
+ type,
153
+ component,
154
+ state,
155
+ reason,
156
+ owner: authoritativeOwner,
157
+ ownerEvidence: authoritativeOwner.clientId === 'unknown' || authoritativeOwner.sessionId === 'unknown'
158
+ ? 'unknown'
159
+ : 'observed',
160
+ missingEvidence,
161
+ details: redactValue(input?.details ?? {})
162
+ };
163
+ if (utf8Bytes(JSON.stringify(event)) > PWA_RUNTIME_DIAGNOSTIC_STORE_MAX_EVENT_UTF8_BYTES) {
164
+ event = { ...event, details: { truncated: true } };
165
+ }
166
+ return { ok: true, event: Object.freeze(event) };
167
+ }
168
+
169
+ /**
170
+ * In-memory Hub retention for authenticated PWA lifecycle diagnostics.
171
+ *
172
+ * The HTTP handler must pass the owner derived from its authenticated request
173
+ * context. Payload owner fields are only mismatch checks and never authority.
174
+ */
175
+ export function createPwaRuntimeDiagnosticStore(options = {}) {
176
+ const now = typeof options.now === 'function' ? options.now : () => Date.now();
177
+ const maxEventsPerOwner = boundedInteger(
178
+ options.maxEventsPerOwner,
179
+ PWA_RUNTIME_DIAGNOSTIC_STORE_MAX_EVENTS_PER_OWNER,
180
+ 1,
181
+ PWA_RUNTIME_DIAGNOSTIC_STORE_MAX_EVENTS_PER_OWNER
182
+ );
183
+ const maxTotalEvents = boundedInteger(
184
+ options.maxTotalEvents,
185
+ PWA_RUNTIME_DIAGNOSTIC_STORE_MAX_TOTAL_EVENTS,
186
+ 1,
187
+ PWA_RUNTIME_DIAGNOSTIC_STORE_MAX_TOTAL_EVENTS
188
+ );
189
+ const maxOwners = boundedInteger(
190
+ options.maxOwners,
191
+ PWA_RUNTIME_DIAGNOSTIC_STORE_MAX_OWNERS,
192
+ 1,
193
+ PWA_RUNTIME_DIAGNOSTIC_STORE_MAX_OWNERS
194
+ );
195
+ let events = [];
196
+ const ownerStats = new Map();
197
+ let hubDroppedTotal = 0;
198
+ let evictedOwnerCount = 0;
199
+
200
+ const incrementHubDrop = key => {
201
+ hubDroppedTotal += 1;
202
+ const stats = ownerStats.get(key);
203
+ if (stats) stats.hubDropped += 1;
204
+ };
205
+
206
+ const ensureOwnerStats = (key, owner, receivedAt) => {
207
+ let stats = ownerStats.get(key);
208
+ if (stats) {
209
+ stats.lastReceivedAt = receivedAt;
210
+ return stats;
211
+ }
212
+ if (ownerStats.size >= maxOwners) {
213
+ let oldestKey = null;
214
+ let oldestTimestamp = Number.POSITIVE_INFINITY;
215
+ for (const [candidateKey, candidate] of ownerStats) {
216
+ if (candidate.lastReceivedMs < oldestTimestamp) {
217
+ oldestTimestamp = candidate.lastReceivedMs;
218
+ oldestKey = candidateKey;
219
+ }
220
+ }
221
+ if (oldestKey !== null) {
222
+ const removedCount = events.filter(event => ownerKey(event.owner) === oldestKey).length;
223
+ events = events.filter(event => ownerKey(event.owner) !== oldestKey);
224
+ hubDroppedTotal += removedCount;
225
+ ownerStats.delete(oldestKey);
226
+ evictedOwnerCount += 1;
227
+ }
228
+ }
229
+ stats = {
230
+ owner,
231
+ clientDroppedTotal: 'unknown',
232
+ hubDropped: 0,
233
+ lastReceivedAt: receivedAt,
234
+ lastReceivedMs: Number(now()) || Date.now()
235
+ };
236
+ ownerStats.set(key, stats);
237
+ return stats;
238
+ };
239
+
240
+ const ingest = (payload, context = {}) => {
241
+ let serialized;
242
+ try {
243
+ serialized = JSON.stringify(payload);
244
+ } catch {
245
+ return Object.freeze({ accepted: false, error: 'invalid-json-payload' });
246
+ }
247
+ if (utf8Bytes(serialized) > PWA_RUNTIME_DIAGNOSTIC_STORE_MAX_BATCH_UTF8_BYTES) {
248
+ return Object.freeze({ accepted: false, error: 'batch-too-large' });
249
+ }
250
+ if (!payload || typeof payload !== 'object' || payload.schema !== PWA_RUNTIME_DIAGNOSTIC_STORE_SCHEMA) {
251
+ return Object.freeze({ accepted: false, error: 'invalid-schema' });
252
+ }
253
+ const instanceId = normalizeInstanceId(payload.instanceId);
254
+ if (!instanceId) {
255
+ return Object.freeze({ accepted: false, error: 'invalid-instance-id' });
256
+ }
257
+ if (!Array.isArray(payload.events) || payload.events.length > PWA_RUNTIME_DIAGNOSTIC_STORE_MAX_BATCH_EVENTS) {
258
+ return Object.freeze({ accepted: false, error: 'invalid-event-count' });
259
+ }
260
+ const authoritativeOwner = normalizeOwner(context.owner || {
261
+ clientId: context.authenticatedClientId,
262
+ sessionId: context.authenticatedSessionId
263
+ });
264
+ const claimedOwner = normalizeOwner(payload.owner);
265
+ if (claimedOwnerMismatch(claimedOwner, authoritativeOwner)) {
266
+ return Object.freeze({ accepted: false, error: 'batch-owner-mismatch' });
267
+ }
268
+ const receivedAt = new Date(Number(now()) || Date.now()).toISOString();
269
+ const normalizedEvents = [];
270
+ for (const input of payload.events) {
271
+ const normalized = normalizeEvent(input, authoritativeOwner, instanceId, receivedAt);
272
+ if (!normalized.ok) return Object.freeze({ accepted: false, error: normalized.error });
273
+ normalizedEvents.push(normalized.event);
274
+ }
275
+ const key = ownerKey(authoritativeOwner);
276
+ const stats = ensureOwnerStats(key, authoritativeOwner, receivedAt);
277
+ stats.lastReceivedMs = Number(now()) || Date.now();
278
+ const clientDroppedTotal = Number(payload?.droppedCount?.total);
279
+ if (Number.isSafeInteger(clientDroppedTotal) && clientDroppedTotal >= 0) {
280
+ stats.clientDroppedTotal = stats.clientDroppedTotal === 'unknown'
281
+ ? clientDroppedTotal
282
+ : Math.max(stats.clientDroppedTotal, clientDroppedTotal);
283
+ }
284
+
285
+ let acceptedEventCount = 0;
286
+ let duplicateEventCount = 0;
287
+ for (const event of normalizedEvents) {
288
+ const duplicate = events.some(existing => (
289
+ ownerKey(existing.owner) === key
290
+ && existing.instanceId === event.instanceId
291
+ && existing.sequence === event.sequence
292
+ ));
293
+ if (duplicate) {
294
+ duplicateEventCount += 1;
295
+ continue;
296
+ }
297
+ const ownerEventCount = events.reduce(
298
+ (count, existing) => count + (ownerKey(existing.owner) === key ? 1 : 0),
299
+ 0
300
+ );
301
+ if (ownerEventCount >= maxEventsPerOwner) {
302
+ const oldestOwnerIndex = events.findIndex(existing => ownerKey(existing.owner) === key);
303
+ if (oldestOwnerIndex >= 0) {
304
+ events.splice(oldestOwnerIndex, 1);
305
+ incrementHubDrop(key);
306
+ }
307
+ }
308
+ if (events.length >= maxTotalEvents) {
309
+ const removed = events.shift();
310
+ if (removed) incrementHubDrop(ownerKey(removed.owner));
311
+ }
312
+ events.push(event);
313
+ acceptedEventCount += 1;
314
+ }
315
+ return Object.freeze({
316
+ accepted: true,
317
+ owner: authoritativeOwner,
318
+ ownerEvidence: authoritativeOwner.clientId === 'unknown' || authoritativeOwner.sessionId === 'unknown'
319
+ ? 'unknown'
320
+ : 'observed',
321
+ acceptedEventCount,
322
+ duplicateEventCount,
323
+ retainedEventCount: events.length,
324
+ clientDroppedTotal: stats.clientDroppedTotal,
325
+ hubDroppedTotal
326
+ });
327
+ };
328
+
329
+ const list = ({ clientId, sessionId, limit = 200 } = {}) => {
330
+ const clientFilter = redactText(clientId, 160) || null;
331
+ const sessionFilter = redactText(sessionId, 160) || null;
332
+ const boundedLimit = boundedInteger(limit, 200, 1, 500);
333
+ const matched = events.filter(event => (
334
+ (!clientFilter || event.owner.clientId === clientFilter)
335
+ && (!sessionFilter || event.owner.sessionId === sessionFilter)
336
+ ));
337
+ const exactKey = clientFilter && sessionFilter ? ownerKey({ clientId: clientFilter, sessionId: sessionFilter }) : null;
338
+ const stats = exactKey ? ownerStats.get(exactKey) : null;
339
+ return Object.freeze({
340
+ schema: PWA_RUNTIME_DIAGNOSTIC_STORE_SCHEMA,
341
+ evidenceStatus: matched.length > 0 ? 'observed' : 'unknown',
342
+ queryOwner: Object.freeze({
343
+ clientId: clientFilter || 'unknown',
344
+ sessionId: sessionFilter || 'unknown'
345
+ }),
346
+ droppedCount: Object.freeze({
347
+ client: stats?.clientDroppedTotal ?? 'unknown',
348
+ hub: stats?.hubDropped ?? hubDroppedTotal
349
+ }),
350
+ events: Object.freeze(matched.slice(-boundedLimit).reverse())
351
+ });
352
+ };
353
+
354
+ const inspect = () => Object.freeze({
355
+ retainedEventCount: events.length,
356
+ retainedOwnerCount: ownerStats.size,
357
+ hubDroppedTotal,
358
+ evictedOwnerCount,
359
+ maxEventsPerOwner,
360
+ maxTotalEvents,
361
+ maxOwners
362
+ });
363
+
364
+ return Object.freeze({ ingest, list, inspect });
365
+ }