@jimhoyd/urlcode 0.4.7 → 0.5.0

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 (49) hide show
  1. package/.claude/skills/urlcode-authoring/SKILL.md +8 -0
  2. package/README.md +9 -3
  3. package/dist/BUILD-MANIFEST.json +23 -21
  4. package/dist/agents-guide.js +9 -5
  5. package/dist/authoring.js +36 -3
  6. package/dist/build-cloudflare.js +5 -2
  7. package/dist/build-static.js +1 -0
  8. package/dist/cli.js +47 -15
  9. package/dist/cloudflare.js +6 -3
  10. package/dist/config.js +7 -1
  11. package/dist/context.js +99 -1
  12. package/dist/extension-artifacts.js +147 -0
  13. package/dist/extension-bundles.js +70 -0
  14. package/dist/extensions.js +4 -0
  15. package/dist/functions.js +2 -1
  16. package/dist/index.js +4 -2
  17. package/dist/init-with.js +55 -23
  18. package/dist/interchange.js +1 -1
  19. package/dist/match.js +23 -5
  20. package/dist/mcp.js +11 -4
  21. package/dist/readiness.js +2 -2
  22. package/dist/router.js +20 -8
  23. package/dist/runtime.js +1 -0
  24. package/dist/site.js +19 -1
  25. package/dist/tooling.js +2 -2
  26. package/dist/types/agents-guide.d.ts +6 -1
  27. package/dist/types/authoring.d.ts +1 -1
  28. package/dist/types/cloudflare.d.ts +1 -0
  29. package/dist/types/context.d.ts +56 -0
  30. package/dist/types/extension-artifacts.d.ts +87 -0
  31. package/dist/types/extension-bundles.d.ts +49 -0
  32. package/dist/types/extensions.d.ts +4 -0
  33. package/dist/types/functions.d.ts +4 -0
  34. package/dist/types/index.d.ts +4 -2
  35. package/dist/types/init-with.d.ts +6 -1
  36. package/dist/types/match.d.ts +1 -0
  37. package/dist/types/site.d.ts +2 -0
  38. package/dist/types/tooling.d.ts +2 -2
  39. package/dist/types/types.d.ts +1 -0
  40. package/dist/types.js +1 -1
  41. package/llms-full.txt +188 -24
  42. package/llms.txt +63 -107
  43. package/package.json +11 -3
  44. package/recipes/redirect/README.md +19 -5
  45. package/recipes/redirect/recipe.yaml +12 -10
  46. package/recipes/redirect/tests/requests.json +22 -0
  47. package/recipes/redirect/urlcode.yaml +11 -1
  48. package/skills/urlcode/SKILL.md +2 -0
  49. package/starters/default/AGENTS.md +3 -3
