streamby-core 0.41.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 CHANGED
@@ -1,5 +1,33 @@
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
+
3
31
  ## 0.41.0
4
32
 
5
33
  ### Added
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
@@ -185,15 +186,16 @@ approved, and never more than the user can: their role in each project still app
185
186
 
186
187
  | Scope | Permissions |
187
188
  |---|---|
188
- | `streamby:read` | Every `:read` permission: `project:read`, `members:read`, `assets:read`, `production:read`, `workflow:read`, `exports:read`, `rights:read`, `jobs:read`. |
189
- | `streamby:write` | Those, and making and changing: `project:update`, `assets:write`, `production:write`, `workflow:write`, `exports:write`, `rights:write`, `jobs:run`, `projects:create`. |
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`. |
190
191
 
191
192
  No scope grants deleting (`*:delete`, `project:delete`) nor managing members. And since some
192
193
  write permissions also reach routes that delete (`project:update` a connection, a worker or an
193
194
  integration; `workflow:write` a pipeline; `exports:write` an export), **an app's `DELETE` is
194
195
  refused (403) whatever its route**, but taking a deliverable's mark off, which only forgets the
195
- mark (the asset stays). API tokens aren't affected. Other scopes (`profile`, `email`) grant
196
- nothing in StreamBy. `OAUTH_SCOPE_PERMISSIONS` is exported.
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.
197
199
 
198
200
  **Telling clients where to sign in**
199
201
 
@@ -297,6 +299,8 @@ A token sees only the tools whose permissions it has (an app: those its scopes g
297
299
  | `delete_sequence`, `delete_shot`, `delete_task` | `production:delete` |
298
300
  | `list_asset_versions`, `get_asset_download_url`, `list_deliverables` | `assets:read` |
299
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` |
300
304
 
301
305
  The tools take the path a user does in the UI, and each one's description says where its
302
306
  result shows there. Production data lives under a production node of the project's workflow:
@@ -446,6 +450,49 @@ person removes it in their account's connected apps (in Nhexa, accounts.nhexa.cl
446
450
 
447
451
  ---
448
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.
493
+
494
+ ---
495
+
449
496
  ## ⚙️ Processing workers
450
497
 
451
498
  StreamBy doesn't process assets itself (optimizing a glTF, making LODs, transcoding...): it
@@ -560,7 +607,7 @@ What a user can do in a project depends on their role in that project, as an act
560
607
  | Role | Can |
561
608
  |---|---|
562
609
  | `viewer` | See the project and its content, and archive the project in their own list. |
563
- | `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. |
564
611
  | `admin` | Everything an editor can, plus manage the project's members and delete the project. |
565
612
 
566
613
  A project's public exports (`GET /streamby/:projectId/export/:exportName`) are read without a
@@ -254,6 +254,38 @@ const createTables = async (pool) => {
254
254
  ALTER TABLE streamby.mcp_audit ALTER COLUMN "tokenId" DROP NOT NULL;
255
255
  `);
256
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.');
257
289
  }
