@lotics/cli 0.86.0 → 0.86.1

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,39 @@
1
+ /**
2
+ * Dev-only GET relay: the read half of the same problem `upload_relay.ts` solves.
3
+ *
4
+ * The server presigns every file cell an app query returns (`url` / `thumbnail_url` /
5
+ * `preview_url` on each file object), and those URLs point at the PROD bucket, whose
6
+ * CORS admits `https://*.lotics.app` and not `http://localhost:<port>`. Displaying
7
+ * such a URL is fine — `<img>` and `<video>` loads are not CORS-gated, and neither is
8
+ * the top-level navigation `openExternal` performs. But **fetching the bytes** is, and
9
+ * that is exactly what every preview engine does (PDF, Word, Excel all read the file
10
+ * into memory), so file preview could never work in `lotics app dev`.
11
+ *
12
+ * So the dev server hands the app same-origin URLs and fetches the bytes itself.
13
+ *
14
+ * The security property is the one that governs the upload relay: the app never names
15
+ * a destination. It receives an opaque token, and the relay reads ONLY from a URL the
16
+ * server itself observed the API return for that token. No client-controlled target,
17
+ * nothing to allowlist, no SSRF surface.
18
+ *
19
+ * Production is untouched: it serves presigned URLs straight from storage.
20
+ */
21
+ export interface FileRelay {
22
+ /**
23
+ * Deep-copy an RPC result with every presigned file URL swapped for one served by
24
+ * this dev server. Immutable: the input is never mutated. Anything that isn't a
25
+ * presigned URL on a file object passes through byte-identical.
26
+ */
27
+ rewrite(result: unknown): unknown;
28
+ /** The URL this token stands for — `null` if unknown or expired. */
29
+ destinationFor(token: string): string | null;
30
+ /** Live tokens — for tests. */
31
+ size(): number;
32
+ }
33
+ /**
34
+ * `wrapperOrigin` — e.g. `http://localhost:5174`. The URL must be ABSOLUTE: these
35
+ * URLs are consumed inside the app **iframe**, which is served from Vite's origin, so
36
+ * a relative `/_file/…` would resolve against Vite and 404. (The upload relay's URL is
37
+ * consumed by the wrapper page itself, where relative is correct.)
38
+ */
39
+ export declare function createFileRelay(wrapperOrigin: string, now?: () => number): FileRelay;
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Dev-only GET relay: the read half of the same problem `upload_relay.ts` solves.
3
+ *
4
+ * The server presigns every file cell an app query returns (`url` / `thumbnail_url` /
5
+ * `preview_url` on each file object), and those URLs point at the PROD bucket, whose
6
+ * CORS admits `https://*.lotics.app` and not `http://localhost:<port>`. Displaying
7
+ * such a URL is fine — `<img>` and `<video>` loads are not CORS-gated, and neither is
8
+ * the top-level navigation `openExternal` performs. But **fetching the bytes** is, and
9
+ * that is exactly what every preview engine does (PDF, Word, Excel all read the file
10
+ * into memory), so file preview could never work in `lotics app dev`.
11
+ *
12
+ * So the dev server hands the app same-origin URLs and fetches the bytes itself.
13
+ *
14
+ * The security property is the one that governs the upload relay: the app never names
15
+ * a destination. It receives an opaque token, and the relay reads ONLY from a URL the
16
+ * server itself observed the API return for that token. No client-controlled target,
17
+ * nothing to allowlist, no SSRF surface.
18
+ *
19
+ * Production is untouched: it serves presigned URLs straight from storage.
20
+ */
21
+ import { createHash } from "node:crypto";
22
+ /** Presigned GETs live ~24h; a token outlives its URL by nothing. */
23
+ const TTL_MS = 24 * 60 * 60 * 1000;
24
+ /** A long dev session re-queries constantly; each query re-presigns. Bound the table. */
25
+ const MAX_ENTRIES = 5000;
26
+ /**
27
+ * The keys the backend presigns on a file object (`file_url_resolver.ts`). Only these,
28
+ * and only on something that is actually a file, get rewritten — a record's own text
29
+ * cell holding a link (a public app URL, say) must keep pointing where it points.
30
+ */
31
+ const PRESIGNED_KEYS = new Set(["url", "thumbnail_url", "preview_url"]);
32
+ const isFileObject = (o) => typeof o.filename === "string" && typeof o.mime_type === "string";
33
+ const isRemoteUrl = (v) => typeof v === "string" && /^https?:\/\//i.test(v);
34
+ /**
35
+ * `wrapperOrigin` — e.g. `http://localhost:5174`. The URL must be ABSOLUTE: these
36
+ * URLs are consumed inside the app **iframe**, which is served from Vite's origin, so
37
+ * a relative `/_file/…` would resolve against Vite and 404. (The upload relay's URL is
38
+ * consumed by the wrapper page itself, where relative is correct.)
39
+ */
40
+ export function createFileRelay(wrapperOrigin, now = Date.now) {
41
+ const seen = new Map();
42
+ // A stable token per URL: the same file re-queried doesn't mint a new entry, so the
43
+ // table tracks distinct presigns, not query volume. (Truncated — this is a lookup
44
+ // key in a localhost-only process, not a secret.)
45
+ const tokenFor = (url) => createHash("sha256").update(url).digest("hex").slice(0, 24);
46
+ const remember = (url) => {
47
+ const token = tokenFor(url);
48
+ seen.set(token, { url, at: now() });
49
+ if (seen.size > MAX_ENTRIES) {
50
+ // Insertion-ordered: drop the oldest.
51
+ const oldest = seen.keys().next();
52
+ if (!oldest.done)
53
+ seen.delete(oldest.value);
54
+ }
55
+ return token;
56
+ };
57
+ const walk = (node) => {
58
+ if (Array.isArray(node))
59
+ return node.map(walk);
60
+ if (!node || typeof node !== "object")
61
+ return node;
62
+ const obj = node;
63
+ const file = isFileObject(obj);
64
+ const out = {};
65
+ for (const [key, value] of Object.entries(obj)) {
66
+ out[key] =
67
+ file && PRESIGNED_KEYS.has(key) && isRemoteUrl(value)
68
+ ? `${wrapperOrigin}/_file/${remember(value)}`
69
+ : walk(value);
70
+ }
71
+ return out;
72
+ };
73
+ return {
74
+ rewrite: (result) => walk(result),
75
+ destinationFor(token) {
76
+ const entry = seen.get(token);
77
+ if (!entry)
78
+ return null;
79
+ if (now() - entry.at > TTL_MS) {
80
+ seen.delete(token);
81
+ return null;
82
+ }
83
+ return entry.url;
84
+ },
85
+ size: () => seen.size,
86
+ };
87
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,87 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { createFileRelay } from "./file_relay.js";
3
+ const ORIGIN = "http://localhost:5174";
4
+ const PRESIGNED = "https://production.r2.cloudflarestorage.com/org/files/fil_a/photo.jpg?X-Amz-Signature=abc";
5
+ const THUMB = "https://production.r2.cloudflarestorage.com/org/files/fil_a/thumb.jpg?X-Amz-Signature=def";
6
+ const fileCell = {
7
+ id: "fil_a",
8
+ filename: "photo.jpg",
9
+ mime_type: "image/jpeg",
10
+ url: PRESIGNED,
11
+ thumbnail_url: THUMB,
12
+ };
13
+ /** A query result, shaped like the real thing: rows, each with a files cell. */
14
+ const queryResult = { rows: [{ id: "rec_1", giay_to: [fileCell] }] };
15
+ describe("file relay — the app reads bytes from us, not from storage", () => {
16
+ it("rewrites every presigned URL on a file cell to an absolute URL on the dev server", () => {
17
+ const relay = createFileRelay(ORIGIN);
18
+ const out = relay.rewrite(queryResult);
19
+ const cell = out.rows[0].giay_to[0];
20
+ // ABSOLUTE: the app runs in the Vite-origin iframe, so a relative path would
21
+ // resolve against Vite and 404.
22
+ expect(cell.url).toMatch(/^http:\/\/localhost:5174\/_file\/[a-f0-9]{24}$/);
23
+ expect(cell.thumbnail_url).toMatch(/^http:\/\/localhost:5174\/_file\/[a-f0-9]{24}$/);
24
+ expect(cell.url).not.toBe(cell.thumbnail_url);
25
+ });
26
+ it("reads back the exact storage URL the token stands for", () => {
27
+ const relay = createFileRelay(ORIGIN);
28
+ const out = relay.rewrite(queryResult);
29
+ const token = out.rows[0].giay_to[0].url.split("/_file/")[1];
30
+ expect(relay.destinationFor(token)).toBe(PRESIGNED);
31
+ });
32
+ it("leaves the rest of the payload byte-identical and never mutates the input", () => {
33
+ const relay = createFileRelay(ORIGIN);
34
+ const out = relay.rewrite(queryResult);
35
+ expect(out.rows[0].id).toBe("rec_1");
36
+ expect(out.rows[0].giay_to[0].filename).toBe("photo.jpg");
37
+ expect(fileCell.url).toBe(PRESIGNED); // input untouched
38
+ });
39
+ });
40
+ describe("file relay — it rewrites file URLs, not every URL it sees", () => {
41
+ it("does NOT touch a link that lives in a record's own text cell", () => {
42
+ // The customer's "Link tự khai" field holds a real URL the user clicks. Relaying it
43
+ // would send them to the dev server instead of the site.
44
+ const relay = createFileRelay(ORIGIN);
45
+ const row = { id: "rec_1", link_tu_khai: "https://dang-ky-noxh.lotics.app?kh=KH-2026-919" };
46
+ const out = relay.rewrite({ rows: [row] });
47
+ expect(out.rows[0].link_tu_khai).toBe("https://dang-ky-noxh.lotics.app?kh=KH-2026-919");
48
+ expect(relay.size()).toBe(0);
49
+ });
50
+ it("does NOT touch a `url` key on something that isn't a file", () => {
51
+ const relay = createFileRelay(ORIGIN);
52
+ const out = relay.rewrite({ webhook: { url: "https://example.com/hook" } });
53
+ expect(out.webhook.url).toBe("https://example.com/hook");
54
+ expect(relay.size()).toBe(0);
55
+ });
56
+ it("reaches file cells wherever they are nested — a workflow's generated files, too", () => {
57
+ const relay = createFileRelay(ORIGIN);
58
+ const out = relay.rewrite({
59
+ data: { status: "ok" },
60
+ files: [{ id: "fil_z", filename: "don.docx", mime_type: "application/vnd...", url: PRESIGNED }],
61
+ });
62
+ expect(out.files[0].url).toContain("/_file/");
63
+ });
64
+ });
65
+ describe("file relay — it reads ONLY what it handed out", () => {
66
+ it("refuses a token it never minted", () => {
67
+ const relay = createFileRelay(ORIGIN);
68
+ expect(relay.destinationFor("deadbeefdeadbeefdeadbeef")).toBeNull();
69
+ });
70
+ it("refuses a token whose presign has aged out", () => {
71
+ let clock = 0;
72
+ const relay = createFileRelay(ORIGIN, () => clock);
73
+ const out = relay.rewrite(queryResult);
74
+ const token = out.rows[0].giay_to[0].url.split("/_file/")[1];
75
+ clock += 25 * 60 * 60 * 1000; // presigned GETs live ~24h
76
+ expect(relay.destinationFor(token)).toBeNull();
77
+ });
78
+ });
79
+ describe("file relay — a long dev session re-queries constantly", () => {
80
+ it("mints one stable token per URL rather than growing on every query", () => {
81
+ const relay = createFileRelay(ORIGIN);
82
+ const a = relay.rewrite(queryResult);
83
+ const b = relay.rewrite(queryResult);
84
+ expect(b.rows[0].giay_to[0].url).toBe(a.rows[0].giay_to[0].url);
85
+ expect(relay.size()).toBe(2); // the file and its thumbnail — not four
86
+ });
87
+ });
@@ -5,11 +5,29 @@
5
5
  * 1. Vite dev server (npx vite --port <vite-port>) — child_process.spawn,
6
6
  * stdio inherited so Vite's own logging surfaces to the developer.
7
7
  * 2. node:http server on <port> serving:
8
- * GET / → wrapper HTML (cached: no)
9
- * POST /_rpc → JSON in, dispatched via rpc_handler, JSON out
10
- * * 404
8
+ * GET / → wrapper HTML (cached: no)
9
+ * POST /_rpc → JSON in, dispatched via rpc_handler, JSON out
10
+ * POST /_agent_run SSE, piped from the run
11
+ * PUT /_upload/:id → file bytes in (relayed to storage)
12
+ * GET /_file/:token → file bytes out (relayed from storage)
13
+ * * → 404
11
14
  *
12
15
  * SIGINT (Ctrl-C) → kill Vite child, close HTTP server, exit 0.
16
+ *
17
+ * Why the byte relays exist: dev runs against the PROD bucket (there is no dev
18
+ * bucket), whose CORS allowlist holds the real app origins (`https://*.lotics.app`),
19
+ * not `http://localhost:<port>`. So a browser transfer straight to/from the presigned
20
+ * URL is blocked before it leaves the page — no upload could complete, and no preview
21
+ * engine (PDF/Word/Excel all FETCH the bytes) could read a file. Relaying through THIS
22
+ * server fixes both: Node has no same-origin policy, and the one cross-origin hop that
23
+ * remains (the app iframe reading from us) is OUR response to allow.
24
+ *
25
+ * Neither relay ever takes a destination from the client: the page sends a `file_id`
26
+ * or an opaque token, and the server transfers only to/from a URL IT minted or observed
27
+ * for that id. No client-controlled target ⇒ no SSRF surface, nothing to allowlist.
28
+ *
29
+ * Production is untouched — it transfers direct-to-storage, keeping every byte off the
30
+ * API server.
13
31
  */
14
32
  import { type ChildProcess } from "node:child_process";
15
33
  import { LoticsClient } from "../client.js";
@@ -5,11 +5,29 @@
5
5
  * 1. Vite dev server (npx vite --port <vite-port>) — child_process.spawn,
6
6
  * stdio inherited so Vite's own logging surfaces to the developer.
7
7
  * 2. node:http server on <port> serving:
8
- * GET / → wrapper HTML (cached: no)
9
- * POST /_rpc → JSON in, dispatched via rpc_handler, JSON out
10
- * * 404
8
+ * GET / → wrapper HTML (cached: no)
9
+ * POST /_rpc → JSON in, dispatched via rpc_handler, JSON out
10
+ * POST /_agent_run SSE, piped from the run
11
+ * PUT /_upload/:id → file bytes in (relayed to storage)
12
+ * GET /_file/:token → file bytes out (relayed from storage)
13
+ * * → 404
11
14
  *
12
15
  * SIGINT (Ctrl-C) → kill Vite child, close HTTP server, exit 0.
16
+ *
17
+ * Why the byte relays exist: dev runs against the PROD bucket (there is no dev
18
+ * bucket), whose CORS allowlist holds the real app origins (`https://*.lotics.app`),
19
+ * not `http://localhost:<port>`. So a browser transfer straight to/from the presigned
20
+ * URL is blocked before it leaves the page — no upload could complete, and no preview
21
+ * engine (PDF/Word/Excel all FETCH the bytes) could read a file. Relaying through THIS
22
+ * server fixes both: Node has no same-origin policy, and the one cross-origin hop that
23
+ * remains (the app iframe reading from us) is OUR response to allow.
24
+ *
25
+ * Neither relay ever takes a destination from the client: the page sends a `file_id`
26
+ * or an opaque token, and the server transfers only to/from a URL IT minted or observed
27
+ * for that id. No client-controlled target ⇒ no SSRF surface, nothing to allowlist.
28
+ *
29
+ * Production is untouched — it transfers direct-to-storage, keeping every byte off the
30
+ * API server.
13
31
  */
14
32
  import http from "node:http";
15
33
  import net from "node:net";
@@ -17,6 +35,8 @@ import { spawn } from "node:child_process";
17
35
  import { ipv4ChildEnv } from "../child_env.js";
18
36
  import { dispatchRpc } from "./rpc_handler.js";
19
37
  import { buildWrapperPage } from "./wrapper_page.js";
38
+ import { createUploadRelay } from "./upload_relay.js";
39
+ import { createFileRelay } from "./file_relay.js";
20
40
  const DEFAULT_PORT = 5174;
21
41
  const DEFAULT_VITE_PORT = 5173;
22
42
  /**
@@ -84,6 +104,28 @@ export async function startDevServer(args) {
84
104
  }
85
105
  };
86
106
  process.once("exit", killViteOnExit);
107
+ const uploads = createUploadRelay();
108
+ // The app iframe is served from Vite's origin, so the file URLs it receives must be
109
+ // absolute against THIS server, and its byte reads are cross-origin to us — which is
110
+ // ours to allow (unlike the storage bucket's policy, which is not).
111
+ const wrapperOrigin = `http://localhost:${wrapperPort}`;
112
+ const viteOrigin = `http://localhost:${vitePort}`;
113
+ const files = createFileRelay(wrapperOrigin);
114
+ const fileCors = {
115
+ "Access-Control-Allow-Origin": viteOrigin,
116
+ "Access-Control-Allow-Headers": "range, content-type",
117
+ "Access-Control-Expose-Headers": "content-length, content-range, accept-ranges, content-type, content-disposition, etag",
118
+ };
119
+ // Headers a byte-reader actually needs: the type to decode, the length/range to seek.
120
+ const PASS_THROUGH = [
121
+ "content-type",
122
+ "content-length",
123
+ "content-range",
124
+ "accept-ranges",
125
+ "etag",
126
+ "last-modified",
127
+ "content-disposition",
128
+ ];
87
129
  // ── HTTP server ────────────────────────────────────────────────────────
88
130
  const server = http.createServer(async (req, res) => {
89
131
  const url = req.url ?? "/";
@@ -110,7 +152,9 @@ export async function startDevServer(args) {
110
152
  const ms = Date.now() - startedAt;
111
153
  process.stderr.write(`[rpc] ${body.op} ${ms}ms\n`);
112
154
  res.writeHead(200, { "Content-Type": "application/json" });
113
- res.end(serializeRpcResult(result));
155
+ // `upload_url` mints a PUT destination (upload relay); every other op may carry
156
+ // presigned file URLs the app will read bytes from (file relay).
157
+ res.end(serializeRpcResult(body.op === "upload_url" ? uploads.rewriteMint(result) : files.rewrite(result)));
114
158
  }
115
159
  catch (err) {
116
160
  const message = err instanceof Error ? err.message : String(err);
@@ -171,12 +215,138 @@ export async function startDevServer(args) {
171
215
  }
172
216
  return;
173
217
  }
218
+ // File relay — the app reads file bytes from here instead of straight from storage,
219
+ // whose CORS doesn't admit a localhost origin. The app is on Vite's origin, so this
220
+ // IS cross-origin — but it's our response, so we allow it. Serving from here also
221
+ // means <img>/<video> and openExternal keep working unchanged.
222
+ if (pathname.startsWith("/_file/")) {
223
+ if (req.method === "OPTIONS") {
224
+ res.writeHead(204, fileCors);
225
+ res.end();
226
+ return;
227
+ }
228
+ if (req.method !== "GET" && req.method !== "HEAD") {
229
+ res.writeHead(405, { ...fileCors, Allow: "GET, HEAD, OPTIONS" });
230
+ res.end();
231
+ return;
232
+ }
233
+ const token = decodeURIComponent(pathname.slice("/_file/".length));
234
+ const destination = files.destinationFor(token);
235
+ if (!destination) {
236
+ // Not a URL this server handed out (or its presign has aged out). Reading from
237
+ // anywhere else is the thing this design refuses to do.
238
+ process.stderr.write(`[file] ERROR unknown or expired token ${token}\n`);
239
+ res.writeHead(404, { ...fileCors, "Content-Type": "application/json" });
240
+ res.end(JSON.stringify({ message: "No file for this token" }));
241
+ return;
242
+ }
243
+ try {
244
+ // Forward Range verbatim: a PDF reader seeks rather than reading the whole file,
245
+ // and media scrubbing depends on 206s coming back intact.
246
+ const range = req.headers.range;
247
+ const upstream = await fetch(destination, {
248
+ method: req.method,
249
+ headers: range ? { Range: range } : undefined,
250
+ });
251
+ const headers = { ...fileCors };
252
+ for (const name of PASS_THROUGH) {
253
+ const value = upstream.headers.get(name);
254
+ if (value)
255
+ headers[name] = value;
256
+ }
257
+ // Same one-line-per-transfer visibility as [rpc] and [upload] — a preview that
258
+ // silently serves nothing is exactly the thing this relay exists to make legible.
259
+ const size = headers["content-length"] ?? "?";
260
+ process.stderr.write(`[file] ${token} ${upstream.status} ${size}B\n`);
261
+ res.writeHead(upstream.status, headers);
262
+ if (req.method === "HEAD" || !upstream.body) {
263
+ res.end();
264
+ return;
265
+ }
266
+ const reader = upstream.body.getReader();
267
+ for (;;) {
268
+ const { value, done } = await reader.read();
269
+ if (done)
270
+ break;
271
+ res.write(Buffer.from(value));
272
+ }
273
+ res.end();
274
+ }
275
+ catch (err) {
276
+ const message = err instanceof Error ? err.message : String(err);
277
+ process.stderr.write(`[file] ERROR ${token} ${message}\n`);
278
+ if (!res.headersSent) {
279
+ res.writeHead(502, { ...fileCors, "Content-Type": "application/json" });
280
+ res.end(JSON.stringify({ message }));
281
+ }
282
+ else {
283
+ res.end();
284
+ }
285
+ }
286
+ return;
287
+ }
288
+ // Upload relay — the wrapper page PUTs the bytes here (same-origin, so the
289
+ // browser never applies CORS), and Node forwards them to the presigned URL.
290
+ if (req.method === "PUT" && pathname.startsWith("/_upload/")) {
291
+ const fileId = decodeURIComponent(pathname.slice("/_upload/".length));
292
+ const destination = uploads.destinationFor(fileId);
293
+ if (!destination) {
294
+ // Not a URL this server minted (or its presign has aged out). Relaying
295
+ // anywhere else is exactly the thing this design refuses to do.
296
+ process.stderr.write(`[upload] ERROR unknown or expired file_id ${fileId}\n`);
297
+ res.writeHead(404, { "Content-Type": "application/json" });
298
+ res.end(JSON.stringify({ message: `No presigned upload pending for ${fileId}` }));
299
+ return;
300
+ }
301
+ try {
302
+ // Buffer rather than stream: the presign signs `content-length`, and a
303
+ // streamed body would go out chunked and fail the signature. Dev-only,
304
+ // and the size ceiling is the API's own upload limit.
305
+ const chunks = [];
306
+ for await (const chunk of req)
307
+ chunks.push(chunk);
308
+ const bytes = Buffer.concat(chunks);
309
+ const startedAt = Date.now();
310
+ const upstream = await fetch(destination, {
311
+ method: "PUT",
312
+ body: bytes,
313
+ headers: { "Content-Type": req.headers["content-type"] ?? "application/octet-stream" },
314
+ });
315
+ const ms = Date.now() - startedAt;
316
+ if (upstream.ok) {
317
+ uploads.settle(fileId);
318
+ process.stderr.write(`[upload] ${fileId} ${bytes.length}B ${ms}ms\n`);
319
+ }
320
+ else {
321
+ // Keep the mint: the wrapper retries 5xx, and the presign is still valid.
322
+ process.stderr.write(`[upload] ERROR ${fileId} storage returned ${upstream.status}\n`);
323
+ }
324
+ res.writeHead(upstream.status);
325
+ res.end();
326
+ }
327
+ catch (err) {
328
+ const message = err instanceof Error ? err.message : String(err);
329
+ process.stderr.write(`[upload] ERROR ${fileId} ${message}\n`);
330
+ if (!res.headersSent) {
331
+ res.writeHead(502, { "Content-Type": "application/json" });
332
+ res.end(JSON.stringify({ message }));
333
+ }
334
+ else {
335
+ res.end();
336
+ }
337
+ }
338
+ return;
339
+ }
174
340
  res.writeHead(404, { "Content-Type": "text/plain" });
175
341
  res.end("Not Found");
176
342
  });
177
343
  await new Promise((resolve, reject) => {
178
344
  server.once("error", reject);
179
- server.listen(wrapperPort, () => {
345
+ // Loopback ONLY — never 0.0.0.0. `/_rpc` dispatches with the developer's API key, so a
346
+ // server bound to every interface hands anyone on the same network full read/write on the
347
+ // workspace (and, since the relays, the file bytes too). Vite already binds loopback; this
348
+ // socket is strictly more sensitive than that one.
349
+ server.listen(wrapperPort, "127.0.0.1", () => {
180
350
  server.off("error", reject);
181
351
  resolve();
182
352
  });
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Dev-only upload relay: the bookkeeping that lets `lotics app dev` upload a file.
3
+ *
4
+ * Dev runs against the PROD bucket (there is no dev bucket), whose CORS allowlist
5
+ * holds the real app origins (`https://*.lotics.app`), not `http://localhost:<port>`.
6
+ * A browser PUT straight to the presigned URL is blocked before it leaves the page,
7
+ * so without a relay no file-touching app can be exercised locally at all.
8
+ *
9
+ * The dev server therefore relays the bytes: the wrapper page PUTs same-origin (no
10
+ * preflight, no CORS) and Node — which has no same-origin policy — forwards them on.
11
+ *
12
+ * The security property, and the reason the page never names its destination: a
13
+ * relay that forwarded to a client-supplied URL would be an open proxy. So the page
14
+ * sends only a `file_id`, and the relay writes ONLY to a presigned URL it minted
15
+ * itself for that id, moments earlier, via its own authenticated API call. There is
16
+ * no client-controlled target — hence nothing to allowlist, and no SSRF surface.
17
+ *
18
+ * Production is untouched: it PUTs direct-to-storage, keeping every byte off the
19
+ * API server.
20
+ */
21
+ export interface UploadRelay {
22
+ /**
23
+ * Record the presigned PUT from an `upload_url` mint and return the result the
24
+ * page should see — identical but for a same-origin `upload_url`. A result that
25
+ * carries no mint passes through untouched: nothing is recorded, nothing is
26
+ * rewritten, and the page's PUT fails loudly rather than uploading into a void.
27
+ */
28
+ rewriteMint(result: unknown): unknown;
29
+ /** The presigned URL minted for this id — `null` if unknown or expired. */
30
+ destinationFor(fileId: string): string | null;
31
+ /** Drop a mint once its bytes have landed. One presign, one object. */
32
+ settle(fileId: string): void;
33
+ /** Outstanding mints — for tests. */
34
+ size(): number;
35
+ }
36
+ export declare function createUploadRelay(now?: () => number): UploadRelay;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Dev-only upload relay: the bookkeeping that lets `lotics app dev` upload a file.
3
+ *
4
+ * Dev runs against the PROD bucket (there is no dev bucket), whose CORS allowlist
5
+ * holds the real app origins (`https://*.lotics.app`), not `http://localhost:<port>`.
6
+ * A browser PUT straight to the presigned URL is blocked before it leaves the page,
7
+ * so without a relay no file-touching app can be exercised locally at all.
8
+ *
9
+ * The dev server therefore relays the bytes: the wrapper page PUTs same-origin (no
10
+ * preflight, no CORS) and Node — which has no same-origin policy — forwards them on.
11
+ *
12
+ * The security property, and the reason the page never names its destination: a
13
+ * relay that forwarded to a client-supplied URL would be an open proxy. So the page
14
+ * sends only a `file_id`, and the relay writes ONLY to a presigned URL it minted
15
+ * itself for that id, moments earlier, via its own authenticated API call. There is
16
+ * no client-controlled target — hence nothing to allowlist, and no SSRF surface.
17
+ *
18
+ * Production is untouched: it PUTs direct-to-storage, keeping every byte off the
19
+ * API server.
20
+ */
21
+ /**
22
+ * How long a mint is worth keeping. This is a pruning window, not a correctness gate —
23
+ * storage is the authority on whether a presign is still valid (an expired one is refused
24
+ * there, and the wrapper surfaces that). It only needs to outlive the presign (~10 min) so
25
+ * the relay never 404s a PUT that storage would still have accepted.
26
+ */
27
+ const PRESIGN_TTL_MS = 15 * 60 * 1000;
28
+ export function createUploadRelay(now = Date.now) {
29
+ const minted = new Map();
30
+ const prune = (at) => {
31
+ for (const [id, entry] of minted) {
32
+ if (at - entry.mintedAt > PRESIGN_TTL_MS)
33
+ minted.delete(id);
34
+ }
35
+ };
36
+ return {
37
+ rewriteMint(result) {
38
+ const mint = result;
39
+ if (typeof mint?.file_id !== "string" || typeof mint?.upload_url !== "string")
40
+ return result;
41
+ const at = now();
42
+ prune(at);
43
+ minted.set(mint.file_id, { url: mint.upload_url, mintedAt: at });
44
+ return { ...mint, upload_url: `/_upload/${encodeURIComponent(mint.file_id)}` };
45
+ },
46
+ destinationFor(fileId) {
47
+ const entry = minted.get(fileId);
48
+ if (!entry)
49
+ return null;
50
+ if (now() - entry.mintedAt > PRESIGN_TTL_MS) {
51
+ minted.delete(fileId);
52
+ return null;
53
+ }
54
+ return entry.url;
55
+ },
56
+ settle(fileId) {
57
+ minted.delete(fileId);
58
+ },
59
+ size: () => minted.size,
60
+ };
61
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,76 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { createUploadRelay } from "./upload_relay.js";
3
+ const MINT = {
4
+ file_id: "fil_abc",
5
+ file_storage_key: "org/files/fil_abc/photo.jpg",
6
+ upload_url: "https://production.r2.cloudflarestorage.com/org/files/fil_abc/photo.jpg?X-Amz-Signature=deadbeef",
7
+ };
8
+ describe("upload relay — the page PUTs same-origin", () => {
9
+ it("hands the page a same-origin URL and keeps everything else it needs to finalize", () => {
10
+ const relay = createUploadRelay();
11
+ const seen = relay.rewriteMint(MINT);
12
+ expect(seen.upload_url).toBe("/_upload/fil_abc");
13
+ // file_storage_key + file_id still reach the page — `upload_complete` needs both.
14
+ expect(seen.file_id).toBe("fil_abc");
15
+ expect(seen.file_storage_key).toBe(MINT.file_storage_key);
16
+ });
17
+ it("does not mutate the mint it was handed", () => {
18
+ const relay = createUploadRelay();
19
+ relay.rewriteMint(MINT);
20
+ expect(MINT.upload_url).toContain("r2.cloudflarestorage.com");
21
+ });
22
+ });
23
+ describe("upload relay — it writes ONLY where it was told to by itself", () => {
24
+ it("relays to the presigned URL it minted for that id", () => {
25
+ const relay = createUploadRelay();
26
+ relay.rewriteMint(MINT);
27
+ expect(relay.destinationFor("fil_abc")).toBe(MINT.upload_url);
28
+ });
29
+ it("refuses an id it never minted — there is no client-supplied destination to honour", () => {
30
+ const relay = createUploadRelay();
31
+ expect(relay.destinationFor("fil_never_seen")).toBeNull();
32
+ });
33
+ it("refuses a mint that has aged past its presign", () => {
34
+ let clock = 1_000_000;
35
+ const relay = createUploadRelay(() => clock);
36
+ relay.rewriteMint(MINT);
37
+ clock += 16 * 60 * 1000; // presigns die at ~10 min; the relay holds 15
38
+ expect(relay.destinationFor("fil_abc")).toBeNull();
39
+ });
40
+ });
41
+ describe("upload relay — lifecycle", () => {
42
+ it("forgets a mint once its bytes have landed", () => {
43
+ const relay = createUploadRelay();
44
+ relay.rewriteMint(MINT);
45
+ relay.settle("fil_abc");
46
+ expect(relay.destinationFor("fil_abc")).toBeNull();
47
+ expect(relay.size()).toBe(0);
48
+ });
49
+ it("keeps the mint on a failed PUT so the page's retry can reuse it", () => {
50
+ const relay = createUploadRelay();
51
+ relay.rewriteMint(MINT);
52
+ // No settle() — the storage PUT 5xx'd and the wrapper page retries.
53
+ expect(relay.destinationFor("fil_abc")).toBe(MINT.upload_url);
54
+ });
55
+ it("prunes dead mints instead of growing forever across a long dev session", () => {
56
+ let clock = 0;
57
+ const relay = createUploadRelay(() => clock);
58
+ relay.rewriteMint({ ...MINT, file_id: "fil_old" });
59
+ clock += 16 * 60 * 1000;
60
+ relay.rewriteMint({ ...MINT, file_id: "fil_new" });
61
+ expect(relay.size()).toBe(1); // the stale one was swept on the next mint
62
+ expect(relay.destinationFor("fil_new")).toBe(MINT.upload_url);
63
+ });
64
+ });
65
+ describe("upload relay — a response that carries no mint", () => {
66
+ it("passes through untouched rather than inventing a relay for it", () => {
67
+ const relay = createUploadRelay();
68
+ const odd = { message: "upstream changed shape" };
69
+ expect(relay.rewriteMint(odd)).toBe(odd);
70
+ expect(relay.size()).toBe(0);
71
+ });
72
+ it("tolerates null without throwing", () => {
73
+ const relay = createUploadRelay();
74
+ expect(relay.rewriteMint(null)).toBeNull();
75
+ });
76
+ });
@@ -120,7 +120,13 @@ export function buildWrapperPage(args) {
120
120
 
121
121
  // The iframe SDK sends one "upload" op carrying a File. A File can't
122
122
  // cross the JSON /_rpc hop, so the upload runs here in the browser:
123
- // mint a presigned URL, PUT the bytes to storage, then finalize.
123
+ // mint an upload URL, PUT the bytes, then finalize.
124
+ //
125
+ // In dev the minted URL is same-origin (/_upload/<file_id>) — the dev
126
+ // server relays the bytes to storage on our behalf, because the prod
127
+ // bucket's CORS does not admit a localhost origin. Production PUTs the
128
+ // presigned storage URL directly. Same three lines either way: the page
129
+ // PUTs wherever the mint points.
124
130
  //
125
131
  // Retry policy parity with the production SDK
126
132
  // (packages/app-sdk/src/upload/transport.ts): 3 PUT attempts with
package/dist/src/cli.js CHANGED
@@ -31598,7 +31598,13 @@ function buildWrapperPage(args) {
31598
31598
 
31599
31599
  // The iframe SDK sends one "upload" op carrying a File. A File can't
31600
31600
  // cross the JSON /_rpc hop, so the upload runs here in the browser:
31601
- // mint a presigned URL, PUT the bytes to storage, then finalize.
31601
+ // mint an upload URL, PUT the bytes, then finalize.
31602
+ //
31603
+ // In dev the minted URL is same-origin (/_upload/<file_id>) \u2014 the dev
31604
+ // server relays the bytes to storage on our behalf, because the prod
31605
+ // bucket's CORS does not admit a localhost origin. Production PUTs the
31606
+ // presigned storage URL directly. Same three lines either way: the page
31607
+ // PUTs wherever the mint points.
31602
31608
  //
31603
31609
  // Retry policy parity with the production SDK
31604
31610
  // (packages/app-sdk/src/upload/transport.ts): 3 PUT attempts with
@@ -31803,6 +31809,85 @@ function escapeHtml2(s) {
31803
31809
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
31804
31810
  }
31805
31811
 
31812
+ // src/dev/upload_relay.ts
31813
+ var PRESIGN_TTL_MS = 15 * 60 * 1e3;
31814
+ function createUploadRelay(now = Date.now) {
31815
+ const minted = /* @__PURE__ */ new Map();
31816
+ const prune = (at) => {
31817
+ for (const [id, entry] of minted) {
31818
+ if (at - entry.mintedAt > PRESIGN_TTL_MS) minted.delete(id);
31819
+ }
31820
+ };
31821
+ return {
31822
+ rewriteMint(result) {
31823
+ const mint = result;
31824
+ if (typeof mint?.file_id !== "string" || typeof mint?.upload_url !== "string") return result;
31825
+ const at = now();
31826
+ prune(at);
31827
+ minted.set(mint.file_id, { url: mint.upload_url, mintedAt: at });
31828
+ return { ...mint, upload_url: `/_upload/${encodeURIComponent(mint.file_id)}` };
31829
+ },
31830
+ destinationFor(fileId) {
31831
+ const entry = minted.get(fileId);
31832
+ if (!entry) return null;
31833
+ if (now() - entry.mintedAt > PRESIGN_TTL_MS) {
31834
+ minted.delete(fileId);
31835
+ return null;
31836
+ }
31837
+ return entry.url;
31838
+ },
31839
+ settle(fileId) {
31840
+ minted.delete(fileId);
31841
+ },
31842
+ size: () => minted.size
31843
+ };
31844
+ }
31845
+
31846
+ // src/dev/file_relay.ts
31847
+ import { createHash } from "node:crypto";
31848
+ var TTL_MS = 24 * 60 * 60 * 1e3;
31849
+ var MAX_ENTRIES = 5e3;
31850
+ var PRESIGNED_KEYS = /* @__PURE__ */ new Set(["url", "thumbnail_url", "preview_url"]);
31851
+ var isFileObject = (o) => typeof o.filename === "string" && typeof o.mime_type === "string";
31852
+ var isRemoteUrl = (v) => typeof v === "string" && /^https?:\/\//i.test(v);
31853
+ function createFileRelay(wrapperOrigin, now = Date.now) {
31854
+ const seen = /* @__PURE__ */ new Map();
31855
+ const tokenFor = (url2) => createHash("sha256").update(url2).digest("hex").slice(0, 24);
31856
+ const remember = (url2) => {
31857
+ const token = tokenFor(url2);
31858
+ seen.set(token, { url: url2, at: now() });
31859
+ if (seen.size > MAX_ENTRIES) {
31860
+ const oldest = seen.keys().next();
31861
+ if (!oldest.done) seen.delete(oldest.value);
31862
+ }
31863
+ return token;
31864
+ };
31865
+ const walk2 = (node) => {
31866
+ if (Array.isArray(node)) return node.map(walk2);
31867
+ if (!node || typeof node !== "object") return node;
31868
+ const obj = node;
31869
+ const file2 = isFileObject(obj);
31870
+ const out = {};
31871
+ for (const [key, value] of Object.entries(obj)) {
31872
+ out[key] = file2 && PRESIGNED_KEYS.has(key) && isRemoteUrl(value) ? `${wrapperOrigin}/_file/${remember(value)}` : walk2(value);
31873
+ }
31874
+ return out;
31875
+ };
31876
+ return {
31877
+ rewrite: (result) => walk2(result),
31878
+ destinationFor(token) {
31879
+ const entry = seen.get(token);
31880
+ if (!entry) return null;
31881
+ if (now() - entry.at > TTL_MS) {
31882
+ seen.delete(token);
31883
+ return null;
31884
+ }
31885
+ return entry.url;
31886
+ },
31887
+ size: () => seen.size
31888
+ };
31889
+ }
31890
+
31806
31891
  // src/dev/server.ts
31807
31892
  var DEFAULT_PORT = 5174;
31808
31893
  var DEFAULT_VITE_PORT = 5173;
@@ -31849,6 +31934,24 @@ async function startDevServer(args) {
31849
31934
  }
31850
31935
  };
31851
31936
  process.once("exit", killViteOnExit);
31937
+ const uploads = createUploadRelay();
31938
+ const wrapperOrigin = `http://localhost:${wrapperPort}`;
31939
+ const viteOrigin = `http://localhost:${vitePort}`;
31940
+ const files = createFileRelay(wrapperOrigin);
31941
+ const fileCors = {
31942
+ "Access-Control-Allow-Origin": viteOrigin,
31943
+ "Access-Control-Allow-Headers": "range, content-type",
31944
+ "Access-Control-Expose-Headers": "content-length, content-range, accept-ranges, content-type, content-disposition, etag"
31945
+ };
31946
+ const PASS_THROUGH = [
31947
+ "content-type",
31948
+ "content-length",
31949
+ "content-range",
31950
+ "accept-ranges",
31951
+ "etag",
31952
+ "last-modified",
31953
+ "content-disposition"
31954
+ ];
31852
31955
  const server = http.createServer(async (req, res) => {
31853
31956
  const url2 = req.url ?? "/";
31854
31957
  const pathname = url2.split("?")[0];
@@ -31877,7 +31980,11 @@ async function startDevServer(args) {
31877
31980
  process.stderr.write(`[rpc] ${body.op} ${ms}ms
31878
31981
  `);
31879
31982
  res.writeHead(200, { "Content-Type": "application/json" });
31880
- res.end(serializeRpcResult(result));
31983
+ res.end(
31984
+ serializeRpcResult(
31985
+ body.op === "upload_url" ? uploads.rewriteMint(result) : files.rewrite(result)
31986
+ )
31987
+ );
31881
31988
  } catch (err2) {
31882
31989
  const message = err2 instanceof Error ? err2.message : String(err2);
31883
31990
  process.stderr.write(`[rpc] ERROR ${message}
@@ -31930,12 +32037,115 @@ async function startDevServer(args) {
31930
32037
  }
31931
32038
  return;
31932
32039
  }
32040
+ if (pathname.startsWith("/_file/")) {
32041
+ if (req.method === "OPTIONS") {
32042
+ res.writeHead(204, fileCors);
32043
+ res.end();
32044
+ return;
32045
+ }
32046
+ if (req.method !== "GET" && req.method !== "HEAD") {
32047
+ res.writeHead(405, { ...fileCors, Allow: "GET, HEAD, OPTIONS" });
32048
+ res.end();
32049
+ return;
32050
+ }
32051
+ const token = decodeURIComponent(pathname.slice("/_file/".length));
32052
+ const destination = files.destinationFor(token);
32053
+ if (!destination) {
32054
+ process.stderr.write(`[file] ERROR unknown or expired token ${token}
32055
+ `);
32056
+ res.writeHead(404, { ...fileCors, "Content-Type": "application/json" });
32057
+ res.end(JSON.stringify({ message: "No file for this token" }));
32058
+ return;
32059
+ }
32060
+ try {
32061
+ const range = req.headers.range;
32062
+ const upstream = await fetch(destination, {
32063
+ method: req.method,
32064
+ headers: range ? { Range: range } : void 0
32065
+ });
32066
+ const headers = { ...fileCors };
32067
+ for (const name of PASS_THROUGH) {
32068
+ const value = upstream.headers.get(name);
32069
+ if (value) headers[name] = value;
32070
+ }
32071
+ const size = headers["content-length"] ?? "?";
32072
+ process.stderr.write(`[file] ${token} ${upstream.status} ${size}B
32073
+ `);
32074
+ res.writeHead(upstream.status, headers);
32075
+ if (req.method === "HEAD" || !upstream.body) {
32076
+ res.end();
32077
+ return;
32078
+ }
32079
+ const reader = upstream.body.getReader();
32080
+ for (; ; ) {
32081
+ const { value, done } = await reader.read();
32082
+ if (done) break;
32083
+ res.write(Buffer.from(value));
32084
+ }
32085
+ res.end();
32086
+ } catch (err2) {
32087
+ const message = err2 instanceof Error ? err2.message : String(err2);
32088
+ process.stderr.write(`[file] ERROR ${token} ${message}
32089
+ `);
32090
+ if (!res.headersSent) {
32091
+ res.writeHead(502, { ...fileCors, "Content-Type": "application/json" });
32092
+ res.end(JSON.stringify({ message }));
32093
+ } else {
32094
+ res.end();
32095
+ }
32096
+ }
32097
+ return;
32098
+ }
32099
+ if (req.method === "PUT" && pathname.startsWith("/_upload/")) {
32100
+ const fileId = decodeURIComponent(pathname.slice("/_upload/".length));
32101
+ const destination = uploads.destinationFor(fileId);
32102
+ if (!destination) {
32103
+ process.stderr.write(`[upload] ERROR unknown or expired file_id ${fileId}
32104
+ `);
32105
+ res.writeHead(404, { "Content-Type": "application/json" });
32106
+ res.end(JSON.stringify({ message: `No presigned upload pending for ${fileId}` }));
32107
+ return;
32108
+ }
32109
+ try {
32110
+ const chunks = [];
32111
+ for await (const chunk of req) chunks.push(chunk);
32112
+ const bytes = Buffer.concat(chunks);
32113
+ const startedAt = Date.now();
32114
+ const upstream = await fetch(destination, {
32115
+ method: "PUT",
32116
+ body: bytes,
32117
+ headers: { "Content-Type": req.headers["content-type"] ?? "application/octet-stream" }
32118
+ });
32119
+ const ms = Date.now() - startedAt;
32120
+ if (upstream.ok) {
32121
+ uploads.settle(fileId);
32122
+ process.stderr.write(`[upload] ${fileId} ${bytes.length}B ${ms}ms
32123
+ `);
32124
+ } else {
32125
+ process.stderr.write(`[upload] ERROR ${fileId} storage returned ${upstream.status}
32126
+ `);
32127
+ }
32128
+ res.writeHead(upstream.status);
32129
+ res.end();
32130
+ } catch (err2) {
32131
+ const message = err2 instanceof Error ? err2.message : String(err2);
32132
+ process.stderr.write(`[upload] ERROR ${fileId} ${message}
32133
+ `);
32134
+ if (!res.headersSent) {
32135
+ res.writeHead(502, { "Content-Type": "application/json" });
32136
+ res.end(JSON.stringify({ message }));
32137
+ } else {
32138
+ res.end();
32139
+ }
32140
+ }
32141
+ return;
32142
+ }
31933
32143
  res.writeHead(404, { "Content-Type": "text/plain" });
31934
32144
  res.end("Not Found");
31935
32145
  });
31936
32146
  await new Promise((resolve2, reject2) => {
31937
32147
  server.once("error", reject2);
31938
- server.listen(wrapperPort, () => {
32148
+ server.listen(wrapperPort, "127.0.0.1", () => {
31939
32149
  server.off("error", reject2);
31940
32150
  resolve2();
31941
32151
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/cli",
3
- "version": "0.86.0",
3
+ "version": "0.86.1",
4
4
  "description": "Lotics SDK and CLI for AI agents",
5
5
  "type": "module",
6
6
  "bin": {