@@ -43,6 +43,14 @@ When the project has an operator host file, inspect `urlcode extensions
43
43
  before writing extension configuration or project hooks. The report is the
44
44
  machine-readable source for config/policy schemas, hook contracts, supported
45
45
  project-owned authoring surfaces and fast checks.
46
+ When `urlcode.extensions.lock.json` is committed, use MCP
47
+ `get_extension_artifacts` to verify and inventory the locked declarative data,
48
+ then `get_extension_artifact` for only the needed schema, example or README.
49
+ Without MCP, run `urlcode extension-artifacts inspect --project <dir> --json`
50
+ before reading its cache. An artifact is inert authoring data: it does not
51
+ install the matching npm package, register executable code or grant authority.
52
+ Do not install/update one unless the user requests that project change and
53
+ names an immutable `extensions@v…` release.
46
54
  The `SPECIFICATION` section of `llms-full.txt` and
47
55
  `schemas/urlcode.schema.json` resolve contract questions in an installed
48
56
  package. A source checkout also has `docs/SPECIFICATION.md`. Archived plans are
package/README.md CHANGED
@@ -110,9 +110,12 @@ it. Cross-repository acceptance is tracked in
110
110
  ## Status
111
111
 
112
112
  <!-- urlcode-current-version:start -->
113
- The `0.4.7` release line brings core, UI, auth and admin to matching stable
114
- versions. A stable version selects the npm `latest` channel; it does not close
115
- the review and deployment evidence gaps below. `0.4.0-alpha.1`
113
+ This checkout prepares the coordinated `0.5.0` core, UI, auth, admin and store
114
+ release. Package availability remains a live registry fact: use `npm run
115
+ release:status` or the release's signed `train.json` before selecting an
116
+ install combination. Independent package versioning remains enabled, and a
117
+ stable core version does not close the review and deployment evidence gaps
118
+ below. `0.4.0-alpha.1`
116
119
  added the extension contract, capabilities and provider conformance, strict
117
120
  redirect interchange, bulk import, recipes and search, TypeScript guest
118
121
  authoring, conditions, bounded proxy and signals, and the MCP read and
@@ -144,6 +147,9 @@ Start with the [YAML guide and recipe book](docs/YAML-GUIDE.md),
144
147
  authoring, use [the AI guide](docs/AI-AUTHORING.md), the bundled agent skills
145
148
  ([authoring](.claude/skills/urlcode-authoring/SKILL.md),
146
149
  [operations](.claude/skills/urlcode-operations/SKILL.md)) and [llms.txt](llms.txt).
150
+ Agents can also read project-pinned, signed declarative extension schemas through
151
+ the read-only MCP tools described in [extensions](docs/EXTENSIONS.md#signed-declarative-artifacts);
152
+ those artifacts are inert data, not an alternate executable package channel.
147
153
  Follow [organization and readability practices](docs/BEST-PRACTICES.md) as your
148
154
  project grows. Operators should read [capacity/concurrency](docs/CAPACITY.md) and the
149
155
  [DDoS and recovery playbook](docs/RESILIENCE.md). Embedding the runtime from
@@ -4,21 +4,21 @@
4
4
  "dist/adapters.js": "a7153ec52f2ed7e0cdd8ed7d433504815f402ec53b2b1c3d38d0cf0e2fcf6509",
5
5
  "dist/agent-context.js": "1e4e9c7b85d155c06d99d8b70f625bd7ab31330a0207aef6ed1803243b759611",
6
6
  "dist/agent-lists.js": "35ab484198897501d011cb6cc00b5a6787192e19df29c97646e8c118fcc19eef",
7
- "dist/agents-guide.js": "14684bf514cabbe7c9bb1ad6b6b2c479f8aaf3feb299b64655fd9fba218268d6",
7
+ "dist/agents-guide.js": "421df50bcf258f23aba742148e53074512584a10ba35a6264e70d3eacc016e3e",
8
8
  "dist/assets.js": "0833b093d2fe457ce68281110dc07d47914992753607741c13b339d92c76cf5c",
9
9
  "dist/authoring-files.js": "ad32814f9ae9c549c6247396f982aa700d13a293cc0a589609eccc68f8965550",
10
- "dist/authoring.js": "bb3d4e7c6c982c4a1985a7663f960d05a08459d935e2609d404f4001b7ea767f",
10
+ "dist/authoring.js": "3ec96a88441527c43fac18ec21c824ed452365e362edc2576e881acc89b15fec",
11
11
  "dist/aws.js": "7d8e3a97b4f6dbefbd68da058f05192469d1efcaed5458b284863ebe462e060e",
12
12
  "dist/body-schema.js": "0227ef8cf2b380e3ef65a358533aa0e0f4a119c94e63747b8eb03fe65a6b9193",
13
- "dist/build-cloudflare.js": "e552dbf3c08036f02b0e5eb4bfd1309229bbba41d021ac15051e3f6fb165d8b4",
14
- "dist/build-static.js": "fe01ef4fcc66d83d8be7a787538b55974e84fd3d7c89e26f0a2f1a152762d565",
13
+ "dist/build-cloudflare.js": "ff92e72d0a63d70dd8c28b52978edd9fb23cb16601e4dfebd07c2cef7bd73661",
14
+ "dist/build-static.js": "3920b54bad9a44c75de79f0bef2b2a919bfb3e20a8619eaf8d48cad2856978ac",
15
15
  "dist/bulk.js": "aac88d422bd9a7a341a421fb4e29ff5580b28d39250b7f1f5315ba20ae92da8a",
16
16
  "dist/capabilities.js": "bb98f5bbf54cc4e82fa197f999a621ad815273176f02692c35566886e2bb8777",
17
17
  "dist/capability-query.js": "9f68bc94901451d7b9ecdd3aee8c3376dca7f0b890e31d8205306cf8120dea86",
18
18
  "dist/catalog.js": "c378ef9bfd63b940790f6975f7db54557c2459722b65a7c170b97df24184bc52",
19
- "dist/cli.js": "e1c5fb1a09a420e905ccb760e826a2626f275fb6fa2890b15c36aaaf185b17d5",
19
+ "dist/cli.js": "623cdf3dceb26b77248d04d5636807492cf269937029555c8ca323c5a61716e6",
20
20
  "dist/client-address.js": "9d0d01466aab23124330605c5a0e0f981f87c897858acfad909f546a63df165b",
21
- "dist/cloudflare.js": "17b43b0a6b6a0ef8a893415c989e4ae3976d97cc966adbf897b785f9adc472e3",
21
+ "dist/cloudflare.js": "31da478a6b851b5923c14dbd11885684cecec51cc7e0a5d6031d8512f3ecc040",
22
22
  "dist/compliance-rules/baseline.js": "6296dea6bcb9f9bb80ed6ed5ab2f456e7f74e6de2be5d2d98ca8c983fb4df3ab",
23
23
  "dist/compliance-rules/privacy.js": "4cfb1c07d7df698c362dfc8761cd3bba893769e92d7fb0a3901e1c2d25e801f1",
24
24
  "dist/compliance-rules/shared.js": "86fd6db6a93375689acc64975cbc394ce9452dea21ae5941e743f99822515995",
@@ -26,31 +26,33 @@
26
26
  "dist/compliance.js": "29a18da3cefea94802ac7d817d1ab4e3674abd5c3ac4ef893cb8b01c6a23fb61",
27
27
  "dist/conditions.js": "ff25e97da550a3bdd80af55522045669438aa31a77d2bc83ad602bcefdee0475",
28
28
  "dist/config-worker.js": "bc2170d60c35f8d5de227cbfc4c5067dc61d7cbc5b98aed88e68a11797072da5",
29
- "dist/config.js": "33c8d1f80aa7f1ce25eab12644f6cbef961a897b72b1585d4bfa6424985f0d16",
30
- "dist/context.js": "f0de473b1e13649b0778fe2d5e7a65d4d6ac82614b47a1f86b453b73a85cb166",
29
+ "dist/config.js": "6fecd80a269c5c8097449205a09a7e3522336e1000dcb3a3c88adb1b349093db",
30
+ "dist/context.js": "225a25ad8dce00f52de2bb52a17aa85128cf34c4b82aa0cae846c8fc89b1cc52",
31
31
  "dist/ecosystem-cli.js": "1e71bacd53d6fa8c7857cfcce3c3012ce054771a434b78d1f39e892e32642edd",
32
32
  "dist/egress.js": "2ae29fe2cb4590fd2f715abe6817db47fed2f50dd31f37946ecf42eafbb0dd9e",
33
33
  "dist/errors.js": "fef26eb834dd61b8b5f587eb7ab265d216122d182545f0a1c5b19c20840dffb0",
34
34
  "dist/examples.js": "8523799797d2530fe48ec5e96c34970bdba03c49afb31fdaab6a9c162a17dfa9",
35
35
  "dist/explain-cli.js": "f0f8f3b5046430d03c6abb629b38651c9c734b03456040735ce9d7e582b2bcd5",
36
36
  "dist/explain.js": "7fb632036cc68280701dd512c74d27d6e0f97b4848e9024b284d7c5393b35eb5",
37
- "dist/extensions.js": "966441cca677b0d88683d29d89d36c61cf487ae2b70d94eaa0c71c50730a4212",
37
+ "dist/extension-artifacts.js": "9809125725cbac63141fe7c214f41888c8c2b66d2893cebc9f333b836988ea14",
38
+ "dist/extension-bundles.js": "d76e5937d93fa5f6545826178bab94d24ea16f220efab6ff23f22f976413f5a7",
39
+ "dist/extensions.js": "ec4b1784fe278a5293ec12651765184bf57dac7146c0627dc9cb31deeb01a928",
38
40
  "dist/function-sources.js": "16fad4abc81c7ee07b6cbcef2d23a9fc50e97a17cb4dde1db53469bbb555c96f",
39
41
  "dist/function-worker.js": "35771790ada4e1b36d447d4967a5e6cf0b543f32944e4c904f5fbf8998e824a5",
40
- "dist/functions.js": "0f09d1bd27eb655ceb5900a4a5e2b760d2028b02cb637a6a89a4eb5940828008",
42
+ "dist/functions.js": "d9f5a493ba389191707107972562edc5b5f435579622d5a0caa4d942d7b1326a",
41
43
  "dist/guest-api.js": "49aec9ed72733ad119e63bcfcee18158dcc8ceb951e1823b8947f6407ff24afc",
42
44
  "dist/header-validation.js": "465181dbb08ff05f52defdd29fda025c0c64589bf319fa87fa6d3ab4b68216d5",
43
45
  "dist/http-policy.js": "393a6383bdf9e05597c98721b3a4c4207a9bbe359883babcbfa06b696c70f56c",
44
46
  "dist/http-response.js": "565826d1ece4bd30dfc1150c75800acec1fef584f732e964409aa92958fc8550",
45
- "dist/index.js": "8d7532ae0a31f9439ebb62e7b01a2748aee00f350262ff55d1226121d206c1ed",
46
- "dist/init-with.js": "0d2dccb83f0ad53181c6f5c95a4c663ce06ee9a1fa9074facac9a1aee7599a96",
47
+ "dist/index.js": "764b998af6ef41e4df2a9c3d74a6ba989ee7a231d5f7071d0ebca791a12719c2",
48
+ "dist/init-with.js": "4b4bfca3081f57c525ec2b3617c4735d773c5317f285337948227ede4734f214",
47
49
  "dist/interchange-cli.js": "35bd70ba8077af5c3e39404ff0e5d28707632090a59a90645141252449fd3f04",
48
- "dist/interchange.js": "26789420af33344d9a08c00fe6b2708aa71994224df527b3075295be2151611e",
50
+ "dist/interchange.js": "3352228bc4ca652dd285fdaeddc8586f731fea5bd49521f408cce9a727f50ea8",
49
51
  "dist/logging.js": "8ec57fea26983aa071b05fe15d680d6550b03488101163ed9666da674118cf31",
50
52
  "dist/manifest.js": "83eed0d59621039ef364cf6e7b54c5bd47e0d8cf0a5abe7b07344bfc1db56ed8",
51
- "dist/match.js": "53ebcc2cda529a8d07fb83f8cb69a1446036ffc1641af3c4bd45a55851bded28",
53
+ "dist/match.js": "2299245c0ba85481afe470c95f0f79502b6310b6870f776d16d88525a5e5da63",
52
54
  "dist/mcp-authoring.js": "1183a56c5decb99ecb67a5da7bd91d1b494bc17a2965051aa63c4a7e291462b5",
53
- "dist/mcp.js": "2fe29baed673e10c5dbb0578c982e5d53837872976a5cd9f7e0a519d3eaf12db",
55
+ "dist/mcp.js": "5f2d19ffb1a6bc72fa258c3f788af24eaf732bce9f87ca5dcd431d84abaa02bf",
54
56
  "dist/observability.js": "f4b1ab496f051fe3ef2ed36b2b2e469c63a8940700e2df852e6ff2d9cccdd5ae",
55
57
  "dist/operator-host.js": "e3dac9d43a83beb775202b4ab9eeeba7ac63cebfc5dcc669be38d825be587bd0",
56
58
  "dist/pattern-guard.js": "6f157ced99666a89ca5af1418f5a14568d850985042dea309bf9ec0c19dbaca5",
@@ -67,20 +69,20 @@
67
69
  "dist/project-tests.js": "63562978411ee94a69dcf45d4d47e84fba009d8654ac421c2a02a418cd80b4db",
68
70
  "dist/provider-verification.js": "ffcf9f05e8cb5f71ea46a97e279e8a613fdfdf9435ab02bd57af11e2680a9107",
69
71
  "dist/proxy.js": "34107e59ac0dbbea090efa3cf5badf3226c5302ba25e8f8ff9b2b5596ed80e50",
70
- "dist/readiness.js": "2b67bc86c0c73528f3f798b6a0ee1bd5aec5ac6f4a0998c45443c903a622af4f",
72
+ "dist/readiness.js": "04143b22dbb8deec4b21b0f86c0c45c67f62a511efe1f99764b660791ef83343",
71
73
  "dist/recipes.js": "7af62ea9f612c82ea21e97e9d14451f05e0b509d6e420fb99fec96b22e3533f1",
72
74
  "dist/route-diff.js": "8d71d50d332ffaaed588656bd32aa36763260d1ad87bb62d7ea1c8fb23383969",
73
- "dist/router.js": "b2b26409321c5d119ac54793efe6bb6a2f9bbf2f36db251c68337c25b9d836b9",
74
- "dist/runtime.js": "e36153ab250b566ea778584a447ce603cf283274d9f8ac54a22aa9969c2284e8",
75
+ "dist/router.js": "52899195e0dc9f001e4202faf0db1ed48f5531de8d63f1a6173b7ceab570ca33",
76
+ "dist/runtime.js": "6731200cdc1e8ac66b5d0ba7d0f42686ec31321b24e7d51130fe130e69478f37",
75
77
  "dist/sandbox.js": "c88c5aa5dd95b4f55f1ba62da7969ad66dbd97c30f72e5b5f2817a2098ac1c65",
76
78
  "dist/scaffold.js": "0f936cabd20b609c7ebfebd7895710ce6c30882c3088a2356ac75bb4e0d10c55",
77
79
  "dist/schema-query.js": "bfd1844acd8d54ac361115191fcfbaf828a67e223b9ec6e9c22f158761af149b",
78
80
  "dist/server.js": "165b66fc4ed3b8e7c46ae4402ef66a5bc988c0910fb3e11556b040781e09d03c",
79
81
  "dist/signals.js": "b55e54efc8fb6703e08f2a1e808ec8e011bda67738db8ceadf1b248f695795be",
80
- "dist/site.js": "38176ac4d1970ebf73e3f96094f39900724ce96b29d6de892b8baae01e183aa5",
81
- "dist/tooling.js": "3d2848aba0a374ef980328c6924d4b314c53094efc2cc94638f1a58b726f8161",
82
+ "dist/site.js": "9f8154d0a62ed1b650c064a24e609d50dd370f0ff2cee2567628029e30855616",
83
+ "dist/tooling.js": "f377128dc37cff4685ba8503f4c38089ca4d7e97551e832bae4cdc24e357ba97",
82
84
  "dist/trusted-functions.js": "f3800c75f45ce90faf85ba6664398ad9031c5a9ac51d5ce42bdcff08a497d48e",
83
- "dist/types.js": "827f1afde90d19715e79500515ecaa80f5b65ce552f0ef24d2fc561ef77e6e40",
85
+ "dist/types.js": "11d71c522e51c707a363f734fa5715f0a47b2009431ae59f6075d6eff6074ff0",
84
86
  "dist/typescript-authoring.js": "df3d9c82e6b545cae5caae04c443ed4d2feae96def5e5cde250c0a90d83ae98c",
85
87
  "dist/vercel.js": "a0da20c095d0c7fa8f579ed4dd5951e7a34191f8a72a57c5e782c9eab39f6f08",
86
88
  "dist/verify-deployment.js": "2d3b6301b70684abfccea669f7901afda7fae9d9d66902809ecdef65f22aea16",
@@ -11,10 +11,14 @@ export const mcpConfigFile = '.mcp.json';
11
11
  /**
12
12
  * Renders `.mcp.json` registering the read-only `urlcode mcp` server for the project at `project`, relative
13
13
  * to the file. `--allow-authoring` is deliberately absent: the operator adds it by hand when they want it.
14
+ * `local` is for a project whose package.json pins the runtime: the server is then launched through `npx --no`,
15
+ * which uses the installed copy and refuses to fetch anything (a bare `urlcode` is not on PATH for a local-only install,
16
+ * and `npx urlcode` would resolve an unrelated registry package). Without a pin the bare command is kept for global installs.
14
17
  */
15
- export function renderMcpConfig(project = '.') {
18
+ export function renderMcpConfig(project = '.', { local = false } = {}) {
16
19
  assert(/^[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)*$/.test(project) && !project.split('/').includes('..'), 'MCP project path must be a relative path without ..');
17
- return JSON.stringify({ mcpServers: { urlcode: { command: 'urlcode', args: ['mcp', '--project', project] } } }, null, 2) + '\n';
20
+ const server = local ? { command: 'npx', args: ['--no', '--package', '@jimhoyd/urlcode', 'urlcode', 'mcp', '--project', project] } : { command: 'urlcode', args: ['mcp', '--project', project] };
21
+ return JSON.stringify({ mcpServers: { urlcode: server } }, null, 2) + '\n';
18
22
  }
19
23
  const handlerNames = ['redirect','respond','page','static','download','function','proxy','conditional','extension'];
20
24
 
@@ -47,14 +51,14 @@ static serving and authentication. Read this file before changing anything.
47
51
  (limits; \`--target NAME\` before promising a provider), \`get_schema\`,
48
52
  \`recipes search TEXT\`/\`search_recipes\` then \`recipes add NAME --out DIR\`,
49
53
  \`explain\` and, with an operator host file, \`get_extensions\`. Bare
50
- \`capabilities\` and \`recipes list\` are complete catalogs: fallback, not step one.
54
+ \`capabilities\` and \`recipes list\` are complete catalogs: fallback, not step one. Do not read or grep \`llms-full.txt\` or the packaged docs for a routine task.
51
55
  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.
52
56
 
53
57
  ## MCP
54
58
 
55
59
  When present, \`${mcpConfigFile}\` registers the read-only \`urlcode mcp\` server; prefer its
56
60
  tools (also \`get_manifest\`) to reading documents. Inspect \`get_extensions\` before
57
- replacing extension behavior. \`--allow-authoring\` is an operator opt-in; never add it.
61
+ replacing extension behavior. \`--allow-authoring\` is an operator opt-in; never add it. For a committed artifact lock, use \`get_extension_artifacts\`/\`get_extension_artifact\`; they expose verified inert data and never activate an extension.
58
62
 
59
63
  ## What the runtime provides (this version)
60
64
 
@@ -81,7 +85,7 @@ skill only in a React frontend with \`components.json\`; start with \`shadcn inf
81
85
  A \`function\`/\`middleware\` is trusted, in-process Node with only declared
82
86
  \`args\`/\`env\`/\`secrets\`. Add \`sandbox: true\` for code needing isolation, not
83
87
  merely untrusted input. The sandbox is text/JSON-only; use \`proxy\`/a binding and
84
- record the reason in \`sandboxReason\`.
88
+ record the reason in \`sandboxReason\`. Try \`redirect\` (relative or \`/**\`) or \`respond\` first; a function gets \`context.route.pattern\`.
85
89
 
86
90
  ## Checks that count as evidence
87
91
 
package/dist/authoring.js CHANGED
@@ -10,6 +10,7 @@ import { prepareFunctionSnapshot, requestedPermissions } from './policy.js';
10
10
  import { assert } from './errors.js';
11
11
  import { renderPackageManifest } from './project-dependencies.js';
12
12
 
13
+ import { redirectStarter } from './context.js';
13
14
 
14
15
 
15
16
 
@@ -19,7 +20,7 @@ import { renderPackageManifest } from './project-dependencies.js';
19
20
 
20
21
 
21
22
 
22
-
23
+
23
24
 
24
25
  export async function initProject(destination , { manifest, template = 'default' } = {}) {
25
26
  const target = resolve(destination);
@@ -27,13 +28,17 @@ export async function initProject(destination , { manifest, template = 'd
27
28
  // Reserve destination before copying; never merge into existing user files.
28
29
  await mkdir(target);
29
30
  try {
31
+ if (template === 'redirects') { await writeRedirectsStarter(target); return target; }
30
32
  const source = fileURLToPath(new URL(`../starters/${template === 'page' ? 'page' : 'default'}/`, import.meta.url));
31
33
  for (const file of await readdir(source)) {
32
34
  if (file === '.gitignore' || file === 'AGENTS.md' || file === mcpConfigFile) continue;
33
35
  await cp(join(source,file), join(target,file === 'gitignore.template' ? '.gitignore' : file), { recursive: true, force: false, errorOnExist: true });
34
36
  }
35
37
  if (template === 'page') {
36
- await loadDocument(target);
38
+ const routes = Object.keys((await loadDocument(target)).routes).length;
39
+ // The page starter is the smallest project, but an agent opened in it still needs the same first-step guidance and MCP registration.
40
+ await writeExclusive(join(target, 'AGENTS.md'), renderAgentsGuide({ routes }));
41
+ await writeExclusive(join(target, mcpConfigFile), renderMcpConfig('.', { local: manifest !== undefined }));
37
42
  if (manifest) {
38
43
  const pkg = await open(join(target,'package.json'), 'wx', 0o644);
39
44
  try { await pkg.writeFile(renderPackageManifest(target, manifest)); } finally { await pkg.close(); }
@@ -48,7 +53,7 @@ export async function initProject(destination , { manifest, template = 'd
48
53
  try { await guide.writeFile(renderAgentsGuide({ routes })); } finally { await guide.close(); }
49
54
  // .mcp.json registers the read-only server for repository-aware agents; the starter carries the same bytes.
50
55
  const mcp = await open(join(target,mcpConfigFile), 'wx', 0o644);
51
- try { await mcp.writeFile(renderMcpConfig('.')); } finally { await mcp.close(); }
56
+ try { await mcp.writeFile(renderMcpConfig('.', { local: manifest !== undefined })); } finally { await mcp.close(); }
52
57
  if (manifest) {
53
58
  // Exclusive create: the starter ships no package.json, so this never merges into or overwrites one.
54
59
  const pkg = await open(join(target,'package.json'), 'wx', 0o644);
@@ -57,6 +62,34 @@ export async function initProject(destination , { manifest, template = 'd
57
62
  } catch (error) { await rm(target, { recursive: true, force: true }); throw error; }
58
63
  return target;
59
64
  }
65
+ const redirectFixtures = [
66
+ { path: '/old', status: 301, expectHeaders: { location: 'https://example.com/new' } },
67
+ { path: '/users/42', status: 308, expectHeaders: { location: 'https://example.com/profiles/42' } },
68
+ { path: '/people/7', status: 302, expectHeaders: { location: '/profiles/7' } },
69
+ { path: '/legacy/a/b/c/d/e/f/g/h/i', status: 302, expectHeaders: { location: 'https://example.com/modern/a/b/c/d/e/f/g/h/i' } },
70
+ { path: '/search?q=tea', status: 302, expectHeaders: { location: 'https://example.com/find?q=tea' } },
71
+ { path: '/missing', status: 404 },
72
+ ];
73
+ /** The redirect starter is the `--task redirects` starter, so init and `urlcode context` cannot disagree. */
74
+ async function writeRedirectsStarter(target ) {
75
+ const starter = redirectStarter();
76
+ const version = (JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')) ).version;
77
+ const files = {
78
+ [starter.file]: starter.yaml,
79
+ ...starter.companions,
80
+ 'package.json': JSON.stringify({ name: 'redirects', version: '1.0.0', private: true, scripts: starter.packageScripts, dependencies: { '@jimhoyd/urlcode': version } }, null, 2) + '\n',
81
+ 'tests/requests.json': JSON.stringify(redirectFixtures, null, 2) + '\n',
82
+ };
83
+ await mkdir(join(target, 'tests'));
84
+ for (const [name, body] of Object.entries(files)) await writeExclusive(join(target, name), body);
85
+ const routes = Object.keys((await loadDocument(target)).routes).length;
86
+ await writeExclusive(join(target, 'AGENTS.md'), renderAgentsGuide({ routes }));
87
+ await writeExclusive(join(target, mcpConfigFile), renderMcpConfig('.', { local: true }));
88
+ }
89
+ async function writeExclusive(file , body ) {
90
+ const handle = await open(file, 'wx', 0o644);
91
+ try { await handle.writeFile(body); } finally { await handle.close(); }
92
+ }
60
93
  export async function addRedirect(project , destination , alias ) {
61
94
  const loaded = await loadDocument(project);
62
95
  const slug = alias || randomBytes(6).toString('base64url');
@@ -9,7 +9,7 @@ import { compileRoutes } from './router.js';
9
9
  import { assert } from './errors.js';
10
10
  import { effectivePolicies, registry } from './policies.js';
11
11
  import { resolveLists } from './agent-lists.js';
12
- import { applySite } from './site.js';
12
+ import { applySite, inlineNotFound } from './site.js';
13
13
  import { buildManifest, renderManifest, manifestPath } from './manifest.js';
14
14
 
15
15
 
@@ -71,6 +71,8 @@ export async function buildCloudflare(project , { out = 'dist/cloudflare'
71
71
  // Generated site routes are built like declared ones; the ones that need
72
72
  // an origin get it from --origin, exactly as the server does.
73
73
  await applySite(loaded, { origin, log });
74
+ // The one not-found page travels inline; every other page route is still refused below.
75
+ const notFound = await inlineNotFound(loaded);
74
76
  assertTargetCompatibility(analyzeProjectCapabilities(loaded, 'cloudflare'));
75
77
  // No bindings are resolved: a build artifact must never carry a secret, and
76
78
  // this target has no per-request operator policy to pin one to.
@@ -79,6 +81,7 @@ export async function buildCloudflare(project , { out = 'dist/cloudflare'
79
81
 
80
82
  assertTargetCompatibility(analyzeCompiledCapabilities(loaded.document, compiled, 'cloudflare'));
81
83
  for (const route of routes) {
84
+ assert(!route.wildcard, `${route.pattern}: the Worker route table has no suffix matching yet, so a /** wildcard redirect is refused on this target`);
82
85
  const policies = effectivePolicies(loaded.document, route);
83
86
  for (const name of Object.keys(policies)) {
84
87
  assert(isPolicyName(name), `Unknown policy "${name}"`);
@@ -132,7 +135,7 @@ export async function buildCloudflare(project , { out = 'dist/cloudflare'
132
135
  ? { security: projectPolicies.security } : undefined;
133
136
  if (errorPolicy) registry.security.compile(errorPolicy.security, { route: { pattern: '(project)' }, shared: {}, target: 'cloudflare', document: loaded.document });
134
137
 
135
- const artifact = { format:FORMAT, version:loaded.version, routes:serialised, ...(errorPolicy ? { policies: errorPolicy } : {}) };
138
+ const artifact = { format:FORMAT, version:loaded.version, routes:serialised, ...(errorPolicy ? { policies: errorPolicy } : {}), ...(notFound ? { notFound: true } : {}) };
136
139
  await mkdir(out, { recursive:true });
137
140
  await writeFile(join(out,'validators.js'), await linkRuntime(standaloneCode.default(ajv, validators)));
138
141
  await writeFile(join(out,'artifact.js'),
@@ -64,6 +64,7 @@ export async function buildStatic(project , { out = 'dist/static', origin
64
64
  assert(defaultMethods.size === route.methods.length && [...defaultMethods].every(method => route.methods.includes(method)), `${route.pattern}: static hosting cannot preserve a GET-only or HEAD-only method restriction; declare both GET and HEAD`);
65
65
  if (route.reply) assert(route.reply.status === 200, `${route.pattern}: static hosting serves response objects with status 200; declared status ${route.reply.status} cannot be preserved`);
66
66
  if (route.redirect) {
67
+ assert(!route.wildcard, `${route.pattern}: static hosting cannot redirect a path suffix; S3 per-object redirects match one exact path`);
67
68
  assert(!route.names.length, `${route.pattern}: static hosting cannot redirect a path pattern; only an exact literal path can carry an S3 per-object redirect`);
68
69
  const query = route.redirect.query;
69
70
  assert(!query?.pass?.length && !(query?.map && Object.keys(query.map).length), `${route.pattern}: static hosting cannot pass or map query parameters into a redirect target; this needs request-time logic S3 does not have`);
package/dist/cli.js CHANGED
@@ -27,11 +27,14 @@ import { registry as policyRegistry } from './policies.js';
27
27
  import { loadComplianceRules, profileNames as complianceProfiles } from './compliance.js';
28
28
  import { parseRouteSnapshot, diffRoutes, renderRouteDiff } from './route-diff.js';
29
29
  import { readFile } from 'node:fs/promises';
30
+ import { installArtifact, inspectArtifacts } from './extension-artifacts.js';
31
+ import { installBundle, readBundleLock } from './extension-bundles.js';
30
32
 
31
- const usage = `URLCode 0.4.7 — local/self-hosted runtime
32
- urlcode init <directory> [--template page] [--with ui,auth,admin] [--ack extension:id] [--manifest|--no-manifest] [--pin @scope/pkg=specifier]
33
+ const usage = `URLCode 0.5.0 — local/self-hosted runtime
34
+ urlcode init <directory> [--template page|redirects] [--with ui,auth,admin] [--bundle-release extension-bundles@vX.Y.Z] [--ack extension:id] [--manifest|--no-manifest] [--pin @scope/pkg=specifier]
35
+ # --template redirects: the tested redirect starter from 'urlcode context --task redirects' (urlcode.yaml, 404.html, package.json with a PORT-aware start script, tests, AGENTS.md, .mcp.json); not combinable with --with
33
36
  # --template page: the smallest project (urlcode.yaml, public/index.html, README.md, tests/requests.json), one page route; not combinable with --with
34
- # --with: layered site from installed @jimhoyd/urlcode-<name> packages, with a package.json pinning them exactly; --with is an unordered set, core orders the host from each extension's declared requirements and refuses a missing requirement, conflict or cycle before writing
37
+ # --with: layered site from installed @jimhoyd/urlcode-<name> packages, with a package.json pinning them exactly; --bundle-release instead verifies frozen first-party bundles and writes no npm extension dependency. --with is an unordered set, core orders the host from each extension's declared requirements and refuses a missing requirement, conflict or cycle before writing
35
38
  # --ack: repeatable, qualified acknowledgement of a risk an extension names when it refuses (for example store:public-write); do not pass it pre-emptively, the refusal prints the exact command. Rejected when no scaffold consumes it
36
39
  # --manifest: also pin the runtime for a route-only project; --no-manifest: --with without a package.json
37
40
  # --pin: record a local path or tarball instead of the registry version; repeatable. No install is ever run for you.
@@ -64,6 +67,13 @@ const usage = `URLCode 0.4.7 — local/self-hosted runtime
64
67
  # effective methods, handler, middleware, inputs, policies, cache outcome, bindings and target support from the compiled configuration
65
68
  urlcode manifest [--project directory] [--json] # generated semantic manifest; build writes the same file as manifest.json
66
69
  urlcode extensions [--project directory] [--host-file /absolute/operator/host.mjs] [--json] # registered contracts and schemas; executes trusted host code, activates nothing
70
+ urlcode extension-artifacts install <name> --artifact-release extensions@vX.Y.Z [--project directory]
71
+ urlcode extension-artifacts update <name> --artifact-release extensions@vX.Y.Z [--project directory]
72
+ urlcode extension-artifacts inspect [--project directory] [--json]
73
+ # signed, data-only extension bundles cached under .urlcode/extensions; they never execute or replace --host-file
74
+ urlcode extension-bundles install <name> --bundle-release extension-bundles@vX.Y.Z [--project directory]
75
+ urlcode extension-bundles inspect [--project directory] [--json]
76
+ # signed executable first-party bundles cached under .urlcode/extension-bundles; installation is explicit and host code loads them
67
77
  urlcode import [netlify|cloudflare|vercel|netlify-toml] <file> [--format csv|json|yaml] [--out new-file] [--dry-run] [--report json]
68
78
  urlcode export --target netlify|cloudflare|vercel|netlify-toml|csv|json|yaml [--project directory] [--out new-file] [--report json]
69
79
  conversion: [--accept-provider-differences] # explicit non-lossless migration candidate; exact behavior requires runtime
@@ -77,8 +87,8 @@ const usage = `URLCode 0.4.7 — local/self-hosted runtime
77
87
  urlcode capabilities [--target self-hosted|cloudflare|aws|vercel|static] [--json]
78
88
  urlcode capabilities <name> [--json] # one catalog entry: schema fragment, constraints, grants, targets, bundled uses
79
89
  urlcode schema <path> [--json|--yaml] # schema fragment for route, redirect, policies.cache, site.sitemap, ...
80
- urlcode context [--project directory] [--target self-hosted|cloudflare|aws|vercel|static] [--budget 500] [--json] [--stats]
81
- # compact facts for an authoring agent from the compiled project; --stats compares estimated tokens with the docs
90
+ urlcode context [--project directory] [--target self-hosted|cloudflare|aws|vercel|static | --task redirects] [--budget 500] [--json] [--stats]
91
+ # compact facts for an authoring agent from the compiled project; --task redirects: supported redirect shapes, gaps and this project's redirects in one bounded call; --stats compares estimated tokens with the docs
82
92
  urlcode doctor
83
93
  serve/dev/validate/test/routes/audit/benchmark/explain/context/extensions/mcp: --host-file /absolute/operator/host.mjs (trusted code outside project)
84
94
  Dev loads .env.local and watches; serve does neither. Functions run trusted and in-process by default; a route declaring sandbox: true runs in WASM isolation. External bindings require --policy outside the project.
@@ -93,7 +103,8 @@ const options = {
93
103
  workers:{type:'string'}, 'function-timeout-ms':{type:'string'}, 'max-response-bytes':{type:'string'}, 'max-body-bytes':{type:'string'},
94
104
  'max-in-flight':{type:'string'}, 'max-in-flight-health':{type:'string'}, 'request-log':{type:'string'}, 'trust-request-id':{type:'boolean'}, 'trusted-proxies':{type:'string'}, metrics:{type:'boolean'},
95
105
  release:{type:'string'}, 'git-commit':{type:'string'}, 'timeout-ms':{type:'string'}, 'fail-on':{type:'string'}, 'expect-metrics':{type:'boolean'},
96
- budget:{type:'string'}, stats:{type:'boolean'}, out:{type:'string'}, 'dry-run':{type:'boolean'}, compare:{type:'string'}, format:{type:'string'}, compliance:{type:'string'}, 'compliance-rules':{type:'string'}, 'compliance-ignore':{type:'string'}, 'compliance-warn':{type:'boolean'}, policy:{ type:'string' }, origin:{ type:'string' }, alias:{ type:'string' }, local:{ type:'boolean' }, verbose:{ type:'boolean' }, 'allow-authoring':{ type:'boolean' }, help:{ type:'boolean', short:'h' },
106
+ budget:{type:'string'}, task:{type:'string'}, stats:{type:'boolean'}, out:{type:'string'}, 'dry-run':{type:'boolean'}, compare:{type:'string'}, format:{type:'string'}, compliance:{type:'string'}, 'compliance-rules':{type:'string'}, 'compliance-ignore':{type:'string'}, 'compliance-warn':{type:'boolean'}, policy:{ type:'string' }, origin:{ type:'string' }, alias:{ type:'string' }, local:{ type:'boolean' }, verbose:{ type:'boolean' }, 'allow-authoring':{ type:'boolean' }, help:{ type:'boolean', short:'h' },
107
+ 'artifact-release':{type:'string'}, 'bundle-release':{type:'string'},
97
108
  } ;
98
109
 
99
110
 
@@ -170,10 +181,22 @@ try {
170
181
  if (values.manifest && values['no-manifest']) throw new ConfigError('Use either --manifest or --no-manifest');
171
182
  if (values.ack !== undefined && (command !== 'init' || values.with === undefined)) throw new ConfigError('--ack is only supported by init with --with');
172
183
  if (values['allow-authoring'] && command !== 'mcp') throw new ConfigError('--allow-authoring is only supported by mcp');
184
+ if (values['artifact-release'] !== undefined && command !== 'extension-artifacts') throw new ConfigError('--artifact-release is only supported by extension-artifacts');
185
+ if (values['bundle-release'] !== undefined && command !== 'extension-bundles' && command !== 'init') throw new ConfigError('--bundle-release is only supported by extension-bundles or init --with');
186
+ if (values['bundle-release'] !== undefined && command === 'init' && values.with === undefined) throw new ConfigError('--bundle-release needs init --with');
173
187
  const hostOptions = { extensions: operatorHost.extensions, plugins: operatorHost.plugins };
174
- if ((!['import','recipes','recipe','examples','example','bulk-import'].includes(command) && extra.length) || (!['init','add','import','recipes','recipe','examples','example','bulk-import','explain','capabilities','schema'].includes(command) && arg)) throw new ConfigError('Unexpected positional arguments');
188
+ if ((!['import','recipes','recipe','examples','example','bulk-import','extension-artifacts','extension-bundles'].includes(command) && extra.length) || (!['init','add','import','recipes','recipe','examples','example','bulk-import','explain','capabilities','schema','extension-artifacts','extension-bundles'].includes(command) && arg)) throw new ConfigError('Unexpected positional arguments');
175
189
 
176
- if(command==='import'||command==='export'){
190
+ if(command==='extension-artifacts'){
191
+ const operation=arg;
192
+ if(operation==='install'||operation==='update') { const artifact=extra[0]; if(!artifact || extra.length!==1) throw new ConfigError(`Use urlcode extension-artifacts ${operation} <name> --artifact-release extensions@vX.Y.Z`); if(!values['artifact-release']) throw new ConfigError('Use --artifact-release with an immutable extension release tag'); const lock=await installArtifact(values.project,values['artifact-release'],artifact); print(values.json?lock:{event:operation==='install'?'extension-artifact-installed':'extension-artifact-updated',name:artifact,lockfile:'urlcode.extensions.lock.json'}); }
193
+ else if(operation==='inspect') { if(extra.length) throw new ConfigError('Use urlcode extension-artifacts inspect'); const report=await inspectArtifacts(values.project); print(values.json?report:{artifacts:report.lock.artifacts.map(item=>({...item,status:report.cached.includes(item.name)?'cached':report.invalid.includes(item.name)?'invalid':'missing'}))}); }
194
+ else throw new ConfigError('Use extension-artifacts install, update or inspect');
195
+ }else if(command==='extension-bundles'){
196
+ if(arg==='install') { const bundle=extra[0]; if(!bundle || extra.length!==1) throw new ConfigError('Use urlcode extension-bundles install <name> --bundle-release extension-bundles@vX.Y.Z'); if(!values['bundle-release']) throw new ConfigError('Use --bundle-release with an immutable extension bundle release tag'); const lock=await installBundle(values.project,values['bundle-release'],bundle); print(values.json?lock:{event:'extension-bundle-installed',name:bundle,lockfile:'urlcode.extension-bundles.lock.json'}); }
197
+ else if(arg==='inspect') { if(extra.length) throw new ConfigError('Use urlcode extension-bundles inspect'); const lock=await readBundleLock(values.project); print(values.json?lock:{bundles:lock.bundles.map(item=>({name:item.name,version:item.version,release:item.catalog.tag,coreVersion:item.coreVersion}))}); }
198
+ else throw new ConfigError('Use extension-bundles install or inspect');
199
+ }else if(command==='import'||command==='export'){
177
200
  const { runInterchange } = await import('./interchange-cli.js');
178
201
  const converted = await runInterchange(command,positionals.slice(1),{project:values.project,target:values.target,format:values.format,out:values.out,report:values.report,dryRun:values['dry-run'],acceptProviderDifferences:values['accept-provider-differences']});
179
202
  print(converted.text); if(!converted.report.ok)process.exitCode=1;
@@ -193,9 +216,17 @@ try {
193
216
  print(values.yaml ? stringifyYaml(fragment.schema) : JSON.stringify(fragment.schema,null,2)+'\n');
194
217
  }else if(command==='context'){
195
218
  if (values.budget !== undefined && !/^\d{1,9}$/.test(values.budget)) throw new ConfigError('Invalid --budget');
196
- const { buildContext, renderContext, estimateTokens, documentationTokens } = await import('./context.js');
197
- const context = await buildContext(values.project, { target:values.target, hostFile:values['host-file'], ...(values.budget === undefined ? {} : { budget:Number(values.budget) }) });
198
- const text = values.json ? JSON.stringify(context) + '\n' : renderContext(context);
219
+ const { buildContext, buildTaskContext, renderContext, renderTaskContext, estimateTokens, documentationTokens } = await import('./context.js');
220
+ const budget = values.budget === undefined ? {} : { budget:Number(values.budget) };
221
+ let text ;
222
+ if (values.task !== undefined) {
223
+ if (values.target !== undefined) throw new ConfigError('--task cannot be combined with --target');
224
+ const task = await buildTaskContext(values.project, values.task, { hostFile:values['host-file'], ...budget });
225
+ text = values.json ? JSON.stringify(task) + '\n' : renderTaskContext(task);
226
+ } else {
227
+ const context = await buildContext(values.project, { target:values.target, hostFile:values['host-file'], ...budget });
228
+ text = values.json ? JSON.stringify(context) + '\n' : renderContext(context);
229
+ }
199
230
  print(text);
200
231
  // Estimates only (characters / 4); a tokenizer is not a dependency. Stats go to stderr so stdout stays parseable.
201
232
  if (values.stats) process.stderr.write(JSON.stringify({ event:'stats', estimate:'characters/4', documentationTokens:await documentationTokens(), contextTokens:estimateTokens(text) }) + '\n');
@@ -295,15 +326,16 @@ try {
295
326
  const wanted = values.with === undefined ? values.manifest === true : !values['no-manifest'];
296
327
  const pins = new Map((values.pin ?? []).map(parsePin));
297
328
  if (pins.size && !wanted) throw new ConfigError('--pin needs a manifest; drop --no-manifest or add --manifest');
298
- if (values.template !== undefined && values.template !== 'default' && values.template !== 'page') throw new ConfigError('--template must be page');
299
- if (values.template === 'page' && values.with !== undefined) throw new ConfigError('--template page cannot be combined with --with');
329
+ if (values.template !== undefined && values.template !== 'default' && values.template !== 'page' && values.template !== 'redirects') throw new ConfigError('--template must be page or redirects');
330
+ if ((values.template === 'page' || values.template === 'redirects') && values.with !== undefined) throw new ConfigError(`--template ${values.template} cannot be combined with --with`);
331
+ if (values.template === 'redirects' && (wanted || pins.size)) throw new ConfigError('--template redirects writes its own package.json pinning this runtime; drop --manifest and --pin');
300
332
  if (values.with === undefined) {
301
333
  const set = wanted ? await collectDependencySet([], [], { overrides: pins }) : undefined;
302
- const created = await initProject(arg, { manifest: set, template: values.template === 'page' ? 'page' : 'default' });
334
+ const created = await initProject(arg, { manifest: set, template: values.template === 'page' || values.template === 'redirects' ? values.template : 'default' });
303
335
  print(set ? { event:'created', dependencies:set.pins, nextSteps:installSteps(created, set) } : { event:'created' });
304
336
  break;
305
337
  }
306
- const created = await initProjectWith(arg, parseWithNames(values.with), { manifest: wanted, pins, acknowledgements: values.ack ?? [] });
338
+ const created = await initProjectWith(arg, parseWithNames(values.with), { manifest: wanted, pins, acknowledgements: values.ack ?? [], bundleRelease: values['bundle-release'] });
307
339
  print({ event:'created', ...created, review:`Review ${created.project}/urlcode.yaml and pin its revision explicitly (for example PROJECT_SHA256=${created.projectSha256}); re-review after any project change` });
308
340
  break;
309
341
  }
@@ -22,7 +22,7 @@ import * as security from './policies/security.js';
22
22
 
23
23
 
24
24
 
25
-
25
+
26
26
 
27
27
 
28
28
  /** A route's compiled policy chain on this target: the same hook pairs the Node runtime holds. */
