@stage5/lumine 0.3.14 → 0.3.16

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,271 @@
1
+ import { spawn } from "node:child_process";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import path from "node:path";
4
+
5
+ import { createCdpPipe, findChromeExecutable } from "./build-review.js";
6
+
7
+ // `lumine sponsor job preview` renders a Workshop job's saved draft from the
8
+ // Twinkle preview origin: the same version URL (with SDK injection and asset
9
+ // rewrites) that the requester's workspace "App preview" frame loads. It runs
10
+ // top-level in headless Chrome, without the Twinkle host page around it, so
11
+ // host-bridged SDK calls (sign-in identity, saves, rewards) are not exercised.
12
+
13
+ export const DEFAULT_PREVIEW_RENDER_MS = 8_000;
14
+ export const MAX_PREVIEW_RENDER_MS = 30_000;
15
+ const MAX_PREVIEW_LOG_LINES = 200;
16
+
17
+ export function normalizePreviewRenderMs(value) {
18
+ if (value === undefined || value === null || value === "") {
19
+ return DEFAULT_PREVIEW_RENDER_MS;
20
+ }
21
+ const parsed = Number(value);
22
+ if (
23
+ !Number.isSafeInteger(parsed) ||
24
+ parsed < 1_000 ||
25
+ parsed > MAX_PREVIEW_RENDER_MS
26
+ ) {
27
+ throw new Error(
28
+ `--wait-ms for a job preview must be an integer between 1000 and ${MAX_PREVIEW_RENDER_MS}.`,
29
+ );
30
+ }
31
+ return parsed;
32
+ }
33
+
34
+ // The preview URL carries a short-lived preview credential for the
35
+ // requester's workspace; nothing the CLI prints or stores keeps it.
36
+ export function redactPreviewCredential(value) {
37
+ // Plain (?buildApiToken=…), percent-encoded (buildApiToken%3D…, as in a
38
+ // nested URL) and double-encoded (%253D) forms all lose their value.
39
+ return String(value ?? "").replace(
40
+ /(buildApiToken(?:=|%3D|%253D))(?:[A-Za-z0-9._~+\/-]|%(?!26|2526|23|2523)[0-9A-Fa-f]{2})+/gi,
41
+ "$1<redacted>",
42
+ );
43
+ }
44
+
45
+ const PAGE_STATE_EXPRESSION = `(() => {
46
+ const body = document.body;
47
+ const visible = (el) => {
48
+ const rect = el.getBoundingClientRect();
49
+ const style = getComputedStyle(el);
50
+ return rect.width > 1 && rect.height > 1 && style.display !== 'none' &&
51
+ style.visibility !== 'hidden' && Number(style.opacity || 1) > 0;
52
+ };
53
+ const media = body
54
+ ? [...body.querySelectorAll('canvas, img, svg, video')].filter(visible).length
55
+ : 0;
56
+ const text = body ? String(body.innerText || '').replace(/\\s+/g, ' ').trim() : '';
57
+ return {
58
+ readyState: document.readyState,
59
+ title: document.title || '',
60
+ textLength: text.length,
61
+ textSample: text.slice(0, 160),
62
+ visibleMediaCount: media,
63
+ elementCount: body ? body.querySelectorAll('*').length : 0
64
+ };
65
+ })()`;
66
+
67
+ function remoteArgumentText(argument) {
68
+ if (Object.hasOwn(argument || {}, "value")) {
69
+ const value = argument.value;
70
+ return typeof value === "string" ? value : JSON.stringify(value);
71
+ }
72
+ return String(argument?.description || argument?.type || "");
73
+ }
74
+
75
+ function delay(milliseconds) {
76
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
77
+ }
78
+
79
+ async function waitForPageTarget(cdp) {
80
+ const deadline = Date.now() + 10_000;
81
+ while (Date.now() < deadline) {
82
+ const result = await cdp.send("Target.getTargets");
83
+ const page = (result.targetInfos || []).find(
84
+ (target) => target.type === "page",
85
+ );
86
+ if (page) return page;
87
+ await delay(100);
88
+ }
89
+ throw new Error("Chrome did not open a page for the job preview.");
90
+ }
91
+
92
+ export async function renderDraftPreview({
93
+ url,
94
+ outputDir,
95
+ waitMs = DEFAULT_PREVIEW_RENDER_MS,
96
+ browserPath = "",
97
+ }) {
98
+ let executable;
99
+ try {
100
+ executable = findChromeExecutable(browserPath);
101
+ } catch (error) {
102
+ return {
103
+ ok: false,
104
+ skipped: true,
105
+ reason: "browser_not_found",
106
+ message: String(error?.message || error),
107
+ };
108
+ }
109
+ const profileDir = mkdtempSync(path.join(outputDir, "browser-profile-"));
110
+ const screenshotPath = path.join(outputDir, "preview.png");
111
+ const child = spawn(
112
+ executable,
113
+ [
114
+ "--headless=new",
115
+ "--no-first-run",
116
+ "--no-default-browser-check",
117
+ "--disable-background-networking",
118
+ "--disable-component-update",
119
+ "--disable-sync",
120
+ "--hide-scrollbars",
121
+ "--mute-audio",
122
+ "--remote-debugging-pipe",
123
+ `--user-data-dir=${profileDir}`,
124
+ "--window-size=1280,900",
125
+ "about:blank",
126
+ ],
127
+ { stdio: ["ignore", "ignore", "ignore", "pipe", "pipe"] },
128
+ );
129
+ const closed = new Promise((resolve) => {
130
+ child.once("error", () => resolve({ code: -1 }));
131
+ child.once("close", (code) => resolve({ code: Number(code ?? -1) }));
132
+ });
133
+ const hardKill = setTimeout(() => child.kill("SIGTERM"), waitMs + 30_000);
134
+ const consoleLines = [];
135
+ const exceptions = [];
136
+ const failedRequests = [];
137
+ let documentStatus = null;
138
+ const requestUrls = new Map();
139
+ const push = (list, line) => {
140
+ if (list.length < MAX_PREVIEW_LOG_LINES) {
141
+ list.push(redactPreviewCredential(line).slice(0, 500));
142
+ }
143
+ };
144
+ try {
145
+ const cdp = createCdpPipe(child);
146
+ const enableDomains = (sessionId) =>
147
+ Promise.all([
148
+ cdp.send("Runtime.enable", {}, sessionId),
149
+ cdp.send("Log.enable", {}, sessionId),
150
+ cdp.send("Network.enable", {}, sessionId),
151
+ ]).catch(() => undefined);
152
+ cdp.onEvent((message) => {
153
+ const params = message.params || {};
154
+ if (message.method === "Runtime.consoleAPICalled") {
155
+ const values = (params.args || []).map(remoteArgumentText);
156
+ push(consoleLines, `[console.${params.type || "log"}] ${values.join(" ")}`);
157
+ } else if (message.method === "Runtime.exceptionThrown") {
158
+ const details = params.exceptionDetails || {};
159
+ push(
160
+ exceptions,
161
+ details.exception?.description || details.text || "Uncaught exception",
162
+ );
163
+ } else if (message.method === "Log.entryAdded") {
164
+ const entry = params.entry || {};
165
+ if (entry.level === "error" || entry.level === "warning") {
166
+ push(
167
+ consoleLines,
168
+ `[${entry.level}] ${entry.text || ""}${entry.url ? ` (${entry.url})` : ""}`,
169
+ );
170
+ }
171
+ } else if (message.method === "Network.requestWillBeSent") {
172
+ if (params.requestId && requestUrls.size < 2_000) {
173
+ requestUrls.set(params.requestId, String(params.request?.url || ""));
174
+ }
175
+ } else if (message.method === "Network.responseReceived") {
176
+ const response = params.response || {};
177
+ const status = Number(response.status || 0);
178
+ if (params.type === "Document" && documentStatus === null) {
179
+ documentStatus = status;
180
+ }
181
+ if (status >= 400) push(failedRequests, `${status} ${response.url || ""}`);
182
+ } else if (message.method === "Network.loadingFailed") {
183
+ if (!params.canceled) {
184
+ push(
185
+ failedRequests,
186
+ `${params.errorText || "failed"} (${params.type || "request"}) ${requestUrls.get(params.requestId) || ""}`.trim(),
187
+ );
188
+ }
189
+ } else if (message.method === "Target.attachedToTarget") {
190
+ const sessionId = String(params.sessionId || "");
191
+ if (sessionId) void enableDomains(sessionId);
192
+ }
193
+ });
194
+ const target = await waitForPageTarget(cdp);
195
+ const attached = await cdp.send("Target.attachToTarget", {
196
+ targetId: target.targetId,
197
+ flatten: true,
198
+ });
199
+ const sessionId = String(attached.sessionId || "");
200
+ if (!sessionId) throw new Error("Chrome did not attach to the preview page.");
201
+ await cdp.send("Page.enable", {}, sessionId);
202
+ await enableDomains(sessionId);
203
+ await cdp.send(
204
+ "Target.setAutoAttach",
205
+ { autoAttach: true, waitForDebuggerOnStart: false, flatten: true },
206
+ sessionId,
207
+ );
208
+ await cdp.send("Page.navigate", { url }, sessionId);
209
+ await delay(waitMs);
210
+ const evaluated = await cdp.send(
211
+ "Runtime.evaluate",
212
+ { expression: PAGE_STATE_EXPRESSION, returnByValue: true },
213
+ sessionId,
214
+ );
215
+ const page = evaluated.result?.value || null;
216
+ const screenshot = await cdp.send(
217
+ "Page.captureScreenshot",
218
+ { format: "png", fromSurface: true, captureBeyondViewport: false },
219
+ sessionId,
220
+ );
221
+ let screenshotBytes = 0;
222
+ if (screenshot.data) {
223
+ const buffer = Buffer.from(screenshot.data, "base64");
224
+ writeFileSync(screenshotPath, buffer);
225
+ screenshotBytes = buffer.length;
226
+ }
227
+ await cdp.send("Browser.close").catch(() => undefined);
228
+ await Promise.race([closed, delay(5_000)]);
229
+ const blank =
230
+ !page || (Number(page.textLength || 0) === 0 &&
231
+ Number(page.visibleMediaCount || 0) === 0);
232
+ const problems = [
233
+ ...(documentStatus !== null && documentStatus >= 400
234
+ ? [`The preview document answered HTTP ${documentStatus}.`]
235
+ : []),
236
+ ...(exceptions.length > 0
237
+ ? [`${exceptions.length} uncaught exception(s) while the draft started.`]
238
+ : []),
239
+ ...(blank ? ["The page shows no text, canvas, image, svg or video after the wait."] : []),
240
+ ];
241
+ return {
242
+ ok: problems.length === 0,
243
+ skipped: false,
244
+ waitMs,
245
+ documentStatus,
246
+ page,
247
+ problems,
248
+ exceptions,
249
+ console: consoleLines,
250
+ failedRequests,
251
+ screenshot: screenshotBytes > 0
252
+ ? { path: screenshotPath, bytes: screenshotBytes }
253
+ : null,
254
+ };
255
+ } catch (error) {
256
+ return {
257
+ ok: false,
258
+ skipped: false,
259
+ error: redactPreviewCredential(String(error?.message || error)),
260
+ exceptions,
261
+ console: consoleLines,
262
+ failedRequests,
263
+ screenshot: null,
264
+ };
265
+ } finally {
266
+ clearTimeout(hardKill);
267
+ if (child.exitCode === null && child.signalCode === null) child.kill("SIGTERM");
268
+ await Promise.race([closed, delay(5_000)]);
269
+ rmSync(profileDir, { recursive: true, force: true });
270
+ }
271
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.3.14",
3
+ "version": "0.3.16",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -198,6 +198,12 @@ IP evidence, private messages, or unrelated activity. `--include-private-evidenc
198
198
  adds DOB values and verified email addresses only; exact device IDs are never
199
199
  returned.
200
200
 
201
+ The CLI sends `identity inspect` and `identity network` as reads, so it keeps
202
+ no copy of the result on disk; the server still records who looked, when and
203
+ why, one audit row per call. When a case needs a kept copy (a bullying or police
204
+ matter, say), add `--output <file>`: the result is written there with
205
+ owner-only (0600) permissions, and nowhere else.
206
+
201
207
  Every inspection requires a concrete `--reason`. Before loading the evidence,
202
208
  the API commits a private `identity.inspect` audit receipt containing the real
203
209
  operator, requested target, reason, and whether private evidence was requested.
@@ -1176,7 +1182,7 @@ lumine admin identity network <userId|username> --reason <text> [--include-priva
1176
1182
  **Network evidence** (`users_actions` addresses, added 2026-10-04): the account's most recently used addresses (up to 8), and for each one who else acted from it.
1177
1183
  - An address more than 8 other accounts use is a **shared network** (an academy, a school, a café): it is only counted, never listed, because it links strangers. Teachers and students at the same academy share one.
1178
1184
  - An address 8 or fewer other accounts use is a **small network** (a home): every account on it is listed with its age, role and account type. The person's own linked accounts (email, device, bucket) are marked `their linked account` and never raise a flag; logged-out visits are not accounts. If the addresses cannot be read (time cap), the pack still answers, with `network_evidence_unavailable`.
1179
- - Addresses are masked (`58.151.x.59`) with a stable `addressId` for comparing reports; `--include-private-evidence` on `identity network` shows the full address. `identity network` commits an access receipt with your reason before reading anything, like `identity inspect`.
1185
+ - Addresses are masked (`58.151.x.59`) with a stable `addressId` for comparing reports; `--include-private-evidence` on `identity network` shows the full address. `identity network` commits a server-side access audit row with your reason before reading anything, like `identity inspect`; neither leaves a local copy unless you pass `--output <file>`.
1180
1186
  - Only each address's newest 5,000 actions and each person's newest 5,000 are read (index-bounded, replica, time-capped), so `older history not read` can appear.
1181
1187
 
1182
1188
  **Flags.** A linked account counts as a *minor* only with a recorded age under 18, and as a *member* when it holds no teacher/staff role (most accounts record no age, so "member" does not mean student):
@@ -1362,6 +1368,28 @@ those exact files and does not depend on keeping the review copy. Re-offers
1362
1368
  reuse the media; a failed transaction cleans up its copied objects. Declining
1363
1369
  leaves the offered media as unused uploads in the creator's library.
1364
1370
 
1371
+ ### Owner client trace (any time, read-only; added 2026-10-06)
1372
+
1373
+ When Mikey reports a UI issue from his phone, read what his browser did
1374
+ before guessing. Only the owner's own signed-in browser records it (everyone
1375
+ else's is a no-op and the upload route refuses them); the server keeps 30 days.
1376
+
1377
+ ```bash
1378
+ lumine admin owner-trace --since 30m # readable timeline, oldest first
1379
+ lumine admin owner-trace --since 2026-10-06T09:00 --type route,feed-open
1380
+ lumine admin owner-trace --path /comments/123 --json # raw rows
1381
+ ```
1382
+
1383
+ Event types: `session` (version, viewport, platform, PWA or tab), `route`
1384
+ (PUSH/POP/REPLACE, previous path, Home feed navigation/intent ids and action),
1385
+ `feed-open` (content type and id, open/comment/reward/recommend, how it was
1386
+ triggered and the tapped element's tag, card position), `scroll-anchor` (the
1387
+ scroll-anchor diagnostics: save/restore/cancel with scroll numbers),
1388
+ `content-scroll` (scrollTop at mount, when the content is ready, and 1 s/3 s
1389
+ later), `error`/`rejection` (short message and source file), `visibility`,
1390
+ `pagehide`, `trace-dropped`. Times are the phone's clock in this machine's
1391
+ time zone. Ids and paths only, never message text or other members' content.
1392
+
1365
1393
  ### Reward question-bank seeding (every full daily review; added 2026-09-28)
1366
1394
 
1367
1395
  `generated-quiz` rules fill a question bank on the server: a question is