@studio-foundation/api 0.3.0-beta.1
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/ARCHITECTURE.md +52 -0
- package/LICENSE +663 -0
- package/README.md +161 -0
- package/dist/api.d.ts +8 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +6 -0
- package/dist/api.js.map +1 -0
- package/dist/bootstrap.d.ts +66 -0
- package/dist/bootstrap.d.ts.map +1 -0
- package/dist/bootstrap.js +193 -0
- package/dist/bootstrap.js.map +1 -0
- package/dist/event-bus.d.ts +16 -0
- package/dist/event-bus.d.ts.map +1 -0
- package/dist/event-bus.js +37 -0
- package/dist/event-bus.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +52 -0
- package/dist/index.js.map +1 -0
- package/dist/integration-runtime.d.ts +22 -0
- package/dist/integration-runtime.d.ts.map +1 -0
- package/dist/integration-runtime.js +150 -0
- package/dist/integration-runtime.js.map +1 -0
- package/dist/integration-store.d.ts +26 -0
- package/dist/integration-store.d.ts.map +1 -0
- package/dist/integration-store.js +80 -0
- package/dist/integration-store.js.map +1 -0
- package/dist/integrations/linear/failure-handler.d.ts +5 -0
- package/dist/integrations/linear/failure-handler.d.ts.map +1 -0
- package/dist/integrations/linear/failure-handler.js +73 -0
- package/dist/integrations/linear/failure-handler.js.map +1 -0
- package/dist/integrations/linear/webhook-handler.d.ts +6 -0
- package/dist/integrations/linear/webhook-handler.d.ts.map +1 -0
- package/dist/integrations/linear/webhook-handler.js +131 -0
- package/dist/integrations/linear/webhook-handler.js.map +1 -0
- package/dist/integrations/registry.d.ts +4 -0
- package/dist/integrations/registry.d.ts.map +1 -0
- package/dist/integrations/registry.js +9 -0
- package/dist/integrations/registry.js.map +1 -0
- package/dist/integrations/types.d.ts +33 -0
- package/dist/integrations/types.d.ts.map +1 -0
- package/dist/integrations/types.js +2 -0
- package/dist/integrations/types.js.map +1 -0
- package/dist/launcher.d.ts +45 -0
- package/dist/launcher.d.ts.map +1 -0
- package/dist/launcher.js +149 -0
- package/dist/launcher.js.map +1 -0
- package/dist/logger.d.ts +7 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +37 -0
- package/dist/logger.js.map +1 -0
- package/dist/plans.d.ts +13 -0
- package/dist/plans.d.ts.map +1 -0
- package/dist/plans.js +33 -0
- package/dist/plans.js.map +1 -0
- package/dist/routes/agents.d.ts +6 -0
- package/dist/routes/agents.d.ts.map +1 -0
- package/dist/routes/agents.js +118 -0
- package/dist/routes/agents.js.map +1 -0
- package/dist/routes/config.d.ts +6 -0
- package/dist/routes/config.d.ts.map +1 -0
- package/dist/routes/config.js +129 -0
- package/dist/routes/config.js.map +1 -0
- package/dist/routes/contracts.d.ts +6 -0
- package/dist/routes/contracts.d.ts.map +1 -0
- package/dist/routes/contracts.js +188 -0
- package/dist/routes/contracts.js.map +1 -0
- package/dist/routes/pipelines.d.ts +6 -0
- package/dist/routes/pipelines.d.ts.map +1 -0
- package/dist/routes/pipelines.js +133 -0
- package/dist/routes/pipelines.js.map +1 -0
- package/dist/routes/projects.d.ts +6 -0
- package/dist/routes/projects.d.ts.map +1 -0
- package/dist/routes/projects.js +220 -0
- package/dist/routes/projects.js.map +1 -0
- package/dist/routes/runs.d.ts +6 -0
- package/dist/routes/runs.d.ts.map +1 -0
- package/dist/routes/runs.js +427 -0
- package/dist/routes/runs.js.map +1 -0
- package/dist/routes/skills.d.ts +6 -0
- package/dist/routes/skills.d.ts.map +1 -0
- package/dist/routes/skills.js +123 -0
- package/dist/routes/skills.js.map +1 -0
- package/dist/routes/tools.d.ts +6 -0
- package/dist/routes/tools.d.ts.map +1 -0
- package/dist/routes/tools.js +193 -0
- package/dist/routes/tools.js.map +1 -0
- package/dist/routes/users.d.ts +6 -0
- package/dist/routes/users.d.ts.map +1 -0
- package/dist/routes/users.js +152 -0
- package/dist/routes/users.js.map +1 -0
- package/dist/routes/validate.d.ts +6 -0
- package/dist/routes/validate.d.ts.map +1 -0
- package/dist/routes/validate.js +171 -0
- package/dist/routes/validate.js.map +1 -0
- package/dist/routes/webhooks.d.ts +8 -0
- package/dist/routes/webhooks.d.ts.map +1 -0
- package/dist/routes/webhooks.js +102 -0
- package/dist/routes/webhooks.js.map +1 -0
- package/dist/server.d.ts +46 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +101 -0
- package/dist/server.js.map +1 -0
- package/dist/spawners/http-api-spawner.d.ts +10 -0
- package/dist/spawners/http-api-spawner.d.ts.map +1 -0
- package/dist/spawners/http-api-spawner.js +88 -0
- package/dist/spawners/http-api-spawner.js.map +1 -0
- package/dist/user-store-pg.d.ts +21 -0
- package/dist/user-store-pg.d.ts.map +1 -0
- package/dist/user-store-pg.js +97 -0
- package/dist/user-store-pg.js.map +1 -0
- package/dist/user-store.d.ts +29 -0
- package/dist/user-store.d.ts.map +1 -0
- package/dist/user-store.js +97 -0
- package/dist/user-store.js.map +1 -0
- package/dist/utils/repo-resolver.d.ts +3 -0
- package/dist/utils/repo-resolver.d.ts.map +1 -0
- package/dist/utils/repo-resolver.js +3 -0
- package/dist/utils/repo-resolver.js.map +1 -0
- package/dist/webhook-dispatcher.d.ts +13 -0
- package/dist/webhook-dispatcher.d.ts.map +1 -0
- package/dist/webhook-dispatcher.js +108 -0
- package/dist/webhook-dispatcher.js.map +1 -0
- package/dist/webhook-store.d.ts +31 -0
- package/dist/webhook-store.d.ts.map +1 -0
- package/dist/webhook-store.js +91 -0
- package/dist/webhook-store.js.map +1 -0
- package/package.json +59 -0
- package/src/.gitkeep +0 -0
- package/src/api.ts +8 -0
- package/src/bootstrap.ts +259 -0
- package/src/event-bus.ts +64 -0
- package/src/index.ts +58 -0
- package/src/integration-runtime.ts +180 -0
- package/src/integration-store.ts +125 -0
- package/src/integrations/linear/failure-handler.ts +93 -0
- package/src/integrations/linear/webhook-handler.ts +156 -0
- package/src/integrations/registry.ts +12 -0
- package/src/integrations/types.ts +37 -0
- package/src/launcher.ts +214 -0
- package/src/logger.ts +50 -0
- package/src/plans.ts +43 -0
- package/src/routes/agents.ts +131 -0
- package/src/routes/config.ts +154 -0
- package/src/routes/contracts.ts +205 -0
- package/src/routes/pipelines.ts +146 -0
- package/src/routes/projects.ts +237 -0
- package/src/routes/runs.ts +468 -0
- package/src/routes/skills.ts +136 -0
- package/src/routes/tools.ts +222 -0
- package/src/routes/users.ts +169 -0
- package/src/routes/validate.ts +196 -0
- package/src/routes/webhooks.ts +120 -0
- package/src/server.ts +155 -0
- package/src/spawners/http-api-spawner.ts +96 -0
- package/src/user-store-pg.ts +138 -0
- package/src/user-store.ts +125 -0
- package/src/utils/repo-resolver.ts +3 -0
- package/src/webhook-dispatcher.ts +142 -0
- package/src/webhook-store.ts +141 -0
- package/tests/agents.test.ts +164 -0
- package/tests/cancel.test.ts +120 -0
- package/tests/config.test.ts +196 -0
- package/tests/contracts.test.ts +302 -0
- package/tests/event-bus.test.ts +53 -0
- package/tests/http-api-spawner.test.ts +158 -0
- package/tests/integration-runtime.test.ts +199 -0
- package/tests/integration-store.test.ts +66 -0
- package/tests/integrations/linear/failure-handler.test.ts +113 -0
- package/tests/integrations/linear/webhook-handler.test.ts +191 -0
- package/tests/launcher.test.ts +380 -0
- package/tests/linear-webhook.test.ts +390 -0
- package/tests/pipelines.test.ts +166 -0
- package/tests/projects.test.ts +283 -0
- package/tests/runs.test.ts +379 -0
- package/tests/server.test.ts +208 -0
- package/tests/skills.test.ts +149 -0
- package/tests/sse.test.ts +129 -0
- package/tests/tools.test.ts +233 -0
- package/tests/user-store.test.ts +93 -0
- package/tests/users.test.ts +189 -0
- package/tests/utils/repo-resolver.test.ts +105 -0
- package/tests/validate.test.ts +233 -0
- package/tests/webhook-dispatcher.test.ts +214 -0
- package/tests/webhook-store.test.ts +98 -0
- package/tests/webhooks.test.ts +176 -0
- package/tsconfig.json +24 -0
- package/vitest.config.ts +7 -0
package/src/server.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Fastify server builder — takes deps via injection for testability
|
|
2
|
+
// buildServer(deps) → FastifyInstance, ready to listen
|
|
3
|
+
|
|
4
|
+
import Fastify, { type FastifyRequest } from 'fastify';
|
|
5
|
+
import cors from '@fastify/cors';
|
|
6
|
+
import rateLimit from '@fastify/rate-limit';
|
|
7
|
+
import swagger from '@fastify/swagger';
|
|
8
|
+
import swaggerUi from '@fastify/swagger-ui';
|
|
9
|
+
import type { AnyRunStore } from '@studio-foundation/engine';
|
|
10
|
+
import type { RunLauncher } from './launcher.js';
|
|
11
|
+
import type { WebhookStore } from './webhook-store.js';
|
|
12
|
+
import type { IntegrationStore } from './integration-store.js';
|
|
13
|
+
import type { IntegrationRuntime } from './integration-runtime.js';
|
|
14
|
+
import type { UserStore } from './user-store.js';
|
|
15
|
+
import type { PgUserStore } from './user-store-pg.js';
|
|
16
|
+
import { getPlanLimits, DEFAULT_PLANS, type PlansConfig } from './plans.js';
|
|
17
|
+
import { runsRoutes } from './routes/runs.js';
|
|
18
|
+
import { projectsRoutes } from './routes/projects.js';
|
|
19
|
+
import { contractsRoutes } from './routes/contracts.js';
|
|
20
|
+
import { pipelinesRoutes } from './routes/pipelines.js';
|
|
21
|
+
import { toolsRoutes } from './routes/tools.js';
|
|
22
|
+
import { agentsRoutes } from './routes/agents.js';
|
|
23
|
+
import { configRoutes } from './routes/config.js';
|
|
24
|
+
import { skillsRoutes } from './routes/skills.js';
|
|
25
|
+
import { validateRoutes } from './routes/validate.js';
|
|
26
|
+
import { webhooksRoutes } from './routes/webhooks.js';
|
|
27
|
+
import { usersRoutes } from './routes/users.js';
|
|
28
|
+
|
|
29
|
+
export interface ApiConfig {
|
|
30
|
+
key?: string;
|
|
31
|
+
port?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type MaskedConfig = {
|
|
35
|
+
defaults?: { provider?: string; model?: string };
|
|
36
|
+
providers: string[];
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export interface ServerDeps {
|
|
40
|
+
store: AnyRunStore;
|
|
41
|
+
launcher: RunLauncher;
|
|
42
|
+
configsDir: string;
|
|
43
|
+
/** Raw projects_dir from config (may contain ~). Used by route handlers for repo cloning. */
|
|
44
|
+
projectsDir?: string;
|
|
45
|
+
projectName: string;
|
|
46
|
+
apiConfig: ApiConfig;
|
|
47
|
+
studioVersion: string;
|
|
48
|
+
maskedConfig: MaskedConfig;
|
|
49
|
+
webhookStore: WebhookStore;
|
|
50
|
+
integrationStore: IntegrationStore;
|
|
51
|
+
integrationRuntime: IntegrationRuntime;
|
|
52
|
+
userStore?: UserStore | PgUserStore;
|
|
53
|
+
plans?: PlansConfig;
|
|
54
|
+
/** true when at least one user exists in the DB — computed at bootstrap time to avoid per-request DB calls */
|
|
55
|
+
hasUsers?: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// request.user type augmentation for Fastify
|
|
59
|
+
declare module 'fastify' {
|
|
60
|
+
interface FastifyRequest {
|
|
61
|
+
user?: import('./user-store.js').User;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function buildServer(deps: ServerDeps) {
|
|
66
|
+
const fastify = Fastify({ logger: false });
|
|
67
|
+
|
|
68
|
+
// Register text/plain parser so PUT routes that accept YAML bodies work correctly.
|
|
69
|
+
// Without this, Fastify v5 cannot parse text/plain and body validation fails with 400.
|
|
70
|
+
fastify.addContentTypeParser('text/plain', { parseAs: 'string' }, (_req, body, done) => {
|
|
71
|
+
done(null, body);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
void fastify.register(cors, { origin: true });
|
|
75
|
+
|
|
76
|
+
void fastify.register(rateLimit, {
|
|
77
|
+
global: true,
|
|
78
|
+
max: (req: FastifyRequest) => {
|
|
79
|
+
const planName = req.user?.plan ?? 'free';
|
|
80
|
+
const plans = deps.plans ?? DEFAULT_PLANS;
|
|
81
|
+
return getPlanLimits(plans, planName).rate_limit_per_minute;
|
|
82
|
+
},
|
|
83
|
+
timeWindow: '1 minute',
|
|
84
|
+
keyGenerator: (req: FastifyRequest) => req.user?.id ?? req.ip ?? 'anonymous',
|
|
85
|
+
addHeaders: {
|
|
86
|
+
'x-ratelimit-limit': true,
|
|
87
|
+
'x-ratelimit-remaining': true,
|
|
88
|
+
'x-ratelimit-reset': true,
|
|
89
|
+
},
|
|
90
|
+
allowList: (req: FastifyRequest) => req.url.startsWith('/api/integrations/'),
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
if (process.env['NODE_ENV'] !== 'production') {
|
|
94
|
+
void fastify.register(swagger, {
|
|
95
|
+
openapi: {
|
|
96
|
+
info: {
|
|
97
|
+
title: 'Studio API',
|
|
98
|
+
description: 'REST API for Studio pipeline orchestration',
|
|
99
|
+
version: '1.0.0',
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
void fastify.register(swaggerUi, {
|
|
104
|
+
routePrefix: '/api/docs',
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Auth hook — supports three modes:
|
|
109
|
+
// 1. Multi-user: userStore provided + hasUsers=true → lookup by api_key
|
|
110
|
+
// 2. Legacy single-key: hasUsers=false + api.key configured → Bearer check
|
|
111
|
+
// 3. Open/dev: no users, no api.key → allow all (local dev only)
|
|
112
|
+
fastify.addHook('onRequest', async (request, reply) => {
|
|
113
|
+
const path = request.url.split('?')[0];
|
|
114
|
+
if (path.startsWith('/api/integrations/')) return;
|
|
115
|
+
|
|
116
|
+
const auth = request.headers['authorization'];
|
|
117
|
+
const token = auth?.startsWith('Bearer ') ? auth.slice(7) : undefined;
|
|
118
|
+
|
|
119
|
+
if (deps.userStore && deps.hasUsers) {
|
|
120
|
+
// Multi-user mode: look up user by api_key
|
|
121
|
+
const user = token ? await deps.userStore.getUserByApiKey(token) : null;
|
|
122
|
+
if (!user) {
|
|
123
|
+
await reply.status(401).send({ error: 'Unauthorized' });
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
request.user = user;
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Legacy single-key mode
|
|
131
|
+
if (deps.apiConfig.key) {
|
|
132
|
+
if (!auth || auth !== `Bearer ${deps.apiConfig.key}`) {
|
|
133
|
+
await reply.status(401).send({ error: 'Unauthorized' });
|
|
134
|
+
}
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// No userStore with users, no api.key → open (local dev)
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
void fastify.register(runsRoutes, { prefix: '/api', deps });
|
|
142
|
+
void fastify.register(projectsRoutes, { prefix: '/api', deps });
|
|
143
|
+
void fastify.register(contractsRoutes, { prefix: '/api', deps });
|
|
144
|
+
void fastify.register(pipelinesRoutes, { prefix: '/api', deps });
|
|
145
|
+
void fastify.register(toolsRoutes, { prefix: '/api', deps });
|
|
146
|
+
void fastify.register(agentsRoutes, { prefix: '/api', deps });
|
|
147
|
+
void fastify.register(configRoutes, { prefix: '/api', deps });
|
|
148
|
+
void fastify.register(skillsRoutes, { prefix: '/api', deps });
|
|
149
|
+
void fastify.register(validateRoutes, { prefix: '/api', deps });
|
|
150
|
+
void fastify.register(webhooksRoutes, { prefix: '/api', deps });
|
|
151
|
+
void fastify.register(usersRoutes, { prefix: '/api', deps });
|
|
152
|
+
deps.integrationRuntime.registerRoutes(fastify, '/api');
|
|
153
|
+
|
|
154
|
+
return fastify;
|
|
155
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { RunSpawner, SpawnConfig, SpawnResult, PipelineRun } from '@studio-foundation/contracts';
|
|
2
|
+
|
|
3
|
+
export class HttpApiSpawner implements RunSpawner {
|
|
4
|
+
constructor(private apiUrl: string, private apiKey?: string) {}
|
|
5
|
+
|
|
6
|
+
private authHeaders(): Record<string, string> {
|
|
7
|
+
return this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {};
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async spawnAndWait(config: SpawnConfig): Promise<SpawnResult> {
|
|
11
|
+
// 1. Launch the run
|
|
12
|
+
const postRes = await fetch(`${this.apiUrl}/api/runs`, {
|
|
13
|
+
method: 'POST',
|
|
14
|
+
headers: {
|
|
15
|
+
'Content-Type': 'application/json',
|
|
16
|
+
'X-Studio-Depth': String(config.depth),
|
|
17
|
+
'X-Studio-Parent-Run-Id': config.parentRunId,
|
|
18
|
+
...this.authHeaders(),
|
|
19
|
+
},
|
|
20
|
+
body: JSON.stringify({ pipeline: config.pipeline, input: config.input }),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
if (!postRes.ok) {
|
|
24
|
+
const text = await postRes.text();
|
|
25
|
+
throw new Error(`Failed to launch child run: ${postRes.status} ${text}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const { run_id } = (await postRes.json()) as { run_id: string };
|
|
29
|
+
|
|
30
|
+
// 2. Wait for pipeline_complete via SSE
|
|
31
|
+
await this.waitForCompletion(run_id);
|
|
32
|
+
|
|
33
|
+
// 3. Fetch full run result to get output
|
|
34
|
+
const getRes = await fetch(`${this.apiUrl}/api/runs/${run_id}`, {
|
|
35
|
+
headers: { ...this.authHeaders() },
|
|
36
|
+
});
|
|
37
|
+
if (!getRes.ok) {
|
|
38
|
+
throw new Error(`Failed to fetch child run result ${run_id}: ${getRes.status}`);
|
|
39
|
+
}
|
|
40
|
+
const run = (await getRes.json()) as PipelineRun;
|
|
41
|
+
|
|
42
|
+
if (run.status === 'failed' || run.status === 'rejected' || run.status === 'cancelled') {
|
|
43
|
+
throw new Error(`Child run ${run_id} ${run.status}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const lastStage = [...run.stages].reverse().find(s => s.status === 'success');
|
|
47
|
+
const output = (lastStage as { output?: unknown } | undefined)?.output ?? null;
|
|
48
|
+
|
|
49
|
+
return { run_id, status: run.status, output };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private waitForCompletion(runId: string): Promise<void> {
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
fetch(`${this.apiUrl}/api/runs/${runId}/stream`, {
|
|
55
|
+
headers: { Accept: 'text/event-stream', ...this.authHeaders() },
|
|
56
|
+
})
|
|
57
|
+
.then(response => {
|
|
58
|
+
if (!response.ok || !response.body) {
|
|
59
|
+
reject(new Error(`SSE connection failed for run ${runId}: ${response.status}`));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const reader = response.body.getReader();
|
|
63
|
+
const decoder = new TextDecoder();
|
|
64
|
+
let buffer = '';
|
|
65
|
+
let currentEventType = '';
|
|
66
|
+
|
|
67
|
+
const pump = (): Promise<void> =>
|
|
68
|
+
reader.read().then(({ done, value }) => {
|
|
69
|
+
if (done) {
|
|
70
|
+
reject(new Error(`SSE stream ended without pipeline_complete for run ${runId}`));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
buffer += decoder.decode(value, { stream: true });
|
|
74
|
+
const lines = buffer.split('\n');
|
|
75
|
+
buffer = lines.pop() ?? '';
|
|
76
|
+
|
|
77
|
+
for (const line of lines) {
|
|
78
|
+
if (line.startsWith('event: ')) {
|
|
79
|
+
currentEventType = line.slice(7).trim();
|
|
80
|
+
} else if (line.startsWith('data: ') && currentEventType === 'pipeline_complete') {
|
|
81
|
+
reader.cancel();
|
|
82
|
+
resolve();
|
|
83
|
+
return;
|
|
84
|
+
} else if (line === '') {
|
|
85
|
+
currentEventType = '';
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return pump();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
pump().catch(reject);
|
|
92
|
+
})
|
|
93
|
+
.catch(reject);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// api/src/user-store-pg.ts
|
|
2
|
+
// PgUserStore — PostgreSQL async variant of UserStore
|
|
3
|
+
// Follows same pattern as PgRunStore in @studio/engine
|
|
4
|
+
// Table prefix studio_ to avoid conflicts with user app tables
|
|
5
|
+
|
|
6
|
+
import { createRequire } from 'node:module';
|
|
7
|
+
import type { User, DailyUsage } from './user-store.js';
|
|
8
|
+
export type { User, DailyUsage } from './user-store.js';
|
|
9
|
+
|
|
10
|
+
export class PgUserStore {
|
|
11
|
+
private pool: import('pg').Pool;
|
|
12
|
+
private schemaReady: Promise<void> | null = null;
|
|
13
|
+
|
|
14
|
+
constructor(connectionString: string) {
|
|
15
|
+
const _require = createRequire(import.meta.url);
|
|
16
|
+
const { Pool } = _require('pg') as typeof import('pg');
|
|
17
|
+
this.pool = new Pool({ connectionString });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
private ensureSchema(): Promise<void> {
|
|
21
|
+
if (!this.schemaReady) {
|
|
22
|
+
this.schemaReady = this.initSchema();
|
|
23
|
+
}
|
|
24
|
+
return this.schemaReady;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
private async initSchema(): Promise<void> {
|
|
28
|
+
await this.pool.query(`
|
|
29
|
+
CREATE TABLE IF NOT EXISTS studio_users (
|
|
30
|
+
id TEXT PRIMARY KEY,
|
|
31
|
+
email TEXT UNIQUE NOT NULL,
|
|
32
|
+
plan TEXT NOT NULL DEFAULT 'free',
|
|
33
|
+
api_key TEXT UNIQUE NOT NULL,
|
|
34
|
+
created_at TEXT NOT NULL
|
|
35
|
+
)
|
|
36
|
+
`);
|
|
37
|
+
await this.pool.query(`
|
|
38
|
+
CREATE TABLE IF NOT EXISTS studio_usage (
|
|
39
|
+
user_id TEXT NOT NULL,
|
|
40
|
+
date TEXT NOT NULL,
|
|
41
|
+
runs_count INTEGER NOT NULL DEFAULT 0,
|
|
42
|
+
tokens_used INTEGER NOT NULL DEFAULT 0,
|
|
43
|
+
PRIMARY KEY (user_id, date)
|
|
44
|
+
)
|
|
45
|
+
`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async getUserByApiKey(apiKey: string): Promise<User | null> {
|
|
49
|
+
await this.ensureSchema();
|
|
50
|
+
const res = await this.pool.query<User>(
|
|
51
|
+
'SELECT id, email, plan, api_key, created_at FROM studio_users WHERE api_key = $1',
|
|
52
|
+
[apiKey]
|
|
53
|
+
);
|
|
54
|
+
return res.rows[0] ?? null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async getUserById(id: string): Promise<User | null> {
|
|
58
|
+
await this.ensureSchema();
|
|
59
|
+
const res = await this.pool.query<User>(
|
|
60
|
+
'SELECT id, email, plan, api_key, created_at FROM studio_users WHERE id = $1',
|
|
61
|
+
[id]
|
|
62
|
+
);
|
|
63
|
+
return res.rows[0] ?? null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async getUserByEmail(email: string): Promise<User | null> {
|
|
67
|
+
await this.ensureSchema();
|
|
68
|
+
const res = await this.pool.query<User>(
|
|
69
|
+
'SELECT id, email, plan, api_key, created_at FROM studio_users WHERE email = $1',
|
|
70
|
+
[email]
|
|
71
|
+
);
|
|
72
|
+
return res.rows[0] ?? null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async listUsers(): Promise<User[]> {
|
|
76
|
+
await this.ensureSchema();
|
|
77
|
+
const res = await this.pool.query<User>(
|
|
78
|
+
'SELECT id, email, plan, api_key, created_at FROM studio_users ORDER BY created_at ASC'
|
|
79
|
+
);
|
|
80
|
+
return res.rows;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async saveUser(user: User): Promise<void> {
|
|
84
|
+
await this.ensureSchema();
|
|
85
|
+
await this.pool.query(
|
|
86
|
+
`INSERT INTO studio_users (id, email, plan, api_key, created_at)
|
|
87
|
+
VALUES ($1, $2, $3, $4, $5)
|
|
88
|
+
ON CONFLICT (id) DO UPDATE SET
|
|
89
|
+
email = EXCLUDED.email,
|
|
90
|
+
plan = EXCLUDED.plan,
|
|
91
|
+
api_key = EXCLUDED.api_key,
|
|
92
|
+
created_at = EXCLUDED.created_at`,
|
|
93
|
+
[user.id, user.email, user.plan, user.api_key, user.created_at]
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async deleteUser(id: string): Promise<void> {
|
|
98
|
+
await this.ensureSchema();
|
|
99
|
+
await this.pool.query('DELETE FROM studio_users WHERE id = $1', [id]);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async getDailyUsage(userId: string, date: string): Promise<DailyUsage> {
|
|
103
|
+
await this.ensureSchema();
|
|
104
|
+
const res = await this.pool.query<DailyUsage>(
|
|
105
|
+
'SELECT user_id, date, runs_count, tokens_used FROM studio_usage WHERE user_id = $1 AND date = $2',
|
|
106
|
+
[userId, date]
|
|
107
|
+
);
|
|
108
|
+
return res.rows[0] ?? { user_id: userId, date, runs_count: 0, tokens_used: 0 };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async incrementRuns(userId: string, date: string): Promise<void> {
|
|
112
|
+
await this.ensureSchema();
|
|
113
|
+
await this.pool.query(
|
|
114
|
+
`INSERT INTO studio_usage (user_id, date, runs_count, tokens_used)
|
|
115
|
+
VALUES ($1, $2, 1, 0)
|
|
116
|
+
ON CONFLICT (user_id, date) DO UPDATE SET
|
|
117
|
+
runs_count = studio_usage.runs_count + 1`,
|
|
118
|
+
[userId, date]
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async incrementTokens(userId: string, date: string, tokens: number): Promise<void> {
|
|
123
|
+
await this.ensureSchema();
|
|
124
|
+
await this.pool.query(
|
|
125
|
+
`INSERT INTO studio_usage (user_id, date, runs_count, tokens_used)
|
|
126
|
+
VALUES ($1, $2, 0, $3)
|
|
127
|
+
ON CONFLICT (user_id, date) DO UPDATE SET
|
|
128
|
+
tokens_used = studio_usage.tokens_used + EXCLUDED.tokens_used`,
|
|
129
|
+
[userId, date, tokens]
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async close(): Promise<void> {
|
|
134
|
+
await this.pool.end();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export type AnyUserStore = import('./user-store.js').UserStore | PgUserStore;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// api/src/user-store.ts
|
|
2
|
+
// UserStore — SQLite persistence for users and daily usage
|
|
3
|
+
// Follows same pattern as WebhookStore and IntegrationStore
|
|
4
|
+
// Uses the same DB file as the run store (.studio/runs/runs.db)
|
|
5
|
+
|
|
6
|
+
import { createRequire } from 'node:module';
|
|
7
|
+
|
|
8
|
+
export interface User {
|
|
9
|
+
id: string;
|
|
10
|
+
email: string;
|
|
11
|
+
plan: string;
|
|
12
|
+
api_key: string;
|
|
13
|
+
created_at: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface DailyUsage {
|
|
17
|
+
user_id: string;
|
|
18
|
+
date: string; // YYYY-MM-DD
|
|
19
|
+
runs_count: number;
|
|
20
|
+
tokens_used: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class UserStore {
|
|
24
|
+
private db: import('better-sqlite3').Database;
|
|
25
|
+
|
|
26
|
+
constructor(dbPath: string) {
|
|
27
|
+
const _require = createRequire(import.meta.url);
|
|
28
|
+
const Database = _require('better-sqlite3') as new (path: string) => import('better-sqlite3').Database;
|
|
29
|
+
this.db = new Database(dbPath);
|
|
30
|
+
this.db.pragma('journal_mode = WAL');
|
|
31
|
+
this.initSchema();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private initSchema(): void {
|
|
35
|
+
this.db.exec(`
|
|
36
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
37
|
+
id TEXT PRIMARY KEY,
|
|
38
|
+
email TEXT UNIQUE NOT NULL,
|
|
39
|
+
plan TEXT NOT NULL DEFAULT 'free',
|
|
40
|
+
api_key TEXT UNIQUE NOT NULL,
|
|
41
|
+
created_at TEXT NOT NULL
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
CREATE TABLE IF NOT EXISTS usage (
|
|
45
|
+
user_id TEXT NOT NULL,
|
|
46
|
+
date TEXT NOT NULL,
|
|
47
|
+
runs_count INTEGER NOT NULL DEFAULT 0,
|
|
48
|
+
tokens_used INTEGER NOT NULL DEFAULT 0,
|
|
49
|
+
PRIMARY KEY (user_id, date)
|
|
50
|
+
);
|
|
51
|
+
`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
getUserByApiKey(apiKey: string): User | null {
|
|
55
|
+
const row = this.db
|
|
56
|
+
.prepare('SELECT id, email, plan, api_key, created_at FROM users WHERE api_key = ?')
|
|
57
|
+
.get(apiKey) as User | undefined;
|
|
58
|
+
return row ?? null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
getUserById(id: string): User | null {
|
|
62
|
+
const row = this.db
|
|
63
|
+
.prepare('SELECT id, email, plan, api_key, created_at FROM users WHERE id = ?')
|
|
64
|
+
.get(id) as User | undefined;
|
|
65
|
+
return row ?? null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
getUserByEmail(email: string): User | null {
|
|
69
|
+
const row = this.db
|
|
70
|
+
.prepare('SELECT id, email, plan, api_key, created_at FROM users WHERE email = ?')
|
|
71
|
+
.get(email) as User | undefined;
|
|
72
|
+
return row ?? null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
listUsers(): User[] {
|
|
76
|
+
return this.db
|
|
77
|
+
.prepare('SELECT id, email, plan, api_key, created_at FROM users ORDER BY created_at ASC')
|
|
78
|
+
.all() as User[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
saveUser(user: User): void {
|
|
82
|
+
this.db.prepare(`
|
|
83
|
+
INSERT INTO users (id, email, plan, api_key, created_at)
|
|
84
|
+
VALUES (?, ?, ?, ?, ?)
|
|
85
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
86
|
+
email = excluded.email,
|
|
87
|
+
plan = excluded.plan,
|
|
88
|
+
api_key = excluded.api_key,
|
|
89
|
+
created_at = excluded.created_at
|
|
90
|
+
`).run(user.id, user.email, user.plan, user.api_key, user.created_at);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
deleteUser(id: string): void {
|
|
94
|
+
this.db.prepare('DELETE FROM users WHERE id = ?').run(id);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
getDailyUsage(userId: string, date: string): DailyUsage {
|
|
98
|
+
const row = this.db
|
|
99
|
+
.prepare('SELECT user_id, date, runs_count, tokens_used FROM usage WHERE user_id = ? AND date = ?')
|
|
100
|
+
.get(userId, date) as DailyUsage | undefined;
|
|
101
|
+
return row ?? { user_id: userId, date, runs_count: 0, tokens_used: 0 };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
incrementRuns(userId: string, date: string): void {
|
|
105
|
+
this.db.prepare(`
|
|
106
|
+
INSERT INTO usage (user_id, date, runs_count, tokens_used)
|
|
107
|
+
VALUES (?, ?, 1, 0)
|
|
108
|
+
ON CONFLICT (user_id, date) DO UPDATE SET
|
|
109
|
+
runs_count = runs_count + 1
|
|
110
|
+
`).run(userId, date);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
incrementTokens(userId: string, date: string, tokens: number): void {
|
|
114
|
+
this.db.prepare(`
|
|
115
|
+
INSERT INTO usage (user_id, date, runs_count, tokens_used)
|
|
116
|
+
VALUES (?, ?, 0, ?)
|
|
117
|
+
ON CONFLICT (user_id, date) DO UPDATE SET
|
|
118
|
+
tokens_used = tokens_used + excluded.tokens_used
|
|
119
|
+
`).run(userId, date, tokens);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
close(): void {
|
|
123
|
+
this.db.close();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// WebhookDispatcher — listens to pipeline events and delivers them to registered webhooks
|
|
2
|
+
// Handles HMAC-SHA256 signing, retry logic (30s / 5min / 15min), and failure tracking
|
|
3
|
+
|
|
4
|
+
import { createHmac, randomUUID } from 'node:crypto';
|
|
5
|
+
import type { SseEventType } from './event-bus.js';
|
|
6
|
+
import type { WebhookStore } from './webhook-store.js';
|
|
7
|
+
|
|
8
|
+
export type WebhookEventType =
|
|
9
|
+
| 'pipeline_start'
|
|
10
|
+
| 'pipeline_complete'
|
|
11
|
+
| 'stage_complete'
|
|
12
|
+
| 'stage_rejected'
|
|
13
|
+
| 'stage_failed'
|
|
14
|
+
| 'group_feedback';
|
|
15
|
+
|
|
16
|
+
// Retry delays in ms: attempt 1 failed → 30s, attempt 2 failed → 5min, attempt 3 failed → 15min
|
|
17
|
+
const RETRY_DELAYS = [30_000, 5 * 60_000, 15 * 60_000];
|
|
18
|
+
|
|
19
|
+
function mapToWebhookEvent(sseType: SseEventType, data: unknown): WebhookEventType | null {
|
|
20
|
+
switch (sseType) {
|
|
21
|
+
case 'pipeline_start':
|
|
22
|
+
return 'pipeline_start';
|
|
23
|
+
case 'pipeline_complete':
|
|
24
|
+
return 'pipeline_complete';
|
|
25
|
+
case 'stage_complete': {
|
|
26
|
+
const status = (data as { status?: string }).status;
|
|
27
|
+
if (status === 'rejected') return 'stage_rejected';
|
|
28
|
+
if (status === 'failed') return 'stage_failed';
|
|
29
|
+
return 'stage_complete';
|
|
30
|
+
}
|
|
31
|
+
case 'group_feedback':
|
|
32
|
+
return 'group_feedback';
|
|
33
|
+
default:
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function sign(body: string, secret: string): string {
|
|
39
|
+
const hmac = createHmac('sha256', secret);
|
|
40
|
+
hmac.update(body, 'utf-8');
|
|
41
|
+
return `sha256=${hmac.digest('hex')}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class WebhookDispatcher {
|
|
45
|
+
constructor(
|
|
46
|
+
private readonly store: WebhookStore,
|
|
47
|
+
private readonly projectName: string,
|
|
48
|
+
private readonly fetcher: typeof fetch = fetch,
|
|
49
|
+
) {}
|
|
50
|
+
|
|
51
|
+
async handleBusEvent(runId: string, sseType: SseEventType, data: unknown): Promise<void> {
|
|
52
|
+
const webhookEvent = mapToWebhookEvent(sseType, data);
|
|
53
|
+
if (!webhookEvent) return;
|
|
54
|
+
|
|
55
|
+
const webhooks = this.store.listWebhooks().filter(
|
|
56
|
+
w => w.status === 'active' && w.events.includes(webhookEvent),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
await Promise.all(
|
|
60
|
+
webhooks.map(webhook => this.dispatch(webhook.id, webhookEvent, runId, data, 1)),
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private async dispatch(
|
|
65
|
+
webhookId: string,
|
|
66
|
+
event: WebhookEventType,
|
|
67
|
+
runId: string,
|
|
68
|
+
sourceData: unknown,
|
|
69
|
+
attempt: number,
|
|
70
|
+
): Promise<void> {
|
|
71
|
+
const webhook = this.store.getWebhook(webhookId);
|
|
72
|
+
if (!webhook || webhook.status === 'failed') return;
|
|
73
|
+
|
|
74
|
+
const payload = {
|
|
75
|
+
event,
|
|
76
|
+
run_id: runId,
|
|
77
|
+
project: this.projectName,
|
|
78
|
+
ts: new Date().toISOString(),
|
|
79
|
+
...(sourceData as object),
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const body = JSON.stringify(payload);
|
|
83
|
+
const headers: Record<string, string> = {
|
|
84
|
+
'Content-Type': 'application/json',
|
|
85
|
+
'X-Studio-Event': event,
|
|
86
|
+
};
|
|
87
|
+
if (webhook.secret) {
|
|
88
|
+
headers['X-Studio-Signature'] = sign(body, webhook.secret);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const deliveryId = randomUUID();
|
|
92
|
+
this.store.saveDelivery({
|
|
93
|
+
id: deliveryId,
|
|
94
|
+
webhook_id: webhookId,
|
|
95
|
+
event,
|
|
96
|
+
run_id: runId,
|
|
97
|
+
status: 'pending',
|
|
98
|
+
attempt,
|
|
99
|
+
created_at: new Date().toISOString(),
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const response = await this.fetcher(webhook.url, {
|
|
104
|
+
method: 'POST',
|
|
105
|
+
headers,
|
|
106
|
+
body,
|
|
107
|
+
signal: AbortSignal.timeout(10_000),
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
if (response.ok) {
|
|
111
|
+
this.store.updateDelivery(deliveryId, 'success');
|
|
112
|
+
} else {
|
|
113
|
+
this.handleFailure(webhookId, event, runId, sourceData, attempt, deliveryId);
|
|
114
|
+
}
|
|
115
|
+
} catch {
|
|
116
|
+
this.handleFailure(webhookId, event, runId, sourceData, attempt, deliveryId);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private handleFailure(
|
|
121
|
+
webhookId: string,
|
|
122
|
+
event: WebhookEventType,
|
|
123
|
+
runId: string,
|
|
124
|
+
sourceData: unknown,
|
|
125
|
+
attempt: number,
|
|
126
|
+
deliveryId: string,
|
|
127
|
+
): void {
|
|
128
|
+
const maxAttempts = RETRY_DELAYS.length + 1; // 4 total (1 original + 3 retries)
|
|
129
|
+
|
|
130
|
+
if (attempt >= maxAttempts) {
|
|
131
|
+
this.store.updateDelivery(deliveryId, 'failed');
|
|
132
|
+
this.store.markWebhookFailed(webhookId);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
this.store.updateDelivery(deliveryId, 'retrying');
|
|
137
|
+
const delay = RETRY_DELAYS[attempt - 1];
|
|
138
|
+
setTimeout(() => {
|
|
139
|
+
void this.dispatch(webhookId, event, runId, sourceData, attempt + 1);
|
|
140
|
+
}, delay);
|
|
141
|
+
}
|
|
142
|
+
}
|