@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
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { readdir, readFile, writeFile, unlink, mkdir, access } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import yaml from 'js-yaml';
|
|
4
|
+
import type { FastifyInstance } from 'fastify';
|
|
5
|
+
import type { ServerDeps } from '../server.js';
|
|
6
|
+
import {
|
|
7
|
+
BUILTIN_TOOL_NAMES,
|
|
8
|
+
listAvailableToolTemplates,
|
|
9
|
+
getBundledToolTemplate,
|
|
10
|
+
} from '@studio-foundation/runner';
|
|
11
|
+
|
|
12
|
+
function toolPath(configsDir: string, name: string): string {
|
|
13
|
+
return join(configsDir, 'tools', `${name}.tool.yaml`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function isCustomTool(configsDir: string, name: string): Promise<boolean> {
|
|
17
|
+
try {
|
|
18
|
+
await access(toolPath(configsDir, name));
|
|
19
|
+
return true;
|
|
20
|
+
} catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function toolsRoutes(
|
|
26
|
+
fastify: FastifyInstance,
|
|
27
|
+
options: { deps: ServerDeps }
|
|
28
|
+
): Promise<void> {
|
|
29
|
+
const { configsDir } = options.deps;
|
|
30
|
+
const errorSchema = { type: 'object', properties: { error: { type: 'string' } } };
|
|
31
|
+
|
|
32
|
+
// GET /api/tools — list builtins + custom
|
|
33
|
+
fastify.get('/tools', {
|
|
34
|
+
schema: {
|
|
35
|
+
tags: ['tools'],
|
|
36
|
+
summary: 'List all available tools (builtins + custom)',
|
|
37
|
+
response: {
|
|
38
|
+
200: {
|
|
39
|
+
type: 'object',
|
|
40
|
+
properties: {
|
|
41
|
+
tools: {
|
|
42
|
+
type: 'array',
|
|
43
|
+
items: {
|
|
44
|
+
type: 'object',
|
|
45
|
+
properties: {
|
|
46
|
+
name: { type: 'string' },
|
|
47
|
+
description: { type: 'string' },
|
|
48
|
+
is_builtin: { type: 'boolean' },
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
}, async (_request, reply) => {
|
|
57
|
+
const toolsList: { name: string; description: string; is_builtin: boolean }[] = [];
|
|
58
|
+
|
|
59
|
+
// Builtins from bundled templates (skip if overridden by a custom file)
|
|
60
|
+
const available = await listAvailableToolTemplates();
|
|
61
|
+
for (const t of available) {
|
|
62
|
+
const overridden = await isCustomTool(configsDir, t.name);
|
|
63
|
+
if (!overridden) {
|
|
64
|
+
toolsList.push({ name: t.name, description: t.description, is_builtin: true });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Custom tools from .studio/tools/
|
|
69
|
+
const toolsDir = join(configsDir, 'tools');
|
|
70
|
+
let entries: string[] = [];
|
|
71
|
+
try {
|
|
72
|
+
entries = await readdir(toolsDir);
|
|
73
|
+
} catch {
|
|
74
|
+
// dir doesn't exist — no custom tools
|
|
75
|
+
}
|
|
76
|
+
for (const file of entries.filter(f => f.endsWith('.tool.yaml'))) {
|
|
77
|
+
const name = file.replace('.tool.yaml', '');
|
|
78
|
+
const content = await readFile(join(toolsDir, file), 'utf-8');
|
|
79
|
+
const def = yaml.load(content) as { description?: string };
|
|
80
|
+
const isBuiltin = BUILTIN_TOOL_NAMES.has(name);
|
|
81
|
+
toolsList.push({ name, description: def.description ?? '', is_builtin: isBuiltin });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return reply.send({ tools: toolsList });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// GET /api/tools/:name — read a tool definition
|
|
88
|
+
fastify.get<{ Params: { name: string } }>('/tools/:name', {
|
|
89
|
+
schema: {
|
|
90
|
+
tags: ['tools'],
|
|
91
|
+
summary: 'Get a tool definition by name',
|
|
92
|
+
params: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] },
|
|
93
|
+
response: {
|
|
94
|
+
200: { type: 'object', additionalProperties: true },
|
|
95
|
+
404: errorSchema,
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
}, async (request, reply) => {
|
|
99
|
+
const { name } = request.params;
|
|
100
|
+
|
|
101
|
+
// Custom tool takes priority
|
|
102
|
+
if (await isCustomTool(configsDir, name)) {
|
|
103
|
+
const content = await readFile(toolPath(configsDir, name), 'utf-8');
|
|
104
|
+
const parsed = yaml.load(content) as Record<string, unknown>;
|
|
105
|
+
return reply.send({ ...parsed, is_builtin: BUILTIN_TOOL_NAMES.has(name) });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Fall back to bundled builtin template
|
|
109
|
+
const template = await getBundledToolTemplate(name);
|
|
110
|
+
if (template) {
|
|
111
|
+
const parsed = yaml.load(template) as Record<string, unknown>;
|
|
112
|
+
return reply.send({ ...parsed, is_builtin: true });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return reply.status(404).send({ error: `Tool '${name}' not found` });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// PUT /api/tools/:name — create or update a custom tool
|
|
119
|
+
fastify.put<{ Params: { name: string }; Body: unknown }>(
|
|
120
|
+
'/tools/:name',
|
|
121
|
+
{
|
|
122
|
+
schema: {
|
|
123
|
+
tags: ['tools'],
|
|
124
|
+
summary: 'Create or update a custom tool (YAML text or JSON body)',
|
|
125
|
+
params: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] },
|
|
126
|
+
body: { oneOf: [{ type: 'object', additionalProperties: true }, { type: 'string' }] },
|
|
127
|
+
response: {
|
|
128
|
+
200: { type: 'object', properties: { name: { type: 'string' } } },
|
|
129
|
+
400: errorSchema,
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
async (request, reply) => {
|
|
134
|
+
const { name } = request.params;
|
|
135
|
+
const contentType = request.headers['content-type'] ?? '';
|
|
136
|
+
|
|
137
|
+
let yamlContent: string;
|
|
138
|
+
if (contentType.includes('application/json')) {
|
|
139
|
+
yamlContent = yaml.dump(request.body);
|
|
140
|
+
} else {
|
|
141
|
+
yamlContent = request.body as string;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
yaml.load(yamlContent);
|
|
146
|
+
} catch (err) {
|
|
147
|
+
const message = err instanceof Error ? err.message : 'Invalid YAML';
|
|
148
|
+
return reply.status(400).send({ error: message });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
await mkdir(join(configsDir, 'tools'), { recursive: true });
|
|
152
|
+
await writeFile(toolPath(configsDir, name), yamlContent, 'utf-8');
|
|
153
|
+
return reply.send({ name });
|
|
154
|
+
}
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
// DELETE /api/tools/:name — delete a custom tool (403 for builtins not overridden)
|
|
158
|
+
fastify.delete<{ Params: { name: string } }>('/tools/:name', {
|
|
159
|
+
schema: {
|
|
160
|
+
tags: ['tools'],
|
|
161
|
+
summary: 'Delete a custom tool',
|
|
162
|
+
params: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] },
|
|
163
|
+
response: {
|
|
164
|
+
200: { type: 'object', properties: { deleted: { type: 'string' } } },
|
|
165
|
+
403: errorSchema,
|
|
166
|
+
404: errorSchema,
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
}, async (request, reply) => {
|
|
170
|
+
const { name } = request.params;
|
|
171
|
+
|
|
172
|
+
// 403 if builtin with no custom override file
|
|
173
|
+
if (BUILTIN_TOOL_NAMES.has(name) && !(await isCustomTool(configsDir, name))) {
|
|
174
|
+
return reply.status(403).send({ error: `Cannot delete builtin tool '${name}'` });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
await unlink(toolPath(configsDir, name));
|
|
179
|
+
} catch {
|
|
180
|
+
return reply.status(404).send({ error: `Tool '${name}' not found` });
|
|
181
|
+
}
|
|
182
|
+
return reply.send({ deleted: name });
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// POST /api/tools/install — install from the bundled registry
|
|
186
|
+
fastify.post<{ Body: { name: string } }>('/tools/install', {
|
|
187
|
+
schema: {
|
|
188
|
+
tags: ['tools'],
|
|
189
|
+
summary: 'Install a tool from the bundled registry',
|
|
190
|
+
body: {
|
|
191
|
+
type: 'object',
|
|
192
|
+
properties: { name: { type: 'string' } },
|
|
193
|
+
required: ['name'],
|
|
194
|
+
},
|
|
195
|
+
response: {
|
|
196
|
+
200: { type: 'object', properties: { installed: { type: 'string' } } },
|
|
197
|
+
404: errorSchema,
|
|
198
|
+
409: errorSchema,
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
}, async (request, reply) => {
|
|
202
|
+
const { name } = request.body;
|
|
203
|
+
|
|
204
|
+
const template = await getBundledToolTemplate(name);
|
|
205
|
+
if (!template) {
|
|
206
|
+
const available = await listAvailableToolTemplates();
|
|
207
|
+
return reply.status(404).send({
|
|
208
|
+
error: `Tool '${name}' not found in registry. Available: ${available.map(t => t.name).join(', ')}`,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const destPath = toolPath(configsDir, name);
|
|
213
|
+
const alreadyInstalled = await access(destPath).then(() => true).catch(() => false);
|
|
214
|
+
if (alreadyInstalled) {
|
|
215
|
+
return reply.status(409).send({ error: `Tool '${name}' is already installed` });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
await mkdir(join(configsDir, 'tools'), { recursive: true });
|
|
219
|
+
await writeFile(destPath, template, 'utf-8');
|
|
220
|
+
return reply.send({ installed: name });
|
|
221
|
+
});
|
|
222
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// api/src/routes/users.ts
|
|
2
|
+
import { randomUUID, randomBytes } from 'node:crypto';
|
|
3
|
+
import type { FastifyInstance } from 'fastify';
|
|
4
|
+
import type { ServerDeps } from '../server.js';
|
|
5
|
+
|
|
6
|
+
const errorSchema = { type: 'object', properties: { error: { type: 'string' } } };
|
|
7
|
+
|
|
8
|
+
const userSchema = {
|
|
9
|
+
type: 'object',
|
|
10
|
+
properties: {
|
|
11
|
+
id: { type: 'string' },
|
|
12
|
+
email: { type: 'string' },
|
|
13
|
+
plan: { type: 'string' },
|
|
14
|
+
created_at: { type: 'string' },
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const userWithKeySchema = {
|
|
19
|
+
...userSchema,
|
|
20
|
+
properties: { ...userSchema.properties, api_key: { type: 'string' } },
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const todayUsageSchema = {
|
|
24
|
+
type: 'object',
|
|
25
|
+
properties: {
|
|
26
|
+
runs_count: { type: 'number' },
|
|
27
|
+
tokens_used: { type: 'number' },
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const userWithUsageSchema = {
|
|
32
|
+
type: 'object',
|
|
33
|
+
properties: {
|
|
34
|
+
...userSchema.properties,
|
|
35
|
+
today_usage: todayUsageSchema,
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export async function usersRoutes(
|
|
40
|
+
fastify: FastifyInstance,
|
|
41
|
+
options: { deps: ServerDeps },
|
|
42
|
+
): Promise<void> {
|
|
43
|
+
const { userStore } = options.deps;
|
|
44
|
+
if (!userStore) return; // no-op if userStore not configured
|
|
45
|
+
|
|
46
|
+
// POST /api/users — create a user
|
|
47
|
+
fastify.post<{ Body: { email: string; plan?: string } }>('/users', {
|
|
48
|
+
schema: {
|
|
49
|
+
tags: ['users'],
|
|
50
|
+
summary: 'Create a new user',
|
|
51
|
+
body: {
|
|
52
|
+
type: 'object',
|
|
53
|
+
required: ['email'],
|
|
54
|
+
properties: {
|
|
55
|
+
email: { type: 'string', format: 'email' },
|
|
56
|
+
plan: { type: 'string', default: 'free' },
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
response: {
|
|
60
|
+
201: userWithKeySchema,
|
|
61
|
+
400: errorSchema,
|
|
62
|
+
409: errorSchema,
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
}, async (request, reply) => {
|
|
66
|
+
const { email, plan = 'free' } = request.body;
|
|
67
|
+
|
|
68
|
+
const apiKey = randomBytes(32).toString('hex');
|
|
69
|
+
const user = {
|
|
70
|
+
id: randomUUID(),
|
|
71
|
+
email,
|
|
72
|
+
plan,
|
|
73
|
+
api_key: apiKey,
|
|
74
|
+
created_at: new Date().toISOString(),
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
await userStore.saveUser(user);
|
|
79
|
+
} catch (err: unknown) {
|
|
80
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
81
|
+
if (msg.includes('UNIQUE constraint failed: users.email')) {
|
|
82
|
+
return reply.status(409).send({ error: 'Email already in use' });
|
|
83
|
+
}
|
|
84
|
+
throw err;
|
|
85
|
+
}
|
|
86
|
+
return reply.status(201).send(user);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// GET /api/users — list users (no api_key exposed)
|
|
90
|
+
fastify.get('/users', {
|
|
91
|
+
schema: {
|
|
92
|
+
tags: ['users'],
|
|
93
|
+
summary: 'List all users',
|
|
94
|
+
response: {
|
|
95
|
+
200: { type: 'array', items: userSchema },
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
}, async (_request, reply) => {
|
|
99
|
+
const users = await userStore.listUsers();
|
|
100
|
+
return reply.send(users.map(({ api_key: _k, ...u }) => u));
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// GET /api/users/me — current user + today usage
|
|
104
|
+
fastify.get('/users/me', {
|
|
105
|
+
schema: {
|
|
106
|
+
tags: ['users'],
|
|
107
|
+
summary: 'Get current authenticated user',
|
|
108
|
+
response: {
|
|
109
|
+
200: userWithUsageSchema,
|
|
110
|
+
401: errorSchema,
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
}, async (request, reply) => {
|
|
114
|
+
if (!request.user) return reply.status(401).send({ error: 'Unauthorized' });
|
|
115
|
+
|
|
116
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
117
|
+
const usage = await userStore.getDailyUsage(request.user.id, today);
|
|
118
|
+
const { api_key: _k, ...userWithoutKey } = request.user;
|
|
119
|
+
|
|
120
|
+
return reply.send({
|
|
121
|
+
...userWithoutKey,
|
|
122
|
+
today_usage: { runs_count: usage.runs_count, tokens_used: usage.tokens_used },
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// GET /api/users/:id — user detail + today usage
|
|
127
|
+
fastify.get<{ Params: { id: string } }>('/users/:id', {
|
|
128
|
+
schema: {
|
|
129
|
+
tags: ['users'],
|
|
130
|
+
summary: 'Get a user by ID',
|
|
131
|
+
params: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
|
|
132
|
+
response: {
|
|
133
|
+
200: userWithUsageSchema,
|
|
134
|
+
404: errorSchema,
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
}, async (request, reply) => {
|
|
138
|
+
const user = await userStore.getUserById(request.params.id);
|
|
139
|
+
if (!user) return reply.status(404).send({ error: 'User not found' });
|
|
140
|
+
|
|
141
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
142
|
+
const usage = await userStore.getDailyUsage(user.id, today);
|
|
143
|
+
const { api_key: _k, ...userWithoutKey } = user;
|
|
144
|
+
|
|
145
|
+
return reply.send({
|
|
146
|
+
...userWithoutKey,
|
|
147
|
+
today_usage: { runs_count: usage.runs_count, tokens_used: usage.tokens_used },
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// DELETE /api/users/:id
|
|
152
|
+
fastify.delete<{ Params: { id: string } }>('/users/:id', {
|
|
153
|
+
schema: {
|
|
154
|
+
tags: ['users'],
|
|
155
|
+
summary: 'Delete a user',
|
|
156
|
+
params: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
|
|
157
|
+
response: {
|
|
158
|
+
204: { type: 'null', description: 'No content' },
|
|
159
|
+
404: errorSchema,
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
}, async (request, reply) => {
|
|
163
|
+
const user = await userStore.getUserById(request.params.id);
|
|
164
|
+
if (!user) return reply.status(404).send({ error: 'User not found' });
|
|
165
|
+
|
|
166
|
+
await userStore.deleteUser(request.params.id);
|
|
167
|
+
return reply.status(204).send();
|
|
168
|
+
});
|
|
169
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { readdir } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import yaml from 'js-yaml';
|
|
4
|
+
import type { FastifyInstance } from 'fastify';
|
|
5
|
+
import type { ServerDeps } from '../server.js';
|
|
6
|
+
import {
|
|
7
|
+
createRepoManagerTools,
|
|
8
|
+
createShellTools,
|
|
9
|
+
createSearchTools,
|
|
10
|
+
createPatchTools,
|
|
11
|
+
createGitTools,
|
|
12
|
+
} from '@studio-foundation/runner';
|
|
13
|
+
|
|
14
|
+
// Derived at module load from the actual factory functions — single source of truth.
|
|
15
|
+
// Using '.' as a dummy repoPath: only `execute` uses the path, `name` is hardcoded.
|
|
16
|
+
const BUILTIN_TOOL_ACTIONS: Set<string> = new Set([
|
|
17
|
+
...createRepoManagerTools('.').map(t => t.name),
|
|
18
|
+
...createShellTools('.').map(t => t.name),
|
|
19
|
+
...createSearchTools('.').map(t => t.name),
|
|
20
|
+
...createPatchTools('.').map(t => t.name),
|
|
21
|
+
...createGitTools('.').map(t => t.name),
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
async function listNames(dir: string, suffix: string): Promise<Set<string>> {
|
|
25
|
+
try {
|
|
26
|
+
const entries = await readdir(dir);
|
|
27
|
+
return new Set(
|
|
28
|
+
entries
|
|
29
|
+
.filter(f => f.endsWith(suffix))
|
|
30
|
+
.map(f => f.slice(0, -suffix.length))
|
|
31
|
+
);
|
|
32
|
+
} catch {
|
|
33
|
+
return new Set();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Read tool plugin YAMLs and return the `name` field from each.
|
|
39
|
+
* Falls back to the filename stem if the YAML cannot be parsed.
|
|
40
|
+
*/
|
|
41
|
+
async function listToolPluginNames(dir: string): Promise<Set<string>> {
|
|
42
|
+
try {
|
|
43
|
+
const { readFile } = await import('node:fs/promises');
|
|
44
|
+
const entries = await readdir(dir);
|
|
45
|
+
const names = new Set<string>();
|
|
46
|
+
await Promise.all(
|
|
47
|
+
entries
|
|
48
|
+
.filter(f => f.endsWith('.tool.yaml'))
|
|
49
|
+
.map(async (f) => {
|
|
50
|
+
try {
|
|
51
|
+
const raw = await readFile(join(dir, f), 'utf-8');
|
|
52
|
+
const parsed = yaml.load(raw) as Record<string, unknown> | null;
|
|
53
|
+
const name = parsed && typeof parsed.name === 'string'
|
|
54
|
+
? parsed.name
|
|
55
|
+
: f.slice(0, -'.tool.yaml'.length);
|
|
56
|
+
names.add(name);
|
|
57
|
+
} catch {
|
|
58
|
+
names.add(f.slice(0, -'.tool.yaml'.length));
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
);
|
|
62
|
+
return names;
|
|
63
|
+
} catch {
|
|
64
|
+
return new Set();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
type StageRef = { agents: string[]; contracts: string[] };
|
|
69
|
+
|
|
70
|
+
function collectStageRefs(stage: Record<string, unknown>): StageRef {
|
|
71
|
+
const agents: string[] = [];
|
|
72
|
+
const contracts: string[] = [];
|
|
73
|
+
if (typeof stage.agent === 'string') agents.push(stage.agent);
|
|
74
|
+
if (typeof stage.contract === 'string') contracts.push(stage.contract);
|
|
75
|
+
// Recurse into group stages
|
|
76
|
+
if (Array.isArray(stage.stages)) {
|
|
77
|
+
for (const s of stage.stages) {
|
|
78
|
+
if (s && typeof s === 'object' && !Array.isArray(s)) {
|
|
79
|
+
const inner = collectStageRefs(s as Record<string, unknown>);
|
|
80
|
+
agents.push(...inner.agents);
|
|
81
|
+
contracts.push(...inner.contracts);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return { agents, contracts };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function validateRoutes(
|
|
89
|
+
fastify: FastifyInstance,
|
|
90
|
+
options: { deps: ServerDeps }
|
|
91
|
+
): Promise<void> {
|
|
92
|
+
const { configsDir } = options.deps;
|
|
93
|
+
|
|
94
|
+
const errorSchema = { type: 'object', properties: { error: { type: 'string' } } };
|
|
95
|
+
|
|
96
|
+
fastify.post<{
|
|
97
|
+
Body: { type: string; name?: string; content: string };
|
|
98
|
+
}>('/validate', {
|
|
99
|
+
schema: {
|
|
100
|
+
tags: ['validate'],
|
|
101
|
+
summary: 'Validate a Studio config for structural coherence',
|
|
102
|
+
body: {
|
|
103
|
+
type: 'object',
|
|
104
|
+
required: ['type', 'content'],
|
|
105
|
+
properties: {
|
|
106
|
+
type: {
|
|
107
|
+
type: 'string',
|
|
108
|
+
enum: ['pipeline', 'contract', 'agent', 'tool', 'skill'],
|
|
109
|
+
},
|
|
110
|
+
name: { type: 'string' },
|
|
111
|
+
content: { type: 'string' },
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
response: {
|
|
115
|
+
200: {
|
|
116
|
+
type: 'object',
|
|
117
|
+
properties: {
|
|
118
|
+
valid: { type: 'boolean' },
|
|
119
|
+
errors: { type: 'array', items: { type: 'string' } },
|
|
120
|
+
},
|
|
121
|
+
required: ['valid', 'errors'],
|
|
122
|
+
},
|
|
123
|
+
400: errorSchema,
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
}, async (request, reply) => {
|
|
127
|
+
const { type, content } = request.body;
|
|
128
|
+
const errors: string[] = [];
|
|
129
|
+
|
|
130
|
+
// Skills are markdown — just check non-empty
|
|
131
|
+
if (type === 'skill') {
|
|
132
|
+
if (!content.trim()) errors.push('Skill content is empty');
|
|
133
|
+
return reply.send({ valid: errors.length === 0, errors });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Parse YAML
|
|
137
|
+
let parsed: unknown;
|
|
138
|
+
try {
|
|
139
|
+
parsed = yaml.load(content);
|
|
140
|
+
} catch (e) {
|
|
141
|
+
errors.push(`YAML parse error: ${e instanceof Error ? e.message : String(e)}`);
|
|
142
|
+
return reply.send({ valid: false, errors });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
146
|
+
errors.push('Content must be a YAML object');
|
|
147
|
+
return reply.send({ valid: false, errors });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const obj = parsed as Record<string, unknown>;
|
|
151
|
+
|
|
152
|
+
if (type === 'pipeline') {
|
|
153
|
+
const stages = obj.stages;
|
|
154
|
+
if (!Array.isArray(stages)) {
|
|
155
|
+
errors.push('Pipeline must have a "stages" array');
|
|
156
|
+
return reply.send({ valid: false, errors });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const [agentNames, contractNames] = await Promise.all([
|
|
160
|
+
listNames(join(configsDir, 'agents'), '.agent.yaml'),
|
|
161
|
+
listNames(join(configsDir, 'contracts'), '.contract.yaml'),
|
|
162
|
+
]);
|
|
163
|
+
|
|
164
|
+
for (const stage of stages) {
|
|
165
|
+
if (!stage || typeof stage !== 'object' || Array.isArray(stage)) continue;
|
|
166
|
+
const { agents: refs_a, contracts: refs_c } = collectStageRefs(
|
|
167
|
+
stage as Record<string, unknown>
|
|
168
|
+
);
|
|
169
|
+
for (const agent of refs_a) {
|
|
170
|
+
if (!agentNames.has(agent)) errors.push(`Agent '${agent}' not found`);
|
|
171
|
+
}
|
|
172
|
+
for (const contract of refs_c) {
|
|
173
|
+
if (!contractNames.has(contract)) errors.push(`Contract '${contract}' not found`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (type === 'agent') {
|
|
179
|
+
const toolsUsed = obj.tools;
|
|
180
|
+
if (Array.isArray(toolsUsed)) {
|
|
181
|
+
const customPlugins = await listToolPluginNames(join(configsDir, 'tools'));
|
|
182
|
+
for (const tool of toolsUsed) {
|
|
183
|
+
if (typeof tool !== 'string') continue;
|
|
184
|
+
// Custom tool actions follow <plugin>-<action> naming (e.g. claude_code-run
|
|
185
|
+
// from claude_code.tool.yaml). Match if any plugin name is a prefix.
|
|
186
|
+
const isCustom = [...customPlugins].some(p => tool === p || tool.startsWith(p + '-'));
|
|
187
|
+
if (!BUILTIN_TOOL_ACTIONS.has(tool) && !isCustom) {
|
|
188
|
+
errors.push(`Tool '${tool}' not found`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return reply.send({ valid: errors.length === 0, errors });
|
|
195
|
+
});
|
|
196
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import type { FastifyInstance } from 'fastify';
|
|
3
|
+
import type { ServerDeps } from '../server.js';
|
|
4
|
+
|
|
5
|
+
const VALID_EVENTS = [
|
|
6
|
+
'pipeline_start',
|
|
7
|
+
'pipeline_complete',
|
|
8
|
+
'stage_complete',
|
|
9
|
+
'stage_rejected',
|
|
10
|
+
'stage_failed',
|
|
11
|
+
'group_feedback',
|
|
12
|
+
] as const;
|
|
13
|
+
|
|
14
|
+
const errorSchema = { type: 'object', properties: { error: { type: 'string' } } };
|
|
15
|
+
|
|
16
|
+
const webhookSchema = {
|
|
17
|
+
type: 'object',
|
|
18
|
+
properties: {
|
|
19
|
+
id: { type: 'string' },
|
|
20
|
+
url: { type: 'string' },
|
|
21
|
+
events: { type: 'array', items: { type: 'string' } },
|
|
22
|
+
status: { type: 'string', enum: ['active', 'failed'] },
|
|
23
|
+
created_at: { type: 'string' },
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export async function webhooksRoutes(
|
|
28
|
+
fastify: FastifyInstance,
|
|
29
|
+
options: { deps: ServerDeps },
|
|
30
|
+
): Promise<void> {
|
|
31
|
+
const { webhookStore } = options.deps;
|
|
32
|
+
|
|
33
|
+
// POST /api/webhooks
|
|
34
|
+
fastify.post<{
|
|
35
|
+
Body: { url: string; events: string[]; secret?: string };
|
|
36
|
+
}>('/webhooks', {
|
|
37
|
+
schema: {
|
|
38
|
+
tags: ['webhooks'],
|
|
39
|
+
summary: 'Register a new webhook',
|
|
40
|
+
body: {
|
|
41
|
+
type: 'object',
|
|
42
|
+
required: ['url', 'events'],
|
|
43
|
+
properties: {
|
|
44
|
+
url: { type: 'string' },
|
|
45
|
+
events: { type: 'array', items: { type: 'string' }, minItems: 1 },
|
|
46
|
+
secret: { type: 'string' },
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
response: {
|
|
50
|
+
201: webhookSchema,
|
|
51
|
+
400: errorSchema,
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
}, async (request, reply) => {
|
|
55
|
+
const { url, events, secret } = request.body;
|
|
56
|
+
|
|
57
|
+
if (!events || events.length === 0) {
|
|
58
|
+
return reply.status(400).send({ error: 'events must not be empty' });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const id = randomUUID();
|
|
62
|
+
const created_at = new Date().toISOString();
|
|
63
|
+
|
|
64
|
+
webhookStore.saveWebhook({
|
|
65
|
+
id,
|
|
66
|
+
url,
|
|
67
|
+
events,
|
|
68
|
+
...(secret != null ? { secret } : {}),
|
|
69
|
+
status: 'active',
|
|
70
|
+
created_at,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
return reply.status(201).send({ id, url, events, status: 'active', created_at });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// GET /api/webhooks
|
|
77
|
+
fastify.get('/webhooks', {
|
|
78
|
+
schema: {
|
|
79
|
+
tags: ['webhooks'],
|
|
80
|
+
summary: 'List all registered webhooks',
|
|
81
|
+
response: {
|
|
82
|
+
200: {
|
|
83
|
+
type: 'object',
|
|
84
|
+
properties: {
|
|
85
|
+
webhooks: { type: 'array', items: webhookSchema },
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
}, async (_request, reply) => {
|
|
91
|
+
const webhooks = webhookStore.listWebhooks().map(({ secret: _secret, ...w }) => w);
|
|
92
|
+
return reply.send({ webhooks });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// DELETE /api/webhooks/:id
|
|
96
|
+
fastify.delete<{ Params: { id: string } }>('/webhooks/:id', {
|
|
97
|
+
schema: {
|
|
98
|
+
tags: ['webhooks'],
|
|
99
|
+
summary: 'Delete a webhook',
|
|
100
|
+
params: {
|
|
101
|
+
type: 'object',
|
|
102
|
+
properties: { id: { type: 'string' } },
|
|
103
|
+
required: ['id'],
|
|
104
|
+
},
|
|
105
|
+
response: {
|
|
106
|
+
204: { type: 'null', description: 'No content' },
|
|
107
|
+
404: errorSchema,
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
}, async (request, reply) => {
|
|
111
|
+
const deleted = webhookStore.deleteWebhook(request.params.id);
|
|
112
|
+
if (!deleted) {
|
|
113
|
+
return reply.status(404).send({ error: 'Webhook not found' });
|
|
114
|
+
}
|
|
115
|
+
return reply.status(204).send();
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Export VALID_EVENTS for documentation/validation use
|
|
120
|
+
export { VALID_EVENTS };
|