@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.
- package/lib/admin-owner-trace.js +160 -0
- package/lib/admin.js +62 -13
- package/lib/build-review.js +2 -2
- package/lib/commands.js +15 -4
- package/lib/sponsor-duty.js +1201 -221
- package/lib/sponsor-preview.js +271 -0
- package/package.json +1 -1
- package/sdk/LUMINE_ADMIN.md +29 -1
|
@@ -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
package/sdk/LUMINE_ADMIN.md
CHANGED
|
@@ -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
|
|
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
|