streamby-core 0.32.1 → 0.34.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 +67 -0
- package/LICENSE +661 -0
- package/README.md +53 -2
- package/dist/middleware/createRouter.js +17 -2
- package/dist/middleware/routes/ai.js +3 -3
- package/dist/middleware/routes/connection.js +3 -12
- package/dist/middleware/routes/dbConnection.js +7 -26
- package/dist/middleware/routes/distribution.js +12 -5
- package/dist/middleware/routes/export.js +5 -4
- package/dist/middleware/routes/media.js +23 -10
- package/dist/middleware/routes/member.js +3 -12
- package/dist/middleware/routes/{credential.d.ts → oauth.d.ts} +1 -1
- package/dist/middleware/routes/oauth.js +101 -0
- package/dist/middleware/routes/pipeline.js +3 -3
- package/dist/middleware/routes/production.js +9 -9
- package/dist/middleware/routes/project.js +24 -31
- package/dist/middleware/routes/renderFarm.js +13 -6
- package/dist/middleware/routes/review.js +4 -4
- package/dist/middleware/routes/rights.js +1 -1
- package/dist/middleware/routes/storage.js +21 -15
- package/dist/middleware/routes/storageConnection.js +2 -8
- package/dist/middleware/routes/userIntegration.js +17 -2
- package/dist/middleware/routes/workflow.js +1 -1
- package/dist/services/auth.d.ts +1 -2
- package/dist/services/auth.js +9 -11
- package/dist/services/connectionResolver.js +6 -6
- package/dist/services/executor.d.ts +4 -1
- package/dist/services/executor.js +63 -6
- package/dist/services/oauthClient.d.ts +10 -0
- package/dist/services/oauthClient.js +134 -0
- package/dist/services/projectIntegration.d.ts +4 -1
- package/dist/services/projectIntegration.js +13 -3
- package/dist/services/reencryptCredentials.d.ts +5 -0
- package/dist/services/reencryptCredentials.js +74 -0
- package/dist/services/userIntegration.d.ts +23 -3
- package/dist/services/userIntegration.js +130 -7
- package/dist/types/index.d.ts +31 -3
- package/dist/utils/auth.d.ts +15 -1
- package/dist/utils/auth.js +21 -6
- package/dist/utils/encryption.d.ts +6 -1
- package/dist/utils/encryption.js +80 -27
- package/dist/utils/sanitize.d.ts +6 -0
- package/dist/utils/sanitize.js +18 -1
- package/dist/utils/serviceEndpoints.d.ts +2 -0
- package/dist/utils/serviceEndpoints.js +26 -0
- package/package.json +4 -3
- package/dist/middleware/routes/apiConnection.d.ts +0 -3
- package/dist/middleware/routes/apiConnection.js +0 -100
- package/dist/middleware/routes/credential.js +0 -80
- package/dist/services/apiConnection.d.ts +0 -18
- package/dist/services/apiConnection.js +0 -65
- package/dist/services/credential.d.ts +0 -8
- package/dist/services/credential.js +0 -70
- package/dist/services/pipeline.d.ts +0 -2
- package/dist/services/pipeline.js +0 -292
|
@@ -3,8 +3,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.InvalidCredentialUpdateError = exports.OAuthRefreshError = void 0;
|
|
7
|
+
exports.isOAuthServiceProvider = isOAuthServiceProvider;
|
|
6
8
|
exports.listUserIntegrations = listUserIntegrations;
|
|
7
9
|
exports.createUserIntegration = createUserIntegration;
|
|
10
|
+
exports.upsertOAuthServiceIntegration = upsertOAuthServiceIntegration;
|
|
8
11
|
exports.updateUserIntegration = updateUserIntegration;
|
|
9
12
|
exports.deleteUserIntegration = deleteUserIntegration;
|
|
10
13
|
exports.getDecryptedIntegrationCredential = getDecryptedIntegrationCredential;
|
|
@@ -12,9 +15,55 @@ exports.getDecryptedIntegrationCredentialById = getDecryptedIntegrationCredentia
|
|
|
12
15
|
const crypto_1 = __importDefault(require("crypto"));
|
|
13
16
|
const manager_1 = require("../models/manager");
|
|
14
17
|
const encryption_1 = require("../utils/encryption");
|
|
18
|
+
const auth_1 = require("../utils/auth");
|
|
19
|
+
const oauthClient_1 = require("./oauthClient");
|
|
20
|
+
const OAUTH_SERVICE_PROVIDERS = new Set(['jira', 'google']);
|
|
21
|
+
function isOAuthServiceProvider(provider) {
|
|
22
|
+
return OAUTH_SERVICE_PROVIDERS.has(provider);
|
|
23
|
+
}
|
|
24
|
+
// Refresh if the token expires within this window — a small buffer against clock skew and
|
|
25
|
+
// request latency so a caller never receives a token that expires mid-flight.
|
|
26
|
+
const REFRESH_SKEW_MS = 60000;
|
|
27
|
+
class OAuthRefreshError extends Error {
|
|
28
|
+
constructor(provider, integrationId, message) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.provider = provider;
|
|
31
|
+
this.integrationId = integrationId;
|
|
32
|
+
this.name = 'OAuthRefreshError';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
exports.OAuthRefreshError = OAuthRefreshError;
|
|
36
|
+
// Thrown when a client tries to set/overwrite the credential of an OAuth-backed (jira/
|
|
37
|
+
// google) integration directly — those can only be linked/relinked via the OAuth flow.
|
|
38
|
+
class InvalidCredentialUpdateError extends Error {
|
|
39
|
+
constructor(message) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.name = 'InvalidCredentialUpdateError';
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
exports.InvalidCredentialUpdateError = InvalidCredentialUpdateError;
|
|
15
45
|
function encryptCredential(kind, value) {
|
|
16
46
|
return kind === 'storage' ? (0, encryption_1.encrypt)(JSON.stringify(value)) : (0, encryption_1.encrypt)(String(value));
|
|
17
47
|
}
|
|
48
|
+
// OAuth tokens never go through encryptCredential/decryptIntegration (those stay unchanged,
|
|
49
|
+
// used only for database/storage/claude's single encryptedCredential field) — each token is
|
|
50
|
+
// encrypted independently via the existing single-string encrypt()/decrypt() primitives.
|
|
51
|
+
function encryptOAuthTokens(tokens) {
|
|
52
|
+
return {
|
|
53
|
+
encryptedAccessToken: (0, encryption_1.encrypt)(tokens.accessToken),
|
|
54
|
+
encryptedRefreshToken: (0, encryption_1.encrypt)(tokens.refreshToken),
|
|
55
|
+
tokenExpiresAt: tokens.expiresAt,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
function decryptOAuthTokens(integration) {
|
|
59
|
+
if (!integration.encryptedAccessToken || !integration.encryptedRefreshToken || !integration.tokenExpiresAt)
|
|
60
|
+
return null;
|
|
61
|
+
return {
|
|
62
|
+
accessToken: (0, encryption_1.decrypt)(integration.encryptedAccessToken),
|
|
63
|
+
refreshToken: (0, encryption_1.decrypt)(integration.encryptedRefreshToken),
|
|
64
|
+
expiresAt: integration.tokenExpiresAt,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
18
67
|
async function listUserIntegrations(userId) {
|
|
19
68
|
const UserIntegrationModel = (0, manager_1.getModel)('user_integrations');
|
|
20
69
|
return UserIntegrationModel.find({ userId });
|
|
@@ -23,6 +72,12 @@ async function createUserIntegration(userId, input) {
|
|
|
23
72
|
if (!(0, encryption_1.isEncryptionKeySet)()) {
|
|
24
73
|
throw new Error('Encryption key is not set. Cannot create integration.');
|
|
25
74
|
}
|
|
75
|
+
// Defense in depth — the router rejects direct jira/google creation before this is ever
|
|
76
|
+
// called (see userIntegration.ts route's POST handler), but guard here too rather than
|
|
77
|
+
// silently mis-encrypting an OAuth provider's value into the wrong (single-string) field.
|
|
78
|
+
if (isOAuthServiceProvider(input.provider)) {
|
|
79
|
+
throw new InvalidCredentialUpdateError(`Provider '${input.provider}' requires OAuth — cannot be created with a direct credential.`);
|
|
80
|
+
}
|
|
26
81
|
const UserIntegrationModel = (0, manager_1.getModel)('user_integrations');
|
|
27
82
|
const now = new Date();
|
|
28
83
|
const integration = {
|
|
@@ -38,6 +93,34 @@ async function createUserIntegration(userId, input) {
|
|
|
38
93
|
};
|
|
39
94
|
return UserIntegrationModel.create(integration);
|
|
40
95
|
}
|
|
96
|
+
// Creates or updates the OAuth-backed UserIntegration for (userId, provider) — called only
|
|
97
|
+
// from the OAuth callback (oauth.ts), never from the generic CRUD router. Upserts rather
|
|
98
|
+
// than always inserting, so a user re-consenting (e.g. after revoking access on the
|
|
99
|
+
// provider's side) re-links the same integration instead of creating a duplicate.
|
|
100
|
+
async function upsertOAuthServiceIntegration(userId, provider, name, tokens) {
|
|
101
|
+
if (!(0, encryption_1.isEncryptionKeySet)()) {
|
|
102
|
+
throw new Error('Encryption key is not set. Cannot store integration tokens.');
|
|
103
|
+
}
|
|
104
|
+
const UserIntegrationModel = (0, manager_1.getModel)('user_integrations');
|
|
105
|
+
const existing = await UserIntegrationModel.findOne({ userId, kind: 'service', provider });
|
|
106
|
+
const encryptedTokens = encryptOAuthTokens(tokens);
|
|
107
|
+
const now = new Date();
|
|
108
|
+
if (existing) {
|
|
109
|
+
const updated = await UserIntegrationModel.update({ id: existing.id, userId }, { ...encryptedTokens, updatedAt: now });
|
|
110
|
+
return updated ?? existing;
|
|
111
|
+
}
|
|
112
|
+
const integration = {
|
|
113
|
+
id: crypto_1.default.randomUUID(),
|
|
114
|
+
userId,
|
|
115
|
+
kind: 'service',
|
|
116
|
+
provider,
|
|
117
|
+
name,
|
|
118
|
+
...encryptedTokens,
|
|
119
|
+
createdAt: now,
|
|
120
|
+
updatedAt: now,
|
|
121
|
+
};
|
|
122
|
+
return UserIntegrationModel.create(integration);
|
|
123
|
+
}
|
|
41
124
|
async function updateUserIntegration(userId, integrationId, updates) {
|
|
42
125
|
const UserIntegrationModel = (0, manager_1.getModel)('user_integrations');
|
|
43
126
|
const existing = await UserIntegrationModel.findOne({ id: integrationId, userId });
|
|
@@ -49,6 +132,9 @@ async function updateUserIntegration(userId, integrationId, updates) {
|
|
|
49
132
|
if (updates.description !== undefined)
|
|
50
133
|
$set.description = updates.description;
|
|
51
134
|
if (updates.credentialValue !== undefined) {
|
|
135
|
+
if (existing.kind === 'service' && isOAuthServiceProvider(existing.provider)) {
|
|
136
|
+
throw new InvalidCredentialUpdateError('OAuth-backed integrations cannot have their credential set directly — reconnect via OAuth.');
|
|
137
|
+
}
|
|
52
138
|
if (!(0, encryption_1.isEncryptionKeySet)()) {
|
|
53
139
|
throw new Error('Encryption key is not set. Cannot update integration.');
|
|
54
140
|
}
|
|
@@ -61,26 +147,63 @@ async function deleteUserIntegration(userId, integrationId) {
|
|
|
61
147
|
const deletedCount = await UserIntegrationModel.delete({ id: integrationId, userId });
|
|
62
148
|
return deletedCount > 0;
|
|
63
149
|
}
|
|
64
|
-
async function getDecryptedIntegrationCredential(userId, integrationId) {
|
|
150
|
+
async function getDecryptedIntegrationCredential(userId, integrationId, config) {
|
|
65
151
|
const UserIntegrationModel = (0, manager_1.getModel)('user_integrations');
|
|
66
152
|
const integration = await UserIntegrationModel.findOne({ id: integrationId, userId });
|
|
67
|
-
return
|
|
153
|
+
return resolveCredential(integration, config);
|
|
68
154
|
}
|
|
69
155
|
// Used when resolving a project connection's `integrationId` at connection-use time — the
|
|
70
156
|
// project already validated ownership when the connection was created (see project.ts /
|
|
71
157
|
// dbConnection.ts POST), so there's no userId to filter by here, only the integrationId
|
|
72
|
-
// stored on the connection itself.
|
|
73
|
-
|
|
158
|
+
// stored on the connection itself. `config` is required (not just for OAuth's sake) so a
|
|
159
|
+
// near-expiry jira/google token can be refreshed using the host-injected client
|
|
160
|
+
// credentials in config.oauthProviders.
|
|
161
|
+
// A member's own integration, used by a project it was connected to: it resolves only while its
|
|
162
|
+
// owner is an active member of that project. A project its owner isn't in (never was, left it,
|
|
163
|
+
// or a connection pointing at someone else's integration) never gets the credential.
|
|
164
|
+
async function getDecryptedIntegrationCredentialById(integrationId, config, project) {
|
|
74
165
|
const UserIntegrationModel = (0, manager_1.getModel)('user_integrations');
|
|
75
166
|
const integration = await UserIntegrationModel.findOne({ id: integrationId });
|
|
76
|
-
|
|
167
|
+
if (!integration || !(0, auth_1.activeMemberOf)(project, integration.userId))
|
|
168
|
+
return null;
|
|
169
|
+
return resolveCredential(integration, config);
|
|
77
170
|
}
|
|
78
171
|
async function decryptIntegration(integration) {
|
|
79
|
-
if (!integration)
|
|
80
|
-
return null;
|
|
81
172
|
if (!(0, encryption_1.isEncryptionKeySet)()) {
|
|
82
173
|
throw new Error('Encryption key is not set. Cannot decrypt integration credential.');
|
|
83
174
|
}
|
|
84
175
|
const decrypted = (0, encryption_1.decrypt)(integration.encryptedCredential);
|
|
85
176
|
return integration.kind === 'storage' ? JSON.parse(decrypted) : decrypted;
|
|
86
177
|
}
|
|
178
|
+
// OAuth-backed integrations (jira/google) transparently refresh-and-repersist their access
|
|
179
|
+
// token when it's expired or near-expiry, before returning it — callers never see a stale
|
|
180
|
+
// token or have to think about refresh themselves.
|
|
181
|
+
async function resolveCredential(integration, config) {
|
|
182
|
+
if (!integration)
|
|
183
|
+
return null;
|
|
184
|
+
if (integration.kind === 'service' && isOAuthServiceProvider(integration.provider)) {
|
|
185
|
+
const tokens = decryptOAuthTokens(integration);
|
|
186
|
+
if (!tokens) {
|
|
187
|
+
throw new OAuthRefreshError(integration.provider, integration.id, `The ${integration.provider} connection for this integration is incomplete — reconnect it from account integrations.`);
|
|
188
|
+
}
|
|
189
|
+
if (tokens.expiresAt.getTime() > Date.now() + REFRESH_SKEW_MS) {
|
|
190
|
+
return { provider: integration.provider, kind: integration.kind, credential: tokens.accessToken };
|
|
191
|
+
}
|
|
192
|
+
const provider = integration.provider;
|
|
193
|
+
const oauthConfig = config.oauthProviders?.[provider];
|
|
194
|
+
if (!oauthConfig) {
|
|
195
|
+
throw new OAuthRefreshError(integration.provider, integration.id, `OAuth for '${provider}' is not configured on this deployment — cannot refresh the token.`);
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
const refreshed = await (0, oauthClient_1.refreshTokens)(provider, oauthConfig, tokens.refreshToken);
|
|
199
|
+
const encryptedTokens = encryptOAuthTokens(refreshed);
|
|
200
|
+
const UserIntegrationModel = (0, manager_1.getModel)('user_integrations');
|
|
201
|
+
await UserIntegrationModel.update({ id: integration.id }, { ...encryptedTokens, updatedAt: new Date() });
|
|
202
|
+
return { provider: integration.provider, kind: integration.kind, credential: refreshed.accessToken };
|
|
203
|
+
}
|
|
204
|
+
catch (e) {
|
|
205
|
+
throw new OAuthRefreshError(integration.provider, integration.id, `The ${integration.provider} connection for this integration has expired — reconnect it from account integrations.`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return { provider: integration.provider, kind: integration.kind, credential: await decryptIntegration(integration) };
|
|
209
|
+
}
|
package/dist/types/index.d.ts
CHANGED
|
@@ -63,6 +63,7 @@ export interface ProjectInfo {
|
|
|
63
63
|
role: 'viewer' | 'editor' | 'admin';
|
|
64
64
|
userId: string;
|
|
65
65
|
archived?: boolean;
|
|
66
|
+
status?: 'active' | 'pending';
|
|
66
67
|
}[];
|
|
67
68
|
description?: string;
|
|
68
69
|
exports?: {
|
|
@@ -76,6 +77,7 @@ export interface ProjectInfo {
|
|
|
76
77
|
connections?: Connection[];
|
|
77
78
|
dbConnections?: DbConnection[];
|
|
78
79
|
storageConnections?: StorageConnection[];
|
|
80
|
+
integrationConnections?: IntegrationConnection[];
|
|
79
81
|
pipelines?: PipelineRef[];
|
|
80
82
|
builtinBackfilledAt?: Date;
|
|
81
83
|
workflow?: Workflow;
|
|
@@ -117,16 +119,28 @@ export interface DatabaseCredential {
|
|
|
117
119
|
connectionString?: string;
|
|
118
120
|
main?: boolean;
|
|
119
121
|
}
|
|
122
|
+
export interface OAuthClientConfig {
|
|
123
|
+
clientId: string;
|
|
124
|
+
clientSecret: string;
|
|
125
|
+
redirectUri: string;
|
|
126
|
+
successRedirectUrl: string;
|
|
127
|
+
failureRedirectUrl: string;
|
|
128
|
+
}
|
|
120
129
|
export interface StreamByConfig {
|
|
121
130
|
storageProviders: StorageProvider[];
|
|
122
131
|
authProvider: AuthProvider;
|
|
123
132
|
databases?: DatabaseCredential[];
|
|
124
133
|
adapter?: StorageAdapter;
|
|
125
134
|
encrypt?: string;
|
|
135
|
+
encryptPreviousKeys?: string[];
|
|
126
136
|
websocket?: {
|
|
127
137
|
server: WebSocketServer;
|
|
128
138
|
};
|
|
129
139
|
canUseBuiltin?: (auth: Auth, builtinId: string, kind: 'database' | 'storage') => boolean | Promise<boolean>;
|
|
140
|
+
oauthProviders?: {
|
|
141
|
+
jira?: OAuthClientConfig;
|
|
142
|
+
google?: OAuthClientConfig;
|
|
143
|
+
};
|
|
130
144
|
}
|
|
131
145
|
export interface Notification {
|
|
132
146
|
_id?: string;
|
|
@@ -167,15 +181,29 @@ export interface DbConnection {
|
|
|
167
181
|
integrationId?: string;
|
|
168
182
|
source?: 'builtin' | 'integration' | 'manual';
|
|
169
183
|
}
|
|
170
|
-
export
|
|
184
|
+
export interface IntegrationConnection {
|
|
185
|
+
id: string;
|
|
186
|
+
name: string;
|
|
187
|
+
provider: ServiceProviderType;
|
|
188
|
+
projectId: string;
|
|
189
|
+
createdAt: Date;
|
|
190
|
+
description?: string;
|
|
191
|
+
integrationId: string;
|
|
192
|
+
source: 'integration';
|
|
193
|
+
}
|
|
194
|
+
export type IntegrationKind = 'database' | 'storage' | 'service';
|
|
195
|
+
export type ServiceProviderType = 'claude' | 'jira' | 'google';
|
|
171
196
|
export interface UserIntegration {
|
|
172
197
|
id: string;
|
|
173
198
|
userId: string;
|
|
174
199
|
kind: IntegrationKind;
|
|
175
|
-
provider: ExternalDbType | StorageProviderType;
|
|
200
|
+
provider: ExternalDbType | StorageProviderType | ServiceProviderType;
|
|
176
201
|
name: string;
|
|
177
202
|
description?: string;
|
|
178
|
-
encryptedCredential
|
|
203
|
+
encryptedCredential?: string;
|
|
204
|
+
encryptedAccessToken?: string;
|
|
205
|
+
encryptedRefreshToken?: string;
|
|
206
|
+
tokenExpiresAt?: Date;
|
|
179
207
|
createdAt: Date;
|
|
180
208
|
updatedAt: Date;
|
|
181
209
|
}
|
package/dist/utils/auth.d.ts
CHANGED
|
@@ -1,2 +1,16 @@
|
|
|
1
1
|
import { ProjectInfo } from '../types';
|
|
2
|
-
|
|
2
|
+
type ProjectMembers = Pick<ProjectInfo, 'members'> | null | undefined;
|
|
3
|
+
export declare function activeMemberOf(project: ProjectMembers, userId: string | undefined): {
|
|
4
|
+
role: "viewer" | "editor" | "admin";
|
|
5
|
+
userId: string;
|
|
6
|
+
archived?: boolean;
|
|
7
|
+
status?: "active" | "pending";
|
|
8
|
+
} | undefined;
|
|
9
|
+
export declare const projectRoleOf: (project: ProjectMembers, userId: string | undefined) => "viewer" | "editor" | "admin" | undefined;
|
|
10
|
+
/** An active member, whatever their role: can see the project. */
|
|
11
|
+
export declare const isProjectMember: (project: ProjectMembers, userId: string | undefined) => boolean;
|
|
12
|
+
/** An editor or an admin of the project: can change it (its content, its connections). */
|
|
13
|
+
export declare const canEditProject: (project: ProjectMembers, userId: string | undefined) => boolean;
|
|
14
|
+
/** An admin of the project: can manage its members, and delete it. */
|
|
15
|
+
export declare const isProjectAdmin: (project: ProjectMembers, userId: string | undefined) => boolean;
|
|
16
|
+
export {};
|
package/dist/utils/auth.js
CHANGED
|
@@ -1,9 +1,24 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.isProjectMember =
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
3
|
+
exports.isProjectAdmin = exports.canEditProject = exports.isProjectMember = exports.projectRoleOf = void 0;
|
|
4
|
+
exports.activeMemberOf = activeMemberOf;
|
|
5
|
+
// A project's member who's in it now: invited and accepted (status 'active'). (A member's
|
|
6
|
+
// `archived` is only that member having archived the project in their own list.)
|
|
7
|
+
function activeMemberOf(project, userId) {
|
|
8
|
+
if (!project?.members || !userId)
|
|
9
|
+
return undefined;
|
|
10
|
+
return project.members.find(member => member.userId?.toString() === userId && member.status === 'active');
|
|
9
11
|
}
|
|
12
|
+
// What a user can do in a project is their role in it, as an active member -- never their
|
|
13
|
+
// role on the platform (Auth.role): a pending invitation is no access yet.
|
|
14
|
+
const projectRoleOf = (project, userId) => activeMemberOf(project, userId)?.role;
|
|
15
|
+
exports.projectRoleOf = projectRoleOf;
|
|
16
|
+
/** An active member, whatever their role: can see the project. */
|
|
17
|
+
const isProjectMember = (project, userId) => !!(0, exports.projectRoleOf)(project, userId);
|
|
18
|
+
exports.isProjectMember = isProjectMember;
|
|
19
|
+
/** An editor or an admin of the project: can change it (its content, its connections). */
|
|
20
|
+
const canEditProject = (project, userId) => ['editor', 'admin'].includes((0, exports.projectRoleOf)(project, userId) ?? '');
|
|
21
|
+
exports.canEditProject = canEditProject;
|
|
22
|
+
/** An admin of the project: can manage its members, and delete it. */
|
|
23
|
+
const isProjectAdmin = (project, userId) => (0, exports.projectRoleOf)(project, userId) === 'admin';
|
|
24
|
+
exports.isProjectAdmin = isProjectAdmin;
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
|
|
1
|
+
/** The key credentials are encrypted with, and the previous ones they may still be in. */
|
|
2
|
+
export declare const setEncryptionKey: (key: string, previousKeys?: string[]) => void;
|
|
2
3
|
export declare const encrypt: (text: string) => string;
|
|
3
4
|
export declare const decrypt: (text: string) => string;
|
|
5
|
+
/** Whether a stored value is encrypted the current way (GCM, with the current key). */
|
|
6
|
+
export declare const isCurrentEncryption: (text: string) => boolean;
|
|
7
|
+
/** Whether a stored value is in this module's own format (GCM, any key). */
|
|
8
|
+
export declare const isVersionedEncryption: (text: string) => boolean;
|
|
4
9
|
export declare const isEncryptionKeySet: () => boolean;
|
package/dist/utils/encryption.js
CHANGED
|
@@ -33,43 +33,96 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.isEncryptionKeySet = exports.decrypt = exports.encrypt = exports.setEncryptionKey = void 0;
|
|
36
|
+
exports.isEncryptionKeySet = exports.isVersionedEncryption = exports.isCurrentEncryption = exports.decrypt = exports.encrypt = exports.setEncryptionKey = void 0;
|
|
37
37
|
const crypto = __importStar(require("crypto"));
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
38
|
+
// Stored credentials, encrypted with AES-256-GCM (authenticated: an altered value fails to
|
|
39
|
+
// decrypt instead of decrypting to garbage), each value naming the key it was encrypted with:
|
|
40
|
+
//
|
|
41
|
+
// v2:<key id>:<iv>:<auth tag>:<ciphertext> (base64url parts; a 12-byte iv)
|
|
42
|
+
//
|
|
43
|
+
// A key's id is a fingerprint of it, so rotating is only configuring the new key as the
|
|
44
|
+
// current one and the old ones as previous keys: what's stored keeps decrypting, and is
|
|
45
|
+
// re-encrypted with the current key (see services/reencryptCredentials).
|
|
46
|
+
//
|
|
47
|
+
// Values stored before (AES-256-CBC, "<iv hex>:<ciphertext hex>", no key id) still decrypt,
|
|
48
|
+
// with the current key or a previous one.
|
|
49
|
+
const GCM = 'aes-256-gcm';
|
|
50
|
+
const GCM_IV_LENGTH = 12;
|
|
51
|
+
const LEGACY_CBC = 'aes-256-cbc';
|
|
52
|
+
const VERSION = 'v2';
|
|
53
|
+
let currentKey;
|
|
54
|
+
const keysById = new Map();
|
|
55
|
+
const keyBuffer = (hex) => {
|
|
56
|
+
if (typeof hex !== 'string' || !/^[0-9a-fA-F]{64}$/.test(hex)) {
|
|
43
57
|
throw new Error('Invalid encryption key length. Key must be 32 bytes (64 hexadecimal characters).');
|
|
44
58
|
}
|
|
45
|
-
|
|
59
|
+
return Buffer.from(hex, 'hex');
|
|
60
|
+
};
|
|
61
|
+
// A key's id: a fingerprint of it (never the key, nor enough to find it).
|
|
62
|
+
const keyIdOf = (key) => crypto.createHash('sha256').update('streamby-encryption-key:').update(key).digest('hex').slice(0, 16);
|
|
63
|
+
/** The key credentials are encrypted with, and the previous ones they may still be in. */
|
|
64
|
+
const setEncryptionKey = (key, previousKeys = []) => {
|
|
65
|
+
const current = keyBuffer(key);
|
|
66
|
+
const previous = previousKeys.map(keyBuffer);
|
|
67
|
+
keysById.clear();
|
|
68
|
+
for (const k of [...previous, current])
|
|
69
|
+
keysById.set(keyIdOf(k), k);
|
|
70
|
+
currentKey = { id: keyIdOf(current), key: current };
|
|
46
71
|
};
|
|
47
72
|
exports.setEncryptionKey = setEncryptionKey;
|
|
73
|
+
const requireKey = () => {
|
|
74
|
+
if (!currentKey)
|
|
75
|
+
throw new Error('Encryption key is not set. Cannot encrypt or decrypt credentials.');
|
|
76
|
+
return currentKey;
|
|
77
|
+
};
|
|
48
78
|
const encrypt = (text) => {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
encrypted = Buffer.concat([encrypted, cipher.final()]);
|
|
56
|
-
return iv.toString('hex') + ':' + encrypted.toString('hex');
|
|
79
|
+
const { id, key } = requireKey();
|
|
80
|
+
const iv = crypto.randomBytes(GCM_IV_LENGTH);
|
|
81
|
+
const cipher = crypto.createCipheriv(GCM, key, iv);
|
|
82
|
+
const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
|
|
83
|
+
const tag = cipher.getAuthTag();
|
|
84
|
+
return [VERSION, id, iv.toString('base64url'), tag.toString('base64url'), encrypted.toString('base64url')].join(':');
|
|
57
85
|
};
|
|
58
86
|
exports.encrypt = encrypt;
|
|
87
|
+
const decryptLegacy = (text, key) => {
|
|
88
|
+
const [ivHex, ...rest] = text.split(':');
|
|
89
|
+
const decipher = crypto.createDecipheriv(LEGACY_CBC, key, Buffer.from(ivHex, 'hex'));
|
|
90
|
+
return Buffer.concat([decipher.update(Buffer.from(rest.join(':'), 'hex')), decipher.final()]).toString();
|
|
91
|
+
};
|
|
59
92
|
const decrypt = (text) => {
|
|
60
|
-
|
|
61
|
-
|
|
93
|
+
requireKey();
|
|
94
|
+
if (text.startsWith(`${VERSION}:`)) {
|
|
95
|
+
const [, id, iv, tag, encrypted] = text.split(':');
|
|
96
|
+
const key = keysById.get(id);
|
|
97
|
+
if (!key)
|
|
98
|
+
throw new Error('This credential was encrypted with a key that is not configured.');
|
|
99
|
+
const decipher = crypto.createDecipheriv(GCM, key, Buffer.from(iv, 'base64url'));
|
|
100
|
+
decipher.setAuthTag(Buffer.from(tag, 'base64url'));
|
|
101
|
+
try {
|
|
102
|
+
return Buffer.concat([decipher.update(Buffer.from(encrypted, 'base64url')), decipher.final()]).toString('utf8');
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
throw new Error('This credential could not be decrypted: it was altered or damaged.');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// Stored before: CBC, with whichever configured key it was encrypted with.
|
|
109
|
+
let lastError;
|
|
110
|
+
for (const key of [currentKey.key, ...[...keysById.values()].filter(k => k !== currentKey.key)]) {
|
|
111
|
+
try {
|
|
112
|
+
return decryptLegacy(text, key);
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
lastError = err;
|
|
116
|
+
}
|
|
62
117
|
}
|
|
63
|
-
|
|
64
|
-
const iv = Buffer.from(textParts.shift(), 'hex');
|
|
65
|
-
const encryptedText = Buffer.from(textParts.join(':'), 'hex');
|
|
66
|
-
const decipher = crypto.createDecipheriv(ALGORITHM, Buffer.from(ENCRYPTION_KEY, 'hex'), iv);
|
|
67
|
-
let decrypted = decipher.update(encryptedText);
|
|
68
|
-
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
|
69
|
-
return decrypted.toString();
|
|
118
|
+
throw lastError instanceof Error ? lastError : new Error('This credential could not be decrypted.');
|
|
70
119
|
};
|
|
71
120
|
exports.decrypt = decrypt;
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
121
|
+
/** Whether a stored value is encrypted the current way (GCM, with the current key). */
|
|
122
|
+
const isCurrentEncryption = (text) => !!currentKey && text.startsWith(`${VERSION}:${currentKey.id}:`);
|
|
123
|
+
exports.isCurrentEncryption = isCurrentEncryption;
|
|
124
|
+
/** Whether a stored value is in this module's own format (GCM, any key). */
|
|
125
|
+
const isVersionedEncryption = (text) => text.startsWith(`${VERSION}:`);
|
|
126
|
+
exports.isVersionedEncryption = isVersionedEncryption;
|
|
127
|
+
const isEncryptionKeySet = () => !!currentKey;
|
|
75
128
|
exports.isEncryptionKeySet = isEncryptionKeySet;
|
package/dist/utils/sanitize.d.ts
CHANGED
|
@@ -8,5 +8,11 @@ export declare function sanitizeConnections<T extends {
|
|
|
8
8
|
}>(conns: T[] | undefined): (Omit<T, 'encryptedCredential'> & {
|
|
9
9
|
hasCredential: boolean;
|
|
10
10
|
})[];
|
|
11
|
+
export declare function sanitizeIntegrationConnection<T extends object>(conn: T): T & {
|
|
12
|
+
hasCredential: true;
|
|
13
|
+
};
|
|
14
|
+
export declare function sanitizeIntegrationConnections<T extends object>(conns: T[] | undefined): (T & {
|
|
15
|
+
hasCredential: true;
|
|
16
|
+
})[];
|
|
11
17
|
export declare function sanitizeProject(project: any): any;
|
|
12
18
|
export declare function sanitizeUserIntegration(integration: any): any;
|
package/dist/utils/sanitize.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.sanitizeConnection = sanitizeConnection;
|
|
4
4
|
exports.sanitizeConnections = sanitizeConnections;
|
|
5
|
+
exports.sanitizeIntegrationConnection = sanitizeIntegrationConnection;
|
|
6
|
+
exports.sanitizeIntegrationConnections = sanitizeIntegrationConnections;
|
|
5
7
|
exports.sanitizeProject = sanitizeProject;
|
|
6
8
|
exports.sanitizeUserIntegration = sanitizeUserIntegration;
|
|
7
9
|
function sanitizeConnection(conn) {
|
|
@@ -11,6 +13,16 @@ function sanitizeConnection(conn) {
|
|
|
11
13
|
function sanitizeConnections(conns) {
|
|
12
14
|
return (conns ?? []).map(sanitizeConnection);
|
|
13
15
|
}
|
|
16
|
+
// IntegrationConnection has no encryptedCredential field at all — credentials never live on
|
|
17
|
+
// the connection, only integrationId does, resolved on-demand from the account-level
|
|
18
|
+
// UserIntegration. hasCredential is hardcoded true (never derived from a local field) for
|
|
19
|
+
// API shape parity with the other three connection types.
|
|
20
|
+
function sanitizeIntegrationConnection(conn) {
|
|
21
|
+
return { ...conn, hasCredential: true };
|
|
22
|
+
}
|
|
23
|
+
function sanitizeIntegrationConnections(conns) {
|
|
24
|
+
return (conns ?? []).map(sanitizeIntegrationConnection);
|
|
25
|
+
}
|
|
14
26
|
function sanitizeProject(project) {
|
|
15
27
|
if (!project)
|
|
16
28
|
return project;
|
|
@@ -20,14 +32,19 @@ function sanitizeProject(project) {
|
|
|
20
32
|
connections: sanitizeConnections(project.connections),
|
|
21
33
|
storageConnections: sanitizeConnections(project.storageConnections),
|
|
22
34
|
dbConnections: sanitizeConnections(project.dbConnections),
|
|
35
|
+
integrationConnections: sanitizeIntegrationConnections(project.integrationConnections),
|
|
23
36
|
distributionConnections: sanitizeConnections(project.distributionConnections),
|
|
24
37
|
renderFarmConnections: sanitizeConnections(project.renderFarmConnections),
|
|
25
38
|
exports: project.exports?.map((e) => e && 'encryptedCredential' in e ? sanitizeConnection(e) : e),
|
|
26
39
|
};
|
|
27
40
|
}
|
|
41
|
+
// Strips every raw secret an OAuth-backed integration might carry — encryptedCredential
|
|
42
|
+
// (db/storage/claude) plus encryptedAccessToken/encryptedRefreshToken (jira/google).
|
|
43
|
+
// tokenExpiresAt is deliberately kept: it's not secret, and its presence lets a future UI
|
|
44
|
+
// show "reconnect needed" without a decrypt round-trip.
|
|
28
45
|
function sanitizeUserIntegration(integration) {
|
|
29
46
|
if (!integration)
|
|
30
47
|
return integration;
|
|
31
|
-
const { encryptedCredential, ...rest } = integration;
|
|
48
|
+
const { encryptedCredential, encryptedAccessToken, encryptedRefreshToken, ...rest } = integration;
|
|
32
49
|
return rest;
|
|
33
50
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.isServiceEndpoint = isServiceEndpoint;
|
|
4
|
+
// Where a service integration's credential may be sent: its own provider's API, over https.
|
|
5
|
+
// A node chooses the endpoint it calls, and anyone who can edit the node could otherwise point
|
|
6
|
+
// it at a server of their own and receive the credential of whoever connected the integration.
|
|
7
|
+
const PROVIDER_HOSTS = {
|
|
8
|
+
claude: (host) => host === 'api.anthropic.com',
|
|
9
|
+
jira: (host) => host === 'api.atlassian.com' || host.endsWith('.atlassian.net'),
|
|
10
|
+
google: (host) => host === 'googleapis.com' || host.endsWith('.googleapis.com'),
|
|
11
|
+
};
|
|
12
|
+
function isServiceEndpoint(provider, endpoint) {
|
|
13
|
+
if (typeof endpoint !== 'string')
|
|
14
|
+
return false;
|
|
15
|
+
let url;
|
|
16
|
+
try {
|
|
17
|
+
url = new URL(endpoint);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
if (url.protocol !== 'https:' || url.username || url.password || url.port)
|
|
23
|
+
return false;
|
|
24
|
+
const allowed = PROVIDER_HOSTS[provider];
|
|
25
|
+
return !!allowed && allowed(url.hostname.toLowerCase());
|
|
26
|
+
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "streamby-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.0",
|
|
4
4
|
"description": "StreamBy middleware framework for media storage management",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
7
7
|
"files": [
|
|
8
|
-
"dist"
|
|
8
|
+
"dist",
|
|
9
|
+
"CHANGELOG.md"
|
|
9
10
|
],
|
|
10
11
|
"scripts": {
|
|
11
12
|
"build": "tsc",
|
|
@@ -27,7 +28,7 @@
|
|
|
27
28
|
"typescript"
|
|
28
29
|
],
|
|
29
30
|
"author": "TerminalCore Labs",
|
|
30
|
-
"license": "
|
|
31
|
+
"license": "AGPL-3.0-only",
|
|
31
32
|
"dependencies": {
|
|
32
33
|
"@aws-sdk/client-s3": "^3.528.0",
|
|
33
34
|
"@aws-sdk/s3-request-presigner": "^3.787.0",
|