@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.
- package/.env.example +12 -5
- package/PLATFORM_SPEC.md +143 -2
- package/README.md +165 -16
- package/SKILL.developer.md +258 -0
- package/SKILL.director.md +599 -0
- package/dist/infra/cdk/bin/vidfarm-prod.js +59 -0
- package/dist/infra/cdk/lib/vidfarm-prod-stack.js +212 -0
- package/dist/src/account-pages.js +630 -0
- package/dist/src/app.js +897 -66
- package/dist/src/cli.js +284 -5
- package/dist/src/config.js +25 -5
- package/dist/src/context.js +1 -1
- package/dist/src/db.js +427 -18
- package/dist/src/dev-app.js +59 -12
- package/dist/src/homepage.js +441 -0
- package/dist/src/index.js +12 -7
- package/dist/src/lib/crypto.js +14 -0
- package/dist/src/lib/template-dna.js +542 -0
- package/dist/src/lib/template-style-options.js +49 -0
- package/dist/src/registry.js +54 -7
- package/dist/src/runtime.js +3 -1
- package/dist/src/services/auth.js +69 -5
- package/dist/src/services/jobs.js +23 -4
- package/dist/src/services/providers.js +74 -12
- package/dist/src/services/storage.js +74 -18
- package/dist/src/services/template-certification.js +160 -0
- package/dist/src/services/template-loader.js +37 -0
- package/dist/src/services/template-sources.js +135 -0
- package/dist/src/worker.js +19 -7
- package/dist/templates/template_0000/src/lib/images.js +242 -0
- package/dist/templates/template_0000/src/remotion/Root.js +33 -0
- package/dist/templates/template_0000/src/sdk.js +3 -0
- package/dist/templates/template_0000/src/style-options.js +51 -0
- package/dist/templates/template_0000/src/template-dna.js +9 -0
- package/dist/templates/template_0000/src/template.js +1217 -0
- package/package.json +10 -1
- package/templates/template_0000/README.md +121 -0
- package/templates/template_0000/SKILL.md +193 -0
- package/templates/template_0000/assets/Abel-Regular.ttf +0 -0
- package/templates/template_0000/assets/DMSerifDisplay-Regular.ttf +0 -0
- package/templates/template_0000/assets/Montserrat[wght].ttf +0 -0
- package/templates/template_0000/assets/SourceCodePro[wght].ttf +0 -0
- package/templates/template_0000/assets/TikTokSans-SemiBold.ttf +0 -0
- package/templates/template_0000/assets/Yesteryear-Regular.ttf +0 -0
- package/templates/template_0000/composition.json +11 -0
- package/templates/template_0000/package-lock.json +5505 -0
- package/templates/template_0000/package.json +31 -0
- package/templates/template_0000/research/preview/.gitkeep +1 -0
- package/templates/template_0000/research/source_notes.md +7 -0
- package/templates/template_0000/scripts/create-site.mjs +27 -0
- package/templates/template_0000/scripts/render-cloud.mjs +72 -0
- package/templates/template_0000/src/lib/images.js +242 -0
- package/templates/template_0000/src/lib/images.ts +284 -0
- package/templates/template_0000/src/remotion/Root.js +33 -0
- package/templates/template_0000/src/remotion/Root.tsx +75 -0
- package/templates/template_0000/src/remotion/index.js +3 -0
- package/templates/template_0000/src/remotion/index.tsx +4 -0
- package/templates/template_0000/src/sdk.js +3 -0
- package/templates/template_0000/src/sdk.ts +122 -0
- package/templates/template_0000/src/style-options.js +51 -0
- package/templates/template_0000/src/style-options.ts +60 -0
- package/templates/template_0000/src/template-dna.ts +15 -0
- package/templates/template_0000/src/template.js +1117 -0
- package/templates/template_0000/src/template.ts +1747 -0
- package/templates/template_0000/template.config.json +26 -0
- package/templates/template_0000/tsconfig.json +19 -0
- package/dist/templates/template_0000/demo-template.js +0 -196
- package/dist/templates/template_0000/remotion/Root.js +0 -66
- /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 {
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
79
|
-
app.use(
|
|
80
|
-
app.
|
|
81
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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(
|
|
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(
|
|
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 !==
|
|
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(
|
|
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 !==
|
|
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
|
|
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(
|
|
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 !==
|
|
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(
|
|
189
|
-
app.get(
|
|
190
|
-
|
|
191
|
-
|
|
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:
|
|
945
|
+
encryptedSecret: body.secret,
|
|
200
946
|
weight: body.weight
|
|
201
947
|
});
|
|
202
948
|
return c.json({ ok: true }, 201);
|
|
203
949
|
});
|
|
204
|
-
app.
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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"));
|