streamby-core 0.34.0 → 0.35.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 +33 -0
- package/README.md +59 -10
- package/dist/adapters/database/connectionManager.js +18 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +4 -1
- package/dist/middleware/createRouter.js +4 -0
- package/dist/middleware/routes/apiToken.d.ts +3 -0
- package/dist/middleware/routes/apiToken.js +58 -0
- package/dist/middleware/routes/media.js +4 -0
- package/dist/middleware/routes/project.js +4 -0
- package/dist/services/apiTokens.d.ts +47 -0
- package/dist/services/apiTokens.js +110 -0
- package/dist/services/auth.d.ts +4 -1
- package/dist/services/auth.js +41 -1
- package/dist/services/wsHub.js +6 -0
- package/dist/types/index.d.ts +7 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.35.0
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **API tokens**, so agents and services can work with StreamBy as a user without the user's
|
|
8
|
+
browser session. The README has the details.
|
|
9
|
+
- `GET`, `POST` and `DELETE /user/api-tokens` let a user list, make and revoke their own
|
|
10
|
+
tokens. These routes need a session: a token can't manage tokens.
|
|
11
|
+
- A token is `sbt_` followed by 32 random bytes. It's shown once, when it's made.
|
|
12
|
+
- **Scope:** each token is `read` (only reads) or `write` (reads and writes). It reaches a
|
|
13
|
+
list of projects, or `'*'` for every project its owner is a member of, including ones they
|
|
14
|
+
join later. It expires in 7, 30, 90 or 365 days, or never.
|
|
15
|
+
- A request made with a token reaches only its scope:
|
|
16
|
+
- a `read` token can't write;
|
|
17
|
+
- a project outside its scope answers 404, and the project list and jobs show only its
|
|
18
|
+
projects;
|
|
19
|
+
- outside a project, it can only make a few reads.
|
|
20
|
+
|
|
21
|
+
The owner's role in each project still applies on top, so a token never does more than
|
|
22
|
+
its owner can, and it stops reaching a project once its owner leaves it.
|
|
23
|
+
- **`resolveApiToken`** (exported, with `API_TOKEN_PREFIX`) is for the host's
|
|
24
|
+
`authProvider`. It turns `Authorization: Bearer sbt_...` into the token's owner and scope,
|
|
25
|
+
or `null` once the token is revoked or expired. The `authProvider` returns the scope as
|
|
26
|
+
`Auth.apiToken`. Without it, a token would have all of its owner's access.
|
|
27
|
+
- **New table: `api_tokens`.** It's created at startup in the main database, and it keeps
|
|
28
|
+
only each token's SHA-256 hash, never the token itself.
|
|
29
|
+
|
|
30
|
+
### Security
|
|
31
|
+
|
|
32
|
+
- A connection authenticated with an API token can't open the WebSocket. It's closed with
|
|
33
|
+
code 1008. The WebSocket belongs to the browser session and receives every event of its
|
|
34
|
+
user, from any project.
|
|
35
|
+
|
|
3
36
|
## 0.34.0
|
|
4
37
|
|
|
5
38
|
### ⚠️ Migration and deploy
|
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#
|
|
1
|
+
# streamby-core
|
|
2
2
|
|
|
3
3
|
[](https://www.gnu.org/licenses/agpl-3.0)
|
|
4
4
|
|
|
@@ -9,7 +9,7 @@ Part of the [StreamBy](https://streamby.nhexa.cl) ecosystem by TerminalCore Labs
|
|
|
9
9
|
|
|
10
10
|
## 🚀 What is it?
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
`streamby-core` is a plug-and-play middleware for Express (or compatible frameworks) that enables file uploads, listings and **multi-project access** over services like:
|
|
13
13
|
|
|
14
14
|
- ✅ AWS S3
|
|
15
15
|
- ✅ Google Cloud Storage (soon)
|
|
@@ -23,7 +23,7 @@ It's designed to be installed inside your existing API as a library — **no nee
|
|
|
23
23
|
## 📦 Installation
|
|
24
24
|
|
|
25
25
|
```bash
|
|
26
|
-
npm install
|
|
26
|
+
npm install streamby-core ws
|
|
27
27
|
npm install -D @types/ws
|
|
28
28
|
```
|
|
29
29
|
|
|
@@ -35,7 +35,7 @@ npm install -D @types/ws
|
|
|
35
35
|
import http from 'http';
|
|
36
36
|
import express from 'express';
|
|
37
37
|
import { WebSocketServer } from 'ws';
|
|
38
|
-
import { createStreamByRouter } from '
|
|
38
|
+
import { createStreamByRouter } from 'streamby-core';
|
|
39
39
|
|
|
40
40
|
const app = express();
|
|
41
41
|
const server = http.createServer(app);
|
|
@@ -100,6 +100,55 @@ createStreamByRouter({
|
|
|
100
100
|
|
|
101
101
|
---
|
|
102
102
|
|
|
103
|
+
## 🎫 API tokens
|
|
104
|
+
|
|
105
|
+
Agents and services work with StreamBy as a user through API tokens, without the user's
|
|
106
|
+
browser session. A user manages their own tokens (`GET`, `POST` and
|
|
107
|
+
`DELETE /streamby/user/api-tokens`, only with a session). Each token has:
|
|
108
|
+
|
|
109
|
+
- **an access level**: `read` (only reads) or `write` (reads and writes);
|
|
110
|
+
- **the projects it reaches**: a list of project ids, or `'*'` for every project the user is a
|
|
111
|
+
member of, including ones they join later;
|
|
112
|
+
- **an expiry**: 7, 30, 90 or 365 days, or none.
|
|
113
|
+
|
|
114
|
+
A token is `sbt_` followed by 32 random bytes. It's shown once, when it's made. Only its
|
|
115
|
+
SHA-256 hash is stored, with a short prefix to tell it apart.
|
|
116
|
+
|
|
117
|
+
Requests send it as `Authorization: Bearer sbt_...`. Your `authProvider` resolves it with
|
|
118
|
+
`resolveApiToken` and returns its owner, with the token's scope in `apiToken`:
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
import { createStreamByRouter, resolveApiToken, API_TOKEN_PREFIX } from 'streamby-core';
|
|
122
|
+
|
|
123
|
+
createStreamByRouter({
|
|
124
|
+
// ...
|
|
125
|
+
authProvider: async (req) => {
|
|
126
|
+
const bearer = req.headers.authorization?.replace(/^Bearer /, '');
|
|
127
|
+
if (bearer?.startsWith(API_TOKEN_PREFIX)) {
|
|
128
|
+
const resolved = await resolveApiToken(bearer); // null once revoked or expired
|
|
129
|
+
if (!resolved) throw new Error('Invalid API token');
|
|
130
|
+
const user = await findUser(resolved.userId); // your own user lookup
|
|
131
|
+
return { userId: user.id, username: user.username, role: user.role, apiToken: resolved.scope };
|
|
132
|
+
}
|
|
133
|
+
// ...your session-based authentication
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
A request made with a token reaches only what the token's scope allows:
|
|
139
|
+
|
|
140
|
+
- a `read` token can't write;
|
|
141
|
+
- a project outside the token's projects answers 404, and the project list shows only the
|
|
142
|
+
token's projects;
|
|
143
|
+
- a token can't manage tokens;
|
|
144
|
+
- outside a project, a token can only make a few reads (`/auth`, `/projects`, `/jobs/:jobId`,
|
|
145
|
+
`/user/subscription`, `/users/search`).
|
|
146
|
+
|
|
147
|
+
The owner's role in each project still applies on top of that. A token never does more than
|
|
148
|
+
its owner can, and it stops reaching a project as soon as its owner leaves it.
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
103
152
|
## 👥 Project roles
|
|
104
153
|
|
|
105
154
|
What a user can do in a project depends on their role in that project, as an active member
|
|
@@ -120,7 +169,7 @@ nor one that starts a job.
|
|
|
120
169
|
|
|
121
170
|
## 🗄️ Database Schema Setup
|
|
122
171
|
|
|
123
|
-
|
|
172
|
+
`streamby-core` requires a pre-configured database schema for its internal operations. It does not manage database connections directly; instead, it expects the host application to provide initialized connection instances. This ensures full control and flexibility for the host environment.
|
|
124
173
|
|
|
125
174
|
The library will only interact with the specified schema and will not touch other schemas in your database. The `reset` option is opt-in and will only drop and recreate the specified `streamby` schema if explicitly set to `true`.
|
|
126
175
|
|
|
@@ -128,7 +177,7 @@ The library will only interact with the specified schema and will not touch othe
|
|
|
128
177
|
|
|
129
178
|
```ts
|
|
130
179
|
import { Pool } from "pg";
|
|
131
|
-
import { setupStreambyPg } from "
|
|
180
|
+
import { setupStreambyPg } from "streamby-core/pg/setup"; // Adjust path as per your package structure
|
|
132
181
|
|
|
133
182
|
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
134
183
|
|
|
@@ -141,7 +190,7 @@ await setupStreambyPg({ pool, schema: "streamby", reset: false });
|
|
|
141
190
|
|
|
142
191
|
```ts
|
|
143
192
|
import { MongoClient } from "mongodb";
|
|
144
|
-
import { setupStreambyMongo } from "
|
|
193
|
+
import { setupStreambyMongo } from "streamby-core/mongo/setup"; // Adjust path as per your package structure
|
|
145
194
|
|
|
146
195
|
const client = new MongoClient(process.env.MONGO_URL!);
|
|
147
196
|
|
|
@@ -154,7 +203,7 @@ await setupStreambyMongo({ client, dbName: "streamby" });
|
|
|
154
203
|
|
|
155
204
|
```ts
|
|
156
205
|
import express from 'express';
|
|
157
|
-
import { createStreamByRouter } from '
|
|
206
|
+
import { createStreamByRouter } from 'streamby-core';
|
|
158
207
|
|
|
159
208
|
const app = express();
|
|
160
209
|
|
|
@@ -215,7 +264,7 @@ Unit tests use [Vitest](https://vitest.dev) and [Supertest](https://www.npmjs.co
|
|
|
215
264
|
|
|
216
265
|
## 🗺️ API Endpoints
|
|
217
266
|
|
|
218
|
-
Here's a summary of the key API endpoints provided by
|
|
267
|
+
Here's a summary of the key API endpoints provided by `streamby-core`:
|
|
219
268
|
|
|
220
269
|
### `GET /streamby/projects`
|
|
221
270
|
|
|
@@ -330,5 +379,5 @@ Copyright (C) 2026 TerminalCore Labs
|
|
|
330
379
|
|
|
331
380
|
Licensed under the [GNU Affero General Public License v3.0](./LICENSE) (AGPL-3.0-only).
|
|
332
381
|
|
|
333
|
-
> **Note for consumers:** unlike most installable packages, this one is intentionally AGPL rather than MIT. AGPL's copyleft applies to any project that depends on it -- if you install
|
|
382
|
+
> **Note for consumers:** unlike most installable packages, this one is intentionally AGPL rather than MIT. AGPL's copyleft applies to any project that depends on it -- if you install `streamby-core`, your own project must also be licensed under AGPL-3.0 (or a compatible copyleft license). Confirm this is acceptable for your use case before adding this dependency.
|
|
334
383
|
|
|
@@ -147,6 +147,24 @@ const ensureTablesExist = async (pool) => {
|
|
|
147
147
|
CREATE INDEX IF NOT EXISTS user_integrations_userid_idx ON streamby.user_integrations ("userId");
|
|
148
148
|
`);
|
|
149
149
|
console.log('✅ "user_integrations" table ensured to exist.');
|
|
150
|
+
// API tokens: only their hash is kept (see services/apiTokens). Main db only, like
|
|
151
|
+
// user_integrations above.
|
|
152
|
+
await pool.query(`
|
|
153
|
+
CREATE TABLE IF NOT EXISTS streamby.api_tokens (
|
|
154
|
+
id TEXT PRIMARY KEY DEFAULT uuid_generate_v4()::text,
|
|
155
|
+
"userId" VARCHAR(255) NOT NULL,
|
|
156
|
+
name VARCHAR(255) NOT NULL,
|
|
157
|
+
prefix VARCHAR(32) NOT NULL,
|
|
158
|
+
hash CHAR(64) NOT NULL UNIQUE,
|
|
159
|
+
access VARCHAR(10) NOT NULL,
|
|
160
|
+
"projectIds" JSONB NOT NULL,
|
|
161
|
+
"createdAt" TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
162
|
+
"expiresAt" TIMESTAMP WITH TIME ZONE,
|
|
163
|
+
"lastUsedAt" TIMESTAMP WITH TIME ZONE
|
|
164
|
+
);
|
|
165
|
+
CREATE INDEX IF NOT EXISTS api_tokens_userid_idx ON streamby.api_tokens ("userId");
|
|
166
|
+
`);
|
|
167
|
+
console.log('✅ "api_tokens" table ensured to exist.');
|
|
150
168
|
}
|
|
151
169
|
catch (error) {
|
|
152
170
|
console.error('❌ Error ensuring tables exist:', error);
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export { createStreamByRouter } from './middleware/createRouter';
|
|
2
2
|
export { emitToUser } from './services/wsHub';
|
|
3
3
|
export { createNotification } from './services/notification';
|
|
4
|
-
export type { UserPlan, UserSubscription } from './types';
|
|
4
|
+
export type { UserPlan, UserSubscription, Auth, ApiTokenScope, ApiTokenAccess } from './types';
|
|
5
|
+
export { resolveApiToken, API_TOKEN_PREFIX } from './services/apiTokens';
|
|
6
|
+
export type { ResolvedApiToken } from './services/apiTokens';
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.createNotification = exports.emitToUser = exports.createStreamByRouter = void 0;
|
|
3
|
+
exports.API_TOKEN_PREFIX = exports.resolveApiToken = exports.createNotification = exports.emitToUser = exports.createStreamByRouter = void 0;
|
|
4
4
|
var createRouter_1 = require("./middleware/createRouter");
|
|
5
5
|
Object.defineProperty(exports, "createStreamByRouter", { enumerable: true, get: function () { return createRouter_1.createStreamByRouter; } });
|
|
6
6
|
var wsHub_1 = require("./services/wsHub");
|
|
7
7
|
Object.defineProperty(exports, "emitToUser", { enumerable: true, get: function () { return wsHub_1.emitToUser; } });
|
|
8
8
|
var notification_1 = require("./services/notification");
|
|
9
9
|
Object.defineProperty(exports, "createNotification", { enumerable: true, get: function () { return notification_1.createNotification; } });
|
|
10
|
+
var apiTokens_1 = require("./services/apiTokens");
|
|
11
|
+
Object.defineProperty(exports, "resolveApiToken", { enumerable: true, get: function () { return apiTokens_1.resolveApiToken; } });
|
|
12
|
+
Object.defineProperty(exports, "API_TOKEN_PREFIX", { enumerable: true, get: function () { return apiTokens_1.API_TOKEN_PREFIX; } });
|
|
@@ -12,6 +12,7 @@ const backfillBuiltinConnections_1 = require("../services/backfillBuiltinConnect
|
|
|
12
12
|
const reencryptCredentials_1 = require("../services/reencryptCredentials");
|
|
13
13
|
const wsHub_1 = require("../services/wsHub");
|
|
14
14
|
const auth_1 = require("../services/auth");
|
|
15
|
+
const apiToken_1 = require("./routes/apiToken");
|
|
15
16
|
const encryption_1 = require("../utils/encryption");
|
|
16
17
|
const auth_2 = require("./routes/auth");
|
|
17
18
|
const user_1 = require("./routes/user");
|
|
@@ -95,6 +96,7 @@ function createStreamByRouter(config) {
|
|
|
95
96
|
(0, manager_1.registerModel)('users', [mainDb.id], 'users', 'accounts');
|
|
96
97
|
(0, manager_1.registerModel)('user_subscriptions', [mainDb.id], 'user_subscriptions', 'streamby');
|
|
97
98
|
(0, manager_1.registerModel)('user_integrations', [mainDb.id], 'user_integrations', streambySchema);
|
|
99
|
+
(0, manager_1.registerModel)('api_tokens', [mainDb.id], 'api_tokens', streambySchema);
|
|
98
100
|
}
|
|
99
101
|
}
|
|
100
102
|
if (config.websocket?.server) {
|
|
@@ -104,8 +106,10 @@ function createStreamByRouter(config) {
|
|
|
104
106
|
// but for the public export route (see authenticate).
|
|
105
107
|
router.use((0, auth_2.authRouter)(config));
|
|
106
108
|
router.use((0, auth_1.authenticate)(config));
|
|
109
|
+
router.use(auth_1.enforceApiTokenScope);
|
|
107
110
|
router.use(subscription_1.ensureSubscription);
|
|
108
111
|
router.use((0, user_1.userRouter)(config));
|
|
112
|
+
router.use((0, apiToken_1.apiTokenRouter)(config));
|
|
109
113
|
router.use((0, export_1.exportRouter)(config));
|
|
110
114
|
router.use((0, member_1.memberRouter)(config));
|
|
111
115
|
router.use((0, storage_2.storageRouter)(config));
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.apiTokenRouter = apiTokenRouter;
|
|
4
|
+
const express_1 = require("express");
|
|
5
|
+
const manager_1 = require("../../models/manager");
|
|
6
|
+
const auth_1 = require("../../utils/auth");
|
|
7
|
+
const apiTokens_1 = require("../../services/apiTokens");
|
|
8
|
+
// The signed-in user's own API tokens (see services/apiTokens): listed, made, revoked. Only
|
|
9
|
+
// with a session: a token can't manage tokens (see enforceApiTokenScope).
|
|
10
|
+
function apiTokenRouter(config) {
|
|
11
|
+
const router = (0, express_1.Router)();
|
|
12
|
+
const Project = (0, manager_1.getModel)('projects');
|
|
13
|
+
router.get('/user/api-tokens', async (req, res) => {
|
|
14
|
+
try {
|
|
15
|
+
const auth = req.auth;
|
|
16
|
+
res.json({ apiTokens: await (0, apiTokens_1.listApiTokens)(auth.userId) });
|
|
17
|
+
}
|
|
18
|
+
catch (err) {
|
|
19
|
+
res.status(500).json({ message: 'Failed to list API tokens', details: err.message });
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
// The token itself is in this response only: it's never stored, nor shown again.
|
|
23
|
+
router.post('/user/api-tokens', async (req, res) => {
|
|
24
|
+
try {
|
|
25
|
+
const auth = req.auth;
|
|
26
|
+
const { name, access, projectIds, expiresInDays } = req.body ?? {};
|
|
27
|
+
// A token reaches only projects its owner is in.
|
|
28
|
+
if (Array.isArray(projectIds)) {
|
|
29
|
+
for (const id of projectIds) {
|
|
30
|
+
const project = typeof id === 'string' ? await Project.findOne({ _id: id }) : null;
|
|
31
|
+
if (!(0, auth_1.isProjectMember)(project, auth.userId)) {
|
|
32
|
+
return res.status(400).json({ message: `You are not a member of project '${id}'` });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const { token, info } = await (0, apiTokens_1.createApiToken)(auth.userId, { name, access, projectIds, expiresInDays: expiresInDays ?? null }, config);
|
|
37
|
+
res.status(201).json({ token, apiToken: info, message: 'Copy the token now: it will not be shown again' });
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
if (err instanceof apiTokens_1.ApiTokenError)
|
|
41
|
+
return res.status(err.status).json({ message: err.message });
|
|
42
|
+
res.status(500).json({ message: 'Failed to create API token', details: err.message });
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
router.delete('/user/api-tokens/:tokenId', async (req, res) => {
|
|
46
|
+
try {
|
|
47
|
+
const auth = req.auth;
|
|
48
|
+
const revoked = await (0, apiTokens_1.revokeApiToken)(auth.userId, req.params.tokenId);
|
|
49
|
+
if (!revoked)
|
|
50
|
+
return res.status(404).json({ message: 'API token not found' });
|
|
51
|
+
res.status(204).end();
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
res.status(500).json({ message: 'Failed to revoke API token', details: err.message });
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
return router;
|
|
58
|
+
}
|
|
@@ -8,6 +8,7 @@ const jobQueue_1 = require("../../services/jobQueue");
|
|
|
8
8
|
const mediaProcessor_1 = require("../../services/mediaProcessor");
|
|
9
9
|
const storage_1 = require("../../providers/storage");
|
|
10
10
|
const connectionManager_1 = require("../../adapters/database/connectionManager");
|
|
11
|
+
const auth_2 = require("../../services/auth");
|
|
11
12
|
function getMetadataCollection() {
|
|
12
13
|
const ids = (0, connectionManager_1.getConnectedIds)().filter(id => (0, connectionManager_1.getConnection)(id).type === 'nosql');
|
|
13
14
|
if (!ids.length)
|
|
@@ -33,6 +34,9 @@ function mediaRouter(config) {
|
|
|
33
34
|
const job = (0, jobQueue_1.getJob)(req.params.jobId);
|
|
34
35
|
if (!job)
|
|
35
36
|
return res.status(404).json({ message: 'Job not found' });
|
|
37
|
+
// With an API token, only a job of a project it reaches.
|
|
38
|
+
if (!(0, auth_2.scopeReachesProject)(auth.apiToken, job.projectId))
|
|
39
|
+
return res.status(404).json({ message: 'Job not found' });
|
|
36
40
|
if (job.userId !== auth.userId) {
|
|
37
41
|
const project = await Project.findOne({ _id: job.projectId });
|
|
38
42
|
if (!(0, auth_1.isProjectMember)(project, auth.userId))
|
|
@@ -8,6 +8,7 @@ const connectionManager_1 = require("../../adapters/database/connectionManager")
|
|
|
8
8
|
const sql_1 = require("../../adapters/database/sql");
|
|
9
9
|
const sanitize_1 = require("../../utils/sanitize");
|
|
10
10
|
const projectIntegration_1 = require("../../services/projectIntegration");
|
|
11
|
+
const auth_2 = require("../../services/auth");
|
|
11
12
|
function projectRouter(config) {
|
|
12
13
|
const router = (0, express_1.Router)();
|
|
13
14
|
const Project = (0, manager_1.getModel)('projects');
|
|
@@ -32,6 +33,9 @@ function projectRouter(config) {
|
|
|
32
33
|
const currentUserMember = project.members?.find((m) => m.userId?.toString() === auth.userId?.toString());
|
|
33
34
|
if (!currentUserMember || currentUserMember.status !== 'active')
|
|
34
35
|
return false;
|
|
36
|
+
// With an API token, only the projects it reaches.
|
|
37
|
+
if (!(0, auth_2.scopeReachesProject)(auth.apiToken, (project._id ?? project.id)?.toString()))
|
|
38
|
+
return false;
|
|
35
39
|
if (filterArchived !== undefined) {
|
|
36
40
|
return (currentUserMember.archived || false) === filterArchived;
|
|
37
41
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { ApiTokenAccess, ApiTokenScope, StreamByConfig } from '../types';
|
|
2
|
+
export declare const API_TOKEN_PREFIX = "sbt_";
|
|
3
|
+
export declare const API_TOKEN_EXPIRY_DAYS: readonly [7, 30, 90, 365];
|
|
4
|
+
export interface ApiTokenRecord {
|
|
5
|
+
id: string;
|
|
6
|
+
userId: string;
|
|
7
|
+
name: string;
|
|
8
|
+
prefix: string;
|
|
9
|
+
hash: string;
|
|
10
|
+
access: ApiTokenAccess;
|
|
11
|
+
projectIds: string[];
|
|
12
|
+
createdAt: Date;
|
|
13
|
+
expiresAt: Date | null;
|
|
14
|
+
lastUsedAt: Date | null;
|
|
15
|
+
}
|
|
16
|
+
/** What a token's owner sees of it. */
|
|
17
|
+
export type ApiTokenInfo = Omit<ApiTokenRecord, 'hash' | 'userId' | 'projectIds'> & {
|
|
18
|
+
projectIds: string[] | '*';
|
|
19
|
+
};
|
|
20
|
+
export declare class ApiTokenError extends Error {
|
|
21
|
+
status: number;
|
|
22
|
+
constructor(message: string, status?: number);
|
|
23
|
+
}
|
|
24
|
+
export interface CreateApiTokenInput {
|
|
25
|
+
name: string;
|
|
26
|
+
access: ApiTokenAccess;
|
|
27
|
+
projectIds: string[] | '*';
|
|
28
|
+
expiresInDays: number | null;
|
|
29
|
+
}
|
|
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<{
|
|
32
|
+
token: string;
|
|
33
|
+
info: ApiTokenInfo;
|
|
34
|
+
}>;
|
|
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>;
|
|
39
|
+
export interface ResolvedApiToken {
|
|
40
|
+
userId: string;
|
|
41
|
+
scope: ApiTokenScope;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
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.
|
|
46
|
+
*/
|
|
47
|
+
export declare function resolveApiToken(token: unknown): Promise<ResolvedApiToken | null>;
|
|
@@ -0,0 +1,110 @@
|
|
|
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.ApiTokenError = exports.API_TOKEN_EXPIRY_DAYS = exports.API_TOKEN_PREFIX = void 0;
|
|
7
|
+
exports.createApiToken = createApiToken;
|
|
8
|
+
exports.listApiTokens = listApiTokens;
|
|
9
|
+
exports.revokeApiToken = revokeApiToken;
|
|
10
|
+
exports.resolveApiToken = resolveApiToken;
|
|
11
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
12
|
+
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.
|
|
19
|
+
exports.API_TOKEN_PREFIX = 'sbt_';
|
|
20
|
+
const PREFIX_SHOWN = exports.API_TOKEN_PREFIX.length + 8;
|
|
21
|
+
exports.API_TOKEN_EXPIRY_DAYS = [7, 30, 90, 365];
|
|
22
|
+
const ALL_PROJECTS = '*';
|
|
23
|
+
class ApiTokenError extends Error {
|
|
24
|
+
constructor(message, status = 400) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.status = status;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
exports.ApiTokenError = ApiTokenError;
|
|
30
|
+
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) => {
|
|
37
|
+
if (typeof value === 'string') {
|
|
38
|
+
try {
|
|
39
|
+
return parseProjectIds(JSON.parse(value));
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (!Array.isArray(value))
|
|
46
|
+
return [];
|
|
47
|
+
const ids = value.map(String);
|
|
48
|
+
return ids.includes(ALL_PROJECTS) ? '*' : ids;
|
|
49
|
+
};
|
|
50
|
+
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) {
|
|
53
|
+
const name = typeof input.name === 'string' ? input.name.trim() : '';
|
|
54
|
+
if (!name)
|
|
55
|
+
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.");
|
|
60
|
+
}
|
|
61
|
+
if (input.expiresInDays !== null && !exports.API_TOKEN_EXPIRY_DAYS.includes(input.expiresInDays)) {
|
|
62
|
+
throw new ApiTokenError(`expiresInDays must be one of ${exports.API_TOKEN_EXPIRY_DAYS.join(', ')}, or null.`);
|
|
63
|
+
}
|
|
64
|
+
const token = exports.API_TOKEN_PREFIX + crypto_1.default.randomBytes(32).toString('base64url');
|
|
65
|
+
const now = new Date();
|
|
66
|
+
const record = {
|
|
67
|
+
id: crypto_1.default.randomUUID(),
|
|
68
|
+
userId,
|
|
69
|
+
name,
|
|
70
|
+
prefix: token.slice(0, PREFIX_SHOWN),
|
|
71
|
+
hash: hashOf(token),
|
|
72
|
+
access: input.access,
|
|
73
|
+
projectIds: input.projectIds === '*' ? [ALL_PROJECTS] : [...new Set(input.projectIds)],
|
|
74
|
+
createdAt: now,
|
|
75
|
+
expiresAt: input.expiresInDays === null ? null : new Date(now.getTime() + input.expiresInDays * 86400000),
|
|
76
|
+
lastUsedAt: null,
|
|
77
|
+
};
|
|
78
|
+
await (0, manager_1.getModel)('api_tokens').create({ ...record, dbType: mainDbType(config) });
|
|
79
|
+
return { token, info: infoOf(record) };
|
|
80
|
+
}
|
|
81
|
+
/** The tokens `userId` has. */
|
|
82
|
+
async function listApiTokens(userId) {
|
|
83
|
+
const records = (await (0, manager_1.getModel)('api_tokens').find({ userId }));
|
|
84
|
+
return records
|
|
85
|
+
.map(infoOf)
|
|
86
|
+
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
|
87
|
+
}
|
|
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;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
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.
|
|
95
|
+
*/
|
|
96
|
+
async function resolveApiToken(token) {
|
|
97
|
+
if (typeof token !== 'string' || !token.startsWith(exports.API_TOKEN_PREFIX))
|
|
98
|
+
return null;
|
|
99
|
+
const ApiTokens = (0, manager_1.getModel)('api_tokens');
|
|
100
|
+
const record = (await ApiTokens.findOne({ hash: hashOf(token) }));
|
|
101
|
+
if (!record)
|
|
102
|
+
return null;
|
|
103
|
+
if (record.expiresAt && new Date(record.expiresAt).getTime() <= Date.now())
|
|
104
|
+
return null;
|
|
105
|
+
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) },
|
|
109
|
+
};
|
|
110
|
+
}
|
package/dist/services/auth.d.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { Request, Response, NextFunction } from 'express';
|
|
2
|
-
import { StreamByConfig } from '../types';
|
|
2
|
+
import { Auth, StreamByConfig } from '../types';
|
|
3
3
|
export declare const isPublicRoute: (method: string, path: string) => boolean;
|
|
4
|
+
/** Whether a token's scope reaches a project. */
|
|
5
|
+
export declare const scopeReachesProject: (scope: Auth["apiToken"], projectId: string) => boolean;
|
|
6
|
+
export declare const enforceApiTokenScope: (req: Request, res: Response, next: NextFunction) => void | Response<any, Record<string, any>>;
|
|
4
7
|
export declare const authenticate: (config: StreamByConfig) => (req: Request, res: Response, next: NextFunction) => Promise<void | Response<any, Record<string, any>>>;
|
package/dist/services/auth.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.authenticate = exports.isPublicRoute = void 0;
|
|
3
|
+
exports.authenticate = exports.enforceApiTokenScope = exports.scopeReachesProject = exports.isPublicRoute = void 0;
|
|
4
4
|
// The one route open without a session: a project's public export, read
|
|
5
5
|
// (GET /:projectId/export/:exportName, see routes/export). Exactly that path -- not any path
|
|
6
6
|
// that has an "export" segment somewhere.
|
|
@@ -8,6 +8,46 @@ const PUBLIC_EXPORT = /^\/[^/]+\/export\/[^/]+\/?$/;
|
|
|
8
8
|
const READ_METHODS = ['GET', 'HEAD', 'OPTIONS'];
|
|
9
9
|
const isPublicRoute = (method, path) => READ_METHODS.includes(method.toUpperCase()) && PUBLIC_EXPORT.test(path);
|
|
10
10
|
exports.isPublicRoute = isPublicRoute;
|
|
11
|
+
// ── 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).
|
|
16
|
+
// The project a route acts on, by its path.
|
|
17
|
+
const PROJECT_PATHS = [/^\/projects\/([^/]+)/, /^\/(?:upload|delete)-project-image-url\/([^/]+)/];
|
|
18
|
+
const NOT_A_PROJECT = new Set(['create', 'explore']);
|
|
19
|
+
const projectIdOf = (path) => {
|
|
20
|
+
for (const pattern of PROJECT_PATHS) {
|
|
21
|
+
const id = pattern.exec(path)?.[1];
|
|
22
|
+
if (id && !NOT_A_PROJECT.has(id))
|
|
23
|
+
return decodeURIComponent(id);
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
};
|
|
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
|
+
const scopeReachesProject = (scope, projectId) => !scope || scope.projectIds === '*' || scope.projectIds.includes(projectId);
|
|
31
|
+
exports.scopeReachesProject = scopeReachesProject;
|
|
32
|
+
const enforceApiTokenScope = (req, res, next) => {
|
|
33
|
+
const scope = req.auth?.apiToken;
|
|
34
|
+
if (!scope)
|
|
35
|
+
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' });
|
|
41
|
+
}
|
|
42
|
+
const projectId = projectIdOf(req.path);
|
|
43
|
+
if (projectId) {
|
|
44
|
+
return (0, exports.scopeReachesProject)(scope, projectId) ? next() : res.status(404).json({ message: 'Project not found' });
|
|
45
|
+
}
|
|
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' });
|
|
49
|
+
};
|
|
50
|
+
exports.enforceApiTokenScope = enforceApiTokenScope;
|
|
11
51
|
const authenticate = (config) => async (req, res, next) => {
|
|
12
52
|
if ((0, exports.isPublicRoute)(req.method, req.path)) {
|
|
13
53
|
return next();
|
package/dist/services/wsHub.js
CHANGED
|
@@ -15,6 +15,12 @@ function initWsHub(wss, config) {
|
|
|
15
15
|
ws.close(1008, 'Unauthorized');
|
|
16
16
|
return;
|
|
17
17
|
}
|
|
18
|
+
// The WebSocket is the browser session's: it gets every event of its user, whatever the
|
|
19
|
+
// project, so an API token (scoped to some projects, maybe read-only) never opens it.
|
|
20
|
+
if (auth.apiToken) {
|
|
21
|
+
ws.close(1008, "API tokens can't open the WebSocket");
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
18
24
|
userId = auth.userId;
|
|
19
25
|
}
|
|
20
26
|
catch (err) {
|
package/dist/types/index.d.ts
CHANGED
|
@@ -158,6 +158,13 @@ export interface Auth {
|
|
|
158
158
|
userId: string;
|
|
159
159
|
username: string;
|
|
160
160
|
role: 'viewer' | 'editor' | 'admin';
|
|
161
|
+
apiToken?: ApiTokenScope;
|
|
162
|
+
}
|
|
163
|
+
export type ApiTokenAccess = 'read' | 'write';
|
|
164
|
+
export interface ApiTokenScope {
|
|
165
|
+
tokenId: string;
|
|
166
|
+
access: ApiTokenAccess;
|
|
167
|
+
projectIds: string[] | '*';
|
|
161
168
|
}
|
|
162
169
|
export type AuthProvider = (req: Request) => Promise<Auth>;
|
|
163
170
|
export type UserPlan = 'freemium' | 'subscriber' | 'admin';
|