@mediamonks/canopy-cli 0.7.42 → 0.7.43
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/dist/{ai-DpQ1nLka.mjs → ai-FBHTSCLZ.mjs} +2 -2
- package/dist/{api-Do9c8tZ_.mjs → api-Bl6SG8Oq.mjs} +2 -2
- package/dist/{browse-Dp5HytqK.mjs → browse-dyOThTyf.mjs} +1 -1
- package/dist/{db-BNsX7sXo.mjs → db-BJJGydgL.mjs} +3 -3
- package/dist/{deploy-CXMLABvH.mjs → deploy-ExrIeX_m.mjs} +22 -3
- package/dist/dev-BmI5kwoG.mjs +2 -0
- package/dist/{dev-CPR-9slK.mjs → dev-adLLSY1F.mjs} +7 -7
- package/dist/{dev-worker-D78leKBJ.mjs → dev-worker-DJekKSdb.mjs} +1 -1
- package/dist/docs/developers-architecture.md +12 -0
- package/dist/docs/platform-egress.md +14 -7
- package/dist/docs/static-serving.md +13 -4
- package/dist/{docs-CkJGcCpR.mjs → docs-CaAgJWK3.mjs} +3 -3
- package/dist/{egress-D6qyaaAn.mjs → egress-C2amvqmE.mjs} +2 -2
- package/dist/{feedback-DMgUUl0V.mjs → feedback-CMnG2Zvs.mjs} +3 -3
- package/dist/index.mjs +32 -32
- package/dist/{local-w2wXfE1d.mjs → local-2xtxtoUd.mjs} +3 -3
- package/dist/{login-CeNvHLUv.mjs → login-CREcZfLH.mjs} +2 -2
- package/dist/{logs-BzgJfWNP.mjs → logs-BnrNZvLc.mjs} +2 -2
- package/dist/{mcp-DFLaXiY_.mjs → mcp-BVIU4j9N.mjs} +2 -2
- package/dist/{migrations-Bsu4quya.mjs → migrations-D8NqjC5I.mjs} +1 -1
- package/dist/{open-D71Ms1nG.mjs → open-Bpz4C2Sr.mjs} +1 -1
- package/dist/{package-CVDpzGgA.mjs → package-XOtqboS1.mjs} +1 -1
- package/dist/project-template-715H_lnu.mjs +2 -0
- package/dist/{project-template-BXfHpsI8.mjs → project-template-Cdr1uwwE.mjs} +2 -2
- package/dist/{projects-BWTIk80a.mjs → projects-BRG2HnBs.mjs} +4 -4
- package/dist/{rename-BdIFrqvG.mjs → rename-BGKa25Rq.mjs} +2 -2
- package/dist/{repo-LSOxnmUk.mjs → repo-CwT2RDoW.mjs} +2 -2
- package/dist/repo-DmFs6H9m.mjs +2 -0
- package/dist/{schedule-DpMIpCGC.mjs → schedule-BcqBspn3.mjs} +2 -2
- package/dist/{secrets-BVx8L0Gj.mjs → secrets-BjbIi7Gp.mjs} +2 -2
- package/dist/{skill-D58yV6K8.mjs → skill-Ct-aCOzg.mjs} +2 -2
- package/dist/{src-RUf3Gfuh.mjs → src-CScXotBB.mjs} +727 -1
- package/dist/{staging-ynPlMiHW.mjs → staging-_2Js0Fkc.mjs} +2 -2
- package/dist/{token-CxiPrKn_.mjs → token-7vSNuz-X.mjs} +2 -2
- package/dist/{ui-CDUJq-X-.mjs → ui-CLnICOne.mjs} +1 -1
- package/dist/{upgrade-Dll5rhOQ.mjs → upgrade-DJogeXAF.mjs} +2 -2
- package/dist/{usage-BtPsb7Iu.mjs → usage-DGGmTwjJ.mjs} +2 -2
- package/package.json +1 -1
- package/dist/dev-Dj3wFKHe.mjs +0 -2
- package/dist/project-template-BbmSmraT.mjs +0 -2
- package/dist/repo-JBKJqyoS.mjs +0 -2
|
@@ -839,7 +839,7 @@ function realtimeRouter(config) {
|
|
|
839
839
|
* How a project's backend addresses the platform — the one definition.
|
|
840
840
|
*
|
|
841
841
|
* Three hosts run project workers and each builds this request itself: the
|
|
842
|
-
* Workers-for-Platforms shim (
|
|
842
|
+
* Workers-for-Platforms shim (packages/data-plane/src/worker-shim.ts), the Node
|
|
843
843
|
* runtime (apps/project-runtime/src/canopy-fetch.ts), and `canopy dev`
|
|
844
844
|
* (packages/cli/src/dev-worker.ts). They had three copies of the header
|
|
845
845
|
* shaping, and the two that hold a worker token had two copies of the
|
|
@@ -931,5 +931,731 @@ function stampWorkerHeaders(headers, ctx) {
|
|
|
931
931
|
else headers.delete("x-canopy-channel");
|
|
932
932
|
return headers;
|
|
933
933
|
}
|
|
934
|
+
String.raw`// Canopy Workers-for-Platforms shim (generated by canopy-api — do not edit).
|
|
935
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
936
|
+
import worker from "./bundle.mjs";
|
|
937
|
+
|
|
938
|
+
// Platform bindings the user env gets wrapped rather than raw. Hiding them is
|
|
939
|
+
// tidiness, not protection: each is the project's own or scoped outside this
|
|
940
|
+
// isolate (see the header).
|
|
941
|
+
const INTERNAL_KEYS = new Set(["__CANOPY_DB", "__CANOPY_BLOB", "__CANOPY_API_URL", "__CANOPY_GATEWAY"]);
|
|
942
|
+
|
|
943
|
+
// The sealed credential the gateway put on the inbound request in place of
|
|
944
|
+
// the visitor's Canopy cookies / agent token. Only ProjectGateway opens it.
|
|
945
|
+
const CREDENTIAL_HANDLE_HEADER = "x-canopy-credential-handle";
|
|
946
|
+
|
|
947
|
+
// Workers Logs flattens console args and drops an Error's message line —
|
|
948
|
+
// log the stack as a plain string (its first line is "Error: <message>") so
|
|
949
|
+
// the entry matches what Node's console shows on GCP.
|
|
950
|
+
function describeError(err) {
|
|
951
|
+
return err && err.stack ? String(err.stack) : String(err);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// Transcribed from IDENTITY_STAMP_HEADERS in @mediamonks/canopy-data-plane.
|
|
955
|
+
const IDENTITY_STAMP_HEADERS = [
|
|
956
|
+
"x-canopy-user-sub",
|
|
957
|
+
"x-canopy-user-email",
|
|
958
|
+
"x-canopy-user-name",
|
|
959
|
+
"x-canopy-identity-kind",
|
|
960
|
+
"x-canopy-identity-id",
|
|
961
|
+
"x-canopy-identity-email",
|
|
962
|
+
"x-canopy-identity-name",
|
|
963
|
+
];
|
|
964
|
+
|
|
965
|
+
function userFromHeaders(headers) {
|
|
966
|
+
const sub = (headers.get("x-canopy-user-sub") || "").trim();
|
|
967
|
+
if (!sub) return null;
|
|
968
|
+
return {
|
|
969
|
+
sub,
|
|
970
|
+
email: (headers.get("x-canopy-user-email") || "").trim(),
|
|
971
|
+
name: (headers.get("x-canopy-user-name") || "").trim(),
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
// env.CALLER (worker-caller-identity) — transcribed from
|
|
976
|
+
// project-runtime/src/user.ts: everyone the wall let through, client-password
|
|
977
|
+
// visitors included, from headers older shims never read.
|
|
978
|
+
function callerFromHeaders(headers) {
|
|
979
|
+
const kind = (headers.get("x-canopy-identity-kind") || "").trim();
|
|
980
|
+
const id = (headers.get("x-canopy-identity-id") || "").trim();
|
|
981
|
+
if ((kind !== "monk" && kind !== "external") || !id) return null;
|
|
982
|
+
return {
|
|
983
|
+
kind,
|
|
984
|
+
id,
|
|
985
|
+
email: (headers.get("x-canopy-identity-email") || "").trim(),
|
|
986
|
+
name: (headers.get("x-canopy-identity-name") || "").trim(),
|
|
987
|
+
agent: headers.get("x-canopy-caller") === "agent",
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
function callerFromUser(user) {
|
|
992
|
+
return user ? { kind: "monk", id: user.sub, email: user.email, name: user.name, agent: false } : null;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
function rewriteCanopyUrl(raw, apiUrl) {
|
|
996
|
+
const base = apiUrl.replace(/\/$/, "");
|
|
997
|
+
let path;
|
|
998
|
+
let search = "";
|
|
999
|
+
try {
|
|
1000
|
+
const u = new URL(raw, "http://canopy.local");
|
|
1001
|
+
path = u.pathname;
|
|
1002
|
+
search = u.search;
|
|
1003
|
+
} catch {
|
|
1004
|
+
path = raw.startsWith("/") ? raw : "/" + raw;
|
|
1005
|
+
}
|
|
1006
|
+
if (path.startsWith("/_api/")) path = path.slice("/_api".length);
|
|
1007
|
+
// The data plane is the only plane a project may address (R-01): a
|
|
1008
|
+
// /control/v1 path folds under /data/v1, where it 404s instead of running a
|
|
1009
|
+
// control-plane call as whoever is visiting. isControlPlanePath refuses it
|
|
1010
|
+
// before it gets here; this is the belt to that brace.
|
|
1011
|
+
if (!path.startsWith("/data/v1/")) {
|
|
1012
|
+
path = "/data/v1" + (path.startsWith("/") ? path : "/" + path);
|
|
1013
|
+
}
|
|
1014
|
+
return base + path + search;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// Whether a project asked for the control plane, which is never theirs to call.
|
|
1018
|
+
function isControlPlanePath(raw) {
|
|
1019
|
+
let path;
|
|
1020
|
+
try {
|
|
1021
|
+
path = new URL(raw, "http://canopy.local").pathname;
|
|
1022
|
+
} catch {
|
|
1023
|
+
path = raw.startsWith("/") ? raw : "/" + raw;
|
|
1024
|
+
}
|
|
1025
|
+
if (path.startsWith("/_api/")) path = path.slice("/_api".length);
|
|
1026
|
+
return path === "/control/v1" || path.startsWith("/control/v1/");
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// Canopy's own cookies — platform credentials, never tenant-readable (R-01).
|
|
1030
|
+
const CANOPY_COOKIE_NAMES = ["canopy_session", "canopy_client", "canopy_agent"];
|
|
1031
|
+
|
|
1032
|
+
// Strip Canopy's credentials from the request the bundle gets — a
|
|
1033
|
+
// transcription of redactCanopyCredentials in @mediamonks/canopy-data-plane,
|
|
1034
|
+
// plus the credential handle. A sealed backend's request carries no raw
|
|
1035
|
+
// credential to begin with (the gateway sealed it); this keeps ordinary code
|
|
1036
|
+
// from seeing even the handle. An app's own cookies and its own Authorization
|
|
1037
|
+
// scheme survive untouched.
|
|
1038
|
+
function redactCanopyCredentials(headers) {
|
|
1039
|
+
const cookie = headers.get("cookie");
|
|
1040
|
+
if (cookie !== null) {
|
|
1041
|
+
const parts = cookie.split(";");
|
|
1042
|
+
const kept = [];
|
|
1043
|
+
for (const part of parts) {
|
|
1044
|
+
const name = part.split("=")[0].trim();
|
|
1045
|
+
if (name !== "" && CANOPY_COOKIE_NAMES.indexOf(name) === -1) kept.push(part);
|
|
1046
|
+
}
|
|
1047
|
+
const rest = kept.join(";").trim();
|
|
1048
|
+
if (rest === "") headers.delete("cookie");
|
|
1049
|
+
else headers.set("cookie", rest);
|
|
1050
|
+
}
|
|
1051
|
+
const auth = headers.get("authorization");
|
|
1052
|
+
if (auth && auth.startsWith("Bearer cnpy_")) headers.delete("authorization");
|
|
1053
|
+
// A tools call's agent token (docs/49) is a credential like the cookie.
|
|
1054
|
+
headers.delete("x-canopy-agent-credential");
|
|
1055
|
+
headers.delete(CREDENTIAL_HANDLE_HEADER);
|
|
1056
|
+
return headers;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
// upstream is the __CANOPY_GATEWAY service binding to the gateway's
|
|
1060
|
+
// ProjectGateway entrypoint: a plain fetch to the api host would be a
|
|
1061
|
+
// same-zone subrequest, which Cloudflare sends to the zone's origin (a dummy A
|
|
1062
|
+
// record) instead of back into the gateway. The global-fetch fallback only
|
|
1063
|
+
// exists for hosts without the binding (tests).
|
|
1064
|
+
//
|
|
1065
|
+
// The entrypoint does the rest from props canopy-api set at upload: forces
|
|
1066
|
+
// the project and channel, refuses anything but /data/v1, opens the handle
|
|
1067
|
+
// and applies the one credential rule (applyWorkerCredentials) with the
|
|
1068
|
+
// worker token, which is never in this isolate. All this side sends is the
|
|
1069
|
+
// call, the handle the request arrived with, and its traceparent.
|
|
1070
|
+
function createCanopyFetch(apiUrl, inboundHeaders, upstream) {
|
|
1071
|
+
const fetchImpl =
|
|
1072
|
+
upstream && typeof upstream.fetch === "function" ? upstream.fetch.bind(upstream) : fetch;
|
|
1073
|
+
return async (input, init) => {
|
|
1074
|
+
const rawUrl =
|
|
1075
|
+
typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
1076
|
+
if (isControlPlanePath(rawUrl)) {
|
|
1077
|
+
return new Response(
|
|
1078
|
+
JSON.stringify({
|
|
1079
|
+
error: "control_plane_forbidden",
|
|
1080
|
+
detail:
|
|
1081
|
+
"env.CANOPY.fetch reaches the data plane only — /control/v1 is not available to project code",
|
|
1082
|
+
}),
|
|
1083
|
+
{ status: 403, headers: { "content-type": "application/json" } },
|
|
1084
|
+
);
|
|
1085
|
+
}
|
|
1086
|
+
const url = rewriteCanopyUrl(rawUrl, apiUrl);
|
|
1087
|
+
|
|
1088
|
+
const headers = new Headers(input instanceof Request ? input.headers : init && init.headers);
|
|
1089
|
+
headers.delete("host");
|
|
1090
|
+
// Identity stamps are for the worker env only — never relay to canopy-api.
|
|
1091
|
+
for (const name of IDENTITY_STAMP_HEADERS) headers.delete(name);
|
|
1092
|
+
// Credentials are the platform's to choose, from the handle alone; nothing
|
|
1093
|
+
// the bundle sets here is ever one.
|
|
1094
|
+
headers.delete("cookie");
|
|
1095
|
+
headers.delete("authorization");
|
|
1096
|
+
headers.delete("x-canopy-agent-credential");
|
|
1097
|
+
headers.delete("x-canopy-worker-attest");
|
|
1098
|
+
const handle = inboundHeaders.get(CREDENTIAL_HANDLE_HEADER);
|
|
1099
|
+
if (handle) headers.set(CREDENTIAL_HANDLE_HEADER, handle);
|
|
1100
|
+
else headers.delete(CREDENTIAL_HANDLE_HEADER);
|
|
1101
|
+
// Trace continuity (docs/42 §3.1): the inbound platform traceparent wins;
|
|
1102
|
+
// the bundle never supplies one.
|
|
1103
|
+
const traceparent = inboundHeaders.get("traceparent");
|
|
1104
|
+
if (traceparent) headers.set("traceparent", traceparent);
|
|
1105
|
+
else headers.delete("traceparent");
|
|
1106
|
+
|
|
1107
|
+
const method = (input instanceof Request ? input.method : init && init.method) || "GET";
|
|
1108
|
+
const body = input instanceof Request ? input.body : init && init.body;
|
|
1109
|
+
const outbound = {
|
|
1110
|
+
method,
|
|
1111
|
+
headers,
|
|
1112
|
+
body: body || undefined,
|
|
1113
|
+
redirect: input instanceof Request ? input.redirect : init && init.redirect,
|
|
1114
|
+
};
|
|
1115
|
+
if (body) outbound.duplex = "half";
|
|
1116
|
+
return fetchImpl(url, outbound);
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
// env.DB — one query() contract on every host. The dialect field mirrors
|
|
1121
|
+
// canopy.db.dialect() (D15): raw SQL is the target's own. D1 carries no
|
|
1122
|
+
// dialect, so it reads as sqlite; the Google Cloud runtime's binding says
|
|
1123
|
+
// postgres.
|
|
1124
|
+
function wrapDb(d1) {
|
|
1125
|
+
if (!d1) return null;
|
|
1126
|
+
return {
|
|
1127
|
+
dialect: d1.dialect || "sqlite",
|
|
1128
|
+
async query(text, params) {
|
|
1129
|
+
const res = await d1
|
|
1130
|
+
.prepare(text)
|
|
1131
|
+
.bind(...(params || []))
|
|
1132
|
+
.all();
|
|
1133
|
+
const rows = res.results || [];
|
|
1134
|
+
const changes = res.meta && typeof res.meta.changes === "number" ? res.meta.changes : 0;
|
|
1135
|
+
// pg parity: returned rows for reads, affected rows for writes.
|
|
1136
|
+
return { rows, rowCount: rows.length > 0 ? rows.length : changes };
|
|
1137
|
+
},
|
|
1138
|
+
};
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
// Collapse traversal/empty segments and pin the key under the prefix
|
|
1142
|
+
// (port of project-runtime/src/blob.ts normalizeBlobKey).
|
|
1143
|
+
function normalizeBlobKey(prefix, key) {
|
|
1144
|
+
const cleaned = String(key)
|
|
1145
|
+
.replace(/\\/g, "/")
|
|
1146
|
+
.split("/")
|
|
1147
|
+
.filter((part) => part && part !== "." && part !== "..")
|
|
1148
|
+
.join("/");
|
|
1149
|
+
return prefix + cleaned;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
// env.BLOB — the channel's own bucket, every key forced under the channel's
|
|
1153
|
+
// prefix so keys keep the shape the platform's blob routes use. The bucket
|
|
1154
|
+
// holds nothing but this channel's objects, so the raw binding is no prize.
|
|
1155
|
+
function wrapBlob(bucket, prefix) {
|
|
1156
|
+
if (!bucket) return null;
|
|
1157
|
+
return {
|
|
1158
|
+
prefix,
|
|
1159
|
+
async put(key, body, contentType) {
|
|
1160
|
+
await bucket.put(
|
|
1161
|
+
normalizeBlobKey(prefix, key),
|
|
1162
|
+
body,
|
|
1163
|
+
contentType ? { httpMetadata: { contentType } } : undefined,
|
|
1164
|
+
);
|
|
1165
|
+
},
|
|
1166
|
+
async get(key) {
|
|
1167
|
+
const object = await bucket.get(normalizeBlobKey(prefix, key));
|
|
1168
|
+
if (!object) return null;
|
|
1169
|
+
const body = new Uint8Array(await object.arrayBuffer());
|
|
1170
|
+
const contentType = object.httpMetadata && object.httpMetadata.contentType;
|
|
1171
|
+
return contentType ? { body, contentType } : { body };
|
|
1172
|
+
},
|
|
1173
|
+
async list(sub) {
|
|
1174
|
+
const full = sub ? normalizeBlobKey(prefix, sub) : prefix;
|
|
1175
|
+
const out = [];
|
|
1176
|
+
let cursor;
|
|
1177
|
+
do {
|
|
1178
|
+
const page = await bucket.list({ prefix: full, cursor });
|
|
1179
|
+
for (const object of page.objects) {
|
|
1180
|
+
out.push({ key: object.key.slice(prefix.length), size: object.size });
|
|
1181
|
+
}
|
|
1182
|
+
cursor = page.truncated ? page.cursor : undefined;
|
|
1183
|
+
} while (cursor);
|
|
1184
|
+
return out;
|
|
1185
|
+
},
|
|
1186
|
+
async delete(key) {
|
|
1187
|
+
await bucket.delete(normalizeBlobKey(prefix, key));
|
|
1188
|
+
},
|
|
1189
|
+
};
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
// --- Platform blob channel (POST /__canopy/blob) ----------------------------
|
|
1193
|
+
// The operation and key ride in headers so a put's body can stream straight
|
|
1194
|
+
// into the bucket; list and purge take a JSON body. Object metadata comes back
|
|
1195
|
+
// as JSON in x-canopy-blob-meta (get) or the body (head, list). Keys are the
|
|
1196
|
+
// bucket's own, untouched: the bucket holds this channel's objects only.
|
|
1197
|
+
|
|
1198
|
+
const BLOB_MARKER = { "x-canopy-blob": "1" };
|
|
1199
|
+
const BLOB_PURGE_BATCH = 1000;
|
|
1200
|
+
|
|
1201
|
+
function blobJson(body, status) {
|
|
1202
|
+
return new Response(JSON.stringify(body), {
|
|
1203
|
+
status: status || 200,
|
|
1204
|
+
headers: { "content-type": "application/json", ...BLOB_MARKER },
|
|
1205
|
+
});
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
function blobMeta(object) {
|
|
1209
|
+
const md5 = object.checksums && object.checksums.md5;
|
|
1210
|
+
return {
|
|
1211
|
+
key: object.key,
|
|
1212
|
+
size: object.size,
|
|
1213
|
+
uploaded: object.uploaded instanceof Date ? object.uploaded.toISOString() : String(object.uploaded),
|
|
1214
|
+
contentType: (object.httpMetadata && object.httpMetadata.contentType) || null,
|
|
1215
|
+
md5: md5 ? btoa(String.fromCharCode(...new Uint8Array(md5))) : null,
|
|
1216
|
+
};
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
async function handleBlobChannel(request, bucket) {
|
|
1220
|
+
if (!bucket) return blobJson({ error: "no_bucket" }, 503);
|
|
1221
|
+
const op = request.headers.get("x-canopy-blob-op") || "";
|
|
1222
|
+
const key = request.headers.get("x-canopy-blob-key") || "";
|
|
1223
|
+
try {
|
|
1224
|
+
if (op === "get" || op === "head") {
|
|
1225
|
+
const object = op === "get" ? await bucket.get(key) : await bucket.head(key);
|
|
1226
|
+
if (!object) return blobJson({ error: "not_found" }, 404);
|
|
1227
|
+
if (op === "head") return blobJson(blobMeta(object));
|
|
1228
|
+
return new Response(object.body, {
|
|
1229
|
+
headers: { "x-canopy-blob-meta": JSON.stringify(blobMeta(object)), ...BLOB_MARKER },
|
|
1230
|
+
});
|
|
1231
|
+
}
|
|
1232
|
+
if (op === "put") {
|
|
1233
|
+
const contentType = request.headers.get("x-canopy-blob-content-type");
|
|
1234
|
+
await bucket.put(
|
|
1235
|
+
key,
|
|
1236
|
+
await request.arrayBuffer(),
|
|
1237
|
+
contentType ? { httpMetadata: { contentType } } : undefined,
|
|
1238
|
+
);
|
|
1239
|
+
return blobJson({ ok: true });
|
|
1240
|
+
}
|
|
1241
|
+
if (op === "delete") {
|
|
1242
|
+
await bucket.delete(key);
|
|
1243
|
+
return blobJson({ ok: true });
|
|
1244
|
+
}
|
|
1245
|
+
if (op === "list") {
|
|
1246
|
+
const params = await request.json().catch(() => ({}));
|
|
1247
|
+
const page = await bucket.list({
|
|
1248
|
+
prefix: typeof params.prefix === "string" ? params.prefix : undefined,
|
|
1249
|
+
cursor: typeof params.cursor === "string" ? params.cursor : undefined,
|
|
1250
|
+
include: ["httpMetadata"],
|
|
1251
|
+
});
|
|
1252
|
+
return blobJson({
|
|
1253
|
+
objects: page.objects.map(blobMeta),
|
|
1254
|
+
truncated: page.truncated,
|
|
1255
|
+
cursor: page.truncated ? page.cursor : null,
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
if (op === "purge") {
|
|
1259
|
+
// One page per call so a large bucket can't outrun a request's limits;
|
|
1260
|
+
// the caller repeats while more remain.
|
|
1261
|
+
const params = await request.json().catch(() => ({}));
|
|
1262
|
+
const page = await bucket.list({
|
|
1263
|
+
prefix: typeof params.prefix === "string" ? params.prefix : undefined,
|
|
1264
|
+
limit: BLOB_PURGE_BATCH,
|
|
1265
|
+
});
|
|
1266
|
+
const keys = page.objects.map((o) => o.key);
|
|
1267
|
+
if (keys.length > 0) await bucket.delete(keys);
|
|
1268
|
+
return blobJson({ deleted: keys.length, more: page.truncated });
|
|
1269
|
+
}
|
|
1270
|
+
return blobJson({ error: "unknown_op" }, 400);
|
|
1271
|
+
} catch (err) {
|
|
1272
|
+
return blobJson(
|
|
1273
|
+
{ error: "blob_error", detail: err instanceof Error ? err.message : String(err) },
|
|
1274
|
+
500,
|
|
1275
|
+
);
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
// --- Self-fetch loopback (docs/27) -----------------------------------------
|
|
1280
|
+
// A worker fetching its own /api/* — the ported-Next.js BASE_URL idiom — is
|
|
1281
|
+
// re-entered in-process instead of leaving the isolate: outbound it would die
|
|
1282
|
+
// on the egress allowlist, and even allowed it would hit the auth wall with
|
|
1283
|
+
// no session. Identity rides AsyncLocalStorage per inbound request (never a
|
|
1284
|
+
// closure — the userland patch this replaces bled users across concurrent
|
|
1285
|
+
// requests). Transcribed from project-runtime/src/self-fetch.ts, which this
|
|
1286
|
+
// module cannot import (it ships standalone beside the bundle);
|
|
1287
|
+
// wfp-shim.test.ts and project-runtime's self-fetch.test.ts run matching
|
|
1288
|
+
// cases so the two can't drift silently.
|
|
1289
|
+
|
|
1290
|
+
const SELF_FETCH_DEPTH_HEADER = "x-canopy-self-fetch-depth";
|
|
1291
|
+
const SELF_FETCH_MAX_DEPTH = 8;
|
|
1292
|
+
// Everything that says who is calling (CALLER_IDENTITY_HEADERS in
|
|
1293
|
+
// project-runtime/src/user.ts): the user and caller stamps plus the agent mark.
|
|
1294
|
+
const IDENTITY_HEADERS = [...IDENTITY_STAMP_HEADERS, "x-canopy-caller"];
|
|
1295
|
+
|
|
1296
|
+
const selfFetchContext = new AsyncLocalStorage();
|
|
1297
|
+
|
|
1298
|
+
function isSelfApiPath(pathname) {
|
|
1299
|
+
return pathname === "/api" || pathname.startsWith("/api/");
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
function identityHeaders(headers) {
|
|
1303
|
+
const identity = new Headers();
|
|
1304
|
+
for (const name of IDENTITY_HEADERS) {
|
|
1305
|
+
const value = headers.get(name);
|
|
1306
|
+
if (value) identity.set(name, value);
|
|
1307
|
+
}
|
|
1308
|
+
return identity;
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
function selfFetchDepth(headers) {
|
|
1312
|
+
const depth = Number.parseInt(headers.get(SELF_FETCH_DEPTH_HEADER) || "", 10);
|
|
1313
|
+
return Number.isInteger(depth) && depth > 0 ? depth : 0;
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
// Hostnames that are "self" for this worker: the channel's own public host
|
|
1317
|
+
// (live: <slug>.<domain> plus v<N>-dot-<slug> pinned hosts — a pin only
|
|
1318
|
+
// selects assets, /api/* still dispatches to the live worker; staging:
|
|
1319
|
+
// staging-dot-<slug> only — the plain host is a *different* backend), and
|
|
1320
|
+
// localhost/127.0.0.1 on any port, ported code's BASE_URL fallback. Derived
|
|
1321
|
+
// from the api host (api.<domain>) the shim already has as a binding.
|
|
1322
|
+
function createSelfHostMatcher(apiUrl, projectSlug, channel) {
|
|
1323
|
+
let parentDomain = "";
|
|
1324
|
+
try {
|
|
1325
|
+
const apiHost = new URL(apiUrl).hostname;
|
|
1326
|
+
parentDomain = apiHost.slice(apiHost.indexOf(".") + 1).toLowerCase();
|
|
1327
|
+
} catch {
|
|
1328
|
+
// No usable api host — only the localhost fallbacks match.
|
|
1329
|
+
}
|
|
1330
|
+
const slug = projectSlug.toLowerCase();
|
|
1331
|
+
return (hostname) => {
|
|
1332
|
+
const host = hostname.toLowerCase();
|
|
1333
|
+
if (host === "localhost" || host === "127.0.0.1") return true;
|
|
1334
|
+
if (!slug || !parentDomain || !host.endsWith("." + parentDomain)) return false;
|
|
1335
|
+
const label = host.slice(0, host.length - parentDomain.length - 1);
|
|
1336
|
+
if (label.includes(".")) return false;
|
|
1337
|
+
if (channel === "staging") return label === "staging-dot-" + slug;
|
|
1338
|
+
if (label === slug) return true;
|
|
1339
|
+
const pinned = /^v\d+-dot-(.+)$/.exec(label);
|
|
1340
|
+
return Boolean(pinned) && pinned[1] === slug;
|
|
1341
|
+
};
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
function loopbackError(code, detail, status) {
|
|
1345
|
+
return new Response(JSON.stringify({ error: code, detail }), {
|
|
1346
|
+
status,
|
|
1347
|
+
headers: { "content-type": "application/json" },
|
|
1348
|
+
});
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
let passthroughFetch = null;
|
|
1352
|
+
|
|
1353
|
+
async function selfFetchInterceptor(input, init) {
|
|
1354
|
+
const context = selfFetchContext.getStore();
|
|
1355
|
+
if (context) {
|
|
1356
|
+
let url = null;
|
|
1357
|
+
try {
|
|
1358
|
+
url =
|
|
1359
|
+
typeof input === "string" ? new URL(input) : input instanceof URL ? input : new URL(input.url);
|
|
1360
|
+
} catch {
|
|
1361
|
+
// Relative URL — not a self-origin fetch; let the real fetch decide.
|
|
1362
|
+
}
|
|
1363
|
+
if (
|
|
1364
|
+
url &&
|
|
1365
|
+
(url.protocol === "http:" || url.protocol === "https:") &&
|
|
1366
|
+
context.matchesSelf(url.hostname) &&
|
|
1367
|
+
isSelfApiPath(url.pathname)
|
|
1368
|
+
) {
|
|
1369
|
+
const depth = context.depth + 1;
|
|
1370
|
+
if (depth > SELF_FETCH_MAX_DEPTH) {
|
|
1371
|
+
console.error("self-fetch loop: " + url.pathname + " exceeded depth " + SELF_FETCH_MAX_DEPTH);
|
|
1372
|
+
return loopbackError(
|
|
1373
|
+
"self_fetch_loop",
|
|
1374
|
+
"self-fetch recursion exceeded " + SELF_FETCH_MAX_DEPTH + " — /api routes are fetching each other in a cycle",
|
|
1375
|
+
508,
|
|
1376
|
+
);
|
|
1377
|
+
}
|
|
1378
|
+
// The caller's method/headers/body pass through; identity headers are
|
|
1379
|
+
// overwritten from the tracked inbound context — same posture as the
|
|
1380
|
+
// gateway, which never trusts a caller's own identity bag.
|
|
1381
|
+
const request = new Request(input, init);
|
|
1382
|
+
for (const name of IDENTITY_HEADERS) request.headers.delete(name);
|
|
1383
|
+
for (const pair of context.identity) request.headers.set(pair[0], pair[1]);
|
|
1384
|
+
request.headers.set(SELF_FETCH_DEPTH_HEADER, String(depth));
|
|
1385
|
+
try {
|
|
1386
|
+
return await invokeWorkerFetch(request, context.env, context.ctx);
|
|
1387
|
+
} catch (err) {
|
|
1388
|
+
// Over the network the caller would have seen the runtime's JSON 500,
|
|
1389
|
+
// not a rejection — keep that shape for the in-process path.
|
|
1390
|
+
console.error("self-fetch loopback failed: " + describeError(err));
|
|
1391
|
+
return loopbackError("worker_error", err instanceof Error ? err.message : String(err), 500);
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
return (passthroughFetch || fetch)(input, init);
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
// Installed lazily and re-asserted per invocation: cheap (one comparison),
|
|
1399
|
+
// and it stays the outermost wrapper even if something else patched
|
|
1400
|
+
// globalThis.fetch after us. env.CANOPY.fetch rides the gateway service
|
|
1401
|
+
// binding, never global fetch, so it can't be shadowed by this.
|
|
1402
|
+
function ensureSelfFetchInterceptor() {
|
|
1403
|
+
if (globalThis.fetch !== selfFetchInterceptor) {
|
|
1404
|
+
passthroughFetch = globalThis.fetch.bind(globalThis);
|
|
1405
|
+
globalThis.fetch = selfFetchInterceptor;
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
function runWithSelfFetchContext(env, ctx, identity, depth, credentials, fn) {
|
|
1410
|
+
ensureSelfFetchInterceptor();
|
|
1411
|
+
return selfFetchContext.run(
|
|
1412
|
+
{
|
|
1413
|
+
matchesSelf: createSelfHostMatcher(
|
|
1414
|
+
env.__CANOPY_API_URL || "",
|
|
1415
|
+
env.PROJECT_SLUG || "",
|
|
1416
|
+
env.CANOPY_CHANNEL || "live",
|
|
1417
|
+
),
|
|
1418
|
+
identity,
|
|
1419
|
+
depth,
|
|
1420
|
+
credentials,
|
|
1421
|
+
env,
|
|
1422
|
+
ctx,
|
|
1423
|
+
},
|
|
1424
|
+
fn,
|
|
1425
|
+
);
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
function invokeWorkerFetch(request, env, ctx) {
|
|
1429
|
+
// The inbound credential handle is for CANOPY.fetch, not the bundle (R-01):
|
|
1430
|
+
// keep it for CANOPY.fetch and hand the bundle a copy without it. On a
|
|
1431
|
+
// loopback the outermost request's handle is the one that counts — bundle
|
|
1432
|
+
// code has none of its own to re-supply.
|
|
1433
|
+
const existing = selfFetchContext.getStore();
|
|
1434
|
+
const credentials = existing ? existing.credentials : new Headers(request.headers);
|
|
1435
|
+
const userRequest = new Request(request);
|
|
1436
|
+
redactCanopyCredentials(userRequest.headers);
|
|
1437
|
+
return runWithSelfFetchContext(
|
|
1438
|
+
env,
|
|
1439
|
+
ctx,
|
|
1440
|
+
identityHeaders(request.headers),
|
|
1441
|
+
selfFetchDepth(request.headers),
|
|
1442
|
+
credentials,
|
|
1443
|
+
() => worker.fetch(userRequest, buildEnv(env, userRequest, credentials), ctx),
|
|
1444
|
+
);
|
|
1445
|
+
}
|
|
1446
|
+
// ---------------------------------------------------------------------------
|
|
1447
|
+
|
|
1448
|
+
// The request arg is the redacted copy the bundle sees; inboundHeaders carries
|
|
1449
|
+
// the credential handle CANOPY.fetch relays (R-01, wfp-binding-isolation).
|
|
1450
|
+
function buildEnv(env, request, inboundHeaders) {
|
|
1451
|
+
const out = {};
|
|
1452
|
+
for (const key of Object.keys(env)) {
|
|
1453
|
+
if (!INTERNAL_KEYS.has(key)) out[key] = env[key];
|
|
1454
|
+
}
|
|
1455
|
+
const apiUrl = env.__CANOPY_API_URL || "";
|
|
1456
|
+
const slug = env.PROJECT_SLUG || "";
|
|
1457
|
+
// Every deploy binds BLOB_PREFIX with __CANOPY_BLOB; no prefix, no blob
|
|
1458
|
+
// (the db-only shim has neither). Never derived from the slug: it can change.
|
|
1459
|
+
const blobPrefix = env.BLOB_PREFIX || "";
|
|
1460
|
+
out.CANOPY = { fetch: createCanopyFetch(apiUrl, inboundHeaders, env.__CANOPY_GATEWAY) };
|
|
1461
|
+
out.DB = wrapDb(env.__CANOPY_DB);
|
|
1462
|
+
out.BLOB = blobPrefix ? wrapBlob(env.__CANOPY_BLOB, blobPrefix) : null;
|
|
1463
|
+
// Verified monk identity from the gateway; null for cron / anonymous.
|
|
1464
|
+
out.USER = userFromHeaders(request.headers);
|
|
1465
|
+
// Everyone the gateway let through, client-password visitors included.
|
|
1466
|
+
out.CALLER = callerFromHeaders(request.headers);
|
|
1467
|
+
out.PROJECT_SLUG = slug;
|
|
1468
|
+
out.BLOB_PREFIX = blobPrefix;
|
|
1469
|
+
return out;
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
export default {
|
|
1473
|
+
async fetch(request, env, ctx) {
|
|
1474
|
+
const url = new URL(request.url);
|
|
1475
|
+
|
|
1476
|
+
if (url.pathname === "/__canopy/health") {
|
|
1477
|
+
return Response.json({
|
|
1478
|
+
ok: true,
|
|
1479
|
+
service: "canopy-wfp-shim",
|
|
1480
|
+
project: env.PROJECT_SLUG || "",
|
|
1481
|
+
scheduled: typeof worker.scheduled === "function",
|
|
1482
|
+
slack: typeof worker.slack === "function",
|
|
1483
|
+
});
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
// Slack traffic for the project's own app (docs/33 §6), delivered by
|
|
1487
|
+
// canopy-api through the dispatch binding after signature verification.
|
|
1488
|
+
// Kept in lockstep with the twin branch in project-runtime/src/loader.ts.
|
|
1489
|
+
// Platform database channel (docs/43 §5.5): canopy-data runs the project's
|
|
1490
|
+
// SDK statements through THIS script's own D1 binding instead of the REST
|
|
1491
|
+
// API — a dispatch hop plus a binding call, not an HTTPS round trip to
|
|
1492
|
+
// api.cloudflare.com. Reachable only through the DISPATCH binding (the
|
|
1493
|
+
// gateway forwards /api/* alone), and the raw binding never leaves this
|
|
1494
|
+
// closure: the bundle still sees only env.DB. The marker header lets the
|
|
1495
|
+
// caller tell this route apart from a bundle that answered the same path
|
|
1496
|
+
// under an older shim, which never saw it.
|
|
1497
|
+
if (url.pathname === "/__canopy/db" && request.method === "POST") {
|
|
1498
|
+
const dbHeaders = { "content-type": "application/json", "x-canopy-db": "1" };
|
|
1499
|
+
const d1 = env.__CANOPY_DB;
|
|
1500
|
+
if (!d1) {
|
|
1501
|
+
return new Response(JSON.stringify({ error: "no_database" }), {
|
|
1502
|
+
status: 503,
|
|
1503
|
+
headers: dbHeaders,
|
|
1504
|
+
});
|
|
1505
|
+
}
|
|
1506
|
+
let body;
|
|
1507
|
+
try {
|
|
1508
|
+
body = await request.json();
|
|
1509
|
+
} catch {
|
|
1510
|
+
body = null;
|
|
1511
|
+
}
|
|
1512
|
+
const statements = body && Array.isArray(body.statements) ? body.statements : null;
|
|
1513
|
+
const sql = body && typeof body.sql === "string" ? body.sql : null;
|
|
1514
|
+
if (!statements && !sql) {
|
|
1515
|
+
return new Response(JSON.stringify({ error: "invalid_body" }), {
|
|
1516
|
+
status: 400,
|
|
1517
|
+
headers: dbHeaders,
|
|
1518
|
+
});
|
|
1519
|
+
}
|
|
1520
|
+
try {
|
|
1521
|
+
let res;
|
|
1522
|
+
if (statements) {
|
|
1523
|
+
// One D1 batch: atomic, one round trip — the doc-store DDL, a reset.
|
|
1524
|
+
const batch = await d1.batch(statements.map((s) => d1.prepare(s)));
|
|
1525
|
+
res = batch[batch.length - 1] || { results: [], meta: {} };
|
|
1526
|
+
} else {
|
|
1527
|
+
res = await d1
|
|
1528
|
+
.prepare(sql)
|
|
1529
|
+
.bind(...(Array.isArray(body.params) ? body.params : []))
|
|
1530
|
+
.all();
|
|
1531
|
+
}
|
|
1532
|
+
const changes = res.meta && typeof res.meta.changes === "number" ? res.meta.changes : 0;
|
|
1533
|
+
return new Response(JSON.stringify({ results: res.results || [], meta: { changes } }), {
|
|
1534
|
+
headers: dbHeaders,
|
|
1535
|
+
});
|
|
1536
|
+
} catch (err) {
|
|
1537
|
+
return new Response(
|
|
1538
|
+
JSON.stringify({ error: "sql_error", detail: err instanceof Error ? err.message : String(err) }),
|
|
1539
|
+
{ status: 400, headers: dbHeaders },
|
|
1540
|
+
);
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
// Platform blob channel (wfp-binding-isolation): each channel's blobs live
|
|
1544
|
+
// in a bucket of its own that only this script binds, so canopy-data and
|
|
1545
|
+
// canopy-api reach them through here — the SDK and browser blob module,
|
|
1546
|
+
// signed downloads, usage, teardown. Dispatch-only like /__canopy/db; the
|
|
1547
|
+
// marker header tells this route apart from a bundle answering the path.
|
|
1548
|
+
if (url.pathname === "/__canopy/blob" && request.method === "POST") {
|
|
1549
|
+
return handleBlobChannel(request, env.__CANOPY_BLOB);
|
|
1550
|
+
}
|
|
1551
|
+
if (url.pathname === "/__canopy/slack" && request.method === "POST") {
|
|
1552
|
+
if (typeof worker.slack !== "function") {
|
|
1553
|
+
return Response.json(
|
|
1554
|
+
{ error: "no_slack_handler", detail: "export slack() next to fetch" },
|
|
1555
|
+
{ status: 501 },
|
|
1556
|
+
);
|
|
1557
|
+
}
|
|
1558
|
+
const body = await request.json().catch(() => ({}));
|
|
1559
|
+
const kind = body.kind;
|
|
1560
|
+
if (kind !== "event" && kind !== "command") {
|
|
1561
|
+
return Response.json({ error: "missing_kind" }, { status: 400 });
|
|
1562
|
+
}
|
|
1563
|
+
const event = {
|
|
1564
|
+
kind,
|
|
1565
|
+
teamId: typeof body.teamId === "string" ? body.teamId : "",
|
|
1566
|
+
appId: typeof body.appId === "string" ? body.appId : "",
|
|
1567
|
+
payload: typeof body.payload === "object" && body.payload !== null ? body.payload : {},
|
|
1568
|
+
...(typeof body.responseUrl === "string" ? { responseUrl: body.responseUrl } : {}),
|
|
1569
|
+
...(typeof body.slackUser === "object" && body.slackUser !== null
|
|
1570
|
+
? { user: body.slackUser }
|
|
1571
|
+
: {}),
|
|
1572
|
+
};
|
|
1573
|
+
const workerEnv = buildEnv(env, request, new Headers(request.headers));
|
|
1574
|
+
// Inside the platform's own workspace the caller maps to their Canopy
|
|
1575
|
+
// user, delivered pre-resolved by canopy-api (docs/33 §6); any other
|
|
1576
|
+
// workspace, or an unknown caller, is nobody.
|
|
1577
|
+
workerEnv.USER =
|
|
1578
|
+
typeof body.user === "object" && body.user !== null && typeof body.user.sub === "string"
|
|
1579
|
+
? { sub: body.user.sub, email: body.user.email || "", name: body.user.name || "" }
|
|
1580
|
+
: null;
|
|
1581
|
+
workerEnv.CALLER = callerFromUser(workerEnv.USER);
|
|
1582
|
+
try {
|
|
1583
|
+
// Empty identity, like scheduled: a self-fetch loops back as nobody.
|
|
1584
|
+
const result = await runWithSelfFetchContext(env, ctx, new Headers(), 0, new Headers(), () =>
|
|
1585
|
+
worker.slack(event, workerEnv, ctx),
|
|
1586
|
+
);
|
|
1587
|
+
// A command handler's return value rides back to canopy-api, which
|
|
1588
|
+
// relays it to Slack inside the ack window when it arrives in time.
|
|
1589
|
+
return Response.json(
|
|
1590
|
+
result === undefined || result === null ? { ok: true } : { ok: true, result },
|
|
1591
|
+
);
|
|
1592
|
+
} catch (err) {
|
|
1593
|
+
console.error("slack handler failed: " + describeError(err));
|
|
1594
|
+
return Response.json(
|
|
1595
|
+
{ error: "slack_failed", detail: err instanceof Error ? err.message : String(err) },
|
|
1596
|
+
{ status: 500 },
|
|
1597
|
+
);
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
if (url.pathname === "/__canopy/scheduled" && request.method === "POST") {
|
|
1602
|
+
if (typeof worker.scheduled !== "function") {
|
|
1603
|
+
return Response.json(
|
|
1604
|
+
{ error: "no_scheduled_handler", detail: "export scheduled() next to fetch" },
|
|
1605
|
+
{ status: 501 },
|
|
1606
|
+
);
|
|
1607
|
+
}
|
|
1608
|
+
const body = await request.json().catch(() => ({}));
|
|
1609
|
+
const name = typeof body.name === "string" ? body.name : "";
|
|
1610
|
+
const cron = typeof body.cron === "string" ? body.cron : "";
|
|
1611
|
+
if (!name) return Response.json({ error: "missing_name" }, { status: 400 });
|
|
1612
|
+
const event = {
|
|
1613
|
+
name,
|
|
1614
|
+
cron,
|
|
1615
|
+
scheduledTime: typeof body.scheduledTime === "number" ? body.scheduledTime : Date.now(),
|
|
1616
|
+
type: "scheduled",
|
|
1617
|
+
};
|
|
1618
|
+
const workerEnv = buildEnv(env, request, new Headers(request.headers));
|
|
1619
|
+
// Cron has no monk session, and no caller at all.
|
|
1620
|
+
workerEnv.USER = null;
|
|
1621
|
+
workerEnv.CALLER = null;
|
|
1622
|
+
try {
|
|
1623
|
+
// Empty identity: a self-fetch during scheduled() loops back with
|
|
1624
|
+
// env.USER null, like a real request from nobody (docs/27).
|
|
1625
|
+
await runWithSelfFetchContext(env, ctx, new Headers(), 0, new Headers(), () =>
|
|
1626
|
+
worker.scheduled(event, workerEnv, ctx),
|
|
1627
|
+
);
|
|
1628
|
+
return Response.json({ ok: true, name, cron });
|
|
1629
|
+
} catch (err) {
|
|
1630
|
+
console.error("scheduled handler failed " + name + ": " + describeError(err));
|
|
1631
|
+
return Response.json(
|
|
1632
|
+
{ error: "scheduled_failed", detail: err instanceof Error ? err.message : String(err) },
|
|
1633
|
+
{ status: 500 },
|
|
1634
|
+
);
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
if (typeof worker.fetch !== "function") {
|
|
1639
|
+
return Response.json(
|
|
1640
|
+
{ error: "no_fetch_handler", detail: "worker bundle must default-export fetch()" },
|
|
1641
|
+
{ status: 501 },
|
|
1642
|
+
);
|
|
1643
|
+
}
|
|
1644
|
+
// Frameworks (Hono etc.) catch their own route errors; this guards raw
|
|
1645
|
+
// workers whose fetch() throws — one ERROR log entry with the stack
|
|
1646
|
+
// (docs/17) and a JSON 500, instead of the throw escaping to the gateway
|
|
1647
|
+
// as a generic 502 backend_unavailable. Parity with the GCP loader.
|
|
1648
|
+
try {
|
|
1649
|
+
return await invokeWorkerFetch(request, env, ctx);
|
|
1650
|
+
} catch (err) {
|
|
1651
|
+
console.error("worker fetch failed: " + describeError(err));
|
|
1652
|
+
return Response.json(
|
|
1653
|
+
{ error: "worker_error", detail: err instanceof Error ? err.message : String(err) },
|
|
1654
|
+
{ status: 500 },
|
|
1655
|
+
);
|
|
1656
|
+
}
|
|
1657
|
+
},
|
|
1658
|
+
};
|
|
1659
|
+
`;
|
|
934
1660
|
//#endregion
|
|
935
1661
|
export { realtimeRouter as a, META_COLUMNS as c, stampWorkerHeaders as i, blobRouter as l, WORKER_ATTEST_HEADER as n, dbRouter as o, redactCanopyCredentials as r, hasPrivateNames as s, CALLER_HEADER as t };
|