@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.
Files changed (188) hide show
  1. package/ARCHITECTURE.md +52 -0
  2. package/LICENSE +663 -0
  3. package/README.md +161 -0
  4. package/dist/api.d.ts +8 -0
  5. package/dist/api.d.ts.map +1 -0
  6. package/dist/api.js +6 -0
  7. package/dist/api.js.map +1 -0
  8. package/dist/bootstrap.d.ts +66 -0
  9. package/dist/bootstrap.d.ts.map +1 -0
  10. package/dist/bootstrap.js +193 -0
  11. package/dist/bootstrap.js.map +1 -0
  12. package/dist/event-bus.d.ts +16 -0
  13. package/dist/event-bus.d.ts.map +1 -0
  14. package/dist/event-bus.js +37 -0
  15. package/dist/event-bus.js.map +1 -0
  16. package/dist/index.d.ts +3 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +52 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/integration-runtime.d.ts +22 -0
  21. package/dist/integration-runtime.d.ts.map +1 -0
  22. package/dist/integration-runtime.js +150 -0
  23. package/dist/integration-runtime.js.map +1 -0
  24. package/dist/integration-store.d.ts +26 -0
  25. package/dist/integration-store.d.ts.map +1 -0
  26. package/dist/integration-store.js +80 -0
  27. package/dist/integration-store.js.map +1 -0
  28. package/dist/integrations/linear/failure-handler.d.ts +5 -0
  29. package/dist/integrations/linear/failure-handler.d.ts.map +1 -0
  30. package/dist/integrations/linear/failure-handler.js +73 -0
  31. package/dist/integrations/linear/failure-handler.js.map +1 -0
  32. package/dist/integrations/linear/webhook-handler.d.ts +6 -0
  33. package/dist/integrations/linear/webhook-handler.d.ts.map +1 -0
  34. package/dist/integrations/linear/webhook-handler.js +131 -0
  35. package/dist/integrations/linear/webhook-handler.js.map +1 -0
  36. package/dist/integrations/registry.d.ts +4 -0
  37. package/dist/integrations/registry.d.ts.map +1 -0
  38. package/dist/integrations/registry.js +9 -0
  39. package/dist/integrations/registry.js.map +1 -0
  40. package/dist/integrations/types.d.ts +33 -0
  41. package/dist/integrations/types.d.ts.map +1 -0
  42. package/dist/integrations/types.js +2 -0
  43. package/dist/integrations/types.js.map +1 -0
  44. package/dist/launcher.d.ts +45 -0
  45. package/dist/launcher.d.ts.map +1 -0
  46. package/dist/launcher.js +149 -0
  47. package/dist/launcher.js.map +1 -0
  48. package/dist/logger.d.ts +7 -0
  49. package/dist/logger.d.ts.map +1 -0
  50. package/dist/logger.js +37 -0
  51. package/dist/logger.js.map +1 -0
  52. package/dist/plans.d.ts +13 -0
  53. package/dist/plans.d.ts.map +1 -0
  54. package/dist/plans.js +33 -0
  55. package/dist/plans.js.map +1 -0
  56. package/dist/routes/agents.d.ts +6 -0
  57. package/dist/routes/agents.d.ts.map +1 -0
  58. package/dist/routes/agents.js +118 -0
  59. package/dist/routes/agents.js.map +1 -0
  60. package/dist/routes/config.d.ts +6 -0
  61. package/dist/routes/config.d.ts.map +1 -0
  62. package/dist/routes/config.js +129 -0
  63. package/dist/routes/config.js.map +1 -0
  64. package/dist/routes/contracts.d.ts +6 -0
  65. package/dist/routes/contracts.d.ts.map +1 -0
  66. package/dist/routes/contracts.js +188 -0
  67. package/dist/routes/contracts.js.map +1 -0
  68. package/dist/routes/pipelines.d.ts +6 -0
  69. package/dist/routes/pipelines.d.ts.map +1 -0
  70. package/dist/routes/pipelines.js +133 -0
  71. package/dist/routes/pipelines.js.map +1 -0
  72. package/dist/routes/projects.d.ts +6 -0
  73. package/dist/routes/projects.d.ts.map +1 -0
  74. package/dist/routes/projects.js +220 -0
  75. package/dist/routes/projects.js.map +1 -0
  76. package/dist/routes/runs.d.ts +6 -0
  77. package/dist/routes/runs.d.ts.map +1 -0
  78. package/dist/routes/runs.js +427 -0
  79. package/dist/routes/runs.js.map +1 -0
  80. package/dist/routes/skills.d.ts +6 -0
  81. package/dist/routes/skills.d.ts.map +1 -0
  82. package/dist/routes/skills.js +123 -0
  83. package/dist/routes/skills.js.map +1 -0
  84. package/dist/routes/tools.d.ts +6 -0
  85. package/dist/routes/tools.d.ts.map +1 -0
  86. package/dist/routes/tools.js +193 -0
  87. package/dist/routes/tools.js.map +1 -0
  88. package/dist/routes/users.d.ts +6 -0
  89. package/dist/routes/users.d.ts.map +1 -0
  90. package/dist/routes/users.js +152 -0
  91. package/dist/routes/users.js.map +1 -0
  92. package/dist/routes/validate.d.ts +6 -0
  93. package/dist/routes/validate.d.ts.map +1 -0
  94. package/dist/routes/validate.js +171 -0
  95. package/dist/routes/validate.js.map +1 -0
  96. package/dist/routes/webhooks.d.ts +8 -0
  97. package/dist/routes/webhooks.d.ts.map +1 -0
  98. package/dist/routes/webhooks.js +102 -0
  99. package/dist/routes/webhooks.js.map +1 -0
  100. package/dist/server.d.ts +46 -0
  101. package/dist/server.d.ts.map +1 -0
  102. package/dist/server.js +101 -0
  103. package/dist/server.js.map +1 -0
  104. package/dist/spawners/http-api-spawner.d.ts +10 -0
  105. package/dist/spawners/http-api-spawner.d.ts.map +1 -0
  106. package/dist/spawners/http-api-spawner.js +88 -0
  107. package/dist/spawners/http-api-spawner.js.map +1 -0
  108. package/dist/user-store-pg.d.ts +21 -0
  109. package/dist/user-store-pg.d.ts.map +1 -0
  110. package/dist/user-store-pg.js +97 -0
  111. package/dist/user-store-pg.js.map +1 -0
  112. package/dist/user-store.d.ts +29 -0
  113. package/dist/user-store.d.ts.map +1 -0
  114. package/dist/user-store.js +97 -0
  115. package/dist/user-store.js.map +1 -0
  116. package/dist/utils/repo-resolver.d.ts +3 -0
  117. package/dist/utils/repo-resolver.d.ts.map +1 -0
  118. package/dist/utils/repo-resolver.js +3 -0
  119. package/dist/utils/repo-resolver.js.map +1 -0
  120. package/dist/webhook-dispatcher.d.ts +13 -0
  121. package/dist/webhook-dispatcher.d.ts.map +1 -0
  122. package/dist/webhook-dispatcher.js +108 -0
  123. package/dist/webhook-dispatcher.js.map +1 -0
  124. package/dist/webhook-store.d.ts +31 -0
  125. package/dist/webhook-store.d.ts.map +1 -0
  126. package/dist/webhook-store.js +91 -0
  127. package/dist/webhook-store.js.map +1 -0
  128. package/package.json +59 -0
  129. package/src/.gitkeep +0 -0
  130. package/src/api.ts +8 -0
  131. package/src/bootstrap.ts +259 -0
  132. package/src/event-bus.ts +64 -0
  133. package/src/index.ts +58 -0
  134. package/src/integration-runtime.ts +180 -0
  135. package/src/integration-store.ts +125 -0
  136. package/src/integrations/linear/failure-handler.ts +93 -0
  137. package/src/integrations/linear/webhook-handler.ts +156 -0
  138. package/src/integrations/registry.ts +12 -0
  139. package/src/integrations/types.ts +37 -0
  140. package/src/launcher.ts +214 -0
  141. package/src/logger.ts +50 -0
  142. package/src/plans.ts +43 -0
  143. package/src/routes/agents.ts +131 -0
  144. package/src/routes/config.ts +154 -0
  145. package/src/routes/contracts.ts +205 -0
  146. package/src/routes/pipelines.ts +146 -0
  147. package/src/routes/projects.ts +237 -0
  148. package/src/routes/runs.ts +468 -0
  149. package/src/routes/skills.ts +136 -0
  150. package/src/routes/tools.ts +222 -0
  151. package/src/routes/users.ts +169 -0
  152. package/src/routes/validate.ts +196 -0
  153. package/src/routes/webhooks.ts +120 -0
  154. package/src/server.ts +155 -0
  155. package/src/spawners/http-api-spawner.ts +96 -0
  156. package/src/user-store-pg.ts +138 -0
  157. package/src/user-store.ts +125 -0
  158. package/src/utils/repo-resolver.ts +3 -0
  159. package/src/webhook-dispatcher.ts +142 -0
  160. package/src/webhook-store.ts +141 -0
  161. package/tests/agents.test.ts +164 -0
  162. package/tests/cancel.test.ts +120 -0
  163. package/tests/config.test.ts +196 -0
  164. package/tests/contracts.test.ts +302 -0
  165. package/tests/event-bus.test.ts +53 -0
  166. package/tests/http-api-spawner.test.ts +158 -0
  167. package/tests/integration-runtime.test.ts +199 -0
  168. package/tests/integration-store.test.ts +66 -0
  169. package/tests/integrations/linear/failure-handler.test.ts +113 -0
  170. package/tests/integrations/linear/webhook-handler.test.ts +191 -0
  171. package/tests/launcher.test.ts +380 -0
  172. package/tests/linear-webhook.test.ts +390 -0
  173. package/tests/pipelines.test.ts +166 -0
  174. package/tests/projects.test.ts +283 -0
  175. package/tests/runs.test.ts +379 -0
  176. package/tests/server.test.ts +208 -0
  177. package/tests/skills.test.ts +149 -0
  178. package/tests/sse.test.ts +129 -0
  179. package/tests/tools.test.ts +233 -0
  180. package/tests/user-store.test.ts +93 -0
  181. package/tests/users.test.ts +189 -0
  182. package/tests/utils/repo-resolver.test.ts +105 -0
  183. package/tests/validate.test.ts +233 -0
  184. package/tests/webhook-dispatcher.test.ts +214 -0
  185. package/tests/webhook-store.test.ts +98 -0
  186. package/tests/webhooks.test.ts +176 -0
  187. package/tsconfig.json +24 -0
  188. 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,3 @@
1
+ // Re-exported from @studio/engine — implementation lives there
2
+ export { resolveRepoPath, cloneRepo } from '@studio-foundation/engine';
3
+ export type { RepoResolveOptions } from '@studio-foundation/engine';
@@ -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
+ }