@jimhoyd/urlcode 0.4.6 → 0.4.8

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/dist/mcp.js CHANGED
@@ -2,12 +2,13 @@ import {realpath} from 'node:fs/promises';
2
2
 
3
3
  import {once} from 'node:events';
4
4
  import {Ajv} from 'ajv';
5
- import {inspectProject,validateProject,explainRoute,getCapabilities,getCapability,getSchemaFragment,previewImport,previewExport,listRecipes,showRecipe,searchRecipes,searchExamples,describeExtensions,buildContext} from './tooling.js';
5
+ import {inspectProject,validateProject,explainRoute,getCapabilities,getCapability,getSchemaFragment,previewImport,previewExport,listRecipes,showRecipe,searchRecipes,searchExamples,describeExtensions,buildContext,buildTaskContext} from './tooling.js';
6
6
  import {loadOperatorHost} from './operator-host.js';
7
7
  import {buildManifest} from './manifest.js';
8
8
 
9
9
  import {authoringDefinitions,callAuthoringTool} from './mcp-authoring.js';
10
10
  import {listSkills,getSkill,searchDocs,getExample,validateYaml,explainError} from './agent-context.js';
11
+ import {describeArtifactCache,readArtifactMember} from './extension-artifacts.js';
11
12
  const protocolVersion='2025-11-25';
12
13
  const maxBytes=1048576;
13
14
  const text={type:'string',maxLength:8192};
@@ -32,7 +33,9 @@ const definitions=[
32
33
  {name:'get_example',description:'Return the README and urlcode.yaml from one bundled runnable example.',properties:{name:{type:'string',maxLength:64}},required:['name']},
33
34
  {name:'validate_yaml',description:'Validate supplied URLCode YAML syntax and schema only. It never reads includes, source files, bindings or a project directory.',properties:{yaml:{type:'string',maxLength:524288}},required:['yaml']},
34
35
  {name:'explain_error',description:'Give deterministic next-step guidance for supplied URLCode validation output.',properties:{error:{type:'string',maxLength:8192}},required:['error']},
35
- {name:'get_context',description:'Emit the compact project context an authoring agent needs: versions, project summary, constraints, target support and exact commands, derived from the compiled project. Optional token budget drops sections in a fixed order.',properties:{target:text,budget:{type:'integer',minimum:1}}},
36
+ {name:'get_extension_artifacts',description:'Validate and list the project\'s locked declarative extension artifacts and their allowlisted files. Artifacts are inert data and do not activate extension code.',properties:{}},
37
+ {name:'get_extension_artifact',description:'Read one bounded JSON or Markdown file from a verified cached declarative extension artifact. The artifact name and member path must exist in the project lock/cache.',properties:{name:{type:'string',maxLength:64},path:{type:'string',maxLength:128}},required:['name','path']},
38
+ {name:'get_context',description:'Emit the compact project context an authoring agent needs: versions, project summary, constraints, target support and exact commands, derived from the compiled project. Pass `task: "redirects"` for a bounded, redirect-focused call instead (supported/gap shapes, exact YAML, this project\'s redirects). Optional token budget drops sections in a fixed order.',properties:{target:text,task:{enum:['redirects']},budget:{type:'integer',minimum:1}}},
36
39
  ];
37
40
  // Only the operator's own --host-file exposes registered extension contracts; no tool argument can name one.
38
41
  const hostDefinition={name:'get_extensions',description:'List operator-registered extension contracts, schemas, hooks, and supported project-owned customization surfaces with fast checks; use these before generating replacement framework code. Activates nothing.',properties:{}};
@@ -76,8 +79,12 @@ export async function serveMcp(options ) {
76
79
  case 'get_example':return getExample(args.name );
77
80
  case 'validate_yaml':return validateYaml(args.yaml );
78
81
  case 'explain_error':return explainError(args.error );
82
+ case 'get_extension_artifacts':return describeArtifactCache(project);
83
+ case 'get_extension_artifact':return readArtifactMember(project,args.name ,args.path );
79
84
  case 'get_extensions':return describeExtensions(project,host.extensions??[]);
80
- case 'get_context':return buildContext(project,{projectFlag:'.',...(typeof args.target==='string'?{target:args.target}:{}),...(typeof args.budget==='number'?{budget:args.budget}:{})});
85
+ case 'get_context':return typeof args.task==='string'
86
+ ?buildTaskContext(project,args.task,{...(typeof args.budget==='number'?{budget:args.budget}:{})})
87
+ :buildContext(project,{projectFlag:'.',...(typeof args.target==='string'?{target:args.target}:{}),...(typeof args.budget==='number'?{budget:args.budget}:{})});
81
88
  default:if(authoring)return callAuthoringTool(project,name,args,options.origin);throw new Error('Unknown tool');
82
89
  }
83
90
  };
