@runeditrun/rer 0.2.0 → 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 (70) hide show
  1. package/README.md +3 -2
  2. package/cli/README.md +4 -2
  3. package/cli/contract.mjs +2 -2
  4. package/cli/deploy.mjs +17 -3
  5. package/cli/detached-server.mjs +51 -0
  6. package/cli/hosted-output.mjs +2 -2
  7. package/cli/hydration/check.mjs +2 -2
  8. package/cli/inspect.mjs +1 -1
  9. package/cli/main.mjs +1 -0
  10. package/cli/managed-server.mjs +12 -5
  11. package/cli/parse.mjs +3 -2
  12. package/cli/release.mjs +1 -0
  13. package/cli/site-bindings.mjs +291 -0
  14. package/cli/site-browser-objects.mjs +36 -0
  15. package/cli/site-check.mjs +46 -31
  16. package/cli/site-client.mjs +50 -0
  17. package/cli/site-data.mjs +54 -0
  18. package/cli/site-entities.json +1 -0
  19. package/cli/site-input-assets.mjs +58 -0
  20. package/cli/site-inputs.mjs +79 -0
  21. package/cli/site-markup.mjs +102 -0
  22. package/cli/site-private.mjs +36 -0
  23. package/cli/site-references.mjs +93 -0
  24. package/cli/site-render.mjs +7 -5
  25. package/cli/site-syntax.mjs +21 -0
  26. package/cli/site.mjs +32 -11
  27. package/global/CHANGELOG.md +25 -0
  28. package/global/README.md +3 -2
  29. package/global/main.mjs +2 -0
  30. package/global/output.mjs +12 -0
  31. package/global/rer.mjs +3 -0
  32. package/modules/deploy-kit/deploy.mjs +5 -2
  33. package/modules/deploy-kit/diagnostics.mjs +55 -0
  34. package/modules/deploy-kit/owner-cli.mjs +10 -4
  35. package/modules/deploy-kit/owner.mjs +3 -0
  36. package/modules/deploy-kit/provider.mjs +45 -6
  37. package/modules/operator-auth/accounts/native.mjs +1 -1
  38. package/modules/operator-auth/dev/badge.mjs +4 -1
  39. package/modules/operator-auth/index.d.mts +4 -9
  40. package/modules/operator-auth/page.mjs +1 -1
  41. package/modules/operator-auth/service.mjs +1 -0
  42. package/modules/operator-auth/signin/index.d.mts +11 -0
  43. package/modules/operator-auth/signin/index.mjs +10 -7
  44. package/modules/release/README.md +63 -0
  45. package/modules/release/keygen.mjs +18 -0
  46. package/modules/release/produce.mjs +9 -0
  47. package/modules/release/sign.mjs +44 -0
  48. package/modules/release/signature.mjs +336 -0
  49. package/modules/release/signing-cli.mjs +56 -0
  50. package/modules/release/signing-io.mjs +61 -0
  51. package/modules/release/signing-key.mjs +22 -0
  52. package/modules/release/statement.mjs +80 -0
  53. package/modules/release/vectors/generate.mjs +381 -0
  54. package/modules/release/vectors/release-signature-vectors.json +2305 -0
  55. package/modules/release/verify.mjs +27 -0
  56. package/onboarding/production-assets.mjs +23 -0
  57. package/onboarding/production-runtime.mjs +8 -4
  58. package/package.json +2 -1
  59. package/scripts/publish-check/README.md +27 -17
  60. package/scripts/publish-check/content_identities.py +14 -233
  61. package/scripts/publish-check/git_history.py +2 -2
  62. package/scripts/publish-check/gitleaks.toml +1 -1
  63. package/scripts/publish-check/history_scan.py +6 -4
  64. package/scripts/publish-check/hygiene_config.py +3 -1
  65. package/scripts/publish-check/public_hygiene.py +29 -5
  66. package/scripts/publish-check/publication-config.json +3 -1
  67. package/scripts/publish-check/source_metadata.py +62 -0
  68. package/scripts/publish-check/source_shapes.py +91 -0
  69. package/scripts/publish-check/tree_scan.py +2 -2
  70. package/scripts/publish-check/source_hashes.mjs +0 -6
package/README.md CHANGED
@@ -247,7 +247,7 @@ line that still names it. A later refresh is a version bump:
247
247
  | `@runeditrun/rer/server-resources` | managed server resource readings (runtime journeys) | `scripts/base-cli/cli/server-resources.mjs` |
248
248
  | `@runeditrun/rer/operations` | OPERATIONS describe and check | `scripts/base-cli/operations/operations.mjs` |
249
249
  | `@runeditrun/rer/modules/portability`, `/catalogue`, `/cli`, `/cli-options`, `/files`, `/format`, `/http`, `/import`, `/journal`, `/legacy-safety`, `/migration-history`, `/storage` | portability module | `scripts/base-cli/modules/portability/*.mjs` |
250
- | `@runeditrun/rer/modules/release/export`, `/publication-scan`, `/source` | release export and publication scan | `scripts/base-cli/modules/release/*.mjs` |
250
+ | `@runeditrun/rer/modules/release/export`, `/publication-scan`, `/source`, `/signature` | release export, publication scan and shared signature verifier | `scripts/base-cli/modules/release/*.mjs` |
251
251
  | `@runeditrun/rer/modules/deploy-kit/owner`, `/owner-cli`, `/prepare`, `/provider` | deploy kit | `scripts/base-cli/modules/deploy-kit/*.mjs` |
