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,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)
@@ -0,0 +1,60 @@
1
+ import { ApiTokenScope, OAuthResourceConfig } from '../types';
2
+ import { ApiTokenPermission } from './apiTokenPermissions';
3
+ export declare const OAUTH_SCOPES: readonly ["streamby:read", "streamby:write"];
4
+ export type OAuthScope = typeof OAUTH_SCOPES[number];
5
+ /** The permissions each scope grants. */
6
+ export declare const OAUTH_SCOPE_PERMISSIONS: Record<OAuthScope, ApiTokenPermission[]>;
7
+ /** What a token's scopes let it do (scopes that aren't StreamBy's grant nothing here). */
8
+ export declare const permissionsForOAuthScopes: (scopes: string[]) => ApiTokenPermission[];
9
+ export interface Jwk {
10
+ kty: string;
11
+ kid?: string;
12
+ n?: string;
13
+ e?: string;
14
+ alg?: string;
15
+ use?: string;
16
+ }
17
+ export interface Jwks {
18
+ keys: Jwk[];
19
+ }
20
+ export interface OAuthTokenVerifierOptions {
21
+ issuer: string;
22
+ audiences: string[];
23
+ jwks: string | (() => Promise<Jwks>);
24
+ jwksCacheMs?: number;
25
+ fetcher?: (url: string, init: RequestInit) => Promise<Response>;
26
+ now?: () => number;
27
+ }
28
+ export interface VerifiedOAuthToken {
29
+ userId: string;
30
+ clientId: string;
31
+ scopes: string[];
32
+ expiresAt: Date;
33
+ scope: ApiTokenScope;
34
+ }
35
+ /**
36
+ * Whether a bearer is shaped as an OAuth access token (a JWT signed with RS256), not whether
37
+ * it's valid: for an authProvider to tell it from its own session tokens.
38
+ */
39
+ export declare const looksLikeOAuthAccessToken: (token: unknown) => token is string;
40
+ /** The grant an OAuth request counts as (its rate limit's key): one app, for one user. */
41
+ export declare const oauthGrantId: (clientId: string, userId: string) => string;
42
+ /**
43
+ * Checks OAuth access tokens: their signature with the issuer's keys, the issuer, that they're
44
+ * for StreamBy (their audience), and that they haven't expired. The verified token, or null.
45
+ * It doesn't know whether the user has since revoked the app: the host may check that too.
46
+ */
47
+ export declare function createOAuthTokenVerifier(options: OAuthTokenVerifierOptions): (token: unknown) => Promise<VerifiedOAuthToken | null>;
48
+ /** Where RFC 9728 puts a resource's metadata: at its origin, before its path. */
49
+ export declare const defaultResourceMetadataUrl: (resource: string) => string;
50
+ export declare const resourceMetadataUrlOf: (oauth: OAuthResourceConfig) => string;
51
+ /** The metadata a client reads to know where to sign in, and what to ask for. */
52
+ export declare const protectedResourceMetadata: (oauth: OAuthResourceConfig) => {
53
+ resource: string;
54
+ authorization_servers: string[];
55
+ scopes_supported: ("streamby:read" | "streamby:write")[];
56
+ bearer_methods_supported: string[];
57
+ resource_name: string;
58
+ };
59
+ /** The WWW-Authenticate challenge of a 401: the metadata's URL, and why the token was refused. */
60
+ export declare const wwwAuthenticate: (oauth: OAuthResourceConfig, invalidToken: boolean) => string;
@@ -0,0 +1,164 @@
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.wwwAuthenticate = exports.protectedResourceMetadata = exports.resourceMetadataUrlOf = exports.defaultResourceMetadataUrl = exports.oauthGrantId = exports.looksLikeOAuthAccessToken = exports.permissionsForOAuthScopes = exports.OAUTH_SCOPE_PERMISSIONS = exports.OAUTH_SCOPES = void 0;
7
+ exports.createOAuthTokenVerifier = createOAuthTokenVerifier;
8
+ const crypto_1 = __importDefault(require("crypto"));
9
+ // ── OAuth access tokens ──
10
+ // An app the user signed in to with their account (e.g. claude.ai's connector) comes with an
11
+ // OAuth access token: a JWT the authorization server signed (RS256) for one resource (its
12
+ // audience, RFC 8707). It's checked with the server's published keys alone (JWKS): no secret
13
+ // is shared. What it may do is what the user approved -- its scopes -- as permissions of the
14
+ // API tokens' kind, so the same checks limit it; and, as a user's token, never more than the
15
+ // user can (their role in each project still applies).
16
+ exports.OAUTH_SCOPES = ['streamby:read', 'streamby:write'];
17
+ const READ_PERMISSIONS = [
18
+ 'project:read', 'members:read', 'assets:read', 'production:read', 'workflow:read',
19
+ 'exports:read', 'rights:read', 'jobs:read', 'docs:read',
20
+ ];
21
+ // Making and changing, as the consent screen says: never deleting, nor managing members.
22
+ const WRITE_PERMISSIONS = [
23
+ 'project:update', 'assets:write', 'production:write', 'workflow:write', 'exports:write',
24
+ 'rights:write', 'jobs:run', 'projects:create', 'docs:write',
25
+ ];
26
+ /** The permissions each scope grants. */
27
+ exports.OAUTH_SCOPE_PERMISSIONS = {
28
+ 'streamby:read': READ_PERMISSIONS,
29
+ 'streamby:write': [...READ_PERMISSIONS, ...WRITE_PERMISSIONS],
30
+ };
31
+ /** What a token's scopes let it do (scopes that aren't StreamBy's grant nothing here). */
32
+ const permissionsForOAuthScopes = (scopes) => [...new Set(scopes.flatMap(scope => exports.OAUTH_SCOPE_PERMISSIONS[scope] ?? []))];
33
+ exports.permissionsForOAuthScopes = permissionsForOAuthScopes;
34
+ const SKEW_SECONDS = 60;
35
+ const REFETCH_MIN_MS = 60 * 1000;
36
+ const decodePart = (part) => {
37
+ try {
38
+ const value = JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
39
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ };
45
+ /**
46
+ * Whether a bearer is shaped as an OAuth access token (a JWT signed with RS256), not whether
47
+ * it's valid: for an authProvider to tell it from its own session tokens.
48
+ */
49
+ const looksLikeOAuthAccessToken = (token) => {
50
+ if (typeof token !== 'string')
51
+ return false;
52
+ const parts = token.split('.');
53
+ return parts.length === 3 && decodePart(parts[0])?.alg === 'RS256';
54
+ };
55
+ exports.looksLikeOAuthAccessToken = looksLikeOAuthAccessToken;
56
+ /** The grant an OAuth request counts as (its rate limit's key): one app, for one user. */
57
+ const oauthGrantId = (clientId, userId) => `oauth:${clientId}:${userId}`;
58
+ exports.oauthGrantId = oauthGrantId;
59
+ /**
60
+ * Checks OAuth access tokens: their signature with the issuer's keys, the issuer, that they're
61
+ * for StreamBy (their audience), and that they haven't expired. The verified token, or null.
62
+ * It doesn't know whether the user has since revoked the app: the host may check that too.
63
+ */
64
+ function createOAuthTokenVerifier(options) {
65
+ const now = () => (options.now ? options.now() : Date.now());
66
+ const cacheMs = options.jwksCacheMs ?? 5 * 60 * 1000;
67
+ const fetcher = options.fetcher ?? fetch;
68
+ let keys = new Map();
69
+ let fetchedAt = -Infinity;
70
+ let inFlight = null;
71
+ const loadKeys = async () => {
72
+ const jwks = typeof options.jwks === 'function'
73
+ ? await options.jwks()
74
+ : await fetcher(options.jwks, { headers: { Accept: 'application/json' }, redirect: 'error', signal: AbortSignal.timeout(5000) })
75
+ .then(res => (res.ok ? res.json() : Promise.reject(new Error(`JWKS answered ${res.status}`))));
76
+ const next = new Map();
77
+ for (const jwk of jwks?.keys ?? []) {
78
+ if (jwk.kty !== 'RSA' || !jwk.kid || (jwk.alg && jwk.alg !== 'RS256') || (jwk.use && jwk.use !== 'sig'))
79
+ continue;
80
+ try {
81
+ next.set(jwk.kid, crypto_1.default.createPublicKey({ key: jwk, format: 'jwk' }));
82
+ }
83
+ catch { /* not a usable key */ }
84
+ }
85
+ keys = next;
86
+ fetchedAt = now();
87
+ };
88
+ const refresh = async () => {
89
+ inFlight ?? (inFlight = loadKeys().catch(err => {
90
+ fetchedAt = now(); // don't hammer a failing JWKS
91
+ console.error('❌ StreamBy OAuth: could not load the issuer\'s keys:', err instanceof Error ? err.message : err);
92
+ }).finally(() => { inFlight = null; }));
93
+ await inFlight;
94
+ };
95
+ const keyFor = async (kid) => {
96
+ if (now() - fetchedAt > cacheMs)
97
+ await refresh();
98
+ if (!keys.has(kid) && now() - fetchedAt > REFETCH_MIN_MS)
99
+ await refresh();
100
+ return keys.get(kid);
101
+ };
102
+ return async function verify(token) {
103
+ if (!(0, exports.looksLikeOAuthAccessToken)(token))
104
+ return null;
105
+ const [headerPart, payloadPart, signaturePart] = token.split('.');
106
+ const header = decodePart(headerPart);
107
+ const claims = decodePart(payloadPart);
108
+ if (!header || !claims || typeof header.kid !== 'string')
109
+ return null;
110
+ const key = await keyFor(header.kid);
111
+ if (!key)
112
+ return null;
113
+ const signed = crypto_1.default.verify('RSA-SHA256', Buffer.from(`${headerPart}.${payloadPart}`), key, Buffer.from(signaturePart, 'base64url'));
114
+ if (!signed)
115
+ return null;
116
+ const seconds = Math.floor(now() / 1000);
117
+ const audiences = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
118
+ if (claims.iss !== options.issuer)
119
+ return null;
120
+ if (!audiences.some(aud => typeof aud === 'string' && options.audiences.includes(aud)))
121
+ return null;
122
+ if (typeof claims.exp !== 'number' || claims.exp + SKEW_SECONDS <= seconds)
123
+ return null;
124
+ if (typeof claims.nbf === 'number' && claims.nbf - SKEW_SECONDS > seconds)
125
+ return null;
126
+ if (typeof claims.sub !== 'string' || !claims.sub || typeof claims.client_id !== 'string' || !claims.client_id)
127
+ return null;
128
+ const scopes = typeof claims.scope === 'string' ? claims.scope.split(' ').filter(Boolean) : [];
129
+ return {
130
+ userId: claims.sub,
131
+ clientId: claims.client_id,
132
+ scopes,
133
+ expiresAt: new Date(claims.exp * 1000),
134
+ scope: {
135
+ tokenId: (0, exports.oauthGrantId)(claims.client_id, claims.sub),
136
+ kind: 'user',
137
+ permissions: (0, exports.permissionsForOAuthScopes)(scopes),
138
+ projectIds: '*',
139
+ },
140
+ };
141
+ };
142
+ }
143
+ // ── The protected resource's metadata (RFC 9728) ──
144
+ /** Where RFC 9728 puts a resource's metadata: at its origin, before its path. */
145
+ const defaultResourceMetadataUrl = (resource) => {
146
+ const url = new URL(resource);
147
+ const path = url.pathname === '/' ? '' : url.pathname.replace(/\/$/, '');
148
+ return `${url.origin}/.well-known/oauth-protected-resource${path}`;
149
+ };
150
+ exports.defaultResourceMetadataUrl = defaultResourceMetadataUrl;
151
+ const resourceMetadataUrlOf = (oauth) => oauth.resourceMetadataUrl ?? (0, exports.defaultResourceMetadataUrl)(oauth.resource);
152
+ exports.resourceMetadataUrlOf = resourceMetadataUrlOf;
153
+ /** The metadata a client reads to know where to sign in, and what to ask for. */
154
+ const protectedResourceMetadata = (oauth) => ({
155
+ resource: oauth.resource,
156
+ authorization_servers: [oauth.issuer],
157
+ scopes_supported: [...exports.OAUTH_SCOPES],
158
+ bearer_methods_supported: ['header'],
159
+ resource_name: oauth.resourceName ?? 'StreamBy',
160
+ });
161
+ exports.protectedResourceMetadata = protectedResourceMetadata;
162
+ /** The WWW-Authenticate challenge of a 401: the metadata's URL, and why the token was refused. */
163
+ const wwwAuthenticate = (oauth, invalidToken) => `Bearer resource_metadata="${(0, exports.resourceMetadataUrlOf)(oauth)}"${invalidToken ? ', error="invalid_token"' : ''}`;
164
+ exports.wwwAuthenticate = wwwAuthenticate;
@@ -146,6 +146,7 @@ export interface StreamByConfig {
146
146
  download?: number;
147
147
  };
148
148
  };
149
+ oauth?: OAuthResourceConfig;
149
150
  websocket?: {
150
151
  server: WebSocketServer;
151
152
  };
@@ -179,6 +180,17 @@ export interface Auth {
179
180
  username: string;
180
181
  role: 'viewer' | 'editor' | 'admin';
181
182
  apiToken?: ApiTokenScope;
183
+ oauth?: OAuthClientInfo;
184
+ }
185
+ export interface OAuthClientInfo {
186
+ clientId: string;
187
+ clientName?: string;
188
+ }
189
+ export interface OAuthResourceConfig {
190
+ issuer: string;
191
+ resource: string;
192
+ resourceMetadataUrl?: string;
193
+ resourceName?: string;
182
194
  }
183
195
  export type { ApiTokenPermission, ProjectPermission, AccountPermission };
184
196
  export type ApiTokenKind = 'user' | 'project';
@@ -517,3 +529,43 @@ export interface AssetVersionRecord {
517
529
  changeNote?: string;
518
530
  createdAt: Date;
519
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.40.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",