@gentbajko/slopify 0.1.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.
- package/LICENSE +21 -0
- package/README.md +63 -0
- package/dist/adapter-registry.js +68 -0
- package/dist/adapters/image/bytes.js +64 -0
- package/dist/adapters/image/fal.js +164 -0
- package/dist/adapters/image/openai.js +155 -0
- package/dist/adapters/image/replicate.js +183 -0
- package/dist/adapters/llm/claude-code.js +158 -0
- package/dist/adapters/llm/codex.js +126 -0
- package/dist/adapters/llm/openrouter.js +193 -0
- package/dist/adapters/llm/run-cli.js +134 -0
- package/dist/adapters/llm/sse-lines.js +46 -0
- package/dist/adapters/retry-after.js +21 -0
- package/dist/adapters/tts/cartesia.js +105 -0
- package/dist/adapters/tts/elevenlabs.js +107 -0
- package/dist/adapters/tts/openai.js +95 -0
- package/dist/edge/cli.js +36 -0
- package/dist/edge/events/hub.js +76 -0
- package/dist/edge/http/actions.js +125 -0
- package/dist/edge/http/app.js +72 -0
- package/dist/edge/http/entries.js +41 -0
- package/dist/edge/http/files.js +67 -0
- package/dist/edge/http/problem.js +65 -0
- package/dist/edge/http/projects.js +128 -0
- package/dist/edge/http/prompts.js +73 -0
- package/dist/edge/http/providers.js +62 -0
- package/dist/edge/http/settings.js +90 -0
- package/dist/edge/http/staging.js +104 -0
- package/dist/edge/http/telemetry.js +29 -0
- package/dist/edge/http/usage.js +19 -0
- package/dist/edge/open-browser.js +20 -0
- package/dist/kernel/clock.js +18 -0
- package/dist/kernel/config/index.js +32 -0
- package/dist/kernel/db/index.js +24 -0
- package/dist/kernel/db/migrate.js +59 -0
- package/dist/kernel/db/migrations/0001-init.sql +17 -0
- package/dist/kernel/db/tx.js +53 -0
- package/dist/kernel/events.js +1 -0
- package/dist/kernel/ids.js +4 -0
- package/dist/kernel/lock.js +73 -0
- package/dist/kernel/log.js +34 -0
- package/dist/kernel/paths.js +21 -0
- package/dist/kernel/pipeline.js +22 -0
- package/dist/kernel/ports/image.js +1 -0
- package/dist/kernel/ports/llm.js +1 -0
- package/dist/kernel/ports/model.js +34 -0
- package/dist/kernel/ports/registry.js +1 -0
- package/dist/kernel/ports/tts.js +1 -0
- package/dist/kernel/runner/attempt-repo.js +50 -0
- package/dist/kernel/runner/attempt.js +146 -0
- package/dist/kernel/runner/graph.js +82 -0
- package/dist/kernel/runner/index.js +205 -0
- package/dist/kernel/runner/piece-repo.js +58 -0
- package/dist/kernel/runner/providers.js +92 -0
- package/dist/kernel/version.js +14 -0
- package/dist/main.js +212 -0
- package/dist/slices/admission/model.js +6 -0
- package/dist/slices/admission/repo.js +178 -0
- package/dist/slices/admission/rules.js +201 -0
- package/dist/slices/admission/start.js +103 -0
- package/dist/slices/admission/substitute.js +80 -0
- package/dist/slices/article/continuation.js +81 -0
- package/dist/slices/article/plain.js +14 -0
- package/dist/slices/article/run.js +166 -0
- package/dist/slices/article/split.js +51 -0
- package/dist/slices/article/store.js +21 -0
- package/dist/slices/cancel/index.js +61 -0
- package/dist/slices/images/run.js +221 -0
- package/dist/slices/library/lint.js +53 -0
- package/dist/slices/library/model.js +13 -0
- package/dist/slices/library/repo.js +111 -0
- package/dist/slices/library/save.js +87 -0
- package/dist/slices/library/slots.js +86 -0
- package/dist/slices/narration/chunk.js +71 -0
- package/dist/slices/narration/concat.js +79 -0
- package/dist/slices/narration/run.js +280 -0
- package/dist/slices/reruns/cascade.js +66 -0
- package/dist/slices/reruns/index.js +269 -0
- package/dist/slices/research/planner.js +81 -0
- package/dist/slices/research/run.js +192 -0
- package/dist/slices/research/synthesis.js +48 -0
- package/dist/slices/settings/cli-status.js +37 -0
- package/dist/slices/settings/keys.js +55 -0
- package/dist/slices/settings/model.js +54 -0
- package/dist/slices/settings/playback.js +60 -0
- package/dist/slices/settings/readiness.js +21 -0
- package/dist/slices/settings/repo.js +59 -0
- package/dist/slices/settings/voices.js +48 -0
- package/dist/slices/storage/asset-name.js +16 -0
- package/dist/slices/storage/delete-project.js +35 -0
- package/dist/slices/storage/downloads.js +100 -0
- package/dist/slices/storage/layout.js +59 -0
- package/dist/slices/storage/model.js +20 -0
- package/dist/slices/storage/reconcile.js +102 -0
- package/dist/slices/storage/repo.js +104 -0
- package/dist/slices/storage/staging.js +224 -0
- package/dist/slices/telemetry/collector-client.js +51 -0
- package/dist/slices/telemetry/flush.js +111 -0
- package/dist/slices/telemetry/machine.js +33 -0
- package/dist/slices/telemetry/model.js +39 -0
- package/dist/slices/telemetry/record.js +41 -0
- package/dist/slices/telemetry/repo.js +73 -0
- package/dist/slices/telemetry/usage.js +82 -0
- package/dist/slices/thumbnail/by-llm.js +30 -0
- package/dist/slices/thumbnail/run.js +200 -0
- package/dist/slices/video/ffmpeg.js +230 -0
- package/dist/slices/video/plan.js +71 -0
- package/dist/slices/video/run.js +179 -0
- package/dist/web/app-icon.svg +1 -0
- package/dist/web/assets/barlow-condensed-latin-600-normal-BFJEwTuo.woff +0 -0
- package/dist/web/assets/barlow-condensed-latin-600-normal-DepVgxBB.woff2 +0 -0
- package/dist/web/assets/barlow-condensed-latin-700-normal-Dmwat-ge.woff +0 -0
- package/dist/web/assets/barlow-condensed-latin-700-normal-v1xN8_Wq.woff2 +0 -0
- package/dist/web/assets/barlow-condensed-latin-ext-600-normal-18ESti3H.woff2 +0 -0
- package/dist/web/assets/barlow-condensed-latin-ext-600-normal-Clv9cIcR.woff +0 -0
- package/dist/web/assets/barlow-condensed-latin-ext-700-normal-BIHFfxf0.woff +0 -0
- package/dist/web/assets/barlow-condensed-latin-ext-700-normal-CwuXbfVR.woff2 +0 -0
- package/dist/web/assets/barlow-condensed-vietnamese-600-normal-A5AYRdjN.woff2 +0 -0
- package/dist/web/assets/barlow-condensed-vietnamese-600-normal-CNlPk46_.woff +0 -0
- package/dist/web/assets/barlow-condensed-vietnamese-700-normal-DYeBwlKR.woff2 +0 -0
- package/dist/web/assets/barlow-condensed-vietnamese-700-normal-DhIzd8Tb.woff +0 -0
- package/dist/web/assets/barlow-latin-400-normal-fsAxiSwU.woff +0 -0
- package/dist/web/assets/barlow-latin-400-normal-qiz4-Cze.woff2 +0 -0
- package/dist/web/assets/barlow-latin-500-normal-BPAOfeC8.woff2 +0 -0
- package/dist/web/assets/barlow-latin-500-normal-C1h8hMer.woff +0 -0
- package/dist/web/assets/barlow-latin-600-normal-CNwfPWQD.woff +0 -0
- package/dist/web/assets/barlow-latin-600-normal-DILqtrty.woff2 +0 -0
- package/dist/web/assets/barlow-latin-700-normal-A9pxMQ4z.woff2 +0 -0
- package/dist/web/assets/barlow-latin-700-normal-__SGTsZ1.woff +0 -0
- package/dist/web/assets/barlow-latin-800-normal-BdVooDN4.woff +0 -0
- package/dist/web/assets/barlow-latin-800-normal-s1sAMnoV.woff2 +0 -0
- package/dist/web/assets/barlow-latin-ext-400-normal-CvBsJvxq.woff +0 -0
- package/dist/web/assets/barlow-latin-ext-400-normal-HxX4XjxC.woff2 +0 -0
- package/dist/web/assets/barlow-latin-ext-500-normal-CJPcKP2Q.woff +0 -0
- package/dist/web/assets/barlow-latin-ext-500-normal-DOaysfXq.woff2 +0 -0
- package/dist/web/assets/barlow-latin-ext-600-normal-B8NK_A3D.woff2 +0 -0
- package/dist/web/assets/barlow-latin-ext-600-normal-DMVRjfRT.woff +0 -0
- package/dist/web/assets/barlow-latin-ext-700-normal-BLuWmldJ.woff2 +0 -0
- package/dist/web/assets/barlow-latin-ext-700-normal-CctuGmmz.woff +0 -0
- package/dist/web/assets/barlow-latin-ext-800-normal-BiucknKG.woff2 +0 -0
- package/dist/web/assets/barlow-latin-ext-800-normal-D7I3yvUw.woff +0 -0
- package/dist/web/assets/barlow-vietnamese-400-normal-BFeobeCK.woff +0 -0
- package/dist/web/assets/barlow-vietnamese-400-normal-Dpl4UHAZ.woff2 +0 -0
- package/dist/web/assets/barlow-vietnamese-500-normal-GNfB7rCE.woff +0 -0
- package/dist/web/assets/barlow-vietnamese-500-normal-zTViEIzf.woff2 +0 -0
- package/dist/web/assets/barlow-vietnamese-600-normal-CA_GiK2e.woff +0 -0
- package/dist/web/assets/barlow-vietnamese-600-normal-DcjprdFV.woff2 +0 -0
- package/dist/web/assets/barlow-vietnamese-700-normal-4Jt4k04K.woff +0 -0
- package/dist/web/assets/barlow-vietnamese-700-normal-D6euyNzi.woff2 +0 -0
- package/dist/web/assets/barlow-vietnamese-800-normal-Cl1Mc_Dv.woff2 +0 -0
- package/dist/web/assets/barlow-vietnamese-800-normal-D0VWpbij.woff +0 -0
- package/dist/web/assets/index-BPqQnrpy.css +1 -0
- package/dist/web/assets/index-D_sWbKQi.js +81 -0
- package/dist/web/favicon.svg +1 -0
- package/dist/web/index.html +15 -0
- package/package.json +41 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { zValidator } from "@hono/zod-validator";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { stageKinds } from "../../kernel/pipeline.js";
|
|
5
|
+
import { derive } from "../../kernel/runner/graph.js";
|
|
6
|
+
import { projectById, stagesOf } from "../../slices/admission/repo.js";
|
|
7
|
+
import { cancelProject } from "../../slices/cancel/index.js";
|
|
8
|
+
import { deleteImage, editArticle, regenerateImage, rerunStage, retryStage, } from "../../slices/reruns/index.js";
|
|
9
|
+
import { outputsOf } from "../../slices/storage/repo.js";
|
|
10
|
+
import { onInvalid, problem, titleOf } from "./problem.js";
|
|
11
|
+
// The actions `mockup/08-project.md` puts on the project header and on each stage: Cancel,
|
|
12
|
+
// Retry, Re-run, Save & re-run, Regenerate one image, Delete one image. Every one of them
|
|
13
|
+
// changes rows and files through a slice and then ticks the runner; the route itself
|
|
14
|
+
// decides nothing (`logic/12`, `logic/13`).
|
|
15
|
+
const idParam = z.object({
|
|
16
|
+
id: z
|
|
17
|
+
.string()
|
|
18
|
+
.min(1)
|
|
19
|
+
.max(64)
|
|
20
|
+
.regex(/^[0-9A-Za-z_-]+$/),
|
|
21
|
+
});
|
|
22
|
+
const stageParam = idParam.extend({ kind: z.enum(stageKinds) });
|
|
23
|
+
const imageParam = idParam.extend({
|
|
24
|
+
outputId: z
|
|
25
|
+
.string()
|
|
26
|
+
.min(1)
|
|
27
|
+
.max(64)
|
|
28
|
+
.regex(/^[0-9A-Za-z_-]+$/),
|
|
29
|
+
});
|
|
30
|
+
// ceiling: half a megabyte of markdown, which is far past any narratable article and
|
|
31
|
+
// keeps a paste from filling the process's memory. A longer one needs a streamed edit.
|
|
32
|
+
const articleBody = z.object({ markdown: z.string().min(1).max(500_000) });
|
|
33
|
+
const status = {
|
|
34
|
+
"no-project": 404,
|
|
35
|
+
"unknown-image": 404,
|
|
36
|
+
"empty-article": 400,
|
|
37
|
+
running: 409,
|
|
38
|
+
"not-rerunnable": 409,
|
|
39
|
+
"not-retryable": 409,
|
|
40
|
+
"no-article": 409,
|
|
41
|
+
"last-image": 409,
|
|
42
|
+
};
|
|
43
|
+
const details = {
|
|
44
|
+
"no-project": "No project has that id.",
|
|
45
|
+
"unknown-image": "This project has no image with that id.",
|
|
46
|
+
"empty-article": "An article cannot be saved empty.",
|
|
47
|
+
// logic/12 preconditions: "no stage of the project is `running` (§Q106)".
|
|
48
|
+
running: "This project is still running. Cancel it or wait for it to finish.",
|
|
49
|
+
"not-rerunnable": "Only a stage that has finished, failed, or been canceled can be re-run.",
|
|
50
|
+
"not-retryable": "Only a failed or canceled stage can be retried.",
|
|
51
|
+
"no-article": "This project has no article to edit yet.",
|
|
52
|
+
// logic/09 §Q75 and logic/12's invariant: "at least one image always remains".
|
|
53
|
+
"last-image": "At least one image must remain, so the last one cannot be deleted.",
|
|
54
|
+
};
|
|
55
|
+
// The return type is inferred so Hono keeps the route types the SPA's client is
|
|
56
|
+
// generated from; see stagingRoutes.
|
|
57
|
+
export function actionRoutes(deps) {
|
|
58
|
+
const reruns = {
|
|
59
|
+
db: deps.db,
|
|
60
|
+
paths: deps.paths,
|
|
61
|
+
ids: deps.ids,
|
|
62
|
+
clock: deps.clock,
|
|
63
|
+
log: deps.log,
|
|
64
|
+
};
|
|
65
|
+
const cancel = {
|
|
66
|
+
db: deps.db,
|
|
67
|
+
clock: deps.clock,
|
|
68
|
+
log: deps.log,
|
|
69
|
+
abort: (projectId) => deps.runner.abortProject(projectId),
|
|
70
|
+
emit: (projectId, event) => {
|
|
71
|
+
deps.hub.emit(projectId, event);
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
const view = (projectId) => {
|
|
75
|
+
const stages = stagesOf(deps.db, projectId);
|
|
76
|
+
return {
|
|
77
|
+
project: { ...projectById(deps.db, projectId), status: derive(stages) },
|
|
78
|
+
stages,
|
|
79
|
+
outputs: outputsOf(deps.db, projectId),
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
// Every re-run action ends the same way: the rows are written, then the runner is
|
|
83
|
+
// asked to look at the project. `logic/12` step 9 does the rest by itself.
|
|
84
|
+
const started = (c, projectId, result) => {
|
|
85
|
+
if (!result.ok) {
|
|
86
|
+
return problem(c, {
|
|
87
|
+
status: status[result.reason],
|
|
88
|
+
title: titleOf(status[result.reason]),
|
|
89
|
+
detail: details[result.reason],
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
deps.runner.tick(projectId);
|
|
93
|
+
return c.json({ ...view(projectId), redone: result.redone });
|
|
94
|
+
};
|
|
95
|
+
return new Hono()
|
|
96
|
+
.post("/:id/cancel", zValidator("param", idParam, onInvalid), async (c) => {
|
|
97
|
+
const { id } = c.req.valid("param");
|
|
98
|
+
const result = await cancelProject(cancel, id);
|
|
99
|
+
if (!result.ok) {
|
|
100
|
+
return problem(c, { status: 404, title: titleOf(404), detail: details["no-project"] });
|
|
101
|
+
}
|
|
102
|
+
// No tick: §Q111 leaves a canceled project sitting until the user retries a stage.
|
|
103
|
+
return c.json({ ...view(id), canceled: result.canceled });
|
|
104
|
+
})
|
|
105
|
+
.post("/:id/stages/:kind/retry", zValidator("param", stageParam, onInvalid), (c) => {
|
|
106
|
+
const { id, kind } = c.req.valid("param");
|
|
107
|
+
return started(c, id, retryStage(reruns, id, kind));
|
|
108
|
+
})
|
|
109
|
+
.post("/:id/stages/:kind/rerun", zValidator("param", stageParam, onInvalid), (c) => {
|
|
110
|
+
const { id, kind } = c.req.valid("param");
|
|
111
|
+
return started(c, id, rerunStage(reruns, id, kind));
|
|
112
|
+
})
|
|
113
|
+
.put("/:id/article", zValidator("param", idParam, onInvalid), zValidator("json", articleBody, onInvalid), (c) => {
|
|
114
|
+
const { id } = c.req.valid("param");
|
|
115
|
+
return started(c, id, editArticle(reruns, id, c.req.valid("json").markdown));
|
|
116
|
+
})
|
|
117
|
+
.delete("/:id/images/:outputId", zValidator("param", imageParam, onInvalid), (c) => {
|
|
118
|
+
const { id, outputId } = c.req.valid("param");
|
|
119
|
+
return started(c, id, deleteImage(reruns, id, outputId));
|
|
120
|
+
})
|
|
121
|
+
.post("/:id/images/:outputId/regenerate", zValidator("param", imageParam, onInvalid), (c) => {
|
|
122
|
+
const { id, outputId } = c.req.valid("param");
|
|
123
|
+
return started(c, id, regenerateImage(reruns, id, outputId));
|
|
124
|
+
});
|
|
125
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { serveStatic } from "@hono/node-server/serve-static";
|
|
3
|
+
import { Hono } from "hono";
|
|
4
|
+
import { streamSSE } from "hono/streaming";
|
|
5
|
+
import { actionRoutes } from "./actions.js";
|
|
6
|
+
import { entryRoutes } from "./entries.js";
|
|
7
|
+
import { fileRoutes } from "./files.js";
|
|
8
|
+
import { problem, problemFromError, titleOf } from "./problem.js";
|
|
9
|
+
import { projectRoutes } from "./projects.js";
|
|
10
|
+
import { promptRoutes } from "./prompts.js";
|
|
11
|
+
import { providerRoutes } from "./providers.js";
|
|
12
|
+
import { settingsRoutes } from "./settings.js";
|
|
13
|
+
import { stagingRoutes } from "./staging.js";
|
|
14
|
+
import { telemetryRoutes } from "./telemetry.js";
|
|
15
|
+
import { usageRoutes } from "./usage.js";
|
|
16
|
+
const notBuilt = "The Slopify UI is not built yet. The API is running: try /api/health.\n" +
|
|
17
|
+
"Build the SPA with `npm run build` at the repository root.\n";
|
|
18
|
+
function apiRoutes(deps, startedAt) {
|
|
19
|
+
return (new Hono()
|
|
20
|
+
.get("/health", (c) => c.json({
|
|
21
|
+
status: "ok",
|
|
22
|
+
version: deps.version,
|
|
23
|
+
uptimeMs: deps.clock.now().getTime() - startedAt,
|
|
24
|
+
}))
|
|
25
|
+
.route("/staging", stagingRoutes(deps))
|
|
26
|
+
.route("/projects", projectRoutes(deps))
|
|
27
|
+
// The actions of `logic/12` and `logic/13` sit on the same prefix as the project
|
|
28
|
+
// itself; they are their own router because they are their own concern.
|
|
29
|
+
.route("/projects", actionRoutes(deps))
|
|
30
|
+
.route("/prompts", promptRoutes(deps))
|
|
31
|
+
.route("/entries", entryRoutes(deps))
|
|
32
|
+
.route("/telemetry", telemetryRoutes(deps))
|
|
33
|
+
.route("/usage", usageRoutes(deps))
|
|
34
|
+
.route("/settings", settingsRoutes(deps))
|
|
35
|
+
.route("/providers", providerRoutes(deps)));
|
|
36
|
+
}
|
|
37
|
+
export function createApp(deps) {
|
|
38
|
+
const startedAt = deps.clock.now().getTime();
|
|
39
|
+
const app = new Hono()
|
|
40
|
+
.use("*", async (c, next) => {
|
|
41
|
+
c.header("X-Slopify-Version", deps.version);
|
|
42
|
+
await next();
|
|
43
|
+
})
|
|
44
|
+
.onError((error, c) => problemFromError(c, error, deps))
|
|
45
|
+
.notFound(missing)
|
|
46
|
+
.route("/api", apiRoutes(deps, startedAt))
|
|
47
|
+
.get("/api/events/global", (c) => streamSSE(c, (stream) => deps.hub.subscribeGlobal(stream, c.req.raw.signal)))
|
|
48
|
+
.get("/api/events/projects/:id", (c) => streamSSE(c, (stream) => deps.hub.subscribe(c.req.param("id"), stream, c.req.raw.signal)))
|
|
49
|
+
// Files are served by URL, not through the API (02-models Boundaries).
|
|
50
|
+
.route("/", fileRoutes(deps))
|
|
51
|
+
// The API answers for its whole prefix, so an unknown endpoint is a problem+json 404
|
|
52
|
+
// rather than the SPA's index.html with a 200.
|
|
53
|
+
.all("/api/*", missing);
|
|
54
|
+
// ceiling: the SPA directory is looked up once, at boot. The build that fills it runs
|
|
55
|
+
// before the server starts; a server started before its own build needs a restart.
|
|
56
|
+
if (existsSync(deps.webDist)) {
|
|
57
|
+
app.use("/*", serveStatic({ root: deps.webDist }));
|
|
58
|
+
// Client-side routes are not files: anything the SPA owns falls back to its shell.
|
|
59
|
+
app.get("/*", serveStatic({ root: deps.webDist, path: "index.html" }));
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
app.get("/*", (c) => c.text(notBuilt));
|
|
63
|
+
}
|
|
64
|
+
return app;
|
|
65
|
+
}
|
|
66
|
+
function missing(c) {
|
|
67
|
+
return problem(c, {
|
|
68
|
+
status: 404,
|
|
69
|
+
title: titleOf(404),
|
|
70
|
+
detail: `${c.req.method} ${c.req.path} is not a route of this API.`,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { zValidator } from "@hono/zod-validator";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { entryCategories, entryModes } from "../../slices/library/model.js";
|
|
5
|
+
import { listEntries } from "../../slices/library/repo.js";
|
|
6
|
+
import { createEntry, removeEntry, updateEntry } from "../../slices/library/save.js";
|
|
7
|
+
import { onInvalid } from "./problem.js";
|
|
8
|
+
// `logic/15` §Q121: one rule set for prompts and entries, so one refusal mapping too.
|
|
9
|
+
import { refused } from "./prompts.js";
|
|
10
|
+
const idParam = z.object({
|
|
11
|
+
id: z
|
|
12
|
+
.string()
|
|
13
|
+
.min(1)
|
|
14
|
+
.max(64)
|
|
15
|
+
.regex(/^[0-9A-Za-z_-]+$/),
|
|
16
|
+
});
|
|
17
|
+
const entryBody = z.object({
|
|
18
|
+
category: z.enum(entryCategories),
|
|
19
|
+
mode: z.enum(entryModes),
|
|
20
|
+
name: z.string(),
|
|
21
|
+
body: z.string(),
|
|
22
|
+
});
|
|
23
|
+
// The return type is inferred so Hono keeps the route types the SPA's client is
|
|
24
|
+
// generated from; see stagingRoutes.
|
|
25
|
+
export function entryRoutes(deps) {
|
|
26
|
+
const library = { db: deps.db, ids: deps.ids, clock: deps.clock };
|
|
27
|
+
return new Hono()
|
|
28
|
+
.get("/", (c) => c.json({ entries: listEntries(deps.db) }))
|
|
29
|
+
.post("/", zValidator("json", entryBody, onInvalid), (c) => {
|
|
30
|
+
const result = createEntry(library, c.req.valid("json"));
|
|
31
|
+
return result.ok ? c.json(result.value, 201) : refused(c, result, "entry");
|
|
32
|
+
})
|
|
33
|
+
.put("/:id", zValidator("param", idParam, onInvalid), zValidator("json", entryBody, onInvalid), (c) => {
|
|
34
|
+
const result = updateEntry(library, c.req.valid("param").id, c.req.valid("json"));
|
|
35
|
+
return result.ok ? c.json(result.value) : refused(c, result, "entry");
|
|
36
|
+
})
|
|
37
|
+
.delete("/:id", zValidator("param", idParam, onInvalid), (c) => {
|
|
38
|
+
const result = removeEntry(library, c.req.valid("param").id);
|
|
39
|
+
return result.ok ? c.body(null, 204) : refused(c, result, "entry");
|
|
40
|
+
});
|
|
41
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { createReadStream } from "node:fs";
|
|
2
|
+
import { Readable } from "node:stream";
|
|
3
|
+
import { zValidator } from "@hono/zod-validator";
|
|
4
|
+
import { Hono } from "hono";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { findDownload, imagesZip } from "../../slices/storage/downloads.js";
|
|
7
|
+
import { onInvalid, problem, titleOf } from "./problem.js";
|
|
8
|
+
const projectParam = z.object({
|
|
9
|
+
projectId: z
|
|
10
|
+
.string()
|
|
11
|
+
.min(1)
|
|
12
|
+
.max(64)
|
|
13
|
+
.regex(/^[0-9A-Za-z_-]+$/),
|
|
14
|
+
});
|
|
15
|
+
const assetParam = projectParam.extend({
|
|
16
|
+
asset: z
|
|
17
|
+
.string()
|
|
18
|
+
.min(1)
|
|
19
|
+
.max(64)
|
|
20
|
+
.regex(/^[a-z0-9-]+$/),
|
|
21
|
+
});
|
|
22
|
+
// The return type is inferred so Hono keeps the route types; see stagingRoutes.
|
|
23
|
+
export function fileRoutes(deps) {
|
|
24
|
+
const storage = { db: deps.db, paths: deps.paths };
|
|
25
|
+
return (new Hono()
|
|
26
|
+
// Declared before :asset so the zip is never read as an asset name.
|
|
27
|
+
.get("/files/:projectId/images.zip", zValidator("param", projectParam, onInvalid), (c) => {
|
|
28
|
+
const result = imagesZip(storage, c.req.valid("param").projectId);
|
|
29
|
+
if (!result.ok) {
|
|
30
|
+
return missing(c, result.reason);
|
|
31
|
+
}
|
|
32
|
+
return c.body(result.bytes, 200, {
|
|
33
|
+
"content-type": "application/zip",
|
|
34
|
+
"content-length": String(result.bytes.byteLength),
|
|
35
|
+
"content-disposition": disposition(result.filename),
|
|
36
|
+
});
|
|
37
|
+
})
|
|
38
|
+
.get("/files/:projectId/:asset", zValidator("param", assetParam, onInvalid), (c) => {
|
|
39
|
+
const { projectId, asset } = c.req.valid("param");
|
|
40
|
+
const result = findDownload(storage, projectId, asset);
|
|
41
|
+
if (!result.ok) {
|
|
42
|
+
return missing(c, result.reason);
|
|
43
|
+
}
|
|
44
|
+
const { download } = result;
|
|
45
|
+
// ceiling: whole-file responses only. Seeking inside the finished video would
|
|
46
|
+
// need a Range handler answering 206 with the requested slice.
|
|
47
|
+
return c.body(Readable.toWeb(createReadStream(download.path)), 200, {
|
|
48
|
+
"content-type": download.contentType,
|
|
49
|
+
"content-length": String(download.bytes),
|
|
50
|
+
"content-disposition": disposition(download.filename),
|
|
51
|
+
});
|
|
52
|
+
}));
|
|
53
|
+
}
|
|
54
|
+
// A download name is built from a slug, a role, and an extension, so it is always
|
|
55
|
+
// `[a-z0-9.-]` and needs neither quoting nor the RFC 5987 encoded form.
|
|
56
|
+
function disposition(filename) {
|
|
57
|
+
return `attachment; filename="${filename}"`;
|
|
58
|
+
}
|
|
59
|
+
function missing(c, reason) {
|
|
60
|
+
const details = {
|
|
61
|
+
"unknown-project": "No project has that id.",
|
|
62
|
+
"unknown-asset": "This project has no such file.",
|
|
63
|
+
"missing-file": "This file is recorded but is no longer on disk; re-run the stage.",
|
|
64
|
+
"no-images": "This project has no images or thumbnail yet.",
|
|
65
|
+
};
|
|
66
|
+
return problem(c, { status: 404, title: titleOf(404), detail: details[reason] });
|
|
67
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { HTTPException } from "hono/http-exception";
|
|
2
|
+
const titles = {
|
|
3
|
+
400: "Bad Request",
|
|
4
|
+
404: "Not Found",
|
|
5
|
+
409: "Conflict",
|
|
6
|
+
415: "Unsupported Media Type",
|
|
7
|
+
500: "Internal Server Error",
|
|
8
|
+
};
|
|
9
|
+
export function problem(c, init) {
|
|
10
|
+
const body = {
|
|
11
|
+
// Extensions go first so a route cannot shadow a member RFC 9457 reserves.
|
|
12
|
+
...init.extensions,
|
|
13
|
+
type: init.type ?? "about:blank",
|
|
14
|
+
title: init.title,
|
|
15
|
+
status: init.status,
|
|
16
|
+
...(init.detail === undefined ? {} : { detail: init.detail }),
|
|
17
|
+
instance: c.req.path,
|
|
18
|
+
};
|
|
19
|
+
return c.body(JSON.stringify(body), init.status, {
|
|
20
|
+
"content-type": "application/problem+json",
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
export function problemFromError(c, error, deps) {
|
|
24
|
+
if (error instanceof HTTPException) {
|
|
25
|
+
return problem(c, {
|
|
26
|
+
status: error.status,
|
|
27
|
+
title: titleOf(error.status),
|
|
28
|
+
detail: error.message === "" ? undefined : error.message,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
// An unexpected exception can carry a provider's response text, and that text can
|
|
32
|
+
// carry the key that was sent. Only the correlation id crosses to the client; the
|
|
33
|
+
// message goes to the log, which redacts key-shaped strings.
|
|
34
|
+
const correlationId = deps.ids.next();
|
|
35
|
+
deps.log.write("error", "http.error", {
|
|
36
|
+
detail: `${correlationId} ${c.req.method} ${c.req.path}: ${error.message}`,
|
|
37
|
+
});
|
|
38
|
+
return problem(c, {
|
|
39
|
+
status: 500,
|
|
40
|
+
title: titleOf(500),
|
|
41
|
+
detail: `The server failed to handle this request. Correlation id ${correlationId} is in the log.`,
|
|
42
|
+
extensions: { correlationId },
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
export function titleOf(status) {
|
|
46
|
+
return titles[status] ?? "Error";
|
|
47
|
+
}
|
|
48
|
+
// The hook shape @hono/zod-validator calls, described structurally so this module
|
|
49
|
+
// carries none of the validator's or the schema's generics.
|
|
50
|
+
export function onInvalid(result, c) {
|
|
51
|
+
if (result.success) {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
return problem(c, {
|
|
55
|
+
status: 400,
|
|
56
|
+
title: titleOf(400),
|
|
57
|
+
detail: "The request does not match this endpoint's schema.",
|
|
58
|
+
extensions: {
|
|
59
|
+
errors: result.error.issues.map((issue) => ({
|
|
60
|
+
path: issue.path.join("."),
|
|
61
|
+
message: issue.message,
|
|
62
|
+
})),
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { zValidator } from "@hono/zod-validator";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { derive, progressOf } from "../../kernel/runner/graph.js";
|
|
5
|
+
import { listProjects, projectById, runDraftSchema, stageStandingsByProject, stagesOf, } from "../../slices/admission/repo.js";
|
|
6
|
+
import { admit } from "../../slices/admission/rules.js";
|
|
7
|
+
import { startRun } from "../../slices/admission/start.js";
|
|
8
|
+
import { pickTemplates, renderPicked } from "../../slices/library/slots.js";
|
|
9
|
+
import { deleteProject } from "../../slices/storage/delete-project.js";
|
|
10
|
+
import { outputsOf, stagedFiles } from "../../slices/storage/repo.js";
|
|
11
|
+
import { record } from "../../slices/telemetry/record.js";
|
|
12
|
+
import { onInvalid, problem, titleOf } from "./problem.js";
|
|
13
|
+
const idParam = z.object({
|
|
14
|
+
id: z
|
|
15
|
+
.string()
|
|
16
|
+
.min(1)
|
|
17
|
+
.max(64)
|
|
18
|
+
.regex(/^[0-9A-Za-z_-]+$/),
|
|
19
|
+
});
|
|
20
|
+
// The return type is inferred so Hono keeps the route types the SPA's client is
|
|
21
|
+
// generated from; see stagingRoutes.
|
|
22
|
+
export function projectRoutes(deps) {
|
|
23
|
+
const storage = {
|
|
24
|
+
db: deps.db,
|
|
25
|
+
paths: deps.paths,
|
|
26
|
+
ids: deps.ids,
|
|
27
|
+
clock: deps.clock,
|
|
28
|
+
log: deps.log,
|
|
29
|
+
emit: (event) => {
|
|
30
|
+
deps.hub.emitGlobal(event);
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
const telemetry = {
|
|
34
|
+
db: deps.db,
|
|
35
|
+
ids: deps.ids,
|
|
36
|
+
clock: deps.clock,
|
|
37
|
+
log: deps.log,
|
|
38
|
+
appVersion: deps.version,
|
|
39
|
+
};
|
|
40
|
+
const storageForDelete = { db: deps.db, paths: deps.paths, log: deps.log };
|
|
41
|
+
const summarise = (project) => ({
|
|
42
|
+
...project,
|
|
43
|
+
status: derive(stagesOf(deps.db, project.id)),
|
|
44
|
+
});
|
|
45
|
+
return (new Hono()
|
|
46
|
+
.post("/", zValidator("json", runDraftSchema, onInvalid), (c) => {
|
|
47
|
+
// logic/04 §Q34: the bodies are read here, at the click, so an edit made since the
|
|
48
|
+
// prompt was selected is the one that runs.
|
|
49
|
+
const picked = pickTemplates(deps.db, c.req.valid("json"));
|
|
50
|
+
const admitted = admit({
|
|
51
|
+
draft: picked.draft,
|
|
52
|
+
staged: stagedFiles(deps.db),
|
|
53
|
+
requiredSlots: picked.requiredSlots,
|
|
54
|
+
});
|
|
55
|
+
if (!admitted.ok || picked.missing.length > 0) {
|
|
56
|
+
return problem(c, {
|
|
57
|
+
status: 400,
|
|
58
|
+
title: titleOf(400),
|
|
59
|
+
detail: "This run cannot start yet; the listed fields need attention.",
|
|
60
|
+
extensions: {
|
|
61
|
+
fields: [...picked.missing, ...(admitted.ok ? [] : admitted.fields)],
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
// Rendered from the values admit() has trimmed, so the stored text carries no
|
|
66
|
+
// padding the user did not intend (logic/03 step 4 and step 5).
|
|
67
|
+
const { project } = startRun(storage, admitted.draft, renderPicked(picked, admitted.draft.values));
|
|
68
|
+
// logic/16 step 2: one event per project created. record() swallows its own
|
|
69
|
+
// failures, so a broken telemetry write cannot cost the user the run.
|
|
70
|
+
record(telemetry, "project.created", {});
|
|
71
|
+
deps.flushSoon();
|
|
72
|
+
// logic/04 step 6: the run starts only once the project is committed.
|
|
73
|
+
deps.runner.tick(project.id);
|
|
74
|
+
// Read back after the tick, not from the rows startRun built: the runner has
|
|
75
|
+
// already claimed every eligible stage, and a body that paired status "running"
|
|
76
|
+
// with "video: pending" would contradict itself.
|
|
77
|
+
return c.json({ project: summarise(project), stages: stagesOf(deps.db, project.id) }, 201);
|
|
78
|
+
})
|
|
79
|
+
// One statement for every project's stage standings, not one per row: the list needs
|
|
80
|
+
// a status word and a meter, and both come out of the same five columns.
|
|
81
|
+
.get("/", (c) => {
|
|
82
|
+
const standings = stageStandingsByProject(deps.db);
|
|
83
|
+
const projects = listProjects(deps.db).map((project) => {
|
|
84
|
+
const stages = standings.get(project.id) ?? [];
|
|
85
|
+
return { ...project, status: derive(stages), progress: progressOf(stages) };
|
|
86
|
+
});
|
|
87
|
+
return c.json({ projects });
|
|
88
|
+
})
|
|
89
|
+
.get("/:id", zValidator("param", idParam, onInvalid), (c) => {
|
|
90
|
+
const project = projectById(deps.db, c.req.valid("param").id);
|
|
91
|
+
if (project === undefined) {
|
|
92
|
+
return problem(c, {
|
|
93
|
+
status: 404,
|
|
94
|
+
title: titleOf(404),
|
|
95
|
+
detail: "No project has that id.",
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
return c.json({
|
|
99
|
+
project: summarise(project),
|
|
100
|
+
stages: stagesOf(deps.db, project.id),
|
|
101
|
+
outputs: outputsOf(deps.db, project.id),
|
|
102
|
+
});
|
|
103
|
+
})
|
|
104
|
+
// `logic/14` step 4. Irreversible, and only from the app: 07 Projects puts the
|
|
105
|
+
// confirmation dialog in front of it.
|
|
106
|
+
.delete("/:id", zValidator("param", idParam, onInvalid), (c) => {
|
|
107
|
+
const result = deleteProject(storageForDelete, c.req.valid("param").id);
|
|
108
|
+
if (result.ok) {
|
|
109
|
+
return c.body(null, 204);
|
|
110
|
+
}
|
|
111
|
+
return problem(c, {
|
|
112
|
+
status: deleteStatus[result.reason],
|
|
113
|
+
title: titleOf(deleteStatus[result.reason]),
|
|
114
|
+
detail: result.detail ?? deleteDetails[result.reason],
|
|
115
|
+
});
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
118
|
+
const deleteStatus = {
|
|
119
|
+
"no-project": 404,
|
|
120
|
+
running: 409,
|
|
121
|
+
files: 500,
|
|
122
|
+
};
|
|
123
|
+
const deleteDetails = {
|
|
124
|
+
"no-project": "No project has that id.",
|
|
125
|
+
// §Q117: the run has to be stopped before its files can go.
|
|
126
|
+
running: "This project is still running. Cancel the run first.",
|
|
127
|
+
files: "Some of this project's files could not be removed.",
|
|
128
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { zValidator } from "@hono/zod-validator";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { promptKinds } from "../../slices/library/model.js";
|
|
5
|
+
import { listPrompts } from "../../slices/library/repo.js";
|
|
6
|
+
import { createPrompt, removePrompt, updatePrompt } from "../../slices/library/save.js";
|
|
7
|
+
import { onInvalid, problem, titleOf } from "./problem.js";
|
|
8
|
+
const idParam = z.object({
|
|
9
|
+
id: z
|
|
10
|
+
.string()
|
|
11
|
+
.min(1)
|
|
12
|
+
.max(64)
|
|
13
|
+
.regex(/^[0-9A-Za-z_-]+$/),
|
|
14
|
+
});
|
|
15
|
+
// Shape only. The rules - trimming, emptiness, length, the slot lint - are the slice's,
|
|
16
|
+
// so one set of messages reaches the editor (03-conventions).
|
|
17
|
+
const promptBody = z.object({
|
|
18
|
+
kind: z.enum(promptKinds),
|
|
19
|
+
name: z.string(),
|
|
20
|
+
body: z.string(),
|
|
21
|
+
});
|
|
22
|
+
// The return type is inferred so Hono keeps the route types the SPA's client is
|
|
23
|
+
// generated from; see stagingRoutes.
|
|
24
|
+
export function promptRoutes(deps) {
|
|
25
|
+
const library = { db: deps.db, ids: deps.ids, clock: deps.clock };
|
|
26
|
+
return (new Hono()
|
|
27
|
+
// Every kind in one list: 04 Prompts filters by tab, and Duplicate needs the body it
|
|
28
|
+
// is copying (`logic/15` step 3).
|
|
29
|
+
.get("/", (c) => c.json({ prompts: listPrompts(deps.db) }))
|
|
30
|
+
.post("/", zValidator("json", promptBody, onInvalid), (c) => {
|
|
31
|
+
const result = createPrompt(library, c.req.valid("json"));
|
|
32
|
+
return result.ok ? c.json(result.value, 201) : refused(c, result, "prompt");
|
|
33
|
+
})
|
|
34
|
+
.put("/:id", zValidator("param", idParam, onInvalid), zValidator("json", promptBody, onInvalid), (c) => {
|
|
35
|
+
const result = updatePrompt(library, c.req.valid("param").id, c.req.valid("json"));
|
|
36
|
+
return result.ok ? c.json(result.value) : refused(c, result, "prompt");
|
|
37
|
+
})
|
|
38
|
+
// `logic/15` §Q123: a project holds its own rendered text, so nothing cascades and a
|
|
39
|
+
// template used by past projects is deleted like any other.
|
|
40
|
+
.delete("/:id", zValidator("param", idParam, onInvalid), (c) => {
|
|
41
|
+
const result = removePrompt(library, c.req.valid("param").id);
|
|
42
|
+
return result.ok ? c.body(null, 204) : refused(c, result, "prompt");
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
45
|
+
// One refusal mapping for both libraries: `logic/15` §Q121 puts one rule set over
|
|
46
|
+
// prompts and entries, so entryRoutes shares this rather than mirroring it.
|
|
47
|
+
export function refused(c, failure, noun) {
|
|
48
|
+
switch (failure.reason) {
|
|
49
|
+
case "invalid":
|
|
50
|
+
return problem(c, {
|
|
51
|
+
status: 400,
|
|
52
|
+
title: titleOf(400),
|
|
53
|
+
detail: `This ${noun} cannot be saved; the listed fields need attention.`,
|
|
54
|
+
extensions: { fields: failure.fields },
|
|
55
|
+
});
|
|
56
|
+
// §Q122: the name is refused against a row that exists, and the form marks the field.
|
|
57
|
+
case "duplicate-name":
|
|
58
|
+
return problem(c, {
|
|
59
|
+
status: 409,
|
|
60
|
+
title: titleOf(409),
|
|
61
|
+
detail: `Another ${noun} already has this name.`,
|
|
62
|
+
extensions: {
|
|
63
|
+
fields: [{ field: "name", message: `Another ${noun} already has this name.` }],
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
case "not-found":
|
|
67
|
+
return problem(c, {
|
|
68
|
+
status: 404,
|
|
69
|
+
title: titleOf(404),
|
|
70
|
+
detail: `No ${noun} has that id.`,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { zValidator } from "@hono/zod-validator";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { keyStatus, removeProviderKey, saveProviderKey } from "../../slices/settings/keys.js";
|
|
5
|
+
import { providerById, providerIds } from "../../slices/settings/model.js";
|
|
6
|
+
import { providerStatuses } from "../../slices/settings/readiness.js";
|
|
7
|
+
import { onInvalid, problem, titleOf } from "./problem.js";
|
|
8
|
+
const providerParam = z.object({ id: z.enum(providerIds) });
|
|
9
|
+
// ceiling: `logic/02` §Q11 forbids any format check, so the only thing said about the
|
|
10
|
+
// value is that it is a string of a length a key could plausibly have. Raise the bound
|
|
11
|
+
// if a provider ever issues something longer.
|
|
12
|
+
const keyBody = z.object({ key: z.string().min(1).max(4096) });
|
|
13
|
+
// The return type is inferred so Hono keeps the route types the SPA's client is
|
|
14
|
+
// generated from; see stagingRoutes.
|
|
15
|
+
export function providerRoutes(deps) {
|
|
16
|
+
const keys = { db: deps.db, clock: deps.clock };
|
|
17
|
+
const readiness = { db: deps.db, probe: deps.probe };
|
|
18
|
+
return (new Hono()
|
|
19
|
+
// What Settings draws its rails from and Play its dropdowns: every provider, with
|
|
20
|
+
// the one fact that decides whether it is selectable (`logic/02` step 5, §Q135).
|
|
21
|
+
.get("/", async (c) => c.json({ providers: await providerStatuses(readiness) }))
|
|
22
|
+
.put("/:id/key", zValidator("param", providerParam, onInvalid), zValidator("json", keyBody, onInvalid), (c) => {
|
|
23
|
+
const { id } = c.req.valid("param");
|
|
24
|
+
const result = saveProviderKey(keys, id, c.req.valid("json").key);
|
|
25
|
+
if (!result.ok) {
|
|
26
|
+
return refusal(c, id, result.reason);
|
|
27
|
+
}
|
|
28
|
+
// The response says a key is stored and shows the mask, never the value.
|
|
29
|
+
return c.json(keyStatus(keys, id));
|
|
30
|
+
})
|
|
31
|
+
.delete("/:id/key", zValidator("param", providerParam, onInvalid), (c) => {
|
|
32
|
+
const { id } = c.req.valid("param");
|
|
33
|
+
const result = removeProviderKey(keys, id);
|
|
34
|
+
if (!result.ok) {
|
|
35
|
+
return refusal(c, id, result.reason);
|
|
36
|
+
}
|
|
37
|
+
return c.body(null, 204);
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
40
|
+
function refusal(c, id, reason) {
|
|
41
|
+
const name = providerById(id).displayName;
|
|
42
|
+
if (reason === "cli-provider") {
|
|
43
|
+
return problem(c, {
|
|
44
|
+
status: 400,
|
|
45
|
+
title: titleOf(400),
|
|
46
|
+
detail: `${name} signs in through its own CLI, so there is no key to store here.`,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
if (reason === "absent") {
|
|
50
|
+
return problem(c, {
|
|
51
|
+
status: 404,
|
|
52
|
+
title: titleOf(404),
|
|
53
|
+
detail: `No key is stored for ${name}.`,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return problem(c, {
|
|
57
|
+
status: 400,
|
|
58
|
+
title: titleOf(400),
|
|
59
|
+
detail: "This key cannot be saved; the listed fields need attention.",
|
|
60
|
+
extensions: { fields: [{ field: "key", message: "An API key is required." }] },
|
|
61
|
+
});
|
|
62
|
+
}
|