@mevdragon/vidfarm-devcli 0.1.0 → 0.2.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 (69) hide show
  1. package/.env.example +12 -5
  2. package/PLATFORM_SPEC.md +143 -2
  3. package/README.md +165 -16
  4. package/SKILL.developer.md +258 -0
  5. package/SKILL.director.md +599 -0
  6. package/dist/infra/cdk/bin/vidfarm-prod.js +59 -0
  7. package/dist/infra/cdk/lib/vidfarm-prod-stack.js +212 -0
  8. package/dist/src/account-pages.js +630 -0
  9. package/dist/src/app.js +897 -66
  10. package/dist/src/cli.js +284 -5
  11. package/dist/src/config.js +25 -5
  12. package/dist/src/context.js +1 -1
  13. package/dist/src/db.js +427 -18
  14. package/dist/src/dev-app.js +59 -12
  15. package/dist/src/homepage.js +441 -0
  16. package/dist/src/index.js +12 -7
  17. package/dist/src/lib/crypto.js +14 -0
  18. package/dist/src/lib/template-dna.js +542 -0
  19. package/dist/src/lib/template-style-options.js +49 -0
  20. package/dist/src/registry.js +54 -7
  21. package/dist/src/runtime.js +3 -1
  22. package/dist/src/services/auth.js +69 -5
  23. package/dist/src/services/jobs.js +23 -4
  24. package/dist/src/services/providers.js +74 -12
  25. package/dist/src/services/storage.js +74 -18
  26. package/dist/src/services/template-certification.js +160 -0
  27. package/dist/src/services/template-loader.js +37 -0
  28. package/dist/src/services/template-sources.js +135 -0
  29. package/dist/src/worker.js +19 -7
  30. package/dist/templates/template_0000/src/lib/images.js +242 -0
  31. package/dist/templates/template_0000/src/remotion/Root.js +33 -0
  32. package/dist/templates/template_0000/src/sdk.js +3 -0
  33. package/dist/templates/template_0000/src/style-options.js +51 -0
  34. package/dist/templates/template_0000/src/template-dna.js +9 -0
  35. package/dist/templates/template_0000/src/template.js +1217 -0
  36. package/package.json +10 -1
  37. package/templates/template_0000/README.md +121 -0
  38. package/templates/template_0000/SKILL.md +193 -0
  39. package/templates/template_0000/assets/Abel-Regular.ttf +0 -0
  40. package/templates/template_0000/assets/DMSerifDisplay-Regular.ttf +0 -0
  41. package/templates/template_0000/assets/Montserrat[wght].ttf +0 -0
  42. package/templates/template_0000/assets/SourceCodePro[wght].ttf +0 -0
  43. package/templates/template_0000/assets/TikTokSans-SemiBold.ttf +0 -0
  44. package/templates/template_0000/assets/Yesteryear-Regular.ttf +0 -0
  45. package/templates/template_0000/composition.json +11 -0
  46. package/templates/template_0000/package-lock.json +5505 -0
  47. package/templates/template_0000/package.json +31 -0
  48. package/templates/template_0000/research/preview/.gitkeep +1 -0
  49. package/templates/template_0000/research/source_notes.md +7 -0
  50. package/templates/template_0000/scripts/create-site.mjs +27 -0
  51. package/templates/template_0000/scripts/render-cloud.mjs +72 -0
  52. package/templates/template_0000/src/lib/images.js +242 -0
  53. package/templates/template_0000/src/lib/images.ts +284 -0
  54. package/templates/template_0000/src/remotion/Root.js +33 -0
  55. package/templates/template_0000/src/remotion/Root.tsx +75 -0
  56. package/templates/template_0000/src/remotion/index.js +3 -0
  57. package/templates/template_0000/src/remotion/index.tsx +4 -0
  58. package/templates/template_0000/src/sdk.js +3 -0
  59. package/templates/template_0000/src/sdk.ts +122 -0
  60. package/templates/template_0000/src/style-options.js +51 -0
  61. package/templates/template_0000/src/style-options.ts +60 -0
  62. package/templates/template_0000/src/template-dna.ts +15 -0
  63. package/templates/template_0000/src/template.js +1117 -0
  64. package/templates/template_0000/src/template.ts +1747 -0
  65. package/templates/template_0000/template.config.json +26 -0
  66. package/templates/template_0000/tsconfig.json +19 -0
  67. package/dist/templates/template_0000/demo-template.js +0 -196
  68. package/dist/templates/template_0000/remotion/Root.js +0 -66
  69. /package/dist/templates/template_0000/{remotion → src/remotion}/index.js +0 -0
