@jimhoyd/urlcode 0.4.0-alpha.1 → 0.4.0-alpha.2

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 (191) hide show
  1. package/.claude/skills/urlcode-authoring/SKILL.md +24 -8
  2. package/.claude/skills/urlcode-operations/SKILL.md +10 -16
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/README.md +75 -62
  5. package/ROADMAP.md +61 -16
  6. package/SECURITY.md +23 -8
  7. package/dist/BUILD-MANIFEST.json +38 -43
  8. package/dist/agents-guide.js +18 -18
  9. package/dist/build-static.js +134 -0
  10. package/dist/capabilities.js +85 -31
  11. package/dist/capability-query.js +0 -1
  12. package/dist/cli.js +23 -31
  13. package/dist/compliance-rules/baseline.js +2 -10
  14. package/dist/compliance-rules/privacy.js +5 -16
  15. package/dist/compliance-rules/shared.js +0 -2
  16. package/dist/compliance.js +6 -8
  17. package/dist/config.js +15 -8
  18. package/dist/context.js +9 -10
  19. package/dist/examples.js +2 -2
  20. package/dist/explain-cli.js +3 -3
  21. package/dist/explain.js +5 -6
  22. package/dist/extensions.js +61 -2
  23. package/dist/function-sources.js +34 -2
  24. package/dist/function-worker.js +3 -1
  25. package/dist/functions.js +84 -13
  26. package/dist/guest-api.js +29 -3
  27. package/dist/index.js +3 -6
  28. package/dist/manifest.js +5 -7
  29. package/dist/mcp.js +1 -1
  30. package/dist/observability.js +1 -21
  31. package/dist/policies/cache.js +8 -3
  32. package/dist/policy.js +28 -9
  33. package/dist/project-tests.js +3 -3
  34. package/dist/readiness.js +32 -9
  35. package/dist/router.js +5 -7
  36. package/dist/runtime.js +77 -58
  37. package/dist/sandbox.js +48 -0
  38. package/dist/scaffold.js +0 -0
  39. package/dist/scripts/operational-drills.js +12 -54
  40. package/dist/server.js +3 -29
  41. package/dist/tooling.js +1 -1
  42. package/dist/trusted-functions.js +210 -0
  43. package/dist/types/build-static.d.ts +43 -0
  44. package/dist/types/capabilities.d.ts +14 -5
  45. package/dist/types/compliance-rules/shared.d.ts +0 -2
  46. package/dist/types/compliance.d.ts +0 -3
  47. package/dist/types/config.d.ts +2 -1
  48. package/dist/types/context.d.ts +2 -1
  49. package/dist/types/examples.d.ts +1 -1
  50. package/dist/types/extensions.d.ts +55 -0
  51. package/dist/types/function-sources.d.ts +4 -0
  52. package/dist/types/functions.d.ts +48 -5
  53. package/dist/types/guest-api.d.ts +1 -0
  54. package/dist/types/index.d.ts +3 -6
  55. package/dist/types/manifest.d.ts +1 -3
  56. package/dist/types/observability.d.ts +1 -14
  57. package/dist/types/project-tests.d.ts +1 -2
  58. package/dist/types/readiness.d.ts +10 -3
  59. package/dist/types/router.d.ts +2 -1
  60. package/dist/types/runtime.d.ts +0 -27
  61. package/dist/types/sandbox.d.ts +12 -0
  62. package/dist/types/scaffold.d.ts +0 -2
  63. package/dist/types/server.d.ts +1 -4
  64. package/dist/types/tooling.d.ts +3 -3
  65. package/dist/types/trusted-functions.d.ts +29 -0
  66. package/dist/types/types.d.ts +14 -7
  67. package/dist/types/verify-deployment.d.ts +2 -2
  68. package/dist/types.js +17 -4
  69. package/dist/typescript-authoring.js +55 -17
  70. package/dist/verify-deployment.js +3 -3
  71. package/docs/AI-AUTHORING.md +104 -9
  72. package/docs/AWS.md +4 -4
  73. package/docs/BEST-PRACTICES.md +3 -2
  74. package/docs/CAPABILITIES.md +29 -16
  75. package/docs/CAPACITY.md +128 -31
  76. package/docs/CLOUDFLARE.md +1 -2
  77. package/docs/COMPLIANCE.md +6 -9
  78. package/docs/DEPLOYMENT-CHECKS.md +1 -1
  79. package/docs/EXTENSIONS.md +183 -11
  80. package/docs/FRAMEWORK.md +35 -19
  81. package/docs/FUNCTION-SECURITY.md +129 -32
  82. package/docs/INSTALL.md +0 -5
  83. package/docs/LOAD-TESTING.md +4 -4
  84. package/docs/MIDDLEWARE.md +27 -16
  85. package/docs/MONITORING.md +2 -19
  86. package/docs/NEXT-PHASE-PLAN.md +12 -4
  87. package/docs/NEXT-STEPS.md +259 -40
  88. package/docs/OBSERVABILITY.md +7 -16
  89. package/docs/OPEN-DECISIONS.md +212 -0
  90. package/docs/OPERATIONAL-PROOF.md +26 -30
  91. package/docs/OPERATIONS.md +23 -32
  92. package/docs/POLICIES.md +15 -4
  93. package/docs/PRERENDER.md +2 -2
  94. package/docs/PROJECT-DIRECTION.md +34 -9
  95. package/docs/README.md +13 -11
  96. package/docs/RELEASE-READINESS.md +49 -29
  97. package/docs/RELEASE-SECURITY.md +89 -3
  98. package/docs/RESILIENCE.md +16 -15
  99. package/docs/ROUTING.md +8 -10
  100. package/docs/SANDBOX-REVIEW.md +19 -6
  101. package/docs/SCAFFOLDING.md +0 -2
  102. package/docs/SECURITY-AUDIT.md +40 -0
  103. package/docs/SPECIFICATION.md +60 -26
  104. package/docs/SPIKE-AI-FRAMEWORK-BENCHMARK.md +287 -0
  105. package/docs/SPIKE-BUSINESS-SUITE.md +8 -0
  106. package/docs/SPIKE-CORE-LAYERING.md +337 -0
  107. package/docs/SPIKE-DEFAULT-TRUST-MODEL.md +209 -0
  108. package/docs/SPIKE-EXTENSION-MODEL.md +2 -2
  109. package/docs/SPIKE-EXTENSIONS.md +5 -0
  110. package/docs/SPIKE-LAMBDA-COMPILE.md +4 -2
  111. package/docs/SPIKE-MONOREPO.md +322 -0
  112. package/docs/STARTERS.md +1 -1
  113. package/docs/STATIC.md +94 -0
  114. package/docs/TOOLING.md +13 -9
  115. package/docs/TUNNELS.md +0 -3
  116. package/docs/TYPESCRIPT-AUTHORING.md +27 -12
  117. package/docs/TYPESCRIPT.md +25 -4
  118. package/docs/USABILITY-REVIEW.md +7 -1
  119. package/docs/VERCEL.md +4 -5
  120. package/docs/VERSION-ALIGNMENT.md +205 -0
  121. package/docs/YAML-GUIDE.md +0 -3
  122. package/docs/YAML-REFERENCE.md +16 -6
  123. package/docs/policies/cache.md +13 -0
  124. package/examples/assets/example.yaml +1 -1
  125. package/examples/aws/example.yaml +1 -1
  126. package/examples/cloudflare/example.yaml +1 -1
  127. package/examples/conditions/example.yaml +1 -1
  128. package/examples/cookbook/example.yaml +1 -1
  129. package/examples/cookbook/middleware/bucket.mjs +12 -2
  130. package/examples/cookbook/middleware/locale.mjs +7 -3
  131. package/examples/egress/example.yaml +1 -1
  132. package/examples/extensions/example.yaml +1 -1
  133. package/examples/prerender/README.md +2 -2
  134. package/examples/prerender/example.yaml +1 -1
  135. package/examples/provider-conformance/example.yaml +1 -1
  136. package/examples/vercel/example.yaml +1 -1
  137. package/llms-full.txt +594 -219
  138. package/llms.txt +23 -12
  139. package/package.json +10 -3
  140. package/packaging/claude-plugin/.claude-plugin/plugin.json +1 -1
  141. package/packaging/claude-plugin/skills/urlcode-authoring/SKILL.md +24 -8
  142. package/packaging/claude-plugin/skills/urlcode-operations/SKILL.md +10 -16
  143. package/recipes/authenticated-json-api/recipe.yaml +1 -1
  144. package/recipes/contact-form/recipe.yaml +1 -1
  145. package/recipes/cors-api/recipe.yaml +1 -1
  146. package/recipes/health-page/recipe.yaml +1 -1
  147. package/recipes/json-api/recipe.yaml +1 -1
  148. package/recipes/middleware/middleware/bucket.mjs +12 -2
  149. package/recipes/middleware/middleware/locale.mjs +7 -3
  150. package/recipes/middleware/recipe.yaml +1 -1
  151. package/recipes/protected-download/recipe.yaml +1 -1
  152. package/recipes/redirect/recipe.yaml +1 -1
  153. package/recipes/static-plus-api/recipe.yaml +1 -1
  154. package/recipes/typescript/README.md +2 -1
  155. package/recipes/typescript/recipe.yaml +1 -1
  156. package/recipes/webhook-receiver/README.md +5 -1
  157. package/recipes/webhook-receiver/recipe.yaml +2 -1
  158. package/recipes/webhook-receiver/urlcode.yaml +7 -0
  159. package/schemas/recipe.schema.json +4 -3
  160. package/schemas/urlcode.schema.json +14 -41
  161. package/skills/urlcode/SKILL.md +29 -8
  162. package/starters/default/AGENTS.md +18 -18
  163. package/starters/default/urlcode.yaml +0 -1
  164. package/dist/link-api.js +0 -136
  165. package/dist/link-cli.js +0 -141
  166. package/dist/link-events.js +0 -76
  167. package/dist/link-records.js +0 -31
  168. package/dist/link-store-worker.js +0 -150
  169. package/dist/link-store.js +0 -250
  170. package/dist/management-policy.js +0 -40
  171. package/dist/sqlite-version.js +0 -6
  172. package/dist/types/link-api.d.ts +0 -30
  173. package/dist/types/link-cli.d.ts +0 -37
  174. package/dist/types/link-events.d.ts +0 -27
  175. package/dist/types/link-records.d.ts +0 -11
  176. package/dist/types/link-store-worker.d.ts +0 -1
  177. package/dist/types/link-store.d.ts +0 -130
  178. package/dist/types/management-policy.d.ts +0 -8
  179. package/dist/types/sqlite-version.d.ts +0 -1
  180. package/docs/DYNAMIC-LINKS.md +0 -61
  181. package/docs/MANAGEMENT-SECURITY.md +0 -82
  182. package/docs/links/cli.md +0 -110
  183. package/docs/links/limits.md +0 -175
  184. package/docs/links/management-api.md +0 -80
  185. package/docs/links/pools.md +0 -75
  186. package/docs/links/setup.md +0 -135
  187. package/docs/yaml/links.md +0 -30
  188. package/examples/live-links/README.md +0 -11
  189. package/examples/live-links/example.yaml +0 -21
  190. package/examples/live-links/tests/requests.json +0 -6
  191. package/examples/live-links/urlcode.yaml +0 -16
