@jimhoyd/urlcode 0.3.0 → 0.4.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (326) hide show
  1. package/.claude/skills/urlcode-authoring/SKILL.md +106 -0
  2. package/.claude/skills/urlcode-operations/SKILL.md +114 -0
  3. package/.claude-plugin/marketplace.json +18 -0
  4. package/CONTRIBUTING.md +30 -2
  5. package/README.md +157 -230
  6. package/ROADMAP.md +85 -2
  7. package/SECURITY.md +8 -1
  8. package/dist/BUILD-MANIFEST.json +62 -32
  9. package/dist/adapters.js +4 -23
  10. package/dist/agent-lists.js +1 -1
  11. package/dist/agents-guide.js +113 -0
  12. package/dist/authoring-files.js +60 -0
  13. package/dist/authoring.js +11 -1
  14. package/dist/aws.js +4 -3
  15. package/dist/build-cloudflare.js +11 -24
  16. package/dist/bulk.js +37 -0
  17. package/dist/capabilities.js +208 -0
  18. package/dist/capability-query.js +72 -0
  19. package/dist/catalog.js +105 -0
  20. package/dist/cli.js +154 -15
  21. package/dist/client-address.js +1 -1
  22. package/dist/compliance-rules/baseline.js +8 -8
  23. package/dist/compliance-rules/privacy.js +3 -3
  24. package/dist/compliance-rules/strict.js +5 -5
  25. package/dist/conditions.js +88 -0
  26. package/dist/config.js +60 -4
  27. package/dist/context.js +156 -0
  28. package/dist/ecosystem-cli.js +88 -0
  29. package/dist/egress.js +98 -0
  30. package/dist/examples.js +92 -0
  31. package/dist/explain-cli.js +64 -0
  32. package/dist/explain.js +132 -0
  33. package/dist/extensions.js +172 -0
  34. package/dist/function-sources.js +15 -3
  35. package/dist/index.js +37 -0
  36. package/dist/init-with.js +165 -0
  37. package/dist/interchange-cli.js +42 -0
  38. package/dist/interchange.js +189 -0
  39. package/dist/link-cli.js +1 -1
  40. package/dist/management-policy.js +0 -1
  41. package/dist/manifest.js +111 -0
  42. package/dist/match.js +2 -2
  43. package/dist/mcp-authoring.js +147 -0
  44. package/dist/mcp.js +97 -0
  45. package/dist/observability.js +6 -0
  46. package/dist/operator-host.js +29 -0
  47. package/dist/plugins.js +12 -0
  48. package/dist/policies/agents.js +2 -2
  49. package/dist/policies/compression.js +2 -1
  50. package/dist/policies/security.js +0 -0
  51. package/dist/policies.js +1 -1
  52. package/dist/policy.js +29 -7
  53. package/dist/prerender.js +100 -41
  54. package/dist/project-tests.js +3 -3
  55. package/dist/provider-verification.js +92 -0
  56. package/dist/proxy.js +44 -0
  57. package/dist/readiness.js +5 -5
  58. package/dist/recipes.js +41 -0
  59. package/dist/route-diff.js +106 -0
  60. package/dist/router.js +42 -2
  61. package/dist/runtime.js +95 -14
  62. package/dist/schema-query.js +62 -0
  63. package/dist/signals.js +24 -0
  64. package/dist/site.js +0 -0
  65. package/dist/tooling.js +96 -0
  66. package/dist/types/adapters.d.ts +7 -4
  67. package/dist/types/agent-lists.d.ts +0 -1
  68. package/dist/types/agents-guide.d.ts +17 -0
  69. package/dist/types/authoring-files.d.ts +10 -0
  70. package/dist/types/aws.d.ts +3 -1
  71. package/dist/types/build-cloudflare.d.ts +1 -0
  72. package/dist/types/bulk.d.ts +27 -0
  73. package/dist/types/capabilities.d.ts +55 -0
  74. package/dist/types/capability-query.d.ts +24 -0
  75. package/dist/types/catalog.d.ts +65 -0
  76. package/dist/types/client-address.d.ts +0 -1
  77. package/dist/types/compliance-rules/baseline.d.ts +1 -9
  78. package/dist/types/compliance-rules/privacy.d.ts +1 -4
  79. package/dist/types/compliance-rules/strict.d.ts +0 -5
  80. package/dist/types/conditions.d.ts +19 -0
  81. package/dist/types/config.d.ts +20 -2
  82. package/dist/types/context.d.ts +65 -0
  83. package/dist/types/ecosystem-cli.d.ts +17 -0
  84. package/dist/types/egress.d.ts +46 -0
  85. package/dist/types/examples.d.ts +50 -0
  86. package/dist/types/explain-cli.d.ts +11 -0
  87. package/dist/types/explain.d.ts +95 -0
  88. package/dist/types/extensions.d.ts +122 -0
  89. package/dist/types/function-sources.d.ts +5 -0
  90. package/dist/types/index.d.ts +33 -0
  91. package/dist/types/init-with.d.ts +30 -0
  92. package/dist/types/interchange-cli.d.ts +16 -0
  93. package/dist/types/interchange.d.ts +42 -0
  94. package/dist/types/link-cli.d.ts +2 -1
  95. package/dist/types/management-policy.d.ts +0 -1
  96. package/dist/types/manifest.d.ts +81 -0
  97. package/dist/types/match.d.ts +1 -0
  98. package/dist/types/mcp-authoring.d.ts +92 -0
  99. package/dist/types/mcp.d.ts +12 -0
  100. package/dist/types/observability.d.ts +2 -0
  101. package/dist/types/operator-host.d.ts +8 -0
  102. package/dist/types/plugins.d.ts +2 -0
  103. package/dist/types/policies/agents.d.ts +0 -2
  104. package/dist/types/policies/compression.d.ts +2 -0
  105. package/dist/types/policies/security.d.ts +0 -1
  106. package/dist/types/policy.d.ts +15 -4
  107. package/dist/types/project-tests.d.ts +3 -1
  108. package/dist/types/provider-verification.d.ts +53 -0
  109. package/dist/types/proxy.d.ts +21 -0
  110. package/dist/types/readiness.d.ts +1 -1
  111. package/dist/types/recipes.d.ts +30 -0
  112. package/dist/types/route-diff.d.ts +27 -0
  113. package/dist/types/runtime.d.ts +11 -0
  114. package/dist/types/schema-query.d.ts +12 -0
  115. package/dist/types/signals.d.ts +25 -0
  116. package/dist/types/site.d.ts +0 -1
  117. package/dist/types/tooling.d.ts +115 -0
  118. package/dist/types/types.d.ts +57 -0
  119. package/dist/types/typescript-authoring.d.ts +12 -0
  120. package/dist/types/vercel.d.ts +3 -1
  121. package/dist/types/verify-deployment.d.ts +47 -0
  122. package/dist/types.js +21 -2
  123. package/dist/typescript-authoring.js +104 -0
  124. package/dist/vercel.js +4 -3
  125. package/dist/verify-deployment.js +270 -0
  126. package/docs/AI-AUTHORING.md +130 -8
  127. package/docs/BULK.md +79 -0
  128. package/docs/CAPABILITIES.md +179 -0
  129. package/docs/CAPACITY.md +1 -1
  130. package/docs/CI.md +142 -0
  131. package/docs/CONDITIONS.md +74 -0
  132. package/docs/DEPLOYMENT-CHECKS.md +108 -0
  133. package/docs/DYNAMIC-LINKS.md +18 -518
  134. package/docs/EGRESS.md +125 -0
  135. package/docs/EXTENSIONS.md +226 -0
  136. package/docs/FRAMEWORK.md +182 -0
  137. package/docs/INSTALL.md +45 -7
  138. package/docs/INTERCHANGE.md +134 -0
  139. package/docs/MIDDLEWARE-EXAMPLES.md +75 -0
  140. package/docs/MIDDLEWARE.md +2 -0
  141. package/docs/NEXT-PHASE-PLAN.md +90 -0
  142. package/docs/NEXT-STEPS.md +415 -0
  143. package/docs/OBSERVABILITY.md +4 -2
  144. package/docs/OPERATIONAL-PROOF.md +4 -1
  145. package/docs/OPERATIONS.md +6 -3
  146. package/docs/PLUGINS.md +37 -0
  147. package/docs/POLICIES.md +12 -309
  148. package/docs/PRERENDER.md +40 -0
  149. package/docs/PROJECT-DIRECTION.md +42 -0
  150. package/docs/PROVIDER-VERIFICATION.md +84 -0
  151. package/docs/READINESS.md +21 -1
  152. package/docs/README.md +82 -31
  153. package/docs/RECIPES.md +99 -0
  154. package/docs/RELEASE-READINESS.md +11 -9
  155. package/docs/RELEASE-SECURITY.md +27 -4
  156. package/docs/SECURITY-AUDIT.md +1 -1
  157. package/docs/SPECIFICATION.md +95 -8
  158. package/docs/SPIKE-BUSINESS-SUITE.md +1013 -0
  159. package/docs/SPIKE-EXTENSION-MODEL.md +419 -0
  160. package/docs/SPIKE-EXTENSIONS.md +1 -0
  161. package/docs/SPIKE-LAMBDA-COMPILE.md +199 -0
  162. package/docs/STANDARDS.md +150 -142
  163. package/docs/STARTERS.md +21 -1
  164. package/docs/TOOLING.md +291 -0
  165. package/docs/TYPESCRIPT-AUTHORING.md +67 -0
  166. package/docs/TYPESCRIPT.md +1 -1
  167. package/docs/USABILITY-REVIEW.md +123 -0
  168. package/docs/YAML-GUIDE.md +18 -479
  169. package/docs/YAML-REFERENCE.md +127 -16
  170. package/docs/links/cli.md +110 -0
  171. package/docs/links/limits.md +175 -0
  172. package/docs/links/management-api.md +80 -0
  173. package/docs/links/pools.md +75 -0
  174. package/docs/links/setup.md +135 -0
  175. package/docs/policies/agents.md +1 -1
  176. package/docs/policies/contract.md +52 -0
  177. package/docs/policies/hardened.md +56 -0
  178. package/docs/policies/interoperability.md +169 -0
  179. package/docs/policies/operations.md +45 -0
  180. package/docs/yaml/assets.md +36 -0
  181. package/docs/yaml/conditions.md +20 -0
  182. package/docs/yaml/functions.md +160 -0
  183. package/docs/yaml/links.md +30 -0
  184. package/docs/yaml/middleware.md +29 -0
  185. package/docs/yaml/organization.md +74 -0
  186. package/docs/yaml/policies.md +37 -0
  187. package/docs/yaml/redirects.md +64 -0
  188. package/docs/yaml/responses.md +57 -0
  189. package/docs/yaml/site.md +24 -0
  190. package/examples/assets/example.yaml +17 -0
  191. package/examples/aws/example.yaml +20 -0
  192. package/examples/cloudflare/example.yaml +19 -0
  193. package/examples/compliance/example.yaml +11 -0
  194. package/examples/conditions/README.md +12 -0
  195. package/examples/conditions/example.yaml +19 -0
  196. package/examples/conditions/tests/requests.json +13 -0
  197. package/examples/conditions/urlcode.yaml +24 -0
  198. package/examples/cookbook/README.md +8 -4
  199. package/examples/cookbook/example.yaml +17 -0
  200. package/examples/cookbook/functions/catalog.mjs +3 -0
  201. package/examples/cookbook/functions/fail.mjs +4 -0
  202. package/examples/cookbook/functions/items.mjs +3 -0
  203. package/examples/cookbook/functions/profile.mjs +3 -0
  204. package/examples/cookbook/functions/resource.mjs +3 -0
  205. package/examples/cookbook/functions/status.mjs +3 -0
  206. package/examples/cookbook/middleware/auth.mjs +48 -0
  207. package/examples/cookbook/middleware/body.mjs +15 -0
  208. package/examples/cookbook/middleware/bucket.mjs +19 -0
  209. package/examples/cookbook/middleware/cors.mjs +21 -0
  210. package/examples/cookbook/middleware/debug.mjs +13 -0
  211. package/examples/cookbook/middleware/envelope.mjs +11 -0
  212. package/examples/cookbook/middleware/errors.mjs +11 -0
  213. package/examples/cookbook/middleware/etag.mjs +18 -0
  214. package/examples/cookbook/middleware/locale.mjs +16 -0
  215. package/examples/cookbook/middleware/maintenance.mjs +10 -0
  216. package/examples/cookbook/middleware/methods.mjs +15 -0
  217. package/examples/cookbook/middleware/negotiate.mjs +20 -0
  218. package/examples/cookbook/middleware/referer.mjs +12 -0
  219. package/examples/cookbook/middleware/request-id.mjs +16 -0
  220. package/examples/cookbook/route-index.json +676 -0
  221. package/examples/cookbook/routes/middleware.yaml +126 -0
  222. package/examples/cookbook/tests/requests.json +526 -0
  223. package/examples/cookbook/urlcode.yaml +1 -0
  224. package/examples/egress/README.md +22 -0
  225. package/examples/egress/example.yaml +19 -0
  226. package/examples/egress/urlcode.yaml +19 -0
  227. package/examples/extensions/README.md +7 -0
  228. package/examples/extensions/example.yaml +21 -0
  229. package/examples/extensions/urlcode.yaml +25 -0
  230. package/examples/live-links/example.yaml +21 -0
  231. package/examples/monitoring/example.yaml +8 -0
  232. package/examples/prerender/example.yaml +16 -0
  233. package/examples/provider-conformance/README.md +12 -0
  234. package/examples/provider-conformance/example.yaml +14 -0
  235. package/examples/provider-conformance/urlcode.yaml +34 -0
  236. package/examples/tunnel/example.yaml +8 -0
  237. package/examples/vercel/example.yaml +19 -0
  238. package/llms-full.txt +2709 -0
  239. package/llms.txt +48 -19
  240. package/package.json +29 -7
  241. package/packaging/claude-plugin/.claude-plugin/plugin.json +19 -0
  242. package/packaging/claude-plugin/skills/urlcode-authoring/SKILL.md +106 -0
  243. package/packaging/claude-plugin/skills/urlcode-operations/SKILL.md +114 -0
  244. package/recipes/authenticated-json-api/README.md +51 -0
  245. package/recipes/authenticated-json-api/functions/profile.mjs +5 -0
  246. package/recipes/authenticated-json-api/recipe.yaml +34 -0
  247. package/recipes/authenticated-json-api/tests/requests.json +39 -0
  248. package/recipes/authenticated-json-api/urlcode.yaml +12 -0
  249. package/recipes/contact-form/README.md +25 -0
  250. package/recipes/contact-form/functions/contact.mjs +17 -0
  251. package/recipes/contact-form/recipe.yaml +33 -0
  252. package/recipes/contact-form/tests/requests.json +47 -0
  253. package/recipes/contact-form/urlcode.yaml +18 -0
  254. package/recipes/cors-api/README.md +16 -0
  255. package/recipes/cors-api/functions/items.mjs +3 -0
  256. package/recipes/cors-api/middleware/cors.mjs +21 -0
  257. package/recipes/cors-api/recipe.yaml +26 -0
  258. package/recipes/cors-api/tests/requests.json +65 -0
  259. package/recipes/cors-api/urlcode.yaml +12 -0
  260. package/recipes/health-page/README.md +13 -0
  261. package/recipes/health-page/recipe.yaml +23 -0
  262. package/recipes/health-page/tests/requests.json +36 -0
  263. package/recipes/health-page/urlcode.yaml +19 -0
  264. package/recipes/json-api/README.md +6 -0
  265. package/recipes/json-api/functions/echo.mjs +3 -0
  266. package/recipes/json-api/recipe.yaml +25 -0
  267. package/recipes/json-api/tests/requests.json +34 -0
  268. package/recipes/json-api/urlcode.yaml +12 -0
  269. package/recipes/middleware/README.md +34 -0
  270. package/recipes/middleware/functions/catalog.mjs +3 -0
  271. package/recipes/middleware/functions/fail.mjs +4 -0
  272. package/recipes/middleware/functions/items.mjs +3 -0
  273. package/recipes/middleware/functions/profile.mjs +3 -0
  274. package/recipes/middleware/functions/resource.mjs +3 -0
  275. package/recipes/middleware/functions/status.mjs +3 -0
  276. package/recipes/middleware/middleware/auth.mjs +48 -0
  277. package/recipes/middleware/middleware/body.mjs +15 -0
  278. package/recipes/middleware/middleware/bucket.mjs +19 -0
  279. package/recipes/middleware/middleware/cors.mjs +21 -0
  280. package/recipes/middleware/middleware/debug.mjs +13 -0
  281. package/recipes/middleware/middleware/envelope.mjs +11 -0
  282. package/recipes/middleware/middleware/errors.mjs +11 -0
  283. package/recipes/middleware/middleware/etag.mjs +18 -0
  284. package/recipes/middleware/middleware/locale.mjs +16 -0
  285. package/recipes/middleware/middleware/maintenance.mjs +10 -0
  286. package/recipes/middleware/middleware/methods.mjs +15 -0
  287. package/recipes/middleware/middleware/negotiate.mjs +20 -0
  288. package/recipes/middleware/middleware/referer.mjs +12 -0
  289. package/recipes/middleware/middleware/request-id.mjs +16 -0
  290. package/recipes/middleware/public/guide.txt +1 -0
  291. package/recipes/middleware/recipe.yaml +50 -0
  292. package/recipes/middleware/tests/requests.json +528 -0
  293. package/recipes/middleware/urlcode.yaml +127 -0
  294. package/recipes/protected-download/README.md +22 -0
  295. package/recipes/protected-download/files/report.txt +1 -0
  296. package/recipes/protected-download/recipe.yaml +31 -0
  297. package/recipes/protected-download/tests/requests.json +32 -0
  298. package/recipes/protected-download/urlcode.yaml +15 -0
  299. package/recipes/redirect/README.md +7 -0
  300. package/recipes/redirect/recipe.yaml +25 -0
  301. package/recipes/redirect/tests/requests.json +19 -0
  302. package/recipes/redirect/urlcode.yaml +9 -0
  303. package/recipes/static-plus-api/README.md +15 -0
  304. package/recipes/static-plus-api/functions/info.mjs +3 -0
  305. package/recipes/static-plus-api/public/assets/index.html +3 -0
  306. package/recipes/static-plus-api/public/assets/site.css +1 -0
  307. package/recipes/static-plus-api/public/index.html +8 -0
  308. package/recipes/static-plus-api/recipe.yaml +29 -0
  309. package/recipes/static-plus-api/tests/requests.json +56 -0
  310. package/recipes/static-plus-api/urlcode.yaml +23 -0
  311. package/recipes/typescript/README.md +7 -0
  312. package/recipes/typescript/functions/hello.ts +5 -0
  313. package/recipes/typescript/recipe.yaml +23 -0
  314. package/recipes/typescript/tests/requests.json +18 -0
  315. package/recipes/typescript/urlcode.yaml +5 -0
  316. package/recipes/webhook-receiver/README.md +16 -0
  317. package/recipes/webhook-receiver/functions/receive.mjs +16 -0
  318. package/recipes/webhook-receiver/recipe.yaml +26 -0
  319. package/recipes/webhook-receiver/tests/requests.json +59 -0
  320. package/recipes/webhook-receiver/urlcode.yaml +16 -0
  321. package/schemas/recipe.schema.json +138 -0
  322. package/schemas/urlcode.schema.json +656 -80
  323. package/skills/urlcode/SKILL.md +98 -0
  324. package/starters/default/.github/workflows/urlcode.yml +23 -0
  325. package/starters/default/.mcp.json +12 -0
  326. package/starters/default/AGENTS.md +79 -0
