@jimhoyd/urlcode 0.4.2 → 0.4.6

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 (214) hide show
  1. package/.claude/skills/urlcode-authoring/SKILL.md +57 -14
  2. package/.claude/skills/urlcode-operations/SKILL.md +4 -0
  3. package/README.md +19 -15
  4. package/SECURITY.md +5 -3
  5. package/dist/BUILD-MANIFEST.json +30 -27
  6. package/dist/agent-context.js +82 -0
  7. package/dist/agents-guide.js +42 -42
  8. package/dist/authoring.js +12 -2
  9. package/dist/body-schema.js +159 -0
  10. package/dist/build-cloudflare.js +2 -0
  11. package/dist/capabilities.js +1 -1
  12. package/dist/cli.js +28 -12
  13. package/dist/config.js +74 -6
  14. package/dist/context.js +4 -6
  15. package/dist/errors.js +3 -1
  16. package/dist/examples.js +1 -1
  17. package/dist/extensions.js +60 -2
  18. package/dist/http-policy.js +19 -4
  19. package/dist/http-response.js +2 -2
  20. package/dist/init-with.js +71 -9
  21. package/dist/mcp.js +15 -2
  22. package/dist/pattern-guard.js +32 -0
  23. package/dist/policies/security.js +0 -0
  24. package/dist/project-tests.js +35 -11
  25. package/dist/readiness.js +184 -32
  26. package/dist/recipes.js +1 -1
  27. package/dist/router.js +17 -0
  28. package/dist/runtime.js +26 -3
  29. package/dist/scaffold.js +0 -0
  30. package/dist/server.js +26 -3
  31. package/dist/site.js +0 -0
  32. package/dist/tooling.js +2 -1
  33. package/dist/types/agent-context.d.ts +44 -0
  34. package/dist/types/authoring.d.ts +3 -1
  35. package/dist/types/body-schema.d.ts +58 -0
  36. package/dist/types/config.d.ts +10 -2
  37. package/dist/types/context.d.ts +1 -1
  38. package/dist/types/errors.d.ts +9 -1
  39. package/dist/types/examples.d.ts +1 -1
  40. package/dist/types/extensions.d.ts +47 -2
  41. package/dist/types/http-policy.d.ts +3 -0
  42. package/dist/types/init-with.d.ts +10 -1
  43. package/dist/types/pattern-guard.d.ts +10 -0
  44. package/dist/types/project-tests.d.ts +9 -0
  45. package/dist/types/readiness.d.ts +68 -0
  46. package/dist/types/recipes.d.ts +1 -1
  47. package/dist/types/runtime.d.ts +4 -0
  48. package/dist/types/server.d.ts +9 -1
  49. package/dist/types/tooling.d.ts +1 -0
  50. package/dist/types/types.d.ts +13 -0
  51. package/dist/types.js +10 -2
  52. package/dist/typescript-authoring.js +5 -3
  53. package/dist/verify-deployment.js +16 -10
  54. package/examples/body-validation/README.md +16 -0
  55. package/examples/body-validation/example.yaml +17 -0
  56. package/examples/body-validation/tests/requests.json +15 -0
  57. package/examples/body-validation/urlcode.yaml +34 -0
  58. package/examples/coverage-waiver/README.md +8 -0
  59. package/examples/coverage-waiver/example.yaml +16 -0
  60. package/examples/coverage-waiver/functions/notes.mjs +2 -0
  61. package/examples/coverage-waiver/tests/requests.json +3 -0
  62. package/examples/coverage-waiver/urlcode.yaml +10 -0
  63. package/examples/data-dir/README.md +39 -0
  64. package/examples/data-dir/data/welcome.txt +1 -0
  65. package/examples/data-dir/example.yaml +22 -0
  66. package/examples/data-dir/functions/note.mjs +18 -0
  67. package/examples/data-dir/tests/requests.json +6 -0
  68. package/examples/data-dir/urlcode.yaml +6 -0
  69. package/examples/lifecycle/README.md +15 -0
  70. package/examples/lifecycle/example.yaml +19 -0
  71. package/examples/lifecycle/functions/notes.mjs +29 -0
  72. package/examples/lifecycle/tests/requests.json +12 -0
  73. package/examples/lifecycle/urlcode.yaml +29 -0
  74. package/examples/not-found/README.md +10 -0
  75. package/examples/not-found/example.yaml +17 -0
  76. package/examples/not-found/public/404.html +3 -0
  77. package/examples/not-found/public/index.html +3 -0
  78. package/examples/not-found/tests/requests.json +7 -0
  79. package/examples/not-found/urlcode.yaml +7 -0
  80. package/examples/shared-blocks/README.md +11 -0
  81. package/examples/shared-blocks/example.yaml +17 -0
  82. package/examples/shared-blocks/tests/requests.json +7 -0
  83. package/examples/shared-blocks/urlcode.yaml +37 -0
  84. package/llms-full.txt +364 -49
  85. package/llms.txt +43 -5
  86. package/package.json +15 -22
  87. package/recipes/static-page/README.md +9 -0
  88. package/recipes/static-page/public/index.html +11 -0
  89. package/recipes/static-page/recipe.yaml +21 -0
  90. package/recipes/static-page/tests/requests.json +22 -0
  91. package/recipes/static-page/urlcode.yaml +7 -0
  92. package/recipes/static-plus-api/README.md +6 -0
  93. package/recipes/static-plus-api/urlcode.yaml +4 -0
  94. package/recipes/store-crud/README.md +53 -0
  95. package/recipes/store-crud/recipe.yaml +31 -0
  96. package/recipes/store-crud/tests/requests.json +18 -0
  97. package/recipes/store-crud/urlcode.yaml +18 -0
  98. package/schemas/urlcode.schema.json +125 -60
  99. package/skills/urlcode/SKILL.md +47 -27
  100. package/starters/default/AGENTS.md +43 -43
  101. package/starters/page/README.md +14 -0
  102. package/starters/page/public/index.html +12 -0
  103. package/starters/page/tests/requests.json +17 -0
  104. package/starters/page/urlcode.yaml +6 -0
  105. package/.claude-plugin/marketplace.json +0 -18
  106. package/CONTRIBUTING.md +0 -148
  107. package/ROADMAP.md +0 -70
  108. package/docs/AI-AUTHORING.md +0 -339
  109. package/docs/ASSETS.md +0 -107
  110. package/docs/AUTH-BACKUP.md +0 -32
  111. package/docs/AWS.md +0 -86
  112. package/docs/BEST-PRACTICES.md +0 -276
  113. package/docs/BULK.md +0 -79
  114. package/docs/CAPABILITIES.md +0 -192
  115. package/docs/CAPACITY.md +0 -305
  116. package/docs/CI-FOLLOWUP-2026-09-19.md +0 -97
  117. package/docs/CI-RELEASE-AUDIT-2026-09-19.md +0 -322
  118. package/docs/CI.md +0 -147
  119. package/docs/CLOUDFLARE.md +0 -109
  120. package/docs/CODEBASE-AUDIT-2026-09-20.md +0 -284
  121. package/docs/COMPLIANCE.md +0 -239
  122. package/docs/COMPOSING-A-SITE.md +0 -287
  123. package/docs/CONDITIONS.md +0 -74
  124. package/docs/CONTAINER-PROMOTION.md +0 -74
  125. package/docs/DEPLOYMENT-CHECKS.md +0 -108
  126. package/docs/DEVELOPMENT-PIPELINE.md +0 -304
  127. package/docs/EGRESS.md +0 -125
  128. package/docs/EXTENSIONS.md +0 -403
  129. package/docs/FRAMEWORK.md +0 -217
  130. package/docs/FUNCTION-SECURITY.md +0 -251
  131. package/docs/HTTP.md +0 -129
  132. package/docs/INSTALL.md +0 -128
  133. package/docs/INTERCHANGE.md +0 -134
  134. package/docs/LOAD-TESTING.md +0 -91
  135. package/docs/LOCAL-DEVELOPMENT.md +0 -102
  136. package/docs/MIDDLEWARE-EXAMPLES.md +0 -75
  137. package/docs/MIDDLEWARE.md +0 -102
  138. package/docs/MONITORING.md +0 -115
  139. package/docs/OBSERVABILITY.md +0 -222
  140. package/docs/OPEN-DECISIONS.md +0 -149
  141. package/docs/OPERATIONAL-PROOF.md +0 -41
  142. package/docs/OPERATIONS.md +0 -201
  143. package/docs/ORGANIZATION.md +0 -135
  144. package/docs/PERFORMANCE.md +0 -72
  145. package/docs/PLUGINS.md +0 -271
  146. package/docs/POLICIES.md +0 -211
  147. package/docs/PRERENDER.md +0 -245
  148. package/docs/PROJECT-DIRECTION.md +0 -118
  149. package/docs/PROVIDER-VERIFICATION.md +0 -84
  150. package/docs/READINESS.md +0 -153
  151. package/docs/README.md +0 -93
  152. package/docs/RECIPES.md +0 -99
  153. package/docs/RELEASE-0.4.0-alpha.3.md +0 -50
  154. package/docs/RELEASE-0.4.1.md +0 -73
  155. package/docs/RELEASE-0.4.2.md +0 -30
  156. package/docs/RELEASE-READINESS.md +0 -146
  157. package/docs/RELEASE-SECURITY.md +0 -100
  158. package/docs/RESILIENCE.md +0 -161
  159. package/docs/ROUTING.md +0 -92
  160. package/docs/SANDBOX-REVIEW.md +0 -72
  161. package/docs/SCAFFOLDING.md +0 -70
  162. package/docs/SECURITY-AUDIT.md +0 -164
  163. package/docs/SITE.md +0 -150
  164. package/docs/SPECIFICATION.md +0 -359
  165. package/docs/SPIKE-AI-FRAMEWORK-BENCHMARK.md +0 -288
  166. package/docs/SPIKE-BUSINESS-SUITE.md +0 -1029
  167. package/docs/SPIKE-CORE-LAYERING.md +0 -368
  168. package/docs/SPIKE-DEFAULT-TRUST-MODEL.md +0 -207
  169. package/docs/STANDARDS.md +0 -311
  170. package/docs/STARTERS.md +0 -83
  171. package/docs/STATIC.md +0 -105
  172. package/docs/TOOLING.md +0 -298
  173. package/docs/TUNNELS.md +0 -72
  174. package/docs/TYPESCRIPT-AUTHORING.md +0 -87
  175. package/docs/TYPESCRIPT.md +0 -123
  176. package/docs/VERCEL.md +0 -114
  177. package/docs/VERSION-ALIGNMENT.md +0 -88
  178. package/docs/YAML-GUIDE.md +0 -57
  179. package/docs/YAML-REFERENCE.md +0 -449
  180. package/docs/archive/2026-09-19/EXTENSION-IMPLEMENTATION.md +0 -68
  181. package/docs/archive/2026-09-19/MANAGEMENT-SECURITY.md +0 -102
  182. package/docs/archive/2026-09-19/NEXT-PHASE-PLAN.md +0 -108
  183. package/docs/archive/2026-09-19/NEXT-STEPS.md +0 -646
  184. package/docs/archive/2026-09-19/OPEN-DECISIONS.md +0 -277
  185. package/docs/archive/2026-09-19/RELEASE-SECURITY.md +0 -186
  186. package/docs/archive/2026-09-19/ROADMAP.md +0 -387
  187. package/docs/archive/2026-09-19/SPIKE-EXTENSION-MODEL.md +0 -430
  188. package/docs/archive/2026-09-19/SPIKE-EXTENSIONS.md +0 -492
  189. package/docs/archive/2026-09-19/SPIKE-LAMBDA-COMPILE.md +0 -365
  190. package/docs/archive/2026-09-19/SPIKE-MONOREPO.md +0 -778
  191. package/docs/archive/2026-09-19/USABILITY-REVIEW.md +0 -139
  192. package/docs/archive/2026-09-20/OPEN-DECISIONS-COMPLETED.md +0 -116
  193. package/docs/archive/README.md +0 -29
  194. package/docs/policies/agents.md +0 -182
  195. package/docs/policies/cache.md +0 -152
  196. package/docs/policies/compression.md +0 -169
  197. package/docs/policies/contract.md +0 -52
  198. package/docs/policies/hardened.md +0 -56
  199. package/docs/policies/interoperability.md +0 -169
  200. package/docs/policies/operations.md +0 -45
  201. package/docs/policies/security.md +0 -161
  202. package/docs/policies/throttle.md +0 -103
  203. package/docs/yaml/assets.md +0 -36
  204. package/docs/yaml/conditions.md +0 -20
  205. package/docs/yaml/functions.md +0 -168
  206. package/docs/yaml/middleware.md +0 -31
  207. package/docs/yaml/organization.md +0 -74
  208. package/docs/yaml/policies.md +0 -37
  209. package/docs/yaml/redirects.md +0 -64
  210. package/docs/yaml/responses.md +0 -57
  211. package/docs/yaml/site.md +0 -24
  212. package/packaging/claude-plugin/.claude-plugin/plugin.json +0 -19
  213. package/packaging/claude-plugin/skills/urlcode-authoring/SKILL.md +0 -130
  214. package/packaging/claude-plugin/skills/urlcode-operations/SKILL.md +0 -108
