streamby-core 0.35.0 → 0.37.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,98 @@
1
+ "use strict";
2
+ // What an API token may do: a list of permissions, each a resource and an action. A request
3
+ // made with a token goes through only when its route's permission is among the token's (see
4
+ // enforceApiTokenScope), and a route without one isn't reachable with a token at all.
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.isAccountPermission = exports.isProjectPermission = exports.ACCOUNT_PERMISSIONS = exports.PROJECT_PERMISSIONS = void 0;
7
+ exports.routeRuleFor = routeRuleFor;
8
+ /** Permissions on a project, for the projects a token reaches. */
9
+ exports.PROJECT_PERMISSIONS = [
10
+ 'project:read', 'project:update', 'project:delete',
11
+ 'members:read', 'members:manage',
12
+ 'assets:read', 'assets:write', 'assets:delete',
13
+ 'production:read', 'production:write', 'production:delete',
14
+ 'workflow:read', 'workflow:write',
15
+ 'exports:read', 'exports:write',
16
+ 'rights:read', 'rights:write',
17
+ 'jobs:read', 'jobs:run',
18
+ ];
19
+ /** Permissions on the account: only a user's token has them. */
20
+ exports.ACCOUNT_PERMISSIONS = ['projects:create'];
21
+ const isProjectPermission = (value) => exports.PROJECT_PERMISSIONS.includes(value);
22
+ exports.isProjectPermission = isProjectPermission;
23
+ const isAccountPermission = (value) => exports.ACCOUNT_PERMISSIONS.includes(value);
24
+ exports.isAccountPermission = isAccountPermission;
25
+ const READ = ['GET', 'HEAD', 'OPTIONS'];
26
+ const methodsOf = (methods) => methods === 'read' ? READ : methods === 'write' ? ['POST', 'PUT', 'PATCH'] : methods === 'delete' ? ['DELETE'] : methods;
27
+ // A project's own path (not /projects/create nor /projects/explore).
28
+ const P = '^/projects/(?!(?:create|explore)/?$)[^/]+';
29
+ const rule = (methods, path, permission, extra = {}) => ({ methods: methodsOf(methods), pattern: new RegExp(`${path}/?$`), permission, ...extra });
30
+ // The first rule that matches a request decides it; a request no rule matches is refused.
31
+ // Connections, integrations and allowed origins are the project's settings (project:*);
32
+ // reviews, QC reports and dependency graphs are of its assets; pipelines are workflow.
33
+ const RULES = [
34
+ // Outside a project.
35
+ rule('read', '^/auth', null),
36
+ rule('read', '^/projects', null),
37
+ rule(['POST'], '^/projects/create', 'projects:create'),
38
+ rule('read', '^/user/subscription', null, { userTokenOnly: true }),
39
+ rule('read', '^/users/search', 'members:manage'),
40
+ rule('read', '^/jobs/[^/]+', 'jobs:read'),
41
+ // The project itself and its settings.
42
+ rule('read', `${P}(/preview)?`, 'project:read'),
43
+ rule(['PATCH'], P, 'project:update'),
44
+ rule('delete', P, 'project:delete'),
45
+ rule(['PATCH'], `${P}/(archive|unarchive)`, 'project:update', { userTokenOnly: true }),
46
+ rule(['POST'], `${P}/image`, 'project:update'),
47
+ rule(['PATCH'], `${P}/origins`, 'project:update'),
48
+ rule(['POST'], `${P}/integrations`, 'project:update'),
49
+ rule('delete', `${P}/integrations/[^/]+`, 'project:update'),
50
+ rule('read', '^/upload-project-image-url/[^/]+', 'project:update'),
51
+ rule('delete', '^/delete-project-image-url/[^/]+', 'project:update'),
52
+ // Members.
53
+ rule('read', `${P}/members`, 'members:read'),
54
+ rule(['POST'], `${P}/members`, 'members:manage'),
55
+ rule(['PATCH'], `${P}/members/[^/]+(/approve)?`, 'members:manage'),
56
+ rule('delete', `${P}/members/[^/]+`, 'members:manage'),
57
+ // Assets: the built-in storage, and a storage connection's files and folders.
58
+ rule('read', `${P}/(storage|connections/storage/[^/]+)/(.+/)?(upload-url|replace-url)`, 'assets:write'),
59
+ rule('read', `${P}/storage/.+`, 'assets:read'),
60
+ rule(['PATCH'], `${P}/storage/files/[^/]+`, 'assets:write'),
61
+ rule('delete', `${P}/storage/files/[^/]+`, 'assets:delete'),
62
+ rule('read', `${P}/connections/storage/[^/]+/(files|folders)(/.+)?`, 'assets:read'),
63
+ rule(['POST', 'PATCH'], `${P}/connections/storage/[^/]+/(files|folders)(/.+)?`, 'assets:write'),
64
+ rule('delete', `${P}/connections/storage/[^/]+/(files|folders)/[^/]+`, 'assets:delete'),
65
+ rule('read', `${P}/assets/[^/]+/(metadata|versions|qc-report|dependency-graph)`, 'assets:read'),
66
+ rule(['PATCH'], `${P}/assets/[^/]+/metadata`, 'assets:write'),
67
+ rule(['POST'], `${P}/assets/[^/]+/qc-report`, 'assets:write'),
68
+ rule('read', `${P}/(reviews(/[^/]+)?|assets/[^/]+/versions/[^/]+/annotations)`, 'assets:read'),
69
+ rule(['POST', 'PATCH'], `${P}/(reviews(/[^/]+/decision)?|assets/[^/]+/versions/[^/]+/annotations(/[^/]+/resolve)?)`, 'assets:write'),
70
+ // Rights.
71
+ rule('read', `${P}/assets/[^/]+/rights`, 'rights:read'),
72
+ rule(['PUT'], `${P}/assets/[^/]+/rights`, 'rights:write'),
73
+ // Connections (API, database, storage, distribution, render farm): the project's settings.
74
+ rule('read', `${P}/(connections|get-connection|distribution-connections|render-farm-connections)(/.+)?`, 'project:read'),
75
+ rule('write', `${P}/(connections|distribution-connections|render-farm-connections)(/.+)?`, 'project:update'),
76
+ rule('delete', `${P}/(connections|distribution-connections|render-farm-connections)/.+`, 'project:update'),
77
+ // Production.
78
+ rule('read', `${P}/production/.+`, 'production:read'),
79
+ rule('write', `${P}/production/.+`, 'production:write'),
80
+ rule('delete', `${P}/production/.+`, 'production:delete'),
81
+ // Workflow and pipelines.
82
+ rule('read', `${P}/(workflow|pipelines(/[^/]+)?)`, 'workflow:read'),
83
+ rule(['PATCH'], `${P}/workflow`, 'workflow:write'),
84
+ rule(['POST'], `${P}/pipelines`, 'workflow:write'),
85
+ rule(['PATCH', 'DELETE'], `${P}/pipelines/[^/]+`, 'workflow:write'),
86
+ // Exports.
87
+ rule('read', `${P}/exports/[^/]+`, 'exports:read'),
88
+ rule(['POST'], `${P}/exports`, 'exports:write'),
89
+ rule(['PATCH', 'DELETE'], `${P}/exports/[^/]+`, 'exports:write'),
90
+ // Jobs: transcoding, rendering, AI, publishing a deliverable.
91
+ rule(['POST'], `${P}/(jobs/[^/]+|pipeline-suggest|deliverables/[^/]+/publish)`, 'jobs:run'),
92
+ ];
93
+ /** The rule for a request made with an API token, or undefined: not reachable with one. */
94
+ function routeRuleFor(method, path) {
95
+ const upper = method.toUpperCase();
96
+ const found = RULES.find(r => r.methods.includes(upper) && r.pattern.test(path));
97
+ return found && { permission: found.permission, userTokenOnly: found.userTokenOnly };
98
+ }
@@ -1,21 +1,28 @@
1
- import { ApiTokenAccess, ApiTokenScope, StreamByConfig } from '../types';
1
+ import { ApiTokenKind, ApiTokenScope, StreamByConfig } from '../types';
2
+ import { ApiTokenPermission } from './apiTokenPermissions';
2
3
  export declare const API_TOKEN_PREFIX = "sbt_";