package/dist/policy.js CHANGED
@@ -3,8 +3,8 @@ import { readFile, realpath, stat } from 'node:fs/promises';
3
3
  import { relative, isAbsolute, sep } from 'node:path';
4
4
  import { createHash } from 'node:crypto';
5
5
  import { functionFile } from './config.js';
6
- import { collectFunctionSources, routeFunctions } from './function-sources.js';
7
-
6
+ import { collectFunctionSources, collectTrustedSources, routeFunctions } from './function-sources.js';
7
+
8
8
  import { assert } from './errors.js';
9
9
 
10
10
 
@@ -16,18 +16,37 @@ import { assert } from './errors.js';
16
16
 
17
17
 
18
18
  export async function prepareFunctionSnapshot(loaded ) {
19
- const definitions = [];
20
- for (const [pattern,route] of Object.entries(loaded.routes)) for (const definition of routeFunctions(route)) definitions.push({pattern,function:{
21
- source:await functionFile(loaded.root,definition.source),export:definition.export || 'default',
22
- }});
23
- const collected = await collectFunctionSources(definitions,loaded.root);
24
- const sources = Object.fromEntries(Object.entries(collected.sources).sort(([a],[b])=>a < b ? -1 : a > b ? 1 : 0));
19
+ // Only `sandbox: true` routes are bundled into the QuickJS module snapshot
20
+ // FunctionPool loads (docs/SPIKE-DEFAULT-TRUST-MODEL.md): a trusted route's
21
+ // module never needs to satisfy the sandbox's relative-static-import-only
22
+ // rule or its per-module/total byte budgets, since it runs through Node's
23
+ // own module resolution, not the WASM guest.
24
+ const sandboxed = [];
25
+ const trusted = [];
26
+ const resolveOne = async (definition ) =>
27
+ ({source:await functionFile(loaded.root,definition.source),export:definition.export || 'default'});
28
+ for (const [pattern,route] of Object.entries(loaded.routes)) {
29
+ if (!routeFunctions(route).length) continue;
30
+ const middleware = await Promise.all((route.middleware || []).map(resolveOne));
31
+ const fn = route.function ? await resolveOne(route.function) : undefined;
32
+ if (route.sandbox) { for (const definition of [...middleware, ...(fn ? [fn] : [])]) sandboxed.push({pattern,function:definition}); }
33
+ else trusted.push({middleware, function: fn});
34
+ }
35
+ const collected = await collectFunctionSources(sandboxed,loaded.root);
36
+ const trustedSources = await collectTrustedSources(trusted,loaded.root);
37
+ // The hash operator grants pin to still covers trusted routes' own source, so
38
+ // an env/secret grant is invalidated when the trusted code that could use it
39
+ // changes, even though that code never enters collectFunctionSources's
40
+ // sandbox-shaped snapshot (see collectTrustedSources for what this does not
41
+ // catch: changes to a helper module a trusted entry imports but does not
42
+ // itself change).
43
+ const sources = Object.fromEntries(Object.entries({...collected.sources,...trustedSources}).sort(([a],[b])=>a < b ? -1 : a > b ? 1 : 0));
25
44
  // Generated site routes carry no bindings and depend on the origin, so they
26
45
  // stay out of the hash that operator grants are pinned to. Declared site and
27
46
  // inherited policy/profile behavior are included: changing a pre-egress
28
47
  // restriction must require a fresh operator review even if routes are unchanged.
29
48
  const declared = Object.fromEntries(Object.entries(loaded.routes).filter(([,route])=>!route.generated));
30
- const snapshot = { ...collected, projectSha256: createHash('sha256').update(JSON.stringify({...(loaded.document.extensions?{extensions:loaded.document.extensions}:{}),routes:declared,...(loaded.document.dynamicLinks===true?{dynamicLinks:true}:{}),...(loaded.document.policies?{policies:loaded.document.policies}:{}),...(loaded.document.profiles?{profiles:loaded.document.profiles}:{}),...(loaded.document.site?{site:loaded.document.site}:{}),sources})).digest('hex') };
49
+ const snapshot = { ...collected, projectSha256: createHash('sha256').update(JSON.stringify({...(loaded.document.extensions?{extensions:loaded.document.extensions}:{}),routes:declared,...(loaded.document.policies?{policies:loaded.document.policies}:{}),...(loaded.document.profiles?{profiles:loaded.document.profiles}:{}),...(loaded.document.site?{site:loaded.document.site}:{}),sources})).digest('hex') };
31
50
  return snapshot;
32
51
  }
