@abide/abide 0.112.0 → 0.114.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.
@@ -7,6 +7,11 @@ import {
7
7
  } from '#shared/ANCHORS.ts'
8
8
  import { anchorMarkup } from '#shared/anchorMarkup.ts'
9
9
  import { CHILDREN } from '#shared/CHILDREN.ts'
10
+ import { FOREIGN_ATTRIBUTES } from '#shared/FOREIGN_ATTRIBUTES.ts'
11
+ import {
12
+ HTML_NAMESPACE,
13
+ INTEGRATION_POINTS,
14
+ } from '#shared/INTEGRATION_POINTS.ts'
10
15
  import { RUNTIME_SPECIFIER, SHARED_SPECIFIER } from '#shared/SPECIFIERS.ts'
11
16
  import { type CheckerAnswer, UNCHECKED } from './CheckerAnswer.ts'
12
17
  import {
@@ -880,6 +885,9 @@ const SHARE_AT = '__shareAt'
880
885
  const PARENT = '__parent'
881
886
  // The document a built region makes its nodes with, read off `PARENT` once a build.
882
887
  const DOCUMENT = '__document'
888
+ // The namespace a built region's top-level elements take, read off `PARENT` once a build.
889
+ const SPACE = '__space'
890
+ const HTML_SPACE = JSON.stringify(HTML_NAMESPACE)
883
891
 
884
892
  // A run's anchors while it holds markup (32.30), as the JS string literals a built run parses with.
885
893
  const RAW_OPEN_MARKUP = JSON.stringify(anchorMarkup(RAW_OPEN))
@@ -942,17 +950,29 @@ function emitMaker(
942
950
  *
943
951
  * A NESTED REGION IS AN EMPTY ANCHOR PAIR, filled by its own machinery on the mount's first run
944
952
  * (`fillEmpty`), and a component or a slot is the same pair filled by the child.
953
+ *
954
+ * 32.70 — AN ELEMENT IS BUILT IN THE NAMESPACE THE PARSER WOULD GIVE IT, read off the parent it goes
955
+ * into (`namespaceUnder`) rather than decided here: a component's root does not know it is invoked
956
+ * inside an `<svg>`, and a `<g>` made with `createElement` is an HTML element that draws nothing.
957
+ * Read once a build, and again only below an `<annotation-xml>`, which reads its children as HTML by
958
+ * the encoding it declares; below any other integration point (`INTEGRATION_POINTS`) a child is
959
+ * HTML's, and everywhere else it takes its parent's. `createElementNS` with
960
+ * HTML's namespace costs what `createElement` does (Chromium, 2026-10-22), and a build that makes
961
+ * no top-level element reads none.
945
962
  */
946
963
  function emitBuild(row: Cursor, anchored: boolean, lowering: Lowering): string {
947
964
  const lines: string[] = [
948
965
  `const ${DOCUMENT} = ${PARENT}.ownerDocument`,
949
966
  'const __built = []',
950
967
  ]
968
+ let readsSpace = false
951
969
  if (anchored)
952
970
  lines.push(
953
971
  `__built.push(${DOCUMENT}.createComment(${JSON.stringify(ROW_OPEN)}))`,
954
972
  )
955
973
  const render = buildRender(lowering)
974
+ // The expression each built element's namespace was, for its children to take.
975
+ const spaceOf = new Map<PlacedNode, string>()
956
976
  const into = (at: PlacedNode, node: string): string =>
957
977
  at.within === null
958
978
  ? `__built.push(${node})`
@@ -975,8 +995,19 @@ function emitBuild(row: Cursor, anchored: boolean, lowering: Lowering): string {
975
995
  for (const line of builtRun(at, lowering, into)) lines.push(line)
976
996
  continue
977
997
  }
998
+ const within = at.within
999
+ const space =
1000
+ within === null
1001
+ ? SPACE
1002
+ : INTEGRATION_POINTS.has(within.tag)
1003
+ ? HTML_SPACE
1004
+ : within.tag === 'annotation-xml'
1005
+ ? `${lowering.use('namespaceUnder')}(${within.name})`
1006
+ : (spaceOf.get(within) ?? SPACE)
1007
+ if (space === SPACE) readsSpace = true
1008
+ spaceOf.set(at, space)
978
1009
  lines.push(
979
- `const ${at.name} = ${DOCUMENT}.createElement(${JSON.stringify(at.tag)})`,
1010
+ `const ${at.name} = ${DOCUMENT}.createElementNS(${space}, ${JSON.stringify(at.tag)})`,
980
1011
  )
981
1012
  for (const one of classifyAttributes(
982
1013
  at.holder as ElementNode,
@@ -984,7 +1015,13 @@ function emitBuild(row: Cursor, anchored: boolean, lowering: Lowering): string {
984
1015
  )) {
985
1016
  const value = builtAttribute(one, lowering, render)
986
1017
  if (value === null) continue
987
- const set = `${at.name}.setAttribute(${JSON.stringify(one.kind === 'merged' ? one.group?.name : one.attribute.name)}, `
1018
+ const name =
1019
+ one.kind === 'merged' ? one.group?.name : one.attribute.name
1020
+ // 32.70 — one the parser would put in a namespace is written in it (`addAttribute`).
1021
+ const set =
1022
+ name !== undefined && FOREIGN_ATTRIBUTES.has(name)
1023
+ ? `${lowering.use('addAttribute')}(${at.name}, ${JSON.stringify(name)}, `
1024
+ : `${at.name}.setAttribute(${JSON.stringify(name)}, `
988
1025
  // 32.37 — a whole-value attribute may be absent, which the server wrote as nothing.
989
1026
  lines.push(
990
1027
  one.kind === 'dynamic'
@@ -994,6 +1031,12 @@ function emitBuild(row: Cursor, anchored: boolean, lowering: Lowering): string {
994
1031
  }
995
1032
  lines.push(into(at, at.name))
996
1033
  }
1034
+ if (readsSpace)
1035
+ lines.splice(
1036
+ 1,
1037
+ 0,
1038
+ `const ${SPACE} = ${lowering.use('namespaceUnder')}(${PARENT})`,
1039
+ )
997
1040
  lines.push('return __built')
998
1041
  return lines.join('\n ')
999
1042
  }
@@ -8,9 +8,10 @@
8
8
  // POST /__abide/bridge what the bridge reports, one frame per line: a turn's events, the
9
9
  // MCP requests its Claude Code made, the turn's end or failure.
10
10
  //
11
- // A PAIRING IS TO A BROWSER — the `abide-caller` its page carries (26.5) — for as long as the bridge's
12
- // stream is open, or resumable. HELD IN THIS PROCESS: an app served by several needs a visitor's requests to reach
13
- // the one its bridge dialed.
11
+ // A PAIRING IS TO A BROWSER — the `abide-caller` its page carries (26.5) — and to whoever was signed
12
+ // in there when its code was minted (44.33), for as long as the bridge's stream is open, or
13
+ // resumable. HELD IN THIS PROCESS: an app served by several needs a visitor's requests to reach the
14
+ // one its bridge dialed.
14
15
 
15
16
  import { isFailed } from '#shared/Failed.ts'
16
17
  import { refuse } from '#shared/refuse.ts'
@@ -35,7 +36,8 @@ export type TurnItem = {
35
36
 
36
37
  /** One paired bridge: what it is sent, and the turns it is answering. */
37
38
  export type Bridge = {
38
- caller: string
39
+ /** The browser and the sign-in it is paired to (`pairingKey`). 44.33. */
40
+ key: string
39
41
  token: string
40
42
  frames: Inbox<unknown>
41
43
  turns: Map<string, Inbox<TurnItem>>
@@ -53,40 +55,68 @@ const IDLE_GRACE = 30_000
53
55
  /** How long a pairing whose stream dropped waits to be resumed. 44.26. */
54
56
  export const RESUME_MILLISECONDS = 60_000
55
57
 
56
- const CODES = new Map<string, { caller: string; expires: number }>()
57
- const BY_CALLER = new Map<string, Bridge>()
58
+ const CODES = new Map<string, { key: string; expires: number }>()
59
+ const BY_KEY = new Map<string, Bridge>()
58
60
  const BY_TOKEN = new Map<string, Bridge>()
59
61
  const WATCHERS = new Map<string, Set<() => void>>()
62
+ const FOLLOWERS = new Map<
63
+ string,
64
+ Set<(caller: string, signedIn: string) => void>
65
+ >()
66
+
67
+ /**
68
+ * What a pairing is filed under: the browser, and who was signed in there (`signedInAs`).
69
+ *
70
+ * 44.33 — ONE PER SIGN-IN, so pairing at a browser replaces only that sign-in's own bridge, and a
71
+ * page watching for one sign-in neither reaches another's nor keeps it from going idle (44.22).
72
+ */
73
+ function pairingKey(caller: string, signedIn: string): string {
74
+ return `${caller}\n${signedIn}`
75
+ }
60
76
 
61
77
  // 44.19 — the relay answers once the app has handed out a pairing command, and never before.
62
78
  let minted = false
63
79
 
64
- /** A single-use pairing code for `caller`, and when it stops standing. 44.14. */
65
- export function mintCode(caller: string): { code: string; expires: number } {
80
+ /** A single-use pairing code for `caller` signed in as `signedIn`, and when it stops standing. 44.14. */
81
+ export function mintCode(
82
+ caller: string,
83
+ signedIn: string,
84
+ ): { code: string; expires: number } {
66
85
  const now = Date.now()
67
86
  for (const [code, pairing] of CODES)
68
87
  if (pairing.expires <= now) CODES.delete(code)
69
88
  const code = randomToken(12, 'base64url')
70
89
  const expires = now + CODE_LIFETIME
71
- CODES.set(code, { caller, expires })
90
+ CODES.set(code, { key: pairingKey(caller, signedIn), expires })
72
91
  minted = true
73
92
  return { code, expires }
74
93
  }
75
94
 
76
- /** The bridge paired to `caller`, if its stream is open. */
77
- export function pairedBridge(caller: string): Bridge | undefined {
78
- return BY_CALLER.get(caller)
95
+ /** The bridge paired to `caller` signed in as `signedIn`, if its stream is open. 44.33. */
96
+ export function pairedBridge(
97
+ caller: string,
98
+ signedIn: string,
99
+ ): Bridge | undefined {
100
+ return BY_KEY.get(pairingKey(caller, signedIn))
79
101
  }
80
102
 
81
- /** Registers `wake` to run whenever `caller`'s pairing changes; the returned function undoes it. */
82
- export function watchPairing(caller: string, wake: () => void): () => void {
83
- let watchers = WATCHERS.get(caller)
103
+ /**
104
+ * Registers `wake` to run whenever the pairing of `caller` signed in as `signedIn` changes; the
105
+ * returned function undoes it.
106
+ */
107
+ export function watchPairing(
108
+ caller: string,
109
+ signedIn: string,
110
+ wake: () => void,
111
+ ): () => void {
112
+ const key = pairingKey(caller, signedIn)
113
+ let watchers = WATCHERS.get(key)
84
114
  if (watchers === undefined) {
85
115
  watchers = new Set()
86
- WATCHERS.set(caller, watchers)
116
+ WATCHERS.set(key, watchers)
87
117
  }
88
118
  watchers.add(wake)
89
- const bridge = BY_CALLER.get(caller)
119
+ const bridge = BY_KEY.get(key)
90
120
  if (bridge !== undefined) {
91
121
  bridge.watched = true
92
122
  if (bridge.idle !== undefined) clearTimeout(bridge.idle)
@@ -95,8 +125,8 @@ export function watchPairing(caller: string, wake: () => void): () => void {
95
125
  return () => {
96
126
  watchers.delete(wake)
97
127
  if (watchers.size > 0) return
98
- WATCHERS.delete(caller)
99
- armIdle(caller)
128
+ WATCHERS.delete(key)
129
+ armIdle(key)
100
130
  }
101
131
  }
102
132
 
@@ -114,15 +144,15 @@ export function openTurn(bridge: Bridge): {
114
144
  inbox,
115
145
  close: () => {
116
146
  bridge.turns.delete(id)
117
- if (!WATCHERS.has(bridge.caller)) armIdle(bridge.caller)
147
+ if (!WATCHERS.has(bridge.key)) armIdle(bridge.key)
118
148
  },
119
149
  }
120
150
  }
121
151
 
122
152
  // 44.22 — A BRIDGE A PAGE WATCHED, THAT NO PAGE WATCHES NOW AND THAT IS ANSWERING NO TURN, is sent
123
153
  // away once the grace runs.
124
- function armIdle(caller: string): void {
125
- const bridge = BY_CALLER.get(caller)
154
+ function armIdle(key: string): void {
155
+ const bridge = BY_KEY.get(key)
126
156
  if (
127
157
  bridge === undefined ||
128
158
  !bridge.watched ||
@@ -136,8 +166,40 @@ function armIdle(caller: string): void {
136
166
  }, IDLE_GRACE)
137
167
  }
138
168
 
139
- function wakeWatchers(caller: string): void {
140
- for (const wake of WATCHERS.get(caller) ?? []) wake()
169
+ function wakeWatchers(key: string): void {
170
+ for (const wake of WATCHERS.get(key) ?? []) wake()
171
+ }
172
+
173
+ /**
174
+ * Registers `follow` to hear who signs in at `caller` from here on, and the caller a sign-out rotates
175
+ * it to (26.9); the returned function undoes it.
176
+ *
177
+ * 44.34 — A STATUS STREAM OPENED BEFORE A SIGN-IN carries the cookies it was opened with, so it hears
178
+ * of the new one here rather than off its own request.
179
+ */
180
+ export function followSignIn(
181
+ caller: string,
182
+ follow: (caller: string, signedIn: string) => void,
183
+ ): () => void {
184
+ let followers = FOLLOWERS.get(caller)
185
+ if (followers === undefined) {
186
+ followers = new Set()
187
+ FOLLOWERS.set(caller, followers)
188
+ }
189
+ followers.add(follow)
190
+ return () => {
191
+ followers.delete(follow)
192
+ if (followers.size === 0) FOLLOWERS.delete(caller)
193
+ }
194
+ }
195
+
196
+ /** Tells every status stream at `caller` that the browser is `now`, signed in as `signedIn`. 44.34. */
197
+ export function signedInAt(
198
+ caller: string,
199
+ now: string,
200
+ signedIn: string,
201
+ ): void {
202
+ for (const follow of FOLLOWERS.get(caller) ?? []) follow(now, signedIn)
141
203
  }
142
204
 
143
205
  function item(
@@ -161,7 +223,7 @@ function item(
161
223
  }
162
224
 
163
225
  function dropBridge(bridge: Bridge): void {
164
- if (BY_CALLER.get(bridge.caller) === bridge) BY_CALLER.delete(bridge.caller)
226
+ if (BY_KEY.get(bridge.key) === bridge) BY_KEY.delete(bridge.key)
165
227
  BY_TOKEN.delete(bridge.token)
166
228
  if (bridge.idle !== undefined) clearTimeout(bridge.idle)
167
229
  bridge.idle = undefined
@@ -175,7 +237,7 @@ function dropBridge(bridge: Bridge): void {
175
237
  }
176
238
  bridge.turns.clear()
177
239
  bridge.frames.close()
178
- wakeWatchers(bridge.caller)
240
+ wakeWatchers(bridge.key)
179
241
  }
180
242
 
181
243
  /** Answers `/__abide/bridge`, or `undefined` until a pairing code has been minted. 44.19. */
@@ -199,24 +261,24 @@ function pair(request: Request): Response {
199
261
  'This pairing code was not issued here, or it has been used, or it has expired.',
200
262
  ),
201
263
  )
202
- const standing = BY_CALLER.get(pairing.caller)
264
+ const standing = BY_KEY.get(pairing.key)
203
265
  if (standing !== undefined) {
204
266
  standing.frames.push({ type: 'bye' })
205
267
  dropBridge(standing)
206
268
  }
207
269
  const bridge: Bridge = {
208
- caller: pairing.caller,
270
+ key: pairing.key,
209
271
  token: randomToken(24, 'base64url'),
210
272
  frames: new Inbox(),
211
273
  turns: new Map(),
212
- watched: WATCHERS.has(pairing.caller),
274
+ watched: WATCHERS.has(pairing.key),
213
275
  idle: undefined,
214
276
  held: undefined,
215
277
  }
216
- BY_CALLER.set(bridge.caller, bridge)
278
+ BY_KEY.set(bridge.key, bridge)
217
279
  BY_TOKEN.set(bridge.token, bridge)
218
280
  bridge.frames.push({ type: 'paired', token: bridge.token })
219
- wakeWatchers(bridge.caller)
281
+ wakeWatchers(bridge.key)
220
282
  return attach(bridge, request)
221
283
  }
222
284
 
@@ -18,6 +18,7 @@ import {
18
18
  } from './assistant.ts'
19
19
  import { BRIDGE_VERSION } from './BRIDGE_VERSION.ts'
20
20
  import {
21
+ followSignIn,
21
22
  mintCode,
22
23
  openTurn,
23
24
  pairedBridge,
@@ -27,6 +28,7 @@ import { callerOf } from './callerOf.ts'
27
28
  import { appOrigin } from './config.ts'
28
29
  import { mcpFailure } from './generatedSurfaces.ts'
29
30
  import { Inbox } from './Inbox.ts'
31
+ import { signedInAs } from './principal.ts'
30
32
  import { request } from './requestScope.ts'
31
33
 
32
34
  /** The Claude Code options a site may set for a turn, and the built-in tools it asks the visitor for. */
@@ -52,12 +54,12 @@ export type BridgeStatus = { paired: boolean; command: string | undefined }
52
54
  function claudeCodeProvider(config: ClaudeCodeConfig = {}): Provider {
53
55
  return {
54
56
  async *run(messages, tools, { maxTurns, signal }) {
55
- const bridge = pairedBridge(askingCaller())
56
- // 44.13 — nothing paired is the page's cue to show the command.
57
+ const bridge = pairedBridge(askingCaller(), signedInAs())
58
+ // 44.13, 44.33 — nothing paired by this sign-in is the page's cue to show the command.
57
59
  if (bridge === undefined)
58
60
  throw refuse(
59
61
  409,
60
- 'No Claude Code is paired with this browser. claudeCode.bridgeCommand() is the command that pairs one.',
62
+ 'No Claude Code is paired with this browser as the one signed in. claudeCode.bridgeCommand() is the command that pairs one.',
61
63
  )
62
64
  const turn = openTurn(bridge)
63
65
  bridge.frames.push({
@@ -130,11 +132,20 @@ function askingCaller(): string {
130
132
  * The command that pairs a bridge with this browser, and when its code stops standing. 44.14. The
131
133
  * `tools` it is given are written after its `--`, so the visitor running it grants them (44.29, 44.32).
132
134
  */
133
- function bridgeCommand({ tools }: PairingOptions = {}): {
135
+ function bridgeCommand(options: PairingOptions = {}): {
134
136
  command: string
135
137
  expires: number
136
138
  } {
137
- const { code, expires } = mintCode(askingCaller())
139
+ return commandFor(askingCaller(), signedInAs(), options)
140
+ }
141
+
142
+ /** The command pairing a bridge with `caller` signed in as `signedIn` (44.33). */
143
+ function commandFor(
144
+ caller: string,
145
+ signedIn: string,
146
+ { tools }: PairingOptions,
147
+ ): { command: string; expires: number } {
148
+ const { code, expires } = mintCode(caller, signedIn)
138
149
  const site = `${appOrigin() ?? new URL(request().url).origin}${mounted('')}`
139
150
  const grant = tools?.length ? ` -- --tools ${tools.join(',')}` : ''
140
151
  return {
@@ -146,29 +157,50 @@ function bridgeCommand({ tools }: PairingOptions = {}): {
146
157
  /**
147
158
  * This browser's pairing as it changes: unpaired with the command to run, then paired the moment a
148
159
  * bridge connects, and unpaired again if it goes. 44.20.
160
+ *
161
+ * 44.34 — IT FOLLOWS WHOEVER SIGNS IN AT THE BROWSER, though its own request carries the cookies it
162
+ * opened with: a sign-in or a sign-out made since is the pairing it watches from then on, and the
163
+ * command it offers is that sign-in's.
149
164
  */
150
165
  async function* bridge(
151
166
  options: PairingOptions = {},
152
167
  ): AsyncGenerator<BridgeStatus> {
153
- const caller = askingCaller()
168
+ let caller = askingCaller()
169
+ let signedIn = signedInAs()
154
170
  const signal = request().signal
155
171
  // A change landing while the loop was not waiting — paused at its `yield` — stays queued, so it
156
172
  // looks again; a look that finds nothing new yields nothing.
157
173
  const changes = new Inbox<undefined>()
158
174
  const waiting = changes[Symbol.asyncIterator]()
159
175
  const rouse = (): void => changes.push(undefined)
160
- const unwatch = watchPairing(caller, rouse)
176
+ let watchingAs = ''
177
+ let unwatch = (): void => {}
178
+ let unfollow = (): void => {}
179
+ const follow = (now: string, next: string): void => {
180
+ caller = now
181
+ signedIn = next
182
+ rouse()
183
+ }
161
184
  signal.addEventListener('abort', rouse)
162
185
  let pairing: { command: string; expires: number } | undefined
163
186
  let said = ''
164
187
  try {
165
188
  while (!signal.aborted) {
166
- const paired = pairedBridge(caller) !== undefined
189
+ const watching = `${caller} ${signedIn}`
190
+ if (watching !== watchingAs) {
191
+ unwatch()
192
+ unfollow()
193
+ unwatch = watchPairing(caller, signedIn, rouse)
194
+ unfollow = followSignIn(caller, follow)
195
+ watchingAs = watching
196
+ pairing = undefined
197
+ }
198
+ const paired = pairedBridge(caller, signedIn) !== undefined
167
199
  if (
168
200
  !paired &&
169
201
  (pairing === undefined || pairing.expires <= Date.now())
170
202
  )
171
- pairing = bridgeCommand(options)
203
+ pairing = commandFor(caller, signedIn, options)
172
204
  const status: BridgeStatus = {
173
205
  paired,
174
206
  command: paired ? undefined : pairing?.command,
@@ -188,6 +220,7 @@ async function* bridge(
188
220
  } finally {
189
221
  signal.removeEventListener('abort', rouse)
190
222
  unwatch()
223
+ unfollow()
191
224
  }
192
225
  }
193
226
 
@@ -6,6 +6,7 @@
6
6
  import { resolve } from 'node:path' // bun exposes no path helpers of its own
7
7
  import { parseArgs } from 'node:util' // bun exposes no argv parser of its own yet
8
8
  import { MCP_ADDRESS, OPENAPI_ADDRESS } from '#shared/ADDRESSES.ts'
9
+ import { IN_PROCESS_ORIGIN } from '#shared/IN_PROCESS_ORIGIN.ts'
9
10
  import { banner } from './banner.ts'
10
11
  import { type App, type AppHandle, enterApp } from './createApp.ts'
11
12
  import { stopOnSignalOrFault } from './faultPolicy.ts'
@@ -494,7 +495,7 @@ async function surfaceAt(
494
495
  const booted = await booting(host, app, given)
495
496
  if (typeof booted === 'number') return booted
496
497
  return (init) =>
497
- booted.fetch(new Request(`http://abide.local${address}`, init))
498
+ booted.fetch(new Request(`${IN_PROCESS_ORIGIN}${address}`, init))
498
499
  }
499
500
 
500
501
  /**
@@ -49,6 +49,16 @@ export interface Config {
49
49
  APP_DATA_DIR: string
50
50
  }
51
51
 
52
+ // What a preload derives beside 27.3's three — the test preload's principal secret (38.120). Declared
53
+ // above `config`, which resolves as it is built — and so is invalidated by an addition.
54
+ const ADDED: Record<string, string> = {}
55
+
56
+ /** Adds `values` to the derived layer, so every `onConfig` default and the env still win (27.2). */
57
+ export function addDerived(values: Record<string, string>): void {
58
+ Object.assign(ADDED, values)
59
+ config.invalidate()
60
+ }
61
+
52
62
  /** The resolved configuration: abide's derived values, then `onConfig`'s defaults, then the env. */
53
63
  export const config = reactive(resolve, { global: true })
54
64
 
@@ -100,7 +110,8 @@ function coerce(text: string, standing: unknown): unknown {
100
110
  }
101
111
 
102
112
  /**
103
- * The app's name, version and data directory, off its own `package.json` and the platform.
113
+ * The app's name, version and data directory, off its own `package.json` and the platform, and
114
+ * what `addDerived` added.
104
115
  *
105
116
  * 27.3 — a layer under the other two, so a config or an environment that carries one wins.
106
117
  */
@@ -108,6 +119,7 @@ function derived(): Record<string, string> {
108
119
  const manifest = APP_PACKAGE.current
109
120
  const name = manifest.name ?? 'app'
110
121
  return {
122
+ ...ADDED,
111
123
  APP_NAME: name,
112
124
  APP_VERSION: manifest.version ?? '0.0.0',
113
125
  // 36.4 — A PATH, and never created: an app that wants the directory makes it.
@@ -7,6 +7,7 @@ import { existsSync } from 'node:fs' // bun exposes no sync existence check of i
7
7
  import { resolve } from 'node:path' // bun exposes no path helpers of its own
8
8
  import { FAILURE_STATUS_TEXT } from '#shared/FAILURE_STATUS_TEXT.ts'
9
9
  import { framingOf } from '#shared/framing.ts'
10
+ import { IN_PROCESS_ORIGIN } from '#shared/IN_PROCESS_ORIGIN.ts'
10
11
  import { LONGEST_DELAY } from '#shared/LONGEST_DELAY.ts'
11
12
  import { namedLogger } from '#shared/log.ts'
12
13
  import { compose } from '#shared/Middleware.ts'
@@ -32,6 +33,7 @@ import { IDLE_TIMEOUT_SECONDS } from './IDLE_TIMEOUT_SECONDS.ts'
32
33
  import { installServer } from './installServer.ts'
33
34
  import { admitsMux, muxSocketOptions, upgraded } from './muxUpgrade.ts'
34
35
  import { plainAnswer } from './plainAnswer.ts'
36
+ import { presumePrincipal } from './principal.ts'
35
37
  import { realOrAsGiven } from './realOrAsGiven.ts'
36
38
  import {
37
39
  type AnsweringApp,
@@ -60,7 +62,7 @@ export interface App {
60
62
  fetch(request: Request): Promise<Response>
61
63
  run<Result>(
62
64
  fn: () => Result | Promise<Result>,
63
- options?: { request?: Request },
65
+ options?: { request?: Request; principal?: Record<string, unknown> },
64
66
  ): Promise<Result>
65
67
  listen(options?: {
66
68
  port?: number
@@ -289,11 +291,21 @@ export async function bootEntered(
289
291
  let stopping = false
290
292
  let bound: Bun.Server<unknown> | undefined
291
293
 
292
- const run = async <Result>(
293
- fn: () => Result | Promise<Result>,
294
- options?: { request?: Request },
295
- ): Promise<Result> =>
296
- inReleasedScope(options?.request, async () => await fn(), answering)
294
+ const run: App['run'] = async (fn, options) => {
295
+ const claims = options?.principal
296
+ // 42.19 — a principal is a request's, so one given alone is given a request to be.
297
+ const request =
298
+ options?.request ??
299
+ (claims === undefined ? undefined : new Request(IN_PROCESS_ORIGIN))
300
+ return inReleasedScope(
301
+ request,
302
+ async () => {
303
+ if (claims !== undefined) presumePrincipal(claims)
304
+ return await fn()
305
+ },
306
+ answering,
307
+ )
308
+ }
297
309
 
298
310
  const fetch = async (given: Request): Promise<Response> => {
299
311
  if (stopping) return plainAnswer('the app is stopping', 503)
@@ -5,8 +5,13 @@
5
5
  // one place that runs first. The build is `createApp`'s own under `bun test` (42.16), so a test that
6
6
  // boots the app afterwards reuses it rather than building twice.
7
7
 
8
+ import { addDerived } from './config.ts'
8
9
  import { appRoot, servedBuild } from './createApp.ts'
9
10
 
11
+ // 38.120 — a sign-in seals under test where the app declared no secret: derived, so the app's
12
+ // `onConfig` default and the env both still win (27.2), where an env value would beat the default.
13
+ addDerived({ ABIDE_PRINCIPAL_SECRET: 'abide-test-principal-secret' })
14
+
10
15
  const root = appRoot(undefined)
11
16
  // A run from a directory that holds no app preloads nothing — nor from a workspace's root, whose
12
17
  // packages' `.abide` files are each their own app's rather than one app laid out across them.
@@ -18,10 +18,11 @@ import {
18
18
  import { refuse } from '#shared/refuse.ts'
19
19
  import { declare } from '#shared/rpc.ts'
20
20
  import { mountedOwn } from './answerRpc.ts'
21
- import { callerOrMint, mintCaller } from './callerOf.ts'
21
+ import { signedInAt } from './bridgeRelay.ts'
22
+ import { callerOf, callerOrMint, mintCaller } from './callerOf.ts'
22
23
  import { config, positiveSetting } from './config.ts'
23
24
  import { HOOKS, inOrder } from './hooks.ts'
24
- import { within } from './requestScope.ts'
25
+ import { type RequestScope, type Sealed, within } from './requestScope.ts'
25
26
 
26
27
  // 26.18.
27
28
  const PRINCIPAL_COOKIE = 'abide-principal'
@@ -84,9 +85,7 @@ function seal(
84
85
  *
85
86
  * 26.13 — A BAD SIGNATURE, A LAPSED SEAL AND A MALFORMED COOKIE ARE ONE ANSWER: nothing.
86
87
  */
87
- function unseal(
88
- sealed: string | null,
89
- ): { claims: Record<string, unknown>; expires: number } | undefined {
88
+ function unseal(sealed: string | null): Sealed | undefined {
90
89
  if (sealed === null) return undefined
91
90
  const [header, payload, signed, ...rest] = sealed.split('.')
92
91
  // ONE HEADER IS READ, abide's own: a token naming another `alg` — `none` above all — is no seal.
@@ -182,8 +181,8 @@ async function documentOf(
182
181
  function resolvePrincipal(): Promise<Principal> {
183
182
  const scope = within('principal')
184
183
  scope.principal ??= (async () => {
185
- const opened = unseal(cookies().get(PRINCIPAL_COOKIE))
186
- if (opened === undefined) return ANONYMOUS
184
+ const opened = openedSeal(scope)
185
+ if (opened === null) return ANONYMOUS
187
186
  let expires = opened.expires
188
187
  const life = ttl()
189
188
  if (expires - Date.now() < life / 2 && scope.applied === false) {
@@ -195,6 +194,51 @@ function resolvePrincipal(): Promise<Principal> {
195
194
  return scope.principal
196
195
  }
197
196
 
197
+ /**
198
+ * This request's principal, resolved from `claims` as a sealed cookie's are, with no cookie written.
199
+ *
200
+ * 42.19 — `App.run`'s `principal`, which is why it throws where `principal.set` does.
201
+ */
202
+ export function presumePrincipal(claims: Record<string, unknown>): void {
203
+ const scope = within('App.run principal')
204
+ if (!isRecord(claims))
205
+ throw new Error(
206
+ 'abide: App.run takes a principal of claims, as principal.set does. RULEBOOK 26.11.',
207
+ )
208
+ const expires = Date.now() + ttl()
209
+ scope.principal = documentOf(claims, expires)
210
+ scope.opened = { claims: asSealed(claims), expires }
211
+ }
212
+
213
+ /** What this request's principal cookie opened to, read and verified once a request. */
214
+ function openedSeal(scope: RequestScope): Sealed | null {
215
+ if (scope.opened === undefined)
216
+ scope.opened = unseal(cookies().get(PRINCIPAL_COOKIE)) ?? null
217
+ return scope.opened
218
+ }
219
+
220
+ /** `claims` as a later request opens them from their seal: through JSON, and `exp` and `iat` aside. */
221
+ function asSealed(claims: Record<string, unknown>): Record<string, unknown> {
222
+ const { exp: _, iat: __, ...sealed } = JSON.parse(JSON.stringify(claims))
223
+ return sealed
224
+ }
225
+
226
+ /** Who `opened` signs in, as one string: its claims as JSON, or `null`'s. 44.33. */
227
+ function identityOf(opened: Sealed | null): string {
228
+ return JSON.stringify(opened === null ? null : opened.claims)
229
+ }
230
+
231
+ /**
232
+ * Who this request is signed in as, as one string.
233
+ *
234
+ * 44.33 — THE CLAIMS, NOT THE RESOLVED DOCUMENT: what `onPrincipal` reads off them can change
235
+ * between requests, and a seal sealed again for another life (26.15) holds the same claims. Read
236
+ * synchronously, off the cookie, so the pairing command stays a plain call.
237
+ */
238
+ export function signedInAs(): string {
239
+ return identityOf(openedSeal(within('claudeCode')))
240
+ }
241
+
198
242
  /**
199
243
  * Signs this caller in with `claims`, sealing them into the cookie.
200
244
  *
@@ -219,6 +263,11 @@ async function set(claims: Record<string, unknown>): Promise<void> {
219
263
  scope.response.headers.set(PRINCIPAL_HEADER, 'set')
220
264
  const resolved = documentOf(claims, expires)
221
265
  scope.principal = resolved
266
+ scope.opened = { claims: asSealed(claims), expires }
267
+ // 44.34 — a status stream opened before this sign-in follows it.
268
+ const caller = callerOf()
269
+ if (caller !== undefined)
270
+ signedInAt(caller, caller, identityOf(scope.opened))
222
271
  principalMembers().document.set(await resolved)
223
272
  PRINCIPAL_LOG.debug('set')
224
273
  }
@@ -233,8 +282,12 @@ function clear(): void {
233
282
  const scope = within('principal.clear')
234
283
  cookies().delete(PRINCIPAL_COOKIE)
235
284
  scope.response.headers.set(PRINCIPAL_HEADER, 'clear')
285
+ const signedOut = callerOf()
236
286
  const minted = mintCaller(scope)
237
287
  scope.principal = Promise.resolve(ANONYMOUS)
288
+ scope.opened = null
289
+ // 44.34 — and one opened before this sign-out follows it to the caller it rotated to.
290
+ if (signedOut !== undefined) signedInAt(signedOut, minted, identityOf(null))
238
291
  principalMembers().document.set(ANONYMOUS)
239
292
  principalMembers().caller.set(minted)
240
293
  PRINCIPAL_LOG.debug('clear')