@jimhoyd/urlcode 0.3.0 → 0.4.0-alpha.2

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 (380) hide show
  1. package/.claude/skills/urlcode-authoring/SKILL.md +122 -0
  2. package/.claude/skills/urlcode-operations/SKILL.md +108 -0
  3. package/.claude-plugin/marketplace.json +18 -0
  4. package/CONTRIBUTING.md +30 -2
  5. package/README.md +195 -255
  6. package/ROADMAP.md +143 -15
  7. package/SECURITY.md +31 -9
  8. package/dist/BUILD-MANIFEST.json +72 -47
  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/build-static.js +134 -0
  17. package/dist/bulk.js +37 -0
  18. package/dist/capabilities.js +262 -0
  19. package/dist/capability-query.js +71 -0
  20. package/dist/catalog.js +105 -0
  21. package/dist/cli.js +165 -34
  22. package/dist/client-address.js +1 -1
  23. package/dist/compliance-rules/baseline.js +9 -17
  24. package/dist/compliance-rules/privacy.js +7 -18
  25. package/dist/compliance-rules/shared.js +0 -2
  26. package/dist/compliance-rules/strict.js +5 -5
  27. package/dist/compliance.js +6 -8
  28. package/dist/conditions.js +88 -0
  29. package/dist/config.js +69 -6
  30. package/dist/context.js +155 -0
  31. package/dist/ecosystem-cli.js +88 -0
  32. package/dist/egress.js +98 -0
  33. package/dist/examples.js +92 -0
  34. package/dist/explain-cli.js +64 -0
  35. package/dist/explain.js +131 -0
  36. package/dist/extensions.js +231 -0
  37. package/dist/function-sources.js +49 -5
  38. package/dist/function-worker.js +3 -1
  39. package/dist/functions.js +84 -13
  40. package/dist/guest-api.js +29 -3
  41. package/dist/index.js +40 -6
  42. package/dist/init-with.js +165 -0
  43. package/dist/interchange-cli.js +42 -0
  44. package/dist/interchange.js +189 -0
  45. package/dist/manifest.js +109 -0
  46. package/dist/match.js +2 -2
  47. package/dist/mcp-authoring.js +147 -0
  48. package/dist/mcp.js +97 -0
  49. package/dist/observability.js +7 -21
  50. package/dist/operator-host.js +29 -0
  51. package/dist/plugins.js +12 -0
  52. package/dist/policies/agents.js +2 -2
  53. package/dist/policies/cache.js +8 -3
  54. package/dist/policies/compression.js +2 -1
  55. package/dist/policies/security.js +0 -0
  56. package/dist/policies.js +1 -1
  57. package/dist/policy.js +56 -15
  58. package/dist/prerender.js +100 -41
  59. package/dist/project-tests.js +3 -3
  60. package/dist/provider-verification.js +92 -0
  61. package/dist/proxy.js +44 -0
  62. package/dist/readiness.js +34 -11
  63. package/dist/recipes.js +41 -0
  64. package/dist/route-diff.js +106 -0
  65. package/dist/router.js +45 -7
  66. package/dist/runtime.js +164 -64
  67. package/dist/sandbox.js +48 -0
  68. package/dist/scaffold.js +0 -0
  69. package/dist/schema-query.js +62 -0
  70. package/dist/scripts/operational-drills.js +12 -54
  71. package/dist/server.js +3 -29
  72. package/dist/signals.js +24 -0
  73. package/dist/site.js +0 -0
  74. package/dist/tooling.js +96 -0
  75. package/dist/trusted-functions.js +210 -0
  76. package/dist/types/adapters.d.ts +7 -4
  77. package/dist/types/agent-lists.d.ts +0 -1
  78. package/dist/types/agents-guide.d.ts +17 -0
  79. package/dist/types/authoring-files.d.ts +10 -0
  80. package/dist/types/aws.d.ts +3 -1
  81. package/dist/types/build-cloudflare.d.ts +1 -0
  82. package/dist/types/build-static.d.ts +43 -0
  83. package/dist/types/bulk.d.ts +27 -0
  84. package/dist/types/capabilities.d.ts +64 -0
  85. package/dist/types/capability-query.d.ts +24 -0
  86. package/dist/types/catalog.d.ts +65 -0
  87. package/dist/types/client-address.d.ts +0 -1
  88. package/dist/types/compliance-rules/baseline.d.ts +1 -9
  89. package/dist/types/compliance-rules/privacy.d.ts +1 -4
  90. package/dist/types/compliance-rules/shared.d.ts +0 -2
  91. package/dist/types/compliance-rules/strict.d.ts +0 -5
  92. package/dist/types/compliance.d.ts +0 -3
  93. package/dist/types/conditions.d.ts +19 -0
  94. package/dist/types/config.d.ts +21 -2
  95. package/dist/types/context.d.ts +66 -0
  96. package/dist/types/ecosystem-cli.d.ts +17 -0
  97. package/dist/types/egress.d.ts +46 -0
  98. package/dist/types/examples.d.ts +50 -0
  99. package/dist/types/explain-cli.d.ts +11 -0
  100. package/dist/types/explain.d.ts +95 -0
  101. package/dist/types/extensions.d.ts +177 -0
  102. package/dist/types/function-sources.d.ts +9 -0
  103. package/dist/types/functions.d.ts +48 -5
  104. package/dist/types/guest-api.d.ts +1 -0
  105. package/dist/types/index.d.ts +36 -6
  106. package/dist/types/init-with.d.ts +30 -0
  107. package/dist/types/interchange-cli.d.ts +16 -0
  108. package/dist/types/interchange.d.ts +42 -0
  109. package/dist/types/manifest.d.ts +79 -0
  110. package/dist/types/match.d.ts +1 -0
  111. package/dist/types/mcp-authoring.d.ts +92 -0
  112. package/dist/types/mcp.d.ts +12 -0
  113. package/dist/types/observability.d.ts +3 -14
  114. package/dist/types/operator-host.d.ts +8 -0
  115. package/dist/types/plugins.d.ts +2 -0
  116. package/dist/types/policies/agents.d.ts +0 -2
  117. package/dist/types/policies/compression.d.ts +2 -0
  118. package/dist/types/policies/security.d.ts +0 -1
  119. package/dist/types/policy.d.ts +15 -4
  120. package/dist/types/project-tests.d.ts +3 -2
  121. package/dist/types/provider-verification.d.ts +53 -0
  122. package/dist/types/proxy.d.ts +21 -0
  123. package/dist/types/readiness.d.ts +10 -3
  124. package/dist/types/recipes.d.ts +30 -0
  125. package/dist/types/route-diff.d.ts +27 -0
  126. package/dist/types/router.d.ts +2 -1
  127. package/dist/types/runtime.d.ts +11 -27
  128. package/dist/types/sandbox.d.ts +12 -0
  129. package/dist/types/scaffold.d.ts +0 -2
  130. package/dist/types/schema-query.d.ts +12 -0
  131. package/dist/types/server.d.ts +1 -4
  132. package/dist/types/signals.d.ts +25 -0
  133. package/dist/types/site.d.ts +0 -1
  134. package/dist/types/tooling.d.ts +115 -0
  135. package/dist/types/trusted-functions.d.ts +29 -0
  136. package/dist/types/types.d.ts +71 -7
  137. package/dist/types/typescript-authoring.d.ts +12 -0
  138. package/dist/types/vercel.d.ts +3 -1
  139. package/dist/types/verify-deployment.d.ts +47 -0
  140. package/dist/types.js +37 -5
  141. package/dist/typescript-authoring.js +142 -0
  142. package/dist/vercel.js +4 -3
  143. package/dist/verify-deployment.js +270 -0
  144. package/docs/AI-AUTHORING.md +232 -15
  145. package/docs/AWS.md +4 -4
  146. package/docs/BEST-PRACTICES.md +3 -2
  147. package/docs/BULK.md +79 -0
  148. package/docs/CAPABILITIES.md +192 -0
  149. package/docs/CAPACITY.md +129 -32
  150. package/docs/CI.md +142 -0
  151. package/docs/CLOUDFLARE.md +1 -2
  152. package/docs/COMPLIANCE.md +6 -9
  153. package/docs/CONDITIONS.md +74 -0
  154. package/docs/DEPLOYMENT-CHECKS.md +108 -0
  155. package/docs/EGRESS.md +125 -0
  156. package/docs/EXTENSIONS.md +398 -0
  157. package/docs/FRAMEWORK.md +198 -0
  158. package/docs/FUNCTION-SECURITY.md +129 -32
  159. package/docs/INSTALL.md +45 -12
  160. package/docs/INTERCHANGE.md +134 -0
  161. package/docs/LOAD-TESTING.md +4 -4
  162. package/docs/MIDDLEWARE-EXAMPLES.md +75 -0
  163. package/docs/MIDDLEWARE.md +29 -16
  164. package/docs/MONITORING.md +2 -19
  165. package/docs/NEXT-PHASE-PLAN.md +98 -0
  166. package/docs/NEXT-STEPS.md +634 -0
  167. package/docs/OBSERVABILITY.md +11 -18
  168. package/docs/OPEN-DECISIONS.md +212 -0
  169. package/docs/OPERATIONAL-PROOF.md +30 -31
  170. package/docs/OPERATIONS.md +29 -35
  171. package/docs/PLUGINS.md +37 -0
  172. package/docs/POLICIES.md +23 -309
  173. package/docs/PRERENDER.md +41 -1
  174. package/docs/PROJECT-DIRECTION.md +75 -8
  175. package/docs/PROVIDER-VERIFICATION.md +84 -0
  176. package/docs/READINESS.md +21 -1
  177. package/docs/README.md +87 -34
  178. package/docs/RECIPES.md +99 -0
  179. package/docs/RELEASE-READINESS.md +57 -35
  180. package/docs/RELEASE-SECURITY.md +116 -7
  181. package/docs/RESILIENCE.md +16 -15
  182. package/docs/ROUTING.md +8 -10
  183. package/docs/SANDBOX-REVIEW.md +19 -6
  184. package/docs/SCAFFOLDING.md +0 -2
  185. package/docs/SECURITY-AUDIT.md +41 -1
  186. package/docs/SPECIFICATION.md +150 -29
  187. package/docs/SPIKE-AI-FRAMEWORK-BENCHMARK.md +287 -0
  188. package/docs/SPIKE-BUSINESS-SUITE.md +1021 -0
  189. package/docs/SPIKE-CORE-LAYERING.md +337 -0
  190. package/docs/SPIKE-DEFAULT-TRUST-MODEL.md +209 -0
  191. package/docs/SPIKE-EXTENSION-MODEL.md +419 -0
  192. package/docs/SPIKE-EXTENSIONS.md +6 -0
  193. package/docs/SPIKE-LAMBDA-COMPILE.md +201 -0
  194. package/docs/SPIKE-MONOREPO.md +322 -0
  195. package/docs/STANDARDS.md +150 -142
  196. package/docs/STARTERS.md +21 -1
  197. package/docs/STATIC.md +94 -0
  198. package/docs/TOOLING.md +295 -0
  199. package/docs/TUNNELS.md +0 -3
  200. package/docs/TYPESCRIPT-AUTHORING.md +82 -0
  201. package/docs/TYPESCRIPT.md +25 -4
  202. package/docs/USABILITY-REVIEW.md +129 -0
  203. package/docs/VERCEL.md +4 -5
  204. package/docs/VERSION-ALIGNMENT.md +205 -0
  205. package/docs/YAML-GUIDE.md +15 -479
  206. package/docs/YAML-REFERENCE.md +143 -22
  207. package/docs/policies/agents.md +1 -1
  208. package/docs/policies/cache.md +13 -0
  209. package/docs/policies/contract.md +52 -0
  210. package/docs/policies/hardened.md +56 -0
  211. package/docs/policies/interoperability.md +169 -0
  212. package/docs/policies/operations.md +45 -0
  213. package/docs/yaml/assets.md +36 -0
  214. package/docs/yaml/conditions.md +20 -0
  215. package/docs/yaml/functions.md +160 -0
  216. package/docs/yaml/middleware.md +29 -0
  217. package/docs/yaml/organization.md +74 -0
  218. package/docs/yaml/policies.md +37 -0
  219. package/docs/yaml/redirects.md +64 -0
  220. package/docs/yaml/responses.md +57 -0
  221. package/docs/yaml/site.md +24 -0
  222. package/examples/assets/example.yaml +17 -0
  223. package/examples/aws/example.yaml +20 -0
  224. package/examples/cloudflare/example.yaml +19 -0
  225. package/examples/compliance/example.yaml +11 -0
  226. package/examples/conditions/README.md +12 -0
  227. package/examples/conditions/example.yaml +19 -0
  228. package/examples/conditions/tests/requests.json +13 -0
  229. package/examples/conditions/urlcode.yaml +24 -0
  230. package/examples/cookbook/README.md +8 -4
  231. package/examples/cookbook/example.yaml +17 -0
  232. package/examples/cookbook/functions/catalog.mjs +3 -0
  233. package/examples/cookbook/functions/fail.mjs +4 -0
  234. package/examples/cookbook/functions/items.mjs +3 -0
  235. package/examples/cookbook/functions/profile.mjs +3 -0
  236. package/examples/cookbook/functions/resource.mjs +3 -0
  237. package/examples/cookbook/functions/status.mjs +3 -0
  238. package/examples/cookbook/middleware/auth.mjs +48 -0
  239. package/examples/cookbook/middleware/body.mjs +15 -0
  240. package/examples/cookbook/middleware/bucket.mjs +29 -0
  241. package/examples/cookbook/middleware/cors.mjs +21 -0
  242. package/examples/cookbook/middleware/debug.mjs +13 -0
  243. package/examples/cookbook/middleware/envelope.mjs +11 -0
  244. package/examples/cookbook/middleware/errors.mjs +11 -0
  245. package/examples/cookbook/middleware/etag.mjs +18 -0
  246. package/examples/cookbook/middleware/locale.mjs +20 -0
  247. package/examples/cookbook/middleware/maintenance.mjs +10 -0
  248. package/examples/cookbook/middleware/methods.mjs +15 -0
  249. package/examples/cookbook/middleware/negotiate.mjs +20 -0
  250. package/examples/cookbook/middleware/referer.mjs +12 -0
  251. package/examples/cookbook/middleware/request-id.mjs +16 -0
  252. package/examples/cookbook/route-index.json +676 -0
  253. package/examples/cookbook/routes/middleware.yaml +126 -0
  254. package/examples/cookbook/tests/requests.json +526 -0
  255. package/examples/cookbook/urlcode.yaml +1 -0
  256. package/examples/egress/README.md +22 -0
  257. package/examples/egress/example.yaml +19 -0
  258. package/examples/egress/urlcode.yaml +19 -0
  259. package/examples/extensions/README.md +7 -0
  260. package/examples/extensions/example.yaml +21 -0
  261. package/examples/extensions/urlcode.yaml +25 -0
  262. package/examples/monitoring/example.yaml +8 -0
  263. package/examples/prerender/README.md +2 -2
  264. package/examples/prerender/example.yaml +16 -0
  265. package/examples/provider-conformance/README.md +12 -0
  266. package/examples/provider-conformance/example.yaml +14 -0
  267. package/examples/provider-conformance/urlcode.yaml +34 -0
  268. package/examples/tunnel/example.yaml +8 -0
  269. package/examples/vercel/example.yaml +19 -0
  270. package/llms-full.txt +3084 -0
  271. package/llms.txt +61 -21
  272. package/package.json +36 -7
  273. package/packaging/claude-plugin/.claude-plugin/plugin.json +19 -0
  274. package/packaging/claude-plugin/skills/urlcode-authoring/SKILL.md +122 -0
  275. package/packaging/claude-plugin/skills/urlcode-operations/SKILL.md +108 -0
  276. package/recipes/authenticated-json-api/README.md +51 -0
  277. package/recipes/authenticated-json-api/functions/profile.mjs +5 -0
  278. package/recipes/authenticated-json-api/recipe.yaml +34 -0
  279. package/recipes/authenticated-json-api/tests/requests.json +39 -0
  280. package/recipes/authenticated-json-api/urlcode.yaml +12 -0
  281. package/recipes/contact-form/README.md +25 -0
  282. package/recipes/contact-form/functions/contact.mjs +17 -0
  283. package/recipes/contact-form/recipe.yaml +33 -0
  284. package/recipes/contact-form/tests/requests.json +47 -0
  285. package/recipes/contact-form/urlcode.yaml +18 -0
  286. package/recipes/cors-api/README.md +16 -0
  287. package/recipes/cors-api/functions/items.mjs +3 -0
  288. package/recipes/cors-api/middleware/cors.mjs +21 -0
  289. package/recipes/cors-api/recipe.yaml +26 -0
  290. package/recipes/cors-api/tests/requests.json +65 -0
  291. package/recipes/cors-api/urlcode.yaml +12 -0
  292. package/recipes/health-page/README.md +13 -0
  293. package/recipes/health-page/recipe.yaml +23 -0
  294. package/recipes/health-page/tests/requests.json +36 -0
  295. package/recipes/health-page/urlcode.yaml +19 -0
  296. package/recipes/json-api/README.md +6 -0
  297. package/recipes/json-api/functions/echo.mjs +3 -0
  298. package/recipes/json-api/recipe.yaml +25 -0
  299. package/recipes/json-api/tests/requests.json +34 -0
  300. package/recipes/json-api/urlcode.yaml +12 -0
  301. package/recipes/middleware/README.md +34 -0
  302. package/recipes/middleware/functions/catalog.mjs +3 -0
  303. package/recipes/middleware/functions/fail.mjs +4 -0
  304. package/recipes/middleware/functions/items.mjs +3 -0
  305. package/recipes/middleware/functions/profile.mjs +3 -0
  306. package/recipes/middleware/functions/resource.mjs +3 -0
  307. package/recipes/middleware/functions/status.mjs +3 -0
  308. package/recipes/middleware/middleware/auth.mjs +48 -0
  309. package/recipes/middleware/middleware/body.mjs +15 -0
  310. package/recipes/middleware/middleware/bucket.mjs +29 -0
  311. package/recipes/middleware/middleware/cors.mjs +21 -0
  312. package/recipes/middleware/middleware/debug.mjs +13 -0
  313. package/recipes/middleware/middleware/envelope.mjs +11 -0
  314. package/recipes/middleware/middleware/errors.mjs +11 -0
  315. package/recipes/middleware/middleware/etag.mjs +18 -0
  316. package/recipes/middleware/middleware/locale.mjs +20 -0
  317. package/recipes/middleware/middleware/maintenance.mjs +10 -0
  318. package/recipes/middleware/middleware/methods.mjs +15 -0
  319. package/recipes/middleware/middleware/negotiate.mjs +20 -0
  320. package/recipes/middleware/middleware/referer.mjs +12 -0
  321. package/recipes/middleware/middleware/request-id.mjs +16 -0
  322. package/recipes/middleware/public/guide.txt +1 -0
  323. package/recipes/middleware/recipe.yaml +50 -0
  324. package/recipes/middleware/tests/requests.json +528 -0
  325. package/recipes/middleware/urlcode.yaml +127 -0
  326. package/recipes/protected-download/README.md +22 -0
  327. package/recipes/protected-download/files/report.txt +1 -0
  328. package/recipes/protected-download/recipe.yaml +31 -0
  329. package/recipes/protected-download/tests/requests.json +32 -0
  330. package/recipes/protected-download/urlcode.yaml +15 -0
  331. package/recipes/redirect/README.md +7 -0
  332. package/recipes/redirect/recipe.yaml +25 -0
  333. package/recipes/redirect/tests/requests.json +19 -0
  334. package/recipes/redirect/urlcode.yaml +9 -0
  335. package/recipes/static-plus-api/README.md +15 -0
  336. package/recipes/static-plus-api/functions/info.mjs +3 -0
  337. package/recipes/static-plus-api/public/assets/index.html +3 -0
  338. package/recipes/static-plus-api/public/assets/site.css +1 -0
  339. package/recipes/static-plus-api/public/index.html +8 -0
  340. package/recipes/static-plus-api/recipe.yaml +29 -0
  341. package/recipes/static-plus-api/tests/requests.json +56 -0
  342. package/recipes/static-plus-api/urlcode.yaml +23 -0
  343. package/recipes/typescript/README.md +8 -0
  344. package/recipes/typescript/functions/hello.ts +5 -0
  345. package/recipes/typescript/recipe.yaml +23 -0
  346. package/recipes/typescript/tests/requests.json +18 -0
  347. package/recipes/typescript/urlcode.yaml +5 -0
  348. package/recipes/webhook-receiver/README.md +20 -0
  349. package/recipes/webhook-receiver/functions/receive.mjs +16 -0
  350. package/recipes/webhook-receiver/recipe.yaml +27 -0
  351. package/recipes/webhook-receiver/tests/requests.json +59 -0
  352. package/recipes/webhook-receiver/urlcode.yaml +23 -0
  353. package/schemas/recipe.schema.json +139 -0
  354. package/schemas/urlcode.schema.json +659 -110
  355. package/skills/urlcode/SKILL.md +119 -0
  356. package/starters/default/.github/workflows/urlcode.yml +23 -0
  357. package/starters/default/.mcp.json +12 -0
  358. package/starters/default/AGENTS.md +79 -0
  359. package/starters/default/urlcode.yaml +0 -1
  360. package/dist/link-api.js +0 -136
  361. package/dist/link-cli.js +0 -141
  362. package/dist/link-events.js +0 -76
  363. package/dist/link-records.js +0 -31
  364. package/dist/link-store-worker.js +0 -150
  365. package/dist/link-store.js +0 -250
  366. package/dist/management-policy.js +0 -41
  367. package/dist/sqlite-version.js +0 -6
  368. package/dist/types/link-api.d.ts +0 -30
  369. package/dist/types/link-cli.d.ts +0 -36
  370. package/dist/types/link-events.d.ts +0 -27
  371. package/dist/types/link-records.d.ts +0 -11
  372. package/dist/types/link-store-worker.d.ts +0 -1
  373. package/dist/types/link-store.d.ts +0 -130
  374. package/dist/types/management-policy.d.ts +0 -9
  375. package/dist/types/sqlite-version.d.ts +0 -1
  376. package/docs/DYNAMIC-LINKS.md +0 -561
  377. package/docs/MANAGEMENT-SECURITY.md +0 -82
  378. package/examples/live-links/README.md +0 -11
  379. package/examples/live-links/tests/requests.json +0 -6
  380. package/examples/live-links/urlcode.yaml +0 -16
