@mevdragon/vidfarm-devcli 0.1.0 → 0.2.0

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