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.
@@ -0,0 +1,343 @@
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.documentRouter = documentRouter;
7
+ const express_1 = require("express");
8
+ const crypto_1 = __importDefault(require("crypto"));
9
+ const manager_1 = require("../../models/manager");
10
+ const auth_1 = require("../../utils/auth");
11
+ const wsHub_1 = require("../../services/wsHub");
12
+ const documents_1 = require("../../services/documents");
13
+ const auth_2 = require("../../services/auth");
14
+ const MAX_TITLE = 200;
15
+ const MAX_CONTENT = 1000000;
16
+ // What happened to a project's documents, to whoever is in it now: a view of a document
17
+ // refreshes itself, an editor warns of a newer version. `version` and `author` come with a save.
18
+ function broadcast(project, data) {
19
+ const projectId = String(project._id ?? project.id);
20
+ for (const member of project.members ?? []) {
21
+ if ((0, auth_1.activeMemberOf)(project, member.userId))
22
+ (0, wsHub_1.emitToUser)(member.userId, { type: 'documentEvent', data: { projectId, ...data } });
23
+ }
24
+ }
25
+ const isVersion = (value) => Number.isInteger(value) && value >= 1;
26
+ function checkTitle(value) {
27
+ if (typeof value !== 'string' || !value.trim())
28
+ return { error: 'A title is required' };
29
+ if (value.trim().length > MAX_TITLE)
30
+ return { error: `A title has at most ${MAX_TITLE} characters` };
31
+ return value.trim();
32
+ }
33
+ function checkContent(value) {
34
+ if (typeof value !== 'string')
35
+ return { error: 'content must be text (markdown)' };
36
+ if (value.length > MAX_CONTENT)
37
+ return { error: `A document has at most ${MAX_CONTENT} characters` };
38
+ return value;
39
+ }
40
+ // Where a document may go: the top (null), or a folder of the same project that isn't the
41
+ // document itself nor inside it.
42
+ function checkParent(value, documents, movingId) {
43
+ if (value === null)
44
+ return null;
45
+ if (typeof value !== 'string')
46
+ return { error: 'parentId must be a folder\'s id, or null' };
47
+ const parent = documents.find(d => d.id === value);
48
+ if (!parent)
49
+ return { error: 'No such folder in this project' };
50
+ if (parent.kind !== 'folder')
51
+ return { error: 'A document goes in a folder, not in another document' };
52
+ if (movingId && (0, documents_1.withDescendants)(documents, movingId).includes(value))
53
+ return { error: 'A folder can\'t go inside itself' };
54
+ return value;
55
+ }
56
+ const failed = (value) => typeof value === 'object' && value !== null && 'error' in value;
57
+ function documentRouter(_config) {
58
+ const router = (0, express_1.Router)();
59
+ const Project = (0, manager_1.getModel)('projects');
60
+ // The project, if the caller may see it (or, with `edit`, change it); otherwise the answer.
61
+ async function projectFor(req, res, edit = false) {
62
+ const auth = req.auth;
63
+ const project = await Project.findOne({ _id: req.params.projectId });
64
+ if (!project) {
65
+ res.status(404).json({ message: 'Project not found' });
66
+ return null;
67
+ }
68
+ if (!(edit ? auth_1.canEditProject : auth_1.isProjectMember)(project, auth.userId)) {
69
+ res.status(403).json({ message: 'Unauthorized' });
70
+ return null;
71
+ }
72
+ return { project, auth, projectId: String(req.params.projectId) };
73
+ }
74
+ const conflict = (res, current) => res.status(409).json({
75
+ message: `The document changed: it's at version ${current.version} now. Read it again, and make the change on that version.`,
76
+ document: current,
77
+ });
78
+ router.get('/projects/:projectId/documents', async (req, res) => {
79
+ try {
80
+ const found = await projectFor(req, res);
81
+ if (!found)
82
+ return;
83
+ res.json({ documents: await (0, documents_1.documentStore)().list(found.projectId) });
84
+ }
85
+ catch (err) {
86
+ res.status(500).json({ message: err.message });
87
+ }
88
+ });
89
+ router.post('/projects/:projectId/documents', async (req, res) => {
90
+ try {
91
+ const found = await projectFor(req, res, true);
92
+ if (!found)
93
+ return;
94
+ const { project, auth, projectId } = found;
95
+ const kind = req.body?.kind === 'folder' ? 'folder' : 'document';
96
+ if (req.body?.kind !== undefined && !['document', 'folder'].includes(req.body.kind))
97
+ return res.status(400).json({ message: "kind is 'document' or 'folder'" });
98
+ const title = checkTitle(req.body?.title);
99
+ if (failed(title))
100
+ return res.status(400).json({ message: title.error });
101
+ if (kind === 'folder' && req.body?.content)
102
+ return res.status(400).json({ message: 'A folder has no content' });
103
+ const content = kind === 'folder' ? '' : checkContent(req.body?.content ?? '');
104
+ if (failed(content))
105
+ return res.status(400).json({ message: content.error });
106
+ const store = (0, documents_1.documentStore)();
107
+ const documents = await store.list(projectId);
108
+ const parentId = checkParent(req.body?.parentId ?? null, documents);
109
+ if (failed(parentId))
110
+ return res.status(400).json({ message: parentId.error });
111
+ if (req.body?.position !== undefined && !Number.isInteger(req.body.position))
112
+ return res.status(400).json({ message: 'position must be a whole number' });
113
+ // At the end of its folder, unless placed.
114
+ const siblings = documents.filter(d => d.parentId === parentId);
115
+ const position = req.body?.position ?? (siblings.length ? Math.max(...siblings.map(d => d.position)) + 1 : 0);
116
+ const now = new Date();
117
+ const author = (0, documents_1.authorOf)(auth);
118
+ const document = await store.create({
119
+ id: crypto_1.default.randomUUID(), projectId, kind, title, parentId, position, content, version: 1,
120
+ exportable: false, publishedVersion: null, createdAt: now, createdBy: author, updatedAt: now, updatedBy: author,
121
+ });
122
+ broadcast(project, { action: 'created', documentIds: [document.id], version: 1, title, author });
123
+ res.status(201).json({ document });
124
+ }
125
+ catch (err) {
126
+ res.status(500).json({ message: err.message });
127
+ }
128
+ });
129
+ router.get('/projects/:projectId/documents/:docId', async (req, res) => {
130
+ try {
131
+ const found = await projectFor(req, res);
132
+ if (!found)
133
+ return;
134
+ const document = await (0, documents_1.documentStore)().get(found.projectId, req.params.docId);
135
+ if (!document)
136
+ return res.status(404).json({ message: 'Document not found' });
137
+ res.json({ document });
138
+ }
139
+ catch (err) {
140
+ res.status(500).json({ message: err.message });
141
+ }
142
+ });
143
+ // Its title or content (a new version, made on `baseVersion`), and where it is in the tree
144
+ // or whether it's exportable (not a version).
145
+ router.patch('/projects/:projectId/documents/:docId', async (req, res) => {
146
+ try {
147
+ const found = await projectFor(req, res, true);
148
+ if (!found)
149
+ return;
150
+ const { project, auth, projectId } = found;
151
+ const { docId } = req.params;
152
+ const body = req.body ?? {};
153
+ const store = (0, documents_1.documentStore)();
154
+ const current = await store.get(projectId, docId);
155
+ if (!current)
156
+ return res.status(404).json({ message: 'Document not found' });
157
+ const changesText = body.title !== undefined || body.content !== undefined;
158
+ const changesPlace = body.parentId !== undefined || body.position !== undefined || body.exportable !== undefined;
159
+ if (!changesText && !changesPlace)
160
+ return res.status(400).json({ message: 'Nothing to change: title, content, parentId, position or exportable' });
161
+ // Everything is checked before anything changes.
162
+ let text;
163
+ if (changesText) {
164
+ if (!isVersion(body.baseVersion))
165
+ return res.status(400).json({ message: 'baseVersion is required: the version the change was made on' });
166
+ const title = body.title === undefined ? current.title : checkTitle(body.title);
167
+ if (failed(title))
168
+ return res.status(400).json({ message: title.error });
169
+ if (current.kind === 'folder' && body.content)
170
+ return res.status(400).json({ message: 'A folder has no content' });
171
+ const content = body.content === undefined ? current.content : checkContent(body.content);
172
+ if (failed(content))
173
+ return res.status(400).json({ message: content.error });
174
+ text = { title, content };
175
+ }
176
+ const place = {};
177
+ if (body.parentId !== undefined) {
178
+ const parentId = checkParent(body.parentId, await store.list(projectId), docId);
179
+ if (failed(parentId))
180
+ return res.status(400).json({ message: parentId.error });
181
+ place.parentId = parentId;
182
+ }
183
+ if (body.position !== undefined) {
184
+ if (!Number.isInteger(body.position))
185
+ return res.status(400).json({ message: 'position must be a whole number' });
186
+ place.position = body.position;
187
+ }
188
+ if (body.exportable !== undefined) {
189
+ // Whether its exports deliver it is publishing it: a token needs docs:publish.
190
+ if (auth.apiToken && !(0, auth_2.tokenAllows)(auth.apiToken, 'docs:publish')) {
191
+ return res.status(403).json({ error: "This API token doesn't have the 'docs:publish' permission" });
192
+ }
193
+ if (typeof body.exportable !== 'boolean')
194
+ return res.status(400).json({ message: 'exportable is true or false' });
195
+ if (current.kind === 'folder')
196
+ return res.status(400).json({ message: 'A folder isn\'t exported: its documents are' });
197
+ place.exportable = body.exportable;
198
+ }
199
+ const author = (0, documents_1.authorOf)(auth);
200
+ let document = current;
201
+ if (text) {
202
+ document = await store.save(projectId, docId, body.baseVersion, text, author, new Date());
203
+ if (!document) {
204
+ const now = await store.get(projectId, docId);
205
+ return now ? conflict(res, now) : res.status(404).json({ message: 'Document not found' });
206
+ }
207
+ }
208
+ if (changesPlace)
209
+ document = await store.update(projectId, docId, place);
210
+ if (!document)
211
+ return res.status(404).json({ message: 'Document not found' });
212
+ broadcast(project, text
213
+ ? { action: 'saved', documentIds: [docId], version: document.version, title: document.title, author }
214
+ : { action: 'changed', documentIds: [docId], author });
215
+ res.json({ document });
216
+ }
217
+ catch (err) {
218
+ res.status(500).json({ message: err.message });
219
+ }
220
+ });
221
+ // What's under one of its headings, the rest untouched: a new version, made on `baseVersion`.
222
+ router.patch('/projects/:projectId/documents/:docId/sections', async (req, res) => {
223
+ try {
224
+ const found = await projectFor(req, res, true);
225
+ if (!found)
226
+ return;
227
+ const { project, auth, projectId } = found;
228
+ const { docId } = req.params;
229
+ const { baseVersion, heading, content } = req.body ?? {};
230
+ if (!isVersion(baseVersion))
231
+ return res.status(400).json({ message: 'baseVersion is required: the version the change was made on' });
232
+ if (typeof heading !== 'string' || !heading.trim())
233
+ return res.status(400).json({ message: 'heading is required: the section\'s heading, e.g. "## Architecture"' });
234
+ const body = checkContent(content);
235
+ if (failed(body))
236
+ return res.status(400).json({ message: body.error });
237
+ const store = (0, documents_1.documentStore)();
238
+ const current = await store.get(projectId, docId);
239
+ if (!current)
240
+ return res.status(404).json({ message: 'Document not found' });
241
+ if (current.kind === 'folder')
242
+ return res.status(400).json({ message: 'A folder has no content' });
243
+ // A section of another version than the one it was read on could be another text.
244
+ if (current.version !== baseVersion)
245
+ return conflict(res, current);
246
+ const replaced = (0, documents_1.replaceSection)(current.content, heading, body);
247
+ if ('error' in replaced) {
248
+ return replaced.error === 'not-found'
249
+ ? res.status(404).json({ message: `No section "${heading.trim()}" in the document` })
250
+ : res.status(400).json({ message: `More than one section is "${heading.trim()}": give the heading with its #s, or update the whole document` });
251
+ }
252
+ const author = (0, documents_1.authorOf)(auth);
253
+ const document = await store.save(projectId, docId, baseVersion, { title: current.title, content: replaced.content }, author, new Date());
254
+ if (!document) {
255
+ const now = await store.get(projectId, docId);
256
+ return now ? conflict(res, now) : res.status(404).json({ message: 'Document not found' });
257
+ }
258
+ broadcast(project, { action: 'saved', documentIds: [docId], version: document.version, title: document.title, author });
259
+ res.json({ document });
260
+ }
261
+ catch (err) {
262
+ res.status(500).json({ message: err.message });
263
+ }
264
+ });
265
+ router.get('/projects/:projectId/documents/:docId/versions', async (req, res) => {
266
+ try {
267
+ const found = await projectFor(req, res);
268
+ if (!found)
269
+ return;
270
+ const store = (0, documents_1.documentStore)();
271
+ if (!(await store.get(found.projectId, req.params.docId)))
272
+ return res.status(404).json({ message: 'Document not found' });
273
+ res.json({ versions: await store.versions(found.projectId, req.params.docId) });
274
+ }
275
+ catch (err) {
276
+ res.status(500).json({ message: err.message });
277
+ }
278
+ });
279
+ router.get('/projects/:projectId/documents/:docId/versions/:version', async (req, res) => {
280
+ try {
281
+ const found = await projectFor(req, res);
282
+ if (!found)
283
+ return;
284
+ const number = Number(req.params.version);
285
+ const version = isVersion(number) ? await (0, documents_1.documentStore)().version(found.projectId, req.params.docId, number) : null;
286
+ if (!version)
287
+ return res.status(404).json({ message: 'Version not found' });
288
+ res.json({ version });
289
+ }
290
+ catch (err) {
291
+ res.status(500).json({ message: err.message });
292
+ }
293
+ });
294
+ // The version its exports deliver (null: none, it's no longer published). Editing it later
295
+ // changes nothing exported until another version is published.
296
+ router.post('/projects/:projectId/documents/:docId/publish', async (req, res) => {
297
+ try {
298
+ const found = await projectFor(req, res, true);
299
+ if (!found)
300
+ return;
301
+ const { project, auth, projectId } = found;
302
+ const { docId } = req.params;
303
+ const version = req.body?.version;
304
+ if (version !== null && !isVersion(version))
305
+ return res.status(400).json({ message: 'version is the version to publish, or null to publish none' });
306
+ const store = (0, documents_1.documentStore)();
307
+ const current = await store.get(projectId, docId);
308
+ if (!current)
309
+ return res.status(404).json({ message: 'Document not found' });
310
+ if (current.kind === 'folder')
311
+ return res.status(400).json({ message: 'A folder isn\'t published: its documents are' });
312
+ if (version !== null && version > current.version)
313
+ return res.status(404).json({ message: 'Version not found' });
314
+ const document = await store.update(projectId, docId, { publishedVersion: version });
315
+ broadcast(project, { action: 'changed', documentIds: [docId], author: (0, documents_1.authorOf)(auth) });
316
+ res.json({ document });
317
+ }
318
+ catch (err) {
319
+ res.status(500).json({ message: err.message });
320
+ }
321
+ });
322
+ // A document, or a folder with everything in it.
323
+ router.delete('/projects/:projectId/documents/:docId', async (req, res) => {
324
+ try {
325
+ const found = await projectFor(req, res, true);
326
+ if (!found)
327
+ return;
328
+ const { project, auth, projectId } = found;
329
+ const store = (0, documents_1.documentStore)();
330
+ const documents = await store.list(projectId);
331
+ if (!documents.some(d => d.id === req.params.docId))
332
+ return res.status(404).json({ message: 'Document not found' });
333
+ const ids = (0, documents_1.withDescendants)(documents, req.params.docId);
334
+ await store.remove(projectId, ids);
335
+ broadcast(project, { action: 'deleted', documentIds: ids, author: (0, documents_1.authorOf)(auth) });
336
+ res.json({ deleted: ids });
337
+ }
338
+ catch (err) {
339
+ res.status(500).json({ message: err.message });
340
+ }
341
+ });
342
+ return router;
343
+ }
@@ -5,11 +5,13 @@ const express_1 = require("express");
5
5
  const streamableHttp_js_1 = require("@modelcontextprotocol/sdk/server/streamableHttp.js");