@@ -0,0 +1,119 @@
1
+ ---
2
+ name: urlcode
3
+ description: Work on a URLCode project, recognized by a urlcode.yaml file with version "1" and served by the @jimhoyd/urlcode runtime. Use this skill whenever a task touches urlcode.yaml, its included route files, functions or middleware under a URLCode project, or asks to add, change, test or deploy URL behavior (redirects, responses, pages, files, functions, policies) in such a project. It teaches the authoring loop and how to retrieve the minimum reference from the installed runtime instead of guessing fields.
4
+ ---
5
+
6
+ # URLCode authoring loop
7
+
8
+ A URLCode project declares URL behavior in YAML; the installed runtime serves
9
+ it. Your job is to change the declaration and the minimal guest code it names,
10
+ then prove the change with the runtime's own checks. Never reimplement what the
11
+ runtime provides, and never invent fields.
12
+
13
+ ## Declarative-first default
14
+
15
+ > Use URLCode's highest-level declarative features whenever possible. Generate custom code only when the framework cannot express the requirement.
16
+
17
+ Check the installed version's primitives, YAML configuration, policies, supported
18
+ extensions and recipes/templates before writing a custom function or middleware.
19
+ Keep necessary custom code focused and report the capability gap; never invent
20
+ fields or bypass target limits or operator grants. See `docs/PROJECT-DIRECTION.md` in the installed runtime.
21
+
22
+ ## 1. Recognize the project
23
+
24
+ - The root has `urlcode.yaml` with `version: "1"`. Included route files are
25
+ listed under `includes`; functions, middleware and assets are referenced from
26
+ the project root.
27
+ - Read the project's `AGENTS.md` first if present; it lists the handlers,
28
+ policies and commands this runtime version supports.
29
+ - Find the runtime: `urlcode` on the PATH, or
30
+ `node node_modules/@jimhoyd/urlcode/dist/cli.js`, or
31
+ `node /path/to/urlcode/src/cli.ts` for a source checkout. Use one form for
32
+ every command below.
33
+
34
+ ## 2. Retrieve the minimum, do not read everything
35
+
36
+ If the project carries `.mcp.json` (written by `urlcode init`) and your client
37
+ has registered the `urlcode` server, prefer its tools over reading documents:
38
+ `get_context` (project summary, constraints, exact commands), `get_capability`
39
+ and `get_schema` (one capability or YAML fragment), `search_recipes`,
40
+ `explain` (a route's effective behavior) and `get_manifest`. The server is
41
+ read-only; `--allow-authoring` is an operator opt-in you never add yourself.
42
+ Without the server, run the CLI equivalents and read only the output:
43
+
44
+ ```sh
45
+ urlcode context --project DIR # get_context: summary, constraints, commands
46
+ urlcode capabilities # what this version implements, per target
47
+ urlcode capabilities --target NAME # before promising a provider deployment
48
+ urlcode capabilities NAME # get_capability: one capability's contract
49
+ urlcode schema PATH # get_schema: one YAML fragment
50
+ urlcode recipes search TEXT # search_recipes
51
+ urlcode explain PATH --project DIR # explain: a route's effective behavior
52
+ urlcode manifest --project DIR # get_manifest
53
+ urlcode recipes list # bundled starting points
54
+ urlcode recipes show NAME # one recipe's files, inline
55
+ urlcode routes --project DIR # the routes the project already has
56
+ ```
57
+
58
+ When a field or handler is unclear, ask the runtime, not memory:
59
+ `urlcode validate --local` names the rejected field and the route. The
60
+ installed package also ships `docs/YAML-REFERENCE.md` (generated field
61
+ inventory) and `schemas/urlcode.schema.json`; search them for the one key you
62
+ need rather than reading them whole.
63
+
64
+ ## 3. Choose the highest-level supported abstraction
65
+
66
+ 1. If a native handler expresses the behavior (`redirect`, `respond`, `page`,
67
+ `static`, `download`, `proxy`, `conditional`), write YAML only.
68
+ 2. Check supported extensions and their configuration before custom code. If a
69
+ recipe from `recipes list` is close, `urlcode recipes add NAME --out DIR`
70
+ and adapt the copy into the project's layout.
71
+ 3. Only then write a function or middleware: one exported handler, relative
72
+ ES-module imports inside the project only, inputs from validated `args`,
73
+ output as a `Response`. `function`/`middleware` routes run trusted and
74
+ unsandboxed by default: full Node, npm, filesystem and `fetch` access, like
75
+ any other project code. Add `sandbox: true` only when that route's own code
76
+ warrants isolation (untrusted input, an unreviewed contribution, a
77
+ particularly sensitive secret) — a `sandbox: true` route then has no
78
+ `fetch`, Node, npm, filesystem, WebSocket, streaming, crypto API or timers;
79
+ a need for those in a sandboxed route is a `proxy` route, a binding, or a
80
+ report.
81
+ 4. Declare routing, validation, middleware chains, policies, static serving,
82
+ caching, throttling and authentication wherever the runtime or a supported
83
+ extension provides them. Use custom code only for the unmet requirement.
84
+ Where a short form exists, it is the highest-level form: `auth: true` or
85
+ `auth: { role: admin }` on a route whose project declares an `auth`
86
+ extension, and `cache: { … }` for `policies.cache`. Each expands to the long
87
+ form; declaring both is refused.
88
+
89
+ Keep every route you were not asked to change. Match the file organization the
90
+ project already uses.
91
+
92
+ ## 4. Prove it
93
+
94
+ ```sh
95
+ urlcode validate --local --project DIR
96
+ urlcode test --project DIR
97
+ urlcode audit --project DIR --expect-routes N
98
+ ```
99
+
100
+ Add fixtures to `tests/requests.json` for each new route: positive and negative
101
+ cases, every active method, `HEAD`. Update `N` deliberately when routes are
102
+ added or removed, and update any `--expect-routes` in the project's README,
103
+ Makefile or CI workflow to match. A failing validation names the route; fix
104
+ the declaration rather than working around it.
105
+
106
+ ## 5. Grants, secrets and what to report
107
+
108
+ - A function that needs a secret or environment value declares a named `env`
109
+ or `secrets` binding in YAML and stops there. The operator grants it outside
110
+ the project, pinned to the project revision. Never create, edit or approve a
111
+ grant, policy file or host file, and never put a value in the project.
112
+ - Secrets stay out of YAML, functions, fixtures, unignored `.env` files and
113
+ commit messages.
114
+ - When the runtime cannot express a requirement (the validator rejects it,
115
+ `capabilities` marks it refused for the target, or it needs guest network
116
+ or persistence), report exactly that with the route and capability named.
117
+ Do not invent fields, degrade silently or claim a workaround is equivalent.
118
+ - Report the three commands' results as the evidence. They are not a
119
+ deployment, a soak test or a security review.
@@ -0,0 +1,23 @@
1
+ # Validates, tests and audits this project on every push and pull request, and
2
+ # comments a route-inventory diff on pull requests. See
3
+ # https://github.com/jimhoyd-com/urlcode/blob/main/docs/CI.md
4
+ name: urlcode
5
+ on:
6
+ push:
7
+ pull_request:
8
+ permissions:
9
+ contents: read
10
+ pull-requests: write # the sticky route-diff comment; drop it to only log the diff
11
+ jobs:
12
+ check:
13
+ runs-on: ubuntu-latest
14
+ timeout-minutes: 10
15
+ steps:
16
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
17
+ with:
18
+ persist-credentials: false
19
+ # Pin to a release tag or commit of the runtime you depend on.
20
+ - uses: jimhoyd-com/urlcode/action@main
21
+ with:
22
+ expect-routes: 2 # update when adding or removing routes
23
+ compliance: baseline
@@ -0,0 +1,12 @@
1
+ {
2
+ "mcpServers": {
3
+ "urlcode": {
4
+ "command": "urlcode",
5
+ "args": [
6
+ "mcp",
7
+ "--project",
8
+ "."
9
+ ]
10
+ }
11
+ }
12
+ }
@@ -0,0 +1,79 @@
1
+ # Working on this project
2
+
3
+ This project uses URLCode: URL behavior is declared in `urlcode.yaml`, and the
4
+ installed `@jimhoyd/urlcode` runtime serves it. There is no framework code to
5
+ write for routing, validation, middleware wiring, policies, static serving or
6
+ authentication; the runtime provides them. Read this file before changing anything.
7
+
8
+ ## Before writing code
9
+
10
+ 1. Inspect `urlcode.yaml` first, then every file its `includes` list names,
11
+ the referenced functions, middleware and `tests/requests.json`. Preserve the
12
+ existing organization and every route you were not asked to change.
13
+ 2. Run `urlcode capabilities` to see what this runtime version implements and
14
+ which targets support it; `urlcode capabilities --target NAME` before
15
+ promising any provider deployment.
16
+ 3. Run `urlcode recipes list` and `urlcode recipes show NAME` before writing a
17
+ route from scratch. If a recipe covers the need, add it with
18
+ `urlcode recipes add NAME --out DIR` and adapt the copy.
19
+ 4. Use URLCode's highest-level declarative features whenever possible. Generate custom code only when the framework cannot express the requirement. Check supported extensions and recipes first; explain any capability gap.
20
+
21
+ ## Ask the runtime through MCP first
22
+
23
+ When present, `.mcp.json` registers the read-only `urlcode mcp` server. When it is
24
+ available, prefer its tools over reading documents: `get_context`,
25
+ `get_capability`, `get_schema`, `search_recipes`, `explain`, `get_manifest`.
26
+ The CLI equivalents are the fallback: `urlcode context`, `urlcode capabilities NAME`,
27
+ `urlcode schema PATH`, `urlcode recipes search TEXT`, `urlcode explain PATH`,
28
+ `urlcode manifest`. `--allow-authoring` is an operator opt-in; never add it yourself.
29
+
30
+ ## What the runtime provides (this version)
31
+
32
+ - Handlers, exactly one per route: `redirect`, `respond`, `page`, `static`, `download`, `function`, `proxy`, `conditional`.
33
+ - Ordered `middleware` around any handler, declared in YAML, trusted by default.
34
+ - Validated inputs: `parameters`, `request.body` and `methods` on the route;
35
+ functions receive validated `args`, never raw user input.
36
+ - Policies, host-enforced and off by default: `agents`, `throttle`, `cache`, `security`, `compression`.
37
+ - Site conventions under `site`, each generating one native route: `robots` (/robots.txt), `sitemap` (/sitemap.xml), `favicon` (/favicon.ico), `securityTxt` (/.well-known/security.txt), `llms` (/llms.txt).
38
+ - Bindings: named `env` and `secrets` references resolved by the operator, never values in YAML.
39
+
40
+ Never recreate any of these in a function; a missing one is a report, not an
41
+ invitation to reimplement it.
42
+
43
+ ## Functions and middleware are trusted by default; sandbox is opt-in
44
+
45
+ A route's `function`/`middleware` code runs trusted, in-process, with full
46
+ Node/filesystem/`fetch` access, receiving only the declared/granted `args`
47
+ and `env`/`secrets`. Add `sandbox: true` when code warrants isolation
48
+ (untrusted input, an unreviewed contribution, an especially sensitive
49
+ secret): that route then gets a text/JSON subset only, no Node/filesystem/
50
+ outside imports — use `proxy`/a binding instead, and say why in `description`.
51
+
52
+ ## Checks that count as evidence
53
+
54
+ ```sh
55
+ urlcode validate --local
56
+ urlcode test
57
+ urlcode audit --expect-routes 2
58
+ ```
59
+
60
+ Run all three after every change, updating the route count deliberately and
61
+ adding `tests/requests.json` fixtures for every new route (positive/negative,
62
+ every active method, HEAD). No global install: use `node /path/to/urlcode/src/cli.ts`.
63
+
64
+ ## Rules
65
+
66
+ - Report unsupported requirements instead of inventing fields. The schema is
67
+ exact; a field the validator rejects does not exist. Say what is missing.
68
+ - Never create or approve operator grants. Request a named binding in YAML and
69
+ stop; the operator grants it outside this project, pinned to the revision.
70
+ - Secrets stay out of the project: no keys, tokens or credentials in YAML,
71
+ functions, fixtures, `.env` files that are not ignored, or commit messages.
72
+ - Protect a route with `auth: true`/`auth: { role: admin }` where an `auth`
73
+ extension is declared; `cache` likewise expands to `policies.cache`.
74
+ - Validation, tests and the audit are the evidence. Local checks are not a
75
+ deployment, a soak test or a security review; do not claim otherwise.
76
+
77
+ The installed package ships an agent skill with the same loop at
78
+ `skills/urlcode/SKILL.md` inside `@jimhoyd/urlcode` (for example
79
+ `node_modules/@jimhoyd/urlcode/skills/urlcode/SKILL.md`).
@@ -1,7 +1,6 @@
1
1
  # yaml-language-server: $schema=https://raw.githubusercontent.com/jimhoyd-com/urlcode/669632fb4f188f293a0f622847f87ee019c0627f/schemas/urlcode.schema.json
2
2
  # Organize files however you like; list their paths from this project root.
3
3
  version: "1"
4
- dynamicLinks: false
5
4
  includes:
6
5
  - routes/functions.yaml
7
6
  - routes/marketing/links.yaml
package/dist/link-api.js DELETED
@@ -1,136 +0,0 @@
1
- import http from 'node:http';
2
- import {createHash,timingSafeEqual,randomUUID} from 'node:crypto';
3
- import {readFile,lstat} from 'node:fs/promises';
4
-
5
- import {outsideProject} from './link-store.js';
6
-
7
-
8
- import {linkCollection,linkCode} from './link-records.js';
9
- import {assert,HttpError} from './errors.js';
10
- import {createJsonLogger} from './logging.js';
11
-
12
- /** The store surface management needs: the pooled link store, or anything with the same contract. */
13
-
14
-
15
-
16
-
17
-
18
-
19
-
20
-
21
-
22
-
23
-
24
-
25
-
26
- export async function loadLinkToken(file ,project ) {
27
- const path=await outsideProject(file,project);const info=await lstat(path);
28
- assert(info.isFile()&&!info.isSymbolicLink()&&info.nlink===1&&info.size<=1024,'Invalid management token file');
29
- assert(process.platform==='win32'||(info.mode&0o077)===0,'Management token file must be private (mode 600)');
30
- const token=(await readFile(path,'utf8')).trim();
31
- assert(/^[A-Za-z0-9_-]{43,256}$/.test(token),'Management token must contain at least 43 base64url characters');return token;
32
- }
33
- function body(req ) {
34
- return new Promise((resolve,reject)=>{
35
- let bytes=0;const chunks =[];
36
- const cleanup=()=>{req.off('data',data);req.off('end',end);req.off('error',failed);req.off('aborted',failed);};
37
- const failed=()=>{cleanup();reject(new HttpError(400,'Incomplete management body'));};
38
- const data=(chunk )=>{
39
- bytes+=chunk.length;
40
- if(bytes>16384){cleanup();req.resume();reject(new HttpError(413,'Management body too large'));return;}
41
- chunks.push(chunk);
42
- };
43
- const end=()=>{
44
- cleanup();
45
- if(!bytes){reject(new HttpError(400,'JSON body required'));return;}
46
- try{resolve(JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(Buffer.concat(chunks))));}
47
- catch{reject(new HttpError(400,'Invalid JSON body'));}
48
- };
49
- req.on('data',data);req.once('end',end);req.once('error',failed);req.once('aborted',failed);
50
- });
51
- }
52
- const isRecord=(value ) =>value!==null && typeof value==='object' && !Array.isArray(value);
53
- const mutations ={POST:'create',PUT:'update',DELETE:'delete'};
54
- export async function startLinkApi({store,collection,token,authorize,host='127.0.0.1',port=3001,maxInFlightRequests=32,socketTimeoutMs=10000,log=createJsonLogger()} ) {
55
- linkCollection(collection);assert(['127.0.0.1','::1'].includes(host),'Management must bind a literal loopback address; use a private authenticated tunnel');
56
- assert(!authorize || store.atomicAudit===true,'Scoped management requires a store with atomic audit support');
57
- assert(authorize===undefined || typeof authorize==='function','Invalid management authorizer');
58
- assert(authorize || typeof token==='string'&&/^[A-Za-z0-9_-]{43,256}$/.test(token),'Invalid management token');
59
- assert(Number.isInteger(maxInFlightRequests)&&maxInFlightRequests>=1&&maxInFlightRequests<=64,'Management admission must be 1–64');
60
- assert(Number.isInteger(socketTimeoutMs)&&socketTimeoutMs>=100&&socketTimeoutMs<=60000,'Management socket timeout must be 100–60000 ms');
61
- let inFlight=0,shuttingDown=false,closing ;
62
- const digest=(value )=>createHash('sha256').update(value).digest();const expected=digest('Bearer '+token);
63
- const server=http.createServer({maxHeaderSize:8192,headersTimeout:5000,requestTimeout:10000,keepAliveTimeout:5000},async(req,res)=>{
64
- req.on('error',()=>{});res.on('error',()=>{});
65
- const requestId=randomUUID(),started=performance.now();let principal ;let authenticated=false,action='request',admitted=false,reported=false;
66
- const target=req.url??'',method=req.method??'';
67
- const report=()=>{
68
- if(reported)return;reported=true;if(admitted)inFlight--;
69
- try{log({event:'management_request',timestamp:new Date().toISOString(),requestId,collection,action,authenticated,principal:principal?.id,status:res.headersSent?res.statusCode:0,outcome:res.writableFinished?'finished':'aborted',durationMs:Math.round((performance.now()-started)*100)/100});}catch{/* Logging must not fail requests. */}
70
- };
71
- res.once('finish',report);res.once('close',report);
72
- const send=(status ,value ,headers ={})=>{const payload=value===undefined?undefined:JSON.stringify(value);res.writeHead(status,{'content-type':'application/json','cache-control':'no-store','x-content-type-options':'nosniff','x-request-id':requestId,...headers});res.end(payload);};
73
- try {
74
- if(shuttingDown || inFlight>=maxInFlightRequests)throw new HttpError(503,'Management capacity unavailable');
75
- inFlight++;admitted=true;
76
- let authCount=0;for(let i=0;i<req.rawHeaders.length;i+=2)if(req.rawHeaders[i]?.toLowerCase()==='authorization')authCount++;
77
- if(authCount===1){
78
- if(authorize){
79
- const bearer=/^Bearer ([A-Za-z0-9_-]{43,256})$/.exec(req.headers.authorization||'');
80
- if(bearer?.[1]!==undefined)principal=await authorize(bearer[1]);
81
- }else if(timingSafeEqual(expected,digest(req.headers.authorization||'')))principal={id:'legacy-shared',collections:[collection],actions:['get','list','create','update','delete']};
82
- }
83
- if(!principal) {req.resume();send(401,{error:'Unauthorized'},{'www-authenticate':'Bearer',connection:'close'});return;}
84
- authenticated=true;
85
- if(req.headers.origin!==undefined)throw new HttpError(403,'Browser-origin management requests are unsupported');
86
- if(!target.startsWith('/')||target.startsWith('//')||target.length>2048)throw new HttpError(400,'Invalid management target');
87
- const url=new URL(target,'http://localhost');
88
- if(target.split('?')[0]!==url.pathname)throw new HttpError(400,'Noncanonical management path');
89
- const match=/^\/v1\/links(?:\/([A-Za-z0-9_-]{1,128}))?$/.exec(url.pathname);
90
- if(!match)throw new HttpError(404,'Not found');
91
- const code=match[1];if(code)linkCode(code);
92
- if([...url.searchParams.keys()].some(k=>!['limit','after'].includes(k)) || (code && url.search))throw new HttpError(400,'Unsupported query');
93
- const allowed=code?['GET','PUT','DELETE']:['GET','POST'];
94
- if(!allowed.includes(method)){req.resume();send(405,{error:'Method not allowed'},{allow:allowed.join(', '),connection:'close'});return;}
95
- action=method==='GET'?(code?'get':'list'):mutations[method]??'request';
96
- if(!principal.collections.includes(collection) || !principal.actions.includes(action))throw new HttpError(403,'Management permission denied');
97
- const audit ={actor:principal.id,requestId};
98
- if(method==='GET'){
99
- req.resume();
100
- if(code){const value=await store.get(collection,code);if(!value)throw new HttpError(404,'Link not found');send(200,value,{etag:`"${value.version}"`});}
101
- else {
102
- if([...url.searchParams.keys()].some(k=>url.searchParams.getAll(k).length!==1))throw new HttpError(400,'Duplicate query');
103
- const raw=url.searchParams.get('limit')??'100';if(!/^\d{1,3}$/.test(raw))throw new HttpError(400,'Invalid limit');
104
- const items=await store.list(collection,{limit:Number(raw),after:url.searchParams.get('after')||''});
105
- send(200,{items,nextAfter:items.length===Number(raw)?items.at(-1)?.code??null:null});
106
- }
107
- return;
108
- }
109
- if(url.search)throw new HttpError(400,'Query unsupported for mutations');
110
- let expectedVersion ;
111
- if(method!=='POST'){
112
- const etag=req.headers['if-match'];if(!etag)throw new HttpError(428,'If-Match is required');
113
- if(!/^"[1-9]\d{0,15}"$/.test(etag))throw new HttpError(400,'Invalid If-Match');expectedVersion=Number(etag.slice(1,-1));
114
- }
115
- if(method==='DELETE'){req.resume();await store.delete(collection,code??'',expectedVersion,audit);send(204);return;}
116
- if(req.headers['content-encoding'] && req.headers['content-encoding']!=='identity')throw new HttpError(415,'Encoding unsupported');
117
- if(req.headers['content-type']?.split(';')[0]?.trim().toLowerCase()!=='application/json')throw new HttpError(415,'Expected application/json');
118
- const data=await body(req);
119
- if(!isRecord(data))throw new HttpError(400,'Invalid record');
120
- let value ;
121
- if(method==='POST'){const {code:assigned,...record}=data;value=await store.create(collection,record,assigned,audit);}
122
- else value=await store.update(collection,code??'',data,expectedVersion,audit);
123
- send(method==='POST'?201:200,value,{etag:`"${value.version}"`});
124
- }catch(e){req.resume();if(res.headersSent){res.destroy();return;}if(!res.destroyed){const status=e instanceof HttpError?e.status:503;send(status,{error:e instanceof HttpError?e.message:'Management service unavailable'},{connection:'close'});}}
125
- });
126
- server.setTimeout(socketTimeoutMs,socket=>socket.destroy());
127
- server.maxConnections=64;server.maxRequestsPerSocket=100;
128
- server.on('clientError',(_error,socket)=>{if(socket.writable)socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');});
129
- await new Promise ((resolve,reject)=>{server.once('error',reject);server.listen(port,host,()=>{server.off('error',reject);resolve();});});
130
- const address=server.address();
131
- assert(address!==null && typeof address==='object','Management server has no address');
132
- return {address,close(){
133
- if(closing)return closing;shuttingDown=true;
134
- closing=(async()=>{const timeout=setTimeout(()=>server.closeAllConnections(),10000);timeout.unref();try{await new Promise (resolve=>server.close(()=>resolve()));}finally{clearTimeout(timeout);}})();return closing;
135
- }};
136
- }
package/dist/link-cli.js DELETED
@@ -1,141 +0,0 @@
1
- import {createHash} from 'node:crypto';
2
- import {createReadStream} from 'node:fs';
3
- import {createInterface} from 'node:readline';
4
- import {isAbsolute} from 'node:path';
5
- import {managementPolicy} from './management-policy.js';
6
- import {openLinkStore} from './link-store.js';
7
-
8
- import {startLinkApi,loadLinkToken} from './link-api.js';
9
- import {linkCollection,linkCode,linkData} from './link-records.js';
10
- import {assert} from './errors.js';
11
- /** The parsed command-line values the links commands read; every flag is a string or absent. */
12
-
13
-
14
-
15
-
16
-
17
-
18
-
19
-
20
-
21
-
22
- const poolFlags=[['link-readers','readers',8],['link-read-limit','maxReads',32],['link-write-limit','maxWrites',32]] ;
23
- export function linkPoolOptions(values ) {
24
- const result ={};
25
- for(const [flag,key,max] of poolFlags){
26
- const value=values[flag];
27
- if(value===undefined)continue;
28
- assert(/^\d+$/.test(value)&&Number(value)>=1&&Number(value)<=max,`Invalid --${flag}`);result[key]=Number(value);
29
- }
30
- return result;
31
- }
32
- export function parseLinkBinding(value ,options ={}) {
33
- if(value===undefined)return undefined;
34
- const index=value.indexOf('=');assert(index>0,'Use --link-store collection=/absolute/path.sqlite');
35
- return {collection:linkCollection(value.slice(0,index)),file:value.slice(index+1),...options};
36
- }
37
- const isRecord=(value ) =>value!==null && typeof value==='object' && !Array.isArray(value);
38
- export async function runLinkCommand(action ,values ,print ) {
39
- assert(action!==undefined && ['init','create','get','list','update','delete','export','import','api'].includes(action),'Use links init/create/get/list/update/delete/export/import/api');
40
- assert(values.store,'Links commands require --store with an absolute database path');
41
- const collection=linkCollection(values.collection||'links');
42
- const poolOptions=linkPoolOptions(values);
43
- // Authentication material is checked before creating or opening a writable store.
44
- if(values['auth-file'] && values['token-file'])throw new Error('Use either --auth-file or --token-file');
45
- const project=values.project??'.';
46
- const authorize=action==='api' && values['auth-file']?await managementPolicy(values['auth-file'],project):undefined;
47
- if(authorize)await authorize('');
48
- const token=action==='api' && !authorize?await loadLinkToken(values['token-file'],project):undefined;
49
- const storeOptions ={file:values.store,project:values.project,readOnly:['get','list','export'].includes(action),...poolOptions};
50
- const store=await openLinkStore(storeOptions);
51
- try {
52
- if(action==='api'){
53
- const port=Number(values.port);assert(values.port!==undefined&&/^\d+$/.test(values.port)&&port>=0&&port<=65535,'Invalid port');
54
- const api=await startLinkApi({store,collection,token,authorize,host:values.host,port});
55
- print({event:'link-management-listening',address:api.address.address,port:api.address.port,collection});
56
- await new Promise (resolve=>{
57
- const stop=()=>{process.off('SIGINT',stop);process.off('SIGTERM',stop);resolve();};
58
- process.once('SIGINT',stop);process.once('SIGTERM',stop);
59
- });
60
- await api.close();return;
61
- }
62
- let value ;
63
- if(action==='init')value={event:'link-store-initialized'};
64
- if(action==='get'){value=await store.get(collection,values.code??'');assert(value,'Link not found');}
65
- if(action==='list')value=await store.list(collection,{limit:values.limit===undefined?100:Number(values.limit),after:values.after||''});
66
- if(action==='create'||action==='update'){
67
- assert(values.destination,'Use --destination with an HTTP(S) URL');
68
- assert(values.enabled===undefined||['true','false'].includes(values.enabled),'Enabled must be true or false');
69
- const data={url:values.destination,status:values.status===undefined?302:Number(values.status),enabled:values.enabled!=='false',expires:values.expires||null};
70
- value=action==='create'?await store.create(collection,data,values.code):await store.update(collection,values.code??'',data,Number(values['if-version']));
71
- }
72
- if(action==='delete')value={deleted:await store.delete(collection,values.code??'',Number(values['if-version']))};
73
- // Export and import stream their own NDJSON; they never buffer the store.
74
- if(action==='export'){await exportLinks(store,values,print);return;}
75
- if(action==='import'){print(await importLinks(store,values));return;}
76
- print(value);
77
- }finally{await store.close();}
78
- }
79
-
80
- // A restorable point-in-time snapshot: a header line carrying format, schema and
81
- // snapshot identity, one line per record, and a completion line with the record
82
- // count and a digest over every preceding line. Operator data goes to stdout
83
- // deliberately; redirect it to a file only operators can read.
84
- async function exportLinks(store ,values ,print ) {
85
- const pageSize=values['page-size']===undefined?100:Number(values['page-size']);
86
- assert(Number.isInteger(pageSize)&&pageSize>=1&&pageSize<=100,'Use --page-size 1–100');
87
- const hash=createHash('sha256');
88
- const line=(value )=>{hash.update(JSON.stringify(value)+'\n');print(value);};
89
- const summary=await store.exportSnapshot(
90
- {collection:values.collection===undefined?undefined:linkCollection(values.collection),pageSize},
91
- {
92
- onHeader:header=>line({event:'link-export-begin',format:header.format,schemaVersion:header.schemaVersion,
93
- applicationId:header.applicationId,collection:header.collection,revision:header.revision,
94
- records:header.records,generatedAt:header.generatedAt}),
95
- onRecords:records=>{for(const record of records)line({record});},
96
- });
97
- print({event:'link-export-complete',exported:summary.exported,sha256:hash.digest('hex')});
98
- }
99
- async function importLinks(store ,values ) {
100
- assert(typeof values.input==='string' && isAbsolute(values.input),'Use --input with an absolute export file path');
101
- const hash=createHash('sha256');
102
- const counts=new Map ();let imported=0,header ,complete ;
103
- const stream=createReadStream(values.input,{encoding:'utf8'});
104
- try {
105
- for await (const text of createInterface({input:stream,crlfDelay:Infinity})) {
106
- if(text==='')continue;
107
- assert(Buffer.byteLength(text)<=65536,'Export line exceeds 65536 bytes');
108
- assert(!complete,'Export file has content after its completion line');
109
- let parsed ;try{parsed=JSON.parse(text);}catch{throw new Error('Export file is not valid NDJSON');}
110
- const line =isRecord(parsed)?parsed:{};
111
- if(line.event==='link-export-complete'){
112
- assert(header,'Export file has no header line');
113
- assert(hash.copy().digest('hex')===line.sha256,'Export digest does not match its content');
114
- assert(line.exported===imported,'Export record count does not match its record lines');
115
- complete=line;continue;
116
- }
117
- hash.update(text+'\n');
118
- if(line.event==='link-export-begin'){
119
- assert(!header && !imported,'Export file has more than one header line');
120
- assert(line.format==='urlcode.links.v1' && line.schemaVersion===1 && line.applicationId===1431456835,
121
- 'Unsupported export format, schema version or store identity');
122
- header=line;continue;
123
- }
124
- assert(header,'Export records precede the header line');
125
- assert(isRecord(line.record),'Unsupported export line');
126
- const collection=linkCollection(line.record.collection),code=linkCode(line.record.code);
127
- // Restore into empty collections only: this never overwrites live records.
128
- if(!counts.has(collection)){
129
- assert((await store.list(collection,{limit:1})).length===0,'Target collection already contains records');
130
- counts.set(collection,0);
131
- }
132
- await store.create(collection,linkData({url:line.record.url,status:line.record.status,enabled:line.record.enabled,expires:line.record.expires}),code);
133
- counts.set(collection,(counts.get(collection)??0)+1);imported++;
134
- }
135
- } finally { stream.destroy(); }
136
- assert(complete,'Export file has no completion line; it is truncated');
137
- // Versions and audit revisions are reassigned by this store, so management
138
- // ETags taken against the exported database do not survive a restore.
139
- return {event:'link-import-complete',imported,collections:Object.fromEntries(counts),
140
- source:{revision:header?.revision,generatedAt:header?.generatedAt,sha256:complete.sha256},versionsReassigned:true};
141
- }
@@ -1,76 +0,0 @@
1
- import {assert} from './errors.js';
2
-
3
-
4
-
5
-
6
-
7
-
8
-
9
- // Trusted-operator observation of finished link requests. The observer is
10
- // supplied by the embedding operator process, never by route YAML or guest
11
- // code, and it runs after the response is over: it can never change, delay or
12
- // fail a redirect. Work is bounded by an explicit queue; overload drops events
13
- // and counts the drops rather than growing memory behind the operator's back.
14
- const outcomes=new Set(['completed','aborted','missing','disabled','expired','invalid_code','invalid_record','unavailable']);
15
- export function createLinkObserver(options , log = () => {}) {
16
- if(options===undefined)return undefined;
17
- assert(options && typeof options==='object' && !Array.isArray(options),'Link events must be an options object');
18
- const {observe,includeCode=false,maxQueue=256,timeoutMs=1000}=options ;
19
- assert(Object.keys(options).every(key=>['observe','includeCode','maxQueue','timeoutMs'].includes(key)),'Unsupported link event option');
20
- assert(typeof observe==='function','Link events require an observe(event) function');
21
- assert(typeof includeCode==='boolean','Link event code disclosure must be a boolean');
22
- assert(Number.isInteger(maxQueue)&&maxQueue>=1&&maxQueue<=4096,'Link event queue must be 1–4096');
23
- assert(Number.isInteger(timeoutMs)&&timeoutMs>=1&&timeoutMs<=10000,'Link event timeout must be 1–10000 ms');
24
- const queue =[];
25
- let delivered=0,dropped=0,failed=0,timedOut=0,closed=false,draining ;
26
- const report =event=>{try{log(event);}catch{/* Logging cannot fail the observer. */}};
27
- async function deliver(event ) {
28
- let timer ;
29
- try {
30
- // A slow collector must not pin the queue: the budget is per event and the
31
- // observer keeps running afterwards, whatever the abandoned call does.
32
- await Promise.race([
33
- Promise.resolve(observe (event)),
34
- new Promise((_resolve,reject)=>{timer=setTimeout(()=>reject(new Error('timeout')),timeoutMs);timer.unref?.();}),
35
- ]);
36
- delivered++;
37
- } catch(error) {
38
- failed++;
39
- const timeout=(error )?.message==='timeout';
40
- if(timeout)timedOut++;
41
- report({event:'link_observer',status:'failed',reason:timeout?'timeout':'error'});
42
- } finally { clearTimeout(timer); }
43
- }
44
- async function pump() {
45
- if(draining)return;
46
- draining=(async()=>{while(queue.length)await deliver(queue.shift() );})();
47
- try{await draining;}finally{draining=undefined;}
48
- }
49
- return {
50
- // Called from the request path. It must stay synchronous and total.
51
- emit(event ) {
52
- try {
53
- if(closed||!outcomes.has(event.outcome)){dropped++;return;}
54
- if(queue.length>=maxQueue){
55
- dropped++;
56
- if(dropped===1||dropped%maxQueue===0)report({event:'link_observer',status:'dropped',dropped});
57
- return;
58
- }
59
- const {code:_code,...redacted}=event;
60
- queue.push(includeCode?{...event}:redacted);
61
- void pump();
62
- } catch { dropped++; }
63
- },
64
- stats:()=>({queued:queue.length,delivered,dropped,failed,timedOut,closed}),
65
- async close() {
66
- closed=true;
67
- // Drain what was already accepted, then stop; a stuck collector cannot
68
- // hold shutdown open past its own per-event budget plus this deadline.
69
- void pump();
70
- const deadline=new Promise (resolve=>{const timer=setTimeout(()=>resolve(false),timeoutMs*2+1000);timer.unref?.();});
71
- while(draining)if(!await Promise.race([draining.then(()=>true,()=>true),deadline]))break;
72
- dropped+=queue.length;queue.length=0;
73
- return this.stats();
74
- },
75
- };
76
- }
@@ -1,31 +0,0 @@
1
- import {randomBytes} from 'node:crypto';
2
- import {HttpError} from './errors.js';
3
-
4
- const check = (value,message) => { if(!value)throw new HttpError(400,message); };
5
- export function linkCollection(value ) {
6
- check(typeof value==='string' && /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(value),'Invalid link collection');return value;
7
- }
8
- export function linkCode(value ) {
9
- check(typeof value==='string' && /^[A-Za-z0-9_-]{1,128}$/.test(value),'Invalid short code');return value;
10
- }
11
- export function linkVersion(value ) {
12
- check(typeof value==='number' && Number.isSafeInteger(value) && value>0,'A positive expected version is required');return value;
13
- }
14
- export function linkData(value ) {
15
- check(value && typeof value==='object' && !Array.isArray(value),'Invalid link record');
16
- check(Object.keys(value).every(k=>['url','status','enabled','expires'].includes(k)),'Unsupported link field');
17
- const record=value ;
18
- check(typeof record.url==='string' && record.url.length<=8192 && !/[\s\u0000-\u001f\u007f\\]/u.test(record.url),'Invalid destination');
19
- let url ;try{url=new URL(record.url);}catch{throw new HttpError(400,'Invalid destination');}
20
- check(['http:','https:'].includes(url.protocol) && !url.username && !url.password,'Destination must be HTTP(S) without credentials');
21
- check(Buffer.byteLength(url.href)<=8192,'Destination exceeds 8192 bytes');
22
- const status=record.status??302,enabled=record.enabled??true,expires=record.expires??null;
23
- check(typeof status==='number' && [301,302,303,307,308].includes(status),'Invalid redirect status');
24
- check(typeof enabled==='boolean','Invalid enabled flag');
25
- if(expires!==null){
26
- check(typeof expires==='string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(expires) && Number.isFinite(Date.parse(expires)),'Invalid expiry');
27
- check(new Date(expires).toISOString().replace('.000Z','Z')===expires.replace('.000Z','Z'),'Invalid expiry');
28
- }
29
- return {url:url.href,status,enabled,expires};
30
- }
31
- export const randomLinkCode=() =>randomBytes(12).toString('base64url');