@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.
- package/dist/adapters/iframe.shell-adapter.d.ts +10 -0
- package/dist/adapters/iframe.shell-adapter.d.ts.map +1 -0
- package/dist/adapters/iframe.shell-adapter.js +55 -0
- package/dist/adapters/kernel-client.adapter.d.ts +53 -0
- package/dist/adapters/kernel-client.adapter.d.ts.map +1 -0
- package/dist/adapters/kernel-client.adapter.js +69 -0
- package/dist/adapters/message-port.transport.d.ts +7 -0
- package/dist/adapters/message-port.transport.d.ts.map +1 -0
- package/dist/adapters/message-port.transport.js +42 -0
- package/dist/application/channels/children-channel.d.ts +21 -0
- package/dist/application/channels/children-channel.d.ts.map +1 -0
- package/dist/application/channels/children-channel.js +82 -0
- package/dist/application/channels/observer.d.ts +9 -0
- package/dist/application/channels/observer.d.ts.map +1 -0
- package/dist/application/channels/observer.js +23 -0
- package/dist/application/channels/parent-channel.d.ts +17 -0
- package/dist/application/channels/parent-channel.d.ts.map +1 -0
- package/dist/application/channels/parent-channel.js +21 -0
- package/dist/application/channels/types.d.ts +12 -0
- package/dist/application/channels/types.d.ts.map +1 -0
- package/dist/application/channels/types.js +29 -0
- package/dist/application/intent/pipeline-runner.d.ts +12 -0
- package/dist/application/intent/pipeline-runner.d.ts.map +1 -0
- package/dist/application/intent/pipeline-runner.js +27 -0
- package/dist/application/intent/request.d.ts +34 -0
- package/dist/application/intent/request.d.ts.map +1 -0
- package/dist/application/intent/request.js +70 -0
- package/dist/application/intent/router.d.ts +56 -0
- package/dist/application/intent/router.d.ts.map +1 -0
- package/dist/application/intent/router.js +94 -0
- package/dist/application/kernel-facade.d.ts +33 -0
- package/dist/application/kernel-facade.d.ts.map +1 -0
- package/dist/application/kernel-facade.js +77 -0
- package/dist/application/shell.d.ts +100 -0
- package/dist/application/shell.d.ts.map +1 -0
- package/dist/application/shell.js +256 -0
- package/dist/application/windowing/child-init.d.ts +23 -0
- package/dist/application/windowing/child-init.d.ts.map +1 -0
- package/dist/application/windowing/child-init.js +101 -0
- package/dist/application/windowing/handshake.d.ts +30 -0
- package/dist/application/windowing/handshake.d.ts.map +1 -0
- package/dist/application/windowing/handshake.js +63 -0
- package/dist/application/windowing/lifecycle-controller.d.ts +10 -0
- package/dist/application/windowing/lifecycle-controller.d.ts.map +1 -0
- package/dist/application/windowing/lifecycle-controller.js +30 -0
- package/dist/application/windowing/mount-controller.d.ts +25 -0
- package/dist/application/windowing/mount-controller.d.ts.map +1 -0
- package/dist/application/windowing/mount-controller.js +277 -0
- package/dist/application/windowing/protocol.d.ts +21 -0
- package/dist/application/windowing/protocol.d.ts.map +1 -0
- package/dist/application/windowing/protocol.js +19 -0
- package/dist/application/windowing/token-refresh.d.ts +19 -0
- package/dist/application/windowing/token-refresh.d.ts.map +1 -0
- package/dist/application/windowing/token-refresh.js +60 -0
- package/dist/domain/identity/delegation.d.ts +15 -0
- package/dist/domain/identity/delegation.d.ts.map +1 -0
- package/dist/domain/identity/delegation.js +12 -0
- package/dist/domain/identity/path.d.ts +11 -0
- package/dist/domain/identity/path.d.ts.map +1 -0
- package/dist/domain/identity/path.js +13 -0
- package/dist/domain/identity/resolve-self.d.ts +21 -0
- package/dist/domain/identity/resolve-self.d.ts.map +1 -0
- package/dist/domain/identity/resolve-self.js +36 -0
- package/dist/domain/intent/middleware/capabilities.d.ts +28 -0
- package/dist/domain/intent/middleware/capabilities.d.ts.map +1 -0
- package/dist/domain/intent/middleware/capabilities.js +18 -0
- package/dist/domain/intent/middleware/logging.d.ts +14 -0
- package/dist/domain/intent/middleware/logging.d.ts.map +1 -0
- package/dist/domain/intent/middleware/logging.js +21 -0
- package/dist/domain/intent/middleware/rate-limit.d.ts +20 -0
- package/dist/domain/intent/middleware/rate-limit.d.ts.map +1 -0
- package/dist/domain/intent/middleware/rate-limit.js +33 -0
- package/dist/domain/intent/pipeline.d.ts +3 -0
- package/dist/domain/intent/pipeline.d.ts.map +1 -0
- package/dist/domain/intent/pipeline.js +1 -0
- package/dist/domain/intent/router.d.ts +6 -0
- package/dist/domain/intent/router.d.ts.map +1 -0
- package/dist/domain/intent/router.js +1 -0
- package/dist/domain/message/errors.d.ts +2 -0
- package/dist/domain/message/errors.d.ts.map +1 -0
- package/dist/domain/message/errors.js +1 -0
- package/dist/domain/message/registries.d.ts +145 -0
- package/dist/domain/message/registries.d.ts.map +1 -0
- package/dist/domain/message/registries.js +1 -0
- package/dist/domain/message/types.d.ts +30 -0
- package/dist/domain/message/types.d.ts.map +1 -0
- package/dist/domain/message/types.js +1 -0
- package/dist/domain/window/config.d.ts +26 -0
- package/dist/domain/window/config.d.ts.map +1 -0
- package/dist/domain/window/config.js +1 -0
- package/dist/domain/window/lifecycle.d.ts +27 -0
- package/dist/domain/window/lifecycle.d.ts.map +1 -0
- package/dist/domain/window/lifecycle.js +36 -0
- package/dist/domain/window/window.d.ts +12 -0
- package/dist/domain/window/window.d.ts.map +1 -0
- package/dist/domain/window/window.js +1 -0
- package/dist/index.d.ts +42 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/ports/kernel-client.d.ts +33 -0
- package/dist/ports/kernel-client.d.ts.map +1 -0
- package/dist/ports/kernel-client.js +1 -0
- package/dist/ports/shell-adapter.d.ts +13 -0
- package/dist/ports/shell-adapter.d.ts.map +1 -0
- package/dist/ports/shell-adapter.js +1 -0
- package/dist/ports/transport.d.ts +7 -0
- package/dist/ports/transport.d.ts.map +1 -0
- package/dist/ports/transport.js +1 -0
- package/package.json +41 -0
- package/src/adapters/iframe.shell-adapter.ts +68 -0
- package/src/adapters/kernel-client.adapter.ts +138 -0
- package/src/adapters/message-port.transport.ts +50 -0
- package/src/application/channels/children-channel.ts +112 -0
- package/src/application/channels/observer.ts +33 -0
- package/src/application/channels/parent-channel.ts +43 -0
- package/src/application/channels/types.ts +41 -0
- package/src/application/intent/pipeline-runner.ts +36 -0
- package/src/application/intent/request.ts +87 -0
- package/src/application/intent/router.ts +161 -0
- package/src/application/kernel-facade.ts +143 -0
- package/src/application/shell.ts +433 -0
- package/src/application/windowing/child-init.ts +137 -0
- package/src/application/windowing/handshake.ts +98 -0
- package/src/application/windowing/lifecycle-controller.ts +40 -0
- package/src/application/windowing/mount-controller.ts +335 -0
- package/src/application/windowing/protocol.ts +37 -0
- package/src/application/windowing/token-refresh.ts +78 -0
- package/src/domain/identity/delegation.ts +25 -0
- package/src/domain/identity/path.ts +14 -0
- package/src/domain/identity/resolve-self.ts +38 -0
- package/src/domain/intent/middleware/capabilities.ts +53 -0
- package/src/domain/intent/middleware/logging.ts +45 -0
- package/src/domain/intent/middleware/rate-limit.ts +54 -0
- package/src/domain/intent/pipeline.ts +3 -0
- package/src/domain/intent/router.ts +6 -0
- package/src/domain/message/errors.ts +9 -0
- package/src/domain/message/registries.ts +76 -0
- package/src/domain/message/types.ts +32 -0
- package/src/domain/window/config.ts +28 -0
- package/src/domain/window/lifecycle.ts +39 -0
- package/src/domain/window/window.ts +13 -0
- package/src/index.ts +103 -0
- package/src/ports/kernel-client.ts +32 -0
- package/src/ports/shell-adapter.ts +11 -0
- 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
|
+
}
|