streamby-core 0.36.0 → 0.38.0

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.
Files changed (46) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/README.md +141 -44
  3. package/dist/adapters/database/connectionManager.js +24 -11
  4. package/dist/adapters/storage/s3.d.ts +3 -2
  5. package/dist/adapters/storage/s3.js +18 -5
  6. package/dist/index.d.ts +2 -1
  7. package/dist/index.js +4 -1
  8. package/dist/mcp/server.d.ts +4 -3
  9. package/dist/mcp/server.js +25 -10
  10. package/dist/mcp/tools.d.ts +7 -2
  11. package/dist/mcp/tools.js +187 -35
  12. package/dist/mcp/workflowNodes.d.ts +24 -0
  13. package/dist/mcp/workflowNodes.js +48 -0
  14. package/dist/middleware/createRouter.js +2 -0
  15. package/dist/middleware/routes/apiToken.js +77 -14
  16. package/dist/middleware/routes/deliverable.d.ts +3 -0
  17. package/dist/middleware/routes/deliverable.js +131 -0
  18. package/dist/middleware/routes/export.js +2 -0
  19. package/dist/middleware/routes/member.js +8 -0
  20. package/dist/middleware/routes/production.js +60 -67
  21. package/dist/middleware/routes/project.js +15 -3
  22. package/dist/middleware/routes/storage.js +115 -24
  23. package/dist/middleware/routes/subscription.js +3 -0
  24. package/dist/services/apiTokenPermissions.d.ts +16 -0
  25. package/dist/services/apiTokenPermissions.js +102 -0
  26. package/dist/services/apiTokens.d.ts +38 -14
  27. package/dist/services/apiTokens.js +134 -40
  28. package/dist/services/assetVersions.d.ts +48 -0
  29. package/dist/services/assetVersions.js +84 -0
  30. package/dist/services/auth.d.ts +4 -1
  31. package/dist/services/auth.js +24 -18
  32. package/dist/services/deliverables.d.ts +26 -0
  33. package/dist/services/deliverables.js +55 -0
  34. package/dist/services/mediaProcessor.js +3 -1
  35. package/dist/services/production.d.ts +2 -0
  36. package/dist/services/production.js +13 -0
  37. package/dist/services/productionFields.d.ts +16 -0
  38. package/dist/services/productionFields.js +80 -0
  39. package/dist/services/signedUrls.d.ts +6 -0
  40. package/dist/services/signedUrls.js +9 -0
  41. package/dist/types/index.d.ts +12 -4
  42. package/dist/utils/auth.d.ts +12 -1
  43. package/dist/utils/auth.js +23 -2
  44. package/dist/utils/nosqlDb.d.ts +2 -0
  45. package/dist/utils/nosqlDb.js +11 -0
  46. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,102 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.38.0
