@runeditrun/rer 0.1.1 → 0.2.1

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 (154) hide show
  1. package/README.md +113 -2
  2. package/cli/README.md +32 -5
  3. package/cli/contract.mjs +5 -5
  4. package/cli/deploy.mjs +17 -3
  5. package/cli/detached-server.mjs +51 -0
  6. package/cli/dev-identity.mjs +2 -1
  7. package/cli/door-mode.mjs +134 -0
  8. package/cli/door.mjs +263 -0
  9. package/cli/hosted-output.mjs +17 -7
  10. package/cli/hydration/browser.mjs +91 -0
  11. package/cli/hydration/cases.mjs +106 -0
  12. package/cli/hydration/check.mjs +105 -0
  13. package/cli/hydration/probe.mjs +164 -0
  14. package/cli/hydration/scripts.mjs +39 -0
  15. package/cli/hydration/verdict.mjs +85 -0
  16. package/cli/inspect.mjs +57 -10
  17. package/cli/lifecycle.mjs +36 -12
  18. package/cli/local.mjs +9 -3
  19. package/cli/main.mjs +3 -0
  20. package/cli/managed-server.mjs +62 -21
  21. package/cli/package-calls.mjs +81 -0
  22. package/cli/package-mode.mjs +46 -0
  23. package/cli/parse.mjs +15 -4
  24. package/cli/pnpm-runtime.mjs +96 -0
  25. package/cli/portability-worker.mjs +2 -2
  26. package/cli/release.mjs +1 -0
  27. package/cli/runtime-front-door.mjs +4 -2
  28. package/cli/server-proxy.mjs +69 -5
  29. package/cli/server-registry.mjs +10 -7
  30. package/cli/settings.mjs +14 -0
  31. package/cli/site-bindings.mjs +291 -0
  32. package/cli/site-browser-objects.mjs +36 -0
  33. package/cli/site-check.mjs +111 -0
  34. package/cli/site-client.mjs +50 -0
  35. package/cli/site-data.mjs +54 -0
  36. package/cli/site-entities.json +1 -0
  37. package/cli/site-input-assets.mjs +58 -0
  38. package/cli/site-inputs.mjs +79 -0
  39. package/cli/site-markup.mjs +102 -0
  40. package/cli/site-preview.mjs +53 -0
  41. package/cli/site-private.mjs +36 -0
  42. package/cli/site-references.mjs +93 -0
  43. package/cli/site-render.mjs +79 -0
  44. package/cli/site-syntax.mjs +21 -0
  45. package/cli/site.mjs +130 -0
  46. package/global/CHANGELOG.md +88 -0
  47. package/global/README.md +113 -2
  48. package/global/checkout.mjs +24 -10
  49. package/global/cli/install.mjs +6 -2
  50. package/global/delegate.mjs +9 -7
  51. package/global/door/command.mjs +254 -0
  52. package/global/door/control.mjs +65 -0
  53. package/global/door/cookies.mjs +41 -0
  54. package/global/door/pages.mjs +49 -0
  55. package/global/door/policy.mjs +49 -0
  56. package/global/door/routes.mjs +45 -0
  57. package/global/door/server.mjs +255 -0
  58. package/global/door/sites.mjs +192 -0
  59. package/global/launch/dev.mjs +21 -7
  60. package/global/launch/door.mjs +36 -0
  61. package/global/launch/install-pinned.mjs +22 -0
  62. package/global/launch/prepare.mjs +38 -5
  63. package/global/launch/render.mjs +6 -1
  64. package/global/launch/steps.mjs +12 -7
  65. package/global/launch/worker.mjs +3 -3
  66. package/global/list.mjs +6 -3
  67. package/global/main.mjs +39 -3
  68. package/global/output.mjs +12 -0
  69. package/global/rer.mjs +3 -0
  70. package/global/version.mjs +8 -8
  71. package/modules/dependencies/protocol.mjs +2 -1
  72. package/modules/deploy-kit/README.md +34 -1
  73. package/modules/deploy-kit/artifact.mjs +10 -0
  74. package/modules/deploy-kit/deploy.mjs +5 -2
  75. package/modules/deploy-kit/diagnostics.mjs +55 -0
  76. package/modules/deploy-kit/owner-cli.mjs +10 -4
  77. package/modules/deploy-kit/owner.mjs +8 -4
  78. package/modules/deploy-kit/prepare.mjs +3 -2
  79. package/modules/deploy-kit/provider.mjs +51 -9
  80. package/modules/deploy-kit/site.mjs +135 -0
  81. package/modules/erasure/CONTRACT.md +6 -3
  82. package/modules/erasure/README.md +8 -1
  83. package/modules/erasure/audit.mjs +2 -2
  84. package/modules/erasure/cascade.mjs +52 -11
  85. package/modules/erasure/order.mjs +29 -22
  86. package/modules/erasure/plan.mjs +4 -2
  87. package/modules/erasure/queue.mjs +41 -0
  88. package/modules/erasure/scope.mjs +3 -3
  89. package/modules/erasure/verification.mjs +4 -2
  90. package/modules/mail/README.md +1 -1
  91. package/modules/mail/index.mjs +1 -1
  92. package/modules/mail/src/core/errors.mjs +9 -0
  93. package/modules/mail/src/development/index.d.mts +6 -0
  94. package/modules/mail/src/development/index.mjs +6 -3
  95. package/modules/mail/src/development/viewer-html.mjs +3 -3
  96. package/modules/mail/src/development/viewer.mjs +8 -1
  97. package/modules/operator-auth/accounts/native.mjs +1 -1
  98. package/modules/operator-auth/dev/badge.mjs +4 -1
  99. package/modules/operator-auth/dev/index.mjs +15 -5
  100. package/modules/operator-auth/http.mjs +2 -3
  101. package/modules/operator-auth/index.d.mts +4 -9
  102. package/modules/operator-auth/loopback.mjs +17 -0
  103. package/modules/operator-auth/page.mjs +1 -1
  104. package/modules/operator-auth/service.mjs +1 -0
  105. package/modules/operator-auth/signin/index.d.mts +11 -0
  106. package/modules/operator-auth/signin/index.mjs +10 -7
  107. package/modules/owner-claim/verify.mjs +2 -1
  108. package/modules/portability/README.md +8 -0
  109. package/modules/portability/hash.mjs +6 -2
  110. package/modules/portability/import.mjs +1 -1
  111. package/modules/portability/legacy-files.mjs +4 -4
  112. package/modules/portability/legacy-safety.mjs +51 -10
  113. package/modules/portability/migration-history.mjs +15 -0
  114. package/modules/release/README.md +68 -3
  115. package/modules/release/keygen.mjs +18 -0
  116. package/modules/release/produce.mjs +14 -2
  117. package/modules/release/sign.mjs +44 -0
  118. package/modules/release/signature.mjs +336 -0
  119. package/modules/release/signing-cli.mjs +56 -0
  120. package/modules/release/signing-io.mjs +61 -0
  121. package/modules/release/signing-key.mjs +22 -0
  122. package/modules/release/source.mjs +15 -4
  123. package/modules/release/statement.mjs +80 -0
  124. package/modules/release/vectors/generate.mjs +381 -0
  125. package/modules/release/vectors/release-signature-vectors.json +2305 -0
  126. package/modules/release/verify.mjs +27 -0
  127. package/onboarding/adapter-auth.mjs +4 -3
  128. package/onboarding/adapter-settings.mjs +21 -5
  129. package/onboarding/adapter.mjs +2 -1
  130. package/onboarding/app-fetch.mjs +79 -0
  131. package/onboarding/archive-output.d.mts +4 -2
  132. package/onboarding/archive-output.mjs +22 -10
  133. package/onboarding/dependency-approval.mjs +4 -3
  134. package/onboarding/local/environment.mjs +7 -2
  135. package/onboarding/operator-install.mjs +8 -2
  136. package/onboarding/operator-prerequisites.mjs +5 -2
  137. package/onboarding/production-assets.mjs +23 -0
  138. package/onboarding/production-runtime.mjs +8 -4
  139. package/onboarding/queued-entry.mjs +9 -5
  140. package/operations/operations.mjs +11 -2
  141. package/package.json +76 -1
  142. package/scripts/publish-check/README.md +27 -17
  143. package/scripts/publish-check/content_identities.py +14 -233
  144. package/scripts/publish-check/git_history.py +2 -2
  145. package/scripts/publish-check/github_refs.py +28 -2
  146. package/scripts/publish-check/gitleaks.toml +1 -1
  147. package/scripts/publish-check/history_scan.py +6 -4
  148. package/scripts/publish-check/hygiene_config.py +3 -1
  149. package/scripts/publish-check/public_hygiene.py +63 -8
  150. package/scripts/publish-check/publication-config.json +16 -1
  151. package/scripts/publish-check/source_metadata.py +62 -0
  152. package/scripts/publish-check/source_shapes.py +91 -0
  153. package/scripts/publish-check/tree_scan.py +2 -2
  154. package/scripts/publish-check/source_hashes.mjs +0 -6
