@jimhoyd/urlcode 0.4.8 → 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.
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.8` 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
@@ -4,19 +4,19 @@
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": "8ad311348154a00b62598e7a93e21ebee125cfce67482f748b31f98e1c49efbf",
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": "ea25c36b99f9bd7d7df0272e0bd104b426c169b3717ed689eb37e813daff93ea",
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": "b15a87501d2d73073b33e880934a972acc0a1c54763cf3ae3ea3791ad5cf603c",
19
+ "dist/cli.js": "623cdf3dceb26b77248d04d5636807492cf269937029555c8ca323c5a61716e6",
20
20
  "dist/client-address.js": "9d0d01466aab23124330605c5a0e0f981f87c897858acfad909f546a63df165b",
21
21
  "dist/cloudflare.js": "31da478a6b851b5923c14dbd11885684cecec51cc7e0a5d6031d8512f3ecc040",
22
22
  "dist/compliance-rules/baseline.js": "6296dea6bcb9f9bb80ed6ed5ab2f456e7f74e6de2be5d2d98ca8c983fb4df3ab",
@@ -26,32 +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": "b3a45856662fbda7a0217c7856ab1deb7c09e3e12d7dd1559c0a0cac85a9bcff",
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/extension-artifacts.js": "b196f3a9372188eb5f21f57b6e6ccee0c03aa0bbdd34cd6d88a0b3ccc470d363",
38
- "dist/extensions.js": "966441cca677b0d88683d29d89d36c61cf487ae2b70d94eaa0c71c50730a4212",
37
+ "dist/extension-artifacts.js": "9809125725cbac63141fe7c214f41888c8c2b66d2893cebc9f333b836988ea14",
38
+ "dist/extension-bundles.js": "d76e5937d93fa5f6545826178bab94d24ea16f220efab6ff23f22f976413f5a7",
39
+ "dist/extensions.js": "ec4b1784fe278a5293ec12651765184bf57dac7146c0627dc9cb31deeb01a928",
39
40
  "dist/function-sources.js": "16fad4abc81c7ee07b6cbcef2d23a9fc50e97a17cb4dde1db53469bbb555c96f",
40
41
  "dist/function-worker.js": "35771790ada4e1b36d447d4967a5e6cf0b543f32944e4c904f5fbf8998e824a5",
41
- "dist/functions.js": "0f09d1bd27eb655ceb5900a4a5e2b760d2028b02cb637a6a89a4eb5940828008",
42
+ "dist/functions.js": "d9f5a493ba389191707107972562edc5b5f435579622d5a0caa4d942d7b1326a",
42
43
  "dist/guest-api.js": "49aec9ed72733ad119e63bcfcee18158dcc8ceb951e1823b8947f6407ff24afc",
43
44
  "dist/header-validation.js": "465181dbb08ff05f52defdd29fda025c0c64589bf319fa87fa6d3ab4b68216d5",
44
45
  "dist/http-policy.js": "393a6383bdf9e05597c98721b3a4c4207a9bbe359883babcbfa06b696c70f56c",
45
46
  "dist/http-response.js": "565826d1ece4bd30dfc1150c75800acec1fef584f732e964409aa92958fc8550",
46
- "dist/index.js": "a852f2512d8af0ba0645a6a8317f7ab2e576c442ab7a31e1570794ff0e635074",
47
- "dist/init-with.js": "0d2dccb83f0ad53181c6f5c95a4c663ce06ee9a1fa9074facac9a1aee7599a96",
47
+ "dist/index.js": "764b998af6ef41e4df2a9c3d74a6ba989ee7a231d5f7071d0ebca791a12719c2",
48
+ "dist/init-with.js": "4b4bfca3081f57c525ec2b3617c4735d773c5317f285337948227ede4734f214",
48
49
  "dist/interchange-cli.js": "35bd70ba8077af5c3e39404ff0e5d28707632090a59a90645141252449fd3f04",
49
- "dist/interchange.js": "26789420af33344d9a08c00fe6b2708aa71994224df527b3075295be2151611e",
50
+ "dist/interchange.js": "3352228bc4ca652dd285fdaeddc8586f731fea5bd49521f408cce9a727f50ea8",
50
51
  "dist/logging.js": "8ec57fea26983aa071b05fe15d680d6550b03488101163ed9666da674118cf31",
51
52
  "dist/manifest.js": "83eed0d59621039ef364cf6e7b54c5bd47e0d8cf0a5abe7b07344bfc1db56ed8",
52
- "dist/match.js": "53ebcc2cda529a8d07fb83f8cb69a1446036ffc1641af3c4bd45a55851bded28",
53
+ "dist/match.js": "2299245c0ba85481afe470c95f0f79502b6310b6870f776d16d88525a5e5da63",
53
54
  "dist/mcp-authoring.js": "1183a56c5decb99ecb67a5da7bd91d1b494bc17a2965051aa63c4a7e291462b5",
54
- "dist/mcp.js": "51965c4e85c8e3ce283baf4255ca9db149ce5ee650e5a1a7c6a097516d9de5b2",
55
+ "dist/mcp.js": "5f2d19ffb1a6bc72fa258c3f788af24eaf732bce9f87ca5dcd431d84abaa02bf",
55
56
  "dist/observability.js": "f4b1ab496f051fe3ef2ed36b2b2e469c63a8940700e2df852e6ff2d9cccdd5ae",
56
57
  "dist/operator-host.js": "e3dac9d43a83beb775202b4ab9eeeba7ac63cebfc5dcc669be38d825be587bd0",
57
58
  "dist/pattern-guard.js": "6f157ced99666a89ca5af1418f5a14568d850985042dea309bf9ec0c19dbaca5",
@@ -68,11 +69,11 @@
68
69
  "dist/project-tests.js": "63562978411ee94a69dcf45d4d47e84fba009d8654ac421c2a02a418cd80b4db",
69
70
  "dist/provider-verification.js": "ffcf9f05e8cb5f71ea46a97e279e8a613fdfdf9435ab02bd57af11e2680a9107",
70
71
  "dist/proxy.js": "34107e59ac0dbbea090efa3cf5badf3226c5302ba25e8f8ff9b2b5596ed80e50",
71
- "dist/readiness.js": "2b67bc86c0c73528f3f798b6a0ee1bd5aec5ac6f4a0998c45443c903a622af4f",
72
+ "dist/readiness.js": "04143b22dbb8deec4b21b0f86c0c45c67f62a511efe1f99764b660791ef83343",
72
73
  "dist/recipes.js": "7af62ea9f612c82ea21e97e9d14451f05e0b509d6e420fb99fec96b22e3533f1",
73
74
  "dist/route-diff.js": "8d71d50d332ffaaed588656bd32aa36763260d1ad87bb62d7ea1c8fb23383969",
74
- "dist/router.js": "b2b26409321c5d119ac54793efe6bb6a2f9bbf2f36db251c68337c25b9d836b9",
75
- "dist/runtime.js": "e36153ab250b566ea778584a447ce603cf283274d9f8ac54a22aa9969c2284e8",
75
+ "dist/router.js": "52899195e0dc9f001e4202faf0db1ed48f5531de8d63f1a6173b7ceab570ca33",
76
+ "dist/runtime.js": "6731200cdc1e8ac66b5d0ba7d0f42686ec31321b24e7d51130fe130e69478f37",
76
77
  "dist/sandbox.js": "c88c5aa5dd95b4f55f1ba62da7969ad66dbd97c30f72e5b5f2817a2098ac1c65",
77
78
  "dist/scaffold.js": "0f936cabd20b609c7ebfebd7895710ce6c30882c3088a2356ac75bb4e0d10c55",
78
79
  "dist/schema-query.js": "bfd1844acd8d54ac361115191fcfbaf828a67e223b9ec6e9c22f158761af149b",
@@ -81,7 +82,7 @@
81
82
  "dist/site.js": "9f8154d0a62ed1b650c064a24e609d50dd370f0ff2cee2567628029e30855616",
82
83
  "dist/tooling.js": "f377128dc37cff4685ba8503f4c38089ca4d7e97551e832bae4cdc24e357ba97",
83
84
  "dist/trusted-functions.js": "f3800c75f45ce90faf85ba6664398ad9031c5a9ac51d5ce42bdcff08a497d48e",
84
- "dist/types.js": "827f1afde90d19715e79500515ecaa80f5b65ce552f0ef24d2fc561ef77e6e40",
85
+ "dist/types.js": "11d71c522e51c707a363f734fa5715f0a47b2009431ae59f6075d6eff6074ff0",
85
86
  "dist/typescript-authoring.js": "df3d9c82e6b545cae5caae04c443ed4d2feae96def5e5cde250c0a90d83ae98c",
86
87
  "dist/vercel.js": "a0da20c095d0c7fa8f579ed4dd5951e7a34191f8a72a57c5e782c9eab39f6f08",
87
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,7 +51,7 @@ 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
@@ -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');
@@ -81,6 +81,7 @@ export async function buildCloudflare(project , { out = 'dist/cloudflare'
81
81
 
82
82
  assertTargetCompatibility(analyzeCompiledCapabilities(loaded.document, compiled, 'cloudflare'));
83
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`);
84
85
  const policies = effectivePolicies(loaded.document, route);
