@astrale-os/shell 0.1.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 (145) hide show
  1. package/dist/adapters/iframe.shell-adapter.d.ts +10 -0
  2. package/dist/adapters/iframe.shell-adapter.d.ts.map +1 -0
  3. package/dist/adapters/iframe.shell-adapter.js +55 -0
  4. package/dist/adapters/kernel-client.adapter.d.ts +53 -0
  5. package/dist/adapters/kernel-client.adapter.d.ts.map +1 -0
  6. package/dist/adapters/kernel-client.adapter.js +69 -0
  7. package/dist/adapters/message-port.transport.d.ts +7 -0
  8. package/dist/adapters/message-port.transport.d.ts.map +1 -0
  9. package/dist/adapters/message-port.transport.js +42 -0
  10. package/dist/application/channels/children-channel.d.ts +21 -0
  11. package/dist/application/channels/children-channel.d.ts.map +1 -0
  12. package/dist/application/channels/children-channel.js +82 -0
  13. package/dist/application/channels/observer.d.ts +9 -0
  14. package/dist/application/channels/observer.d.ts.map +1 -0
  15. package/dist/application/channels/observer.js +23 -0
  16. package/dist/application/channels/parent-channel.d.ts +17 -0
  17. package/dist/application/channels/parent-channel.d.ts.map +1 -0
  18. package/dist/application/channels/parent-channel.js +21 -0
  19. package/dist/application/channels/types.d.ts +12 -0
  20. package/dist/application/channels/types.d.ts.map +1 -0
  21. package/dist/application/channels/types.js +29 -0
  22. package/dist/application/intent/pipeline-runner.d.ts +12 -0
  23. package/dist/application/intent/pipeline-runner.d.ts.map +1 -0
  24. package/dist/application/intent/pipeline-runner.js +27 -0
  25. package/dist/application/intent/request.d.ts +34 -0
  26. package/dist/application/intent/request.d.ts.map +1 -0
  27. package/dist/application/intent/request.js +70 -0
  28. package/dist/application/intent/router.d.ts +56 -0
  29. package/dist/application/intent/router.d.ts.map +1 -0
  30. package/dist/application/intent/router.js +94 -0
  31. package/dist/application/kernel-facade.d.ts +33 -0
  32. package/dist/application/kernel-facade.d.ts.map +1 -0
  33. package/dist/application/kernel-facade.js +77 -0
  34. package/dist/application/shell.d.ts +100 -0
  35. package/dist/application/shell.d.ts.map +1 -0
  36. package/dist/application/shell.js +256 -0
  37. package/dist/application/windowing/child-init.d.ts +23 -0
  38. package/dist/application/windowing/child-init.d.ts.map +1 -0
  39. package/dist/application/windowing/child-init.js +101 -0
  40. package/dist/application/windowing/handshake.d.ts +30 -0
  41. package/dist/application/windowing/handshake.d.ts.map +1 -0
  42. package/dist/application/windowing/handshake.js +63 -0
  43. package/dist/application/windowing/lifecycle-controller.d.ts +10 -0
  44. package/dist/application/windowing/lifecycle-controller.d.ts.map +1 -0
  45. package/dist/application/windowing/lifecycle-controller.js +30 -0
  46. package/dist/application/windowing/mount-controller.d.ts +25 -0
  47. package/dist/application/windowing/mount-controller.d.ts.map +1 -0
  48. package/dist/application/windowing/mount-controller.js +277 -0
  49. package/dist/application/windowing/protocol.d.ts +21 -0
  50. package/dist/application/windowing/protocol.d.ts.map +1 -0
  51. package/dist/application/windowing/protocol.js +19 -0
  52. package/dist/application/windowing/token-refresh.d.ts +19 -0
  53. package/dist/application/windowing/token-refresh.d.ts.map +1 -0
  54. package/dist/application/windowing/token-refresh.js +60 -0
  55. package/dist/domain/identity/delegation.d.ts +15 -0
  56. package/dist/domain/identity/delegation.d.ts.map +1 -0
  57. package/dist/domain/identity/delegation.js +12 -0
  58. package/dist/domain/identity/path.d.ts +11 -0
  59. package/dist/domain/identity/path.d.ts.map +1 -0
  60. package/dist/domain/identity/path.js +13 -0
  61. package/dist/domain/identity/resolve-self.d.ts +21 -0
  62. package/dist/domain/identity/resolve-self.d.ts.map +1 -0
  63. package/dist/domain/identity/resolve-self.js +36 -0
  64. package/dist/domain/intent/middleware/capabilities.d.ts +28 -0
  65. package/dist/domain/intent/middleware/capabilities.d.ts.map +1 -0
  66. package/dist/domain/intent/middleware/capabilities.js +18 -0
  67. package/dist/domain/intent/middleware/logging.d.ts +14 -0
  68. package/dist/domain/intent/middleware/logging.d.ts.map +1 -0
  69. package/dist/domain/intent/middleware/logging.js +21 -0
  70. package/dist/domain/intent/middleware/rate-limit.d.ts +20 -0
  71. package/dist/domain/intent/middleware/rate-limit.d.ts.map +1 -0
  72. package/dist/domain/intent/middleware/rate-limit.js +33 -0
  73. package/dist/domain/intent/pipeline.d.ts +3 -0
  74. package/dist/domain/intent/pipeline.d.ts.map +1 -0
  75. package/dist/domain/intent/pipeline.js +1 -0
  76. package/dist/domain/intent/router.d.ts +6 -0
  77. package/dist/domain/intent/router.d.ts.map +1 -0
  78. package/dist/domain/intent/router.js +1 -0
  79. package/dist/domain/message/errors.d.ts +2 -0
  80. package/dist/domain/message/errors.d.ts.map +1 -0
  81. package/dist/domain/message/errors.js +1 -0
  82. package/dist/domain/message/registries.d.ts +145 -0
  83. package/dist/domain/message/registries.d.ts.map +1 -0
  84. package/dist/domain/message/registries.js +1 -0
  85. package/dist/domain/message/types.d.ts +30 -0
  86. package/dist/domain/message/types.d.ts.map +1 -0
  87. package/dist/domain/message/types.js +1 -0
  88. package/dist/domain/window/config.d.ts +26 -0
  89. package/dist/domain/window/config.d.ts.map +1 -0
  90. package/dist/domain/window/config.js +1 -0
  91. package/dist/domain/window/lifecycle.d.ts +27 -0
  92. package/dist/domain/window/lifecycle.d.ts.map +1 -0
  93. package/dist/domain/window/lifecycle.js +36 -0
  94. package/dist/domain/window/window.d.ts +12 -0
  95. package/dist/domain/window/window.d.ts.map +1 -0
  96. package/dist/domain/window/window.js +1 -0
  97. package/dist/index.d.ts +42 -0
  98. package/dist/index.d.ts.map +1 -0
  99. package/dist/index.js +20 -0
  100. package/dist/ports/kernel-client.d.ts +33 -0
  101. package/dist/ports/kernel-client.d.ts.map +1 -0
  102. package/dist/ports/kernel-client.js +1 -0
  103. package/dist/ports/shell-adapter.d.ts +13 -0
  104. package/dist/ports/shell-adapter.d.ts.map +1 -0
  105. package/dist/ports/shell-adapter.js +1 -0
  106. package/dist/ports/transport.d.ts +7 -0
  107. package/dist/ports/transport.d.ts.map +1 -0
  108. package/dist/ports/transport.js +1 -0
  109. package/package.json +41 -0
  110. package/src/adapters/iframe.shell-adapter.ts +68 -0
  111. package/src/adapters/kernel-client.adapter.ts +138 -0
  112. package/src/adapters/message-port.transport.ts +50 -0
  113. package/src/application/channels/children-channel.ts +112 -0
  114. package/src/application/channels/observer.ts +33 -0
  115. package/src/application/channels/parent-channel.ts +43 -0
  116. package/src/application/channels/types.ts +41 -0
  117. package/src/application/intent/pipeline-runner.ts +36 -0
  118. package/src/application/intent/request.ts +87 -0
  119. package/src/application/intent/router.ts +161 -0
  120. package/src/application/kernel-facade.ts +143 -0
  121. package/src/application/shell.ts +433 -0
  122. package/src/application/windowing/child-init.ts +137 -0
  123. package/src/application/windowing/handshake.ts +98 -0
  124. package/src/application/windowing/lifecycle-controller.ts +40 -0
  125. package/src/application/windowing/mount-controller.ts +335 -0
  126. package/src/application/windowing/protocol.ts +37 -0
  127. package/src/application/windowing/token-refresh.ts +78 -0
  128. package/src/domain/identity/delegation.ts +25 -0
  129. package/src/domain/identity/path.ts +14 -0
  130. package/src/domain/identity/resolve-self.ts +38 -0
  131. package/src/domain/intent/middleware/capabilities.ts +53 -0
  132. package/src/domain/intent/middleware/logging.ts +45 -0
  133. package/src/domain/intent/middleware/rate-limit.ts +54 -0
  134. package/src/domain/intent/pipeline.ts +3 -0
  135. package/src/domain/intent/router.ts +6 -0
  136. package/src/domain/message/errors.ts +9 -0
  137. package/src/domain/message/registries.ts +76 -0
  138. package/src/domain/message/types.ts +32 -0
  139. package/src/domain/window/config.ts +28 -0
  140. package/src/domain/window/lifecycle.ts +39 -0
  141. package/src/domain/window/window.ts +13 -0
  142. package/src/index.ts +103 -0
  143. package/src/ports/kernel-client.ts +32 -0
  144. package/src/ports/shell-adapter.ts +11 -0
  145. package/src/ports/transport.ts +7 -0
