streamby-core 0.37.0 → 0.39.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/LICENSE +661 -661
  3. package/README.md +573 -512
  4. package/dist/adapters/database/connectionManager.js +124 -124
  5. package/dist/adapters/database/sql.js +1 -1
  6. package/dist/adapters/storage/s3.d.ts +3 -2
  7. package/dist/adapters/storage/s3.js +18 -5
  8. package/dist/mcp/server.js +12 -0
  9. package/dist/mcp/tools.d.ts +2 -0
  10. package/dist/mcp/tools.js +107 -14
  11. package/dist/mcp/workflowNodes.d.ts +24 -0
  12. package/dist/mcp/workflowNodes.js +48 -0
  13. package/dist/middleware/createRouter.js +2 -0
  14. package/dist/middleware/routes/apiConnection.d.ts +3 -0
  15. package/dist/middleware/routes/apiConnection.js +100 -0
  16. package/dist/middleware/routes/credential.d.ts +3 -0
  17. package/dist/middleware/routes/credential.js +80 -0
  18. package/dist/middleware/routes/deliverable.d.ts +3 -0
  19. package/dist/middleware/routes/deliverable.js +131 -0
  20. package/dist/middleware/routes/export.js +18 -0
  21. package/dist/middleware/routes/production.js +60 -67
  22. package/dist/middleware/routes/project.js +39 -11
  23. package/dist/middleware/routes/storage.js +115 -24
  24. package/dist/models/Model.js +9 -9
  25. package/dist/services/apiConnection.d.ts +18 -0
  26. package/dist/services/apiConnection.js +65 -0
  27. package/dist/services/apiTokenPermissions.js +7 -3
  28. package/dist/services/assetVersions.d.ts +48 -0
  29. package/dist/services/assetVersions.js +84 -0
  30. package/dist/services/credential.d.ts +8 -0
  31. package/dist/services/credential.js +70 -0
  32. package/dist/services/deliverables.d.ts +26 -0
  33. package/dist/services/deliverables.js +55 -0
  34. package/dist/services/mediaProcessor.js +3 -1
  35. package/dist/services/pipeline.d.ts +2 -0
  36. package/dist/services/pipeline.js +292 -0
  37. package/dist/services/production.d.ts +2 -0
  38. package/dist/services/production.js +13 -0
  39. package/dist/services/productionFields.d.ts +16 -0
  40. package/dist/services/productionFields.js +80 -0
  41. package/dist/services/signedUrls.d.ts +6 -0
  42. package/dist/services/signedUrls.js +9 -0
  43. package/dist/types/index.d.ts +7 -2
  44. package/dist/utils/crypto.d.ts +3 -0
  45. package/dist/utils/crypto.js +32 -0
  46. package/dist/utils/nosqlDb.d.ts +2 -0
  47. package/dist/utils/nosqlDb.js +11 -0
  48. package/dist/utils/sanitize.d.ts +1 -0
  49. package/dist/utils/sanitize.js +14 -0
  50. package/package.json +1 -1
