@thatopen/services 0.15.0 → 0.16.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.
@@ -51,7 +51,8 @@ Steps 0–2 are identical for both; they diverge only at `create` (step 3) and h
51
51
 
52
52
  - **Node.js ≥ 18** and **npm** — verify with `node -v`.
53
53
  - A **That Open Platform API token**. The user creates it themselves:
54
- → **https://platform.thatopen.com/dashboard/data → API Tokens → create → copy.**
54
+ → **https://platform.thatopen.com/dashboard → Account → Tokens → create → copy.**
55
+ (The Data overview shows the same Tokens card; both paths work.)
55
56
  Ask the user to paste it. **Never print it back, never write it into a file, never commit it.**
56
57
 
57
58
  ## 1. Install the CLI
@@ -62,8 +63,9 @@ npm install -g @thatopen/services@latest
62
63
 
63
64
  ## 2. Log in (do this BEFORE creating the project)
64
65
 
65
- Get the token from the platform dashboard — **https://platform.thatopen.com/dashboard/data**
66
- → **API Tokens** → create → copy. Ask the user to paste it, then run:
66
+ Get the token from the platform dashboard — **https://platform.thatopen.com/dashboard**
67
+ → **Account → Tokens** → create → copy (the Data overview shows the same card). Ask the
68
+ user to paste it, then run:
67
69
 
68
70
  ```bash
69
71
  thatopen login --token <platform-token>
@@ -127,6 +129,14 @@ machine lives at:
127
129
  https://platform.thatopen.com/dashboard/projects/<PROJECT_ID>/apps/local-app
128
130
  ```
129
131
 
132
+ > **If the platform shows "failed to load application" and the console says
133
+ > `Permission was denied for this request to access the `loopback` address space`**:
134
+ > current Chrome blocks an https page from fetching `http://localhost` until the
135
+ > user grants the **Local network access** permission. Tell the user: click the
136
+ > icon left of the URL → Site settings → **Local network access** → **Allow**,
137
+ > then reload. One-time, per browser profile. Verified 2026-09-22 — without the
138
+ > grant the dev loop does not work at all; with it, everything below does.
139
+
130
140
  Substitute the project id. That is the local app running inside the platform's own UI, against
131
141
  the project's real data. Send the user there rather than to a bare localhost port, which is the
132
142
  app without the platform around it.
@@ -257,6 +267,62 @@ engine / UI example sets. Load those indexes before writing code. For an **app**
257
267
  must be built with Lit + `@thatopen/ui` (`BUI`)** — consult the design system first. Run the
258
268
  scaffold first, then extend it; don't rebuild from scratch.
259
269
 
270
+ ## 5b. The channel: drive the running app from outside, and multiplayer
271
+
272
+ The scaffold is already listening. `src/setups/channel.ts` joins the platform
273
+ channel at boot with two typed commands (`ping`, `get-loaded-models`), and a
274
+ collaboration room shared by every open tab of the app. This is the bridge for
275
+ "an LLM on the desktop drives the app" and for multiplayer, and it is the same
276
+ system: the platform relays messages between app tabs and external tools.
277
+
278
+ To command the app from outside (a script, an MCP server, your own tool):
279
+
280
+ 1. The user creates a **platform token** with the **API** permission
281
+ (dashboard → Account → Tokens). Same secrecy rules as always.
282
+ 2. Connect with `socket.io-client` and subscribe, then publish:
283
+
284
+ ```bash
285
+ npm i socket.io-client # in the external tool's own project, not the app
286
+ ```
287
+
288
+ ```js
289
+ import { io } from "socket.io-client";
290
+
291
+ const socket = io(`https://platform.thatopen.com?accessToken=${process.env.THATOPEN_TOKEN}`);
292
+ // While iterating with `thatopen serve`, OMIT appId (the dev channel is keyed
293
+ // to the account); pass the appId once the app is published.
294
+ socket.emit("channelSubscribe", { projectId, appId, kind: "cli" });
295
+ socket.on("channelMessage", (msg) => {
296
+ if (msg.type === "channel:subscribed") {
297
+ socket.emit(
298
+ "channelPublish",
299
+ { type: "ping", requestId: crypto.randomUUID(), payload: { echo: "hi" } },
300
+ (ack) => console.log(ack), // { delivered: 0 } = no app tab is listening
301
+ );
302
+ } else {
303
+ console.log("app says:", msg);
304
+ }
305
+ });
306
+ ```
307
+
308
+ A reply to your `requestId` reaches only your socket — a second tool connected
309
+ at the same time never sees it. Replies arrive as a `channelMessage` with
310
+ `type: "reply"` and your `requestId` — match on the `requestId`, not the type.
311
+
312
+ `{ delivered: 0 }` on the ack means no tab of that account's app has the
313
+ channel joined — usually the app is simply not open. Apps scaffolded before
314
+ services 0.16.1 had one more way here: they joined the channel only at the END
315
+ of boot, so a stalled startup request left an app that looked healthy but had
316
+ never joined, with nothing in the console either way (0.16.1 scaffolds join
317
+ before anything boot can stall on). With the app visibly open and still
318
+ `delivered: 0`, **ask the user to reload the app tab and retry**.
319
+
320
+ To grow the app's command surface, extend `AppCommands` in
321
+ `src/setups/channel.ts` and handle the new command — the handler's return value
322
+ is the reply. The full walkthrough (typed contracts, collaboration events,
323
+ presence) is `node_modules/@thatopen/services/src/core/examples/channel.ts`,
324
+ indexed in the client API index you already loaded.
325
+
260
326
  ## 6. Publish (when ready)
261
327
 
262
328
  ```bash
@@ -283,8 +349,10 @@ component, once added to a project, is then triggered by an app or an automation
283
349
  four-point plan and waiting for approval to do what was just requested costs the user a whole turn
284
350
  and buys nothing. Stop and ask only when you are about to change files you did not create, when
285
351
  the request can be read two ways that mean different work, or when the next step is destructive.
286
- - **Never save to the platform on every change.** Drafts belong in `localStorage` /
287
- IndexedDB; the platform gets an explicit save. Writes are capped at 30 per minute and a
352
+ - **Never save to the platform on every change.** Drafts live in memory — the
353
+ production sandbox runs apps on an opaque origin where `localStorage`,
354
+ `sessionStorage`, IndexedDB and cookies all **throw** (they work in dev mode
355
+ and then break for every real user). The platform gets an explicit save. Writes are capped at 30 per minute and a
288
356
  `429` loses the write. See
289
357
  `node_modules/@thatopen/services/docs/rate-limits.md` before you write any save, sync or
290
358
  polling code.
@@ -8,7 +8,7 @@ here rather than in each of them.
8
8
  Say this to the user, and wait:
9
9
 
10
10
  > "To log in I need a That Open Platform **access token**. You create it yourself:
11
- > **https://platform.thatopen.com/dashboard/data → API Tokens → create → copy**, then paste it
11
+ > **https://platform.thatopen.com/dashboard → Account → Tokens → create → copy** (the Data overview shows the same card), then paste it
12
12
  > here."
13
13
 
14
14
  **Never print it back, never write it into a file, never commit it.** Not into a script, not into a