@@ -1,4 +1,5 @@
1
- import type { LoadedDocument, ProjectDocument, RouteConfig } from './types.ts';
1
+ import type { ErrorObject } from 'ajv';
2
+ import type { LoadedDocument, ProjectDocument, RouteConfig, SharedBlock } from './types.ts';
2
3
  /** What config-worker.ts posts back: the loaded document, or the ConfigError message. */
3
4
  export type ConfigWorkerResult = {
4
5
  value: LoadedDocument;
@@ -10,7 +11,14 @@ export interface ConfigWorkerData {
10
11
  }
11
12
  export declare const MAX_CONFIG_BYTES: number;
12
13
  export declare function parseYaml(text: string): unknown;
13
- export declare function validateDocument(data: unknown): ProjectDocument;
14
+ /**
15
+ * One line for the first schema violation. Closed-key-set failures name the offending key and the keys the
16
+ * schema allows, and required failures name the missing key, because a bare keyword sends the reader hunting.
17
+ * Only key names, which come from the schema or the author's own mapping keys, are echoed, never values
18
+ * (values may hold secrets), and never more than MAX_NAMED_KEY characters of a key.
19
+ */
20
+ export declare function describeSchemaError(e: ErrorObject): string;
21
+ export declare function validateDocument(data: unknown, inherited?: Record<string, SharedBlock>): ProjectDocument;
14
22
  /** The input declaration a short-form function gets for each `{param}` it does not declare itself. */
15
23
  export declare const SHORT_FORM_PATH_SCHEMA: {
16
24
  readonly type: "string";
@@ -62,5 +62,5 @@ export declare function estimateTokens(text: string): number;
62
62
  export declare function renderContext(context: ProjectContext): string;
63
63
  /** Derived only from the compiled project and the capability catalog, never from prose. Key order is fixed. */
64
64
  export declare function buildContext(project: string, options?: ContextOptions): Promise<ProjectContext>;
65
- /** Estimated size of the shipped documentation (docs/*.md and llms.txt), for comparison with an emitted context. */
65
+ /** Estimated size of the shipped offline documentation bundle, for comparison with an emitted context. */
66
66
  export declare function documentationTokens(): Promise<number>;
@@ -2,6 +2,14 @@ export declare class ConfigError extends Error {
2
2
  }
3
3
  export declare class HttpError extends Error {
4
4
  readonly status: number;
5
- constructor(status: number, message: string);
5
+ /** A pre-rendered, fixed-content answer that replaces the plain-text message (used for negotiated 422 bodies). */
6
+ readonly answer: {
7
+ contentType: string;
8
+ text: string;
9
+ } | undefined;
10
+ constructor(status: number, message: string, answer?: {
11
+ contentType: string;
12
+ text: string;
13
+ });
6
14
  }
7
15
  export declare function assert(condition: unknown, message: string): asserts condition;
@@ -31,7 +31,7 @@ export interface ExampleSearchResult {
31
31
  matchedRoutes: RouteIndexEntry[];
32
32
  })[];
33
33
  }
