@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
@@ -0,0 +1,159 @@
1
+ import { assert } from './errors.js';
2
+ import { assertSafePattern, maxPatternInputLength } from './pattern-guard.js';
3
+
4
+ /**
5
+ * The JSON Schema subset a route may declare for `request.body.schema`.
6
+ * It is interpreted here rather than compiled by Ajv so the same code runs on
7
+ * every host, including the Worker, with no code generation and no author
8
+ * regex outside `pattern-guard.ts`.
9
+ */
10
+
11
+
12
+
13
+
14
+
15
+
16
+
17
+ export const uuidFormat = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
18
+ const types = ['object', 'array', 'string', 'integer', 'number', 'boolean', 'null'];
19
+ const keywords = new Set(['type','properties','required','additionalProperties','items','enum','minLength','maxLength','pattern','format','minimum','maximum','minItems','maxItems']);
20
+ const limits = { depth: 6, nodes: 128, properties: 64, enums: 64, length: 8192, items: 10000 };
21
+ const isRecord = (value ) => typeof value === 'object' && value !== null && !Array.isArray(value);
22
+ const own = (object , key ) => Object.hasOwn(object, key);
23
+ const patterns = new WeakMap ();
24
+
25
+ /** Rejects, at load time, any schema outside the supported subset or its limits. */
26
+ export function assertBodySchema(schema ) {
27
+ let nodes = 0;
28
+ const walk = (node , depth ) => {
29
+ assert(isRecord(node), 'Body schema must be an object');
30
+ assert(depth <= limits.depth && ++nodes <= limits.nodes, 'Body schema is too large or deeply nested');
31
+ for (const key of Object.keys(node)) assert(keywords.has(key), `Unsupported body schema keyword: ${key}`);
32
+ const kind = node.type;
33
+ assert(kind === undefined || (typeof kind === 'string' && types.includes(kind)), 'Body schema type must be one of ' + types.join(', '));
34
+ const isKind = (...allowed ) => typeof kind === 'string' && allowed.includes(kind);
35
+ const uses = (...names ) => names.some(name => own(node, name));
36
+ assert(!uses('properties', 'required', 'additionalProperties') || isKind('object'), 'properties, required and additionalProperties require type object');
37
+ assert(!uses('items', 'minItems', 'maxItems') || isKind('array'), 'items, minItems and maxItems require type array');
38
+ assert(!uses('minLength', 'maxLength', 'pattern', 'format') || isKind('string'), 'String keywords require type string');
39
+ assert(!uses('minimum', 'maximum') || isKind('integer', 'number'), 'Numeric bounds require type integer or number');
40
+ for (const name of ['minLength', 'maxLength', 'minItems', 'maxItems']) {
41
+ if (!own(node, name)) continue;
42
+ const value = node[name], cap = name.endsWith('Items') ? limits.items : limits.length;
43
+ assert(Number.isInteger(value) && (value ) >= 0 && (value ) <= cap, `${name} must be an integer from 0 to ${cap}`);
44
+ }
45
+ for (const name of ['minimum', 'maximum']) assert(!own(node, name) || (typeof node[name] === 'number' && Number.isFinite(node[name])), `${name} must be a finite number`);
46
+ if (own(node, 'format')) assert(node.format === 'uuid', 'Unsupported body schema format (supported: uuid)');
47
+ if (own(node, 'pattern')) {
48
+ assert(typeof node.pattern === 'string', 'pattern must be a string');
49
+ assertSafePattern(node.pattern);
50
+ assert(typeof node.maxLength === 'number' && node.maxLength <= maxPatternInputLength, `pattern requires maxLength of at most ${maxPatternInputLength}`);
51
+ }
52
+ if (own(node, 'enum')) {
53
+ assert(Array.isArray(node.enum) && node.enum.length >= 1 && node.enum.length <= limits.enums, `enum must list 1 to ${limits.enums} values`);
54
+ assert(node.enum.every(value => value === null || ['string', 'number', 'boolean'].includes(typeof value)), 'enum values must be scalars');
55
+ }
56
+ if (own(node, 'required')) {
57
+ assert(Array.isArray(node.required) && node.required.every(name => typeof name === 'string') && new Set(node.required).size === node.required.length, 'required must be a list of unique names');
58
+ const declared = isRecord(node.properties) ? node.properties : {};
59
+ assert((node.required ).every(name => own(declared, name)), 'required names must be declared in properties');
60
+ }
61
+ if (own(node, 'additionalProperties')) assert(typeof node.additionalProperties === 'boolean', 'additionalProperties must be true or false');
62
+ if (own(node, 'properties')) {
63
+ assert(isRecord(node.properties) && Object.keys(node.properties).length <= limits.properties, `properties must declare at most ${limits.properties} names`);
64
+ for (const child of Object.values(node.properties)) walk(child, depth + 1);
65
+ }
66
+ if (own(node, 'items')) walk(node.items, depth + 1);
67
+ };
68
+ walk(schema, 1);
69
+ }
70
+
71
+ const describe = (value ) => value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value;
72
+ /**
73
+ * One validation failure. `pointer` is an RFC 6901 pointer built only from names
74
+ * the schema declared (array positions appear as `[]`, not an index), `keyword`
75
+ * is the schema keyword that failed, and `expected` is the schema's own
76
+ * constraint. Nothing the client sent is ever placed in an issue.
77
+ */
78
+
79
+ const escapePointer = (name ) => name.replace(/~/g, '~0').replace(/\//g, '~1');
80
+ /** Structured failures for `value`; `checkBodySchema` renders the same list as text. */
81
+ export function bodySchemaIssues(schema , value , path = '', issues = [], max = 8) {
82
+ const fail = (keyword , message , extra = {}) => { if (issues.length < max) issues.push({ pointer: path, keyword, message, ...extra }); };
83
+ const kind = schema.type;
84
+ if (kind) {
85
+ const actual = describe(value);
86
+ const ok = kind === 'integer' ? Number.isInteger(value) : kind === 'number' ? typeof value === 'number' : actual === kind;
87
+ if (!ok) { fail('type', `must be ${kind === 'array' || kind === 'object' || kind === 'integer' ? 'an' : 'a'} ${kind}`, { expected: kind }); return issues; }
88
+ }
89
+ if (schema.enum && !schema.enum.some(item => item === value)) {
90
+ const listable = schema.enum.length <= 16 && schema.enum.every(item => typeof item !== 'string' || item.length <= 64);
91
+ fail('enum', 'must be one of the declared values', listable ? { expected: schema.enum } : {});
92
+ }
93
+ if (typeof value === 'string') {
94
+ if (schema.minLength !== undefined && [...value].length < schema.minLength) fail('minLength', `must be at least ${schema.minLength} characters`, { expected: schema.minLength });
95
+ if (schema.maxLength !== undefined && [...value].length > schema.maxLength) fail('maxLength', `must be at most ${schema.maxLength} characters`, { expected: schema.maxLength });
96
+ else {
97
+ if (schema.format === 'uuid' && !uuidFormat.test(value)) fail('format', 'must be a uuid', { expected: 'uuid' });
98
+ if (schema.pattern !== undefined) {
99
+ let regex = patterns.get(schema);
100
+ if (!regex) patterns.set(schema, regex = new RegExp(schema.pattern, 'u'));
101
+ if (!regex.test(value)) fail('pattern', 'does not match the declared pattern');
102
+ }
103
+ }
104
+ }
105
+ if (typeof value === 'number') {
106
+ if (schema.minimum !== undefined && value < schema.minimum) fail('minimum', `must be at least ${schema.minimum}`, { expected: schema.minimum });
107
+ if (schema.maximum !== undefined && value > schema.maximum) fail('maximum', `must be at most ${schema.maximum}`, { expected: schema.maximum });
108
+ }
109
+ if (Array.isArray(value)) {
110
+ if (schema.minItems !== undefined && value.length < schema.minItems) fail('minItems', `must have at least ${schema.minItems} items`, { expected: schema.minItems });
111
+ if (schema.maxItems !== undefined && value.length > schema.maxItems) fail('maxItems', `must have at most ${schema.maxItems} items`, { expected: schema.maxItems });
112
+ else if (schema.items) for (const item of value) { if (issues.length >= max) break; bodySchemaIssues(schema.items, item, `${path}/[]`, issues, max); }
113
+ }
114
+ if (isRecord(value)) {
115
+ const declared = schema.properties || {};
116
+ for (const name of schema.required || []) if (!own(value, name)) fail('required', `is missing required property ${name}`, { property: name });
117
+ if (schema.additionalProperties === false && Object.keys(value).some(name => !own(declared, name))) fail('additionalProperties', 'has a property the schema does not declare');
118
+ for (const [name, child] of Object.entries(declared)) if (own(value, name)) bodySchemaIssues(child, value[name], `${path}/${escapePointer(name)}`, issues, max);
119
+ }
120
+ return issues;
121
+ }
122
+ /**
123
+ * Returns fixed-wording failures for `value`, each naming only a path the
124
+ * schema itself declared (array positions appear as `[]`). Nothing the client
125
+ * sent is echoed, so the answer stays safe to render as plain text.
126
+ */
127
+ export function checkBodySchema(schema , value ) {
128
+ return bodySchemaIssues(schema, value).map(bodySchemaLine);
129
+ }
130
+ /** The plain-text line for an issue: array positions print as `[]` appended to the path, root as `/`. */
131
+ export const bodySchemaLine = (issue ) => `${issue.pointer.replace(/\/\[\]/g, '[]') || '/'} ${issue.message}`;
132
+
133
+ /**
134
+ * Negotiation rule (conservative): the structured answer is sent only when the
135
+ * Accept header names application/json explicitly with q > 0 and no higher q
136
+ * for an explicit text/plain. Wildcard ranges, a missing header and everything
137
+ * else keep the plain-text answer, so curl, browsers and existing clients see
138
+ * no change.
139
+ */
140
+ export function prefersJson(accept ) {
141
+ if (!accept || accept.length > 1024) return false;
142
+ let json = 0, text = 0;
143
+ for (const range of accept.split(',')) {
144
+ const [type = '', ...params] = range.split(';').map(part => part.trim().toLowerCase());
145
+ const q = params.find(param => param.startsWith('q='));
146
+ const weight = q === undefined ? 1 : /^q=(?:0(?:\.\d{0,3})?|1(?:\.0{0,3})?)$/.test(q) ? Number(q.slice(2)) : 0; // a malformed weight never opts in
147
+ if (type === 'application/json') json = Math.max(json, weight);
148
+ else if (type === 'text/plain') text = Math.max(text, weight);
149
+ }
150
+ return json > 0 && json >= text;
151
+ }
152
+ const maxIssueBytes = 4096;
153
+ /** Renders the JSON answer: never more than `maxIssueBytes`, dropping trailing issues and saying so. */
154
+ export function bodySchemaJson(issues ) {
155
+ const shape = (list , truncated ) => JSON.stringify({ error: 'body_validation_failed', message: 'Request body failed validation', ...(truncated ? { truncated } : {}), issues: list });
156
+ let list = issues, text = shape(list, false);
157
+ while (new TextEncoder().encode(text).length > maxIssueBytes && list.length) { list = list.slice(0, -1); text = shape(list, true); }
158
+ return text;
159
+ }
@@ -3,6 +3,7 @@ import { mkdir, writeFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import Ajv from 'ajv/dist/2020.js';
5
5
  import standaloneCode from 'ajv/dist/standalone/index.js';
6
+ import { uuidFormat } from './body-schema.js';
6
7
  import { loadDocument } from './config.js';
7
8
  import { compileRoutes } from './router.js';
8
9
  import { assert } from './errors.js';
@@ -100,6 +101,7 @@ export async function buildCloudflare(project , { out = 'dist/cloudflare'
100
101
  assert(routes.length, 'No routes to build');
101
102
 
102
103
  const ajv = new Ajv.default({ code:{ source:true, esm:true }, strict:false, allErrors:false });
104
+ ajv.addFormat('uuid',uuidFormat); // the same allowlisted format router.ts registers; without it the compiled validator silently ignores `format`
103
105
  const validators = {}, serialised = [];
104
106
  for (const route of routes) {
105
107
  const parameters = [];
@@ -248,7 +248,7 @@ export const capabilityDetails = {
248
248
  enabled: { kind: 'routing', summary: 'Route on/off switch; disabled routes are still validated.', schema: ['enabled'], constraints: ['Boolean; defaults to true'], grants: [] },
249
249
  expires: { kind: 'routing', summary: 'Timestamp after which the route stops matching.', schema: ['expires'], constraints: ['UTC timestamp YYYY-MM-DDTHH:MM:SS[.mmm]Z; expired routes are still validated'], grants: [] },
250
250
  'request.body': { kind: 'request', summary: 'Request body admission limits and format.', schema: ['request.body'],
251
- constraints: ['`maxBytes` 0 to 1048576; up to 16 lowercase `contentTypes`', '`format` text or json'], grants: [] },
251
+ constraints: ['`maxBytes` 0 to 1048576; up to 16 lowercase `contentTypes`', '`format` text or json', '`schema` (JSON only): a bounded JSON Schema subset; failures return 422'], grants: [] },
252
252
  'response.headers': { kind: 'request', summary: 'Static response headers added to the reply.', schema: ['response.headers'],
253
253
  constraints: ['At most 64 headers; values up to 4096 characters or lists of at most 16', 'Cloudflare coalesces duplicate headers'], grants: [] },
254
254
  bindings: { kind: 'binding', summary: 'Route `env` literals/references and `secrets` references.', schema: ['env', 'secrets'],
package/dist/cli.js CHANGED
@@ -15,7 +15,7 @@ import {scaffoldProject} from './scaffold.js';
15
15
  import { initProject, addRedirect } from './authoring.js';
16
16
  import { initProjectWith, parseWithNames } from './init-with.js';
17
17
  import { collectDependencySet, installSteps, parsePin } from './project-dependencies.js';
18
- import { runProjectTests } from './project-tests.js';
18
+ import { runProjectTests, startRestartable } from './project-tests.js';
19
19
  import { verifyDeployment, failLevels } from './verify-deployment.js';
20
20
 
21
21
  import { loadOperatorPolicy, prepareFunctionSnapshot, requestedPermissions } from './policy.js';
@@ -28,9 +28,11 @@ import { loadComplianceRules, profileNames as complianceProfiles } from './compl
28
28
  import { parseRouteSnapshot, diffRoutes, renderRouteDiff } from './route-diff.js';
29
29
  import { readFile } from 'node:fs/promises';
30
30
 
31
- const usage = `URLCode 0.4.2 — local/self-hosted runtime
32
- urlcode init <directory> [--with ui,auth,admin] [--manifest|--no-manifest] [--pin @scope/pkg=specifier]
33
- # --with: layered site from installed @jimhoyd/urlcode-<name> packages, with a package.json pinning them exactly; name ui first
31
+ const usage = `URLCode 0.4.6 — local/self-hosted runtime
32
+ urlcode init <directory> [--template page] [--with ui,auth,admin] [--ack extension:id] [--manifest|--no-manifest] [--pin @scope/pkg=specifier]
33
+ # --template page: the smallest project (urlcode.yaml, public/index.html, README.md, tests/requests.json), one page route; not combinable with --with
34
+ # --with: layered site from installed @jimhoyd/urlcode-<name> packages, with a package.json pinning them exactly; --with is an unordered set, core orders the host from each extension's declared requirements and refuses a missing requirement, conflict or cycle before writing
35
+ # --ack: repeatable, qualified acknowledgement of a risk an extension names when it refuses (for example store:public-write); do not pass it pre-emptively, the refusal prints the exact command. Rejected when no scaffold consumes it
34
36
  # --manifest: also pin the runtime for a route-only project; --no-manifest: --with without a package.json
35
37
  # --pin: record a local path or tarball instead of the registry version; repeatable. No install is ever run for you.
36
38
  urlcode scaffold [--project directory] [--dry-run]
@@ -57,6 +59,7 @@ const usage = `URLCode 0.4.2 — local/self-hosted runtime
57
59
  [--compliance baseline|strict|privacy|none] [--compliance-rules ...] [--compliance-ignore id,id] [--compliance-warn]
58
60
  # compares the running deployment's responses with what this project declares; never follows redirects, no --insecure
59
61
  urlcode permissions [--project directory] # inspect requested bindings and egress origins; grants nothing
62
+ urlcode test [--project directory] [--verbose] # quiet by default: prints failing cases and the summary; --verbose adds every request log
60
63
  urlcode explain [/route] [--project directory] [--target self-hosted|cloudflare|aws|vercel|static] [--host-file ...] [--json]
61
64
  # effective methods, handler, middleware, inputs, policies, cache outcome, bindings and target support from the compiled configuration
62
65
  urlcode manifest [--project directory] [--json] # generated semantic manifest; build writes the same file as manifest.json
@@ -83,14 +86,14 @@ Dev loads .env.local and watches; serve does neither. Functions run trusted and
83
86
  const print = (value ) => process.stdout.write(typeof value === 'string' ? value : JSON.stringify(value) + '\n');
84
87
  const options = {
85
88
  json:{ type:'boolean' }, yaml:{ type:'boolean' }, report:{type:'string'}, 'accept-provider-differences':{type:'boolean'},
86
- project:{ type:'string', default:'.' }, 'host-file':{type:'string'}, with:{type:'string'},
87
- manifest:{type:'boolean'}, 'no-manifest':{type:'boolean'}, pin:{type:'string', multiple:true},
89
+ project:{ type:'string', default:'.' }, 'host-file':{type:'string'}, with:{type:'string'}, template:{type:'string'},
90
+ manifest:{type:'boolean'}, 'no-manifest':{type:'boolean'}, pin:{type:'string', multiple:true}, ack:{type:'string', multiple:true},
88
91
  port:{ type:'string' }, host:{ type:'string', default:'127.0.0.1' },
89
92
  'expect-routes':{type:'string'}, requests:{type:'string'}, concurrency:{type:'string'}, seconds:{type:'string'}, 'max-p95-ms':{type:'string'}, warmup:{type:'string'}, target:{type:'string'},
90
93
  workers:{type:'string'}, 'function-timeout-ms':{type:'string'}, 'max-response-bytes':{type:'string'}, 'max-body-bytes':{type:'string'},
91
94
  'max-in-flight':{type:'string'}, 'max-in-flight-health':{type:'string'}, 'request-log':{type:'string'}, 'trust-request-id':{type:'boolean'}, 'trusted-proxies':{type:'string'}, metrics:{type:'boolean'},
92
95
  release:{type:'string'}, 'git-commit':{type:'string'}, 'timeout-ms':{type:'string'}, 'fail-on':{type:'string'}, 'expect-metrics':{type:'boolean'},
93
- budget:{type:'string'}, stats:{type:'boolean'}, out:{type:'string'}, 'dry-run':{type:'boolean'}, compare:{type:'string'}, format:{type:'string'}, compliance:{type:'string'}, 'compliance-rules':{type:'string'}, 'compliance-ignore':{type:'string'}, 'compliance-warn':{type:'boolean'}, policy:{ type:'string' }, origin:{ type:'string' }, alias:{ type:'string' }, local:{ type:'boolean' }, 'allow-authoring':{ type:'boolean' }, help:{ type:'boolean', short:'h' },
96
+ budget:{type:'string'}, stats:{type:'boolean'}, out:{type:'string'}, 'dry-run':{type:'boolean'}, compare:{type:'string'}, format:{type:'string'}, compliance:{type:'string'}, 'compliance-rules':{type:'string'}, 'compliance-ignore':{type:'string'}, 'compliance-warn':{type:'boolean'}, policy:{ type:'string' }, origin:{ type:'string' }, alias:{ type:'string' }, local:{ type:'boolean' }, verbose:{ type:'boolean' }, 'allow-authoring':{ type:'boolean' }, help:{ type:'boolean', short:'h' },
94
97
  } ;
95
98
 
96
99
 
@@ -136,10 +139,17 @@ function formatExtensions(report ) {
136
139
  for (const item of report.extensions) lines.push(`Registered: ${item.name} (contract ${item.version}; targets ${item.targets.join(', ') || '(none)'}; ${item.declared ? 'declared' : 'not declared'}; revision ${item.revisionPinned ? 'pinned' : 'NOT pinned'})`,
137
140
  ` mounts: ${item.mounts.join(', ') || '(none)'}`, ` policy routes: ${item.policyRoutes.join(', ') || '(none)'}`, ` credential headers: ${item.credentialHeaders.join(', ') || '(none)'}`,
138
141
  ` hooks: ${item.hooks.length ? item.hooks.map(hook => `${String((hook ).name)} (${String((hook ).kind)})`).join(', ') : '(none)'}`,
142
+ ` authoring: ${item.authoring ? JSON.stringify(item.authoring) : '(none)'}`,
139
143
  ` configuration schema: ${JSON.stringify(item.schema)}`, ` policy schema: ${item.policySchema ? JSON.stringify(item.policySchema) : '(none)'}`);
140
144
  lines.push(report.note);
141
145
  return lines.join('\n') + '\n';
142
146
  }
147
+ // Name the bound host and port (from the error, never user text) and a next step. Values are validated, not echoed.
148
+ function addressInUseMessage(error ) {
149
+ const { address,port } = error ;
150
+ const where = typeof port === 'number' && Number.isInteger(port) && port > 0 && port < 65536 ? `Port ${port}${typeof address === 'string' && /^[0-9A-Fa-f:.]{2,45}$/.test(address) ? ` on ${address}` : ''}` : 'The port';
151
+ return `${where} is already in use; pick another with --port N, or stop the process using it`;
152
+ }
143
153
  const errorMessages = { ERR_PARSE_ARGS_UNKNOWN_OPTION:'Unknown option; use --help', EEXIST:'Destination or edit lock already exists', ENOENT:'Required file or directory not found', EADDRINUSE:'Port is already in use', EACCES:'Permission denied' };
144
154
  let operatorHost = {};
145
155
  let serving = false;
@@ -154,9 +164,11 @@ try {
154
164
  // The MCP server and context command load and release the host themselves.
155
165
  if (command !== 'mcp' && command !== 'context') operatorHost = await loadOperatorHost(values['host-file'], values.project);
156
166
  }
167
+ if (values.template !== undefined && command !== 'init') throw new ConfigError('--template is only supported by init');
157
168
  if (values.with !== undefined && command !== 'init') throw new ConfigError('--with is only supported by init');
158
169
  if ((values.manifest || values['no-manifest'] || values.pin !== undefined) && command !== 'init') throw new ConfigError('--manifest/--no-manifest/--pin are only supported by init');
159
170
  if (values.manifest && values['no-manifest']) throw new ConfigError('Use either --manifest or --no-manifest');
171
+ if (values.ack !== undefined && (command !== 'init' || values.with === undefined)) throw new ConfigError('--ack is only supported by init with --with');
160
172
  if (values['allow-authoring'] && command !== 'mcp') throw new ConfigError('--allow-authoring is only supported by mcp');
161
173
  const hostOptions = { extensions: operatorHost.extensions, plugins: operatorHost.plugins };
162
174
  if ((!['import','recipes','recipe','examples','example','bulk-import'].includes(command) && extra.length) || (!['init','add','import','recipes','recipe','examples','example','bulk-import','explain','capabilities','schema'].includes(command) && arg)) throw new ConfigError('Unexpected positional arguments');
@@ -204,7 +216,9 @@ try {
204
216
  if(!['json','markdown'].includes(format))throw new ConfigError('Use --format json or markdown');
205
217
  if(values.format!==undefined && values.compare===undefined)throw new ConfigError('--format applies to routes --compare');
206
218
  const started=performance.now();
207
- const app=await startServer({...hostOptions,project:values.project,port:0,local:true,permissions,origin:values.origin,log:()=>{}});
219
+ const serverOptions={...hostOptions,project:values.project,port:0,local:true,permissions,origin:values.origin,log:()=>{}};
220
+ // Only audit replays fixtures, so only audit needs a server its restart steps can restart.
221
+ const app=command==='audit'?await startRestartable(serverOptions):await startServer(serverOptions);
208
222
  const startupMs=performance.now()-started;
209
223
  try {
210
224
  if(command==='routes') {
@@ -281,13 +295,15 @@ try {
281
295
  const wanted = values.with === undefined ? values.manifest === true : !values['no-manifest'];
282
296
  const pins = new Map((values.pin ?? []).map(parsePin));
283
297
  if (pins.size && !wanted) throw new ConfigError('--pin needs a manifest; drop --no-manifest or add --manifest');
298
+ if (values.template !== undefined && values.template !== 'default' && values.template !== 'page') throw new ConfigError('--template must be page');
299
+ if (values.template === 'page' && values.with !== undefined) throw new ConfigError('--template page cannot be combined with --with');
284
300
  if (values.with === undefined) {
285
301
  const set = wanted ? await collectDependencySet([], [], { overrides: pins }) : undefined;
286
- const created = await initProject(arg, { manifest: set });
302
+ const created = await initProject(arg, { manifest: set, template: values.template === 'page' ? 'page' : 'default' });
287
303
  print(set ? { event:'created', dependencies:set.pins, nextSteps:installSteps(created, set) } : { event:'created' });
288
304
  break;
289
305
  }
290
- const created = await initProjectWith(arg, parseWithNames(values.with), { manifest: wanted, pins });
306
+ const created = await initProjectWith(arg, parseWithNames(values.with), { manifest: wanted, pins, acknowledgements: values.ack ?? [] });
291
307
  print({ event:'created', ...created, review:`Review ${created.project}/urlcode.yaml and pin its revision explicitly (for example PROJECT_SHA256=${created.projectSha256}); re-review after any project change` });
292
308
  break;
293
309
  }
@@ -299,7 +315,7 @@ try {
299
315
  if (!arg) throw new ConfigError('Provide an HTTP(S) destination URL');
300
316
  print({ event:'added', path:await addRedirect(values.project,arg,values.alias) }); break;
301
317
  case 'test': {
302
- const result = await runProjectTests(values.project, { ...hostOptions, log:print, permissions, origin:values.origin });
318
+ const result = await runProjectTests(values.project, { ...hostOptions, log:values.verbose ? print : (event ) => { if ((event ).event === 'test' && (event ).pass === false) print(event); }, permissions, origin:values.origin });
303
319
  print(result); if (result.failed) process.exitCode = 1; break;
304
320
  }
305
321
  case 'doctor':
@@ -324,7 +340,7 @@ try {
324
340
  }
325
341
  } catch (error) {
326
342
  const code = typeof error === 'object' && error !== null && 'code' in error && typeof error.code === 'string' ? error.code : undefined;
327
- const message = (error instanceof ConfigError || error instanceof HttpError) ? error.message : ((code !== undefined ? errorMessages[code] : undefined) || 'Operation failed; check project files, module dependencies and command options');
343
+ const message = code === 'EADDRINUSE' ? addressInUseMessage(error) : (error instanceof ConfigError || error instanceof HttpError) ? error.message : ((code !== undefined ? errorMessages[code] : undefined) || 'Operation failed; check project files, module dependencies and command options');
328
344
  process.stderr.write(JSON.stringify({ event:'error', message }) + '\n'); process.exitCode = 1;
329
345
  } finally {
330
346
  if (!serving) {
package/dist/config.js CHANGED
@@ -4,8 +4,10 @@ import { resolve, relative, isAbsolute, extname } from 'node:path';
4
4
  import { createHash } from 'node:crypto';
5
5
  import { parseDocument, visit, isAlias, isScalar, isMap, isNode } from 'yaml';
6
6
  import Ajv from 'ajv/dist/2020.js';
7
+
7
8
  import { assert, ConfigError } from './errors.js';
8
-
9
+ import { reservedResponseHeaders } from './http-policy.js';
10
+
9
11
 
10
12
  /** What config-worker.ts posts back: the loaded document, or the ConfigError message. */
11
13
 
@@ -14,7 +16,7 @@ import { assert, ConfigError } from './errors.js';
14
16
  // The schema file is this package's own; JSON.parse gives unknown and Ajv takes it as a schema object.
15
17
  const schema = JSON.parse(await readFile(new URL('../schemas/urlcode.schema.json', import.meta.url), 'utf8')) ;
16
18
  // Node hands the CJS module.exports (the class) to a default import; TypeScript types it as the namespace, whose .default is the same class.
17
- const validate = new Ajv.default({ allErrors: false, strict: true, strictRequired: false, allowUnionTypes: true }).compile(schema);
19
+ const validate = new Ajv.default({ allErrors: false, verbose: true, strict: true, strictRequired: false, allowUnionTypes: true }).compile(schema);
18
20
  export const MAX_CONFIG_BYTES = 32 * 1024 * 1024;
19
21
  export function parseYaml(text ) {
20
22
  assert(Buffer.byteLength(text) <= MAX_CONFIG_BYTES, 'Configuration exceeds 32 MiB');
@@ -48,15 +50,80 @@ export function parseYaml(text ) {
48
50
  inspect(data);
49
51
  return data;
50
52
  }
51
- export function validateDocument(data ) {
53
+ const MAX_NAMED_KEY = 64;
54
+ const quoteKey = (key ) => JSON.stringify(key.length > MAX_NAMED_KEY ? `${key.slice(0, MAX_NAMED_KEY)}...` : key);
55
+ const MAX_LISTED_KEYS = 10;
56
+ const editDistance = (a , b ) => {
57
+ let row = Array.from({ length: b.length + 1 }, (_, j) => j);
58
+ for (let i = 1; i <= a.length; i++) {
59
+ const next = [i];
60
+ for (let j = 1; j <= b.length; j++) next[j] = Math.min(row[j] + 1, next[j - 1] + 1, row[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
61
+ row = next;
62
+ }
63
+ return row[b.length] ;
64
+ };
65
+ /** The allowed key an unknown key most likely meant: case-insensitive match, a prefix (3+ chars) either way, or edit distance <= 2. */
66
+ function closestKey(key , allowed ) {
67
+ if (key.length > MAX_NAMED_KEY) return undefined;
68
+ const k = key.toLowerCase();
69
+ let best ;
70
+ let bestScore = Infinity;
71
+ for (const candidate of allowed) {
72
+ const c = candidate.toLowerCase();
73
+ const prefix = Math.min(k.length, c.length) >= 3 && (c.startsWith(k) || k.startsWith(c));
74
+ const score = k === c ? 0 : Math.min(editDistance(k, c), prefix ? 2 : Infinity);
75
+ if (score <= 2 && score < bestScore) { best = candidate; bestScore = score; }
76
+ }
77
+ return best;
78
+ }
79
+ /**
80
+ * One line for the first schema violation. Closed-key-set failures name the offending key and the keys the
81
+ * schema allows, and required failures name the missing key, because a bare keyword sends the reader hunting.
82
+ * Only key names, which come from the schema or the author's own mapping keys, are echoed, never values
83
+ * (values may hold secrets), and never more than MAX_NAMED_KEY characters of a key.
84
+ */
85
+ export function describeSchemaError(e ) {
86
+ const base = `Invalid configuration at ${e.instancePath || '/'} (${e.keyword})`;
87
+ const parent = e.parentSchema ;
88
+ if (e.keyword === 'additionalProperties') {
89
+ const key = String((e.params ).additionalProperty);
90
+ const allowed = Object.keys(parent?.properties ?? {});
91
+ const close = closestKey(key, allowed);
92
+ let list = '; no keys are allowed here';
93
+ if (close) list = `; did you mean ${quoteKey(close)}?`;
94
+ else if (allowed.length > MAX_LISTED_KEYS) list = `; allowed keys: ${allowed.slice(0, MAX_LISTED_KEYS).join(', ')}, ... (${allowed.length - MAX_LISTED_KEYS} more)`;
95
+ else if (allowed.length) list = `; allowed keys: ${allowed.join(', ')}`;
96
+ return `${base}: unknown key ${quoteKey(key)}${list} (run urlcode schema <path> for the shape)`;
97
+ }
98
+ if (e.keyword === 'required') return `${base}: missing required key ${quoteKey(String((e.params ).missingProperty))}`;
99
+ return base;
100
+ }
101
+ export function validateDocument(data , inherited ) {
52
102
  if (!validate(data)) {
53
103
  const e = validate.errors [0] ;
54
- throw new ConfigError(`Invalid configuration at ${e.instancePath || '/'} (${e.keyword})`);
104
+ throw new ConfigError(describeSchemaError(e));
55
105
  }
56
106
  const document = data ; // trust boundary: the schema just admitted it
57
- for (const [pattern, route] of Object.entries(document.routes)) document.routes[pattern] = normalizeRoute(pattern, route);
107
+ for (const [name, block] of Object.entries(document.shared ?? {}))
108
+ for (const header of Object.keys(block.response?.headers ?? {})) assert(!reservedResponseHeaders.has(header.toLowerCase()), `shared.${name}: response header ${header} is owned by the runtime or handler`);
109
+ for (const [pattern, route] of Object.entries(document.routes)) document.routes[pattern] = expandShared(pattern, normalizeRoute(pattern, route), inherited ?? document.shared);
58
110
  return document;
59
111
  }
112
+ /**
113
+ * Resolves `use: <name>` at load time. The route's own `request` or `response` key wins as a whole block
114
+ * (no deep merge); otherwise the shared block's key is copied in. `use` is removed, so the route hash,
115
+ * audit and routes output show what actually applies. An unknown name fails validation.
116
+ */
117
+ function expandShared(pattern , route , shared ) {
118
+ if (route.use === undefined) return route;
119
+ const block = shared !== undefined && Object.hasOwn(shared, route.use) ? shared[route.use] : undefined;
120
+ assert(block, `${pattern}: use references unknown shared block ${route.use}`);
121
+ const { use: _use, ...rest } = route;
122
+ const result = { ...rest };
123
+ if (rest.request === undefined && block.request !== undefined) result.request = structuredClone(block.request);
124
+ if (rest.response === undefined && block.response !== undefined) result.response = structuredClone(block.response);
125
+ return result;
126
+ }
60
127
  /** The input declaration a short-form function gets for each `{param}` it does not declare itself. */
61
128
  export const SHORT_FORM_PATH_SCHEMA = { type: 'string', minLength: 1, maxLength: 128 } ;
62
129
  // A short-form path is checked here, before the file system, so the error can name the route.
@@ -185,9 +252,10 @@ export async function loadDocumentInWorker(project )
185
252
  const path = await safeFile(root, include);
186
253
  assert(!files.includes(path), 'Duplicate include');
187
254
  files.push(path);
188
- const part = validateDocument(await readConfig(path, budget));
255
+ const part = validateDocument(await readConfig(path, budget), document.shared ?? {});
189
256
  assert(!part.includes?.length, 'Nested includes are unsupported');
190
257
  assert(part.site===undefined, 'site may only be set in the entry urlcode.yaml');
258
+ assert(part.shared===undefined, 'shared may only be set in the entry urlcode.yaml');
191
259
  for(const [name,extension]of Object.entries(part.extensions??{})){assert(!Object.hasOwn(extensions,name),'Duplicate extension declaration across files');extensions[name]=extension;assert(Object.keys(extensions).length<=16,'Maximum 16 extensions per project');}
192
260
  for (const [pattern, route] of Object.entries(part.routes)) {
193
261
  assert(!Object.hasOwn(routes, pattern), 'Duplicate route across files');
package/dist/context.js CHANGED
@@ -1,4 +1,4 @@
1
- import {readdir,readFile} from 'node:fs/promises';
1
+ import {readFile} from 'node:fs/promises';
2
2
  import {relative} from 'node:path';
3
3
  import {stringify} from 'yaml';
4
4
  import {loadDocument} from './config.js';
@@ -51,6 +51,7 @@ const constraints ={
51
51
  pathShape:{value:'exact or {param}',note:'No greedy captures or general-purpose wildcards; a segment is a literal or a named placeholder'},
52
52
  wildcardMounts:{value:false,note:'Only static and extension routes mount a subtree; nothing else matches below its path'},
53
53
  yamlInterpolation:{value:false,note:'No ${...} templating; bind typed inputs through parameters, args and context'},
54
+ builtInBeforeCode:{value:true,note:'Check built-ins before writing code: policies.security (security headers), cacheControl (four fixed values on page/download/static), request.body (size, type, JSON), methods, policies.throttle/agents/compression/cache, site (robots, sitemap, favicon, security.txt); no native storage or CORS'},
54
55
  secretsByOperatorGrant:{value:true,note:'Projects request named env and secret bindings; only an operator policy pinned to the project revision grants them'},
55
56
  };
56
57
  const routesOf=(table ) =>[...table.exact.values(),...[...table.byLength.values()].flat(),...table.mounts];
@@ -146,10 +147,7 @@ function fitBudget(context ,budget ) {
146
147
  if(!fits())throw new Error(`Context budget ${budget} is below the smallest rendering`);
147
148
  return omitted.length?{...context,omitted}:context;
148
149
  }
149
- /** Estimated size of the shipped documentation (docs/*.md and llms.txt), for comparison with an emitted context. */
150
+ /** Estimated size of the shipped offline documentation bundle, for comparison with an emitted context. */
150
151
  export async function documentationTokens() {
151
- const docs=new URL('../docs/',import.meta.url);let chars=0;
152
- for(const name of (await readdir(docs)).filter(name=>name.endsWith('.md')).sort())chars+=(await readFile(new URL(name,docs),'utf8')).length;
153
- chars+=(await readFile(new URL('../llms.txt',import.meta.url),'utf8')).length;
154
- return Math.ceil(chars/4);
152
+ return Math.ceil((await readFile(new URL('../llms-full.txt',import.meta.url),'utf8')).length/4);
155
153
  }
package/dist/errors.js CHANGED
@@ -1,7 +1,9 @@
1
1
  export class ConfigError extends Error {}
2
2
  export class HttpError extends Error {
3
3
  status ;
4
- constructor(status , message ) { super(message); this.status = status; }
4
+ /** A pre-rendered, fixed-content answer that replaces the plain-text message (used for negotiated 422 bodies). */
5
+ answer ;
6
+ constructor(status , message , answer ) { super(message); this.status = status; this.answer = answer; }
5
7
  }
6
8
  export function assert(condition , message ) {
7
9
  if (!condition) throw new ConfigError(message);
package/dist/examples.js CHANGED
@@ -21,7 +21,7 @@ import {assert} from './errors.js';
21
21
 
22
22
 
23
23
  // Fixed package data, like the recipe catalog: names come from here, metadata from each example.yaml.
24
- export const exampleNames=['assets','aws','cloudflare','compliance','conditions','cookbook','egress','extensions','monitoring','prerender','provider-conformance','tunnel','vercel'] ;
24
+ export const exampleNames=['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'] ;
25
25
  export const routeIndexFile='route-index.json';
26
26
  const examplesRoot=fileURLToPath(new URL('../examples/',import.meta.url));
27
27
  const root=(name )=>examplesRoot+name+'/';
@@ -65,6 +65,27 @@ import { validateHeaderName, validateHeaderValue } from './header-validation.js'
65
65
 
66
66
 
67
67
 
68
+
69
+ /** One project-owned customization surface, shown to people and authoring agents by CLI/MCP inspection. */
70
+
71
+
72
+
73
+
74
+
75
+
76
+
77
+
78
+
79
+ /**
80
+ * Machine-readable guidance for changing an installed extension without
81
+ * copying its behavior into the application. This is descriptive only: it
82
+ * grants nothing and is never executed by the runtime.
83
+ */
84
+
85
+
86
+
87
+
88
+
68
89
 
69
90
  /** Shared schema for project hook references. Omission means trusted execution. */
70
91
  export const extensionHookReferenceSchema={
@@ -124,6 +145,8 @@ export async function loadExtensionHooks (config
124
145
 
125
146
 
126
147
 
148
+
149
+
127
150
 
128
151
 
129
152
 
@@ -151,20 +174,39 @@ export async function loadExtensionHooks (config
151
174
 
152
175
 
153
176
 
154
-
177
+
155
178
 
179
+
180
+
181
+
182
+
183
+
184
+
185
+
156
186
 
157
187
 
158
188
 
159
189
 
160
190
 
191
+
192
+
193
+
194
+
195
+
196
+
197
+
198
+
199
+
200
+
201
+
202
+
161
203
 
162
204
 
163
205
 
164
206
 
165
207
 
166
208
 
167
-
209
+
168
210
 
169
211
 
170
212
 
@@ -175,6 +217,7 @@ export async function loadExtensionHooks (config
175
217
 
176
218
  const namePattern=/^[a-z][a-z0-9-]{0,63}$/;
177
219
  const hookNamePattern=/^[a-z][A-Za-z0-9]{0,63}$/;
220
+ const authoringNamePattern=/^[A-Za-z0-9][A-Za-z0-9 ._/-]{0,127}$/;
178
221
  const cacheHeaders=new Set(['cache-control','cdn-cache-control','vercel-cdn-cache-control','surrogate-control']);
179
222
  const segmentPattern=/^[A-Za-z0-9_-][A-Za-z0-9._-]{0,63}$/;
180
223
  export const immutableCacheControl='public, max-age=31536000, immutable';
@@ -230,6 +273,21 @@ export function prepareExtensions(document ,routes
230
273
  assert(hook.inputSchema&&typeof hook.inputSchema==='object'&&(!hook.outputSchema||typeof hook.outputSchema==='object'),'Invalid extension hook contract');
231
274
  hookNames.add(hook.name);
232
275
  }
276
+ if(registration.authoring!==undefined){
277
+ const authoring=registration.authoring;
278
+ assert(authoring&&typeof authoring==='object'&&typeof authoring.description==='string'&&authoring.description.length>=1&&authoring.description.length<=1024,'Invalid extension authoring contract');
279
+ assert(Array.isArray(authoring.surfaces)&&authoring.surfaces.length<=64,'Invalid extension authoring surfaces');
280
+ const surfaceNames=new Set ();
281
+ for(const surface of authoring.surfaces){
282
+ assert(surface&&typeof surface==='object'&&['configuration','theme','copy','component','template','stylesheet','hook','extension'].includes(surface.kind),'Invalid extension authoring surface kind');
283
+ assert(typeof surface.name==='string'&&authoringNamePattern.test(surface.name)&&!surfaceNames.has(surface.name),'Invalid extension authoring surface name');
284
+ assert(typeof surface.description==='string'&&surface.description.length>=1&&surface.description.length<=1024,'Invalid extension authoring surface description');
285
+ assert(surface.path===undefined||typeof surface.path==='string'&&surface.path.length>=1&&surface.path.length<=1024,'Invalid extension authoring surface path');
286
+ assert(surface.command===undefined||typeof surface.command==='string'&&surface.command.length>=1&&surface.command.length<=2048,'Invalid extension authoring surface command');
287
+ surfaceNames.add(surface.name);
288
+ }
289
+ assert(authoring.fastChecks===undefined||Array.isArray(authoring.fastChecks)&&authoring.fastChecks.length<=32&&authoring.fastChecks.every(check=>typeof check==='string'&&check.length>=1&&check.length<=2048),'Invalid extension authoring fast checks');
290
+ }
233
291
  provided.set(registration.name,registration);
234
292
  }
235
293
  const declarations=document.extensions??{};