@@ -74,27 +74,36 @@ try {
74
74
  copy of the change was in that request, it is gone — which is the real reason
75
75
  the local-draft pattern below matters.
76
76
 
77
- ## The pattern: local drafts, explicit saves
77
+ ## The pattern: drafts in memory, explicit saves
78
78
 
79
- Keep every intermediate state in the browser. Write to the platform only when
80
- the user asks for it.
79
+ Keep every intermediate state in memory. Write to the platform only when the
80
+ user asks for it, or on a slow timer.
81
+
82
+ > **Why not `localStorage`?** Platform apps run in a sandboxed iframe on an
83
+ > **opaque origin**, where `localStorage`, `sessionStorage`, IndexedDB and
84
+ > cookies don't return empty — they **throw, on the property access itself**.
85
+ > Dev mode grants `allow-same-origin`, so origin storage works on the machine
86
+ > of whoever writes the app and fails for every user. This has shipped broken
87
+ > at least once. Inside a platform app there are exactly two stores: memory,
88
+ > and the platform through the client API.
81
89
 
82
90
  ```ts
83
- const draftKey = `draft:${fileId}`;
91
+ const drafts = new Map<string, { state: unknown; at: number }>();
84
92
 
85
- function onChange(state: unknown) {
86
- localStorage.setItem(draftKey, JSON.stringify({ state, at: Date.now() }));
93
+ function onChange(fileId: string, state: unknown) {
94
+ drafts.set(fileId, { state, at: Date.now() });
87
95
  }
88
96
 
89
- async function onSave(state: unknown) {
97
+ async function onSave(fileId: string, state: unknown) {
90
98
  const blob = new Blob([JSON.stringify(state)], { type: 'application/json' });
91
99
  await client.updateFile(fileId, { file: blob, versionTag: `v${Date.now()}` });
92
- localStorage.removeItem(draftKey);
100
+ drafts.delete(fileId);
93
101
  }
94
102
  ```
95
103
 
96
- On load, if a draft exists for the file, offer to restore it. That gives crash
97
- recovery — the thing autosave was really for — at zero requests.
104
+ A draft in memory does not survive a reload. If surviving one matters, the
105
+ draft itself has to go to the platform — on the explicit save, or on the slow
106
+ timer below — because there is nowhere else to put it.
98
107
 
99
108
  Rules of thumb:
100
109
 
@@ -104,10 +113,11 @@ Rules of thumb:
104
113
  - **Never save while a save is in flight.** Keep a flag per file, and drop or
105
114
  queue the second save. Overlapping writes also create versions that are hard
106
115
  to reconcile.
107
- - **Big or binary work-in-progress** belongs in IndexedDB, not `localStorage`
108
- (about 5 MB per origin).
109
- - There is **no draft-write method in the client on purpose.** Local storage is
110
- the draft store; the platform stores versions the user chose to keep.
116
+ - **Big or binary work-in-progress** stays in memory too — there is no
117
+ IndexedDB fallback in the sandbox, so size is bounded by RAM, which is fine:
118
+ it is the same object the app is already editing.
119
+ - There is **no draft-write method in the client on purpose.** Memory is the
120
+ draft store; the platform stores versions the user chose to keep.
111
121
 
112
122
  ## Retries
113
123
 
package/package.json CHANGED
@@ -15,7 +15,7 @@
15
15
  "access": "public"
16
16
  },
17
17
  "private": false,
18
- "version": "0.15.0",
18
+ "version": "0.16.1",
19
19
  "main": "dist/index.cjs.js",
20
20
  "module": "dist/index.es.js",
21
21
  "types": "dist/index.d.ts",
