@mediamonks/canopy-cli 0.7.41 → 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.
Files changed (47) hide show
  1. package/dist/{ai-PXXMv7kO.mjs → ai-FBHTSCLZ.mjs} +2 -2
  2. package/dist/{api-BHzCvDcY.mjs → api-Bl6SG8Oq.mjs} +63 -5
  3. package/dist/{browse-DRz1DowL.mjs → browse-dyOThTyf.mjs} +2 -2
  4. package/dist/{db-5LfBG6N7.mjs → db-BJJGydgL.mjs} +4 -4
  5. package/dist/{deploy-6rYq1Iq8.mjs → deploy-ExrIeX_m.mjs} +27 -8
  6. package/dist/dev-BmI5kwoG.mjs +2 -0
  7. package/dist/{dev-D_gyIeUq.mjs → dev-adLLSY1F.mjs} +8 -8
  8. package/dist/{dev-worker-D78leKBJ.mjs → dev-worker-DJekKSdb.mjs} +1 -1
  9. package/dist/docs/ci.md +19 -1
  10. package/dist/docs/cli-rename.md +51 -0
  11. package/dist/docs/developers-architecture.md +12 -0
  12. package/dist/docs/git.md +5 -0
  13. package/dist/docs/index.json +11 -1
  14. package/dist/docs/platform-egress.md +14 -7
  15. package/dist/docs/rename.md +65 -0
  16. package/dist/docs/static-serving.md +13 -4
  17. package/dist/{docs-B9xGnLSR.mjs → docs-CaAgJWK3.mjs} +4 -4
  18. package/dist/{egress-CCpOsYpS.mjs → egress-C2amvqmE.mjs} +3 -3
  19. package/dist/{feedback-CdjtPumG.mjs → feedback-CMnG2Zvs.mjs} +4 -4
  20. package/dist/index.mjs +33 -32
  21. package/dist/{local-Dshy0NsJ.mjs → local-2xtxtoUd.mjs} +3 -3
  22. package/dist/{login-CKNFCp7c.mjs → login-CREcZfLH.mjs} +3 -3
  23. package/dist/{logs-D3WakFME.mjs → logs-BnrNZvLc.mjs} +3 -3
  24. package/dist/{mcp-CoVS5Mj3.mjs → mcp-BVIU4j9N.mjs} +8 -6
  25. package/dist/{migrations-DDJ5JJqj.mjs → migrations-D8NqjC5I.mjs} +2 -2
  26. package/dist/{open-BPYozPQo.mjs → open-Bpz4C2Sr.mjs} +2 -2
  27. package/dist/{package-SWgTmctH.mjs → package-XOtqboS1.mjs} +1 -1
  28. package/dist/project-template-715H_lnu.mjs +2 -0
  29. package/dist/{project-template-DACdMdpn.mjs → project-template-Cdr1uwwE.mjs} +3 -3
  30. package/dist/{projects-naWQ89Qj.mjs → projects-BRG2HnBs.mjs} +5 -5
  31. package/dist/rename-BGKa25Rq.mjs +81 -0
  32. package/dist/{repo-BCua4L-I.mjs → repo-CwT2RDoW.mjs} +5 -3
  33. package/dist/repo-DmFs6H9m.mjs +2 -0
  34. package/dist/{schedule-DjHcaxyt.mjs → schedule-BcqBspn3.mjs} +3 -3
  35. package/dist/{secrets-COwRvCev.mjs → secrets-BjbIi7Gp.mjs} +3 -3
  36. package/dist/{site-DM2J10CB.mjs → site-0i7wAJqg.mjs} +24 -1
  37. package/dist/{skill-DSYQA4Ls.mjs → skill-Ct-aCOzg.mjs} +2 -2
  38. package/dist/{src-RUf3Gfuh.mjs → src-CScXotBB.mjs} +727 -1
  39. package/dist/{staging-CYMTZyyM.mjs → staging-_2Js0Fkc.mjs} +3 -3
  40. package/dist/{token-CCpJ1ES4.mjs → token-7vSNuz-X.mjs} +3 -3
  41. package/dist/{ui-SBUdC9rX.mjs → ui-CLnICOne.mjs} +1 -1
  42. package/dist/{upgrade-B_tLCpc4.mjs → upgrade-DJogeXAF.mjs} +2 -2
  43. package/dist/{usage-BSPnsMXd.mjs → usage-DGGmTwjJ.mjs} +3 -3
  44. package/package.json +1 -1
  45. package/dist/dev-D38FQ2LD.mjs +0 -2
  46. package/dist/project-template-CMgD5hnV.mjs +0 -2
  47. package/dist/repo-Un_p617X.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 (apps/api/src/platform/wfp-shim.ts), the Node
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 };