agent-cms 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/PROMPT.md +55 -0
- package/README.md +220 -0
- package/dist/handler-ClOW1ldA.mjs +5703 -0
- package/dist/http-transport-DbFCI6Cs.mjs +993 -0
- package/dist/index.d.mts +213 -0
- package/dist/index.mjs +1170 -0
- package/dist/structured-text-service-B4xSlUg_.mjs +1952 -0
- package/dist/token-service-BDjccMmz.mjs +3820 -0
- package/migrations/0000_genesis.sql +118 -0
- package/package.json +98 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1170 @@
|
|
|
1
|
+
import { $ as listRecords, A as scheduleUnpublish, B as getAsset, C as SearchInput, D as clearSchedule, E as UpdateModelInput, G as updateAssetMetadata, H as listAssets, J as publishRecord, M as deleteLocale, N as listLocales, O as runScheduledTransitions, Q as getRecord, R as createAsset, S as SearchAssetsInput, T as UpdateFieldInput, U as replaceAsset, W as searchAssets, X as bulkCreateRecords, Y as unpublishRecord, Z as createRecord, _ as PatchBlocksInput, _t as updateModel, a as exportSchema, at as getVersion, b as ReorderInput, bt as VectorizeContext, c as CreateAssetInput, ct as HooksContext, d as CreateLocaleInput, dt as listFields, et as patchBlocksForField, f as CreateModelInput, ft as updateField, g as ImportSchemaInput, gt as listModels, ht as getModel, i as validateEditorToken, j as createLocale, k as schedulePublish, l as CreateEditorTokenInput, lt as createField, m as CreateUploadUrlInput, mt as deleteModel, n as listEditorTokens, nt as removeRecord, o as importSchema, ot as listVersions, p as CreateRecordInput, pt as createModel, r as revokeEditorToken, rt as reorderRecords, s as BulkCreateRecordsInput, st as restoreVersion, t as createEditorToken, tt as patchRecord, u as CreateFieldInput, ut as deleteField, v as PatchRecordInput, vt as reindexAll, w as UpdateAssetMetadataInput, x as ScheduleRecordInput, y as ReindexSearchInput, yt as search, z as deleteAsset } from "./token-service-BDjccMmz.mjs";
|
|
2
|
+
import { J as ValidationError, X as isCmsError, Y as errorToResponse, q as UnauthorizedError } from "./structured-text-service-B4xSlUg_.mjs";
|
|
3
|
+
import { D1Client } from "@effect/sql-d1";
|
|
4
|
+
import { Cause, Effect, Layer, Logger, Option, ParseResult, Schema } from "effect";
|
|
5
|
+
import { HttpApp, HttpRouter, HttpServerError, HttpServerRequest, HttpServerResponse } from "@effect/platform";
|
|
6
|
+
import { SqlClient } from "@effect/sql";
|
|
7
|
+
//#region src/migrations.ts
|
|
8
|
+
/**
|
|
9
|
+
* Embedded schema migrations — runs automatically on first request.
|
|
10
|
+
* The project is still pre-deployment, so the full schema lives in a single genesis migration.
|
|
11
|
+
*/
|
|
12
|
+
const MIGRATIONS = [{
|
|
13
|
+
version: 1,
|
|
14
|
+
statements: [
|
|
15
|
+
`CREATE TABLE IF NOT EXISTS "assets" (
|
|
16
|
+
"id" text PRIMARY KEY,
|
|
17
|
+
"filename" text NOT NULL,
|
|
18
|
+
"mime_type" text NOT NULL,
|
|
19
|
+
"size" integer NOT NULL,
|
|
20
|
+
"width" integer,
|
|
21
|
+
"height" integer,
|
|
22
|
+
"alt" text,
|
|
23
|
+
"title" text,
|
|
24
|
+
"r2_key" text NOT NULL,
|
|
25
|
+
"blurhash" text,
|
|
26
|
+
"colors" text,
|
|
27
|
+
"focal_point" text,
|
|
28
|
+
"tags" text DEFAULT '[]',
|
|
29
|
+
"custom_data" text DEFAULT '{}',
|
|
30
|
+
"created_at" text NOT NULL,
|
|
31
|
+
"updated_at" text NOT NULL,
|
|
32
|
+
"created_by" text,
|
|
33
|
+
"updated_by" text
|
|
34
|
+
)`,
|
|
35
|
+
`CREATE TABLE IF NOT EXISTS "models" (
|
|
36
|
+
"id" text PRIMARY KEY,
|
|
37
|
+
"name" text NOT NULL,
|
|
38
|
+
"api_key" text NOT NULL UNIQUE,
|
|
39
|
+
"is_block" integer DEFAULT false NOT NULL,
|
|
40
|
+
"singleton" integer DEFAULT false NOT NULL,
|
|
41
|
+
"sortable" integer DEFAULT false NOT NULL,
|
|
42
|
+
"tree" integer DEFAULT false NOT NULL,
|
|
43
|
+
"has_draft" integer DEFAULT true NOT NULL,
|
|
44
|
+
"all_locales_required" integer DEFAULT 0 NOT NULL,
|
|
45
|
+
"ordering" text,
|
|
46
|
+
"created_at" text NOT NULL,
|
|
47
|
+
"updated_at" text NOT NULL
|
|
48
|
+
)`,
|
|
49
|
+
`CREATE TABLE IF NOT EXISTS "fieldsets" (
|
|
50
|
+
"id" text PRIMARY KEY,
|
|
51
|
+
"model_id" text NOT NULL,
|
|
52
|
+
"title" text NOT NULL,
|
|
53
|
+
"position" integer DEFAULT 0 NOT NULL,
|
|
54
|
+
CONSTRAINT "fk_fieldsets_model_id_models_id_fk" FOREIGN KEY ("model_id") REFERENCES "models"("id") ON DELETE CASCADE
|
|
55
|
+
)`,
|
|
56
|
+
`CREATE TABLE IF NOT EXISTS "fields" (
|
|
57
|
+
"id" text PRIMARY KEY,
|
|
58
|
+
"model_id" text NOT NULL,
|
|
59
|
+
"label" text NOT NULL,
|
|
60
|
+
"api_key" text NOT NULL,
|
|
61
|
+
"field_type" text NOT NULL,
|
|
62
|
+
"position" integer DEFAULT 0 NOT NULL,
|
|
63
|
+
"localized" integer DEFAULT false NOT NULL,
|
|
64
|
+
"validators" text DEFAULT '{}',
|
|
65
|
+
"default_value" text,
|
|
66
|
+
"appearance" text,
|
|
67
|
+
"hint" text,
|
|
68
|
+
"fieldset_id" text,
|
|
69
|
+
"created_at" text NOT NULL,
|
|
70
|
+
"updated_at" text NOT NULL,
|
|
71
|
+
CONSTRAINT "fk_fields_model_id_models_id_fk" FOREIGN KEY ("model_id") REFERENCES "models"("id") ON DELETE CASCADE,
|
|
72
|
+
CONSTRAINT "fk_fields_fieldset_id_fieldsets_id_fk" FOREIGN KEY ("fieldset_id") REFERENCES "fieldsets"("id") ON DELETE SET NULL
|
|
73
|
+
)`,
|
|
74
|
+
`CREATE TABLE IF NOT EXISTS "locales" (
|
|
75
|
+
"id" text PRIMARY KEY,
|
|
76
|
+
"code" text NOT NULL UNIQUE,
|
|
77
|
+
"position" integer DEFAULT 0 NOT NULL,
|
|
78
|
+
"fallback_locale_id" text,
|
|
79
|
+
CONSTRAINT "fk_locales_fallback_locale_id_locales_id_fk" FOREIGN KEY ("fallback_locale_id") REFERENCES "locales"("id") ON DELETE SET NULL
|
|
80
|
+
)`,
|
|
81
|
+
`CREATE TABLE IF NOT EXISTS "site_settings" (
|
|
82
|
+
"id" text PRIMARY KEY DEFAULT 'default',
|
|
83
|
+
"site_name" text,
|
|
84
|
+
"title_suffix" text,
|
|
85
|
+
"no_index" integer DEFAULT 0 NOT NULL,
|
|
86
|
+
"favicon_id" text,
|
|
87
|
+
"facebook_page_url" text,
|
|
88
|
+
"twitter_account" text,
|
|
89
|
+
"fallback_seo_title" text,
|
|
90
|
+
"fallback_seo_description" text,
|
|
91
|
+
"fallback_seo_image_id" text,
|
|
92
|
+
"fallback_seo_twitter_card" text DEFAULT 'summary',
|
|
93
|
+
"updated_at" text NOT NULL DEFAULT (datetime('now')),
|
|
94
|
+
CONSTRAINT "fk_site_settings_favicon" FOREIGN KEY ("favicon_id") REFERENCES "assets"("id") ON DELETE SET NULL,
|
|
95
|
+
CONSTRAINT "fk_site_settings_seo_image" FOREIGN KEY ("fallback_seo_image_id") REFERENCES "assets"("id") ON DELETE SET NULL
|
|
96
|
+
)`,
|
|
97
|
+
`CREATE TABLE IF NOT EXISTS "record_versions" (
|
|
98
|
+
"id" text PRIMARY KEY,
|
|
99
|
+
"model_api_key" text NOT NULL,
|
|
100
|
+
"record_id" text NOT NULL,
|
|
101
|
+
"version_number" integer NOT NULL,
|
|
102
|
+
"snapshot" text NOT NULL,
|
|
103
|
+
"action" text NOT NULL DEFAULT 'publish',
|
|
104
|
+
"actor_type" text,
|
|
105
|
+
"actor_label" text,
|
|
106
|
+
"actor_token_id" text,
|
|
107
|
+
"created_at" text NOT NULL
|
|
108
|
+
)`,
|
|
109
|
+
`CREATE INDEX IF NOT EXISTS "idx_record_versions_lookup"
|
|
110
|
+
ON "record_versions" ("model_api_key", "record_id", "version_number" DESC)`,
|
|
111
|
+
`CREATE TABLE IF NOT EXISTS "editor_tokens" (
|
|
112
|
+
"id" TEXT PRIMARY KEY,
|
|
113
|
+
"name" TEXT NOT NULL,
|
|
114
|
+
"token_prefix" TEXT NOT NULL,
|
|
115
|
+
"secret_hash" TEXT NOT NULL,
|
|
116
|
+
"created_at" TEXT NOT NULL DEFAULT (datetime('now')),
|
|
117
|
+
"last_used_at" TEXT,
|
|
118
|
+
"expires_at" TEXT
|
|
119
|
+
)`,
|
|
120
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS "idx_editor_tokens_secret_hash" ON "editor_tokens" ("secret_hash")`
|
|
121
|
+
]
|
|
122
|
+
}, {
|
|
123
|
+
version: 2,
|
|
124
|
+
statements: [
|
|
125
|
+
`ALTER TABLE "assets" ADD COLUMN "basename" text`,
|
|
126
|
+
`ALTER TABLE "assets" ADD COLUMN "format" text`,
|
|
127
|
+
`UPDATE "assets"
|
|
128
|
+
SET "basename" = CASE
|
|
129
|
+
WHEN instr("filename", '.') > 0 THEN substr("filename", 1, length("filename") - length(substr("filename", instr("filename", '.') + 1)) - 1)
|
|
130
|
+
ELSE "filename"
|
|
131
|
+
END`,
|
|
132
|
+
`UPDATE "assets"
|
|
133
|
+
SET "format" = lower(CASE
|
|
134
|
+
WHEN instr("filename", '.') > 0 THEN substr("filename", instr("filename", '.') + 1)
|
|
135
|
+
WHEN instr("mime_type", '/') > 0 THEN substr("mime_type", instr("mime_type", '/') + 1)
|
|
136
|
+
ELSE 'bin'
|
|
137
|
+
END)`,
|
|
138
|
+
`CREATE INDEX IF NOT EXISTS "idx_assets_basename" ON "assets" ("basename")`,
|
|
139
|
+
`CREATE INDEX IF NOT EXISTS "idx_assets_format" ON "assets" ("format")`
|
|
140
|
+
]
|
|
141
|
+
}];
|
|
142
|
+
/**
|
|
143
|
+
* Ensure all CMS system tables exist.
|
|
144
|
+
* Uses a _cms_migrations tracking table. Idempotent — safe to call on every request.
|
|
145
|
+
* Fast path: single SELECT after first run.
|
|
146
|
+
*/
|
|
147
|
+
function ensureSchema() {
|
|
148
|
+
return Effect.gen(function* () {
|
|
149
|
+
const sql = yield* SqlClient.SqlClient;
|
|
150
|
+
yield* sql.unsafe(`CREATE TABLE IF NOT EXISTS "_cms_migrations" ("version" integer PRIMARY KEY, "applied_at" text NOT NULL DEFAULT (datetime('now')))`);
|
|
151
|
+
const applied = yield* sql.unsafe("SELECT version FROM _cms_migrations");
|
|
152
|
+
const appliedSet = new Set(applied.map((r) => r.version));
|
|
153
|
+
for (const migration of MIGRATIONS) {
|
|
154
|
+
if (appliedSet.has(migration.version)) continue;
|
|
155
|
+
for (const stmt of migration.statements) yield* sql.unsafe(stmt);
|
|
156
|
+
yield* sql.unsafe("INSERT INTO _cms_migrations (version) VALUES (?)", [migration.version]);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
//#endregion
|
|
161
|
+
//#region src/attribution.ts
|
|
162
|
+
const ACTOR_TYPE_HEADER = "X-Cms-Actor-Type";
|
|
163
|
+
const ACTOR_LABEL_HEADER = "X-Cms-Actor-Label";
|
|
164
|
+
const ACTOR_TOKEN_ID_HEADER = "X-Cms-Actor-Token-Id";
|
|
165
|
+
function actorHeaders(actor) {
|
|
166
|
+
if (!actor) return {};
|
|
167
|
+
const headers = {
|
|
168
|
+
[ACTOR_TYPE_HEADER]: actor.type,
|
|
169
|
+
[ACTOR_LABEL_HEADER]: actor.label
|
|
170
|
+
};
|
|
171
|
+
if (actor.tokenId) headers[ACTOR_TOKEN_ID_HEADER] = actor.tokenId;
|
|
172
|
+
return headers;
|
|
173
|
+
}
|
|
174
|
+
function actorFromHeaders(headers) {
|
|
175
|
+
const type = headers.get(ACTOR_TYPE_HEADER);
|
|
176
|
+
const label = headers.get(ACTOR_LABEL_HEADER);
|
|
177
|
+
if (type !== "admin" && type !== "editor" || !label) return null;
|
|
178
|
+
return {
|
|
179
|
+
type,
|
|
180
|
+
label,
|
|
181
|
+
tokenId: headers.get(ACTOR_TOKEN_ID_HEADER)
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
//#endregion
|
|
185
|
+
//#region src/http/router.ts
|
|
186
|
+
function describeUnknown(error) {
|
|
187
|
+
if (error instanceof Error) return `${error.name}: ${error.message}`;
|
|
188
|
+
if (typeof error === "string") return error;
|
|
189
|
+
try {
|
|
190
|
+
return JSON.stringify(error);
|
|
191
|
+
} catch {
|
|
192
|
+
return String(error);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
function getRequestIdFromHeaders(headers) {
|
|
196
|
+
return headers.get("x-request-id") ?? headers.get("cf-ray") ?? crypto.randomUUID();
|
|
197
|
+
}
|
|
198
|
+
/** Helper: run a CMS Effect and return an HTTP response */
|
|
199
|
+
function handle(effect, status = 200) {
|
|
200
|
+
return effect.pipe(Effect.flatMap((result) => HttpServerResponse.json(result, { status })), Effect.tapErrorCause((cause) => Effect.logError("REST effect failed", Cause.pretty(cause))), Effect.catchAll((error) => {
|
|
201
|
+
if (isCmsError(error)) {
|
|
202
|
+
const mapped = errorToResponse(error);
|
|
203
|
+
return HttpServerResponse.json(mapped.body, { status: mapped.status });
|
|
204
|
+
}
|
|
205
|
+
return Effect.logError("Unhandled REST error").pipe(Effect.annotateLogs({ error: describeUnknown(error) }), Effect.zipRight(HttpServerResponse.json({ error: "Internal server error" }, { status: 500 })));
|
|
206
|
+
}), Effect.catchAllDefect((defect) => {
|
|
207
|
+
return Effect.logError("REST defect").pipe(Effect.annotateLogs({ defect: describeUnknown(defect) }), Effect.zipRight(HttpServerResponse.json({ error: "Internal server error" }, { status: 500 })));
|
|
208
|
+
}));
|
|
209
|
+
}
|
|
210
|
+
/** Extract a required path parameter, defaulting to empty string if missing */
|
|
211
|
+
function param(params, name) {
|
|
212
|
+
return params[name] ?? "";
|
|
213
|
+
}
|
|
214
|
+
/** Get query param */
|
|
215
|
+
function queryParam(name) {
|
|
216
|
+
return Effect.gen(function* () {
|
|
217
|
+
const req = yield* HttpServerRequest.HttpServerRequest;
|
|
218
|
+
return new URL(req.url, "http://localhost").searchParams.get(name) ?? "";
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
function decodeUnknownInput(schema, input, message = "Invalid input") {
|
|
222
|
+
return Schema.decodeUnknown(schema)(input).pipe(Effect.mapError((e) => new ValidationError({ message: `${message}: ${e.message}` })));
|
|
223
|
+
}
|
|
224
|
+
function readJsonBody(message = "Invalid JSON body") {
|
|
225
|
+
return Effect.gen(function* () {
|
|
226
|
+
return yield* (yield* HttpServerRequest.HttpServerRequest).json.pipe(Effect.mapError((e) => new ValidationError({ message: `${message}: ${describeUnknown(e)}` })));
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
function currentActor() {
|
|
230
|
+
return Effect.gen(function* () {
|
|
231
|
+
const req = yield* HttpServerRequest.HttpServerRequest;
|
|
232
|
+
return actorFromHeaders(new Headers(req.headers));
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
const modelsRouter = HttpRouter.empty.pipe(HttpRouter.get("/", handle(listModels())), HttpRouter.post("/", Effect.gen(function* () {
|
|
236
|
+
return yield* handle(createModel(yield* decodeUnknownInput(CreateModelInput, yield* readJsonBody())), 201);
|
|
237
|
+
})), HttpRouter.get("/:id", Effect.gen(function* () {
|
|
238
|
+
return yield* handle(getModel(param(yield* HttpRouter.params, "id")));
|
|
239
|
+
})), HttpRouter.patch("/:id", Effect.gen(function* () {
|
|
240
|
+
const params = yield* HttpRouter.params;
|
|
241
|
+
const input = yield* decodeUnknownInput(UpdateModelInput, yield* readJsonBody());
|
|
242
|
+
return yield* handle(updateModel(param(params, "id"), input));
|
|
243
|
+
})), HttpRouter.del("/:id", Effect.gen(function* () {
|
|
244
|
+
return yield* handle(deleteModel(param(yield* HttpRouter.params, "id")));
|
|
245
|
+
})));
|
|
246
|
+
const fieldsRouter = HttpRouter.empty.pipe(HttpRouter.get("/models/:modelId/fields", Effect.gen(function* () {
|
|
247
|
+
return yield* handle(listFields(param(yield* HttpRouter.params, "modelId")));
|
|
248
|
+
})), HttpRouter.post("/models/:modelId/fields", Effect.gen(function* () {
|
|
249
|
+
const params = yield* HttpRouter.params;
|
|
250
|
+
const input = yield* decodeUnknownInput(CreateFieldInput, yield* readJsonBody());
|
|
251
|
+
return yield* handle(createField(param(params, "modelId"), input), 201);
|
|
252
|
+
})), HttpRouter.patch("/models/:modelId/fields/:fieldId", Effect.gen(function* () {
|
|
253
|
+
const params = yield* HttpRouter.params;
|
|
254
|
+
const input = yield* decodeUnknownInput(UpdateFieldInput, yield* readJsonBody());
|
|
255
|
+
return yield* handle(updateField(param(params, "fieldId"), input));
|
|
256
|
+
})), HttpRouter.del("/models/:modelId/fields/:fieldId", Effect.gen(function* () {
|
|
257
|
+
return yield* handle(deleteField(param(yield* HttpRouter.params, "fieldId")));
|
|
258
|
+
})));
|
|
259
|
+
const recordsRouter = HttpRouter.empty.pipe(HttpRouter.post("/records/bulk", Effect.gen(function* () {
|
|
260
|
+
return yield* handle(bulkCreateRecords(yield* decodeUnknownInput(BulkCreateRecordsInput, yield* readJsonBody()), yield* currentActor()), 201);
|
|
261
|
+
})), HttpRouter.post("/records", Effect.gen(function* () {
|
|
262
|
+
return yield* handle(createRecord(yield* decodeUnknownInput(CreateRecordInput, yield* readJsonBody()), yield* currentActor()), 201);
|
|
263
|
+
})), HttpRouter.get("/records", Effect.gen(function* () {
|
|
264
|
+
return yield* handle(listRecords(yield* queryParam("modelApiKey")));
|
|
265
|
+
})), HttpRouter.get("/records/:id/versions", Effect.gen(function* () {
|
|
266
|
+
const params = yield* HttpRouter.params;
|
|
267
|
+
return yield* handle(listVersions(yield* queryParam("modelApiKey"), param(params, "id")));
|
|
268
|
+
})), HttpRouter.get("/records/:id/versions/:versionId", Effect.gen(function* () {
|
|
269
|
+
return yield* handle(getVersion(param(yield* HttpRouter.params, "versionId")));
|
|
270
|
+
})), HttpRouter.post("/records/:id/versions/:versionId/restore", Effect.gen(function* () {
|
|
271
|
+
const params = yield* HttpRouter.params;
|
|
272
|
+
const modelApiKey = yield* queryParam("modelApiKey");
|
|
273
|
+
const actor = yield* currentActor();
|
|
274
|
+
return yield* handle(restoreVersion(modelApiKey, param(params, "id"), param(params, "versionId"), actor));
|
|
275
|
+
})), HttpRouter.get("/records/:id", Effect.gen(function* () {
|
|
276
|
+
const params = yield* HttpRouter.params;
|
|
277
|
+
return yield* handle(getRecord(yield* queryParam("modelApiKey"), param(params, "id")));
|
|
278
|
+
})), HttpRouter.patch("/records/:id", Effect.gen(function* () {
|
|
279
|
+
const params = yield* HttpRouter.params;
|
|
280
|
+
const input = yield* decodeUnknownInput(PatchRecordInput, yield* readJsonBody());
|
|
281
|
+
const actor = yield* currentActor();
|
|
282
|
+
return yield* handle(patchRecord(param(params, "id"), input, actor));
|
|
283
|
+
})), HttpRouter.patch("/records/:id/blocks", Effect.gen(function* () {
|
|
284
|
+
const params = yield* HttpRouter.params;
|
|
285
|
+
const body = yield* readJsonBody();
|
|
286
|
+
return yield* handle(patchBlocksForField(yield* decodeUnknownInput(PatchBlocksInput, typeof body === "object" && body !== null ? {
|
|
287
|
+
...body,
|
|
288
|
+
recordId: param(params, "id")
|
|
289
|
+
} : { recordId: param(params, "id") }), yield* currentActor()));
|
|
290
|
+
})), HttpRouter.del("/records/:id", Effect.gen(function* () {
|
|
291
|
+
const params = yield* HttpRouter.params;
|
|
292
|
+
return yield* handle(removeRecord(yield* queryParam("modelApiKey"), param(params, "id")));
|
|
293
|
+
})), HttpRouter.post("/records/:id/publish", Effect.gen(function* () {
|
|
294
|
+
const params = yield* HttpRouter.params;
|
|
295
|
+
const modelApiKey = yield* queryParam("modelApiKey");
|
|
296
|
+
const actor = yield* currentActor();
|
|
297
|
+
return yield* handle(publishRecord(modelApiKey, param(params, "id"), actor));
|
|
298
|
+
})), HttpRouter.post("/records/:id/unpublish", Effect.gen(function* () {
|
|
299
|
+
const params = yield* HttpRouter.params;
|
|
300
|
+
const modelApiKey = yield* queryParam("modelApiKey");
|
|
301
|
+
const actor = yield* currentActor();
|
|
302
|
+
return yield* handle(unpublishRecord(modelApiKey, param(params, "id"), actor));
|
|
303
|
+
})), HttpRouter.post("/records/:id/schedule-publish", Effect.gen(function* () {
|
|
304
|
+
const params = yield* HttpRouter.params;
|
|
305
|
+
const input = yield* decodeUnknownInput(ScheduleRecordInput, yield* readJsonBody());
|
|
306
|
+
const actor = yield* currentActor();
|
|
307
|
+
return yield* handle(schedulePublish(input.modelApiKey, param(params, "id"), input.at, actor));
|
|
308
|
+
})), HttpRouter.post("/records/:id/schedule-unpublish", Effect.gen(function* () {
|
|
309
|
+
const params = yield* HttpRouter.params;
|
|
310
|
+
const input = yield* decodeUnknownInput(ScheduleRecordInput, yield* readJsonBody());
|
|
311
|
+
const actor = yield* currentActor();
|
|
312
|
+
return yield* handle(scheduleUnpublish(input.modelApiKey, param(params, "id"), input.at, actor));
|
|
313
|
+
})), HttpRouter.post("/records/:id/clear-schedule", Effect.gen(function* () {
|
|
314
|
+
const params = yield* HttpRouter.params;
|
|
315
|
+
const body = yield* readJsonBody();
|
|
316
|
+
const input = yield* decodeUnknownInput(Schema.Struct({ modelApiKey: Schema.NonEmptyString }), body);
|
|
317
|
+
const actor = yield* currentActor();
|
|
318
|
+
return yield* handle(clearSchedule(input.modelApiKey, param(params, "id"), actor));
|
|
319
|
+
})), HttpRouter.post("/reorder", Effect.gen(function* () {
|
|
320
|
+
const rawBody = yield* readJsonBody();
|
|
321
|
+
return yield* handle(Effect.gen(function* () {
|
|
322
|
+
const { modelApiKey, recordIds } = yield* decodeUnknownInput(ReorderInput, rawBody);
|
|
323
|
+
return yield* reorderRecords(modelApiKey, recordIds, yield* currentActor());
|
|
324
|
+
}));
|
|
325
|
+
})));
|
|
326
|
+
const assetsRouter = HttpRouter.empty.pipe(HttpRouter.get("/", Effect.gen(function* () {
|
|
327
|
+
const req = yield* HttpServerRequest.HttpServerRequest;
|
|
328
|
+
const url = new URL(req.url, "http://localhost");
|
|
329
|
+
const q = url.searchParams.get("q");
|
|
330
|
+
const limit = url.searchParams.get("limit");
|
|
331
|
+
const offset = url.searchParams.get("offset");
|
|
332
|
+
if (q !== null || limit !== null || offset !== null) {
|
|
333
|
+
const parsed = yield* decodeUnknownInput(SearchAssetsInput, {
|
|
334
|
+
query: q ?? void 0,
|
|
335
|
+
limit: limit !== null ? Number(limit) : void 0,
|
|
336
|
+
offset: offset !== null ? Number(offset) : void 0
|
|
337
|
+
}, "Invalid asset search input");
|
|
338
|
+
return yield* handle(searchAssets({
|
|
339
|
+
query: parsed.query,
|
|
340
|
+
limit: Math.min(parsed.limit, 100),
|
|
341
|
+
offset: parsed.offset
|
|
342
|
+
}));
|
|
343
|
+
}
|
|
344
|
+
return yield* handle(listAssets());
|
|
345
|
+
})), HttpRouter.post("/", Effect.gen(function* () {
|
|
346
|
+
return yield* handle(createAsset(yield* decodeUnknownInput(CreateAssetInput, yield* readJsonBody()), yield* currentActor()), 201);
|
|
347
|
+
})), HttpRouter.get("/:id", Effect.gen(function* () {
|
|
348
|
+
return yield* handle(getAsset(param(yield* HttpRouter.params, "id")));
|
|
349
|
+
})), HttpRouter.put("/:id", Effect.gen(function* () {
|
|
350
|
+
const params = yield* HttpRouter.params;
|
|
351
|
+
const input = yield* decodeUnknownInput(CreateAssetInput, yield* readJsonBody());
|
|
352
|
+
const actor = yield* currentActor();
|
|
353
|
+
return yield* handle(replaceAsset(param(params, "id"), input, actor));
|
|
354
|
+
})), HttpRouter.patch("/:id", Effect.gen(function* () {
|
|
355
|
+
const params = yield* HttpRouter.params;
|
|
356
|
+
const input = yield* decodeUnknownInput(UpdateAssetMetadataInput, yield* readJsonBody());
|
|
357
|
+
const actor = yield* currentActor();
|
|
358
|
+
return yield* handle(updateAssetMetadata(param(params, "id"), input, actor));
|
|
359
|
+
})), HttpRouter.del("/:id", Effect.gen(function* () {
|
|
360
|
+
return yield* handle(deleteAsset(param(yield* HttpRouter.params, "id")));
|
|
361
|
+
})));
|
|
362
|
+
const localesRouter = HttpRouter.empty.pipe(HttpRouter.get("/", handle(listLocales())), HttpRouter.post("/", Effect.gen(function* () {
|
|
363
|
+
return yield* handle(createLocale(yield* decodeUnknownInput(CreateLocaleInput, yield* readJsonBody())), 201);
|
|
364
|
+
})), HttpRouter.del("/:id", Effect.gen(function* () {
|
|
365
|
+
return yield* handle(deleteLocale(param(yield* HttpRouter.params, "id")));
|
|
366
|
+
})));
|
|
367
|
+
const schemaRouter = HttpRouter.empty.pipe(HttpRouter.get("/", handle(exportSchema())), HttpRouter.post("/", Effect.gen(function* () {
|
|
368
|
+
return yield* handle(importSchema(yield* decodeUnknownInput(ImportSchemaInput, yield* readJsonBody())), 201);
|
|
369
|
+
})));
|
|
370
|
+
const searchRouter = HttpRouter.empty.pipe(HttpRouter.post("/", Effect.gen(function* () {
|
|
371
|
+
return yield* handle(search(yield* decodeUnknownInput(SearchInput, yield* readJsonBody(), "Invalid search input")));
|
|
372
|
+
})), HttpRouter.post("/reindex", Effect.gen(function* () {
|
|
373
|
+
const modelApiKey = (yield* decodeUnknownInput(ReindexSearchInput, yield* readJsonBody())).modelApiKey;
|
|
374
|
+
return yield* handle(reindexAll(modelApiKey));
|
|
375
|
+
})));
|
|
376
|
+
const tokensRouter = HttpRouter.empty.pipe(HttpRouter.get("/", handle(listEditorTokens())), HttpRouter.post("/", Effect.gen(function* () {
|
|
377
|
+
return yield* handle(createEditorToken(yield* decodeUnknownInput(CreateEditorTokenInput, yield* readJsonBody())), 201);
|
|
378
|
+
})), HttpRouter.del("/:id", Effect.gen(function* () {
|
|
379
|
+
return yield* handle(revokeEditorToken(param(yield* HttpRouter.params, "id")));
|
|
380
|
+
})));
|
|
381
|
+
const setupRouter = HttpRouter.empty.pipe(HttpRouter.post("/setup", handle(ensureSchema().pipe(Effect.as({ ok: true })))));
|
|
382
|
+
const healthRouter = HttpRouter.empty.pipe(HttpRouter.get("/health", HttpServerResponse.json({ status: "ok" })));
|
|
383
|
+
const appRouter = HttpRouter.empty.pipe(HttpRouter.concat(healthRouter), HttpRouter.concat(modelsRouter.pipe(HttpRouter.prefixAll("/api/models"))), HttpRouter.concat(fieldsRouter.pipe(HttpRouter.prefixAll("/api"))), HttpRouter.concat(recordsRouter.pipe(HttpRouter.prefixAll("/api"))), HttpRouter.concat(assetsRouter.pipe(HttpRouter.prefixAll("/api/assets"))), HttpRouter.concat(localesRouter.pipe(HttpRouter.prefixAll("/api/locales"))), HttpRouter.concat(schemaRouter.pipe(HttpRouter.prefixAll("/api/schema"))), HttpRouter.concat(searchRouter.pipe(HttpRouter.prefixAll("/api/search"))), HttpRouter.concat(tokensRouter.pipe(HttpRouter.prefixAll("/api/tokens"))), HttpRouter.concat(setupRouter.pipe(HttpRouter.prefixAll("/api"))));
|
|
384
|
+
function createWebHandler(sqlLayer, options) {
|
|
385
|
+
const vectorizeLayer = Layer.succeed(VectorizeContext, options?.ai && options.vectorize ? Option.some({
|
|
386
|
+
ai: options.ai,
|
|
387
|
+
vectorize: options.vectorize
|
|
388
|
+
}) : Option.none());
|
|
389
|
+
const hooksLayer = Layer.succeed(HooksContext, options?.hooks ? Option.some(options.hooks) : Option.none());
|
|
390
|
+
const fullLayer = Layer.mergeAll(sqlLayer, vectorizeLayer, hooksLayer, Logger.json);
|
|
391
|
+
const runLoggedEffect = (effect) => {
|
|
392
|
+
Effect.runFork(effect.pipe(Effect.provide(fullLayer), Effect.orDie));
|
|
393
|
+
};
|
|
394
|
+
const logInfo = (message, fields) => {
|
|
395
|
+
runLoggedEffect(Effect.logInfo(message).pipe(Effect.annotateLogs(fields)));
|
|
396
|
+
};
|
|
397
|
+
const logError = (message, fields) => {
|
|
398
|
+
runLoggedEffect(Effect.logError(message).pipe(Effect.annotateLogs(fields)));
|
|
399
|
+
};
|
|
400
|
+
const restApp = Effect.flatten(HttpRouter.toHttpApp(appRouter)).pipe(Effect.catchAll((error) => {
|
|
401
|
+
if (isCmsError(error)) {
|
|
402
|
+
const mapped = errorToResponse(error);
|
|
403
|
+
return HttpServerResponse.json(mapped.body, { status: mapped.status });
|
|
404
|
+
}
|
|
405
|
+
if (error instanceof HttpServerError.RouteNotFound) return HttpServerResponse.json({ error: "Not found" }, { status: 404 });
|
|
406
|
+
return Effect.logError("REST handler error").pipe(Effect.annotateLogs({ error: describeUnknown(error) }), Effect.zipRight(HttpServerResponse.json({ error: "Internal server error" }, { status: 500 })));
|
|
407
|
+
}), Effect.provide(fullLayer));
|
|
408
|
+
const restHandler = HttpApp.toWebHandler(restApp);
|
|
409
|
+
let graphqlInstance = null;
|
|
410
|
+
let mcpHandler = null;
|
|
411
|
+
let graphqlModulePromise = null;
|
|
412
|
+
let mcpEditorHandler = null;
|
|
413
|
+
function invalidateGraphqlSchemaCache() {
|
|
414
|
+
if (graphqlInstance) graphqlInstance.invalidateSchema();
|
|
415
|
+
}
|
|
416
|
+
async function getGraphqlInstance() {
|
|
417
|
+
if (!graphqlInstance) {
|
|
418
|
+
if (!graphqlModulePromise) graphqlModulePromise = import("./handler-ClOW1ldA.mjs");
|
|
419
|
+
graphqlInstance = (await graphqlModulePromise).createGraphQLHandler(sqlLayer, {
|
|
420
|
+
assetBaseUrl: options?.assetBaseUrl,
|
|
421
|
+
isProduction: options?.isProduction
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
return graphqlInstance;
|
|
425
|
+
}
|
|
426
|
+
async function runScheduledTransitions$1(now = /* @__PURE__ */ new Date()) {
|
|
427
|
+
const result = await Effect.runPromise(runScheduledTransitions(now).pipe(Effect.withSpan("schedule.run_transitions"), Effect.annotateLogs({ now: now.toISOString() }), Effect.provide(fullLayer)));
|
|
428
|
+
if (result.published.length > 0 || result.unpublished.length > 0) invalidateGraphqlSchemaCache();
|
|
429
|
+
return result;
|
|
430
|
+
}
|
|
431
|
+
function isSchemaMutationRequest(url, method) {
|
|
432
|
+
if (![
|
|
433
|
+
"POST",
|
|
434
|
+
"PATCH",
|
|
435
|
+
"DELETE"
|
|
436
|
+
].includes(method)) return false;
|
|
437
|
+
return url.pathname.startsWith("/api/models") || url.pathname.startsWith("/api/locales") || url.pathname.startsWith("/api/schema") || url.pathname === "/api/setup";
|
|
438
|
+
}
|
|
439
|
+
/** Add CORS headers to a response */
|
|
440
|
+
function withCors(response, request) {
|
|
441
|
+
const origin = request.headers.get("Origin") ?? "*";
|
|
442
|
+
const headers = new Headers(response.headers);
|
|
443
|
+
headers.set("Access-Control-Allow-Origin", origin);
|
|
444
|
+
headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
|
|
445
|
+
headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Include-Drafts, X-Exclude-Invalid, X-Filename, X-Requested-With, Accept, User-Agent");
|
|
446
|
+
headers.set("Access-Control-Max-Age", "600");
|
|
447
|
+
return new Response(response.body, {
|
|
448
|
+
status: response.status,
|
|
449
|
+
statusText: response.statusText,
|
|
450
|
+
headers
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Extract Bearer token from Authorization header.
|
|
455
|
+
* Accepts: "Bearer <token>" or raw "<token>"
|
|
456
|
+
*/
|
|
457
|
+
function getBearerToken(request) {
|
|
458
|
+
const header = request.headers.get("Authorization");
|
|
459
|
+
if (!header) return null;
|
|
460
|
+
if (header.startsWith("Bearer ")) return header.slice(7);
|
|
461
|
+
return header;
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Check if a request is authorized for write access.
|
|
465
|
+
* If no writeKey is configured, all requests are allowed (local dev).
|
|
466
|
+
* When adminOnly is true, only writeKey is accepted (not editor tokens).
|
|
467
|
+
*/
|
|
468
|
+
async function checkWriteAuth(request, adminOnly = false) {
|
|
469
|
+
if (!options?.writeKey) return null;
|
|
470
|
+
const token = getBearerToken(request);
|
|
471
|
+
if (token === options.writeKey) return null;
|
|
472
|
+
if (adminOnly) return new UnauthorizedError({ message: "Unauthorized. This endpoint requires admin (writeKey) access." });
|
|
473
|
+
if (token && token.startsWith("etk_")) try {
|
|
474
|
+
await Effect.runPromise(validateEditorToken(token).pipe(Effect.provide(fullLayer)));
|
|
475
|
+
return null;
|
|
476
|
+
} catch {
|
|
477
|
+
return new UnauthorizedError({ message: "Unauthorized. Invalid or expired editor token." });
|
|
478
|
+
}
|
|
479
|
+
return new UnauthorizedError({ message: "Unauthorized. Provide a valid write API key or editor token via Authorization: Bearer <key>" });
|
|
480
|
+
}
|
|
481
|
+
async function getRequestActor(request) {
|
|
482
|
+
if (!options?.writeKey) return {
|
|
483
|
+
type: "admin",
|
|
484
|
+
label: "admin"
|
|
485
|
+
};
|
|
486
|
+
const token = getBearerToken(request);
|
|
487
|
+
if (token === options.writeKey) return {
|
|
488
|
+
type: "admin",
|
|
489
|
+
label: "admin"
|
|
490
|
+
};
|
|
491
|
+
if (token && token.startsWith("etk_")) try {
|
|
492
|
+
const editorToken = await Effect.runPromise(validateEditorToken(token).pipe(Effect.provide(fullLayer)));
|
|
493
|
+
return {
|
|
494
|
+
type: "editor",
|
|
495
|
+
label: editorToken.name,
|
|
496
|
+
tokenId: editorToken.id
|
|
497
|
+
};
|
|
498
|
+
} catch {
|
|
499
|
+
return null;
|
|
500
|
+
}
|
|
501
|
+
return null;
|
|
502
|
+
}
|
|
503
|
+
async function getCredentialType(request) {
|
|
504
|
+
return (await getRequestActor(request))?.type ?? null;
|
|
505
|
+
}
|
|
506
|
+
const fetchHandler = async (request) => {
|
|
507
|
+
const requestId = getRequestIdFromHeaders(request.headers);
|
|
508
|
+
const headers = new Headers(request.headers);
|
|
509
|
+
headers.set("x-request-id", requestId);
|
|
510
|
+
let instrumentedRequest = new Request(request, { headers });
|
|
511
|
+
const startedAt = Date.now();
|
|
512
|
+
const finish = (response) => {
|
|
513
|
+
const corsResponse = withCors(response, instrumentedRequest);
|
|
514
|
+
const responseHeaders = new Headers(corsResponse.headers);
|
|
515
|
+
responseHeaders.set("x-request-id", requestId);
|
|
516
|
+
const wrapped = new Response(corsResponse.body, {
|
|
517
|
+
status: corsResponse.status,
|
|
518
|
+
statusText: corsResponse.statusText,
|
|
519
|
+
headers: responseHeaders
|
|
520
|
+
});
|
|
521
|
+
const durationMs = Date.now() - startedAt;
|
|
522
|
+
if (wrapped.status >= 500 || instrumentedRequest.url.includes("/api/assets/")) {
|
|
523
|
+
const logFields = {
|
|
524
|
+
requestId,
|
|
525
|
+
method: instrumentedRequest.method,
|
|
526
|
+
path: new URL(instrumentedRequest.url).pathname,
|
|
527
|
+
status: wrapped.status,
|
|
528
|
+
durationMs
|
|
529
|
+
};
|
|
530
|
+
if (wrapped.status >= 500) logError("worker request completed", logFields);
|
|
531
|
+
else logInfo("worker request completed", logFields);
|
|
532
|
+
}
|
|
533
|
+
return wrapped;
|
|
534
|
+
};
|
|
535
|
+
try {
|
|
536
|
+
if (instrumentedRequest.method === "OPTIONS") return finish(new Response(null, { status: 204 }));
|
|
537
|
+
const url = new URL(instrumentedRequest.url);
|
|
538
|
+
if (url.pathname.startsWith("/assets/") && options?.r2Bucket) {
|
|
539
|
+
const assetId = url.pathname.replace("/assets/", "").split("/")[0];
|
|
540
|
+
if (assetId) {
|
|
541
|
+
const r2Key = await Effect.runPromise(Effect.gen(function* () {
|
|
542
|
+
return (yield* (yield* SqlClient.SqlClient).unsafe("SELECT r2_key FROM assets WHERE id = ?", [assetId]))[0]?.r2_key ?? null;
|
|
543
|
+
}).pipe(Effect.provide(fullLayer), Effect.orDie));
|
|
544
|
+
if (r2Key) {
|
|
545
|
+
const object = await options.r2Bucket.get(r2Key);
|
|
546
|
+
if (object) {
|
|
547
|
+
const headers = new Headers();
|
|
548
|
+
headers.set("Content-Type", object.httpMetadata?.contentType || "application/octet-stream");
|
|
549
|
+
headers.set("Cache-Control", "public, max-age=31536000, immutable");
|
|
550
|
+
return finish(new Response(object.body, { headers }));
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
return finish(new Response("Not found", { status: 404 }));
|
|
555
|
+
}
|
|
556
|
+
if (url.pathname === "/health") return finish(new Response(JSON.stringify({ status: "ok" }), { headers: { "Content-Type": "application/json" } }));
|
|
557
|
+
if (url.pathname === "/graphql") {
|
|
558
|
+
const credentialType = await getCredentialType(instrumentedRequest);
|
|
559
|
+
const h = new Headers(instrumentedRequest.headers);
|
|
560
|
+
if (credentialType) h.set("X-Credential-Type", credentialType);
|
|
561
|
+
else h.delete("X-Credential-Type");
|
|
562
|
+
instrumentedRequest = new Request(instrumentedRequest, { headers: h });
|
|
563
|
+
} else if (url.pathname === "/mcp") {
|
|
564
|
+
if ((await getRequestActor(instrumentedRequest))?.type === "editor") return finish(new Response(JSON.stringify({ error: "Unauthorized. Editor tokens must use /mcp/editor." }), {
|
|
565
|
+
status: 401,
|
|
566
|
+
headers: { "Content-Type": "application/json" }
|
|
567
|
+
}));
|
|
568
|
+
const denied = await checkWriteAuth(instrumentedRequest, true);
|
|
569
|
+
if (denied) {
|
|
570
|
+
const mapped = errorToResponse(denied);
|
|
571
|
+
return finish(new Response(JSON.stringify(mapped.body), {
|
|
572
|
+
status: mapped.status,
|
|
573
|
+
headers: { "Content-Type": "application/json" }
|
|
574
|
+
}));
|
|
575
|
+
}
|
|
576
|
+
} else if (url.pathname === "/mcp/editor") {
|
|
577
|
+
const denied = await checkWriteAuth(instrumentedRequest, false);
|
|
578
|
+
if (denied) {
|
|
579
|
+
const mapped = errorToResponse(denied);
|
|
580
|
+
return finish(new Response(JSON.stringify(mapped.body), {
|
|
581
|
+
status: mapped.status,
|
|
582
|
+
headers: { "Content-Type": "application/json" }
|
|
583
|
+
}));
|
|
584
|
+
}
|
|
585
|
+
} else if (url.pathname.startsWith("/api/")) {
|
|
586
|
+
const adminOnly = isSchemaMutationRequest(url, instrumentedRequest.method) || url.pathname.startsWith("/api/tokens");
|
|
587
|
+
const denied = await checkWriteAuth(instrumentedRequest, adminOnly);
|
|
588
|
+
if (denied) {
|
|
589
|
+
const mapped = errorToResponse(denied);
|
|
590
|
+
return finish(new Response(JSON.stringify(mapped.body), {
|
|
591
|
+
status: mapped.status,
|
|
592
|
+
headers: { "Content-Type": "application/json" }
|
|
593
|
+
}));
|
|
594
|
+
}
|
|
595
|
+
const actor = await getRequestActor(instrumentedRequest);
|
|
596
|
+
const h = new Headers(instrumentedRequest.headers);
|
|
597
|
+
for (const [key, value] of Object.entries(actorHeaders(actor))) h.set(key, value);
|
|
598
|
+
instrumentedRequest = new Request(instrumentedRequest, { headers: h });
|
|
599
|
+
}
|
|
600
|
+
if (url.pathname === "/mcp") {
|
|
601
|
+
const { createMcpHttpHandler } = await import("./http-transport-DbFCI6Cs.mjs");
|
|
602
|
+
const actor = await getRequestActor(instrumentedRequest);
|
|
603
|
+
return finish(await (actor?.type === "admin" ? mcpHandler ??= createMcpHttpHandler(fullLayer, {
|
|
604
|
+
mode: "admin",
|
|
605
|
+
path: "/mcp",
|
|
606
|
+
r2Bucket: options?.r2Bucket,
|
|
607
|
+
assetBaseUrl: options?.assetBaseUrl,
|
|
608
|
+
actor
|
|
609
|
+
}) : createMcpHttpHandler(fullLayer, {
|
|
610
|
+
mode: "admin",
|
|
611
|
+
path: "/mcp",
|
|
612
|
+
r2Bucket: options?.r2Bucket,
|
|
613
|
+
assetBaseUrl: options?.assetBaseUrl,
|
|
614
|
+
actor
|
|
615
|
+
}))(instrumentedRequest));
|
|
616
|
+
}
|
|
617
|
+
if (url.pathname === "/mcp/editor") {
|
|
618
|
+
const { createMcpHttpHandler } = await import("./http-transport-DbFCI6Cs.mjs");
|
|
619
|
+
const actor = await getRequestActor(instrumentedRequest);
|
|
620
|
+
return finish(await (actor?.type === "editor" ? createMcpHttpHandler(fullLayer, {
|
|
621
|
+
mode: "editor",
|
|
622
|
+
path: "/mcp/editor",
|
|
623
|
+
r2Bucket: options?.r2Bucket,
|
|
624
|
+
assetBaseUrl: options?.assetBaseUrl,
|
|
625
|
+
actor
|
|
626
|
+
}) : mcpEditorHandler ??= createMcpHttpHandler(fullLayer, {
|
|
627
|
+
mode: "editor",
|
|
628
|
+
path: "/mcp/editor",
|
|
629
|
+
r2Bucket: options?.r2Bucket,
|
|
630
|
+
assetBaseUrl: options?.assetBaseUrl,
|
|
631
|
+
actor
|
|
632
|
+
}))(instrumentedRequest));
|
|
633
|
+
}
|
|
634
|
+
if (url.pathname === "/graphql") {
|
|
635
|
+
const traceEnabled = instrumentedRequest.headers.get("X-Bench-Trace") === "1" || instrumentedRequest.headers.get("X-Debug-Sql") === "true";
|
|
636
|
+
let graphqlImportMs = 0;
|
|
637
|
+
let graphqlInitMs = 0;
|
|
638
|
+
let graphqlImportCache = "hit";
|
|
639
|
+
let graphqlInitCache = "hit";
|
|
640
|
+
if (!graphqlInstance) {
|
|
641
|
+
graphqlInitCache = "miss";
|
|
642
|
+
if (!graphqlModulePromise) {
|
|
643
|
+
graphqlImportCache = "miss";
|
|
644
|
+
const importStartedAt = performance.now();
|
|
645
|
+
graphqlModulePromise = import("./handler-ClOW1ldA.mjs").then((module) => {
|
|
646
|
+
graphqlImportMs = Number((performance.now() - importStartedAt).toFixed(3));
|
|
647
|
+
return module;
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
const module = await graphqlModulePromise;
|
|
651
|
+
const initStartedAt = performance.now();
|
|
652
|
+
graphqlInstance = module.createGraphQLHandler(sqlLayer, {
|
|
653
|
+
assetBaseUrl: options?.assetBaseUrl,
|
|
654
|
+
isProduction: options?.isProduction
|
|
655
|
+
});
|
|
656
|
+
graphqlInitMs = Number((performance.now() - initStartedAt).toFixed(3));
|
|
657
|
+
}
|
|
658
|
+
const response = await graphqlInstance.handle(instrumentedRequest);
|
|
659
|
+
if (!traceEnabled) return finish(response);
|
|
660
|
+
const headers = new Headers(response.headers);
|
|
661
|
+
headers.set("X-Cms-Graphql-Import-Ms", graphqlImportMs.toFixed(3));
|
|
662
|
+
headers.set("X-Cms-Graphql-Import-Cache", graphqlImportCache);
|
|
663
|
+
headers.set("X-Cms-Graphql-Init-Ms", graphqlInitMs.toFixed(3));
|
|
664
|
+
headers.set("X-Cms-Graphql-Init-Cache", graphqlInitCache);
|
|
665
|
+
return finish(new Response(response.body, {
|
|
666
|
+
status: response.status,
|
|
667
|
+
statusText: response.statusText,
|
|
668
|
+
headers
|
|
669
|
+
}));
|
|
670
|
+
}
|
|
671
|
+
if (url.pathname === "/api/assets/upload-url" && instrumentedRequest.method === "POST") {
|
|
672
|
+
if (!options?.r2Credentials) return finish(new Response(JSON.stringify({ error: "Presigned uploads not configured" }), {
|
|
673
|
+
status: 501,
|
|
674
|
+
headers: { "Content-Type": "application/json" }
|
|
675
|
+
}));
|
|
676
|
+
const body = await instrumentedRequest.json();
|
|
677
|
+
const parsed = Schema.decodeUnknownSync(CreateUploadUrlInput)(body);
|
|
678
|
+
const { S3Client, PutObjectCommand } = await import("@aws-sdk/client-s3");
|
|
679
|
+
const { getSignedUrl } = await import("@aws-sdk/s3-request-presigner");
|
|
680
|
+
const creds = options.r2Credentials;
|
|
681
|
+
const assetId = crypto.randomUUID();
|
|
682
|
+
const r2Key = `uploads/${assetId}/${parsed.filename}`;
|
|
683
|
+
const uploadUrl = await getSignedUrl(new S3Client({
|
|
684
|
+
region: "auto",
|
|
685
|
+
endpoint: `https://${creds.accountId}.r2.cloudflarestorage.com`,
|
|
686
|
+
credentials: {
|
|
687
|
+
accessKeyId: creds.accessKeyId,
|
|
688
|
+
secretAccessKey: creds.secretAccessKey
|
|
689
|
+
}
|
|
690
|
+
}), new PutObjectCommand({
|
|
691
|
+
Bucket: creds.bucketName,
|
|
692
|
+
Key: r2Key,
|
|
693
|
+
ContentType: parsed.mimeType
|
|
694
|
+
}), { expiresIn: 3600 });
|
|
695
|
+
return finish(new Response(JSON.stringify({
|
|
696
|
+
uploadUrl,
|
|
697
|
+
r2Key,
|
|
698
|
+
assetId
|
|
699
|
+
}), {
|
|
700
|
+
status: 200,
|
|
701
|
+
headers: { "Content-Type": "application/json" }
|
|
702
|
+
}));
|
|
703
|
+
}
|
|
704
|
+
if (url.pathname.match(/^\/api\/assets\/[^/]+\/file$/) && instrumentedRequest.method === "PUT") {
|
|
705
|
+
if (!options?.r2Bucket) return finish(new Response(JSON.stringify({ error: "R2 bucket not configured" }), {
|
|
706
|
+
status: 501,
|
|
707
|
+
headers: { "Content-Type": "application/json" }
|
|
708
|
+
}));
|
|
709
|
+
const assetId = url.pathname.split("/")[3];
|
|
710
|
+
const contentType = instrumentedRequest.headers.get("Content-Type") ?? "application/octet-stream";
|
|
711
|
+
const r2Key = `uploads/${assetId}/${instrumentedRequest.headers.get("X-Filename") ?? "upload"}`;
|
|
712
|
+
const body = await instrumentedRequest.arrayBuffer();
|
|
713
|
+
await options.r2Bucket.put(r2Key, body, { httpMetadata: { contentType } });
|
|
714
|
+
return finish(new Response(JSON.stringify({
|
|
715
|
+
r2Key,
|
|
716
|
+
assetId
|
|
717
|
+
}), {
|
|
718
|
+
status: 200,
|
|
719
|
+
headers: { "Content-Type": "application/json" }
|
|
720
|
+
}));
|
|
721
|
+
}
|
|
722
|
+
const response = await restHandler(instrumentedRequest);
|
|
723
|
+
if (response.status < 400 && isSchemaMutationRequest(url, instrumentedRequest.method)) invalidateGraphqlSchemaCache();
|
|
724
|
+
return finish(response);
|
|
725
|
+
} catch (error) {
|
|
726
|
+
logError("worker request crashed", {
|
|
727
|
+
requestId,
|
|
728
|
+
method: instrumentedRequest.method,
|
|
729
|
+
path: new URL(instrumentedRequest.url).pathname,
|
|
730
|
+
error: describeUnknown(error)
|
|
731
|
+
});
|
|
732
|
+
return finish(new Response(JSON.stringify({ error: "Internal server error" }), {
|
|
733
|
+
status: 500,
|
|
734
|
+
headers: { "Content-Type": "application/json" }
|
|
735
|
+
}));
|
|
736
|
+
}
|
|
737
|
+
};
|
|
738
|
+
return {
|
|
739
|
+
fetch: fetchHandler,
|
|
740
|
+
async execute(query, variables, context) {
|
|
741
|
+
return (await getGraphqlInstance()).execute(query, variables, context);
|
|
742
|
+
},
|
|
743
|
+
runScheduledTransitions: runScheduledTransitions$1
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
//#endregion
|
|
747
|
+
//#region src/config-schema.ts
|
|
748
|
+
const RuntimeObject = Schema.Unknown.pipe(Schema.filter((value) => typeof value === "object" && value !== null, { message: () => "Expected runtime binding object" }));
|
|
749
|
+
const OptionalNonEmptyString = Schema.optional(Schema.NonEmptyTrimmedString);
|
|
750
|
+
const AssetBaseUrl = Schema.String.pipe(Schema.filter((value) => {
|
|
751
|
+
try {
|
|
752
|
+
new URL(value);
|
|
753
|
+
return true;
|
|
754
|
+
} catch {
|
|
755
|
+
return false;
|
|
756
|
+
}
|
|
757
|
+
}, { message: () => "assetBaseUrl must be a valid URL" }));
|
|
758
|
+
const RawCmsBindingsSchema = Schema.Struct({
|
|
759
|
+
db: RuntimeObject,
|
|
760
|
+
assets: Schema.optional(RuntimeObject),
|
|
761
|
+
environment: Schema.optional(Schema.Literal("production", "development")),
|
|
762
|
+
assetBaseUrl: Schema.optional(AssetBaseUrl),
|
|
763
|
+
writeKey: OptionalNonEmptyString,
|
|
764
|
+
ai: Schema.optional(RuntimeObject),
|
|
765
|
+
vectorize: Schema.optional(RuntimeObject),
|
|
766
|
+
r2AccessKeyId: OptionalNonEmptyString,
|
|
767
|
+
r2SecretAccessKey: OptionalNonEmptyString,
|
|
768
|
+
r2BucketName: OptionalNonEmptyString,
|
|
769
|
+
cfAccountId: OptionalNonEmptyString
|
|
770
|
+
}).pipe(Schema.filter((bindings) => {
|
|
771
|
+
return bindings.ai !== void 0 === (bindings.vectorize !== void 0);
|
|
772
|
+
}, { message: () => "ai and vectorize bindings must be configured together" }), Schema.filter((bindings) => {
|
|
773
|
+
const r2Fields = [
|
|
774
|
+
bindings.r2AccessKeyId,
|
|
775
|
+
bindings.r2SecretAccessKey,
|
|
776
|
+
bindings.r2BucketName,
|
|
777
|
+
bindings.cfAccountId
|
|
778
|
+
];
|
|
779
|
+
const presentCount = r2Fields.filter((value) => value !== void 0).length;
|
|
780
|
+
return presentCount === 0 || presentCount === r2Fields.length;
|
|
781
|
+
}, { message: () => "R2 credentials must include r2AccessKeyId, r2SecretAccessKey, r2BucketName, and cfAccountId together" }));
|
|
782
|
+
function formatConfigParseError(error) {
|
|
783
|
+
return ParseResult.ArrayFormatter.formatErrorSync(error).map((issue) => issue.path.length > 0 ? `${issue.path.join(".")}: ${issue.message}` : issue.message).join("; ");
|
|
784
|
+
}
|
|
785
|
+
function decodeCmsBindings(input) {
|
|
786
|
+
const decoded = Schema.decodeUnknownEither(RawCmsBindingsSchema)(input);
|
|
787
|
+
if (decoded._tag === "Left") throw new Error(`Invalid CMS bindings: ${formatConfigParseError(decoded.left)}`);
|
|
788
|
+
const bindings = decoded.right;
|
|
789
|
+
return {
|
|
790
|
+
db: bindings.db,
|
|
791
|
+
assets: bindings.assets,
|
|
792
|
+
environment: bindings.environment,
|
|
793
|
+
assetBaseUrl: bindings.assetBaseUrl,
|
|
794
|
+
writeKey: bindings.writeKey,
|
|
795
|
+
ai: bindings.ai,
|
|
796
|
+
vectorize: bindings.vectorize,
|
|
797
|
+
r2Credentials: bindings.r2AccessKeyId ? {
|
|
798
|
+
accessKeyId: bindings.r2AccessKeyId,
|
|
799
|
+
secretAccessKey: bindings.r2SecretAccessKey,
|
|
800
|
+
bucketName: bindings.r2BucketName,
|
|
801
|
+
accountId: bindings.cfAccountId
|
|
802
|
+
} : void 0
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
//#endregion
|
|
806
|
+
//#region src/admin-client.ts
|
|
807
|
+
function trimTrailingSlash$1(input) {
|
|
808
|
+
return input.replace(/\/$/, "");
|
|
809
|
+
}
|
|
810
|
+
async function readJsonOrError(response) {
|
|
811
|
+
if ((response.headers.get("content-type") ?? "").includes("application/json")) return response.json();
|
|
812
|
+
return { error: await response.text() };
|
|
813
|
+
}
|
|
814
|
+
function createCmsAdminClient(config) {
|
|
815
|
+
const endpoint = trimTrailingSlash$1(config.endpoint);
|
|
816
|
+
const fetchFn = config.fetch ?? globalThis.fetch;
|
|
817
|
+
async function request(path, init) {
|
|
818
|
+
const headers = new Headers(init?.headers);
|
|
819
|
+
headers.set("Authorization", `Bearer ${config.writeKey}`);
|
|
820
|
+
if (init?.body && !headers.has("Content-Type")) headers.set("Content-Type", "application/json");
|
|
821
|
+
const response = await fetchFn(`${endpoint}${path}`, {
|
|
822
|
+
...init,
|
|
823
|
+
headers
|
|
824
|
+
});
|
|
825
|
+
if (!response.ok) {
|
|
826
|
+
const errorPayload = await readJsonOrError(response);
|
|
827
|
+
const message = typeof errorPayload === "object" && errorPayload !== null && "error" in errorPayload ? String(errorPayload.error) : `Request failed with status ${response.status}`;
|
|
828
|
+
throw new Error(message);
|
|
829
|
+
}
|
|
830
|
+
return response;
|
|
831
|
+
}
|
|
832
|
+
return {
|
|
833
|
+
async createEditorToken(input) {
|
|
834
|
+
return (await request("/api/tokens", {
|
|
835
|
+
method: "POST",
|
|
836
|
+
body: JSON.stringify(input)
|
|
837
|
+
})).json();
|
|
838
|
+
},
|
|
839
|
+
async listEditorTokens() {
|
|
840
|
+
return (await request("/api/tokens")).json();
|
|
841
|
+
},
|
|
842
|
+
async revokeEditorToken(id) {
|
|
843
|
+
return (await request(`/api/tokens/${id}`, { method: "DELETE" })).json();
|
|
844
|
+
}
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
//#endregion
|
|
848
|
+
//#region src/editor-mcp-proxy.ts
|
|
849
|
+
function trimTrailingSlash(input) {
|
|
850
|
+
return input.replace(/\/$/, "");
|
|
851
|
+
}
|
|
852
|
+
function ensureLeadingSlash(input) {
|
|
853
|
+
return input.startsWith("/") ? input : `/${input}`;
|
|
854
|
+
}
|
|
855
|
+
function base64UrlEncodeBytes(bytes) {
|
|
856
|
+
return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
|
857
|
+
}
|
|
858
|
+
function base64UrlEncodeText(input) {
|
|
859
|
+
return base64UrlEncodeBytes(new TextEncoder().encode(input));
|
|
860
|
+
}
|
|
861
|
+
function base64UrlDecodeText(input) {
|
|
862
|
+
const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
|
|
863
|
+
const padded = normalized + "=".repeat((4 - normalized.length % 4) % 4);
|
|
864
|
+
const decoded = atob(padded);
|
|
865
|
+
const bytes = Uint8Array.from(decoded, (char) => char.charCodeAt(0));
|
|
866
|
+
return new TextDecoder().decode(bytes);
|
|
867
|
+
}
|
|
868
|
+
async function importHmacKey(secret) {
|
|
869
|
+
return crypto.subtle.importKey("raw", new TextEncoder().encode(secret), {
|
|
870
|
+
name: "HMAC",
|
|
871
|
+
hash: "SHA-256"
|
|
872
|
+
}, false, ["sign", "verify"]);
|
|
873
|
+
}
|
|
874
|
+
async function signJwt(secret, claims) {
|
|
875
|
+
const key = await importHmacKey(secret);
|
|
876
|
+
const message = `${base64UrlEncodeText(JSON.stringify({
|
|
877
|
+
alg: "HS256",
|
|
878
|
+
typ: "JWT"
|
|
879
|
+
}))}.${base64UrlEncodeText(JSON.stringify(claims))}`;
|
|
880
|
+
const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(message));
|
|
881
|
+
return `${message}.${base64UrlEncodeBytes(new Uint8Array(signature))}`;
|
|
882
|
+
}
|
|
883
|
+
async function verifyJwt(secret, token) {
|
|
884
|
+
const [header, payload, signature] = token.split(".");
|
|
885
|
+
if (!header || !payload || !signature) return null;
|
|
886
|
+
const key = await importHmacKey(secret);
|
|
887
|
+
const message = `${header}.${payload}`;
|
|
888
|
+
const signatureBytes = Uint8Array.from(atob(signature.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - signature.length % 4) % 4)), (char) => char.charCodeAt(0));
|
|
889
|
+
if (!await crypto.subtle.verify("HMAC", key, signatureBytes, new TextEncoder().encode(message))) return null;
|
|
890
|
+
const claims = JSON.parse(base64UrlDecodeText(payload));
|
|
891
|
+
if (claims.exp <= Math.floor(Date.now() / 1e3)) return null;
|
|
892
|
+
return claims;
|
|
893
|
+
}
|
|
894
|
+
async function sha256Base64Url(input) {
|
|
895
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input));
|
|
896
|
+
return base64UrlEncodeBytes(new Uint8Array(digest));
|
|
897
|
+
}
|
|
898
|
+
function jsonResponse(body, status = 200) {
|
|
899
|
+
return new Response(JSON.stringify(body), {
|
|
900
|
+
status,
|
|
901
|
+
headers: { "Content-Type": "application/json" }
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
function redirectResponse(url) {
|
|
905
|
+
return new Response(null, {
|
|
906
|
+
status: 302,
|
|
907
|
+
headers: { Location: url.toString() }
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
function unauthorizedForMcp(resourceMetadataUrl) {
|
|
911
|
+
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
|
912
|
+
status: 401,
|
|
913
|
+
headers: {
|
|
914
|
+
"Content-Type": "application/json",
|
|
915
|
+
"WWW-Authenticate": `Bearer resource_metadata="${resourceMetadataUrl}"`
|
|
916
|
+
}
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
function cloneHeadersWithoutAuthorization(headers) {
|
|
920
|
+
const cloned = new Headers(headers);
|
|
921
|
+
cloned.delete("authorization");
|
|
922
|
+
return cloned;
|
|
923
|
+
}
|
|
924
|
+
function parseBearerToken(request) {
|
|
925
|
+
const header = request.headers.get("authorization");
|
|
926
|
+
if (!header) return null;
|
|
927
|
+
return header.startsWith("Bearer ") ? header.slice(7) : header;
|
|
928
|
+
}
|
|
929
|
+
function appendPathToUrl(base, path) {
|
|
930
|
+
return `${trimTrailingSlash(base)}${ensureLeadingSlash(path)}`;
|
|
931
|
+
}
|
|
932
|
+
function getPathname(request) {
|
|
933
|
+
return new URL(request.url).pathname;
|
|
934
|
+
}
|
|
935
|
+
function createEditorMcpProxy(config) {
|
|
936
|
+
const appBaseUrl = trimTrailingSlash(config.appBaseUrl);
|
|
937
|
+
const cmsBaseUrl = trimTrailingSlash(config.cmsBaseUrl);
|
|
938
|
+
const mountPath = ensureLeadingSlash(config.mountPath ?? "/editor-access");
|
|
939
|
+
const fetchFn = config.fetch ?? globalThis.fetch;
|
|
940
|
+
const oauthTokenTtlSeconds = config.oauthTokenTtlSeconds ?? 3600;
|
|
941
|
+
const cmsTokenTtlSeconds = config.cmsTokenTtlSeconds ?? 3600;
|
|
942
|
+
const issuer = appendPathToUrl(appBaseUrl, mountPath);
|
|
943
|
+
const paths = {
|
|
944
|
+
mountPath,
|
|
945
|
+
issuer,
|
|
946
|
+
mcpPath: `${mountPath}/mcp`,
|
|
947
|
+
authorizationPath: `${mountPath}/authorize`,
|
|
948
|
+
tokenPath: `${mountPath}/token`,
|
|
949
|
+
registrationPath: `${mountPath}/register`,
|
|
950
|
+
oauthAuthorizationServerMetadataPath: `/.well-known/oauth-authorization-server${mountPath}`,
|
|
951
|
+
protectedResourceMetadataPath: `/.well-known/oauth-protected-resource${mountPath}/mcp`
|
|
952
|
+
};
|
|
953
|
+
const resourceUrl = appendPathToUrl(appBaseUrl, paths.mcpPath);
|
|
954
|
+
const protectedResourceMetadataUrl = appendPathToUrl(appBaseUrl, paths.protectedResourceMetadataPath);
|
|
955
|
+
const cmsAdmin = createCmsAdminClient({
|
|
956
|
+
endpoint: cmsBaseUrl,
|
|
957
|
+
writeKey: config.cmsWriteKey,
|
|
958
|
+
fetch: fetchFn
|
|
959
|
+
});
|
|
960
|
+
async function handleAuthorize(request) {
|
|
961
|
+
const editor = await config.getEditor(request);
|
|
962
|
+
if (!editor) {
|
|
963
|
+
const loginUrl = new URL(config.getLoginUrl(request), appBaseUrl);
|
|
964
|
+
loginUrl.searchParams.set("returnTo", request.url);
|
|
965
|
+
return redirectResponse(loginUrl);
|
|
966
|
+
}
|
|
967
|
+
const url = new URL(request.url);
|
|
968
|
+
const responseType = url.searchParams.get("response_type");
|
|
969
|
+
const clientId = url.searchParams.get("client_id");
|
|
970
|
+
const redirectUri = url.searchParams.get("redirect_uri");
|
|
971
|
+
const codeChallenge = url.searchParams.get("code_challenge");
|
|
972
|
+
const codeChallengeMethod = url.searchParams.get("code_challenge_method");
|
|
973
|
+
const state = url.searchParams.get("state");
|
|
974
|
+
const resource = url.searchParams.get("resource") ?? resourceUrl;
|
|
975
|
+
const scope = url.searchParams.get("scope") ?? "editor:mcp";
|
|
976
|
+
if (responseType !== "code" || !clientId || !redirectUri || !codeChallenge || codeChallengeMethod !== "S256") return jsonResponse({ error: "Invalid authorization request" }, 400);
|
|
977
|
+
const cmsToken = await cmsAdmin.createEditorToken({
|
|
978
|
+
name: editor.name,
|
|
979
|
+
expiresIn: cmsTokenTtlSeconds
|
|
980
|
+
});
|
|
981
|
+
const code = await signJwt(config.oauthSecret, {
|
|
982
|
+
sub: editor.id,
|
|
983
|
+
name: editor.name,
|
|
984
|
+
client_id: clientId,
|
|
985
|
+
redirect_uri: redirectUri,
|
|
986
|
+
code_challenge: codeChallenge,
|
|
987
|
+
code_challenge_method: "S256",
|
|
988
|
+
resource,
|
|
989
|
+
scope,
|
|
990
|
+
cms_token: cmsToken.token,
|
|
991
|
+
exp: Math.floor(Date.now() / 1e3) + 120
|
|
992
|
+
});
|
|
993
|
+
const redirectUrl = new URL(redirectUri);
|
|
994
|
+
redirectUrl.searchParams.set("code", code);
|
|
995
|
+
if (state) redirectUrl.searchParams.set("state", state);
|
|
996
|
+
return redirectResponse(redirectUrl);
|
|
997
|
+
}
|
|
998
|
+
async function handleToken(request) {
|
|
999
|
+
const body = await request.formData();
|
|
1000
|
+
const grantType = body.get("grant_type");
|
|
1001
|
+
const code = body.get("code");
|
|
1002
|
+
const clientId = body.get("client_id");
|
|
1003
|
+
const redirectUri = body.get("redirect_uri");
|
|
1004
|
+
const codeVerifier = body.get("code_verifier");
|
|
1005
|
+
if (grantType !== "authorization_code" || typeof code !== "string" || typeof clientId !== "string" || typeof redirectUri !== "string" || typeof codeVerifier !== "string") return jsonResponse({ error: "invalid_request" }, 400);
|
|
1006
|
+
const claims = await verifyJwt(config.oauthSecret, code);
|
|
1007
|
+
if (!claims || claims.client_id !== clientId || claims.redirect_uri !== redirectUri || await sha256Base64Url(codeVerifier) !== claims.code_challenge) return jsonResponse({ error: "invalid_grant" }, 400);
|
|
1008
|
+
return jsonResponse({
|
|
1009
|
+
access_token: await signJwt(config.oauthSecret, {
|
|
1010
|
+
sub: claims.sub,
|
|
1011
|
+
name: claims.name,
|
|
1012
|
+
resource: claims.resource,
|
|
1013
|
+
scope: claims.scope,
|
|
1014
|
+
cms_token: claims.cms_token,
|
|
1015
|
+
exp: Math.floor(Date.now() / 1e3) + oauthTokenTtlSeconds
|
|
1016
|
+
}),
|
|
1017
|
+
token_type: "Bearer",
|
|
1018
|
+
expires_in: oauthTokenTtlSeconds,
|
|
1019
|
+
scope: claims.scope
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1022
|
+
async function handleRegister(request) {
|
|
1023
|
+
const metadata = await request.json();
|
|
1024
|
+
const clientId = metadata.client_id || `editor-mcp-${crypto.randomUUID()}`;
|
|
1025
|
+
return jsonResponse({
|
|
1026
|
+
...metadata,
|
|
1027
|
+
client_id: clientId,
|
|
1028
|
+
client_id_issued_at: Math.floor(Date.now() / 1e3),
|
|
1029
|
+
token_endpoint_auth_method: "none",
|
|
1030
|
+
grant_types: ["authorization_code"],
|
|
1031
|
+
response_types: ["code"]
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
async function handleMcp(request) {
|
|
1035
|
+
const accessToken = parseBearerToken(request);
|
|
1036
|
+
if (!accessToken) return unauthorizedForMcp(protectedResourceMetadataUrl);
|
|
1037
|
+
const claims = await verifyJwt(config.oauthSecret, accessToken);
|
|
1038
|
+
if (!claims || claims.resource !== resourceUrl) return unauthorizedForMcp(protectedResourceMetadataUrl);
|
|
1039
|
+
const upstreamUrl = new URL(cmsBaseUrl + "/mcp/editor");
|
|
1040
|
+
const upstreamRequest = new Request(upstreamUrl, {
|
|
1041
|
+
method: request.method,
|
|
1042
|
+
headers: cloneHeadersWithoutAuthorization(request.headers),
|
|
1043
|
+
body: request.body,
|
|
1044
|
+
duplex: "half",
|
|
1045
|
+
redirect: "manual"
|
|
1046
|
+
});
|
|
1047
|
+
upstreamRequest.headers.set("Authorization", `Bearer ${claims.cms_token}`);
|
|
1048
|
+
return fetchFn(upstreamRequest);
|
|
1049
|
+
}
|
|
1050
|
+
async function handle(request) {
|
|
1051
|
+
const pathname = getPathname(request);
|
|
1052
|
+
if (pathname === paths.oauthAuthorizationServerMetadataPath) return jsonResponse({
|
|
1053
|
+
issuer,
|
|
1054
|
+
authorization_endpoint: appendPathToUrl(appBaseUrl, paths.authorizationPath),
|
|
1055
|
+
token_endpoint: appendPathToUrl(appBaseUrl, paths.tokenPath),
|
|
1056
|
+
registration_endpoint: appendPathToUrl(appBaseUrl, paths.registrationPath),
|
|
1057
|
+
response_types_supported: ["code"],
|
|
1058
|
+
grant_types_supported: ["authorization_code"],
|
|
1059
|
+
token_endpoint_auth_methods_supported: ["none"],
|
|
1060
|
+
code_challenge_methods_supported: ["S256"],
|
|
1061
|
+
scopes_supported: ["editor:mcp"]
|
|
1062
|
+
});
|
|
1063
|
+
if (pathname === paths.protectedResourceMetadataPath) return jsonResponse({
|
|
1064
|
+
resource: resourceUrl,
|
|
1065
|
+
authorization_servers: [issuer],
|
|
1066
|
+
bearer_methods_supported: ["header"],
|
|
1067
|
+
scopes_supported: ["editor:mcp"],
|
|
1068
|
+
resource_name: config.resourceName ?? "agent-cms editor MCP proxy"
|
|
1069
|
+
});
|
|
1070
|
+
if (pathname === paths.authorizationPath) return handleAuthorize(request);
|
|
1071
|
+
if (pathname === paths.tokenPath && request.method === "POST") return handleToken(request);
|
|
1072
|
+
if (pathname === paths.registrationPath && request.method === "POST") return handleRegister(request);
|
|
1073
|
+
if (pathname === paths.mcpPath) return handleMcp(request);
|
|
1074
|
+
return new Response("Not found", { status: 404 });
|
|
1075
|
+
}
|
|
1076
|
+
return {
|
|
1077
|
+
paths,
|
|
1078
|
+
fetch: handle
|
|
1079
|
+
};
|
|
1080
|
+
}
|
|
1081
|
+
//#endregion
|
|
1082
|
+
//#region src/index.ts
|
|
1083
|
+
const objectIds = /* @__PURE__ */ new WeakMap();
|
|
1084
|
+
let nextObjectId = 1;
|
|
1085
|
+
const handlerCache = /* @__PURE__ */ new Map();
|
|
1086
|
+
function getObjectId(value) {
|
|
1087
|
+
if (!value) return 0;
|
|
1088
|
+
const existing = objectIds.get(value);
|
|
1089
|
+
if (existing) return existing;
|
|
1090
|
+
const id = nextObjectId++;
|
|
1091
|
+
objectIds.set(value, id);
|
|
1092
|
+
return id;
|
|
1093
|
+
}
|
|
1094
|
+
function cacheKey(bindings, hooks) {
|
|
1095
|
+
return [
|
|
1096
|
+
getObjectId(bindings.db),
|
|
1097
|
+
getObjectId(bindings.assets),
|
|
1098
|
+
getObjectId(bindings.ai),
|
|
1099
|
+
getObjectId(bindings.vectorize),
|
|
1100
|
+
getObjectId(hooks),
|
|
1101
|
+
bindings.environment ?? "",
|
|
1102
|
+
bindings.assetBaseUrl ?? "",
|
|
1103
|
+
bindings.writeKey ?? "",
|
|
1104
|
+
bindings.r2Credentials?.accessKeyId ?? "",
|
|
1105
|
+
bindings.r2Credentials?.bucketName ?? "",
|
|
1106
|
+
bindings.r2Credentials?.accountId ?? ""
|
|
1107
|
+
].join("|");
|
|
1108
|
+
}
|
|
1109
|
+
/**
|
|
1110
|
+
* Create the agent-cms fetch handler.
|
|
1111
|
+
*
|
|
1112
|
+
* Usage in your Worker's src/index.ts:
|
|
1113
|
+
* ```typescript
|
|
1114
|
+
* import { createCMSHandler } from "agent-cms";
|
|
1115
|
+
*
|
|
1116
|
+
* export default {
|
|
1117
|
+
* fetch: (request, env) => getHandler(env).fetch(request),
|
|
1118
|
+
* scheduled: (_controller, env) => getHandler(env).runScheduledTransitions(),
|
|
1119
|
+
* };
|
|
1120
|
+
*
|
|
1121
|
+
* let cachedHandler: ReturnType<typeof createCMSHandler> | null = null;
|
|
1122
|
+
*
|
|
1123
|
+
* function getHandler(env: Env) {
|
|
1124
|
+
* if (!cachedHandler) {
|
|
1125
|
+
* cachedHandler = createCMSHandler({
|
|
1126
|
+
* bindings: {
|
|
1127
|
+
* db: env.DB,
|
|
1128
|
+
* assets: env.ASSETS,
|
|
1129
|
+
* environment: env.ENVIRONMENT,
|
|
1130
|
+
* assetBaseUrl: env.ASSET_BASE_URL,
|
|
1131
|
+
* writeKey: env.CMS_WRITE_KEY,
|
|
1132
|
+
* ai: env.AI,
|
|
1133
|
+
* vectorize: env.VECTORIZE,
|
|
1134
|
+
* },
|
|
1135
|
+
* });
|
|
1136
|
+
* }
|
|
1137
|
+
* return cachedHandler;
|
|
1138
|
+
* }
|
|
1139
|
+
* ```
|
|
1140
|
+
*/
|
|
1141
|
+
function createCMSHandler(config) {
|
|
1142
|
+
const decodedBindings = decodeCmsBindings(config.bindings);
|
|
1143
|
+
const key = cacheKey(decodedBindings, config.hooks);
|
|
1144
|
+
const cached = handlerCache.get(key);
|
|
1145
|
+
if (cached) return cached;
|
|
1146
|
+
const handler = createCMSHandlerUncached(decodedBindings, config.hooks);
|
|
1147
|
+
handlerCache.set(key, handler);
|
|
1148
|
+
return handler;
|
|
1149
|
+
}
|
|
1150
|
+
function createCMSHandlerUncached(bindings, hooks) {
|
|
1151
|
+
const webHandler = createWebHandler(D1Client.layer({ db: bindings.db }).pipe(Layer.orDie), {
|
|
1152
|
+
assetBaseUrl: bindings.assetBaseUrl,
|
|
1153
|
+
isProduction: bindings.environment === "production",
|
|
1154
|
+
writeKey: bindings.writeKey,
|
|
1155
|
+
r2Bucket: bindings.assets,
|
|
1156
|
+
ai: bindings.ai,
|
|
1157
|
+
vectorize: bindings.vectorize,
|
|
1158
|
+
hooks,
|
|
1159
|
+
r2Credentials: bindings.r2Credentials
|
|
1160
|
+
});
|
|
1161
|
+
return {
|
|
1162
|
+
fetch: (request) => webHandler.fetch(request),
|
|
1163
|
+
execute: webHandler.execute,
|
|
1164
|
+
runScheduledTransitions: (now) => webHandler.runScheduledTransitions(now)
|
|
1165
|
+
};
|
|
1166
|
+
}
|
|
1167
|
+
//#endregion
|
|
1168
|
+
export { createCMSHandler, createCmsAdminClient, createEditorMcpProxy };
|
|
1169
|
+
|
|
1170
|
+
//# sourceMappingURL=index.mjs.map
|