@jimhoyd/urlcode 0.4.7 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/.claude/skills/urlcode-authoring/SKILL.md +8 -0
  2. package/README.md +9 -3
  3. package/dist/BUILD-MANIFEST.json +23 -21
  4. package/dist/agents-guide.js +9 -5
  5. package/dist/authoring.js +36 -3
  6. package/dist/build-cloudflare.js +5 -2
  7. package/dist/build-static.js +1 -0
  8. package/dist/cli.js +47 -15
  9. package/dist/cloudflare.js +6 -3
  10. package/dist/config.js +7 -1
  11. package/dist/context.js +99 -1
  12. package/dist/extension-artifacts.js +147 -0
  13. package/dist/extension-bundles.js +70 -0
  14. package/dist/extensions.js +4 -0
  15. package/dist/functions.js +2 -1
  16. package/dist/index.js +4 -2
  17. package/dist/init-with.js +55 -23
  18. package/dist/interchange.js +1 -1
  19. package/dist/match.js +23 -5
  20. package/dist/mcp.js +11 -4
  21. package/dist/readiness.js +2 -2
  22. package/dist/router.js +20 -8
  23. package/dist/runtime.js +1 -0
  24. package/dist/site.js +19 -1
  25. package/dist/tooling.js +2 -2
  26. package/dist/types/agents-guide.d.ts +6 -1
  27. package/dist/types/authoring.d.ts +1 -1
  28. package/dist/types/cloudflare.d.ts +1 -0
  29. package/dist/types/context.d.ts +56 -0
  30. package/dist/types/extension-artifacts.d.ts +87 -0
  31. package/dist/types/extension-bundles.d.ts +49 -0
  32. package/dist/types/extensions.d.ts +4 -0
  33. package/dist/types/functions.d.ts +4 -0
  34. package/dist/types/index.d.ts +4 -2
  35. package/dist/types/init-with.d.ts +6 -1
  36. package/dist/types/match.d.ts +1 -0
  37. package/dist/types/site.d.ts +2 -0
  38. package/dist/types/tooling.d.ts +2 -2
  39. package/dist/types/types.d.ts +1 -0
  40. package/dist/types.js +1 -1
  41. package/llms-full.txt +188 -24
  42. package/llms.txt +63 -107
  43. package/package.json +11 -3
  44. package/recipes/redirect/README.md +19 -5
  45. package/recipes/redirect/recipe.yaml +12 -10
  46. package/recipes/redirect/tests/requests.json +22 -0
  47. package/recipes/redirect/urlcode.yaml +11 -1
  48. package/skills/urlcode/SKILL.md +2 -0
  49. package/starters/default/AGENTS.md +3 -3
