streamby-core 0.36.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.
- package/CHANGELOG.md +34 -0
- package/README.md +80 -44
- package/dist/adapters/database/connectionManager.js +24 -11
- package/dist/index.d.ts +2 -1
- package/dist/index.js +4 -1
- package/dist/mcp/server.d.ts +4 -3
- package/dist/mcp/server.js +13 -10
- package/dist/mcp/tools.d.ts +5 -2
- package/dist/mcp/tools.js +83 -24
- package/dist/middleware/routes/apiToken.js +77 -14
- package/dist/middleware/routes/member.js +8 -0
- package/dist/middleware/routes/project.js +8 -2
- package/dist/middleware/routes/subscription.js +3 -0
- package/dist/services/apiTokenPermissions.d.ts +16 -0
- package/dist/services/apiTokenPermissions.js +98 -0
- package/dist/services/apiTokens.d.ts +38 -14
- package/dist/services/apiTokens.js +134 -40
- package/dist/services/auth.d.ts +4 -1
- package/dist/services/auth.js +24 -18
- package/dist/types/index.d.ts +5 -2
- package/dist/utils/auth.d.ts +12 -1
- package/dist/utils/auth.js +23 -2
- package/package.json +1 -1
|
@@ -5,25 +5,34 @@ const express_1 = require("express");
|
|
|
5
5
|
const manager_1 = require("../../models/manager");
|
|
6
6
|
const auth_1 = require("../../utils/auth");
|
|
7
7
|
const apiTokens_1 = require("../../services/apiTokens");
|
|
8
|
-
//
|
|
9
|
-
// with a
|
|
8
|
+
// API tokens (see services/apiTokens), managed only with a session: no route here is
|
|
9
|
+
// reachable with a token (see apiTokenPermissions).
|
|
10
|
+
//
|
|
11
|
+
// - A user's own: listed, made and revoked by them.
|
|
12
|
+
// - A project's: listed, made and revoked by its admins only.
|
|
10
13
|
function apiTokenRouter(config) {
|
|
11
14
|
const router = (0, express_1.Router)();
|
|
12
15
|
const Project = (0, manager_1.getModel)('projects');
|
|
16
|
+
const failed = (res, what, err) => {
|
|
17
|
+
if (err instanceof apiTokens_1.ApiTokenError)
|
|
18
|
+
return res.status(err.status).json({ message: err.message });
|
|
19
|
+
res.status(500).json({ message: `Failed to ${what}`, details: err.message });
|
|
20
|
+
};
|
|
21
|
+
// The token itself is in the response that makes it only: it's never stored, nor shown again.
|
|
22
|
+
const created = (res, token, info) => res.status(201).json({ token, apiToken: info, message: 'Copy the token now: it will not be shown again' });
|
|
13
23
|
router.get('/user/api-tokens', async (req, res) => {
|
|
14
24
|
try {
|
|
15
25
|
const auth = req.auth;
|
|
16
|
-
res.json({ apiTokens: await (0, apiTokens_1.
|
|
26
|
+
res.json({ apiTokens: await (0, apiTokens_1.listUserApiTokens)(auth.userId) });
|
|
17
27
|
}
|
|
18
28
|
catch (err) {
|
|
19
|
-
res
|
|
29
|
+
failed(res, 'list API tokens', err);
|
|
20
30
|
}
|
|
21
31
|
});
|
|
22
|
-
// The token itself is in this response only: it's never stored, nor shown again.
|
|
23
32
|
router.post('/user/api-tokens', async (req, res) => {
|
|
24
33
|
try {
|
|
25
34
|
const auth = req.auth;
|
|
26
|
-
const { name,
|
|
35
|
+
const { name, permissions, projectIds, expiresInDays } = req.body ?? {};
|
|
27
36
|
// A token reaches only projects its owner is in.
|
|
28
37
|
if (Array.isArray(projectIds)) {
|
|
29
38
|
for (const id of projectIds) {
|
|
@@ -33,25 +42,79 @@ function apiTokenRouter(config) {
|
|
|
33
42
|
}
|
|
34
43
|
}
|
|
35
44
|
}
|
|
36
|
-
const { token, info } = await (0, apiTokens_1.createApiToken)(auth.userId, { name,
|
|
37
|
-
res
|
|
45
|
+
const { token, info } = await (0, apiTokens_1.createApiToken)({ kind: 'user', userId: auth.userId, username: auth.username }, { name, permissions, projectIds, expiresInDays: expiresInDays ?? null }, config);
|
|
46
|
+
created(res, token, info);
|
|
38
47
|
}
|
|
39
48
|
catch (err) {
|
|
40
|
-
|
|
41
|
-
return res.status(err.status).json({ message: err.message });
|
|
42
|
-
res.status(500).json({ message: 'Failed to create API token', details: err.message });
|
|
49
|
+
failed(res, 'create API token', err);
|
|
43
50
|
}
|
|
44
51
|
});
|
|
45
52
|
router.delete('/user/api-tokens/:tokenId', async (req, res) => {
|
|
46
53
|
try {
|
|
47
54
|
const auth = req.auth;
|
|
48
|
-
|
|
49
|
-
if (!revoked)
|
|
55
|
+
if (!(await (0, apiTokens_1.revokeUserApiToken)(auth.userId, req.params.tokenId))) {
|
|
50
56
|
return res.status(404).json({ message: 'API token not found' });
|
|
57
|
+
}
|
|
58
|
+
res.status(204).end();
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
failed(res, 'revoke API token', err);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
// ── A project's tokens: its admins only ──
|
|
65
|
+
const adminsProject = async (req, res) => {
|
|
66
|
+
const auth = req.auth;
|
|
67
|
+
const project = await Project.findOne({ _id: req.params.id });
|
|
68
|
+
if (!project) {
|
|
69
|
+
res.status(404).json({ message: 'Project not found' });
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
if (!(0, auth_1.isProjectAdmin)(project, auth.userId)) {
|
|
73
|
+
res.status(403).json({ message: "Only a project admin manages the project's API tokens" });
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
return project;
|
|
77
|
+
};
|
|
78
|
+
router.get('/projects/:id/api-tokens', async (req, res) => {
|
|
79
|
+
try {
|
|
80
|
+
const project = await adminsProject(req, res);
|
|
81
|
+
if (!project)
|
|
82
|
+
return;
|
|
83
|
+
// Whether whoever made each one is still in the project: it keeps working if they aren't.
|
|
84
|
+
const apiTokens = (await (0, apiTokens_1.listProjectApiTokens)(req.params.id)).map(token => ({
|
|
85
|
+
...token,
|
|
86
|
+
createdBy: { ...token.createdBy, isMember: !!(0, auth_1.activeMemberOf)(project, token.createdBy.userId) },
|
|
87
|
+
}));
|
|
88
|
+
res.json({ apiTokens });
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
failed(res, "list the project's API tokens", err);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
router.post('/projects/:id/api-tokens', async (req, res) => {
|
|
95
|
+
try {
|
|
96
|
+
const auth = req.auth;
|
|
97
|
+
if (!(await adminsProject(req, res)))
|
|
98
|
+
return;
|
|
99
|
+
const { name, permissions, expiresInDays } = req.body ?? {};
|
|
100
|
+
const { token, info } = await (0, apiTokens_1.createApiToken)({ kind: 'project', userId: auth.userId, username: auth.username, projectId: req.params.id }, { name, permissions, expiresInDays: expiresInDays ?? null }, config);
|
|
101
|
+
created(res, token, info);
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
failed(res, "create the project's API token", err);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
router.delete('/projects/:id/api-tokens/:tokenId', async (req, res) => {
|
|
108
|
+
try {
|
|
109
|
+
if (!(await adminsProject(req, res)))
|
|
110
|
+
return;
|
|
111
|
+
if (!(await (0, apiTokens_1.revokeProjectApiToken)(req.params.id, req.params.tokenId))) {
|
|
112
|
+
return res.status(404).json({ message: 'API token not found' });
|
|
113
|
+
}
|
|
51
114
|
res.status(204).end();
|
|
52
115
|
}
|
|
53
116
|
catch (err) {
|
|
54
|
-
res
|
|
117
|
+
failed(res, "revoke the project's API token", err);
|
|
55
118
|
}
|
|
56
119
|
});
|
|
57
120
|
return router;
|
|
@@ -7,6 +7,7 @@ const auth_1 = require("../../utils/auth");
|
|
|
7
7
|
const connectionManager_1 = require("../../adapters/database/connectionManager");
|
|
8
8
|
const sql_1 = require("../../adapters/database/sql");
|
|
9
9
|
const notification_1 = require("../../services/notification");
|
|
10
|
+
const apiTokens_1 = require("../../services/apiTokens");
|
|
10
11
|
const VALID_ROLES = ['viewer', 'editor', 'admin'];
|
|
11
12
|
function memberRouter(config) {
|
|
12
13
|
const router = (0, express_1.Router)();
|
|
@@ -302,6 +303,13 @@ function memberRouter(config) {
|
|
|
302
303
|
else {
|
|
303
304
|
await Project.update({ _id: projectId }, { $pull: { members: { userId: targetUserId } } });
|
|
304
305
|
}
|
|
306
|
+
// A project's tokens they made keep working: its admins should know.
|
|
307
|
+
const tokens = await (0, apiTokens_1.projectApiTokensMadeBy)(projectId, targetUserId);
|
|
308
|
+
if (tokens.length) {
|
|
309
|
+
const names = tokens.map(t => `"${t.name}"`).join(', ');
|
|
310
|
+
const admins = (project.members ?? []).filter((m) => m.role === 'admin' && m.status === 'active' && m.userId?.toString() !== targetUserId?.toString());
|
|
311
|
+
await Promise.all(admins.map((admin) => (0, notification_1.createNotification)(admin.userId.toString(), 'api_token_creator_left', `${tokens.length === 1 ? 'An API token' : `${tokens.length} API tokens`} of "${project.name}" (${names}) made by a member who left keep working until revoked`, { projectId, tokenIds: tokens.map(t => t.id) }).catch(() => { })));
|
|
312
|
+
}
|
|
305
313
|
res.status(200).json({ message: 'Member removed successfully' });
|
|
306
314
|
}
|
|
307
315
|
catch (err) {
|
|
@@ -9,6 +9,7 @@ const sql_1 = require("../../adapters/database/sql");
|
|
|
9
9
|
const sanitize_1 = require("../../utils/sanitize");
|
|
10
10
|
const projectIntegration_1 = require("../../services/projectIntegration");
|
|
11
11
|
const auth_2 = require("../../services/auth");
|
|
12
|
+
const apiTokens_1 = require("../../services/apiTokens");
|
|
12
13
|
function projectRouter(config) {
|
|
13
14
|
const router = (0, express_1.Router)();
|
|
14
15
|
const Project = (0, manager_1.getModel)('projects');
|
|
@@ -30,8 +31,8 @@ function projectRouter(config) {
|
|
|
30
31
|
const allProjects = await Project.find({}); // Fetch all projects
|
|
31
32
|
const projects = allProjects
|
|
32
33
|
.filter(project => {
|
|
33
|
-
const currentUserMember =
|
|
34
|
-
if (!currentUserMember
|
|
34
|
+
const currentUserMember = (0, auth_1.activeMemberOf)(project, auth.userId?.toString());
|
|
35
|
+
if (!currentUserMember)
|
|
35
36
|
return false;
|
|
36
37
|
// With an API token, only the projects it reaches.
|
|
37
38
|
if (!(0, auth_2.scopeReachesProject)(auth.apiToken, (project._id ?? project.id)?.toString()))
|
|
@@ -122,6 +123,9 @@ function projectRouter(config) {
|
|
|
122
123
|
integrationConnections: [],
|
|
123
124
|
});
|
|
124
125
|
const newProjectId = newProject._id || newProject.id;
|
|
126
|
+
// A token that made it reaches it.
|
|
127
|
+
if (auth.apiToken?.kind === 'user')
|
|
128
|
+
await (0, apiTokens_1.addProjectToApiToken)(auth.apiToken.tokenId, String(newProjectId));
|
|
125
129
|
if (dbConnections.length || storageConnections.length || integrationConnections.length) {
|
|
126
130
|
const stamped = await Project.update({ _id: newProjectId }, {
|
|
127
131
|
dbConnections: dbConnections.map(c => ({ ...c, projectId: newProjectId })),
|
|
@@ -253,6 +257,8 @@ function projectRouter(config) {
|
|
|
253
257
|
return res.status(403).json({ message: 'Only a project admin can delete it' });
|
|
254
258
|
}
|
|
255
259
|
await Project.useDbType(project.dbType).delete({ _id: projectId });
|
|
260
|
+
// Its tokens have nothing left to reach.
|
|
261
|
+
await (0, apiTokens_1.deleteProjectApiTokens)(String(projectId));
|
|
256
262
|
const allProjects = await Project.find({});
|
|
257
263
|
const projects = allProjects
|
|
258
264
|
.filter(project => {
|
|
@@ -18,6 +18,9 @@ const ensureSubscription = async (req, res, next) => {
|
|
|
18
18
|
const auth = req.auth;
|
|
19
19
|
if (!auth)
|
|
20
20
|
return next();
|
|
21
|
+
// A project's token is no user: it has no plan of its own.
|
|
22
|
+
if (auth.apiToken?.kind === 'project')
|
|
23
|
+
return next();
|
|
21
24
|
const plan = await resolveSubscription(auth);
|
|
22
25
|
req.subscription = plan;
|
|
23
26
|
next();
|
|
@@ -0,0 +1,16 @@
|
|
|
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"];
|
|
3
|
+
/** Permissions on the account: only a user's token has them. */
|
|
4
|
+
export declare const ACCOUNT_PERMISSIONS: readonly ["projects:create"];
|
|
5
|
+
export type ProjectPermission = typeof PROJECT_PERMISSIONS[number];
|
|
6
|
+
export type AccountPermission = typeof ACCOUNT_PERMISSIONS[number];
|
|
7
|
+
export type ApiTokenPermission = ProjectPermission | AccountPermission;
|
|
8
|
+
export declare const isProjectPermission: (value: unknown) => value is ProjectPermission;
|
|
9
|
+
export declare const isAccountPermission: (value: unknown) => value is AccountPermission;
|
|
10
|
+
/** What a route needs of a token: a permission, or only being a token (`null`). */
|
|
11
|
+
export interface RouteRule {
|
|
12
|
+
permission: ApiTokenPermission | null;
|
|
13
|
+
userTokenOnly?: boolean;
|
|
14
|
+
}
|
|
15
|
+
/** The rule for a request made with an API token, or undefined: not reachable with one. */
|
|
16
|
+
export declare function routeRuleFor(method: string, path: string): RouteRule | undefined;
|
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
27
|
-
projectIds
|
|
33
|
+
permissions: ApiTokenPermission[];
|
|
34
|
+
projectIds?: string[] | '*';
|
|
28
35
|
expiresInDays: number | null;
|
|
29
36
|
}
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
/**
|
|
36
|
-
export declare function
|
|
37
|
-
/**
|
|
38
|
-
export declare function
|
|
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
|
|
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.
|
|
9
|
-
exports.
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
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
|
-
|
|
32
|
-
|
|
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
|
|
45
|
+
return listOf(JSON.parse(value));
|
|
40
46
|
}
|
|
41
47
|
catch {
|
|
42
48
|
return [];
|
|
43
49
|
}
|
|
44
50
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
if (
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
|
|
73
|
-
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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(
|
|
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
|
-
/**
|
|
89
|
-
async function
|
|
90
|
-
|
|
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
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
}
|