streamby-core 0.34.0 → 0.36.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.
- package/CHANGELOG.md +63 -0
- package/README.md +152 -10
- package/dist/adapters/database/connectionManager.js +41 -5
- package/dist/index.d.ts +3 -1
- package/dist/index.js +4 -1
- package/dist/mcp/audit.d.ts +12 -0
- package/dist/mcp/audit.js +17 -0
- package/dist/mcp/rateLimit.d.ts +14 -0
- package/dist/mcp/rateLimit.js +21 -0
- package/dist/mcp/server.d.ts +14 -0
- package/dist/mcp/server.js +57 -0
- package/dist/mcp/tools.d.ts +17 -0
- package/dist/mcp/tools.js +227 -0
- package/dist/middleware/createRouter.js +7 -0
- package/dist/middleware/routes/apiToken.d.ts +3 -0
- package/dist/middleware/routes/apiToken.js +58 -0
- package/dist/middleware/routes/mcp.d.ts +3 -0
- package/dist/middleware/routes/mcp.js +62 -0
- package/dist/middleware/routes/media.js +4 -0
- package/dist/middleware/routes/project.js +4 -0
- package/dist/services/apiTokens.d.ts +47 -0
- package/dist/services/apiTokens.js +110 -0
- package/dist/services/auth.d.ts +4 -1
- package/dist/services/auth.js +44 -1
- package/dist/services/wsHub.js +6 -0
- package/dist/types/index.d.ts +14 -0
- package/package.json +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,68 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.36.0
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **MCP server** at `POST /mcp`, for agents such as Claude Code to work with StreamBy projects
|
|
8
|
+
with an API token (see the README).
|
|
9
|
+
- It takes only an API token.
|
|
10
|
+
- Its tools call the REST API with that token, so they reach exactly what the token and its
|
|
11
|
+
owner can, and writes are saved as the UI's are.
|
|
12
|
+
- **Reading tools:** projects, assets with their metadata and rights, workflow, pipelines,
|
|
13
|
+
exports, jobs, and production sequences, shots and tasks.
|
|
14
|
+
- **Writing tools**, listed only for `write` tokens: asset metadata and rights, pipelines,
|
|
15
|
+
sequences, shots and tasks.
|
|
16
|
+
- **`mcp.rateLimit`** (default 120 requests a minute per token; past it, 429 with
|
|
17
|
+
`Retry-After`) and **`mcp.apiBaseUrl`** in the router config.
|
|
18
|
+
- **New table: `mcp_audit`.** It's created at startup in the main database. Every tool call is
|
|
19
|
+
recorded there, with the user, token, project, tool and result, but never the arguments nor
|
|
20
|
+
the token.
|
|
21
|
+
- New dependencies: `@modelcontextprotocol/sdk` and `zod`.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- **Projects can be made with PostgreSQL as the main database.** The `projects` table that
|
|
26
|
+
StreamBy creates at startup was missing columns the routes write: `public`, `category`,
|
|
27
|
+
`integrationConnections`, `distributionConnections` and `renderFarmConnections`.
|
|
28
|
+
- Making a project failed with `column "public" does not exist`.
|
|
29
|
+
- Adding service, distribution or render farm connections failed too.
|
|
30
|
+
- They're added at startup now, only where they're missing; databases that already have
|
|
31
|
+
them aren't changed.
|
|
32
|
+
|
|
33
|
+
## 0.35.0
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
|
|
37
|
+
- **API tokens**, so agents and services can work with StreamBy as a user without the user's
|
|
38
|
+
browser session. The README has the details.
|
|
39
|
+
- `GET`, `POST` and `DELETE /user/api-tokens` let a user list, make and revoke their own
|
|
40
|
+
tokens. These routes need a session: a token can't manage tokens.
|
|
41
|
+
- A token is `sbt_` followed by 32 random bytes. It's shown once, when it's made.
|
|
42
|
+
- **Scope:** each token is `read` (only reads) or `write` (reads and writes). It reaches a
|
|
43
|
+
list of projects, or `'*'` for every project its owner is a member of, including ones they
|
|
44
|
+
join later. It expires in 7, 30, 90 or 365 days, or never.
|
|
45
|
+
- A request made with a token reaches only its scope:
|
|
46
|
+
- a `read` token can't write;
|
|
47
|
+
- a project outside its scope answers 404, and the project list and jobs show only its
|
|
48
|
+
projects;
|
|
49
|
+
- outside a project, it can only make a few reads.
|
|
50
|
+
|
|
51
|
+
The owner's role in each project still applies on top, so a token never does more than
|
|
52
|
+
its owner can, and it stops reaching a project once its owner leaves it.
|
|
53
|
+
- **`resolveApiToken`** (exported, with `API_TOKEN_PREFIX`) is for the host's
|
|
54
|
+
`authProvider`. It turns `Authorization: Bearer sbt_...` into the token's owner and scope,
|
|
55
|
+
or `null` once the token is revoked or expired. The `authProvider` returns the scope as
|
|
56
|
+
`Auth.apiToken`. Without it, a token would have all of its owner's access.
|
|
57
|
+
- **New table: `api_tokens`.** It's created at startup in the main database, and it keeps
|
|
58
|
+
only each token's SHA-256 hash, never the token itself.
|
|
59
|
+
|
|
60
|
+
### Security
|
|
61
|
+
|
|
62
|
+
- A connection authenticated with an API token can't open the WebSocket. It's closed with
|
|
63
|
+
code 1008. The WebSocket belongs to the browser session and receives every event of its
|
|
64
|
+
user, from any project.
|
|
65
|
+
|
|
3
66
|
## 0.34.0
|
|
4
67
|
|
|
5
68
|
### ⚠️ Migration and deploy
|
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#
|
|
1
|
+
# streamby-core
|
|
2
2
|
|
|
3
3
|
[](https://www.gnu.org/licenses/agpl-3.0)
|
|
4
4
|
|
|
@@ -9,7 +9,7 @@ Part of the [StreamBy](https://streamby.nhexa.cl) ecosystem by TerminalCore Labs
|
|
|
9
9
|
|
|
10
10
|
## 🚀 What is it?
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
`streamby-core` is a plug-and-play middleware for Express (or compatible frameworks) that enables file uploads, listings and **multi-project access** over services like:
|
|
13
13
|
|
|
14
14
|
- ✅ AWS S3
|
|
15
15
|
- ✅ Google Cloud Storage (soon)
|
|
@@ -23,7 +23,7 @@ It's designed to be installed inside your existing API as a library — **no nee
|
|
|
23
23
|
## 📦 Installation
|
|
24
24
|
|
|
25
25
|
```bash
|
|
26
|
-
npm install
|
|
26
|
+
npm install streamby-core ws
|
|
27
27
|
npm install -D @types/ws
|
|
28
28
|
```
|
|
29
29
|
|
|
@@ -35,7 +35,7 @@ npm install -D @types/ws
|
|
|
35
35
|
import http from 'http';
|
|
36
36
|
import express from 'express';
|
|
37
37
|
import { WebSocketServer } from 'ws';
|
|
38
|
-
import { createStreamByRouter } from '
|
|
38
|
+
import { createStreamByRouter } from 'streamby-core';
|
|
39
39
|
|
|
40
40
|
const app = express();
|
|
41
41
|
const server = http.createServer(app);
|
|
@@ -100,6 +100,148 @@ createStreamByRouter({
|
|
|
100
100
|
|
|
101
101
|
---
|
|
102
102
|
|
|
103
|
+
## 🎫 API tokens
|
|
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.
|
|
116
|
+
|
|
117
|
+
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`:
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
import { createStreamByRouter, resolveApiToken, API_TOKEN_PREFIX } from 'streamby-core';
|
|
122
|
+
|
|
123
|
+
createStreamByRouter({
|
|
124
|
+
// ...
|
|
125
|
+
authProvider: async (req) => {
|
|
126
|
+
const bearer = req.headers.authorization?.replace(/^Bearer /, '');
|
|
127
|
+
if (bearer?.startsWith(API_TOKEN_PREFIX)) {
|
|
128
|
+
const resolved = await resolveApiToken(bearer); // null once revoked or expired
|
|
129
|
+
if (!resolved) throw new Error('Invalid API token');
|
|
130
|
+
const user = await findUser(resolved.userId); // your own user lookup
|
|
131
|
+
return { userId: user.id, username: user.username, role: user.role, apiToken: resolved.scope };
|
|
132
|
+
}
|
|
133
|
+
// ...your session-based authentication
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
```
|
|
137
|
+
|
|
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.
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## 🤖 MCP server
|
|
153
|
+
|
|
154
|
+
StreamBy serves an [MCP](https://modelcontextprotocol.io) server at `POST /streamby/mcp`
|
|
155
|
+
(Streamable HTTP, stateless). Agents such as Claude Code use it to read and work on StreamBy
|
|
156
|
+
projects with an API token (see above). It takes only an API token; a browser session gets 401.
|
|
157
|
+
|
|
158
|
+
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.
|
|
161
|
+
|
|
162
|
+
**Tools**
|
|
163
|
+
|
|
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`.
|
|
177
|
+
|
|
178
|
+
**Limits and audit**
|
|
179
|
+
|
|
180
|
+
- Each token can make at most 120 requests a minute (a sliding window). Past that, StreamBy
|
|
181
|
+
answers 429 with `Retry-After`. Each instance counts its own requests. Change the limit with
|
|
182
|
+
`mcp.rateLimit`.
|
|
183
|
+
- 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.
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
createStreamByRouter({
|
|
189
|
+
// ...
|
|
190
|
+
mcp: {
|
|
191
|
+
rateLimit: { requests: 120, windowMs: 60_000 }, // the default
|
|
192
|
+
// apiBaseUrl: 'http://127.0.0.1:3000/streamby', // where tools reach the REST API;
|
|
193
|
+
// by default this same process, at the request's local port and mount path
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### Connecting from Claude Code
|
|
199
|
+
|
|
200
|
+
Make an API token in StreamBy (Settings → Security → Personal API tokens), and keep it in an
|
|
201
|
+
environment variable, never in a file:
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
export STREAMBY_TOKEN=sbt_...
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Add the server to your project's `.mcp.json`. Claude Code expands `${STREAMBY_TOKEN}` from the
|
|
208
|
+
environment:
|
|
209
|
+
|
|
210
|
+
```json
|
|
211
|
+
{
|
|
212
|
+
"mcpServers": {
|
|
213
|
+
"streamby": {
|
|
214
|
+
"type": "http",
|
|
215
|
+
"url": "https://your-host/streamby/mcp",
|
|
216
|
+
"headers": { "Authorization": "Bearer ${STREAMBY_TOKEN}" }
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
### Connecting from Claude Desktop
|
|
223
|
+
|
|
224
|
+
Claude Desktop runs local (stdio) servers from `claude_desktop_config.json`. Reach StreamBy
|
|
225
|
+
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:
|
|
228
|
+
|
|
229
|
+
```json
|
|
230
|
+
{
|
|
231
|
+
"mcpServers": {
|
|
232
|
+
"streamby": {
|
|
233
|
+
"command": "npx",
|
|
234
|
+
"args": ["mcp-remote", "https://your-host/streamby/mcp", "--header", "Authorization:${STREAMBY_AUTH}"],
|
|
235
|
+
"env": { "STREAMBY_AUTH": "Bearer sbt_..." }
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
The claude.ai connector needs OAuth, which isn't available yet: it's for API tokens only for now.
|
|
242
|
+
|
|
243
|
+
---
|
|
244
|
+
|
|
103
245
|
## 👥 Project roles
|
|
104
246
|
|
|
105
247
|
What a user can do in a project depends on their role in that project, as an active member
|
|
@@ -120,7 +262,7 @@ nor one that starts a job.
|
|
|
120
262
|
|
|
121
263
|
## 🗄️ Database Schema Setup
|
|
122
264
|
|
|
123
|
-
|
|
265
|
+
`streamby-core` requires a pre-configured database schema for its internal operations. It does not manage database connections directly; instead, it expects the host application to provide initialized connection instances. This ensures full control and flexibility for the host environment.
|
|
124
266
|
|
|
125
267
|
The library will only interact with the specified schema and will not touch other schemas in your database. The `reset` option is opt-in and will only drop and recreate the specified `streamby` schema if explicitly set to `true`.
|
|
126
268
|
|
|
@@ -128,7 +270,7 @@ The library will only interact with the specified schema and will not touch othe
|
|
|
128
270
|
|
|
129
271
|
```ts
|
|
130
272
|
import { Pool } from "pg";
|
|
131
|
-
import { setupStreambyPg } from "
|
|
273
|
+
import { setupStreambyPg } from "streamby-core/pg/setup"; // Adjust path as per your package structure
|
|
132
274
|
|
|
133
275
|
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
134
276
|
|
|
@@ -141,7 +283,7 @@ await setupStreambyPg({ pool, schema: "streamby", reset: false });
|
|
|
141
283
|
|
|
142
284
|
```ts
|
|
143
285
|
import { MongoClient } from "mongodb";
|
|
144
|
-
import { setupStreambyMongo } from "
|
|
286
|
+
import { setupStreambyMongo } from "streamby-core/mongo/setup"; // Adjust path as per your package structure
|
|
145
287
|
|
|
146
288
|
const client = new MongoClient(process.env.MONGO_URL!);
|
|
147
289
|
|
|
@@ -154,7 +296,7 @@ await setupStreambyMongo({ client, dbName: "streamby" });
|
|
|
154
296
|
|
|
155
297
|
```ts
|
|
156
298
|
import express from 'express';
|
|
157
|
-
import { createStreamByRouter } from '
|
|
299
|
+
import { createStreamByRouter } from 'streamby-core';
|
|
158
300
|
|
|
159
301
|
const app = express();
|
|
160
302
|
|
|
@@ -215,7 +357,7 @@ Unit tests use [Vitest](https://vitest.dev) and [Supertest](https://www.npmjs.co
|
|
|
215
357
|
|
|
216
358
|
## 🗺️ API Endpoints
|
|
217
359
|
|
|
218
|
-
Here's a summary of the key API endpoints provided by
|
|
360
|
+
Here's a summary of the key API endpoints provided by `streamby-core`:
|
|
219
361
|
|
|
220
362
|
### `GET /streamby/projects`
|
|
221
363
|
|
|
@@ -330,5 +472,5 @@ Copyright (C) 2026 TerminalCore Labs
|
|
|
330
472
|
|
|
331
473
|
Licensed under the [GNU Affero General Public License v3.0](./LICENSE) (AGPL-3.0-only).
|
|
332
474
|
|
|
333
|
-
> **Note for consumers:** unlike most installable packages, this one is intentionally AGPL rather than MIT. AGPL's copyleft applies to any project that depends on it -- if you install
|
|
475
|
+
> **Note for consumers:** unlike most installable packages, this one is intentionally AGPL rather than MIT. AGPL's copyleft applies to any project that depends on it -- if you install `streamby-core`, your own project must also be licensed under AGPL-3.0 (or a compatible copyleft license). Confirm this is acceptable for your use case before adding this dependency.
|
|
334
476
|
|
|
@@ -80,11 +80,12 @@ const ensureTablesExist = async (pool) => {
|
|
|
80
80
|
ALTER TABLE streamby.projects ADD COLUMN IF NOT EXISTS workflow JSONB;
|
|
81
81
|
ALTER TABLE streamby.projects ADD COLUMN IF NOT EXISTS pipelines JSONB DEFAULT '[]';
|
|
82
82
|
ALTER TABLE streamby.projects ADD COLUMN IF NOT EXISTS "builtinBackfilledAt" TIMESTAMP WITH TIME ZONE;
|
|
83
|
-
--
|
|
84
|
-
|
|
85
|
-
ALTER TABLE streamby.projects
|
|
86
|
-
ALTER TABLE streamby.projects
|
|
87
|
-
ALTER TABLE streamby.projects
|
|
83
|
+
-- Written when a project is made (routes/project) and by its connection routes.
|
|
84
|
+
ALTER TABLE streamby.projects ADD COLUMN IF NOT EXISTS public BOOLEAN DEFAULT TRUE;
|
|
85
|
+
ALTER TABLE streamby.projects ADD COLUMN IF NOT EXISTS category TEXT;
|
|
86
|
+
ALTER TABLE streamby.projects ADD COLUMN IF NOT EXISTS "integrationConnections" JSONB DEFAULT '[]';
|
|
87
|
+
ALTER TABLE streamby.projects ADD COLUMN IF NOT EXISTS "distributionConnections" JSONB DEFAULT '[]';
|
|
88
|
+
ALTER TABLE streamby.projects ADD COLUMN IF NOT EXISTS "renderFarmConnections" JSONB DEFAULT '[]';
|
|
88
89
|
`);
|
|
89
90
|
// Ensure dbType exists with a non-null default
|
|
90
91
|
await pool.query(`
|
|
@@ -147,6 +148,41 @@ const ensureTablesExist = async (pool) => {
|
|
|
147
148
|
CREATE INDEX IF NOT EXISTS user_integrations_userid_idx ON streamby.user_integrations ("userId");
|
|
148
149
|
`);
|
|
149
150
|
console.log('✅ "user_integrations" table ensured to exist.');
|
|
151
|
+
// API tokens: only their hash is kept (see services/apiTokens). Main db only, like
|
|
152
|
+
// user_integrations above.
|
|
153
|
+
await pool.query(`
|
|
154
|
+
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
|
|
165
|
+
);
|
|
166
|
+
CREATE INDEX IF NOT EXISTS api_tokens_userid_idx ON streamby.api_tokens ("userId");
|
|
167
|
+
`);
|
|
168
|
+
console.log('✅ "api_tokens" table ensured to exist.');
|
|
169
|
+
// The MCP server's audit log: every tool call (see mcp/audit). Main db only.
|
|
170
|
+
await pool.query(`
|
|
171
|
+
CREATE TABLE IF NOT EXISTS streamby.mcp_audit (
|
|
172
|
+
id TEXT PRIMARY KEY DEFAULT uuid_generate_v4()::text,
|
|
173
|
+
"userId" VARCHAR(255) NOT NULL,
|
|
174
|
+
"tokenId" VARCHAR(255) NOT NULL,
|
|
175
|
+
"projectId" VARCHAR(255),
|
|
176
|
+
tool VARCHAR(100) NOT NULL,
|
|
177
|
+
ok BOOLEAN NOT NULL,
|
|
178
|
+
status INTEGER,
|
|
179
|
+
error TEXT,
|
|
180
|
+
"durationMs" INTEGER NOT NULL,
|
|
181
|
+
"createdAt" TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
182
|
+
);
|
|
183
|
+
CREATE INDEX IF NOT EXISTS mcp_audit_token_idx ON streamby.mcp_audit ("tokenId", "createdAt");
|
|
184
|
+
`);
|
|
185
|
+
console.log('✅ "mcp_audit" table ensured to exist.');
|
|
150
186
|
}
|
|
151
187
|
catch (error) {
|
|
152
188
|
console.error('❌ Error ensuring tables exist:', error);
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
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 } from './types';
|
|
4
|
+
export type { UserPlan, UserSubscription, Auth, ApiTokenScope, ApiTokenAccess } from './types';
|
|
5
|
+
export { resolveApiToken, API_TOKEN_PREFIX } from './services/apiTokens';
|
|
6
|
+
export type { ResolvedApiToken } from './services/apiTokens';
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.createNotification = exports.emitToUser = exports.createStreamByRouter = void 0;
|
|
3
|
+
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");
|
|
7
7
|
Object.defineProperty(exports, "emitToUser", { enumerable: true, get: function () { return wsHub_1.emitToUser; } });
|
|
8
8
|
var notification_1 = require("./services/notification");
|
|
9
9
|
Object.defineProperty(exports, "createNotification", { enumerable: true, get: function () { return notification_1.createNotification; } });
|
|
10
|
+
var apiTokens_1 = require("./services/apiTokens");
|
|
11
|
+
Object.defineProperty(exports, "resolveApiToken", { enumerable: true, get: function () { return apiTokens_1.resolveApiToken; } });
|
|
12
|
+
Object.defineProperty(exports, "API_TOKEN_PREFIX", { enumerable: true, get: function () { return apiTokens_1.API_TOKEN_PREFIX; } });
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { StreamByConfig } from '../types';
|
|
2
|
+
export interface McpAuditEntry {
|
|
3
|
+
userId: string;
|
|
4
|
+
tokenId: string;
|
|
5
|
+
projectId: string | null;
|
|
6
|
+
tool: string;
|
|
7
|
+
ok: boolean;
|
|
8
|
+
status: number | null;
|
|
9
|
+
error: string | null;
|
|
10
|
+
durationMs: number;
|
|
11
|
+
}
|
|
12
|
+
export declare function recordMcpCall(entry: McpAuditEntry, config: StreamByConfig): Promise<void>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.recordMcpCall = recordMcpCall;
|
|
7
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
8
|
+
const manager_1 = require("../models/manager");
|
|
9
|
+
async function recordMcpCall(entry, config) {
|
|
10
|
+
try {
|
|
11
|
+
const dbType = config.databases?.find(db => db.main)?.type ?? 'nosql';
|
|
12
|
+
await (0, manager_1.getModel)('mcp_audit').create({ id: crypto_1.default.randomUUID(), ...entry, createdAt: new Date(), dbType });
|
|
13
|
+
}
|
|
14
|
+
catch (err) {
|
|
15
|
+
console.error('❌ StreamBy MCP: failed to record a tool call:', err instanceof Error ? err.message : err);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface RateLimit {
|
|
2
|
+
requests: number;
|
|
3
|
+
windowMs: number;
|
|
4
|
+
}
|
|
5
|
+
export declare const DEFAULT_MCP_RATE_LIMIT: RateLimit;
|
|
6
|
+
export declare function createRateLimiter({ requests, windowMs }: RateLimit): {
|
|
7
|
+
/** Counts a request by `key`: whether it's allowed, and if not, how long until it would be. */
|
|
8
|
+
take(key: string, now?: number): {
|
|
9
|
+
allowed: true;
|
|
10
|
+
} | {
|
|
11
|
+
allowed: false;
|
|
12
|
+
retryAfterMs: number;
|
|
13
|
+
};
|
|
14
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DEFAULT_MCP_RATE_LIMIT = void 0;
|
|
4
|
+
exports.createRateLimiter = createRateLimiter;
|
|
5
|
+
exports.DEFAULT_MCP_RATE_LIMIT = { requests: 120, windowMs: 60000 };
|
|
6
|
+
function createRateLimiter({ requests, windowMs }) {
|
|
7
|
+
const seen = new Map();
|
|
8
|
+
return {
|
|
9
|
+
/** Counts a request by `key`: whether it's allowed, and if not, how long until it would be. */
|
|
10
|
+
take(key, now = Date.now()) {
|
|
11
|
+
const recent = (seen.get(key) ?? []).filter(at => at > now - windowMs);
|
|
12
|
+
if (recent.length >= requests) {
|
|
13
|
+
seen.set(key, recent);
|
|
14
|
+
return { allowed: false, retryAfterMs: recent[0] + windowMs - now };
|
|
15
|
+
}
|
|
16
|
+
recent.push(now);
|
|
17
|
+
seen.set(key, recent);
|
|
18
|
+
return { allowed: true };
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { Auth, StreamByConfig } from '../types';
|
|
3
|
+
import { ToolCall } from './tools';
|
|
4
|
+
export declare const MCP_SERVER_NAME = "streamby";
|
|
5
|
+
export type ApiCaller = (call: ToolCall) => Promise<{
|
|
6
|
+
status: number;
|
|
7
|
+
body: unknown;
|
|
8
|
+
}>;
|
|
9
|
+
/**
|
|
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.
|
|
13
|
+
*/
|
|
14
|
+
export declare function createMcpServer(auth: Auth, api: ApiCaller, config: StreamByConfig, version: string): McpServer;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MCP_SERVER_NAME = void 0;
|
|
4
|
+
exports.createMcpServer = createMcpServer;
|
|
5
|
+
const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
6
|
+
const tools_1 = require("./tools");
|
|
7
|
+
const audit_1 = require("./audit");
|
|
8
|
+
exports.MCP_SERVER_NAME = 'streamby';
|
|
9
|
+
const errorText = (body, status) => {
|
|
10
|
+
const message = body?.message ?? body?.error;
|
|
11
|
+
return typeof message === 'string' ? message : `StreamBy answered ${status}`;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
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.
|
|
17
|
+
*/
|
|
18
|
+
function createMcpServer(auth, api, config, version) {
|
|
19
|
+
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;
|
|
24
|
+
server.registerTool(tool.name, {
|
|
25
|
+
title: tool.title,
|
|
26
|
+
description: tool.description,
|
|
27
|
+
inputSchema: tool.input,
|
|
28
|
+
annotations: { readOnlyHint: !tool.write, destructiveHint: false, openWorldHint: false },
|
|
29
|
+
}, async (args) => {
|
|
30
|
+
const started = Date.now();
|
|
31
|
+
const projectId = tool.projectOf?.(args) ?? null;
|
|
32
|
+
let status = null;
|
|
33
|
+
try {
|
|
34
|
+
const answer = await api(tool.call(args));
|
|
35
|
+
status = answer.status;
|
|
36
|
+
const ok = status >= 200 && status < 300;
|
|
37
|
+
await (0, audit_1.recordMcpCall)({
|
|
38
|
+
userId: auth.userId, tokenId: auth.apiToken.tokenId, projectId, tool: tool.name,
|
|
39
|
+
ok, status, error: ok ? null : errorText(answer.body, status), durationMs: Date.now() - started,
|
|
40
|
+
}, config);
|
|
41
|
+
if (!ok)
|
|
42
|
+
return { isError: true, content: [{ type: 'text', text: errorText(answer.body, status) }] };
|
|
43
|
+
const result = tool.pick ? tool.pick(answer.body, args) : answer.body;
|
|
44
|
+
return { content: [{ type: 'text', text: JSON.stringify(result ?? null, null, 2) }] };
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
48
|
+
await (0, audit_1.recordMcpCall)({
|
|
49
|
+
userId: auth.userId, tokenId: auth.apiToken.tokenId, projectId, tool: tool.name,
|
|
50
|
+
ok: false, status, error: message, durationMs: Date.now() - started,
|
|
51
|
+
}, config);
|
|
52
|
+
return { isError: true, content: [{ type: 'text', text: `The call failed: ${message}` }] };
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return server;
|
|
57
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export interface ToolCall {
|
|
3
|
+
method: 'GET' | 'POST' | 'PATCH' | 'PUT';
|
|
4
|
+
path: string;
|
|
5
|
+
body?: unknown;
|
|
6
|
+
}
|
|
7
|
+
export interface ToolDefinition<S extends z.ZodRawShape = z.ZodRawShape> {
|
|
8
|
+
name: string;
|
|
9
|
+
title: string;
|
|
10
|
+
description: string;
|
|
11
|
+
write: boolean;
|
|
12
|
+
input: S;
|
|
13
|
+
projectOf?: (args: z.infer<z.ZodObject<S>>) => string | undefined;
|
|
14
|
+
call: (args: z.infer<z.ZodObject<S>>) => ToolCall;
|
|
15
|
+
pick?: (data: any, args: z.infer<z.ZodObject<S>>) => unknown;
|
|
16
|
+
}
|
|
17
|
+
export declare const TOOLS: ToolDefinition<any>[];
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.TOOLS = void 0;
|
|
4
|
+
const zod_1 = require("zod");
|
|
5
|
+
const tool = (definition) => definition;
|
|
6
|
+
const id = (what) => zod_1.z.string().min(1).describe(`The ${what}'s id`);
|
|
7
|
+
const projectId = id('project');
|
|
8
|
+
const enc = encodeURIComponent;
|
|
9
|
+
const byProject = ({ projectId }) => projectId;
|
|
10
|
+
const CATEGORIES = ['images', 'audios', 'videos', '3d-models'];
|
|
11
|
+
const shotFields = {
|
|
12
|
+
description: zod_1.z.string().optional(),
|
|
13
|
+
status: zod_1.z.string().optional().describe('Its status, as the project uses them'),
|
|
14
|
+
assignedTo: zod_1.z.string().optional().describe("The assignee's user id"),
|
|
15
|
+
assetId: zod_1.z.string().optional(),
|
|
16
|
+
exportId: zod_1.z.string().optional(),
|
|
17
|
+
dueDate: zod_1.z.string().optional().describe('An ISO 8601 date'),
|
|
18
|
+
order: zod_1.z.number().optional(),
|
|
19
|
+
};
|
|
20
|
+
const taskFields = {
|
|
21
|
+
status: zod_1.z.string().optional(),
|
|
22
|
+
priority: zod_1.z.string().optional(),
|
|
23
|
+
assignedTo: zod_1.z.string().optional().describe("The assignee's user id"),
|
|
24
|
+
dueDate: zod_1.z.string().optional().describe('An ISO 8601 date'),
|
|
25
|
+
notes: zod_1.z.string().optional(),
|
|
26
|
+
};
|
|
27
|
+
const rightsFields = {
|
|
28
|
+
rightsHolder: zod_1.z.string().optional(),
|
|
29
|
+
licenseType: zod_1.z.string().optional().describe('e.g. CC0, CC BY 4.0, proprietary'),
|
|
30
|
+
licenseUrl: zod_1.z.string().optional(),
|
|
31
|
+
territory: zod_1.z.string().optional(),
|
|
32
|
+
expiresAt: zod_1.z.string().optional().describe('An ISO 8601 date'),
|
|
33
|
+
usageRestrictions: zod_1.z.string().optional(),
|
|
34
|
+
notes: zod_1.z.string().optional(),
|
|
35
|
+
};
|
|
36
|
+
// Only the fields given (an unset optional field isn't sent as undefined).
|
|
37
|
+
const defined = (fields) => Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined));
|
|
38
|
+
exports.TOOLS = [
|
|
39
|
+
// ── Reading ──
|
|
40
|
+
tool({
|
|
41
|
+
name: 'list_projects', title: 'List projects', write: false,
|
|
42
|
+
description: 'The StreamBy projects this token reaches.',
|
|
43
|
+
input: {},
|
|
44
|
+
call: () => ({ method: 'GET', path: '/projects' }),
|
|
45
|
+
pick: (data) => data.projects,
|
|
46
|
+
}),
|
|
47
|
+
tool({
|
|
48
|
+
name: 'get_project', title: 'Get a project', write: false,
|
|
49
|
+
description: "A project's details: its name, description, members, exports and connections.",
|
|
50
|
+
input: { projectId },
|
|
51
|
+
projectOf: byProject,
|
|
52
|
+
call: ({ projectId }) => ({ method: 'GET', path: `/projects/${enc(projectId)}` }),
|
|
53
|
+
}),
|
|
54
|
+
tool({
|
|
55
|
+
name: 'list_assets', title: 'List assets', write: false,
|
|
56
|
+
description: "A project's assets of a category (images, audios, videos, 3d-models), optionally in one storage connection, and optionally only those whose name contains `query`.",
|
|
57
|
+
input: {
|
|
58
|
+
projectId,
|
|
59
|
+
category: zod_1.z.enum(CATEGORIES),
|
|
60
|
+
connectionId: zod_1.z.string().optional().describe("A storage connection's id; the project's built-in storage if not given"),
|
|
61
|
+
query: zod_1.z.string().optional().describe('Only assets whose name contains it (case-insensitive)'),
|
|
62
|
+
},
|
|
63
|
+
projectOf: byProject,
|
|
64
|
+
call: ({ projectId, category, connectionId }) => ({
|
|
65
|
+
method: 'GET',
|
|
66
|
+
path: connectionId
|
|
67
|
+
? `/projects/${enc(projectId)}/connections/storage/${enc(connectionId)}/files/${category}`
|
|
68
|
+
: `/projects/${enc(projectId)}/storage/${category}`,
|
|
69
|
+
}),
|
|
70
|
+
pick: (data, { query }) => {
|
|
71
|
+
const files = Array.isArray(data?.data) ? data.data : [];
|
|
72
|
+
if (!query)
|
|
73
|
+
return files;
|
|
74
|
+
const q = query.toLowerCase();
|
|
75
|
+
return files.filter(f => [f.name, f.displayName, f.originalName, f.fileName]
|
|
76
|
+
.some(n => typeof n === 'string' && n.toLowerCase().includes(q)));
|
|
77
|
+
},
|
|
78
|
+
}),
|
|
79
|
+
tool({
|
|
80
|
+
name: 'get_asset_metadata', title: "Get an asset's metadata", write: false,
|
|
81
|
+
description: "An asset's metadata (technical data and custom tags).",
|
|
82
|
+
input: { projectId, fileId: id('asset') },
|
|
83
|
+
projectOf: byProject,
|
|
84
|
+
call: ({ projectId, fileId }) => ({ method: 'GET', path: `/projects/${enc(projectId)}/assets/${enc(fileId)}/metadata` }),
|
|
85
|
+
}),
|
|
86
|
+
tool({
|
|
87
|
+
name: 'get_asset_rights', title: "Get an asset's rights", write: false,
|
|
88
|
+
description: "An asset's rights: holder, license, territory, expiry and restrictions.",
|
|
89
|
+
input: { projectId, assetId: id('asset') },
|
|
90
|
+
projectOf: byProject,
|
|
91
|
+
call: ({ projectId, assetId }) => ({ method: 'GET', path: `/projects/${enc(projectId)}/assets/${enc(assetId)}/rights` }),
|
|
92
|
+
}),
|
|
93
|
+
tool({
|
|
94
|
+
name: 'get_workflow', title: "Get a project's workflow", write: false,
|
|
95
|
+
description: "A project's workflow (its node graph).",
|
|
96
|
+
input: { projectId },
|
|
97
|
+
projectOf: byProject,
|
|
98
|
+
call: ({ projectId }) => ({ method: 'GET', path: `/projects/${enc(projectId)}/workflow` }),
|
|
99
|
+
}),
|
|
100
|
+
tool({
|
|
101
|
+
name: 'list_pipelines', title: 'List pipelines', write: false,
|
|
102
|
+
description: "A project's pipelines.",
|
|
103
|
+
input: { projectId },
|
|
104
|
+
projectOf: byProject,
|
|
105
|
+
call: ({ projectId }) => ({ method: 'GET', path: `/projects/${enc(projectId)}/pipelines` }),
|
|
106
|
+
}),
|
|
107
|
+
tool({
|
|
108
|
+
name: 'get_pipeline', title: 'Get a pipeline', write: false,
|
|
109
|
+
description: 'A pipeline, with its node schema.',
|
|
110
|
+
input: { projectId, pipelineId: id('pipeline') },
|
|
111
|
+
projectOf: byProject,
|
|
112
|
+
call: ({ projectId, pipelineId }) => ({ method: 'GET', path: `/projects/${enc(projectId)}/pipelines/${enc(pipelineId)}` }),
|
|
113
|
+
}),
|
|
114
|
+
tool({
|
|
115
|
+
name: 'list_exports', title: 'List exports', write: false,
|
|
116
|
+
description: "A project's exports (its published endpoints).",
|
|
117
|
+
input: { projectId },
|
|
118
|
+
projectOf: byProject,
|
|
119
|
+
call: ({ projectId }) => ({ method: 'GET', path: `/projects/${enc(projectId)}` }),
|
|
120
|
+
pick: (data) => data?.project?.exports ?? data?.exports ?? [],
|
|
121
|
+
}),
|
|
122
|
+
tool({
|
|
123
|
+
name: 'get_export', title: 'Get an export', write: false,
|
|
124
|
+
description: 'An export, with its node schema.',
|
|
125
|
+
input: { projectId, exportId: id('export') },
|
|
126
|
+
projectOf: byProject,
|
|
127
|
+
call: ({ projectId, exportId }) => ({ method: 'GET', path: `/projects/${enc(projectId)}/exports/${enc(exportId)}` }),
|
|
128
|
+
}),
|
|
129
|
+
tool({
|
|
130
|
+
name: 'get_job', title: 'Get a job', write: false,
|
|
131
|
+
description: "A job's status and progress (transcoding, rendering, ingesting...).",
|
|
132
|
+
input: { jobId: id('job') },
|
|
133
|
+
call: ({ jobId }) => ({ method: 'GET', path: `/jobs/${enc(jobId)}` }),
|
|
134
|
+
}),
|
|
135
|
+
tool({
|
|
136
|
+
name: 'list_sequences', title: 'List production sequences', write: false,
|
|
137
|
+
description: "A project's production sequences.",
|
|
138
|
+
input: { projectId },
|
|
139
|
+
projectOf: byProject,
|
|
140
|
+
call: ({ projectId }) => ({ method: 'GET', path: `/projects/${enc(projectId)}/production/sequences` }),
|
|
141
|
+
}),
|
|
142
|
+
tool({
|
|
143
|
+
name: 'list_shots', title: 'List shots', write: false,
|
|
144
|
+
description: "A production sequence's shots.",
|
|
145
|
+
input: { projectId, sequenceId: id('sequence') },
|
|
146
|
+
projectOf: byProject,
|
|
147
|
+
call: ({ projectId, sequenceId }) => ({ method: 'GET', path: `/projects/${enc(projectId)}/production/sequences/${enc(sequenceId)}/shots` }),
|
|
148
|
+
}),
|
|
149
|
+
tool({
|
|
150
|
+
name: 'list_tasks', title: 'List shot tasks', write: false,
|
|
151
|
+
description: "A shot's tasks.",
|
|
152
|
+
input: { projectId, shotId: id('shot') },
|
|
153
|
+
projectOf: byProject,
|
|
154
|
+
call: ({ projectId, shotId }) => ({ method: 'GET', path: `/projects/${enc(projectId)}/production/shots/${enc(shotId)}/tasks` }),
|
|
155
|
+
}),
|
|
156
|
+
// ── Writing ──
|
|
157
|
+
tool({
|
|
158
|
+
name: 'update_asset_metadata', title: "Update an asset's custom tags", write: true,
|
|
159
|
+
description: "Sets an asset's custom tags (key/value pairs), replacing the ones it had.",
|
|
160
|
+
input: { projectId, fileId: id('asset'), customTags: zod_1.z.record(zod_1.z.string(), zod_1.z.string()) },
|
|
161
|
+
projectOf: byProject,
|
|
162
|
+
call: ({ projectId, fileId, customTags }) => ({ method: 'PATCH', path: `/projects/${enc(projectId)}/assets/${enc(fileId)}/metadata`, body: { customTags } }),
|
|
163
|
+
}),
|
|
164
|
+
tool({
|
|
165
|
+
name: 'set_asset_rights', title: "Set an asset's rights", write: true,
|
|
166
|
+
description: "Sets an asset's rights: holder, license, territory, expiry, restrictions and notes.",
|
|
167
|
+
input: { projectId, assetId: id('asset'), ...rightsFields },
|
|
168
|
+
projectOf: byProject,
|
|
169
|
+
call: ({ projectId, assetId, ...rights }) => ({ method: 'PUT', path: `/projects/${enc(projectId)}/assets/${enc(assetId)}/rights`, body: defined(rights) }),
|
|
170
|
+
}),
|
|
171
|
+
tool({
|
|
172
|
+
name: 'create_pipeline', title: 'Create a pipeline', write: true,
|
|
173
|
+
description: 'Creates a pipeline in a project.',
|
|
174
|
+
input: { projectId, name: zod_1.z.string().min(1), description: zod_1.z.string().optional(), order: zod_1.z.number().optional() },
|
|
175
|
+
projectOf: byProject,
|
|
176
|
+
call: ({ projectId, ...fields }) => ({ method: 'POST', path: `/projects/${enc(projectId)}/pipelines`, body: defined(fields) }),
|
|
177
|
+
}),
|
|
178
|
+
tool({
|
|
179
|
+
name: 'update_pipeline', title: 'Update a pipeline', write: true,
|
|
180
|
+
description: "Changes a pipeline's name, description or order.",
|
|
181
|
+
input: { projectId, pipelineId: id('pipeline'), name: zod_1.z.string().min(1).optional(), description: zod_1.z.string().optional(), order: zod_1.z.number().optional() },
|
|
182
|
+
projectOf: byProject,
|
|
183
|
+
call: ({ projectId, pipelineId, ...fields }) => ({ method: 'PATCH', path: `/projects/${enc(projectId)}/pipelines/${enc(pipelineId)}`, body: defined(fields) }),
|
|
184
|
+
}),
|
|
185
|
+
tool({
|
|
186
|
+
name: 'create_sequence', title: 'Create a production sequence', write: true,
|
|
187
|
+
description: 'Creates a production sequence in a project.',
|
|
188
|
+
input: { projectId, name: zod_1.z.string().min(1), description: zod_1.z.string().optional(), order: zod_1.z.number().optional() },
|
|
189
|
+
projectOf: byProject,
|
|
190
|
+
call: ({ projectId, ...fields }) => ({ method: 'POST', path: `/projects/${enc(projectId)}/production/sequences`, body: defined(fields) }),
|
|
191
|
+
}),
|
|
192
|
+
tool({
|
|
193
|
+
name: 'update_sequence', title: 'Update a production sequence', write: true,
|
|
194
|
+
description: "Changes a sequence's name, description or order.",
|
|
195
|
+
input: { projectId, sequenceId: id('sequence'), name: zod_1.z.string().min(1).optional(), description: zod_1.z.string().optional(), order: zod_1.z.number().optional() },
|
|
196
|
+
projectOf: byProject,
|
|
197
|
+
call: ({ projectId, sequenceId, ...fields }) => ({ method: 'PATCH', path: `/projects/${enc(projectId)}/production/sequences/${enc(sequenceId)}`, body: defined(fields) }),
|
|
198
|
+
}),
|
|
199
|
+
tool({
|
|
200
|
+
name: 'create_shot', title: 'Create a shot', write: true,
|
|
201
|
+
description: 'Creates a shot in a production sequence.',
|
|
202
|
+
input: { projectId, sequenceId: id('sequence'), name: zod_1.z.string().min(1), ...shotFields },
|
|
203
|
+
projectOf: byProject,
|
|
204
|
+
call: ({ projectId, sequenceId, ...fields }) => ({ method: 'POST', path: `/projects/${enc(projectId)}/production/sequences/${enc(sequenceId)}/shots`, body: defined(fields) }),
|
|
205
|
+
}),
|
|
206
|
+
tool({
|
|
207
|
+
name: 'update_shot', title: 'Update a shot', write: true,
|
|
208
|
+
description: 'Changes a shot (its name, status, assignee, due date...).',
|
|
209
|
+
input: { projectId, shotId: id('shot'), name: zod_1.z.string().min(1).optional(), ...shotFields },
|
|
210
|
+
projectOf: byProject,
|
|
211
|
+
call: ({ projectId, shotId, ...fields }) => ({ method: 'PATCH', path: `/projects/${enc(projectId)}/production/shots/${enc(shotId)}`, body: defined(fields) }),
|
|
212
|
+
}),
|
|
213
|
+
tool({
|
|
214
|
+
name: 'create_task', title: 'Create a shot task', write: true,
|
|
215
|
+
description: 'Creates a task in a shot.',
|
|
216
|
+
input: { projectId, shotId: id('shot'), name: zod_1.z.string().min(1), ...taskFields },
|
|
217
|
+
projectOf: byProject,
|
|
218
|
+
call: ({ projectId, shotId, ...fields }) => ({ method: 'POST', path: `/projects/${enc(projectId)}/production/shots/${enc(shotId)}/tasks`, body: defined(fields) }),
|
|
219
|
+
}),
|
|
220
|
+
tool({
|
|
221
|
+
name: 'update_task', title: 'Update a shot task', write: true,
|
|
222
|
+
description: 'Changes a task (its name, status, priority, assignee, due date, notes).',
|
|
223
|
+
input: { projectId, shotId: id('shot'), taskId: id('task'), name: zod_1.z.string().min(1).optional(), ...taskFields },
|
|
224
|
+
projectOf: byProject,
|
|
225
|
+
call: ({ projectId, shotId, taskId, ...fields }) => ({ method: 'PATCH', path: `/projects/${enc(projectId)}/production/shots/${enc(shotId)}/tasks/${enc(taskId)}`, body: defined(fields) }),
|
|
226
|
+
}),
|
|
227
|
+
];
|
|
@@ -12,6 +12,8 @@ const backfillBuiltinConnections_1 = require("../services/backfillBuiltinConnect
|
|
|
12
12
|
const reencryptCredentials_1 = require("../services/reencryptCredentials");
|
|
13
13
|
const wsHub_1 = require("../services/wsHub");
|
|
14
14
|
const auth_1 = require("../services/auth");
|
|
15
|
+
const apiToken_1 = require("./routes/apiToken");
|
|
16
|
+
const mcp_1 = require("./routes/mcp");
|
|
15
17
|
const encryption_1 = require("../utils/encryption");
|
|
16
18
|
const auth_2 = require("./routes/auth");
|
|
17
19
|
const user_1 = require("./routes/user");
|
|
@@ -95,6 +97,8 @@ function createStreamByRouter(config) {
|
|
|
95
97
|
(0, manager_1.registerModel)('users', [mainDb.id], 'users', 'accounts');
|
|
96
98
|
(0, manager_1.registerModel)('user_subscriptions', [mainDb.id], 'user_subscriptions', 'streamby');
|
|
97
99
|
(0, manager_1.registerModel)('user_integrations', [mainDb.id], 'user_integrations', streambySchema);
|
|
100
|
+
(0, manager_1.registerModel)('api_tokens', [mainDb.id], 'api_tokens', streambySchema);
|
|
101
|
+
(0, manager_1.registerModel)('mcp_audit', [mainDb.id], 'mcp_audit', streambySchema);
|
|
98
102
|
}
|
|
99
103
|
}
|
|
100
104
|
if (config.websocket?.server) {
|
|
@@ -104,8 +108,11 @@ function createStreamByRouter(config) {
|
|
|
104
108
|
// but for the public export route (see authenticate).
|
|
105
109
|
router.use((0, auth_2.authRouter)(config));
|
|
106
110
|
router.use((0, auth_1.authenticate)(config));
|
|
111
|
+
router.use(auth_1.enforceApiTokenScope);
|
|
107
112
|
router.use(subscription_1.ensureSubscription);
|
|
108
113
|
router.use((0, user_1.userRouter)(config));
|
|
114
|
+
router.use((0, apiToken_1.apiTokenRouter)(config));
|
|
115
|
+
router.use((0, mcp_1.mcpRouter)(config));
|
|
109
116
|
router.use((0, export_1.exportRouter)(config));
|
|
110
117
|
router.use((0, member_1.memberRouter)(config));
|
|
111
118
|
router.use((0, storage_2.storageRouter)(config));
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.apiTokenRouter = apiTokenRouter;
|
|
4
|
+
const express_1 = require("express");
|
|
5
|
+
const manager_1 = require("../../models/manager");
|
|
6
|
+
const auth_1 = require("../../utils/auth");
|
|
7
|
+
const apiTokens_1 = require("../../services/apiTokens");
|
|
8
|
+
// The signed-in user's own API tokens (see services/apiTokens): listed, made, revoked. Only
|
|
9
|
+
// with a session: a token can't manage tokens (see enforceApiTokenScope).
|
|
10
|
+
function apiTokenRouter(config) {
|
|
11
|
+
const router = (0, express_1.Router)();
|
|
12
|
+
const Project = (0, manager_1.getModel)('projects');
|
|
13
|
+
router.get('/user/api-tokens', async (req, res) => {
|
|
14
|
+
try {
|
|
15
|
+
const auth = req.auth;
|
|
16
|
+
res.json({ apiTokens: await (0, apiTokens_1.listApiTokens)(auth.userId) });
|
|
17
|
+
}
|
|
18
|
+
catch (err) {
|
|
19
|
+
res.status(500).json({ message: 'Failed to list API tokens', details: err.message });
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
// The token itself is in this response only: it's never stored, nor shown again.
|
|
23
|
+
router.post('/user/api-tokens', async (req, res) => {
|
|
24
|
+
try {
|
|
25
|
+
const auth = req.auth;
|
|
26
|
+
const { name, access, projectIds, expiresInDays } = req.body ?? {};
|
|
27
|
+
// A token reaches only projects its owner is in.
|
|
28
|
+
if (Array.isArray(projectIds)) {
|
|
29
|
+
for (const id of projectIds) {
|
|
30
|
+
const project = typeof id === 'string' ? await Project.findOne({ _id: id }) : null;
|
|
31
|
+
if (!(0, auth_1.isProjectMember)(project, auth.userId)) {
|
|
32
|
+
return res.status(400).json({ message: `You are not a member of project '${id}'` });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const { token, info } = await (0, apiTokens_1.createApiToken)(auth.userId, { name, access, projectIds, expiresInDays: expiresInDays ?? null }, config);
|
|
37
|
+
res.status(201).json({ token, apiToken: info, message: 'Copy the token now: it will not be shown again' });
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
if (err instanceof apiTokens_1.ApiTokenError)
|
|
41
|
+
return res.status(err.status).json({ message: err.message });
|
|
42
|
+
res.status(500).json({ message: 'Failed to create API token', details: err.message });
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
router.delete('/user/api-tokens/:tokenId', async (req, res) => {
|
|
46
|
+
try {
|
|
47
|
+
const auth = req.auth;
|
|
48
|
+
const revoked = await (0, apiTokens_1.revokeApiToken)(auth.userId, req.params.tokenId);
|
|
49
|
+
if (!revoked)
|
|
50
|
+
return res.status(404).json({ message: 'API token not found' });
|
|
51
|
+
res.status(204).end();
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
res.status(500).json({ message: 'Failed to revoke API token', details: err.message });
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
return router;
|
|
58
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.mcpRouter = mcpRouter;
|
|
4
|
+
const express_1 = require("express");
|
|
5
|
+
const streamableHttp_js_1 = require("@modelcontextprotocol/sdk/server/streamableHttp.js");
|
|
6
|
+
const server_1 = require("../../mcp/server");
|
|
7
|
+
const rateLimit_1 = require("../../mcp/rateLimit");
|
|
8
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
9
|
+
const { version } = require('../../../package.json');
|
|
10
|
+
const jsonRpcError = (res, status, message) => res.status(status).json({ jsonrpc: '2.0', error: { code: -32000, message }, id: null });
|
|
11
|
+
// StreamBy's MCP server (Streamable HTTP, stateless: one server per request), for agents
|
|
12
|
+
// working with StreamBy through an API token. Each tool calls the REST API right here -- this
|
|
13
|
+
// same process, under the same mount -- with the request's own Authorization header, so it
|
|
14
|
+
// reaches what the token and its owner can and nothing more.
|
|
15
|
+
function mcpRouter(config) {
|
|
16
|
+
const router = (0, express_1.Router)();
|
|
17
|
+
const limiter = (0, rateLimit_1.createRateLimiter)(config.mcp?.rateLimit ?? rateLimit_1.DEFAULT_MCP_RATE_LIMIT);
|
|
18
|
+
router.post('/mcp', async (req, res) => {
|
|
19
|
+
const auth = req.auth;
|
|
20
|
+
if (!auth.apiToken)
|
|
21
|
+
return jsonRpcError(res, 401, 'The MCP server takes an API token (Authorization: Bearer sbt_...)');
|
|
22
|
+
const taken = limiter.take(auth.apiToken.tokenId);
|
|
23
|
+
if (!taken.allowed) {
|
|
24
|
+
res.setHeader('Retry-After', String(Math.ceil(taken.retryAfterMs / 1000)));
|
|
25
|
+
return jsonRpcError(res, 429, 'Too many requests for this API token; try again later');
|
|
26
|
+
}
|
|
27
|
+
const base = config.mcp?.apiBaseUrl ?? `http://127.0.0.1:${req.socket.localPort}${req.baseUrl}`;
|
|
28
|
+
const api = async ({ method, path, body }) => {
|
|
29
|
+
const answer = await fetch(`${base}${path}`, {
|
|
30
|
+
method,
|
|
31
|
+
headers: { Authorization: req.headers.authorization ?? '', ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}) },
|
|
32
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
33
|
+
redirect: 'error',
|
|
34
|
+
});
|
|
35
|
+
const text = await answer.text();
|
|
36
|
+
let parsed = null;
|
|
37
|
+
try {
|
|
38
|
+
parsed = text ? JSON.parse(text) : null;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
parsed = text;
|
|
42
|
+
}
|
|
43
|
+
return { status: answer.status, body: parsed };
|
|
44
|
+
};
|
|
45
|
+
const server = (0, server_1.createMcpServer)(auth, api, config, version);
|
|
46
|
+
const transport = new streamableHttp_js_1.StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
47
|
+
res.on('close', () => { transport.close(); server.close(); });
|
|
48
|
+
try {
|
|
49
|
+
await server.connect(transport);
|
|
50
|
+
await transport.handleRequest(req, res, req.body);
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
console.error('❌ StreamBy MCP: request failed:', err instanceof Error ? err.message : err);
|
|
54
|
+
if (!res.headersSent)
|
|
55
|
+
jsonRpcError(res, 500, 'Internal error');
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
// Stateless: no session to stream to, nor to end.
|
|
59
|
+
router.get('/mcp', (_req, res) => jsonRpcError(res, 405, 'Method not allowed'));
|
|
60
|
+
router.delete('/mcp', (_req, res) => jsonRpcError(res, 405, 'Method not allowed'));
|
|
61
|
+
return router;
|
|
62
|
+
}
|
|
@@ -8,6 +8,7 @@ const jobQueue_1 = require("../../services/jobQueue");
|
|
|
8
8
|
const mediaProcessor_1 = require("../../services/mediaProcessor");
|
|
9
9
|
const storage_1 = require("../../providers/storage");
|
|
10
10
|
const connectionManager_1 = require("../../adapters/database/connectionManager");
|
|
11
|
+
const auth_2 = require("../../services/auth");
|
|
11
12
|
function getMetadataCollection() {
|
|
12
13
|
const ids = (0, connectionManager_1.getConnectedIds)().filter(id => (0, connectionManager_1.getConnection)(id).type === 'nosql');
|
|
13
14
|
if (!ids.length)
|
|
@@ -33,6 +34,9 @@ function mediaRouter(config) {
|
|
|
33
34
|
const job = (0, jobQueue_1.getJob)(req.params.jobId);
|
|
34
35
|
if (!job)
|
|
35
36
|
return res.status(404).json({ message: 'Job not found' });
|
|
37
|
+
// With an API token, only a job of a project it reaches.
|
|
38
|
+
if (!(0, auth_2.scopeReachesProject)(auth.apiToken, job.projectId))
|
|
39
|
+
return res.status(404).json({ message: 'Job not found' });
|
|
36
40
|
if (job.userId !== auth.userId) {
|
|
37
41
|
const project = await Project.findOne({ _id: job.projectId });
|
|
38
42
|
if (!(0, auth_1.isProjectMember)(project, auth.userId))
|
|
@@ -8,6 +8,7 @@ const connectionManager_1 = require("../../adapters/database/connectionManager")
|
|
|
8
8
|
const sql_1 = require("../../adapters/database/sql");
|
|
9
9
|
const sanitize_1 = require("../../utils/sanitize");
|
|
10
10
|
const projectIntegration_1 = require("../../services/projectIntegration");
|
|
11
|
+
const auth_2 = require("../../services/auth");
|
|
11
12
|
function projectRouter(config) {
|
|
12
13
|
const router = (0, express_1.Router)();
|
|
13
14
|
const Project = (0, manager_1.getModel)('projects');
|
|
@@ -32,6 +33,9 @@ function projectRouter(config) {
|
|
|
32
33
|
const currentUserMember = project.members?.find((m) => m.userId?.toString() === auth.userId?.toString());
|
|
33
34
|
if (!currentUserMember || currentUserMember.status !== 'active')
|
|
34
35
|
return false;
|
|
36
|
+
// With an API token, only the projects it reaches.
|
|
37
|
+
if (!(0, auth_2.scopeReachesProject)(auth.apiToken, (project._id ?? project.id)?.toString()))
|
|
38
|
+
return false;
|
|
35
39
|
if (filterArchived !== undefined) {
|
|
36
40
|
return (currentUserMember.archived || false) === filterArchived;
|
|
37
41
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { ApiTokenAccess, ApiTokenScope, StreamByConfig } from '../types';
|
|
2
|
+
export declare const API_TOKEN_PREFIX = "sbt_";
|
|
3
|
+
export declare const API_TOKEN_EXPIRY_DAYS: readonly [7, 30, 90, 365];
|
|
4
|
+
export interface ApiTokenRecord {
|
|
5
|
+
id: string;
|
|
6
|
+
userId: string;
|
|
7
|
+
name: string;
|
|
8
|
+
prefix: string;
|
|
9
|
+
hash: string;
|
|
10
|
+
access: ApiTokenAccess;
|
|
11
|
+
projectIds: string[];
|
|
12
|
+
createdAt: Date;
|
|
13
|
+
expiresAt: Date | null;
|
|
14
|
+
lastUsedAt: Date | null;
|
|
15
|
+
}
|
|
16
|
+
/** What a token's owner sees of it. */
|
|
17
|
+
export type ApiTokenInfo = Omit<ApiTokenRecord, 'hash' | 'userId' | 'projectIds'> & {
|
|
18
|
+
projectIds: string[] | '*';
|
|
19
|
+
};
|
|
20
|
+
export declare class ApiTokenError extends Error {
|
|
21
|
+
status: number;
|
|
22
|
+
constructor(message: string, status?: number);
|
|
23
|
+
}
|
|
24
|
+
export interface CreateApiTokenInput {
|
|
25
|
+
name: string;
|
|
26
|
+
access: ApiTokenAccess;
|
|
27
|
+
projectIds: string[] | '*';
|
|
28
|
+
expiresInDays: number | null;
|
|
29
|
+
}
|
|
30
|
+
/** A new token for `userId`: the token itself, shown only now, and what's kept of it. */
|
|
31
|
+
export declare function createApiToken(userId: string, input: CreateApiTokenInput, config: StreamByConfig): Promise<{
|
|
32
|
+
token: string;
|
|
33
|
+
info: ApiTokenInfo;
|
|
34
|
+
}>;
|
|
35
|
+
/** The tokens `userId` has. */
|
|
36
|
+
export declare function listApiTokens(userId: string): Promise<ApiTokenInfo[]>;
|
|
37
|
+
/** Revokes one of `userId`'s tokens: it stops working at once. Whether there was one. */
|
|
38
|
+
export declare function revokeApiToken(userId: string, tokenId: string): Promise<boolean>;
|
|
39
|
+
export interface ResolvedApiToken {
|
|
40
|
+
userId: string;
|
|
41
|
+
scope: ApiTokenScope;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The token's owner and scope, for the host's authProvider; null when it isn't a token, isn't
|
|
45
|
+
* known (revoked), or has expired. Marks when it was last used.
|
|
46
|
+
*/
|
|
47
|
+
export declare function resolveApiToken(token: unknown): Promise<ResolvedApiToken | null>;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.ApiTokenError = exports.API_TOKEN_EXPIRY_DAYS = exports.API_TOKEN_PREFIX = void 0;
|
|
7
|
+
exports.createApiToken = createApiToken;
|
|
8
|
+
exports.listApiTokens = listApiTokens;
|
|
9
|
+
exports.revokeApiToken = revokeApiToken;
|
|
10
|
+
exports.resolveApiToken = resolveApiToken;
|
|
11
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
12
|
+
const manager_1 = require("../models/manager");
|
|
13
|
+
// API tokens: how an agent or a service works with StreamBy as a user, without their browser
|
|
14
|
+
// session. A token is "sbt_" and 32 random bytes; only its hash is stored, with a prefix to
|
|
15
|
+
// tell it apart in a list. It's sent as `Authorization: Bearer sbt_...` and the host's
|
|
16
|
+
// authProvider resolves it (resolveApiToken), so the request is its owner's -- limited by the
|
|
17
|
+
// token's scope (read or write, which projects), and never more than its owner can do: the
|
|
18
|
+
// owner's role in each project still decides.
|
|
19
|
+
exports.API_TOKEN_PREFIX = 'sbt_';
|
|
20
|
+
const PREFIX_SHOWN = exports.API_TOKEN_PREFIX.length + 8;
|
|
21
|
+
exports.API_TOKEN_EXPIRY_DAYS = [7, 30, 90, 365];
|
|
22
|
+
const ALL_PROJECTS = '*';
|
|
23
|
+
class ApiTokenError extends Error {
|
|
24
|
+
constructor(message, status = 400) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.status = status;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
exports.ApiTokenError = ApiTokenError;
|
|
30
|
+
const hashOf = (token) => crypto_1.default.createHash('sha256').update(token).digest('hex');
|
|
31
|
+
const infoOf = ({ hash: _hash, userId: _userId, ...info }) => ({
|
|
32
|
+
...info,
|
|
33
|
+
projectIds: parseProjectIds(info.projectIds),
|
|
34
|
+
});
|
|
35
|
+
// As stored (a list, read back from SQL JSON as a list or its text): ['*'] is every project.
|
|
36
|
+
const parseProjectIds = (value) => {
|
|
37
|
+
if (typeof value === 'string') {
|
|
38
|
+
try {
|
|
39
|
+
return parseProjectIds(JSON.parse(value));
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (!Array.isArray(value))
|
|
46
|
+
return [];
|
|
47
|
+
const ids = value.map(String);
|
|
48
|
+
return ids.includes(ALL_PROJECTS) ? '*' : ids;
|
|
49
|
+
};
|
|
50
|
+
const mainDbType = (config) => config.databases?.find(db => db.main)?.type ?? 'nosql';
|
|
51
|
+
/** A new token for `userId`: the token itself, shown only now, and what's kept of it. */
|
|
52
|
+
async function createApiToken(userId, input, config) {
|
|
53
|
+
const name = typeof input.name === 'string' ? input.name.trim() : '';
|
|
54
|
+
if (!name)
|
|
55
|
+
throw new ApiTokenError('A token needs a name.');
|
|
56
|
+
if (input.access !== 'read' && input.access !== 'write')
|
|
57
|
+
throw new ApiTokenError("access must be 'read' or 'write'.");
|
|
58
|
+
if (input.projectIds !== '*' && (!Array.isArray(input.projectIds) || !input.projectIds.length || !input.projectIds.every(id => typeof id === 'string' && id && id !== ALL_PROJECTS))) {
|
|
59
|
+
throw new ApiTokenError("projectIds must be '*' or a list of project ids.");
|
|
60
|
+
}
|
|
61
|
+
if (input.expiresInDays !== null && !exports.API_TOKEN_EXPIRY_DAYS.includes(input.expiresInDays)) {
|
|
62
|
+
throw new ApiTokenError(`expiresInDays must be one of ${exports.API_TOKEN_EXPIRY_DAYS.join(', ')}, or null.`);
|
|
63
|
+
}
|
|
64
|
+
const token = exports.API_TOKEN_PREFIX + crypto_1.default.randomBytes(32).toString('base64url');
|
|
65
|
+
const now = new Date();
|
|
66
|
+
const record = {
|
|
67
|
+
id: crypto_1.default.randomUUID(),
|
|
68
|
+
userId,
|
|
69
|
+
name,
|
|
70
|
+
prefix: token.slice(0, PREFIX_SHOWN),
|
|
71
|
+
hash: hashOf(token),
|
|
72
|
+
access: input.access,
|
|
73
|
+
projectIds: input.projectIds === '*' ? [ALL_PROJECTS] : [...new Set(input.projectIds)],
|
|
74
|
+
createdAt: now,
|
|
75
|
+
expiresAt: input.expiresInDays === null ? null : new Date(now.getTime() + input.expiresInDays * 86400000),
|
|
76
|
+
lastUsedAt: null,
|
|
77
|
+
};
|
|
78
|
+
await (0, manager_1.getModel)('api_tokens').create({ ...record, dbType: mainDbType(config) });
|
|
79
|
+
return { token, info: infoOf(record) };
|
|
80
|
+
}
|
|
81
|
+
/** The tokens `userId` has. */
|
|
82
|
+
async function listApiTokens(userId) {
|
|
83
|
+
const records = (await (0, manager_1.getModel)('api_tokens').find({ userId }));
|
|
84
|
+
return records
|
|
85
|
+
.map(infoOf)
|
|
86
|
+
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
|
87
|
+
}
|
|
88
|
+
/** Revokes one of `userId`'s tokens: it stops working at once. Whether there was one. */
|
|
89
|
+
async function revokeApiToken(userId, tokenId) {
|
|
90
|
+
return (await (0, manager_1.getModel)('api_tokens').delete({ id: tokenId, userId })) > 0;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The token's owner and scope, for the host's authProvider; null when it isn't a token, isn't
|
|
94
|
+
* known (revoked), or has expired. Marks when it was last used.
|
|
95
|
+
*/
|
|
96
|
+
async function resolveApiToken(token) {
|
|
97
|
+
if (typeof token !== 'string' || !token.startsWith(exports.API_TOKEN_PREFIX))
|
|
98
|
+
return null;
|
|
99
|
+
const ApiTokens = (0, manager_1.getModel)('api_tokens');
|
|
100
|
+
const record = (await ApiTokens.findOne({ hash: hashOf(token) }));
|
|
101
|
+
if (!record)
|
|
102
|
+
return null;
|
|
103
|
+
if (record.expiresAt && new Date(record.expiresAt).getTime() <= Date.now())
|
|
104
|
+
return null;
|
|
105
|
+
ApiTokens.update({ id: record.id }, { lastUsedAt: new Date() }).catch(() => { });
|
|
106
|
+
return {
|
|
107
|
+
userId: record.userId,
|
|
108
|
+
scope: { tokenId: record.id, access: record.access, projectIds: parseProjectIds(record.projectIds) },
|
|
109
|
+
};
|
|
110
|
+
}
|
package/dist/services/auth.d.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { Request, Response, NextFunction } from 'express';
|
|
2
|
-
import { StreamByConfig } from '../types';
|
|
2
|
+
import { Auth, StreamByConfig } from '../types';
|
|
3
3
|
export declare const isPublicRoute: (method: string, path: string) => boolean;
|
|
4
|
+
/** Whether a token's scope reaches a project. */
|
|
5
|
+
export declare const scopeReachesProject: (scope: Auth["apiToken"], projectId: string) => boolean;
|
|
6
|
+
export declare const enforceApiTokenScope: (req: Request, res: Response, next: NextFunction) => void | Response<any, Record<string, any>>;
|
|
4
7
|
export declare const authenticate: (config: StreamByConfig) => (req: Request, res: Response, next: NextFunction) => Promise<void | Response<any, Record<string, any>>>;
|
package/dist/services/auth.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.authenticate = exports.isPublicRoute = void 0;
|
|
3
|
+
exports.authenticate = exports.enforceApiTokenScope = exports.scopeReachesProject = exports.isPublicRoute = void 0;
|
|
4
4
|
// The one route open without a session: a project's public export, read
|
|
5
5
|
// (GET /:projectId/export/:exportName, see routes/export). Exactly that path -- not any path
|
|
6
6
|
// that has an "export" segment somewhere.
|
|
@@ -8,6 +8,49 @@ const PUBLIC_EXPORT = /^\/[^/]+\/export\/[^/]+\/?$/;
|
|
|
8
8
|
const READ_METHODS = ['GET', 'HEAD', 'OPTIONS'];
|
|
9
9
|
const isPublicRoute = (method, path) => READ_METHODS.includes(method.toUpperCase()) && PUBLIC_EXPORT.test(path);
|
|
10
10
|
exports.isPublicRoute = isPublicRoute;
|
|
11
|
+
// ── API tokens ──
|
|
12
|
+
// A request made with an API token reaches only what the token's scope does: no writes with a
|
|
13
|
+
// read token, no project outside its projects (as if it didn't exist), no managing tokens, and
|
|
14
|
+
// of the routes outside a project only the few below. Its owner's role in each project still
|
|
15
|
+
// decides on top (the routes' own checks).
|
|
16
|
+
// The project a route acts on, by its path.
|
|
17
|
+
const PROJECT_PATHS = [/^\/projects\/([^/]+)/, /^\/(?:upload|delete)-project-image-url\/([^/]+)/];
|
|
18
|
+
const NOT_A_PROJECT = new Set(['create', 'explore']);
|
|
19
|
+
const projectIdOf = (path) => {
|
|
20
|
+
for (const pattern of PROJECT_PATHS) {
|
|
21
|
+
const id = pattern.exec(path)?.[1];
|
|
22
|
+
if (id && !NOT_A_PROJECT.has(id))
|
|
23
|
+
return decodeURIComponent(id);
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
};
|
|
27
|
+
// Outside a project, a token reaches only these reads (each limited to its scope there).
|
|
28
|
+
const OUTSIDE_A_PROJECT = [/^\/auth\/?$/, /^\/projects\/?$/, /^\/jobs\/[^/]+\/?$/, /^\/user\/subscription\/?$/, /^\/users\/search\/?$/];
|
|
29
|
+
/** Whether a token's scope reaches a project. */
|
|
30
|
+
const scopeReachesProject = (scope, projectId) => !scope || scope.projectIds === '*' || scope.projectIds.includes(projectId);
|
|
31
|
+
exports.scopeReachesProject = scopeReachesProject;
|
|
32
|
+
const enforceApiTokenScope = (req, res, next) => {
|
|
33
|
+
const scope = req.auth?.apiToken;
|
|
34
|
+
if (!scope)
|
|
35
|
+
return next();
|
|
36
|
+
// The MCP server handles a token itself: its tools call the routes here, each one checked.
|
|
37
|
+
if (req.path === '/mcp')
|
|
38
|
+
return next();
|
|
39
|
+
if (/^\/user\/api-tokens(\/|$)/.test(req.path)) {
|
|
40
|
+
return res.status(403).json({ error: 'API tokens are managed with a session, not with a token' });
|
|
41
|
+
}
|
|
42
|
+
if (!READ_METHODS.includes(req.method.toUpperCase()) && scope.access !== 'write') {
|
|
43
|
+
return res.status(403).json({ error: 'This API token can only read' });
|
|
44
|
+
}
|
|
45
|
+
const projectId = projectIdOf(req.path);
|
|
46
|
+
if (projectId) {
|
|
47
|
+
return (0, exports.scopeReachesProject)(scope, projectId) ? next() : res.status(404).json({ message: 'Project not found' });
|
|
48
|
+
}
|
|
49
|
+
if (READ_METHODS.includes(req.method.toUpperCase()) && OUTSIDE_A_PROJECT.some(route => route.test(req.path)))
|
|
50
|
+
return next();
|
|
51
|
+
return res.status(403).json({ error: 'Not available with an API token' });
|
|
52
|
+
};
|
|
53
|
+
exports.enforceApiTokenScope = enforceApiTokenScope;
|
|
11
54
|
const authenticate = (config) => async (req, res, next) => {
|
|
12
55
|
if ((0, exports.isPublicRoute)(req.method, req.path)) {
|
|
13
56
|
return next();
|
package/dist/services/wsHub.js
CHANGED
|
@@ -15,6 +15,12 @@ function initWsHub(wss, config) {
|
|
|
15
15
|
ws.close(1008, 'Unauthorized');
|
|
16
16
|
return;
|
|
17
17
|
}
|
|
18
|
+
// The WebSocket is the browser session's: it gets every event of its user, whatever the
|
|
19
|
+
// project, so an API token (scoped to some projects, maybe read-only) never opens it.
|
|
20
|
+
if (auth.apiToken) {
|
|
21
|
+
ws.close(1008, "API tokens can't open the WebSocket");
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
18
24
|
userId = auth.userId;
|
|
19
25
|
}
|
|
20
26
|
catch (err) {
|
package/dist/types/index.d.ts
CHANGED
|
@@ -133,6 +133,13 @@ export interface StreamByConfig {
|
|
|
133
133
|
adapter?: StorageAdapter;
|
|
134
134
|
encrypt?: string;
|
|
135
135
|
encryptPreviousKeys?: string[];
|
|
136
|
+
mcp?: {
|
|
137
|
+
rateLimit?: {
|
|
138
|
+
requests: number;
|
|
139
|
+
windowMs: number;
|
|
140
|
+
};
|
|
141
|
+
apiBaseUrl?: string;
|
|
142
|
+
};
|
|
136
143
|
websocket?: {
|
|
137
144
|
server: WebSocketServer;
|
|
138
145
|
};
|
|
@@ -158,6 +165,13 @@ export interface Auth {
|
|
|
158
165
|
userId: string;
|
|
159
166
|
username: string;
|
|
160
167
|
role: 'viewer' | 'editor' | 'admin';
|
|
168
|
+
apiToken?: ApiTokenScope;
|
|
169
|
+
}
|
|
170
|
+
export type ApiTokenAccess = 'read' | 'write';
|
|
171
|
+
export interface ApiTokenScope {
|
|
172
|
+
tokenId: string;
|
|
173
|
+
access: ApiTokenAccess;
|
|
174
|
+
projectIds: string[] | '*';
|
|
161
175
|
}
|
|
162
176
|
export type AuthProvider = (req: Request) => Promise<Auth>;
|
|
163
177
|
export type UserPlan = 'freemium' | 'subscriber' | 'admin';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "streamby-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.36.0",
|
|
4
4
|
"description": "StreamBy middleware framework for media storage management",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"@aws-sdk/client-s3": "^3.528.0",
|
|
34
34
|
"@aws-sdk/s3-request-presigner": "^3.787.0",
|
|
35
|
+
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
35
36
|
"@prisma/client": "^6.12.0",
|
|
36
37
|
"bcrypt": "^5.1.1",
|
|
37
38
|
"dotenv": "^16.5.0",
|
|
@@ -41,7 +42,8 @@
|
|
|
41
42
|
"node-fetch": "^3.0.0",
|
|
42
43
|
"pg": "^8.16.3",
|
|
43
44
|
"prisma": "^6.12.0",
|
|
44
|
-
"ws": "^8.20.0"
|
|
45
|
+
"ws": "^8.20.0",
|
|
46
|
+
"zod": "^4.6.7"
|
|
45
47
|
},
|
|
46
48
|
"devDependencies": {
|
|
47
49
|
"@types/bcrypt": "^5.0.2",
|