@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 CHANGED
@@ -227,7 +227,7 @@ lotics package config app_... --set show_done=false # edit a live inst
227
227
  lotics uninstall app_... [--archive-tables] # remove an installation; workflows stop firing
228
228
  lotics package doctor app_... # version pin vs latest + drift + local edits (exit 1 on findings)
229
229
  lotics upgrade app_... # preview, then apply (additive; overlay preserved)
230
- lotics upgrade apg_... # FLEET: upgrade EVERY org installation (clean ones apply; findings skip, exit 1)
230
+ lotics upgrade apg_... # APP package → FLEET: upgrade EVERY org installation (clean ones apply; findings skip, exit 1)
231
231
  lotics upgrade app_... --resolve fields.deal.stage=recreate # resolve reported drift
232
232
  lotics upgrade app_... --resolve queries.tasks=keep # consent for a local edit (or =revert)
233
233
  lotics upgrade app_... --resolve template.quote=revert # a locally-edited template (clean ones auto-update; or =keep)
@@ -238,7 +238,7 @@ lotics package eject app_... # one-way: sever the package link
238
238
  lotics workspace doctor # dangling schema references across the workspace (exit 1 on findings)
239
239
  ```
240
240
 
241
- After the first publish **the origin is the master** — develop it in its workspace (`app pull` / `app deploy` / `app workflow set` / table tools), and `lotics app release` snapshots each version. Its **binding-aware re-extract** keeps aliases stable through the origin's current binding, so re-extraction never churns the upgrade diff (which is why extract-again is the loop, not a hazard).
241
+ After the first publish **the origin is the master** — develop it in its workspace (`app pull` / `app deploy` / `app workflow set` / table tools), and `lotics app release` snapshots each version. Its **binding-aware re-extract** keeps aliases stable through the origin's current binding, so re-extraction never churns the upgrade diff (which is why extract-again is the loop, not a hazard). Release is also the **bundled-knowledge declaration surface**: run from the app project, the manifest's `lotics.knowledge` (`[{alias,doc_id}]`) re-declares which docs the package bundles — an alias added ships as a new doc, a bound alias dropped surfaces to consumers as `removed` (archive|keep), a present one re-snapshots; a bare id from elsewhere reconstructs the current corpus from the pin (never drops it). So a published app package evolves its bundled docs across versions without ejecting.
242
242
 
243
243
  ### Content packages
244
244
 
@@ -257,15 +257,16 @@ lotics run release_content '{"package_id":"apg_...","changelog":"v2: revised SOP
257
257
  lotics install apg_... # app OR content, shape-branched
258
258
  lotics install apg_... --bind-to policy=kdc_... # adopt an existing doc as package-managed
259
259
 
260
- # Discover installed content (the source for a pci_ id — install prints it once)
261
- lotics package list-content # id · package · version (standalone content installs)
260
+ # Discover installed content (each row leads with the PACKAGE id)
261
+ lotics package list-content # package id · package · version (standalone content installs)
262
262
 
263
- # Refresh a standalone content install (pci_ id from install / list-content) — one gate over docs + templates
264
- lotics upgrade pci_... # preview, then apply
265
- lotics upgrade pci_... --resolve knowledge.policy=apply # a doc: apply|keep|archive|recreate|unbind
266
- lotics upgrade pci_... --resolve template.quote=revert # a locally-edited template: revert|keep (clean ones auto-update)
267
- lotics upgrade pci_... --apply-all # accept upstream for every doc + template (OVERWRITES local edits)
268
- lotics uninstall pci_... --keep-content # remove the pin; keep docs + templates (default archives them)
263
+ # Refresh this workspace's content install addressed by PACKAGE id (one install per
264
+ # package per workspace, so the package id is the address; one gate over docs + templates)
265
+ lotics upgrade apg_... # CONTENT package → preview, then apply (this workspace's install)
266
+ lotics upgrade apg_... --resolve knowledge.policy=apply # a doc: apply|keep|archive|recreate|unbind
267
+ lotics upgrade apg_... --resolve template.quote=revert # a locally-edited template: revert|keep (clean ones auto-update)
268
+ lotics upgrade apg_... --apply-all # accept upstream for every doc + template (OVERWRITES local edits)
269
+ lotics uninstall apg_... --keep-content # remove the pin; keep docs + templates (default archives them)
269
270
  ```
270
271
 