34
- export declare const exampleNames: readonly ["assets", "aws", "cloudflare", "compliance", "conditions", "cookbook", "egress", "extensions", "monitoring", "prerender", "provider-conformance", "tunnel", "vercel"];
34
+ export declare const exampleNames: readonly ["assets", "aws", "body-validation", "cloudflare", "compliance", "conditions", "cookbook", "coverage-waiver", "data-dir", "egress", "extensions", "lifecycle", "monitoring", "not-found", "prerender", "provider-conformance", "shared-blocks", "tunnel", "vercel"];
35
35
  export declare const routeIndexFile = "route-index.json";
36
36
  export declare function listExamples(): Promise<ExampleSummary[]>;
37
37
  /**
@@ -82,6 +82,27 @@ export interface ExtensionHookContract {
82
82
  inputSchema: object;
83
83
  outputSchema?: object;
84
84
  }
85
+ export type ExtensionAuthoringKind = 'configuration' | 'theme' | 'copy' | 'component' | 'template' | 'stylesheet' | 'hook' | 'extension';
86
+ /** One project-owned customization surface, shown to people and authoring agents by CLI/MCP inspection. */
87
+ export interface ExtensionAuthoringSurface {
88
+ kind: ExtensionAuthoringKind;
89
+ name: string;
90
+ description: string;
91
+ /** Project-relative convention or configuration path, when the surface has one. */
92
+ path?: string;
93
+ /** A bounded local command that discovers, previews or checks the surface. */
94
+ command?: string;
95
+ }
96
+ /**
97
+ * Machine-readable guidance for changing an installed extension without
98
+ * copying its behavior into the application. This is descriptive only: it
99
+ * grants nothing and is never executed by the runtime.
100
+ */
101
+ export interface ExtensionAuthoringContract {
102
+ description: string;
103
+ surfaces: readonly ExtensionAuthoringSurface[];
104
+ fastChecks?: readonly string[];
105
+ }
85
106
  export type LoadedExtensionHooks<T extends string = string> = Partial<Record<T, (input: unknown) => unknown>>;
86
107
  /** Shared schema for project hook references. Omission means trusted execution. */