252
252
  | `@runeditrun/rer/modules/operator-auth`, `/dev`, `/dev/badge`, `/dev-admission`, `/accounts/native`, `/identity/email-link` | operator auth | `scripts/base-cli/modules/operator-auth/index.mjs`, `dev/index.mjs`, `dev/badge.mjs`, `dev-admission.mjs`, `accounts/native.mjs`, `identity/email-link/index.mjs` |
253
253
  | `@runeditrun/rer/modules/erasure/fence` | erasure fence statements | `scripts/base-cli/modules/erasure/fence.mjs` |
@@ -260,7 +260,8 @@ In a checkout, every verb runs that checkout's own pinned `rer`; `rer VERB --hel
260
260
  there lists its flags:
261
261
 
262
262
  `install`, `setup`, `dev`, `preview`, `build`, `verify`, `reset --yes`, `deploy`,
263
- `export`, `import`, `outbox`, `doctor`, `describe`, `check`, `release`.
263
+ `export`, `import`, `outbox`, `doctor`, `describe`, `check`, `release`,
264
+ `release sign`, `release receipt` and `release keygen`.
264
265
 
265
266
  Anywhere: `rer list [DIR]`, `rer cli status|install|uninstall`, `rer update`,
266
267
  `rer queue NAME -- COMMAND`, `rer --version` and `rer --help`.
package/cli/README.md CHANGED
@@ -131,8 +131,10 @@ Existing seeds receive this policy by refreshing `scripts/base-cli/` with
131
131
  onboarding copy). For production, adopt the persistent root-head stylesheet,
132
132
  primary preload and matched fallback described in `export/UI-CONVENTIONS.md`.
133
133
 
134
- Both are managed foreground servers; their first invocation stays active while
135
- serving. A compatible repeated invocation verifies the registered application,
134
+ Both are managed servers. `--detach` returns after readiness, leaving the server
135
+ running; `--foreground` keeps the invocation active while serving. Preview
136
+ refreshes changed built assets and their ETags; source edits need a restart and
137
+ rebuild. A compatible repeated invocation verifies the registered application,
136
138
  prints its safe ready/outbox URLs and exits without a new build or sign-in.
137
139
  Explicit conflicting host, port or timeout, or the other mode, refuses with
138
140
  exit 4 and the correct stop command. `--stop` is idempotent;
package/cli/contract.mjs CHANGED
@@ -6,8 +6,8 @@ export const commonFlags = ['--help', '--cwd', '--non-interactive', '--json'];
6
6
  const definitions = {
7
7
  install: [[], 'Frozen dependency installation; no setup or global tool changes.'],
8
8
  setup: [['--owner-email', '--owner-name', '--port', '--host', '--answers', '--door'], 'Build, migrate, prepare private settings and first owner; stop the temporary app. --door sets a fresh checkout up at its front-door origin http://NAME.localhost:PORT (after rer door enable).'],
9
- dev: [['--port', '--host', '--idle-timeout', '--stop', '--as', '--login', '--models', '--models-service', '--door', '--direct'], 'Foreground development with assets, HMR and the declared scheduled triggers run locally on their schedule. With devIdentity it opens signed in as the owner (--as PERSONA picks another start, --login uses the outbox sign-in instead); otherwise ordinary owner sign-in through the outbox. --door moves this checkout to its front-door origin http://NAME.localhost:PORT, keeping its data; --direct moves it back; --port and --host serve directly.'],
10
- preview: [['--port', '--host', '--idle-timeout', '--stop'], 'Foreground fixed production output; reuse intact build, preserve owner/state, idle timeout and explicit stop.'],
9
+ dev: [['--port', '--host', '--idle-timeout', '--stop', '--detach', '--foreground', '--as', '--login', '--models', '--models-service', '--door', '--direct'], 'Development with assets, HMR and the declared scheduled triggers run locally on their schedule. --detach returns once ready; --foreground keeps this command serving. With devIdentity it opens signed in as the owner (--as PERSONA picks another start, --login uses the outbox sign-in instead); otherwise ordinary owner sign-in through the outbox. --door moves this checkout to its front-door origin http://NAME.localhost:PORT, keeping its data; --direct moves it back; --port and --host serve directly.'],
10
+ preview: [['--port', '--host', '--idle-timeout', '--stop', '--detach', '--foreground'], 'Production output; --detach returns once ready, --foreground keeps this command serving. Reuse intact build, refresh changed assets, preserve owner/state, idle timeout and explicit stop.'],
11
11
  build: [['--site'], 'Production build with private local settings excluded and restored. --site instead writes the static run site (homepage, /run, docs) to its declared folder and prints that folder.'],
12
12
  verify: [[], 'Run the complete internal verification target.'],
13
13
  reset: [['--yes'], 'Confirmed removal of the declared local inventory only.'],
package/cli/deploy.mjs CHANGED
@@ -1,5 +1,7 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { resolve } from 'node:path';
3
+ import { deployDiagnostic } from '../modules/deploy-kit/diagnostics.mjs';
4
+ import { shared } from './shared.mjs';
3
5
  import { contained } from './project.mjs';
4
6
  import { missing, invalid, refuse } from './errors.mjs';
5
7
  import { ownerPlan, ownerWorkerName, localOnlyBinding } from '../modules/deploy-kit/owner.mjs';
@@ -30,8 +32,20 @@ export function ownerDeploy(declarationPath, lifecycle) {
30
32
  if (names.some(name => !process.env[name]?.trim())) throw missing('CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN are required. See the deploy-kit owner-account instructions.');
31
33
  const { main } = await import('../modules/deploy-kit/owner-cli.mjs');
32
34
  const argv = ['deploy', '--declaration', declarationPath, '--environment', environment];
33
- const result = await main(argv, process.env, { cwd: root, localOnlyKeys, notice: line => store.report(line) });
34
- await main(['health', ...argv.slice(1)], process.env, { cwd: root, localOnlyKeys });
35
- return { deployed: true, origin: result.origin };
35
+ const {createRedaction,credentialValues} = await shared('redaction.mjs');
36
+ const redaction = createRedaction();
37
+ redaction.addSecrets([...credentialValues(process.env),process.env.CLOUDFLARE_ACCOUNT_ID,...(declaration.secrets ?? []).map(item=>process.env[item.environment]).filter(Boolean)]);
38
+ // Keep confirmed creation events across deploy and the independent health call.
39
+ const created = [];
40
+ try {
41
+ const result = await main(argv, process.env, { cwd: root, localOnlyKeys, notice: line => store.report(line), onCreated: resource => created.push(redaction.redact(resource)) });
42
+ await main(['health', ...argv.slice(1)], process.env, { cwd: root, localOnlyKeys });
43
+ return { deployed: true, origin: result.origin };
44
+ } catch (error) {
45
+ const diagnostic = deployDiagnostic(error,redaction.redact);
46
+ const code = /^[a-zA-Z0-9_-]{1,120}$/.test(error.message) ? redaction.redact(error.message) : 'deploy-failed';
47
+ const {commandFailure} = await shared('failures.mjs');
48
+ throw commandFailure(`Deploy failed at ${diagnostic?.step ?? 'prepare deploy'}: ${diagnostic?.code ?? code}${diagnostic?.providerCodes?.length ? ` (provider ${diagnostic.providerCodes.join(', ')})` : ''}.\nFirst redacted output lines:\n${diagnostic?.output ?? '(no provider output)'}\nResources created this attempt: ${created.join(', ') || 'none recorded'}.`);
49
+ }
36
50
  }, { declaration: declarationPath });