@@ -1,5 +1,6 @@
1
1
  import { prepareLiveModels } from './ai-local.mjs';
2
2
  import {fileURLToPath} from 'node:url';
3
+ import {join} from 'node:path';
3
4
  import { serverRegistry } from './server-registry.mjs';
4
5
  import { activityClock } from './server-activity.mjs';
5
6
  import { runtimeFrontDoor } from './runtime-front-door.mjs';
@@ -13,24 +14,29 @@ import { invalid,missing,refuse,RerError,lifecycleError } from './errors.mjs';
13
14
  import { processInfo } from '../modules/job-queue/processes.mjs';
14
15
  import { declaredCrons,runScheduled } from './scheduled.mjs';
15
16
  import { readDevKey,requireDevKey,refuseManagedInstallation,resolvePersona,openUrl,DEV_KEY_HEADER } from './dev-identity.mjs';
17
+ import { announceDoorRoute,claimSavedName,moveCheckout,routeRunning,verifyDoorFor } from './door-mode.mjs';
16
18
 
17
19
  // Seeds without devIdentity keep the outbox sign-in unchanged. With it, dev
18
20
  // opens signed in (RER_SIGNED_IN, RER_OPEN); --login and preview keep the
19
21
  // outbox, reached through RER_OPEN's one-time key exchange. A runtime that
20
22
  // serves no outbox (a seed's preview without one) is not pointed at it.