87
108
  export declare const extensionHookReferenceSchema: {
@@ -133,6 +154,8 @@ export interface RuntimeExtension {
133
154
  immutableAssets?: ExtensionImmutableAssets;
134
155
  /** Project customization points, exposed by CLI/MCP for authors and agents. */
135
156
  hooks?: readonly ExtensionHookContract[];
157
+ /** Supported project-owned customization surfaces, exposed by CLI/MCP. */
158
+ authoring?: ExtensionAuthoringContract;
136
159
  /**
137
160
  * Reviewed, operator-declared cache sensitivity for `policies.extensions.<name>`
138
161
  * routes (never for an `extension:` mount, which is always treated as
@@ -160,8 +183,15 @@ export interface ScaffoldRequest {
160
183
  project: string;
161
184
  /** Absolute path of the combined host module core writes, `<directory>/host.mjs`. */
162
185
  hostFile: string;
163
- /** Every extension name being scaffolded together, in `--with` order, including this one. */
186
+ /** Every extension name being scaffolded together, including this one, in a canonical (sorted) order that is independent of the `--with` spelling. */
164
187
  names: readonly string[];
188
+ /**
189
+ * Operator acknowledgements from repeated `--ack <extension>:<id>` flags, sorted and de-duplicated; empty when none. Core treats
190
+ * them as opaque strings and never invents one. An extension reads only the ones qualified with its own name. To require one, throw
191
+ * an Error carrying `acknowledgement: '<name>:<id>'` whose message states the risk; core appends the exact re-run command with
192
+ * `--ack <name>:<id>`. List each one the scaffold used in `ScaffoldResult.acknowledged`.
193
+ */
194
+ acknowledgements: readonly string[];
165
195
  }
166
196
  export interface ScaffoldFile {
167
197
  path: string;
@@ -171,6 +201,21 @@ export interface ScaffoldFile {
171
201
  export interface ScaffoldResult {
172
202
  /** Must equal the requested name. */
173
203
  name: string;
204
+ /**
205
+ * Declarative composition contract; `--with` is an unordered set and core derives the host, activation and README order from these.
206
+ * `provides`: capability names this extension offers (a name must not equal an extension name). `requires`: extensions or
207
+ * capabilities that must be in the set and are placed before this one; a missing one refuses. `after`: the same ordering
208
+ * without requiring presence. `conflicts`: extensions or capabilities that must not be in the set. A cycle refuses. Core never
209
+ * adds an extension or infers policy from these lists.
210
+ */
211
+ provides?: string[];
212
+ requires?: string[];
213
+ after?: string[];
214
+ conflicts?: string[];
215
+ /** The `<name>:<id>` acknowledgements this scaffold consumed. Core refuses any passed `--ack` that no scaffold lists here, so an acknowledgement cannot be passed with no effect. */
216
+ acknowledged?: string[];
217
+ /** One-line notes written as comments above this extension's routes in the route fragment (for example the selected access model). */
218
+ routeNotes?: string[];
174
219
  /** Fragments merged into the project's top-level `extensions` and `routes`; duplicate keys are refused. */
175
220
  extensions: Record<string, unknown>;
176
221
  routes: Record<string, unknown>;
@@ -181,7 +226,7 @@ export interface ScaffoldResult {
181
226
  hostClose?: string[];
182
227
  /** Files written relative to `directory` with their modes; never inside the project, never overwriting. */
183
228
  files: ScaffoldFile[];
184
- /** Markdown appended to README.md under a heading core adds; the numbered steps merged in `--with` order. */
229
+ /** Markdown appended to README.md under a heading core adds; the numbered steps merged in the resolved order. */
185
230
  readme: string;
186
231
  nextSteps: string[];
187
232
  /** Environment variables the host reads, with one-line descriptions. */
@@ -1,5 +1,6 @@
1
1
  import type { HandlerResult, HeaderPair } from './http-response.ts';
2
2
  import type { HeadersLike } from './match.ts';
3
+ import type { BodySchema } from './body-schema.ts';
3
4
  export interface RespondSpec {
4
5
  status?: number;
5
6
  json?: unknown;
@@ -10,6 +11,7 @@ export interface RequestBodyPolicy {
10
11
  required?: boolean;
11
12
  contentTypes?: string[];
12
13
  format?: 'json' | 'text';
14
+ schema?: BodySchema;
13
15
  }
14
16
  /** A static reply compiled from `respond`; the body is bytes so every host, including the Worker, shares the type. */
15
17
  export interface Reply {
@@ -32,6 +34,7 @@ export interface HttpRoute {
32
34
  responseHeaders?: HeaderPair[];
33
35
  reply?: Reply | undefined;
34
36
  }
37
+ export declare const reservedResponseHeaders: Set<string>;
35
38
  export declare function compileHttp(route: HttpRoute): void;
36
39
  export declare function checkRequest(route: HttpRoute, body: Uint8Array, headers: HeadersLike, counts?: Record<string, number>): void;
37
40
  export declare function decorateResponse(route: HttpRoute & {
@@ -1,3 +1,4 @@
1
+ import type { ScaffoldResult } from './extensions.ts';
1
2
  import type { DependencyPin } from './project-dependencies.ts';
2
3
  export interface InitWithOptions {
3
4
  cwd?: string | undefined;
@@ -5,6 +6,8 @@ export interface InitWithOptions {
5
6
  manifest?: boolean | undefined;
6
7
  /** `--pin <package>=<specifier>` overrides, for local tarballs, checkouts and mirrors. */
7
8
  pins?: ReadonlyMap<string, string> | undefined;
9
+ /** `--ack <extension>:<id>`, repeatable: opaque qualified acknowledgements handed to every scaffold. Core refuses one that no scaffold consumed. */
10
+ acknowledgements?: readonly string[] | undefined;
8
11
  }
9
12
  export interface InitWithResult {
10
13
  directory: string;
@@ -16,9 +19,15 @@ export interface InitWithResult {
16
19
  dependencies: DependencyPin[];
17
20
  }
18
21
  export declare function parseWithNames(value: string): string[];
22
+ /**
23
+ * Orders the requested set from the scaffolds' declared `requires`, `after`, `provides` and `conflicts`, never from
24
+ * the `--with` spelling. Kahn's algorithm with the lexically smallest ready extension first, so the result is
25
+ * deterministic and identical for every permutation. Refuses a missing requirement, a conflict or a cycle by name.
26
+ */
27
+ export declare function orderScaffolds(results: readonly ScaffoldResult[]): ScaffoldResult[];
19
28
  /**
20
29
  * `urlcode init <directory> --with a,b`: the starter under `app/`, every extension's fragments merged into one
21
30
  * `urlcode.yaml`, one `host.mjs`, one `README.md` and the extensions' own files. All packages are resolved and
22
31
  * their scaffolds computed before anything is written, so a refusal leaves no directory behind.
23
32
  */
24
- export declare function initProjectWith(destination: string, names: readonly string[], { cwd, manifest, pins }?: InitWithOptions): Promise<InitWithResult>;
33
+ export declare function initProjectWith(destination: string, requested: readonly string[], { cwd, manifest, pins, acknowledgements }?: InitWithOptions): Promise<InitWithResult>;
@@ -0,0 +1,10 @@
1
+ export declare const maxPatternLength = 128;
2
+ export declare const maxPatternInputLength = 128;
3
+ /**
4
+ * Accepts an author regex only when it is conservatively safe to run on every
5
+ * request in the host process. Node has no linear-time engine, so this refuses
6
+ * the constructs that make backtracking blow up — repeated groups, lookaround
7
+ * and backreferences — and caps unbounded quantifiers. It is a restriction, not
8
+ * a proof: callers must also bound the input length to `maxPatternInputLength`.
9
+ */
10
+ export declare function assertSafePattern(pattern: string): void;
@@ -1,4 +1,5 @@
1
1
  import type { ServerOptions } from './server.ts';
2
+ import type { RestartableApp } from './readiness.ts';
2
3
  import type { LogFn } from './types.ts';
3
4
  export interface ProjectTestOptions {
4
5
  extensions?: ServerOptions['extensions'];
@@ -11,4 +12,12 @@ export interface ProjectTestResult {
11
12
  total: number;
12
13
  failed: number;
13
14
  }
15
+ /**
16
+ * A server that fixture `restart` steps can close and start again on the same project and the
17
+ * same data directory. The data directory is fresh and empty, offered to the project as
18
+ * `URLCODE_DATA_DIR`, and removed by `close()`; on a filesystem where it cannot be created, none is offered. `restart()` gets a new port; read `address` after it.
19
+ */
20
+ export declare function startRestartable(options: ServerOptions): Promise<RestartableApp & {
21
+ close(): Promise<void>;
22
+ }>;
14
23
  export declare function runProjectTests(project: string, { log, permissions, origin, extensions, plugins }?: ProjectTestOptions): Promise<ProjectTestResult>;
@@ -23,17 +23,38 @@ export interface RequestCase {
23
23
  body?: string | undefined;
24
24
  expectHeaders?: Record<string, string> | undefined;
25
25
  expectBody?: string | undefined;
26
+ /** Only inside `steps`: values kept from this step's response for later steps' `{{name}}` references. */
27
+ capture?: Record<string, CaptureSpec> | undefined;
26
28
  }
29
+ /** Where a captured value comes from: a dotted path into a JSON response body, or one response header. */
30
+ export type CaptureSpec = {
31
+ json: string;
32
+ } | {
33
+ header: string;
34
+ };
35
+ /** A step that closes and restarts the runtime on the same project and data directory. */
36
+ export interface RestartStep {
37
+ restart: true;
38
+ }
39
+ /** An ordered fixture: requests that share captured values, optionally with restarts between them. */
40
+ export interface StepsFixture {
41
+ steps: (RequestCase | RestartStep)[];
42
+ }
43
+ export type Fixture = RequestCase | StepsFixture;
44
+ export declare const isStepsFixture: (fixture: Fixture) => fixture is StepsFixture;
45
+ export declare const isRestartable: (app: AuditableApp) => app is RestartableApp;
27
46
  export interface ProjectPlan {
28
47
  inventory: RouteInventory[];
29
48
  cases: RequestCase[];
30
49
  resolve: (path: string) => string | undefined;
31
50
  }
51
+ /** `captured` holds values from a step's `capture`; callers use it for substitution only and never print it. */
32
52
  export interface HitResult {
33
53
  pass: boolean;
34
54
  status: number;
35
55
  durationMs: number;
36
56
  error?: string;
57
+ captured?: Record<string, string>;
37
58
  }
38
59
  export interface BenchmarkTarget {
39
60
  protocol: string;
@@ -48,6 +69,10 @@ export interface AuditableApp {
48
69
  policies?: Record<string, PolicyInventory>;
49
70
  };
50
71
  }
72
+ /** An app that can also close and restart itself on the same project and data directory (fixture `restart` steps). */
73
+ export interface RestartableApp extends AuditableApp {
74
+ restart(): Promise<void>;
75
+ }
51
76
  export type { ComplianceOptions, ComplianceReport } from './compliance.ts';
52
77
  export interface AuditOptions {
53
78
  expectRoutes?: number | undefined;
@@ -60,8 +85,12 @@ export interface AuditReport {
60
85
  /** Empty when `ready`; otherwise one stable code per failed condition:
61
86
  * `no-active-routes`, `route-count-mismatch`, `failed-checks`, `uncovered-route-methods`. */
62
87
  notReadyReasons: string[];
88
+ /** `configured` is every route in the table, including routes generated from `site` keys; `--expect-routes` compares against it.
89
+ * `declared` + `generated` always equals `configured`. */
63
90
  counts: {
64
91
  configured: number;
92
+ declared: number;
93
+ generated: number;
65
94
  active: number;
66
95
  disabled: number;
67
96
  expired: number;
@@ -78,6 +107,24 @@ export interface AuditReport {
78
107
  route: string;
79
108
  method: string;
80
109
  }[];
110
+ /** Route/method pairs excused by a `coveredElsewhere` waiver, with the reason. Shown even when `ready`. */
111
+ waivedRouteMethods: {
112
+ route: string;
113
+ method: string;
114
+ reason: string;
115
+ }[];
116
+ /** Waivers not honored (the route has no normally covered method, e.g. an error-only function route); their pairs stay in `uncovered`. */
117
+ ignoredWaivers: {
118
+ route: string;
119
+ method: string;
120
+ reason: string;
121
+ }[];
122
+ /** Waivers whose pair already has a passing normal-response fixture: remove them. Never blocks `ready`. */
123
+ redundantWaivers: {
124
+ route: string;
125
+ method: string;
126
+ reason: string;
127
+ }[];
81
128
  policies: Record<string, PolicyInventory>;
82
129
  compliance: ComplianceReport | null;
83
130
  /** Non-blocking `audit` observations, e.g. a route that looks webhook-shaped
@@ -126,7 +173,28 @@ export declare const hasRedirect: (route: CompiledRoute) => route is CompiledRou
126
173
  };
127
174
  export declare const probeAgent = "Mozilla/5.0 (compatible; RouteProbe/0.1)";
128
175
  export declare function projectPlan(compiled: CompiledRoutes<CompiledRoute>): ProjectPlan;
176
+ export declare function readFixtures(root: string, optional?: boolean): Promise<Fixture[]>;
177
+ /** Single-request fixtures only: the benchmark replays these and cannot run ordered steps. */
129
178
  export declare function readCases(root: string, optional?: boolean): Promise<RequestCase[]>;
179
+ /** One request a fixture run sent: `test` is the request as sent (captured values filled in), `original` as written. Print `original`, never `test`. */
180
+ export interface FixtureStep {
181
+ case: number;
182
+ fixture: number;
183
+ test: RequestCase;
184
+ original: RequestCase;
185
+ result: HitResult;
186
+ }
187
+ export interface FixtureHost {
188
+ app: AuditableApp;
189
+ agent: Agent;
190
+ target?: BenchmarkTarget | undefined;
191
+ /** Close and restart the runtime on the same project and data directory. Absent: the host cannot restart. */
192
+ restart?: (() => Promise<void>) | undefined;
193
+ /** Called with the 1-based fixture number and a reason when a fixture with a restart step is skipped because the host cannot restart. Absent: such a fixture is refused with an error. */
194
+ skipped?: ((fixture: number, reason: string) => void) | undefined;
195
+ }
196
+ /** Runs every fixture in file order. A step after a failed step in the same fixture is reported failed with error `skipped` and never sent, so a broken chain cannot pass. */
197
+ export declare function runFixtures(fixtures: Fixture[], host: FixtureHost, visit: (step: FixtureStep) => void | Promise<void>, firstCase?: number): Promise<void>;
130
198
  export declare function benchmarkTarget(value: string): BenchmarkTarget;
131
199
  export declare function hit(app: AuditableApp, test: RequestCase, agent: Agent, target?: BenchmarkTarget): Promise<HitResult>;
132
200
  export declare function auditProject(app: AuditableApp, { expectRoutes, log, compliance }?: AuditOptions): Promise<AuditReport>;
@@ -20,7 +20,7 @@ export interface RecipeSearchResult {
20
20
  matched: string[];
21
21
  })[];
22
22
  }
23
- export declare const recipeNames: readonly ["redirect", "json-api", "typescript", "middleware", "health-page", "static-plus-api", "cors-api", "webhook-receiver", "contact-form", "authenticated-json-api", "protected-download"];
23
+ export declare const recipeNames: readonly ["redirect", "json-api", "typescript", "middleware", "health-page", "static-page", "static-plus-api", "cors-api", "webhook-receiver", "contact-form", "authenticated-json-api", "protected-download", "store-crud"];
24
24
  export declare function listRecipes(): Promise<RecipeSummary[]>;
25
25
  /** Matches id, description, tags and capabilities locally; no service is consulted. */
26
26
  export declare function searchRecipes(text: string): Promise<RecipeSearchResult>;
@@ -34,6 +34,10 @@ export interface RuntimeOptions {
34
34
  * whole project denies every binding it grants. Prerendering uses it to render a
35
35
  * project too large for one function snapshot in passes (docs/PRERENDER.md). */
36
36
  only?: readonly string[] | undefined;
37
+ /** Test harness only (set by `startServer` when it is given a data directory): also grant every
38
+ * route's `{env: URLCODE_DATA_DIR}` binding, and only that name, for this project revision. The value
39
+ * is the harness's own directory, not an ambient secret. Never supplied by project YAML or guest code. */
40
+ grantDataDir?: boolean | undefined;
37
41
  }
38
42
  /** Per-request facts the host may read after handle() settles; never request text. */
39
43
  export interface RequestTrace {
@@ -16,6 +16,12 @@ export interface ServerOptions extends Omit<RuntimeOptions, 'observers'> {
16
16
  observers?: Observer[] | undefined;
17
17
  metrics?: boolean | undefined;
18
18
  metricsIntervalMs?: number | undefined;
19
+ /** Test helper. Directory the project may use for its own files, offered as the `URLCODE_DATA_DIR`
20
+ * environment value (a route reads it through a declared `env` binding). Created if absent and
21
+ * never deleted by `close()`, so a later server started on the same directory sees the same data. */
22
+ dataDir?: string | undefined;
23
+ /** Test helper. Create a fresh empty data directory, offer it as `URLCODE_DATA_DIR`, and remove it on `close()`. Exclusive with `dataDir`. */
24
+ isolateData?: boolean | undefined;
19
25
  }
20
26
  export interface Server {
21
27
  server: http.Server;
@@ -31,4 +37,6 @@ export interface Server {
31
37
  origin: string;
32
38
  close(): Promise<void>;
33
39
  }
34
- export declare function startServer({ project, host, port, watch, local, log, maxBodyBytes, maxInFlightRequests, maxInFlightHealthRequests, requestLog, trustRequestId, origin, trustedProxies, observers, metrics, metricsIntervalMs, ...runtimeOptions }?: ServerOptions): Promise<Server>;
40
+ /** Starts the server. `port: 0` picks a free port, so this is also the in-process helper for tests:
41
+ * `startServer({ project, port: 0, local: true, isolateData: true })`. */
42
+ export declare function startServer(options?: ServerOptions): Promise<Server>;
@@ -94,6 +94,7 @@ export interface ExtensionInspection {
94
94
  schema: object;
95
95
  policySchema: object | null;
96
96
  hooks: object[];
97
+ authoring: object | null;
97
98
  declared: boolean;
98
99
  revisionPinned: boolean;
99
100
  mounts: string[];
@@ -113,6 +113,7 @@ export interface SiteConfig {
113
113
  favicon?: string;
114
114
  securityTxt?: SecurityTxtConfig;
115
115
  llms?: string;
116
+ notFound?: string;
116
117
  }
117
118
  /** One route as declared in YAML (plus `generated`, which site.ts stamps on the routes it adds). */
118
119
  export interface ConditionalReply {
@@ -169,6 +170,8 @@ export interface RouteConfig {
169
170
  * Never inferred or enforced: the trust decision remains the author's judgment call
170
171
  * (docs/AI-AUTHORING.md, "Deciding when a route needs sandbox: true"). */
171
172
  sandboxReason?: string;
173
+ /** Per-method `audit` coverage waiver: method to a non-empty reason. Project file only (docs/READINESS.md). */
174
+ coveredElsewhere?: Record<string, string>;
172
175
  parameters?: ParameterConfig[];
173
176
  redirect?: RedirectConfig;
174
177
  function?: FunctionConfig;
@@ -186,9 +189,16 @@ export interface RouteConfig {
186
189
  respond?: RespondSpec;
187
190
  middleware?: MiddlewareConfig[];
188
191
  policies?: PoliciesConfig;
192
+ /** Name of a top-level `shared` block; expanded away at load time, so nothing downstream sees it. */
193
+ use?: string;
189
194
  /** Set by site.ts on a route it generated (`site.<key>`); never declared in YAML. */
190
195
  generated?: string;
191
196
  }
197
+ /** A named, reusable `request` and `response.headers` block a route selects with `use`. */
198
+ export interface SharedBlock {
199
+ request?: RouteConfig['request'];
200
+ response?: RouteConfig['response'];
201
+ }
192
202
  export interface ProjectDocument {
193
203
  version: '1';
194
204
  extensions?: Record<string, ExtensionDeclaration>;
@@ -196,6 +206,7 @@ export interface ProjectDocument {
196
206
  includes?: string[];
197
207
  policies?: PoliciesConfig;
198
208
  profiles?: Record<string, PolicyLayer>;
209
+ shared?: Record<string, SharedBlock>;
199
210
  site?: SiteConfig;
200
211
  }
201
212
  /** What config.ts returns: the entry document, the merged route table and the files it came from. */
@@ -394,6 +405,8 @@ export interface PlanInventoryEntry {
394
405
  sandbox?: boolean;
395
406
  /** The route's declared `sandboxReason`, when it has one. */
396
407
  sandboxReason?: string;
408
+ /** The route's declared `coveredElsewhere` audit waivers (method to reason), when it has any. */
409
+ coveredElsewhere?: Record<string, string>;
397
410
  }
398
411
  export interface TestPlan {
399
412
  inventory: PlanInventoryEntry[];
package/dist/types.js CHANGED
@@ -55,7 +55,7 @@
55
55
 
56
56
 
57
57
 
58
-
58
+
59
59
  /** One route as declared in YAML (plus `generated`, which site.ts stamps on the routes it adds). */
60
60
 
61
61
 
@@ -83,17 +83,23 @@
83
83
 
84
84
 
85
85
 
86
+
87
+
86
88
 
87
89
 
88
90
 
89
91
 
90
92
 
93
+
94
+
91
95
 
92
96
 
93
97
 
98
+ /** A named, reusable `request` and `response.headers` block a route selects with `use`. */
99
+
94
100
 
95
101
 
96
-
102
+
97
103
 
98
104
  /** What config.ts returns: the entry document, the merged route table and the files it came from. */
99
105
 
@@ -209,6 +215,8 @@
209
215
 
210
216
 
211
217
 
218
+
219
+
212
220
 
213
221
 
214
222
 
@@ -7,7 +7,7 @@ import {stringify} from 'yaml';
7
7
  import {loadDocument} from './config.js';
8
8
  import {collectFunctionSources,routeFunctions} from './function-sources.js';
9
9
  import {authoringPath,authoringFile,readAuthoringFile,publishAuthoringProject} from './authoring-files.js';
10
- import {assert} from './errors.js';
10
+ import {assert,ConfigError} from './errors.js';
11
11
 
12
12
 
13
13
  const emitted=(path )=>path.replace(/\.ts$/,'.js');
@@ -22,7 +22,9 @@ const SANDBOX_MODULE_LIMIT=128,SANDBOX_MODULE_BYTE_LIMIT=1048576,SANDBOX_TOTAL_B
22
22
  * plugins, package resolution, subprocesses or guest code execution are used. */
23
23
  export async function buildTypeScriptProject(project ,output ,{dryRun=false} ={}) {
24
24
  const root=await realpath(project),loaded=await loadDocument(root),files=new Map (),modules=new Map ();
25
- const {default:ts}=await import('typescript');
25
+ const ts=await import('typescript').then(module=>module.default).catch(()=>{
26
+ throw new ConfigError('TypeScript authoring requires the optional typescript package (npm install --save-dev --save-exact typescript@6.0.3)');
27
+ });
26
28
  // Which trust levels a module has already been visited under, so a graph
27
29
  // reachable from both a sandboxed and a trusted route is validated under each
28
30
  // route's own rules exactly once instead of being refused outright. The
@@ -128,7 +130,7 @@ export async function buildTypeScriptProject(project ,output ,{dry
128
130
  for(const ref of [route.page,route.download])if(ref)await asset(ref.file);
129
131
  if(route.static)await directory(route.static.directory);
130
132
  }
131
- for(const ref of [loaded.document.site?.favicon,loaded.document.site?.llms])if(ref)await asset(ref);
133
+ for(const ref of [loaded.document.site?.favicon,loaded.document.site?.llms,loaded.document.site?.notFound])if(ref)await asset(ref);
132
134
  // Request fixtures describe behavior the emitted project must keep, so they travel with it.
133
135
  try{await asset('tests/requests.json');}catch(error){if(!(error instanceof Error && 'code' in error && error.code==='ENOENT'))throw error;}
134
136
  // Flatten includes using the loader's duplicate-checked route table. Original
@@ -5,7 +5,7 @@ import { randomBytes } from 'node:crypto';
5
5
  import { assert } from './errors.js';
6
6
  import { createRuntime } from './runtime.js';
7
7
 
8
- import { benchmarkTarget, hit, readCases, probeAgent } from './readiness.js';
8
+ import { benchmarkTarget, hit, readFixtures, runFixtures, isStepsFixture, probeAgent } from './readiness.js';
9
9
 
10
10
  import { runCompliance, severities } from './compliance.js';
11
11
 
@@ -45,7 +45,7 @@ const CONCURRENCY = 4;
45
45
  const MAX_REQUESTS = 10000;
46
46
  const BODY_LIMIT = 1048576;
47
47
  const SNIPPET = 200;
48
- const siteTypes = { 'site.robots': 'text/plain', 'site.sitemap': 'application/xml', 'site.securityTxt': 'text/plain', 'site.llms': 'text/plain' };
48
+ const siteTypes = { 'site.robots': 'text/plain', 'site.sitemap': 'application/xml', 'site.securityTxt': 'text/plain', 'site.llms': 'text/plain', 'site.notFound': 'text/html' };
49
49
  const isRecord = (value ) => value !== null && typeof value === 'object' && !Array.isArray(value);
50
50
  const snippet = (body ) => body.length > SNIPPET ? `${body.length} bytes: ${body.subarray(0, SNIPPET).toString('utf8')}` : body.toString('utf8');
51
51
  const tlsCode = /CERT|TLS|SSL|SELF_SIGNED/;
@@ -129,12 +129,12 @@ export async function verifyDeployment(project , { target, origin, expect
129
129
  const routes = { local: runtime.count, observed: null, expected: expectRoutes ?? null };
130
130
  let complianceReport = null;
131
131
  try {
132
- const plan = runtime.testPlan(), fixtures = await readCases(runtime.root, true);
132
+ const plan = runtime.testPlan(), fixtures = await readFixtures(runtime.root, true);
133
133
  const loaded = await loadDocument(runtime.root);
134
134
  await applySite(loaded, { origin: publicOrigin, log: () => {} });
135
- const cases = [...plan.cases, ...fixtures];
135
+ const fixtureRequests = fixtures.reduce((sum, fixture) => sum + (isStepsFixture(fixture) ? fixture.steps.filter(step => !('restart' in step)).length : 1), 0);
136
136
  const literal = plan.inventory.filter(route => route.state === 'active' && !route.path.includes('{'));
137
- assert(4 + cases.length + literal.length * 4 <= MAX_REQUESTS, `Verification would send more than ${MAX_REQUESTS} requests`);
137
+ assert(4 + plan.cases.length + fixtureRequests + literal.length * 4 <= MAX_REQUESTS, `Verification would send more than ${MAX_REQUESTS} requests`);
138
138
  check(expectRoutes === undefined || plan.inventory.length === expectRoutes, { check: 'probes', severity: 'high', message: 'configured route count differs from --expect-routes', expected: String(expectRoutes), observed: String(plan.inventory.length) });
139
139
 
140
140
  // 1. Probes. A transport failure on the health probe ends the run: nothing
@@ -162,13 +162,19 @@ export async function verifyDeployment(project , { target, origin, expect
162
162
  // 2. Fixtures and generated cases, exactly as `urlcode test --target`
163
163
  // would send them: sequentially, through hit(), against the target.
164
164
  const stub = { address: { address: '127.0.0.1', family: 'IPv4', port: 0 }, root: runtime.root, testPlan: () => plan };
165
- for (const [i, test] of cases.entries()) {
166
- requests++;
167
- const result = await hit(stub, test, agent, destination);
165
+ // A fixture with a restart step cannot run here: a live deployment is not ours to close and
166
+ // start. It is skipped as a whole, never partly, and named in the report and the log.
167
+ const record = (n , source , test , original , result ) => {
168
168
  let route ; try { route = plan.resolve(test.path); } catch { /* an invalid-path negative fixture */ }
169
- const label = `${i < plan.cases.length ? 'generated' : 'fixture'} case ${i + 1} ${test.method ?? 'GET'} ${test.path}`;
169
+ // The label prints the fixture as written: a substituted path may hold a captured value.
170
+ const label = `${source} case ${n} ${original.method ?? 'GET'} ${original.path}`;
170
171
  check(result.pass, { check: 'fixtures', severity: 'high', ...(route === undefined ? {} : { route }), message: result.error ? `${label}: ${result.error} error` : `${label}: response did not match the case`, expected: String(test.status), observed: String(result.status) });
171
- }
172
+ };
173
+ for (const [i, test] of plan.cases.entries()) { requests++; record(i + 1, 'generated', test, test, await hit(stub, test, agent, destination)); }
174
+ await runFixtures(fixtures, {
175
+ app: stub, agent, target: destination,
176
+ skipped: (fixture, reason) => { notes.push(`fixture ${fixture} ${reason}; none of its requests were sent and it was not verified`); log({ event: 'skipped', check: 'fixtures', fixture, reason: 'restart' }); },
177
+ }, step => { if (step.result.error !== 'skipped' && step.result.error !== 'unresolved') requests++; record(step.case, 'fixture', step.test, step.original, step.result); }, plan.cases.length + 1);
172
178
 
173
179
  // 3. Declared versus observed, per active literal route.
174
180
  await each(literal, async route => {
@@ -0,0 +1,16 @@
1
+ # Body validation
2
+
3
+ Declares the shape of a JSON request body and two input formats in YAML, so a
4
+ route needs no hand-written validation code.
5
+
6
+ - `request.body.schema` accepts a small JSON Schema subset. A body that breaks it
7
+ answers **422**; malformed JSON is still 400 and a wrong media type 415.
8
+ - Parameter `format: uuid` and `pattern` reject bad path and query values with 400.
9
+ - `pattern` must set `maxLength` (at most 128) and is refused when it repeats a
10
+ group, uses lookaround or a backreference, or has more than three unbounded
11
+ quantifiers. See [HTTP configuration](../../docs/HTTP.md).
12
+
13
+ The 422 answer is plain text listing only paths the schema declares; it never
14
+ echoes what the client sent. A client that sends `Accept: application/json`
15
+ receives a structured body instead, with each issue's `pointer`, `keyword` and
16
+ expected constraint (never the value); `*/*` and browsers keep the text.
@@ -0,0 +1,17 @@
1
+ id: body-validation
2
+ description: Declarative JSON body validation with request.body.schema, and parameter format uuid and a bounded pattern, with no validation code.
3
+ tags: [request, body, validation, schema, json, parameters, uuid, pattern, "422"]
4
+ complexity: intermediate
5
+ capabilities: [enabled, methods, parameters, request.body, respond]
6
+ targets: {self-hosted: compatible, aws: compatible, vercel: compatible, cloudflare: compatible, static: refused}
7
+ routes: 3
8
+ files: [urlcode.yaml, tests/requests.json, README.md]
9
+ tests:
10
+ fixtures: tests/requests.json
11
+ commands:
12
+ - urlcode validate --local --project examples/body-validation
13
+ - urlcode test --project examples/body-validation
14
+ - urlcode audit --project examples/body-validation --expect-routes 3
15
+ behavior:
16
+ - POST /todos with a valid JSON body answers 201; a body that breaks the schema answers 422 with the declared paths that failed; malformed JSON answers 400; a wrong media type answers 415
17
+ - a path id that is not a uuid, or a slug that does not match the declared pattern, answers 400