@ultimat3/cli 7.0.0 → 8.0.0

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 (67) hide show
  1. package/CLAUDE.md +15 -1
  2. package/README.md +8 -3
  3. package/package.json +25 -25
  4. package/src/app-boundaries.ts +55 -5
  5. package/src/bin.ts +6 -3
  6. package/src/ci-log.ts +0 -0
  7. package/src/cmd-db-backfill.ts +240 -0
  8. package/src/cmd-db-branch.ts +3 -2
  9. package/src/cmd-db.ts +35 -156
  10. package/src/cmd-deploy.ts +37 -3
  11. package/src/cmd-dev.ts +7 -1
  12. package/src/cmd-errors.ts +2 -3
  13. package/src/cmd-fix.ts +3 -3
  14. package/src/cmd-i18n.ts +67 -5
  15. package/src/cmd-jobs.ts +27 -4
  16. package/src/cmd-mcp.ts +18 -9
  17. package/src/cmd-new.ts +91 -4
  18. package/src/cmd-policy.ts +3 -2
  19. package/src/cmd-pr.ts +55 -4
  20. package/src/cmd-registries.ts +3 -2
  21. package/src/cmd-shot.ts +68 -6
  22. package/src/cmd-tasks.ts +9 -4
  23. package/src/cmd-verify.ts +47 -6
  24. package/src/dev-cache.ts +1 -1
  25. package/src/dev-lock.ts +124 -12
  26. package/src/dev-queue.ts +12 -7
  27. package/src/dev-replicator.ts +3 -7
  28. package/src/dev-roles-fixture.ts +1 -1
  29. package/src/dev-roles.ts +40 -8
  30. package/src/dev-runtime.ts +96 -4
  31. package/src/dev-sync.ts +9 -4
  32. package/src/dispatch.ts +35 -5
  33. package/src/drift.ts +52 -7
  34. package/src/error-codes.ts +5 -0
  35. package/src/framework-scope.ts +57 -5
  36. package/src/generate-kinds.ts +19 -1
  37. package/src/i18n-registration.ts +67 -4
  38. package/src/index.ts +1 -1
  39. package/src/jobs-report.ts +10 -13
  40. package/src/mcp-errors.ts +3 -0
  41. package/src/messages.ts +12 -0
  42. package/src/output.ts +22 -2
  43. package/src/parse.ts +81 -37
  44. package/src/realtime-browser-probe-fixture.ts +9 -0
  45. package/src/runtime-overrides.ts +11 -3
  46. package/src/shot-settle.ts +57 -0
  47. package/src/shot-verdict.ts +27 -4
  48. package/src/sync-authenticator.ts +86 -14
  49. package/src/templates/guard-bare-error.ts +122 -0
  50. package/src/templates/guard-raw-colour.ts +138 -0
  51. package/src/templates/guard-untranslated-string.ts +138 -0
  52. package/src/templates/guard-unzoned-date.ts +142 -0
  53. package/src/templates/index.ts +3 -0
  54. package/src/templates/island.ts +2 -1
  55. package/src/templates/route.ts +1 -1
  56. package/src/templates/scaffold-app.ts +3 -82
  57. package/src/templates/scaffold-container.ts +30 -4
  58. package/src/templates/scaffold-db-package.ts +14 -6
  59. package/src/templates/scaffold-docs.ts +24 -13
  60. package/src/templates/scaffold-entries.ts +131 -0
  61. package/src/templates/scaffold-guards.ts +26 -0
  62. package/src/templates/scaffold-repo.ts +37 -6
  63. package/src/test-select.ts +4 -3
  64. package/src/verify-run.ts +25 -3
  65. package/src/verify-step.ts +11 -2
  66. package/src/verify-tests.ts +11 -3
  67. package/src/write-line.ts +23 -5
package/src/parse.ts CHANGED
@@ -2,7 +2,19 @@
2
2
  // same way and `--json` / `--help` behave identically everywhere. Pure: no I/O, no process
3
3
  // access, so the parser is unit-testable and the dispatcher owns all side effects.
4
4
 
5
+ import { nearestName } from '@ultimat3/core';
5
6
  import { BadFlagError, MissingSubcommandError, UnknownCommandError } from './errors';
7
+ // `shell-quote.ts` is a leaf — it imports nothing — so the parser stays pure and importable from
8
+ // anywhere while still refusing with a fix line a shell reads as one argument.
9
+ import { quoteArg } from './shell-quote';
10
+
11
+ /**
12
+ * The historical name for `@ultimat3/core`'s `nearestName`, kept because it shipped on this
13
+ * package's exported surface and removing it would be a major for a rename. One implementation
14
+ * behind both — this is a delegation, not the second copy of the algorithm that `@ultimat3/policy`
15
+ * used to carry. New callers import `nearestName` from core.
16
+ */
17
+ export const nearest = nearestName;
6
18
 
