streamby-core 0.40.0 → 0.42.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 +60 -0
- package/README.md +163 -11
- package/dist/adapters/database/connectionManager.js +35 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +9 -1
- package/dist/mcp/audit.d.ts +5 -2
- package/dist/mcp/audit.js +8 -0
- package/dist/mcp/server.js +3 -3
- package/dist/mcp/tools.js +68 -0
- package/dist/middleware/createRouter.js +7 -0
- package/dist/middleware/routes/document.d.ts +3 -0
- package/dist/middleware/routes/document.js +343 -0
- package/dist/middleware/routes/mcp.js +11 -4
- package/dist/middleware/routes/oauthResource.d.ts +3 -0
- package/dist/middleware/routes/oauthResource.js +18 -0
- package/dist/middleware/routes/project.js +3 -0
- package/dist/services/apiTokenPermissions.d.ts +1 -1
- package/dist/services/apiTokenPermissions.js +8 -0
- package/dist/services/auth.d.ts +4 -0
- package/dist/services/auth.js +52 -10
- package/dist/services/documents.d.ts +66 -0
- package/dist/services/documents.js +347 -0
- package/dist/services/executor.js +8 -0
- package/dist/services/oauthTokens.d.ts +60 -0
- package/dist/services/oauthTokens.js +164 -0
- package/dist/types/index.d.ts +52 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,65 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.42.0
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **Project documents**: markdown a project's team reads and edits in the UI, and agents read and
|
|
8
|
+
keep up to date through the MCP server, with every save a version (who saved it, and when). The
|
|
9
|
+
README's Documents section documents them.
|
|
10
|
+
- **New tables: `documents` and `document_versions`.** They're created at startup in the main
|
|
11
|
+
database (collections in MongoDB; in memory without one).
|
|
12
|
+
- Routes under `/projects/:projectId/documents`: the tree, a document, creating, a new version
|
|
13
|
+
(`PATCH` with `baseVersion`; 409 with the document as it is now when it changed since), a
|
|
14
|
+
section's (`PATCH .../sections`), moving and marking exportable, the history
|
|
15
|
+
(`.../versions[/:version]`), publishing a version (`POST .../publish`) and deleting (a folder
|
|
16
|
+
with everything in it). Deleting a project deletes its documents.
|
|
17
|
+
- Permissions `docs:read`, `docs:write`, `docs:publish` and `docs:delete`. Publishing (a
|
|
18
|
+
version for the exports, or whether they deliver it: `POST .../publish`, `PATCH` with
|
|
19
|
+
`exportable`) needs `docs:publish`, not `docs:write`. The OAuth scopes grant reading
|
|
20
|
+
(`streamby:read`) and writing (`streamby:write`); an app publishes and deletes none, whatever
|
|
21
|
+
its scopes: what it edits stays a draft.
|
|
22
|
+
- In MongoDB, a save writes the document and its version in a transaction when the server has
|
|
23
|
+
them (a replica set or a sharded cluster); on a standalone server, two writes.
|
|
24
|
+
- MCP tools `list_documents`, `get_document`, `list_document_versions`, `create_document`,
|
|
25
|
+
`update_document` and `update_document_section`.
|
|
26
|
+
- An export's `documentsNode` delivers the published version of the project's exportable
|
|
27
|
+
documents (all, one folder's, or some), never a draft.
|
|
28
|
+
- A `documentEvent` to the project's active members on each change.
|
|
29
|
+
- Types `ProjectDocument`, `DocumentVersion`, `DocumentAuthor` and `DocumentKind`.
|
|
30
|
+
|
|
31
|
+
## 0.41.0
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
|
|
35
|
+
- **OAuth access tokens**: apps the user signs in to with their account (claude.ai's
|
|
36
|
+
connector) work with StreamBy and its MCP server, as a protected resource (RFC 9728). The
|
|
37
|
+
README documents the setup, and how to add the connector in claude.ai.
|
|
38
|
+
- `createOAuthTokenVerifier({ issuer, audiences, jwks })` checks a token with the
|
|
39
|
+
authorization server's keys (RS256, from its JWKS URL or a function), its issuer, its
|
|
40
|
+
audience (one of StreamBy's resources, RFC 8707) and its expiry. The host's `authProvider`
|
|
41
|
+
returns its user with `apiToken: verified.scope` and `oauth: { clientId, clientName? }`.
|
|
42
|
+
- Scopes map to permissions: `streamby:read` to every `:read` one, `streamby:write` to those
|
|
43
|
+
and making and changing; none to deleting nor managing members (`OAUTH_SCOPE_PERMISSIONS`,
|
|
44
|
+
`permissionsForOAuthScopes`). The user's role in each project still applies.
|
|
45
|
+
- An app deletes nothing: its `DELETE` requests answer 403 whatever their route's permission
|
|
46
|
+
(`project:update`, `workflow:write` and `exports:write` reach some that delete), but taking
|
|
47
|
+
a deliverable's mark off. API tokens are unchanged.
|
|
48
|
+
- The router's `oauth` config (`{ issuer, resource, resourceMetadataUrl?, resourceName? }`)
|
|
49
|
+
makes a request without a valid token answer 401 with
|
|
50
|
+
`WWW-Authenticate: Bearer resource_metadata="..."`, and serves the metadata at
|
|
51
|
+
`GET /.well-known/oauth-protected-resource`; `protectedResourceMetadata` builds it for the
|
|
52
|
+
host to serve at its root too.
|
|
53
|
+
- `looksLikeOAuthAccessToken` tells an access token from a host's session tokens.
|
|
54
|
+
- `Auth.oauth`: the app a request comes from. A request with it and without `apiToken` is
|
|
55
|
+
refused (an app never gets all of its user's access).
|
|
56
|
+
|
|
57
|
+
### Changed
|
|
58
|
+
|
|
59
|
+
- The MCP server takes an OAuth access token too. An app's requests are limited per user it acts
|
|
60
|
+
for, and its tool calls are recorded in `mcp_audit` by its client (`oauthClientId`, a new
|
|
61
|
+
column added at startup) with no `tokenId` (now nullable).
|
|
62
|
+
|
|
3
63
|
## 0.40.0
|
|
4
64
|
|
|
5
65
|
### ⚠️ Breaking changes
|
package/README.md
CHANGED
|
@@ -134,6 +134,7 @@ hash is stored, with a short prefix to tell it apart.
|
|
|
134
134
|
| Exports | `exports:read`, `exports:write` | The project's exports. |
|
|
135
135
|
| Rights | `rights:read`, `rights:write` | Assets' rights. |
|
|
136
136
|
| Jobs | `jobs:read`, `jobs:run` | Following jobs; starting them (transcoding, rendering, AI, publishing a deliverable). |
|
|
137
|
+
| Documents | `docs:read`, `docs:write`, `docs:publish`, `docs:delete` | The project's documents: reading them and their history; creating, editing and moving them; publishing them (the version its exports deliver, and whether they deliver it); deleting them. |
|
|
137
138
|
| Account | `projects:create` | Creating projects. A user's token only; the token reaches what it creates. |
|
|
138
139
|
|
|
139
140
|
`PROJECT_PERMISSIONS` and `ACCOUNT_PERMISSIONS` are exported. A route no permission covers
|
|
@@ -173,19 +174,107 @@ list shows only the token's projects.
|
|
|
173
174
|
|
|
174
175
|
---
|
|
175
176
|
|
|
177
|
+
## 🔐 OAuth: apps the user signs in to
|
|
178
|
+
|
|
179
|
+
An app the user signs in to with their account, such as claude.ai's connector, works with
|
|
180
|
+
StreamBy through an **OAuth access token**, not an API token. StreamBy is the protected resource
|
|
181
|
+
(RFC 9728); the tokens are issued by the host's authorization server (for Nhexa, Nhexa-API).
|
|
182
|
+
|
|
183
|
+
A token is a JWT the server signs with RS256 for one resource (its audience, RFC 8707), checked
|
|
184
|
+
with the server's published keys (JWKS) alone: no secret is shared. It reaches what the user
|
|
185
|
+
approved, and never more than the user can: their role in each project still applies.
|
|
186
|
+
|
|
187
|
+
| Scope | Permissions |
|
|
188
|
+
|---|---|
|
|
189
|
+
| `streamby:read` | Every `:read` permission: `project:read`, `members:read`, `assets:read`, `production:read`, `workflow:read`, `exports:read`, `rights:read`, `jobs:read`, `docs:read`. |
|
|
190
|
+
| `streamby:write` | Those, and making and changing: `project:update`, `assets:write`, `production:write`, `workflow:write`, `exports:write`, `rights:write`, `jobs:run`, `projects:create`, `docs:write`. |
|
|
191
|
+
|
|
192
|
+
No scope grants deleting (`*:delete`, `project:delete`) nor managing members. And since some
|
|
193
|
+
write permissions also reach routes that delete (`project:update` a connection, a worker or an
|
|
194
|
+
integration; `workflow:write` a pipeline; `exports:write` an export), **an app's `DELETE` is
|
|
195
|
+
refused (403) whatever its route**, but taking a deliverable's mark off, which only forgets the
|
|
196
|
+
mark (the asset stays). Nor does an app publish a document (`POST .../publish`, or a `PATCH`
|
|
197
|
+
changing `exportable`), whatever its scopes: what it edits stays a draft until someone publishes
|
|
198
|
+
it. API tokens aren't affected. Other scopes (`profile`, `email`) grant nothing in StreamBy. `OAUTH_SCOPE_PERMISSIONS` is exported.
|
|
199
|
+
|
|
200
|
+
**Telling clients where to sign in**
|
|
201
|
+
|
|
202
|
+
Set `oauth` in the router's config. A request without a valid token then gets a 401 with
|
|
203
|
+
`WWW-Authenticate: Bearer resource_metadata="..."`, and the metadata says which authorization
|
|
204
|
+
server to use. The router serves it at `GET /streamby/.well-known/oauth-protected-resource`;
|
|
205
|
+
serve it at your root too, where RFC 9728 puts it (`resourceMetadataUrl` defaults to that):
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
import express from 'express';
|
|
209
|
+
import { createStreamByRouter, protectedResourceMetadata } from 'streamby-core';
|
|
210
|
+
|
|
211
|
+
const oauth = { issuer: 'https://api.example.com', resource: 'https://api.example.com/streamby/mcp' };
|
|
212
|
+
app.get('/.well-known/oauth-protected-resource/streamby/mcp', (_req, res) => res.json(protectedResourceMetadata(oauth)));
|
|
213
|
+
app.use('/streamby', createStreamByRouter({ /* ... */ oauth }));
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
**Validating a token (your `authProvider`)**
|
|
217
|
+
|
|
218
|
+
Your `authProvider` checks the token with `createOAuthTokenVerifier` and returns the user, with
|
|
219
|
+
the token's scope in `apiToken` and the app in `oauth`. A request with `oauth` but no `apiToken`
|
|
220
|
+
is refused, so an app never gets all of its user's access. `looksLikeOAuthAccessToken` tells the
|
|
221
|
+
token from your own session tokens.
|
|
222
|
+
|
|
223
|
+
```ts
|
|
224
|
+
import { createOAuthTokenVerifier, looksLikeOAuthAccessToken, API_TOKEN_PREFIX } from 'streamby-core';
|
|
225
|
+
|
|
226
|
+
const verifyOAuthToken = createOAuthTokenVerifier({
|
|
227
|
+
issuer: 'https://api.example.com',
|
|
228
|
+
// What a token may be for: StreamBy's resources.
|
|
229
|
+
audiences: ['https://api.example.com/streamby', 'https://api.example.com/streamby/mcp'],
|
|
230
|
+
// The server's JWKS URL, or a function giving its keys (when the host is the server itself).
|
|
231
|
+
jwks: 'https://api.example.com/.well-known/jwks.json',
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
createStreamByRouter({
|
|
235
|
+
// ...
|
|
236
|
+
authProvider: async (req) => {
|
|
237
|
+
const bearer = req.headers.authorization?.replace(/^Bearer /, '');
|
|
238
|
+
if (bearer?.startsWith(API_TOKEN_PREFIX)) { /* an API token, as above */ }
|
|
239
|
+
if (looksLikeOAuthAccessToken(bearer)) {
|
|
240
|
+
const verified = await verifyOAuthToken(bearer); // null: forged, expired, or for another resource
|
|
241
|
+
if (!verified) throw new Error('Invalid OAuth access token');
|
|
242
|
+
// Optional but recommended: has the user taken the app's access away? (your own records)
|
|
243
|
+
if (!(await stillConnected(verified.userId, verified.clientId))) throw new Error('Access revoked');
|
|
244
|
+
const user = await findUser(verified.userId);
|
|
245
|
+
return {
|
|
246
|
+
userId: user.id, username: user.username, role: user.role,
|
|
247
|
+
apiToken: verified.scope,
|
|
248
|
+
oauth: { clientId: verified.clientId, clientName: 'Claude' }, // the name, if you know it
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
// ...your session-based authentication
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
The verifier keeps the keys for 5 minutes (`jwksCacheMs`) and fetches them again for a key id it
|
|
257
|
+
doesn't know (a rotation), at most once a minute. It doesn't know whether the user has since
|
|
258
|
+
taken the app's access away: without that check, a revoked app keeps its access until its access
|
|
259
|
+
token expires (Nhexa's last an hour). Nhexa-API checks the user's consent on every request, so
|
|
260
|
+
taking the access away in accounts.nhexa.cl ends it at once.
|
|
261
|
+
|
|
262
|
+
---
|
|
263
|
+
|
|
176
264
|
## 🤖 MCP server
|
|
177
265
|
|
|
178
266
|
StreamBy serves an [MCP](https://modelcontextprotocol.io) server at `POST /streamby/mcp`
|
|
179
267
|
(Streamable HTTP, stateless). Agents such as Claude Code use it to read and work on StreamBy
|
|
180
|
-
projects with an API token
|
|
268
|
+
projects with an API token, and apps the user signed in to (claude.ai's connector) with an OAuth
|
|
269
|
+
access token (see above). A browser session gets 401.
|
|
181
270
|
|
|
182
271
|
Each tool calls StreamBy's own REST API with the caller's token. So it reaches exactly what the
|
|
183
|
-
token allows (and, for a user's token, what
|
|
272
|
+
token allows (and, for a user's token or an app, what the user's role in each project allows), and a
|
|
184
273
|
change is saved just like one made from the UI, which shows it when it loads the project.
|
|
185
274
|
|
|
186
275
|
**Tools**
|
|
187
276
|
|
|
188
|
-
A token sees only the tools whose permissions it has.
|
|
277
|
+
A token sees only the tools whose permissions it has (an app: those its scopes grant).
|
|
189
278
|
|
|
190
279
|
| Tools | Permissions |
|
|
191
280
|
|---|---|
|
|
@@ -210,6 +299,8 @@ A token sees only the tools whose permissions it has.
|
|
|
210
299
|
| `delete_sequence`, `delete_shot`, `delete_task` | `production:delete` |
|
|
211
300
|
| `list_asset_versions`, `get_asset_download_url`, `list_deliverables` | `assets:read` |
|
|
212
301
|
| `request_asset_upload`, `mark_deliverable`, `remove_deliverable` | `assets:write` |
|
|
302
|
+
| `list_documents`, `get_document`, `list_document_versions` | `docs:read` |
|
|
303
|
+
| `create_document`, `update_document`, `update_document_section` | `docs:write` |
|
|
213
304
|
|
|
214
305
|
The tools take the path a user does in the UI, and each one's description says where its
|
|
215
306
|
result shows there. Production data lives under a production node of the project's workflow:
|
|
@@ -273,13 +364,14 @@ code there that downloads the URL and imports the file (in Blender,
|
|
|
273
364
|
|
|
274
365
|
**Limits and audit**
|
|
275
366
|
|
|
276
|
-
- Each token can make at most 120 requests a minute (a sliding window)
|
|
277
|
-
answers 429 with `Retry-After`. Each instance counts its own
|
|
278
|
-
`mcp.rateLimit`.
|
|
367
|
+
- Each token can make at most 120 requests a minute (a sliding window); an app, per user it acts
|
|
368
|
+
for. Past that, StreamBy answers 429 with `Retry-After`. Each instance counts its own
|
|
369
|
+
requests. Change the limit with `mcp.rateLimit`.
|
|
279
370
|
- Every tool call is recorded in the `mcp_audit` table, created at startup in the main database.
|
|
280
|
-
Each row has the user (a project's token: the token itself), the token's id
|
|
281
|
-
|
|
282
|
-
the arguments or the
|
|
371
|
+
Each row has the user (a project's token: the token itself), the token's id (an app's call:
|
|
372
|
+
its OAuth client, in `oauthClientId`, and no token), the project, the tool, whether it went
|
|
373
|
+
through, the status, the error and the duration. A row never includes the arguments or the
|
|
374
|
+
token.
|
|
283
375
|
|
|
284
376
|
```ts
|
|
285
377
|
createStreamByRouter({
|
|
@@ -337,7 +429,67 @@ and an expiry:
|
|
|
337
429
|
}
|
|
338
430
|
```
|
|
339
431
|
|
|
340
|
-
|
|
432
|
+
### Connecting from claude.ai
|
|
433
|
+
|
|
434
|
+
claude.ai connects through a custom connector, which signs in with OAuth (see above): nobody on
|
|
435
|
+
the team sets up a token or a file. Each person signs in with their own account and approves what
|
|
436
|
+
Claude may do, and Claude reaches only that, and only what their role in each project allows.
|
|
437
|
+
|
|
438
|
+
- **For yourself**: in claude.ai, Settings → Connectors → Add custom connector. Give it a name
|
|
439
|
+
(StreamBy) and the server's URL, `https://your-host/streamby/mcp`, and add it. Then Connect:
|
|
440
|
+
claude.ai opens the sign-in page, and after you sign in, the consent screen, with what it asks
|
|
441
|
+
for (`streamby:read`, and `streamby:write` to make and change things). Approve it.
|
|
442
|
+
- **For your organization** (Team and Enterprise plans): an owner adds it once in Organization
|
|
443
|
+
settings → Connectors, with the same URL; each member then connects it with their own account
|
|
444
|
+
from their Settings → Connectors.
|
|
445
|
+
|
|
446
|
+
claude.ai registers itself with the authorization server (dynamic client registration or a
|
|
447
|
+
client ID metadata document), so there's nothing to set up there. To take its access away, the
|
|
448
|
+
person removes it in their account's connected apps (in Nhexa, accounts.nhexa.cl → Aplicaciones
|
|
449
|
+
→ Quitar acceso), or disconnects it in claude.ai.
|
|
450
|
+
|
|
451
|
+
---
|
|
452
|
+
|
|
453
|
+
## 📄 Documents
|
|
454
|
+
|
|
455
|
+
Each project keeps its documents: markdown its team reads and edits (its concept, its
|
|
456
|
+
architecture, its decisions), and agents read and keep up to date through the MCP server. They're
|
|
457
|
+
kept in the main database (the `documents` and `document_versions` tables, created at startup,
|
|
458
|
+
or collections), each one in a single project, in folders.
|
|
459
|
+
|
|
460
|
+
- **Every save is a version**, with who saved it and when: a user, an API token, or an app on a
|
|
461
|
+
user's behalf (`{ userId, username, via: 'session' | 'apiToken' | 'oauth', tokenId?, clientId?, clientName? }`).
|
|
462
|
+
- **Nothing is overwritten without notice.** A change says the version it was made on
|
|
463
|
+
(`baseVersion`); if someone saved another one since, it's refused with a 409 and the document
|
|
464
|
+
as it is now, to read again.
|
|
465
|
+
- **A section** is what's under a heading (`## Architecture`), up to the next one of the same
|
|
466
|
+
level or above: it can be replaced alone, the rest untouched.
|
|
467
|
+
- **Drafts and published versions.** A document marked exportable delivers, through the project's
|
|
468
|
+
exports, only the version published from its history; editing it changes nothing exported until
|
|
469
|
+
another version is published. An export's `documentsNode` (on the StreamBy node's data input)
|
|
470
|
+
delivers them, `[{ id, title, parentId, position, version, content, publishedAt }]`: all the
|
|
471
|
+
project's, those in one folder (`data.folderId`), or some (`data.documentIds`).
|
|
472
|
+
- **In real time**, a `documentEvent` reaches the project's active members on each change:
|
|
473
|
+
`{ projectId, action: 'created' | 'saved' | 'changed' | 'deleted', documentIds, version?, title?, author }`
|
|
474
|
+
(`saved` is a new version; `changed`, a move or a publication).
|
|
475
|
+
|
|
476
|
+
| Route | Permission |
|
|
477
|
+
|---|---|
|
|
478
|
+
| `GET /projects/:projectId/documents`: the tree, without contents | `docs:read` |
|
|
479
|
+
| `POST /projects/:projectId/documents` `{ title, content?, kind?: 'document' \| 'folder', parentId?, position? }` | `docs:write` |
|
|
480
|
+
| `GET /projects/:projectId/documents/:docId` | `docs:read` |
|
|
481
|
+
| `PATCH /projects/:projectId/documents/:docId` `{ baseVersion, title?, content? }` (a new version) and/or `{ parentId?, position? }` | `docs:write` |
|
|
482
|
+
| `PATCH /projects/:projectId/documents/:docId` `{ exportable }` | `docs:write` and `docs:publish` |
|
|
483
|
+
| `PATCH /projects/:projectId/documents/:docId/sections` `{ baseVersion, heading, content }` | `docs:write` |
|
|
484
|
+
| `GET /projects/:projectId/documents/:docId/versions` and `/versions/:version` | `docs:read` |
|
|
485
|
+
| `POST /projects/:projectId/documents/:docId/publish` `{ version }` (`null`: none) | `docs:publish` |
|
|
486
|
+
| `DELETE /projects/:projectId/documents/:docId` (a folder, with everything in it) | `docs:delete` |
|
|
487
|
+
|
|
488
|
+
Editors and admins create, edit, publish and delete them; viewers read them. An app connected
|
|
489
|
+
with OAuth reads and edits them, but publishes and deletes none: what it edits stays a draft.
|
|
490
|
+
|
|
491
|
+
In MongoDB, a save writes the document and its version in a transaction when the server has them
|
|
492
|
+
(a replica set or a sharded cluster). A standalone server doesn't: there they're two writes.
|
|
341
493
|
|
|
342
494
|
---
|
|
343
495
|
|
|
@@ -455,7 +607,7 @@ What a user can do in a project depends on their role in that project, as an act
|
|
|
455
607
|
| Role | Can |
|
|
456
608
|
|---|---|
|
|
457
609
|
| `viewer` | See the project and its content, and archive the project in their own list. |
|
|
458
|
-
| `editor` | Everything a viewer can, plus change the project: its details, connections, exports, files and folders, workflow, pipelines, production, reviews, rights, and start jobs. |
|
|
610
|
+
| `editor` | Everything a viewer can, plus change the project: its details, connections, exports, files and folders, workflow, pipelines, production, reviews, rights, documents, and start jobs. |
|
|
459
611
|
| `admin` | Everything an editor can, plus manage the project's members and delete the project. |
|
|
460
612
|
|
|
461
613
|
A project's public exports (`GET /streamby/:projectId/export/:exportName`) are read without a
|
|
@@ -249,8 +249,43 @@ const createTables = async (pool) => {
|
|
|
249
249
|
"createdAt" TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
250
250
|
);
|
|
251
251
|
CREATE INDEX IF NOT EXISTS mcp_audit_token_idx ON streamby.mcp_audit ("tokenId", "createdAt");
|
|
252
|
+
-- An app's calls (an OAuth access token) are recorded by its client, with no token.
|
|
253
|
+
ALTER TABLE streamby.mcp_audit ADD COLUMN IF NOT EXISTS "oauthClientId" VARCHAR(255);
|
|
254
|
+
ALTER TABLE streamby.mcp_audit ALTER COLUMN "tokenId" DROP NOT NULL;
|
|
252
255
|
`);
|
|
253
256
|
console.log('✅ "mcp_audit" table ensured to exist.');
|
|
257
|
+
// Projects' documents and their versions (see services/documents). Main db only.
|
|
258
|
+
await pool.query(`
|
|
259
|
+
CREATE TABLE IF NOT EXISTS streamby.documents (
|
|
260
|
+
id TEXT PRIMARY KEY,
|
|
261
|
+
"projectId" VARCHAR(255) NOT NULL,
|
|
262
|
+
kind VARCHAR(20) NOT NULL DEFAULT 'document',
|
|
263
|
+
title TEXT NOT NULL,
|
|
264
|
+
"parentId" TEXT,
|
|
265
|
+
position INTEGER NOT NULL DEFAULT 0,
|
|
266
|
+
content TEXT NOT NULL DEFAULT '',
|
|
267
|
+
version INTEGER NOT NULL,
|
|
268
|
+
exportable BOOLEAN NOT NULL DEFAULT FALSE,
|
|
269
|
+
"publishedVersion" INTEGER,
|
|
270
|
+
"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
|
271
|
+
"createdBy" JSONB NOT NULL,
|
|
272
|
+
"updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
|
273
|
+
"updatedBy" JSONB NOT NULL
|
|
274
|
+
);
|
|
275
|
+
CREATE INDEX IF NOT EXISTS documents_project_idx ON streamby.documents ("projectId");
|
|
276
|
+
CREATE TABLE IF NOT EXISTS streamby.document_versions (
|
|
277
|
+
"documentId" TEXT NOT NULL,
|
|
278
|
+
"projectId" VARCHAR(255) NOT NULL,
|
|
279
|
+
version INTEGER NOT NULL,
|
|
280
|
+
title TEXT NOT NULL,
|
|
281
|
+
content TEXT NOT NULL,
|
|
282
|
+
author JSONB NOT NULL,
|
|
283
|
+
"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
|
284
|
+
PRIMARY KEY ("documentId", version)
|
|
285
|
+
);
|
|
286
|
+
CREATE INDEX IF NOT EXISTS document_versions_project_idx ON streamby.document_versions ("projectId");
|
|
287
|
+
`);
|
|
288
|
+
console.log('✅ "documents" and "document_versions" tables ensured to exist.');
|
|
254
289
|
}
|
|
255
290
|
catch (error) {
|
|
256
291
|
console.error('❌ Error ensuring tables exist:', error);
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
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, ApiTokenKind, ApiTokenPermission, ProjectPermission, AccountPermission } from './types';
|
|
4
|
+
export type { UserPlan, UserSubscription, Auth, ApiTokenScope, ApiTokenKind, ApiTokenPermission, ProjectPermission, AccountPermission, OAuthClientInfo, OAuthResourceConfig, ProjectDocument, DocumentVersion, DocumentAuthor, DocumentKind } from './types';
|
|
5
5
|
export { resolveApiToken, API_TOKEN_PREFIX } from './services/apiTokens';
|
|
6
6
|
export { PROJECT_PERMISSIONS, ACCOUNT_PERMISSIONS } from './services/apiTokenPermissions';
|
|
7
7
|
export type { ResolvedApiToken } from './services/apiTokens';
|
|
8
|
+
export { createOAuthTokenVerifier, looksLikeOAuthAccessToken, protectedResourceMetadata, defaultResourceMetadataUrl, permissionsForOAuthScopes, OAUTH_SCOPES, OAUTH_SCOPE_PERMISSIONS, } from './services/oauthTokens';
|
|
9
|
+
export type { VerifiedOAuthToken, OAuthTokenVerifierOptions, Jwks, Jwk, OAuthScope } from './services/oauthTokens';
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.ACCOUNT_PERMISSIONS = exports.PROJECT_PERMISSIONS = exports.API_TOKEN_PREFIX = exports.resolveApiToken = exports.createNotification = exports.emitToUser = exports.createStreamByRouter = void 0;
|
|
3
|
+
exports.OAUTH_SCOPE_PERMISSIONS = exports.OAUTH_SCOPES = exports.permissionsForOAuthScopes = exports.defaultResourceMetadataUrl = exports.protectedResourceMetadata = exports.looksLikeOAuthAccessToken = exports.createOAuthTokenVerifier = 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");
|
|
@@ -13,3 +13,11 @@ Object.defineProperty(exports, "API_TOKEN_PREFIX", { enumerable: true, get: func
|
|
|
13
13
|
var apiTokenPermissions_1 = require("./services/apiTokenPermissions");
|
|
14
14
|
Object.defineProperty(exports, "PROJECT_PERMISSIONS", { enumerable: true, get: function () { return apiTokenPermissions_1.PROJECT_PERMISSIONS; } });
|
|
15
15
|
Object.defineProperty(exports, "ACCOUNT_PERMISSIONS", { enumerable: true, get: function () { return apiTokenPermissions_1.ACCOUNT_PERMISSIONS; } });
|
|
16
|
+
var oauthTokens_1 = require("./services/oauthTokens");
|
|
17
|
+
Object.defineProperty(exports, "createOAuthTokenVerifier", { enumerable: true, get: function () { return oauthTokens_1.createOAuthTokenVerifier; } });
|
|
18
|
+
Object.defineProperty(exports, "looksLikeOAuthAccessToken", { enumerable: true, get: function () { return oauthTokens_1.looksLikeOAuthAccessToken; } });
|
|
19
|
+
Object.defineProperty(exports, "protectedResourceMetadata", { enumerable: true, get: function () { return oauthTokens_1.protectedResourceMetadata; } });
|
|
20
|
+
Object.defineProperty(exports, "defaultResourceMetadataUrl", { enumerable: true, get: function () { return oauthTokens_1.defaultResourceMetadataUrl; } });
|
|
21
|
+
Object.defineProperty(exports, "permissionsForOAuthScopes", { enumerable: true, get: function () { return oauthTokens_1.permissionsForOAuthScopes; } });
|
|
22
|
+
Object.defineProperty(exports, "OAUTH_SCOPES", { enumerable: true, get: function () { return oauthTokens_1.OAUTH_SCOPES; } });
|
|
23
|
+
Object.defineProperty(exports, "OAUTH_SCOPE_PERMISSIONS", { enumerable: true, get: function () { return oauthTokens_1.OAUTH_SCOPE_PERMISSIONS; } });
|
package/dist/mcp/audit.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { StreamByConfig } from '../types';
|
|
1
|
+
import { Auth, StreamByConfig } from '../types';
|
|
2
2
|
export interface McpAuditEntry {
|
|
3
3
|
userId: string;
|
|
4
|
-
tokenId: string;
|
|
4
|
+
tokenId: string | null;
|
|
5
|
+
oauthClientId: string | null;
|
|
5
6
|
projectId: string | null;
|
|
6
7
|
tool: string;
|
|
7
8
|
ok: boolean;
|
|
@@ -9,4 +10,6 @@ export interface McpAuditEntry {
|
|
|
9
10
|
error: string | null;
|
|
10
11
|
durationMs: number;
|
|
11
12
|
}
|
|
13
|
+
/** Who made an MCP request, as the audit log keeps it. */
|
|
14
|
+
export declare const auditCallerOf: (auth: Auth) => Pick<McpAuditEntry, "userId" | "tokenId" | "oauthClientId">;
|
|
12
15
|
export declare function recordMcpCall(entry: McpAuditEntry, config: StreamByConfig): Promise<void>;
|
package/dist/mcp/audit.js
CHANGED
|
@@ -3,9 +3,17 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.auditCallerOf = void 0;
|
|
6
7
|
exports.recordMcpCall = recordMcpCall;
|
|
7
8
|
const crypto_1 = __importDefault(require("crypto"));
|
|
8
9
|
const manager_1 = require("../models/manager");
|
|
10
|
+
/** Who made an MCP request, as the audit log keeps it. */
|
|
11
|
+
const auditCallerOf = (auth) => ({
|
|
12
|
+
userId: auth.userId,
|
|
13
|
+
tokenId: auth.oauth ? null : auth.apiToken?.tokenId ?? null,
|
|
14
|
+
oauthClientId: auth.oauth?.clientId ?? null,
|
|
15
|
+
});
|
|
16
|
+
exports.auditCallerOf = auditCallerOf;
|
|
9
17
|
async function recordMcpCall(entry, config) {
|
|
10
18
|
try {
|
|
11
19
|
const dbType = config.databases?.find(db => db.main)?.type ?? 'nosql';
|
package/dist/mcp/server.js
CHANGED
|
@@ -40,7 +40,7 @@ function createMcpServer(auth, api, config, version) {
|
|
|
40
40
|
const refused = await (0, workflowNodes_1.ensureWorkflowNode)(tool.workflowNode, tool.projectOf?.(args), auth, api);
|
|
41
41
|
if (refused) {
|
|
42
42
|
await (0, audit_1.recordMcpCall)({
|
|
43
|
-
|
|
43
|
+
...(0, audit_1.auditCallerOf)(auth), projectId, tool: tool.name,
|
|
44
44
|
ok: false, status: null, error: refused, durationMs: Date.now() - started,
|
|
45
45
|
}, config);
|
|
46
46
|
return { isError: true, content: [{ type: 'text', text: refused }] };
|
|
@@ -50,7 +50,7 @@ function createMcpServer(auth, api, config, version) {
|
|
|
50
50
|
status = answer.status;
|
|
51
51
|
const ok = status >= 200 && status < 300;
|
|
52
52
|
await (0, audit_1.recordMcpCall)({
|
|
53
|
-
|
|
53
|
+
...(0, audit_1.auditCallerOf)(auth), projectId, tool: tool.name,
|
|
54
54
|
ok, status, error: ok ? null : errorText(answer.body, status), durationMs: Date.now() - started,
|
|
55
55
|
}, config);
|
|
56
56
|
if (!ok)
|
|
@@ -61,7 +61,7 @@ function createMcpServer(auth, api, config, version) {
|
|
|
61
61
|
catch (err) {
|
|
62
62
|
const message = err instanceof Error ? err.message : String(err);
|
|
63
63
|
await (0, audit_1.recordMcpCall)({
|
|
64
|
-
|
|
64
|
+
...(0, audit_1.auditCallerOf)(auth), projectId, tool: tool.name,
|
|
65
65
|
ok: false, status, error: message, durationMs: Date.now() - started,
|
|
66
66
|
}, config);
|
|
67
67
|
return { isError: true, content: [{ type: 'text', text: `The call failed: ${message}` }] };
|
package/dist/mcp/tools.js
CHANGED
|
@@ -358,6 +358,74 @@ exports.TOOLS = [
|
|
|
358
358
|
call: ({ projectId, jobId }) => ({ method: 'POST', path: `/projects/${enc(projectId)}/jobs/${enc(jobId)}/retry` }),
|
|
359
359
|
pick: (data) => data?.job,
|
|
360
360
|
}),
|
|
361
|
+
// ── Documents ──
|
|
362
|
+
tool({
|
|
363
|
+
name: 'list_documents', title: 'List documents', permissions: ['docs:read'],
|
|
364
|
+
description: "A project's documents (its concept, architecture, decisions...), as the tree of its Documents section shows them: each one's id, title, folder (parentId; a folder's kind is 'folder'), position, current version and the version published through its exports, without their content (read one with get_document).",
|
|
365
|
+
input: { projectId },
|
|
366
|
+
projectOf: byProject,
|
|
367
|
+
call: ({ projectId }) => ({ method: 'GET', path: `/projects/${enc(projectId)}/documents` }),
|
|
368
|
+
pick: (data) => data?.documents ?? [],
|
|
369
|
+
}),
|
|
370
|
+
tool({
|
|
371
|
+
name: 'get_document', title: 'Read a document', permissions: ['docs:read'],
|
|
372
|
+
description: "A document's markdown and its current version, the one an update is made on. With `version`, that past version instead (see list_document_versions).",
|
|
373
|
+
input: { projectId, documentId: id('document'), version: zod_1.z.number().int().min(1).optional().describe('A past version to read; the current one if not given') },
|
|
374
|
+
projectOf: byProject,
|
|
375
|
+
call: ({ projectId, documentId, version }) => ({
|
|
376
|
+
method: 'GET',
|
|
377
|
+
path: `/projects/${enc(projectId)}/documents/${enc(documentId)}${version ? `/versions/${version}` : ''}`,
|
|
378
|
+
}),
|
|
379
|
+
pick: (data) => data?.document ?? data?.version,
|
|
380
|
+
}),
|
|
381
|
+
tool({
|
|
382
|
+
name: 'list_document_versions', title: "List a document's versions", permissions: ['docs:read'],
|
|
383
|
+
description: "A document's history, the latest first: each version's number, title, who saved it (a user, an API token, or an app on a user's behalf) and when.",
|
|
384
|
+
input: { projectId, documentId: id('document') },
|
|
385
|
+
projectOf: byProject,
|
|
386
|
+
call: ({ projectId, documentId }) => ({ method: 'GET', path: `/projects/${enc(projectId)}/documents/${enc(documentId)}/versions` }),
|
|
387
|
+
pick: (data) => data?.versions ?? [],
|
|
388
|
+
}),
|
|
389
|
+
tool({
|
|
390
|
+
name: 'create_document', title: 'Create a document', permissions: ['docs:write'], write: true,
|
|
391
|
+
description: "Creates a markdown document (or, with kind 'folder', a folder to group them) in a project's Documents section, at the end of its folder. It's a draft: nothing is exported until someone publishes a version of it in the UI.",
|
|
392
|
+
input: {
|
|
393
|
+
projectId,
|
|
394
|
+
title: zod_1.z.string().min(1),
|
|
395
|
+
content: zod_1.z.string().optional().describe('Its markdown'),
|
|
396
|
+
kind: zod_1.z.enum(['document', 'folder']).optional(),
|
|
397
|
+
parentId: zod_1.z.string().optional().describe("The folder it goes in (a folder's id); at the top if not given"),
|
|
398
|
+
},
|
|
399
|
+
projectOf: byProject,
|
|
400
|
+
call: ({ projectId, ...fields }) => ({ method: 'POST', path: `/projects/${enc(projectId)}/documents`, body: defined(fields) }),
|
|
401
|
+
pick: (data) => data?.document,
|
|
402
|
+
}),
|
|
403
|
+
tool({
|
|
404
|
+
name: 'update_document', title: 'Update a document', permissions: ['docs:write'], write: true,
|
|
405
|
+
description: "Saves a new version of a document: its whole markdown, its title, or both. `baseVersion` is the version it was read at (get_document): if someone saved another one since, nothing is saved and the answer says so; read it again and make the change on the new version. To change one part, update_document_section rewrites less.",
|
|
406
|
+
input: {
|
|
407
|
+
projectId, documentId: id('document'),
|
|
408
|
+
baseVersion: zod_1.z.number().int().min(1).describe('The version the change was made on'),
|
|
409
|
+
title: zod_1.z.string().min(1).optional(),
|
|
410
|
+
content: zod_1.z.string().optional().describe('The whole new markdown'),
|
|
411
|
+
},
|
|
412
|
+
projectOf: byProject,
|
|
413
|
+
call: ({ projectId, documentId, ...fields }) => ({ method: 'PATCH', path: `/projects/${enc(projectId)}/documents/${enc(documentId)}`, body: defined(fields) }),
|
|
414
|
+
pick: (data) => data?.document,
|
|
415
|
+
}),
|
|
416
|
+
tool({
|
|
417
|
+
name: 'update_document_section', title: "Update a document's section", permissions: ['docs:write'], write: true,
|
|
418
|
+
description: "Saves a new version of a document with only what's under one of its headings replaced (up to the next heading of the same level or above), the rest untouched. `heading` is the heading as written (\"## Architecture\") or its title alone. `baseVersion` is the version it was read at: if the document changed since, nothing is saved; read it again.",
|
|
419
|
+
input: {
|
|
420
|
+
projectId, documentId: id('document'),
|
|
421
|
+
baseVersion: zod_1.z.number().int().min(1).describe('The version the change was made on'),
|
|
422
|
+
heading: zod_1.z.string().min(1).describe('The section\'s heading, e.g. "## Architecture"'),
|
|
423
|
+
content: zod_1.z.string().describe("The section's new markdown, without its heading"),
|
|
424
|
+
},
|
|
425
|
+
projectOf: byProject,
|
|
426
|
+
call: ({ projectId, documentId, ...fields }) => ({ method: 'PATCH', path: `/projects/${enc(projectId)}/documents/${enc(documentId)}/sections`, body: fields }),
|
|
427
|
+
pick: (data) => data?.document,
|
|
428
|
+
}),
|
|
361
429
|
// ── Projects ──
|
|
362
430
|
tool({
|
|
363
431
|
name: 'create_project', title: 'Create a project', permissions: ['projects:create'], write: true,
|
|
@@ -28,6 +28,7 @@ const dbConnection_1 = require("./routes/dbConnection");
|
|
|
28
28
|
const storageConnection_1 = require("./routes/storageConnection");
|
|
29
29
|
const userIntegration_1 = require("./routes/userIntegration");
|
|
30
30
|
const oauth_1 = require("./routes/oauth");
|
|
31
|
+
const oauthResource_1 = require("./routes/oauthResource");
|
|
31
32
|
const notification_1 = require("./routes/notification");
|
|
32
33
|
const subscription_1 = require("./routes/subscription");
|
|
33
34
|
const media_1 = require("./routes/media");
|
|
@@ -43,11 +44,14 @@ const worker_1 = require("./routes/worker");
|
|
|
43
44
|
const workerCallback_1 = require("./routes/workerCallback");
|
|
44
45
|
const workerJobs_1 = require("../services/workerJobs");
|
|
45
46
|
const deliverable_1 = require("./routes/deliverable");
|
|
47
|
+
const document_1 = require("./routes/document");
|
|
48
|
+
const documents_1 = require("../services/documents");
|
|
46
49
|
function createStreamByRouter(config) {
|
|
47
50
|
const router = express_1.default.Router();
|
|
48
51
|
if (config.encrypt)
|
|
49
52
|
(0, encryption_1.setEncryptionKey)(config.encrypt, config.encryptPreviousKeys);
|
|
50
53
|
(0, jobStore_1.setJobStoreDatabase)(config);
|
|
54
|
+
(0, documents_1.setDocumentStoreDatabase)(config);
|
|
51
55
|
// Establish DB connections asynchronously (Postgres/Mongo + schema migrations).
|
|
52
56
|
// Until they're ready, HOLD incoming requests instead of letting them through —
|
|
53
57
|
// route handlers resolve projects/exports (e.g. the /menu-list export) against a
|
|
@@ -112,6 +116,8 @@ function createStreamByRouter(config) {
|
|
|
112
116
|
if (config.websocket?.server) {
|
|
113
117
|
(0, wsHub_1.initWsHub)(config.websocket.server, config);
|
|
114
118
|
}
|
|
119
|
+
// Where to sign in for a token (RFC 9728): open, before anything asks for one.
|
|
120
|
+
router.use((0, oauthResource_1.oauthResourceRouter)(config));
|
|
115
121
|
// /auth answers whether there's a session (it reads it itself); everything after needs one,
|
|
116
122
|
// but for the public export route (see authenticate).
|
|
117
123
|
router.use((0, auth_2.authRouter)(config));
|
|
@@ -146,5 +152,6 @@ function createStreamByRouter(config) {
|
|
|
146
152
|
router.use((0, pipeline_1.pipelineRouter)(config));
|
|
147
153
|
router.use((0, worker_1.workerRouter)({ ...config, adapter }));
|
|
148
154
|
router.use((0, deliverable_1.deliverableRouter)({ ...config, adapter }));
|
|
155
|
+
router.use((0, document_1.documentRouter)(config));
|
|
149
156
|
return router;
|
|
150
157
|
}
|