6
6
  const server_1 = require("../../mcp/server");
7
7
  const rateLimit_1 = require("../../mcp/rateLimit");
8
+ const oauthTokens_1 = require("../../services/oauthTokens");
8
9
  // eslint-disable-next-line @typescript-eslint/no-var-requires
9
10
  const { version } = require('../../../package.json');
10
11
  const jsonRpcError = (res, status, message) => res.status(status).json({ jsonrpc: '2.0', error: { code: -32000, message }, id: null });
11
12
  // 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
+ // working with StreamBy through an API token, or an OAuth access token of an app the user
14
+ // signed in to (e.g. claude.ai's connector). Each tool calls the REST API right here -- this
13
15
  // same process, under the same mount -- with the request's own Authorization header, so it
14
16
  // reaches what the token and its owner can and nothing more.
15
17
  function mcpRouter(config) {
@@ -17,12 +19,17 @@ function mcpRouter(config) {
17
19
  const limiter = (0, rateLimit_1.createRateLimiter)(config.mcp?.rateLimit ?? rateLimit_1.DEFAULT_MCP_RATE_LIMIT);
18
20
  router.post('/mcp', async (req, res) => {
19
21
  const auth = req.auth;
20
- if (!auth.apiToken)
21
- return jsonRpcError(res, 401, 'The MCP server takes an API token (Authorization: Bearer sbt_...)');
22
+ // A browser session isn't for agents: they come with a token.
23
+ if (!auth.apiToken) {
24
+ if (config.oauth)
25
+ res.setHeader('WWW-Authenticate', (0, oauthTokens_1.wwwAuthenticate)(config.oauth, false));
26
+ return jsonRpcError(res, 401, 'The MCP server takes an API token (Authorization: Bearer sbt_...) or an OAuth access token');
27
+ }
28
+ // Per token; an app is counted per user it acts for.
22
29
  const taken = limiter.take(auth.apiToken.tokenId);
23
30
  if (!taken.allowed) {
24
31
  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');
32
+ return jsonRpcError(res, 429, 'Too many requests for this token; try again later');
26
33
  }
27
34
  const base = config.mcp?.apiBaseUrl ?? `http://127.0.0.1:${req.socket.localPort}${req.baseUrl}`;
28
35
  const api = async ({ method, path, body }) => {
@@ -0,0 +1,3 @@
1
+ import { Router } from 'express';
2
+ import { StreamByConfig } from '../../types';
3
+ export declare function oauthResourceRouter(config: StreamByConfig): Router;
@@ -0,0 +1,18 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.oauthResourceRouter = oauthResourceRouter;
4
+ const express_1 = require("express");
5
+ const oauthTokens_1 = require("../../services/oauthTokens");
6
+ // StreamBy's metadata as an OAuth protected resource (RFC 9728), open to anyone: an MCP client
7
+ // that got a 401 reads it to know which authorization server to sign the user in with. The
8
+ // host serves the same document at its root too, where RFC 9728 puts it.
9
+ function oauthResourceRouter(config) {
10
+ const router = (0, express_1.Router)();
11
+ router.get('/.well-known/oauth-protected-resource', (_req, res) => {
12
+ if (!config.oauth)
13
+ return res.status(404).json({ error: 'Not an OAuth protected resource' });
14
+ res.set({ 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'public, max-age=300' });
15
+ return res.json((0, oauthTokens_1.protectedResourceMetadata)(config.oauth));
16
+ });
17
+ return router;
18
+ }
@@ -13,6 +13,7 @@ const apiTokens_1 = require("../../services/apiTokens");
13
13
  const nosqlDb_1 = require("../../utils/nosqlDb");
14
14
  const production_1 = require("../../services/production");
15
15
  const jobStore_1 = require("../../services/jobStore");
16
+ const documents_1 = require("../../services/documents");
16
17
  function projectRouter(config) {
17
18
  const router = (0, express_1.Router)();
18
19
  const Project = (0, manager_1.getModel)('projects');
@@ -261,6 +262,8 @@ function projectRouter(config) {
261
262
  await (0, production_1.deleteProductionData)(String(projectId));
262
263
  if (projectId)
263
264
  await (0, jobStore_1.jobStore)().deleteProject(String(projectId));
265
+ if (projectId)
266
+ await (0, documents_1.documentStore)().deleteProject(String(projectId));
264
267
  const allProjects = await Project.find({});
265
268
  const projects = allProjects
266
269
  .filter(project => {
@@ -1,5 +1,5 @@
1
1
  /** Permissions on a project, for the projects a token reaches. */
2
- export declare const PROJECT_PERMISSIONS: readonly ["project:read", "project:update", "project:delete", "members:read", "members:manage", "assets:read", "assets:write", "assets:delete", "production:read", "production:write", "production:delete", "workflow:read", "workflow:write", "exports:read", "exports:write", "rights:read", "rights:write", "jobs:read", "jobs:run"];
2
+ export declare const PROJECT_PERMISSIONS: readonly ["project:read", "project:update", "project:delete", "members:read", "members:manage", "assets:read", "assets:write", "assets:delete", "production:read", "production:write", "production:delete", "workflow:read", "workflow:write", "exports:read", "exports:write", "rights:read", "rights:write", "jobs:read", "jobs:run", "docs:read", "docs:write", "docs:publish", "docs:delete"];
3
3
  /** Permissions on the account: only a user's token has them. */
4
4
  export declare const ACCOUNT_PERMISSIONS: readonly ["projects:create"];
5
5
  export type ProjectPermission = typeof PROJECT_PERMISSIONS[number];
@@ -15,6 +15,7 @@ exports.PROJECT_PERMISSIONS = [
15
15
  'exports:read', 'exports:write',
16
16
  'rights:read', 'rights:write',
17
17
  'jobs:read', 'jobs:run',
18
+ 'docs:read', 'docs:write', 'docs:publish', 'docs:delete',
18
19
  ];
19
20
  /** Permissions on the account: only a user's token has them. */
20
21
  exports.ACCOUNT_PERMISSIONS = ['projects:create'];
@@ -94,6 +95,13 @@ const RULES = [
94
95
  // Jobs: transcoding, rendering, AI, publishing a deliverable.
95
96
  rule('read', `${P}/jobs`, 'jobs:read'),
96
97
  rule(['POST'], `${P}/(jobs/[^/]+(/retry)?|pipeline-suggest|deliverables/[^/]+/publish)`, 'jobs:run'),
98
+ // Documents: writing them is saving a version or moving them; publishing one (choosing what
99
+ // the exports deliver, or whether they deliver it at all) is apart. A PATCH that changes
100
+ // `exportable` needs docs:publish too (see routes/document).
101
+ rule('read', `${P}/documents(/.+)?`, 'docs:read'),
102
+ rule(['POST'], `${P}/documents/[^/]+/publish`, 'docs:publish'),
103
+ rule('write', `${P}/documents(/[^/]+(/sections)?)?`, 'docs:write'),
104
+ rule('delete', `${P}/documents/[^/]+`, 'docs:delete'),
97
105
  ];
98
106
  /** The rule for a request made with an API token, or undefined: not reachable with one. */
99
107
  function routeRuleFor(method, path) {
@@ -6,5 +6,9 @@ export declare const isPublicRoute: (method: string, path: string) => boolean;
6
6
  export declare const scopeReachesProject: (scope: Auth["apiToken"], projectId: string) => boolean;
7
7
  /** Whether a token has a permission (a request without a token: always). */
8
8
  export declare const tokenAllows: (scope: Auth["apiToken"], permission: ApiTokenPermission) => boolean;
9
+ /** Whether an app (an OAuth request) may make this request's method on its path. */
10
+ export declare const oauthMayDelete: (method: string, path: string) => boolean;
11
+ /** Whether a request publishes a document (its version, or whether it's exported). */
12
+ export declare const publishesDocument: (method: string, path: string, body: unknown) => boolean;
9
13
  export declare const enforceApiTokenScope: (req: Request, res: Response, next: NextFunction) => void | Response<any, Record<string, any>>;
10
14
  export declare const authenticate: (config: StreamByConfig) => (req: Request, res: Response, next: NextFunction) => Promise<void | Response<any, Record<string, any>>>;
@@ -1,8 +1,9 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.authenticate = exports.enforceApiTokenScope = exports.tokenAllows = exports.scopeReachesProject = exports.isPublicRoute = void 0;
3
+ exports.authenticate = exports.enforceApiTokenScope = exports.publishesDocument = exports.oauthMayDelete = exports.tokenAllows = exports.scopeReachesProject = exports.isPublicRoute = void 0;
4
4
  const auth_1 = require("../utils/auth");
5
5
  const apiTokenPermissions_1 = require("./apiTokenPermissions");
6
+ const oauthTokens_1 = require("./oauthTokens");
6
7
  // The one route open without a session: a project's public export, read
7
8
  // (GET /:projectId/export/:exportName, see routes/export). Exactly that path -- not any path
8
9
  // that has an "export" segment somewhere.
@@ -33,13 +34,45 @@ exports.scopeReachesProject = scopeReachesProject;
33
34
  /** Whether a token has a permission (a request without a token: always). */
34
35
  const tokenAllows = (scope, permission) => !scope || scope.permissions.includes(permission);
35
36
  exports.tokenAllows = tokenAllows;
37
+ // ── OAuth apps ──
38
+ // An app the user signed in to was approved to make and change things, never to delete them:
39
+ // its permissions come from its scopes, and some of those (project:update, workflow:write,
40
+ // exports:write) also reach routes that delete (a connection, a worker, an integration, a
41
+ // pipeline, an export). So an app's DELETE is refused, whatever its route's permission.
42
+ // The one DELETE it may make is taking a deliverable's mark off: that forgets the mark only
43
+ // (the asset, its versions and its files stay, and it can be marked again), and it's the
44
+ // counterpart of marking one, which assets:write already allows.
45
+ const OAUTH_DELETE_ALLOWED = new RegExp(`^/projects/[^/]+/deliverables/[^/]+/?$`);
46
+ /** Whether an app (an OAuth request) may make this request's method on its path. */
47
+ const oauthMayDelete = (method, path) => method.toUpperCase() !== 'DELETE' || OAUTH_DELETE_ALLOWED.test(path);
48
+ exports.oauthMayDelete = oauthMayDelete;
49
+ // Nor does an app publish: what it edits stays a draft until someone publishes it. Publishing
50
+ // is choosing a document's version for its exports (POST .../publish) or whether they deliver
51
+ // it (`exportable`). Refused whatever its scopes grant, as deleting is.
52
+ const DOCUMENT_PUBLISH = /^\/projects\/[^/]+\/documents\/[^/]+\/publish\/?$/;
53
+ const DOCUMENT = /^\/projects\/[^/]+\/documents\/[^/]+\/?$/;
54
+ /** Whether a request publishes a document (its version, or whether it's exported). */
55
+ const publishesDocument = (method, path, body) => {
56
+ const upper = method.toUpperCase();
57
+ if (upper === 'POST' && DOCUMENT_PUBLISH.test(path))
58
+ return true;
59
+ return upper === 'PATCH' && DOCUMENT.test(path) && typeof body === 'object' && body !== null && 'exportable' in body;
60
+ };
61
+ exports.publishesDocument = publishesDocument;
36
62
  const enforceApiTokenScope = (req, res, next) => {
37
- const scope = req.auth?.apiToken;
63
+ const auth = req.auth;
64
+ const scope = auth?.apiToken;
38
65
  if (!scope)
39
66
  return next();
40
67
  // The MCP server handles a token itself: its tools call the routes here, each one checked.
41
68
  if (req.path === '/mcp')
42
69
  return next();
70
+ if (auth?.oauth && !(0, exports.oauthMayDelete)(req.method, req.path)) {
71
+ return res.status(403).json({ error: 'An app connected with OAuth can\'t delete' });
72
+ }
73
+ if (auth?.oauth && (0, exports.publishesDocument)(req.method, req.path, req.body)) {
74
+ return res.status(403).json({ error: 'An app connected with OAuth can\'t publish: what it edits stays a draft' });
75
+ }
43
76
  const rule = (0, apiTokenPermissions_1.routeRuleFor)(req.method, req.path);
44
77
  if (!rule || (rule.userTokenOnly && scope.kind !== 'user')) {
45
78
  return res.status(403).json({ error: 'Not available with an API token' });
@@ -53,25 +86,34 @@ const enforceApiTokenScope = (req, res, next) => {
53
86
  next();
54
87
  };
55
88
  exports.enforceApiTokenScope = enforceApiTokenScope;
89
+ const hasBearer = (req) => /^Bearer\s+\S/i.test(req.headers.authorization ?? '');
56
90
  const authenticate = (config) => async (req, res, next) => {
57
91
  if ((0, exports.isPublicRoute)(req.method, req.path)) {
58
92
  return next();
59
93
  }
94
+ // A client without a valid token is told where to sign in (RFC 9728), when StreamBy is set
95
+ // up as an OAuth resource.
96
+ const unauthorized = () => {
97
+ if (config.oauth)
98
+ res.setHeader('WWW-Authenticate', (0, oauthTokens_1.wwwAuthenticate)(config.oauth, hasBearer(req)));
99
+ return res.status(401).json({ error: 'Unauthorized' });
100
+ };
60
101
  try {
61
102
  const auth = await config.authProvider(req);
62
- if (!auth || !auth.userId || !auth.role) {
63
- return res.status(401).json({ error: 'Unauthorized' });
64
- }
103
+ if (!auth || !auth.userId || !auth.role)
104
+ return unauthorized();
65
105
  // A project token's principal is only ever a project's token (see utils/auth).
66
- if ((0, auth_1.isProjectTokenPrincipal)(auth.userId) && auth.apiToken?.kind !== 'project') {
67
- return res.status(401).json({ error: 'Unauthorized' });
68
- }
106
+ if ((0, auth_1.isProjectTokenPrincipal)(auth.userId) && auth.apiToken?.kind !== 'project')
107
+ return unauthorized();
108
+ // An app acting for the user reaches only what its scopes grant: never without them.
109
+ if (auth.oauth && auth.apiToken?.kind !== 'user')
110
+ return unauthorized();
69
111
  req.auth = auth;
70
112
  next();
71
113
  }
72
114
  catch (error) {
73
- console.error('Authentication failed:', error);
74
- return res.status(401).json({ error: 'Unauthorized' });
115
+ console.error('Authentication failed:', error instanceof Error ? error.message : error);
116
+ return unauthorized();
75
117
  }
76
118
  };
77
119
  exports.authenticate = authenticate;
@@ -0,0 +1,66 @@
1
+ import { Auth, DocumentAuthor, DocumentVersion, ProjectDocument, StreamByConfig } from '../types';
2
+ /** A document as the tree lists it: all but its content. */
3
+ export type DocumentSummary = Omit<ProjectDocument, 'content'>;
4
+ /** A version as the history lists it: all but its content. */
5
+ export type VersionSummary = Omit<DocumentVersion, 'content'>;
6
+ type Structure = Partial<Pick<ProjectDocument, 'parentId' | 'position' | 'exportable' | 'publishedVersion'>>;
7
+ export interface DocumentStore {
8
+ list(projectId: string): Promise<DocumentSummary[]>;
9
+ get(projectId: string, id: string): Promise<ProjectDocument | null>;
10
+ /** A new document, and its first version. */
11
+ create(document: ProjectDocument): Promise<ProjectDocument>;
12
+ /**
13
+ * A new version, only if the document is still at `baseVersion`: null if it isn't (someone
14
+ * saved in between) or there's no such document.
15
+ */
16
+ save(projectId: string, id: string, baseVersion: number, change: {
17
+ title: string;
18
+ content: string;
19
+ }, author: DocumentAuthor, at: Date): Promise<ProjectDocument | null>;
20
+ /** Where it is in the tree, and what's published of it: not a version. */
21
+ update(projectId: string, id: string, patch: Structure): Promise<ProjectDocument | null>;
22
+ /** Its versions, the latest first. */
23
+ versions(projectId: string, id: string): Promise<VersionSummary[]>;
24
+ version(projectId: string, id: string, version: number): Promise<DocumentVersion | null>;
25
+ /** Documents and their versions. */
26
+ remove(projectId: string, ids: string[]): Promise<void>;
27
+ /** A deleted project's documents. */
28
+ deleteProject(projectId: string): Promise<void>;
29
+ }
30
+ /** The router's main database, where documents are kept. */
31
+ export declare const setDocumentStoreDatabase: (config: StreamByConfig) => void;
32
+ export declare function documentStore(): DocumentStore;
33
+ /** Who's saving: the user, through the UI, an API token, or an app acting for them. */
34
+ export declare function authorOf(auth: Auth): DocumentAuthor;
35
+ /** A document and everything inside it (a folder's documents, at any depth). */
36
+ export declare function withDescendants(documents: DocumentSummary[], id: string): string[];
37
+ export type SectionResult = {
38
+ content: string;
39
+ } | {
40
+ error: 'not-found' | 'ambiguous';
41
+ };
42
+ /**
43
+ * The markdown with what's under `heading` replaced by `body`, the rest untouched. `heading` is
44
+ * its title, with or without its #s ("## Architecture", "Architecture"): with them, only a
45
+ * heading of that level matches.
46
+ */
47
+ export declare function replaceSection(markdown: string, heading: string, body: string): SectionResult;
48
+ /** A document as an export delivers it: its published version, never a draft. */
49
+ export interface PublishedDocument {
50
+ id: string;
51
+ title: string;
52
+ parentId: string | null;
53
+ position: number;
54
+ version: number;
55
+ content: string;
56
+ publishedAt: Date;
57
+ }
58
+ /**
59
+ * The project's exportable documents that have a published version (in one folder, at any
60
+ * depth, or only those given), as that version was saved.
61
+ */
62
+ export declare function publishedDocuments(projectId: string, scope?: {
63
+ folderId?: string;
64
+ documentIds?: string[];
65
+ }): Promise<PublishedDocument[]>;
66
+ export {};