@@ -43,157 +43,157 @@ const getConnection = (id) => {
43
43
  exports.getConnection = getConnection;
44
44
  const ensureTablesExist = async (pool) => {
45
45
  try {
46
- await pool.query(`
47
- CREATE SCHEMA IF NOT EXISTS streamby;
48
- CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
46
+ await pool.query(`
47
+ CREATE SCHEMA IF NOT EXISTS streamby;
48
+ CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
49
49
  `);
50
50
  // Migrate id column from UUID to TEXT if it was created with the old schema
51
- await pool.query(`
52
- DO $$ BEGIN
53
- ALTER TABLE streamby.projects ALTER COLUMN id TYPE TEXT USING id::text;
54
- ALTER TABLE streamby.projects ALTER COLUMN id SET DEFAULT uuid_generate_v4()::text;
55
- EXCEPTION WHEN OTHERS THEN NULL; END $$;
51
+ await pool.query(`
52
+ DO $$ BEGIN
53
+ ALTER TABLE streamby.projects ALTER COLUMN id TYPE TEXT USING id::text;
54
+ ALTER TABLE streamby.projects ALTER COLUMN id SET DEFAULT uuid_generate_v4()::text;
55
+ EXCEPTION WHEN OTHERS THEN NULL; END $$;
56
56
  `);
57
- await pool.query(`
58
- CREATE TABLE IF NOT EXISTS streamby.projects (
59
- id TEXT PRIMARY KEY DEFAULT uuid_generate_v4()::text,
60
- name VARCHAR(255) NOT NULL,
61
- description TEXT,
62
- image TEXT,
63
- "allowedOrigin" JSONB DEFAULT '[]',
64
- exports JSONB DEFAULT '[]',
65
- connections JSONB DEFAULT '[]',
66
- "dbConnections" JSONB DEFAULT '[]',
67
- "dbType" VARCHAR(50) NOT NULL DEFAULT 'sql',
68
- "createdAt" TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
69
- "updatedAt" TIMESTAMP WITH TIME ZONE DEFAULT NOW()
70
- );
57
+ await pool.query(`
58
+ CREATE TABLE IF NOT EXISTS streamby.projects (
59
+ id TEXT PRIMARY KEY DEFAULT uuid_generate_v4()::text,
60
+ name VARCHAR(255) NOT NULL,
61
+ description TEXT,
62
+ image TEXT,
63
+ "allowedOrigin" JSONB DEFAULT '[]',
64
+ exports JSONB DEFAULT '[]',
65
+ connections JSONB DEFAULT '[]',
66
+ "dbConnections" JSONB DEFAULT '[]',
67
+ "dbType" VARCHAR(50) NOT NULL DEFAULT 'sql',
68
+ "createdAt" TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
69
+ "updatedAt" TIMESTAMP WITH TIME ZONE DEFAULT NOW()
70
+ );
71
71
  `);
72
72
  console.log('✅ "projects" table ensured to exist.');
73
73
  // Add JSONB columns to existing tables that predate this schema version
74
- await pool.query(`
75
- ALTER TABLE streamby.projects ADD COLUMN IF NOT EXISTS "allowedOrigin" JSONB DEFAULT '[]';
76
- ALTER TABLE streamby.projects ADD COLUMN IF NOT EXISTS exports JSONB DEFAULT '[]';
77
- ALTER TABLE streamby.projects ADD COLUMN IF NOT EXISTS connections JSONB DEFAULT '[]';
78
- ALTER TABLE streamby.projects ADD COLUMN IF NOT EXISTS "dbConnections" JSONB DEFAULT '[]';
79
- ALTER TABLE streamby.projects ADD COLUMN IF NOT EXISTS "storageConnections" JSONB DEFAULT '[]';
80
- ALTER TABLE streamby.projects ADD COLUMN IF NOT EXISTS workflow JSONB;
81
- ALTER TABLE streamby.projects ADD COLUMN IF NOT EXISTS pipelines JSONB DEFAULT '[]';
82
- ALTER TABLE streamby.projects ADD COLUMN IF NOT EXISTS "builtinBackfilledAt" TIMESTAMP WITH TIME ZONE;
83
- -- Written when a project is made (routes/project) and by its connection routes.
84
- ALTER TABLE streamby.projects ADD COLUMN IF NOT EXISTS public BOOLEAN DEFAULT TRUE;
85
- ALTER TABLE streamby.projects ADD COLUMN IF NOT EXISTS category TEXT;
86
- ALTER TABLE streamby.projects ADD COLUMN IF NOT EXISTS "integrationConnections" JSONB DEFAULT '[]';
87
- ALTER TABLE streamby.projects ADD COLUMN IF NOT EXISTS "distributionConnections" JSONB DEFAULT '[]';
88
- ALTER TABLE streamby.projects ADD COLUMN IF NOT EXISTS "renderFarmConnections" JSONB DEFAULT '[]';
74
+ await pool.query(`
75
+ ALTER TABLE streamby.projects ADD COLUMN IF NOT EXISTS "allowedOrigin" JSONB DEFAULT '[]';
76
+ ALTER TABLE streamby.projects ADD COLUMN IF NOT EXISTS exports JSONB DEFAULT '[]';
77
+ ALTER TABLE streamby.projects ADD COLUMN IF NOT EXISTS connections JSONB DEFAULT '[]';
78
+ ALTER TABLE streamby.projects ADD COLUMN IF NOT EXISTS "dbConnections" JSONB DEFAULT '[]';
79
+ ALTER TABLE streamby.projects ADD COLUMN IF NOT EXISTS "storageConnections" JSONB DEFAULT '[]';
80
+ ALTER TABLE streamby.projects ADD COLUMN IF NOT EXISTS workflow JSONB;
81
+ ALTER TABLE streamby.projects ADD COLUMN IF NOT EXISTS pipelines JSONB DEFAULT '[]';
82
+ ALTER TABLE streamby.projects ADD COLUMN IF NOT EXISTS "builtinBackfilledAt" TIMESTAMP WITH TIME ZONE;
83
+ -- Written when a project is made (routes/project) and by its connection routes.
84
+ ALTER TABLE streamby.projects ADD COLUMN IF NOT EXISTS public BOOLEAN DEFAULT TRUE;
85
+ ALTER TABLE streamby.projects ADD COLUMN IF NOT EXISTS category TEXT;
86
+ ALTER TABLE streamby.projects ADD COLUMN IF NOT EXISTS "integrationConnections" JSONB DEFAULT '[]';
87
+ ALTER TABLE streamby.projects ADD COLUMN IF NOT EXISTS "distributionConnections" JSONB DEFAULT '[]';
88
+ ALTER TABLE streamby.projects ADD COLUMN IF NOT EXISTS "renderFarmConnections" JSONB DEFAULT '[]';
89
89
  `);
90
90
  // Ensure dbType exists with a non-null default
91
- await pool.query(`
92
- ALTER TABLE streamby.projects ADD COLUMN IF NOT EXISTS "dbType" VARCHAR(50) NOT NULL DEFAULT 'sql';
91
+ await pool.query(`
92
+ ALTER TABLE streamby.projects ADD COLUMN IF NOT EXISTS "dbType" VARCHAR(50) NOT NULL DEFAULT 'sql';
93
93
  `);
94
- await pool.query(`
95
- CREATE TABLE IF NOT EXISTS streamby.project_members (
96
- id TEXT PRIMARY KEY DEFAULT uuid_generate_v4()::text,
97
- "projectId" TEXT NOT NULL REFERENCES streamby.projects(id) ON DELETE CASCADE,
98
- "userId" VARCHAR(255) NOT NULL,
99
- role VARCHAR(255) NOT NULL DEFAULT 'member',
100
- status VARCHAR(50) NOT NULL DEFAULT 'active',
101
- archived BOOLEAN NOT NULL DEFAULT FALSE,
102
- "archivedBy" VARCHAR(255),
103
- "archivedAt" TIMESTAMP WITH TIME ZONE,
104
- "createdAt" TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
105
- "updatedAt" TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
106
- UNIQUE ("projectId", "userId")
107
- );
94
+ await pool.query(`
95
+ CREATE TABLE IF NOT EXISTS streamby.project_members (
96
+ id TEXT PRIMARY KEY DEFAULT uuid_generate_v4()::text,
97
+ "projectId" TEXT NOT NULL REFERENCES streamby.projects(id) ON DELETE CASCADE,
98
+ "userId" VARCHAR(255) NOT NULL,
99
+ role VARCHAR(255) NOT NULL DEFAULT 'member',
100
+ status VARCHAR(50) NOT NULL DEFAULT 'active',
101
+ archived BOOLEAN NOT NULL DEFAULT FALSE,
102
+ "archivedBy" VARCHAR(255),
103
+ "archivedAt" TIMESTAMP WITH TIME ZONE,
104
+ "createdAt" TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
105
+ "updatedAt" TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
106
+ UNIQUE ("projectId", "userId")
107
+ );
108
108
  `);
109
- await pool.query(`
110
- ALTER TABLE streamby.project_members ADD COLUMN IF NOT EXISTS status VARCHAR(50) NOT NULL DEFAULT 'active';
109
+ await pool.query(`
110
+ ALTER TABLE streamby.project_members ADD COLUMN IF NOT EXISTS status VARCHAR(50) NOT NULL DEFAULT 'active';
111
111
  `);
112
112
  console.log('✅ "project_members" table ensured to exist.');
113
113
  // Migrate projectId FK column from UUID to TEXT if table predates this schema version
114
- await pool.query(`
115
- DO $$ BEGIN
116
- ALTER TABLE streamby.project_members DROP CONSTRAINT IF EXISTS project_members_projectId_fkey;
117
- ALTER TABLE streamby.project_members ALTER COLUMN "projectId" TYPE TEXT;
118
- ALTER TABLE streamby.project_members ADD CONSTRAINT project_members_projectid_fkey
119
- FOREIGN KEY ("projectId") REFERENCES streamby.projects(id) ON DELETE CASCADE;
120
- EXCEPTION WHEN OTHERS THEN NULL; END $$;
114
+ await pool.query(`
115
+ DO $$ BEGIN
116
+ ALTER TABLE streamby.project_members DROP CONSTRAINT IF EXISTS project_members_projectId_fkey;
117
+ ALTER TABLE streamby.project_members ALTER COLUMN "projectId" TYPE TEXT;
118
+ ALTER TABLE streamby.project_members ADD CONSTRAINT project_members_projectid_fkey
119
+ FOREIGN KEY ("projectId") REFERENCES streamby.projects(id) ON DELETE CASCADE;
120
+ EXCEPTION WHEN OTHERS THEN NULL; END $$;
121
121
  `);
122
- await pool.query(`
123
- CREATE TABLE IF NOT EXISTS streamby.exports (
124
- id TEXT PRIMARY KEY DEFAULT uuid_generate_v4()::text,
125
- "projectId" TEXT NOT NULL REFERENCES streamby.projects(id) ON DELETE CASCADE,
126
- status VARCHAR(50) NOT NULL DEFAULT 'pending',
127
- "filePath" TEXT,
128
- "createdAt" TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
129
- "updatedAt" TIMESTAMP WITH TIME ZONE DEFAULT NOW()
130
- );
122
+ await pool.query(`
123
+ CREATE TABLE IF NOT EXISTS streamby.exports (
124
+ id TEXT PRIMARY KEY DEFAULT uuid_generate_v4()::text,
125
+ "projectId" TEXT NOT NULL REFERENCES streamby.projects(id) ON DELETE CASCADE,
126
+ status VARCHAR(50) NOT NULL DEFAULT 'pending',
127
+ "filePath" TEXT,
128
+ "createdAt" TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
129
+ "updatedAt" TIMESTAMP WITH TIME ZONE DEFAULT NOW()
130
+ );
131
131
  `);
132
132
  console.log('✅ "exports" table ensured to exist.');
133
133
  // user_integrations is registered against the main db only (see createRouter.ts), but
134
134
  // this function runs per-SQL-connection like projects/exports above — harmless on
135
135
  // non-main connections since the model never queries them there.
136
- await pool.query(`
137
- CREATE TABLE IF NOT EXISTS streamby.user_integrations (
138
- id TEXT PRIMARY KEY DEFAULT uuid_generate_v4()::text,
139
- "userId" VARCHAR(255) NOT NULL,
140
- kind VARCHAR(20) NOT NULL,
141
- provider VARCHAR(20) NOT NULL,
142
- name VARCHAR(255) NOT NULL,
143
- description TEXT,
144
- "encryptedCredential" TEXT NOT NULL,
145
- "createdAt" TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
146
- "updatedAt" TIMESTAMP WITH TIME ZONE DEFAULT NOW()
147
- );
148
- CREATE INDEX IF NOT EXISTS user_integrations_userid_idx ON streamby.user_integrations ("userId");
136
+ await pool.query(`
137
+ CREATE TABLE IF NOT EXISTS streamby.user_integrations (
138
+ id TEXT PRIMARY KEY DEFAULT uuid_generate_v4()::text,
139
+ "userId" VARCHAR(255) NOT NULL,
140
+ kind VARCHAR(20) NOT NULL,
141
+ provider VARCHAR(20) NOT NULL,
142
+ name VARCHAR(255) NOT NULL,
143
+ description TEXT,
144
+ "encryptedCredential" TEXT NOT NULL,
145
+ "createdAt" TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
146
+ "updatedAt" TIMESTAMP WITH TIME ZONE DEFAULT NOW()
147
+ );
148
+ CREATE INDEX IF NOT EXISTS user_integrations_userid_idx ON streamby.user_integrations ("userId");
149
149
  `);
150
150
  console.log('✅ "user_integrations" table ensured to exist.');
151
151
  // API tokens: only their hash is kept (see services/apiTokens). Main db only, like
152
152
  // user_integrations above. A table from before tokens had a kind and permissions gets the
153
153
  // new columns; its old tokens no longer work (they have no kind) and aren't listed.
154
- await pool.query(`
155
- CREATE TABLE IF NOT EXISTS streamby.api_tokens (
156
- id TEXT PRIMARY KEY DEFAULT uuid_generate_v4()::text,
157
- kind VARCHAR(10),
158
- "userId" VARCHAR(255) NOT NULL,
159
- "createdByName" VARCHAR(255),
160
- name VARCHAR(255) NOT NULL,
161
- prefix VARCHAR(32) NOT NULL,
162
- hash CHAR(64) NOT NULL UNIQUE,
163
- permissions JSONB NOT NULL DEFAULT '[]',
164
- "projectIds" JSONB NOT NULL,
165
- "createdAt" TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
166
- "expiresAt" TIMESTAMP WITH TIME ZONE,
167
- "lastUsedAt" TIMESTAMP WITH TIME ZONE
168
- );
169
- ALTER TABLE streamby.api_tokens ADD COLUMN IF NOT EXISTS kind VARCHAR(10);
170
- ALTER TABLE streamby.api_tokens ADD COLUMN IF NOT EXISTS "createdByName" VARCHAR(255);
171
- ALTER TABLE streamby.api_tokens ADD COLUMN IF NOT EXISTS permissions JSONB NOT NULL DEFAULT '[]';
172
- DO $$ BEGIN
173
- IF EXISTS (SELECT 1 FROM information_schema.columns
174
- WHERE table_schema = 'streamby' AND table_name = 'api_tokens' AND column_name = 'access') THEN
175
- ALTER TABLE streamby.api_tokens ALTER COLUMN access DROP NOT NULL;
176
- END IF;
177
- END $$;
178
- CREATE INDEX IF NOT EXISTS api_tokens_userid_idx ON streamby.api_tokens ("userId");
179
- CREATE INDEX IF NOT EXISTS api_tokens_kind_idx ON streamby.api_tokens (kind);
154
+ await pool.query(`
155
+ CREATE TABLE IF NOT EXISTS streamby.api_tokens (
156
+ id TEXT PRIMARY KEY DEFAULT uuid_generate_v4()::text,
157
+ kind VARCHAR(10),
158
+ "userId" VARCHAR(255) NOT NULL,
159
+ "createdByName" VARCHAR(255),
160
+ name VARCHAR(255) NOT NULL,
161
+ prefix VARCHAR(32) NOT NULL,
162
+ hash CHAR(64) NOT NULL UNIQUE,
163
+ permissions JSONB NOT NULL DEFAULT '[]',
164
+ "projectIds" JSONB NOT NULL,
165
+ "createdAt" TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
166
+ "expiresAt" TIMESTAMP WITH TIME ZONE,
167
+ "lastUsedAt" TIMESTAMP WITH TIME ZONE
168
+ );
169
+ ALTER TABLE streamby.api_tokens ADD COLUMN IF NOT EXISTS kind VARCHAR(10);
170
+ ALTER TABLE streamby.api_tokens ADD COLUMN IF NOT EXISTS "createdByName" VARCHAR(255);
171
+ ALTER TABLE streamby.api_tokens ADD COLUMN IF NOT EXISTS permissions JSONB NOT NULL DEFAULT '[]';
172
+ DO $$ BEGIN
173
+ IF EXISTS (SELECT 1 FROM information_schema.columns
174
+ WHERE table_schema = 'streamby' AND table_name = 'api_tokens' AND column_name = 'access') THEN
175
+ ALTER TABLE streamby.api_tokens ALTER COLUMN access DROP NOT NULL;
176
+ END IF;
177
+ END $$;
178
+ CREATE INDEX IF NOT EXISTS api_tokens_userid_idx ON streamby.api_tokens ("userId");
179
+ CREATE INDEX IF NOT EXISTS api_tokens_kind_idx ON streamby.api_tokens (kind);
180
180
  `);
181
181
  console.log('✅ "api_tokens" table ensured to exist.');
182
182
  // The MCP server's audit log: every tool call (see mcp/audit). Main db only.
183
- await pool.query(`
184
- CREATE TABLE IF NOT EXISTS streamby.mcp_audit (
185
- id TEXT PRIMARY KEY DEFAULT uuid_generate_v4()::text,
186
- "userId" VARCHAR(255) NOT NULL,
187
- "tokenId" VARCHAR(255) NOT NULL,
188
- "projectId" VARCHAR(255),
189
- tool VARCHAR(100) NOT NULL,
190
- ok BOOLEAN NOT NULL,
191
- status INTEGER,
192
- error TEXT,
193
- "durationMs" INTEGER NOT NULL,
194
- "createdAt" TIMESTAMP WITH TIME ZONE DEFAULT NOW()
195
- );
196
- CREATE INDEX IF NOT EXISTS mcp_audit_token_idx ON streamby.mcp_audit ("tokenId", "createdAt");
183
+ await pool.query(`
184
+ CREATE TABLE IF NOT EXISTS streamby.mcp_audit (
185
+ id TEXT PRIMARY KEY DEFAULT uuid_generate_v4()::text,
186
+ "userId" VARCHAR(255) NOT NULL,
187
+ "tokenId" VARCHAR(255) NOT NULL,
188
+ "projectId" VARCHAR(255),
189
+ tool VARCHAR(100) NOT NULL,
190
+ ok BOOLEAN NOT NULL,
191
+ status INTEGER,
192
+ error TEXT,
193
+ "durationMs" INTEGER NOT NULL,
194
+ "createdAt" TIMESTAMP WITH TIME ZONE DEFAULT NOW()
195
+ );
196
+ CREATE INDEX IF NOT EXISTS mcp_audit_token_idx ON streamby.mcp_audit ("tokenId", "createdAt");
197
197
  `);
198
198
  console.log('✅ "mcp_audit" table ensured to exist.');
199
199
  }
@@ -10,7 +10,7 @@ function serializeForPg(v) {
10
10
  }
11
11
  const createSQLExportTable = async (connection, projectId, exportName, nodeSchema) => {
12
12
  const exportId = (0, crypto_1.randomUUID)();
13
- await connection.query(`INSERT INTO "exports" (id, "projectId", node_schema, name, method, created_at, updated_at)
13
+ await connection.query(`INSERT INTO "exports" (id, "projectId", node_schema, name, method, created_at, updated_at)
14
14
  VALUES ($1, $2, $3, $4, 'GET', NOW(), NOW())`, [exportId, projectId, nodeSchema ? JSON.stringify(nodeSchema) : null, exportName]);
15
15
  return { exportId };
16
16
  };
@@ -8,8 +8,9 @@ export declare class S3Adapter implements StorageAdapter {
8
8
  deleteProjectDirectory(projectId: string): Promise<any>;
9
9
  getPresignedUrl(contentType: string, projectId: string): Promise<any>;
10
10
  getPresignedProjectImageUrl(projectId: string): Promise<any>;
11
- getPresignedGetUrl(key: string): Promise<string>;
12
- getPresignedUploadUrl(key: string, contentType: string): Promise<string>;
11
+ getPresignedGetUrl(key: string, expiresIn?: number, downloadName?: string): Promise<string>;
12
+ getPresignedUploadUrl(key: string, contentType: string, expiresIn?: number): Promise<string>;
13
+ copyFile(from: string, to: string): Promise<void>;
13
14
  deleteFile(key: string): Promise<void>;
14
15
  listFilesByCategory(projectId: string, category: string): Promise<StorageFileInfo[]>;
15
16
  private inferMimeType;
@@ -64,13 +64,21 @@ class S3Adapter {
64
64
  const publicUrl = `https://${this.bucket}.s3.amazonaws.com/${key}`;
65
65
  return { url, publicUrl };
66
66
  }
67
- async getPresignedGetUrl(key) {
68
- const command = new client_s3_1.GetObjectCommand({ Bucket: this.bucket, Key: key });
69
- return await (0, s3_request_presigner_1.getSignedUrl)(this.s3, command, { expiresIn: 86400 });
67
+ async getPresignedGetUrl(key, expiresIn = 86400, downloadName) {
68
+ const command = new client_s3_1.GetObjectCommand({
69
+ Bucket: this.bucket,
70
+ Key: key,
71
+ ...(downloadName ? { ResponseContentDisposition: contentDisposition(downloadName) } : {}),
72
+ });
73
+ return await (0, s3_request_presigner_1.getSignedUrl)(this.s3, command, { expiresIn });
70
74
  }
71
- async getPresignedUploadUrl(key, contentType) {
75
+ async getPresignedUploadUrl(key, contentType, expiresIn = 3600) {
72
76
  const command = new client_s3_1.PutObjectCommand({ Bucket: this.bucket, Key: key, ContentType: contentType });
73
- return await (0, s3_request_presigner_1.getSignedUrl)(this.s3, command, { expiresIn: 3600 });
77
+ return await (0, s3_request_presigner_1.getSignedUrl)(this.s3, command, { expiresIn });
78
+ }
79
+ async copyFile(from, to) {
80
+ const source = `${this.bucket}/${from.split('/').map(encodeURIComponent).join('/')}`;
81
+ await this.s3.send(new client_s3_1.CopyObjectCommand({ Bucket: this.bucket, CopySource: source, Key: to }));
74
82
  }
75
83
  async deleteFile(key) {
76
84
  const command = new client_s3_1.DeleteObjectCommand({ Bucket: this.bucket, Key: key });
@@ -114,3 +122,8 @@ class S3Adapter {
114
122
  }
115
123
  }
116
124
  exports.S3Adapter = S3Adapter;
125
+ // An attachment with its name: ASCII in `filename`, the full name in `filename*` (RFC 6266).
126
+ const contentDisposition = (name) => {
127
+ const ascii = name.replace(/[^\x20-\x7e]|["\\]/g, '_');
128
+ return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(name)}`;
129
+ };
@@ -5,6 +5,7 @@ exports.createMcpServer = createMcpServer;
5
5
  const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
6
6
  const tools_1 = require("./tools");
7
7
  const audit_1 = require("./audit");
8
+ const workflowNodes_1 = require("./workflowNodes");
8
9
  exports.MCP_SERVER_NAME = 'streamby';
9
10
  const errorText = (body, status) => {
10
11
  const message = body?.message ?? body?.error;
@@ -34,6 +35,17 @@ function createMcpServer(auth, api, config, version) {
34
35
  const projectId = tool.projectOf?.(args) ?? tokenProject;
35
36
  let status = null;
36
37
  try {
38
+ // What the workflow needs first, as a user would add it.
39
+ if (tool.workflowNode) {
40
+ const refused = await (0, workflowNodes_1.ensureWorkflowNode)(tool.workflowNode, tool.projectOf?.(args), auth, api);
41
+ if (refused) {
42
+ await (0, audit_1.recordMcpCall)({
43
+ userId: auth.userId, tokenId: auth.apiToken.tokenId, projectId, tool: tool.name,
44
+ ok: false, status: null, error: refused, durationMs: Date.now() - started,
45
+ }, config);
46
+ return { isError: true, content: [{ type: 'text', text: refused }] };
47
+ }
48
+ }
37
49
  const answer = await api(tool.call(args));
38
50
  status = answer.status;
39
51
  const ok = status >= 200 && status < 300;
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { ApiTokenPermission } from '../services/apiTokenPermissions';
3
+ import type { WorkflowNodeRequirement } from './workflowNodes';
3
4
  export interface ToolCall {
4
5
  method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
5
6
  path: string;
@@ -12,6 +13,7 @@ export interface ToolDefinition<S extends z.ZodRawShape = z.ZodRawShape> {
12
13
  permissions: ApiTokenPermission[];
13
14
  write?: boolean;
14
15
  destructive?: boolean;
16
+ workflowNode?: WorkflowNodeRequirement;
15
17
  input: S;
16
18
  projectOf?: (args: z.infer<z.ZodObject<S>>) => string | undefined;
17
19
  call: (args: z.infer<z.ZodObject<S>>) => ToolCall;
package/dist/mcp/tools.js CHANGED
@@ -8,19 +8,20 @@ const projectId = id('project');
8
8
  const enc = encodeURIComponent;
9
9
  const byProject = ({ projectId }) => projectId;
10
10
  const CATEGORIES = ['images', 'audios', 'videos', '3d-models'];
11
+ const STATUSES = ['todo', 'in_progress', 'review', 'done'];
11
12
  const shotFields = {
12
13
  description: zod_1.z.string().optional(),
13
- status: zod_1.z.string().optional().describe('Its status, as the project uses them'),
14
- assignedTo: zod_1.z.string().optional().describe("The assignee's user id"),
14
+ status: zod_1.z.enum(STATUSES).optional(),
15
+ assignedTo: zod_1.z.array(zod_1.z.string()).optional().describe("The assignees' user ids: members of the project"),
15
16
  assetId: zod_1.z.string().optional(),
16
17
  exportId: zod_1.z.string().optional(),
17
18
  dueDate: zod_1.z.string().optional().describe('An ISO 8601 date'),
18
19
  order: zod_1.z.number().optional(),
19
20
  };
20
21
  const taskFields = {
21
- status: zod_1.z.string().optional(),
22
- priority: zod_1.z.string().optional(),
23
- assignedTo: zod_1.z.string().optional().describe("The assignee's user id"),
22
+ status: zod_1.z.enum(STATUSES).optional(),
23
+ priority: zod_1.z.enum(['low', 'medium', 'high']).optional(),
24
+ assignedTo: zod_1.z.string().optional().describe("The assignee's user id: a member of the project"),
24
25
  dueDate: zod_1.z.string().optional().describe('An ISO 8601 date'),
25
26
  notes: zod_1.z.string().optional(),
26
27
  };
@@ -156,21 +157,21 @@ exports.TOOLS = [
156
157
  // ── Writing ──
157
158
  tool({
158
159
  name: 'update_asset_metadata', title: "Update an asset's custom tags", permissions: ['assets:write'], write: true,
159
- description: "Sets an asset's custom tags (key/value pairs), replacing the ones it had.",
160
+ description: "Sets an asset's custom tags (key/value pairs), replacing the ones it had. They show in the file's Metadata tab, in Storage.",
160
161
  input: { projectId, fileId: id('asset'), customTags: zod_1.z.record(zod_1.z.string(), zod_1.z.string()) },
161
162
  projectOf: byProject,
162
163
  call: ({ projectId, fileId, customTags }) => ({ method: 'PATCH', path: `/projects/${enc(projectId)}/assets/${enc(fileId)}/metadata`, body: { customTags } }),
163
164
  }),
164
165
  tool({
165
166
  name: 'set_asset_rights', title: "Set an asset's rights", permissions: ['rights:write'], write: true,
166
- description: "Sets an asset's rights: holder, license, territory, expiry, restrictions and notes.",
167
+ description: "Sets an asset's rights: holder, license, territory, expiry, restrictions and notes. They show in the file's Rights tab, in Storage.",
167
168
  input: { projectId, assetId: id('asset'), ...rightsFields },
168
169
  projectOf: byProject,
169
170
  call: ({ projectId, assetId, ...rights }) => ({ method: 'PUT', path: `/projects/${enc(projectId)}/assets/${enc(assetId)}/rights`, body: defined(rights) }),
170
171
  }),
171
172
  tool({
172
173
  name: 'create_pipeline', title: 'Create a pipeline', permissions: ['workflow:write'], write: true,
173
- description: 'Creates a pipeline in a project.',
174
+ description: "Creates a pipeline in a project. It shows under Pipelines, and the workflow canvas draws its node.",
174
175
  input: { projectId, name: zod_1.z.string().min(1), description: zod_1.z.string().optional(), order: zod_1.z.number().optional() },
175
176
  projectOf: byProject,
176
177
  call: ({ projectId, ...fields }) => ({ method: 'POST', path: `/projects/${enc(projectId)}/pipelines`, body: defined(fields) }),
@@ -183,8 +184,8 @@ exports.TOOLS = [
183
184
  call: ({ projectId, pipelineId, ...fields }) => ({ method: 'PATCH', path: `/projects/${enc(projectId)}/pipelines/${enc(pipelineId)}`, body: defined(fields) }),
184
185
  }),
185
186
  tool({
186
- name: 'create_sequence', title: 'Create a production sequence', permissions: ['production:write'], write: true,
187
- description: 'Creates a production sequence in a project.',
187
+ name: 'create_sequence', title: 'Create a production sequence', permissions: ['production:write', 'workflow:write'], write: true, workflowNode: 'production',
188
+ description: "Creates a production sequence in a project, shown in its Production board. If the project's workflow has no production node yet, it first adds one (a Shot node, as dropping it on the canvas does), so the project's Production section shows it: that's why it also needs workflow:write.",
188
189
  input: { projectId, name: zod_1.z.string().min(1), description: zod_1.z.string().optional(), order: zod_1.z.number().optional() },
189
190
  projectOf: byProject,
190
191
  call: ({ projectId, ...fields }) => ({ method: 'POST', path: `/projects/${enc(projectId)}/production/sequences`, body: defined(fields) }),
@@ -197,8 +198,8 @@ exports.TOOLS = [
197
198
  call: ({ projectId, sequenceId, ...fields }) => ({ method: 'PATCH', path: `/projects/${enc(projectId)}/production/sequences/${enc(sequenceId)}`, body: defined(fields) }),
198
199
  }),
199
200
  tool({
200
- name: 'create_shot', title: 'Create a shot', permissions: ['production:write'], write: true,
201
- description: 'Creates a shot in a production sequence.',
201
+ name: 'create_shot', title: 'Create a shot', permissions: ['production:write', 'workflow:write'], write: true, workflowNode: 'production',
202
+ description: "Creates a shot in a production sequence, shown in its Production board. If the project's workflow has no production node yet, it first adds one (a Shot node, as dropping it on the canvas does), so the project's Production section shows it: that's why it also needs workflow:write.",
202
203
  input: { projectId, sequenceId: id('sequence'), name: zod_1.z.string().min(1), ...shotFields },
203
204
  projectOf: byProject,
204
205
  call: ({ projectId, sequenceId, ...fields }) => ({ method: 'POST', path: `/projects/${enc(projectId)}/production/sequences/${enc(sequenceId)}/shots`, body: defined(fields) }),
@@ -211,8 +212,8 @@ exports.TOOLS = [
211
212
  call: ({ projectId, shotId, ...fields }) => ({ method: 'PATCH', path: `/projects/${enc(projectId)}/production/shots/${enc(shotId)}`, body: defined(fields) }),
212
213
  }),
213
214
  tool({
214
- name: 'create_task', title: 'Create a shot task', permissions: ['production:write'], write: true,
215
- description: 'Creates a task in a shot.',
215
+ name: 'create_task', title: 'Create a shot task', permissions: ['production:write', 'workflow:write'], write: true, workflowNode: 'production',
216
+ description: "Creates a task in a shot, shown in the shot's panel in the Production board. If the project's workflow has no production node yet, it first adds one (a Shot node, as dropping it on the canvas does), so the project's Production section shows it: that's why it also needs workflow:write.",
216
217
  input: { projectId, shotId: id('shot'), name: zod_1.z.string().min(1), ...taskFields },
217
218
  projectOf: byProject,
218
219
  call: ({ projectId, shotId, ...fields }) => ({ method: 'POST', path: `/projects/${enc(projectId)}/production/shots/${enc(shotId)}/tasks`, body: defined(fields) }),
@@ -224,6 +225,98 @@ exports.TOOLS = [
224
225
  projectOf: byProject,
225
226
  call: ({ projectId, shotId, taskId, ...fields }) => ({ method: 'PATCH', path: `/projects/${enc(projectId)}/production/shots/${enc(shotId)}/tasks/${enc(taskId)}`, body: defined(fields) }),
226
227
  }),
228
+ // ── Files: uploading, downloading, deliverables ──
229
+ // The file itself never goes through the conversation: these tools give a signed URL, and
230
+ // the user's own tool (Blender's or Godot's MCP, a script) uploads or downloads it directly.
231
+ tool({
232
+ name: 'list_asset_versions', title: "List an asset's versions", permissions: ['assets:read'],
233
+ description: "An asset's versions, newest first: each one's id, number, file name and when it was made. Any of them can be downloaded (get_asset_download_url).",
234
+ input: { projectId, assetId: id('asset') },
235
+ projectOf: byProject,
236
+ call: ({ projectId, assetId }) => ({ method: 'GET', path: `/projects/${enc(projectId)}/assets/${enc(assetId)}/versions` }),
237
+ pick: (data) => (data?.versions ?? []).map((v) => ({
238
+ versionId: v.versionId, version: v.version, fileName: v.fileName ?? null, contentType: v.contentType ?? null, createdAt: v.createdAt,
239
+ })),
240
+ }),
241
+ tool({
242
+ name: 'request_asset_upload', title: 'Request an upload URL', permissions: ['assets:write'], write: true,
243
+ description: [
244
+ "Makes a new asset (or, with assetId, a new version of one) and gives a signed URL to upload its file to, as the UI does.",
245
+ "It doesn't upload anything: the file is sent by the tool that has it (for example Blender's MCP, running Python), with an HTTP PUT of its bytes to `upload.url` and the `upload.headers` given, before `expiresAt`.",
246
+ "The URL works for that file only. A new version keeps the asset's URL (it replaces what it serves); the earlier ones are archived and can still be downloaded.",
247
+ "Categories: images, audios, videos, 3d-models (.glb, .gltf, .obj, .fbx, .stl, .ply); contentType must match it.",
248
+ ].join(' '),
249
+ input: {
250
+ projectId,
251
+ category: zod_1.z.enum(CATEGORIES),
252
+ fileName: zod_1.z.string().min(1).describe('The file name, with its extension (e.g. shot010_layout.glb)'),
253
+ contentType: zod_1.z.string().min(1).describe('Its MIME type (e.g. model/gltf-binary, image/png, video/mp4)'),
254
+ assetId: zod_1.z.string().optional().describe('An existing asset to upload a new version of; a new asset if not given'),
255
+ connectionId: zod_1.z.string().optional().describe("A storage connection to store it in; the project's built-in storage if not given"),
256
+ },
257
+ projectOf: byProject,
258
+ call: ({ projectId, category, fileName, contentType, assetId, connectionId }) => {
259
+ const query = new URLSearchParams({ fileName, contentType, ...(connectionId && !assetId ? { category } : {}) });
260
+ const files = connectionId ? `/projects/${enc(projectId)}/connections/storage/${enc(connectionId)}` : `/projects/${enc(projectId)}/storage`;
261
+ const path = assetId
262
+ ? `${files}/files/${enc(assetId)}/replace-url`
263
+ : connectionId ? `${files}/upload-url` : `${files}/${category}/upload-url`;
264
+ return { method: 'GET', path: `${path}?${query}` };
265
+ },
266
+ pick: (data, { contentType }) => ({
267
+ assetId: data?.fileId,
268
+ ...(data?.versionId ? { versionId: data.versionId, version: data.version } : { version: 1 }),
269
+ upload: { method: 'PUT', url: data?.url, headers: { 'Content-Type': contentType } },
270
+ expiresAt: data?.expiresAt ?? null,
271
+ }),
272
+ }),
273
+ tool({
274
+ name: 'get_asset_download_url', title: 'Get a download URL', permissions: ['assets:read'],
275
+ description: "A short-lived signed URL to download an asset's file (its latest version, or the one given), for the tool that will use it (for example Blender's or Godot's MCP) to fetch with an HTTP GET before `expiresAt`. It works for that file only.",
276
+ input: { projectId, assetId: id('asset'), versionId: zod_1.z.string().optional().describe('A version (list_asset_versions); the latest if not given') },
277
+ projectOf: byProject,
278
+ call: ({ projectId, assetId, versionId }) => ({
279
+ method: 'GET',
280
+ path: `/projects/${enc(projectId)}/assets/${enc(assetId)}/download-url${versionId ? `?${new URLSearchParams({ versionId })}` : ''}`,
281
+ }),
282
+ }),
283
+ tool({
284
+ name: 'list_deliverables', title: 'List deliverables', permissions: ['assets:read'],
285
+ description: "A project's deliverables (assets delivered for a shot or an export), of one shot or export if given: each one's asset, version, note and what it's for.",
286
+ input: {
287
+ projectId,
288
+ shotId: zod_1.z.string().optional().describe("A shot's id: only its deliverables"),
289
+ exportId: zod_1.z.string().optional().describe("An export's id: only its deliverables"),
290
+ },
291
+ projectOf: byProject,
292
+ call: ({ projectId, shotId, exportId }) => {
293
+ const query = new URLSearchParams(defined({ shotId, exportId })).toString();
294
+ return { method: 'GET', path: `/projects/${enc(projectId)}/deliverables${query ? `?${query}` : ''}` };
295
+ },
296
+ pick: (data) => data?.deliverables ?? [],
297
+ }),
298
+ tool({
299
+ name: 'mark_deliverable', title: 'Mark a deliverable', permissions: ['assets:write'], write: true,
300
+ description: "Marks an asset (or one of its versions) as a deliverable of a production shot or of an export. It shows in the shot's panel in the Production board, or in the export's Deliverables tab. Give shotId or exportId, one of them. Upload the file first (request_asset_upload).",
301
+ input: {
302
+ projectId,
303
+ assetId: id('asset'),
304
+ versionId: zod_1.z.string().optional().describe('A version of it; without one, the asset as it is (its latest version)'),
305
+ shotId: zod_1.z.string().optional().describe("The shot it's delivered for"),
306
+ exportId: zod_1.z.string().optional().describe("The export it's delivered for"),
307
+ note: zod_1.z.string().max(1000).optional(),
308
+ },
309
+ projectOf: byProject,
310
+ call: ({ projectId, ...fields }) => ({ method: 'POST', path: `/projects/${enc(projectId)}/deliverables`, body: defined(fields) }),
311
+ pick: (data) => data?.deliverable,
312
+ }),
313
+ tool({
314
+ name: 'remove_deliverable', title: 'Remove a deliverable', permissions: ['assets:write'], write: true,
315
+ description: 'Unmarks a deliverable. Its asset stays where it is.',
316
+ input: { projectId, deliverableId: id('deliverable') },
317
+ projectOf: byProject,
318
+ call: ({ projectId, deliverableId }) => ({ method: 'DELETE', path: `/projects/${enc(projectId)}/deliverables/${enc(deliverableId)}` }),
319
+ }),
227
320
  // ── Projects ──
228
321
  tool({
229
322
  name: 'create_project', title: 'Create a project', permissions: ['projects:create'], write: true,
@@ -0,0 +1,24 @@
1
+ import { Auth } from '../types';
2
+ import type { ApiCaller } from './server';
3
+ export type WorkflowNodeRequirement = 'production';
4
+ /** A Shot node, as the UI makes one dropped on the canvas without a position. */
5
+ export declare const newShotNode: () => {
6
+ id: string;
7
+ type: string;
8
+ position: {
9
+ x: number;
10
+ y: number;
11
+ };
12
+ data: {
13
+ label: string;
14
+ subtitle: string;
15
+ bgColor: string;
16
+ iconColor: string;
17
+ };
18
+ };
19
+ /**
20
+ * Makes sure a project's workflow has what `requirement` needs, adding it (through the REST
21
+ * API, as the caller) if it's missing. An error to answer with, or null to go on. A project
22
+ * the caller doesn't reach is left alone: the call that follows answers for it.
23
+ */
24
+ export declare function ensureWorkflowNode(requirement: WorkflowNodeRequirement, projectId: string | undefined, auth: Auth, api: ApiCaller): Promise<string | null>;