@staix/agent-console 0.0.0-stage → 0.1.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/CHANGELOG.md +41 -0
- package/LICENSE +21 -0
- package/README.md +321 -2
- package/bin/acon.js +8 -0
- package/package.json +38 -4
- package/shell/icon.svg +1 -0
- package/shell/index.html +52 -0
- package/shell/shell.css +534 -0
- package/shell/shell.js +2106 -0
- package/shell/space-view.js +164 -0
- package/shell/space.js +241 -0
- package/shell/space3d.js +364 -0
- package/shell/vendor/BufferGeometryUtils.js +1523 -0
- package/shell/vendor/GLTFLoader.js +5006 -0
- package/shell/vendor/NOTICE +30 -0
- package/shell/vendor/SkeletonUtils.js +496 -0
- package/shell/vendor/manifest.json +42 -0
- package/shell/vendor/mermaid/NOTICE +25 -0
- package/shell/vendor/mermaid/manifest.json +19 -0
- package/shell/vendor/mermaid/mermaid.js +3580 -0
- package/shell/vendor/three.core.js +61162 -0
- package/shell/vendor/three.module.js +19537 -0
- package/shell/visual-frame.css +5 -0
- package/shell/visual-frame.html +14 -0
- package/shell/visual-frame.js +54 -0
- package/src/assets.ts +200 -0
- package/src/canonical.ts +16 -0
- package/src/cli.ts +155 -0
- package/src/conformance.ts +299 -0
- package/src/contract.ts +276 -0
- package/src/install.ts +248 -0
- package/src/pets.ts +63 -0
- package/src/plugins.ts +357 -0
- package/src/serve.ts +175 -0
- package/src/server.ts +429 -0
- package/src/space.ts +106 -0
- package/test/fixtures/review/cases.json +726 -0
package/src/server.ts
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { canonical } from "./canonical.ts";
|
|
4
|
+
import { ACTION_ID, type ActionMeta, type ActionResult } from "./contract.ts";
|
|
5
|
+
import { ASSET_TYPES, assetResultProblems, TimeoutError, withTimeout, type PluginEntry } from "./plugins.ts";
|
|
6
|
+
import type { PetPackage } from "./pets.ts";
|
|
7
|
+
import { readValidatedImage, readValidatedModel, type AssetPack } from "./assets.ts";
|
|
8
|
+
import { sanitizeSpaceScene } from "./space.ts";
|
|
9
|
+
import type { ServeOptions } from "./serve.ts";
|
|
10
|
+
|
|
11
|
+
export interface ServerOptions {
|
|
12
|
+
plugins: PluginEntry[];
|
|
13
|
+
/**
|
|
14
|
+
* Re-scans the plugin directory (#26), reusing the given ready entries. `/tools` calls it at most every 5 s; a newly
|
|
15
|
+
* registered version serves pages loaded after it, while open pages keep the ref they started with.
|
|
16
|
+
*/
|
|
17
|
+
reload?: (previous: PluginEntry[]) => Promise<PluginEntry[]>;
|
|
18
|
+
version: string;
|
|
19
|
+
pluginsDir: string;
|
|
20
|
+
pluginsError?: string;
|
|
21
|
+
/** Local pet packages (src/pets.ts); problems are listed in System. */
|
|
22
|
+
pets?: PetPackage[];
|
|
23
|
+
petProblems?: string[];
|
|
24
|
+
assetPacks?: AssetPack[];
|
|
25
|
+
assetProblems?: string[];
|
|
26
|
+
now?: () => number;
|
|
27
|
+
idleMs?: number;
|
|
28
|
+
/** Limit for one provider call; 30 s by default. */
|
|
29
|
+
callMs?: number;
|
|
30
|
+
onIdle?: () => void;
|
|
31
|
+
log?: (line: string) => void;
|
|
32
|
+
/** Largest plugin asset served on GET /p/<tool>/asset; 32 MB by default. */
|
|
33
|
+
assetMaxBytes?: number;
|
|
34
|
+
/** H1 (`acon serve`): fixed bind, allowed hosts and origins, per-request authentication, no ticket and no idle exit. */
|
|
35
|
+
serve?: ServeOptions;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ConsoleError { at: string; tool: string; where: string; message: string }
|
|
39
|
+
|
|
40
|
+
const TICKET_MS = 60_000;
|
|
41
|
+
const SESSION_MS = 60 * 60_000;
|
|
42
|
+
const CONFIRM_MS = 60_000;
|
|
43
|
+
// The process exits within 5 minutes of the last request: 4 min 55 s idle plus at most 5 s of provider close().
|
|
44
|
+
const IDLE_MS = 5 * 60_000 - 5_000;
|
|
45
|
+
const CALL_MS = 30_000;
|
|
46
|
+
const RESCAN_MS = 5_000;
|
|
47
|
+
const LIMIT = 64;
|
|
48
|
+
const ERRORS = 50;
|
|
49
|
+
const RESERVED = ["confirmed", "fingerprint"];
|
|
50
|
+
const secret = () => randomBytes(32).toString("hex");
|
|
51
|
+
const asset = (name: string) => readFileSync(new URL(`../shell/${name}`, import.meta.url), "utf8");
|
|
52
|
+
|
|
53
|
+
const CSP = "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'none'; frame-src 'self'; frame-ancestors 'none'";
|
|
54
|
+
const ASSET_INLINE = new Set(["application/pdf", "image/png", "image/jpeg", "text/plain", "text/csv"]);
|
|
55
|
+
// Assets may be framed by the shell (PDF viewer), never by another origin, and load nothing themselves.
|
|
56
|
+
const ASSET_CSP = "default-src 'none'; frame-ancestors 'self'";
|
|
57
|
+
const ASSET_ID_MAX = 512;
|
|
58
|
+
// Cut by code points, never inside a surrogate pair, so encodeURIComponent cannot throw on the result.
|
|
59
|
+
const codePoints = (name: string, max: number) => Array.from(name).slice(0, max).join("");
|
|
60
|
+
const fileName = (name: string) => codePoints(name.replace(/[^A-Za-z0-9._ -]/gu, "_"), 120) || "asset";
|
|
61
|
+
/**
|
|
62
|
+
* CSP for the sandboxed Mermaid frame (#29): its own scripts and /visual/frame.css only, data: images, no connect-src
|
|
63
|
+
* (so no fetch or XHR), and only the console may frame it. style-src keeps 'unsafe-inline' for Mermaid alone: during
|
|
64
|
+
* render() it styles its own temporary measuring element and the SVG's <style>, and under style-src 'self' those
|
|
65
|
+
* are blocked, so text is measured in the wrong font and labels are laid out too narrow (checked in Chromium:
|
|
66
|
+
* 901 vs 918 px for the demo flowchart). The frame's own code sets no inline styles (test/shell.test.ts asserts it).
|
|
67
|
+
*/
|
|
68
|
+
export const FRAME_CSP = "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src data:; font-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'";
|
|
69
|
+
const HEADERS = {
|
|
70
|
+
"Cache-Control": "no-store",
|
|
71
|
+
"Referrer-Policy": "no-referrer",
|
|
72
|
+
"X-Content-Type-Options": "nosniff",
|
|
73
|
+
"X-Frame-Options": "DENY",
|
|
74
|
+
"Content-Security-Policy": CSP,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* H0 local host. Ticket, session cookie and Host/Origin checks follow STAIxBWLB/agent-hub src/hub/ui.ts
|
|
79
|
+
* (startDashboard), copied rather than imported so the console has no runtime dependency.
|
|
80
|
+
*/
|
|
81
|
+
export function startServer(options: ServerOptions) {
|
|
82
|
+
const now = options.now ?? Date.now;
|
|
83
|
+
const idleMs = options.idleMs ?? IDLE_MS;
|
|
84
|
+
const callMs = options.callMs ?? CALL_MS;
|
|
85
|
+
const log = options.log ?? ((line: string) => console.error(`acon: ${line}`));
|
|
86
|
+
const startedAt = new Date(now()).toISOString();
|
|
87
|
+
const serve = options.serve;
|
|
88
|
+
const shell = { "/": ["index.html", "text/html; charset=utf-8"], "/shell.js": ["shell.js", "text/javascript; charset=utf-8"], "/space.js": ["space.js", "text/javascript; charset=utf-8"], "/space-view.js": ["space-view.js", "text/javascript; charset=utf-8"], "/space3d.js": ["space3d.js", "text/javascript; charset=utf-8"], "/shell.css": ["shell.css", "text/css; charset=utf-8"], "/icon.svg": ["icon.svg", "image/svg+xml"] } as const;
|
|
89
|
+
const files: Record<string, { body: string; type: string; frame?: true }> = Object.fromEntries(Object.entries(shell).map(([path, [name, type]]) => [path, { body: asset(name), type }]));
|
|
90
|
+
// Sandboxed visual frame: fixed files only, served with FRAME_CSP and framable by the console itself.
|
|
91
|
+
files["/visual/frame"] = { body: asset("visual-frame.html"), type: "text/html; charset=utf-8", frame: true };
|
|
92
|
+
files["/visual/frame.css"] = { body: asset("visual-frame.css"), type: "text/css; charset=utf-8", frame: true };
|
|
93
|
+
files["/visual/frame.js"] = { body: asset("visual-frame.js"), type: "text/javascript; charset=utf-8", frame: true };
|
|
94
|
+
files["/visual/mermaid.js"] = { body: readFileSync(new URL("../shell/vendor/mermaid/mermaid.js", import.meta.url), "utf8"), type: "text/javascript; charset=utf-8", frame: true };
|
|
95
|
+
// Fixed vendored module allowlist, never a request-derived filesystem path.
|
|
96
|
+
for (const name of ["three.module.js", "three.core.js", "GLTFLoader.js", "BufferGeometryUtils.js", "SkeletonUtils.js"]) files[`/vendor/${name}`] = { body: asset(`vendor/${name}`), type: "text/javascript; charset=utf-8" };
|
|
97
|
+
// tools: the current load per tool, for new pages. refs: every load since start, so an open page keeps its version.
|
|
98
|
+
// A tool whose registration later goes stale or disappears keeps its current load until the console restarts.
|
|
99
|
+
const tools = new Map<string, PluginEntry>();
|
|
100
|
+
const refs = new Map<string, PluginEntry>();
|
|
101
|
+
let entries: PluginEntry[] = [];
|
|
102
|
+
const adopt = (list: PluginEntry[]) => {
|
|
103
|
+
for (const p of list) if (p.status === "ready") { refs.set(p.ref ?? p.tool, p); tools.set(p.tool, p); }
|
|
104
|
+
entries = list;
|
|
105
|
+
};
|
|
106
|
+
adopt(options.plugins);
|
|
107
|
+
const reported = new Set<string>();
|
|
108
|
+
let scannedAt = now();
|
|
109
|
+
let scanning: Promise<void> | undefined;
|
|
110
|
+
const refresh = () => {
|
|
111
|
+
if (!options.reload || scanning || now() - scannedAt < RESCAN_MS) return scanning;
|
|
112
|
+
scannedAt = now();
|
|
113
|
+
return scanning = options.reload([...refs.values()]).then((list) => {
|
|
114
|
+
for (const p of list) {
|
|
115
|
+
// A tool that still serves an earlier load would hide this problem in the tool list; say it in System once.
|
|
116
|
+
if (p.status === "ready" || p.status === "superseded" || !tools.has(p.tool)) continue;
|
|
117
|
+
const key = `${p.file}\n${p.reason}`;
|
|
118
|
+
if (!reported.has(key)) { reported.add(key); record(p.tool, "plugin re-scan", new Error(`${p.status}: ${p.reason}; the current load keeps serving`)); }
|
|
119
|
+
}
|
|
120
|
+
// A deleted registration: the load keeps serving this process, but it will not load after a restart.
|
|
121
|
+
for (const tool of tools.keys()) {
|
|
122
|
+
const key = `${tool}\nremoved`;
|
|
123
|
+
if (list.some((p) => p.tool === tool)) reported.delete(key);
|
|
124
|
+
else if (!reported.has(key)) { reported.add(key); record(tool, "plugin re-scan", new Error(`no registration is left in ${options.pluginsDir}; the current load keeps serving until acon restarts and will not load after that; run \`${tool} setup\` to register it again`)); }
|
|
125
|
+
}
|
|
126
|
+
adopt(list);
|
|
127
|
+
}, (error) => record("acon", "plugin re-scan", error)).finally(() => { scanning = undefined; });
|
|
128
|
+
};
|
|
129
|
+
const tickets = new Map<string, { expires: number }>();
|
|
130
|
+
const sessions = new Map<string, { expires: number }>();
|
|
131
|
+
const confirms = new Map<string, { expires: number; session: string; tool: string; input: string; fingerprint: string }>();
|
|
132
|
+
const errors: ConsoleError[] = [];
|
|
133
|
+
let lastSeen = now();
|
|
134
|
+
const pets = options.pets ?? [];
|
|
135
|
+
const assetPacks = options.assetPacks ?? [];
|
|
136
|
+
const assetMaxBytes = options.assetMaxBytes ?? 32 * 1024 * 1024;
|
|
137
|
+
|
|
138
|
+
const record = (tool: string, where: string, error: unknown) => {
|
|
139
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
140
|
+
log(`${tool} ${where} failed: ${message}`);
|
|
141
|
+
errors.unshift({ at: new Date(now()).toISOString(), tool, where, message: message.split("\n")[0]!.slice(0, 200) });
|
|
142
|
+
errors.length = Math.min(errors.length, ERRORS);
|
|
143
|
+
};
|
|
144
|
+
for (const problem of options.petProblems ?? []) record("acon", "pet package skipped", new Error(problem));
|
|
145
|
+
for (const problem of options.assetProblems ?? []) record("acon", "asset pack skipped", new Error(problem));
|
|
146
|
+
const json = (body: unknown, status = 200, extra: Record<string, string> = {}) =>
|
|
147
|
+
new Response(JSON.stringify(body), { status, headers: { ...HEADERS, "Content-Type": "application/json", ...extra } });
|
|
148
|
+
const reject = (status: number, error: string, extra: Record<string, unknown> = {}) => json({ ok: false, error, ...extra }, status);
|
|
149
|
+
const prune = (map: Map<string, { expires: number }>) => { for (const [key, entry] of map) if (entry.expires <= now()) map.delete(key); };
|
|
150
|
+
const cap = (map: Map<string, unknown>) => { if (map.size >= LIMIT) map.delete(map.keys().next().value!); };
|
|
151
|
+
const cookieOf = (req: Request) => req.headers.get("cookie")?.split(";").map((v) => v.trim()).find((v) => v.startsWith(`${cookieName}=`))?.slice(cookieName.length + 1);
|
|
152
|
+
const call = <T>(tool: string, where: string, run: () => Promise<T>) => withTimeout(run(), callMs, `${tool} ${where}`);
|
|
153
|
+
|
|
154
|
+
const toolList = () => [...new Set([...entries.map((p) => p.tool), ...tools.keys()])]
|
|
155
|
+
.map((name) => tools.get(name) ?? entries.find((p) => p.tool === name && p.status !== "superseded") ?? entries.find((p) => p.tool === name)!)
|
|
156
|
+
.map((p) => p.status === "ready" && p.manifest
|
|
157
|
+
? { tool: p.tool, ref: p.ref ?? p.tool, status: p.status, title: p.manifest.title, version: p.manifest.version, icon: p.manifest.icon, panels: p.manifest.panels, pollMs: p.manifest.pollMs, commands: p.manifest.commands ?? [], capabilities: p.manifest.capabilities ?? {} }
|
|
158
|
+
: { tool: p.tool, status: p.status, reason: p.reason, file: p.file });
|
|
159
|
+
|
|
160
|
+
async function snapshot(entry: PluginEntry, input: Record<string, unknown>) {
|
|
161
|
+
const caps = entry.manifest!.capabilities ?? {};
|
|
162
|
+
const panel = input.panel;
|
|
163
|
+
const allowed = entry.manifest!.panels.some((p) => p.id === panel) || (panel === "inbox" && caps.inbox) || (panel === "search" && caps.search) || (panel === "space" && caps.space === true);
|
|
164
|
+
if (typeof panel !== "string" || !allowed) return reject(400, "unknown panel");
|
|
165
|
+
const after = input.after ?? 0;
|
|
166
|
+
if (typeof after !== "number" || !Number.isSafeInteger(after) || after < 0) return reject(400, "invalid cursor");
|
|
167
|
+
if (panel === "search") {
|
|
168
|
+
if (typeof input.q !== "string" || input.q.length > 200) return reject(400, "q must be a string of at most 200 characters");
|
|
169
|
+
const limit = input.limit ?? 10;
|
|
170
|
+
if (typeof limit !== "number" || !Number.isSafeInteger(limit) || limit < 1 || limit > 10) return reject(400, "limit must be 1-10");
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
const result = await call(entry.tool, "snapshot", () => entry.provider!.snapshot({ ...input, after, panel }));
|
|
174
|
+
if (!result || typeof result !== "object" || Array.isArray(result)) throw new Error("snapshot must return an object");
|
|
175
|
+
if (panel === "space" && caps.space === true) {
|
|
176
|
+
const clean = sanitizeSpaceScene(result.scene);
|
|
177
|
+
return json({ ok: true, ...clean, ...(Number.isSafeInteger(result.cursor) && (result.cursor as number) >= 0 ? { cursor: result.cursor } : {}) });
|
|
178
|
+
}
|
|
179
|
+
return json(result);
|
|
180
|
+
} catch (error) {
|
|
181
|
+
record(entry.tool, `snapshot ${panel}`, error);
|
|
182
|
+
return reject(502, `${entry.tool} could not refresh; details are in System`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** GET /p/<tool>/asset?id=: provider bytes behind the session, type allowlist and size cap. */
|
|
187
|
+
async function pluginAsset(entry: PluginEntry, id: string | null) {
|
|
188
|
+
const provider = entry.provider!;
|
|
189
|
+
if (typeof provider.asset !== "function") return reject(404, "this plugin has no assets");
|
|
190
|
+
if (!id || id.length > ASSET_ID_MAX) return reject(400, "asset id required");
|
|
191
|
+
let result: Awaited<ReturnType<NonNullable<typeof provider.asset>>>;
|
|
192
|
+
try {
|
|
193
|
+
result = await call(entry.tool, "asset", () => Promise.resolve().then(() => provider.asset!({ id })));
|
|
194
|
+
} catch (error) {
|
|
195
|
+
record(entry.tool, "asset", error);
|
|
196
|
+
return reject(502, `${entry.tool} could not read the asset; details are in System`);
|
|
197
|
+
}
|
|
198
|
+
if (result === null || result === undefined) return reject(404, "unknown asset");
|
|
199
|
+
const problem = assetResultProblems(result)[0];
|
|
200
|
+
if (problem) { record(entry.tool, "asset", new Error(problem)); return reject(502, `${entry.tool} sent an invalid asset; details are in System`); }
|
|
201
|
+
const mime = result.mime;
|
|
202
|
+
if (!ASSET_TYPES.has(mime)) { record(entry.tool, "asset", new Error(`type ${mime.slice(0, 60)} is not allowed`)); return reject(415, `${entry.tool} sent a type the console does not serve`); }
|
|
203
|
+
if (result.bytes.byteLength > assetMaxBytes) { record(entry.tool, "asset", new Error(`${result.bytes.byteLength} bytes is over the ${assetMaxBytes} byte limit`)); return reject(413, "asset is larger than the console serves"); }
|
|
204
|
+
const name = fileName(result.name);
|
|
205
|
+
const disposition = `${ASSET_INLINE.has(mime) ? "inline" : "attachment"}; filename="${name}"; filename*=UTF-8''${encodeURIComponent(codePoints(result.name, 120).toWellFormed())}`;
|
|
206
|
+
const type = mime.startsWith("text/") ? `${mime}; charset=utf-8` : mime;
|
|
207
|
+
return new Response(new Uint8Array(result.bytes), { headers: { ...HEADERS, "X-Frame-Options": "SAMEORIGIN", "Content-Security-Policy": ASSET_CSP, "Content-Type": type, "Content-Disposition": disposition, "Content-Length": String(result.bytes.byteLength) } });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function action(entry: PluginEntry, session: string, input: Record<string, unknown>, meta: ActionMeta) {
|
|
211
|
+
const name = input.action;
|
|
212
|
+
if (typeof name !== "string" || !ACTION_ID.test(name)) return reject(400, "invalid action");
|
|
213
|
+
const declared = entry.manifest!.capabilities?.actions;
|
|
214
|
+
if (declared && !declared.includes(name)) return reject(400, "action not declared by the plugin");
|
|
215
|
+
if (RESERVED.some((key) => key in input)) return reject(400, "confirmed and fingerprint are set by the console, not the page");
|
|
216
|
+
const { confirmToken, ...rest } = input;
|
|
217
|
+
let request: Record<string, unknown> = rest;
|
|
218
|
+
if (confirmToken !== undefined) {
|
|
219
|
+
prune(confirms);
|
|
220
|
+
const bound = typeof confirmToken === "string" ? confirms.get(confirmToken) : undefined;
|
|
221
|
+
if (typeof confirmToken === "string") confirms.delete(confirmToken);
|
|
222
|
+
if (!bound || bound.session !== session || bound.tool !== (entry.ref ?? entry.tool) || bound.input !== canonical(rest)) {
|
|
223
|
+
return reject(409, "this preview expired or was already used; review it again", { stale: true });
|
|
224
|
+
}
|
|
225
|
+
request = { ...rest, confirmed: true, fingerprint: bound.fingerprint };
|
|
226
|
+
}
|
|
227
|
+
let result: ActionResult;
|
|
228
|
+
// meta comes from the verified session, never from the body; an input key named meta stays page data.
|
|
229
|
+
const running = Promise.resolve().then(() => entry.provider!.action(request as { action: string }, meta));
|
|
230
|
+
try {
|
|
231
|
+
result = await withTimeout(running, callMs, `${entry.tool} action`);
|
|
232
|
+
if (!result || typeof result !== "object" || typeof (result as { ok?: unknown }).ok !== "boolean") throw new Error("action must return {ok: boolean}");
|
|
233
|
+
} catch (error) {
|
|
234
|
+
if (error instanceof TimeoutError) {
|
|
235
|
+
// The provider may still finish: say the outcome is unknown, and log it in System when it settles.
|
|
236
|
+
record(entry.tool, `action ${name}`, new Error(`still running after ${callMs / 1000} s; outcome unknown`));
|
|
237
|
+
running.then((late) => record(entry.tool, `action ${name}`, new Error(`finished after the timeout: ${late && typeof late === "object" && "ok" in late && late.ok ? "ok" : "not ok"}`)),
|
|
238
|
+
(late) => record(entry.tool, `action ${name}`, late));
|
|
239
|
+
return reject(504, `${entry.tool} is still working on ${name}; check System for the outcome before you retry`, { indeterminate: true });
|
|
240
|
+
}
|
|
241
|
+
record(entry.tool, `action ${name}`, error);
|
|
242
|
+
return reject(502, `${entry.tool} could not run ${name}; details are in System`);
|
|
243
|
+
}
|
|
244
|
+
if (!result.ok && "needsConfirm" in result && result.needsConfirm) {
|
|
245
|
+
if (typeof result.fingerprint !== "string" || !result.fingerprint || !Array.isArray(result.preview)) {
|
|
246
|
+
record(entry.tool, `action ${name}`, new Error("needsConfirm without fingerprint or preview"));
|
|
247
|
+
return reject(502, `${entry.tool} sent an incomplete preview; details are in System`);
|
|
248
|
+
}
|
|
249
|
+
prune(confirms);
|
|
250
|
+
cap(confirms);
|
|
251
|
+
const token = secret();
|
|
252
|
+
confirms.set(token, { expires: now() + CONFIRM_MS, session, tool: entry.ref ?? entry.tool, input: canonical(rest), fingerprint: result.fingerprint });
|
|
253
|
+
return json({ ...result, confirmToken: token, validMs: CONFIRM_MS, ...(confirmToken !== undefined ? { changed: true } : {}) });
|
|
254
|
+
}
|
|
255
|
+
return json(result);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const server = Bun.serve({
|
|
259
|
+
hostname: serve ? serve.hostname.replace(/^\[(.*)\]$/, "$1") : "127.0.0.1",
|
|
260
|
+
port: serve?.port ?? 0,
|
|
261
|
+
maxRequestBodySize: 32 * 1024,
|
|
262
|
+
idleTimeout: 60,
|
|
263
|
+
async fetch(req): Promise<Response> {
|
|
264
|
+
const url = new URL(req.url);
|
|
265
|
+
const path = url.pathname;
|
|
266
|
+
// H1 health probe: no auth, no body, before every other check so container and tunnel probes work.
|
|
267
|
+
if (serve && path === "/healthz" && (req.method === "GET" || req.method === "HEAD")) return new Response(null, { status: 204, headers: HEADERS });
|
|
268
|
+
lastSeen = now();
|
|
269
|
+
if (!serve) armIdle();
|
|
270
|
+
if (serve ? !serve.hosts.includes(req.headers.get("host") ?? "") : req.headers.get("host") !== host) return reject(403, "forbidden host");
|
|
271
|
+
const requestOrigin = req.headers.get("origin");
|
|
272
|
+
const originOk = requestOrigin !== null && (serve ? serve.origins.includes(requestOrigin) : requestOrigin === origin);
|
|
273
|
+
if (requestOrigin !== null && !originOk) return reject(403, "forbidden origin");
|
|
274
|
+
// Under serve the verified identity is the session: every route needs it, the shell files too.
|
|
275
|
+
const principal = serve ? await serve.authenticate(req) : null;
|
|
276
|
+
if (serve && principal === null) return reject(401, "authentication required");
|
|
277
|
+
prune(tickets);
|
|
278
|
+
prune(sessions);
|
|
279
|
+
const cookie = serve ? principal! : cookieOf(req);
|
|
280
|
+
const live = serve ? true : cookie !== undefined && sessions.has(cookie);
|
|
281
|
+
const pet = /^\/pets\/([a-z0-9][a-z0-9-]{0,31})\/sheet$/.exec(path);
|
|
282
|
+
const packedImage = /^\/assets\/([a-z0-9][a-z0-9-]{0,31})\/([a-z0-9][a-z0-9-]{0,31})$/.exec(path);
|
|
283
|
+
const packedModel = /^\/models\/([a-z0-9][a-z0-9-]{0,31})\/([a-z0-9][a-z0-9-]{0,31})$/.exec(path);
|
|
284
|
+
const plugin = /^\/p\/([a-z][a-z0-9-]{1,15}(?:@[0-9a-f]{12})?)\/(panel\.js|panel\.css|snapshot|action|asset)$/.exec(path);
|
|
285
|
+
// `<tool>` means the current load; `<tool>@<hash>` the load a page started with, kept until restart.
|
|
286
|
+
const pluginEntry = () => plugin![1]!.includes("@") ? refs.get(plugin![1]!) : tools.get(plugin![1]!);
|
|
287
|
+
const unknownTool = () => plugin![1]!.includes("@") && tools.has(plugin![1]!.split("@")[0]!)
|
|
288
|
+
? reject(409, "this page uses a plugin version the console no longer has; reload the page", { reload: true })
|
|
289
|
+
: reject(404, "unknown tool");
|
|
290
|
+
if (req.method === "GET") {
|
|
291
|
+
const file = files[path];
|
|
292
|
+
if (file) return new Response(file.body, { headers: { ...HEADERS, ...(file.frame ? { "Content-Security-Policy": FRAME_CSP, "X-Frame-Options": "SAMEORIGIN", "Cache-Control": "private, max-age=3600" } : {}), "Content-Type": file.type } });
|
|
293
|
+
if (plugin && (plugin[2] === "panel.js" || plugin[2] === "panel.css")) {
|
|
294
|
+
if (!live) return reject(401, "session expired or missing; run acon ui");
|
|
295
|
+
const entry = pluginEntry();
|
|
296
|
+
if (!entry) return unknownTool();
|
|
297
|
+
const js = plugin[2] === "panel.js";
|
|
298
|
+
return new Response(js ? entry.provider!.assets.js : entry.provider!.assets.css ?? "", { headers: { ...HEADERS, "Content-Type": js ? "text/javascript; charset=utf-8" : "text/css; charset=utf-8" } });
|
|
299
|
+
}
|
|
300
|
+
if (plugin && plugin[2] === "asset") {
|
|
301
|
+
if (!live) return reject(401, "session expired or missing; run acon ui");
|
|
302
|
+
// Same-origin fetches and frames only; the SameSite=Strict cookie already keeps cross-site requests out.
|
|
303
|
+
const site = req.headers.get("sec-fetch-site");
|
|
304
|
+
if (site !== null && site !== "same-origin" && site !== "none") return reject(403, "cross-site asset request");
|
|
305
|
+
const entry = pluginEntry();
|
|
306
|
+
if (!entry) return unknownTool();
|
|
307
|
+
return pluginAsset(entry, url.searchParams.get("id"));
|
|
308
|
+
}
|
|
309
|
+
if (pet) {
|
|
310
|
+
if (!live) return reject(401, "session expired or missing; run acon ui");
|
|
311
|
+
const entry = pets.find((p) => p.id === pet[1]);
|
|
312
|
+
// Re-check at serve time: the file may have been swapped for a link or grown since the scan.
|
|
313
|
+
if (!entry) return reject(404, "unknown pet");
|
|
314
|
+
const image = readValidatedImage(entry.file, 8 * 1024 * 1024, "the sprite sheet");
|
|
315
|
+
if (typeof image === "string") return reject(404, "unknown pet");
|
|
316
|
+
return new Response(new Uint8Array(image.bytes), { headers: { ...HEADERS, "Content-Type": image.type } });
|
|
317
|
+
}
|
|
318
|
+
if (packedImage) {
|
|
319
|
+
if (!live) return reject(401, "session expired or missing; run acon ui");
|
|
320
|
+
const pack = assetPacks.find((p) => p.id === packedImage[1]);
|
|
321
|
+
const entry = pack?.images[packedImage[2]!];
|
|
322
|
+
if (!entry) return reject(404, "unknown asset");
|
|
323
|
+
const image = readValidatedImage(entry.file, 4 * 1024 * 1024);
|
|
324
|
+
if (typeof image === "string") return reject(404, "unknown asset");
|
|
325
|
+
return new Response(new Uint8Array(image.bytes), { headers: { ...HEADERS, "Content-Type": image.type } });
|
|
326
|
+
}
|
|
327
|
+
if (packedModel) {
|
|
328
|
+
if (!live) return reject(401, "session expired or missing; run acon ui");
|
|
329
|
+
const pack = assetPacks.find((p) => p.id === packedModel[1]);
|
|
330
|
+
const entry = pack?.models?.[packedModel[2]!];
|
|
331
|
+
if (!entry) return reject(404, "unknown model");
|
|
332
|
+
const model = readValidatedModel(entry.file);
|
|
333
|
+
if (typeof model === "string") return reject(404, "unknown model");
|
|
334
|
+
return new Response(new Uint8Array(model.bytes), { headers: { ...HEADERS, "Content-Type": model.type } });
|
|
335
|
+
}
|
|
336
|
+
return reject(404, "not found");
|
|
337
|
+
}
|
|
338
|
+
if (req.method !== "POST") return reject(405, "POST required");
|
|
339
|
+
if (!originOk) return reject(403, "origin required");
|
|
340
|
+
if (req.headers.get("content-type")?.split(";")[0]?.trim() !== "application/json") return reject(415, "JSON required");
|
|
341
|
+
const known = path === "/session" || path === "/tools" || path === "/ping" || (plugin && (plugin[2] === "snapshot" || plugin[2] === "action"));
|
|
342
|
+
if (!known) return reject(404, "not found");
|
|
343
|
+
if (path !== "/session" && !live) return reject(401, "session expired or missing; run acon ui");
|
|
344
|
+
let input: Record<string, unknown>;
|
|
345
|
+
try {
|
|
346
|
+
const value = await req.json();
|
|
347
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return reject(400, "JSON object required");
|
|
348
|
+
input = value as Record<string, unknown>;
|
|
349
|
+
} catch {
|
|
350
|
+
return reject(400, "invalid JSON");
|
|
351
|
+
}
|
|
352
|
+
// Every accepted body has a canonical form; this refuses numbers like 1e400 that would collide with null.
|
|
353
|
+
try { canonical(input); } catch { return reject(400, "numbers must be finite"); }
|
|
354
|
+
if (path === "/session") {
|
|
355
|
+
if (serve) return json({ ok: true, mode: "serve" });
|
|
356
|
+
const ticket = typeof input.ticket === "string" ? input.ticket : "";
|
|
357
|
+
if (!tickets.delete(ticket)) return reject(401, "this link was already used or has expired; run acon ui");
|
|
358
|
+
cap(sessions);
|
|
359
|
+
const session = secret();
|
|
360
|
+
sessions.set(session, { expires: now() + SESSION_MS });
|
|
361
|
+
return json({ ok: true }, 200, { "Set-Cookie": `${cookieName}=${session}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${SESSION_MS / 1000}` });
|
|
362
|
+
}
|
|
363
|
+
if (path === "/ping") return json({ ok: true });
|
|
364
|
+
if (path === "/tools") {
|
|
365
|
+
await refresh();
|
|
366
|
+
return json({ ok: true, node: { id: "local", version: options.version, startedAt, pluginsDir: options.pluginsDir, pluginsError: options.pluginsError }, tools: toolList(), pets: pets.map(({ id, name, description }) => ({ id, name, ...(description ? { description } : {}) })), assetPacks: assetPacks.map(({ id, name, version, tile, images, models }) => ({ id, name, version, tile, images: Object.keys(images), ...(models ? { models: Object.keys(models) } : {}) })), errors });
|
|
367
|
+
}
|
|
368
|
+
const entry = pluginEntry();
|
|
369
|
+
if (!entry) return unknownTool();
|
|
370
|
+
return plugin![2] === "snapshot" ? snapshot(entry, input) : action(entry, cookie!, input, { identity: serve ? principal! : "local", node: "local", via: "console" });
|
|
371
|
+
},
|
|
372
|
+
error(error) {
|
|
373
|
+
log(`request failed: ${error.message}`);
|
|
374
|
+
return reject(500, "console request failed");
|
|
375
|
+
},
|
|
376
|
+
});
|
|
377
|
+
const host = `127.0.0.1:${server.port}`;
|
|
378
|
+
const bare = serve?.hostname.replace(/^\[(.*)\]$/, "$1");
|
|
379
|
+
const origin = serve ? `http://${bare!.includes(":") ? `[${bare}]` : bare}:${server.port}` : `http://${host}`;
|
|
380
|
+
if (serve) {
|
|
381
|
+
// The bound port is known only now (tests bind port 0): local probes on the bind address are allowed hosts.
|
|
382
|
+
const port = server.port;
|
|
383
|
+
serve.hosts = [...new Set([...serve.hosts, `127.0.0.1:${port}`, `localhost:${port}`, new URL(origin).host])];
|
|
384
|
+
if (serve.mode === "loopback") serve.origins = [...new Set([...serve.origins, `http://127.0.0.1:${port}`, `http://localhost:${port}`])];
|
|
385
|
+
}
|
|
386
|
+
const cookieName = `acon_ui_${server.port}`;
|
|
387
|
+
|
|
388
|
+
let stopped = false;
|
|
389
|
+
const stop = async () => {
|
|
390
|
+
if (stopped) return;
|
|
391
|
+
stopped = true;
|
|
392
|
+
clearTimeout(timer);
|
|
393
|
+
tickets.clear();
|
|
394
|
+
sessions.clear();
|
|
395
|
+
confirms.clear();
|
|
396
|
+
server.stop(true);
|
|
397
|
+
// Each provider gets 5 s to close; a hanging one never keeps the process alive.
|
|
398
|
+
await Promise.all([...refs.values()].map((entry) => withTimeout(Promise.resolve().then(() => entry.provider!.close?.()), 5_000, `${entry.tool} close`)
|
|
399
|
+
.catch((error) => log(`${entry.tool} close failed: ${error instanceof Error ? error.message : String(error)}`))));
|
|
400
|
+
};
|
|
401
|
+
/** True when no request arrived for idleMs; then the server stops and onIdle runs. */
|
|
402
|
+
const checkIdle = () => {
|
|
403
|
+
if (serve || stopped || now() - lastSeen < idleMs) return false;
|
|
404
|
+
void stop().finally(() => options.onIdle?.());
|
|
405
|
+
return true;
|
|
406
|
+
};
|
|
407
|
+
// One timer, re-armed on every request, fires exactly idleMs after the last one.
|
|
408
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
409
|
+
function armIdle() {
|
|
410
|
+
clearTimeout(timer);
|
|
411
|
+
timer = setTimeout(() => { if (!checkIdle()) armIdle(); }, Math.max(0, idleMs - (now() - lastSeen)));
|
|
412
|
+
}
|
|
413
|
+
if (!serve) armIdle();
|
|
414
|
+
|
|
415
|
+
return {
|
|
416
|
+
origin,
|
|
417
|
+
port: server.port,
|
|
418
|
+
errors,
|
|
419
|
+
issue(): string {
|
|
420
|
+
prune(tickets);
|
|
421
|
+
cap(tickets);
|
|
422
|
+
const ticket = secret();
|
|
423
|
+
tickets.set(ticket, { expires: now() + TICKET_MS });
|
|
424
|
+
return `${origin}/#${ticket}`;
|
|
425
|
+
},
|
|
426
|
+
checkIdle,
|
|
427
|
+
stop,
|
|
428
|
+
};
|
|
429
|
+
}
|
package/src/space.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { SpaceScene } from "./contract.ts";
|
|
2
|
+
|
|
3
|
+
export const SPACE_CAPS = { actors: 32, cards: 200, links: 64 } as const;
|
|
4
|
+
const STATES = ["idle", "working", "waiting", "blocked", "away"];
|
|
5
|
+
const MARKS = ["approval", "inbox", "paused", "mode"];
|
|
6
|
+
const KINDS = ["queue", "work", "review", "done"];
|
|
7
|
+
const LINKS = ["message", "overlap", "conflict", "cohort"];
|
|
8
|
+
const TONES = ["ok", "warn", "err", "neutral"];
|
|
9
|
+
const object = (v: unknown): v is Record<string, unknown> => Boolean(v) && typeof v === "object" && !Array.isArray(v);
|
|
10
|
+
const text = (v: unknown): v is string => typeof v === "string" && v.trim().length > 0 && v.length <= 200;
|
|
11
|
+
|
|
12
|
+
/** Strict conformance check: extra fields are named instead of silently entering the public model. */
|
|
13
|
+
export function spaceSceneProblems(value: unknown): string[] {
|
|
14
|
+
const out: string[] = [];
|
|
15
|
+
const fields = (v: Record<string, unknown>, allowed: string[], path: string) => {
|
|
16
|
+
for (const key of Object.keys(v)) if (!allowed.includes(key)) out.push(`${path}.${key} is not a public field`);
|
|
17
|
+
};
|
|
18
|
+
const required = (v: Record<string, unknown>, key: string, path: string) => { if (!text(v[key])) out.push(`${path}.${key} must be a string of 1-200 characters`); };
|
|
19
|
+
const optional = (v: Record<string, unknown>, key: string, path: string) => { if (v[key] !== undefined) required(v, key, path); };
|
|
20
|
+
const choice = (v: Record<string, unknown>, key: string, choices: string[], path: string, opt = false) => {
|
|
21
|
+
if ((!opt || v[key] !== undefined) && !choices.includes(v[key] as string)) out.push(`${path}.${key} must be ${choices.join(" | ")}`);
|
|
22
|
+
};
|
|
23
|
+
const link = (v: unknown, path: string) => {
|
|
24
|
+
if (!object(v)) { out.push(`${path} must be a panel link`); return; }
|
|
25
|
+
fields(v, ["panel", "params"], path); required(v, "panel", path);
|
|
26
|
+
if (v.params !== undefined && (!object(v.params) || Object.entries(v.params).some(([k, x]) => !text(k) || typeof x !== "string" || x.length > 200))) out.push(`${path}.params must contain string routing values of at most 200 characters`);
|
|
27
|
+
};
|
|
28
|
+
if (!object(value)) return ["scene must be an object"];
|
|
29
|
+
fields(value, ["actors", "zones", "cards", "links"], "scene");
|
|
30
|
+
for (const key of ["actors", "zones", "cards", "links"] as const) {
|
|
31
|
+
if (key === "links" && value[key] === undefined) continue;
|
|
32
|
+
const list = value[key];
|
|
33
|
+
if (!Array.isArray(list)) { out.push(`scene.${key} must be an array`); continue; }
|
|
34
|
+
if (key !== "zones" && list.length > SPACE_CAPS[key]) out.push(`scene.${key} exceeds ${SPACE_CAPS[key]}`);
|
|
35
|
+
const ids = new Set<string>();
|
|
36
|
+
list.forEach((v: unknown, i: number) => {
|
|
37
|
+
const path = `scene.${key}[${i}]`;
|
|
38
|
+
if (!object(v)) { out.push(`${path} must be an object`); return; }
|
|
39
|
+
if (key !== "links") {
|
|
40
|
+
required(v, "id", path);
|
|
41
|
+
if (typeof v.id === "string") { if (ids.has(v.id)) out.push(`${path}.id is duplicated`); ids.add(v.id); }
|
|
42
|
+
}
|
|
43
|
+
if (key === "actors") {
|
|
44
|
+
fields(v, ["id", "name", "state", "pet", "card", "marks", "link"], path);
|
|
45
|
+
required(v, "name", path); choice(v, "state", STATES, path); optional(v, "pet", path); optional(v, "card", path); link(v.link, `${path}.link`);
|
|
46
|
+
if (v.marks !== undefined) {
|
|
47
|
+
if (!Array.isArray(v.marks)) out.push(`${path}.marks must be an array`);
|
|
48
|
+
else v.marks.forEach((m: unknown, j: number) => {
|
|
49
|
+
const at = `${path}.marks[${j}]`;
|
|
50
|
+
if (!object(m)) { out.push(`${at} must be an object`); return; }
|
|
51
|
+
fields(m, ["kind", "text"], at); choice(m, "kind", MARKS, at); required(m, "text", at);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
} else if (key === "zones") {
|
|
55
|
+
fields(v, ["id", "title", "kind", "count"], path); required(v, "title", path); choice(v, "kind", KINDS, path, true);
|
|
56
|
+
if (!Number.isSafeInteger(v.count) || (v.count as number) < 0) out.push(`${path}.count must be a non-negative integer`);
|
|
57
|
+
} else if (key === "cards") {
|
|
58
|
+
fields(v, ["id", "title", "zone", "owner", "tone", "link"], path); required(v, "title", path); required(v, "zone", path);
|
|
59
|
+
optional(v, "owner", path); choice(v, "tone", TONES, path, true); link(v.link, `${path}.link`);
|
|
60
|
+
} else {
|
|
61
|
+
fields(v, ["kind", "from", "to", "label", "at"], path); choice(v, "kind", LINKS, path);
|
|
62
|
+
required(v, "from", path); required(v, "to", path); optional(v, "label", path); optional(v, "at", path);
|
|
63
|
+
if (typeof v.at === "string" && Number.isNaN(Date.parse(v.at))) out.push(`${path}.at must be an ISO time`);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Transport allowlist. Unknown fields are removed; excess records are counted, never rendered. */
|
|
71
|
+
export function sanitizeSpaceScene(value: unknown): { scene: SpaceScene; dropped: { actors: number; cards: number; links: number } } {
|
|
72
|
+
const src = object(value) ? value : {};
|
|
73
|
+
const dropped = { actors: 0, cards: 0, links: 0 };
|
|
74
|
+
const s = (v: unknown) => typeof v === "string" ? v.slice(0, 200) : "";
|
|
75
|
+
const route = (v: unknown): { panel: string; params?: Record<string, string> } => {
|
|
76
|
+
const l = object(v) ? v : {};
|
|
77
|
+
const params = object(l.params) ? Object.fromEntries(Object.entries(l.params).filter(([k, x]) => text(k) && typeof x === "string" && x.length <= 200).map(([k, x]) => [k, x as string])) : undefined;
|
|
78
|
+
return { panel: s(l.panel), ...(params ? { params } : {}) };
|
|
79
|
+
};
|
|
80
|
+
const rows = (key: "actors" | "zones" | "cards" | "links") => Array.isArray(src[key]) ? src[key].filter(object) : [];
|
|
81
|
+
const unique = (list: Record<string, unknown>[]) => {
|
|
82
|
+
const seen = new Set<string>();
|
|
83
|
+
return list.filter((v) => { if (!text(v.id) || seen.has(v.id)) return false; seen.add(v.id); return true; });
|
|
84
|
+
};
|
|
85
|
+
const actors = unique(rows("actors")).filter((a) => text(s(a.name)) && STATES.includes(a.state as string) && object(a.link) && text(a.link.panel));
|
|
86
|
+
const zones = unique(rows("zones")).filter((z) => text(s(z.title)) && Number.isSafeInteger(z.count) && (z.count as number) >= 0);
|
|
87
|
+
const zoneIds = new Set(zones.map((z) => s(z.id)));
|
|
88
|
+
const cards = unique(rows("cards")).filter((c) => text(s(c.title)) && text(c.zone) && zoneIds.has(c.zone) && object(c.link) && text(c.link.panel));
|
|
89
|
+
const keptActors = actors.slice(0, SPACE_CAPS.actors);
|
|
90
|
+
const keptCards = cards.slice(0, SPACE_CAPS.cards);
|
|
91
|
+
const actorIds = new Set(keptActors.map((a) => s(a.id)));
|
|
92
|
+
const cardIds = new Set(keptCards.map((c) => s(c.id)));
|
|
93
|
+
const links = rows("links").filter((l) => LINKS.includes(l.kind as string) && text(l.from) && text(l.to) && actorIds.has(l.from) && actorIds.has(l.to));
|
|
94
|
+
dropped.actors = (Array.isArray(src.actors) ? src.actors.length : 0) - keptActors.length;
|
|
95
|
+
dropped.cards = (Array.isArray(src.cards) ? src.cards.length : 0) - keptCards.length;
|
|
96
|
+
dropped.links = (Array.isArray(src.links) ? src.links.length : 0) - Math.min(links.length, SPACE_CAPS.links);
|
|
97
|
+
const scene: SpaceScene = {
|
|
98
|
+
actors: keptActors.map((a) => ({ id: s(a.id), name: s(a.name), state: a.state as SpaceScene["actors"][number]["state"],
|
|
99
|
+
...(text(a.pet) ? { pet: a.pet } : {}), ...(text(a.card) && cardIds.has(a.card) ? { card: a.card } : {}),
|
|
100
|
+
...(Array.isArray(a.marks) ? { marks: a.marks.filter(object).filter((m) => MARKS.includes(m.kind as string) && typeof m.text === "string").map((m) => ({ kind: m.kind as "approval" | "inbox" | "paused" | "mode", text: s(m.text) })) } : {}), link: route(a.link) })),
|
|
101
|
+
zones: zones.map((z) => ({ id: s(z.id), title: s(z.title), ...(KINDS.includes(z.kind as string) ? { kind: z.kind as "queue" | "work" | "review" | "done" } : {}), count: z.count as number })),
|
|
102
|
+
cards: keptCards.map((c) => ({ id: s(c.id), title: s(c.title), zone: s(c.zone), ...(text(c.owner) && actorIds.has(c.owner) ? { owner: c.owner } : {}), ...(TONES.includes(c.tone as string) ? { tone: c.tone as "ok" | "warn" | "err" | "neutral" } : {}), link: route(c.link) })),
|
|
103
|
+
links: links.slice(0, SPACE_CAPS.links).map((l) => ({ kind: l.kind as "message" | "overlap" | "conflict" | "cohort", from: s(l.from), to: s(l.to), ...(typeof l.label === "string" ? { label: s(l.label) } : {}), ...(typeof l.at === "string" && !Number.isNaN(Date.parse(l.at)) ? { at: s(l.at) } : {}) })),
|
|
104
|
+
};
|
|
105
|
+
return { scene, dropped };
|
|
106
|
+
}
|