271
272
  A package's knowledge can also arrive **bundled with an app**. Upgrading such an app (`lotics upgrade <app_id>`) previews its bundled-knowledge changes alongside the plan and resolves them in the SAME namespaced grammar — `--resolve knowledge.<alias>=apply|keep|archive|recreate|unbind` (the flags pass to the server verbatim; no routing, no ambiguity), `--bind-to` consents an added-doc collision, `--apply-all` takes the package's version for every consent-requiring doc.
package/dist/cli.js CHANGED
@@ -14,7 +14,7 @@ import { LoticsClient, API_BASE_URL } from "./client.js";
14
14
  import { resolveContext, deleteConfig, getConfigPath, loadGlobalConfig, saveGlobalConfig, loadLocalConfig, upsertProfile, removeProfile, setActiveOrg, setSelectedWorkspace, resolveProfileByNameOrId, checkForUpdate, } from "./config.js";
15
15
  import { VERSION } from "./version.js";
16
16
  import { appCreate, appPull, appDeploy, appDev, appSetSubdomain, appRename, appVersions, appCodegen, appExecuteWorkflow, appWorkflowSet, appWorkflowPull, appWorkflowCheck, appQuerySet, appUiLink, } from "./app_commands.js";
17
- import { packageInstall, packageUninstall, packageListContent, packageConfig, packageShow, parseInstallConfigFlags, packageEject, packageDoctor, packageUpgrade, packageUpgradeKnowledge, parseBindToFlags, appPublish, appRelease, appUnpublish, packageFleetUpgrade, packageYank, } from "./package_commands.js";
17
+ import { packageInstall, packageUninstall, packageListContent, packageConfig, packageShow, parseInstallConfigFlags, packageEject, packageDoctor, packageUpgrade, packageUpgradeByPackageId, redirectContentPciForm, parseBindToFlags, appPublish, appRelease, appUnpublish, packageYank, } from "./package_commands.js";
18
18
  import { parseArgs } from "./args.js";
19
19
  import { ingestJsonArgs } from "./inputs.js";
20
20
  import { runXlsxCommand } from "./xlsx.js";
@@ -114,18 +114,19 @@ COMMANDS
114
114
  Install a package (app: scaffolds/deploys/materializes,
115
115
  --config sets its knobs; content: installs the doc corpus,
116
116
  --bind-to consents to adopt a same-named doc on a collision)
117
- lotics uninstall <app_id|pci_id> [--archive-tables] [--keep-content]
117
+ lotics uninstall <app_id|package_id> [--archive-tables] [--keep-content]
118
118
  Uninstall — dispatched by id. App (app_id): archives
119
119
  artifacts (+ --archive-tables also archives scaffolded
120
- tables). Content (pci_): archives its package-bound docs
121
- unless --keep-content
122
- lotics upgrade <app_id|pci_id|package_id> [--version N] [--resolve <key>=... ] [--bind-to ...] [--apply-all]
120
+ tables). Content package (apg_): archives its package-bound
121
+ docs unless --keep-content
122
+ lotics upgrade <app_id|package_id> [--version N] [--resolve <key>=... ] [--bind-to ...] [--apply-all]
123
123
  Preview-then-apply an upgrade, dispatched by id. App
124
124
  (app_id): refuses while any drift/modified/bundled-knowledge
125
- finding lacks a --resolve. Content (pci_): --resolve
126
- <alias>=apply|keep|archive|recreate|unbind. Package (apg_):
127
- FLEET every installation across your org (clean apply,
128
- findings skip). --apply-all accepts the package's version
125
+ finding lacks a --resolve. Package (apg_): a CONTENT package
126
+ upgrades THIS workspace's install (--resolve
127
+ <alias>=apply|keep|archive|recreate|unbind); an APP package
128
+ FLEET-upgrades every installation across your org (clean
129
+ apply, findings skip). --apply-all accepts the package's version
129
130
  lotics package doctor [app_id] Installation health: version pin vs latest, binding
130
131
  drift, locally modified core, knowledge drift/edits
131
132
  (exit 1 on findings)
@@ -135,7 +136,8 @@ COMMANDS
135
136
  (re-deploys the pinned source as a bespoke app)
136
137
  lotics package show <package_id> Registry metadata + version history
137
138
  lotics package list-content List the workspace's content installations
138
- (standalone content installs) — source for a pci_ id
139
+ (standalone content installs) — each row leads with
140
+ the package id for upgrade/uninstall
139
141
  lotics package yank <package_id> <version> [--undo]
140
142
  Refuse new installs/upgrades of a broken published
141
143
  version (pinned installations keep running)