7
19
  export type FlagValue = string | boolean;
8
20
 
@@ -12,6 +24,15 @@ export interface FlagSpec {
12
24
  readonly summary: string;
13
25
  readonly short?: string;
14
26
  readonly default?: FlagValue;
27
+ /**
28
+ * The subcommands that READ this flag, where it is not command-wide. Absent means every one —
29
+ * opt-in, because most flags really are. Declared from the same fact the summary states, and
30
+ * enforced: `x db gen --dry-run` parsed, ran the generator and WROTE the migration, because the
31
+ * parser validates a flag against the COMMAND and nothing then validates it against the word
32
+ * that decides what runs. A dry run that writes a file is the direction a mistake may never
33
+ * fail in. `parse.test.ts` pins that every entry names a subcommand its command declares.
34
+ */
35
+ readonly subcommands?: readonly string[];
15
36
  }
16
37
 
17
38
  export interface CommandSpec {
@@ -87,41 +108,11 @@ export const wantsJson = (argv: readonly string[]): boolean =>
87
108
  const HELP_ALIASES = new Set(['--help', '-h', 'help']);
88
109
  const VERSION_ALIASES = new Set(['--version', '-v', '-V']);
89
110
 
90
- function distance(a: string, b: string): number {
91
- const rows = a.length + 1;
92
- const cols = b.length + 1;
93
- const grid: number[] = new Array<number>(rows * cols).fill(0);
94
- const at = (r: number, c: number): number => grid[r * cols + c] ?? 0;
95
- for (let r = 0; r < rows; r += 1) grid[r * cols] = r;
96
- for (let c = 0; c < cols; c += 1) grid[c] = c;
97
- for (let r = 1; r < rows; r += 1) {
98
- for (let c = 1; c < cols; c += 1) {
99
- const cost = a[r - 1] === b[c - 1] ? 0 : 1;
100
- grid[r * cols + c] = Math.min(at(r - 1, c) + 1, at(r, c - 1) + 1, at(r - 1, c - 1) + cost);
101
- }
102
- }
103
- return at(rows - 1, cols - 1);
104
- }
105
-
106
- /** Nearest known name within an edit distance of 3, so the error can suggest a retry. */
107
- export function nearest(input: string, candidates: readonly string[]): string | undefined {
108
- let best: string | undefined;
109
- let bestScore = 4;
110
- for (const candidate of candidates) {
111
- const score = distance(input, candidate);
112
- if (score < bestScore) {
113
- best = candidate;
114
- bestScore = score;
115
- }
116
- }
117
- return best;
118
- }
119
-
120
111
  function resolveCommand(token: string, specs: readonly CommandSpec[]): CommandSpec {
121
112
  const found = specs.find((spec) => spec.name === token || (spec.aliases ?? []).includes(token));
122
113
  if (found !== undefined) return found;
123
114
  const names = specs.map((spec) => spec.name);
124
- const suggestion = nearest(token, names);
115
+ const suggestion = nearestName(token, names);
125
116
  throw new UnknownCommandError(
126
117
  suggestion === undefined
127
118
  ? { path: token, known: names }
@@ -168,6 +159,9 @@ export function parseArgs(argv: readonly string[], specs: readonly CommandSpec[]
168
159
  const spec = resolveCommand(first, specs);
169
160
  const flags = defaults(spec);
170
161
  const positionals: string[] = [];
162
+ // What argv actually SET, as against what `defaults()` seeded: a default is nobody's request,
163
+ // and refusing a flag the caller never typed would refuse the command itself.
164
+ const given = new Set<string>();
171
165
  let index = 1;
172
166
 
173
167
  while (index < tokens.length) {
@@ -183,7 +177,7 @@ export function parseArgs(argv: readonly string[], specs: readonly CommandSpec[]
183
177
  const flag = findFlag(name, spec);
184
178
  if (flag === undefined) {
185
179
  const known = [...GLOBAL_FLAGS, ...(spec.flags ?? [])].map((entry) => entry.name);
186
- const suggestion = nearest(name, known);
180
+ const suggestion = nearestName(name, known);
187
181
  throw new BadFlagError({
188
182
  flag: name,
189
183
  command: spec.name,
@@ -193,6 +187,7 @@ export function parseArgs(argv: readonly string[], specs: readonly CommandSpec[]
193
187
  : `unknown flag — did you mean --${suggestion}?`,
194
188
  });
195
189
  }
190
+ given.add(flag.name);
196
191
  if (flag.type === 'boolean') {
197
192
  if (inlineValue !== undefined) {
198
193
  throw new BadFlagError({
@@ -204,30 +199,79 @@ export function parseArgs(argv: readonly string[], specs: readonly CommandSpec[]
204
199
  flags.set(flag.name, !negated);
205
200
  continue;
206
201
  }
202
+ // `--no-<string flag>` used to fall through to the value read below, so `--no-name feat` set
203
+ // `name` to `feat`: the caller asked for the flag to be OFF and argv's next token became its
204
+ // value. There is nothing a string flag can be negated to, so this is a refusal.
205
+ if (negated) {
206
+ throw new BadFlagError({
207
+ flag: flag.name,
208
+ command: spec.name,
209
+ reason: `--no- negates a boolean flag, and --${flag.name} takes a value`,
210
+ });
211
+ }
207
212
  const value = inlineValue ?? tokens[index];
208
- if (value === undefined || value.startsWith('--')) {
213
+ if (value === undefined) {
214
+ throw new BadFlagError({ flag: flag.name, command: spec.name, reason: 'expects a value' });
215
+ }
216
+ // A value beginning `--` is a flag as far as this loop can tell, and the caller who really
217
+ // meant it as a value has one form available — so the refusal names it rather than leaving
218
+ // `--filter --json` looking like a parser that cannot express the input.
219
+ if (value.startsWith('--')) {
209
220
  throw new BadFlagError({
210
221
  flag: flag.name,
211
222
  command: spec.name,
212
- reason: 'expects a value',
223
+ reason: `expects a value, and "${value}" is a flag — write --${flag.name}=${value} to pass it as the value`,
213
224
  });
214
225
  }
215
226
  if (inlineValue === undefined) index += 1;
216
227
  flags.set(flag.name, value);
217
228
  }
218
229
 
219
- const subcommand = readSubcommand(spec, positionals);
230
+ // Before `readSubcommand`, which THROWS on a missing or unknown one: `x db --help`, `x mcp
231
+ // --help` and `x pr --help` all exited 1 with `X_CLI_BAD_FLAG` — usage refused to the caller
232
+ // asking what the usage is, on every command that takes a subcommand. Help is answered by
233
+ // `dispatch`, which needs only the command name.
234
+ const help = flags.get('help') === true;
235
+ const subcommand = help ? undefined : readSubcommand(spec, positionals);
236
+ if (subcommand !== undefined) assertFlagsApply(spec, subcommand, given, flags);
220
237
  return {
221
238
  command: spec.name,
222
239
  subcommand,
223
240
  positionals: subcommand === undefined ? positionals : positionals.slice(1),
224
241
  flags,
225
242
  json: flags.get('json') === true,
226
- help: flags.get('help') === true,
243
+ help,
227
244
  passthrough,
228
245
  };
229
246
  }
230
247
 
248
+ /**
249
+ * A flag the resolved subcommand does not read is refused, never ignored. Only what argv SET is
250
+ * judged, and only against a flag that declared a scope: an undeclared flag stays command-wide.
251
+ *
252
+ * The fix carries the caller's own value through `quoteArg`, because it is pasted into a shell
253
+ * verbatim — `x db backfill --status 'a b'` runs, `--status a b` runs something else.
254
+ */
255
+ function assertFlagsApply(
256
+ spec: CommandSpec,
257
+ subcommand: string,
258
+ given: ReadonlySet<string>,
259
+ flags: ReadonlyMap<string, FlagValue>,
260
+ ): void {
261
+ for (const flag of spec.flags ?? []) {
262
+ const only = flag.subcommands;
263
+ if (only === undefined || only.includes(subcommand) || !given.has(flag.name)) continue;
264
+ const value = flags.get(flag.name);
265
+ const argument = typeof value === 'string' ? ` ${quoteArg(value)}` : '';
266
+ throw new BadFlagError({
267
+ flag: flag.name,
268
+ command: `${spec.name} ${subcommand}`,
269
+ reason: `read by ${only.map((word) => `x ${spec.name} ${word}`).join(' / ')} only — "${subcommand}" would ignore it`,
270
+ fix: `x ${spec.name} ${only[0]} --${flag.name}${argument}`,
271
+ });
272
+ }
273
+ }
274
+
231
275
  function splitInline(raw: string): [string, string | undefined] {
232
276
  const eq = raw.indexOf('=');
233
277
  if (eq === -1) return [raw, undefined];
@@ -243,7 +287,7 @@ function readSubcommand(spec: CommandSpec, positionals: readonly string[]): stri
243
287
  throw new MissingSubcommandError({ command: spec.name, known: allowed });
244
288
  }
245
289
  if (allowed.includes(token)) return token;
246
- const suggestion = nearest(token, allowed);
290
+ const suggestion = nearestName(token, allowed);
247
291
  throw new UnknownCommandError(
248
292
  suggestion === undefined
249
293
  ? { path: `${spec.name} ${token}`, known: allowed }
@@ -0,0 +1,9 @@
1
+ // The browser island `wiki/Realtime.md` promises: one live hook and nothing else. It is a real
2
+ // module rather than a string a test writes to a temp path, because module resolution is the thing
3
+ // under test — `@ultimat3/realtime`'s client entry must reach neither the bus nor the WAL decoder.
4
+ // Bundled AND imported by `realtime-browser-barrel.test.ts` — the import is what gives it an lcov
5
+ // record, since `Bun.build()` reads this file without evaluating it.
6
+
7
+ import { useLive } from '@ultimat3/realtime';
8
+
9
+ export const probeUseLive = useLive;
@@ -8,7 +8,7 @@ import type { PurgeDriver } from '@ultimat3/cache';
8
8
  import type { Middleware, RateLimitStore } from '@ultimat3/http';
9
9
  import type { JobDriver } from '@ultimat3/jobs';
10
10
  import type { MailDriver } from '@ultimat3/mail';
11
- import type { SyncAuthenticator, Transport } from '@ultimat3/realtime';
11
+ import type { SyncAuthenticator, Transport } from '@ultimat3/realtime/server';
12
12
  import type { IsrStore } from '@ultimat3/render';
13
13
  import type { ImageTransformDriver } from '@ultimat3/seo';
14
14
  import type { Storage } from '@ultimat3/storage';
@@ -45,6 +45,11 @@ export interface RuntimeOverrides {
45
45
  * Where the HTTP rate limiter keeps its counters. It also DECIDES `rateLimit.scope`: a store
46
46
  * that says `'shared'` is a deployment declaring fleet-wide numbers, and `assertRateLimitScope`
47
47
  * holds the two halves together rather than a literal in the boot contradicting the store.
48
+ *
49
+ * Omitted, the boot installs `postgresRateLimitStore` over the pool it already opened
50
+ * (`startServices`) — so this replaces a SHARED default, not an absent one. A store whose scope
51
+ * is `'process'` is legal and warned about: it is every declared limit enforced once per
52
+ * replica, and `docker/helm/values.yaml` runs three.
48
53
  */
49
54
  readonly rateLimitStore?: RateLimitStore;
50
55
  /**
@@ -59,8 +64,11 @@ export interface RuntimeOverrides {
59
64
  readonly images?: ImageTransformDriver;
60
65
  /**
61
66
  * Who is dialling the `sync` node. Omitted, the app's own `configureAuthenticator()` is adapted
62
- * — this field exists because that adapter can only ever answer `{ actor }`, and a real
63
- * deployment's token has an `expiresAt` and a `refresh`, which is the whole of re-authorization.
67
+ * — and that adapter now carries `expiresAt` and `refresh` of its own (`SYNC_GRANT_TTL_MS`),
68
+ * re-asking the app's resolver with the upgrade's own `cookie`/`authorization`. So this field is
69
+ * no longer the only way to get re-authorization; it is how a deployment states a window the
70
+ * credential itself declares (a token's `exp`), or resolves identity from a header the adapter
71
+ * deliberately does not retain per socket.
64
72
  */
65
73
  readonly syncAuthenticate?: SyncAuthenticator;
66
74
  }
@@ -0,0 +1,57 @@
1
+ // When a shot may be TAKEN: the two rules that decide whether the page has finished hydrating,
2
+ // separated from both the command that drives a browser and the verdict that judges what came
3
+ // back. Plain values and an injected sleep, so the whole loop is proved with neither.
4
+
5
+ import type { IslandCount } from './shot-verdict';
6
+
7
+ /**
8
+ * When there is nothing left to wait for. `booted` counts the islands whose chunk the runtime
9
+ * ASKED for; `mounted` and `failed` are the two ways that request can end — so the outcome of
10
+ * every boot exists exactly when they add up to it. An island that never booted is not something
11
+ * to wait for (`visible` with nothing scrolled to it, `never` by declaration), and `null` is "the
12
+ * page answered no probe", which no amount of waiting turns into an answer.
13
+ */
14
+ export const islandsSettled = (islands: IslandCount | null): boolean =>
15
+ islands === null || islands.mounted + islands.failed >= islands.booted;
16
+
17
+ /**
18
+ * How often the island probe is re-read while the page settles. Short enough that a page whose
19
+ * mounts have already resolved pays one extra read and nothing else.
20
+ */
21
+ export const SETTLE_POLL_MS = 100;
22
+
23
+ export interface SettleOptions {
24
+ /** The extra budget a mount gets AFTER the boot deadline. Bounded: a picture is still owed. */
25
+ readonly windowMs: number;
26
+ readonly pollMs: number;
27
+ /** Injected by the test, so the poll is proved without spending its own window in real time. */
28
+ readonly sleep?: ((ms: number) => Promise<void>) | undefined;
29
+ }
30
+
31
+ /**
32
+ * Read the probe until every booted island has settled, or the window runs out.
33
+ *
34
+ * `DEFAULT_SETTLE_MS` is the deadline at which the hydration runtime CALLS `import()` — `mounted`
35
+ * and `failed` land after it — so a single read at that instant reports `mounted: 0` for a page
36
+ * that hydrates perfectly and the verdict was taken one tick before the outcome existed. Polling
37
+ * is the only shape that ends EARLY on a fast page and still bounds a slow one.
38
+ *
39
+ * A `null` answer never overwrites a real count: `null` means "not counted", and a probe that
40
+ * fails once would otherwise turn a page with islands into a page reported to have none.
41
+ */
42
+ export async function settleIslands(
43
+ probe: () => Promise<IslandCount | null>,
44
+ options: SettleOptions,
45
+ ): Promise<IslandCount | null> {
46
+ const sleep = options.sleep ?? ((ms: number): Promise<void> => Bun.sleep(ms));
47
+ let answer = await probe();
48
+ let waited = 0;
49
+ while (!islandsSettled(answer) && waited < options.windowMs) {
50
+ // At least 1ms, or a `pollMs` of zero is a loop with no exit while the window stands.
51
+ const step = Math.max(1, Math.min(options.pollMs, options.windowMs - waited));
52
+ await sleep(step);
53
+ waited += step;
54
+ answer = (await probe()) ?? answer;
55
+ }
56
+ return answer;
57
+ }
@@ -33,6 +33,7 @@ export const SHOT_MESSAGE_KEYS = [
33
33
  'cli.shot.canvasUnreadable',
34
34
  'cli.shot.islands',
35
35
  'cli.shot.islandsUnknown',
36
+ 'cli.shot.islandFailed',
36
37
  'cli.shot.network',
37
38
  'cli.shot.console',
38
39
  'cli.shot.threw',
@@ -176,19 +177,28 @@ const levelCount = (lines: readonly ConsoleLine[], level: ConsoleLine['level']):
176
177
  lines.filter((line) => line.level === level).length;
177
178
 
178
179
  /**
179
- * `ok` is three conditions, and every one is something a picture cannot show: nothing on the page
180
- * logged an error, nothing THREW, and the document photographed is the route that was asked for.
180
+ * `ok` is four conditions, and every one is something a picture cannot show: nothing on the page
181
+ * logged an error, nothing THREW, no island's `mount()` REJECTED, and the document photographed is
182
+ * the route that was asked for.
181
183
  *
182
184
  * The throw is its own clause rather than folded into `errors` because an uncaught exception calls
183
185
  * no console method — a page whose island died can log nothing at all, and `errors === 0` would
184
- * then pass it. A redirect is a failure of the CAPTURE rather than of the app: an agent that
186
+ * then pass it. A rejected mount is a third silent one and was read by NOTHING until 2026-08-22:
187
+ * the prelude pays 129 B an island to write `data-x-failed`, the probe counted it into the
188
+ * artifact, and every island on a page could reject while the run reported "clean". `?? 0` keeps
189
+ * an uncounted probe (`null`) out of the verdict — "not counted" is not "none failed".
190
+ * A redirect is a failure of the CAPTURE rather than of the app: an agent that
185
191
  * photographs the sign-in page and files "the island did not mount" is the outcome this prevents.
186
192
  */
187
193
  export function buildVerdict(input: ShotInput): ShotVerdict {
188
194
  const errors = levelCount(input.console, 'error');
189
195
  return {
190
196
  ...input,
191
- ok: errors === 0 && input.pageErrors.length === 0 && input.requestedUrl === input.finalUrl,
197
+ ok:
198
+ errors === 0 &&
199
+ input.pageErrors.length === 0 &&
200
+ (input.islands?.failed ?? 0) === 0 &&
201
+ input.requestedUrl === input.finalUrl,
192
202
  redirected: input.requestedUrl !== input.finalUrl,
193
203
  errors,
194
204
  warnings: levelCount(input.console, 'warn'),
@@ -328,6 +338,19 @@ export const shotSummary = (verdict: ShotVerdict): string => {
328
338
  first: verdict.pageErrors[0]?.message ?? '',
329
339
  });
330
340
  }
341
+ // Ahead of the console count for the same reason, and it is the same silence: a rejected mount
342
+ // promise calls no console method either, so a page whose every island died can read `errors: 0`.
343
+ // The first failure is NAMED — "1 island failed" sends a reader back to the artifact for the one
344
+ // fact they need to start.
345
+ const failure = verdict.islands?.failures[0];
346
+ if (failure !== undefined) {
347
+ return msg('cli.shot.islandFailed', {
348
+ route: verdict.route,
349
+ failed: verdict.islands?.failed ?? 0,
350
+ island: failure.island,
351
+ message: failure.message,
352
+ });
353
+ }
331
354
  if (verdict.errors > 0) {
332
355
  return msg('cli.shot.errors', { route: verdict.route, errors: verdict.errors });
333
356
  }
@@ -4,7 +4,8 @@
4
4
  // per-tenant subscription cap all decided against an anonymous actor. Realtime was single-tenant
5
5
  // by wiring, not by design.
6
6
 
7
- import type { Actor } from '@ultimat3/core';
7
+ import type { Actor, Clock } from '@ultimat3/core';
8
+ import { systemClock } from '@ultimat3/core';
8
9
  import type { HttpConfig } from '@ultimat3/http';
9
10
  import {
10
11
  configuredAuthenticator,
@@ -12,7 +13,37 @@ import {
12
13
  defineHttpConfig,
13
14
  UltimateRequest,
14
15
  } from '@ultimat3/http';
15
- import type { SyncAuthenticator, SyncGrant } from '@ultimat3/realtime';
16
+ import type { SyncAuthenticator, SyncGrant } from '@ultimat3/realtime/server';
17
+
18
+ /**
19
+ * How long one grant stands before the node re-decides it.
20
+ *
21
+ * A grant with no expiry never appears in `GrantBook.expired()`, so `sweepGrants` — the only path
22
+ * to `hub.onActorChange` and `registry.reauthorize` — never fired for a socket this adapter opened.
23
+ * `logout`, `revokeSession`, `disableUser` and `updatePrivileges` closed the HTTP session and never
24
+ * the websocket, and the client's 15s heartbeat beats the 120s idle sweep, so the socket stayed up
25
+ * with the revoked actor's authority for as long as the tab was open.
26
+ *
27
+ * Five minutes, against `DEFAULT_REAUTH_INTERVAL_MS` (30s): the window a revoked actor keeps its
28
+ * socket is this plus one sweep, and the cost is one resolver call per socket per window — 167/s
29
+ * on the 50,000-socket node this repo has measured, against 1,667/s at a 30s TTL. A deployment
30
+ * whose credential has a shorter real lifetime passes `runtime.syncAuthenticate` and states it.
31
+ */
32
+ export const SYNC_GRANT_TTL_MS = 5 * 60_000;
33
+
34
+ /**
35
+ * The credential this adapter retains per socket, and nothing else.
36
+ *
37
+ * `sync-auth.ts` says the seam is a closure precisely so a node does not hold one `Request` per
38
+ * connection for the life of that connection: `SyncSocket`'s budget is ~1KB and the grant sits
39
+ * beside it. Two header values is what an app that closes over a token string would hold.
40
+ *
41
+ * Dropping the rest can only make a refresh MORE restrictive, never more permissive: an app that
42
+ * resolves identity from some other header sees its refresh answer `null`, which closes the socket
43
+ * with `1008` and the client re-dials carrying that header again. One reconnect per window, not an
44
+ * escalation — and `runtime.syncAuthenticate` is the declared seam for stating something else.
45
+ */
46
+ const CREDENTIAL_HEADERS = ['cookie', 'authorization'] as const;
16
47
 
17
48
  /**
18
49
  * The upgrade request, dressed as the request an `Authenticator` reads.
@@ -27,33 +58,74 @@ function upgradeConfig(buildId: string): HttpConfig {
27
58
  return defineHttpConfig({ buildId, rateLimit: { enabled: false, scope: 'process' } });
28
59
  }
29
60
 
61
+ /** What the closure keeps: enough to ask the app's resolver the same question a second time. */
62
+ interface Credential {
63
+ readonly url: string;
64
+ readonly method: string;
65
+ readonly headers: Headers;
66
+ }
67
+
68
+ function credentialOf(request: Request): Credential {
69
+ const headers = new Headers();
70
+ for (const name of CREDENTIAL_HEADERS) {
71
+ const value = request.headers.get(name);
72
+ if (value !== null) headers.set(name, value);
73
+ }
74
+ return { url: request.url, method: request.method, headers };
75
+ }
76
+
77
+ export interface SyncAuthenticatorOptions {
78
+ /** The clock a grant's window is measured on. Injected so a re-auth is provable without sleeping. */
79
+ readonly clock?: Clock;
80
+ /** Overrides `SYNC_GRANT_TTL_MS`. A test names its own window; nothing in the boot passes one. */
81
+ readonly ttlMs?: number;
82
+ }
83
+
30
84
  /**
31
85
  * What the sync node is given when the app configured an authenticator, and `undefined` when it
32
86
  * did not — which keeps `x dev` anonymous and makes the node log that it is, exactly as
33
87
  * `createSyncNode` documents. A stub that answered `{ actor: anonymous }` would look configured.
34
88
  *
35
- * The grant carries **no `expiresAt` and no `refresh`**, and that is the honest limit of this
36
- * adapter rather than an omission: `configureAuthenticator()` resolves an `Actor` and says nothing
37
- * about how long it stays true, so inventing a window here would either close live sockets that
38
- * are still authorized or claim a lifetime the app never promised. A deployment whose credential
39
- * has a real expiry passes `runtime.syncAuthenticate` and gets re-authorization; the timer for it
40
- * already lives in `createSyncNode.start()`.
89
+ * The grant carries an `expiresAt` and a `refresh`, and both are the app's own resolver asked
90
+ * again: `configureAuthenticator` says who is dialling, and the only honest way to learn that it
91
+ * has stopped being true is to ask. A `null` second answer is a revocation the node turns into a
92
+ * `1008`; a THROW is a backend failure, and `sweepGrants` keeps the grant and retries — the
93
+ * adapter must not collapse those two, here or on the refresh path.
41
94
  */
42
- export function syncAuthenticator(buildId: string): SyncAuthenticator | undefined {
95
+ export function syncAuthenticator(
96
+ buildId: string,
97
+ options: SyncAuthenticatorOptions = {},
98
+ ): SyncAuthenticator | undefined {
43
99
  const authenticate = configuredAuthenticator();
44
100
  if (authenticate === undefined) return undefined;
45
101
  // Once per node, not once per upgrade: resolving a config is pure and a 50k-socket node pays
46
102
  // this per connection otherwise.
47
103
  const config = upgradeConfig(buildId);
48
- return async (request: Request): Promise<SyncGrant | null> => {
104
+ const clock = options.clock ?? systemClock;
105
+ const ttlMs = options.ttlMs ?? SYNC_GRANT_TTL_MS;
106
+
107
+ const resolve = async (credential: Credential): Promise<SyncGrant | null> => {
108
+ const request = new Request(credential.url, {
109
+ method: credential.method,
110
+ headers: credential.headers,
111
+ });
49
112
  const ctx = createRequestContext({
50
- url: new URL(request.url),
51
- method: request.method,
113
+ url: new URL(credential.url),
114
+ method: credential.method,
52
115
  role: 'sync',
53
116
  config,
54
- requestHeaders: request.headers,
117
+ requestHeaders: credential.headers,
55
118
  });
56
119
  const actor: Actor | null = await authenticate(new UltimateRequest(request, ctx), ctx);
57
- return actor === null ? null : { actor };
120
+ if (actor === null) return null;
121
+ return {
122
+ // The window is measured from the answer, not from the upgrade: a refreshed grant that
123
+ // returned its original instant would be expired again on the very next pass.
124
+ actor,
125
+ expiresAt: clock.now().getTime() + ttlMs,
126
+ refresh: () => resolve(credential),
127
+ };
58
128
  };
129
+
130
+ return async (request: Request): Promise<SyncGrant | null> => resolve(credentialOf(request));
59
131
  }
@@ -0,0 +1,122 @@
1
+ // The `bare-error` guard `x new` ships: no shipped module throws a bare `Error`.
2
+ // `AGENTS.md` has always stated the rule and NOTHING enforced it — `throw new Error(...)` in a
3
+ // scaffolded `repo.ts` was green on `x verify`, and it reaches an agent as a stack trace with no
4
+ // code, no cause and nothing to run.
5
+
6
+ import { guardCode } from './guard';
7
+ import type { GeneratedFile } from './naming';
8
+
9
+ /**
10
+ * Derived from the guard's name, never written as a literal — the same rule `x g guard` follows.
11
+ * An `X_*` literal in framework source is a FRAMEWORK code: `error-catalog.test.ts` refuses one the
12
+ * registry does not hold, and `wiki/Error-Codes.md` would owe it a row. The APP owns the codes its
13
+ * own conventions raise, so this one is spelled by the file it lands in and nowhere else.
14
+ */
15
+ const NAME = 'bare-error';
16
+ const CODE = guardCode(NAME);
17
+
18
+ const source =
19
+ (): string => `// bare-error: a failure this app raises carries a code, a cause and an executable fix.
20
+ // \`x verify\` discovers every file in \`guards/\` and runs its \`guard\` inside the \`boundaries\`
21
+ // step — nothing registers this file, so nothing can forget to.
22
+
23
+ import type { Finding, Guard } from '@ultimat3/cli';
24
+
25
+ /** The app owns the codes its own conventions raise — this one is named for the guard. */
26
+ const CODE = '${CODE}';
27
+
28
+ /**
29
+ * A THROW, never a construction. \`new Error(…)\` handed to something as INPUT is legitimate — a
30
+ * test fixture, an \`AbortSignal\` reason, a rejection this module is passing along — and only the
31
+ * throw is this module stating its own verdict.
32
+ */
33
+ const BARE_THROW = /\\bthrow\\s+new\\s+(Error|TypeError|RangeError|SyntaxError)\\s*\\(/g;
34
+
35
+ export interface SourceFile {
36
+ /** App-root-relative POSIX path, so the finding names the file an author opens. */
37
+ readonly path: string;
38
+ readonly source: string;
39
+ }
40
+
41
+ /** Comments blanked IN PLACE — not deleted — so a reported line number still points at the source. */
42
+ const blank = (text: string): string =>
43
+ text
44
+ .replaceAll(/\\/\\*[\\s\\S]*?\\*\\//g, (match) => match.replaceAll(/[^\\n]/g, ' '))
45
+ .replaceAll(/(?<![:\\w])\\/\\/[^\\n]*/g, (match) => ' '.repeat(match.length));
46
+
47
+ const lineOf = (text: string, index: number): number => text.slice(0, index).split('\\n').length;
48
+
49
+ /** Pure — the caller does the I/O — so the rule is testable without a filesystem. */
50
+ export function bareThrows(files: readonly SourceFile[]): readonly Finding[] {
51
+ const findings: Finding[] = [];
52
+ for (const file of files) {
53
+ const text = blank(file.source);
54
+ for (const match of text.matchAll(BARE_THROW)) {
55
+ const line = lineOf(text, match.index);
56
+ const thrown = match[1] ?? 'Error';
57
+ findings.push({
58
+ code: CODE,
59
+ cause: \`\${file.path}:\${line} throws a bare \${thrown} — it reaches its reader as a stack trace with no code, no cause and nothing to run\`,
60
+ fix: \`subclass UltimateError in \${file.path} with an X_SCREAMING_SNAKE code, a cause and a fix naming a command, then: x verify\`,
61
+ at: file.path,
62
+ });
63
+ }
64
+ }
65
+ return findings;
66
+ }
67
+
68
+ export const guard: Guard = {
69
+ summary: 'a failure carries a code, a cause and an executable fix — never a bare Error',
70
+ async check(root) {
71
+ const files: SourceFile[] = [];
72
+ for await (const entry of new Bun.Glob('{apps,packages}/**/*.{ts,tsx}').scan({
73
+ cwd: root,
74
+ absolute: false,
75
+ })) {
76
+ const path = entry.split('\\\\').join('/');
77
+ // A test states its verdict with \`expect.unreachable()\`, which the suite reports on its own
78
+ // terms; \`node_modules\` is not this app's source.
79
+ if (path.includes('node_modules/') || /\\.(?:test|d)\\.tsx?$/.test(path)) continue;
80
+ files.push({ path, source: await Bun.file(\`\${root}/\${path}\`).text() });
81
+ }
82
+ return bareThrows(files);
83
+ },
84
+ };
85
+ `;
86
+
87
+ const test =
88
+ (): string => `// The rule, driven directly. Failure case first: a guard whose rule silently stopped matching is
89
+ // a green gate over the convention it was written to enforce.
90
+
91
+ import { expect, unitTest } from '@ultimat3/testing';
92
+ import { bareThrows } from './bare-error';
93
+
94
+ const file = (source: string) => [{ path: 'apps/web/app/post/repo.ts', source }];
95
+
96
+ unitTest('a bare throw is refused, and the finding names the line', () => {
97
+ const findings = bareThrows(file("const x = 1;\\nthrow new Error('no post');"));
98
+ expect(findings).toHaveLength(1);
99
+ expect(findings[0]?.code).toBe('${CODE}');
100
+ expect(findings[0]?.cause).toContain(':2');
101
+ });
102
+
103
+ unitTest('TypeError and RangeError are the same rule', () => {
104
+ expect(bareThrows(file("throw new TypeError('x');"))).toHaveLength(1);
105
+ expect(bareThrows(file("throw new RangeError('x');"))).toHaveLength(1);
106
+ });
107
+
108
+ unitTest('an UltimateError subclass is what the rule asks for', () => {
109
+ expect(bareThrows(file('throw new PostError(missingPost(id));'))).toEqual([]);
110
+ });
111
+
112
+ unitTest('a bare Error that is INPUT is not a verdict', () => {
113
+ expect(bareThrows(file("controller.abort(new Error('cancelled'));"))).toEqual([]);
114
+ expect(bareThrows(file("// throw new Error('x');"))).toEqual([]);
115
+ });
116
+ `;
117
+
118
+ /** `guards/bare-error.ts` and its test. No index, no registry — the directory registers it. */
119
+ export const bareErrorGuardFiles = (): readonly GeneratedFile[] => [
120
+ { path: 'guards/bare-error.ts', contents: source() },
121
+ { path: 'guards/bare-error.test.ts', contents: test() },
122
+ ];