@@ -91,7 +98,7 @@ export async function serveMcp(options ) {
91
98
  if(message.method==='initialize') {
92
99
  if(initialized){await error(id,-32600,'Already initialized');return;}
93
100
  if(typeof params.protocolVersion!=='string'||!object(params.capabilities)||!object(params.clientInfo)||typeof params.clientInfo.name!=='string'||typeof params.clientInfo.version!=='string'){await error(id,-32602,'Invalid initialize params');return;}
94
- initialized=true;await send({jsonrpc:'2.0',id,result:{protocolVersion,capabilities:{tools:{}},serverInfo:{name:'urlcode',version:'0.4.6'}}});return;
101
+ initialized=true;await send({jsonrpc:'2.0',id,result:{protocolVersion,capabilities:{tools:{}},serverInfo:{name:'urlcode',version:'0.4.8'}}});return;
95
102
  }
96
103
  if(message.method==='ping'){await send({jsonrpc:'2.0',id,result:{}});return;}
97
104
  if(!ready){await error(id,-32002,'Initialize first');return;}
package/dist/site.js CHANGED
@@ -1,4 +1,4 @@
1
- import { stat, readdir, lstat } from 'node:fs/promises';
1
+ import { stat, readdir, lstat, readFile } from 'node:fs/promises';
2
2
  import { join, extname } from 'node:path';
3
3
  import { assert, ConfigError } from './errors.js';
4
4
  import { safeFile } from './config.js';
@@ -233,3 +233,21 @@ export async function applySite(loaded , options = {
233
233
  for (const [path, route] of Object.entries(generated)) loaded.routes[path] = route;
234
234
  return generated;
235
235
  }
236
+
237
+ // The Worker has no filesystem and no asset binding, so the one not-found page
238
+ // is read here and carried inline as a respond route at /404.html. The page is
239
+ // bounded and static: no templating, no request data. It is text/html; the
240
+ // artifact is JSON, which does the escaping, and a body that is not valid
241
+ // UTF-8 is refused rather than silently altered.
242
+ export const notFoundInlineLimit = 65536;
243
+ export async function inlineNotFound(loaded ) {
244
+ const site = loaded.document.site, path = generatedPaths.notFound;
245
+ const route = loaded.routes[path];
246
+ if (!site?.notFound || route?.generated !== 'site.notFound') return false;
247
+ const file = await safeFile(loaded.root, route.page?.file);
248
+ assert((await stat(file)).size <= notFoundInlineLimit, `site.notFound exceeds ${notFoundInlineLimit} bytes; the Cloudflare Worker carries it inline, so keep the page under 64 KiB`);
249
+ let text ;
250
+ try { text = new TextDecoder('utf-8', { fatal: true }).decode(await readFile(file)); } catch { throw new ConfigError('site.notFound must be valid UTF-8 to be carried inline in the Worker'); }
251
+ loaded.routes[path] = { respond: { text }, response: { headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' } }, description: route.description ?? 'generated by site.notFound', generated: 'site.notFound' };
252
+ return true;
253
+ }
package/dist/tooling.js CHANGED
@@ -21,8 +21,8 @@ export {getCapability} from './capability-query.js';
21
21
  export {getSchemaFragment,schemaPathNames} from './schema-query.js';
22
22
 
23
23
  export {listRecipes,showRecipe,searchRecipes,listExamples,searchExamples};
24
- export {buildContext,renderContext,estimateTokens,documentationTokens} from './context.js';
25
-
24
+ export {buildContext,renderContext,estimateTokens,documentationTokens,buildTaskContext,renderTaskContext,contextTasks} from './context.js';
25
+
26
26
 
27
27
  /** `extensions` are operator registrations from a host file; explain reports whether each requirement has a provider. Nothing is activated. */
28
28
 
@@ -39,6 +39,7 @@ export interface Artifact {
39
39
  policies?: {
40
40
  security: SecurityConfig;
41
41
  };
42
+ notFound?: true;
42
43
  }
43
44
  export type Validator = (value: unknown) => boolean;
44
45
  export type Validators = Record<string, Validator | undefined>;
@@ -64,3 +64,47 @@ export declare function renderContext(context: ProjectContext): string;
64
64
  export declare function buildContext(project: string, options?: ContextOptions): Promise<ProjectContext>;
65
65
  /** Estimated size of the shipped offline documentation bundle, for comparison with an emitted context. */
66
66
  export declare function documentationTokens(): Promise<number>;
67
+ /** Tasks `--task` / MCP `get_context` accept. Each is fixed guidance plus the project's own facts for that task. */
68
+ export declare const contextTasks: readonly ["redirects"];
69
+ export type ContextTask = typeof contextTasks[number];
70
+ export interface TaskShape {
71
+ need: string;
72
+ support: 'supported' | 'gap';
73
+ /** Exact YAML to merge into urlcode.yaml (`routes` entries or `site`); absent for a gap. */
74
+ yaml?: Record<string, unknown>;
75
+ /** The rule that applies, or the exact validation error a gap produces. */
76
+ note?: string;
77
+ /** For a gap: the tested declarative alternative. */
78
+ workaround?: string;
79
+ }
80
+ /** Established by running `urlcode validate` and `urlcode test` on each shape; test/context.test.ts compiles every `yaml` entry so this cannot drift from the runtime. */
81
+ export declare const redirectShapes: TaskShape[];
82
+ export interface TaskContext {
83
+ urlcode: string;
84
+ schema: '1';
85
+ task: ContextTask;
86
+ shapes?: TaskShape[];
87
+ project?: {
88
+ entry: string;
89
+ routes: number;
90
+ redirects: {
91
+ path: string;
92
+ status: number;
93
+ url: string;
94
+ }[];
95
+ site: string[];
96
+ };
97
+ recipe?: string;
98
+ commands?: Record<string, string>;
99
+ omitted?: string[];
100
+ }
101
+ export declare function renderTaskContext(context: TaskContext): string;
102
+ /**
103
+ * One bounded call for a task: fixed guidance plus this project's facts for that task. Same compiler as buildContext;
104
+ * a directory without urlcode.yaml still gets the guidance, any other load failure propagates.
105
+ */
106
+ export declare function buildTaskContext(project: string, task: string, options?: {
107
+ budget?: number | undefined;
108
+ hostFile?: string | undefined;
109
+ projectFlag?: string | undefined;
110
+ }): Promise<TaskContext>;
@@ -0,0 +1,74 @@
1
+ /** Offline, declarative extension bundles. These are deliberately not Node packages. */
2
+ export declare const ARTIFACT_REPOSITORY = "jimhoyd-com/urlcode";
3
+ export declare const ARTIFACT_WORKFLOW = "jimhoyd-com/urlcode/.github/workflows/extension-artifacts.yml";
4
+ export interface ArtifactEntry {
5
+ name: string;
6
+ version: string;
7
+ asset: string;
8
+ sha256: string;
9
+ kind: 'declarative';
10
+ }
11
+ export interface Catalog {
12
+ format: 1;
13
+ tag: string;
14
+ commit: string;
15
+ artifacts: ArtifactEntry[];
16
+ revoked: {
17
+ sha256: string;
18
+ reason: string;
19
+ }[];
20
+ }
21
+ export interface LockedArtifact extends ArtifactEntry {
22
+ catalog: {
23
+ tag: string;
24
+ commit: string;
25
+ };
26
+ }
27
+ export interface ExtensionLock {
28
+ format: 1;
29
+ artifacts: LockedArtifact[];
30
+ }
31
+ /** Parse an untrusted catalog only after its GitHub attestation was verified by the caller. */
32
+ export declare function parseCatalog(bytes: Uint8Array, requestedTag: string): Catalog;
33
+ export declare function extractArtifact(bytes: Uint8Array, entry: ArtifactEntry, destination: string): Promise<void>;
34
+ export declare function readLock(project: string): Promise<ExtensionLock>;
35
+ export declare function writeLock(project: string, lock: ExtensionLock): Promise<void>;
36
+ export declare function cachePath(project: string, sha256: string): string;
37
+ export interface ReleaseAsset {
38
+ name: string;
39
+ url: string;
40
+ }
41
+ export interface ArtifactTransport {
42
+ release(tag: string): Promise<ReleaseAsset[]>;
43
+ download(url: string): Promise<Uint8Array>;
44
+ attest(path: string, release: string): Promise<void>;
45
+ }
46
+ /** The default transport accepts only GitHub Release asset URLs and verifies every downloaded subject. */
47
+ export declare const githubTransport: ArtifactTransport;
48
+ export declare function resolveCatalog(release: string, transport?: ArtifactTransport): Promise<{
49
+ catalog: Catalog;
50
+ assets: ReleaseAsset[];
51
+ }>;
52
+ export declare function installArtifact(project: string, release: string, artifactName: string, transport?: ArtifactTransport): Promise<ExtensionLock>;
53
+ export declare function inspectArtifacts(project: string): Promise<{
54
+ lock: ExtensionLock;
55
+ cached: string[];
56
+ missing: string[];
57
+ invalid: string[];
58
+ }>;
59
+ /** Read-only inventory for authoring tools. Paths come from the verified archive, never from an arbitrary filesystem argument. */
60
+ export declare function describeArtifactCache(project: string): Promise<{
61
+ format: 1;
62
+ artifacts: (LockedArtifact & {
63
+ status: 'cached' | 'missing' | 'invalid';
64
+ files: string[];
65
+ })[];
66
+ }>;
67
+ /** Return one bounded text/JSON member from a verified cached artifact for MCP/agent consumers. */
68
+ export declare function readArtifactMember(project: string, artifactName: string, path: string): Promise<{
69
+ format: 1;
70
+ artifact: LockedArtifact;
71
+ path: string;
72
+ mediaType: 'application/json' | 'text/markdown';
73
+ content: unknown;
74
+ }>;
@@ -18,8 +18,8 @@ export { buildTypeScriptProject } from './typescript-authoring.ts';
18
18
  export type { TypeScriptBuildReport } from './typescript-authoring.ts';
19
19
  export { importBulkProject } from './bulk.ts';
20
20
  export type { BulkFormat, BulkFilePlan, BulkImportReport } from './bulk.ts';
21
- export { inspectProject, validateProject, explainRoute, explainProject, previewImport, previewExport, getCapability, getSchemaFragment, schemaPathNames, inspectExtensions, describeExtensions, buildContext, renderContext, estimateTokens, documentationTokens } from './tooling.ts';
22
- export type { InspectOptions, RouteExplanation, RouteMiss, ExplainedHandler, ExplainedCache, ExplainedExtensionRequirement, ExtensionProvider, TargetSupport, CapabilityEntry, CapabilityUsage, SchemaFragment, ExtensionInspection, ContextOptions, ProjectContext, ContextSection } from './tooling.ts';
21
+ export { inspectProject, validateProject, explainRoute, explainProject, previewImport, previewExport, getCapability, getSchemaFragment, schemaPathNames, inspectExtensions, describeExtensions, buildContext, renderContext, estimateTokens, documentationTokens, buildTaskContext, renderTaskContext, contextTasks } from './tooling.ts';
22
+ export type { InspectOptions, RouteExplanation, RouteMiss, ExplainedHandler, ExplainedCache, ExplainedExtensionRequirement, ExtensionProvider, TargetSupport, CapabilityEntry, CapabilityUsage, SchemaFragment, ExtensionInspection, ContextOptions, ProjectContext, ContextSection, ContextTask, TaskContext, TaskShape } from './tooling.ts';
23
23
  export { buildManifest, renderManifest, MANIFEST_SCHEMA_VERSION } from './manifest.ts';
24
24
  export type { Manifest, ManifestRoute, ManifestModule, RecipeProvenance } from './manifest.ts';
25
25
  export { serveMcp } from './mcp.ts';
@@ -8,4 +8,6 @@ type SiteKey = keyof SiteConfig;
8
8
  export declare const generatedPaths: Readonly<Record<SiteKey, string>>;
9
9
  export declare function expandSite(document: ProjectDocument, root: string, { origin, log, routes }?: SiteOptions): Promise<Record<string, RouteConfig>>;
10
10
  export declare function applySite(loaded: LoadedDocument, options?: SiteOptions): Promise<Record<string, RouteConfig>>;
11
+ export declare const notFoundInlineLimit = 65536;
12
+ export declare function inlineNotFound(loaded: LoadedDocument): Promise<boolean>;
11
13
  export {};
@@ -10,8 +10,8 @@ export type { CapabilityEntry, CapabilityUsage } from './capability-query.ts';
10
10
  export { getSchemaFragment, schemaPathNames } from './schema-query.ts';
11
11
  export type { SchemaFragment } from './schema-query.ts';
12
12
  export { listRecipes, showRecipe, searchRecipes, listExamples, searchExamples };
13
- export { buildContext, renderContext, estimateTokens, documentationTokens } from './context.ts';
14
- export type { ContextOptions, ProjectContext, ContextSection } from './context.ts';
13
+ export { buildContext, renderContext, estimateTokens, documentationTokens, buildTaskContext, renderTaskContext, contextTasks } from './context.ts';
14
+ export type { ContextOptions, ProjectContext, ContextSection, ContextTask, TaskContext, TaskShape } from './context.ts';
15
15
  export type { RouteExplanation, ExplainedHandler, ExplainedCache, ExplainedExtensionRequirement, ExtensionProvider, TargetSupport } from './explain.ts';
16
16
  /** `extensions` are operator registrations from a host file; explain reports whether each requirement has a provider. Nothing is activated. */
17
17
  export interface InspectOptions {
package/llms-full.txt CHANGED
@@ -1,5 +1,5 @@
1
1
  <!-- Generated by scripts/build-llms-full.ts (npm run docs:llms). Do not edit; edit the source documents. -->
2
- <!-- Consolidated URLCode authoring reference: 16 documents, about 57,753 tokens (estimate: characters / 4). -->
2
+ <!-- Consolidated URLCode authoring reference: 16 documents, about 59,492 tokens (estimate: characters / 4). -->
3
3
 
4
4
  <!-- urlcode-current-version:start -->
5
5
  # URLCode
@@ -7,7 +7,7 @@
7
7
  > A portable runtime for programmable URL behavior, and the framework that grows
8
8
  > from it: routes in YAML, functions and middleware, then accounts, administration and
9
9
  > stored links as operator-installed extensions. Stable project format
10
- > `version: "1"`. Core is Apache-2.0; this revision is `0.4.6`. `function`/`middleware`
10
+ > `version: "1"`. Core is Apache-2.0; this revision is `0.4.8`. `function`/`middleware`
11
11
  > routes are trusted by default with `sandbox: true` as the per-route opt-in. The
12
12
  > auth, admin and ui extension packages in this repository are versioned at the same
13
13
  > revision; confirm what is published with `npm run release:status`.
@@ -199,6 +199,12 @@ and the MCP `get_extensions` tool report those surfaces and their fast checks,
199
199
  so people and agents can discover the supported path instead of replacing
200
200
  package behavior.
201
201
 
202
+ A signed declarative artifact is a separate, optional authoring input, not a
203
+ fifth way to compose executable behavior. A project may lock an attested
204
+ schema/example bundle and expose it through MCP `get_extension_artifacts` and
205
+ `get_extension_artifact`; the npm package and operator host remain the only
206
+ executable extension path. See [signed declarative artifacts](https://github.com/jimhoyd-com/urlcode/blob/main/docs/EXTENSIONS.md#signed-declarative-artifacts).
207
+
202
208
  ```sh
203
209
  urlcode serve --project /absolute/site --host-file /absolute/operator/host.mjs --origin https://site.example
204
210
  ```
@@ -301,7 +307,10 @@ Make the first retrieval one bounded query: the MCP tool `get_context` when the
301
307
  `urlcode capabilities NAME` (MCP `get_capability`) for one capability's limits,
302
308
  `get_schema` for one YAML fragment, `urlcode recipes search TEXT`
303
309
  (`search_recipes`), `explain` for a route's effective behavior and, when the
304
- operator supplies a host file, `get_extensions`. Context is a summary with the
310
+ operator supplies a host file, `get_extensions`. If the project has a committed
311
+ `urlcode.extensions.lock.json`, use `get_extension_artifacts` to verify and
312
+ inventory its inert data and `get_extension_artifact` to retrieve only the
313
+ needed schema, example or README. Context is a summary with the
305
314
  constraints and exact commands, not a schema dump, and it never hides a
306
315
  capability limit: ask `capabilities NAME` before promising a feature.
307
316
 
@@ -356,7 +365,15 @@ teaches how to retrieve the minimum reference through `urlcode capabilities`,
356
365
  the documentation whole. For a host-composed application, `get_extensions`
357
366
  adds each extension's schemas, hooks, supported authoring surfaces and fast
358
367
  checks. Agents should use those surfaces before generating replacement package
359
- behavior. Neither file replaces the schema; both defer to it.
368
+ behavior. A committed artifact lock is a separate offline authoring input:
369
+ `get_extension_artifacts` validates its cache and lists allowlisted files;
370
+ `get_extension_artifact` reads one bounded JSON or Markdown member from its
371
+ verified archive. The CLI fallback is `urlcode extension-artifacts inspect
372
+ --project DIR --json`. An artifact never installs or activates an npm package,
373
+ registers a host extension or grants authority. Agents must not fetch or update
374
+ one unless the user explicitly requests that project change and names the
375
+ immutable `extensions@v…` release. Neither guide nor artifact replaces the
376
+ runtime schema; all defer to the pinned implementation.
360
377
 
361
378
  Treat core, installed extensions and product UI as one application with
362
379
  different owners. Keep auth/admin security and workflow behavior package-owned;
@@ -482,8 +499,21 @@ Before writing a function, check whether a declarative feature already covers th
482
499
  need. Security headers are the usual miss: a project that declares nothing sends
483
500
  only the runtime's defaults (`nosniff`, `no-store`, a request ID).
484
501
 
502
+ Building only redirects? `urlcode context --project DIR --task redirects` (MCP
503
+ `get_context {"task":"redirects"}`) is a bounded, redirect-only call: every row
504
+ below with the exact YAML, the two gaps with their exact validation error, and
505
+ this project's own redirects — cheaper than this table or the recipe catalog.
506
+
485
507
  | I need | Declare | Reference |
486
508
  |---|---|---|
509
+ | Fixed redirect (301/302/303/307/308, 302 default) | `redirect: {url, status}` | [redirects](https://github.com/jimhoyd-com/urlcode/blob/main/docs/yaml/redirects.md) |
510
+ | Parameterized path redirect (`/users/{id}` to `/profiles/{id}`) | `{name}` placeholder in `redirect.url` naming a declared path parameter | [redirects](https://github.com/jimhoyd-com/urlcode/blob/main/docs/yaml/redirects.md) |
511
+ | Fixed-depth suffix redirect (`/legacy/a/b` to `/modern/a/b`) | one route per depth, one placeholder per segment | [redirects](https://github.com/jimhoyd-com/urlcode/blob/main/docs/yaml/redirects.md) |
512
+ | Redirect that preserves query keys | `redirect.query.pass` (explicit allowlist) or `query.map` | [redirects](https://github.com/jimhoyd-com/urlcode/blob/main/docs/yaml/redirects.md) |
513
+ | Redirect that keeps the method/body (POST) | `methods` plus `status: 307` or `308` | [redirects](https://github.com/jimhoyd-com/urlcode/blob/main/docs/yaml/redirects.md) |
514
+ | 404 for unmatched paths | `site.notFound` (a project-relative `.html` file) | [site](https://github.com/jimhoyd-com/urlcode/blob/main/docs/SITE.md) |
515
+ | Wildcard/suffix redirect (`/legacy/*` to `/modern/*`, any depth) — **gap** | not expressible; terminal `/*` and `{rest...}` are refused on `redirect` | [open decision](https://github.com/jimhoyd-com/urlcode/blob/main/docs/OPEN-DECISIONS.md) |
516
+ | Host-based, scheme-based or relative-URL redirect — **gap** | not expressible; destination must be a literal absolute `https://host/path` | [open decision](https://github.com/jimhoyd-com/urlcode/blob/main/docs/OPEN-DECISIONS.md) |
487
517
  | Security headers (CSP, HSTS, frame and referrer policy) | `policies.security: {headers: oshp}` or `policies.profile: hardened` | [security](https://github.com/jimhoyd-com/urlcode/blob/main/docs/policies/security.md) |
488
518
  | Cache headers on a page, download or static mount | `cacheControl`: `no-cache` (default), `no-store`, `public, max-age=3600` or `public, max-age=31536000, immutable`; nothing else validates | [assets](https://github.com/jimhoyd-com/urlcode/blob/main/docs/yaml/assets.md) |
489
519
  | A cache strategy on any route | `policies.cache` | [cache](https://github.com/jimhoyd-com/urlcode/blob/main/docs/policies/cache.md) |
@@ -727,7 +757,7 @@ programmatic compatibility analysis and provider verification limits.
727
757
  Source: https://github.com/jimhoyd-com/urlcode/blob/main/docs/YAML-GUIDE.md
728
758
 
729
759
  <!-- urlcode-current-version:start -->
730
- This guide targets URLCode 0.4.6. Start with the function example below,
760
+ This guide targets URLCode 0.4.8. Start with the function example below,
731
761
  then add only the fields your route needs. The authoritative machine-readable
732
762
  shape is [JSON Schema](https://github.com/jimhoyd-com/urlcode/blob/main/schemas/urlcode.schema.json); semantic rules are in the
733
763
  [specification](https://github.com/jimhoyd-com/urlcode/blob/main/docs/SPECIFICATION.md). Unsupported fields fail validation.
@@ -2551,15 +2581,19 @@ as the not-found page instead. The page answers every unknown URL, so it cannot
2551
2581
 
2552
2582
  `urlcode build --target static` writes it as the object `404.html` (see
2553
2583
  [static hosting](https://github.com/jimhoyd-com/urlcode/blob/main/docs/STATIC.md)); point the host's error document at that key.
2554
- Cloudflare refuses it like any `page` route (no asset binding); the Worker
2555
- has no per-request fallback page, so use the platform's own 404 asset there.
2584
+ Cloudflare inlines it: the page is small, singular and static, so the build
2585
+ reads it (64 KiB cap, must decode as UTF-8) and carries it in the artifact as a
2586
+ `respond` route at `/404.html`, answering the same status, headers and method
2587
+ rules as every other target. See [`docs/CLOUDFLARE.md`](https://github.com/jimhoyd-com/urlcode/blob/main/docs/CLOUDFLARE.md#site-notfound-is-inlined).
2588
+ `favicon` and `llms` stay refused there like any other `page` route (no asset
2589
+ binding).
2556
2590
 
2557
2591
  ### Per-target support
2558
2592
 
2559
- | Target | `robots`, `sitemap`, `securityTxt` (`respond`) | `favicon`, `llms`, `notFound` (`page`) |
2560
- | --- | --- | --- |
2561
- | self-hosted, Vercel, AWS | served | served |
2562
- | Cloudflare | compiled into the artifact (`build --origin` for absolute URLs) | refused at build time like any `page` route: the target has no asset binding; serve them from the platform's static assets |
2593
+ | Target | `robots`, `sitemap`, `securityTxt` (`respond`) | `favicon`, `llms` (`page`) | `notFound` |
2594
+ | --- | --- | --- | --- |
2595
+ | self-hosted, Vercel, AWS | served | served | served |
2596
+ | Cloudflare | compiled into the artifact (`build --origin` for absolute URLs) | refused at build time like any `page` route: the target has no asset binding; serve them from the platform's static assets | inlined into the artifact (64 KiB cap, UTF-8) |
2563
2597
 
2564
2598
  ### Not in this release
2565
2599
 
@@ -3218,6 +3252,64 @@ Serving the result is the usual explicit host binding:
3218
3252
  urlcode validate --project app --host-file "$PWD/host.mjs" --origin https://site.example
3219
3253
  ```
3220
3254
 
3255
+ ### Signed declarative artifacts
3256
+
3257
+ Core remains the npm-distributed runtime. The workspace extensions are not an
3258
+ alternate npm channel. A release can additionally carry a small, **data-only**
3259
+ extension artifact for tooling that understands its declared format. It is not
3260
+ a Node module and cannot activate an extension, run a hook, replace a trusted
3261
+ operator host, or grant a route any authority.
3262
+
3263
+ Install an artifact only from its immutable `extensions@v…` GitHub Release.
3264
+ For example, after the first release is published, its inert store configuration
3265
+ schema snapshot can be installed with:
3266
+
3267
+ ```sh
3268
+ urlcode extension-artifacts install store-schema --artifact-release extensions@v1.0.0 --project app
3269
+ urlcode extension-artifacts update store-schema --artifact-release extensions@v1.1.0 --project app
3270
+ urlcode extension-artifacts inspect --project app
3271
+ ```
3272
+
3273
+ The command downloads the signed `extensions-catalog.json`, verifies its
3274
+ GitHub attestation against the dedicated artifact workflow in
3275
+ `jimhoyd-com/urlcode` and the exact requested tag ref, then verifies the selected
3276
+ `.tgz` the same way. Self-hosted-runner attestations are refused. The catalog
3277
+ pins its release tag, source commit, filename and SHA-256; a catalog revocation
3278
+ refuses installation. `gh` with support for attestation source-ref verification
3279
+ is therefore a required local dependency for this command.
3280
+
3281
+ The resulting `urlcode.extensions.lock.json` is the reproducibility boundary:
3282
+ commit it with the project. Every locked artifact records its own catalog tag
3283
+ and source commit, so updating one artifact cannot silently relabel another as
3284
+ coming from a newer release. Extracted files live under
3285
+ `app/.urlcode/extensions/<sha256>/` and are checked before extraction. Archives
3286
+ are size- and file-count-bounded, reject links and traversal, and may contain
3287
+ only `extension.json`, JSON configuration/schema data, and an optional README.
3288
+ Any JavaScript, package manifest, install hook, native module, or unknown file
3289
+ causes refusal. `inspect` re-hashes the cached archive and every extracted file,
3290
+ reporting an entry as missing or invalid rather than trusting its directory
3291
+ name. Updates are never automatic: review a newer immutable release and run
3292
+ `update` explicitly.
3293
+
3294
+ Agent tooling can consume a committed lock without gaining write or execution
3295
+ authority. MCP `get_extension_artifacts` validates the lock and cache and lists
3296
+ the signed member paths; `get_extension_artifact {name, path}` returns one
3297
+ verified, bounded JSON or Markdown member directly from the cached archive.
3298
+ The CLI fallback is `urlcode extension-artifacts inspect --project app --json`.
3299
+ Neither MCP tool performs a network request, installs or updates an artifact,
3300
+ loads a host file, or activates code. A missing or modified cache is reported
3301
+ as missing/invalid and its contents are not returned.
3302
+
3303
+ This does not relax the existing host boundary. `--host-file` is still the only
3304
+ way to load trusted operator extension code, and artifact files are never
3305
+ imported by `serve`, `validate`, `init --with`, or the runtime.
3306
+
3307
+ Artifact versions are independent from npm package versions. The initial
3308
+ `store-schema` artifact is a reviewed configuration-schema snapshot and example,
3309
+ not the `@jimhoyd/urlcode-store` implementation. Installing it does not install
3310
+ or activate that package. Its README names the separate executable and operator
3311
+ requirements.
3312
+
3221
3313
  ---
3222
3314
 
3223
3315
  # Composing a site from ui, auth and admin
package/llms.txt CHANGED
@@ -4,7 +4,7 @@
4
4
  > A portable runtime for programmable URL behavior, and the framework that grows
5
5
  > from it: routes in YAML, functions and middleware, then accounts, administration and
6
6
  > stored links as operator-installed extensions. Stable project format
7
- > `version: "1"`. Core is Apache-2.0; this revision is `0.4.6`. `function`/`middleware`
7
+ > `version: "1"`. Core is Apache-2.0; this revision is `0.4.8`. `function`/`middleware`
8
8
  > routes are trusted by default with `sandbox: true` as the per-route opt-in. The
9
9
  > auth, admin and ui extension packages in this repository are versioned at the same
10
10
  > revision; confirm what is published with `npm run release:status`.
@@ -18,6 +18,25 @@ grants. Unsupported features fail with the route named; nothing degrades silentl
18
18
  Agents that explicitly want the complete consolidated reference in one fetch should read
19
19
  [llms-full.txt](llms-full.txt), generated from the documents below (about 50k tokens, estimated).
20
20
 
21
+ ## Building only redirects? Start here
22
+
23
+ Run `urlcode context --project DIR --task redirects` (MCP `get_context {"task":"redirects"}`)
24
+ first: one bounded call returning every supported shape below with exact YAML, the
25
+ gaps with their exact validation error, and this project's own redirects — cheaper
26
+ than reading this file or the recipe catalog. `urlcode recipes search redirect` and
27
+ `urlcode recipes show redirect` give a runnable starting project.
28
+
29
+ | Shape | Supported? | Use |
30
+ |---|---|---|
31
+ | Fixed redirect, any of 301/302/303/307/308 (302 default) | Yes | `redirect: {url, status}` — [redirects](docs/yaml/redirects.md) |
32
+ | Parameterized path (`/users/{id}` to `/profiles/{id}`) | Yes | `{name}` placeholder in `redirect.url`, naming a declared path parameter |
33
+ | Fixed-depth suffix (`/legacy/a/b` to `/modern/a/b`) | Yes, one route per depth | same as above with one placeholder per segment |
34
+ | Query-string preservation | Yes, opt-in only | `redirect.query.pass` (explicit allowlist) / `query.map` |
35
+ | Method-preserving redirect (keep POST body) | Yes | `methods` plus `status: 307` or `308` |
36
+ | 404 for unmatched paths | Yes | `site.notFound` (a project-relative `.html` file) |
37
+ | Wildcard/suffix redirect (`/legacy/*` to `/modern/*`, any depth) | No | terminal `/*` and `{rest...}` are refused on `redirect`; report the gap ([open decision](docs/OPEN-DECISIONS.md)) |
38
+ | Host-based, scheme-based or relative-URL redirect | No | destination must be a literal absolute `https://host/path`; report the gap |
39
+
21
40
  ## Declarative-first default
22
41
 
23
42
  > Use URLCode's highest-level declarative features whenever possible. Generate custom code only when the framework cannot express the requirement.
@@ -105,6 +124,7 @@ publish or comment on an issue without the user's explicit approval.
105
124
 
106
125
  ## Extensions (accounts, administration, presentation)
107
126
  - [Extensions](docs/EXTENSIONS.md): `extensions.<name>` blocks, mounts, policies, operator registration, the shared trusted hook primitive and its machine-readable hook contracts. UI/auth/admin project hooks run trusted in-process; contract v1 rejects `sandbox: true`.
127
+ - [Signed declarative artifacts](docs/EXTENSIONS.md#signed-declarative-artifacts): project-pinned, attested JSON/Markdown data for offline tooling. MCP `get_extension_artifacts` verifies/inventories a committed lock and `get_extension_artifact` reads one bounded member; neither installs or activates an npm extension.
108
128
  - [Composing a site](docs/COMPOSING-A-SITE.md): what `urlcode init site --with ui,auth,admin` wires, which `--with` combinations are supported, the presentation override paths under `ui/`, and per-package lifecycle hook input, verdict, timing and failure semantics. Separates declarative configuration, project functions and the TypeScript a new extension needs.
109
129
  - [urlcode-auth](packages/auth): npm: @jimhoyd/urlcode-auth; accounts, sessions, MFA, roles, account page; its own llms.txt. Lives in this repository as a workspace package.
110
130
  - [urlcode-admin](packages/admin): npm: @jimhoyd/urlcode-admin; users, sessions, roles, audit, cases; its own llms.txt. Lives in this repository as a workspace package.
@@ -120,7 +140,7 @@ publish or comment on an issue without the user's explicit approval.
120
140
 
121
141
  ## Operations
122
142
  - [Operations](docs/OPERATIONS.md), [install](docs/INSTALL.md), [deployment checks](docs/DEPLOYMENT-CHECKS.md), [CI action](docs/CI.md).
123
- - [Readiness](docs/READINESS.md), [capacity](docs/CAPACITY.md), [performance](docs/PERFORMANCE.md), [resilience](docs/RESILIENCE.md), [observability](docs/OBSERVABILITY.md).
143
+ - [Readiness](docs/READINESS.md), [capacity](docs/CAPACITY.md), [resilience](docs/RESILIENCE.md), [observability](docs/OBSERVABILITY.md).
124
144
  - [Vercel](docs/VERCEL.md), [AWS](docs/AWS.md), [Cloudflare](docs/CLOUDFLARE.md): adapters with local conformance tests; no provider deployment verified yet.
125
145
  - [Static hosting](docs/STATIC.md): `urlcode build --target static` compiles redirects/pages/static/downloads to S3 + CloudFront objects and redirect metadata; no server, so function/middleware/extension/proxy/signals/conditions/parameters/bindings/policies are all refused; GitHub Pages is explicitly out of scope.
126
146
  - [Release readiness](docs/RELEASE-READINESS.md), [roadmap](ROADMAP.md): what is proven, what is planned.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jimhoyd/urlcode",
3
- "version": "0.4.6",
3
+ "version": "0.4.8",
4
4
  "description": "Portable runtime for programmable URL behavior",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -89,12 +89,12 @@
89
89
  "test": "node --conditions=development --test test/*.test.ts",
90
90
  "lint": "eslint .",
91
91
  "check": "npm run check:code && npm run check:docs",
92
- "check:code": "node scripts/check.ts && node scripts/check-release-tags.ts && node scripts/check-workspace-links.ts && npm run release:check",
92
+ "check:code": "node scripts/check.ts && node scripts/check-release-tags.ts && node scripts/check-workspace-links.ts && node scripts/check-issue-labels.ts && npm run release:check",
93
+ "test:workerd": "node scripts/workerd-parity.ts",
93
94
  "verify": "npm run lint && npm run typecheck && npm run check && npm run build && npm test && npm run verify:workspaces",
94
95
  "workspace:styles": "npm run styles --workspace @jimhoyd/urlcode-ui",
95
96
  "verify:workspaces": "npm run verify --workspace @jimhoyd/urlcode-ui && npm run verify --workspace @jimhoyd/urlcode-auth && npm run verify --workspace @jimhoyd/urlcode-admin && npm run verify --workspace @jimhoyd/urlcode-store && npm run audit:packages && npm run test:workspace-integration",
96
97
  "audit:packages": "node scripts/package-audit.ts --all",
97
- "benchmark": "node benchmarks/routing.ts",
98
98
  "test:package": "npm run build && npm run test:package:built",
99
99
  "dev": "node src/cli.ts dev --project starters/default",
100
100
  "serve": "node src/cli.ts serve --project starters/default",
@@ -109,10 +109,8 @@
109
109
  "docs:plugin": "node scripts/generate-claude-plugin.ts",
110
110
  "docs:llms": "node scripts/build-llms-full.ts",
111
111
  "docs:cookbook-index": "node scripts/build-cookbook-index.ts",
112
+ "artifacts:prepare": "node --disable-warning=ExperimentalWarning scripts/prepare-extension-artifacts.ts",
112
113
  "check:downstream-skills": "node scripts/check-downstream-skill-drift.ts",
113
- "benchmark:bulk": "node benchmarks/bulk.ts",
114
- "benchmark:sandbox-vs-trusted": "node benchmarks/sandbox-vs-trusted.ts",
115
- "benchmark:agent": "node benchmarks/agent/run.ts",
116
114
  "sync:agents": "node scripts/sync-agent-lists.ts",
117
115
  "check:docs": "node scripts/check-trust-model-prose.ts && node scripts/check-version-statements.ts && node scripts/check-local-links.ts && node scripts/check-guidance-claims.ts && node scripts/generate-yaml-reference.ts --check && node scripts/build-llms-full.ts --check && node scripts/build-cookbook-index.ts --check && node scripts/generate-claude-plugin.ts --check",
118
116
  "ci:plan": "node scripts/ci-plan.ts",
@@ -123,6 +121,7 @@
123
121
  "release:run": "node scripts/release-run.ts",
124
122
  "release:peers": "node scripts/release.ts peers",
125
123
  "release:check": "node scripts/release.ts check && node scripts/release-prepare.ts --check",
124
+ "rehearse:release": "node --conditions=development --test test/release-rehearsal.test.ts",
126
125
  "ci:history": "node scripts/ci-history.ts",
127
126
  "ci:report": "node scripts/ci-report.ts",
128
127
  "test:workspace-integration": "node --test test/workspace-scaffold.integration.ts",
@@ -1,7 +1,21 @@
1
- # Permanent documentation redirect
1
+ # Redirects: fixed, parameterized, query-preserving and 404
2
2
 
3
3
  Run `urlcode validate --local --project .` and `urlcode serve --project .`.
4
- `/docs?campaign=launch&private=discarded` redirects to
5
- `https://example.com/documentation?campaign=launch` with status 301.
6
- Replace the example destination before deploying. Incoming query parameters are
7
- not forwarded unless explicitly allowlisted.
4
+
5
+ - `/docs?campaign=launch&private=discarded` redirects (301) to
6
+ `https://example.com/documentation?campaign=launch`. Only the declared
7
+ `campaign` key is forwarded; everything else, including `private`, is
8
+ dropped. Replace the example destination before deploying.
9
+ - `/users/{id}` redirects (308, method- and body-preserving) to
10
+ `https://example.com/profiles/{id}`, substituting the declared path
11
+ parameter into the destination.
12
+ - Any other path, such as `/missing` or `/users/42/extra`, answers the
13
+ runtime's default 404. `DELETE /docs` answers 405 (only GET/HEAD match by
14
+ default). Add top-level `site: {notFound: 404.html}` for a custom 404 page
15
+ instead of the plain default.
16
+
17
+ For the shapes this recipe does not cover — a wildcard/suffix redirect that
18
+ matches any depth (`/legacy/*`), or a host/scheme-based redirect — run
19
+ `urlcode context --project . --task redirects` (MCP `get_context
20
+ {"task":"redirects"}`) for the exact supported alternative and the exact
21
+ validation error, or see [docs/OPEN-DECISIONS.md](../../docs/OPEN-DECISIONS.md).
@@ -1,25 +1,27 @@
1
1
  id: redirect
2
- description: Permanent redirect that forwards only an allowlisted query key.
3
- tags: [redirect, permanent, "301", query, passthrough, documentation, native]
2
+ description: Fixed, parameterized and query-preserving redirects, and the default 404 for everything else.
3
+ tags: [redirect, permanent, "301", "308", parameterized, query, passthrough, "404", documentation, native]
4
4
  complexity: starter
5
- capabilities: [enabled, methods, redirect]
6
- targets: {self-hosted: compatible, aws: compatible, vercel: compatible, cloudflare: compatible, static: compatible}
7
- routes: 1
5
+ capabilities: [enabled, methods, parameters, redirect]
6
+ targets: {self-hosted: compatible, aws: compatible, vercel: compatible, cloudflare: compatible, static: refused}
7
+ routes: 2
8
8
  inputs:
9
9
  - name: destination
10
10
  file: urlcode.yaml
11
- description: Replace https://example.com/documentation with the real target.
11
+ description: Replace https://example.com/documentation and https://example.com/profiles with the real targets.
12
12
  - name: query.pass
13
13
  file: urlcode.yaml
14
- description: The query keys forwarded to the destination; everything else is dropped.
14
+ description: The query keys forwarded to the /docs destination; everything else is dropped.
15
15
  files: [urlcode.yaml, tests/requests.json, README.md]
16
16
  tests:
17
17
  fixtures: tests/requests.json
18
18
  commands:
19
19
  - urlcode validate --local --project .
20
20
  - urlcode test --project .
21
- - urlcode audit --project . --expect-routes 1
21
+ - urlcode audit --project . --expect-routes 2
22
22
  behavior:
23
23
  - GET /docs?campaign=launch&private=x answers 301 to https://example.com/documentation?campaign=launch
24
- - HEAD answers the same status with an empty body
25
- - unknown paths answer 404
24
+ - HEAD /docs answers the same status with an empty body
25
+ - DELETE /docs answers 405
26
+ - GET /users/42 answers 308 (method- and body-preserving) to https://example.com/profiles/42
27
+ - unknown paths, including /users/42/extra and /missing, answer the runtime's default 404