@@ -12,6 +12,7 @@ import { assert } from './errors.js';
12
12
  // URL, query string, header, body, client address, User-Agent string, binding
13
13
  // or secret; `route` is always the configured pattern.
14
14
  export const events = Object.freeze({
15
+ signal: Object.freeze(['event','outcome','count']),
15
16
  request: Object.freeze(['event', 'requestId', 'status', 'durationMs', 'method', 'route']),
16
17
  reload: Object.freeze(['event', 'status', 'version', 'routes']),
17
18
  watch: Object.freeze(['event', 'status']),
@@ -48,6 +49,7 @@ export const events = Object.freeze({
48
49
 
49
50
 
50
51
 
52
+
51
53
 
52
54
 
53
55
 
@@ -109,6 +111,7 @@ export function createMetrics() {
109
111
  const linkStoreWorkers = { started: 0, restarts: 0 };
110
112
  const policies = { throttle: zeroed(outcomes.throttle), agents: zeroed(outcomes.agents), cache: zeroed(outcomes.cache) };
111
113
  const linkRequests = zeroed(outcomes.link_request);
114
+ const signals = zeroed(['accepted','delivered','failed','dropped']);
112
115
  const linkObserver = { failed: 0, dropped: 0 };
113
116
  let logsDropped = 0, observerErrors = 0;
114
117
  const count = (table , key ) => { if (typeof key === 'string' && Object.hasOwn(table, key)) table[key] ++; };
@@ -129,6 +132,7 @@ export function createMetrics() {
129
132
  if (!event || typeof event !== 'object') return;
130
133
  const record = event ; // any object is read as a record; unknown keys are ignored
131
134
  switch (record.event) {
135
+ case 'signal': if(typeof record.outcome==='string'&&Object.hasOwn(signals,record.outcome)&&Number.isSafeInteger(record.count)&&Number(record.count)>0)signals[record.outcome] +=Number(record.count);break;
132
136
  case 'request': countRequest(context.probe ? health : requests, record.status, context.probe ? null : (record.route ?? context.route)); break;
133
137
  case 'reload': count(reloads, record.status); break;
134
138
  case 'watch': if (record.status === 'failed') watch.failed++; break;
@@ -158,6 +162,7 @@ export function createMetrics() {
158
162
  functionWorkers: { ...functionWorkers, healthySlots: 0, slots: 0 },
159
163
  linkStoreWorkers: { ...linkStoreWorkers },
160
164
  policies: { throttle: { ...policies.throttle }, agents: { ...policies.agents }, cache: { ...policies.cache } },
165
+ signals: {...signals},
161
166
  linkRequests: { ...linkRequests },
162
167
  linkObserver: { ...linkObserver },
163
168
  logsDropped,
@@ -231,6 +236,7 @@ export function renderPrometheus(snapshot ) {
231
236
  metric('agents_total', 'counter', 'Agents policy decisions.', byKey(snapshot.policies?.agents, 'outcome'));
232
237
  metric('cache_total', 'counter', 'Cache policy outcomes.', byKey(snapshot.policies?.cache, 'outcome'));
233
238
  metric('link_requests_total', 'counter', 'Dynamic link requests by outcome.', byKey(snapshot.linkRequests, 'outcome'));
239
+ metric('signals_total','counter','Best-effort webhook outcomes.',Object.entries(snapshot.signals||{}).map(([outcome,value])=>[{outcome},value]));
234
240
  metric('link_observer_failures_total', 'counter', 'Link event collector failures.', [[{}, snapshot.linkObserver?.failed]]);
235
241
  metric('link_observer_dropped_total', 'counter', 'Link events dropped under overload.', [[{}, snapshot.linkObserver?.dropped]]);
236
242
  metric('logs_dropped_total', 'counter', 'Log records the JSON logger shed.', [[{}, snapshot.logsDropped]]);
@@ -0,0 +1,29 @@
1
+ import { realpath, stat } from 'node:fs/promises';
2
+ import { isAbsolute, relative, sep, extname } from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+ import { assert } from './errors.js';
5
+
6
+
7
+ /** Explicitly loaded operator code. Never discovered in application directories. */
8
+
9
+
10
+
11
+
12
+
13
+ export async function loadOperatorHost(file , project ) {
14
+ if (file === undefined) return {};
15
+ assert(isAbsolute(file) && ['.mjs', '.js'].includes(extname(file)), 'Host file must be an absolute ES module path (.mjs or .js)');
16
+ const root = await realpath(project), path = await realpath(file), rel = relative(root, path);
17
+ assert(isAbsolute(rel) || rel === '..' || rel.startsWith('..' + sep), 'Host file must be outside the application project');
18
+ const info = await stat(path);
19
+ assert(info.isFile() && info.size <= 1048576, 'Host file must be a regular file of at most 1 MiB');
20
+ const module = await import(pathToFileURL(path).href) ;
21
+ const host = module.default;
22
+ assert(host !== null && typeof host === 'object' && !Array.isArray(host), 'Host file must default-export an operator configuration object');
23
+ assert(Object.keys(host).every(key => ['extensions', 'plugins', 'close'].includes(key)), 'Unknown operator host setting');
24
+ const result = host ;
25
+ assert(result.extensions === undefined || Array.isArray(result.extensions), 'Host extensions must be an array');
26
+ assert(result.plugins === undefined || Array.isArray(result.plugins), 'Host plugins must be an array');
27
+ assert(result.close === undefined || typeof result.close === 'function', 'Host close must be a function');
28
+ return result;
29
+ }
package/dist/plugins.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { validateHeaderName } from './header-validation.js';
1
2
  import { assert, ConfigError } from './errors.js';
2
3
  import { targets as knownTargets } from './policies.js';
3
4
 
@@ -21,6 +22,8 @@ import { targets as knownTargets } from './policies.js';
21
22
 
22
23
 
23
24
 
25
+
26
+
24
27
 
25
28
 
26
29
 
@@ -41,6 +44,15 @@ export function validatePlugins(plugins = [], target = 'node')
41
44
  assert(typeof plugin.version === 'string' && plugin.version.length <= 64, `Plugin "${plugin.name}" needs a version string`);
42
45
  assert(Array.isArray(plugin.targets) && plugin.targets.every(t => (knownTargets ).includes(t)), `Plugin "${plugin.name}" must list its supported targets`);
43
46
  if (!(plugin.targets ).includes(target)) throw new ConfigError(`Plugin "${plugin.name}" does not support the ${target} target`);
47
+ if(plugin.credentialHeaders!==undefined){
48
+ assert(Array.isArray(plugin.credentialHeaders)&&plugin.credentialHeaders.length<=64,'Plugin credentialHeaders must contain at most 64 header names');
49
+ const headers=new Set ();
50
+ for(const name of plugin.credentialHeaders){
51
+ assert(typeof name==='string'&&name.length<=128,'Invalid plugin credential header name');
52
+ validateHeaderName(name);
53
+ assert(!headers.has(name.toLowerCase()),'Duplicate plugin credential header name');headers.add(name.toLowerCase());
54
+ }
55
+ }
44
56
  for (const hook of hookNames) assert(plugin[hook] === undefined || typeof plugin[hook] === 'function', `Plugin "${plugin.name}" hook ${hook} must be a function`);
45
57
  assert(hookNames.some(hook => plugin[hook]), `Plugin "${plugin.name}" declares no hooks`);
46
58
  }
@@ -35,8 +35,8 @@ export function targets() { return { node
35
35
 
36
36
 
37
37
  export const bundledLists = Object.freeze(Object.keys(bundled));
38
- export const MAX_PATTERN_BYTES = 256;
39
- export const MAX_REPEAT = 64;
38
+ const MAX_PATTERN_BYTES = 256;
39
+ const MAX_REPEAT = 64;
40
40
 
41
41
  // The pattern subset: anchors, literals, `.`, escapes, character classes,
42
42
  // groups, alternation, and quantifiers on a single atom only. Nothing here
@@ -26,7 +26,8 @@ export const phases = ['response'];
26
26
 
27
27
  const DYNAMIC_LIMIT = 1048576; // bytes; above this a body is never compressed on the request path
28
28
  const PRECOMPRESSED_BUDGET = 64 * 1024 * 1024; // aggregate bytes of asset variants per runtime
29
- const DEFAULT_TYPES = ['text/*','application/json','application/javascript','application/xml','image/svg+xml','application/manifest+json','application/ld+json'];
29
+ /** The media types compressed when `types` is not declared; exported so the deployment verifier reads the same table. */
30
+ export const DEFAULT_TYPES = ['text/*','application/json','application/javascript','application/xml','image/svg+xml','application/manifest+json','application/ld+json'];
30
31
  const SUFFIX = { br:'-br', gzip:'-gz', deflate:'-df', zstd:'-zs' };
31
32
  const BODYLESS = new Set([204,205,304]);
32
33
 
Binary file
package/dist/policies.js CHANGED
@@ -72,7 +72,7 @@ export function effectivePolicies(document , routeConfig
72
72
  layers.push(route);
73
73
  const effective = {};
74
74
  for (const layer of layers) for (const [key, value] of Object.entries(layer)) {
75
- if (key === 'profile') continue;
75
+ if (key === 'profile' || key === 'extensions') continue;
76
76
  if (value === false) { delete effective[key]; continue; }
77
77
  effective[key] = { ...(effective[key] || {}), ...value };
78
78
  }
package/dist/policy.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { egressUrl } from './egress.js';
1
2
  import { readFile, realpath, stat } from 'node:fs/promises';
2
3
  import { relative, isAbsolute, sep } from 'node:path';
3
4
  import { createHash } from 'node:crypto';
@@ -7,8 +8,10 @@ import { collectFunctionSources, routeFunctions } from './function-sources.js';
7
8
  import { assert } from './errors.js';
8
9
 
9
10
 
11
+
12
+
10
13
  /** The operator's binding grants: which env and secret names each route may read, pinned to a project hash. */
11
-
14
+
12
15
  /** The function snapshot plus the hash operator grants are pinned to. */
13
16
 
14
17
 
@@ -20,9 +23,11 @@ export async function prepareFunctionSnapshot(loaded )
20
23
  const collected = await collectFunctionSources(definitions,loaded.root);
21
24
  const sources = Object.fromEntries(Object.entries(collected.sources).sort(([a],[b])=>a < b ? -1 : a > b ? 1 : 0));
22
25
  // Generated site routes carry no bindings and depend on the origin, so they
23
- // stay out of the hash that operator grants are pinned to.
26
+ // stay out of the hash that operator grants are pinned to. Declared site and
27
+ // inherited policy/profile behavior are included: changing a pre-egress
28
+ // restriction must require a fresh operator review even if routes are unchanged.
24
29
  const declared = Object.fromEntries(Object.entries(loaded.routes).filter(([,route])=>!route.generated));
25
- const snapshot = { ...collected, projectSha256: createHash('sha256').update(JSON.stringify({routes:declared,...(loaded.document.dynamicLinks===true?{dynamicLinks:true}:{}),sources})).digest('hex') };
30
+ const snapshot = { ...collected, projectSha256: createHash('sha256').update(JSON.stringify({...(loaded.document.extensions?{extensions:loaded.document.extensions}:{}),routes:declared,...(loaded.document.dynamicLinks===true?{dynamicLinks:true}:{}),...(loaded.document.policies?{policies:loaded.document.policies}:{}),...(loaded.document.profiles?{profiles:loaded.document.profiles}:{}),...(loaded.document.site?{site:loaded.document.site}:{}),sources})).digest('hex') };
26
31
  return snapshot;
27
32
  }
28
33
  export function validatePolicy(value ) {
@@ -33,8 +38,13 @@ export function validatePolicy(value ) {
33
38
  assert(routes && typeof routes === 'object' && !Array.isArray(routes), 'Policy requires route grants');
34
39
  for (const [path,grant] of Object.entries(routes)) {
35
40
  assert(path.startsWith('/') && grant && typeof grant === 'object' && !Array.isArray(grant), 'Invalid route grant');
36
- assert(Object.keys(grant).every(k=>['env','secrets'].includes(k)), 'Unsupported policy capability');
37
- for (const list of Object.values(grant)) assert(Array.isArray(list) && list.length <= 64 && list.every(n=>typeof n === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(n)), 'Invalid binding grant');
41
+ assert(Object.keys(grant).every(k=>['env','secrets','egress'].includes(k)), 'Unsupported policy capability');
42
+ const record=grant ;
43
+ if(record.egress!==undefined) {
44
+ assert(record.egress&&typeof record.egress==='object'&&!Array.isArray(record.egress)&&Object.keys(record.egress).every(key=>['proxy','signals'].includes(key)),'Invalid egress grant');
45
+ for(const origins of Object.values(record.egress)){assert(Array.isArray(origins)&&origins.length<=64,'Invalid egress origins');for(const origin of origins){const url=egressUrl(origin);assert(url.origin===origin,'Egress grants must be exact HTTPS origins');}}
46
+ }
47
+ for (const list of [record.env,record.secrets].filter(value=>value!==undefined)) assert(Array.isArray(list) && list.length <= 64 && list.every(n=>typeof n === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(n)), 'Invalid binding grant');
38
48
  }
39
49
  return value ; // every field was just checked
40
50
  }
@@ -47,9 +57,21 @@ export async function loadOperatorPolicy(file , project
47
57
  return validatePolicy(JSON.parse(await readFile(path,'utf8')));
48
58
  }
49
59
  export function requestedPermissions(loaded , snapshot ) {
50
- return {version:1,projectSha256:snapshot.projectSha256,routes:Object.fromEntries(Object.entries(loaded.routes).flatMap(([path,route]) => {
60
+ return {version:1,projectSha256:snapshot.projectSha256,routes:Object.fromEntries(Object.entries(loaded.routes).flatMap(([path,route]) => {
51
61
  const env = Object.values(route.env || {}).flatMap(ref=>ref.env ? [ref.env] : []);
52
62
  const secrets = Object.values(route.secrets || {}).map(ref=>ref.secret);
53
- return env.length || secrets.length ? [[path,{env:[...new Set(env)],secrets:[...new Set(secrets)]}]] : [];
63
+ const egress ={...(route.proxy?{proxy:[egressUrl(route.proxy.url).origin]}:{}),...(route.signals?.length?{signals:[...new Set(route.signals.map(signal=>egressUrl(signal.url).origin))]}:{})};
64
+ return env.length || secrets.length || Object.keys(egress).length ? [[path,{env:[...new Set(env)],secrets:[...new Set(secrets)],...(Object.keys(egress).length?{egress}:{})}]] : [];
54
65
  }))};
55
66
  }
67
+
68
+ /** Called before resources or credentials are activated. Grants never come from YAML. */
69
+ export function authorizeEgress(loaded ,projectSha256 ,policy ) {
70
+ const proxy=new Set (),signals=new Set ();
71
+ for(const [path,route] of Object.entries(loaded.routes))for(const [purpose,urls]of [['proxy',route.proxy?[route.proxy.url]:[]],['signals',route.signals?.map(signal=>signal.url)||[]]] )for(const destination of urls){
72
+ const origin=egressUrl(destination).origin;
73
+ assert(policy?.projectSha256===projectSha256&&policy.routes[path]?.egress?.[purpose]?.includes(origin),'Egress denied by revision-pinned operator policy');
74
+ (purpose==='proxy'?proxy:signals).add(origin);
75
+ }
76
+ assert(proxy.size<=64&&signals.size<=64,'Runtime egress origins exceed limit');return {proxy:[...proxy],signals:[...signals]};
77
+ }
package/dist/prerender.js CHANGED
@@ -1,6 +1,9 @@
1
1
  import {mkdir, writeFile} from 'node:fs/promises';
2
2
  import {resolve, relative, isAbsolute, join, dirname, sep} from 'node:path';
3
3
  import {createRuntime} from './runtime.js';
4
+ import {loadDocument, functionFile} from './config.js';
5
+ import {applySite} from './site.js';
6
+ import {collectFunctionSources, routeFunctions, MODULE_LIMIT, TOTAL_BYTE_LIMIT} from './function-sources.js';
4
7
  import {ConfigError, assert} from './errors.js';
5
8
 
6
9
 
@@ -80,6 +83,55 @@ export async function assertNativeProject(project , {allow = ['page', 'st
80
83
  } finally { await runtime.close(); }
81
84
  }
82
85
 
86
+ // Which routes can share one function snapshot. A guest snapshot is bounded in
87
+ // module count and source bytes (docs/FUNCTION-SECURITY.md), and those bounds
88
+ // are not relaxed for build-time content: a site whose pages each compile to
89
+ // their own module therefore cannot be rendered in a single pass past 127
90
+ // modules, whatever their size. Rather than making that the caller's problem,
91
+ // the helper measures each route's module closure up front — reading sources
92
+ // only, never building a snapshot — and packs routes greedily into passes that
93
+ // stay inside the budgets. Shared modules (a template middleware) are counted
94
+ // once per pass, so they are paid for in every pass that needs them.
95
+ async function planPasses(source ) {
96
+ const loaded = await loadDocument(source);
97
+ // Site conventions become routes before compilation, so they must be named
98
+ // here too: `only` is matched against the expanded route set.
99
+ await applySite(loaded, {log: () => {}});
100
+ const patterns = Object.keys(loaded.routes);
101
+ assert(patterns.length, 'Source project declares no routes');
102
+ const cost = new Map ();
103
+ for (const pattern of patterns) {
104
+ const route = loaded.routes[pattern] ;
105
+ const definitions = [];
106
+ for (const definition of routeFunctions(route))
107
+ definitions.push({function: {source: await functionFile(loaded.root, definition.source), export: definition.export || 'default'}});
108
+ // One route at a time: the same collector the snapshot uses, so a route
109
+ // whose own closure cannot fit fails here with the collector's message.
110
+ const collected = await collectFunctionSources(definitions, loaded.root);
111
+ cost.set(pattern, Object.entries(collected.sources).map(([name, code]) => [name, Buffer.byteLength(code)]));
112
+ }
113
+ const passes = [];
114
+ let current = [], modules = new Map (), bytes = 0;
115
+ for (const pattern of patterns) {
116
+ const own = cost.get(pattern) ;
117
+ const fits = () => {
118
+ const added = own.filter(([name]) => !modules.has(name));
119
+ // `collect` admits a module only while the count is below the limit, so a
120
+ // pass holds at most MODULE_LIMIT - 1 modules.
121
+ return modules.size + added.length <= MODULE_LIMIT - 1 && bytes + added.reduce((total, [, size]) => total + size, 0) <= TOTAL_BYTE_LIMIT;
122
+ };
123
+ if (current.length && !fits()) {
124
+ passes.push(current);
125
+ current = []; modules = new Map(); bytes = 0;
126
+ }
127
+ assert(fits(), `Route ${pattern} needs ${own.length} modules and ${own.reduce((total, [, size]) => total + size, 0)} bytes of source, more than one snapshot can hold`);
128
+ for (const [name, size] of own) if (!modules.has(name)) { modules.set(name, size); bytes += size; }
129
+ current.push(pattern);
130
+ }
131
+ if (current.length) passes.push(current);
132
+ return passes;
133
+ }
134
+
83
135
  export async function prerenderPages(project , output , {
84
136
  origin = 'http://localhost', fileName = pageFileName, ignoreUnrenderable = false,
85
137
  maxPages = 500, maxPageBytes = 512 * 1024, maxTotalBytes = 32 * 1024 * 1024, log = () => {},
@@ -97,49 +149,56 @@ export async function prerenderPages(project , output , {
97
149
  assert(!overlaps(source, directory) && !overlaps(directory, source), 'Source and output directories must not overlap');
98
150
 
99
151
  const pages = [];
100
- let bytes = 0;
101
- const runtime = await createRuntime(source, {log: () => {}});
102
- try {
103
- const inventory = runtime.testPlan().inventory;
104
- const renderable = (route ) => route.handler === 'function' && route.state === 'active' && route.methods.includes('GET');
105
- const targets = inventory.filter(renderable);
106
- // Silently skipping a route publishes an incomplete site that looks whole.
107
- if (!ignoreUnrenderable) {
108
- const skipped = inventory.filter(route => !renderable(route));
109
- assert(!skipped.length,
110
- `Source project has routes this build would not render: ${skipped.map(route => `${route.path} (${route.handler}, ${route.state})`).join(', ')}. Pass ignoreUnrenderable to allow it`);
111
- }
112
- assert(targets.length, 'Source project has no active GET function routes to prerender');
113
- assert(targets.length <= maxPages, `Source project page count ${targets.length} exceeds maxPages ${maxPages}`);
114
- // Case-insensitive: on macOS and Windows two names differing only in case
115
- // are one file, so the second render would silently replace the first.
116
- const taken = new Map ();
117
- for (const route of targets) {
118
- assertLiteralRoutePath(route.path);
119
- const file = fileName(route.path);
120
- assert(typeof file === 'string' && FILE.test(file) && !PROTECTED.has(file), `Unsafe output filename ${file} for route ${route.path}`);
121
- const key = file.toLowerCase();
122
- assert(!taken.has(key), `Routes ${taken.get(key)} and ${route.path} both render ${file}`);
123
- taken.set(key, route.path);
124
- const result = await runtime.handle({target: route.path, method: 'GET', origin});
125
- assert(result.status === 200, `${route.path} rendered ${result.status}; expected 200`);
126
- const type = result.headers.find(([name]) => name.toLowerCase() === 'content-type')?.[1] ?? '';
127
- assert(/^text\/html\s*(?:;|$)/i.test(type), `${route.path} rendered ${type || 'no content type'}; expected text/html`);
128
- // The response body is bytes. Keeping it as a Buffer is what preserves a
129
- // multi-byte character exactly, through the file and its fixture alike.
130
- const body = typeof result.body === 'string' ? Buffer.from(result.body) : Buffer.from(result.body ?? []);
131
- assert(body.length > 0 && body.length <= maxPageBytes,
132
- `${route.path} rendered ${body.length} bytes; expected 1..${maxPageBytes} (maxPageBytes)`);
133
- bytes += body.length;
134
- assert(bytes <= maxTotalBytes, `Rendered pages exceed the total budget of ${maxTotalBytes} bytes (maxTotalBytes)`);
135
- pages.push({path: route.path, file, bytes: body.length, body});
136
- log({event: 'prerendered', path: route.path, file, bytes: body.length});
152
+ let bytes = 0, targetCount = 0;
153
+ const renderable = (route ) => route.handler === 'function' && route.state === 'active' && route.methods.includes('GET');
154
+ // Case-insensitive, and shared across passes: on macOS and Windows two names
155
+ // differing only in case are one file, so the second render would silently
156
+ // replace the first.
157
+ const taken = new Map ();
158
+ const passes = await planPasses(source);
159
+ if (passes.length > 1) log({event: 'prerender-passes', passes: passes.length});
160
+ for (const only of passes) {
161
+ // One runtime per pass, each holding only its own pass's function snapshot.
162
+ const runtime = await createRuntime(source, {log: () => {}, only});
163
+ try {
164
+ const inventory = runtime.testPlan().inventory;
165
+ const targets = inventory.filter(renderable);
166
+ // Silently skipping a route publishes an incomplete site that looks whole.
167
+ if (!ignoreUnrenderable) {
168
+ const skipped = inventory.filter(route => !renderable(route));
169
+ assert(!skipped.length,
170
+ `Source project has routes this build would not render: ${skipped.map(route => `${route.path} (${route.handler}, ${route.state})`).join(', ')}. Pass ignoreUnrenderable to allow it`);
171
+ }
172
+ targetCount += targets.length;
173
+ assert(targetCount <= maxPages, `Source project page count ${targetCount} exceeds maxPages ${maxPages}`);
174
+ for (const route of targets) {
175
+ assertLiteralRoutePath(route.path);
176
+ const file = fileName(route.path);
177
+ assert(typeof file === 'string' && FILE.test(file) && !PROTECTED.has(file), `Unsafe output filename ${file} for route ${route.path}`);
178
+ const key = file.toLowerCase();
179
+ assert(!taken.has(key), `Routes ${taken.get(key)} and ${route.path} both render ${file}`);
180
+ taken.set(key, route.path);
181
+ const result = await runtime.handle({target: route.path, method: 'GET', origin});
182
+ assert(result.status === 200, `${route.path} rendered ${result.status}; expected 200`);
183
+ const type = result.headers.find(([name]) => name.toLowerCase() === 'content-type')?.[1] ?? '';
184
+ assert(/^text\/html\s*(?:;|$)/i.test(type), `${route.path} rendered ${type || 'no content type'}; expected text/html`);
185
+ // The response body is bytes. Keeping it as a Buffer is what preserves a
186
+ // multi-byte character exactly, through the file and its fixture alike.
187
+ const body = typeof result.body === 'string' ? Buffer.from(result.body) : Buffer.from(result.body ?? []);
188
+ assert(body.length > 0 && body.length <= maxPageBytes,
189
+ `${route.path} rendered ${body.length} bytes; expected 1..${maxPageBytes} (maxPageBytes)`);
190
+ bytes += body.length;
191
+ assert(bytes <= maxTotalBytes, `Rendered pages exceed the total budget of ${maxTotalBytes} bytes (maxTotalBytes)`);
192
+ pages.push({path: route.path, file, bytes: body.length, body});
193
+ log({event: 'prerendered', path: route.path, file, bytes: body.length});
194
+ }
195
+ } finally {
196
+ // The runtime owns worker threads. Close it whether or not the render
197
+ // succeeded, so a failing build exits instead of hanging.
198
+ await runtime.close();
137
199
  }
138
- } finally {
139
- // The runtime owns worker threads. Close it whether or not the render
140
- // succeeded, so a failing build exits instead of hanging.
141
- await runtime.close();
142
200
  }
201
+ assert(targetCount, 'Source project has no active GET function routes to prerender');
143
202
 
144
203
  // Nothing is written until every page has rendered, and the directory itself
145
204
  // must be new: a failed or partial build never damages an existing artifact.
@@ -5,12 +5,12 @@ import { startServer } from './server.js';
5
5
  import { readCases, hit } from './readiness.js';
6
6
 
7
7
 
8
-
8
+
9
9
 
10
10
 
11
- export async function runProjectTests(project , { log = () => {}, permissions, linkStore, origin } = {}) {
11
+ export async function runProjectTests(project , { log = () => {}, permissions, linkStore, origin, extensions, plugins } = {}) {
12
12
  const root = await realpath(project), cases = await readCases(root);
13
- const app = await startServer({ project, port: 0, local: true, log, permissions, linkStore, origin });
13
+ const app = await startServer({ project, port: 0, local: true, log, permissions, linkStore, origin, extensions, plugins });
14
14
  const agent = new Agent({keepAlive:true,maxSockets:1}); let failed = 0;
15
15
  try {
16
16
  for (const [i,test] of cases.entries()) {
@@ -0,0 +1,92 @@
1
+ import { request } from 'node:https';
2
+ import { createHash } from 'node:crypto';
3
+
4
+
5
+
6
+
7
+
8
+
9
+
10
+
11
+
12
+
13
+
14
+
15
+
16
+ const MAX_BYTES=64*1024;
17
+ const prefix='/urlcode-conformance';
18
+ /** Synthetic read-only routes, except a POST to a declared constant response. */
19
+ export function providerConformanceCases() {
20
+ return [
21
+ {id:'redirect-query-drop',path:`${prefix}/redirect?ignored=secret`,method:'GET',headers:{},expected:{status:302,headers:{location:'https://example.test/destination'},body:''}},
22
+ {id:'redirect-head',path:`${prefix}/redirect`,method:'HEAD',headers:{},expected:{status:302,headers:{location:'https://example.test/destination'},body:''}},
23
+ {id:'path-component',path:`${prefix}/people/A%20B`,method:'GET',headers:{},expected:{status:307,headers:{location:'https://example.test/people/A%20B'},body:''}},
24
+ {id:'query-validation',path:`${prefix}/query?n=7&ignored=yes`,method:'GET',headers:{},expected:{status:302,headers:{location:'https://example.test/query?n=7'},body:''}},
25
+ {id:'query-invalid',path:`${prefix}/query?n=not-a-number`,method:'GET',headers:{},expected:{status:400}},
26
+ {id:'query-duplicate',path:`${prefix}/query?n=1&n=2`,method:'GET',headers:{},expected:{status:400}},
27
+ {id:'constant-response',path:`${prefix}/hello`,method:'GET',headers:{},expected:{status:200,headers:{'content-type':'text/plain; charset=utf-8','x-conformance':'v1'},body:'provider conformance v1'}},
28
+ {id:'response-head',path:`${prefix}/hello`,method:'HEAD',headers:{},expected:{status:200,body:''}},
29
+ {id:'method-refusal',path:`${prefix}/hello`,method:'POST',headers:{},body:'',expected:{status:405,headers:{allow:'GET, HEAD'}}},
30
+ {id:'body-accepted',path:`${prefix}/body`,method:'POST',headers:{'content-type':'text/plain'},body:'small',expected:{status:200,body:'accepted'}},
31
+ {id:'body-limit',path:`${prefix}/body`,method:'POST',headers:{'content-type':'text/plain'},body:'x'.repeat(33),expected:{status:413}},
32
+ {id:'missing-route',path:`${prefix}/missing`,method:'GET',headers:{},expected:{status:404}},
33
+ ];
34
+ }
35
+ function optionsChecked(target ,options ) {
36
+ if(!['self-hosted','aws','vercel','cloudflare'].includes(target))throw new Error('Unknown verification target');
37
+ const timeout=options.timeoutMs??3000;
38
+ if(!Number.isInteger(timeout)||timeout<50||timeout>10000)throw new Error('Timeout must be 50–10000 ms');
39
+ for(const value of [options.release,options.gitCommit])if(value!==undefined&&(typeof value!=='string'||value.length>256||/[\u0000-\u001f\u007f]/u.test(value)))throw new Error('Release identity must be at most 256 printable characters');
40
+ return timeout;
41
+ }
42
+ async function run(target ,transport ,options ,origin ) {
43
+ const timeout=optionsChecked(target,options),cases=providerConformanceCases(),findings =[];
44
+ const deadline=performance.now()+60000;let requests=0;
45
+ for(const probe of cases){
46
+ const failures =[];let status =null;
47
+ const controller=new AbortController();let timer ;
48
+ try{
49
+ const remaining=deadline-performance.now();if(remaining<=0)throw new Error('suite deadline');
50
+ requests++;
51
+ const answer=await Promise.race([transport(probe,controller.signal),new Promise ((_,reject)=>{timer=setTimeout(()=>{reject(new Error('request deadline'));controller.abort();},Math.min(timeout,remaining));})]);
52
+ status=answer.status;
53
+ if(Buffer.byteLength(answer.body)>MAX_BYTES)throw new Error('response limit');
54
+ if(answer.status!==probe.expected.status)failures.push('status differs');
55
+ const headers=Object.fromEntries(Object.entries(answer.headers).map(([name,value])=>[name.toLowerCase(),value]));
56
+ for(const [name,value]of Object.entries(probe.expected.headers??{}))if(headers[name]!==value)failures.push(`header ${name} differs`);
57
+ if(probe.expected.body!==undefined&&answer.body!==probe.expected.body)failures.push('body differs');
58
+ }catch{failures.push('transport, deadline or response-limit failure');}
59
+ finally{clearTimeout(timer);controller.abort();}
60
+ findings.push({case:probe.id,pass:failures.length===0,expectedStatus:probe.expected.status,observedStatus:status,failures});
61
+ }
62
+ return {schemaVersion:1,fixtureVersion:1,target,origin,evidence:origin===null?'local-adapter':'deployment-http',providerVerification:origin===null?'unverified':'observed',timestamp:new Date().toISOString(),release:options.release??null,gitCommit:options.gitCommit??null,fixtureSha256:createHash('sha256').update(JSON.stringify(cases)).digest('hex'),pass:findings.every(f=>f.pass),requests,findings,notes:[
63
+ origin===null?'Local adapter replay is not evidence of a deployed provider.':'HTTP observations apply only to the supplied origin and timestamp; provider identity and ownership are asserted by the caller, not attested.',
64
+ 'Release and Git identities are caller-supplied labels, not independently verified deployment identity.',
65
+ 'No provisioning, credentials, policy guarantees, soak, recovery or independent security assessment is included.',
66
+ 'Duplicate raw header counts and malformed-path normalization can differ at provider ingress; this common suite does not prove those transports equivalent.'
67
+ ]};
68
+ }
69
+ /** Replay against a caller-supplied local adapter; never label this a provider deployment. */
70
+ export async function runProviderConformance(target ,transport ,options ={}) {
71
+ return run(target,transport,options,null);
72
+ }
73
+ function httpsTransport(origin ) {
74
+ return (probe,signal)=>new Promise((resolve,reject)=>{
75
+ let settled=false;
76
+ const fail=()=>{if(!settled){settled=true;reject(new Error('HTTPS probe failed'));}};
77
+ const req=request(new URL(probe.path,origin),{method:probe.method,headers:{'user-agent':'URLCode-provider-conformance/1','accept-encoding':'identity',...probe.headers},agent:false,maxHeaderSize:16384,signal},res=>{
78
+ const chunks =[];let size=0;
79
+ res.on('error',fail);res.on('aborted',fail);
80
+ res.on('data',(chunk )=>{size+=chunk.length;if(size>MAX_BYTES){fail();res.destroy();req.destroy();}else chunks.push(chunk);});
81
+ res.on('end',()=>{if(settled)return;settled=true;const headers ={};for(const [name,value]of Object.entries(res.headers))if(value!==undefined)headers[name]=Array.isArray(value)?value.join(', '):value;resolve({status:res.statusCode??0,headers,body:Buffer.concat(chunks).toString('utf8')});});
82
+ });
83
+ req.on('error',fail);req.end(probe.body);
84
+ });
85
+ }
86
+ /** Probe only an explicitly supplied, operator-owned deployment of the fixture. Redirects are never followed. */
87
+ export async function verifyProviderDeployment(target ,origin ,options ={}) {
88
+ optionsChecked(target,options);
89
+ const url=new URL(origin);
90
+ if(url.protocol!=='https:'||url.username||url.password||url.pathname!=='/'||url.search||url.hash||url.origin!==origin.replace(/\/$/,''))throw new Error('Provide an explicit HTTPS origin without credentials, path, query or fragment');
91
+ return run(target,httpsTransport(url),options,url.origin);
92
+ }
package/dist/proxy.js ADDED
@@ -0,0 +1,44 @@
1
+ import { EgressError, egressUrl, safeEgressHeaders } from './egress.js';
2
+
3
+
4
+
5
+
6
+ const sensitive=new Set(['authorization','cookie','set-cookie','proxy-authorization','forwarded','x-forwarded-for','x-forwarded-host','x-forwarded-proto']);
7
+ export function validateProxy(definition ) {
8
+ if(!definition||typeof definition!=='object'||Object.keys(definition).some(key=>!['url','query','requestHeaders','responseHeaders','headers'].includes(key))) throw new EgressError('denied');
9
+ const parsed=egressUrl(definition.url);
10
+ if(/[{}]/.test(parsed.host)||(/[{}]/.test(parsed.search)||/%7[bd]/i.test(parsed.search))||/%7[bd]/i.test(parsed.host)) throw new EgressError('denied');
11
+ if(/%7[bd]/i.test(parsed.pathname.replace(/%7B[A-Za-z_][A-Za-z0-9_]*%7D/gi,''))) throw new EgressError('denied');
12
+ if(definition.url.length>8192) throw new EgressError('limit');
13
+ for(const list of [definition.query,definition.requestHeaders,definition.responseHeaders]) if(list&&(!Array.isArray(list)||list.length>32||list.some(value=>typeof value!=='string'||!value||value.length>128))) throw new EgressError('denied');
14
+ for(const name of [...definition.requestHeaders||[],...definition.responseHeaders||[]]) {if(sensitive.has(name.toLowerCase())||/^x-forwarded-/i.test(name)) throw new EgressError('denied');safeEgressHeaders({[name]:'x'});}
15
+ safeEgressHeaders(definition.headers||{});
16
+ }
17
+ export async function executeProxy(client ,definition ,input ) {
18
+ validateProxy(definition);
19
+ const url=egressUrl(definition.url); const origin=url.origin;
20
+ // URL serializes braces in path unchanged. Arguments can never affect origin/query.
21
+ url.pathname=url.pathname.replace(/%7B([A-Za-z_][A-Za-z0-9_]*)%7D/gi,(_all,name )=> {
22
+ const value=input.params[name]; if(typeof value!=='string'||value==='.'||value==='..') throw new EgressError('denied'); return encodeURIComponent(value);
23
+ });
24
+ if((/[{}]/.test(url.pathname))||url.origin!==origin) throw new EgressError('denied');
25
+ const incoming=new URL(input.url); for(const name of definition.query||[]) for(const value of incoming.searchParams.getAll(name)) url.searchParams.append(name,value);
26
+ if(url.href.length>8192) throw new EgressError('limit');
27
+ const headers =Object.create(null);
28
+ const requestHop=new Set((input.headers.connection||'').toLowerCase().split(',').map(name=>name.trim()));
29
+ for(const name of definition.requestHeaders||[]) {const value=input.headers[name.toLowerCase()]; if(value!==undefined&&!requestHop.has(name.toLowerCase())) headers[name.toLowerCase()]=value;}
30
+ Object.assign(headers,definition.headers||{});
31
+ const safeHeaders=safeEgressHeaders(headers);
32
+ if(input.body?.byteLength){
33
+ const coding=(value )=>value?.split(',').map(part=>part.trim().toLowerCase()).join(',')||'identity';
34
+ const incomingCoding=coding(input.headers['content-encoding']);
35
+ if(incomingCoding!==coding(safeHeaders['content-encoding'])||(incomingCoding!=='identity'&&!(definition.requestHeaders||[]).some(name=>name.toLowerCase()==='content-encoding')))throw new EgressError('denied');
36
+ }
37
+ const result=await client.request({url:url.href,method:input.method,headers:safeHeaders,...(input.body?{body:input.body}:{}),...(input.signal?{signal:input.signal}:{})});
38
+ if(result.headers['content-encoding']&&result.headers['content-encoding']!=='identity'&&!(definition.responseHeaders||[]).some(name=>name.toLowerCase()==='content-encoding'))throw new EgressError('denied');
39
+ const selected =Object.create(null);
40
+ const responseHop=new Set((result.headers.connection||'').toLowerCase().split(',').map(name=>name.trim()));
41
+ for(const name of definition.responseHeaders||[]) {const value=result.headers[name.toLowerCase()]; if(value!==undefined&&!responseHop.has(name.toLowerCase())) selected[name.toLowerCase()]=value;}
42
+ if(result.headers['content-encoding']&&result.headers['content-encoding']!=='identity'&&!selected['content-encoding'])throw new EgressError('denied');
43
+ return {...result,headers:safeEgressHeaders(selected)};
44
+ }
package/dist/readiness.js CHANGED
@@ -13,7 +13,7 @@ import { runCompliance } from './compliance.js';
13
13
 
14
14
 
15
15
 
16
-
16
+
17
17
  /** One configured route as the inventory reports it: a PlanInventoryEntry with the handler kind named. */
18
18
 
19
19
  /** One request case: a generated probe or a `tests/requests.json` fixture. */
@@ -41,7 +41,7 @@ import { runCompliance } from './compliance.js';
41
41
 
42
42
 
43
43
 
44
- const handlers = ['redirect','function','page','static','download','respond','link'] ;
44
+ const handlers = ['extension','proxy','conditional','redirect','function','page','static','download','respond','link'] ;
45
45
  /** Narrows a compiled route to one that redirects, so redirectLocation can read its spec. */
46
46
  export const hasRedirect = (route ) => Boolean(route.redirect);
47
47
  const isRecord = (value ) => value !== null && typeof value === 'object' && !Array.isArray(value);
@@ -52,7 +52,7 @@ export function projectPlan(compiled )
52
52
  const routes = [...compiled.exact.values(), ...[...compiled.byLength.values()].flat(), ...compiled.mounts];
53
53
  const now = Date.now();
54
54
  const inventory = routes.map(route => ({ path:route.pattern, handler:handlers.find(key => route[key]), methods:route.methods, middleware:route.middleware?.length || 0,
55
- policies:route.policy ? Object.keys(route.policy.describe) : [],
55
+ policies:[...(route.policy ? Object.keys(route.policy.describe) : []),...(route.extensionPolicyNames??[]).map(name=>`extensions.${name}`)],
56
56
  ...(route.generated ? { generated:route.generated } : {}),
57
57
  state:route.enabled === false ? 'disabled' : route.expiresAt && now >= route.expiresAt ? 'expired' : 'active' }));
58
58
  const cases = [];
@@ -60,10 +60,10 @@ export function projectPlan(compiled )
60
60
  const entry = inventory[i];
61
61
  if (!entry) continue;
62
62
  if (entry.state !== 'active') {
63
- if(!route.names.length && !route.static) cases.push({path:route.pattern,method:'GET',status:entry.state==='disabled'?404:410});
63
+ if(!route.names.length && !route.static && !route.extension && !route.extensionPolicyNames?.length) cases.push({path:route.pattern,method:'GET',status:entry.state==='disabled'?404:410});
64
64
  continue;
65
65
  }
66
- if (route.function || route.link || route.middleware?.length || route.names.length) continue;
66
+ if (route.extension || route.extensionPolicyNames?.length || route.proxy || route.signals?.length || route.match || route.conditional || route.function || route.link || route.middleware?.length || route.names.length) continue;
67
67
  // Required inputs need intentional fixtures; never invent business data.
68
68
  let context ;
69
69
  try { context = contextFor(route,{},new URLSearchParams(),new Headers()); } catch { continue; }