@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.
- package/docs/ai-quickstart.md +73 -5
- package/docs/platform-token.md +1 -1
- package/docs/rate-limits.md +24 -14
- package/package.json +2 -1
- package/resources/AGENTS.md +9 -2
- package/src/cli/templates/app/README.md +33 -0
- package/src/cli/templates/app/src/main.ts +28 -1
- package/src/cli/templates/app/src/setups/channel.ts +76 -0
- package/src/core/examples/.env.example +7 -0
- package/src/core/examples/apps.ts +67 -0
- package/src/core/examples/batch-reads.ts +99 -0
- package/src/core/examples/channel.ts +65 -0
- package/src/core/examples/components.ts +91 -0
- package/src/core/examples/execution.ts +75 -0
- package/src/core/examples/file-metadata.ts +94 -0
- package/src/core/examples/files.ts +81 -0
- package/src/core/examples/folders.ts +82 -0
- package/src/core/examples/hidden-files.ts +81 -0
- package/src/core/examples/icons.ts +68 -0
- package/src/core/examples/permissions.ts +87 -0
package/docs/ai-quickstart.md
CHANGED
|
@@ -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
|
|
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
|
|
66
|
-
→ **
|
|
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
|
|
287
|
-
|
|
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.
|
package/docs/platform-token.md
CHANGED
|
@@ -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
|
|
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
|
package/docs/rate-limits.md
CHANGED
|
@@ -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:
|
|
77
|
+
## The pattern: drafts in memory, explicit saves
|
|
78
78
|
|
|
79
|
-
Keep every intermediate state in
|
|
80
|
-
|
|
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
|
|
91
|
+
const drafts = new Map<string, { state: unknown; at: number }>();
|
|
84
92
|
|
|
85
|
-
function onChange(state: unknown) {
|
|
86
|
-
|
|
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
|
-
|
|
100
|
+
drafts.delete(fileId);
|
|
93
101
|
}
|
|
94
102
|
```
|
|
95
103
|
|
|
96
|
-
|
|
97
|
-
|
|
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**
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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.
|
|
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"
|
package/resources/AGENTS.md
CHANGED
|
@@ -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
|
|
44
|
-
|
|
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
|
-
|
|
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);
|