37
51
  }
@@ -0,0 +1,51 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { open, readFile } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { setTimeout as delay } from 'node:timers/promises';
7
+ import { shared } from './shared.mjs';
8
+ import { ready } from './managed-server.mjs';
9
+ import { RerError } from './errors.mjs';
10
+
11
+ // The actual dispatcher owns the server, registry and shutdown. Only its
12
+ // lifetime changes: its parent follows private JSON events until readiness,
13
+ // then leaves that process serving. A failed/interrupted start stops its child.
14
+ export async function detachedServer(options, argv, out) {
15
+ const {privateDirectory} = await shared('private-files.mjs');
16
+ const directory = await privateDirectory(options.cwd,['.rer','local']);
17
+ const log = join(directory,`${options.verb}-launch-${randomUUID()}.log`), handle = await open(log,'wx',0o600);
18
+ const args = argv.filter(arg => !['--detach','--foreground','--json'].includes(arg));
19
+ let child, exit, spawnError, keep = false;
20
+ const stop = () => child?.kill('SIGTERM');
21
+ for (const signal of ['SIGINT','SIGTERM']) process.once(signal,stop);
22
+ try {
23
+ child = spawn(process.execPath,[fileURLToPath(new URL('../bin/rer.mjs',import.meta.url)),...args,'--foreground','--json'], {cwd:options.cwd, env:process.env, detached:true, stdio:['ignore',handle.fd,handle.fd]});
24
+ child.once('error',error => {spawnError=error;exit=1;});
25
+ child.once('exit',(code,signal) => {exit=code ?? (signal ? 128 : 1);});
26
+ await handle.close();
27
+ let offset=0, pending='', failure;
28
+ for (;;) {
29
+ const bytes = await readFile(log), text = bytes.subarray(offset).toString('utf8'); offset=bytes.length;
30
+ pending += text;
31
+ const lines=pending.split('\n');pending=lines.pop();
32
+ for (const line of lines) {
33
+ let event; try { event=JSON.parse(line); } catch { continue; }
34
+ if (event.event==='error') failure=event;
35
+ if (event.event==='ready') {
36
+ keep=true;child.unref();
37
+ const dev = event.openUrl ? {openUrl:event.openUrl,identity:options.verb === 'dev' && !options.login,signedInAs:event.signedInAs,ownerLeft:options.verb === 'dev' && !options.login && event.signedInAs === null} : undefined;
38
+ ready(out,event.url,dev,options.json,Boolean(event.signInUrl),event.siteUrl);
39
+ return 0;
40
+ }
41
+ if (event.event==='waiting' || event.event==='warning') out.event(event.event,event);
42
+ }
43
+ if (exit!==undefined) throw new RerError(failure?.code ?? 'command_failed',failure?.message ?? `The ${options.verb} server stopped before readiness (${spawnError?.code ?? exit}); inspect ${log}.`,failure?.exitCode ?? (exit || 1));
44
+ await delay(200);
45
+ }
46
+ } finally {
47
+ await handle.close().catch(() => {});
48
+ for (const signal of ['SIGINT','SIGTERM']) process.off(signal,stop);
49
+ if (!keep) stop();
50
+ }
51
+ }
@@ -1,4 +1,4 @@
1
- import { readFile, readdir } from 'node:fs/promises';
1
+ import { readFile, readdir, realpath } from 'node:fs/promises';
2
2
  import { createRequire } from 'node:module';
3
3
  import { createHash, randomBytes } from 'node:crypto';
4
4
  import { join, resolve, extname } from 'node:path';
@@ -114,7 +114,7 @@ export async function checkHostedOutput(root, options) {
114
114
  } finally { await hosted.close(); }
115
115
  }
116
116
 
