@lotics/cli 0.86.0 → 0.86.2
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/dist/cli.js +2 -2
- package/dist/client.d.ts +22 -1
- package/dist/client.js +6 -0
- package/dist/dev/file_relay.d.ts +39 -0
- package/dist/dev/file_relay.js +87 -0
- package/dist/dev/file_relay.test.d.ts +1 -0
- package/dist/dev/file_relay.test.js +87 -0
- package/dist/dev/server.d.ts +21 -3
- package/dist/dev/server.js +175 -5
- package/dist/dev/upload_relay.d.ts +36 -0
- package/dist/dev/upload_relay.js +61 -0
- package/dist/dev/upload_relay.test.d.ts +1 -0
- package/dist/dev/upload_relay.test.js +76 -0
- package/dist/dev/wrapper_page.js +7 -1
- package/dist/package_commands.d.ts +15 -8
- package/dist/package_commands.js +111 -22
- package/dist/package_commands.test.js +131 -25
- package/dist/src/cli.js +304 -12
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -127,7 +127,7 @@ COMMANDS
|
|
|
127
127
|
<alias>=apply|keep|archive|recreate|unbind); an APP package
|
|
128
128
|
FLEET-upgrades every installation across your org (clean
|
|
129
129
|
apply, findings skip). --apply-all accepts the package's version
|
|
130
|
-
lotics package doctor [app_id]
|
|
130
|
+
lotics package doctor [app_id|package_id] Installation health: version pin vs latest, binding
|
|
131
131
|
drift, locally modified core, knowledge drift/edits
|
|
132
132
|
(exit 1 on findings)
|
|
133
133
|
lotics package config <app_id> [--set key=value ...]
|
|
@@ -717,7 +717,7 @@ async function main() {
|
|
|
717
717
|
console.error(" lotics install <package_id> [--version N] [--bind-to <alias>=<kdc_id>] [--config key=value ...] Install a package (app or content)");
|
|
718
718
|
console.error(" lotics uninstall <app_id|package_id> [--archive-tables] [--keep-content] Uninstall — dispatched by id (app vs content)");
|
|
719
719
|
console.error(" lotics upgrade <app_id|package_id> [--version N] [--resolve <key>=... ] [--bind-to ...] [--apply-all] Upgrade — app install / content install / whole app fleet (apg_)");
|
|
720
|
-
console.error(" lotics package doctor [app_id]
|
|
720
|
+
console.error(" lotics package doctor [app_id|package_id] Health: version pin vs latest + binding/knowledge drift (apg_ = this workspace's content install)");
|
|
721
721
|
console.error(" lotics package config <app_id> [--set key=value ...] Show or edit an installation's config knobs");
|
|
722
722
|
console.error(" lotics package eject <app_id> Sever an installation's package link");
|
|
723
723
|
console.error(" lotics package show <package_id> Registry metadata + version history (channel, yank, changelog)");
|
package/dist/client.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { KnowledgeUpgradeEntry, ModifiedArtifact, PackageContentBinding, UpgradeResolutions } from "@lotics/shared/schemas/packages";
|
|
1
|
+
import type { ContractConfigEntry, KnowledgeUpgradeEntry, ModifiedArtifact, PackageContentBinding, UpgradeResolutions } from "@lotics/shared/schemas/packages";
|
|
2
2
|
/**
|
|
3
3
|
* The error message for a non-ok response. A genuine JSON error (a 4xx carrying
|
|
4
4
|
* a `message`) surfaces verbatim; a non-JSON body (a gateway HTML page), any
|
|
@@ -385,6 +385,7 @@ export declare class LoticsClient {
|
|
|
385
385
|
breaking: number;
|
|
386
386
|
drift: number;
|
|
387
387
|
modified: number;
|
|
388
|
+
knowledge: number;
|
|
388
389
|
};
|
|
389
390
|
message?: string;
|
|
390
391
|
}>;
|
|
@@ -555,6 +556,8 @@ export declare class LoticsClient {
|
|
|
555
556
|
alias: string;
|
|
556
557
|
doc_id: string;
|
|
557
558
|
}>;
|
|
559
|
+
/** Config knob declarations re-declaring the config (from the manifest). */
|
|
560
|
+
config?: ContractConfigEntry[];
|
|
558
561
|
}): Promise<{
|
|
559
562
|
package_id: string;
|
|
560
563
|
version: number;
|
|
@@ -566,6 +569,12 @@ export declare class LoticsClient {
|
|
|
566
569
|
removed: string[];
|
|
567
570
|
changed: string[];
|
|
568
571
|
};
|
|
572
|
+
/** Absent from a pre-declaration server (deploy skew) — treat as empty delta. */
|
|
573
|
+
config?: {
|
|
574
|
+
added: string[];
|
|
575
|
+
removed: string[];
|
|
576
|
+
changed: string[];
|
|
577
|
+
};
|
|
569
578
|
findings: ExtractFinding[];
|
|
570
579
|
}>;
|
|
571
580
|
/**
|
|
@@ -585,6 +594,8 @@ export declare class LoticsClient {
|
|
|
585
594
|
alias: string;
|
|
586
595
|
doc_id: string;
|
|
587
596
|
}>;
|
|
597
|
+
/** Config knob declarations re-declaring the config (from the manifest). */
|
|
598
|
+
config?: ContractConfigEntry[];
|
|
588
599
|
}): Promise<{
|
|
589
600
|
package_id: string;
|
|
590
601
|
version: number;
|
|
@@ -596,6 +607,12 @@ export declare class LoticsClient {
|
|
|
596
607
|
removed: string[];
|
|
597
608
|
changed: string[];
|
|
598
609
|
};
|
|
610
|
+
/** Absent from a pre-declaration server (deploy skew) — treat as empty delta. */
|
|
611
|
+
config?: {
|
|
612
|
+
added: string[];
|
|
613
|
+
removed: string[];
|
|
614
|
+
changed: string[];
|
|
615
|
+
};
|
|
599
616
|
}>;
|
|
600
617
|
/**
|
|
601
618
|
* Dry-run preview of a first-release — the `GET` behind `lotics app publish`
|
|
@@ -615,6 +632,8 @@ export declare class LoticsClient {
|
|
|
615
632
|
alias: string;
|
|
616
633
|
doc_id: string;
|
|
617
634
|
}>;
|
|
635
|
+
/** Config knob declarations v1 would freeze (from the manifest's `lotics.config`). */
|
|
636
|
+
config?: ContractConfigEntry[];
|
|
618
637
|
}): Promise<{
|
|
619
638
|
app_id: string;
|
|
620
639
|
package_name: string;
|
|
@@ -649,6 +668,8 @@ export declare class LoticsClient {
|
|
|
649
668
|
alias: string;
|
|
650
669
|
doc_id: string;
|
|
651
670
|
}>;
|
|
671
|
+
/** Config knob declarations v1 freezes (from the manifest's `lotics.config`). */
|
|
672
|
+
config?: ContractConfigEntry[];
|
|
652
673
|
}): Promise<{
|
|
653
674
|
package_id: string;
|
|
654
675
|
version: number;
|
package/dist/client.js
CHANGED
|
@@ -365,6 +365,9 @@ export class LoticsClient {
|
|
|
365
365
|
if (opts.knowledge !== undefined && opts.knowledge.length > 0) {
|
|
366
366
|
params.set("knowledge", JSON.stringify(opts.knowledge));
|
|
367
367
|
}
|
|
368
|
+
if (opts.config !== undefined) {
|
|
369
|
+
params.set("config", JSON.stringify(opts.config));
|
|
370
|
+
}
|
|
368
371
|
const query = params.toString();
|
|
369
372
|
return this.request("GET", `/v1/apps/${encodeURIComponent(app_id)}/package-release${query ? `?${query}` : ""}`);
|
|
370
373
|
}
|
|
@@ -394,6 +397,9 @@ export class LoticsClient {
|
|
|
394
397
|
if (opts.knowledge !== undefined && opts.knowledge.length > 0) {
|
|
395
398
|
params.set("knowledge", JSON.stringify(opts.knowledge));
|
|
396
399
|
}
|
|
400
|
+
if (opts.config !== undefined && opts.config.length > 0) {
|
|
401
|
+
params.set("config", JSON.stringify(opts.config));
|
|
402
|
+
}
|
|
397
403
|
if (opts.renames !== undefined && opts.renames.length > 0) {
|
|
398
404
|
params.set("renames", JSON.stringify(opts.renames));
|
|
399
405
|
}
|
|
@@ -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
|
+
});
|
package/dist/dev/server.d.ts
CHANGED
|
@@ -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 /
|
|
9
|
-
* POST /_rpc
|
|
10
|
-
*
|
|
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";
|
package/dist/dev/server.js
CHANGED
|
@@ -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 /
|
|
9
|
-
* POST /_rpc
|
|
10
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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;
|