@stage5/lumine 0.3.14 → 0.3.17

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.17",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2,7 +2,7 @@
2
2
 
3
3
  Version: 1.63.0
4
4
  Updated: 2026-09-28
5
- Generated: 2026-10-05T14:12:15.800Z
5
+ Generated: 2026-10-06T10:12:09.371Z
6
6
 
7
7
  ## Notes
8
8
  - This SDK is injected into Build iframes via the Build preview/runtime.
@@ -1527,19 +1527,19 @@ world.updatePresence({ x, y, z, facing });
1527
1527
  - Requires an open challenge of the learner's own (build_reward_challenge_not_open, HTTP 409 otherwise). Limit: 120 characters (build_reward_answer_text_invalid, 400), rate limited to a burst of 4 then one read every 5 seconds (build_reward_throttled, 429). build_reward_answer_reader_unavailable (503) means the reader is down: ask the learner to type just the number.
1528
1528
  - Drafts and owner previews return mode 'preview' and need no open challenge.
1529
1529
  - await Twinkle.rewards.getChallengeBoard() | scopes: rewards:claim
1530
- - Returns: { mode: "live", dayKey, puzzles: [{ ruleId, title, setKey, rating, solvers, failers, failures, daysStanding, status: "untouched" | "undefeated" | "defeated" }], topSolvers: [{ rank, userId, username, profilePicUrl, rating, solves, hardestSolve: { ruleId, setKey, rating } | null }], recentSolves: [{ id, userId, username, ruleId, setKey, tier: "solved" | "first-solve" | "hard-won" | "legendary", attempt, firstSolve, failersBefore, failuresBefore, daysStanding, puzzleRating, at }], me: { rating, solves, rank } | null } | { mode: "preview", puzzles: [], topSolvers: [], recentSolves: [], me: null, message }
1530
+ - Returns: { mode: "live", dayKey, puzzles: [{ ruleId, title, setKey, rating, solvers, failers, failures?, daysStanding, status: "untouched" | "undefeated" | "defeated" }], topSolvers: [{ rank, userId, username, profilePicUrl, rating, solves, hardestSolve: { ruleId, setKey, rating } | null }], recentSolves: [{ id, userId, username, ruleId, setKey, tier: "solved" | "first-solve" | "hard-won" | "legendary", firstTry, firstSolve, failersBefore, daysStanding, puzzleRating, at }], me: { rating, solves, rank } | null } | { mode: "preview", puzzles: [], topSolvers: [], recentSolves: [], me: null, message }
1531
1531
  - Front-page difficulty board for until-earned quiz bounties: what is being tried and failed, who is rated highest, and the latest defeats.
1532
1532
  - Computed by Twinkle from its own receipts and attempts, never from anything the app submits. Puzzles are Elo-rated against players: a player's first miss on a puzzle is a win for the puzzle, a solve is a win for the player, and repeat misses by one player never stack.
1533
- - puzzles lists today's set for each until-earned rule. status 'undefeated' means someone tried and nobody has solved it yet; show it as a standing challenge. failers is how many different players missed it; failures is total wrong answers.
1534
- - tier on a recent solve says how loudly to celebrate: 'legendary' (first solve of a long-undefeated puzzle), 'hard-won', 'first-solve', or plain 'solved'. It is fixed at solve time, so it never changes later.
1533
+ - puzzles lists today's set for each until-earned rule. status 'undefeated' means someone tried and nobody has solved it yet; show it as a standing challenge. failers is how many different players missed it; failures is total wrong answers, sent only while the puzzle is still unsolved (absent on defeated puzzles, so a solver's own misses cannot be worked out).
1534
+ - tier on a recent solve says how loudly to celebrate: 'legendary' (first solve of a long-undefeated puzzle), 'hard-won', 'first-solve', or plain 'solved'. It is fixed at solve time, so it never changes later. firstTry is true when the solver got it on the first try; which try it took and the solver's own wrong answers are never shown to other players, so never reconstruct them.
1535
1535
  - No questions, answers, guides or private work are ever returned. Names are the same public names the XP leaderboard shows; other players who missed a puzzle are counted, never named. Drafts and previews return mode 'preview' with empty lists.
1536
1536
  - await Twinkle.rewards.getTimeline({ ruleId?, cursor?, limit? } = {}) | scopes: rewards:claim
