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 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
- # @streamby/core
1
+ # streamby-core
2
2
 
3
3
  [![License: AGPL v3](https://img.shields.io/badge/License-AGPLv3-blue.svg)](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
- `@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:
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 @streamby/core ws
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 '@streamby/core';
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
- `@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.
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 "@streamby/core/pg/setup"; // Adjust path as per your package structure
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 "@streamby/core/mongo/setup"; // Adjust path as per your package structure
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 '@streamby/core';
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 `@streamby/core`:
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 `@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.
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,3 @@
1
+ import { Router } from 'express';
2
+ import { StreamByConfig } from '../../types';
3
+ export declare function apiTokenRouter(config: StreamByConfig): Router;
@@ -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
+ }
@@ -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>>>;
@@ -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();
@@ -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) {
@@ -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';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "streamby-core",
3
- "version": "0.34.0",
3
+ "version": "0.35.0",
4
4
  "description": "StreamBy middleware framework for media storage management",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",