@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/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 (
|
|
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
|
|
264
|
-
|
|
265
|
-
lotics upgrade
|
|
266
|
-
lotics upgrade
|
|
267
|
-
lotics upgrade
|
|
268
|
-
lotics
|
|
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,
|
|
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|
|
|
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 (
|
|
121
|
-
unless --keep-content
|
|
122
|
-
lotics upgrade <app_id|
|
|
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.
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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) —
|
|
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|
|
|
717
|
-
console.error(" lotics upgrade <app_id|
|
|
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|
|
|
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
|
|
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 |
|
|
922
|
-
console.error("Dispatched by id: an app installation (app_id), a
|
|
923
|
-
"
|
|
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
|
|
935
|
-
|
|
936
|
-
|
|
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
|
-
|
|
941
|
-
|
|
942
|
-
|
|
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
|
-
|
|
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|
|
|
1023
|
-
upgrade: "lotics upgrade <app_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\|
|
|
108
|
-
["upgrade", /Usage: lotics upgrade <app_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,
|
|
546
|
-
* any extract findings (an `error` blocks the
|
|
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
|
|
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.
|
|
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,
|
|
357
|
-
* any extract findings (an `error` blocks the
|
|
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
|
-
|
|
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.
|
|
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
|
+
});
|
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";
|