@@ -25,6 +25,7 @@
25
25
  "files": [
26
26
  "dist",
27
27
  "src/cli/templates",
28
+ "src/core/examples",
28
29
  "src/built-in",
29
30
  "docs",
30
31
  "resources"
@@ -36,12 +36,19 @@ Once you have these, you know everything available on the platform. Only then fe
36
36
  **Run it first** (`npm run dev`) to see it work, *then* extend it — don't rebuild a viewer from scratch.
37
37
  - **Do the thing they asked for.** If the request already says what to build, build it. Stop and ask only when you are about to change files you did not create, when the request can be read two ways that mean different work, or when the next step is destructive.
38
38
  - If something already exists in the indexes, use it — don't reimplement it.
39
+ - **The scaffold already listens on the platform channel** (`src/setups/channel.ts`):
40
+ external tools (an MCP server, a CLI) command the app through `AppCommands`, and
41
+ tabs collaborate through `AppCollabEvents`. Extend those maps instead of opening
42
+ sockets — apps cannot hold their own connection. Walkthrough:
43
+ `node_modules/@thatopen/services/src/core/examples/channel.ts`.
39
44
 
40
45
  ## Hard rules (always apply)
41
46
 
42
47
  1. **Never write to the platform on every change.** No autosave per keystroke,
43
- per drag, or inside a render loop. Keep work in progress in `localStorage` /
44
- IndexedDB and call the platform on an **explicit user save** (or a timer no
48
+ per drag, or inside a render loop. Keep work in progress **in memory** — the
49
+ production sandbox has NO origin storage (`localStorage`, `sessionStorage`,
50
+ IndexedDB and cookies **throw** on its opaque origin; dev mode hides this) —
51
+ and call the platform on an **explicit user save** (or a timer no
45
52
  faster than once every 30 seconds). Writes are capped at **30 per minute**;
46
53
  crossing that returns `429` and the write is **lost**, not queued.
47
54
  If the user asks for autosave, build it against local storage and say so.
@@ -209,6 +209,39 @@ client.onExecutionProgress(executionId, (data) => {
209
209
  client.localServerUrl = "http://localhost:4001";
210
210
  ```
211
211
 
212
+ ## The channel: external control + multiplayer
213
+
214
+ This app ships listening on the platform channel (`src/setups/channel.ts`). Two
215
+ things work out of the box:
216
+
217
+ - **External tools can command it.** An MCP server, a CLI or a plugin
218
+ authenticated with a platform token of the same account can send the commands
219
+ declared in `AppCommands` (`ping`, `get-loaded-models`) and gets typed
220
+ replies back. This is how a desktop LLM drives the running app.
221
+ - **Tabs of this app see each other.** Every open tab joins a collaboration
222
+ room shared across accounts (`AppCollabEvents`) — the base for cursors,
223
+ presence and live sync.
224
+
225
+ To add a command, extend the map and handle it; whatever you return is the reply:
226
+
227
+ ```ts
228
+ export type AppCommands = {
229
+ // ...existing commands...
230
+ "select-by-category": { payload: { category: string }; reply: { count: number } };
231
+ };
232
+
233
+ external.on("select-by-category", async ({ category }) => {
234
+ const count = await selectByCategory(category);
235
+ return { count };
236
+ });
237
+ ```
238
+
239
+ The app never opens a socket: the platform shell owns the connection and relays
240
+ it, which is also the security boundary. Joining is opt-in per room — the
241
+ scaffold joins at boot; move the `join()` calls behind a button to make it a
242
+ user choice. Full walkthrough with a collab example:
243
+ `node_modules/@thatopen/services/src/core/examples/channel.ts`.
244
+
212
245
  ## Configuration
213
246
 
214
247
  - `.thatopen` — local config (gitignored). Created by `npm run login`; also holds
@@ -8,6 +8,7 @@ import * as BUI from "@thatopen/ui";
8
8
  import * as MARKERJS from "@markerjs/markerjs3";
9
9
  import { PlatformClient, UIManager } from "@thatopen/services";
10
10
  import { setAppContext } from "./app";
11
+ import { setupChannel } from "./setups/channel";
11
12
 
12
13
  // ─── A2 migration — PHASES 1+2: boot on UIManager + re-dock panels ───────────
13
14
  // Juan consolidated the old AppManager (layout) + ViewportsManager (viewport)
@@ -39,6 +40,20 @@ async function main() {
39
40
  );
40
41
  components.get(UIManager).init();
41
42
 
43
+ // The platform channel, pre-wired (see ./setups/channel.ts): external tools
44
+ // of this account (an MCP server, a CLI, a plugin) can command this app, and
45
+ // other open tabs of it are reachable for collaboration. Joined HERE, before
46
+ // any await the boot can park on (the world, project data): a stalled fetch
47
+ // further down must not leave the app unreachable — and silently so, since a
48
+ // join that is never requested also never gets to time out loudly. It only
49
+ // exists inside the platform iframe, so a failure is a warning, never a boot
50
+ // error.
51
+ try {
52
+ setupChannel(client, components);
53
+ } catch (error) {
54
+ console.warn("[channel] unavailable outside the platform:", error);
55
+ }
56
+
42
57
  // One STABLE top-viewer node, returned by reference so re-rendering top-app
43
58
  // (when we add the panels below) reuses it instead of disposing/recreating
44
59
  // its world. No <top-viewer-tools>: the bim-viewer mounts its own tabbed
@@ -96,7 +111,19 @@ async function main() {
96
111
  const projectId: string | undefined = client?.context?.projectId;
97
112
  let projectData;
98
113
  try {
99
- if (projectId) projectData = await client.getProjectData(projectId);
114
+ // Bounded: this request carries no AbortSignal, so a stalled response
115
+ // parked everything below it for good (that is how the app once ended up
116
+ // booted but with no panels and, before the channel moved up top, deaf to
117
+ // external tools). Past the deadline the app boots without project data —
118
+ // the same degraded path the catch below already provides.
119
+ if (projectId) {
120
+ projectData = await Promise.race([
121
+ client.getProjectData(projectId),
122
+ new Promise<undefined>((resolve) => {
123
+ setTimeout(() => resolve(undefined), 20_000);
124
+ }),
125
+ ]);
126
+ }
100
127
  } catch {
101
128
  /* dev/no-project → consumers degrade gracefully */
102
129
  }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The platform channel, pre-wired: typed commands that an external tool (an
3
+ * MCP server, a CLI, a desktop LLM, a plugin) can send this app while it runs,
4
+ * and a collaboration room where every open tab of this app hears the others.
5
+ *
6
+ * The app never opens a socket. The platform shell owns the one connection and
7
+ * relays over `postMessage`; `client.channel` is the typed layer on top. That
8
+ * also means this only works inside the platform iframe — `setupChannel` is
9
+ * called from a try/catch in `main.ts` so the app still boots anywhere else.
10
+ *
11
+ * To extend: add a command to {@link AppCommands} and handle it below. What a
12
+ * handler returns is sent back to whoever asked; throw and they get the error.
13
+ * Full walkthrough: `node_modules/@thatopen/services/src/core/examples/channel.ts`.
14
+ */
15
+ import * as OBC from "@thatopen/components";
16
+ import { PlatformClient } from "@thatopen/services";
17
+
18
+ /**
19
+ * The contract with external tools. Each command names its payload and, when
20
+ * the caller waits for an answer, its reply. A command without `reply` is a
21
+ * notification. This type is the whole API an MCP server sees: keep it honest.
22
+ */
23
+ export type AppCommands = {
24
+ /** Liveness check — answer whoever is probing that this app is listening. */
25
+ ping: { payload: { echo?: string } | undefined; reply: { pong: true; echo?: string } };
26
+ /** The ids of the models currently loaded in the viewer. */
27
+ "get-loaded-models": { payload: undefined; reply: { models: string[] } };
28
+ };
29
+
30
+ /** Events tabs of this app send each other. Events, so no replies. */
31
+ export type AppCollabEvents = {
32
+ /** Sent once on join, so other tabs can log or greet. Replace with your own. */
33
+ hello: { at: number };
34
+ };
35
+
36
+ /**
37
+ * Wires both rooms and joins them at boot. Joining is what makes the app
38
+ * reachable — until then, nothing arrives. If you'd rather listen only when
39
+ * the user opts in, move the `join()` calls behind a button.
40
+ */
41
+ export function setupChannel(
42
+ client: PlatformClient,
43
+ components: OBC.Components,
44
+ ) {
45
+ // Commands from every kind of external tool of this account. Narrow with
46
+ // `client.channel.external<AppCommands>("mcp")` to hear one kind only.
47
+ const external = client.channel.external<AppCommands>();
48
+
49
+ external.on("ping", (payload) => ({ pong: true as const, echo: payload?.echo }));
50
+
51
+ external.on("get-loaded-models", () => {
52
+ const fragments = components.get(OBC.FragmentsManager);
53
+ return { models: [...fragments.list.keys()] };
54
+ });
55
+
56
+ // Collaboration: other users' tabs of this same app, across accounts.
57
+ const collab = client.channel.collab<AppCollabEvents>();
58
+ collab.on("hello", (event, { from }) => {
59
+ console.log(`[channel] tab ${from} joined the collaboration`, event);
60
+ });
61
+ collab.onPeerLeft(({ from }) => {
62
+ console.log(`[channel] tab ${from} left the collaboration`);
63
+ });
64
+
65
+ // Fire-and-forget joins: the app is usable with or without the channel, so
66
+ // a failure is a warning, never a boot error.
67
+ external
68
+ .join()
69
+ .catch((error) => console.warn("[channel] external join failed:", error));
70
+ collab
71
+ .join()
72
+ .then(() => collab.send("hello", { at: Date.now() }))
73
+ .catch((error) => console.warn("[channel] collab join failed:", error));
74
+
75
+ return { external, collab };
76
+ }
@@ -0,0 +1,7 @@
1
+ ACCESS_TOKEN=your-access-token-here
2
+ API_URL=https://api.thatopen.com
3
+ PROJECT_ID=your-project-id-here
4
+ # Required only for permissions.ts — obtain from DevTools → Network → any /api request → Authorization header (without "Bearer ")
5
+ JWT=
6
+ # Required only for execution.ts — obtain from listComponents() or the platform dashboard
7
+ COMPONENT_ID=
@@ -0,0 +1,67 @@
1
+ // description: "App lifecycle — list, get with versions, create, download bundle, and archive."
2
+ import { config } from 'dotenv';
3
+ import { resolve } from 'path';
4
+ import { EngineServicesClient } from '../client';
5
+
6
+ config({ path: resolve(__dirname, '.env') });
7
+
8
+ const ACCESS_TOKEN = process.env.ACCESS_TOKEN;
9
+ const API_URL = process.env.API_URL;
10
+ const PROJECT_ID = process.env.PROJECT_ID;
11
+
12
+ if (!ACCESS_TOKEN || !API_URL) {
13
+ throw new Error('ACCESS_TOKEN and API_URL are required in src/core/examples/.env');
14
+ }
15
+
16
+ async function main() {
17
+ const client = new EngineServicesClient(ACCESS_TOKEN!, API_URL!);
18
+
19
+ // --- List ---
20
+ const apps = await client.listApps();
21
+ console.log(`\nFound ${apps.length} app(s):`);
22
+ for (const app of apps) {
23
+ console.log(` [${app._id}] ${app.name}`);
24
+ }
25
+
26
+ // --- Get ---
27
+ if (apps.length > 0) {
28
+ const detail = await client.listApps({ ShowVersions: true });
29
+ console.log('\nFirst app detail (with versions):');
30
+ console.log(JSON.stringify(detail[0], null, 2));
31
+ }
32
+
33
+ if (!PROJECT_ID) {
34
+ console.log('\nSkipping create/download/archive — set PROJECT_ID in .env to run the full cycle.');
35
+ return;
36
+ }
37
+
38
+ // --- Create ---
39
+ // The file must be a valid ZIP produced by the thatopen CLI toolchain (`thatopen publish`).
40
+ // Uploading an invalid or empty ZIP causes the server to close the connection — use a real built app ZIP here.
41
+ // appProps is optional; omitting it uses platform defaults.
42
+ const bundle = new Blob([''], { type: 'application/zip' });
43
+ const appName = `__example-test__-${Date.now()}`;
44
+ const created = await client.createApp({
45
+ file: bundle,
46
+ name: appName,
47
+ versionTag: 'v1',
48
+ projectId: PROJECT_ID,
49
+ });
50
+ console.log(`\nCreated app: [${created.item._id}] ${created.item.name}`);
51
+
52
+ const appId = created.item._id;
53
+
54
+ // --- Download bundle ---
55
+ // downloadAppBundle extracts only the JS bundle from the ZIP — not the full archive.
56
+ const bundleResponse = await client.downloadAppBundle(appId);
57
+ const bundleText = await bundleResponse.text();
58
+ console.log(`\nDownloaded bundle (latest): ${bundleText.slice(0, 80)}...`);
59
+
60
+ // --- Archive ---
61
+ // archiveApp is a soft-delete. Unlike files and components, there is no recoverApp method —
62
+ // archived apps can only be managed via the platform UI.
63
+ await client.archiveApp(appId);
64
+ console.log(`\nArchived app [${appId}] — account is clean.`);
65
+ }
66
+
67
+ main().catch(console.error);
@@ -0,0 +1,99 @@
1
+ // description: "Batch reads — sign many hidden files, list versions and metadata for many files, and resolve many folders in one request each."
2
+ import { config } from 'dotenv';
3
+ import { resolve } from 'path';
4
+ import { EngineServicesClient } from '../client';
5
+
6
+ config({ path: resolve(__dirname, '.env') });
7
+
8
+ const ACCESS_TOKEN = process.env.ACCESS_TOKEN;
9
+ const API_URL = process.env.API_URL;
10
+ const PROJECT_ID = process.env.PROJECT_ID;
11
+
12
+ if (!ACCESS_TOKEN || !API_URL) {
13
+ throw new Error('ACCESS_TOKEN and API_URL are required in src/core/examples/.env');
14
+ }
15
+
16
+ async function main() {
17
+ const client = new EngineServicesClient(ACCESS_TOKEN!, API_URL!);
18
+
19
+ if (!PROJECT_ID) {
20
+ console.log('Set PROJECT_ID in .env to run this example.');
21
+ return;
22
+ }
23
+
24
+ const files = await client.listFiles({ projectId: PROJECT_ID });
25
+ if (!files.length) {
26
+ console.log('No files in this project — nothing to batch.');
27
+ return;
28
+ }
29
+ const fileIds = files.map((file) => String(file._id));
30
+
31
+ // --- Versions for a whole page of files ---
32
+ // One request instead of one per file. Entries come back in request order,
33
+ // and a file the token cannot read carries an `error` instead of `versions`.
34
+ const versionEntries = await client.listVersionsBatch(fileIds);
35
+ console.log('\nVersions per file:');
36
+ for (const entry of versionEntries) {
37
+ if (entry.error) {
38
+ console.log(` ${entry.itemId} → ${entry.error.status} ${entry.error.message}`);
39
+ continue;
40
+ }
41
+ console.log(` ${entry.itemId} → ${entry.versions?.length ?? 0} version(s)`);
42
+ }
43
+
44
+ // --- Metadata for specific (file, version) pairs ---
45
+ // The versions above already carry their metadata, so this is only needed
46
+ // when the version list itself is not wanted.
47
+ const pairs = versionEntries
48
+ .filter((entry) => entry.versions?.length)
49
+ .map((entry) => ({
50
+ itemId: entry.itemId,
51
+ versionTag: entry.versions![0].tag,
52
+ }));
53
+ const metadataEntries = await client.getFileVersionMetadataBatch(pairs);
54
+ console.log('\nLatest-version metadata:');
55
+ for (const entry of metadataEntries) {
56
+ console.log(
57
+ ` ${entry.itemId}@${entry.versionTag} → ${JSON.stringify(entry.metadata ?? entry.error)}`,
58
+ );
59
+ }
60
+
61
+ // --- Folders by id ---
62
+ // Use listFolders({ projectId }) for the whole tree; this is for a known set
63
+ // of ids, such as the parents behind a breadcrumb.
64
+ const folderIds = [
65
+ ...new Set(files.map((file) => file.folderId).filter(Boolean).map(String)),
66
+ ];
67
+ if (folderIds.length) {
68
+ const folderEntries = await client.getFoldersBatch(folderIds);
69
+ console.log('\nFolders:');
70
+ for (const entry of folderEntries) {
71
+ console.log(` ${entry.folderId} → ${entry.folder?.name ?? entry.error?.message}`);
72
+ }
73
+ }
74
+
75
+ // --- Signed URLs for many hidden files ---
76
+ // This is the call tile-based viewers (splats, point clouds) should use.
77
+ // Minting one URL per tile as the camera moves is what pushes a single
78
+ // session past the rate limit; one request covers up to 100 tiles, and
79
+ // longer lists are split into several requests automatically.
80
+ const hiddenFiles = await client.getHiddenFilesByParent(fileIds[0]);
81
+ if (!hiddenFiles.length) {
82
+ console.log('\nNo hidden files on the first file — skipping signed URLs.');
83
+ return;
84
+ }
85
+ const signedEntries = await client.getHiddenFileSignedUrlsBatch(
86
+ hiddenFiles.map((hiddenFile) => String(hiddenFile._id)),
87
+ 3600,
88
+ );
89
+ console.log('\nSigned hidden file URLs:');
90
+ for (const entry of signedEntries) {
91
+ if (entry.error) {
92
+ console.log(` ${entry.hiddenFileId} → ${entry.error.status} ${entry.error.message}`);
93
+ continue;
94
+ }
95
+ console.log(` ${entry.hiddenFileId} → expires ${entry.expiresAt}`);
96
+ }
97
+ }
98
+
99
+ main().catch(console.error);
@@ -0,0 +1,65 @@
1
+ // description: "Platform channel (app-side, runs inside the platform iframe) — a collaboration room with presence, and typed commands from external tools (MCP, CLI, Revit, etc) with automatic replies."
2
+ import { PlatformClient } from '../platform-client';
3
+
4
+ // Unlike the other examples this is not a Node script: `client.channel` only
5
+ // exists inside an app running in the platform's iframe, and throws anywhere
6
+ // else. Copy `setupChannel` into your app.
7
+
8
+ // The contract with an external tool, written once. Each command names its
9
+ // payload and, if the caller waits for an answer, its reply. Use `type`, and
10
+ // list only what the app handles.
11
+ type McpCommands = {
12
+ 'select-by-category': {
13
+ payload: { category: string };
14
+ reply: { count: number };
15
+ };
16
+ 'get-loaded-models': { payload: undefined; reply: { models: string[] } };
17
+ };
18
+
19
+ // What tabs of this app send each other. No replies: these are events.
20
+ type CollabEvents = {
21
+ 'cursor-position': { userName: string; x: number; y: number; z: number };
22
+ };
23
+
24
+ export async function setupChannel(client: PlatformClient) {
25
+ // Commands from this account's MCP server only. `external()` with no kind
26
+ // hears every kind of external tool (MCP, CLI, Revit plugin, etc...).
27
+ const mcp = client.channel.external<McpCommands>('mcp');
28
+
29
+ // What you return is sent back to whoever asked. Throw and it gets an error
30
+ // instead of waiting for a timeout. A command that was sent without waiting
31
+ // for an answer (a notification) has its return value ignored.
32
+ mcp.on('select-by-category', async ({ category }) => {
33
+ const count = await selectByCategory(category);
34
+ return { count };
35
+ });
36
+ mcp.on('get-loaded-models', () => ({ models: listModels() }));
37
+
38
+ // Joining is opt-in. Nothing reaches this app until it joins, so wire it to
39
+ // a button, or join at startup if the app should always listen.
40
+ await mcp.join();
41
+
42
+ // Collaboration: other users' tabs of this same app, across accounts.
43
+ const collab = client.channel.collab<CollabEvents>();
44
+ collab.on('cursor-position', (cursor, { from }) => moveAvatar(from, cursor));
45
+ // `from` identifies a tab, so a departure removes exactly its avatar.
46
+ collab.onPeerLeft(({ from }) => removeAvatar(from));
47
+ await collab.join();
48
+
49
+ // Sending does nothing until the platform has confirmed the join.
50
+ collab.send('cursor-position', { userName: 'Ada', x: 0, y: 0, z: 0 });
51
+
52
+ // Being in a room says this tab is listening, not that anyone is there.
53
+ // `peers` is who the platform reports on the other end right now.
54
+ mcp.onChange(() => {
55
+ console.log(`MCP: ${mcp.joined ? 'listening' : 'off'}, ${mcp.peers.length} connected`);
56
+ });
57
+
58
+ return { mcp, collab };
59
+ }
60
+
61
+ // Stand-ins for the app's own logic.
62
+ declare function selectByCategory(category: string): Promise<number>;
63
+ declare function listModels(): string[];
64
+ declare function moveAvatar(id: string, cursor: unknown): void;
65
+ declare function removeAvatar(id: string): void;
@@ -0,0 +1,91 @@
1
+ // description: "Cloud component lifecycle — list, get with versions, create, update, download bundle, archive, and recover."
2
+ import { config } from 'dotenv';
3
+ import { resolve } from 'path';
4
+ import { EngineServicesClient } from '../client';
5
+
6
+ config({ path: resolve(__dirname, '.env') });
7
+
8
+ const ACCESS_TOKEN = process.env.ACCESS_TOKEN;
9
+ const API_URL = process.env.API_URL;
10
+ const PROJECT_ID = process.env.PROJECT_ID;
11
+
12
+ if (!ACCESS_TOKEN || !API_URL) {
13
+ throw new Error('ACCESS_TOKEN and API_URL are required in src/core/examples/.env');
14
+ }
15
+
16
+ async function main() {
17
+ const client = new EngineServicesClient(ACCESS_TOKEN!, API_URL!);
18
+
19
+ // --- List ---
20
+ const components = await client.listComponents();
21
+ console.log(`\nFound ${components.length} component(s):`);
22
+ for (const c of components) {
23
+ console.log(` [${c._id}] ${c.name}`);
24
+ }
25
+
26
+ // --- Get ---
27
+ if (components.length > 0) {
28
+ const detail = await client.getComponent(components[0]._id, { showVersions: true });
29
+ console.log('\nFirst component detail (with versions):');
30
+ console.log(JSON.stringify(detail, null, 2));
31
+ }
32
+
33
+ if (!PROJECT_ID) {
34
+ console.log('\nSkipping create/update/download/archive — set PROJECT_ID in .env to run the full cycle.');
35
+ return;
36
+ }
37
+
38
+ // --- Create ---
39
+ // componentProps is required for TOOL items — the backend rejects creation without it.
40
+ // The file must be a valid ZIP produced by the thatopen CLI toolchain (`thatopen publish`).
41
+ // Uploading a raw JS blob returns 422 — use a real built component ZIP here.
42
+ const bundle = new Blob(['export async function main() { return { type: "SUCCESS", message: "ok" }; }'], { type: 'application/javascript' });
43
+ const componentName = `__example-test__-${Date.now()}`;
44
+ const created = await client.createComponent({
45
+ file: bundle,
46
+ name: componentName,
47
+ versionTag: 'v1',
48
+ projectId: PROJECT_ID,
49
+ componentProps: { type: 'CLOUD', tier: 'FREE', isPublic: false },
50
+ });
51
+ console.log(`\nCreated component: [${created.item._id}] ${created.item.name}`);
52
+
53
+ const componentId = created.item._id;
54
+
55
+ // --- Update (rename) ---
56
+ const renamed = await client.updateComponent(componentId, { name: `${componentName}-renamed` });
57
+ console.log(`\nRenamed to: ${renamed.item?.name}`);
58
+
59
+ // --- Update (new version) ---
60
+ const bundleV2 = new Blob(['export async function main() { return { type: "SUCCESS", message: "v2" }; }'], { type: 'application/javascript' });
61
+ const versioned = await client.updateComponent(componentId, {
62
+ file: bundleV2,
63
+ versionTag: 'v2',
64
+ componentProps: { type: 'CLOUD', tier: 'FREE', isPublic: false },
65
+ });
66
+ console.log(`\nUploaded new version: ${versioned.version?.tag}`);
67
+
68
+ // --- Download bundle ---
69
+ // downloadComponentBundle extracts only the JS bundle from the ZIP — not the full archive.
70
+ const bundleResponse = await client.downloadComponentBundle(componentId);
71
+ const bundleText = await bundleResponse.text();
72
+ console.log(`\nDownloaded bundle (latest): ${bundleText.slice(0, 80)}...`);
73
+
74
+ const bundleV1Response = await client.downloadComponentBundle(componentId, { versionTag: 'v1' });
75
+ const bundleV1Text = await bundleV1Response.text();
76
+ console.log(`Downloaded bundle (v1): ${bundleV1Text.slice(0, 80)}...`);
77
+
78
+ // --- Archive / Recover ---
79
+ // archiveComponent is a soft-delete — recoverable with recoverComponent.
80
+ await client.archiveComponent(componentId);
81
+ console.log(`\nArchived component [${componentId}]`);
82
+
83
+ await client.recoverComponent(componentId);
84
+ console.log('Recovered component.');
85
+
86
+ // Archive again to clean up.
87
+ await client.archiveComponent(componentId);
88
+ console.log(`Re-archived component — account is clean.`);
89
+ }
90
+
91
+ main().catch(console.error);
@@ -0,0 +1,75 @@
1
+ // description: "Cloud component execution — trigger, track progress via WebSocket, list, get, and abort executions."
2
+ import { config } from 'dotenv';
3
+ import { resolve } from 'path';
4
+ import { EngineServicesClient } from '../client';
5
+
6
+ config({ path: resolve(__dirname, '.env') });
7
+
8
+ const ACCESS_TOKEN = process.env.ACCESS_TOKEN;
9
+ const API_URL = process.env.API_URL;
10
+ const PROJECT_ID = process.env.PROJECT_ID;
11
+
12
+ // Required: a published cloud component ID.
13
+ // Obtain it from `listComponents()` or from the platform dashboard.
14
+ // Set it as COMPONENT_ID in src/core/examples/.env.
15
+ const COMPONENT_ID = process.env.COMPONENT_ID;
16
+
17
+ if (!ACCESS_TOKEN || !API_URL) {
18
+ throw new Error('ACCESS_TOKEN and API_URL are required in src/core/examples/.env');
19
+ }
20
+
21
+ if (!COMPONENT_ID) {
22
+ throw new Error(
23
+ 'COMPONENT_ID is required to run execution.ts.\n' +
24
+ 'Get it from `npx tsx src/core/examples/components.ts` once you have a published component,\n' +
25
+ 'or from the platform dashboard. Set it as COMPONENT_ID in src/core/examples/.env.',
26
+ );
27
+ }
28
+
29
+ async function main() {
30
+ const client = new EngineServicesClient(ACCESS_TOKEN!, API_URL!);
31
+
32
+ // --- Execute ---
33
+ // Pass projectId in executionParams to scope the execution to a project.
34
+ // The backend validates that the component is linked to that project — a foreign projectId returns 403.
35
+ // Omit projectId for personal (ownership) executions.
36
+ const { executionId } = await client.executeComponent(
37
+ COMPONENT_ID!,
38
+ { ...(PROJECT_ID && { projectId: PROJECT_ID }) },
39
+ );
40
+ console.log(`\nStarted execution: [${executionId}]`);
41
+
42
+ // --- Track progress via WebSocket ---
43
+ // onExecutionProgress fires on each progress event until the execution completes.
44
+ // The socket is not automatically closed — the callback receives a final event when done.
45
+ await new Promise<void>((resolve) => {
46
+ client.onExecutionProgress(executionId, (data) => {
47
+ if (data.progressUpdate) {
48
+ console.log(` progress: ${data.progressUpdate.progress}%`);
49
+ if (data.progressUpdate.result) {
50
+ console.log(` result: ${data.progressUpdate.result}`, data.progressUpdate.resultMessage ?? '');
51
+ resolve();
52
+ }
53
+ }
54
+ if (data.messageUpdate) {
55
+ console.log(` message: ${data.messageUpdate.content}`);
56
+ }
57
+ });
58
+ });
59
+
60
+ // --- Get execution detail ---
61
+ const execution = await client.getExecution(executionId);
62
+ console.log('\nExecution detail:');
63
+ console.log(JSON.stringify(execution, null, 2));
64
+
65
+ // --- List executions for the component ---
66
+ const executions = await client.listExecutions(COMPONENT_ID!);
67
+ console.log(`\nTotal executions for component: ${executions.length}`);
68
+
69
+ // --- Abort (demonstrates the API — only useful on long-running executions) ---
70
+ // abortExecution on an already-completed execution is a no-op on most backends.
71
+ // In production, call it before the WebSocket resolves to cancel a running execution.
72
+ console.log('\n(Skipping abortExecution — execution already completed.)');
73
+ }
74
+
75
+ main().catch(console.error);
@@ -0,0 +1,94 @@
1
+ // description: "File metadata and versioning — get, update, and delete per-version metadata; list, archive, recover, and permanently delete versions."
2
+ import { config } from 'dotenv';
3
+ import { resolve } from 'path';
4
+ import { EngineServicesClient } from '../client';
5
+
6
+ config({ path: resolve(__dirname, '.env') });
7
+
8
+ const ACCESS_TOKEN = process.env.ACCESS_TOKEN;
9
+ const API_URL = process.env.API_URL;
10
+ const PROJECT_ID = process.env.PROJECT_ID;
11
+
12
+ if (!ACCESS_TOKEN || !API_URL) {
13
+ throw new Error('ACCESS_TOKEN and API_URL are required in src/core/examples/.env');
14
+ }
15
+
16
+ async function main() {
17
+ const client = new EngineServicesClient(ACCESS_TOKEN!, API_URL!);
18
+
19
+ if (!PROJECT_ID) {
20
+ console.log('Set PROJECT_ID in .env to run this example.');
21
+ return;
22
+ }
23
+
24
+ // Create a test file to work with.
25
+ const blob = new Blob([JSON.stringify({ test: true })], { type: 'application/json' });
26
+ const fileName = `__example-test__-${Date.now()}.json`;
27
+ const created = await client.createFile({ file: blob, name: fileName, versionTag: 'v1', projectId: PROJECT_ID });
28
+ const fileId = created.item._id;
29
+ console.log(`\nCreated test file: [${fileId}] ${created.item.name}`);
30
+
31
+ // --- Get metadata (empty on a fresh version) ---
32
+ // Returns {} when the version exists but has no metadata yet — not null or 404.
33
+ const empty = await client.getFileVersionMetadata(fileId, 'v1');
34
+ console.log(`\nMetadata on v1 (fresh): ${JSON.stringify(empty)}`);
35
+
36
+ // --- Update metadata ---
37
+ const updated = await client.updateFileVersionMetadata(fileId, 'v1', {
38
+ discipline: 'structural',
39
+ approved: 'true',
40
+ });
41
+ console.log(`\nUpdated metadata: ${JSON.stringify(updated)}`);
42
+
43
+ // --- Get metadata (after update) ---
44
+ const meta = await client.getFileVersionMetadata(fileId, 'v1');
45
+ console.log(`\nMetadata on v1 (after update): ${JSON.stringify(meta)}`);
46
+
47
+ // --- Upload a second version and set metadata on it ---
48
+ const blobV2 = new Blob([JSON.stringify({ test: true, version: 2 })], { type: 'application/json' });
49
+ await client.updateFile(fileId, { file: blobV2, versionTag: 'v2' });
50
+ await client.updateFileVersionMetadata(fileId, 'v2', { discipline: 'architecture' });
51
+ console.log('\nUploaded v2 with its own metadata.');
52
+
53
+ // Metadata is per-version — each version holds its own independent metadata object.
54
+ const metaV1 = await client.getFileVersionMetadata(fileId, 'v1');
55
+ const metaV2 = await client.getFileVersionMetadata(fileId, 'v2');
56
+ console.log(`\nv1 metadata: ${JSON.stringify(metaV1)}`);
57
+ console.log(`v2 metadata: ${JSON.stringify(metaV2)}`);
58
+
59
+ // --- List versions ---
60
+ const versions = await client.listVersions(fileId);
61
+ console.log(`\nVersions (${versions.length}):`);
62
+ for (const v of versions) {
63
+ console.log(` [${v.tag}] created: ${v.createdAt}`);
64
+ }
65
+
66
+ // --- Archive a version ---
67
+ // A version must be archived before it can be permanently deleted.
68
+ await client.archiveVersion(fileId, 'v1');
69
+ console.log('\nArchived v1.');
70
+
71
+ const versionsAfterArchive = await client.listVersions(fileId, { archived: false });
72
+ console.log(`Active versions after archiving v1: ${versionsAfterArchive.map(v => v.tag).join(', ')}`);
73
+
74
+ // --- Recover a version ---
75
+ await client.recoverVersion(fileId, 'v1');
76
+ console.log('Recovered v1.');
77
+
78
+ // --- Delete metadata ---
79
+ await client.deleteFileVersionMetadata(fileId, 'v1');
80
+ const afterDelete = await client.getFileVersionMetadata(fileId, 'v1');
81
+ console.log(`\nMetadata on v1 after delete: ${JSON.stringify(afterDelete)}`);
82
+
83
+ // --- Delete a version permanently ---
84
+ // archiveVersion first is required — deleteVersion on an active version returns an error.
85
+ await client.archiveVersion(fileId, 'v1');
86
+ await client.deleteVersion(fileId, 'v1');
87
+ console.log('\nPermanently deleted v1.');
88
+
89
+ // --- Cleanup ---
90
+ await client.archiveFile(fileId);
91
+ console.log(`\nArchived test file [${fileId}] — account is clean.`);
92
+ }
93
+
94
+ main().catch(console.error);
@@ -0,0 +1,81 @@
1
+ // description: "File lifecycle — list, get with versions, create, rename, upload new version, download by version, archive, and recover."
2
+ import { config } from 'dotenv';
3
+ import { resolve } from 'path';
4
+ import { EngineServicesClient } from '../client';
5
+
6
+ config({ path: resolve(__dirname, '.env') });
7
+
8
+ const ACCESS_TOKEN = process.env.ACCESS_TOKEN;
9
+ const API_URL = process.env.API_URL;
10
+ const PROJECT_ID = process.env.PROJECT_ID;
11
+
12
+ if (!ACCESS_TOKEN || !API_URL) {
13
+ throw new Error('ACCESS_TOKEN and API_URL are required in src/core/examples/.env');
14
+ }
15
+
16
+ async function main() {
17
+ const client = new EngineServicesClient(ACCESS_TOKEN!, API_URL!);
18
+
19
+ // --- List ---
20
+ const files = await client.listFiles();
21
+ console.log(`\nFound ${files.length} file(s):`);
22
+ for (const file of files) {
23
+ console.log(` [${file._id}] ${file.name}`);
24
+ }
25
+
26
+ // --- Get ---
27
+ if (files.length > 0) {
28
+ const detail = await client.getFile(files[0]._id, { showVersions: true });
29
+ console.log('\nFirst file detail (with versions):');
30
+ console.log(JSON.stringify(detail, null, 2));
31
+ }
32
+
33
+ if (!PROJECT_ID) {
34
+ console.log('\nSkipping create/update/download/archive — set PROJECT_ID in .env to run the full cycle.');
35
+ return;
36
+ }
37
+
38
+ // --- Create ---
39
+ // projectId is required — the backend rejects uploads without one.
40
+ const fileName = `__example-test__-${Date.now()}.json`;
41
+ const blob = new Blob([JSON.stringify({ test: true })], { type: 'application/json' });
42
+ const created = await client.createFile({ file: blob, name: fileName, versionTag: 'v1', projectId: PROJECT_ID });
43
+ console.log(`\nCreated file: [${created.item._id}] ${created.item.name}`);
44
+
45
+ const fileId = created.item._id;
46
+
47
+ // --- Update (rename) ---
48
+ const renamed = await client.updateFile(fileId, { name: `${fileName}-renamed` });
49
+ console.log(`\nRenamed to: ${renamed.item?.name}`);
50
+
51
+ // --- Update (new version) ---
52
+ const blobV2 = new Blob([JSON.stringify({ test: true, version: 2 })], { type: 'application/json' });
53
+ const versioned = await client.updateFile(fileId, { file: blobV2, versionTag: 'v2' });
54
+ console.log(`\nUploaded new version: ${versioned.version?.tag}`);
55
+
56
+ // --- Download ---
57
+ // Without versionTag, always downloads the latest version — not necessarily the one from createFile.
58
+ const response = await client.downloadFile(fileId);
59
+ const text = await response.text();
60
+ console.log(`\nDownloaded content (latest version): ${text}`);
61
+
62
+ const responseV1 = await client.downloadFile(fileId, { versionTag: 'v1' });
63
+ const textV1 = await responseV1.text();
64
+ console.log(`Downloaded content (v1): ${textV1}`);
65
+
66
+ // --- Archive / Recover ---
67
+ // archiveFile is a soft-delete — the file can be recovered with recoverFile.
68
+ await client.archiveFile(fileId);
69
+ console.log(`\nArchived file [${fileId}]`);
70
+
71
+ // recoverFile returns no body — confirm by re-fetching the file.
72
+ await client.recoverFile(fileId);
73
+ const recovered = await client.getFile(fileId);
74
+ console.log(`Recovered file: ${recovered.name}`);
75
+
76
+ // Archive again to leave the account clean.
77
+ await client.archiveFile(fileId);
78
+ console.log(`Re-archived test file [${fileId}] — account is clean.`);
79
+ }
80
+
81
+ main().catch(console.error);
@@ -0,0 +1,82 @@
1
+ // description: "Folder lifecycle — list, get, create nested folders, list files inside a folder, rename, download as ZIP, archive, and recover."
2
+ import { config } from 'dotenv';
3
+ import { resolve } from 'path';
4
+ import { EngineServicesClient } from '../client';
5
+
6
+ config({ path: resolve(__dirname, '.env') });
7
+
8
+ const ACCESS_TOKEN = process.env.ACCESS_TOKEN;
9
+ const API_URL = process.env.API_URL;
10
+ const PROJECT_ID = process.env.PROJECT_ID;
11
+
12
+ if (!ACCESS_TOKEN || !API_URL) {
13
+ throw new Error('ACCESS_TOKEN and API_URL are required in src/core/examples/.env');
14
+ }
15
+
16
+ async function main() {
17
+ const client = new EngineServicesClient(ACCESS_TOKEN!, API_URL!);
18
+
19
+ // --- List ---
20
+ const folders = await client.listFolders();
21
+ console.log(`\nFound ${folders.length} folder(s):`);
22
+ for (const folder of folders) {
23
+ console.log(` [${folder._id}] ${folder.name}`);
24
+ }
25
+
26
+ // --- Get ---
27
+ if (folders.length > 0) {
28
+ const detail = await client.getFolder(folders[0]._id);
29
+ console.log('\nFirst folder detail:');
30
+ console.log(JSON.stringify(detail, null, 2));
31
+ }
32
+
33
+ if (!PROJECT_ID) {
34
+ console.log('\nSkipping create/update/download/archive — set PROJECT_ID in .env to run the full cycle.');
35
+ return;
36
+ }
37
+
38
+ // --- Create (parent) ---
39
+ const folderName = `__example-test__-${Date.now()}`;
40
+ const parent = await client.createFolder(folderName, undefined, PROJECT_ID);
41
+ console.log(`\nCreated folder: [${parent._id}] ${parent.name}`);
42
+
43
+ // --- Create (nested) ---
44
+ // parentId nests the folder inside another — omitting it creates a root-level folder.
45
+ const child = await client.createFolder(`${folderName}-child`, parent._id, PROJECT_ID);
46
+ console.log(`Created nested folder: [${child._id}] ${child.name}`);
47
+
48
+ // --- List by parent ---
49
+ // NOTE: listFolders({ parentFolderId }) returned 0 even after creating a child with that parentId.
50
+ // The backend may not index parentFolderId for filtering, or the field name differs internally.
51
+ const children = await client.listFolders({ parentFolderId: parent._id });
52
+ console.log(`\nChildren of [${parent._id}]: ${children.length} folder(s)`);
53
+
54
+ // --- List files inside a folder ---
55
+ const filesInFolder = await client.listFiles({ folderId: parent._id });
56
+ console.log(`Files inside folder: ${filesInFolder.length}`);
57
+
58
+ // --- Update (rename) ---
59
+ const renamed = await client.updateFolder(parent._id, { name: `${folderName}-renamed` });
60
+ console.log(`\nRenamed to: ${renamed.name}`);
61
+
62
+ // --- Download as ZIP ---
63
+ const zip = await client.downloadFolder(parent._id);
64
+ const buffer = await zip.arrayBuffer();
65
+ console.log(`\nDownloaded folder ZIP: ${buffer.byteLength} bytes`);
66
+
67
+ // --- Archive / Recover ---
68
+ // archiveFolder is a soft-delete — recoverable with recoverFolder.
69
+ // Archiving a parent cascades to all children: sub-folders and their files are archived too.
70
+ await client.archiveFolder(child._id);
71
+ console.log(`\nArchived child folder [${child._id}]`);
72
+
73
+ await client.recoverFolder(child._id);
74
+ console.log(`Recovered child folder [${child._id}]`);
75
+
76
+ // Archive both to clean up.
77
+ await client.archiveFolder(child._id);
78
+ await client.archiveFolder(parent._id);
79
+ console.log(`Re-archived folders — account is clean.`);
80
+ }
81
+
82
+ main().catch(console.error);
@@ -0,0 +1,81 @@
1
+ // description: "Hidden file lifecycle — create, get, list by parent, download, and delete."
2
+ import { config } from 'dotenv';
3
+ import { resolve } from 'path';
4
+ import { EngineServicesClient } from '../client';
5
+
6
+ config({ path: resolve(__dirname, '.env') });
7
+
8
+ const ACCESS_TOKEN = process.env.ACCESS_TOKEN;
9
+ const API_URL = process.env.API_URL;
10
+ const PROJECT_ID = process.env.PROJECT_ID;
11
+
12
+ if (!ACCESS_TOKEN || !API_URL) {
13
+ throw new Error('ACCESS_TOKEN and API_URL are required in src/core/examples/.env');
14
+ }
15
+
16
+ async function main() {
17
+ const client = new EngineServicesClient(ACCESS_TOKEN!, API_URL!);
18
+
19
+ if (!PROJECT_ID) {
20
+ console.log('Set PROJECT_ID in .env to run this example.');
21
+ return;
22
+ }
23
+
24
+ // Hidden files are always attached to a parent item — they have no standalone existence.
25
+ // Create a parent file to work with.
26
+ const parentBlob = new Blob([JSON.stringify({ test: true })], { type: 'application/json' });
27
+ const parentName = `__example-test__-${Date.now()}.json`;
28
+ const parent = await client.createFile({ file: parentBlob, name: parentName, versionTag: 'v1', projectId: PROJECT_ID });
29
+ const parentId = parent.item._id;
30
+ console.log(`\nCreated parent file: [${parentId}] ${parent.item.name}`);
31
+
32
+ // --- Create hidden files ---
33
+ const hiddenBlob1 = new Blob([JSON.stringify({ hidden: 1 })], { type: 'application/json' });
34
+ const hiddenBlob2 = new Blob([JSON.stringify({ hidden: 2 })], { type: 'application/json' });
35
+
36
+ const hidden1 = await client.createHiddenFile(hiddenBlob1, parentId);
37
+ console.log(`\nCreated hidden file 1: [${hidden1.hiddenFileId}]`);
38
+
39
+ const hidden2 = await client.createHiddenFile(hiddenBlob2, parentId);
40
+ console.log(`Created hidden file 2: [${hidden2.hiddenFileId}]`);
41
+
42
+ // --- Get ---
43
+ const fetched = await client.getHiddenFile(hidden1.hiddenFileId);
44
+ console.log(`\nHidden file 1 detail:`);
45
+ console.log(JSON.stringify(fetched, null, 2));
46
+
47
+ // --- List by parent ---
48
+ const allHidden = await client.getHiddenFilesByParent(parentId);
49
+ console.log(`\nHidden files attached to parent: ${allHidden.length}`);
50
+ for (const h of allHidden) {
51
+ console.log(` [${h._id}]`);
52
+ }
53
+
54
+ // --- Download ---
55
+ const response = await client.downloadHiddenFile(hidden1.hiddenFileId);
56
+ const text = await response.text();
57
+ console.log(`\nDownloaded hidden file 1: ${text}`);
58
+
59
+ // --- Delete one ---
60
+ await client.deleteHiddenFile(hidden1.hiddenFileId);
61
+ console.log(`\nDeleted hidden file 1 [${hidden1.hiddenFileId}]`);
62
+
63
+ // NOTE: getHiddenFilesByParent may still return deleted files immediately after deletion —
64
+ // the backend list appears to be eventually consistent.
65
+ const afterDelete = await client.getHiddenFilesByParent(parentId);
66
+ console.log(`Hidden files remaining: ${afterDelete.length}`);
67
+
68
+ // --- Delete all by parent ---
69
+ // deleteHiddenFilesByParent removes all hidden files attached to the parent in one call.
70
+ await client.deleteHiddenFilesByParent(parentId);
71
+ console.log(`\nDeleted all remaining hidden files for parent [${parentId}]`);
72
+
73
+ const afterDeleteAll = await client.getHiddenFilesByParent(parentId);
74
+ console.log(`Hidden files remaining: ${afterDeleteAll.length}`);
75
+
76
+ // --- Cleanup ---
77
+ await client.archiveFile(parentId);
78
+ console.log(`\nArchived parent file [${parentId}] — account is clean.`);
79
+ }
80
+
81
+ main().catch(console.error);
@@ -0,0 +1,68 @@
1
+ // description: "Item icon management — upload, download, and remove icons on any item (file, app, or component)."
2
+ import { config } from 'dotenv';
3
+ import { resolve } from 'path';
4
+ import { EngineServicesClient } from '../client';
5
+
6
+ config({ path: resolve(__dirname, '.env') });
7
+
8
+ const ACCESS_TOKEN = process.env.ACCESS_TOKEN;
9
+ const API_URL = process.env.API_URL;
10
+ const PROJECT_ID = process.env.PROJECT_ID;
11
+
12
+ if (!ACCESS_TOKEN || !API_URL) {
13
+ throw new Error('ACCESS_TOKEN and API_URL are required in src/core/examples/.env');
14
+ }
15
+
16
+ async function main() {
17
+ const client = new EngineServicesClient(ACCESS_TOKEN!, API_URL!);
18
+
19
+ if (!PROJECT_ID) {
20
+ console.log('Set PROJECT_ID in .env to run this example.');
21
+ return;
22
+ }
23
+
24
+ // Create a test file to attach an icon to.
25
+ const blob = new Blob([JSON.stringify({ test: true })], { type: 'application/json' });
26
+ const fileName = `__example-test__-${Date.now()}.json`;
27
+ const created = await client.createFile({ file: blob, name: fileName, versionTag: 'v1', projectId: PROJECT_ID });
28
+ const itemId = created.item._id;
29
+ console.log(`\nCreated test file: [${itemId}] ${created.item.name}`);
30
+
31
+ // --- Upload icon ---
32
+ // Accepted formats: PNG, WebP, or ICO — max 512 KB.
33
+ // uploadItemIcon replaces any existing icon; there is no separate "update icon" method.
34
+ const iconPng = generateMinimalPng();
35
+ const updated = await client.uploadItemIcon(itemId, iconPng);
36
+ console.log(`\nUploaded icon. iconFileId: ${updated.iconFileId}, mimeType: ${updated.iconMimeType}`);
37
+
38
+ // --- Download icon ---
39
+ const iconResponse = await client.getItemIcon(itemId);
40
+ const iconBuffer = await iconResponse.arrayBuffer();
41
+ console.log(`\nDownloaded icon: ${iconBuffer.byteLength} bytes`);
42
+
43
+ // --- Remove icon ---
44
+ const removed = await client.removeItemIcon(itemId);
45
+ console.log(`\nRemoved icon. iconFileId: ${removed.iconFileId ?? 'null'}`);
46
+
47
+ // --- Cleanup ---
48
+ await client.archiveFile(itemId);
49
+ console.log(`\nArchived test file [${itemId}] — account is clean.`);
50
+ }
51
+
52
+ // Generates the smallest valid PNG (1x1 transparent pixel) as a Blob.
53
+ function generateMinimalPng(): Blob {
54
+ const bytes = new Uint8Array([
55
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG signature
56
+ 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, // IHDR chunk length + type
57
+ 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, // width=1, height=1
58
+ 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, // bit depth=8, colorType=6 (RGBA)
59
+ 0x89, 0x00, 0x00, 0x00, 0x0b, 0x49, 0x44, 0x41, // IHDR CRC + IDAT chunk
60
+ 0x54, 0x78, 0x9c, 0x62, 0x00, 0x00, 0x00, 0x02, // IDAT data (zlib compressed)
61
+ 0x00, 0x01, 0xe2, 0x21, 0xbc, 0x33, 0x00, 0x00, // IDAT CRC
62
+ 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, // IEND chunk
63
+ 0x60, 0x82,
64
+ ]);
65
+ return new Blob([bytes], { type: 'image/png' });
66
+ }
67
+
68
+ main().catch(console.error);
@@ -0,0 +1,87 @@
1
+ // description: "Permission checks — single and batch permission verification using PlatformClient (requires user JWT, not API token)."
2
+ import { config } from 'dotenv';
3
+ import { resolve } from 'path';
4
+ import { PlatformClient } from '../platform-client';
5
+
6
+ config({ path: resolve(__dirname, '.env') });
7
+
8
+ const API_URL = process.env.API_URL;
9
+ const PROJECT_ID = process.env.PROJECT_ID;
10
+
11
+ // PlatformClient requires a user JWT — not the API token used by EngineServicesClient.
12
+ // To run this example:
13
+ // 1. Open the platform in your browser and log in.
14
+ // 2. Open DevTools → Network → click any /api request.
15
+ // 3. Copy the Authorization header value (without the "Bearer " prefix).
16
+ // 4. Set it as JWT in src/core/examples/.env.
17
+ const JWT = process.env.JWT;
18
+
19
+ if (!API_URL) {
20
+ throw new Error('API_URL is required in src/core/examples/.env');
21
+ }
22
+
23
+ if (!JWT) {
24
+ throw new Error(
25
+ 'JWT is required to run permissions.ts.\n' +
26
+ 'Get it from DevTools → Network → any /api request → Authorization header (without "Bearer ").\n' +
27
+ 'Set it as JWT in src/core/examples/.env.',
28
+ );
29
+ }
30
+
31
+ if (!PROJECT_ID) {
32
+ throw new Error('PROJECT_ID is required in src/core/examples/.env');
33
+ }
34
+
35
+ async function main() {
36
+ // PlatformClient extends EngineServicesClient and forces useBearer: true.
37
+ // It owns the JWT-only routes — checkPermission, checkPermissionBatch, getProject, getProjectData.
38
+ // These routes are guarded server-side and are not reachable with an API token.
39
+ const client = new PlatformClient(JWT!, API_URL!);
40
+
41
+ // --- Single permission check ---
42
+ const result = await client.checkPermission({
43
+ resourceType: 'STORAGE',
44
+ action: 'READ',
45
+ projectId: PROJECT_ID,
46
+ });
47
+ console.log('\nSTORAGE:READ check:');
48
+ console.log(` hasPermission: ${result.hasPermission}`);
49
+ // scope indicates HOW the permission was granted:
50
+ // 'global' — admin/owner bypass
51
+ // 'project' — role-level grant on the project
52
+ // 'entity' — per-entity override
53
+ // 'none' — denied
54
+ console.log(` scope: ${result.scope}`);
55
+
56
+ // --- Batch permission check ---
57
+ // Evaluates multiple checks in a single round-trip — useful to hydrate
58
+ // action visibility for many rows without N+1 calls.
59
+ const checks = [
60
+ { resourceType: 'STORAGE', action: 'READ', projectId: PROJECT_ID },
61
+ { resourceType: 'STORAGE', action: 'WRITE', projectId: PROJECT_ID },
62
+ { resourceType: 'STORAGE', action: 'DELETE', projectId: PROJECT_ID },
63
+ ];
64
+ const results = await client.checkPermissionBatch(checks);
65
+ console.log('\nBatch permission check:');
66
+ for (let i = 0; i < checks.length; i++) {
67
+ const { resourceType, action } = checks[i];
68
+ const { hasPermission, scope } = results[i];
69
+ console.log(` ${resourceType}:${action} → hasPermission: ${hasPermission}, scope: ${scope}`);
70
+ }
71
+
72
+ // --- Get project ---
73
+ const project = await client.getProject(PROJECT_ID!);
74
+ console.log('\nProject:');
75
+ console.log(JSON.stringify(project, null, 2));
76
+
77
+ // --- Get project data ---
78
+ // getProjectData returns the full project snapshot: users, roles, files, folders.
79
+ // User data is stripped of sensitive fields server-side.
80
+ const data = await client.getProjectData(PROJECT_ID!);
81
+ console.log('\nProject data (summary):');
82
+ console.log(` users: ${data.users?.length ?? 0}`);
83
+ console.log(` files: ${data.files?.length ?? 0}`);
84
+ console.log(` folders: ${data.folders?.length ?? 0}`);
85
+ }
86
+
87
+ main().catch(console.error);