@@ -0,0 +1,87 @@
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 interface TarFile {
34
+ path: string;
35
+ bytes: Uint8Array;
36
+ }
37
+ export interface ArchiveLimits {
38
+ archive: number;
39
+ expanded: number;
40
+ files: number;
41
+ file: number;
42
+ label: string;
43
+ }
44
+ /** A minimal tar reader: only regular files are accepted, before any write occurs. */
45
+ export declare function readBoundedTgz(source: Uint8Array, limits: ArchiveLimits): TarFile[];
46
+ export declare function extractArtifact(bytes: Uint8Array, entry: ArtifactEntry, destination: string): Promise<void>;
47
+ export declare function readLock(project: string): Promise<ExtensionLock>;
48
+ export declare function writeLock(project: string, lock: ExtensionLock): Promise<void>;
49
+ export declare function cachePath(project: string, sha256: string): string;
50
+ export interface ReleaseAsset {
51
+ name: string;
52
+ url: string;
53
+ }
54
+ export interface ArtifactTransport {
55
+ release(tag: string): Promise<ReleaseAsset[]>;
56
+ download(url: string): Promise<Uint8Array>;
57
+ attest(path: string, release: string): Promise<void>;
58
+ }
59
+ /** The default transport accepts only GitHub Release asset URLs and verifies every downloaded subject. */
60
+ export declare const githubTransport: ArtifactTransport;
61
+ export declare function resolveCatalog(release: string, transport?: ArtifactTransport): Promise<{
62
+ catalog: Catalog;
63
+ assets: ReleaseAsset[];
64
+ }>;
65
+ export declare function installArtifact(project: string, release: string, artifactName: string, transport?: ArtifactTransport): Promise<ExtensionLock>;
66
+ export declare function inspectArtifacts(project: string): Promise<{
67
+ lock: ExtensionLock;
68
+ cached: string[];
69
+ missing: string[];
70
+ invalid: string[];
71
+ }>;
72
+ /** Read-only inventory for authoring tools. Paths come from the verified archive, never from an arbitrary filesystem argument. */
73
+ export declare function describeArtifactCache(project: string): Promise<{
74
+ format: 1;
75
+ artifacts: (LockedArtifact & {
76
+ status: 'cached' | 'missing' | 'invalid';
77
+ files: string[];
78
+ })[];
79
+ }>;
80
+ /** Return one bounded text/JSON member from a verified cached artifact for MCP/agent consumers. */
81
+ export declare function readArtifactMember(project: string, artifactName: string, path: string): Promise<{
82
+ format: 1;
83
+ artifact: LockedArtifact;
84
+ path: string;
85
+ mediaType: 'application/json' | 'text/markdown';
86
+ content: unknown;
87
+ }>;
@@ -0,0 +1,49 @@
1
+ /** Verified, executable first-party bundles. Unlike extension artifacts, these are trusted operator code. */
2
+ export declare const BUNDLE_REPOSITORY = "jimhoyd-com/urlcode";
3
+ export declare const BUNDLE_WORKFLOW = "jimhoyd-com/urlcode/.github/workflows/extension-bundles.yml";
4
+ export interface BundleEntry {
5
+ name: string;
6
+ version: string;
7
+ asset: string;
8
+ sha256: string;
9
+ entry: string;
10
+ }
11
+ export interface BundleCatalog {
12
+ format: 1;
13
+ tag: string;
14
+ commit: string;
15
+ coreVersion: string;
16
+ bundles: BundleEntry[];
17
+ revoked: {
18
+ sha256: string;
19
+ reason: string;
20
+ }[];
21
+ }
22
+ export interface LockedBundle extends BundleEntry {
23
+ catalog: {
24
+ tag: string;
25
+ commit: string;
26
+ };
27
+ coreVersion: string;
28
+ }
29
+ export interface BundleLock {
30
+ format: 1;
31
+ bundles: LockedBundle[];
32
+ }
33
+ export interface BundleTransport {
34
+ release(tag: string): Promise<{
35
+ name: string;
36
+ url: string;
37
+ }[]>;
38
+ download(url: string): Promise<Uint8Array>;
39
+ attest(path: string, release: string): Promise<void>;
40
+ }
41
+ /** Parse only a catalog whose attestation was already verified against the requested immutable tag. */
42
+ export declare function parseBundleCatalog(bytes: Uint8Array, requested: string): BundleCatalog;
43
+ export declare function extractBundle(bytes: Uint8Array, item: Pick<LockedBundle, 'name' | 'version' | 'entry' | 'sha256' | 'coreVersion'>, destination: string): Promise<void>;
44
+ export declare function bundleCachePath(project: string, sha256: string): string;
45
+ export declare function readBundleLock(project: string): Promise<BundleLock>;
46
+ export declare const githubBundleTransport: BundleTransport;
47
+ export declare function installBundle(project: string, release: string, bundleName: string, transport?: BundleTransport): Promise<BundleLock>;
48
+ /** Explicit host-only loader. It never reads project YAML, downloads, updates, or discovers code. */
49
+ export declare function loadExtensionBundle(project: string, bundleName: string): Promise<Record<string, unknown>>;
@@ -185,6 +185,8 @@ export interface ScaffoldRequest {
185
185
  hostFile: string;
186
186
  /** Every extension name being scaffolded together, including this one, in a canonical (sorted) order that is independent of the `--with` spelling. */
187
187
  names: readonly string[];
188
+ /** `npm` resolves extension packages from the operator's install; `bundle` resolves only already-verified, locked release bundles. */
189
+ distribution?: 'npm' | 'bundle';
188
190
  /**
189
191
  * Operator acknowledgements from repeated `--ack <extension>:<id>` flags, sorted and de-duplicated; empty when none. Core treats
190
192
  * them as opaque strings and never invents one. An extension reads only the ones qualified with its own name. To require one, throw
@@ -224,6 +226,8 @@ export interface ScaffoldResult {
224
226
  hostSetup: string[];
225
227
  hostEntries: string[];
226
228
  hostClose?: string[];
229
+ /** Named exports core may bind from this extension's already-verified executable bundle. Required for bundle distribution; never a project-controlled module reference. */
230
+ hostBundleExports?: string[];
227
231
  /** Files written relative to `directory` with their modes; never inside the project, never overwriting. */
228
232
  files: ScaffoldFile[];
229
233
  /** Markdown appended to README.md under a heading core adds; the numbered steps merged in the resolved order. */
@@ -36,8 +36,12 @@ export interface FunctionWorkerData {
36
36
  dependencies: Record<string, string[]>;
37
37
  entries: [string, string][];
38
38
  }
39
+ /** `route.pattern` is the route key that matched, so one module can serve several routes without reading `request.url`. */
39
40
  export type FunctionContext = RequestContext & {
40
41
  args?: Record<string, ParameterValue>;
42
+ route?: {
43
+ pattern: string;
44
+ };
41
45
  };
42
46
  export interface FunctionWorkerRequest {
43
47
  id: string;
@@ -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';
@@ -40,3 +40,5 @@ export { initProject, addRedirect } from './authoring.ts';
40
40
  export { initProjectWith } from './init-with.ts';
41
41
  export { collectDependencySet, renderPackageManifest, installSteps } from './project-dependencies.ts';
42
42
  export type { ScaffoldRequest, ScaffoldResult, ScaffoldFile } from './extensions.ts';
43
+ export { installBundle, loadExtensionBundle, readBundleLock, parseBundleCatalog } from './extension-bundles.ts';
44
+ export type { BundleCatalog, BundleEntry, BundleLock, LockedBundle, BundleTransport } from './extension-bundles.ts';
@@ -1,5 +1,6 @@
1
1
  import type { ScaffoldResult } from './extensions.ts';
2
2
  import type { DependencyPin } from './project-dependencies.ts';
3
+ import { type BundleTransport } from './extension-bundles.ts';
3
4
  export interface InitWithOptions {
4
5
  cwd?: string | undefined;
5
6
  /** Default true: record exact pins for core, the named extensions and their declared peers. */
@@ -8,6 +9,10 @@ export interface InitWithOptions {
8
9
  pins?: ReadonlyMap<string, string> | undefined;
9
10
  /** `--ack <extension>:<id>`, repeatable: opaque qualified acknowledgements handed to every scaffold. Core refuses one that no scaffold consumed. */
10
11
  acknowledgements?: readonly string[] | undefined;
12
+ /** Immutable signed release used instead of resolving executable extension packages from npm. */
13
+ bundleRelease?: string | undefined;
14
+ /** Test-only transport injection; production uses GitHub attestation verification. */
15
+ bundleTransport?: BundleTransport | undefined;
11
16
  }
12
17
  export interface InitWithResult {
13
18
  directory: string;
@@ -30,4 +35,4 @@ export declare function orderScaffolds(results: readonly ScaffoldResult[]): Scaf
30
35
  * `urlcode.yaml`, one `host.mjs`, one `README.md` and the extensions' own files. All packages are resolved and
31
36
  * their scaffolds computed before anything is written, so a refusal leaves no directory behind.
32
37
  */
33
- export declare function initProjectWith(destination: string, requested: readonly string[], { cwd, manifest, pins, acknowledgements }?: InitWithOptions): Promise<InitWithResult>;
38
+ export declare function initProjectWith(destination: string, requested: readonly string[], { cwd, manifest, pins, acknowledgements, bundleRelease, bundleTransport }?: InitWithOptions): Promise<InitWithResult>;
@@ -37,6 +37,7 @@ export interface MatchableRoute {
37
37
  pattern: string;
38
38
  parts: string[];
39
39
  prefix?: string;
40
+ wildcard?: boolean;
40
41
  extension?: string;
41
42
  parameters: CompiledParameter[];
42
43
  env: Record<string, string>;
@@ -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 {
@@ -264,6 +264,7 @@ export interface CompiledRoute extends Omit<RouteConfig, 'methods' | 'parameters
264
264
  reply?: Reply;
265
265
  expiresAt?: number;
266
266
  prefix?: string;
267
+ wildcard?: boolean;
267
268
  middleware: CompiledMiddleware[];
268
269
  function?: CompiledFunction;
269
270
  respond?: RespondSpec;
package/dist/types.js CHANGED
@@ -127,7 +127,7 @@
127
127
 
128
128
 
129
129
 
130
-
130
+
131
131
 
132
132
 
133
133
 
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 60,557 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.7`. `function`/`middleware`
10
+ > `version: "1"`. Core is Apache-2.0; this revision is `0.5.0`. `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`.
@@ -18,9 +18,6 @@ or fetch inside a `sandbox: true` function, regex routes, database access,
18
18
  global middleware, YAML interpolation, or packages named in YAML. Secrets need external revision-pinned
19
19
  grants. Unsupported features fail with the route named; nothing degrades silently.
20
20
 
21
- Agents that explicitly want the complete consolidated reference in one fetch should read
22
- [llms-full.txt](https://github.com/jimhoyd-com/urlcode/blob/main/llms-full.txt), generated from the documents below (about 50k tokens, estimated).
23
-
24
21
  ## Contents
25
22
 
26
23
  1. [The URLCode framework](#the-urlcode-framework)
@@ -199,15 +196,23 @@ and the MCP `get_extensions` tool report those surfaces and their fast checks,
199
196
  so people and agents can discover the supported path instead of replacing
200
197
  package behavior.
201
198
 
199
+ A signed declarative artifact is a separate, optional authoring input, not a
200
+ fifth way to compose executable behavior. A project may lock an attested
201
+ schema/example bundle and expose it through MCP `get_extension_artifacts` and
202
+ `get_extension_artifact`; the npm package and operator host remain the only
203
+ executable extension path. See [signed declarative artifacts](https://github.com/jimhoyd-com/urlcode/blob/main/docs/EXTENSIONS.md#signed-declarative-artifacts).
204
+
202
205
  ```sh
203
206
  urlcode serve --project /absolute/site --host-file /absolute/operator/host.mjs --origin https://site.example
204
207
  ```
205
208
 
206
- `urlcode init <dir> --with ui,auth,admin` writes this layout in one step: it
207
- resolves each installed `@jimhoyd/urlcode-<name>` from the current directory,
208
- calls its `scaffold` export and merges the fragments into `app/urlcode.yaml`,
209
- one `host.mjs` and one `README.md`, refusing before writing anything when a
210
- package is missing or two fragments collide (the contract is documented under
209
+ `urlcode init <dir> --with ui,auth,admin` writes this layout in one step: by
210
+ default it resolves each installed `@jimhoyd/urlcode-<name>` from the current
211
+ directory; with `--bundle-release extension-bundles@v…` it verifies and locks
212
+ the named GitHub Release bundles instead. In either mode it calls the verified
213
+ module's `scaffold` export and merges fragments into `app/urlcode.yaml`, one
214
+ explicit `host.mjs` and one `README.md`, refusing before writing a site when a
215
+ package/bundle is missing or two fragments collide (the contract is documented under
211
216
  [scaffolding](https://github.com/jimhoyd-com/urlcode/blob/main/docs/EXTENSIONS.md#scaffolding-with-init---with)). `urlcode-auth init`
212
217
  and `urlcode-admin init` write the same layout for a single package; `urlcode-auth bootstrap` creates the first
213
218
  administrator from JSON on stdin. `inspectExtensionRevision(project)` prints
@@ -301,7 +306,10 @@ Make the first retrieval one bounded query: the MCP tool `get_context` when the
301
306
  `urlcode capabilities NAME` (MCP `get_capability`) for one capability's limits,
302
307
  `get_schema` for one YAML fragment, `urlcode recipes search TEXT`
303
308
  (`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
309
+ operator supplies a host file, `get_extensions`. If the project has a committed
310
+ `urlcode.extensions.lock.json`, use `get_extension_artifacts` to verify and
311
+ inventory its inert data and `get_extension_artifact` to retrieve only the
312
+ needed schema, example or README. Context is a summary with the
305
313
  constraints and exact commands, not a schema dump, and it never hides a
306
314
  capability limit: ask `capabilities NAME` before promising a feature.
307
315
 
@@ -356,7 +364,15 @@ teaches how to retrieve the minimum reference through `urlcode capabilities`,
356
364
  the documentation whole. For a host-composed application, `get_extensions`
357
365
  adds each extension's schemas, hooks, supported authoring surfaces and fast
358
366
  checks. Agents should use those surfaces before generating replacement package
359
- behavior. Neither file replaces the schema; both defer to it.
367
+ behavior. A committed artifact lock is a separate offline authoring input:
368
+ `get_extension_artifacts` validates its cache and lists allowlisted files;
369
+ `get_extension_artifact` reads one bounded JSON or Markdown member from its
370
+ verified archive. The CLI fallback is `urlcode extension-artifacts inspect
371
+ --project DIR --json`. An artifact never installs or activates an npm package,
372
+ registers a host extension or grants authority. Agents must not fetch or update
373
+ one unless the user explicitly requests that project change and names the
374
+ immutable `extensions@v…` release. Neither guide nor artifact replaces the
375
+ runtime schema; all defer to the pinned implementation.
360
376
 
361
377
  Treat core, installed extensions and product UI as one application with
362
378
  different owners. Keep auth/admin security and workflow behavior package-owned;
@@ -482,8 +498,21 @@ Before writing a function, check whether a declarative feature already covers th
482
498
  need. Security headers are the usual miss: a project that declares nothing sends
483
499
  only the runtime's defaults (`nosniff`, `no-store`, a request ID).
484
500
 
501
+ Building only redirects? `urlcode context --project DIR --task redirects` (MCP
502
+ `get_context {"task":"redirects"}`) is a bounded, redirect-only call: every row
503
+ below with the exact YAML, the two gaps with their exact validation error, and
504
+ this project's own redirects — cheaper than this table or the recipe catalog.
505
+
485
506
  | I need | Declare | Reference |
486
507
  |---|---|---|
508
+ | 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) |
509
+ | 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) |
510
+ | Root-relative redirect (`/users/{id}` to `/profiles/{id}`) | `redirect.url: /profiles/{id}`: one leading slash, path only, `{name}` placeholders | [redirects](https://github.com/jimhoyd-com/urlcode/blob/main/docs/yaml/redirects.md) |
511
+ | Wildcard/suffix redirect (`/legacy/**` to `/modern/{**}`, any depth) | terminal `/**` route key with a literal prefix, `{**}` in the destination path; redirect only, not static or Cloudflare | [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
+ | Host-based or scheme-based redirect — **gap** | not expressible; destination is a literal absolute `https://host/path` or a root-relative path | [open decision](https://github.com/jimhoyd-com/urlcode/blob/main/docs/OPEN-DECISIONS.md) |
487
516
  | 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
517
  | 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
518
  | A cache strategy on any route | `policies.cache` | [cache](https://github.com/jimhoyd-com/urlcode/blob/main/docs/policies/cache.md) |
@@ -507,8 +536,9 @@ Which handler serves the response:
507
536
  Data persistence has no native handler. The operator-installed `store` extension
508
537
  serves declared collections as a CRUD API, and `urlcode recipes search "crud store
509
538
  persist"` finds the `store-crud` recipe. It needs the operator to install
510
- `@jimhoyd/urlcode-store` (on npm) and a host file. `init --with
511
- ui,auth,store` scaffolds one from the published packages; a no-auth
539
+ `@jimhoyd/urlcode-store` package or an attested executable bundle and a host
540
+ file. `init --with ui,auth,store --bundle-release extension-bundles@v…`
541
+ scaffolds the npm-free bundle form; a no-auth
512
542
  `--with store` needs `--ack store:public-write`, which only a core release after the
513
543
  store's first publication has, so say so rather than promising it. Report anything beyond that recipe (filtering, sorting, per-record
514
544
  ownership, a database) as a gap. `urlcode context` lists the same built-ins so
@@ -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.7. Start with the function example below,
760
+ This guide targets URLCode 0.5.0. 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.
@@ -1297,7 +1327,8 @@ Keys are absolute case-sensitive paths. Trailing slashes are significant.
1297
1327
  Parameters occupy whole segments, e.g. `/p/{id}`, with distinct identifier names.
1298
1328
  Each parameter matches exactly one nonempty segment, never across `/`; it is not
1299
1329
  greedy. No regex paths, client-controlled host dispatch or dot segments. Only static directory mounts
1300
- support a terminal `/*` wildcard with an otherwise literal path. Route keys cannot contain
1330
+ support a terminal `/*` wildcard with an otherwise literal path; a `redirect` alone supports a
1331
+ terminal `/**` (one or more remaining segments, at least a one-segment literal prefix, see [route matching](https://github.com/jimhoyd-com/urlcode/blob/main/docs/ROUTING.md)). Route keys cannot contain
1301
1332
  percent encoding, spaces, backslashes or query strings. Path length is limited
1302
1333
  to 2,048 characters and 32 segments. `/_urlcode` is reserved.
1303
1334
 
@@ -1436,7 +1467,9 @@ Unknown query keys are ignored unless explicitly passed by a redirect.
1436
1467
  ### Redirects
1437
1468
 
1438
1469
  `redirect.url` is an absolute HTTP(S) URL with literal scheme/host and no embedded
1439
- credentials or whitespace/control characters. `{pathInput}` placeholders are
1470
+ credentials or whitespace/control characters, or a root-relative path (one leading `/`, never
1471
+ `//`, no dot segments) that answers a path-only `Location`. On a `/**` route `{**}` is the captured
1472
+ suffix, once, each segment encoded. `{pathInput}` placeholders are
1440
1473
  allowed only in the destination pathname and encoded as single components.
1441
1474
  No environment/secret interpolation. Status defaults to 302; allowed values are
1442
1475
  301, 302, 303, 307 and 308.
@@ -1679,6 +1712,7 @@ not part of the route key.
1679
1712
  | `/go` | `/go`, `/go?campaign=spring` | `/Go`, `/go/`, `/go/extra` |
1680
1713
  | `/r/{code}` | `/r/abc`, `/r/123` | `/r/`, `/r/abc/extra` |
1681
1714
  | `/r/{code}/details` | `/r/abc/details` | `/r/abc/other/details` |
1715
+ | `/legacy/**` with a `redirect` handler | `/legacy/a`, `/legacy/a/b/c` | `/legacy`, `/legacy/`, `/legacy/a//b` |
1682
1716
  | `/assets/*` with a `static` handler | Files under `/assets/`, including `/assets/css/site.css` | `/assets`, `/assets-other/site.css` |
1683
1717
 
1684
1718
  A `{parameter}` captures exactly one nonempty path segment. It is **not greedy**:
@@ -1691,8 +1725,16 @@ path. It covers the remaining nested file path; it is not a named capture or a
1691
1725
  regex operator. Matching a mount does not guarantee a response file exists:
1692
1726
  missing files return 404. It is not a catch-all for functions or redirects.
1693
1727
 
1728
+ A `redirect` handler alone supports a terminal `/**` after a literal prefix (never bare `/**`, never
1729
+ with a `{parameter}`). It matches one or more remaining segments, and `{**}` in `redirect.url` is
1730
+ those segments, each percent-encoded and joined by `/`, usable once and only in the destination
1731
+ path. Empty segments, `.`/`..`, encoded slashes and captures over 1,024 characters do not match.
1732
+ Exact and `{parameter}` routes always win over it, so `/legacy/keep/{id}` can carve an exception out
1733
+ of `/legacy/**`. It is refused on static hosting (S3 redirects match one path) and on Cloudflare
1734
+ until the Worker table supports suffix matching, and it cannot share a prefix with a `static` mount.
1735
+
1694
1736
  No regex routes, greedy parameters, optional segments, partial-segment parameters,
1695
- `**` globs, or regex constraints inside `{code}` are implemented. Characters such
1737
+ other `**` globs, or regex constraints inside `{code}` are implemented. Characters such
1696
1738
  as `.` and `+` have no regex meaning in a literal path. Do not paste a regex into
1697
1739
  a route key: some regex-looking text is legal literal text, while unsupported
1698
1740
  syntax may fail validation. Parameter-schema `pattern` is also unsupported.
@@ -2551,15 +2593,19 @@ as the not-found page instead. The page answers every unknown URL, so it cannot
2551
2593
 
2552
2594
  `urlcode build --target static` writes it as the object `404.html` (see
2553
2595
  [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.
2596
+ Cloudflare inlines it: the page is small, singular and static, so the build
2597
+ reads it (64 KiB cap, must decode as UTF-8) and carries it in the artifact as a
2598
+ `respond` route at `/404.html`, answering the same status, headers and method
2599
+ rules as every other target. See [`docs/CLOUDFLARE.md`](https://github.com/jimhoyd-com/urlcode/blob/main/docs/CLOUDFLARE.md#site-notfound-is-inlined).
2600
+ `favicon` and `llms` stay refused there like any other `page` route (no asset
2601
+ binding).
2556
2602
 
2557
2603
  ### Per-target support
2558
2604
 
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 |
2605
+ | Target | `robots`, `sitemap`, `securityTxt` (`respond`) | `favicon`, `llms` (`page`) | `notFound` |
2606
+ | --- | --- | --- | --- |
2607
+ | self-hosted, Vercel, AWS | served | served | served |
2608
+ | 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
2609
 
2564
2610
  ### Not in this release
2565
2611
 
@@ -3218,6 +3264,124 @@ Serving the result is the usual explicit host binding:
3218
3264
  urlcode validate --project app --host-file "$PWD/host.mjs" --origin https://site.example
3219
3265
  ```
3220
3266
 
3267
+ ### Signed declarative artifacts
3268
+
3269
+ Core remains the npm-distributed runtime. The workspace extensions are not an
3270
+ alternate npm channel. A release can additionally carry a small, **data-only**
3271
+ extension artifact for tooling that understands its declared format. It is not
3272
+ a Node module and cannot activate an extension, run a hook, replace a trusted
3273
+ operator host, or grant a route any authority.
3274
+
3275
+ Install an artifact only from its immutable `extensions@v…` GitHub Release. The
3276
+ published inert store configuration schema snapshot can be installed with:
3277
+
3278
+ ```sh
3279
+ urlcode extension-artifacts install store-schema --artifact-release extensions@v1.0.0 --project app
3280
+ urlcode extension-artifacts update store-schema --artifact-release extensions@v1.1.0 --project app
3281
+ urlcode extension-artifacts inspect --project app
3282
+ ```
3283
+
3284
+ The command downloads the signed `extensions-catalog.json`, verifies its
3285
+ GitHub attestation against the dedicated artifact workflow in
3286
+ `jimhoyd-com/urlcode` and the exact requested tag ref, then verifies the selected
3287
+ `.tgz` the same way. Self-hosted-runner attestations are refused. The catalog
3288
+ pins its release tag, source commit, filename and SHA-256; a catalog revocation
3289
+ refuses installation. `gh` with support for attestation source-ref verification
3290
+ is therefore a required local dependency for this command.
3291
+
3292
+ The resulting `urlcode.extensions.lock.json` is the reproducibility boundary:
3293
+ commit it with the project. Every locked artifact records its own catalog tag
3294
+ and source commit, so updating one artifact cannot silently relabel another as
3295
+ coming from a newer release. Extracted files live under
3296
+ `app/.urlcode/extensions/<sha256>/` and are checked before extraction. Archives
3297
+ are size- and file-count-bounded, reject links and traversal, and may contain
3298
+ only `extension.json`, JSON configuration/schema data, and an optional README.
3299
+ Any JavaScript, package manifest, install hook, native module, or unknown file
3300
+ causes refusal. `inspect` re-hashes the cached archive and every extracted file,
3301
+ reporting an entry as missing or invalid rather than trusting its directory
3302
+ name. Updates are never automatic: review a newer immutable release and run
3303
+ `update` explicitly.
3304
+
3305
+ Agent tooling can consume a committed lock without gaining write or execution
3306
+ authority. MCP `get_extension_artifacts` validates the lock and cache and lists
3307
+ the signed member paths; `get_extension_artifact {name, path}` returns one
3308
+ verified, bounded JSON or Markdown member directly from the cached archive.
3309
+ The CLI fallback is `urlcode extension-artifacts inspect --project app --json`.
3310
+ Neither MCP tool performs a network request, installs or updates an artifact,
3311
+ loads a host file, or activates code. A missing or modified cache is reported
3312
+ as missing/invalid and its contents are not returned.
3313
+
3314
+ This does not relax the existing host boundary. `--host-file` is still the only
3315
+ way to load trusted operator extension code, and artifact files are never
3316
+ imported by `serve`, `validate`, `init --with`, or the runtime.
3317
+
3318
+ Artifact versions are independent from npm package versions. The initial
3319
+ `store-schema` artifact is a reviewed configuration-schema snapshot and example,
3320
+ not the `@jimhoyd/urlcode-store` implementation. Installing it does not install
3321
+ or activate that package. Its README names the separate executable and operator
3322
+ requirements.
3323
+
3324
+ ### Signed executable extension bundles
3325
+
3326
+ Official executable extensions are migrating away from consumer npm installs.
3327
+ They use a separate, immutable `extension-bundles@v…` GitHub Release namespace;
3328
+ it is intentionally disjoint from the permanently data-only `extensions@v…`
3329
+ artifact channel above. A bundle is a bounded, frozen Node module tree produced
3330
+ from reviewed first-party source, not a general extension marketplace and not
3331
+ a project dependency resolver.
3332
+
3333
+ An operator explicitly installs one named bundle from an immutable release:
3334
+
3335
+ ```sh
3336
+ urlcode extension-bundles install store \
3337
+ --bundle-release extension-bundles@v1.0.0 --project app
3338
+ ```
3339
+
3340
+ For a new composed site, `init --with` can perform that verified installation
3341
+ before it writes the route project. This is the npm-free extension path: the
3342
+ generated `package.json`, when requested, pins URLCode core only; the generated
3343
+ host loads only the names recorded in the bundle lockfile.
3344
+
3345
+ ```sh
3346
+ urlcode init site --with ui,auth,admin \
3347
+ --bundle-release extension-bundles@v1.0.0
3348
+ ```
3349
+
3350
+ `init` verifies each requested bundle in a temporary operator staging root,
3351
+ obtains each scaffold from that verified module tree, then writes the cache and
3352
+ `urlcode.extension-bundles.lock.json` into the new site. It never resolves an
3353
+ extension package from npm in this mode. A failed verification or scaffold
3354
+ refusal leaves no site directory behind. The release tag is still an explicit
3355
+ operator choice; YAML cannot supply it.
3356
+
3357
+ The command verifies attestations for both the catalog and selected archive
3358
+ against the requested tag and dedicated workflow, rejects self-hosted runners,
3359
+ checks the catalog's commit, filename and SHA-256, and extracts only regular
3360
+ files in the signed module tree. It writes
3361
+ `urlcode.extension-bundles.lock.json` and keeps the frozen bytes under
3362
+ `app/.urlcode/extension-bundles/<sha256>/`. There is no automatic discovery,
3363
+ installation, update, or fallback to npm. `inspect` reads the committed lock;
3364
+ a modified cache or an incompatible core version refuses before import.
3365
+
3366
+ Executable bundles are **trusted operator code**, exactly like a hand-written
3367
+ operator host module. Project YAML cannot choose a bundle, name a release,
3368
+ trigger a download, or grant a bundle authority. An operator host explicitly
3369
+ loads a locked entry by name, then chooses which returned registration to pass
3370
+ to `createRuntime`:
3371
+
3372
+ ```js
3373
+ import { loadExtensionBundle } from '@jimhoyd/urlcode/extension-bundles';
3374
+
3375
+ const { storeExtension } = await loadExtensionBundle('/absolute/site/app', 'store');
3376
+ export default { extensions: [storeExtension({ directory: '/srv/site-data', projectSha256: process.env.PROJECT_SHA256 })] };
3377
+ ```
3378
+
3379
+ This does not make bundle code sandboxed and does not alter a route that
3380
+ declares `sandbox: true`; those remain distinct execution modes. npm packages
3381
+ remain the migration fallback until the first signed bundle release and the
3382
+ fresh composed consumer flow have been released and proven. Do not unpublish a
3383
+ package merely because its data-only artifact exists.
3384
+
3221
3385
  ---
3222
3386
 
3223
3387
  # Composing a site from ui, auth and admin