@@ -0,0 +1,335 @@
1
+ import type { Window } from '../../domain/window/window'
2
+ import type { KernelClient } from '../../ports/kernel-client'
3
+ import type { ShellAdapter, WindowHandle } from '../../ports/shell-adapter'
4
+ import type { ChildrenChannelController } from '../channels/children-channel'
5
+ import type { MountParams, MountedWindow, CloseResult } from '../shell'
6
+ import type { LifecycleController } from './lifecycle-controller'
7
+
8
+ import { createMessagePortTransport } from '../../adapters/message-port.transport'
9
+ import { runParentHandshake } from './handshake'
10
+ import { createTokenRefresher, type TokenRefresher } from './token-refresh'
11
+
12
+ const DEFAULT_HANDSHAKE_TIMEOUT = 5_000
13
+ const DEFAULT_DELEGATION_TTL = 3_600
14
+ const DEFAULT_CLOSE_TIMEOUT = 3_000
15
+ const PARENT_SENDER_ID = '<parent>'
16
+
17
+ export type MountControllerConfig = {
18
+ adapter: ShellAdapter
19
+ kernel: KernelClient
20
+ kernelUrl: string
21
+ mintIdentity?: string
22
+ childrenChannel: ChildrenChannelController
23
+ windows: Map<string, Window>
24
+ lifecycle: LifecycleController
25
+ }
26
+
27
+ export interface MountController {
28
+ mount(params: MountParams): Promise<MountedWindow>
29
+ close(windowId: string, opts?: { force?: boolean; timeoutMs?: number }): Promise<CloseResult>
30
+ dispose(): Promise<void>
31
+ }
32
+
33
+ type Entry = {
34
+ readonly handle: WindowHandle
35
+ readonly dispose: () => void
36
+ readonly refresher: TokenRefresher
37
+ /** Plain (no-handshake) mount: no transport, no token, no close ack. */
38
+ readonly plain?: boolean
39
+ }
40
+
41
+ /** No-op refresher for plain mounts (no token to refresh). */
42
+ const NOOP_REFRESHER: TokenRefresher = {
43
+ start: () => {},
44
+ stop: () => {},
45
+ forceRefreshNow: () => Promise.resolve(),
46
+ }
47
+
48
+ export function createMountController(config: MountControllerConfig): MountController {
49
+ const { adapter, kernel, kernelUrl, mintIdentity, childrenChannel, windows, lifecycle } = config
50
+ // The child iframe is always a DIFFERENT origin from this parent window, so a
51
+ // relative kernelUrl (e.g. the SPA's same-origin `/api/`) handed across the
52
+ // handshake would resolve against the iframe's OWN origin and POST to the
53
+ // wrong host. Absolutize it against the parent's location once so every child
54
+ // calls THIS origin's kernel. (The parent's own calls keep using the relative
55
+ // form via its kernel client / fetch.)
56
+ const childKernelUrl = new URL(kernelUrl, window.location.href).href
57
+ const entries = new Map<string, Entry>()
58
+ let seq = 0
59
+ let disposed = false
60
+
61
+ function nextWindowId(functionId: string) {
62
+ seq += 1
63
+ const slug = functionId.replace(/[^a-z0-9]/gi, '').slice(-8) || 'fn'
64
+ return `win-${slug}-${seq}`
65
+ }
66
+
67
+ async function mint(ttlSeconds: number) {
68
+ const credential = await kernel.mintDelegation()
69
+ return { credential, expiresAt: Date.now() + ttlSeconds * 1_000 }
70
+ }
71
+
72
+ function originOf(url: string): string {
73
+ try {
74
+ return new URL(url, window.location.href).origin
75
+ } catch {
76
+ throw new Error(`Invalid mount url: ${url}`)
77
+ }
78
+ }
79
+
80
+ // Plain-iframe mount path — for hosted views that don't speak the shell
81
+ // protocol. No handshake, no transport, no delegation token, no refresher;
82
+ // the iframe just renders. `close()` skips the ack request (no channel) and
83
+ // falls straight to dispose.
84
+ async function mountPlain(params: MountParams): Promise<MountedWindow> {
85
+ const windowId = nextWindowId(params.functionId)
86
+ const { handle, dispose: disposeHandle } = adapter.mount({
87
+ functionId: params.functionId,
88
+ isolation: 'sandboxed',
89
+ capabilities: params.capabilities,
90
+ sandbox: params.sandbox,
91
+ })
92
+ if (!(handle.element instanceof HTMLIFrameElement)) {
93
+ disposeHandle()
94
+ throw new Error('Shell adapter did not produce an HTMLIFrameElement')
95
+ }
96
+ params.host.appendChild(handle.element)
97
+ handle.element.src = params.url
98
+
99
+ windows.set(windowId, {
100
+ windowId,
101
+ functionId: params.functionId,
102
+ targetNodeId: params.targetNodeId,
103
+ isolation: 'sandboxed',
104
+ state: 'active',
105
+ delegationToken: null,
106
+ capabilities: params.capabilities,
107
+ })
108
+ lifecycle.register(windowId, 'active')
109
+ entries.set(windowId, {
110
+ handle,
111
+ dispose: disposeHandle,
112
+ refresher: NOOP_REFRESHER,
113
+ plain: true,
114
+ })
115
+
116
+ return {
117
+ windowId,
118
+ get window() {
119
+ return windows.get(windowId)!
120
+ },
121
+ handle: { element: handle.element },
122
+ close: (opts) => controller.close(windowId, opts),
123
+ }
124
+ }
125
+
126
+ const controller: MountController = {
127
+ async mount(params) {
128
+ if (disposed) throw new Error('MountController is disposed')
129
+
130
+ // Plain (no-handshake) path: hosted views that don't speak the shell
131
+ // protocol. Skip mint/handshake/transport/refresher; the iframe still
132
+ // renders and `close()` falls through to dispose.
133
+ if (params.handshake === 'none') return mountPlain(params)
134
+
135
+ const ttlSeconds = params.delegationTtlSeconds ?? DEFAULT_DELEGATION_TTL
136
+ const handshakeTimeout = params.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT
137
+ const expectedOrigin = originOf(params.url)
138
+ const windowId = nextWindowId(params.functionId)
139
+
140
+ // Mint the token and create the iframe in parallel — the iframe
141
+ // doesn't need the credential until the handshake fires.
142
+ const [{ credential, expiresAt }, mounted] = await Promise.all([
143
+ mint(ttlSeconds),
144
+ Promise.resolve(
145
+ adapter.mount({
146
+ functionId: params.functionId,
147
+ isolation: 'sandboxed',
148
+ capabilities: params.capabilities,
149
+ sandbox: params.sandbox,
150
+ }),
151
+ ),
152
+ ])
153
+ const { handle, dispose: disposeHandle } = mounted
154
+
155
+ if (!(handle.element instanceof HTMLIFrameElement)) {
156
+ disposeHandle()
157
+ throw new Error('Shell adapter did not produce an HTMLIFrameElement')
158
+ }
159
+
160
+ params.host.appendChild(handle.element)
161
+ handle.element.src = params.url
162
+
163
+ let handshakeResult
164
+ try {
165
+ handshakeResult = await runParentHandshake({
166
+ iframe: handle.element,
167
+ expectedOrigin,
168
+ windowId,
169
+ delegationToken: credential,
170
+ tokenExpiresAt: expiresAt,
171
+ kernelUrl: childKernelUrl,
172
+ functionId: params.functionId,
173
+ targetNodeId: params.targetNodeId,
174
+ mintIdentity,
175
+ timeoutMs: handshakeTimeout,
176
+ })
177
+ } catch (err) {
178
+ disposeHandle()
179
+ throw err
180
+ }
181
+
182
+ const transport = createMessagePortTransport({
183
+ port: handshakeResult.port,
184
+ onClose: () => {
185
+ if (!entries.has(windowId)) return
186
+ lifecycle.markCrashed(windowId)
187
+ void controller.close(windowId, { force: true }).catch(() => {})
188
+ },
189
+ })
190
+
191
+ childrenChannel.attach(windowId, transport)
192
+ windows.set(windowId, {
193
+ windowId,
194
+ functionId: params.functionId,
195
+ targetNodeId: params.targetNodeId,
196
+ isolation: 'sandboxed',
197
+ state: 'active',
198
+ delegationToken: credential,
199
+ capabilities: params.capabilities,
200
+ })
201
+ lifecycle.register(windowId, 'active')
202
+
203
+ // The live expiry advances on every refresh. The Window store only keeps
204
+ // the credential, so track expiry here — otherwise `token()` keeps
205
+ // returning the original expiry and `schedule()` collapses its lead time
206
+ // to 0, busy-looping `mint()` forever after the first refresh.
207
+ let currentExpiresAt = expiresAt
208
+ const refresher = createTokenRefresher({
209
+ functionId: params.functionId,
210
+ token: () => {
211
+ const stored = windows.get(windowId)
212
+ return {
213
+ credential: stored?.delegationToken ?? credential,
214
+ expiresAt: currentExpiresAt,
215
+ issuedAt: 0,
216
+ scope: { functionId: params.functionId },
217
+ }
218
+ },
219
+ setToken: (next) => {
220
+ currentExpiresAt = next.expiresAt
221
+ const stored = windows.get(windowId)
222
+ if (stored) {
223
+ windows.set(windowId, { ...stored, delegationToken: next.credential })
224
+ }
225
+ childrenChannel.send(windowId, {
226
+ type: 'ctrl',
227
+ version: 1,
228
+ action: 'tokenRefresh',
229
+ data: { delegationToken: next.credential, tokenExpiresAt: next.expiresAt },
230
+ })
231
+ },
232
+ mint: () => mint(ttlSeconds),
233
+ onFailure: () => {
234
+ void controller.close(windowId, { force: false }).catch(() => {})
235
+ },
236
+ })
237
+ refresher.start()
238
+
239
+ entries.set(windowId, { handle, dispose: disposeHandle, refresher })
240
+
241
+ return {
242
+ windowId,
243
+ get window() {
244
+ return windows.get(windowId)!
245
+ },
246
+ handle: { element: handle.element },
247
+ close: (opts) => controller.close(windowId, opts),
248
+ }
249
+ },
250
+
251
+ async close(windowId, opts) {
252
+ const entry = entries.get(windowId)
253
+ if (!entry) return { kind: 'noop' }
254
+
255
+ const timeoutMs = opts?.timeoutMs ?? DEFAULT_CLOSE_TIMEOUT
256
+
257
+ // Plain mounts have no channel to ack a close; always force.
258
+ if (!opts?.force && !entry.plain) {
259
+ // Move the window into `closing` so the state machine accepts the
260
+ // subsequent `closeRefuse`/`forceClose` transition (both are only valid
261
+ // from `closing`).
262
+ lifecycle.apply(windowId, { kind: 'requestClose' })
263
+ const accepted = await requestCloseAck(childrenChannel, windowId, timeoutMs)
264
+ if (accepted.kind === 'refused') {
265
+ lifecycle.apply(windowId, { kind: 'closeRefuse' })
266
+ return { kind: 'refused', reason: accepted.reason }
267
+ }
268
+ }
269
+
270
+ lifecycle.apply(windowId, { kind: 'forceClose' })
271
+ entry.refresher.stop()
272
+ // Drop the entry before detaching: `detach` synchronously closes the
273
+ // transport, whose `onClose` re-enters `close()`. Removing the entry first
274
+ // makes that re-entrant call's `entries.has(windowId)` guard short-circuit,
275
+ // so `entry.dispose()` runs exactly once (adapters need not be idempotent).
276
+ entries.delete(windowId)
277
+ childrenChannel.detach(windowId)
278
+ windows.delete(windowId)
279
+ entry.dispose()
280
+ lifecycle.remove(windowId)
281
+ return { kind: 'closed' }
282
+ },
283
+
284
+ async dispose() {
285
+ if (disposed) return
286
+ disposed = true
287
+ const ids = [...entries.keys()]
288
+ await Promise.all(ids.map((id) => controller.close(id, { force: true })))
289
+ },
290
+ }
291
+
292
+ return controller
293
+ }
294
+
295
+ type CloseAck = { kind: 'ack' } | { kind: 'refused'; reason?: string } | { kind: 'timeout' }
296
+
297
+ async function requestCloseAck(
298
+ children: ChildrenChannelController,
299
+ windowId: string,
300
+ timeoutMs: number,
301
+ ): Promise<CloseAck> {
302
+ return new Promise((resolve) => {
303
+ let settled = false
304
+ const off = children.on('intent', (from, message) => {
305
+ if (from !== windowId) return
306
+ if (message.envelope.name === 'closeAck') {
307
+ settled = true
308
+ off()
309
+ clearTimeout(timer)
310
+ resolve({ kind: 'ack' })
311
+ } else if (message.envelope.name === 'closeRefuse') {
312
+ settled = true
313
+ off()
314
+ clearTimeout(timer)
315
+ const reason = (message.envelope.payload as { reason?: string }).reason
316
+ resolve({ kind: 'refused', reason })
317
+ }
318
+ })
319
+ const timer = setTimeout(() => {
320
+ if (settled) return
321
+ off()
322
+ resolve({ kind: 'timeout' })
323
+ }, timeoutMs)
324
+
325
+ children.send(windowId, {
326
+ type: 'intent',
327
+ version: 1,
328
+ envelope: {
329
+ name: 'willClose',
330
+ payload: {},
331
+ sender: { windowId: PARENT_SENDER_ID },
332
+ },
333
+ })
334
+ })
335
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Out-of-band protocol for window-level postMessage handshake.
3
+ *
4
+ * These messages travel over `window.postMessage` (no MessagePort yet).
5
+ * Once the handshake succeeds, all further communication moves to the
6
+ * transferred MessagePort.
7
+ */
8
+
9
+ export const INIT_REQUEST_TYPE = 'astrale-shell/init-request'
10
+ export const INIT_RESPONSE_TYPE = 'astrale-shell/init-response'
11
+
12
+ export type InitRequestMessage = {
13
+ readonly type: typeof INIT_REQUEST_TYPE
14
+ readonly version: 1
15
+ }
16
+
17
+ export type InitResponseMessage = {
18
+ readonly type: typeof INIT_RESPONSE_TYPE
19
+ readonly version: 1
20
+ readonly windowId: string
21
+ }
22
+
23
+ export function isInitRequest(value: unknown): value is InitRequestMessage {
24
+ return (
25
+ typeof value === 'object' &&
26
+ value !== null &&
27
+ (value as { type?: unknown }).type === INIT_REQUEST_TYPE
28
+ )
29
+ }
30
+
31
+ export function isInitResponse(value: unknown): value is InitResponseMessage {
32
+ return (
33
+ typeof value === 'object' &&
34
+ value !== null &&
35
+ (value as { type?: unknown }).type === INIT_RESPONSE_TYPE
36
+ )
37
+ }
@@ -0,0 +1,78 @@
1
+ import type { DelegationToken } from '../../domain/identity/delegation'
2
+
3
+ import { REFRESH_WINDOW_MS } from '../../domain/identity/delegation'
4
+
5
+ export type TokenRefreshConfig = {
6
+ readonly token: () => DelegationToken
7
+ readonly setToken: (next: DelegationToken) => void
8
+ readonly mint: () => Promise<{ credential: string; expiresAt: number }>
9
+ readonly functionId: string
10
+ readonly onFailure?: (error: unknown) => void
11
+ readonly now?: () => number
12
+ }
13
+
14
+ export interface TokenRefresher {
15
+ start(): void
16
+ stop(): void
17
+ forceRefreshNow(): Promise<void>
18
+ }
19
+
20
+ const BACKOFF_DELAYS_MS = [1_000, 2_000, 4_000] as const
21
+
22
+ export function createTokenRefresher(config: TokenRefreshConfig): TokenRefresher {
23
+ const now = config.now ?? Date.now
24
+ let timer: ReturnType<typeof setTimeout> | null = null
25
+ let stopped = false
26
+
27
+ function schedule() {
28
+ if (stopped) return
29
+ if (timer) clearTimeout(timer)
30
+ const token = config.token()
31
+ const leadTimeMs = Math.max(token.expiresAt - now() - REFRESH_WINDOW_MS, 0)
32
+ timer = setTimeout(() => {
33
+ void runRefresh()
34
+ }, leadTimeMs)
35
+ }
36
+
37
+ async function runRefresh(): Promise<void> {
38
+ for (let attempt = 0; attempt <= BACKOFF_DELAYS_MS.length; attempt += 1) {
39
+ if (stopped) return
40
+ try {
41
+ const next = await config.mint()
42
+ config.setToken({
43
+ credential: next.credential,
44
+ expiresAt: next.expiresAt,
45
+ issuedAt: now(),
46
+ scope: { functionId: config.functionId },
47
+ })
48
+ schedule()
49
+ return
50
+ } catch (err) {
51
+ if (attempt === BACKOFF_DELAYS_MS.length) {
52
+ config.onFailure?.(err)
53
+ return
54
+ }
55
+ await delay(BACKOFF_DELAYS_MS[attempt]!)
56
+ }
57
+ }
58
+ }
59
+
60
+ return {
61
+ start() {
62
+ stopped = false
63
+ schedule()
64
+ },
65
+ stop() {
66
+ stopped = true
67
+ if (timer) clearTimeout(timer)
68
+ timer = null
69
+ },
70
+ async forceRefreshNow() {
71
+ await runRefresh()
72
+ },
73
+ }
74
+ }
75
+
76
+ function delay(ms: number): Promise<void> {
77
+ return new Promise((resolve) => setTimeout(resolve, ms))
78
+ }
@@ -0,0 +1,25 @@
1
+ export type DelegationScope = {
2
+ readonly functionId: string
3
+ }
4
+
5
+ export type DelegationToken = {
6
+ readonly credential: string
7
+ readonly scope: DelegationScope
8
+ readonly issuedAt: number
9
+ readonly expiresAt: number
10
+ }
11
+
12
+ export type TokenState = 'fresh' | 'expiring' | 'expired' | 'refreshing' | 'failed'
13
+
14
+ /** How long before expiration we start proactively refreshing. */
15
+ export const REFRESH_WINDOW_MS = 120_000
16
+
17
+ export function stateOf(token: DelegationToken, now = Date.now()): TokenState {
18
+ if (token.expiresAt <= now) return 'expired'
19
+ if (token.expiresAt - now <= REFRESH_WINDOW_MS) return 'expiring'
20
+ return 'fresh'
21
+ }
22
+
23
+ export function isExpired(token: DelegationToken, now = Date.now()): boolean {
24
+ return token.expiresAt <= now
25
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Built-in kernel root identity, used as the default mint subject when no
3
+ * explicit `mintIdentity` is configured.
4
+ */
5
+ export const SYSTEM_IDENTITY = '__system__'
6
+
7
+ /**
8
+ * Address a method on an identity or path: `@<id>::method` for a bare node id,
9
+ * `<path>::method` for an absolute path (one starting with `/`).
10
+ */
11
+ export function identityMethod(identity: string, method: string): string {
12
+ const target = identity.startsWith('/') ? identity : `@${identity}`
13
+ return `${target}::${method}`
14
+ }
@@ -0,0 +1,38 @@
1
+ import { SYSTEM_IDENTITY } from './path'
2
+
3
+ /** Kernel path that returns the AUTHENTICATED caller's own identity node. */
4
+ export const WHOAMI_PATH = '/:kernel.astrale.ai:interface.Identity:whoami'
5
+
6
+ /**
7
+ * Resolve the mint SUBJECT — the identity node a delegation/credential should be
8
+ * minted for. It MUST be the principal the kernel actually authenticates this
9
+ * caller as, otherwise the minted token impersonates someone else.
10
+ *
11
+ * Why a kernel round-trip and not the local credential: a cookie-fronted SPA
12
+ * holds an EMPTY credential string (the session token lives in an httpOnly
13
+ * cookie it can't read), so the subject cannot be derived locally. We ask the
14
+ * kernel via `whoami`; that request carries the cookie, so the router unseals it
15
+ * and the kernel answers with the logged-in user's node. Without this the shell
16
+ * defaulted every mint to `__system__`, so a View iframe mounted by a logged-in
17
+ * user acted as the kernel ROOT instead of as that user.
18
+ *
19
+ * `fallback` (the configured `mintIdentity`, default `__system__`) is used when
20
+ * whoami can't be resolved — a genuinely system/dev caller, or an unauthenticated
21
+ * boot. The result is cached: the principal is stable for a session.
22
+ */
23
+ export function createSelfResolver(
24
+ fallback: string = SYSTEM_IDENTITY,
25
+ ): (whoami: () => Promise<unknown>) => Promise<string> {
26
+ let cached: string | undefined
27
+ return async (whoami) => {
28
+ if (cached !== undefined) return cached
29
+ try {
30
+ const me = (await whoami()) as { id?: unknown } | null
31
+ const id = me && typeof me.id === 'string' && me.id.length > 0 ? me.id : undefined
32
+ cached = id ?? fallback
33
+ } catch {
34
+ cached = fallback
35
+ }
36
+ return cached
37
+ }
38
+ }
@@ -0,0 +1,53 @@
1
+ import type { IntentRegistry } from '../../message/registries'
2
+ import type { IntentMessage } from '../../message/types'
3
+ import type { WindowCapabilities } from '../../window/config'
4
+ import type { IntentMiddleware } from '../pipeline'
5
+
6
+ export type CapabilityLookup = (sender: string) => WindowCapabilities | undefined
7
+
8
+ export type CapabilityViolation = {
9
+ readonly sender: string
10
+ readonly intent: keyof IntentRegistry
11
+ }
12
+
13
+ export type CapabilitiesConfig = {
14
+ /**
15
+ * Map each sender windowId to its declared capabilities. Intents whose
16
+ * name is absent from the sender's capability list are blocked.
17
+ */
18
+ readonly lookup: CapabilityLookup
19
+
20
+ /**
21
+ * Called when an intent is blocked. The violation can be used to emit
22
+ * an ErrorMessage back to the sender (shell wiring does that separately).
23
+ */
24
+ readonly onViolation?: (violation: CapabilityViolation) => void
25
+
26
+ /**
27
+ * If true, intents originated by the shell itself (sender === selfWindowId)
28
+ * are never filtered. Default: true.
29
+ */
30
+ readonly allowSelf?: boolean
31
+
32
+ readonly selfWindowId?: string
33
+ }
34
+
35
+ export function capabilitiesMiddleware(config: CapabilitiesConfig): IntentMiddleware {
36
+ const { lookup, onViolation, selfWindowId } = config
37
+ const allowSelf = config.allowSelf ?? true
38
+
39
+ return async (message: IntentMessage, next) => {
40
+ const sender = message.envelope.sender.windowId
41
+ if (allowSelf && selfWindowId !== undefined && sender === selfWindowId) {
42
+ await next()
43
+ return
44
+ }
45
+ const caps = lookup(sender)
46
+ const intentName = message.envelope.name
47
+ if (!caps || !caps.intents.includes(intentName)) {
48
+ onViolation?.({ sender, intent: intentName })
49
+ return
50
+ }
51
+ await next()
52
+ }
53
+ }
@@ -0,0 +1,45 @@
1
+ import type { IntentMessage } from '../../message/types'
2
+ import type { IntentMiddleware } from '../pipeline'
3
+
4
+ export type LoggingSink = (entry: LoggingEntry) => void
5
+
6
+ export type LoggingEntry = {
7
+ readonly name: string
8
+ readonly sender: string
9
+ readonly correlationId?: string
10
+ readonly phase: 'in' | 'out'
11
+ readonly timestamp: number
12
+ }
13
+
14
+ export type LoggingConfig = {
15
+ readonly sink?: LoggingSink
16
+ }
17
+
18
+ export function loggingMiddleware(config: LoggingConfig = {}): IntentMiddleware {
19
+ const sink = config.sink ?? defaultSink
20
+
21
+ return async (message: IntentMessage, next) => {
22
+ sink(toEntry(message, 'in'))
23
+ await next()
24
+ sink(toEntry(message, 'out'))
25
+ }
26
+ }
27
+
28
+ function toEntry(message: IntentMessage, phase: 'in' | 'out'): LoggingEntry {
29
+ return {
30
+ name: message.envelope.name as string,
31
+ sender: message.envelope.sender.windowId,
32
+ correlationId: message.envelope.correlationId,
33
+ phase,
34
+ timestamp: Date.now(),
35
+ }
36
+ }
37
+
38
+ function defaultSink(entry: LoggingEntry): void {
39
+ // eslint-disable-next-line no-console -- built-in sink; opt-in via wiring.
40
+ console.debug(
41
+ `[shell.intent] ${entry.phase} ${entry.name} from=${entry.sender}${
42
+ entry.correlationId ? ` corr=${entry.correlationId}` : ''
43
+ }`,
44
+ )
45
+ }
@@ -0,0 +1,54 @@
1
+ import type { IntentMessage } from '../../message/types'
2
+ import type { IntentMiddleware } from '../pipeline'
3
+
4
+ export type RateLimitKey = (message: IntentMessage) => string
5
+
6
+ export type RateLimitViolation = {
7
+ readonly key: string
8
+ readonly intent: string
9
+ }
10
+
11
+ export type RateLimitConfig = {
12
+ readonly windowMs: number
13
+ readonly max: number
14
+ readonly key?: RateLimitKey
15
+ readonly onViolation?: (violation: RateLimitViolation) => void
16
+ }
17
+
18
+ /**
19
+ * Fixed-window rate limiter. An amortized sweep evicts expired buckets on
20
+ * every `windowMs` interval of activity so the map stays bounded by the
21
+ * number of active keys, not the total ever seen.
22
+ */
23
+ export function rateLimitMiddleware(config: RateLimitConfig): IntentMiddleware {
24
+ const { windowMs, max, onViolation } = config
25
+ const key = config.key ?? ((m) => `${m.envelope.sender.windowId}|${m.envelope.name as string}`)
26
+
27
+ type Bucket = { count: number; resetAt: number }
28
+ const buckets = new Map<string, Bucket>()
29
+ let nextSweepAt = 0
30
+
31
+ return async (message, next) => {
32
+ const k = key(message)
33
+ const now = Date.now()
34
+
35
+ if (now >= nextSweepAt) {
36
+ for (const [bucketKey, bucket] of buckets) {
37
+ if (bucket.resetAt <= now) buckets.delete(bucketKey)
38
+ }
39
+ nextSweepAt = now + windowMs
40
+ }
41
+
42
+ let bucket = buckets.get(k)
43
+ if (!bucket || bucket.resetAt <= now) {
44
+ bucket = { count: 0, resetAt: now + windowMs }
45
+ buckets.set(k, bucket)
46
+ }
47
+ bucket.count += 1
48
+ if (bucket.count > max) {
49
+ onViolation?.({ key: k, intent: message.envelope.name as string })
50
+ return
51
+ }
52
+ await next()
53
+ }
54
+ }
@@ -0,0 +1,3 @@
1
+ import type { IntentMessage } from '../message/types'
2
+
3
+ export type IntentMiddleware = (message: IntentMessage, next: () => Promise<void>) => Promise<void>
@@ -0,0 +1,6 @@
1
+ import type { IntentRegistry } from '../message/registries'
2
+ import type { IntentMessage } from '../message/types'
3
+
4
+ export interface IntentRouter {
5
+ dispatch<K extends keyof IntentRegistry>(message: IntentMessage<K>): Promise<void>
6
+ }