117
- if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
117
+ if (process.argv[1] && await realpath(new URL(import.meta.url)) === await realpath(process.argv[1])) {
118
118
  try { console.log(JSON.stringify(await checkHostedOutput(resolve(process.argv[2] ?? '.'), { declaration: process.argv[3] }))); }
119
119
  catch (error) { console.error(error.message); process.exitCode = 1; }
120
120
  }
@@ -7,7 +7,7 @@
7
7
  // is replaced, an intro replays, a frame loses a section, recovery is noisy or
8
8
  // uncounted, or a hydrated page's document lacks Cache-Control: no-transform.
9
9
  import { resolve } from 'node:path';
10
- import { pathToFileURL } from 'node:url';
10
+ import { realpath } from 'node:fs/promises';
11
11
  import { RerError } from '../errors.mjs';
12
12
  import { hostedServer, launchChromium, originServer, pool, runLoad } from './browser.mjs';
13
13
  import { cloudflareRewrite, describeCase, planCases, selectPages } from './cases.mjs';
@@ -90,7 +90,7 @@ async function declaredPages(root) {
90
90
  catch { return {}; }
91
91
  }
92
92
 
93
- if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
93
+ if (process.argv[1] && await realpath(new URL(import.meta.url)) === await realpath(process.argv[1])) {
94
94
  const args = process.argv.slice(2), flag = name => { const at = args.indexOf(name); return at < 0 ? undefined : args.splice(at, 2)[1]; };
95
95
  const origin = flag('--origin'), pages = flag('--pages')?.split(',').filter(Boolean), details = args.includes('--details');
96
96
  const root = resolve(args.find(arg => !arg.startsWith('--')) ?? '.'), declared = await declaredPages(root);
package/cli/inspect.mjs CHANGED
@@ -31,7 +31,7 @@ export async function describe(project) {
31
31
  };
32
32
  const capabilities = project.adapter?.verbs ?? {};
33
33
  return { contractVersion: 1, ...versioned, profile: 'first-party', defaults, pins: { node: pkg.engines?.node, pnpm: pkg.packageManager?.replace(/^pnpm@/, '') },
34
- verbs: Object.fromEntries(Object.entries(capabilities).map(([verb, capability]) => [verb, { flags: (verbs[verb]?.flags ?? releaseFlags).filter(flag => capability.flags?.includes(flag)), effects: verbs[verb]?.effects ?? 'Existing Base release producer.' }])),
34
+ verbs: Object.fromEntries(Object.entries(capabilities).map(([verb, capability]) => [verb, { flags: (verbs[verb]?.flags ?? releaseFlags).filter(flag => capability.flags?.includes(flag)), effects: verbs[verb]?.effects ?? 'Produce releases; keygen writes a public anchor and, on success, its private key to non-terminal stdout; sign writes a detached signature and prints (but does not execute) the GitHub upload command; receipt verifies against local anchors offline.' }])),
35
35
  aliases: Object.fromEntries(Object.entries(aliases).filter(([alias, verb]) => pkg.scripts?.[alias] === commandLine(verb, declaredPackage(pkg) !== undefined))),
36
36
  local: { state: project.adapter?.state, settings: project.adapter?.settings ?? [], installerState: '.seed/installer', outbox: '/__dev/outbox', ...await localState(project), origin: await savedOrigin(project), ...(project.adapter?.devIdentity ? { devIdentity: true, devKey: '.rer/local/dev-key.env' } : {}) },
37
37
  // This pin serves a checkout through the machine's front door (rer door).
package/cli/main.mjs CHANGED
@@ -27,6 +27,7 @@ export async function main(argv = process.argv.slice(2)) {
27
27
  if (project.profile === 'independent' && !['describe', 'check', 'doctor'].includes(options.verb)) options = parse(argv, process.cwd(), { independent: true });
28
28
  else if (options.forwarded.length) throw invalid('First-party commands accept only the documented flags, without forwarded arguments.');
29
29
  out = output(options);
30
+ if (project.profile === 'first-party' && options.detach && ['dev','preview'].includes(options.verb)) return (await import('./detached-server.mjs')).detachedServer(options, argv, out);
30
31
  if (options.verb === 'describe') { const result = await describe(project); out.human(options.json ? JSON.stringify(result) : JSON.stringify(result, null, 2)); return 0; }
31
32
  if (options.verb === 'check') {
32
33
  const result = await check(project, options.profile);
@@ -131,8 +131,14 @@ export async function managedServer(project,options,out,{clock={}}={}){
131
131
  const outbox=await outboxServed(selected.url,devKey,controller.signal);
132
132
  if(options.verb==='dev'&&project.adapter.site){
133
133
  const {devSite}=await import('./site.mjs');
134
- site=await devSite(project,store,controller.signal,{host:selected.host,port:selected.port});
135
- await registry.update({siteUrl:site.url});
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
+ }
136
142
  }
137
143
  activity.ready();await emitReady(out,options,project.root,selected.url,dev,outbox,site?.url);
138
144
  if(crons.length){
@@ -147,8 +153,9 @@ export async function managedServer(project,options,out,{clock={}}={}){
147
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);}
148
154
  }
149
155
  function reportScheduled(out,options,run){
150
- 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`;
151
- 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}`:''}`);
152
159
  }
153
160
  // GET, not HEAD: a seed's outbox route need not answer HEAD. The body is never read.
154
161
  async function outboxServed(url,devKey,signal){
@@ -169,7 +176,7 @@ async function emitReady(out,options,root,url,dev,outbox,siteUrl){
169
176
  }
170
177
  async function devReady(url,key,options,signal){
171
178
  if(options.verb==='preview')return {openUrl:openUrl(url,key,{path:'/__dev/key',next:'/__dev/outbox'})};
172
- 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'})};
173
180
  const as=options.as??'owner',persona=await resolvePersona(url,key,as,signal);
174
181
  // The owner persona names the owner the session will actually have (the
175
182
  // remaining owner once the setup owner has left), or none when none is left.
package/cli/parse.mjs CHANGED
@@ -2,7 +2,7 @@ import { resolve } from 'node:path';
2
2
  import { commonFlags, serverDefaults, verbs } from './contract.mjs';
3
3
  import { invalid, refuse } from './errors.mjs';
4
4
  import { isLoopbackHost } from '../modules/operator-auth/loopback.mjs';
5
- const booleans = new Set(['--help', '--json', '--non-interactive', '--yes', '--dry-run', '--open', '--stop', '--no-queue', '--login', '--legacy-v1', '--acknowledge-unverified-metadata', '--door', '--direct', '--site']);
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']);
6
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);
7
7
  // A loopback host: the shared predicate (including `*.localhost` door names),
8
8
  // plus `::1` as --host and a bracket-stripped hostname spell it.
@@ -45,7 +45,8 @@ export function parse(argv, cwd = process.cwd(), { independent = false, deferTai
45
45
  }
46
46
  if (options.help) return options;
47
47
  if (deferTail) { options.cwd=resolve(cwd,options.cwd);return options; }
48
- if(options.stop && ['--port','--host','--idle-timeout','--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.');
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.');
49
50
  // --port and --host always mean direct mode; --door and --direct pick a mode.
50
51
  if(options.door&&options.direct)throw invalid('Choose one of --door and --direct.');
51
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.');
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++) {
@@ -0,0 +1,291 @@
1
+ import { walkSyntax, stringValue, memberName } from './site-syntax.mjs';
2
+ import { refuse } from './errors.mjs';
3
+ const finite = (values, key = value => value) => {
4
+ const unique = [...new Map(values.map(value => [key(value), value])).values()];
5
+ if (unique.length > 1024) throw refuse('The run site has too many possible values for a browser resource reference to check.');
6
+ return unique;
7
+ };
8
+ const propertyNames = (node, strings) => {
9
+ const key = node?.type === 'MemberExpression' ? node.property : node?.key;
10
+ if (!key) return [];
11
+ if (!node.computed) return [key.name ?? String(key.value)];
12
+ return key.type === 'Literal' && typeof key.value === 'number' ? [String(key.value)] : [...strings(key)];
13
+ };
14
+
15
+ // Keep bindings lexical: a library's parameter or local variable must not take
16
+ // an unrelated same-named value from the island. Resolve finite constant values
17
+ // at a resource sink, rather than treating every URL-shaped string as a fetch.
18
+ export function localValues(tree) {
19
+ const scopes = new WeakMap(), writesByName = new Map(), computedWrites = [], assigned = new WeakMap(), root = {bindings:new Map(), parent:null, function:true};
20
+ const writeIndex = name => {
21
+ if (!writesByName.has(name)) writesByName.set(name, {writes:new Set(), users:new Set()});
22
+ return writesByName.get(name);
23
+ };
24
+ const bind = (scope, name, value) => {
25
+ if (!scope.bindings.has(name)) scope.bindings.set(name, []);
26
+ if (value) scope.bindings.get(name).push(value);
27
+ };
28
+ const lookup = (node, name) => {
29
+ let scope = scopes.get(node);
30
+ while (scope && !scope.bindings.has(name)) scope = scope.parent;
31
+ return scope?.bindings.get(name);
32
+ };
33
+ const bindPattern = (scope, pattern, value) => {
34
+ if (pattern.type === 'Identifier') { bind(scope, pattern.name, value); return; }
35
+ if (pattern.type === 'RestElement') { bindPattern(scope, pattern.argument); return; }
36
+ if (pattern.type === 'AssignmentPattern') { bindPattern(scope, pattern.left, value); bindPattern(scope, pattern.left, pattern.right); return; }
37
+ for (const [index, item] of (pattern.type === 'ObjectPattern' ? pattern.properties : pattern.elements ?? []).entries()) {
38
+ if (!item) continue;
39
+ const target = pattern.type === 'ObjectPattern' ? item.value ?? item.argument : item;
40
+ const key = pattern.type === 'ObjectPattern' ? item.key : {type:'Literal', value:index};
41
+ const member = value && key ? {type:'MemberExpression', object:value, property:key, computed:pattern.type !== 'ObjectPattern' || item.computed || key.type === 'Literal'} : null;
42
+ if (member) scopes.set(member, scope);
43
+ bindPattern(scope, target, member);
44
+ }
45
+ };
46
+ walkSyntax(tree, (node, path) => {
47
+ const parent = path.at(-1);
48
+ let scope = scopes.get(parent) ?? root;
49
+ if (['FunctionDeclaration','ClassDeclaration'].includes(node.type) && node.id) bind(scope, node.id.name, node);
50
+ if (node.type === 'ImportDeclaration') for (const specifier of node.specifiers) bind(scope, specifier.local.name);
51
+ if (/^(?:FunctionExpression|FunctionDeclaration|ArrowFunctionExpression)$/.test(node.type) || node.type === 'BlockStatement' || node.type === 'CatchClause') {
52
+ scope = {bindings:new Map(), parent:scope, function:node.type !== 'BlockStatement' && node.type !== 'CatchClause'};
53
+ if (node.type === 'FunctionExpression' && node.id) bind(scope, node.id.name, node);
54
+ for (const param of node.params ?? (node.param ? [node.param] : [])) bindPattern(scope, param);
55
+ }
56
+ scopes.set(node, scope);
57
+ if (node.type === 'VariableDeclarator') {
58
+ if (parent.kind === 'var') while (!scope.function && scope.parent) scope = scope.parent;
59
+ bindPattern(scope, node.id, node.init);
60
+ }
61
+ });
62
+ walkSyntax(tree, node => {
63
+ if (node.type !== 'AssignmentExpression') return;
64
+ const operator = node.operator.slice(0, -1);
65
+ const value = node.operator === '=' ? node.right : {type:['&&','||','??'].includes(operator) ? 'LogicalExpression' : 'BinaryExpression', operator, left:node.left, right:node.right};
66
+ assigned.set(node, value);
67
+ if (node.left.type === 'MemberExpression') {
68
+ const name = memberName(node.left), write = {left:node.left, right:value};
69
+ if (name === undefined) computedWrites.push(write);
70
+ else writeIndex(name).writes.add(write);
71
+ }
72
+ if (node.left.type !== 'Identifier') return;
73
+ lookup(node, node.left.name)?.push(value);
74
+ });
75
+ // Resolve each expression once into a shared dataflow graph. Dependencies
76
+ // are re-evaluated only when they gain a value, so cyclic aliases converge
77
+ // instead of exploring every possible path through member assignments.
78
+ const expressionResults = new WeakMap(), expressionStates = new WeakMap(), stringStates = new WeakMap(), writeStates = new WeakMap(), pending = new Set();
79
+ const states = kind => kind === 'strings' ? stringStates : expressionStates;
80
+ let evaluating;
81
+ const state = (map, node, kind) => {
82
+ let result = map.get(node);
83
+ if (!result) {
84
+ result = {node, kind, values:new Set(), users:new Set()};
85
+ map.set(node, result); pending.add(result);
86
+ }
87
+ if (evaluating) result.users.add(evaluating);
88
+ return result;
89
+ };
90
+ const read = (node, kind = 'expressions') => node ? state(states(kind), node, kind).values : new Set();
91
+ const keys = node => propertyNames(node, key => read(key, 'strings'));
92
+ const memberWrites = name => {
93
+ const index = writeIndex(name);
94
+ if (evaluating) index.users.add(evaluating);
95
+ return index.writes;
96
+ };
97
+ // A computed write joins the same fixed point as its key's bindings. Index
98
+ // each discovered key once and wake only readers of that member name.
99
+ for (const write of computedWrites) state(writeStates, write, 'writes');
100
+ function indexWrite(write) {
101
+ const names = keys(write.left);
102
+ for (const name of names) {
103
+ const index = writeIndex(name);
104
+ if (index.writes.has(write)) continue;
105
+ index.writes.add(write);
106
+ for (const user of index.users) pending.add(user);
107
+ }
108
+ return names;
109
+ }
110
+ const isClass = node => ['ClassDeclaration','ClassExpression'].includes(node.type);
111
+ function classProperties(constructor, wanted, isStatic, fields, seen = new Set()) {
112
+ const found = new Map();
113
+ if (!isClass(constructor) || seen.has(constructor)) return found;
114
+ seen = new Set(seen).add(constructor);
115
+ let unknownKey = false;
116
+ for (const property of constructor.body.body) {
117
+ if (Boolean(property.static) !== isStatic || (fields !== undefined && (property.type === 'PropertyDefinition') !== fields)) continue;
118
+ const names = keys(property);
119
+ if (property.computed && !names.length) unknownKey = true;
120
+ for (const name of wanted) if (names.includes(name)) {
121
+ // Static fields initialize after methods even when written before them.
122
+ const previous = found.get(name)?.[0];
123
+ if (property.type === 'PropertyDefinition' || previous?.type !== 'PropertyDefinition') found.set(name, [property]);
124
+ }
125
+ }
126
+ const inherited = wanted.filter(name => !found.has(name));
127
+ // Wait for a computed override's key instead of prematurely retaining the
128
+ // inherited definition in the monotone graph. Unknown keys stay unknown.
129
+ if (inherited.length && !unknownKey) for (const parent of read(constructor.superClass)) {
130
+ for (const [name, properties] of classProperties(parent, inherited, isStatic, fields, seen)) found.set(name, [...(found.get(name) ?? []), ...properties]);
131
+ }
132
+ return found;
133
+ }
134
+ function classMembers(constructor, names, isStatic) {
135
+ if (isStatic) return [...classProperties(constructor, names, true).values()].flat();
136
+ // Instance fields shadow prototype methods, including a method declared by
137
+ // a subclass. Within each category the nearest declaration wins.
138
+ const fields = classProperties(constructor, names, false, true);
139
+ const methods = classProperties(constructor, names.filter(name => !fields.has(name)), false, false);
140
+ return [...fields.values(), ...methods.values()].flat();
141
+ }
142
+ function expressionValues(node) {
143
+ if (node.type === 'Identifier') {
144
+ const values = lookup(node, node.name);
145
+ return values?.length ? values.flatMap(value => [...read(value)]) : [node];
146
+ }
147
+ if (node.type === 'MemberExpression') {
148
+ const names = keys(node);
149
+ const objects = read(node.object), members = [node];
150
+ const properties = items => {
151
+ for (const property of items) if (keys(property).some(key => names.includes(key))) members.push(...read(property.value));
152
+ };
153
+ for (const object of objects) {
154
+ if (object.type === 'ArrayExpression') for (const key of names) members.push(...read(object.elements[Number(key)]));
155
+ if (object.type === 'ObjectExpression') properties(object.properties);
156
+ if (isClass(object)) properties(classMembers(object, names, true));
157
+ if (object.type === 'NewExpression') for (const constructor of read(object.callee)) {
158
+ if (isClass(constructor)) properties(classMembers(constructor, names, false));
159
+ }
160
+ }
161
+ for (const key of names) for (const write of memberWrites(key)) {
162
+ if (write.left !== node && [...read(write.left.object)].some(object => objects.has(object))) members.push(...read(write.right));
163
+ }
164
+ return members;
165
+ }
166
+ if (node.type === 'ConditionalExpression') return [...read(node.consequent), ...read(node.alternate)];
167
+ if (node.type === 'LogicalExpression') return [...read(node.left), ...read(node.right)];
168
+ if (node.type === 'SequenceExpression') return read(node.expressions.at(-1));
169
+ if (node.type === 'AssignmentExpression') return read(assigned.get(node));
170
+ return [node];
171
+ }
172
+ function stringValues(node, seen = new Set()) {
173
+ if (!node || seen.has(node)) return [];
174
+ seen = new Set(seen).add(node);
175
+ const value = stringValue(node);
176
+ if (value !== undefined) return [value];
177
+ if (node.type === 'BinaryExpression' && node.operator === '+') return finite(stringValues(node.left, seen).flatMap(left => stringValues(node.right, seen).map(right => left + right)));
178
+ if (node.type === 'TemplateLiteral') {
179
+ let values = [node.quasis[0].value.cooked ?? ''];
180
+ for (let i = 0; i < node.expressions.length; i++) values = finite(values.flatMap(left => stringValues(node.expressions[i], seen).map(right => left + right + (node.quasis[i + 1].value.cooked ?? ''))));
181
+ return values;
182
+ }
183
+ if (node.type === 'CallExpression' && ['decodeURI','decodeURIComponent','atob'].includes(node.callee.name)) return stringValues(node.arguments[0], seen).flatMap(value => {
184
+ try {return [node.callee.name === 'atob' ? Buffer.from(value,'base64').toString('utf8') : decodeURIComponent(value)];} catch {return [];}
185
+ });
186
+ // Expand only the finite syntax graph. Recursive string construction has
187
+ // an unknown tail, not an ever-growing set of invented complete strings.
188
+ // Alias states already contain their transitive values; unresolved member
189
+ // identities contain no additional strings and must not be expanded again.
190
+ return finite([...read(node)].filter(value => value !== node && !['Identifier','MemberExpression'].includes(value.type)).flatMap(value => stringValues(value, seen)));
191
+ }
192
+ function resolve(node, kind) {
193
+ if (!node) return [];
194
+ const result = state(states(kind), node, kind);
195
+ for (const next of pending) {
196
+ pending.delete(next); evaluating = next;
197
+ const values = next.kind === 'writes' ? indexWrite(next.node) : next.kind === 'strings' ? stringValues(next.node) : expressionValues(next.node);
198
+ let changed = false;
199
+ for (const value of values) if (!next.values.has(value)) { next.values.add(value); changed = true; }
200
+ if (next.kind === 'strings') finite([...next.values]);
201
+ if (changed) for (const user of next.users) pending.add(user);
202
+ }
203
+ evaluating = undefined;
204
+ return [...result.values];
205
+ }
206
+ function expressions(node) {
207
+ if (!node) return [];
208
+ if (expressionResults.has(node)) return expressionResults.get(node);
209
+ const values = resolve(node, 'expressions').filter(value => {
210
+ if (value.type !== 'MemberExpression') return true;
211
+ // A member is its own unresolved identity until a value reaches it. Keep
212
+ // unresolved cycles, but remove that identity after resolving an alias.
213
+ return ![...expressionStates.get(value).values].some(other => other !== value && (other.type !== 'MemberExpression' || !expressionStates.get(other)?.values.has(value)));
214
+ });
215
+ // Indexing is complete and every dependency has settled before caching.
216
+ expressionResults.set(node, values);
217
+ return values;
218
+ }
219
+ const strings = node => resolve(node, 'strings');
220
+ const names = node => propertyNames(node, strings);
221
+ const origins = expressions;
222
+ const localClass = node => {
223
+ const values = expressions(node);
224
+ return values.length > 0 && values.every(value => {
225
+ const constructors = value.type === 'NewExpression' ? expressions(value.callee) : [value];
226
+ return constructors.length > 0 && constructors.every(isClass);
227
+ });
228
+ };
229
+ // Recognize browser globals through aliases without mistaking library methods
230
+ // or shadowed parameters for those APIs.
231
+ function global(node, name, seen = new Set()) {
232
+ if (!node || seen.has(node)) return false;
233
+ seen = new Set(seen).add(node);
234
+ return origins(node).some(value => {
235
+ if (value.type === 'Identifier') return value.name === name && !lookup(value, value.name);
236
+ return value.type === 'MemberExpression' && names(value).includes(name) && ['window','globalThis','self'].some(root => global(value.object, root, seen));
237
+ });
238
+ }
239
+ // A runtime suffix does not erase a known origin or route at a resource sink.
240
+ // Retain only the prefix before the first unknown value; later fragments are
241
+ // not a trustworthy prefix. Complete values still use ordinary file checks.
242
+ const alternatives = values => finite(values, value => JSON.stringify(value));
243
+ const combine = (left, right) => alternatives(left.flatMap(start => start.complete ? right.map(end => ({value:start.value + end.value, complete:end.complete})) : [start]));
244
+ function resources(node, seen = new Set()) {
245
+ const unknown = [{value:'', complete:false}];
246
+ if (!node || seen.has(node)) return unknown;
247
+ seen = new Set(seen).add(node);
248
+ const value = stringValue(node);
249
+ if (value !== undefined) return [{value, complete:true}];
250
+ if (node.type === 'BinaryExpression' && node.operator === '+') return combine(resources(node.left, seen), resources(node.right, seen));
251
+ if (node.type === 'TemplateLiteral') {
252
+ let values = [{value:node.quasis[0].value.cooked ?? '', complete:true}];
253
+ for (let i = 0; i < node.expressions.length; i++) values = combine(combine(values, resources(node.expressions[i], seen)), [{value:node.quasis[i + 1].value.cooked ?? '', complete:true}]);
254
+ return values;
255
+ }
256
+ if (node.type === 'NewExpression' && global(node.callee, 'Request')) return resources(node.arguments[0], seen);
257
+ if (node.type === 'NewExpression' && global(node.callee, 'URL')) {
258
+ const targets = resources(node.arguments[0], seen), bases = resources(node.arguments[1], seen);
259
+ const written = [...memberWrites('href')].filter(write => expressions(write.left.object).includes(node)).flatMap(write => resources(write.right, seen));
260
+ return alternatives([...written, ...targets.flatMap(target => {
261
+ // Absolute targets do not depend on the base. Incomplete targets retain
262
+ // their known prefix, including a foreign origin or an instance subtree.
263
+ if (/^[a-z][a-z0-9+.-]*:/i.test(target.value)) return [target];
264
+ if (!node.arguments[1]) return unknown;
265
+ return bases.flatMap(base => {
266
+ if (!base.complete) {
267
+ if (target.value && /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(base.value)) return [base];
268
+ return target.value.startsWith('/') ? [target] : unknown;
269
+ }
270
+ try {
271
+ if (!target.value && !target.complete) return unknown;
272
+ return [{value:new URL(target.value, base.value).href, complete:target.complete}];
273
+ } catch { return unknown; }
274
+ });
275
+ })]);
276
+ }
277
+ if (node.type === 'MemberExpression' && names(node).some(name => ['href','url'].includes(name))) {
278
+ const objects = expressions(node.object), values = objects.flatMap(object => object.type === 'NewExpression' && (global(object.callee, 'URL') || global(object.callee, 'Request')) ? resources(object, seen) : []);
279
+ if (values.length) return alternatives(values);
280
+ }
281
+ if (node.type === 'CallExpression' && names(node.callee).some(name => ['toString','toJSON'].includes(name))) {
282
+ const values = expressions(node.callee.object).flatMap(object => object.type === 'NewExpression' && global(object.callee, 'URL') ? resources(object, seen) : []);
283
+ if (values.length) return alternatives(values);
284
+ }
285
+ const resolved = expressions(node).filter(value => value !== node);
286
+ if (resolved.length) return alternatives(resolved.flatMap(value => resources(value, seen)));
287
+ const complete = strings(node);
288
+ return complete.length ? complete.map(value => ({value, complete:true})) : unknown;
289
+ }
290
+ return {strings, names, resources, expressions, origins, global, localClass};
291
+ }
@@ -0,0 +1,36 @@
1
+ import { memberName } from './site-syntax.mjs';
2
+
3
+ // Follow actual browser object provenance. An object's field named `href`,
4
+ // or a URL parser's `href` setter, does not make that object a DOM element.
5
+ export function browserObjects({expressions, origins, strings, global}) {
6
+ const window = node => ['window','globalThis','self'].some(name => global(node, name));
7
+ const document = node => global(node, 'document');
8
+ const location = node => global(node, 'location') || origins(node).some(value => memberName(value) === 'location' && (window(value.object) || document(value.object)));
9
+ const constructed = (node, name) => expressions(node).some(value => value.type === 'NewExpression' && global(value.callee, name));
10
+ const queries = new Set(['querySelector','querySelectorAll','getElementById','getElementsByTagName','getElementsByClassName','getElementsByName','createElement','createElementNS']);
11
+ const children = new Set(['body','head','documentElement','activeElement','firstChild','lastChild','firstElementChild','lastElementChild','nextSibling','previousSibling','nextElementSibling','previousElementSibling','parentNode','parentElement','children','childNodes','forms','images','links','content','contentDocument']);
12
+ function dom(node, seen = new Set()) {
13
+ if (!node || seen.has(node)) return false;
14
+ seen = new Set(seen).add(node);
15
+ return expressions(node).some(value => {
16
+ if (document(value)) return true;
17
+ if (value.type === 'NewExpression') return ['Image','Audio','Option'].some(name => global(value.callee, name));
18
+ if (value.type === 'CallExpression') {
19
+ const method = memberName(value.callee), object = value.callee.object;
20
+ return (queries.has(method) || ['closest','cloneNode','item','namedItem'].includes(method)) && dom(object, seen);
21
+ }
22
+ if (value.type !== 'MemberExpression') return false;
23
+ return (children.has(memberName(value)) || value.computed) && dom(value.object, seen);
24
+ });
25
+ }
26
+ function anchor(node) {
27
+ return expressions(node).some(value => {
28
+ if (value.type !== 'CallExpression' || !dom(value.callee.object)) return false;
29
+ const method = memberName(value.callee);
30
+ if (['createElement','createElementNS','getElementsByTagName'].includes(method)) return strings(value.arguments[method === 'createElementNS' ? 1 : 0]).some(tag => /^(?:a|area)$/i.test(tag));
31
+ return ['querySelector','closest'].includes(method) && strings(value.arguments[0]).some(selector => /^(?:a|area)(?:[.#[:]|$)/i.test(selector));
32
+ });
33
+ }
34
+ const style = node => expressions(node).some(value => memberName(value) === 'style' && dom(value.object));
35
+ return {window, document, location, constructed, dom, anchor, style};
36
+ }