@jimhoyd/urlcode 0.4.7 → 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/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.7`. `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.7. 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.7`. `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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jimhoyd/urlcode",
3
- "version": "0.4.7",
3
+ "version": "0.4.8",
4
4
  "description": "Portable runtime for programmable URL behavior",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -89,7 +89,8 @@
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",
@@ -108,6 +109,7 @@
108
109
  "docs:plugin": "node scripts/generate-claude-plugin.ts",
109
110
  "docs:llms": "node scripts/build-llms-full.ts",
110
111
  "docs:cookbook-index": "node scripts/build-cookbook-index.ts",
112
+ "artifacts:prepare": "node --disable-warning=ExperimentalWarning scripts/prepare-extension-artifacts.ts",
111
113
  "check:downstream-skills": "node scripts/check-downstream-skill-drift.ts",
112
114
  "sync:agents": "node scripts/sync-agent-lists.ts",
113
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",
@@ -119,6 +121,7 @@
119
121
  "release:run": "node scripts/release-run.ts",
120
122
  "release:peers": "node scripts/release.ts peers",
121
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",
122
125
  "ci:history": "node scripts/ci-history.ts",
123
126
  "ci:report": "node scripts/ci-report.ts",
124
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
@@ -12,8 +12,30 @@
12
12
  "status": 301,
13
13
  "expectBody": ""
14
14
  },
15
+ {
16
+ "path": "/users/42",
17
+ "status": 308,
18
+ "expectHeaders": {
19
+ "location": "https://example.com/profiles/42"
20
+ }
21
+ },
22
+ {
23
+ "path": "/users/42/extra",
24
+ "status": 404
25
+ },
26
+ {
27
+ "path": "/users/42",
28
+ "method": "HEAD",
29
+ "status": 308,
30
+ "expectBody": ""
31
+ },
15
32
  {
16
33
  "path": "/missing",
17
34
  "status": 404
35
+ },
36
+ {
37
+ "path": "/docs",
38
+ "method": "DELETE",
39
+ "status": 405
18
40
  }
19
41
  ]
@@ -1,9 +1,19 @@
1
1
  version: "1"
2
2
  routes:
3
3
  /docs:
4
- description: Move documentation and preserve only the declared campaign key.
4
+ description: Permanent move, forwarding only the declared campaign key.
5
5
  redirect:
6
6
  url: https://example.com/documentation
7
7
  status: 301
8
8
  query:
9
9
  pass: [campaign]
10
+ /users/{id}:
11
+ description: Parameterized redirect; the id segment carries straight through.
12
+ parameters:
13
+ - name: id
14
+ in: path
15
+ required: true
16
+ schema: {type: string, minLength: 1, maxLength: 64}
17
+ redirect:
18
+ url: https://example.com/profiles/{id}
19
+ status: 308
@@ -34,6 +34,8 @@ When the MCP server was started with an operator host file, `get_extensions`
34
34
  returns installed extension configuration/policy schemas, declared project
35
35
  hook contracts, supported authoring surfaces and fast checks. Otherwise use `urlcode extensions --project DIR --host-file
36
36
  ABSOLUTE_HOST --json` when the operator has supplied that host file.
37
+ If the project commits `urlcode.extensions.lock.json`, call `get_extension_artifacts`, then `get_extension_artifact` for only the locked schema, example or README needed; without MCP, run `urlcode extension-artifacts inspect --project DIR --json` before reading its cache.
38
+ These are verified, inert authoring inputs, not proof of an installed executable extension. Fetch or update one only when the user requests that project change and names an immutable `extensions@v…` release.
37
39
  Without the server, run the CLI equivalents and read only the output:
38
40
 
39
41
  ```sh
@@ -22,7 +22,7 @@ static serving and authentication. Read this file before changing anything.
22
22
 
23
23
  When present, `.mcp.json` registers the read-only `urlcode mcp` server; prefer its
24
24
  tools (also `get_manifest`) to reading documents. Inspect `get_extensions` before
25
- replacing extension behavior. `--allow-authoring` is an operator opt-in; never add it.
25
+ replacing extension behavior. `--allow-authoring` is an operator opt-in; never add it. For a committed artifact lock, use `get_extension_artifacts`/`get_extension_artifact`; they expose verified inert data and never activate an extension.
26
26
 
27
27
  ## What the runtime provides (this version)
28
28