@@ -101,7 +101,10 @@ export function createFetchHandler(artifact , validators )
101
101
  const url = new URL(request.url);
102
102
  origin = url.origin;
103
103
  const parsed = parseTarget(url.pathname + url.search);
104
- const match = matchRoute(compiled, parsed);
104
+ let match = matchRoute(compiled, parsed), fallback = false;
105
+ // site.notFound: an unmatched GET/HEAD is answered with the inlined page
106
+ // (the /404.html route the build emitted) and status 404.
107
+ if (!match && artifact.notFound && (method === 'GET' || method === 'HEAD')) { match = matchRoute(compiled, parseTarget('/404.html')); fallback = match !== null; }
105
108
  if (!match) throw new HttpError(404, 'Not found');
106
109
  const { route, path } = match;
107
110
  matched = route;
@@ -138,7 +141,7 @@ export function createFetchHandler(artifact , validators )
138
141
  const context = contextFor(route, path, parsed.query, request.headers, {});
139
142
  let native ;
140
143
  if (redirecting(route)) native = { status: route.redirect.status || 302, headers:[['location',redirectLocation(route, context, parsed.query)]], body: new Uint8Array(0) };
141
- else if (route.reply) native = { ...route.reply };
144
+ else if (route.reply) native = { ...route.reply, ...(fallback ? { status: 404 } : {}) };
142
145
  else throw new HttpError(502, 'Invalid function response');