85
86
  for (const name of Object.keys(policies)) {
86
87
  assert(isPolicyName(name), `Unknown policy "${name}"`);
@@ -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
@@ -28,11 +28,13 @@ import { loadComplianceRules, profileNames as complianceProfiles } from './compl
28
28
  import { parseRouteSnapshot, diffRoutes, renderRouteDiff } from './route-diff.js';
29
29
  import { readFile } from 'node:fs/promises';
30
30
  import { installArtifact, inspectArtifacts } from './extension-artifacts.js';
31
+ import { installBundle, readBundleLock } from './extension-bundles.js';
31
32
 
32
- const usage = `URLCode 0.4.8 — local/self-hosted runtime
33
- 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
34
36
  # --template page: the smallest project (urlcode.yaml, public/index.html, README.md, tests/requests.json), one page route; not combinable with --with
35
- # --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
36
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
37
39
  # --manifest: also pin the runtime for a route-only project; --no-manifest: --with without a package.json
38
40
  # --pin: record a local path or tarball instead of the registry version; repeatable. No install is ever run for you.
@@ -69,6 +71,9 @@ const usage = `URLCode 0.4.8 — local/self-hosted runtime
69
71
  urlcode extension-artifacts update <name> --artifact-release extensions@vX.Y.Z [--project directory]
70
72
  urlcode extension-artifacts inspect [--project directory] [--json]
71
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
72
77
  urlcode import [netlify|cloudflare|vercel|netlify-toml] <file> [--format csv|json|yaml] [--out new-file] [--dry-run] [--report json]
73
78
  urlcode export --target netlify|cloudflare|vercel|netlify-toml|csv|json|yaml [--project directory] [--out new-file] [--report json]
74
79
  conversion: [--accept-provider-differences] # explicit non-lossless migration candidate; exact behavior requires runtime
@@ -99,7 +104,7 @@ const options = {
99
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'},
100
105
  release:{type:'string'}, 'git-commit':{type:'string'}, 'timeout-ms':{type:'string'}, 'fail-on':{type:'string'}, 'expect-metrics':{type:'boolean'},
101
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' },
102
- 'artifact-release':{type:'string'},
107
+ 'artifact-release':{type:'string'}, 'bundle-release':{type:'string'},
103
108
  } ;
104
109
 
105
110
 
@@ -177,14 +182,20 @@ try {
177
182
  if (values.ack !== undefined && (command !== 'init' || values.with === undefined)) throw new ConfigError('--ack is only supported by init with --with');
178
183
  if (values['allow-authoring'] && command !== 'mcp') throw new ConfigError('--allow-authoring is only supported by mcp');
179
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');
180
187
  const hostOptions = { extensions: operatorHost.extensions, plugins: operatorHost.plugins };
181
- if ((!['import','recipes','recipe','examples','example','bulk-import','extension-artifacts'].includes(command) && extra.length) || (!['init','add','import','recipes','recipe','examples','example','bulk-import','explain','capabilities','schema','extension-artifacts'].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');
182
189
 
183
190
  if(command==='extension-artifacts'){
184
191
  const operation=arg;
185
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'}); }
186
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'}))}); }
187
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');
188
199
  }else if(command==='import'||command==='export'){
189
200
  const { runInterchange } = await import('./interchange-cli.js');
190
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']});
@@ -315,15 +326,16 @@ try {
315
326
  const wanted = values.with === undefined ? values.manifest === true : !values['no-manifest'];
316
327
  const pins = new Map((values.pin ?? []).map(parsePin));
317
328
  if (pins.size && !wanted) throw new ConfigError('--pin needs a manifest; drop --no-manifest or add --manifest');
318
- if (values.template !== undefined && values.template !== 'default' && values.template !== 'page') throw new ConfigError('--template must be page');
319
- 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');
320
332
  if (values.with === undefined) {
321
333
  const set = wanted ? await collectDependencySet([], [], { overrides: pins }) : undefined;
322
- 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' });
323
335
  print(set ? { event:'created', dependencies:set.pins, nextSteps:installSteps(created, set) } : { event:'created' });
324
336
  break;
325
337
  }
326
- 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'] });
327
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` });
328
340
  break;
329
341
  }
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 };
package/dist/context.js CHANGED
@@ -170,17 +170,44 @@ const idParam=(name )=>({name,in:'path',required:true,schema:{type:'string
170
170
  export const redirectShapes =[
171
171
  {need:'fixed',support:'supported',yaml:{routes:{'/old':{redirect:{url:'https://example.com/new',status:301}}}},note:'status defaults to 302; allowed 301, 302, 303, 307, 308. Only GET/HEAD match unless methods is set.'},
172
172
  {need:'parameterized path (/users/:id to /profiles/:id)',support:'supported',yaml:{routes:{'/users/{id}':{parameters:[idParam('id')],redirect:{url:'https://example.com/profiles/{id}',status:308}}}},note:'{name} placeholders only in the destination path, each naming a declared path parameter; the value is encoded as one component.'},
173
- {need:'fixed-depth suffix (/legacy/a/b to /modern/a/b)',support:'supported',yaml:{routes:{'/legacy/{a}/{b}':{parameters:[idParam('a'),idParam('b')],redirect:{url:'https://example.com/modern/{a}/{b}'}}}},note:'One route per depth; a path with more or fewer segments is a 404.'},
173
+ {need:'root-relative destination (/people/:id to /profiles/:id)',support:'supported',yaml:{routes:{'/people/{id}':{parameters:[idParam('id')],redirect:{url:'/profiles/{id}'}}}},note:'A single leading slash keeps the redirect on this site; Location is the path. {name} placeholders as above. `//host`, dot segments and a scheme-less host are refused.'},
174
+ {need:'wildcard suffix (/legacy/* to /modern/*, any depth)',support:'supported',yaml:{routes:{'/legacy/**':{redirect:{url:'https://example.com/modern/{**}'}}}},note:'Route key ends in a terminal `/**` (literal prefix required). `{**}` is the remaining segments, each encoded, at most once and only in the path; one or more segments, so /legacy and /legacy/ are 404. Exact and {param} routes win over it. Redirect only; refused on static and Cloudflare targets. Works with a relative destination too.'},
174
175
  {need:'query-string preservation',support:'supported',yaml:{routes:{'/search':{parameters:[{name:'q',in:'query',schema:{type:'string',maxLength:100}}],redirect:{url:'https://example.com/find',query:{pass:['q','utm_source']}}}}},note:'Nothing is forwarded by default; pass is an explicit allowlist (pass: true is refused by the schema); query.map renames or maps declared inputs.'},
175
176
  {need:'method-preserving redirect',support:'supported',yaml:{routes:{'/form':{methods:['GET','POST'],redirect:{url:'https://example.com/form2',status:307}}}},note:'Default methods GET/HEAD; other methods answer 405. Use 307/308 to keep the method and body.'},
176
177
  {need:'404 for unmatched paths',support:'supported',yaml:{site:{notFound:'404.html'}},note:'Unmatched GET/HEAD answer 404 (plain without site.notFound; that .html file, still status 404, with it). Trailing slashes are not normalized: /old/ is a 404 unless declared as its own route.'},
177
- {need:'wildcard suffix (/legacy/* to /modern/*, any depth)',support:'gap',note:'`/legacy/*` on a redirect fails validation: "Only static or extension routes support a terminal /* wildcard"; `{rest...}` fails with "Invalid route parameter". Report the gap; proposal in docs/OPEN-DECISIONS.md.',workaround:'a fixed-depth route per depth you need, or one route per known path (urlcode bulk-import). A function handler cannot match a subtree either.'},
178
- {need:'host, scheme or relative destination',support:'gap',note:'Destination must be a literal absolute http(s) URL: "/x" and "//h/x" fail with "Redirect URL must be absolute HTTP(S)"; {param} in host or query fails with "Redirect placeholders are allowed only in path segments"; other schemes fail with "Redirect must use HTTP(S) without credentials". Routes do not match on Host.',workaround:'a literal https destination per route; report host-based redirects as a gap.'},
178
+ {need:'host or scheme chosen from the request',support:'gap',note:'A destination is either a literal absolute http(s) URL or a root-relative path: "//h/x" and other schemes fail ("Redirect URL must be an absolute HTTP(S) URL or a root-relative path" / "Redirect must use HTTP(S) without credentials"); {param} in the host or query fails with "Redirect placeholders are allowed only in path segments". Routes do not match on Host.',workaround:'a literal https destination per route; report host-based redirects as a gap.'},
179
179
  {need:'redirect loop detection',support:'gap',note:'Validation accepts a route that redirects to its own URL; nothing detects cycles. Write a fixture with expectHeaders location for each redirect and review chains by hand.'},
180
180
  ];
181
+ /** A complete, paste-ready project skeleton: every supported shape merged into one urlcode.yaml, plus the start script. */
182
+
183
+
184
+
185
+
186
+
187
+
188
+
189
+
190
+ /** Merges every supported shape's YAML; test/context-task.test.ts compiles the result, so it cannot drift from the runtime. */
191
+ export function redirectStarter() {
192
+ const document ={version:'1',routes:{}};
193
+ for(const shape of redirectShapes) {
194
+ if(shape.support!=='supported'||!shape.yaml)continue;
195
+ const {routes,site}=shape.yaml ;
196
+ Object.assign(document.routes,routes);
197
+ if(site)document.site={...document.site,...site};
198
+ }
199
+ return {
200
+ file:'urlcode.yaml',
201
+ yaml:stringify(document,{lineWidth:0,aliasDuplicateObjects:false}),
202
+ companions:{'404.html':'<!doctype html><title>Not found</title><h1>404</h1>\n'},
203
+ packageScripts:{start:'urlcode serve --project . --host 0.0.0.0 --port ${PORT:-3000}'},
204
+ note:'Delete the routes you do not need and adjust the rest. `npm start` honors PORT. Shapes marked gap above are not in this file; do not add them.',
205
+ };
206
+ }
181
207
 
182
208
 
183
209
 
210
+
184
211
 
185
212
 
186
213
 
@@ -196,7 +223,7 @@ export async function buildTaskContext(project ,task ,options
196
223
  const budget=options.budget;
197
224
  if(budget!==undefined&&(!Number.isSafeInteger(budget)||budget<1))throw new Error('Invalid context budget');
198
225
  const flag=options.projectFlag??project;
199
- const context ={urlcode:await packageVersion(),schema:'1',task:'redirects',shapes:redirectShapes.map(shape=>({...shape}))};
226
+ const context ={urlcode:await packageVersion(),schema:'1',task:'redirects',shapes:redirectShapes.map(shape=>({...shape})),starter:redirectStarter()};
200
227
  const exists=await readFile(join(project,'urlcode.yaml')).then(()=>true,()=>false);
201
228
  if(exists) {
202
229
  const host=await loadOperatorHost(options.hostFile,project);
@@ -214,6 +241,7 @@ export async function buildTaskContext(project ,task ,options
214
241
  const steps =[
215
242
  ['project',()=>{delete context.project;}],
216
243
  ['commands',()=>{delete context.commands;delete context.recipe;}],
244
+ ['starter',()=>{delete context.starter;}],
217
245
  ['notes',()=>{context.shapes=context.shapes .map(({need,support,yaml})=>({need,support,...(yaml?{yaml}:{})}));}],
218
246
  ['shapes',()=>{delete context.shapes;}],
219
247
  ];
@@ -47,24 +47,31 @@ export function parseCatalog(bytes , requestedTag ) {
47
47
  return {format:1,tag:catalogTag,commit,artifacts,revoked};
48
48
  }
49
49
 
50
-
50
+
51
+
51
52
  function octal(bytes ) { const value=new TextDecoder().decode(bytes).replace(/\0.*$/,'').trim(); assert(/^[0-7]*$/.test(value),'Malformed extension archive'); return value ? Number.parseInt(value,8) : 0; }
52
- function archivePath(bytes ) { const value=new TextDecoder().decode(bytes).replace(/\0.*$/,''); assert(value.length>0 && !value.includes('\\') && !value.startsWith('/') && !value.split('/').includes('..'),'Unsafe extension archive path'); return value; }
53
+ function archivePath(name ,prefix ) {
54
+ const decode=(bytes )=>new TextDecoder().decode(bytes).replace(/\0.*$/,'');
55
+ const base=decode(name), directory=decode(prefix), value=directory?`${directory}/${base}`:base;
56
+ assert(base.length>0 && !value.includes('\\') && !value.startsWith('/') && !value.split('/').includes('..'),'Unsafe extension archive path');
57
+ return value;
58
+ }
53
59
  /** A minimal tar reader: only regular files are accepted, before any write occurs. */
54
- function readTgz(source ) {
55
- assert(source.byteLength>0 && source.byteLength<=MAX_ARCHIVE,'Extension archive exceeds the 16 MiB limit');
56
- let bytes ; try { bytes=gunzipSync(source,{maxOutputLength:MAX_EXPANDED}); } catch { throw new ConfigError('Extension artifact is not a valid bounded gzip tarball'); }
60
+ export function readBoundedTgz(source , limits ) {
61
+ assert(source.byteLength>0 && source.byteLength<=limits.archive,`${limits.label} exceeds the size limit`);
62
+ let bytes ; try { bytes=gunzipSync(source,{maxOutputLength:limits.expanded}); } catch { throw new ConfigError(`${limits.label} is not a valid bounded gzip tarball`); }
57
63
  const files =[]; let ended=false;
58
64
  for(let at=0;at<bytes.length;) {
59
- const header=bytes.subarray(at,at+512); if(header.length===512&&header.every(byte=>byte===0)) { const second=bytes.subarray(at+512,at+1024); assert(second.length===512&&second.every(byte=>byte===0)&&bytes.subarray(at+1024).every(byte=>byte===0),'Malformed extension archive terminator'); ended=true; break; }
60
- assert(header.length===512,'Truncated extension archive'); const stored=octal(header.subarray(148,156)); let checksum=0; for(let index=0;index<header.length;index++) checksum+=index>=148&&index<156?32:header[index] ; assert(stored===checksum,'Extension archive has an invalid tar checksum'); const size=octal(header.subarray(124,136)); const type=header[156] ?? 0;
61
- assert(type===0 || type===48,'Extension archives may contain regular files only'); assert(size<=MAX_FILE && at+512+size<=bytes.length,'Invalid extension archive member');
62
- const path=archivePath(header.subarray(0,100)); assert(!files.some(file=>file.path===path),'Extension archive repeats a path');
63
- files.push({path,bytes:bytes.slice(at+512,at+512+size)}); assert(files.length<=MAX_FILES,'Extension archive has too many files'); at+=512+Math.ceil(size/512)*512;
65
+ const header=bytes.subarray(at,at+512); if(header.length===512&&header.every(byte=>byte===0)) { const second=bytes.subarray(at+512,at+1024); assert(second.length===512&&second.every(byte=>byte===0)&&bytes.subarray(at+1024).every(byte=>byte===0),`Malformed ${limits.label} terminator`); ended=true; break; }
66
+ assert(header.length===512,`Truncated ${limits.label}`); const stored=octal(header.subarray(148,156)); let checksum=0; for(let index=0;index<header.length;index++) checksum+=index>=148&&index<156?32:header[index] ; assert(stored===checksum,`${limits.label} has an invalid tar checksum`); const size=octal(header.subarray(124,136)); const type=header[156] ?? 0;
67
+ assert(type===0 || type===48,`${limits.label} may contain regular files only`); assert(size<=limits.file && at+512+size<=bytes.length,`Invalid ${limits.label} member`);
68
+ const path=archivePath(header.subarray(0,100),header.subarray(345,500)); assert(!files.some(file=>file.path===path),`${limits.label} repeats a path`);
69
+ files.push({path,bytes:bytes.slice(at+512,at+512+size)}); assert(files.length<=limits.files,`${limits.label} has too many files`); at+=512+Math.ceil(size/512)*512;
64
70
  }
65
- assert(ended,'Extension archive has no complete tar terminator');
71
+ assert(ended,`${limits.label} has no complete tar terminator`);
66
72
  return files;
67
73
  }
74
+ function readTgz(source ) { return readBoundedTgz(source,{archive:MAX_ARCHIVE,expanded:MAX_EXPANDED,files:MAX_FILES,file:MAX_FILE,label:'Extension archive'}); }
68
75
  async function diskFiles(root ,prefix='') { const found =[]; for(const item of await readdir(join(root,prefix),{withFileTypes:true})) { const path=prefix?`${prefix}/${item.name}`:item.name; assert(item.isDirectory()||item.isFile(),'Extension cache contains a link or special file'); if(item.isDirectory()) found.push(...await diskFiles(root,path)); else found.push(path); } return found.sort(); }
69
76
  async function validateCached(root , entry ) { const archive=await readFile(join(root,'.artifact.tgz')); assert(digest(archive)===entry.sha256,`Cached extension artifact ${entry.name} does not match its lockfile`); const files=readTgz(archive); validateFiles(files,entry); const expected=['.artifact.tgz',...files.map(file=>file.path)].sort(); assert(JSON.stringify(await diskFiles(root))===JSON.stringify(expected),`Cached extension artifact ${entry.name} has unexpected files`); for(const file of files) assert(digest(await readFile(join(root,file.path)))===digest(file.bytes),`Cached extension artifact ${entry.name} was modified`); }
70
77
  function validateFiles(files , entry ) {
@@ -0,0 +1,70 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { spawn } from 'node:child_process';
3
+ import { lstat, mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, join, relative, resolve } from 'node:path';
6
+ import { pathToFileURL } from 'node:url';
7
+ import { ConfigError, assert } from './errors.js';
8
+ import { readBoundedTgz, } from './extension-artifacts.js';
9
+
10
+ /** Verified, executable first-party bundles. Unlike extension artifacts, these are trusted operator code. */
11
+ export const BUNDLE_REPOSITORY='jimhoyd-com/urlcode';
12
+ export const BUNDLE_WORKFLOW='jimhoyd-com/urlcode/.github/workflows/extension-bundles.yml';
13
+ const tag=/^extension-bundles@v[0-9][0-9A-Za-z._-]{0,100}$/;
14
+ const name=/^[a-z][a-z0-9-]{0,63}$/;
15
+ const version=/^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$/;
16
+ const hex=/^[a-f0-9]{64}$/;
17
+ const entry=/^node_modules\/@jimhoyd\/urlcode-[a-z][a-z0-9-]{0,63}\/dist\/[A-Za-z0-9._/-]+\.js$/;
18
+ const safeEntry=(value ) =>entry.test(value)&&value.split('/').every(segment=>segment!=='.'&&segment!=='..');
19
+ const limits={archive:128*1024*1024,expanded:512*1024*1024,files:12000,file:32*1024*1024,label:'Extension bundle archive'};
20
+
21
+ const record=(value ) =>value!==null&&typeof value==='object'&&!Array.isArray(value);
22
+ const digest=(bytes )=>createHash('sha256').update(bytes).digest('hex');
23
+ const text=(value ,what ) =>{assert(typeof value==='string'&&value.length>0&&value.length<256,`Invalid ${what} in extension bundle metadata`);return value;};
24
+ const exact=(value ,keys ,what ) =>assert(JSON.stringify(Object.keys(value).sort())===JSON.stringify([...keys].sort()),`${what} has unknown or missing fields`);
25
+
26
+
27
+
28
+
29
+
30
+
31
+
32
+ function parseEntry(value ,what ,strict=true) {
33
+ assert(record(value),`Invalid ${what}`); if(strict)exact(value,['name','version','asset','sha256','entry'],what);
34
+ const item={name:text(value.name,'bundle name'),version:text(value.version,'bundle version'),asset:text(value.asset,'bundle asset'),sha256:text(value.sha256,'bundle SHA-256'),entry:text(value.entry,'bundle entry')};
35
+ assert(name.test(item.name)&&version.test(item.version)&&/^[A-Za-z0-9._-]+\.tgz$/.test(item.asset)&&hex.test(item.sha256)&&safeEntry(item.entry),`Invalid ${what}`);
36
+ return item;
37
+ }
38
+ /** Parse only a catalog whose attestation was already verified against the requested immutable tag. */
39
+ export function parseBundleCatalog(bytes ,requested ) {
40
+ let raw ;try{raw=JSON.parse(new TextDecoder().decode(bytes));}catch{throw new ConfigError('Extension bundle catalog is not valid JSON');}
41
+ assert(record(raw)&&raw.format===1,'Unsupported extension bundle catalog format');exact(raw,['format','tag','commit','coreVersion','bundles','revoked'],'Extension bundle catalog');
42
+ const catalogTag=text(raw.tag,'catalog tag'),commit=text(raw.commit,'catalog commit'),coreVersion=text(raw.coreVersion,'catalog core version');
43
+ assert(tag.test(catalogTag)&&catalogTag===requested,'Extension bundle catalog tag does not match the immutable requested release');assert(/^[a-f0-9]{40}$/.test(commit)&&version.test(coreVersion),'Extension bundle catalog has an invalid pin');assert(Array.isArray(raw.bundles)&&Array.isArray(raw.revoked),'Extension bundle catalog is incomplete');
44
+ const names=new Set (),bundles=raw.bundles.map(value=>{const item=parseEntry(value,'extension bundle catalog entry');assert(!names.has(item.name),`Extension bundle catalog names ${item.name} more than once`);names.add(item.name);return item;});
45
+ const revoked =[],digests=new Set ();for(const value of raw.revoked){assert(record(value),'Invalid extension bundle revocation');exact(value,['sha256','reason'],'Extension bundle revocation');const sha256=text(value.sha256,'revocation SHA-256'),reason=text(value.reason,'revocation reason');assert(hex.test(sha256)&&!digests.has(sha256),'Invalid or duplicate extension bundle revocation');digests.add(sha256);revoked.push({sha256,reason});}
46
+ return {format:1,tag:catalogTag,commit,coreVersion,bundles,revoked};
47
+ }
48
+
49
+ function bundleManifest(files ) {
50
+ const member=files.find(file=>file.path==='bundle.json');assert(member,'Extension bundle is missing bundle.json');let raw ;try{raw=JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(member.bytes));}catch{throw new ConfigError('Extension bundle bundle.json is not valid UTF-8 JSON');}
51
+ assert(record(raw)&&raw.format===1,'Unsupported extension bundle manifest format');exact(raw,['format','coreVersion','bundles'],'Extension bundle manifest');const coreVersion=text(raw.coreVersion,'bundle core version');assert(version.test(coreVersion)&&Array.isArray(raw.bundles)&&raw.bundles.length>0,'Invalid extension bundle manifest');const seen=new Set ();const bundles=raw.bundles.map(value=>{assert(record(value),'Invalid extension bundle manifest entry');exact(value,['name','version','entry'],'Extension bundle manifest entry');const item={name:text(value.name,'bundle name'),version:text(value.version,'bundle version'),entry:text(value.entry,'bundle entry')};assert(name.test(item.name)&&version.test(item.version)&&safeEntry(item.entry)&&!seen.has(item.name),'Invalid or duplicate extension bundle manifest entry');seen.add(item.name);return item;});return {format:1,coreVersion,bundles};
52
+ }
53
+ function validateFiles(files ,item ,coreVersion ) {
54
+ assert(files.length>1&&files.every(file=>file.path==='bundle.json'||file.path.startsWith('node_modules/')),'Extension bundle contains a file outside its frozen module tree');const manifest=bundleManifest(files);assert(manifest.coreVersion===coreVersion,'Extension bundle core version does not match its signed catalog');assert(manifest.bundles.some(value=>value.name===item.name&&value.version===item.version&&value.entry===item.entry),'Extension bundle manifest does not match its signed catalog entry');assert(files.some(file=>file.path===item.entry),'Extension bundle is missing its declared entry module');
55
+ }
56
+ async function diskFiles(root ,prefix='') {const found =[];for(const item of await readdir(join(root,prefix),{withFileTypes:true})){const path=prefix?`${prefix}/${item.name}`:item.name;assert(item.isDirectory()||item.isFile(),'Extension bundle cache contains a link or special file');if(item.isDirectory())found.push(...await diskFiles(root,path));else found.push(path);}return found.sort();}
57
+ async function validateCached(root ,item ) {const archive=await readFile(join(root,'.bundle.tgz'));assert(digest(archive)===item.sha256,`Cached extension bundle ${item.name} does not match its lockfile`);const files=readBoundedTgz(archive,limits);validateFiles(files,item,item.coreVersion);const expected=['.bundle.tgz',...files.map(file=>file.path)].sort();assert(JSON.stringify(await diskFiles(root))===JSON.stringify(expected),`Cached extension bundle ${item.name} has unexpected files`);for(const file of files)assert(digest(await readFile(join(root,file.path)))===digest(file.bytes),`Cached extension bundle ${item.name} was modified`);}
58
+ export async function extractBundle(bytes ,item ,destination ) {assert(digest(bytes)===item.sha256,`Extension bundle ${item.name} does not match its signed SHA-256`);const files=readBoundedTgz(bytes,limits);validateFiles(files,item,item.coreVersion);const root=resolve(destination),temporary=join(tmpdir(),`urlcode-extension-bundle-${process.pid}-${Math.random().toString(16).slice(2)}`);await mkdir(temporary,{recursive:true});try{for(const file of files){const target=resolve(temporary,file.path),rel=relative(temporary,target);assert(rel&&!rel.startsWith('..'),'Unsafe extension bundle path');await mkdir(dirname(target),{recursive:true});await writeFile(target,file.bytes,{flag:'wx'});}await writeFile(join(temporary,'.bundle.tgz'),bytes,{flag:'wx'});await mkdir(dirname(root),{recursive:true});try{await rename(temporary,root);}catch{try{await validateCached(root,item );return;}catch{throw new ConfigError(`Extension bundle cache entry ${item.sha256} already exists but is not identical`);}}}finally{await rm(temporary,{recursive:true,force:true});}}
59
+ export function bundleCachePath(project ,sha256 ) {assert(hex.test(sha256),'Invalid extension bundle digest');return join(project,'.urlcode','extension-bundles',sha256);}
60
+ export async function readBundleLock(project ) {let raw ;try{const path=join(project,'urlcode.extension-bundles.lock.json'),info=await lstat(path);assert(info.isFile()&&!info.isSymbolicLink()&&info.nlink===1,'Extension bundle lockfile must be an ordinary file');raw=JSON.parse(await readFile(path,'utf8'));}catch(error){if(error instanceof ConfigError)throw error;throw new ConfigError('No extension bundle lockfile; install a bundle first');}assert(record(raw)&&raw.format===1&&Array.isArray(raw.bundles),'Invalid extension bundle lockfile');exact(raw,['format','bundles'],'Extension bundle lockfile');const seen=new Set (),bundles=raw.bundles.map(value=>{assert(record(value)&&record(value.catalog),'Invalid extension bundle lock entry');exact(value,['name','version','asset','sha256','entry','catalog','coreVersion'],'Extension bundle lock entry');exact(value.catalog,['tag','commit'],'Extension bundle lock catalog');const item={...parseEntry(value,'extension bundle lock entry',false),catalog:{tag:text(value.catalog.tag,'lock tag'),commit:text(value.catalog.commit,'lock commit')},coreVersion:text(value.coreVersion,'lock core version')};assert(tag.test(item.catalog.tag)&&/^[a-f0-9]{40}$/.test(item.catalog.commit)&&version.test(item.coreVersion)&&!seen.has(item.name),'Invalid or duplicate extension bundle lock entry');seen.add(item.name);return item;});return {format:1,bundles};}
61
+ async function writeLock(project ,lock ) {const path=join(project,'urlcode.extension-bundles.lock.json'),temporary=join(project,`.urlcode.extension-bundles.lock.${process.pid}.${Math.random().toString(16).slice(2)}`);await writeFile(temporary,JSON.stringify(lock,null,2)+'\n',{flag:'wx'});try{await rename(temporary,path);}finally{await rm(temporary,{force:true});}}
62
+ function releaseUrl(value ) {return `https://api.github.com/repos/${BUNDLE_REPOSITORY}/releases/tags/${encodeURIComponent(value)}`;}
63
+ function downloadUrl(value ) {const url=new URL(value);assert(url.protocol==='https:'&&(url.hostname==='github.com'||url.hostname.endsWith('.githubusercontent.com')),'Extension bundle release redirect left GitHub');return url;}
64
+ async function download(value ) {let url=downloadUrl(value);for(let redirects=0;redirects<=3;redirects++){const response=await fetch(url,{redirect:'manual'});if(response.status>=300&&response.status<400){const location=response.headers.get('location');assert(location&&redirects<3,'Extension bundle release asset has an invalid redirect');url=downloadUrl(new URL(location,url).href);continue;}assert(response.ok&&response.body,'Could not download extension bundle release asset');const chunks =[];let size=0;for await(const chunk of response.body){size+=chunk.byteLength;assert(size<=limits.archive,'Extension bundle release asset exceeds the size limit');chunks.push(chunk);}const result=new Uint8Array(size);let offset=0;for(const chunk of chunks){result.set(chunk,offset);offset+=chunk.byteLength;}return result;}throw new ConfigError('Extension bundle release asset redirected too many times');}
65
+ export const githubBundleTransport ={async release(value){assert(tag.test(value),'Use an immutable extension bundle release tag such as extension-bundles@v1.0.0');const response=await fetch(releaseUrl(value),{headers:{accept:'application/vnd.github+json'}});assert(response.ok,`Could not fetch extension bundle release ${value}`);const raw =await response.json();assert(record(raw)&&Array.isArray(raw.assets),'Extension bundle release has no asset inventory');const seen=new Set ();return raw.assets.map(asset=>{assert(record(asset)&&typeof asset.name==='string'&&typeof asset.browser_download_url==='string'&&!seen.has(asset.name),'Invalid or duplicate extension bundle release asset');seen.add(asset.name);const url=new URL(asset.browser_download_url);assert(url.protocol==='https:'&&url.hostname==='github.com'&&url.pathname.startsWith(`/${BUNDLE_REPOSITORY}/releases/download/`),'Extension bundle release asset is not a GitHub download');return {name:asset.name,url:url.href};});},download,async attest(path,release){assert(tag.test(release),'Invalid extension bundle release tag');await new Promise ((accept,reject)=>{const child=spawn('gh',['attestation','verify',path,'--repo',BUNDLE_REPOSITORY,'--signer-workflow',BUNDLE_WORKFLOW,'--source-ref',`refs/tags/${release}`,'--deny-self-hosted-runners'],{stdio:'ignore'});child.on('error',()=>reject(new ConfigError('GitHub CLI with attestation support is required to verify extension bundles')));child.on('exit',code=>code===0?accept():reject(new ConfigError('GitHub attestation verification refused the extension bundle')));});}};
66
+ async function verified(assets ,asset ,release ,transport ) {const found=assets.filter(value=>value.name===asset);assert(found.length===1,`Extension bundle release is missing or repeats ${asset}`);const bytes=await transport.download(found[0] .url),temporary=join(tmpdir(),`urlcode-bundle-attest-${process.pid}-${Math.random().toString(16).slice(2)}`);await writeFile(temporary,bytes,{flag:'wx'});try{await transport.attest(temporary,release);return bytes;}finally{await rm(temporary,{force:true});}}
67
+ export async function installBundle(project ,release ,bundleName ,transport =githubBundleTransport) {assert(name.test(bundleName),'Invalid extension bundle name');const assets=await transport.release(release),catalog=parseBundleCatalog(await verified(assets,'extension-bundles-catalog.json',release,transport),release),item=catalog.bundles.find(value=>value.name===bundleName);assert(item,`Extension bundle ${bundleName} is not in the signed catalog`);const revoked=catalog.revoked.find(value=>value.sha256===item.sha256);assert(!revoked,`Extension bundle ${bundleName} is revoked: ${revoked?.reason??'unknown reason'}`);const locked ={...item,catalog:{tag:catalog.tag,commit:catalog.commit},coreVersion:catalog.coreVersion};await extractBundle(await verified(assets,item.asset,release,transport),locked,bundleCachePath(project,item.sha256));let prior ;try{prior=await readBundleLock(project);}catch{/* first install */}const bundles=(prior?.bundles??[]).filter(value=>value.name!==item.name);bundles.push(locked);bundles.sort((left,right)=>left.name.localeCompare(right.name));const lock={format:1 ,bundles};await writeLock(project,lock);return lock;}
68
+ async function runningCoreVersion() {const raw =JSON.parse(await readFile(new URL('../package.json',import.meta.url),'utf8'));assert(record(raw)&&typeof raw.version==='string'&&version.test(raw.version),'Could not read the running core version');return raw.version;}
69
+ /** Explicit host-only loader. It never reads project YAML, downloads, updates, or discovers code. */
70
+ export async function loadExtensionBundle(project ,bundleName ) {assert(name.test(bundleName),'Invalid extension bundle name');const lock=await readBundleLock(project),item=lock.bundles.find(value=>value.name===bundleName);assert(item,`Extension bundle ${bundleName} is not locked`);assert(item.coreVersion===await runningCoreVersion(),`Extension bundle ${bundleName} requires core ${item.coreVersion}; this runtime is incompatible`);const root=bundleCachePath(project,item.sha256);await validateCached(root,item);const module=await import(pathToFileURL(join(root,item.entry)).href);assert(module&&typeof module==='object',`Extension bundle ${bundleName} entry did not export a module`);return module ;}