package/dist/src/app.js CHANGED
@@ -1,19 +1,27 @@
1
- import { readFileSync } from "node:fs";
1
+ import { readFileSync, statSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { Hono } from "hono";
4
4
  import { z } from "zod";
5
+ import { renderLoginPage, renderPricingPage, renderSettingsPage } from "./account-pages.js";
5
6
  import { config } from "./config.js";
6
7
  import { database } from "./db.js";
7
8
  import { renderDevApp } from "./dev-app.js";
8
- import { encryptString } from "./lib/crypto.js";
9
+ import { renderHomepage } from "./homepage.js";
10
+ import { decryptString, encryptString, hashSecret } from "./lib/crypto.js";
9
11
  import { createId } from "./lib/ids.js";
10
12
  import { templateRegistry } from "./registry.js";
11
13
  import { AuthService } from "./services/auth.js";
12
14
  import { JobsService } from "./services/jobs.js";
13
15
  import { StorageService } from "./services/storage.js";
16
+ import { TemplateSourceService } from "./services/template-sources.js";
14
17
  const auth = new AuthService();
15
18
  const jobs = new JobsService();
16
19
  const storage = new StorageService();
20
+ const templateSources = new TemplateSourceService();
21
+ const API_PREFIX = "/api/v1";
22
+ const USER_PREFIX = `${API_PREFIX}/user`;
23
+ const TEMPLATES_PREFIX = `${API_PREFIX}/templates`;
24
+ const SESSION_COOKIE = "vidfarm_session";
17
25
  const app = new Hono();
18
26
  const otpRequestSchema = z.object({
19
27
  email: z.string().email()
@@ -23,42 +31,530 @@ const otpVerifySchema = z.object({
23
31
  code: z.string().min(6).max(6),
24
32
  name: z.string().optional()
25
33
  });
34
+ const passwordLoginSchema = z.object({
35
+ email: z.string().email(),
36
+ password: z.string().min(1)
37
+ });
38
+ const adminWhitelistSchema = z.object({
39
+ emails: z.array(z.string().email()).min(1)
40
+ });
41
+ const adminCreateUserSchema = z.object({
42
+ email: z.string().email(),
43
+ password: z.string().min(8),
44
+ name: z.string().optional()
45
+ });
26
46
  const providerKeySchema = z.object({
27
47
  provider: z.enum(["openai", "gemini", "openrouter", "perplexity"]),
28
48
  label: z.string().optional(),
29
49
  secret: z.string().min(8),
30
50
  weight: z.number().int().min(1).max(100).default(1)
31
51
  });
32
- const workspacePresignSchema = z.object({
33
- path: z.string().min(1),
34
- contentType: z.string().min(3).default("application/octet-stream")
52
+ const settingsProviderKeyFormSchema = z.object({
53
+ provider: z.enum(["openai", "gemini", "openrouter", "perplexity"]),
54
+ label: z.string().trim().optional(),
55
+ secret: z.string().min(8),
56
+ weight: z.coerce.number().int().min(1).max(100).default(1)
57
+ });
58
+ const settingsProfileFormSchema = z.object({
59
+ about: z.string().max(10000).optional(),
60
+ groupchat_url: z.union([z.literal(""), z.string().url()]).optional(),
61
+ flockposter_api_key: z.string().max(1000).optional()
35
62
  });
63
+ const listJobsQuerySchema = z.object({
64
+ tracer: z.string().min(1).optional(),
65
+ start_time: z.string().min(1).optional(),
66
+ end_time: z.string().min(1).optional(),
67
+ limit: z.coerce.number().int().min(1).max(500).optional(),
68
+ template_id: z.string().min(1).optional()
69
+ });
70
+ function getLoginMode(value) {
71
+ return value === "password" ? "password" : "otp";
72
+ }
73
+ function readRootSkillFile(...filenames) {
74
+ for (const filename of filenames) {
75
+ try {
76
+ return readFileSync(path.resolve(filename), "utf8");
77
+ }
78
+ catch {
79
+ continue;
80
+ }
81
+ }
82
+ return "";
83
+ }
84
+ const allowedAttachmentExtensions = new Set([
85
+ ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg",
86
+ ".mp4", ".mov", ".webm", ".m4v",
87
+ ".mp3", ".wav", ".m4a", ".aac", ".ogg",
88
+ ".pdf", ".md", ".txt"
89
+ ]);
36
90
  app.use("*", async (c, next) => {
37
91
  c.header("x-powered-by", "vidfarm");
92
+ await templateRegistry.ensureInitialized();
38
93
  await next();
39
94
  });
40
- app.get("/", (c) => c.json({ name: "vidfarm", version: "0.1.0", environment: config.NODE_ENV }));
41
- app.get("/health", (c) => c.json({ ok: true, time: new Date().toISOString() }));
42
- app.get("/dev", (c) => c.html(renderDevApp({
43
- environment: config.NODE_ENV,
44
- templates: templateRegistry.list().map((template) => ({
45
- id: template.id,
46
- version: template.version,
47
- description: template.description,
48
- operations: Object.entries(template.operations).map(([name, operation]) => ({
49
- name,
50
- description: operation.description,
51
- workflow: operation.workflow,
52
- providerHint: operation.providerHint ?? null
95
+ function renderConsole(c) {
96
+ return c.html(renderDevApp({
97
+ environment: config.NODE_ENV,
98
+ templates: templateRegistry.list().map((template) => ({
99
+ id: template.id,
100
+ slug_id: template.slugId,
101
+ version: template.version,
102
+ description: template.about.description,
103
+ operations: Object.entries(template.operations).map(([name, operation]) => ({
104
+ name,
105
+ description: operation.description,
106
+ workflow: operation.workflow,
107
+ providerHint: operation.providerHint ?? null
108
+ }))
53
109
  }))
54
- }))
55
- })));
56
- app.post("/auth/request-otp", async (c) => {
110
+ }));
111
+ }
112
+ function parseCookieHeader(value) {
113
+ const cookies = new Map();
114
+ if (!value) {
115
+ return cookies;
116
+ }
117
+ for (const part of value.split(";")) {
118
+ const trimmed = part.trim();
119
+ if (!trimmed) {
120
+ continue;
121
+ }
122
+ const separator = trimmed.indexOf("=");
123
+ if (separator < 0) {
124
+ continue;
125
+ }
126
+ cookies.set(trimmed.slice(0, separator), decodeURIComponent(trimmed.slice(separator + 1)));
127
+ }
128
+ return cookies;
129
+ }
130
+ function appendCookie(c, value) {
131
+ c.header("set-cookie", value, { append: true });
132
+ }
133
+ function setBrowserSession(c, customer) {
134
+ const payload = encryptString(JSON.stringify({
135
+ customerId: customer.id,
136
+ email: customer.email,
137
+ issuedAt: new Date().toISOString()
138
+ }), config.ENCRYPTION_SECRET);
139
+ appendCookie(c, `${SESSION_COOKIE}=${encodeURIComponent(payload)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000${config.isProduction ? "; Secure" : ""}`);
140
+ }
141
+ function clearBrowserSession(c) {
142
+ appendCookie(c, `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${config.isProduction ? "; Secure" : ""}`);
143
+ }
144
+ function getBrowserCustomer(c) {
145
+ try {
146
+ const cookies = parseCookieHeader(c.req.header("cookie"));
147
+ const raw = cookies.get(SESSION_COOKIE);
148
+ if (!raw) {
149
+ return null;
150
+ }
151
+ const session = JSON.parse(decryptString(raw, config.ENCRYPTION_SECRET));
152
+ if (!session.customerId) {
153
+ return null;
154
+ }
155
+ return database.getCustomerById(session.customerId);
156
+ }
157
+ catch {
158
+ return null;
159
+ }
160
+ }
161
+ function requireBrowserCustomer(c) {
162
+ const customer = getBrowserCustomer(c);
163
+ if (!customer?.isPaidPlan) {
164
+ return null;
165
+ }
166
+ return customer;
167
+ }
168
+ function redirect(c, location, status = 302) {
169
+ return c.redirect(location, status);
170
+ }
171
+ function redirectToSettings(c, input) {
172
+ const params = new URLSearchParams();
173
+ if (input?.notice) {
174
+ params.set("notice", input.notice);
175
+ }
176
+ if (input?.error) {
177
+ params.set("error", input.error);
178
+ }
179
+ const suffix = params.size ? `?${params.toString()}` : "";
180
+ return redirect(c, `/settings${suffix}`, 303);
181
+ }
182
+ function sanitizeFileName(value) {
183
+ const normalized = path.basename(value).replace(/[^\w.-]+/g, "_");
184
+ return normalized.length ? normalized : "upload.bin";
185
+ }
186
+ function isAllowedAttachment(fileName, contentType) {
187
+ const extension = path.extname(fileName).toLowerCase();
188
+ if (allowedAttachmentExtensions.has(extension)) {
189
+ return true;
190
+ }
191
+ return contentType.startsWith("image/")
192
+ || contentType.startsWith("video/")
193
+ || contentType.startsWith("audio/")
194
+ || contentType === "application/pdf"
195
+ || contentType === "text/plain"
196
+ || contentType === "text/markdown";
197
+ }
198
+ function isFormFile(value) {
199
+ return Boolean(value
200
+ && typeof value === "object"
201
+ && "name" in value
202
+ && "arrayBuffer" in value);
203
+ }
204
+ function getVisibleApiKey(customerId) {
205
+ const existing = database.getLatestApiKeyForCustomer(customerId);
206
+ const rawValue = existing?.raw_value ? String(existing.raw_value) : null;
207
+ if (rawValue) {
208
+ return rawValue;
209
+ }
210
+ const apiKey = `vf_${createId("key")}`;
211
+ database.insertApiKey({
212
+ id: createId("api"),
213
+ customerId,
214
+ keyHash: hashSecret(apiKey + config.API_KEY_SALT),
215
+ rawValue: apiKey,
216
+ label: "Settings API key"
217
+ });
218
+ return apiKey;
219
+ }
220
+ function readStoredSecret(value) {
221
+ try {
222
+ return decryptString(value, config.ENCRYPTION_SECRET);
223
+ }
224
+ catch {
225
+ return value;
226
+ }
227
+ }
228
+ function getReleaseTimestamp(release) {
229
+ return release.activatedAt ?? release.updatedAt ?? release.createdAt;
230
+ }
231
+ function getApprovedHomepageTemplates(c) {
232
+ const approvedReleaseByTemplateId = new Map();
233
+ for (const release of database.listTemplateReleases()) {
234
+ if (release.status !== "active" && release.status !== "certified") {
235
+ continue;
236
+ }
237
+ const current = approvedReleaseByTemplateId.get(release.templateId);
238
+ if (!current || getReleaseTimestamp(release) > getReleaseTimestamp(current)) {
239
+ approvedReleaseByTemplateId.set(release.templateId, release);
240
+ }
241
+ }
242
+ return templateRegistry.list()
243
+ .map((template) => {
244
+ const release = approvedReleaseByTemplateId.get(template.id);
245
+ const approvedAt = release?.activatedAt
246
+ ?? release?.updatedAt
247
+ ?? release?.createdAt
248
+ ?? (template.skillPath
249
+ ? (() => {
250
+ try {
251
+ return statSync(template.skillPath).mtime.toISOString();
252
+ }
253
+ catch {
254
+ return null;
255
+ }
256
+ })()
257
+ : null);
258
+ const skillContent = template.skillPath
259
+ ? (() => {
260
+ try {
261
+ return readFileSync(template.skillPath, "utf8");
262
+ }
263
+ catch {
264
+ return "";
265
+ }
266
+ })()
267
+ : "";
268
+ return {
269
+ title: template.about.title,
270
+ templateId: template.id,
271
+ slugId: template.slugId,
272
+ viralDna: template.about.viral_dna,
273
+ visualDna: template.about.visual_dna,
274
+ previewUrl: template.about.preview_media[0]
275
+ ? resolveHomepagePreviewUrl(c, template.id, template.about.preview_media[0])
276
+ : null,
277
+ skillContent,
278
+ approvedAt
279
+ };
280
+ })
281
+ .sort((a, b) => {
282
+ const aTime = a.approvedAt ? Date.parse(a.approvedAt) : 0;
283
+ const bTime = b.approvedAt ? Date.parse(b.approvedAt) : 0;
284
+ return bTime - aTime || a.title.localeCompare(b.title);
285
+ });
286
+ }
287
+ function renderApprovedHomepage(c) {
288
+ return c.html(renderHomepage({
289
+ templates: getApprovedHomepageTemplates(c),
290
+ account: {
291
+ isLoggedIn: Boolean(getBrowserCustomer(c))
292
+ }
293
+ }));
294
+ }
295
+ app.get("/", (c) => renderApprovedHomepage(c));
296
+ app.get("/health", (c) => c.json({ ok: true, time: new Date().toISOString() }));
297
+ app.get("/dev", (c) => renderConsole(c));
298
+ app.get("/template-media", async (c) => {
299
+ const key = c.req.query("key") ?? "";
300
+ return serveStorageKeyAsset(c, key);
301
+ });
302
+ app.get(API_PREFIX, (c) => c.json({ name: "vidfarm", version: "0.1.0", environment: config.NODE_ENV }));
303
+ app.get("/login", (c) => {
304
+ const customer = getBrowserCustomer(c);
305
+ if (customer?.isPaidPlan) {
306
+ return redirect(c, "/settings");
307
+ }
308
+ return c.html(renderLoginPage({ mode: getLoginMode(c.req.query("mode")) }));
309
+ });
310
+ app.post("/login/password", async (c) => {
311
+ const mode = getLoginMode(c.req.query("mode"));
312
+ const parsed = passwordLoginSchema.safeParse(await parseFormBody(c));
313
+ if (!parsed.success) {
314
+ return c.html(renderLoginPage({ mode, error: "Enter a valid email and password." }), 400);
315
+ }
316
+ try {
317
+ const result = auth.authenticateWithPassword(parsed.data.email, parsed.data.password);
318
+ if (result.status === "pricing") {
319
+ return c.html(renderPricingPage({ email: result.customer.email }));
320
+ }
321
+ setBrowserSession(c, result.customer);
322
+ return redirect(c, "/settings");
323
+ }
324
+ catch (error) {
325
+ return c.html(renderLoginPage({
326
+ mode,
327
+ email: parsed.data.email,
328
+ error: error instanceof Error ? error.message : "Unable to login."
329
+ }), 401);
330
+ }
331
+ });
332
+ app.post("/login/otp/request", async (c) => {
333
+ const mode = getLoginMode(c.req.query("mode"));
334
+ const parsed = otpRequestSchema.safeParse(await parseFormBody(c));
335
+ if (!parsed.success) {
336
+ return c.html(renderLoginPage({ mode, error: "Enter a valid email address." }), 400);
337
+ }
338
+ await auth.requestOtp(parsed.data.email);
339
+ return c.html(renderLoginPage({
340
+ mode,
341
+ email: parsed.data.email,
342
+ otpSent: true,
343
+ message: "OTP sent. Enter the code to continue."
344
+ }));
345
+ });
346
+ app.post("/login/otp/verify", async (c) => {
347
+ const mode = getLoginMode(c.req.query("mode"));
348
+ const parsed = otpVerifySchema.safeParse(await parseFormBody(c));
349
+ if (!parsed.success) {
350
+ return c.html(renderLoginPage({ mode, error: "Enter the email and 6-digit OTP." }), 400);
351
+ }
352
+ try {
353
+ const result = auth.verifyOtpForBrowserLogin(parsed.data.email, parsed.data.code, parsed.data.name);
354
+ if (result.status === "pricing") {
355
+ clearBrowserSession(c);
356
+ return c.html(renderPricingPage({ email: result.customer.email }));
357
+ }
358
+ setBrowserSession(c, result.customer);
359
+ return redirect(c, "/settings");
360
+ }
361
+ catch (error) {
362
+ return c.html(renderLoginPage({
363
+ mode,
364
+ email: parsed.data.email,
365
+ otpSent: true,
366
+ error: error instanceof Error ? error.message : "Unable to verify OTP."
367
+ }), 401);
368
+ }
369
+ });
370
+ app.get("/settings", (c) => {
371
+ const customer = requireBrowserCustomer(c);
372
+ if (!customer) {
373
+ return redirect(c, "/login");
374
+ }
375
+ const directorSkill = readRootSkillFile("SKILL.director.md", "SKILL.user.md");
376
+ const developerSkill = customer.isDeveloper
377
+ ? readRootSkillFile("SKILL.developer.md") || null
378
+ : null;
379
+ return c.html(renderSettingsPage({
380
+ notice: c.req.query("notice") ?? null,
381
+ error: c.req.query("error") ?? null,
382
+ email: customer.email,
383
+ isPaidPlan: customer.isPaidPlan,
384
+ isDeveloper: customer.isDeveloper,
385
+ about: customer.about,
386
+ groupchatUrl: customer.groupchatUrl,
387
+ flockposterApiKey: customer.flockposterApiKey,
388
+ vidfarmApiKey: getVisibleApiKey(customer.id),
389
+ directorSkill,
390
+ developerSkill,
391
+ providerKeys: database.listProviderKeysWithSecrets(customer.id).map((entry) => ({
392
+ id: String(entry.id),
393
+ provider: String(entry.provider),
394
+ label: entry.label ? String(entry.label) : null,
395
+ secret: readStoredSecret(String(entry.secret)),
396
+ status: String(entry.status),
397
+ weight: Number(entry.weight),
398
+ created_at: String(entry.created_at),
399
+ last_used_at: entry.last_used_at ? String(entry.last_used_at) : null
400
+ })),
401
+ attachments: database.listUserAttachments(customer.id)
402
+ }));
403
+ });
404
+ app.post("/settings/profile", async (c) => {
405
+ const customer = requireBrowserCustomer(c);
406
+ if (!customer) {
407
+ return redirect(c, "/login");
408
+ }
409
+ try {
410
+ const body = settingsProfileFormSchema.parse(await parseFormBody(c));
411
+ database.updateCustomerProfile({
412
+ customerId: customer.id,
413
+ about: body.about?.trim() || null,
414
+ groupchatUrl: body.groupchat_url?.trim() || null,
415
+ flockposterApiKey: body.flockposter_api_key?.trim() || null
416
+ });
417
+ return redirectToSettings(c, { notice: "Profile updated." });
418
+ }
419
+ catch (error) {
420
+ return redirectToSettings(c, {
421
+ error: error instanceof Error ? error.message : "Unable to update profile."
422
+ });
423
+ }
424
+ });
425
+ app.post("/settings/provider-keys", async (c) => {
426
+ const customer = requireBrowserCustomer(c);
427
+ if (!customer) {
428
+ return redirect(c, "/login");
429
+ }
430
+ try {
431
+ const body = settingsProviderKeyFormSchema.parse(await parseFormBody(c));
432
+ database.createProviderKey({
433
+ id: createId("pkey"),
434
+ customerId: customer.id,
435
+ provider: body.provider,
436
+ label: body.label?.trim() || null,
437
+ encryptedSecret: body.secret,
438
+ weight: body.weight
439
+ });
440
+ return redirectToSettings(c, { notice: "Provider key added." });
441
+ }
442
+ catch (error) {
443
+ return redirectToSettings(c, {
444
+ error: error instanceof Error ? error.message : "Unable to add provider key."
445
+ });
446
+ }
447
+ });
448
+ app.post("/settings/provider-keys/:keyId/delete", (c) => {
449
+ const customer = requireBrowserCustomer(c);
450
+ if (!customer) {
451
+ return redirect(c, "/login");
452
+ }
453
+ database.deleteProviderKey(customer.id, c.req.param("keyId"));
454
+ return redirectToSettings(c, { notice: "Provider key removed." });
455
+ });
456
+ app.post("/settings/attachments", async (c) => {
457
+ const customer = requireBrowserCustomer(c);
458
+ if (!customer) {
459
+ return redirect(c, "/login");
460
+ }
461
+ try {
462
+ const formData = await c.req.formData();
463
+ const files = formData.getAll("files").filter(isFormFile);
464
+ if (!files.length) {
465
+ return redirectToSettings(c, { error: "Select at least one file to upload." });
466
+ }
467
+ for (const file of files) {
468
+ const fileName = sanitizeFileName(file.name || "upload.bin");
469
+ const contentType = file.type || "application/octet-stream";
470
+ if (!isAllowedAttachment(fileName, contentType)) {
471
+ return redirectToSettings(c, { error: `Unsupported attachment type: ${fileName}` });
472
+ }
473
+ const arrayBuffer = await file.arrayBuffer();
474
+ const buffer = Buffer.from(arrayBuffer);
475
+ const attachmentId = createId("att");
476
+ const storageKey = storage.userAttachmentKey(customer.id, attachmentId, fileName);
477
+ const stored = await storage.putBuffer(storageKey, buffer, contentType, { publicRead: true });
478
+ database.createUserAttachment({
479
+ id: attachmentId,
480
+ customerId: customer.id,
481
+ fileName,
482
+ contentType,
483
+ sizeBytes: buffer.byteLength,
484
+ storageKey: stored.key,
485
+ publicUrl: stored.url
486
+ });
487
+ }
488
+ return redirectToSettings(c, { notice: "Attachment upload complete." });
489
+ }
490
+ catch (error) {
491
+ return redirectToSettings(c, {
492
+ error: error instanceof Error ? error.message : "Unable to upload attachments."
493
+ });
494
+ }
495
+ });
496
+ app.post("/settings/attachments/:attachmentId/delete", async (c) => {
497
+ const customer = requireBrowserCustomer(c);
498
+ if (!customer) {
499
+ return redirect(c, "/login");
500
+ }
501
+ const attachment = database.getUserAttachment(customer.id, c.req.param("attachmentId"));
502
+ if (!attachment) {
503
+ return redirectToSettings(c, { error: "Attachment not found." });
504
+ }
505
+ await storage.deleteObject(attachment.storageKey).catch(() => undefined);
506
+ database.deleteUserAttachment(customer.id, attachment.id);
507
+ return redirectToSettings(c, { notice: "Attachment removed." });
508
+ });
509
+ app.post("/logout", (c) => {
510
+ clearBrowserSession(c);
511
+ return redirect(c, "/");
512
+ });
513
+ app.post(`${API_PREFIX}/admin/auth/whitelist`, async (c) => {
514
+ try {
515
+ requireSuperagency(c);
516
+ }
517
+ catch (error) {
518
+ return c.json({ error: error instanceof Error ? error.message : "Forbidden" }, 403);
519
+ }
520
+ const parsed = adminWhitelistSchema.parse(await c.req.json());
521
+ const customers = parsed.emails.map((email) => {
522
+ const normalizedEmail = email.trim().toLowerCase();
523
+ const existing = database.getCustomerByEmail(normalizedEmail);
524
+ return database.upsertCustomer({
525
+ id: existing?.id ?? createId("cus"),
526
+ email: normalizedEmail,
527
+ name: existing?.name ?? null,
528
+ defaultWebhookUrl: existing?.defaultWebhookUrl ?? null,
529
+ isDeveloper: existing?.isDeveloper
530
+ || config.adminEmails.includes(normalizedEmail)
531
+ || config.developerEmails.includes(normalizedEmail),
532
+ isPaidPlan: true
533
+ });
534
+ });
535
+ return c.json({ customers });
536
+ });
537
+ app.post(`${API_PREFIX}/admin/auth/users`, async (c) => {
538
+ try {
539
+ requireSuperagency(c);
540
+ }
541
+ catch (error) {
542
+ return c.json({ error: error instanceof Error ? error.message : "Forbidden" }, 403);
543
+ }
544
+ const parsed = adminCreateUserSchema.parse(await c.req.json());
545
+ const customer = auth.createPasswordUser({
546
+ email: parsed.email,
547
+ password: parsed.password,
548
+ name: parsed.name ?? null
549
+ });
550
+ return c.json({ customer }, 201);
551
+ });
552
+ app.post(`${USER_PREFIX}/request-otp`, async (c) => {
57
553
  const body = otpRequestSchema.parse(await c.req.json());
58
554
  await auth.requestOtp(body.email);
59
555
  return c.json({ ok: true });
60
556
  });
61
- app.post("/auth/verify-otp", async (c) => {
557
+ app.post(`${USER_PREFIX}/verify-otp`, async (c) => {
62
558
  const body = otpVerifySchema.parse(await c.req.json());
63
559
  return c.json(auth.verifyOtp(body.email, body.code, body.name));
64
560
  });
@@ -75,13 +571,198 @@ const requireAuth = async (c, next) => {
75
571
  return c.json({ error: error instanceof Error ? error.message : "Unauthorized" }, 401);
76
572
  }
77
573
  };
78
- app.use("/me/*", requireAuth);
79
- app.use("/templates/*", requireAuth);
80
- app.get("/templates", (c) => c.json({
81
- templates: templateRegistry.list().map((template) => ({
574
+ app.use(`${USER_PREFIX}/me`, requireAuth);
575
+ app.use(`${USER_PREFIX}/me/*`, requireAuth);
576
+ app.use(`${TEMPLATES_PREFIX}/*`, requireAuth);
577
+ function requireAdmin(c) {
578
+ const customer = requireCustomer(c);
579
+ if (!config.adminEmails.includes(customer.email.toLowerCase())) {
580
+ throw new Error("Admin access required.");
581
+ }
582
+ return customer;
583
+ }
584
+ function requireDeveloper(c) {
585
+ const customer = requireCustomer(c);
586
+ if (!customer.isDeveloper && !config.adminEmails.includes(customer.email.toLowerCase())) {
587
+ throw new Error("Developer access required.");
588
+ }
589
+ return customer;
590
+ }
591
+ function requireSuperagency(c) {
592
+ const provided = c.req.header("x-superagency-key");
593
+ if (!config.SUPERAGENCY_KEY || provided !== config.SUPERAGENCY_KEY) {
594
+ throw new Error("Superagency access required.");
595
+ }
596
+ }
597
+ async function parseFormBody(c) {
598
+ const body = await c.req.parseBody();
599
+ const normalized = {};
600
+ for (const [key, value] of Object.entries(body)) {
601
+ normalized[key] = typeof value === "string" ? value : "";
602
+ }
603
+ return normalized;
604
+ }
605
+ function buildAbsoluteUrl(c, pathname) {
606
+ return new URL(pathname, config.PUBLIC_BASE_URL || c.req.url).toString();
607
+ }
608
+ function resolveTemplateAboutStorageKey(templateId, entry) {
609
+ const normalizedEntry = entry.replace(/^\/+/, "");
610
+ const templateAboutPrefix = storage.templateAboutKey(templateId, "");
611
+ if (!/^https?:\/\//i.test(entry)) {
612
+ if (normalizedEntry.startsWith("templates/")) {
613
+ return normalizedEntry;
614
+ }
615
+ if (normalizedEntry.startsWith(templateAboutPrefix)) {
616
+ return normalizedEntry;
617
+ }
618
+ return normalizedEntry.startsWith("about/")
619
+ ? joinStoragePath("templates", templateId, normalizedEntry)
620
+ : storage.templateAboutKey(templateId, normalizedEntry);
621
+ }
622
+ try {
623
+ const url = new URL(entry);
624
+ const pathname = url.pathname.replace(/^\/+/, "");
625
+ if (pathname.startsWith(`templates/${templateId}/about/`)) {
626
+ return pathname;
627
+ }
628
+ if (config.AWS_S3_BUCKET && pathname.startsWith(`${config.AWS_S3_BUCKET}/`)) {
629
+ const withoutBucket = pathname.slice(config.AWS_S3_BUCKET.length + 1);
630
+ if (withoutBucket.startsWith(`templates/${templateId}/about/`)) {
631
+ return withoutBucket;
632
+ }
633
+ }
634
+ const embeddedMatch = pathname.match(/(templates\/[^/]+\/about\/.+)$/);
635
+ if (embeddedMatch) {
636
+ return embeddedMatch[1];
637
+ }
638
+ }
639
+ catch {
640
+ return null;
641
+ }
642
+ return null;
643
+ }
644
+ function resolveHomepagePreviewUrl(c, templateId, entry) {
645
+ const storageKey = resolveTemplateAboutStorageKey(templateId, entry);
646
+ if (!storageKey) {
647
+ return entry;
648
+ }
649
+ return buildAbsoluteUrl(c, `/template-media?key=${encodeURIComponent(storageKey)}`);
650
+ }
651
+ async function serveTemplateAboutAsset(c, templateId, assetPath) {
652
+ const template = templateRegistry.get(templateId);
653
+ if (!template) {
654
+ return c.json({ error: "Template not found" }, 404);
655
+ }
656
+ const normalizedAssetPath = assetPath.replace(/^\/+/, "");
657
+ if (!normalizedAssetPath) {
658
+ return c.json({ error: "Template about asset path is required" }, 400);
659
+ }
660
+ const key = storage.templateAboutKey(template.id, normalizedAssetPath);
661
+ const readUrl = await storage.getReadUrl(key);
662
+ if (config.STORAGE_DRIVER === "s3" && readUrl) {
663
+ return c.redirect(readUrl, 302);
664
+ }
665
+ try {
666
+ const object = storage.readLocalObject(key);
667
+ return new Response(object.body, {
668
+ headers: {
669
+ "content-type": object.contentType
670
+ }
671
+ });
672
+ }
673
+ catch {
674
+ return c.json({ error: "Template about asset not found" }, 404);
675
+ }
676
+ }
677
+ async function serveStorageKeyAsset(c, key) {
678
+ const normalizedKey = key.replace(/^\/+/, "");
679
+ if (!normalizedKey) {
680
+ return c.json({ error: "Storage key is required" }, 400);
681
+ }
682
+ const readUrl = await storage.getReadUrl(normalizedKey);
683
+ if (config.STORAGE_DRIVER === "s3" && readUrl) {
684
+ return c.redirect(readUrl, 302);
685
+ }
686
+ try {
687
+ const object = storage.readLocalObject(normalizedKey);
688
+ return new Response(object.body, {
689
+ headers: {
690
+ "content-type": object.contentType
691
+ }
692
+ });
693
+ }
694
+ catch {
695
+ return c.json({ error: "Asset not found" }, 404);
696
+ }
697
+ }
698
+ function serializeJob(job) {
699
+ return {
700
+ job_id: job.id,
701
+ template_id: job.templateId,
702
+ operation_name: job.operationName,
703
+ workflow_name: job.workflowName,
704
+ tracer: job.tracer,
705
+ status: job.status,
706
+ progress: job.progress,
707
+ result: job.result,
708
+ error: job.error,
709
+ parent_job_id: job.parentJobId,
710
+ created_at: job.createdAt,
711
+ updated_at: job.updatedAt,
712
+ started_at: job.startedAt,
713
+ completed_at: job.completedAt
714
+ };
715
+ }
716
+ function serializeTemplate(c, template) {
717
+ return {
82
718
  id: template.id,
719
+ slug_id: template.slugId,
83
720
  version: template.version,
84
- description: template.description,
721
+ title: template.about.title,
722
+ description: template.about.description,
723
+ skill_url: buildAbsoluteUrl(c, `${TEMPLATES_PREFIX}/${template.id}/skill`),
724
+ operations: Object.entries(template.operations).map(([name, operation]) => ({
725
+ name,
726
+ description: operation.description,
727
+ providerHint: operation.providerHint ?? null
728
+ }))
729
+ };
730
+ }
731
+ function serializeTemplateAbout(c, template) {
732
+ return {
733
+ ...serializeTemplate(c, template),
734
+ viral_dna: template.about.viral_dna,
735
+ visual_dna: template.about.visual_dna,
736
+ preview_media: template.about.preview_media.map((entry) => resolveTemplateAboutMediaUrl(c, template.id, entry)),
737
+ link_to_original: template.about.link_to_original
738
+ };
739
+ }
740
+ function resolveTemplateAboutMediaUrl(c, templateId, entry) {
741
+ if (/^https?:\/\//i.test(entry)) {
742
+ return entry;
743
+ }
744
+ const normalizedEntry = entry.replace(/^\/+/, "");
745
+ if (normalizedEntry.startsWith("templates/")) {
746
+ return buildAbsoluteUrl(c, `/template-media?key=${encodeURIComponent(normalizedEntry)}`);
747
+ }
748
+ const templateAboutPrefix = storage.templateAboutKey(templateId, "");
749
+ const aboutPath = normalizedEntry.startsWith(templateAboutPrefix)
750
+ ? `about/${normalizedEntry.slice(templateAboutPrefix.length)}`
751
+ : normalizedEntry.startsWith("about/")
752
+ ? normalizedEntry
753
+ : `about/${normalizedEntry}`;
754
+ return buildAbsoluteUrl(c, `${TEMPLATES_PREFIX}/${templateId}/${aboutPath}`);
755
+ }
756
+ function joinStoragePath(...parts) {
757
+ return parts
758
+ .flatMap((part) => part.split("/"))
759
+ .map((part) => part.trim())
760
+ .filter(Boolean)
761
+ .join("/");
762
+ }
763
+ app.get(TEMPLATES_PREFIX, (c) => c.json({
764
+ templates: templateRegistry.list().map((template) => ({
765
+ ...serializeTemplate(c, template),
85
766
  operations: Object.entries(template.operations).map(([name, operation]) => ({
86
767
  name,
87
768
  description: operation.description,
@@ -90,23 +771,35 @@ app.get("/templates", (c) => c.json({
90
771
  }))
91
772
  }))
92
773
  }));
93
- app.get("/templates/:templateId", (c) => {
774
+ app.get(`${TEMPLATES_PREFIX}/:templateId`, (c) => {
94
775
  const template = templateRegistry.get(c.req.param("templateId"));
95
776
  if (!template) {
96
777
  return c.json({ error: "Template not found" }, 404);
97
778
  }
98
- return c.json({
99
- id: template.id,
100
- version: template.version,
101
- description: template.description,
102
- operations: Object.entries(template.operations).map(([name, operation]) => ({
103
- name,
104
- description: operation.description,
105
- providerHint: operation.providerHint ?? null
106
- }))
107
- });
779
+ return c.json(serializeTemplateAbout(c, template));
108
780
  });
109
- app.post("/templates/:templateId/config", async (c) => {
781
+ app.get(`${TEMPLATES_PREFIX}/:templateId/skill`, (c) => {
782
+ const template = templateRegistry.get(c.req.param("templateId"));
783
+ if (!template) {
784
+ return c.json({ error: "Template not found" }, 404);
785
+ }
786
+ if (!template.skillPath) {
787
+ return c.json({ error: "Template skill file is not configured" }, 404);
788
+ }
789
+ try {
790
+ return c.body(readFileSync(template.skillPath, "utf8"), 200, {
791
+ "content-type": "text/markdown; charset=utf-8"
792
+ });
793
+ }
794
+ catch (error) {
795
+ return c.json({ error: error instanceof Error ? error.message : "Skill file could not be read" }, 500);
796
+ }
797
+ });
798
+ app.get(`${TEMPLATES_PREFIX}/:templateId/about/*`, async (c) => {
799
+ const assetPath = c.req.param("*") ?? "";
800
+ return serveTemplateAboutAsset(c, c.req.param("templateId"), assetPath);
801
+ });
802
+ app.post(`${TEMPLATES_PREFIX}/:templateId/config`, async (c) => {
110
803
  const customer = requireCustomer(c);
111
804
  const template = templateRegistry.get(c.req.param("templateId"));
112
805
  if (!template) {
@@ -122,7 +815,7 @@ app.post("/templates/:templateId/config", async (c) => {
122
815
  });
123
816
  return c.json({ ok: true, template_id: template.id, config: parsed });
124
817
  });
125
- app.post("/templates/:templateId/operations/:operationName", async (c) => {
818
+ app.post(`${TEMPLATES_PREFIX}/:templateId/operations/:operationName`, async (c) => {
126
819
  const customer = requireCustomer(c);
127
820
  const template = templateRegistry.get(c.req.param("templateId"));
128
821
  if (!template) {
@@ -150,45 +843,98 @@ app.post("/templates/:templateId/operations/:operationName", async (c) => {
150
843
  });
151
844
  return c.json({ job_id: job.id, tracer: job.tracer, status: job.status }, 202);
152
845
  });
153
- app.get("/templates/:templateId/jobs/:jobId", (c) => {
846
+ app.get(`${TEMPLATES_PREFIX}/:templateId/jobs`, (c) => {
847
+ const customer = requireCustomer(c);
848
+ const template = templateRegistry.get(c.req.param("templateId"));
849
+ if (!template) {
850
+ return c.json({ error: "Template not found" }, 404);
851
+ }
852
+ const query = listJobsQuerySchema.parse({
853
+ tracer: c.req.query("tracer"),
854
+ start_time: c.req.query("start_time"),
855
+ end_time: c.req.query("end_time"),
856
+ limit: c.req.query("limit")
857
+ });
858
+ const listedJobs = jobs.listJobs({
859
+ customerId: customer.id,
860
+ templateId: template.id,
861
+ tracer: query.tracer,
862
+ startTime: query.start_time,
863
+ endTime: query.end_time,
864
+ limit: query.limit
865
+ });
866
+ return c.json({ template_id: template.id, jobs: listedJobs.map(serializeJob) });
867
+ });
868
+ app.get(`${TEMPLATES_PREFIX}/:templateId/jobs/:jobId`, (c) => {
154
869
  const customer = requireCustomer(c);
870
+ const template = templateRegistry.get(c.req.param("templateId"));
871
+ if (!template) {
872
+ return c.json({ error: "Template not found" }, 404);
873
+ }
155
874
  const job = jobs.getJob(c.req.param("jobId"));
156
- if (!job || job.customerId !== customer.id || job.templateId !== c.req.param("templateId")) {
875
+ if (!job || job.customerId !== customer.id || job.templateId !== template.id) {
157
876
  return c.json({ error: "Job not found" }, 404);
158
877
  }
159
- return c.json({
160
- job_id: job.id,
161
- tracer: job.tracer,
162
- status: job.status,
163
- progress: job.progress,
164
- result: job.result,
165
- error: job.error,
166
- created_at: job.createdAt,
167
- updated_at: job.updatedAt
168
- });
878
+ return c.json(serializeJob(job));
169
879
  });
170
- app.get("/templates/:templateId/jobs/:jobId/logs", (c) => {
880
+ app.get(`${TEMPLATES_PREFIX}/:templateId/jobs/:jobId/logs`, (c) => {
171
881
  const customer = requireCustomer(c);
882
+ const template = templateRegistry.get(c.req.param("templateId"));
883
+ if (!template) {
884
+ return c.json({ error: "Template not found" }, 404);
885
+ }
172
886
  const job = jobs.getJob(c.req.param("jobId"));
173
- if (!job || job.customerId !== customer.id || job.templateId !== c.req.param("templateId")) {
887
+ if (!job || job.customerId !== customer.id || job.templateId !== template.id) {
174
888
  return c.json({ error: "Job not found" }, 404);
175
889
  }
176
- const logs = jobs.listLogs(job.id, c.req.query("logs_from") || undefined, Number(c.req.query("limit") ?? 100));
890
+ const query = listJobsQuerySchema.parse({
891
+ start_time: c.req.query("start_time"),
892
+ end_time: c.req.query("end_time"),
893
+ limit: c.req.query("limit")
894
+ });
895
+ const logs = jobs.listLogs({
896
+ jobId: job.id,
897
+ startTime: query.start_time,
898
+ endTime: query.end_time,
899
+ limit: query.limit
900
+ });
177
901
  return c.json({ job_id: job.id, tracer: job.tracer, logs });
178
902
  });
179
- app.post("/templates/:templateId/jobs/:jobId/cancel", (c) => {
903
+ app.post(`${TEMPLATES_PREFIX}/:templateId/jobs/:jobId/cancel`, (c) => {
180
904
  const customer = requireCustomer(c);
905
+ const template = templateRegistry.get(c.req.param("templateId"));
906
+ if (!template) {
907
+ return c.json({ error: "Template not found" }, 404);
908
+ }
181
909
  const job = jobs.getJob(c.req.param("jobId"));
182
- if (!job || job.customerId !== customer.id || job.templateId !== c.req.param("templateId")) {
910
+ if (!job || job.customerId !== customer.id || job.templateId !== template.id) {
183
911
  return c.json({ error: "Job not found" }, 404);
184
912
  }
185
913
  jobs.cancelJob(job.id);
186
914
  return c.json({ ok: true, job_id: job.id, status: "cancelled" });
187
915
  });
188
- app.get("/me", (c) => c.json({ customer: requireCustomer(c) }));
189
- app.get("/me/jobs", (c) => c.json({ jobs: jobs.listJobs(requireCustomer(c).id, c.req.query("template_id") || undefined) }));
190
- app.get("/me/provider-keys", (c) => c.json({ provider_keys: database.listProviderKeys(requireCustomer(c).id) }));
191
- app.post("/me/provider-keys", async (c) => {
916
+ app.get(`${USER_PREFIX}/me`, (c) => c.json({ customer: requireCustomer(c) }));
917
+ app.get(`${USER_PREFIX}/me/jobs`, (c) => {
918
+ const query = listJobsQuerySchema.parse({
919
+ template_id: c.req.query("template_id"),
920
+ tracer: c.req.query("tracer"),
921
+ start_time: c.req.query("start_time"),
922
+ end_time: c.req.query("end_time"),
923
+ limit: c.req.query("limit")
924
+ });
925
+ return c.json({
926
+ jobs: jobs.listJobs({
927
+ customerId: requireCustomer(c).id,
928
+ templateId: query.template_id,
929
+ tracer: query.tracer,
930
+ startTime: query.start_time,
931
+ endTime: query.end_time,
932
+ limit: query.limit
933
+ }).map(serializeJob)
934
+ });
935
+ });
936
+ app.get(`${USER_PREFIX}/me/provider-keys`, (c) => c.json({ provider_keys: database.listProviderKeys(requireCustomer(c).id) }));
937
+ app.post(`${USER_PREFIX}/me/provider-keys`, async (c) => {
192
938
  const customer = requireCustomer(c);
193
939
  const body = providerKeySchema.parse(await c.req.json());
194
940
  database.createProviderKey({
@@ -196,15 +942,100 @@ app.post("/me/provider-keys", async (c) => {
196
942
  customerId: customer.id,
197
943
  provider: body.provider,
198
944
  label: body.label ?? null,
199
- encryptedSecret: encryptString(body.secret, config.ENCRYPTION_SECRET),
945
+ encryptedSecret: body.secret,
200
946
  weight: body.weight
201
947
  });
202
948
  return c.json({ ok: true }, 201);
203
949
  });
204
- app.post("/me/workspace/presign", async (c) => {
205
- const customer = requireCustomer(c);
206
- const body = workspacePresignSchema.parse(await c.req.json());
207
- return c.json(await storage.createPresignedWorkspaceUpload(customer.id, body.path, body.contentType));
950
+ app.get(`${TEMPLATES_PREFIX}/sources`, (c) => {
951
+ try {
952
+ requireAdmin(c);
953
+ }
954
+ catch (error) {
955
+ return c.json({ error: error instanceof Error ? error.message : "Forbidden" }, 403);
956
+ }
957
+ return c.json({ sources: templateSources.listSources() });
958
+ });
959
+ app.post(`${TEMPLATES_PREFIX}/sources`, async (c) => {
960
+ try {
961
+ requireDeveloper(c);
962
+ }
963
+ catch (error) {
964
+ return c.json({ error: error instanceof Error ? error.message : "Forbidden" }, 403);
965
+ }
966
+ try {
967
+ const body = z.object({
968
+ template_id: z.string().regex(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, "template_id must be a UUIDv4."),
969
+ slug_id: z.string().min(3).regex(/^[a-z0-9_]+$/i, "slug_id must contain only letters, numbers, and underscores."),
970
+ repo_url: z.string().url(),
971
+ branch: z.string().min(1).default("production"),
972
+ template_module_path: z.string().min(1),
973
+ skill_path: z.string().min(1).default("SKILL.md"),
974
+ install_command: z.string().min(1).default("npm install"),
975
+ build_command: z.string().min(1).default("npm run build")
976
+ }).parse(await c.req.json());
977
+ const source = templateSources.registerSource({
978
+ templateId: body.template_id,
979
+ slugId: body.slug_id,
980
+ repoUrl: body.repo_url,
981
+ branch: body.branch,
982
+ templateModulePath: body.template_module_path,
983
+ skillPath: body.skill_path,
984
+ installCommand: body.install_command,
985
+ buildCommand: body.build_command
986
+ });
987
+ return c.json({ source }, 201);
988
+ }
989
+ catch (error) {
990
+ const message = error instanceof Error ? error.message : "Unable to register template source.";
991
+ const status = /already exists/i.test(message) ? 409 : 400;
992
+ return c.json({ error: message }, status);
993
+ }
994
+ });
995
+ app.get(`${TEMPLATES_PREFIX}/releases`, (c) => {
996
+ try {
997
+ requireAdmin(c);
998
+ }
999
+ catch (error) {
1000
+ return c.json({ error: error instanceof Error ? error.message : "Forbidden" }, 403);
1001
+ }
1002
+ return c.json({ releases: templateSources.listReleases(c.req.query("template_id") || undefined) });
1003
+ });
1004
+ app.post(`${TEMPLATES_PREFIX}/sources/:sourceId/import`, async (c) => {
1005
+ try {
1006
+ requireAdmin(c);
1007
+ }
1008
+ catch (error) {
1009
+ return c.json({ error: error instanceof Error ? error.message : "Forbidden" }, 403);
1010
+ }
1011
+ const body = z.object({
1012
+ commit_sha: z.string().min(7).optional()
1013
+ }).parse(await c.req.json().catch(() => ({})));
1014
+ const release = await templateSources.importRelease({
1015
+ sourceId: c.req.param("sourceId"),
1016
+ commitSha: body.commit_sha ?? null
1017
+ });
1018
+ return c.json({ release }, 201);
1019
+ });
1020
+ app.post(`${TEMPLATES_PREFIX}/releases/:releaseId/activate`, async (c) => {
1021
+ try {
1022
+ requireAdmin(c);
1023
+ }
1024
+ catch (error) {
1025
+ return c.json({ error: error instanceof Error ? error.message : "Forbidden" }, 403);
1026
+ }
1027
+ const { release, template } = await templateSources.activateRelease({
1028
+ releaseId: c.req.param("releaseId")
1029
+ });
1030
+ templateRegistry.registerRuntimeTemplate(template);
1031
+ return c.json({
1032
+ release,
1033
+ template: {
1034
+ id: template.id,
1035
+ version: template.version,
1036
+ description: template.about.description
1037
+ }
1038
+ });
208
1039
  });
209
1040
  app.get("/storage/:key", (c) => {
210
1041
  const key = decodeURIComponent(c.req.param("key"));