@@ -713,8 +715,8 @@ async function main() {
713
715
  console.error("Usage (low-traffic consumer / registry ops — author verbs live on `lotics app`):");
714
716
  console.error(" The high-traffic consumer verbs are top-level:");
715
717
  console.error(" lotics install <package_id> [--version N] [--bind-to <alias>=<kdc_id>] [--config key=value ...] Install a package (app or content)");
716
- console.error(" lotics uninstall <app_id|pci_id> [--archive-tables] [--keep-content] Uninstall — dispatched by id (app vs content)");
717
- console.error(" lotics upgrade <app_id|pci_id|package_id> [--version N] [--resolve <key>=... ] [--bind-to ...] [--apply-all] Upgrade — app / content / whole fleet (apg_)");
718
+ console.error(" lotics uninstall <app_id|package_id> [--archive-tables] [--keep-content] Uninstall — dispatched by id (app vs content)");
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_)");
718
720
  console.error(" lotics package doctor [app_id] Health: version pin vs latest + binding/knowledge drift");
719
721
  console.error(" lotics package config <app_id> [--set key=value ...] Show or edit an installation's config knobs");
720
722
  console.error(" lotics package eject <app_id> Sever an installation's package link");
@@ -902,12 +904,17 @@ async function main() {
902
904
  if (command === "uninstall") {
903
905
  const id = subcommand;
904
906
  if (!id) {
905
- console.error("Usage: lotics uninstall <app_id|pci_id> [--archive-tables] [--keep-content]");
907
+ console.error("Usage: lotics uninstall <app_id|package_id> [--archive-tables] [--keep-content]");
906
908
  console.error("Dispatched by id: an app installation (app_id; --archive-tables to also archive its " +
907
- "scaffolded tables) or a standalone content installation (pci_ id from `lotics install` / " +
909
+ "scaffolded tables) or a content package (apg_ id from `lotics install` / " +
908
910
  "lotics package list-content; --keep-content to retain its docs).");
909
911
  process.exit(1);
910
912
  }
913
+ // A `pci_` resource id is retired from human sight — redirect to the package-id form.
914
+ if (id.startsWith("pci_")) {
915
+ await redirectContentPciForm(client, id, "uninstall");
916
+ return;
917
+ }
911
918
  await packageUninstall(client, {
912
919
  id,
913
920
  keep_content: flags.keepContent,
@@ -918,9 +925,9 @@ async function main() {
918
925
  if (command === "upgrade") {
919
926
  const target = subcommand;
920
927
  if (!target) {
921
- console.error("Usage: lotics upgrade <app_id | pci_id | package_id> [--version N] [--resolve <key>=... ...] [--bind-to ...] [--apply-all]");
922
- console.error("Dispatched by id: an app installation (app_id), a standalone content installation (pci_ id), " +
923
- "or a package id (apg_ fleet-upgrades every installation across your org).");
928
+ console.error("Usage: lotics upgrade <app_id | package_id> [--version N] [--resolve <key>=... ...] [--bind-to ...] [--apply-all]");
929
+ console.error("Dispatched by id: an app installation (app_id), or a package id (apg_) — a CONTENT package " +
930
+ "upgrades this workspace's install; an APP package fleet-upgrades every installation across your org.");
924
931
  process.exit(1);
925
932
  }
926
933
  let version;
@@ -931,22 +938,24 @@ async function main() {
931
938
  process.exit(1);
932
939
  }
933
940
  }
934
- // A package id fleet-upgrades every installation across the org; a `pci_` id
935
- // is a standalone content installation; anything else is an app installation.
936
- if (target.startsWith("apg_")) {
937
- await packageFleetUpgrade(client, { package_id: target, version });
941
+ // A `pci_` resource id is retired from human sight redirect to the package-id form.
942
+ if (target.startsWith("pci_")) {
943
+ await redirectContentPciForm(client, target, "upgrade");
938
944
  return;
939
945
  }
940
- if (target.startsWith("pci_")) {
941
- await packageUpgradeKnowledge(client, {
942
- installation_id: target,
946
+ // A package id (apg_) is kind-branched: a content package upgrades THIS
947
+ // workspace's install; an app package fleet-upgrades the whole org.
948
+ if (target.startsWith("apg_")) {
949
+ await packageUpgradeByPackageId(client, {
950
+ package_id: target,
943
951
  version,
944
952
  resolve: flags.resolve,
945
- bind_to: parseBindToFlags(flags.bindTo),
953
+ bindTo: flags.bindTo,
946
954
  applyAll: flags.applyAll,
947
955
  });
948
956
  return;
949
957
  }
958
+ // Anything else is an app installation (addressed by its app id).
950
959
  await packageUpgrade(client, {
951
960
  app_id: target,
952
961
  version,
@@ -1019,8 +1028,8 @@ async function main() {
1019
1028
  // `lotics package <verb>` forms loudly with a redirect, never a silent no-op.
1020
1029
  const movedVerbs = {
1021
1030
  install: "lotics install <package_id>",
1022
- uninstall: "lotics uninstall <app_id|pci_id>",
1023
- upgrade: "lotics upgrade <app_id|pci_id|package_id>",
1031
+ uninstall: "lotics uninstall <app_id|package_id>",
1032
+ upgrade: "lotics upgrade <app_id|package_id>",
1024
1033
  "fleet-upgrade": "lotics upgrade <package_id>",
1025
1034
  };
1026
1035
  if (subcommand !== undefined && subcommand in movedVerbs) {
@@ -1330,8 +1339,10 @@ async function main() {
1330
1339
  console.log(JSON.stringify({ error: result.error }, null, 2));
1331
1340
  }
1332
1341
  else {
1342
+ // Execution errors (not-found, refusals) carry the server's message;
1343
+ // the input already passed schema validation, so no schema hint —
1344
+ // validation 400s bring the server's own hint via throwResponseError.
1333
1345
  console.error(result.error);
1334
- console.error(`\nHint: run "lotics tools ${toolName}" to see the expected input schema.`);
1335
1346
  }
1336
1347
  process.exit(1);
1337
1348
  }
@@ -104,8 +104,8 @@ describe("lotics top-level consumer-verb dispatch", () => {
104
104
  // bare invocation — reaching it fires before any network work.
105
105
  const usageCases = [
106
106
  ["install", /Usage: lotics install <package_id>/],
107
- ["uninstall", /Usage: lotics uninstall <app_id\|pci_id>/],
108
- ["upgrade", /Usage: lotics upgrade <app_id \| pci_id \| package_id>/],
107
+ ["uninstall", /Usage: lotics uninstall <app_id\|package_id>/],
108
+ ["upgrade", /Usage: lotics upgrade <app_id \| package_id>/],
109
109
  ];
110
110
  for (const [verb, usage] of usageCases) {
111
111
  it(`routes top-level "${verb}" to its handler (usage on missing id)`, () => {
@@ -115,6 +115,19 @@ describe("lotics top-level consumer-verb dispatch", () => {
115
115
  expect(status).toBe(1);
116
116
  });
117
117
  }
118
+ // CLEAN BREAK: content installs are addressed by their PACKAGE id — an explicit
119
+ // `pci_` resource id must get a loud redirect to the package-id form. The redirect
120
+ // header prints SYNCHRONOUSLY (before the best-effort list-content resolution that
121
+ // spells out the exact command), so it is observable without a live registry; the
122
+ // process exits 1 either way (best-effort resolution fails on the fake key).
123
+ for (const verb of ["upgrade", "uninstall"]) {
124
+ it(`redirects an explicit pci_ id on "${verb}" to the package-id form`, () => {
125
+ const { status, stderr } = runCli([verb, "pci_dispatch_smoke_fake"]);
126
+ expect(stderr).toMatch(/addressed by their PACKAGE id now/);
127
+ expect(stderr).not.toMatch(/Unknown command/);
128
+ expect(status).toBe(1);
129
+ });
130
+ }
118
131
  });
119
132
  describe("lotics app author-verb dispatch", () => {
120
133
  // `app publish` (no id, empty cwd) must fail on the missing local app
package/dist/client.d.ts CHANGED
@@ -542,31 +542,60 @@ export declare class LoticsClient {
542
542
  /**
543
543
  * Preview a release — the dry run behind `lotics app release`. Runs the
544
544
  * binding-aware extract of the origin (aliases stable through the app's current
545
- * binding) and reports the next version number, the new + changed aliases, and
546
- * any extract findings (an `error` blocks the apply). No writes. Admin,
545
+ * binding) and reports the next version number, the new + changed aliases, the
546
+ * bundled-knowledge delta, and any extract findings (an `error` blocks the
547
+ * apply). An optional `knowledge` declaration (from the pulled app manifest)
548
+ * re-declares the bundle set — added/dropped/changed docs surface in the delta;
549
+ * omitted, the current corpus is reconstructed from the pin. No writes. Admin,
547
550
  * owning-org only.
548
551
  */
549
- previewPackageRelease(app_id: string): Promise<{
552
+ previewPackageRelease(app_id: string, opts?: {
553
+ /** alias → doc_id re-declaring the bundled-knowledge set (from the manifest). */
554
+ knowledge?: Array<{
555
+ alias: string;
556
+ doc_id: string;
557
+ }>;
558
+ }): Promise<{
550
559
  package_id: string;
551
560
  version: number;
552
561
  added_aliases: string[];
553
562
  changed_artifacts: string[];
563
+ /** Absent from a pre-declaration server (deploy skew) — treat as empty delta. */
564
+ knowledge?: {
565
+ added: string[];
566
+ removed: string[];
567
+ changed: string[];
568
+ };
554
569
  findings: ExtractFinding[];
555
570
  }>;
556
571
  /**
557
572
  * Release — snapshot the origin app into the next registry version. The server
558
573
  * binding-aware-extracts it, repackages its deployed source + dist as the
559
574
  * bundle, publishes the next `release`-channel version with the changelog, and
560
- * re-pins the origin. Error findings from extract surface as a 409. Admin,
575
+ * re-pins the origin. An optional `knowledge` declaration re-declares the
576
+ * bundled-knowledge set (added/dropped/re-snapshotted docs; omitted preserves
577
+ * the current corpus). Error findings from extract surface as a 409; a
578
+ * missing/archived declared doc is a 400, a foreign-package doc a 409. Admin,
561
579
  * owning-org only. Backs `lotics app release --yes`.
562
580
  */
563
581
  releasePackage(app_id: string, body: {
564
582
  changelog: string;
583
+ /** alias → doc_id re-declaring the bundled-knowledge set (from the manifest). */
584
+ knowledge?: Array<{
585
+ alias: string;
586
+ doc_id: string;
587
+ }>;
565
588
  }): Promise<{
566
589
  package_id: string;
567
590
  version: number;
568
591
  added_aliases: string[];
569
592
  changed_artifacts: string[];
593
+ /** Absent from a pre-declaration server (deploy skew) — treat as empty delta. */
594
+ knowledge?: {
595
+ added: string[];
596
+ removed: string[];
597
+ changed: string[];
598
+ };
570
599
  }>;
571
600
  /**
572
601
  * Dry-run preview of a first-release — the `GET` behind `lotics app publish`
package/dist/client.js CHANGED
@@ -353,18 +353,29 @@ export class LoticsClient {
353
353
  /**
354
354
  * Preview a release — the dry run behind `lotics app release`. Runs the
355
355
  * binding-aware extract of the origin (aliases stable through the app's current
356
- * binding) and reports the next version number, the new + changed aliases, and
357
- * any extract findings (an `error` blocks the apply). No writes. Admin,
356
+ * binding) and reports the next version number, the new + changed aliases, the
357
+ * bundled-knowledge delta, and any extract findings (an `error` blocks the
358
+ * apply). An optional `knowledge` declaration (from the pulled app manifest)
359
+ * re-declares the bundle set — added/dropped/changed docs surface in the delta;
360
+ * omitted, the current corpus is reconstructed from the pin. No writes. Admin,
358
361
  * owning-org only.
359
362
  */
360
- async previewPackageRelease(app_id) {
361
- return this.request("GET", `/v1/apps/${encodeURIComponent(app_id)}/package-release`);
363
+ async previewPackageRelease(app_id, opts = {}) {
364
+ const params = new URLSearchParams();
365
+ if (opts.knowledge !== undefined && opts.knowledge.length > 0) {
366
+ params.set("knowledge", JSON.stringify(opts.knowledge));
367
+ }
368
+ const query = params.toString();
369
+ return this.request("GET", `/v1/apps/${encodeURIComponent(app_id)}/package-release${query ? `?${query}` : ""}`);
362
370
  }
363
371
  /**
364
372
  * Release — snapshot the origin app into the next registry version. The server
365
373
  * binding-aware-extracts it, repackages its deployed source + dist as the
366
374
  * bundle, publishes the next `release`-channel version with the changelog, and
367
- * re-pins the origin. Error findings from extract surface as a 409. Admin,
375
+ * re-pins the origin. An optional `knowledge` declaration re-declares the
376
+ * bundled-knowledge set (added/dropped/re-snapshotted docs; omitted preserves
377
+ * the current corpus). Error findings from extract surface as a 409; a
378
+ * missing/archived declared doc is a 400, a foreign-package doc a 409. Admin,
368
379
  * owning-org only. Backs `lotics app release --yes`.
369
380
  */
370
381
  async releasePackage(app_id, body) {
@@ -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";