258
290
  catch (error) {
259
291
  console.error('❌ Error ensuring tables exist:', error);
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export { createStreamByRouter } from './middleware/createRouter';
2
2
  export { emitToUser } from './services/wsHub';
3
3
  export { createNotification } from './services/notification';
4
- export type { UserPlan, UserSubscription, Auth, ApiTokenScope, ApiTokenKind, ApiTokenPermission, ProjectPermission, AccountPermission, OAuthClientInfo, OAuthResourceConfig } 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';
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,
@@ -44,11 +44,14 @@ const worker_1 = require("./routes/worker");
44
44
  const workerCallback_1 = require("./routes/workerCallback");
45
45
  const workerJobs_1 = require("../services/workerJobs");
46
46
  const deliverable_1 = require("./routes/deliverable");
47
+ const document_1 = require("./routes/document");
48
+ const documents_1 = require("../services/documents");
47
49
  function createStreamByRouter(config) {
48
50
  const router = express_1.default.Router();
49
51
  if (config.encrypt)
50
52
  (0, encryption_1.setEncryptionKey)(config.encrypt, config.encryptPreviousKeys);
51
53
  (0, jobStore_1.setJobStoreDatabase)(config);
54
+ (0, documents_1.setDocumentStoreDatabase)(config);
52
55
  // Establish DB connections asynchronously (Postgres/Mongo + schema migrations).
53
56
  // Until they're ready, HOLD incoming requests instead of letting them through —
54
57
  // route handlers resolve projects/exports (e.g. the /menu-list export) against a
@@ -149,5 +152,6 @@ function createStreamByRouter(config) {
149
152
  router.use((0, pipeline_1.pipelineRouter)(config));
150
153
  router.use((0, worker_1.workerRouter)({ ...config, adapter }));
151
154
  router.use((0, deliverable_1.deliverableRouter)({ ...config, adapter }));
155
+ router.use((0, document_1.documentRouter)(config));
152
156
  return router;
153
157
  }
@@ -0,0 +1,3 @@
1
+ import { Router } from 'express';
2
+ import { StreamByConfig } from '../../types';
3
+ export declare function documentRouter(_config: StreamByConfig): Router;
@@ -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
+ }
@@ -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) {
@@ -8,5 +8,7 @@ export declare const scopeReachesProject: (scope: Auth["apiToken"], projectId: s
8
8
  export declare const tokenAllows: (scope: Auth["apiToken"], permission: ApiTokenPermission) => boolean;
9
9
  /** Whether an app (an OAuth request) may make this request's method on its path. */
10
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;
11
13
  export declare const enforceApiTokenScope: (req: Request, res: Response, next: NextFunction) => void | Response<any, Record<string, any>>;
12
14
  export declare const authenticate: (config: StreamByConfig) => (req: Request, res: Response, next: NextFunction) => Promise<void | Response<any, Record<string, any>>>;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.authenticate = exports.enforceApiTokenScope = exports.oauthMayDelete = 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
6
  const oauthTokens_1 = require("./oauthTokens");
@@ -46,6 +46,19 @@ const OAUTH_DELETE_ALLOWED = new RegExp(`^/projects/[^/]+/deliverables/[^/]+/?$`
46
46
  /** Whether an app (an OAuth request) may make this request's method on its path. */
47
47
  const oauthMayDelete = (method, path) => method.toUpperCase() !== 'DELETE' || OAUTH_DELETE_ALLOWED.test(path);
48
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;
49
62
  const enforceApiTokenScope = (req, res, next) => {
50
63
  const auth = req.auth;
51
64
  const scope = auth?.apiToken;
@@ -57,6 +70,9 @@ const enforceApiTokenScope = (req, res, next) => {
57
70
  if (auth?.oauth && !(0, exports.oauthMayDelete)(req.method, req.path)) {
58
71
  return res.status(403).json({ error: 'An app connected with OAuth can\'t delete' });
59
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
+ }
60
76
  const rule = (0, apiTokenPermissions_1.routeRuleFor)(req.method, req.path);
61
77
  if (!rule || (rule.userTokenOnly && scope.kind !== 'user')) {
62
78
  return res.status(403).json({ error: 'Not available with an API token' });
@@ -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 {};
@@ -0,0 +1,347 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.setDocumentStoreDatabase = void 0;
4
+ exports.documentStore = documentStore;
5
+ exports.authorOf = authorOf;
6
+ exports.withDescendants = withDescendants;
7
+ exports.replaceSection = replaceSection;
8
+ exports.publishedDocuments = publishedDocuments;
9
+ const connectionManager_1 = require("../adapters/database/connectionManager");
10
+ const withoutContent = ({ content: _content, ...rest }) => rest;
11
+ const byPlace = (a, b) => a.position - b.position || new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
12
+ // ── PostgreSQL ──
13
+ const sqlStore = (pool) => {
14
+ const DOCS = 'streamby.documents';
15
+ const VERSIONS = 'streamby.document_versions';
16
+ const SUMMARY = `id, "projectId", kind, title, "parentId", position, version, exportable, "publishedVersion", "createdAt", "createdBy", "updatedAt", "updatedBy"`;
17
+ const insertVersion = (client, v) => client.query(`INSERT INTO ${VERSIONS} ("documentId", "projectId", version, title, content, author, "createdAt") VALUES ($1, $2, $3, $4, $5, $6, $7)`, [v.documentId, v.projectId, v.version, v.title, v.content, JSON.stringify(v.author), v.createdAt]);
18
+ // A save changes two tables: both, or neither.
19
+ const transaction = async (work) => {
20
+ const client = await pool.connect();
21
+ try {
22
+ await client.query('BEGIN');
23
+ const result = await work(client);
24
+ await client.query(result === null ? 'ROLLBACK' : 'COMMIT');
25
+ return result;
26
+ }
27
+ catch (err) {
28
+ await client.query('ROLLBACK').catch(() => { });
29
+ throw err;
30
+ }
31
+ finally {
32
+ client.release();
33
+ }
34
+ };
35
+ return {
36
+ async list(projectId) {
37
+ const { rows } = await pool.query(`SELECT ${SUMMARY} FROM ${DOCS} WHERE "projectId" = $1 ORDER BY position, "createdAt"`, [projectId]);
38
+ return rows;
39
+ },
40
+ async get(projectId, id) {
41
+ const { rows } = await pool.query(`SELECT * FROM ${DOCS} WHERE "projectId" = $1 AND id = $2`, [projectId, id]);
42
+ return rows[0] ?? null;
43
+ },
44
+ async create(d) {
45
+ return (await transaction(async (client) => {
46
+ const { rows } = await client.query(`INSERT INTO ${DOCS} (id, "projectId", kind, title, "parentId", position, content, version, exportable, "publishedVersion", "createdAt", "createdBy", "updatedAt", "updatedBy")
47
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING *`, [d.id, d.projectId, d.kind, d.title, d.parentId, d.position, d.content, d.version, d.exportable, d.publishedVersion,
48
+ d.createdAt, JSON.stringify(d.createdBy), d.updatedAt, JSON.stringify(d.updatedBy)]);
49
+ await insertVersion(client, { documentId: d.id, projectId: d.projectId, version: d.version, title: d.title, content: d.content, author: d.createdBy, createdAt: d.createdAt });
50
+ return rows[0];
51
+ }));
52
+ },
53
+ async save(projectId, id, baseVersion, change, author, at) {
54
+ return transaction(async (client) => {
55
+ const { rows } = await client.query(`UPDATE ${DOCS} SET title = $4, content = $5, version = version + 1, "updatedAt" = $6, "updatedBy" = $7
56
+ WHERE "projectId" = $1 AND id = $2 AND version = $3 RETURNING *`, [projectId, id, baseVersion, change.title, change.content, at, JSON.stringify(author)]);
57
+ const saved = rows[0];
58
+ if (!saved)
59
+ return null;
60
+ await insertVersion(client, { documentId: id, projectId, version: saved.version, title: saved.title, content: saved.content, author, createdAt: at });
61
+ return saved;
62
+ });
63
+ },
64
+ async update(projectId, id, patch) {
65
+ const entries = Object.entries(patch).filter(([, value]) => value !== undefined);
66
+ if (!entries.length)
67
+ return this.get(projectId, id);
68
+ const sets = entries.map(([key], i) => `"${key}" = $${i + 3}`).join(', ');
69
+ const { rows } = await pool.query(`UPDATE ${DOCS} SET ${sets} WHERE "projectId" = $1 AND id = $2 RETURNING *`, [projectId, id, ...entries.map(([, value]) => value)]);
70
+ return rows[0] ?? null;
71
+ },
72
+ async versions(projectId, id) {
73
+ const { rows } = await pool.query(`SELECT "documentId", "projectId", version, title, author, "createdAt" FROM ${VERSIONS}
74
+ WHERE "projectId" = $1 AND "documentId" = $2 ORDER BY version DESC`, [projectId, id]);
75
+ return rows;
76
+ },
77
+ async version(projectId, id, version) {
78
+ const { rows } = await pool.query(`SELECT * FROM ${VERSIONS} WHERE "projectId" = $1 AND "documentId" = $2 AND version = $3`, [projectId, id, version]);
79
+ return rows[0] ?? null;
80
+ },
81
+ async remove(projectId, ids) {
82
+ if (!ids.length)
83
+ return;
84
+ await transaction(async (client) => {
85
+ await client.query(`DELETE FROM ${VERSIONS} WHERE "projectId" = $1 AND "documentId" = ANY($2)`, [projectId, ids]);
86
+ await client.query(`DELETE FROM ${DOCS} WHERE "projectId" = $1 AND id = ANY($2)`, [projectId, ids]);
87
+ return true;
88
+ });
89
+ },
90
+ async deleteProject(projectId) {
91
+ await pool.query(`DELETE FROM ${VERSIONS} WHERE "projectId" = $1`, [projectId]);
92
+ await pool.query(`DELETE FROM ${DOCS} WHERE "projectId" = $1`, [projectId]);
93
+ },
94
+ };
95
+ };
96
+ // ── MongoDB ──
97
+ // A save writes the document and then its version: in a transaction, both or neither, when
98
+ // the server supports them (a replica set or a sharded cluster, as Atlas is). A standalone
99
+ // server doesn't: there they're two writes, and if the version's failed after the document's,
100
+ // the document would be at a version its history lacks.
101
+ let mongoTransactions;
102
+ const noTransactions = (err) => {
103
+ const { code, message } = (err ?? {});
104
+ return code === 20 || /Transaction numbers are only allowed|replica set/i.test(message ?? '');
105
+ };
106
+ const mongoStore = (client) => {
107
+ const docs = () => client.db().collection('documents');
108
+ const versions = () => client.db().collection('document_versions');
109
+ const strip = (doc) => {
110
+ if (!doc)
111
+ return null;
112
+ const { _id, ...rest } = doc;
113
+ return rest;
114
+ };
115
+ const together = async (work) => {
116
+ if (mongoTransactions !== false && typeof client.startSession === 'function') {
117
+ const session = client.startSession();
118
+ try {
119
+ let result;
120
+ await session.withTransaction(async () => { result = await work(session); });
121
+ mongoTransactions = true;
122
+ return result;
123
+ }
124
+ catch (err) {
125
+ if (!noTransactions(err))
126
+ throw err;
127
+ mongoTransactions = false;
128
+ }
129
+ finally {
130
+ await session.endSession();
131
+ }
132
+ }
133
+ return work();
134
+ };
135
+ return {
136
+ async list(projectId) {
137
+ const found = await docs().find({ projectId }, { projection: { content: 0 } }).toArray();
138
+ return found.map(d => strip(d)).sort(byPlace);
139
+ },
140
+ async get(projectId, id) {
141
+ return strip(await docs().findOne({ projectId, id }));
142
+ },
143
+ async create(d) {
144
+ return together(async (session) => {
145
+ await docs().insertOne({ ...d }, { session });
146
+ await versions().insertOne({ documentId: d.id, projectId: d.projectId, version: d.version, title: d.title, content: d.content, author: d.createdBy, createdAt: d.createdAt }, { session });
147
+ return d;
148
+ });
149
+ },
150
+ async save(projectId, id, baseVersion, change, author, at) {
151
+ return together(async (session) => {
152
+ // Conditional on the version: of two saves made on the same one, only the first goes through.
153
+ const saved = strip(await docs().findOneAndUpdate({ projectId, id, version: baseVersion }, { $set: { ...change, version: baseVersion + 1, updatedAt: at, updatedBy: author } }, { returnDocument: 'after', session }));
154
+ if (!saved)
155
+ return null;
156
+ await versions().insertOne({ documentId: id, projectId, version: saved.version, ...change, author, createdAt: at }, { session });
157
+ return saved;
158
+ });
159
+ },
160
+ async update(projectId, id, patch) {
161
+ const $set = Object.fromEntries(Object.entries(patch).filter(([, value]) => value !== undefined));
162
+ if (!Object.keys($set).length)
163
+ return this.get(projectId, id);
164
+ return strip(await docs().findOneAndUpdate({ projectId, id }, { $set }, { returnDocument: 'after' }));
165
+ },
166
+ async versions(projectId, id) {
167
+ const found = await versions().find({ projectId, documentId: id }, { projection: { content: 0 } }).toArray();
168
+ return found.map(v => strip(v)).sort((a, b) => b.version - a.version);
169
+ },
170
+ async version(projectId, id, version) {
171
+ return strip(await versions().findOne({ projectId, documentId: id, version }));
172
+ },
173
+ async remove(projectId, ids) {
174
+ if (!ids.length)
175
+ return;
176
+ await together(async (session) => {
177
+ await versions().deleteMany({ projectId, documentId: { $in: ids } }, { session });
178
+ await docs().deleteMany({ projectId, id: { $in: ids } }, { session });
179
+ });
180
+ },
181
+ async deleteProject(projectId) {
182
+ await versions().deleteMany({ projectId });
183
+ await docs().deleteMany({ projectId });
184
+ },
185
+ };
186
+ };
187
+ // ── In memory (no main database) ──
188
+ const memoryStore = () => {
189
+ const docs = new Map();
190
+ const versions = [];
191
+ const own = (projectId, id) => {
192
+ const d = docs.get(id);
193
+ return d && d.projectId === projectId ? d : undefined;
194
+ };
195
+ return {
196
+ async list(projectId) {
197
+ return [...docs.values()].filter(d => d.projectId === projectId).map(withoutContent).sort(byPlace);
198
+ },
199
+ async get(projectId, id) {
200
+ const d = own(projectId, id);
201
+ return d ? { ...d } : null;
202
+ },
203
+ async create(d) {
204
+ docs.set(d.id, { ...d });
205
+ versions.push({ documentId: d.id, projectId: d.projectId, version: d.version, title: d.title, content: d.content, author: d.createdBy, createdAt: d.createdAt });
206
+ return { ...d };
207
+ },
208
+ async save(projectId, id, baseVersion, change, author, at) {
209
+ const d = own(projectId, id);
210
+ if (!d || d.version !== baseVersion)
211
+ return null;
212
+ Object.assign(d, change, { version: baseVersion + 1, updatedAt: at, updatedBy: author });
213
+ versions.push({ documentId: id, projectId, version: d.version, ...change, author, createdAt: at });
214
+ return { ...d };
215
+ },
216
+ async update(projectId, id, patch) {
217
+ const d = own(projectId, id);
218
+ if (!d)
219
+ return null;
220
+ Object.assign(d, Object.fromEntries(Object.entries(patch).filter(([, value]) => value !== undefined)));
221
+ return { ...d };
222
+ },
223
+ async versions(projectId, id) {
224
+ return versions.filter(v => v.projectId === projectId && v.documentId === id).map(withoutContent).sort((a, b) => b.version - a.version);
225
+ },
226
+ async version(projectId, id, version) {
227
+ const v = versions.find(x => x.projectId === projectId && x.documentId === id && x.version === version);
228
+ return v ? { ...v } : null;
229
+ },
230
+ async remove(projectId, ids) {
231
+ for (const id of ids)
232
+ if (own(projectId, id))
233
+ docs.delete(id);
234
+ for (let i = versions.length - 1; i >= 0; i--)
235
+ if (versions[i].projectId === projectId && ids.includes(versions[i].documentId))
236
+ versions.splice(i, 1);
237
+ },
238
+ async deleteProject(projectId) {
239
+ for (const [id, d] of docs)
240
+ if (d.projectId === projectId)
241
+ docs.delete(id);
242
+ for (let i = versions.length - 1; i >= 0; i--)
243
+ if (versions[i].projectId === projectId)
244
+ versions.splice(i, 1);
245
+ },
246
+ };
247
+ };
248
+ let mainDbId;
249
+ let memory;
250
+ /** The router's main database, where documents are kept. */
251
+ const setDocumentStoreDatabase = (config) => {
252
+ mainDbId = config.databases?.find(db => db.main)?.id;
253
+ };
254
+ exports.setDocumentStoreDatabase = setDocumentStoreDatabase;
255
+ function documentStore() {
256
+ if (mainDbId && (0, connectionManager_1.getConnectedIds)().includes(mainDbId)) {
257
+ const entry = (0, connectionManager_1.getConnection)(mainDbId);
258
+ return entry.type === 'sql' ? sqlStore(entry.client) : mongoStore(entry.client);
259
+ }
260
+ return (memory ?? (memory = memoryStore()));
261
+ }
262
+ // ── Who saved it ──
263
+ /** Who's saving: the user, through the UI, an API token, or an app acting for them. */
264
+ function authorOf(auth) {
265
+ if (auth.oauth)
266
+ return { userId: auth.userId, username: auth.username, via: 'oauth', clientId: auth.oauth.clientId, clientName: auth.oauth.clientName };
267
+ if (auth.apiToken)
268
+ return { userId: auth.userId, username: auth.username, via: 'apiToken', tokenId: auth.apiToken.tokenId };
269
+ return { userId: auth.userId, username: auth.username, via: 'session' };
270
+ }
271
+ // ── The tree ──
272
+ /** A document and everything inside it (a folder's documents, at any depth). */
273
+ function withDescendants(documents, id) {
274
+ const ids = [id];
275
+ for (let i = 0; i < ids.length; i++) {
276
+ for (const d of documents)
277
+ if (d.parentId === ids[i])
278
+ ids.push(d.id);
279
+ }
280
+ return ids;
281
+ }
282
+ // ── Sections ──
283
+ // A section is what's under a markdown heading ("## Architecture"), up to the next heading of
284
+ // the same level or above. Headings inside a fenced code block aren't headings.
285
+ const HEADING = /^(#{1,6})[ \t]+(.*?)[ \t]*#*[ \t]*$/;
286
+ const FENCE = /^[ \t]*(```|~~~)/;
287
+ const headingOf = (text) => {
288
+ const match = text.trim().match(/^(#{1,6})[ \t]+(.*?)[ \t]*#*$/);
289
+ return match ? { level: match[1].length, title: match[2].trim() } : { level: null, title: text.trim() };
290
+ };
291
+ /**
292
+ * The markdown with what's under `heading` replaced by `body`, the rest untouched. `heading` is
293
+ * its title, with or without its #s ("## Architecture", "Architecture"): with them, only a
294
+ * heading of that level matches.
295
+ */
296
+ function replaceSection(markdown, heading, body) {
297
+ const wanted = headingOf(heading);
298
+ const lines = markdown.split('\n');
299
+ const headings = [];
300
+ let fenced = false;
301
+ lines.forEach((line, i) => {
302
+ if (FENCE.test(line))
303
+ fenced = !fenced;
304
+ const match = !fenced && line.match(HEADING);
305
+ if (match)
306
+ headings.push({ line: i, level: match[1].length, title: match[2].trim() });
307
+ });
308
+ const matches = headings.filter(h => h.title === wanted.title && (wanted.level === null || h.level === wanted.level));
309
+ if (!matches.length)
310
+ return { error: 'not-found' };
311
+ if (matches.length > 1)
312
+ return { error: 'ambiguous' };
313
+ const [target] = matches;
314
+ const next = headings.find(h => h.line > target.line && h.level <= target.level);
315
+ const end = next ? next.line : lines.length;
316
+ const text = body.replace(/^(\s*\n)+/, '').replace(/\s+$/, '');
317
+ const replaced = [
318
+ ...lines.slice(0, target.line + 1),
319
+ ...(text ? ['', ...text.split('\n')] : []),
320
+ ...(next ? [''] : []),
321
+ ...lines.slice(end),
322
+ ];
323
+ // A document's last line ends it, as it did.
324
+ if (!next && markdown.endsWith('\n'))
325
+ replaced.push('');
326
+ return { content: replaced.join('\n') };
327
+ }
328
+ /**
329
+ * The project's exportable documents that have a published version (in one folder, at any
330
+ * depth, or only those given), as that version was saved.
331
+ */
332
+ async function publishedDocuments(projectId, scope = {}) {
333
+ const store = documentStore();
334
+ const documents = await store.list(projectId);
335
+ const within = scope.folderId ? new Set(withDescendants(documents, scope.folderId)) : null;
336
+ const chosen = documents.filter(d => d.kind === 'document' && d.exportable && d.publishedVersion !== null
337
+ && (!within || within.has(d.id))
338
+ && (!scope.documentIds?.length || scope.documentIds.includes(d.id)));
339
+ const published = await Promise.all(chosen.map(async (d) => {
340
+ const version = await store.version(projectId, d.id, d.publishedVersion);
341
+ return version && {
342
+ id: d.id, title: version.title, parentId: d.parentId, position: d.position,
343
+ version: version.version, content: version.content, publishedAt: version.createdAt,
344
+ };
345
+ }));
346
+ return published.filter((d) => !!d);
347
+ }
@@ -8,6 +8,7 @@ const connectionResolver_1 = require("./connectionResolver");
8
8
  const userIntegration_1 = require("./userIntegration");
9
9
  const oauthClient_1 = require("./oauthClient");
10
10
  const jobQueue_1 = require("./jobQueue");
11
+ const documents_1 = require("./documents");
11
12
  const mediaProcessor_1 = require("./mediaProcessor");
12
13
  const vfxProcessor_1 = require("./vfxProcessor");
13
14
  const aiProcessor_1 = require("./aiProcessor");
@@ -143,6 +144,13 @@ async function executeExport(nodeSchema, project, config, { public: isPublic = t
143
144
  await (0, connectionResolver_1.releaseDbClient)(resolved);
144
145
  }
145
146
  }
147
+ else if (node.type === 'documentsNode') {
148
+ // The project's own documents, as published: an edit is a draft, delivered only once a
149
+ // version of it is published. Its own data, so public exports read it too.
150
+ const documentIds = Array.isArray(node.data?.documentIds) ? node.data.documentIds : undefined;
151
+ const folderId = node.data?.folderId || undefined;
152
+ dataResults.push(await (0, documents_1.publishedDocuments)(project._id?.toString() ?? project.id, { folderId, documentIds }));
153
+ }
146
154
  else if (node.type === 'connectionNode') {
147
155
  const connection = project.connections?.find((c) => c.id === node.data?.connectionId);
148
156
  if (!connection)
@@ -16,12 +16,12 @@ const crypto_1 = __importDefault(require("crypto"));
16
16
  exports.OAUTH_SCOPES = ['streamby:read', 'streamby:write'];
17
17
  const READ_PERMISSIONS = [
18
18
  'project:read', 'members:read', 'assets:read', 'production:read', 'workflow:read',
19
- 'exports:read', 'rights:read', 'jobs:read',
19
+ 'exports:read', 'rights:read', 'jobs:read', 'docs:read',
20
20
  ];
21
21
  // Making and changing, as the consent screen says: never deleting, nor managing members.
22
22
  const WRITE_PERMISSIONS = [
23
23
  'project:update', 'assets:write', 'production:write', 'workflow:write', 'exports:write',
24
- 'rights:write', 'jobs:run', 'projects:create',
24
+ 'rights:write', 'jobs:run', 'projects:create', 'docs:write',
25
25
  ];
26
26
  /** The permissions each scope grants. */
27
27
  exports.OAUTH_SCOPE_PERMISSIONS = {
@@ -529,3 +529,43 @@ export interface AssetVersionRecord {
529
529
  changeNote?: string;
530
530
  createdAt: Date;
531
531
  }
532
+ /**
533
+ * Who saved a document's version: a user in the UI, an API token, or an app the user let act
534
+ * for them (OAuth, e.g. Claude in claude.ai).
535
+ */
536
+ export interface DocumentAuthor {
537
+ userId: string;
538
+ username: string;
539
+ via: 'session' | 'apiToken' | 'oauth';
540
+ tokenId?: string;
541
+ clientId?: string;
542
+ clientName?: string;
543
+ }
544
+ export type DocumentKind = 'document' | 'folder';
545
+ /** A project's document as it is now (a folder only groups others: it has no content). */
546
+ export interface ProjectDocument {
547
+ id: string;
548
+ projectId: string;
549
+ kind: DocumentKind;
550
+ title: string;
551
+ parentId: string | null;
552
+ position: number;
553
+ content: string;
554
+ version: number;
555
+ exportable: boolean;
556
+ publishedVersion: number | null;
557
+ createdAt: Date;
558
+ createdBy: DocumentAuthor;
559
+ updatedAt: Date;
560
+ updatedBy: DocumentAuthor;
561
+ }
562
+ /** One save of a document: its title and content then, who saved it and when. */
563
+ export interface DocumentVersion {
564
+ documentId: string;
565
+ projectId: string;
566
+ version: number;
567
+ title: string;
568
+ content: string;
569
+ author: DocumentAuthor;
570
+ createdAt: Date;
571
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "streamby-core",
3
- "version": "0.41.0",
3
+ "version": "0.42.0",
4
4
  "description": "StreamBy middleware framework for media storage management",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",