4
+
5
+ ### Added
6
+
7
+ - **Uploading and downloading files from the MCP server**, so an agent can bring files from and
8
+ to the tools it works with (Blender's or Godot's own MCP, a script). The file never goes
9
+ through the conversation: the tools give signed URLs, and the tool that has the file uploads
10
+ or downloads it directly. See the README for an example with Blender.
11
+ - `request_asset_upload` (`assets:write`): a new asset, or a new version of one, and the URL
12
+ to upload its file to.
13
+ - `get_asset_download_url` (`assets:read`): a URL to download an asset's file, its latest
14
+ version or an earlier one. `list_asset_versions` lists them.
15
+ - **Asset versions are kept, and an asset's URL doesn't change when it's replaced.** A new
16
+ version (`replace-url`, from the UI too) is still uploaded to the asset's own key, so a site
17
+ that links to the asset shows the new one. Before that, the current version is copied to a
18
+ key of its own, `<key>.v<n>.<ext>`, and recorded in `asset_versions` (an asset's first
19
+ version is recorded on upload; an older asset gets it when it's first replaced). If it can't
20
+ be copied, nothing is replaced and no upload URL is given. The `replace-url` routes answer
21
+ the new version's `versionId` and `version`. Deleting an asset deletes every key of it.
22
+ - New `StorageAdapter.copyFile`, implemented by the S3 adapter. With an adapter without it,
23
+ asking for a new version answers 501.
24
+ - StreamBy doesn't set `Cache-Control` on uploads, so a browser or a CDN may serve a replaced
25
+ asset's earlier version for a while: set it where the files are served (see the README).
26
+ - `GET /projects/:projectId/assets/:fileId/download-url` (`?versionId=` for an earlier version):
27
+ a signed URL that downloads the file with its name, wherever it's stored.
28
+ - **Deliverables**: an asset, or one of its versions, delivered for a production shot or for an
29
+ export, kept in the NoSQL database's `deliverables` collection. `GET`, `POST` and
30
+ `DELETE /projects/:projectId/deliverables` list (by `shotId` or `exportId`), mark and remove
31
+ them; `list_deliverables`, `mark_deliverable` and `remove_deliverable` in the MCP server.
32
+ Deleting a shot, a sequence, an export or an asset removes its deliverables.
33
+ - **`mcp.signedUrlTtl`**: how long the signed URLs given to an API token work, also when it
34
+ lists files. Default: 15 minutes to upload, 5 to download. A session's are as before.
35
+ - `StorageAdapter.getPresignedUploadUrl` and `getPresignedGetUrl` take how long the URL works
36
+ (and `getPresignedGetUrl` the name to download it with).
37
+
38
+ - `GET /projects/:id` answers `hasProductionData`: whether the project has production
39
+ sequences, so a client can show them even without a production node in its workflow.
40
+
41
+ ### Changed
42
+
43
+ - **Production data is checked before it's saved.** Sequences, shots and tasks need a name
44
+ that isn't blank; a status must be `todo`, `in_progress`, `review` or `done`, and a task's
45
+ priority `low`, `medium` or `high`; a shot's `assignedTo` must list active members of the
46
+ project, and a task's be one of them (or null); a due date must be a date. Otherwise they
47
+ answer 400. A shot is made only in a sequence of the project, and a task only in a shot of
48
+ it (404 otherwise).
49
+ - The MCP tools for shots take `assignedTo` as a list of user ids, as the API does, and their
50
+ statuses and priorities are listed in their schema.
51
+ - **The MCP tools take the path a user does.** `create_sequence`, `create_shot` and
52
+ `create_task` first add a production node (a Shot node) to the project's workflow when it
53
+ has none, as dropping one on the canvas does, and make nothing if it can't be added. So they
54
+ need `workflow:write` as well as `production:write`. Each tool's description says where its
55
+ result shows in the UI.
56
+
57
+ ### Fixed
58
+
59
+ - **Deleting production data leaves nothing behind.** Deleting a sequence deletes its shots'
60
+ tasks too (they were left), and deleting a project deletes its sequences, shots, tasks and
61
+ deliverables. Deleting a sequence that doesn't exist answers 404.
62
+ - **Only editors and admins upload a new version of an asset.** The `replace-url` routes let
63
+ any member of the project, a viewer included, replace an asset's file.
64
+ - The ingest job no longer records a second version of the file an upload already recorded.
65
+
66
+ ## 0.37.0
67
+
68
+ ### ⚠️ Breaking changes
69
+
70
+ - **API tokens have permissions, not an access level.** The tokens made before this version no
71
+ longer work, and aren't listed: make new ones. There's no migration. The `api_tokens` table
72
+ gets its new columns at startup (`kind`, `permissions`, `createdByName`), and its old
73
+ `access` column becomes optional; nothing else is changed.
74
+ - **`ApiTokenScope` changed.** It has `kind` (`'user'` or `'project'`) and `permissions`,
75
+ instead of `access`. `ApiTokenAccess` is gone.
76
+ - **`createApiToken`** takes who makes the token (`{ kind, userId, username, projectId }`), and
77
+ `permissions` instead of `access`. `listApiTokens` and `revokeApiToken` are now
78
+ `listUserApiTokens` and `revokeUserApiToken`.
79
+ - **The host's `authProvider` must handle a project's token** (see the README): it isn't a
80
+ user, so it must not be looked up as one. `resolveApiToken` returns its `name` to go by.
81
+
82
+ ### Added
83
+
84
+ - **Permissions** for tokens, by resource and action: `project`, `members`, `assets`,
85
+ `production`, `workflow`, `exports`, `rights` and `jobs`, each with `read` and its writes
86
+ (`update`, `write`, `manage`, `delete` or `run`), and `projects:create` for the account.
87
+ Every route a token reaches needs its own permission; a route no permission covers isn't
88
+ reachable with a token. A user's token is still limited by its user's role in each project.
89
+ `PROJECT_PERMISSIONS` and `ACCOUNT_PERMISSIONS` are exported.
90
+ - **Projects' tokens**, at `GET`, `POST` and `DELETE /projects/:id/api-tokens`, managed by the
91
+ project's admins only. They act on their project alone, with their permissions, and keep
92
+ working after whoever made them leaves it; the project's admins are notified when that
93
+ happens (`api_token_creator_left`). Deleting a project ends its tokens.
94
+ - **`projects:create`**: a user's token with it creates projects, and reaches the ones it
95
+ creates.
96
+ - **MCP tools** `create_project`, `delete_project`, `delete_asset`, `delete_sequence`,
97
+ `delete_shot` and `delete_task`. Each tool needs its permissions and is listed only for a
98
+ token that has them; the ones that delete are marked as destructive.
99
+
3
100
  ## 0.36.0
4
101
 
5
102
  ### Added
package/README.md CHANGED
@@ -102,20 +102,49 @@ createStreamByRouter({
102
102
 
103
103
  ## 🎫 API tokens
104
104
 
105
- Agents and services work with StreamBy as a user through API tokens, without the user's
106
- browser session. A user manages their own tokens (`GET`, `POST` and
107
- `DELETE /streamby/user/api-tokens`, only with a session). Each token has:
108
-
109
- - **an access level**: `read` (only reads) or `write` (reads and writes);
110
- - **the projects it reaches**: a list of project ids, or `'*'` for every project the user is a
111
- member of, including ones they join later;
112
- - **an expiry**: 7, 30, 90 or 365 days, or none.
113
-
114
- A token is `sbt_` followed by 32 random bytes. It's shown once, when it's made. Only its
115
- SHA-256 hash is stored, with a short prefix to tell it apart.
105
+ Agents and services work with StreamBy through API tokens, without a browser session. There
106
+ are two kinds:
107
+
108
+ - **A user's token** acts as its user. The user manages their own (`GET`, `POST` and
109
+ `DELETE /streamby/user/api-tokens`). It reaches a list of the user's projects, or `'*'` for
110
+ every project they're a member of, including ones they join later. It never does more than
111
+ its user can: their role in each project still applies.
112
+ - **A project's token** acts on its project alone, as the project's, not as whoever made it:
113
+ it keeps working after they leave the project, for permanent integrations (CI, services, the
114
+ project's agents). Only the project's admins list, make and revoke them (`GET`, `POST` and
115
+ `DELETE /streamby/projects/:id/api-tokens`), and each one records who made it. When they
116
+ leave, the project's admins are notified that it still works. Deleting the project ends
117
+ its tokens.
118
+
119
+ Tokens are managed only with a session: a token can't manage tokens.
120
+
121
+ Each token has a name, **its permissions**, and **an expiry** (7, 30, 90 or 365 days, or
122
+ none). It's `sbt_` followed by 32 random bytes, shown once, when it's made. Only its SHA-256
123
+ hash is stored, with a short prefix to tell it apart.
124
+
125
+ **Permissions**
126
+
127
+ | Resource | Permissions | Covers |
128
+ |---|---|---|
129
+ | Project | `project:read`, `project:update`, `project:delete` | The project, its settings and its connections (API, database, storage, distribution, render farm), integrations and allowed origins. |
130
+ | Members | `members:read`, `members:manage` | Listing members; inviting, changing and removing them, and searching users. |
131
+ | Assets | `assets:read`, `assets:write`, `assets:delete` | Files and folders, upload URLs, metadata, versions, reviews and annotations, QC reports. |
132
+ | Production | `production:read`, `production:write`, `production:delete` | Sequences, shots and tasks. |
133
+ | Workflow | `workflow:read`, `workflow:write` | The workflow and its pipelines. |
134
+ | Exports | `exports:read`, `exports:write` | The project's exports. |
135
+ | Rights | `rights:read`, `rights:write` | Assets' rights. |
136
+ | Jobs | `jobs:read`, `jobs:run` | Following jobs; starting them (transcoding, rendering, AI, publishing a deliverable). |
137
+ | Account | `projects:create` | Creating projects. A user's token only; the token reaches what it creates. |
138
+
139
+ `PROJECT_PERMISSIONS` and `ACCOUNT_PERMISSIONS` are exported. A route no permission covers
140
+ (the user's notifications, integrations and tokens, Explore, accepting an invitation) isn't
141
+ reachable with a token.
142
+
143
+ **Resolving a token**
116
144
 
117
145
  Requests send it as `Authorization: Bearer sbt_...`. Your `authProvider` resolves it with
118
- `resolveApiToken` and returns its owner, with the token's scope in `apiToken`:
146
+ `resolveApiToken`, and returns its `userId` with the token's scope in `apiToken`. A project's
147
+ token isn't a user: its `userId` is the token's own (on its project), and it goes by its name.
119
148
 
120
149
  ```ts
121
150
  import { createStreamByRouter, resolveApiToken, API_TOKEN_PREFIX } from 'streamby-core';
@@ -127,6 +156,9 @@ createStreamByRouter({
127
156
  if (bearer?.startsWith(API_TOKEN_PREFIX)) {
128
157
  const resolved = await resolveApiToken(bearer); // null once revoked or expired
129
158
  if (!resolved) throw new Error('Invalid API token');
159
+ if (resolved.scope.kind === 'project') {
160
+ return { userId: resolved.userId, username: resolved.name, role: 'viewer', apiToken: resolved.scope };
161
+ }
130
162
  const user = await findUser(resolved.userId); // your own user lookup
131
163
  return { userId: user.id, username: user.username, role: user.role, apiToken: resolved.scope };
132
164
  }
@@ -135,17 +167,9 @@ createStreamByRouter({
135
167
  });
136
168
  ```
137
169
 
138
- A request made with a token reaches only what the token's scope allows:
139
-
140
- - a `read` token can't write;
141
- - a project outside the token's projects answers 404, and the project list shows only the
142
- token's projects;
143
- - a token can't manage tokens;
144
- - outside a project, a token can only make a few reads (`/auth`, `/projects`, `/jobs/:jobId`,
145
- `/user/subscription`, `/users/search`).
146
-
147
- The owner's role in each project still applies on top of that. A token never does more than
148
- its owner can, and it stops reaching a project as soon as its owner leaves it.
170
+ A request made with a token goes through only when its route's permission is among the
171
+ token's (otherwise 403), and a project outside the token's projects answers 404; the project
172
+ list shows only the token's projects.
149
173
 
150
174
  ---
151
175
 
@@ -156,24 +180,94 @@ StreamBy serves an [MCP](https://modelcontextprotocol.io) server at `POST /strea
156
180
  projects with an API token (see above). It takes only an API token; a browser session gets 401.
157
181
 
158
182
  Each tool calls StreamBy's own REST API with the caller's token. So it reaches exactly what the
159
- token's scope and its owner's role in each project allow, and a write is saved just like one
160
- made from the UI, which shows it when it loads the project.
183
+ token allows (and, for a user's token, what its user's role in each project allows), and a
184
+ change is saved just like one made from the UI, which shows it when it loads the project.
161
185
 
162
186
  **Tools**
163
187
 
164
- - **Reading**, with any token:
165
- - `list_projects`, `get_project`;
166
- - `list_assets` (by category, optionally in one storage connection, and filtered by name),
167
- `get_asset_metadata`, `get_asset_rights`;
168
- - `get_workflow`, `list_pipelines`, `get_pipeline`, `list_exports`, `get_export`;
169
- - `get_job`;
170
- - `list_sequences`, `list_shots`, `list_tasks`.
171
- - **Writing**, listed only for a `write` token, and allowed only where its owner is an editor or
172
- an admin:
173
- - `update_asset_metadata`, `set_asset_rights`;
174
- - `create_pipeline`, `update_pipeline`;
175
- - `create_sequence`, `update_sequence`, `create_shot`, `update_shot`, `create_task`,
176
- `update_task`.
188
+ A token sees only the tools whose permissions it has.
189
+
190
+ | Tools | Permissions |
191
+ |---|---|
192
+ | `list_projects` | any token |
193
+ | `get_project` | `project:read` |
194
+ | `create_project` | `projects:create` |
195
+ | `delete_project` | `project:delete` |
196
+ | `list_assets` (by category, optionally in one storage connection, and filtered by name), `get_asset_metadata` | `assets:read` |
197
+ | `update_asset_metadata` | `assets:write` |
198
+ | `delete_asset` | `assets:delete` |
199
+ | `get_asset_rights` / `set_asset_rights` | `rights:read` / `rights:write` |
200
+ | `get_workflow`, `list_pipelines`, `get_pipeline` | `workflow:read` |
201
+ | `create_pipeline`, `update_pipeline` | `workflow:write` |
202
+ | `list_exports` | `project:read` and `exports:read` |
203
+ | `get_export` | `exports:read` |
204
+ | `get_job` | `jobs:read` |
205
+ | `list_sequences`, `list_shots`, `list_tasks` | `production:read` |
206
+ | `update_sequence`, `update_shot`, `update_task` | `production:write` |
207
+ | `create_sequence`, `create_shot`, `create_task` | `production:write` and `workflow:write` |
208
+ | `delete_sequence`, `delete_shot`, `delete_task` | `production:delete` |
209
+ | `list_asset_versions`, `get_asset_download_url`, `list_deliverables` | `assets:read` |
210
+ | `request_asset_upload`, `mark_deliverable`, `remove_deliverable` | `assets:write` |
211
+
212
+ The tools take the path a user does in the UI, and each one's description says where its
213
+ result shows there. Production data lives under a production node of the project's workflow:
214
+ if the workflow has none yet, `create_sequence`, `create_shot` and `create_task` first add a
215
+ Shot node to it, as dropping one on the canvas does (so they need `workflow:write`), and make
216
+ nothing if it can't be added.
217
+
218
+ The tools that delete are marked as destructive (`destructiveHint`), so a client can ask before
219
+ running them.
220
+
221
+ **Files**
222
+
223
+ A file never goes through the conversation. `request_asset_upload` makes a new asset (or a new
224
+ version of one) and gives a signed URL to `PUT` its bytes to; `get_asset_download_url` gives a
225
+ signed URL to `GET` an asset's file, its latest version or any earlier one. The tool that has
226
+ the file uploads or downloads it directly, for example Blender's or Godot's own MCP server,
227
+ which can run code. Each URL is for that one file, and given to an API token it works only
228
+ briefly: 15 minutes to upload, 5 to download (`mcp.signedUrlTtl`).
229
+
230
+ **Versions.** Replacing an asset doesn't change its URL: the new version is uploaded to the
231
+ asset's own key, so a site that links to the asset shows the new one with no deploy. Before
232
+ that, the current version is copied to a key of its own (`<key>.v<n>.<ext>`) and recorded, so
233
+ every earlier version can still be downloaded by its id; if it can't be copied, nothing is
234
+ replaced. A storage adapter keeps versions only if it can copy (`copyFile`; the S3 one does);
235
+ otherwise asking for a new version answers 501. Deleting the asset deletes every key of it.
236
+
237
+ Since the URL stays the same, a browser or a CDN may keep serving the earlier version for a
238
+ while. StreamBy doesn't set `Cache-Control` on uploads (a signed URL that did would require
239
+ every client to send the same header): set it where the files are served, for example a short
240
+ `max-age` or `no-cache` on the CDN in front of the bucket, or invalidate the path when an asset
241
+ is replaced.
242
+
243
+ A **deliverable** is an asset, or one of its versions, delivered for a
244
+ production shot or for an export; a shot or an export can have several, and the shot's panel
245
+ in the UI shows them.
246
+
247
+ For example, with Claude connected to StreamBy and to Blender's MCP server, asking *"export the
248
+ scene as glTF and deliver it for shot SH010 of Portfolio"* goes:
249
+
250
+ 1. `list_projects`, `list_sequences`, `list_shots`: the project and the shot's id.
251
+ 2. `request_asset_upload` with `category: '3d-models'`, `fileName: 'SH010_layout.glb'` and
252
+ `contentType: 'model/gltf-binary'`: the new asset's id, and where to upload it.
253
+ 3. In Blender, code that exports the scene and uploads it:
254
+
255
+ ```python
256
+ import bpy, urllib.request
257
+ path = bpy.app.tempdir + "SH010_layout.glb"
258
+ bpy.ops.export_scene.gltf(filepath=path, export_format="GLB")
259
+ with open(path, "rb") as f:
260
+ req = urllib.request.Request(UPLOAD_URL, data=f.read(), method="PUT",
261
+ headers={"Content-Type": "model/gltf-binary"})
262
+ urllib.request.urlopen(req)
263
+ ```
264
+
265
+ 4. `mark_deliverable` with the asset's id and `shotId`: it shows in the shot's panel.
266
+
267
+ Bringing an asset into Blender or Godot is the other way around: `get_asset_download_url`, and
268
+ code there that downloads the URL and imports the file (in Blender,
269
+ `bpy.ops.import_scene.gltf(filepath=...)`). A new version of the same asset is
270
+ `request_asset_upload` with its `assetId`.
177
271
 
178
272
  **Limits and audit**
179
273
 
@@ -181,14 +275,16 @@ made from the UI, which shows it when it loads the project.
181
275
  answers 429 with `Retry-After`. Each instance counts its own requests. Change the limit with
182
276
  `mcp.rateLimit`.
183
277
  - Every tool call is recorded in the `mcp_audit` table, created at startup in the main database.
184
- Each row has the user, the token's id, the project, the tool, whether it went through, the
185
- status, the error and the duration. A row never includes the arguments or the token.
278
+ Each row has the user (a project's token: the token itself), the token's id, the project, the
279
+ tool, whether it went through, the status, the error and the duration. A row never includes
280
+ the arguments or the token.
186
281
 
187
282
  ```ts
188
283
  createStreamByRouter({
189
284
  // ...
190
285
  mcp: {
191
286
  rateLimit: { requests: 120, windowMs: 60_000 }, // the default
287
+ signedUrlTtl: { upload: 900, download: 300 }, // the default, in seconds
192
288
  // apiBaseUrl: 'http://127.0.0.1:3000/streamby', // where tools reach the REST API;
193
289
  // by default this same process, at the request's local port and mount path
194
290
  },
@@ -197,8 +293,9 @@ createStreamByRouter({
197
293
 
198
294
  ### Connecting from Claude Code
199
295
 
200
- Make an API token in StreamBy (Settings → Security → Personal API tokens), and keep it in an
201
- environment variable, never in a file:
296
+ Make an API token in StreamBy, with only the permissions the agent needs: your own
297
+ (Settings → Security → Personal API tokens), or a project's (in the project, Settings → API
298
+ Tokens). Keep it in an environment variable, never in a file:
202
299
 
203
300
  ```bash
204
301
  export STREAMBY_TOKEN=sbt_...
@@ -223,8 +320,8 @@ environment:
223
320
 
224
321
  Claude Desktop runs local (stdio) servers from `claude_desktop_config.json`. Reach StreamBy
225
322
  through [`mcp-remote`](https://www.npmjs.com/package/mcp-remote), passing the token in its
226
- `env`. This file holds it in plain text, so use a token with only the scope you need, and
227
- an expiry:
323
+ `env`. This file holds it in plain text, so use a token with only the permissions you need,
324
+ and an expiry:
228
325
 
229
326
  ```json
230
327
  {
@@ -149,21 +149,34 @@ const ensureTablesExist = async (pool) => {
149
149
  `);
150
150
  console.log('✅ "user_integrations" table ensured to exist.');
151
151
  // API tokens: only their hash is kept (see services/apiTokens). Main db only, like
152
- // user_integrations above.
152
+ // user_integrations above. A table from before tokens had a kind and permissions gets the
153
+ // new columns; its old tokens no longer work (they have no kind) and aren't listed.
153
154
  await pool.query(`
154
155
  CREATE TABLE IF NOT EXISTS streamby.api_tokens (
155
- id TEXT PRIMARY KEY DEFAULT uuid_generate_v4()::text,
156
- "userId" VARCHAR(255) NOT NULL,
157
- name VARCHAR(255) NOT NULL,
158
- prefix VARCHAR(32) NOT NULL,
159
- hash CHAR(64) NOT NULL UNIQUE,
160
- access VARCHAR(10) NOT NULL,
161
- "projectIds" JSONB NOT NULL,
162
- "createdAt" TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
163
- "expiresAt" TIMESTAMP WITH TIME ZONE,
164
- "lastUsedAt" TIMESTAMP WITH TIME ZONE
156
+ id TEXT PRIMARY KEY DEFAULT uuid_generate_v4()::text,
157
+ kind VARCHAR(10),
158
+ "userId" VARCHAR(255) NOT NULL,
159
+ "createdByName" VARCHAR(255),
160
+ name VARCHAR(255) NOT NULL,
161
+ prefix VARCHAR(32) NOT NULL,
162
+ hash CHAR(64) NOT NULL UNIQUE,
163
+ permissions JSONB NOT NULL DEFAULT '[]',
164
+ "projectIds" JSONB NOT NULL,
165
+ "createdAt" TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
166
+ "expiresAt" TIMESTAMP WITH TIME ZONE,
167
+ "lastUsedAt" TIMESTAMP WITH TIME ZONE
165
168
  );
169
+ ALTER TABLE streamby.api_tokens ADD COLUMN IF NOT EXISTS kind VARCHAR(10);
170
+ ALTER TABLE streamby.api_tokens ADD COLUMN IF NOT EXISTS "createdByName" VARCHAR(255);
171
+ ALTER TABLE streamby.api_tokens ADD COLUMN IF NOT EXISTS permissions JSONB NOT NULL DEFAULT '[]';
172
+ DO $$ BEGIN
173
+ IF EXISTS (SELECT 1 FROM information_schema.columns
174
+ WHERE table_schema = 'streamby' AND table_name = 'api_tokens' AND column_name = 'access') THEN
175
+ ALTER TABLE streamby.api_tokens ALTER COLUMN access DROP NOT NULL;
176
+ END IF;
177
+ END $$;
166
178
  CREATE INDEX IF NOT EXISTS api_tokens_userid_idx ON streamby.api_tokens ("userId");
179
+ CREATE INDEX IF NOT EXISTS api_tokens_kind_idx ON streamby.api_tokens (kind);
167
180
  `);
168
181
  console.log('✅ "api_tokens" table ensured to exist.');
169
182
  // The MCP server's audit log: every tool call (see mcp/audit). Main db only.
@@ -8,8 +8,9 @@ export declare class S3Adapter implements StorageAdapter {
8
8
  deleteProjectDirectory(projectId: string): Promise<any>;
9
9
  getPresignedUrl(contentType: string, projectId: string): Promise<any>;
10
10
  getPresignedProjectImageUrl(projectId: string): Promise<any>;
11
- getPresignedGetUrl(key: string): Promise<string>;
12
- getPresignedUploadUrl(key: string, contentType: string): Promise<string>;
11
+ getPresignedGetUrl(key: string, expiresIn?: number, downloadName?: string): Promise<string>;
12
+ getPresignedUploadUrl(key: string, contentType: string, expiresIn?: number): Promise<string>;
13
+ copyFile(from: string, to: string): Promise<void>;
13
14
  deleteFile(key: string): Promise<void>;
14
15
  listFilesByCategory(projectId: string, category: string): Promise<StorageFileInfo[]>;
15
16
  private inferMimeType;
@@ -64,13 +64,21 @@ class S3Adapter {
64
64
  const publicUrl = `https://${this.bucket}.s3.amazonaws.com/${key}`;
65
65
  return { url, publicUrl };
66
66
  }
67
- async getPresignedGetUrl(key) {
68
- const command = new client_s3_1.GetObjectCommand({ Bucket: this.bucket, Key: key });
69
- return await (0, s3_request_presigner_1.getSignedUrl)(this.s3, command, { expiresIn: 86400 });
67
+ async getPresignedGetUrl(key, expiresIn = 86400, downloadName) {
68
+ const command = new client_s3_1.GetObjectCommand({
69
+ Bucket: this.bucket,
70
+ Key: key,
71
+ ...(downloadName ? { ResponseContentDisposition: contentDisposition(downloadName) } : {}),
72
+ });
73
+ return await (0, s3_request_presigner_1.getSignedUrl)(this.s3, command, { expiresIn });
70
74
  }
71
- async getPresignedUploadUrl(key, contentType) {
75
+ async getPresignedUploadUrl(key, contentType, expiresIn = 3600) {
72
76
  const command = new client_s3_1.PutObjectCommand({ Bucket: this.bucket, Key: key, ContentType: contentType });
73
- return await (0, s3_request_presigner_1.getSignedUrl)(this.s3, command, { expiresIn: 3600 });
77
+ return await (0, s3_request_presigner_1.getSignedUrl)(this.s3, command, { expiresIn });
78
+ }
79
+ async copyFile(from, to) {
80
+ const source = `${this.bucket}/${from.split('/').map(encodeURIComponent).join('/')}`;
81
+ await this.s3.send(new client_s3_1.CopyObjectCommand({ Bucket: this.bucket, CopySource: source, Key: to }));
74
82
  }
75
83
  async deleteFile(key) {
76
84
  const command = new client_s3_1.DeleteObjectCommand({ Bucket: this.bucket, Key: key });
@@ -114,3 +122,8 @@ class S3Adapter {
114
122
  }
115
123
  }
116
124
  exports.S3Adapter = S3Adapter;
125
+ // An attachment with its name: ASCII in `filename`, the full name in `filename*` (RFC 6266).
126
+ const contentDisposition = (name) => {
127
+ const ascii = name.replace(/[^\x20-\x7e]|["\\]/g, '_');
128
+ return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(name)}`;
129
+ };
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { createStreamByRouter } from './middleware/createRouter';
2
2
  export { emitToUser } from './services/wsHub';
3
3
  export { createNotification } from './services/notification';
4
- export type { UserPlan, UserSubscription, Auth, ApiTokenScope, ApiTokenAccess } from './types';
4
+ export type { UserPlan, UserSubscription, Auth, ApiTokenScope, ApiTokenKind, ApiTokenPermission, ProjectPermission, AccountPermission } from './types';
5
5
  export { resolveApiToken, API_TOKEN_PREFIX } from './services/apiTokens';
6
+ export { PROJECT_PERMISSIONS, ACCOUNT_PERMISSIONS } from './services/apiTokenPermissions';
6
7
  export type { ResolvedApiToken } from './services/apiTokens';
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.API_TOKEN_PREFIX = exports.resolveApiToken = exports.createNotification = exports.emitToUser = exports.createStreamByRouter = void 0;
3
+ exports.ACCOUNT_PERMISSIONS = exports.PROJECT_PERMISSIONS = exports.API_TOKEN_PREFIX = exports.resolveApiToken = exports.createNotification = exports.emitToUser = exports.createStreamByRouter = void 0;
4
4
  var createRouter_1 = require("./middleware/createRouter");
5
5
  Object.defineProperty(exports, "createStreamByRouter", { enumerable: true, get: function () { return createRouter_1.createStreamByRouter; } });
6
6
  var wsHub_1 = require("./services/wsHub");
@@ -10,3 +10,6 @@ Object.defineProperty(exports, "createNotification", { enumerable: true, get: fu
10
10
  var apiTokens_1 = require("./services/apiTokens");
11
11
  Object.defineProperty(exports, "resolveApiToken", { enumerable: true, get: function () { return apiTokens_1.resolveApiToken; } });
12
12
  Object.defineProperty(exports, "API_TOKEN_PREFIX", { enumerable: true, get: function () { return apiTokens_1.API_TOKEN_PREFIX; } });
13
+ var apiTokenPermissions_1 = require("./services/apiTokenPermissions");
14
+ Object.defineProperty(exports, "PROJECT_PERMISSIONS", { enumerable: true, get: function () { return apiTokenPermissions_1.PROJECT_PERMISSIONS; } });
15
+ Object.defineProperty(exports, "ACCOUNT_PERMISSIONS", { enumerable: true, get: function () { return apiTokenPermissions_1.ACCOUNT_PERMISSIONS; } });
@@ -6,9 +6,10 @@ export type ApiCaller = (call: ToolCall) => Promise<{
6
6
  status: number;
7
7
  body: unknown;
8
8
  }>;
9
+ /** The tools an API token may use: those whose every permission it has. */
10
+ export declare const toolsFor: (scope: Auth["apiToken"]) => import("./tools").ToolDefinition<any>[];
9
11
  /**
10
- * An MCP server for one request of `auth` (an API token's owner): its tools, the writing ones
11
- * only for a token with write access. Each tool call goes to the REST API through `api`, and is
12
- * recorded in the audit log.
12
+ * An MCP server for one request made with an API token (`auth`): only the tools its permissions
13
+ * allow. Each tool call goes to the REST API through `api`, and is recorded in the audit log.
13
14
  */
14
15
  export declare function createMcpServer(auth: Auth, api: ApiCaller, config: StreamByConfig, version: string): McpServer;
@@ -1,36 +1,51 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.MCP_SERVER_NAME = void 0;
3
+ exports.toolsFor = exports.MCP_SERVER_NAME = void 0;
4
4
  exports.createMcpServer = createMcpServer;
5
5
  const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
6
6
  const tools_1 = require("./tools");
7
7
  const audit_1 = require("./audit");
8
+ const workflowNodes_1 = require("./workflowNodes");
8
9
  exports.MCP_SERVER_NAME = 'streamby';
9
10
  const errorText = (body, status) => {
10
11
  const message = body?.message ?? body?.error;
11
12
  return typeof message === 'string' ? message : `StreamBy answered ${status}`;
12
13
  };
14
+ /** The tools an API token may use: those whose every permission it has. */
15
+ const toolsFor = (scope) => tools_1.TOOLS.filter(tool => tool.permissions.every(p => !!scope?.permissions.includes(p)));
16
+ exports.toolsFor = toolsFor;
13
17
  /**
14
- * An MCP server for one request of `auth` (an API token's owner): its tools, the writing ones
15
- * only for a token with write access. Each tool call goes to the REST API through `api`, and is
16
- * recorded in the audit log.
18
+ * An MCP server for one request made with an API token (`auth`): only the tools its permissions
19
+ * allow. Each tool call goes to the REST API through `api`, and is recorded in the audit log.
17
20
  */
18
21
  function createMcpServer(auth, api, config, version) {
19
22
  const server = new mcp_js_1.McpServer({ name: exports.MCP_SERVER_NAME, version });
20
- const canWrite = auth.apiToken?.access === 'write';
21
- for (const tool of tools_1.TOOLS) {
22
- if (tool.write && !canWrite)
23
- continue;
23
+ // A project's token acts on its project: what it does there is the project's.
24
+ const tokenProject = auth.apiToken?.kind === 'project' && Array.isArray(auth.apiToken.projectIds)
25
+ ? auth.apiToken.projectIds[0] ?? null
26
+ : null;
27
+ for (const tool of (0, exports.toolsFor)(auth.apiToken)) {
24
28
  server.registerTool(tool.name, {
25
29
  title: tool.title,
26
30
  description: tool.description,
27
31
  inputSchema: tool.input,
28
- annotations: { readOnlyHint: !tool.write, destructiveHint: false, openWorldHint: false },
32
+ annotations: { readOnlyHint: !tool.write, destructiveHint: !!tool.destructive, openWorldHint: false },
29
33
  }, async (args) => {
30
34
  const started = Date.now();
31
- const projectId = tool.projectOf?.(args) ?? null;
35
+ const projectId = tool.projectOf?.(args) ?? tokenProject;
32
36
  let status = null;
33
37
  try {
38
+ // What the workflow needs first, as a user would add it.
39
+ if (tool.workflowNode) {
40
+ const refused = await (0, workflowNodes_1.ensureWorkflowNode)(tool.workflowNode, tool.projectOf?.(args), auth, api);
41
+ if (refused) {
42
+ await (0, audit_1.recordMcpCall)({
43
+ userId: auth.userId, tokenId: auth.apiToken.tokenId, projectId, tool: tool.name,
44
+ ok: false, status: null, error: refused, durationMs: Date.now() - started,
45
+ }, config);
46
+ return { isError: true, content: [{ type: 'text', text: refused }] };
47
+ }
48
+ }
34
49
  const answer = await api(tool.call(args));
35
50
  status = answer.status;
36
51
  const ok = status >= 200 && status < 300;
@@ -1,6 +1,8 @@
1
1
  import { z } from 'zod';
2
+ import { ApiTokenPermission } from '../services/apiTokenPermissions';
3
+ import type { WorkflowNodeRequirement } from './workflowNodes';
2
4
  export interface ToolCall {
3
- method: 'GET' | 'POST' | 'PATCH' | 'PUT';
5
+ method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
4
6
  path: string;
5
7
  body?: unknown;
6
8
  }
@@ -8,7 +10,10 @@ export interface ToolDefinition<S extends z.ZodRawShape = z.ZodRawShape> {
8
10
  name: string;
9
11
  title: string;
10
12
  description: string;
11
- write: boolean;
13
+ permissions: ApiTokenPermission[];
14
+ write?: boolean;
15
+ destructive?: boolean;
16
+ workflowNode?: WorkflowNodeRequirement;
12
17
  input: S;
13
18
  projectOf?: (args: z.infer<z.ZodObject<S>>) => string | undefined;
14
19
  call: (args: z.infer<z.ZodObject<S>>) => ToolCall;