@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
@@ -0,0 +1,132 @@
1
+ import {relative} from 'node:path';
2
+ import Ajv from 'ajv/dist/2020.js';
3
+ import {analyzeCompiledCapabilities,capabilityTargets,routeCapabilities} from './capabilities.js';
4
+
5
+ import {effectiveExtensionPolicies} from './extensions.js';
6
+
7
+ import {effectivePolicies} from './policies.js';
8
+
9
+
10
+
11
+
12
+
13
+ // Effective route behavior read from the compiled IR (config → router →
14
+ // policies), never from request execution. Everything here is safe to print:
15
+ // binding values are replaced by their names, module paths are made
16
+ // project-relative and secrets never appear.
17
+
18
+ const handlerNames=['extension','proxy','conditional','redirect','function','page','static','download','respond','link'] ;
19
+
20
+
21
+
22
+
23
+
24
+
25
+
26
+
27
+
28
+
29
+
30
+
31
+
32
+
33
+
34
+
35
+
36
+
37
+
38
+
39
+
40
+
41
+
42
+ const relativeSource=(root ,source ) =>relative(root,source).split('\\').join('/');
43
+ function origin(url ) {try{return new URL(url).origin;}catch{return url;}}
44
+
45
+ function handlerOf(route ,root ) {
46
+ const kind=handlerNames.find(key=>route[key]);
47
+ switch(kind){
48
+ case 'extension':return {kind,name:route.extension};
49
+ case 'proxy':return {kind,url:route.proxy .url,...(route.proxy .query?{query:route.proxy .query}:{}),...(route.proxy .requestHeaders?{requestHeaders:route.proxy .requestHeaders}:{}),...(route.proxy .responseHeaders?{responseHeaders:route.proxy .responseHeaders}:{})};
50
+ case 'conditional':{
51
+ const branch=(entry )=>entry.redirect?{redirect:{url:entry.redirect.url,status:entry.redirect.status??302}}:{respond:{status:entry.reply?.status??200}};
52
+ return {kind,cases:(route.conditionalRoutes?.cases??[]).map(item=>({match:item.match,...branch(item.route)})),...(route.conditionalRoutes?.fallback?{fallback:branch(route.conditionalRoutes.fallback)}:{})};
53
+ }
54
+ case 'redirect':return {kind,url:route.redirect .url,status:route.redirect .status??302,...(route.redirect .query?{query:route.redirect .query}:{})};
55
+ case 'function':return {kind,source:relativeSource(root,route.function .source),export:route.function .export,...(route.function .args?{args:route.function .args}:{})};
56
+ case 'page':return {kind,file:route.page .file,...(route.page .contentType?{contentType:route.page .contentType}:{})};
57
+ case 'static':return {kind,directory:route.static .directory,...(route.static .index?{index:route.static .index}:{})};
58
+ case 'download':return {kind,file:route.download .file,...(route.download .filename?{filename:route.download .filename}:{}),...(route.download .contentType?{contentType:route.download .contentType}:{})};
59
+ case 'respond':return {kind,status:route.reply?.status??route.respond .status??200};
60
+ case 'link':return {kind,collection:route.link .collection};
61
+ default:return {kind:'none'};
62
+ }
63
+ }
64
+ function cacheOf(route ,chain ,extensionPolicies ) {
65
+ const forced=route.extension?'extension mount':extensionPolicies.length?'extension-protected route':route.proxy?'proxy':route.match||route.conditional?'conditional routing':undefined;
66
+ if(forced)return {outcome:'no-store',cacheControl:'no-store',forcedNoStore:true,reason:`The runtime replaces every cache header on this ${forced} with no-store`};
67
+ const policy=chain?.describe.cache;
68
+ if(policy){const cacheControl=policy.cacheControl;return {outcome:policy.strategy??'policy',...(cacheControl===undefined?{}:{cacheControl}),forcedNoStore:false,reason:policy.target==='delegated'?'policies.cache is delegated to the provider on this target':'policies.cache compiled for this route'};}
69
+ const header=route.responseHeaders.find(([name])=>name.toLowerCase()==='cache-control');
70
+ if(header)return {outcome:'explicit response header',cacheControl:header[1],forcedNoStore:false,reason:'response.headers declares Cache-Control'};
71
+ const asset=route.page?.cacheControl??route.download?.cacheControl??route.static?.cacheControl;
72
+ if(asset)return {outcome:'asset handler',cacheControl:asset,forcedNoStore:false,reason:'the asset declaration sets cacheControl'};
73
+ return {outcome:'none',forcedNoStore:false,reason:'no cache policy or Cache-Control header is declared'};
74
+ }
75
+ function providerOf(name ,requirement ,options ) {
76
+ if(!options.extensions)return undefined;
77
+ const registration=options.extensions.find(entry=>entry.name===name);
78
+ if(!registration)return {registered:false};
79
+ let requirementValid =null;
80
+ if(requirement)try{requirementValid=registration.policySchema?Boolean(new Ajv.default({strict:false,allErrors:false}).compile(registration.policySchema)(requirement)):false;}catch{requirementValid=false;}
81
+ return {registered:true,version:registration.version,targets:[...registration.targets],revisionMatch:options.projectSha256===undefined?false:registration.projectSha256===options.projectSha256,requirementValid};
82
+ }
83
+ function targetsOf(loaded ,route ) {
84
+ const table={exact:new Map([[route.pattern,route]]),byLength:new Map(),mounts:[],modules:[],count:1};
85
+ const result={} ;
86
+ for(const target of capabilityTargets){
87
+ const report=analyzeCompiledCapabilities(loaded.document,table,target);
88
+ const issues=report.issues.filter(issue=>issue.path===route.pattern).map(({capability,support,reason})=>({capability,support,reason}));
89
+ result[target]={compatible:issues.length===0,issues};
90
+ }
91
+ return result;
92
+ }
93
+ export function routeState(route ,now ) {return route.enabled===false?'disabled':route.expiresAt&&now>=route.expiresAt?'expired':'active';}
94
+ /** Describe one compiled route. `chain` is the policy chain compiled for it, when the project declares policies. */
95
+ export function explainCompiledRoute(loaded ,route ,chain ,options ={}) {
96
+ const root=loaded.root,declared=loaded.routes[route.pattern];
97
+ const extensionRequirements=effectiveExtensionPolicies(loaded.document,route),extensionNames=Object.keys(extensionRequirements).sort();
98
+ const extensions ={};
99
+ for(const name of extensionNames){const provider=providerOf(name,extensionRequirements[name],options);extensions[name]={requirement:extensionRequirements[name] ,...(provider?{provider}:{})};}
100
+ const env ={};
101
+ for(const [alias,ref]of Object.entries(declared?.env??{}))env[alias]=ref.env?{env:ref.env}:{literal:true};
102
+ const secrets ={};
103
+ for(const [alias,ref]of Object.entries(declared?.secrets??{}))secrets[alias]={secret:ref.secret};
104
+ const inventory=chain?.describe??{};
105
+ const names=[...Object.keys(effectivePolicies(loaded.document,route)),...extensionNames.map(name=>`extensions.${name}`)];
106
+ const handler=handlerOf(route,root);
107
+ if(handler.kind==='extension'){const provider=providerOf(route.extension ,undefined,options);if(provider)handler.provider=provider;}
108
+ const egress ={...(route.proxy?{proxy:origin(route.proxy.url)}:{}),...(route.signals?.length?{signals:[...new Set(route.signals.map(signal=>origin(signal.url)))]}:{})};
109
+ return {
110
+ matched:true,path:route.pattern,...(route.description?{description:route.description}:{}),...(route.generated?{generated:route.generated}:{}),
111
+ state:routeState(route,options.now??Date.now()),enabled:route.enabled!==false,...(route.expires?{expires:route.expires}:{}),
112
+ methods:[...route.methods],conditional:Boolean(route.match||route.conditional),handler,
113
+ middleware:route.middleware.map(item=>({source:relativeSource(root,item.source),export:item.export})),
114
+ inputs:{parameters:route.parameters.map(({name,in:location,required,schema})=>({name,in:location,required,schema})),...(route.request?.body?{body:route.request.body}:{})},
115
+ policies:{names,inventory,extensions},
116
+ cache:cacheOf(route,chain,extensionNames),
117
+ bindings:{env,secrets},egress,
118
+ responseHeaders:route.responseHeaders.map(([name,value])=>[name,value]),
119
+ capabilities:routeCapabilities(route,loaded.document),targets:targetsOf(loaded,route),
120
+ note:'Derived from the compiled configuration; request conditions, parameter values and handler execution are not evaluated.',
121
+ };
122
+ }
123
+ // Edit distance between a requested path and each pattern, so a typo points
124
+ // at the route the author probably meant.
125
+ function distance(a ,b ) {
126
+ const previous=Array.from({length:b.length+1},(_,i)=>i);
127
+ for(let i=1;i<=a.length;i++){let diagonal=previous[0] ;previous[0]=i;for(let j=1;j<=b.length;j++){const temp=previous[j] ;previous[j]=Math.min(previous[j] +1,previous[j-1] +1,diagonal+(a[i-1]===b[j-1]?0:1));diagonal=temp;}}
128
+ return previous[b.length] ;
129
+ }
130
+ export function nearestRoutes(target ,patterns ,limit=3) {
131
+ return [...patterns].map(pattern=>({pattern,score:distance(target.toLowerCase(),pattern.toLowerCase())})).sort((a,b)=>a.score-b.score||(a.pattern<b.pattern?-1:1)).slice(0,limit).map(item=>item.pattern);
132
+ }
@@ -0,0 +1,172 @@
1
+ import Ajv from 'ajv/dist/2020.js';
2
+ import { assert, HttpError } from './errors.js';
3
+ import { loadDocument } from './config.js';
4
+ import { prepareFunctionSnapshot } from './policy.js';
5
+ import { validateHeaderName, validateHeaderValue } from './header-validation.js';
6
+
7
+
8
+
9
+
10
+
11
+
12
+
13
+
14
+
15
+
16
+
17
+
18
+
19
+
20
+
21
+
22
+ /** Trusted operator code only. YAML declares names/configuration, never modules. */
23
+ /**
24
+ * Content-hashed assets under `<mount><prefix>/` may be cached publicly. The
25
+ * extension owns the hashed filename; the runtime only relaxes its no-store floor
26
+ * for a GET/HEAD 200/304 that carries a strong ETag, sets no cookie and does not
27
+ * vary on credentials. Everything else under the mount stays no-store.
28
+ */
29
+
30
+
31
+
32
+
33
+
34
+
35
+ /** `urlcode init --with <name>` contract: what core hands `@jimhoyd/urlcode-<name>`'s `scaffold` export. Nothing is written by `scaffold`. */
36
+
37
+
38
+
39
+
40
+
41
+
42
+
43
+
44
+
45
+
46
+
47
+
48
+
49
+
50
+
51
+
52
+
53
+
54
+
55
+
56
+
57
+
58
+
59
+
60
+
61
+
62
+ /** What the runtime knows about the request when it applies the privacy floor. */
63
+
64
+
65
+ const namePattern=/^[a-z][a-z0-9-]{0,63}$/;
66
+ const cacheHeaders=new Set(['cache-control','cdn-cache-control','vercel-cdn-cache-control','surrogate-control']);
67
+ const segmentPattern=/^[A-Za-z0-9_-][A-Za-z0-9._-]{0,63}$/;
68
+ export const immutableCacheControl='public, max-age=31536000, immutable';
69
+ /** A normalized absolute path prefix: literal segments only, no dot segments, no trailing slash. */
70
+ function validateAssetPrefix(prefix ,name ) {
71
+ assert(typeof prefix==='string'&&prefix.length>=2&&prefix.length<=256&&prefix.startsWith('/')&&!prefix.endsWith('/'),`Extension ${name} immutableAssets.prefix must be a normalized absolute path`);
72
+ assert(prefix.slice(1).split('/').every(segment=>segmentPattern.test(segment)&&segment!=='.'&&segment!=='..'),`Extension ${name} immutableAssets.prefix must contain literal path segments only`);
73
+ return prefix;
74
+ }
75
+ function frozen (value ) {if(value&&typeof value==='object'){for(const child of Object.values(value))frozen(child);Object.freeze(value);}return value;}
76
+ /** Inspection alone grants nothing; operators must explicitly pin the returned revision. */
77
+ export async function inspectExtensionRevision(project ) {return (await prepareFunctionSnapshot(await loadDocument(project))).projectSha256;}
78
+ export function effectiveExtensionPolicies(document ,route ) {
79
+ const project=document.policies??{},local=route.policies??{};
80
+ const layers=[project.profile?document.profiles?.[project.profile]:undefined,project,local.profile?document.profiles?.[local.profile]:undefined,local];
81
+ const result =Object.create(null) ;
82
+ for(const layer of layers){if(layer?.extensions===false){for(const key of Object.keys(result))delete result[key];continue;}
83
+ for(const [name,value]of Object.entries(layer?.extensions??{})) {if(value===false)delete result[name];else result[name]={...result[name],...value};}
84
+ }
85
+ return result;
86
+ }
87
+ export function hasExtensionPolicy(document ,route ) {return Object.keys(effectiveExtensionPolicies(document,route)).length>0;}
88
+ export function prepareExtensions(document ,routes ,registrations ,context ) {
89
+ assert(registrations===undefined||Array.isArray(registrations)&&registrations.length<=16,'Extensions must be an array of at most 16 operator registrations');
90
+ const provided=new Map (),entries=new Map (),credentialHeaders=new Set ();
91
+ for(const registration of registrations??[]){
92
+ assert(registration&&typeof registration==='object'&&typeof registration.name==='string'&&namePattern.test(registration.name),'Invalid extension registration');
93
+ assert(!provided.has(registration.name),'Duplicate extension provider');
94
+ assert(registration.version==='1'&&typeof registration.activate==='function','Invalid extension version or activation hook');
95
+ assert(Array.isArray(registration.targets)&&registration.targets.every(target=>['node','aws','vercel'].includes(target)),'Extension targets must be node, aws or vercel');
96
+ assert(typeof registration.projectSha256==='string'&&/^[a-f0-9]{64}$/.test(registration.projectSha256),'Extension requires an explicit operator revision pin');
97
+ provided.set(registration.name,registration);
98
+ }
99
+ const declarations=document.extensions??{};
100
+ if(Object.keys(declarations).length){let origin ;try{origin=new URL(context.origin);}catch{throw new Error('Extensions require an explicit operator origin');}assert(['http:','https:'].includes(origin.protocol)&&!origin.username&&!origin.password&&origin.origin===context.origin,'Extensions require an explicit canonical HTTP(S) operator origin');}
101
+ for(const route of Object.values(routes)){
102
+ if(route.extension)assert(Object.hasOwn(declarations,route.extension),'Extension route has no declaration');
103
+ for(const name of Object.keys(effectiveExtensionPolicies(document,route)))assert(Object.hasOwn(declarations,name),'Extension policy has no declaration');
104
+ }
105
+ const preparations =[];
106
+ for(const [name,declaration]of Object.entries(declarations)){
107
+ const registration=provided.get(name);assert(registration,`Missing operator extension: ${name}`);
108
+ assert(registration.projectSha256===context.projectSha256,`Extension revision pin mismatch: ${name}`);
109
+ assert(registration.targets.includes(context.target),`Extension ${name} refuses target ${context.target}`);
110
+ assert(declaration.version===registration.version,`Extension contract version mismatch: ${name}`);
111
+ const config=structuredClone(declaration.config);
112
+ const ajv=new Ajv.default({strict:true,allErrors:false});
113
+ assert(ajv.compile(registration.schema)(config),`Invalid extension configuration: ${name}`);
114
+ const policyValidator=registration.policySchema?ajv.compile(registration.policySchema):undefined;
115
+ const policies=new Map ();
116
+ for(const [path,route]of Object.entries(routes)){const policy=effectiveExtensionPolicies(document,route)[name];if(policy){assert(policyValidator&&policyValidator(policy),`Invalid extension policy: ${name} at ${path}`);policies.set(path,frozen(structuredClone(policy)));}}
117
+ const declaredHeaders=registration.credentialHeaders??[];
118
+ assert(Array.isArray(declaredHeaders)&&declaredHeaders.length<=64,'Invalid extension credential headers');
119
+ for(const header of declaredHeaders){assert(typeof header==='string'&&header.length<=128,'Invalid extension credential header');validateHeaderName(header);credentialHeaders.add(header.toLowerCase());}
120
+ // Session and bearer credentials never cross into application guests.
121
+ credentialHeaders.add('cookie');credentialHeaders.add('authorization');
122
+ const mounts=Object.entries(routes).filter(([,route])=>route.extension===name).map(([path])=>path.endsWith('/*')?path.slice(0,-2):path);
123
+ const assetPrefixes=registration.immutableAssets===undefined?[]:mounts.map(mount=>mount+validateAssetPrefix((registration.immutableAssets ).prefix,name)+'/');
124
+ preparations.push({name,registration,config:frozen(config),policies,mounts,assetPrefixes});
125
+ }
126
+ return {async activate(){
127
+ try{for(const {name,registration,config,policies,mounts,assetPrefixes}of preparations){
128
+ const instance=await registration.activate(config,frozen({...context,mounts}));
129
+ if(instance&&typeof instance==='object')entries.set(name,{instance,policies,assetPrefixes});
130
+ assert(instance&&typeof instance.handle==='function'&&(!policies.size||typeof instance.authorize==='function'),`Extension ${name} lacks a required handler or authorization hook`);
131
+ entries.set(name,{instance,policies,assetPrefixes});
132
+ }}catch(error){for(const entry of [...entries.values()].reverse())try{await entry.instance.close?.();}catch{/* Keep the activation failure. */}throw error;}
133
+ return {entries,credentialHeaders:[...credentialHeaders],async close(){for(const entry of [...entries.values()].reverse())try{await entry.instance.close?.();}catch{/* Operators own extension lifecycle diagnostics. */}}};
134
+ }};
135
+ }
136
+ const header=(headers ,name ) =>headers.filter(([key])=>key.toLowerCase()===name).map(([,value])=>value);
137
+ const maxAge=(value ) =>{const match=/(?:^|[\s,])max-age\s*=\s*"?(\d+)/i.exec(value);return match?Number(match[1]):undefined;};
138
+ /**
139
+ * The runtime decides; the extension cannot opt in from a response alone. The
140
+ * declared prefix, the GET/HEAD method, a 200/304 status, one strong ETag, no
141
+ * Set-Cookie and no Vary on Cookie/Authorization are all required. A stricter
142
+ * Cache-Control the extension set (no-store, no-cache, private or a shorter
143
+ * max-age) is preserved.
144
+ */
145
+ export function immutableAssetResponse(result ,asset ) {
146
+ if(!asset||!asset.prefixes.some(prefix=>asset.path.startsWith(prefix)))return false;
147
+ if(asset.method!=='GET'&&asset.method!=='HEAD')return false;
148
+ if(result.status!==200&&result.status!==304)return false;
149
+ const etag=header(result.headers,'etag');
150
+ if(etag.length!==1||!/^"[!#-~]+"$/.test(etag[0] ))return false;
151
+ if(header(result.headers,'set-cookie').length)return false;
152
+ if(header(result.headers,'vary').some(value=>value.split(',').some(field=>['cookie','authorization','*'].includes(field.trim().toLowerCase()))))return false;
153
+ return true;
154
+ }
155
+ function assetCacheControl(result ) {
156
+ const declared=header(result.headers,'cache-control');
157
+ if(declared.length!==1)return immutableCacheControl;
158
+ const value=declared[0] ,lower=value.toLowerCase();
159
+ if(/(?:^|[\s,])(?:no-store|no-cache|private)(?:$|[\s,=])/.test(lower))return value;
160
+ const age=maxAge(value);
161
+ return age!==undefined&&age<31536000?value:immutableCacheControl;
162
+ }
163
+ /** Mandatory privacy floor after trusted response hooks, with bounded output. */
164
+ export function extensionResponse(result ,asset ) {
165
+ assert(result&&Number.isInteger(result.status)&&result.status>=200&&result.status<=599&&Array.isArray(result.headers),'Invalid extension response');
166
+ if(result.body&&Buffer.byteLength(result.body)>1048576)throw new HttpError(502,'Extension response exceeds limit');
167
+ let bytes=0;assert(result.headers.length<=256,'Extension response has too many headers');
168
+ for(const [name,value]of result.headers){validateHeaderName(name);validateHeaderValue(name,value);bytes+=Buffer.byteLength(name)+Buffer.byteLength(value)+4;}
169
+ if(bytes>16384)throw new HttpError(502,'Extension response headers exceed limit');
170
+ const cacheControl=immutableAssetResponse(result,asset)?assetCacheControl(result):'no-store';
171
+ return {...result,headers:[...result.headers.filter(([name])=>!cacheHeaders.has(name.toLowerCase())),['cache-control',cacheControl]]};
172
+ }
@@ -17,6 +17,12 @@ import { assert } from './errors.js';
17
17
 
18
18
 
19
19
 
20
+ /** Snapshot budgets: what one set of guest modules may cost. Deliberate bounds
21
+ * of the sandbox contract (docs/FUNCTION-SECURITY.md), not tuning knobs. */
22
+ export const MODULE_LIMIT = 128;
23
+ export const MODULE_BYTE_LIMIT = 1048576;
24
+ export const TOTAL_BYTE_LIMIT = 4194304;
25
+
20
26
  export function routeFunctions (route ) { return [...(route.middleware || []), ...(route.function ? [route.function] : [])]; }
21
27
 
22
28
  // Parse and snapshot source without ever importing project code into Node.
@@ -28,11 +34,17 @@ export async function collectFunctionSources(routes , root
28
34
  async function collect(file ) {
29
35
  const name = '/' + relative(root,file).split(sep).join('/');
30
36
  if (Object.hasOwn(sources,name)) return name;
31
- assert(Object.keys(sources).length < 128, 'Function module limit exceeded');
37
+ // Name the module that crossed the budget and the counts against their
38
+ // limits: the bare limit alone reads as a sandbox fault, when it usually
39
+ // means the project has outgrown what one snapshot holds. Prerendering
40
+ // splits a large site across snapshots rather than inheriting the bound as
41
+ // a page ceiling. See docs/PRERENDER.md#function-budgets.
42
+ assert(Object.keys(sources).length < MODULE_LIMIT, `Function module limit exceeded: ${name} is module ${Object.keys(sources).length + 1}, over the limit of ${MODULE_LIMIT} modules per snapshot`);
32
43
  const info = await stat(file);
33
- assert(info.size <= 1048576 && bytes + info.size <= 4194304, 'Function source limit exceeded');
44
+ assert(info.size <= MODULE_BYTE_LIMIT, `Function source limit exceeded: ${name} is ${info.size} bytes, over the per-module limit of ${MODULE_BYTE_LIMIT} bytes`);
45
+ assert(bytes + info.size <= TOTAL_BYTE_LIMIT, `Function source limit exceeded: ${name} (${info.size} bytes) brings the snapshot to ${bytes + info.size} bytes, over the total limit of ${TOTAL_BYTE_LIMIT} bytes`);
34
46
  const code = await readFile(file,'utf8'); bytes += Buffer.byteLength(code);
35
- assert(bytes <= 4194304, 'Function source limit exceeded');
47
+ assert(bytes <= TOTAL_BYTE_LIMIT, `Function source limit exceeded: ${name} brings the snapshot to ${bytes} bytes, over the total limit of ${TOTAL_BYTE_LIMIT} bytes`);
36
48
  sources[name] = code; const deps = dependencies[name] = [];
37
49
  const [imports] = parse(code);
38
50
  for (const item of imports) {
package/dist/index.js CHANGED
@@ -9,3 +9,40 @@ export {startLinkApi} from './link-api.js';
9
9
 
10
10
 
11
11
  export { events as observabilityEvents, validateObservers, createObserverSink, createMetrics, renderPrometheus } from './observability.js';
12
+
13
+ export { getCapabilities, routeCapabilities, analyzeProjectCapabilities, analyzeCompiledCapabilities, assertTargetCompatibility, normalizeCapabilityTarget } from './capabilities.js';
14
+ export { capabilityDetails } from './capabilities.js';
15
+
16
+
17
+ export { importRoutes, exportRoutes } from './interchange.js';
18
+
19
+
20
+ export {listRecipes, searchRecipes, showRecipe, addRecipe} from './recipes.js';
21
+
22
+ export {listExamples, searchExamples} from './examples.js';
23
+
24
+
25
+ export {buildTypeScriptProject} from './typescript-authoring.js';
26
+
27
+ export {importBulkProject} from './bulk.js';
28
+
29
+ export {inspectProject, validateProject, explainRoute, explainProject, previewImport, previewExport, getCapability, getSchemaFragment, schemaPathNames, inspectExtensions, describeExtensions, buildContext, renderContext, estimateTokens, documentationTokens} from './tooling.js';
30
+
31
+ export {buildManifest, renderManifest, MANIFEST_SCHEMA_VERSION} from './manifest.js';
32
+
33
+ export {serveMcp} from './mcp.js';
34
+
35
+ export {providerConformanceCases, runProviderConformance, verifyProviderDeployment} from './provider-verification.js';
36
+
37
+ export {normalizeMatch, assertDisjointMatches, matchesRoute} from './conditions.js';
38
+
39
+
40
+ export {buildCloudflare} from './build-cloudflare.js';
41
+
42
+ export {runProjectTests} from './project-tests.js';
43
+
44
+ export {scaffoldProject} from './scaffold.js';
45
+
46
+ export {initProject, addRedirect} from './authoring.js';
47
+ export {initProjectWith} from './init-with.js';
48
+
@@ -0,0 +1,165 @@
1
+ import { mkdir, open, readFile, rm, unlink, lstat } from 'node:fs/promises';
2
+ import { createRequire } from 'node:module';
3
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+ import { parseDocument, stringify } from 'yaml';
6
+ import { initProject } from './authoring.js';
7
+ import { mcpConfigFile, renderMcpConfig } from './agents-guide.js';
8
+ import { loadDocument, parseYaml, validateDocument } from './config.js';
9
+ import { inspectExtensionRevision } from './extensions.js';
10
+
11
+ import { ConfigError, assert } from './errors.js';
12
+
13
+ /** Directory names inside the generated site. The route project lives under `app/`; everything else is operator-owned. */
14
+ export const PROJECT_DIRECTORY = 'app', HOST_FILE = 'host.mjs', ROUTES_FILE = 'routes/extensions.yaml';
15
+ const namePattern = /^[a-z][a-z0-9-]{0,63}$/;
16
+
17
+
18
+
19
+ export function parseWithNames(value ) {
20
+ const names = value.split(',').map(name => name.trim());
21
+ assert(names.length > 0 && names.every(name => namePattern.test(name)), 'Use --with name[,name] where each name is a lowercase extension package suffix such as auth');
22
+ assert(new Set(names).size === names.length, 'Duplicate --with names');
23
+ return names;
24
+ }
25
+ export const packageName = (name ) => `@jimhoyd/urlcode-${name}`;
26
+ const isCode = (error , code ) => error instanceof Error && 'code' in error && error.code === code;
27
+ const strings = (value ) => Array.isArray(value) && value.every(item => typeof item === 'string');
28
+ const record = (value ) => value !== null && typeof value === 'object' && !Array.isArray(value);
29
+
30
+ /**
31
+ * Resolves the extension package from the invoking directory (Node's package resolution with the default
32
+ * conditions), imports it, and calls its `scaffold` export. Nothing is bundled; core never imports these packages
33
+ * at build time. Refuses a missing package or a package without `scaffold` before anything is written.
34
+ */
35
+ export async function loadScaffold(name , request , cwd ) {
36
+ const pkg = packageName(name);
37
+ let entry ;
38
+ try { entry = createRequire(join(cwd, 'package.json')).resolve(pkg); }
39
+ catch (error) {
40
+ if (isCode(error, 'MODULE_NOT_FOUND')) throw new ConfigError(`Extension package ${pkg} is not installed in ${cwd}; run: npm install ${pkg}`);
41
+ throw error;
42
+ }
43
+ const module = await import(pathToFileURL(entry).href) ;
44
+ const scaffold = module.scaffold;
45
+ if (typeof scaffold !== 'function') throw new ConfigError(`${pkg} does not export scaffold; upgrade it to a release that supports urlcode init --with, or add ${name} by hand following its README`);
46
+ let result ;
47
+ try { result = await (scaffold )(request); }
48
+ catch (error) { throw new ConfigError(`${pkg} scaffold refused: ${error instanceof Error ? error.message : String(error)}`); }
49
+ assert(record(result) && result.name === name, `${pkg} scaffold must return a result named ${name}`);
50
+ assert(record(result.extensions) && record(result.routes), `${pkg} scaffold must return extensions and routes objects`);
51
+ assert(strings(result.hostImports) && strings(result.hostSetup) && strings(result.hostEntries) && (result.hostClose === undefined || strings(result.hostClose)), `${pkg} scaffold must return host fragments as string arrays`);
52
+ assert(strings(result.nextSteps) && typeof result.readme === 'string', `${pkg} scaffold must return readme text and nextSteps strings`);
53
+ assert(result.env === undefined || (record(result.env) && Object.values(result.env).every(item => typeof item === 'string')), `${pkg} scaffold env must map names to descriptions`);
54
+ assert(Array.isArray(result.files) && result.files.every((file ) => record(file) && typeof file.path === 'string' && (typeof file.content === 'string' || file.content instanceof Uint8Array) && (file.mode === undefined || (Number.isInteger(file.mode) && (file.mode ) >= 0 && (file.mode ) <= 0o777))), `${pkg} scaffold files must carry a path, content and an optional mode`);
55
+ return result ;
56
+ }
57
+
58
+ function filePath(root , path ) {
59
+ assert(typeof path === 'string' && path.length > 0 && path.length <= 1024 && !path.includes('\0'), 'Invalid scaffold file path');
60
+ const target = resolve(root, path), rel = relative(root, target);
61
+ assert(!path.startsWith('/') && rel === path.split('/').join(sep) && rel.length > 0 && !rel.startsWith('..'), `Scaffold file path must stay inside the site directory: ${path}`);
62
+ assert(rel !== PROJECT_DIRECTORY && !rel.startsWith(PROJECT_DIRECTORY + sep), `Scaffold files must stay outside the route project: ${path}`);
63
+ return target;
64
+ }
65
+ /** Creates the file exclusively: nothing generated is ever overwritten. */
66
+ async function write(target , content , mode = 0o644) {
67
+ await mkdir(dirname(target), { recursive: true, mode: 0o700 });
68
+ const file = await open(target, 'wx', mode);
69
+ try { await file.writeFile(content); await file.sync(); } finally { await file.close(); }
70
+ }
71
+ export function renderHost(names , results ) {
72
+ const lines = [`// Generated by urlcode init --with ${names.join(',')}. Trusted operator code: keep it outside ${PROJECT_DIRECTORY}/ and review before serving.`];
73
+ for (const result of results) lines.push(...result.hostImports);
74
+ lines.push('');
75
+ for (const result of results) if (result.hostSetup.length) lines.push(...result.hostSetup);
76
+ lines.push('export default {', ' extensions: [');
77
+ for (const result of results) for (const entry of result.hostEntries) lines.push(` ${entry},`);
78
+ lines.push(' ],', ' async close() {');
79
+ // Later extensions may depend on earlier setup, so release in reverse order.
80
+ for (const result of [...results].reverse()) for (const statement of result.hostClose ?? []) lines.push(` ${statement}`);
81
+ lines.push(' },', '};');
82
+ return lines.join('\n') + '\n';
83
+ }
84
+ function demote(markdown ) {
85
+ let fence = false;
86
+ return markdown.split('\n').map(line => { if (/^\s*(?:```|~~~)/.test(line)) fence = !fence; return !fence && /^#{1,5} /.test(line) ? `#${line}` : line; }).join('\n');
87
+ }
88
+ export function renderReadme(directory , names , results , starter , env , projectSha256 ) {
89
+ const steps = results.flatMap(result => result.nextSteps);
90
+ const parts = [`# ${basename(directory)}`, '',
91
+ `Created with \`urlcode init ${basename(directory)} --with ${names.join(',')}\`. \`${PROJECT_DIRECTORY}/\` is the route project (\`urlcode.yaml\`, functions, tests); \`${HOST_FILE}\` is the trusted operator host that wires the installed extension packages; operator modules and private data stay outside the project. Run every command with \`--project ${PROJECT_DIRECTORY} --host-file "$PWD/${HOST_FILE}"\`.`, '',
92
+ '## Starter', '', `The starter files live in \`${PROJECT_DIRECTORY}/\`; add \`--project ${PROJECT_DIRECTORY}\` and the host file to the commands below.`, '', demote(starter).trim(), ''];
93
+ for (const result of results) parts.push(`## Extension: ${result.name}`, '', result.readme.trim(), '');
94
+ parts.push('## Next steps', '', ...steps.map((step, index) => `${index + 1}. ${step}`), '');
95
+ if (Object.keys(env).length) parts.push('## Environment', '', ...Object.entries(env).map(([key, text]) => `- \`${key}\`: ${text}`), '');
96
+ parts.push('## Project revision', '', `\`${PROJECT_DIRECTORY}/urlcode.yaml\` currently has revision \`${projectSha256}\` (\`inspectExtensionRevision\`). Review the project, then pin exactly that value where the host expects it; any change to extension YAML, policies or mounts changes it and needs a new explicit review.`, '');
97
+ return parts.join('\n');
98
+ }
99
+
100
+ /**
101
+ * `urlcode init <directory> --with a,b`: the starter under `app/`, every extension's fragments merged into one
102
+ * `urlcode.yaml`, one `host.mjs`, one `README.md` and the extensions' own files. All packages are resolved and
103
+ * their scaffolds computed before anything is written, so a refusal leaves no directory behind.
104
+ */
105
+ export async function initProjectWith(destination , names , { cwd = process.cwd() } = {}) {
106
+ assert(names.length > 0, 'Provide at least one --with name');
107
+ const directory = resolve(destination), project = join(directory, PROJECT_DIRECTORY), hostFile = join(directory, HOST_FILE);
108
+ const request = { directory, project, hostFile, names };
109
+ const results = [];
110
+ const wipe = () => { for (const result of results) for (const file of result.files) if (file.content instanceof Uint8Array) file.content.fill(0); };
111
+ try {
112
+ for (const name of names) results.push(await loadScaffold(name, request, cwd));
113
+ // Cross-result conflicts are refused before the destination exists.
114
+ const extensions = Object.create(null) , routes = Object.create(null) , env = {};
115
+ const owners = new Map ();
116
+ for (const result of results) {
117
+ for (const [key, value] of Object.entries(result.extensions)) { assert(!Object.hasOwn(extensions, key), `Extension ${key} is declared by both ${owners.get('e:' + key)} and ${result.name}`); owners.set('e:' + key, result.name); extensions[key] = value; }
118
+ for (const [key, value] of Object.entries(result.routes)) { assert(!Object.hasOwn(routes, key), `Route ${key} is added by both ${owners.get('r:' + key)} and ${result.name}`); owners.set('r:' + key, result.name); routes[key] = value; }
119
+ for (const [key, value] of Object.entries(result.env ?? {})) { assert(!Object.hasOwn(env, key) || env[key] === value, `Environment variable ${key} is described differently by ${result.name}`); env[key] = value; }
120
+ const seen = new Set ();
121
+ for (const file of result.files) { const path = filePath(directory, file.path); assert(!seen.has(path), `${result.name} scaffolds ${file.path} twice`); seen.add(path); }
122
+ }
123
+ await mkdir(dirname(directory), { recursive: true });
124
+ await mkdir(directory, { mode: 0o700 }); // refuses an existing destination
125
+ try {
126
+ await initProject(project);
127
+ const starter = await readFile(join(project, 'README.md'), 'utf8');
128
+ await unlink(join(project, 'README.md')); // its content moves into the site README
129
+ await unlink(join(project, mcpConfigFile)); // re-registered at the site root, pointing at app/
130
+ // Refuse routes or extensions the starter already declares, including in its included files.
131
+ const loaded = await loadDocument(project);
132
+ for (const key of Object.keys(routes)) assert(!Object.hasOwn(loaded.routes, key), `Route ${key} from ${owners.get('r:' + key)} already exists in the starter`);
133
+ for (const key of Object.keys(extensions)) assert(!Object.hasOwn(loaded.document.extensions ?? {}, key), `Extension ${key} from ${owners.get('e:' + key)} already exists in the starter`);
134
+ const yamlFile = join(project, 'urlcode.yaml'), original = await readFile(yamlFile, 'utf8');
135
+ const doc = parseDocument(original);
136
+ // Extensions are declared in the entry file; their routes go into a last include so the starter's own routes
137
+ // stay first in the loaded order and the entry file stays small.
138
+ doc.set('extensions', { ...loaded.document.extensions, ...extensions });
139
+ doc.addIn(['includes'], ROUTES_FILE);
140
+ const fragment = stringify({ version: '1', routes });
141
+ validateDocument(doc.toJS()); validateDocument(parseYaml(fragment));
142
+ await write(join(project, ROUTES_FILE), `# Routes added by urlcode init --with ${names.join(',')}. Mounts are exclusive to the named extension.\n${fragment}`);
143
+ await rm(yamlFile); await write(yamlFile, String(doc));
144
+ await loadDocument(project);
145
+ const projectSha256 = await inspectExtensionRevision(project);
146
+ const written = new Set ();
147
+ for (const result of results) for (const file of result.files) {
148
+ const target = filePath(directory, file.path);
149
+ assert(!written.has(target), `Scaffold file ${file.path} is written by more than one extension`);
150
+ // Parent directories are created; existing files or symlinks anywhere on the path are refused.
151
+ let probe = dirname(target);
152
+ while (probe !== directory && probe.startsWith(directory)) { try { assert(!(await lstat(probe)).isSymbolicLink(), `Scaffold path passes through a symlink: ${file.path}`); } catch (error) { if (!isCode(error, 'ENOENT')) throw error; } probe = dirname(probe); }
153
+ await write(target, file.content, file.mode ?? 0o644); written.add(target);
154
+ }
155
+ await write(hostFile, renderHost(names, results), 0o600);
156
+ await write(join(directory, 'README.md'), renderReadme(directory, names, results, starter, env, projectSha256));
157
+ await write(join(directory, '.gitignore'), 'node_modules/\ndata/\n.env\n.env.*\n');
158
+ // The read-only MCP server for agents opened at the site root; --host-file and --allow-authoring stay operator choices.
159
+ await write(join(directory, mcpConfigFile), renderMcpConfig(PROJECT_DIRECTORY));
160
+ // AGENTS.md: initProject writes the application-level file into app/ once it produces one (NEXT-STEPS 1.1);
161
+ // nothing here overrides it. A site-level agent note would be assembled beside README.md at this point.
162
+ return { directory, project, hostFile, extensions: [...names], projectSha256, nextSteps: results.flatMap(result => result.nextSteps) };
163
+ } catch (error) { await rm(directory, { recursive: true, force: true }); throw error; }
164
+ } finally { wipe(); }
165
+ }
@@ -0,0 +1,42 @@
1
+ import { open, unlink } from 'node:fs/promises';
2
+ import { extname } from 'node:path';
3
+ import { loadDocument } from './config.js';
4
+ import { importRoutes, exportRoutes } from './interchange.js';
5
+
6
+ import { assert } from './errors.js';
7
+
8
+ export async function readConversionInput(file ) {
9
+ const handle=await open(file,'r');
10
+ try {
11
+ const stat=await handle.stat();assert(stat.isFile()&&stat.size<=32*1024*1024,'Conversion input must be a regular file of at most 32 MiB');
12
+ const buffer=Buffer.alloc(stat.size+1);let size=0;
13
+ while(size<buffer.length){const result=await handle.read(buffer,size,buffer.length-size,null);if(!result.bytesRead)break;size+=result.bytesRead;}
14
+ assert(size<=stat.size,'Conversion source grew during read');
15
+ return new TextDecoder('utf-8',{fatal:true}).decode(buffer.subarray(0,size));
16
+ } finally {await handle.close();}
17
+ }
18
+ export async function runInterchange(command ,args ,options ) {
19
+ assert(!options.report||options.report==='json','Use --report json');
20
+ const explicit=args.length===2?args[0]:undefined;
21
+ const file=args.length===2?args[1]:args[0];
22
+ assert(command==='import'?!!file&&args.length<=2:args.length===0,'Invalid conversion arguments');
23
+ const format=(options.format??(command==='export'?options.target:explicit??extname(file ).slice(1))) ;
24
+ let report ;
25
+ if(command==='import') report=await importRoutes({format,text:await readConversionInput(file ),source:file ,acceptProviderDifferences:options.acceptProviderDifferences===true});
26
+ else {
27
+ const loaded=await loadDocument(options.project);
28
+ const {includes:_includes,...document}=loaded.document;
29
+ report=await exportRoutes({format,document:{...document,routes:loaded.routes},source:options.project,acceptProviderDifferences:options.acceptProviderDifferences===true});
30
+ }
31
+ if(report.ok&&options.out&&!options.dryRun) {
32
+ const handle=await open(options.out,'wx',0o600);
33
+ try {await handle.writeFile(report.output );await handle.sync();}
34
+ catch(error){await handle.close();await unlink(options.out);throw error;}
35
+ await handle.close();
36
+ }
37
+ // Acknowledged non-lossless conversion always carries a report, even when
38
+ // output is written to a file; warnings cannot disappear into raw stdout.
39
+ const text=options.report==='json'||options.dryRun||options.out||!report.ok||!report.lossless
40
+ ? JSON.stringify(report)+'\n' : report.output ;
41
+ return {report,text};
42
+ }