3
4
  export declare const API_TOKEN_EXPIRY_DAYS: readonly [7, 30, 90, 365];
4
5
  export interface ApiTokenRecord {
5
6
  id: string;
7
+ kind: ApiTokenKind;
6
8
  userId: string;
9
+ createdByName: string | null;
7
10
  name: string;
8
11
  prefix: string;
9
12
  hash: string;
10
- access: ApiTokenAccess;
13
+ permissions: ApiTokenPermission[];
11
14
  projectIds: string[];
12
15
  createdAt: Date;
13
16
  expiresAt: Date | null;
14
17
  lastUsedAt: Date | null;
15
18
  }
16
- /** What a token's owner sees of it. */
17
- export type ApiTokenInfo = Omit<ApiTokenRecord, 'hash' | 'userId' | 'projectIds'> & {
19
+ /** What's shown of a token: never its hash. */
20
+ export type ApiTokenInfo = Omit<ApiTokenRecord, 'hash' | 'userId' | 'projectIds' | 'createdByName'> & {
18
21
  projectIds: string[] | '*';
22
+ createdBy: {
23
+ userId: string;
24
+ username: string | null;
25
+ };
19
26
  };
20
27
  export declare class ApiTokenError extends Error {
21
28
  status: number;
@@ -23,25 +30,42 @@ export declare class ApiTokenError extends Error {
23
30
  }
24
31
  export interface CreateApiTokenInput {
25
32
  name: string;
26
- access: ApiTokenAccess;
27
- projectIds: string[] | '*';
33
+ permissions: ApiTokenPermission[];
34
+ projectIds?: string[] | '*';
28
35
  expiresInDays: number | null;
29
36
  }
30
- /** A new token for `userId`: the token itself, shown only now, and what's kept of it. */
31
- export declare function createApiToken(userId: string, input: CreateApiTokenInput, config: StreamByConfig): Promise<{
37
+ export interface ApiTokenOwner {
38
+ kind: ApiTokenKind;
39
+ userId: string;
40
+ username?: string | null;
41
+ projectId?: string;
42
+ }
43
+ /** A new token: the token itself, shown only now, and what's kept of it. */
44
+ export declare function createApiToken(owner: ApiTokenOwner, input: CreateApiTokenInput, config: StreamByConfig): Promise<{
32
45
  token: string;
33
46
  info: ApiTokenInfo;
34
47
  }>;
35
- /** The tokens `userId` has. */
36
- export declare function listApiTokens(userId: string): Promise<ApiTokenInfo[]>;
37
- /** Revokes one of `userId`'s tokens: it stops working at once. Whether there was one. */
38
- export declare function revokeApiToken(userId: string, tokenId: string): Promise<boolean>;
48
+ /** A user's own tokens. */
49
+ export declare function listUserApiTokens(userId: string): Promise<ApiTokenInfo[]>;
50
+ /** A project's tokens. */
51
+ export declare function listProjectApiTokens(projectId: string): Promise<ApiTokenInfo[]>;
52
+ /** Revokes a user's own token: it stops working at once. Whether there was one. */
53
+ export declare function revokeUserApiToken(userId: string, tokenId: string): Promise<boolean>;
54
+ /** Revokes a project's token. Whether there was one. */
55
+ export declare function revokeProjectApiToken(projectId: string, tokenId: string): Promise<boolean>;
56
+ /** A project's tokens that `userId` made (to tell its admins they keep working once they leave). */
57
+ export declare function projectApiTokensMadeBy(projectId: string, userId: string): Promise<ApiTokenInfo[]>;
58
+ /** Ends a deleted project's tokens. */
59
+ export declare function deleteProjectApiTokens(projectId: string): Promise<void>;
60
+ /** A project a user's token made: it reaches it from now on (one reaching every project already does). */
61
+ export declare function addProjectToApiToken(tokenId: string, projectId: string): Promise<void>;
39
62
  export interface ResolvedApiToken {
40
63
  userId: string;
64
+ name: string;
41
65
  scope: ApiTokenScope;
42
66
  }
43
67
  /**
44
- * The token's owner and scope, for the host's authProvider; null when it isn't a token, isn't
45
- * known (revoked), or has expired. Marks when it was last used.
68
+ * The token's request: who it is and what it may do, for the host's authProvider; null when
69
+ * it isn't a token, isn't known (revoked), or has expired. Marks when it was last used.
46
70
  */
47
71
  export declare function resolveApiToken(token: unknown): Promise<ResolvedApiToken | null>;
@@ -5,17 +5,27 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.ApiTokenError = exports.API_TOKEN_EXPIRY_DAYS = exports.API_TOKEN_PREFIX = void 0;
7
7
  exports.createApiToken = createApiToken;
8
- exports.listApiTokens = listApiTokens;
9
- exports.revokeApiToken = revokeApiToken;
8
+ exports.listUserApiTokens = listUserApiTokens;
9
+ exports.listProjectApiTokens = listProjectApiTokens;
10
+ exports.revokeUserApiToken = revokeUserApiToken;
11
+ exports.revokeProjectApiToken = revokeProjectApiToken;
12
+ exports.projectApiTokensMadeBy = projectApiTokensMadeBy;
13
+ exports.deleteProjectApiTokens = deleteProjectApiTokens;
14
+ exports.addProjectToApiToken = addProjectToApiToken;
10
15
  exports.resolveApiToken = resolveApiToken;
11
16
  const crypto_1 = __importDefault(require("crypto"));
12
17
  const manager_1 = require("../models/manager");
13
- // API tokens: how an agent or a service works with StreamBy as a user, without their browser
14
- // session. A token is "sbt_" and 32 random bytes; only its hash is stored, with a prefix to
15
- // tell it apart in a list. It's sent as `Authorization: Bearer sbt_...` and the host's
16
- // authProvider resolves it (resolveApiToken), so the request is its owner's -- limited by the
17
- // token's scope (read or write, which projects), and never more than its owner can do: the
18
- // owner's role in each project still decides.
18
+ const auth_1 = require("../utils/auth");
19
+ const apiTokenPermissions_1 = require("./apiTokenPermissions");
20
+ // API tokens: how an agent or a service works with StreamBy without a browser session. A token
21
+ // is "sbt_" and 32 random bytes; only its hash is stored, with a prefix to tell it apart in a
22
+ // list. It's sent as `Authorization: Bearer sbt_...` and the host's authProvider resolves it
23
+ // (resolveApiToken). It does only what its permissions allow (see apiTokenPermissions), and:
24
+ //
25
+ // - a user's token acts as its user, on the projects it reaches: never more than its user can,
26
+ // since their role in each project still decides;
27
+ // - a project's token acts on its project alone, as the project's, not as whoever made it: it
28
+ // keeps working after they leave the project. Only the project's admins manage it.
19
29
  exports.API_TOKEN_PREFIX = 'sbt_';
20
30
  const PREFIX_SHOWN = exports.API_TOKEN_PREFIX.length + 8;
21
31
  exports.API_TOKEN_EXPIRY_DAYS = [7, 30, 90, 365];
@@ -28,35 +38,72 @@ class ApiTokenError extends Error {
28
38
  }
29
39
  exports.ApiTokenError = ApiTokenError;
30
40
  const hashOf = (token) => crypto_1.default.createHash('sha256').update(token).digest('hex');
31
- const infoOf = ({ hash: _hash, userId: _userId, ...info }) => ({
32
- ...info,
33
- projectIds: parseProjectIds(info.projectIds),
34
- });
35
- // As stored (a list, read back from SQL JSON as a list or its text): ['*'] is every project.
36
- const parseProjectIds = (value) => {
41
+ // A list, as stored in SQL JSON: read back as a list or as its text.
42
+ const listOf = (value) => {
37
43
  if (typeof value === 'string') {
38
44
  try {
39
- return parseProjectIds(JSON.parse(value));
45
+ return listOf(JSON.parse(value));
40
46
  }
41
47
  catch {
42
48
  return [];
43
49
  }
44
50
  }
45
- if (!Array.isArray(value))
46
- return [];
47
- const ids = value.map(String);
51
+ return Array.isArray(value) ? value.map(String) : [];
52
+ };
53
+ const parseProjectIds = (value) => {
54
+ const ids = listOf(value);
48
55
  return ids.includes(ALL_PROJECTS) ? '*' : ids;
49
56
  };
57
+ // Only the permissions its kind may have: a stored one that isn't known anymore is dropped.
58
+ const parsePermissions = (value, kind) => listOf(value).filter((p) => (0, apiTokenPermissions_1.isProjectPermission)(p) || (kind === 'user' && (0, apiTokenPermissions_1.isAccountPermission)(p)));
59
+ // Tokens made before they had a kind and permissions don't work anymore, nor are listed.
60
+ const isCurrent = (record) => !!record && (record.kind === 'user' || record.kind === 'project');
61
+ const infoOf = (record) => {
62
+ const { hash: _hash, userId, createdByName, projectIds, permissions, ...info } = record;
63
+ return {
64
+ ...info,
65
+ permissions: parsePermissions(permissions, record.kind),
66
+ projectIds: parseProjectIds(projectIds),
67
+ createdBy: { userId, username: createdByName ?? null },
68
+ };
69
+ };
50
70
  const mainDbType = (config) => config.databases?.find(db => db.main)?.type ?? 'nosql';
51
- /** A new token for `userId`: the token itself, shown only now, and what's kept of it. */
52
- async function createApiToken(userId, input, config) {
71
+ const validPermissions = (value, kind) => {
72
+ if (!Array.isArray(value) || !value.length)
73
+ throw new ApiTokenError('A token needs at least one permission.');
74
+ for (const p of value) {
75
+ if ((0, apiTokenPermissions_1.isAccountPermission)(p) && kind === 'project')
76
+ throw new ApiTokenError(`A project's token can't have '${p}'.`);
77
+ if (!(0, apiTokenPermissions_1.isProjectPermission)(p) && !(0, apiTokenPermissions_1.isAccountPermission)(p))
78
+ throw new ApiTokenError(`Unknown permission '${String(p)}'.`);
79
+ }
80
+ return [...new Set(value)];
81
+ };
82
+ /** A new token: the token itself, shown only now, and what's kept of it. */
83
+ async function createApiToken(owner, input, config) {
53
84
  const name = typeof input.name === 'string' ? input.name.trim() : '';
54
85
  if (!name)
55
86
  throw new ApiTokenError('A token needs a name.');
56
- if (input.access !== 'read' && input.access !== 'write')
57
- throw new ApiTokenError("access must be 'read' or 'write'.");
58
- if (input.projectIds !== '*' && (!Array.isArray(input.projectIds) || !input.projectIds.length || !input.projectIds.every(id => typeof id === 'string' && id && id !== ALL_PROJECTS))) {
59
- throw new ApiTokenError("projectIds must be '*' or a list of project ids.");
87
+ const permissions = validPermissions(input.permissions, owner.kind);
88
+ let projectIds;
89
+ if (owner.kind === 'project') {
90
+ if (!owner.projectId)
91
+ throw new ApiTokenError("A project's token needs its project.");
92
+ projectIds = [owner.projectId];
93
+ }
94
+ else if (input.projectIds === '*') {
95
+ projectIds = [ALL_PROJECTS];
96
+ }
97
+ else {
98
+ const ids = input.projectIds;
99
+ if (!Array.isArray(ids) || !ids.every(id => typeof id === 'string' && id && id !== ALL_PROJECTS)) {
100
+ throw new ApiTokenError("projectIds must be '*' or a list of project ids.");
101
+ }
102
+ // No project yet only for a token that makes them: it reaches the ones it makes.
103
+ if (!ids.length && !permissions.includes('projects:create')) {
104
+ throw new ApiTokenError('A token needs at least one project, or to be able to create them.');
105
+ }
106
+ projectIds = [...new Set(ids)];
60
107
  }
61
108
  if (input.expiresInDays !== null && !exports.API_TOKEN_EXPIRY_DAYS.includes(input.expiresInDays)) {
62
109
  throw new ApiTokenError(`expiresInDays must be one of ${exports.API_TOKEN_EXPIRY_DAYS.join(', ')}, or null.`);
@@ -65,12 +112,14 @@ async function createApiToken(userId, input, config) {
65
112
  const now = new Date();
66
113
  const record = {
67
114
  id: crypto_1.default.randomUUID(),
68
- userId,
115
+ kind: owner.kind,
116
+ userId: owner.userId,
117
+ createdByName: owner.username ?? null,
69
118
  name,
70
119
  prefix: token.slice(0, PREFIX_SHOWN),
71
120
  hash: hashOf(token),
72
- access: input.access,
73
- projectIds: input.projectIds === '*' ? [ALL_PROJECTS] : [...new Set(input.projectIds)],
121
+ permissions,
122
+ projectIds,
74
123
  createdAt: now,
75
124
  expiresAt: input.expiresInDays === null ? null : new Date(now.getTime() + input.expiresInDays * 86400000),
76
125
  lastUsedAt: null,
@@ -78,33 +127,78 @@ async function createApiToken(userId, input, config) {
78
127
  await (0, manager_1.getModel)('api_tokens').create({ ...record, dbType: mainDbType(config) });
79
128
  return { token, info: infoOf(record) };
80
129
  }
81
- /** The tokens `userId` has. */
82
- async function listApiTokens(userId) {
83
- const records = (await (0, manager_1.getModel)('api_tokens').find({ userId }));
130
+ const newestFirst = (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
131
+ /** A user's own tokens. */
132
+ async function listUserApiTokens(userId) {
133
+ const records = (await (0, manager_1.getModel)('api_tokens').find({ userId, kind: 'user' }));
134
+ return records.filter(isCurrent).map(infoOf).sort(newestFirst);
135
+ }
136
+ /** A project's tokens. */
137
+ async function listProjectApiTokens(projectId) {
138
+ const records = (await (0, manager_1.getModel)('api_tokens').find({ kind: 'project' }));
84
139
  return records
140
+ .filter(r => isCurrent(r) && listOf(r.projectIds)[0] === projectId)
85
141
  .map(infoOf)
86
- .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
142
+ .sort(newestFirst);
143
+ }
144
+ /** Revokes a user's own token: it stops working at once. Whether there was one. */
145
+ async function revokeUserApiToken(userId, tokenId) {
146
+ return (await (0, manager_1.getModel)('api_tokens').delete({ id: tokenId, userId, kind: 'user' })) > 0;
147
+ }
148
+ /** Revokes a project's token. Whether there was one. */
149
+ async function revokeProjectApiToken(projectId, tokenId) {
150
+ const ApiTokens = (0, manager_1.getModel)('api_tokens');
151
+ const record = (await ApiTokens.findOne({ id: tokenId, kind: 'project' }));
152
+ if (!record || listOf(record.projectIds)[0] !== projectId)
153
+ return false;
154
+ return (await ApiTokens.delete({ id: tokenId })) > 0;
155
+ }
156
+ /** A project's tokens that `userId` made (to tell its admins they keep working once they leave). */
157
+ async function projectApiTokensMadeBy(projectId, userId) {
158
+ return (await listProjectApiTokens(projectId)).filter(t => t.createdBy.userId === userId);
159
+ }
160
+ /** Ends a deleted project's tokens. */
161
+ async function deleteProjectApiTokens(projectId) {
162
+ const ApiTokens = (0, manager_1.getModel)('api_tokens');
163
+ for (const token of await listProjectApiTokens(projectId))
164
+ await ApiTokens.delete({ id: token.id });
87
165
  }
88
- /** Revokes one of `userId`'s tokens: it stops working at once. Whether there was one. */
89
- async function revokeApiToken(userId, tokenId) {
90
- return (await (0, manager_1.getModel)('api_tokens').delete({ id: tokenId, userId })) > 0;
166
+ /** A project a user's token made: it reaches it from now on (one reaching every project already does). */
167
+ async function addProjectToApiToken(tokenId, projectId) {
168
+ const ApiTokens = (0, manager_1.getModel)('api_tokens');
169
+ const record = (await ApiTokens.findOne({ id: tokenId, kind: 'user' }));
170
+ if (!record)
171
+ return;
172
+ const ids = listOf(record.projectIds);
173
+ if (ids.includes(ALL_PROJECTS) || ids.includes(projectId))
174
+ return;
175
+ await ApiTokens.update({ id: tokenId }, { projectIds: [...ids, projectId] });
91
176
  }
92
177
  /**
93
- * The token's owner and scope, for the host's authProvider; null when it isn't a token, isn't
94
- * known (revoked), or has expired. Marks when it was last used.
178
+ * The token's request: who it is and what it may do, for the host's authProvider; null when
179
+ * it isn't a token, isn't known (revoked), or has expired. Marks when it was last used.
95
180
  */
96
181
  async function resolveApiToken(token) {
97
182
  if (typeof token !== 'string' || !token.startsWith(exports.API_TOKEN_PREFIX))
98
183
  return null;
99
184
  const ApiTokens = (0, manager_1.getModel)('api_tokens');
100
185
  const record = (await ApiTokens.findOne({ hash: hashOf(token) }));
101
- if (!record)
186
+ if (!isCurrent(record))
102
187
  return null;
103
188
  if (record.expiresAt && new Date(record.expiresAt).getTime() <= Date.now())
104
189
  return null;
105
190
  ApiTokens.update({ id: record.id }, { lastUsedAt: new Date() }).catch(() => { });
106
- return {
107
- userId: record.userId,
108
- scope: { tokenId: record.id, access: record.access, projectIds: parseProjectIds(record.projectIds) },
191
+ const scope = {
192
+ tokenId: record.id,
193
+ kind: record.kind,
194
+ permissions: parsePermissions(record.permissions, record.kind),
195
+ projectIds: parseProjectIds(record.projectIds),
109
196
  };
197
+ if (record.kind === 'project') {
198
+ const projectId = listOf(record.projectIds)[0];
199
+ if (!projectId)
200
+ return null;
201
+ return { userId: (0, auth_1.projectTokenPrincipal)(record.id, projectId), name: record.name, scope: { ...scope, projectIds: [projectId] } };
202
+ }
203
+ return { userId: record.userId, name: record.name, scope };
110
204
  }
@@ -1,7 +1,10 @@
1
1
  import { Request, Response, NextFunction } from 'express';
2
2
  import { Auth, StreamByConfig } from '../types';
3
+ import { ApiTokenPermission } from './apiTokenPermissions';
3
4
  export declare const isPublicRoute: (method: string, path: string) => boolean;
4
- /** Whether a token's scope reaches a project. */
5
+ /** Whether a token reaches a project. */
5
6
  export declare const scopeReachesProject: (scope: Auth["apiToken"], projectId: string) => boolean;
7
+ /** Whether a token has a permission (a request without a token: always). */
8
+ export declare const tokenAllows: (scope: Auth["apiToken"], permission: ApiTokenPermission) => boolean;
6
9
  export declare const enforceApiTokenScope: (req: Request, res: Response, next: NextFunction) => void | Response<any, Record<string, any>>;
7
10
  export declare const authenticate: (config: StreamByConfig) => (req: Request, res: Response, next: NextFunction) => Promise<void | Response<any, Record<string, any>>>;
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.authenticate = exports.enforceApiTokenScope = exports.scopeReachesProject = exports.isPublicRoute = void 0;
3
+ exports.authenticate = exports.enforceApiTokenScope = exports.tokenAllows = exports.scopeReachesProject = exports.isPublicRoute = void 0;
4
+ const auth_1 = require("../utils/auth");
5
+ const apiTokenPermissions_1 = require("./apiTokenPermissions");
4
6
  // The one route open without a session: a project's public export, read
5
7
  // (GET /:projectId/export/:exportName, see routes/export). Exactly that path -- not any path
6
8
  // that has an "export" segment somewhere.
@@ -9,10 +11,11 @@ const READ_METHODS = ['GET', 'HEAD', 'OPTIONS'];
9
11
  const isPublicRoute = (method, path) => READ_METHODS.includes(method.toUpperCase()) && PUBLIC_EXPORT.test(path);
10
12
  exports.isPublicRoute = isPublicRoute;
11
13
  // ── API tokens ──
12
- // A request made with an API token reaches only what the token's scope does: no writes with a
13
- // read token, no project outside its projects (as if it didn't exist), no managing tokens, and
14
- // of the routes outside a project only the few below. Its owner's role in each project still
15
- // decides on top (the routes' own checks).
14
+ // A request made with an API token reaches only what the token does: its route's permission
15
+ // must be among the token's (see apiTokenPermissions), and a project outside its projects is as
16
+ // if it didn't exist. A user's token is also limited by its user's role in each project (the
17
+ // routes' own checks); a project's token acts in its project as an admin, so its permissions
18
+ // are all it has.
16
19
  // The project a route acts on, by its path.
17
20
  const PROJECT_PATHS = [/^\/projects\/([^/]+)/, /^\/(?:upload|delete)-project-image-url\/([^/]+)/];
18
21
  const NOT_A_PROJECT = new Set(['create', 'explore']);
@@ -24,28 +27,30 @@ const projectIdOf = (path) => {
24
27
  }
25
28
  return undefined;
26
29
  };
27
- // Outside a project, a token reaches only these reads (each limited to its scope there).
28
- const OUTSIDE_A_PROJECT = [/^\/auth\/?$/, /^\/projects\/?$/, /^\/jobs\/[^/]+\/?$/, /^\/user\/subscription\/?$/, /^\/users\/search\/?$/];
29
- /** Whether a token's scope reaches a project. */
30
+ /** Whether a token reaches a project. */
30
31
  const scopeReachesProject = (scope, projectId) => !scope || scope.projectIds === '*' || scope.projectIds.includes(projectId);
31
32
  exports.scopeReachesProject = scopeReachesProject;
33
+ /** Whether a token has a permission (a request without a token: always). */
34
+ const tokenAllows = (scope, permission) => !scope || scope.permissions.includes(permission);
35
+ exports.tokenAllows = tokenAllows;
32
36
  const enforceApiTokenScope = (req, res, next) => {
33
37
  const scope = req.auth?.apiToken;
34
38
  if (!scope)
35
39
  return next();
36
- if (/^\/user\/api-tokens(\/|$)/.test(req.path)) {
37
- return res.status(403).json({ error: 'API tokens are managed with a session, not with a token' });
38
- }
39
- if (!READ_METHODS.includes(req.method.toUpperCase()) && scope.access !== 'write') {
40
- return res.status(403).json({ error: 'This API token can only read' });
40
+ // The MCP server handles a token itself: its tools call the routes here, each one checked.
41
+ if (req.path === '/mcp')
42
+ return next();
43
+ const rule = (0, apiTokenPermissions_1.routeRuleFor)(req.method, req.path);
44
+ if (!rule || (rule.userTokenOnly && scope.kind !== 'user')) {
45
+ return res.status(403).json({ error: 'Not available with an API token' });
41
46
  }
42
47
  const projectId = projectIdOf(req.path);
43
- if (projectId) {
44
- return (0, exports.scopeReachesProject)(scope, projectId) ? next() : res.status(404).json({ message: 'Project not found' });
48
+ if (projectId && !(0, exports.scopeReachesProject)(scope, projectId))
49
+ return res.status(404).json({ message: 'Project not found' });
50
+ if (rule.permission && !(0, exports.tokenAllows)(scope, rule.permission)) {
51
+ return res.status(403).json({ error: `This API token doesn't have the '${rule.permission}' permission` });
45
52
  }
46
- if (READ_METHODS.includes(req.method.toUpperCase()) && OUTSIDE_A_PROJECT.some(route => route.test(req.path)))
47
- return next();
48
- return res.status(403).json({ error: 'Not available with an API token' });
53
+ next();
49
54
  };
50
55
  exports.enforceApiTokenScope = enforceApiTokenScope;
51
56
  const authenticate = (config) => async (req, res, next) => {
@@ -57,6 +62,10 @@ const authenticate = (config) => async (req, res, next) => {
57
62
  if (!auth || !auth.userId || !auth.role) {
58
63
  return res.status(401).json({ error: 'Unauthorized' });
59
64
  }
65
+ // A project token's principal is only ever a project's token (see utils/auth).
66
+ if ((0, auth_1.isProjectTokenPrincipal)(auth.userId) && auth.apiToken?.kind !== 'project') {
67
+ return res.status(401).json({ error: 'Unauthorized' });
68
+ }
60
69
  req.auth = auth;
61
70
  next();
62
71
  }
@@ -1,3 +1,4 @@
1
+ import type { ApiTokenPermission, ProjectPermission, AccountPermission } from '../services/apiTokenPermissions';
1
2
  import { Request } from 'express';
2
3
  import type { WebSocketServer } from 'ws';
3
4
  export interface S3Config {
@@ -133,6 +134,13 @@ export interface StreamByConfig {
133
134
  adapter?: StorageAdapter;
134
135
  encrypt?: string;
135
136
  encryptPreviousKeys?: string[];
137
+ mcp?: {
138
+ rateLimit?: {
139
+ requests: number;
140
+ windowMs: number;
141
+ };
142
+ apiBaseUrl?: string;
143
+ };
136
144
  websocket?: {
137
145
  server: WebSocketServer;
138
146
  };
@@ -160,10 +168,12 @@ export interface Auth {
160
168
  role: 'viewer' | 'editor' | 'admin';
161
169
  apiToken?: ApiTokenScope;
162
170
  }
163
- export type ApiTokenAccess = 'read' | 'write';
171
+ export type { ApiTokenPermission, ProjectPermission, AccountPermission };
172
+ export type ApiTokenKind = 'user' | 'project';
164
173
  export interface ApiTokenScope {
165
174
  tokenId: string;
166
- access: ApiTokenAccess;
175
+ kind: ApiTokenKind;
176
+ permissions: ApiTokenPermission[];
167
177
  projectIds: string[] | '*';
168
178
  }
169
179
  export type AuthProvider = (req: Request) => Promise<Auth>;
@@ -1,10 +1,21 @@
1
1
  import { ProjectInfo } from '../types';
2
- type ProjectMembers = Pick<ProjectInfo, 'members'> | null | undefined;
2
+ type ProjectMembers = (Pick<ProjectInfo, 'members'> & {
3
+ _id?: unknown;
4
+ id?: unknown;
5
+ }) | null | undefined;
6
+ export declare const projectTokenPrincipal: (tokenId: string, projectId: string) => string;
7
+ export declare const isProjectTokenPrincipal: (userId: unknown) => boolean;
3
8
  export declare function activeMemberOf(project: ProjectMembers, userId: string | undefined): {
4
9
  role: "viewer" | "editor" | "admin";
5
10
  userId: string;
6
11
  archived?: boolean;
7
12
  status?: "active" | "pending";
13
+ } | {
14
+ userId: string;
15
+ username: string;
16
+ role: "admin";
17
+ status: "active";
18
+ archived: boolean;
8
19
  } | undefined;
9
20
  export declare const projectRoleOf: (project: ProjectMembers, userId: string | undefined) => "viewer" | "editor" | "admin" | undefined;
10
21
  /** An active member, whatever their role: can see the project. */
@@ -1,11 +1,32 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.isProjectAdmin = exports.canEditProject = exports.isProjectMember = exports.projectRoleOf = void 0;
3
+ exports.isProjectAdmin = exports.canEditProject = exports.isProjectMember = exports.projectRoleOf = exports.isProjectTokenPrincipal = exports.projectTokenPrincipal = void 0;
4
4
  exports.activeMemberOf = activeMemberOf;
5
+ // A project's API token acts on its project as itself, not as a user: its request's userId is
6
+ // this principal, naming the token and its project. In that project it's an admin -- what it
7
+ // may do is only its permissions (see enforceApiTokenScope) -- and in any other, nobody.
8
+ const PROJECT_TOKEN_PRINCIPAL = 'project-token:';
9
+ const projectTokenPrincipal = (tokenId, projectId) => `${PROJECT_TOKEN_PRINCIPAL}${tokenId}:${projectId}`;
10
+ exports.projectTokenPrincipal = projectTokenPrincipal;
11
+ const isProjectTokenPrincipal = (userId) => typeof userId === 'string' && userId.startsWith(PROJECT_TOKEN_PRINCIPAL);
12
+ exports.isProjectTokenPrincipal = isProjectTokenPrincipal;
13
+ const projectOfPrincipal = (userId) => {
14
+ if (!(0, exports.isProjectTokenPrincipal)(userId))
15
+ return undefined;
16
+ const rest = userId.slice(PROJECT_TOKEN_PRINCIPAL.length);
17
+ return rest.slice(rest.indexOf(':') + 1) || undefined;
18
+ };
5
19
  // A project's member who's in it now: invited and accepted (status 'active'). (A member's
6
20
  // `archived` is only that member having archived the project in their own list.)
7
21
  function activeMemberOf(project, userId) {
8
- if (!project?.members || !userId)
22
+ if (!project || !userId)
23
+ return undefined;
24
+ const tokenProject = projectOfPrincipal(userId);
25
+ if (tokenProject !== undefined) {
26
+ const id = String(project._id ?? project.id ?? '');
27
+ return id && id === tokenProject ? { userId, username: 'API token', role: 'admin', status: 'active', archived: false } : undefined;
28
+ }
29
+ if (!project.members)
9
30
  return undefined;
10
31
  return project.members.find(member => member.userId?.toString() === userId && member.status === 'active');
11
32
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "streamby-core",
3
- "version": "0.35.0",
3
+ "version": "0.37.0",
4
4
  "description": "StreamBy middleware framework for media storage management",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -32,6 +32,7 @@
32
32
  "dependencies": {
33
33
  "@aws-sdk/client-s3": "^3.528.0",
34
34
  "@aws-sdk/s3-request-presigner": "^3.787.0",
35
+ "@modelcontextprotocol/sdk": "^1.32.1",
35
36
  "@prisma/client": "^6.12.0",
36
37
  "bcrypt": "^5.1.1",
37
38
  "dotenv": "^16.5.0",
@@ -41,7 +42,8 @@
41
42
  "node-fetch": "^3.0.0",
42
43
  "pg": "^8.16.3",
43
44
  "prisma": "^6.12.0",
44
- "ws": "^8.20.0"
45
+ "ws": "^8.20.0",
46
+ "zod": "^4.6.7"
45
47
  },
46
48
  "devDependencies": {
47
49
  "@types/bcrypt": "^5.0.2",