@lotics/cli 0.83.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.
- package/README.md +11 -10
- package/dist/cli.js +39 -28
- package/dist/cli_dispatch.test.js +15 -2
- package/dist/client.d.ts +33 -4
- package/dist/client.js +16 -5
- 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/generate_app_workflows_dts.js +11 -2
- package/dist/generate_app_workflows_dts.test.js +3 -3
- package/dist/package_commands.d.ts +36 -34
- package/dist/package_commands.js +143 -44
- package/dist/package_commands.test.js +135 -2
- package/dist/src/cli.js +356 -61
- package/package.json +1 -1
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;
|
|
@@ -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
|
+
});
|
package/dist/dev/wrapper_page.js
CHANGED
|
@@ -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
|
|
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
|
|
@@ -127,7 +127,12 @@ function inputDeclToTsType(decl) {
|
|
|
127
127
|
return null;
|
|
128
128
|
})
|
|
129
129
|
.filter((v) => v !== null);
|
|
130
|
-
|
|
130
|
+
// The literal union keeps autocomplete for hand-typed ids, but a package
|
|
131
|
+
// app addresses options through the runtime-resolved OPT map (typed
|
|
132
|
+
// string — consumer workspaces bind DIFFERENT concrete ids), so the
|
|
133
|
+
// input must also accept string. `(string & {})` widens without
|
|
134
|
+
// collapsing the union in intellisense.
|
|
135
|
+
const inner = literals.length > 0 ? `${literals.join(" | ")} | (string & {})` : "string";
|
|
131
136
|
return decl.multi === true ? `ReadonlyArray<${inner}>` : inner;
|
|
132
137
|
}
|
|
133
138
|
case "date_range":
|
|
@@ -193,7 +198,11 @@ function outputDeclToTsType(decl) {
|
|
|
193
198
|
? JSON.stringify(o.value)
|
|
194
199
|
: null)
|
|
195
200
|
.filter((v) => v !== null);
|
|
196
|
-
|
|
201
|
+
// Same widening on the READ side: a package app's runtime output value is
|
|
202
|
+
// the CONSUMER workspace's concrete opt_ id, not the origin's literal —
|
|
203
|
+
// exhaustive narrowing over origin literals would be unsound. `(string & {})`
|
|
204
|
+
// keeps autocomplete without lying about the value space.
|
|
205
|
+
const inner = literals.length > 0 ? `${literals.join(" | ")} | (string & {})` : "string";
|
|
197
206
|
return decl.multi === true ? `ReadonlyArray<${inner}>` : inner;
|
|
198
207
|
}
|
|
199
208
|
case "object": {
|
|
@@ -77,8 +77,8 @@ describe("generateAppWorkflowsDts", () => {
|
|
|
77
77
|
});
|
|
78
78
|
expect(dts).toContain("record_id: string;"); // required → NOT nullable
|
|
79
79
|
expect(dts).toContain("due?: string | null;"); // optional scalar → clearable
|
|
80
|
-
expect(dts).toContain('status?: "opt_done" | null;');
|
|
81
|
-
expect(dts).toContain('tags?: ReadonlyArray<"opt_a"> | null;'); // optional multi
|
|
80
|
+
expect(dts).toContain('status?: "opt_done" | (string & {}) | null;');
|
|
81
|
+
expect(dts).toContain('tags?: ReadonlyArray<"opt_a" | (string & {})> | null;'); // optional multi
|
|
82
82
|
// The object itself is a top-level optional → nullable; its NESTED optional
|
|
83
83
|
// field mirrors the backend's plain `.optional()` (no `| null`).
|
|
84
84
|
expect(dts).toContain("note?: string;");
|
|
@@ -94,7 +94,7 @@ describe("generateAppWorkflowsDts", () => {
|
|
|
94
94
|
},
|
|
95
95
|
},
|
|
96
96
|
});
|
|
97
|
-
expect(dts).toContain('owner: string; tags: ReadonlyArray<"a" | "b">');
|
|
97
|
+
expect(dts).toContain('owner: string; tags: ReadonlyArray<"a" | "b" | (string & {})>');
|
|
98
98
|
});
|
|
99
99
|
it("types a multi file input as ReadonlyArray<string>, single file as string", () => {
|
|
100
100
|
const dts = generateAppWorkflowsDts({
|
|
@@ -79,24 +79,28 @@ export declare function packageShow(client: LoticsClient, args: {
|
|
|
79
79
|
package_id: string;
|
|
80
80
|
}): Promise<void>;
|
|
81
81
|
/**
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
* drifted→recreate; template→revert) — an explicit bulk "take upstream" that
|
|
89
|
-
* discards local edits. `--resolve knowledge.<alias>=<value>` /
|
|
90
|
-
* `--resolve template.<alias>=revert|keep` and `--bind-to <alias>=<kdc_id>`
|
|
91
|
-
* resolve entries individually — the ONE namespaced grammar, passed to the
|
|
92
|
-
* server verbatim.
|
|
82
|
+
* CLEAN BREAK for an explicit `pci_` argument on `upgrade` / `uninstall`: the
|
|
83
|
+
* `pci_` resource id is retired from human sight — content installs are addressed
|
|
84
|
+
* by their PACKAGE id. Prints a loud redirect, best-effort resolving the `pci_`
|
|
85
|
+
* back to its package id (via list-content) so the exact command is spelled out.
|
|
86
|
+
* The header prints synchronously first, so the redirect is observable even when
|
|
87
|
+
* the best-effort resolution can't reach the registry.
|
|
93
88
|
*/
|
|
94
|
-
export declare function
|
|
95
|
-
|
|
89
|
+
export declare function redirectContentPciForm(client: LoticsClient, pci_id: string, verb: "upgrade" | "uninstall"): Promise<never>;
|
|
90
|
+
/**
|
|
91
|
+
* `lotics upgrade <apg_>` — the package-id upgrade path, kind-branched. `kind` is a
|
|
92
|
+
* DERIVED display hint (`contractHasAppSurface`): `'content'` means no app surface.
|
|
93
|
+
* - a CONTENT package upgrades THIS workspace's standalone content installation
|
|
94
|
+
* (resolved from the package id — the anchor is unique per workspace, so the
|
|
95
|
+
* `pci_` never surfaces), through the same content review gate.
|
|
96
|
+
* - an APP-surface package FLEET-upgrades every installation across the org
|
|
97
|
+
* (unchanged) — the resolve/bind/apply-all flags don't apply to a fleet run.
|
|
98
|
+
*/
|
|
99
|
+
export declare function packageUpgradeByPackageId(client: LoticsClient, args: {
|
|
100
|
+
package_id: string;
|
|
96
101
|
version?: number;
|
|
97
|
-
/** Raw `--resolve <alias>=<value>` flags, routed to knowledge/templates after the preview. */
|
|
98
102
|
resolve: string[];
|
|
99
|
-
|
|
103
|
+
bindTo: string[];
|
|
100
104
|
applyAll: boolean;
|
|
101
105
|
}): Promise<void>;
|
|
102
106
|
/** Parse repeated `--bind-to alias=kdc_id` flags into an alias → doc-id consent map. */
|
|
@@ -110,9 +114,11 @@ export declare function packageInstall(client: LoticsClient, args: {
|
|
|
110
114
|
config?: Record<string, string | number | boolean>;
|
|
111
115
|
}): Promise<void>;
|
|
112
116
|
/**
|
|
113
|
-
* `lotics uninstall <app_id|
|
|
114
|
-
* kinds, dispatched by the id form (mirrors `lotics upgrade`):
|
|
115
|
-
* - a `
|
|
117
|
+
* `lotics uninstall <app_id|package_id>` — ONE top-level command over both
|
|
118
|
+
* installation kinds, dispatched by the id form (mirrors `lotics upgrade`):
|
|
119
|
+
* - a package id (`apg_`) → THIS workspace's STANDALONE CONTENT installation
|
|
120
|
+
* (content installs are addressed by package id, UNIQUE per workspace, so the
|
|
121
|
+
* `pci_` resource id never surfaces): deletes the row and (unless
|
|
116
122
|
* `--keep-content`) archives its package-bound docs AND templates, listing each
|
|
117
123
|
* archived id.
|
|
118
124
|
* - anything else (an `app_id`) → an APP installation: archives its workflow
|
|
@@ -129,8 +135,9 @@ export declare function packageUninstall(client: LoticsClient, args: {
|
|
|
129
135
|
* `lotics package list-content` — list the selected workspace's STANDALONE
|
|
130
136
|
* content installations (an app-bundled corpus rides its app's
|
|
131
137
|
* `binding.knowledge` and shows on the Apps surface instead), each with its
|
|
132
|
-
* registry status. The
|
|
133
|
-
*
|
|
138
|
+
* registry status. The what-is-installed listing: each row leads with the PACKAGE
|
|
139
|
+
* id — the address for `lotics upgrade <package_id>` / `lotics uninstall
|
|
140
|
+
* <package_id>` (the `pci_` resource id stays hidden).
|
|
134
141
|
*/
|
|
135
142
|
export declare function packageListContent(client: LoticsClient): Promise<void>;
|
|
136
143
|
export declare function packageEject(client: LoticsClient, args: {
|
|
@@ -185,9 +192,15 @@ export declare function appPublish(client: LoticsClient, args: {
|
|
|
185
192
|
* § Promotion). The origin is the permanent working copy; a release binding-aware-
|
|
186
193
|
* extracts it (stable aliases), repackages its DEPLOYED source + dist as the
|
|
187
194
|
* bundle, publishes the next version, and re-pins the origin. Prints the preview
|
|
188
|
-
* first (next version, new + changed aliases,
|
|
189
|
-
* `--yes`, else exits 1 so a review step can't be
|
|
190
|
-
* blocks the apply.
|
|
195
|
+
* first (next version, new + changed aliases, the bundled-knowledge delta,
|
|
196
|
+
* findings); applies only with `--yes`, else exits 1 so a review step can't be
|
|
197
|
+
* skipped. An `error` finding blocks the apply.
|
|
198
|
+
*
|
|
199
|
+
* Run from the pulled app project, the manifest's `lotics.knowledge` (alias →
|
|
200
|
+
* doc_id) is the bundle DECLARATION — it re-declares which docs the package owns
|
|
201
|
+
* (add/drop/re-snapshot). Forwarded only when non-empty; empty (or a bare id from
|
|
202
|
+
* elsewhere) sends nothing, so the current corpus is reconstructed from the pin
|
|
203
|
+
* (never silently dropped).
|
|
191
204
|
*/
|
|
192
205
|
export declare function appRelease(client: LoticsClient, args: {
|
|
193
206
|
app_id?: string;
|
|
@@ -206,14 +219,3 @@ export declare function packageYank(client: LoticsClient, args: {
|
|
|
206
219
|
version: number;
|
|
207
220
|
undo: boolean;
|
|
208
221
|
}): Promise<void>;
|
|
209
|
-
/**
|
|
210
|
-
* `lotics upgrade <package_id> [--version N]` (fleet path) — bring every
|
|
211
|
-
* installation of the package across the caller's org to the target version.
|
|
212
|
-
* Hands-off applies only where the preview is clean; skipped/failed
|
|
213
|
-
* installations are reported per line and the process exits 1 so a release
|
|
214
|
-
* script can gate on "fleet fully current".
|
|
215
|
-
*/
|
|
216
|
-
export declare function packageFleetUpgrade(client: LoticsClient, args: {
|
|
217
|
-
package_id: string;
|
|
218
|
-
version?: number;
|
|
219
|
-
}): Promise<void>;
|