1537
- - Returns: { mode: "live", dayKey, entries: [{ receiptId, ruleId, ruleTitle, setKey, title, promptPreview, dayKey, closedAt, solvedAt, solver: { userId, username }, firstSolver, xp, coins, attempt, story: { tier, attempt, failersBefore, failuresBefore, daysStanding } | null, puzzle: { rating, solvers, failers, failures } | null, vault: { rating, solvers, failers, failures } | null, rematchOpen }], nextCursor } | { mode: "preview", entries: [], nextCursor: null, message }
1537
+ - Returns: { mode: "live", dayKey, entries: [{ receiptId, ruleId, ruleTitle, setKey, title, promptPreview, dayKey, closedAt, solvedAt, solver: { userId, username }, firstSolver, xp, coins, firstTry, story: { tier, firstTry, failersBefore, daysStanding } | null, puzzle: { rating, solvers, failers } | null, vault: { rating, solvers, failers, failures } | null, rematchOpen }], nextCursor } | { mode: "preview", entries: [], nextCursor: null, message }
1538
1538
  - Browse confirmed solves of retired until-earned quiz bounties, newest first.
1539
1539
  - A solve appears only after the next site reset (UTC midnight, 9 AM Korea). Today’s solves, unsolved sets, standing quizzes and dated quizzes are excluded. The server decides retirement; client dates cannot unlock content. Zero-reward correct solves are included.
1540
1540
  - One entry per solve receipt, with firstSolver identifying the first receipt for that rule and set across approved versions. limit defaults to 20, max 50; pass nextCursor unchanged for older solves and omit it when changing ruleId. Pages may have fewer entries when old question sheets cannot be recovered; continue while nextCursor is present.
1541
1541
  - No daily-claim limit applies to reading. Requires the current approved published runtime grant; previews return an empty timeline. Never awards or changes balances. Open a receipt with getArchivedProblem to load the original question and guide.
1542
- - Museum fields: story is how THAT solve went, fixed at solve time (tier 'solved' | 'first-solve' | 'hard-won' | 'legendary', the solver's attempt, how many other players had missed it and how often, days it stood); null for receipts older than solve logging. puzzle is the official challenge rating and counts now; vault is the puzzle's living practice stats (its own rating, practice solvers, players whose first practice answer missed, all wrong practice answers), or null before anyone practised it. Counts only: players who missed are never named.
1542
+ - Museum fields: story is how THAT solve went, fixed at solve time (tier 'solved' | 'first-solve' | 'hard-won' | 'legendary', firstTry: whether the solver got it on the first try, never which try, how many other players had missed it, days it stood); null for receipts older than solve logging. puzzle is the official challenge rating and counts now (no total wrong answers: it is solved, and that total would give away the solver's own misses); vault is the puzzle's living practice stats (its own rating, practice solvers, players whose first practice answer missed, all wrong practice answers), or null before anyone practised it. Counts only: players who missed are never named.
1543
1543
  - rematchOpen is true on the entry that is today's daily rematch while the viewer can still answer it for pay: label it and open it as the rematch, not as a lesson. Daily-rematch receipts are never timeline entries.
1544
1544
  - await Twinkle.rewards.getArchivedProblem({ receiptId, withGuide?: true }) | scopes: rewards:claim
1545
1545
  - Returns: { mode: "live", entry: <same solve entry as getTimeline, with rematchOpen>, questions: [{ prompt, hint?, guide? }], rematchOpen } | { mode: "preview", entry: null, questions: [], message }
@@ -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,40 @@ 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
+ lumine admin owner-trace --since 1h --perf # performance events only
1382
+ ```
1383
+
1384
+ Event types: `session` (version, viewport, platform, PWA or tab), `route`
1385
+ (PUSH/POP/REPLACE, previous path, Home feed navigation/intent ids and action),
1386
+ `feed-open` (content type and id, open/comment/reward/recommend, how it was
1387
+ triggered and the tapped element's tag, card position), `scroll-anchor` (the
1388
+ scroll-anchor diagnostics: save/restore/cancel with scroll numbers),
1389
+ `content-scroll` (scrollTop at mount, when the content is ready, and 1 s/3 s
1390
+ later), `error`/`rejection` (short message and source file), `visibility`,
1391
+ `pagehide`, `trace-dropped`. Times are the phone's clock in this machine's
1392
+ time zone. Ids and paths only, never message text or other members' content.
1393
+
1394
+ Performance events (`--perf` shows only these): `nav-timing` (one per
1395
+ navigation: ms from the tap to the URL change, the deploy-probe gate release
1396
+ with the probe's outcome current/armed/timeout/failed/throttled/backoff/pending
1397
+ and how long the tap waited for it, the route commit and, on content pages,
1398
+ content-ready; `cut by next-nav` means another navigation started before this
1399
+ one committed), `slow-request` (API requests over 1 s: method, path pattern
1400
+ with query keys only, status, ms, scheduler queue time), `stall` (main-thread
1401
+ gaps over 200 ms while the page is visible, bursts within 1 s coalesced) and
1402
+ `page-load` (once per load: TTFB, DOMContentLoaded, load, first route ready,
1403
+ ms since navigation start).
1404
+
1365
1405
  ### Reward question-bank seeding (every full daily review; added 2026-09-28)
1366
1406
 
1367
1407
  `generated-quiz` rules fill a question bank on the server: a question is