33
52
  export function validatePolicy(value ) {
@@ -5,12 +5,12 @@ import { startServer } from './server.js';
5
5
  import { readCases, hit } from './readiness.js';
6
6
 
7
7
 
8
-
8
+
9
9
 
10
10
 
11
- export async function runProjectTests(project , { log = () => {}, permissions, linkStore, origin, extensions, plugins } = {}) {
11
+ export async function runProjectTests(project , { log = () => {}, permissions, origin, extensions, plugins } = {}) {
12
12
  const root = await realpath(project), cases = await readCases(root);
13
- const app = await startServer({ project, port: 0, local: true, log, permissions, linkStore, origin, extensions, plugins });
13
+ const app = await startServer({ project, port: 0, local: true, log, permissions, origin, extensions, plugins });
14
14
  const agent = new Agent({keepAlive:true,maxSockets:1}); let failed = 0;
15
15
  try {
16
16
  for (const [i,test] of cases.entries()) {
package/dist/readiness.js CHANGED
@@ -13,9 +13,14 @@ import { runCompliance } from './compliance.js';
13
13
 
14
14
 
15
15
 
16
-
16
+
17
17
  /** One configured route as the inventory reports it: a PlanInventoryEntry with the handler kind named. */
18
-
18
+
19
+
20
+
21
+
22
+
23
+
19
24
  /** One request case: a generated probe or a `tests/requests.json` fixture. */
20
25
 
21
26
 
@@ -25,14 +30,17 @@ import { runCompliance } from './compliance.js';
25
30
 
26
31
 
27
32
  /** A started server as the audit and benchmark see it. structural: the real type is startServer's result in src/server.ts. */
28
-
33
+
29
34
 
30
35
 
31
36
 
32
-
37
+
33
38
 
34
39
 
35
40
 
41
+
42
+
43
+
36
44
 
37
45
 
38
46
 
@@ -41,20 +49,34 @@ import { runCompliance } from './compliance.js';
41
49
 
42
50
 
43
51
 
44
- const handlers = ['extension','proxy','conditional','redirect','function','page','static','download','respond','link'] ;
52
+ const handlers = ['extension','proxy','conditional','redirect','function','page','static','download','respond'] ;
45
53
  /** Narrows a compiled route to one that redirects, so redirectLocation can read its spec. */
46
54
  export const hasRedirect = (route ) => Boolean(route.redirect);
47
55
  const isRecord = (value ) => value !== null && typeof value === 'object' && !Array.isArray(value);
48
56
  // Probes identify themselves so an agents policy that denies an empty
49
57
  // User-Agent does not fail every generated case; fixtures may override it.
50
58
  export const probeAgent = 'Mozilla/5.0 (compatible; RouteProbe/0.1)';
59
+ // Advisory only (docs/AI-AUTHORING.md, "Deciding when a route needs sandbox: true"):
60
+ // a route that runs project code, accepts POST with a declared request.body
61
+ // policy, and declares neither `sandbox: true` nor `sandboxReason` looks
62
+ // plausibly webhook/callback/third-party-input-shaped. This is a nudge to
63
+ // look, never an inferred verdict — it never fails `audit` or changes `ready`.
64
+ function routeAdvisories(route ) {
65
+ const advisories = [];
66
+ const runsCode = Boolean(route.function) || Boolean(route.middleware?.length);
67
+ if (runsCode && route.methods.includes('POST') && route.request?.body && !route.sandbox && !route.sandboxReason) {
68
+ advisories.push("This route accepts POST with a declared request.body policy but declares neither sandbox: true nor sandboxReason; consider whether this route needs sandbox: true.");
69
+ }
70
+ return advisories;
71
+ }
51
72
  export function projectPlan(compiled ) {
52
73
  const routes = [...compiled.exact.values(), ...[...compiled.byLength.values()].flat(), ...compiled.mounts];
53
74
  const now = Date.now();
54
- const inventory = routes.map(route => ({ path:route.pattern, handler:handlers.find(key => route[key]), methods:route.methods, middleware:route.middleware?.length || 0,
75
+ const inventory = routes.map(route => { const advisories = routeAdvisories(route); return { path:route.pattern, handler:handlers.find(key => route[key]), methods:route.methods, middleware:route.middleware?.length || 0,
55
76
  policies:[...(route.policy ? Object.keys(route.policy.describe) : []),...(route.extensionPolicyNames??[]).map(name=>`extensions.${name}`)],
56
77
  ...(route.generated ? { generated:route.generated } : {}),
57
- state:route.enabled === false ? 'disabled' : route.expiresAt && now >= route.expiresAt ? 'expired' : 'active' }));
78
+ ...(advisories.length ? { advisories } : {}),
79
+ state:route.enabled === false ? 'disabled' : route.expiresAt && now >= route.expiresAt ? 'expired' : 'active' }; });
58
80
  const cases = [];
59
81
  for (const [i,route] of routes.entries()) {
60
82
  const entry = inventory[i];
@@ -63,7 +85,7 @@ export function projectPlan(compiled )
63
85
  if(!route.names.length && !route.static && !route.extension && !route.extensionPolicyNames?.length) cases.push({path:route.pattern,method:'GET',status:entry.state==='disabled'?404:410});
64
86
  continue;
65
87
  }
66
- if (route.extension || route.extensionPolicyNames?.length || route.proxy || route.signals?.length || route.match || route.conditional || route.function || route.link || route.middleware?.length || route.names.length) continue;
88
+ if (route.extension || route.extensionPolicyNames?.length || route.proxy || route.signals?.length || route.match || route.conditional || route.function || route.middleware?.length || route.names.length) continue;
67
89
  // Required inputs need intentional fixtures; never invent business data.
68
90
  let context ;
69
91
  try { context = contextFor(route,{},new URLSearchParams(),new Headers()); } catch { continue; }
@@ -160,9 +182,10 @@ export async function auditProject(app , {expectRoutes,log=()=>{},c
160
182
  const counts ={configured:plan.inventory.length,active:0,disabled:0,expired:0,byHandler:{}};
161
183
  for(const route of plan.inventory){counts[route.state]++;const handler=String(route.handler);counts.byHandler[handler]=(counts.byHandler[handler]||0)+1;}
162
184
  const countMatches=expectRoutes===undefined || counts.configured===expectRoutes;
185
+ const advisories=plan.inventory.flatMap(route=>(route.advisories??[]).map(message=>({route:route.path,message})));
163
186
  // The per-route capability table: which policies apply and whether this
164
187
  // host enforces, compiles or delegates each one. Refusals never get here.
165
- return {dynamicLinks:plan.dynamicLinks,elapsedMs:performance.now()-began,ready:countMatches && !failed && !uncovered.length && counts.active>0,counts,expectedRoutes:expectRoutes ?? null,countMatches,checks:cases.length,passed,failed,coveredRouteMethods:covered.size,unassertedCases,uncovered,policies:plan.policies ?? {},compliance:compliance?await runCompliance(app,compliance):null};
188
+ return {elapsedMs:performance.now()-began,ready:countMatches && !failed && !uncovered.length && counts.active>0,counts,expectedRoutes:expectRoutes ?? null,countMatches,checks:cases.length,passed,failed,coveredRouteMethods:covered.size,unassertedCases,uncovered,policies:plan.policies ?? {},compliance:compliance?await runCompliance(app,compliance):null,advisories};
166
189
  }
167
190
  export async function benchmarkProject(app ,{requests=1000,concurrency=2,maxP95Ms,seconds=30,warmup=0,target} ={}) {
168
191
  assert(Number.isInteger(requests)&&requests>=1&&requests<=100000,'Requests must be 1–100000');
package/dist/router.js CHANGED
@@ -1,4 +1,5 @@
1
- import { hasExtensionPolicy } from './extensions.js';
1
+ import { effectiveExtensionPolicies, isSensitiveExtensionPolicy } from './extensions.js';
2
+
2
3
  import { validateProxy } from './proxy.js';
3
4
  import { validateSignal } from './signals.js';
4
5
 
@@ -57,7 +58,7 @@ function compiledRedirect(redirect ) {
57
58
  const { pass: _pass, ...query } = redirect.query;
58
59
  return { ...redirect, query };
59
60
  }
60
- export async function compileRoutes(loaded , bindings , permissions = {}, projectSha256 ) {
61
+ export async function compileRoutes(loaded , bindings , permissions = {}, projectSha256 , extensions ) {
61
62
  const deadline=performance.now()+10000;
62
63
  let processed=0;
63
64
  const exact = new Map (), dynamic = [], mounts = [], modules = new Map ();
@@ -80,7 +81,8 @@ export async function compileRoutes(loaded , bindings
80
81
  compileHttp(route);
81
82
  if (config.match) route.match = normalizeMatch(config.match);
82
83
  if(config.extension){assert(!config.middleware?.length&&!config.parameters?.length&&!config.env&&!config.secrets,'Extension handlers cannot declare guest middleware, parameters or bindings');assert(pattern.endsWith('/*')&&!names.length&&pattern!=='/*','Extension handler requires a non-root literal /* mount');}
83
- if (config.match || config.conditional || config.extension || hasExtensionPolicy(loaded.document,config)) {
84
+ const extensionPolicyNames = Object.keys(effectiveExtensionPolicies(loaded.document,config));
85
+ if (config.match || config.conditional || config.extension || isSensitiveExtensionPolicy(extensionPolicyNames,extensions)) {
84
86
  const cache = effectivePolicies(loaded.document,config).cache;
85
87
  assert(!cache || cache.strategy === 'no-store', `${pattern}: conditional routing requires cache disabled or no-store`);
86
88
  assert(!route.responseHeaders.some(([name,value]) => ['cache-control','cdn-cache-control','vercel-cdn-cache-control','surrogate-control'].includes(name.toLowerCase()) && value !== 'no-store'), 'Conditional responses require no-store');
@@ -165,10 +167,6 @@ export async function compileRoutes(loaded , bindings
165
167
  for (const key of query.pass || []) assert(!reserved.has(key), 'Query passthrough conflicts with destination or mapping');
166
168
  route.redirect = compiledRedirect(declaredRedirect);
167
169
  }
168
- if (config.link) {
169
- assert(route.methods.every(method => methodsDefault.includes(method)), 'Stored links support only GET and HEAD');
170
- referenceCheck(config.link.code,route);
171
- }
172
170
  for (const item of config.middleware || []) {
173
171
  const source = await functionFile(loaded.root,item.source);
174
172
  modules.set(source,true);
package/dist/runtime.js CHANGED
@@ -1,4 +1,4 @@
1
- import { prepareExtensions, effectiveExtensionPolicies, hasExtensionPolicy, extensionResponse } from './extensions.js';
1
+ import { prepareExtensions, effectiveExtensionPolicies, hasExtensionPolicy, isSensitiveExtensionPolicy, extensionResponse } from './extensions.js';
2
2
 
3
3
  import { EgressClient, EgressError } from './egress.js';
4
4
 
@@ -14,11 +14,9 @@ import { loadDocument, loadBindings } from './config.js';
14
14
  import { compileRoutes, parseTarget, matchRoute, contextFor, resolveValue, redirectLocation } from './router.js';
15
15
  import { FunctionPool } from './functions.js';
16
16
 
17
+ import { TrustedFunctions } from './trusted-functions.js';
17
18
  import { prepareFunctionSnapshot, validatePolicy, authorizeEgress } from './policy.js';
18
19
 
19
- import {openLinkStore} from './link-store.js';
20
-
21
- import {linkCode,linkData,linkCollection} from './link-records.js';
22
20
  import {assert} from './errors.js';
23
21
  import { HttpError } from './errors.js';
24
22
  import { compilePolicies, closePolicies, policyRequest, compileErrorPolicy, errorHeaders } from './policies.js';
@@ -31,24 +29,17 @@ import { applySite } from './site.js';
31
29
 
32
30
 
33
31
 
34
- /** A stored link as a link store returns it; the runtime validates the fields it uses. */
35
-
36
- /** What a `linkStores` binding must provide: a reader and, optionally, its health. */
37
-
38
- /** An operator-owned SQLite link store the runtime opens read-only for one collection. */
39
-
40
32
 
41
33
  /** An operator plugin (src/plugins.ts). */
42
34
 
43
35
 
44
-
36
+
45
37
 
46
38
 
47
39
 
48
40
 
49
41
 
50
42
 
51
-
52
43
 
53
44
 
54
45
 
@@ -59,8 +50,7 @@ import { applySite } from './site.js';
59
50
 
60
51
 
61
52
  /** Per-request facts the host may read after handle() settles; never request text. */
62
-
63
-
53
+
64
54
 
65
55
 
66
56
 
@@ -97,15 +87,13 @@ export async function createRuntime(project , rawOptions
97
87
  const kept = new Set(only);
98
88
  for (const pattern of Object.keys(loaded.routes)) if (!kept.has(pattern)) delete loaded.routes[pattern];
99
89
  }
100
- assertTargetCompatibility(analyzeProjectCapabilities(loaded, options.target || 'node'));
101
- const dynamicLinks=loaded.document.dynamicLinks===true;
102
- assert(dynamicLinks || (!options.linkStore && !Object.keys(options.linkStores||{}).length),'Link-store bindings require dynamicLinks: true in urlcode.yaml');
90
+ assertTargetCompatibility(analyzeProjectCapabilities(loaded, options.target || 'node', options.extensions));
103
91
  const snapshot = await prepareFunctionSnapshot(loaded);
104
92
  if (options.permissions) validatePolicy(options.permissions);
105
93
  const egressGrants=authorizeEgress(loaded,snapshot.projectSha256,options.permissions);
106
- const extensionPlan=prepareExtensions(loaded.document,loaded.routes,options.extensions,{origin:options.origin??'',target:options.target??'node',projectSha256:snapshot.projectSha256});
94
+ const extensionPlan=prepareExtensions(loaded.document,loaded.routes,options.extensions,{origin:options.origin??'',target:options.target??'node',projectSha256:snapshot.projectSha256,root:loaded.root});
107
95
  const bindings = await loadBindings(loaded.root, options.local, options.environment);
108
- const compiled = await compileRoutes(loaded, bindings, options.permissions, snapshot.projectSha256);
96
+ const compiled = await compileRoutes(loaded, bindings, options.permissions, snapshot.projectSha256, options.extensions);
109
97
  const routes = [...compiled.mounts, ...compiled.exact.values(), ...[...compiled.byLength.values()].flat()];
110
98
  const assets = await compileAssets(loaded.root, routes);
111
99
  // Host policies compile after assets so a policy can see what a route serves
@@ -124,30 +112,35 @@ export async function createRuntime(project , rawOptions
124
112
  // Which route's policy an error belongs to, so its headers follow the route
125
113
  // the request matched rather than the project default.
126
114
  const errorRoutes = new WeakMap ();
127
- const stores = Object.assign(Object.create(null) ,options.linkStores);
128
- let ownedStore ;
129
- try {
130
- if(options.linkStore){
131
- linkCollection(options.linkStore.collection);
132
- assert(!Object.hasOwn(stores,options.linkStore.collection), 'Duplicate link store binding');
133
- ownedStore=await openLinkStore({...options.linkStore,project:loaded.root,readOnly:true,log:options.log});
134
- stores[options.linkStore.collection]=ownedStore;
135
- }
136
- for(const route of routes)if(route.link)assert(Object.hasOwn(stores,route.link.collection) && typeof stores[route.link.collection]?.get==='function','Missing operator link store binding');
137
- }catch(error){await ownedStore?.close();throw error;}
138
- let pool ;
139
- try{pool=await new FunctionPool(routes, { root:loaded.root, snapshot, log:options.log, workers:options.workers, timeoutMs:options.timeoutMs, maxBytes:options.maxBytes }).start();}
140
- catch(error){await ownedStore?.close();throw error;}
115
+ // Only `sandbox: true` routes go through the worker/QuickJS pool
116
+ // (docs/SPIKE-DEFAULT-TRUST-MODEL.md): every other function/middleware
117
+ // route is trusted-by-default and dispatches through `trusted` below,
118
+ // in-process, with no worker or WASM engine involved at all.
119
+ const pool=await new FunctionPool(routes.filter(route=>route.sandbox===true), { root:loaded.root, snapshot, log:options.log, workers:options.workers, timeoutMs:options.timeoutMs, maxBytes:options.maxBytes }).start();
120
+ const trusted = new TrustedFunctions({ timeoutMs: options.timeoutMs, maxBytes: options.maxBytes, log: options.log });
121
+ // Eagerly validated up front, exactly like the sandboxed pool above: a
122
+ // trusted route with a broken module or a missing export fails activation
123
+ // here rather than on its first request.
124
+ try{await trusted.start(routes.filter(route=>route.sandbox!==true));}
125
+ catch(error){await pool.close();throw error;}
141
126
  const proxyClient=new EgressClient({grantOrigins:egressGrants.proxy},options.egressDependencies);
142
127
  const signalClient=new EgressClient({grantOrigins:egressGrants.signals,concurrency:8},options.egressDependencies);
143
128
  let lastSignals={accepted:0,delivered:0,failed:0,dropped:0};
144
129
  const signalBroker=new SignalBroker(signalClient,8,stats=>{for(const outcome of ['accepted','delivered','failed','dropped'] ){const count=stats[outcome]-lastSignals[outcome];if(count)sink({event:'signal',outcome,count});}lastSignals=stats;});
145
130
  let extensionRegistry ;
146
131
  try{extensionRegistry=await extensionPlan.activate();}
147
- catch(error){await pool.close();await ownedStore?.close();await closePolicies(shared);await proxyClient.close();await signalClient.close();throw error;}
132
+ catch(error){await pool.close();await closePolicies(shared);await proxyClient.close();await signalClient.close();throw error;}
148
133
  for(const name of extensionRegistry.credentialHeaders)credentialHeaders.add(name);
149
134
  for(const route of routes)route.extensionPolicyNames=Object.keys(effectiveExtensionPolicies(loaded.document,route));
135
+ // Gates `authorize()` invocation, the extension request-body cap and
136
+ // request-phase ordering: unaffected by declared cache sensitivity, so
137
+ // every extension-policy route stays protected here regardless.
150
138
  const privateRoutes=new Set(routes.filter(route=>route.extension||hasExtensionPolicy(loaded.document,route)).map(route=>route.pattern));
139
+ // Gates the no-store/compression-disabled/extension-response-cap treatment
140
+ // in `finishPolicies` below. An `extension:` mount is always confidential.
141
+ // A `policies.extensions` route is confidential unless every named
142
+ // extension explicitly declares `cacheSensitive: false` (src/extensions.ts).
143
+ const confidentialRoutes=new Set(routes.filter(route=>route.extension||isSensitiveExtensionPolicy(route.extensionPolicyNames??[],options.extensions)).map(route=>route.pattern));
151
144
  // Only an extension's own mount can serve its declared immutable assets.
152
145
  const assetPrefixes=new Map(routes.filter(route=>route.extension).map(route=>[route.pattern,extensionRegistry.entries.get(route.extension ) .assetPrefixes]));
153
146
  const assetContext=(method ,path ,pattern ) =>{const prefixes=assetPrefixes.get(pattern);return prefixes?.length?{method,path,prefixes}:undefined;};
@@ -160,7 +153,7 @@ export async function createRuntime(project , rawOptions
160
153
  // its status is not cacheable). A plugin short-circuit ran before any
161
154
  // policy, so it skips every request-phase policy's response hook.
162
155
  async function finishPolicies(policy , request , result , producer ) {
163
- const confidential=privateRoutes.has(request.route),asset=assetContext(request.method,request.path,request.route);
156
+ const confidential=confidentialRoutes.has(request.route),asset=assetContext(request.method,request.path,request.route);
164
157
  let out = confidential?extensionResponse(result,asset):result;
165
158
  for (const [module, state] of policy?.response || []) {
166
159
  if(confidential&&module.name==='compression')continue;
@@ -174,11 +167,11 @@ export async function createRuntime(project , rawOptions
174
167
  return Object.fromEntries(routes.flatMap(route => route.policy && Object.keys(route.policy.describe).length ? [[route.pattern, route.policy.describe] ] : []));
175
168
  }
176
169
  const workers = () => ({ healthy: pool.slots.filter(slot => slot?.ready).length, slots: pool.size });
177
- const testPlan = () => ({...projectPlan(compiled),dynamicLinks,policies:policyInventory()});
170
+ const testPlan = () => ({...projectPlan(compiled),policies:policyInventory()});
178
171
  try{await activatePlugins(plugins, { testPlan, version: loaded.version + assets.digest, root: loaded.root, target });}
179
- catch(error){await Promise.all([extensionRegistry.close(),signalBroker.close(),signalClient.close(),proxyClient.close(),pool.close(),ownedStore?.close(),closePolicies(shared),closePlugins(plugins),sink.close()]);throw error;}
172
+ catch(error){await Promise.all([extensionRegistry.close(),signalBroker.close(),signalClient.close(),proxyClient.close(),pool.close(),closePolicies(shared),closePlugins(plugins),sink.close()]);throw error;}
180
173
  return {
181
- get healthy() { return !closing && pool.healthy && Object.values(stores).every(store=>(store.readHealthy??store.healthy)!==false); },
174
+ get healthy() { return !closing && pool.healthy; },
182
175
  assetWatch: assets.watch, version: loaded.version + assets.digest, count: compiled.count, root: loaded.root,
183
176
  testPlan,
184
177
  get plugins() { return plugins.map(plugin => ({ name: plugin.name, version: plugin.version })); },
@@ -219,7 +212,7 @@ export async function createRuntime(project , rawOptions
219
212
  const protectedRoute=privateRoutes.has(route.pattern);
220
213
  const extensionRequest ={method,target,path:parsed.path,query:new URLSearchParams(parsed.query),headers:new Headers(headers),headerCounts:{...headerCounts},body:body??new Uint8Array(),origin:options.origin??origin,route:route.pattern,mount:route.extension?route.pattern.slice(0,-2):null,client:client??null};
221
214
  if(protectedRoute&&(body?.byteLength??0)>Math.min(1048576,route.request?.body?.maxBytes??1048576))throw new HttpError(413,'Request body too large');
222
- const authorize=async() =>{for(const name of Object.keys(effectiveExtensionPolicies(loaded.document,route))){const entry=extensionRegistry.entries.get(name) ;const result=await entry.instance.authorize (entry.policies.get(route.pattern) ,extensionRequest);if(result)return result;}return undefined;};
215
+ const authorize=async() =>{for(const name of route.extensionPolicyNames??[]){const entry=extensionRegistry.entries.get(name) ;if(typeof entry.instance.authorize!=='function')continue;const result=await entry.instance.authorize(entry.policies.get(route.pattern) ,extensionRequest);if(result)return result;}return undefined;};
223
216
  if (policy || plugins.length || protectedRoute) {
224
217
  policyReq = policyRequest({ method, target, path: parsed.path, params: path, query: parsed.query, headers, headerCounts, client, origin, route });
225
218
  trace.client = policyReq.client;
@@ -232,6 +225,13 @@ export async function createRuntime(project , rawOptions
232
225
  }
233
226
  if(protectedRoute){if(!authorized){const denied=await authorize();if(denied)return await finishPolicies(policy,policyReq,denied);}const early=await pluginsRequest(plugins,policyReq);if(early)return await finishPolicies(policy,policyReq,early,'plugin');}
234
227
  }
228
+ // Everything from here on (method contract, native reply, the native
229
+ // `middleware:` chain and the handler) is "the rest of the pipeline"
230
+ // for this route. It is wrapped in a callable so an extension's own
231
+ // `middleware()` hook (policies.extensions.<name>, parallel to
232
+ // `authorize` above and never touching this native chain) can run
233
+ // code before and after it via `next()`, or skip it entirely.
234
+ const runPipeline = async () => {
235
235
  if (!route.methods.includes(method)) {
236
236
  const refused = { status: 405, headers: [['allow', route.methods.join(', ')]], body: Buffer.from('Method not allowed\n') };
237
237
  // Counted by throttle already, so it carries the budget headers and
@@ -266,23 +266,6 @@ export async function createRuntime(project , rawOptions
266
266
  }
267
267
  else if (hasRedirect(route)) native = { status: route.redirect.status || 302,
268
268
  headers: [['location', redirectLocation(route, context, parsed.query)]], body: Buffer.alloc(0) };
269
- else if(route.link){
270
- // Resolution outcome for a trusted post-response observer. It records
271
- // why this request ended the way it did; the caller decides whether a
272
- // finished response is ever reported, and never sees stored data.
273
- const collection=route.link.collection;
274
- const observed =trace.link={collection,code:null,result:'invalid_code'};
275
- let code ;try{code=linkCode(resolveValue(route.link.code,context));}catch{throw new HttpError(404,'Link not found');}
276
- observed.code=code;observed.result='missing';
277
- let record ;
278
- try{const store=stores[collection];assert(store,'Missing operator link store binding');record=await store.get(collection,code);}catch{observed.result='unavailable';throw new HttpError(503,'Link store unavailable');}
279
- if(!record)throw new HttpError(404,'Link not found');
280
- let data;try{data=linkData({url:record.url,status:record.status,enabled:record.enabled,expires:record.expires});}catch{observed.result='invalid_record';throw new HttpError(503,'Invalid stored link');}
281
- if(!data.enabled){observed.result='disabled';throw new HttpError(404,'Link not found');}
282
- if(data.expires && Date.parse(data.expires)<=Date.now()){observed.result='expired';throw new HttpError(410,'Link expired');}
283
- observed.result='redirect';
284
- native={status:data.status,headers:[['location',data.url],['cache-control','no-store']],body:Buffer.alloc(0)};
285
- }
286
269
  else if (route.reply) native = route.reply;
287
270
  else if (route.asset) {
288
271
  try { native = assetResponse(route, parsed.path, method, headers); }
@@ -293,7 +276,43 @@ export async function createRuntime(project , rawOptions
293
276
  }
294
277
  if (native && !route.middleware.length) return await finishResponse(native);
295
278
  context.args = Object.fromEntries(Object.entries(route.function?.args || {}).map(([key, ref]) => [key, resolveValue(ref, context)]));
296
- return await finishResponse(await pool.execute(route, { url: origin + target, method, headers: [...guestHeaders], body }, context, native));
279
+ // Uniform for `function` and `middleware` alike: a route dispatches
280
+ // through the sandboxed worker pool only when it declares
281
+ // `sandbox: true`; every other route runs trusted, in-process
282
+ // (docs/SPIKE-DEFAULT-TRUST-MODEL.md).
283
+ const executor = route.sandbox ? pool : trusted;
284
+ return await finishResponse(await executor.execute(route, { url: origin + target, method, headers: [...guestHeaders], body }, context, native));
285
+ };
286
+ // Extension `middleware()` hooks, declared the same way `authorize` is
287
+ // (policies.extensions.<name> on this route, config already validated
288
+ // against the extension's policySchema): only a name this route names
289
+ // and whose activated instance actually implements `middleware` can
290
+ // ever wrap it, in the route's declared order, each one's `next()`
291
+ // reaching the next one and the innermost `next()` reaching the
292
+ // native pipeline above. `authorize` is untouched: it already ran
293
+ // (or short-circuited) before this point.
294
+ let pipeline = runPipeline;
295
+ for (const name of [...(route.extensionPolicyNames ?? [])].reverse()) {
296
+ const entry = extensionRegistry.entries.get(name) ;
297
+ if (typeof entry.instance.middleware !== 'function') continue;
298
+ const config = entry.policies.get(route.pattern) ;
299
+ const downstream = pipeline;
300
+ pipeline = async () => {
301
+ let called = false;
302
+ const next = async () => {
303
+ assert(!called, `Extension ${name} middleware called next() more than once`);
304
+ called = true;
305
+ return await downstream();
306
+ };
307
+ const result = await entry.instance.middleware (config, extensionRequest, next);
308
+ // `downstream()` already ran the result through `finishPolicies`
309
+ // (it bottoms out in `runPipeline`, whose every exit does). Only
310
+ // a short-circuit that skipped `next()` returns a raw result,
311
+ // which needs exactly the one pass `authorize`'s own denial gets.
312
+ return called ? result : (policyReq ? await finishPolicies(policy, policyReq, result) : result);
313
+ };
314
+ }
315
+ return await pipeline();
297
316
  } catch (error) {
298
317
  if (policy !== undefined && error && typeof error === 'object') errorRoutes.set(error, policy);
299
318
  if (policyReq) {
@@ -315,7 +334,7 @@ export async function createRuntime(project , rawOptions
315
334
  await Promise.all([signalBroker.close(),signalClient.close(),proxyClient.close()]);
316
335
  if (active) await new Promise (resolve => { finish = resolve; });
317
336
  await pool.close();
318
- await ownedStore?.close();
337
+ await trusted.close();
319
338
  await closePolicies(shared);
320
339
  await closePlugins(plugins);
321
340
  await extensionRegistry.close();
@@ -0,0 +1,48 @@
1
+ // Public sandbox execution primitive: `@jimhoyd/urlcode/sandbox`.
2
+ //
3
+ // This is the same QuickJS/worker-thread engine that already backs
4
+ // `sandbox: true` `function`/`middleware` routes (src/functions.ts's
5
+ // `SandboxPool`, src/function-worker.ts, src/guest-api.ts) — there is exactly
6
+ // one place that owns worker spawning, dependency-closure module
7
+ // allowlisting, memory/stack limits and deadline enforcement. This module
8
+ // simply exposes that primitive under a route-independent shape, so an
9
+ // extension package (docs/EXTENSIONS.md's "Project-level lifecycle hooks")
10
+ // can run a project-supplied hook or middleware function through the same
11
+ // trusted/sandboxed dispatch selection route dispatch gets, when the
12
+ // project's own config declares `sandbox: true` on that hook.
13
+ //
14
+ // There is no "trusted" mode exported here, and there never will be: a hook
15
+ // that does NOT declare `sandbox: true` needs no primitive at all — it is
16
+ // ordinary first-party project code the extension can `import()` directly
17
+ // (see `ExtensionActivation.root` in src/extensions.ts). `SandboxPool` is
18
+ // specifically, and only, the isolated path. See
19
+ // docs/SPIKE-DEFAULT-TRUST-MODEL.md for why trusted execution needs no API.
20
+ //
21
+ // Every security property of the route-level sandbox applies unchanged here:
22
+ // a fresh QuickJS heap/module registry per invocation, no Node capability
23
+ // ever injected into the guest, the module-denial allowlist walk (only the
24
+ // entries this pool declares, and their own static-relative-import closure,
25
+ // are reachable — anything else, including a dynamic or unlisted relative
26
+ // import, is denied), a 32MB heap / 512KB stack ceiling per invocation, the
27
+ // deadline enforced twice (the QuickJS interrupt handler first, an outer
28
+ // worker-termination timeout as a hard backstop if the guest engine itself
29
+ // stops responding), `maxBytes` on both input and output, and strict
30
+ // validation of the guest's JSON response shape before any of it is trusted.
31
+ // None of these can be loosened from the outside; there is no option here to
32
+ // raise the memory/stack ceiling, add a module to the allowlist after
33
+ // construction, or pass a host object into the guest.
34
+ export { SandboxPool } from './functions.js';
35
+
36
+
37
+
38
+
39
+
40
+
41
+ /**
42
+ * Resolves a project-relative hook source string the same way a native
43
+ * `function`/`middleware` route's `source` is resolved (`.mjs`/`.js` only, no
44
+ * path traversal outside the project root) into the absolute path
45
+ * `SandboxPool`'s entries and `execute()` targets expect. `root` must be the
46
+ * project's resolved directory (`ExtensionActivation.root`, never `cwd()`).
47
+ */
48
+ export { functionFile } from './config.js';
package/dist/scaffold.js CHANGED
Binary file
@@ -1,75 +1,33 @@
1
1
  // Disposable local proof, never a claim about the production deployment.
2
2
  import assert from 'node:assert/strict';
3
- import {mkdtemp,mkdir,writeFile,copyFile,rm,statfs} from 'node:fs/promises';
3
+ import {mkdtemp,mkdir,writeFile,rm} from 'node:fs/promises';
4
4
  import {tmpdir} from 'node:os';
5
- import {join,resolve} from 'node:path';
6
- import {DatabaseSync} from 'node:sqlite';
7
- import {openLinkStore} from '../link-store.js';
5
+ import {join} from 'node:path';
8
6
  import {startServer} from '../server.js';
9
-
10
7
 
11
- import {HttpError} from '../errors.js';
12
- // The SQLite rows the drills inspect; node:sqlite returns untyped records.
13
-
14
-
15
-
16
-
17
- // node:sqlite boundary: a single-row query, typed by the caller's expectation.
18
- const row= (db ,sql ) =>{const r=db.prepare(sql).get();assert.ok(r,`${sql} returned no row`);return r ;};
8
+
19
9
  const seconds=Number(process.env.URLCODE_SOAK_SECONDS||5);
20
10
  assert(Number.isInteger(seconds)&&seconds>=1&&seconds<=3600,'Soak must be 1–3600 seconds');
21
11
  const directory=await mkdtemp(join(tmpdir(),'urlcode-drills-')),project=join(directory,'app');
22
- let store ,app ;
12
+ let app ;
23
13
  try {
24
14
  await mkdir(project);
25
- const config='version: "1"\ndynamicLinks: true\nroutes:\n /go:\n redirect: {url: "https://example.com/v1"}\n /function:\n function: {source: f.mjs}\n /live/{code}:\n parameters:\n - {name: code, in: path, required: true, schema: {type: string}}\n link: {collection: links, code: {from: path, name: code}}\n';
15
+ const config='version: "1"\nroutes:\n /go:\n redirect: {url: "https://example.com/v1"}\n /function:\n function: {source: f.mjs}\n';
26
16
  await writeFile(join(project,'urlcode.yaml'),config);
27
17
  await writeFile(join(project,'f.mjs'),'export default () => new Response("isolated");');
28
- const file=join(directory,'links.sqlite');
29
- store=await openLinkStore({file,project});let link=await store.create('links',{url:'https://example.com/live'},'demo');
30
- app=await startServer({project,port:0,linkStore:{collection:'links',file},log:()=>{}});
18
+ app=await startServer({project,port:0,log:()=>{}});
31
19
  const server=app;
32
20
  const get=async(path )=>{const r=await fetch(`http://127.0.0.1:${server.address.port}${path}`,{redirect:'manual'});const body=await r.text();return {status:r.status,location:r.headers.get('location'),body};};
33
21
  const start=performance.now(),samples =[];let requests=0;
34
22
  while(performance.now()-start<seconds*1000){
35
- const before=performance.now();const responses=await Promise.all(['/go','/function','/live/demo'].map(get));
36
- assert.deepEqual(responses.map(r=>r.status),[302,200,302]);assert.equal(responses[1]?.body,'isolated');
37
- assert.equal(responses[2]?.location,link.url);samples.push(performance.now()-before);requests+=3;
38
- if(requests%30===0)link=await store.update('links','demo',{url:`https://example.com/live-${requests}`},link.version);
23
+ const before=performance.now();const responses=await Promise.all(['/go','/function'].map(get));
24
+ assert.deepEqual(responses.map(r=>r.status),[302,200]);assert.equal(responses[1]?.body,'isolated');
25
+ samples.push(performance.now()-before);requests+=2;
39
26
  }
40
27
  await writeFile(join(project,'urlcode.yaml'),'invalid: configuration');assert.equal(await app.reload(),false);assert.equal((await get('/go')).location,'https://example.com/v1');
41
28
  await writeFile(join(project,'urlcode.yaml'),config.replace('/v1','/v2'));assert.equal(await app.reload(),true);assert.equal((await get('/go')).location,'https://example.com/v2');
42
29
  await writeFile(join(project,'urlcode.yaml'),config);assert.equal(await app.reload(),true);assert.equal((await get('/go')).location,'https://example.com/v1');
43
- await app.close();app=undefined;await store.close();store=undefined;
44
- // A read-only connection may have been the last to close, leaving WAL frames.
45
- // Quiesce all users, explicitly checkpoint, then close before copying.
46
- const checkpointDb=new DatabaseSync(file);
47
- try{const result=row (checkpointDb,'PRAGMA wal_checkpoint(TRUNCATE)');assert.equal(result.busy,0);assert.equal(result.log,0);assert.equal(result.checkpointed,0);}finally{checkpointDb.close();}
48
- const restoreStart=performance.now(),backup=join(directory,'restored.sqlite');await copyFile(file,backup);
49
- store=await openLinkStore({file:backup,project,readOnly:true});assert.deepEqual(await store.get('links','demo'),link);
50
- const db=new DatabaseSync(backup,{readOnly:true});assert.equal(row (db,'PRAGMA integrity_check').integrity_check,'ok');assert.equal(row (db,'SELECT max(revision) AS revision FROM urlcode_link_audit').revision,link.version);db.close();
30
+ await app.close();app=undefined;
51
31
  samples.sort((a,b)=>a-b);
52
- console.log(JSON.stringify({event:'local-operational-proof',seconds,requests,batchP99Ms:samples[Math.floor(samples.length*.99)],rssBytes:process.memoryUsage().rss,restoreMs:performance.now()-restoreStart,checks:['mixed HTTP load','invalid reload preserves last-good','configuration rollback','quiesced backup/restore including audit'],deploymentProof:false}));
53
- await store.close();store=undefined;
54
- // Only enable inside a disposable, size-limited mount (CI uses 16 MiB tmpfs).
55
- if(process.argv[2]==='--disk-full-dir'){
56
- const volume=resolve(process.argv[3] ?? '');const isolated=await mkdtemp(join(volume,'urlcode-full-'));
57
- try {
58
- const reserve=join(isolated,'reserve');await writeFile(reserve,Buffer.alloc(4*1024*1024));
59
- const fullFile=join(isolated,'full.sqlite');store=await openLinkStore({file:fullFile,project});let full=false,committed=0;
60
- for(let i=0;i<12000;i++){
61
- try{await store.create('links',{url:'https://example.com/'+ 'x'.repeat(7000)},'code-'+i);committed++;}
62
- catch(error){assert.ok(error instanceof HttpError,'store failure must be an HttpError');assert.equal(error.status,503);full=true;break;}
63
- }
64
- assert(full,'Disposable volume did not fill within the 84 MiB write budget');
65
- const space=await statfs(isolated);assert(space.bavail*space.bsize<1024*1024,'Expected actual volume exhaustion');
66
- await rm(reserve);await store.create('links',{url:'https://example.com/recovered'},'recovered');committed++;
67
- await store.close();store=undefined;
68
- const check=new DatabaseSync(fullFile,{readOnly:true});
69
- assert.equal(row (check,'PRAGMA integrity_check').integrity_check,'ok');
70
- assert.equal(row (check,'SELECT count(*) AS n FROM urlcode_links').n,committed);
71
- assert.equal(row (check,'SELECT count(*) AS n FROM urlcode_link_audit').n,committed);check.close();
72
- console.log(JSON.stringify({event:'disposable-volume-exhaustion',committed,integrity:'ok',auditAtomic:true}));
73
- }finally{await store?.close();store=undefined;await rm(isolated,{recursive:true,force:true});}
74
- }
75
- } finally {await app?.close();await store?.close();await rm(directory,{recursive:true,force:true});}
32
+ console.log(JSON.stringify({event:'local-operational-proof',seconds,requests,batchP99Ms:samples[Math.floor(samples.length*.99)],rssBytes:process.memoryUsage().rss,checks:['mixed HTTP load','invalid reload preserves last-good','configuration rollback'],deploymentProof:false}));
33
+ } finally {await app?.close();await rm(directory,{recursive:true,force:true});}