143
146
  return respond(prepareResponse(await finish(decorateResponse(route, native)), { requestId, method }), requestId, method);
144
147
  } catch (error) {
package/dist/config.js CHANGED
@@ -146,7 +146,12 @@ function normalizeRoute(pattern , route
146
146
  const needsFunction = typeof authored.function === 'string';
147
147
  const needsMiddleware = authored.middleware?.some(entry => typeof entry === 'string') ?? false;
148
148
  const needsCache = authored.cache !== undefined;
149
- if (!needsFunction && !needsMiddleware && !needsCache) return route ;
149
+ // Long form without `args`: bind every declared path input, exactly as the short form does, so `function: {source}`
150
+ // is not silently handed an empty `args` (a route that wants none declares `args: {}`).
151
+ const declaredPathNames = typeof authored.function === 'object' && authored.function !== null && authored.function.args === undefined
152
+ ? (authored.parameters ?? []).filter(p => p.in === 'path').map(p => p.name) : [];
153
+ const needsArgs = declaredPathNames.length > 0;
154
+ if (!needsFunction && !needsMiddleware && !needsCache && !needsArgs) return route ;
150
155
  const result = { ...(route ) };
151
156
  if (needsMiddleware) result.middleware = authored.middleware .map((entry) => typeof entry === 'string' ? { source: modulePath(pattern, 'middleware', entry) } : entry);
152
157
  if (needsFunction) {
@@ -158,6 +163,7 @@ function normalizeRoute(pattern , route
158
163
  if (parameters.length) result.parameters = parameters;
159
164
  result.function = expanded;
160
165
  }
166
+ if (needsArgs) result.function = { ...(authored.function ), args: Object.fromEntries(declaredPathNames.map(name => [name, { from: 'path' , name }])) };
161
167
  if (needsCache) {
162
168
  assert(result.policies?.cache === undefined, `Route ${pattern} declares both cache and policies.cache; use one form`);
163
169
  result.policies = { ...result.policies, cache: authored.cache };