21
- export function ready(out,url,dev,json=false,outbox=true){
23
+ // A seed that declares a run site also gets RER_SITE: its static run site
24
+ // (homepage, /run, docs) served locally on its own port, after the ready lines.
25
+ export function ready(out,url,dev,json=false,outbox=true,siteUrl){
22
26
  const signInUrl=url+'/__dev/outbox',open=dev&&(dev.identity||outbox);
23
- if(json){out.event('ready',{url,...(outbox?{signInUrl}:{}),...(dev?{signedInAs:dev.signedInAs??null,...(open?{openUrl:dev.openUrl}:{})}:{})});return;}
27
+ if(json){out.event('ready',{url,...(outbox?{signInUrl}:{}),...(dev?{signedInAs:dev.signedInAs??null,...(open?{openUrl:dev.openUrl}:{})}:{}),...(siteUrl?{siteUrl}:{})});return;}
24
28
  out.human(`RER_READY ${url}`);
25
- if(dev?.identity){if(dev.signedInAs)out.human(`RER_SIGNED_IN ${dev.signedInAs}`);out.human(`RER_OPEN ${dev.openUrl}`);out.human('Open that link on this computer. Its fragment holds this checkout\'s dev key, which the page keeps only as a cookie; never share the link.');return;}
26
- if(!outbox)return;
27
- out.human(`RER_SIGN_IN ${signInUrl}`);
28
- if(dev)out.human(`RER_OPEN ${dev.openUrl}`);
29
- out.human(`Open ${dev?'the last link on this computer (it carries the dev key; never share it)':'the outbox'}, choose the newest sign-in message and follow its link. No email was sent.`);
29
+ if(dev?.identity){if(dev.signedInAs)out.human(`RER_SIGNED_IN ${dev.signedInAs}`);else if(dev.ownerLeft)out.human('No active owner remains in this app, so rer dev cannot sign in as one. Use rer dev --login with another operator, or rer reset --yes and rer setup for a new owner.');out.human(`RER_OPEN ${dev.openUrl}`);out.human('Open that link on this computer. Its fragment holds this checkout\'s dev key, which the page keeps only as a cookie; never share the link.');}
30
+ else if(outbox){
31
+ out.human(`RER_SIGN_IN ${signInUrl}`);
32
+ if(dev)out.human(`RER_OPEN ${dev.openUrl}`);
33
+ out.human(`Open ${dev?'the last link on this computer (it carries the dev key; never share it)':'the outbox'}, choose the newest sign-in message and follow its link. No email was sent.`);
34
+ }
35
+ if(siteUrl)out.human(`RER_SITE ${siteUrl}`);
30
36
  }
31
37
  export async function managedServer(project,options,out,{clock={}}={}){
32
38
  const registry=await serverRegistry(project.root),controller=new AbortController();
33
- let store,operation,proxy,activity,app,termination,reason,cleanupPromise,finished,selected,cleaned;
39
+ let store,operation,proxy,activity,app,termination,reason,cleanupPromise,finished,selected,cleaned,site;
34
40
  const scheduling=new AbortController();
35
41
  let activityWrites=Promise.resolve(),pendingActivity,activityWriting=false,infrastructureFailure;
36
42
  function recordActivity(fields){
@@ -39,13 +45,14 @@ export async function managedServer(project,options,out,{clock={}}={}){
39
45
  }
40
46
  const stopped=new Promise(resolve=>{finished=resolve;});
41
47
  const cleanupDone=new Promise(resolve=>{cleaned=resolve;});
42
- const cleanup=()=>cleanupPromise??=(async()=>{scheduling.abort();activity?.close();await store?.closeCommands();await proxy?.close();await operation?.release();})();
48
+ const cleanup=()=>cleanupPromise??=(async()=>{scheduling.abort();activity?.close();await site?.close();await store?.closeCommands();await proxy?.close();await operation?.release();})();
43
49
  const stop=async why=>{reason??=why;controller.abort();finished();await cleanupDone;return {};};
44
50
  const handlers=new Map(['SIGINT','SIGTERM'].map(name=>[name,()=>{termination=name;void stop(name);} ]));
45
51
  for(const [name,handler]of handlers)process.on(name,handler);
46
52
  try{
47
53
  if(options.stop){await registry.stop(options.verb,options);return {};}
48
54
  controller.signal.throwIfAborted();
55
+ if(options.door||options.direct)await moveCheckout(project,options,out,registry);
49
56
  const devIdentity=project.adapter.devIdentity;
50
57
  if(options.as!==undefined&&!(devIdentity&&options.verb==='dev'))throw invalid('--as needs a seed that declares devIdentity.');
51
58
  let devKey=devIdentity?await readDevKey(project.root):null;
@@ -62,13 +69,20 @@ export async function managedServer(project,options,out,{clock={}}={}){
62
69
  if(options.verb==='dev' && (running.models??'fake')!==(options.models??'fake'))throw refuse('Stop the running dev server before changing --models mode.');
63
70
  if(options.modelsService)throw refuse('Stop the running dev server before saving live-dev credentials.');
64
71
  if(options.verb==='dev'&&Boolean(running.login)!==Boolean(options.login))throw refuse(`The running dev server ${running.login?'uses':'does not use'} --login. Stop it with rer dev --stop to change sign-in mode.`);
65
- const response=await fetch(running.url+'/health',{headers:{'x-rer-probe':running.probe},signal:AbortSignal.timeout(5000)});
72
+ // A door-mode server is probed on its own proxy, never through the door.
73
+ await claimSavedName(project,running.url);
74
+ await routeRunning(running);
75
+ const {appFetch}=await shared('app-fetch.mjs');
76
+ const response=await appFetch(running.url+'/health',{headers:{'x-rer-probe':running.probe},signal:AbortSignal.timeout(5000)});
66
77
  if(!response.ok || (await response.json()).status!=='ok')throw refuse('Registered application failed health verification. Stop it before retrying.');
67
78
  if(devIdentity)devKey=await requireDevKey(project.root);
68
- emitReady(out,options,running.url,devIdentity&&await devReady(running.url,devKey,options,controller.signal));return {reused:true};
79
+ // A key is sent (or printed) for a door origin only once the door proves it is ours.
80
+ if(devIdentity)await verifyDoorFor(running.url);
81
+ const dev=devIdentity&&await devReady(running.url,devKey,options,controller.signal);
82
+ await emitReady(out,options,project.root,running.url,dev,true,running.siteUrl);return {reused:true};
69
83
  }
70
84
  await registry.serve(async action=>{
71
- if(action==='status')return {...activity?.snapshot(),url:selected?.url,probe:proxy?.probe};
85
+ if(action==='status')return {...activity?.snapshot(),url:selected?.url,probe:proxy?.probe,siteUrl:site?.url};
72
86
  if(action==='stop')return stop('explicit stop');
73
87
  throw Error('Unknown management operation');
74
88
  });
@@ -81,7 +95,7 @@ export async function managedServer(project,options,out,{clock={}}={}){
81
95
  const seed=project.adapter.setup?await loadInstallerSeed(project.root,project.adapter):undefined;project.setupManifest=seed?.manifest;
82
96
  const keys=localSettingKeys(project);
83
97
  selected=await selectedOrigin(project,options);
84
- await availablePort(selected.host,selected.port,{root:project.root,saved:!selected.explicitPort&&selected.savedOrigin!==undefined});
98
+ if(!selected.door)await availablePort(selected.host,selected.port,{root:project.root,saved:!selected.explicitPort&&selected.savedOrigin!==undefined});
85
99
  if(!selected.vars[keys.email])throw missing('Private owner settings are missing. Run rer setup first.');
86
100
  if(devIdentity&&!devKey)devKey=await requireDevKey(project.root);
87
101
  if(devIdentity&&options.verb==='dev'&&!options.login)refuseManagedInstallation(selected.vars,process.env);
@@ -97,7 +111,8 @@ export async function managedServer(project,options,out,{clock={}}={}){
97
111
  await tools(project.root,store,controller.signal);
98
112
  activity=activityClock({...clock,seconds:options.idleTimeout,onIdle:()=>void stop('idle timeout'),onChange:recordActivity});
99
113
  const crons=project.adapter.scheduled!==false?await declaredCrons(project.root,project.adapter).catch(error=>{throw refuse(`The declared scheduled triggers cannot run locally: ${error.message}`);}):[];
100
- proxy=await runtimeFrontDoor({host:selected.host,port:selected.port,activity});const internal=proxy.runtimePort;
114
+ proxy=await runtimeFrontDoor({host:selected.host,port:selected.port,activity,door:selected.door,devKey:devKey??undefined,origin:selected.url});const internal=proxy.runtimePort;
115
+ if(selected.door)await announceDoorRoute(selected,proxy,registry);
101
116
  const modelsLive = options.verb==='dev' ? await prepareLiveModels(project.root,options,store,controller.signal) : undefined;
102
117
  const adapterOptions={modelsLive,selectedOrigin:selected.url,operationToken:operation.token,privateSettings:project.adapter.settings,runtimePort:internal,devKey:devKey??undefined,devLogin:Boolean(options.login),scheduled:crons.length>0};
103
118
  const input={[keys.email]:selected.vars[keys.email]};
@@ -110,9 +125,22 @@ export async function managedServer(project,options,out,{clock={}}={}){
110
125
  if(!identity)throw Error('Runtime exited before custody registration.');
111
126
  await registry.update({runtimeProcess:{pid:identity.pid,start:identity.start}});
112
127
  },controller.signal,{...adapterOptions,verb:options.verb,dev:project.adapter.dev,preview:project.adapter.preview});
128
+ // A key is sent (or printed) for a door origin only once the door proves it is ours.
129
+ if(devKey)await verifyDoorFor(selected.url);
113
130
  const dev=devIdentity&&await devReady(selected.url,devKey,options,controller.signal);
114
131
  const outbox=await outboxServed(selected.url,devKey,controller.signal);
115
- activity.ready();emitReady(out,options,selected.url,dev,outbox);
132
+ if(options.verb==='dev'&&project.adapter.site){
133
+ const {devSite}=await import('./site.mjs');
134
+ try {
135
+ site=await devSite(project,store,controller.signal,{host:selected.host,port:selected.port});
136
+ await registry.update({siteUrl:site.url});
137
+ } catch(error) {
138
+ if(error.code!=='site_private_data')throw error;
139
+ const message=`Run site preview refused: ${error.message} The app is running; fix the site and run rer build --site before publishing it.`;
140
+ if(options.json)out.event('warning',{code:error.code,message});else out.progress(`Warning: ${message}\n`);
141
+ }
142
+ }
143
+ activity.ready();await emitReady(out,options,project.root,selected.url,dev,outbox,site?.url);
116
144
  if(crons.length){
117
145
  out.progress(`Running ${crons.length===1?'the scheduled trigger':'the scheduled triggers'} locally: ${crons.join(' | ')}\n`);
118
146
  void runScheduled({crons,url:`http://127.0.0.1:${internal}`,path:options.verb==='preview'?'/cdn-cgi/local/scheduled':undefined,signal:scheduling.signal,report:run=>reportScheduled(out,options,run)});
@@ -125,19 +153,32 @@ export async function managedServer(project,options,out,{clock={}}={}){
125
153
  }catch(error){if(termination)throw new RerError('cancelled',`Stopped by ${termination} after owned children were stopped.`,termination==='SIGINT'?130:143);if(['idle timeout','explicit stop'].includes(reason))return {};throw await lifecycleError(error,store);}finally{await cleanup();cleaned();while(activityWriting)await activityWrites;await registry.close();for(const [name,handler]of handlers)process.off(name,handler);}
126
154
  }
127
155
  function reportScheduled(out,options,run){
128
- const at=new Date().toISOString().replace(/\.\d+Z$/,'Z'),outcome=run.skipped?`skipped (${run.error})`:run.ok?`ok ${run.ms}ms`:`failed (${run.status?`HTTP ${run.status}`:run.error}) ${run.ms}ms`;
129
- if(options.json)out.event('scheduled',{at,cron:run.cron,ok:run.ok,skipped:Boolean(run.skipped),status:run.status,ms:run.ms});else out.human(`RER_SCHEDULED ${at} "${run.cron}" ${outcome}`);
156
+ const error=options.verb==='preview'&&run.status===404 ? 'The preview target does not expose /cdn-cgi/local/scheduled. Forward the adapter context scheduled flag to previewTarget, or set RER_RUNTIME_TRIGGERS=1 in a custom target using the shared production runtime. For another runtime, expose that internal trigger endpoint or declare scheduled:false.' : run.error;
157
+ const at=new Date().toISOString().replace(/\.\d+Z$/,'Z'),outcome=run.skipped?`skipped (${error})`:run.ok?`ok ${run.ms}ms`:`failed (${run.status?`HTTP ${run.status}`:error}) ${run.ms}ms`;
158
+ if(options.json)out.event('scheduled',{at,cron:run.cron,ok:run.ok,skipped:Boolean(run.skipped),status:run.status,ms:run.ms,...(error?{error}:{})});else out.human(`RER_SCHEDULED ${at} "${run.cron}" ${outcome}${error&&run.status?` — ${error}`:''}`);
130
159
  }
131
160
  // GET, not HEAD: a seed's outbox route need not answer HEAD. The body is never read.
132
161
  async function outboxServed(url,devKey,signal){
133
- const response=await fetch(url+'/__dev/outbox',{headers:devKey?{[DEV_KEY_HEADER]:devKey}:{},signal});
162
+ const {appFetch}=await shared('app-fetch.mjs');
163
+ const response=await appFetch(url+'/__dev/outbox',{headers:devKey?{[DEV_KEY_HEADER]:devKey}:{},signal});
134
164
  await response.body?.cancel();
135
165
  return response.ok;
136
166
  }
137
- function emitReady(out,options,url,dev,outbox){ready(out,url,dev||undefined,options.json,outbox);}
167
+ // dev also keeps its RER_OPEN link in owner-only .rer/local/open-url (0600),
168
+ // which reset removes with the rest of .rer/local, for a person or agent that
169
+ // did not keep the terminal output.
170
+ async function emitReady(out,options,root,url,dev,outbox,siteUrl){
171
+ if(options.verb==='dev'&&dev?.openUrl){
172
+ const {privateDirectory,writePrivateFile}=await shared('private-files.mjs');
173
+ await writePrivateFile(join(await privateDirectory(root,['.rer','local']),'open-url'),dev.openUrl+'\n');
174
+ }
175
+ ready(out,url,dev||undefined,options.json,outbox,siteUrl);
176
+ }
138
177
  async function devReady(url,key,options,signal){
139
178
  if(options.verb==='preview')return {openUrl:openUrl(url,key,{path:'/__dev/key',next:'/__dev/outbox'})};
140
- if(options.login)return {openUrl:openUrl(url,key,{next:'/__dev/outbox'})};
179
+ if(options.login)return {openUrl:openUrl(url,key,{path:'/__dev/key',next:'/__dev/outbox'})};
141
180
  const as=options.as??'owner',persona=await resolvePersona(url,key,as,signal);
142
- return {identity:true,signedInAs:persona.email??null,openUrl:openUrl(url,key,{path:'/__dev/key',as})};
181
+ // The owner persona names the owner the session will actually have (the
182
+ // remaining owner once the setup owner has left), or none when none is left.
183
+ return {identity:true,signedInAs:persona.email??null,ownerLeft:as==='owner'&&!persona.email,openUrl:openUrl(url,key,{path:'/__dev/key',as})};
143
184
  }
@@ -0,0 +1,81 @@
1
+ // A package-mode checkout's commands outside package.json and OPERATIONS.json:
2
+ // its delivery operations (rer-project.json, and the delivery producer's
3
+ // packaging.json that rer-project.json is generated from), the scripts those
4
+ // operations and its CI workflows run, and the workflows themselves. Converting
5
+ // to package mode rewrites their calls into the removed vendored copy
6
+ // (scripts/rer.mjs, scripts/base-cli, scripts/base-onboarding); check fails
7
+ // while any remains.
8
+ import { readFile, readdir } from 'node:fs/promises';
9
+ import { resolve } from 'node:path';
10
+
11
+ // A command line that runs a file of the vendored copy, and (in a JavaScript
12
+ // file) a string literal naming one, as a spawn argument does.
13
+ const vendoredCall = /\b(?:node|bash|sh)\s+(?:\.\/)?scripts\/(?:rer\.mjs|base-cli\/|base-onboarding\/)/;
14
+ const vendoredLiteral = /(['"`])(?:\.\/)?scripts\/(?:rer\.mjs|base-cli\/|base-onboarding\/)[^'"`\n]*\1/;
15
+ const commentLine = /^\s*(?:#|\/\/|\/?\*)/;
16
+ const jsFile = /\.(?:mjs|cjs|js)$/;
17
+ // A seed-relative script path in a command.
18
+ const scriptToken = /(?<![\w./$-])(?:\.\/)?((?:[\w.-]+\/)*[\w.-]+\.(?:sh|bash|mjs|cjs|js))(?![\w./-])/g;
19
+
20
+ // The files holding delivery operations, and where in each they are.
21
+ export const operationFiles = [
22
+ { file: 'rer-project.json', operations: value => value?.operations },
23
+ { file: 'scripts/delivery/base/packaging.json', operations: value => value?.project?.operations },
24
+ ];
25
+
26
+ async function text(root, file) {
27
+ try { return await readFile(resolve(root, file), 'utf8'); } catch (error) { if (error.code === 'ENOENT' || error.code === 'EISDIR') return undefined; throw error; }
28
+ }
29
+
30
+ // Each operations file present: { file, text, value, operations }.
31
+ export async function operationSources(root) {
32
+ const found = [];
33
+ for (const { file, operations } of operationFiles) {
34
+ const source = await text(root, file);
35
+ if (source === undefined) continue;
36
+ let value; try { value = JSON.parse(source); } catch { continue; }
37
+ const commands = operations(value);
38
+ if (commands && typeof commands === 'object') found.push({ file, text: source, value, operations: commands });
39
+ }
40
+ return found;
41
+ }
42
+
43
+ // The checkout's CI workflow files: { file, text }.
44
+ export async function workflowSources(root) {
45
+ let names;
46
+ try { names = await readdir(resolve(root, '.github/workflows')); } catch { return []; }
47
+ const found = [];
48
+ for (const name of names.filter(name => /\.ya?ml$/.test(name)).sort()) found.push({ file: `.github/workflows/${name}`, text: await text(root, `.github/workflows/${name}`) });
49
+ return found.filter(source => source.text !== undefined);
50
+ }
51
+
52
+ // The seed's own script files a command runs (that exist, outside node_modules
53
+ // and the vendored copy).
54
+ export async function calledScripts(root, command) {
55
+ const found = [];
56
+ for (const [, path] of command.matchAll(scriptToken)) {
57
+ if (/^(?:node_modules\/|scripts\/(?:rer\.mjs$|base-cli\/|base-onboarding\/))/.test(path) || found.includes(path)) continue;
58
+ if (await text(root, path) !== undefined) found.push(path);
59
+ }
60
+ return found;
61
+ }
62
+
63
+ // Every place these commands still call the vendored copy, as `file:line: text`
64
+ // (or `file operations.NAME: command`).
65
+ export async function vendoredCalls(root) {
66
+ const calls = [], scripts = new Set();
67
+ for (const { file, operations } of await operationSources(root)) for (const [name, command] of Object.entries(operations)) {
68
+ if (typeof command !== 'string') continue;
69
+ if (vendoredCall.test(command)) calls.push(`${file} operations.${name}: ${command}`);
70
+ for (const path of await calledScripts(root, command)) scripts.add(path);
71
+ }
72
+ const lines = (file, source, literals) => source.split('\n').forEach((line, index) => {
73
+ if (!commentLine.test(line) && (vendoredCall.test(line) || (literals && vendoredLiteral.test(line)))) calls.push(`${file}:${index + 1}: ${line.trim().slice(0, 160)}`);
74
+ });
75
+ for (const { file, text: source } of await workflowSources(root)) {
76
+ lines(file, source, false);
77
+ for (const path of await calledScripts(root, source)) scripts.add(path);
78
+ }
79
+ for (const path of [...scripts].sort()) lines(path, await text(root, path), jsFile.test(path));
80
+ return calls;
81
+ }
@@ -0,0 +1,46 @@
1
+ // Package mode: a first-party checkout that pins the @runeditrun/rer package as
2
+ // an exact devDependency instead of carrying a vendored copy (scripts/rer.mjs,
3
+ // scripts/base-cli and scripts/base-onboarding). Its commands run `rer VERB`
4
+ // from node_modules/.bin, and its adapter imports the package's exports.
5
+ import { readFile } from 'node:fs/promises';
6
+ import { resolve } from 'node:path';
7
+
8
+ export const packageName = '@runeditrun/rer';
9
+ export const exactVersion = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
10
+ export const vendoredPaths = ['scripts/rer.mjs', 'scripts/base-cli', 'scripts/base-onboarding'];
11
+
12
+ // The specifier a package.json declares for the package, if any.
13
+ export const declaredPackage = pkg => pkg?.devDependencies?.[packageName] ?? pkg?.dependencies?.[packageName];
14
+
15
+ // What a verb's OPERATIONS argv and package script are in each mode
16
+ // (a null verb is the bare `rer` alias).
17
+ export const commandArgv = (verb, packaged) => packaged ? ['rer', ...(verb ? [verb] : [])] : ['node', 'scripts/rer.mjs', ...(verb ? [verb] : [])];
18
+ export const commandLine = (verb, packaged) => commandArgv(verb, packaged).join(' ');
19
+
20
+ // The importer entry pnpm-lock.yaml holds for the package: { group,
21
+ // specifier, version, locked } (locked: the packages section resolves that
22
+ // version with a sha512 integrity), or undefined. pnpm writes lockfile
23
+ // version 9 in a fixed layout, which this reads line by line.
24
+ export async function lockedPackage(root) {
25
+ let text;
26
+ try { text = await readFile(resolve(root, 'pnpm-lock.yaml'), 'utf8'); } catch { return undefined; }
27
+ const lines = text.split('\n'), indent = line => line.length - line.trimStart().length;
28
+ // The lines under a key at a given indentation, up to the next line at or above it.
29
+ const block = (from, to, key, depth) => {
30
+ const start = lines.findIndex((line, index) => index >= from && index < to && indent(line) === depth && line.trim() === key);
31
+ if (start < 0) return undefined;
32
+ let end = start + 1;
33
+ while (end < to && (!lines[end].trim() || indent(lines[end]) > depth)) end++;
34
+ return [start + 1, end];
35
+ };
36
+ const importers = block(0, lines.length, 'importers:', 0), own = importers && block(...importers, '.:', 2);
37
+ if (!own) return undefined;
38
+ for (const group of ['devDependencies', 'dependencies']) {
39
+ const entries = block(...own, `${group}:`, 4), entry = entries && block(...entries, `'${packageName}':`, 6);
40
+ if (!entry) continue;
41
+ const fields = Object.fromEntries(lines.slice(...entry).map(line => /^\s+(specifier|version):\s*'?([^'\s]*)'?\s*$/.exec(line)).filter(Boolean).map(match => [match[1], match[2]]));
42
+ const packages = block(0, lines.length, 'packages:', 0), resolved = packages && block(...packages, `'${packageName}@${fields.version}':`, 2);
43
+ return { group, specifier: fields.specifier, version: fields.version, locked: Boolean(resolved && lines.slice(...resolved).some(line => /^\s+resolution: \{integrity: sha512-/.test(line))) };
44
+ }
45
+ return undefined;
46
+ }
package/cli/parse.mjs CHANGED
@@ -1,9 +1,15 @@
1
1
  import { resolve } from 'node:path';
2
2
  import { commonFlags, serverDefaults, verbs } from './contract.mjs';
3
3
  import { invalid, refuse } from './errors.mjs';
4
- const booleans = new Set(['--help', '--json', '--non-interactive', '--yes', '--dry-run', '--open', '--stop', '--no-queue', '--login', '--legacy-v1', '--acknowledge-unverified-metadata']);
4
+ import { isLoopbackHost } from '../modules/operator-auth/loopback.mjs';
5
+ const booleans = new Set(['--help', '--json', '--non-interactive', '--yes', '--dry-run', '--open', '--stop', '--detach', '--foreground', '--no-queue', '--login', '--legacy-v1', '--acknowledge-unverified-metadata', '--door', '--direct', '--site']);
5
6
  export const mailbox = value => typeof value === 'string' && value.length <= 254 && /^[a-zA-Z0-9.!+_%\-]+@[a-zA-Z0-9\-]+(?:\.[a-zA-Z0-9\-]+)+$/.test(value);
6
- export const loopback = host => ['127.0.0.1', 'localhost', '::1'].includes(host);
7
+ // A loopback host: the shared predicate (including `*.localhost` door names),
8
+ // plus `::1` as --host and a bracket-stripped hostname spell it.
9
+ export const loopback = host => host === '::1' || isLoopbackHost(host);
10
+ // The addresses rer binds directly. A `*.localhost` name is reached through the
11
+ // front door; binding one would give a `::1`-only listener on macOS.
12
+ export const listenHost = host => ['127.0.0.1', 'localhost', '::1'].includes(host);
7
13
  export const origin = (host, port) => `http://${host === '::1' ? '[::1]' : host}:${port}`;
8
14
 
9
15
  export function parse(argv, cwd = process.cwd(), { independent = false, deferTail = false } = {}) {
@@ -39,7 +45,11 @@ export function parse(argv, cwd = process.cwd(), { independent = false, deferTai
39
45
  }
40
46
  if (options.help) return options;
41
47
  if (deferTail) { options.cwd=resolve(cwd,options.cwd);return options; }
42
- if(options.stop && ['--port','--host','--idle-timeout','--no-queue','--as','--login','--models','--models-service'].some(flag=>options.supplied.has(flag)))throw invalid('--stop cannot be combined with start-only flags.');
48
+ if (options.detach && options.foreground) throw invalid('Choose one of --detach and --foreground.');
49
+ if(options.stop && ['--port','--host','--idle-timeout','--detach','--foreground','--no-queue','--as','--login','--models','--models-service','--door','--direct'].some(flag=>options.supplied.has(flag)))throw invalid('--stop cannot be combined with start-only flags.');
50
+ // --port and --host always mean direct mode; --door and --direct pick a mode.
51
+ if(options.door&&options.direct)throw invalid('Choose one of --door and --direct.');
52
+ if(options.door&&['--port','--host'].some(flag=>options.supplied.has(flag)))throw invalid('--door cannot be combined with --port or --host, which always serve directly.');
43
53
  if(options.models!==undefined && !['fake','live'].includes(options.models))throw invalid('--models takes fake or live.');
44
54
  if(options.modelsService!==undefined && options.models!=='live')throw invalid('--models-service requires --models live.');
45
55
  if(options.as!==undefined && (options.login || !/^[a-z][a-z0-9-]{0,39}$/.test(options.as)))throw invalid(options.login?'--as and --login cannot be combined.':'--as takes a persona name such as owner, signed-out or admin.');
@@ -53,9 +63,10 @@ export function parse(argv, cwd = process.cwd(), { independent = false, deferTai
53
63
  options.port = Number(options.port);
54
64
  }
55
65
  for(const key of ['httpPort','httpsPort'])if(options[key]!==undefined){if(!/^[0-9]+$/.test(options[key])||Number(options[key])<1||Number(options[key])>65535)throw invalid('Ingress port must be an integer from 1 to 65535.');options[key]=Number(options[key]);}
66
+ if(options.site&&['platform','image','bindAddress','httpPort','httpsPort','envFile','host'].some(key=>options[key]!==undefined))throw invalid('--site takes no platform, container or host flags: the run site is static files on an assets-only Worker.');
56
67
  if(['image','bindAddress','httpPort','httpsPort'].some(key=>options[key]!==undefined)&&options.platform!=='container')throw invalid('Container image and ingress flags require --platform container.');
57
68
  if(options.platform !== undefined && !['workers','container'].includes(options.platform))throw invalid('Platform must be workers or container.');
58
- if (options.host !== undefined && !(verb === 'deploy' && options.platform === 'container') && !loopback(options.host)) throw refuse('Host must be exactly 127.0.0.1, localhost or ::1.');
69
+ if (options.host !== undefined && !(verb === 'deploy' && options.platform === 'container') && !listenHost(options.host)) throw refuse('Host must be exactly 127.0.0.1, localhost or ::1.');
59
70
  if (options.ownerEmail !== undefined) { if (!mailbox(options.ownerEmail)) throw invalid('Provide a valid plain owner mailbox.'); options.ownerEmail = options.ownerEmail.toLowerCase(); }
60
71
  if (options.ownerName !== undefined && (options.ownerName.length > 200 || /[\p{Cc}\p{Cf}]/u.test(options.ownerName))) throw invalid('Provide a valid owner name.');
61
72
  if (options.target !== undefined && !['local', 'remote'].includes(options.target)) throw invalid('Target must be local or remote.');
@@ -0,0 +1,96 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { createReadStream, createWriteStream } from 'node:fs';
3
+ import { chmod, lstat, mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises';
4
+ import { homedir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { pipeline } from 'node:stream/promises';
7
+ import { Transform } from 'node:stream';
8
+ import { spawn } from 'node:child_process';
9
+ import { missing } from './errors.mjs';
10
+ import { shellQuote } from './node-runtime-cache.mjs';
11
+
12
+ // The project's pinned pnpm, provided the way rer provides its pinned Node:
13
+ // fetched once from the npm registry, its tarball checked against the
14
+ // registry's sha512 integrity before anything is extracted or run, and kept in
15
+ // rer's own cache (~/.rer/pnpm/pnpm-VERSION). Only rer's own commands for the
16
+ // project put it on their PATH; nothing global changes, and it is used only
17
+ // when the person's PATH has no pnpm of the pinned version.
18
+ export const pnpmCache = () => join(homedir(), '.rer', 'pnpm');
19
+ const directoryOf = version => join(pnpmCache(), `pnpm-${version}`);
20
+ const shim = version => join(directoryOf(version), 'shim');
21
+ const receipt = version => join(directoryOf(version), '.rer-verified.json');
22
+ const entry = version => join(directoryOf(version), 'bin', 'pnpm.cjs');
23
+
24
+ async function digest(path, algorithm, encoding) {
25
+ const hash = createHash(algorithm);
26
+ for await (const chunk of createReadStream(path)) hash.update(chunk);
27
+ return hash.digest(encoding);
28
+ }
29
+ async function owned(path, directory) {
30
+ const stat = await lstat(path);
31
+ if (stat.isSymbolicLink() || (directory ? !stat.isDirectory() : !stat.isFile()) || (process.getuid && stat.uid !== process.getuid()) || (stat.mode & 0o022)) throw Error('Unsafe pnpm cache placement.');
32
+ }
33
+ async function prepareCache() {
34
+ for (const path of [join(homedir(), '.rer'), pnpmCache()]) {
35
+ await mkdir(path, { mode: 0o700 }).catch(error => { if (error.code !== 'EEXIST') throw error; });
36
+ try { await owned(path, true); } catch { throw missing(`The pnpm cache directory must be an owned, non-linked directory without group/other write access: ${path}. Correct its placement and permissions before retrying.`); }
37
+ }
38
+ return pnpmCache();
39
+ }
40
+
41
+ /** The verified cached pnpm's PATH directory, or undefined when none is cached. */
42
+ export async function cachedPnpm(version) {
43
+ try { await lstat(directoryOf(version)); } catch (error) { if (error.code === 'ENOENT') return; throw error; }
44
+ try {
45
+ for (const path of [join(homedir(), '.rer'), pnpmCache(), directoryOf(version), shim(version)]) await owned(path, true);
46
+ for (const path of [receipt(version), entry(version), join(shim(version), 'pnpm')]) await owned(path, false);
47
+ const record = JSON.parse(await readFile(receipt(version), 'utf8'));
48
+ if (record.version !== version || !/^sha512-/.test(record.integrity) || record.entrySha256 !== await digest(entry(version), 'sha256', 'hex')) throw Error('pnpm cache integrity mismatch.');
49
+ return shim(version);
50
+ } catch {
51
+ throw missing(`The cached pnpm is unsafe or incomplete. Preserve it for inspection with: mv ${shellQuote(directoryOf(version))} ${shellQuote(directoryOf(version) + '.quarantine-' + Date.now())}. Then rerun the original rer command to fetch a verified release.`);
52
+ }
53
+ }
54
+
55
+ async function download(url, destination, limit, signal) {
56
+ const response = await fetch(url, { signal, redirect: 'error' });
57
+ if (!response.ok || !response.body) throw Error('The pnpm release could not be downloaded.');
58
+ let bytes = 0;
59
+ const bounded = new Transform({ transform(chunk, encoding, callback) { bytes += chunk.length; callback(bytes > limit ? Error('pnpm release download exceeds its size limit.') : null, chunk); } });
60
+ await pipeline(response.body, bounded, createWriteStream(destination, { flags: 'wx', mode: 0o600 }), { signal });
61
+ }
62
+ function extract(archive, directory, signal) {
63
+ const child = spawn('tar', ['-xzf', archive, '-C', directory], { stdio: 'ignore', signal });
64
+ return new Promise((resolve, reject) => {
65
+ let failure;
66
+ child.once('error', error => { failure = error; });
67
+ child.once('close', code => code === 0 && !failure ? resolve() : reject(failure ?? Error('Verified pnpm release extraction failed.')));
68
+ });
69
+ }
70
+
71
+ /** Fetch, verify and cache pnpm VERSION; returns its PATH directory. `registry` is the npm registry origin. */
72
+ export async function downloadPnpm(version, cancellation, { registry = 'https://registry.npmjs.org' } = {}) {
73
+ const cache = await prepareCache(), temporary = await mkdtemp(join(cache, '.download-'));
74
+ const signal = AbortSignal.any([cancellation, AbortSignal.timeout(60000)].filter(Boolean));
75
+ try {
76
+ const metadata = join(temporary, 'release.json'), archive = join(temporary, 'pnpm.tgz');
77
+ await download(`${registry}/pnpm/${version}`, metadata, 1024 * 1024, signal);
78
+ const release = JSON.parse(await readFile(metadata, 'utf8'));
79
+ const integrity = release?.dist?.integrity;
80
+ if (release?.version !== version || typeof integrity !== 'string' || !/^sha512-[A-Za-z0-9+/]{86}==$/.test(integrity)) throw Error('The pnpm release integrity is missing.');
81
+ await download(`${registry}/pnpm/-/pnpm-${version}.tgz`, archive, 64 * 1024 * 1024, signal);
82
+ if (`sha512-${await digest(archive, 'sha512', 'base64')}` !== integrity) throw Error('pnpm release integrity verification failed; nothing was extracted or run.');
83
+ // This is the first point at which any downloaded content can be run.
84
+ await extract(archive, temporary, signal);
85
+ const extracted = join(temporary, 'package');
86
+ if (JSON.parse(await readFile(join(extracted, 'package.json'), 'utf8')).version !== version) throw Error('Verified pnpm release did not name the project pin.');
87
+ await mkdir(join(extracted, 'shim'), { mode: 0o700 });
88
+ // `node` is rer's own: the command puts its Node first on PATH beside this.
89
+ await writeFile(join(extracted, 'shim', 'pnpm'), `#!/bin/sh\nexec node ${shellQuote(entry(version))} "$@"\n`, { mode: 0o700 });
90
+ await writeFile(join(extracted, '.rer-verified.json'), JSON.stringify({ version, integrity, entrySha256: await digest(join(extracted, 'bin', 'pnpm.cjs'), 'sha256', 'hex') }) + '\n', { flag: 'wx', mode: 0o600 });
91
+ await chmod(extracted, 0o700);
92
+ try { await rename(extracted, directoryOf(version)); }
93
+ catch (error) { if (!['EEXIST', 'ENOTEMPTY'].includes(error.code) || !(await cachedPnpm(version))) throw error; }
94
+ return shim(version);
95
+ } finally { await rm(temporary, { recursive: true, force: true }); }
96
+ }
@@ -2,7 +2,7 @@ import { portabilityPlan, ensurePortabilitySchema } from '../modules/container/p
2
2
  import { exportArchive, importArchive, fileRecords } from '../modules/portability/files.mjs';
3
3
  import { readLegacyArchive } from '../modules/portability/format.mjs';
4
4
  import { recoverLegacyArchive } from '../modules/portability/legacy-files.mjs';
5
- import { legacyEmptyTables, requireLegacyEmpty } from '../modules/portability/legacy-safety.mjs';
5
+ import { legacyTargetState, requireLegacyEmpty } from '../modules/portability/legacy-safety.mjs';
6
6
  import { migrationReplay } from '../modules/portability/migration-history.mjs';
7
7
  import { join } from 'node:path';
8
8
  import { readFile } from 'node:fs/promises';
@@ -33,7 +33,7 @@ async function run(direction, { root, path, journal, binding, buckets, policy, t
33
33
  const migrations=async()=>migrationReplay(await appliedMigrations(root,proxy.env[binding],binding,unstable_splitSqlQuery),policy);
34
34
  const options={ db: proxy.env[binding], buckets: Object.fromEntries(Object.entries(buckets).map(([name, binding]) => [name, proxy.env[binding]])), policy, migrations };
35
35
  if(direction==='legacy-preflight') {
36
- await requireLegacyEmpty(options,await legacyEmptyTables(options.db));
36
+ await requireLegacyEmpty(options,await legacyTargetState(options.db,options.migrations));
37
37
  return {checkedEmpty:true};
38
38
  }
39
39
  await ensurePortabilitySchema(proxy.env[binding], await portabilityPlan(unstable_splitSqlQuery));
package/cli/release.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  // Preserve release's accepted parser, producer, receipt and validation exits.
2
2
  export async function dispatchRelease(args) {
3
+ if (['sign', 'receipt', 'keygen'].includes(args[0])) return (await import('../modules/release/signing-cli.mjs')).dispatchSigning(args[0], args.slice(1));
3
4
  try {
4
5
  const options = {};
5
6
  for (let index = 0; index < args.length; index++) {
@@ -7,10 +7,12 @@ import { trafficProxy } from './server-proxy.mjs';
7
7
  // All public serving, including setup's bounded runtime, uses this listener.
8
8
  // The adapter receives a separate loopback port and keeps the public origin
9
9
  // for application URLs, cookies and sign-in links.
10
- export async function runtimeFrontDoor({ host, port, activity }) {
10
+ // In door mode (`door`) the listener is 127.0.0.1 on a port the OS assigns,
11
+ // and only the machine's front door reaches it.
12
+ export async function runtimeFrontDoor({ host, port, activity, door, devKey, origin }) {
11
13
  const runtimePort = await runtimeLoopbackPort(port);
12
14
  const observer = activity ?? { begin: () => () => {}, touch: () => {} };
13
- const proxy = await trafficProxy({ host, port, target: runtimePort, activity: observer });
15
+ const proxy = await trafficProxy({ host, port, target: runtimePort, activity: observer, door, devKey, origin });
14
16
  return { ...proxy, runtimePort };
15
17
  }
16
18
 
@@ -1,18 +1,33 @@
1
1
  import http from 'node:http';
2
2
  import net from 'node:net';
3
- import { randomUUID } from 'node:crypto';
3
+ import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
4
4
  import { posix } from 'node:path';
5
5
  import { refuse } from './errors.mjs';
6
+ import { loopback } from './parse.mjs';
6
7
  import { fontResponseHeaders } from './font-cache.mjs';
7
8
 
8
9
  // The public listener observes transport lifetime without buffering or replacing
9
10
  // application responses. Preserve Host, Origin, cookies, status and streaming.
10
11
  // Each request opens its own loopback connection: a pooled keep-alive socket
11
12
  // that the runtime has already dropped would otherwise fail a fresh request.
12
- export async function trafficProxy({host,port,target,activity}) {
13
- const probe=randomUUID(),sockets=new Set();
13
+ // In door mode (`door`: {host, header, secret}) it answers only requests whose
14
+ // Host is its own `<name>.localhost:<port>` (or a subdomain) and that carry the
15
+ // per-start secret the front door adds; cookies are not scoped by port, so
16
+ // anything else could bring another site's cookies here.
17
+ // Vite's /@fs/<absolute path> answers only a request holding the checkout's dev
18
+ // key (devKey: the key gate's header or cookie); anything else gets 404, so
19
+ // Vite's refusal page, which lists the serving allow list, never answers it: a
20
+ // 403 carrying that page for any other path (a checkout-root file) is turned
21
+ // into the same 404.
22
+ // A Host that is neither loopback, a `*.localhost` name nor the configured
23
+ // origin's (origin) gets a plain 421 here: the application would refuse it
24
+ // anyway, and an auth check that throws on it must not answer with a crash page.
25
+ export async function trafficProxy({host,port,target,activity,door,devKey,origin}) {
26
+ const probe=randomUUID(),sockets=new Set(),keyed=keyHolder(devKey),configured=origin?new URL(origin).host:null;
14
27
  const server=http.createServer((req,res)=>{
15
- if(!publicPath(req.url)){req.resume();notFound(res);return;}
28
+ if(door&&!doorAdmits(req,door)){req.resume();res.writeHead(403,{'content-type':'text/plain; charset=utf-8'});res.end('This checkout answers only through its front door.');return;}
29
+ if(!hostAdmits(req,configured)){req.resume();res.writeHead(421,{'content-type':'text/plain; charset=utf-8'});res.end('This local server answers only its own host.');return;}
30
+ if(!publicPath(req.url)||fsPath(req.url)&&!keyed(req)){req.resume();notFound(res);return;}
16
31
  const internal=req.headers['x-rer-probe']===probe;delete req.headers['x-rer-probe'];
17
32
  const finish=internal?()=>{}:activity.begin();
18
33
  let requestEnded=req.complete,responseEnded=false;
@@ -20,6 +35,20 @@ export async function trafficProxy({host,port,target,activity}) {
20
35
  req.once('end',()=>{requestEnded=true;completed();});req.once('aborted',finish);
21
36
  res.once('close',()=>{responseEnded=true;if(req.destroyed)finish();else completed();});res.once('finish',()=>{responseEnded=true;completed();});
22
37
  const upstream=http.request({host:'127.0.0.1',port:target,path:req.url,method:req.method,headers:req.headers,agent:false},reply=>{
38
+ // Vite answers a path outside its serving allow list with a 403 page that lists the
39
+ // allow list (checkout and home paths); a request without the dev key gets 404 instead.
40
+ // Any other 403 is the application's own and passes through.
41
+ if(reply.statusCode===403&&!keyed(req)){
42
+ const parts=[];
43
+ reply.on('data',chunk=>parts.push(chunk));
44
+ reply.once('end',()=>{
45
+ const body=Buffer.concat(parts);
46
+ if(body.includes(restrictedPage)){notFound(res);return;}
47
+ res.writeHead(reply.statusCode,fontResponseHeaders(req.url,reply.statusCode,reply.rawHeaders,req.headers));res.end(body);
48
+ });
49
+ reply.once('error',()=>res.destroy());
50
+ return;
51
+ }
23
52
  res.writeHead(reply.statusCode,fontResponseHeaders(req.url,reply.statusCode,reply.rawHeaders,req.headers));reply.pipe(res);
24
53
  });
25
54
  req.once('aborted',()=>upstream.destroy());res.once('close',()=>upstream.destroy());
@@ -27,7 +56,9 @@ export async function trafficProxy({host,port,target,activity}) {
27
56
  });
28
57
  server.on('connection',socket=>{sockets.add(socket);socket.on('close',()=>sockets.delete(socket));});
29
58
  server.on('upgrade',(req,socket,head)=>{
30
- if(!publicPath(req.url)){socket.end('HTTP/1.1 404 Not Found\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');return;}
59
+ if(door&&!doorAdmits(req,door)){socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');return;}
60
+ if(!hostAdmits(req,configured)){socket.end('HTTP/1.1 421 Misdirected Request\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');return;}
61
+ if(!publicPath(req.url)||fsPath(req.url)&&!keyed(req)){socket.end('HTTP/1.1 404 Not Found\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');return;}
31
62
  const hmr=String(req.headers['sec-websocket-protocol']??'').split(',').map(s=>s.trim()).includes('vite-hmr');
32
63
  const upstream=net.connect(target,'127.0.0.1',()=>{
33
64
  upstream.write(`${req.method} ${req.url} HTTP/${req.httpVersion}\r\n${req.rawHeaders.reduce((lines,value,i)=>i%2?lines:lines+value+': '+req.rawHeaders[i+1]+'\r\n','')}\r\n`);
@@ -55,6 +86,33 @@ export async function trafficProxy({host,port,target,activity}) {
55
86
  }
56
87
  return {probe,port:server.address().port,async close(){for(const socket of sockets)socket.destroy();await new Promise(resolve=>server.close(resolve));}};
57
88
  }
89
+ function doorAdmits(req,door){
90
+ const host=String(req.headers.host??'').toLowerCase(),given=Buffer.from(String(req.headers[door.header]??'')),secret=Buffer.from(door.secret);
91
+ // The secret never reaches the application.
92
+ delete req.headers[door.header];
93
+ for(let i=req.rawHeaders.length-2;i>=0;i-=2)if(req.rawHeaders[i].toLowerCase()===door.header)req.rawHeaders.splice(i,2);
94
+ return (host===door.host||host.endsWith('.'+door.host))&&given.length===secret.length&&timingSafeEqual(given,secret);
95
+ }
96
+ function hostAdmits(req,configured){
97
+ const host=req.headers.host;
98
+ if(host===undefined)return true;
99
+ let url;try{url=new URL(`http://${host}`);}catch{return false;}
100
+ if(url.username||url.password||url.pathname!=='/'||url.search||url.hash)return false;
101
+ return loopback(url.hostname)||url.host===configured;
102
+ }
103
+ // The key gate's own credentials (modules/operator-auth/dev-gate.mjs): the
104
+ // x-rer-dev-key header, or the browser's rer_dev_gate_<port> cookie.
105
+ function keyHolder(key){
106
+ if(!key)return ()=>false;
107
+ const same=(a,b)=>{a=Buffer.from(a);b=Buffer.from(b);return a.length===b.length&&timingSafeEqual(a,b);};
108
+ const cookie=createHmac('sha256',key).update('rer-dev-gate\0cookie').digest('hex');
109
+ return req=>{
110
+ const header=req.headers['x-rer-dev-key'];
111
+ if(header!==undefined)return same(String(header),key);
112
+ return String(req.headers.cookie??'').split(';').map(part=>part.trim()).some(part=>/^rer_dev_gate_\d+=/.test(part)&&same(part.slice(part.indexOf('=')+1),cookie));
113
+ };
114
+ }
115
+ const restrictedPage=Buffer.from('<h1>403 Restricted</h1>');
58
116
  function notFound(res){res.writeHead(404,{'content-type':'text/plain; charset=utf-8'});res.end('Not found');}
59
117
  // The runtime's built-in routes (/cdn-cgi/*: Local Explorer, trigger handlers,
60
118
  // R2 and platform proxies), the checkout's private files and export archives
@@ -73,6 +131,12 @@ export function publicPath(url){
73
131
  const served=posix.normalize('/'+segments.join('/')).split('/').filter(Boolean),exports=segment=>/^exports$/i.test(segment);
74
132
  return !exports(served[0])&&!(served[0]?.startsWith('@')&&served.some(exports));
75
133
  }
134
+ // Vite's /@fs/ prefix in any spelling publicPath decodes.
135
+ export function fsPath(url){
136
+ let path=String(url).split(/[?#]/)[0];
137
+ for(let round=0;round<4;round++){let decoded;try{decoded=decodeURIComponent(path);}catch{return true;}if(decoded===path)break;path=decoded;}
138
+ return /^@fs$/i.test(posix.normalize('/'+path.replace(/\\/g,'/').split('/').map(segment=>segment.trim()).join('/')).split('/').filter(Boolean)[0]??'');
139
+ }
76
140
  // Count application data frames, not protocol ping/pong or an open control
77
141
  // socket. Parse lengths incrementally without retaining application payloads.
78
142
  export function frameObserver(touch) {