@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,43 @@
|
|
|
1
|
+
import type { ShellMessage } from '../../domain/message/types'
|
|
2
|
+
import type { Transport } from '../../ports/transport'
|
|
3
|
+
import type { MessageObserver } from './observer'
|
|
4
|
+
import type { MessageHandler, MessageOf, MessageType } from './types'
|
|
5
|
+
|
|
6
|
+
import { createMessageListeners } from './types'
|
|
7
|
+
|
|
8
|
+
export interface ParentChannel {
|
|
9
|
+
send(message: ShellMessage): void
|
|
10
|
+
on<T extends MessageType>(type: T, handler: MessageHandler<MessageOf<T>>): () => void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ParentChannelController extends ParentChannel {
|
|
14
|
+
dispose(): void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type ParentChannelConfig = {
|
|
18
|
+
transport: Transport
|
|
19
|
+
observer: MessageObserver
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createParentChannel(config: ParentChannelConfig): ParentChannelController {
|
|
23
|
+
const { transport, observer } = config
|
|
24
|
+
const listeners = createMessageListeners()
|
|
25
|
+
|
|
26
|
+
transport.onMessage((message) => {
|
|
27
|
+
observer.notify('inbound', message)
|
|
28
|
+
listeners.dispatch(message)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
send(message) {
|
|
33
|
+
observer.notify('outbound', message)
|
|
34
|
+
transport.send(message)
|
|
35
|
+
},
|
|
36
|
+
on(type, handler) {
|
|
37
|
+
return listeners.on(type, handler)
|
|
38
|
+
},
|
|
39
|
+
dispose() {
|
|
40
|
+
transport.close()
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ShellMessage } from '../../domain/message/types'
|
|
2
|
+
|
|
3
|
+
export type MessageHandler<M extends ShellMessage = ShellMessage> = (message: M) => void
|
|
4
|
+
|
|
5
|
+
export type MessageType = ShellMessage['type']
|
|
6
|
+
|
|
7
|
+
export type MessageOf<T extends MessageType> = Extract<ShellMessage, { type: T }>
|
|
8
|
+
|
|
9
|
+
export interface MessageListeners {
|
|
10
|
+
on<T extends MessageType>(type: T, handler: MessageHandler<MessageOf<T>>): () => void
|
|
11
|
+
dispatch(message: ShellMessage): void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createMessageListeners(): MessageListeners {
|
|
15
|
+
const byType = new Map<MessageType, Set<MessageHandler>>()
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
on(type, handler) {
|
|
19
|
+
let bucket = byType.get(type)
|
|
20
|
+
if (!bucket) {
|
|
21
|
+
bucket = new Set()
|
|
22
|
+
byType.set(type, bucket)
|
|
23
|
+
}
|
|
24
|
+
bucket.add(handler as MessageHandler)
|
|
25
|
+
return () => {
|
|
26
|
+
bucket.delete(handler as MessageHandler)
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
dispatch(message) {
|
|
30
|
+
const bucket = byType.get(message.type)
|
|
31
|
+
if (!bucket || bucket.size === 0) return
|
|
32
|
+
for (const handler of bucket) {
|
|
33
|
+
try {
|
|
34
|
+
handler(message)
|
|
35
|
+
} catch {
|
|
36
|
+
// one rogue handler must not break siblings
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { IntentMiddleware } from '../../domain/intent/pipeline'
|
|
2
|
+
import type { IntentMessage } from '../../domain/message/types'
|
|
3
|
+
|
|
4
|
+
export interface IntentPipeline {
|
|
5
|
+
use(middleware: IntentMiddleware): void
|
|
6
|
+
run(message: IntentMessage, terminate: () => Promise<void>): Promise<void>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Composable middleware runner. Each middleware may transform, short-circuit,
|
|
11
|
+
* or wrap the call to `next`. `terminate` is the innermost handler (router).
|
|
12
|
+
*/
|
|
13
|
+
export function createIntentPipeline(): IntentPipeline {
|
|
14
|
+
const middlewares: IntentMiddleware[] = []
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
use(middleware) {
|
|
18
|
+
middlewares.push(middleware)
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
async run(message, terminate) {
|
|
22
|
+
let i = -1
|
|
23
|
+
const dispatch = async (idx: number): Promise<void> => {
|
|
24
|
+
if (idx <= i) throw new Error('next() called multiple times')
|
|
25
|
+
i = idx
|
|
26
|
+
if (idx === middlewares.length) {
|
|
27
|
+
await terminate()
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
const mw = middlewares[idx]!
|
|
31
|
+
await mw(message, () => dispatch(idx + 1))
|
|
32
|
+
}
|
|
33
|
+
await dispatch(0)
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { NO_PERMS } from '@astrale-os/kernel-core/permissions'
|
|
2
|
+
|
|
3
|
+
import type { IntentRegistry } from '../../domain/message/registries'
|
|
4
|
+
import type { IntentMessage } from '../../domain/message/types'
|
|
5
|
+
import type { ChildrenChannel } from '../channels/children-channel'
|
|
6
|
+
import type { ParentChannel } from '../channels/parent-channel'
|
|
7
|
+
import type { Shell } from '../shell'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Request/reply over the otherwise fire-and-forget intent transport.
|
|
11
|
+
*
|
|
12
|
+
* The child raises `name` with a generated `correlationId` and awaits the
|
|
13
|
+
* `intentReply` whose payload echoes that id. The reply also re-enters the
|
|
14
|
+
* child router as an inbound parent intent, but no handler is registered for
|
|
15
|
+
* `intentReply`, so it is harmlessly dropped there — only this resolver,
|
|
16
|
+
* matching by `correlationId`, consumes it.
|
|
17
|
+
*
|
|
18
|
+
* No timeout by design: callers (e.g. `requestAccess`) wait on a human, and
|
|
19
|
+
* the parent always replies on every path. A dead parent means the view is
|
|
20
|
+
* gone too, so a never-resolving promise cannot strand a live view.
|
|
21
|
+
*/
|
|
22
|
+
export function requestIntent<K extends keyof IntentRegistry>(
|
|
23
|
+
parent: ParentChannel,
|
|
24
|
+
name: K,
|
|
25
|
+
payload: IntentRegistry[K]['payload'],
|
|
26
|
+
): Promise<IntentRegistry[K]['response']> {
|
|
27
|
+
const correlationId = crypto.randomUUID()
|
|
28
|
+
return new Promise((resolve) => {
|
|
29
|
+
const off = parent.on('intent', (message) => {
|
|
30
|
+
if (message.envelope.name !== 'intentReply') return
|
|
31
|
+
const reply = message.envelope.payload as IntentRegistry['intentReply']['payload']
|
|
32
|
+
if (reply.correlationId !== correlationId) return
|
|
33
|
+
off()
|
|
34
|
+
resolve(reply.result as IntentRegistry[K]['response'])
|
|
35
|
+
})
|
|
36
|
+
parent.send({
|
|
37
|
+
type: 'intent',
|
|
38
|
+
version: 1,
|
|
39
|
+
envelope: { name, payload, sender: { windowId: 'self' }, correlationId },
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Parent-side counterpart to `requestIntent`: deliver `result` back to the
|
|
46
|
+
* originating child window, keyed by the request's `correlationId`. No-op for
|
|
47
|
+
* a request that carried no `correlationId` (a fire-and-forget intent), or if
|
|
48
|
+
* the child window has since detached (best-effort, drops silently).
|
|
49
|
+
*/
|
|
50
|
+
export function replyToIntent<K extends keyof IntentRegistry>(
|
|
51
|
+
children: ChildrenChannel,
|
|
52
|
+
windowId: string,
|
|
53
|
+
request: IntentMessage<K>,
|
|
54
|
+
result: IntentRegistry[K]['response'],
|
|
55
|
+
): void {
|
|
56
|
+
const correlationId = request.envelope.correlationId
|
|
57
|
+
if (!correlationId) return
|
|
58
|
+
children.send(windowId, {
|
|
59
|
+
type: 'intent',
|
|
60
|
+
version: 1,
|
|
61
|
+
envelope: {
|
|
62
|
+
name: 'intentReply',
|
|
63
|
+
payload: { correlationId, result },
|
|
64
|
+
sender: { windowId: 'root' },
|
|
65
|
+
},
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* View SDK convenience: ask the user (via the root shell) to grant `perms` on
|
|
71
|
+
* `node` to `identity`. Resolves with the bitmask actually granted (0 if the
|
|
72
|
+
* user declined or there was no parent to ask). The view typically passes its
|
|
73
|
+
* own identity (from `whoami`) as `identity`.
|
|
74
|
+
*/
|
|
75
|
+
export function requestAccess(
|
|
76
|
+
shell: Shell,
|
|
77
|
+
opts: IntentRegistry['requestAccess']['payload'],
|
|
78
|
+
): Promise<IntentRegistry['requestAccess']['response']> {
|
|
79
|
+
if (!shell.parent) return Promise.resolve({ granted: false, perms: NO_PERMS })
|
|
80
|
+
// A sandboxed shell's `parent` is a proxy that throws until `init()` resolves;
|
|
81
|
+
// honour the documented "no parent to ask" fallback instead of surfacing that
|
|
82
|
+
// opaque rejection to the caller.
|
|
83
|
+
return requestIntent(shell.parent, 'requestAccess', opts).catch(() => ({
|
|
84
|
+
granted: false,
|
|
85
|
+
perms: NO_PERMS,
|
|
86
|
+
}))
|
|
87
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import type { IntentRegistry } from '../../domain/message/registries'
|
|
2
|
+
import type { IntentMessage } from '../../domain/message/types'
|
|
3
|
+
import type { Window } from '../../domain/window/window'
|
|
4
|
+
import type { ChildrenChannelController } from '../channels/children-channel'
|
|
5
|
+
import type { ParentChannel } from '../channels/parent-channel'
|
|
6
|
+
import type { IntentPipeline } from './pipeline-runner'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Decision produced by a routing policy: where an inbound intent should go.
|
|
10
|
+
*
|
|
11
|
+
* - `local`: the shell (entry point or sandboxed parent) handles it itself.
|
|
12
|
+
* - `parent`: forward upstream (only valid for sandboxed children).
|
|
13
|
+
* - `child`: forward to a specific child window.
|
|
14
|
+
* - `drop`: discard silently (e.g., handled fully by a middleware).
|
|
15
|
+
*/
|
|
16
|
+
export type IntentDecision<K extends keyof IntentRegistry = keyof IntentRegistry> =
|
|
17
|
+
| { kind: 'local'; message: IntentMessage<K> }
|
|
18
|
+
| { kind: 'parent'; message: IntentMessage<K> }
|
|
19
|
+
| { kind: 'child'; windowId: string; message: IntentMessage<K> }
|
|
20
|
+
| { kind: 'drop' }
|
|
21
|
+
|
|
22
|
+
export type IntentOrigin = 'parent' | 'child' | 'self'
|
|
23
|
+
|
|
24
|
+
export type IntentPolicy = (
|
|
25
|
+
message: IntentMessage,
|
|
26
|
+
ctx: IntentContext,
|
|
27
|
+
) => IntentDecision | Promise<IntentDecision>
|
|
28
|
+
|
|
29
|
+
export interface IntentContext {
|
|
30
|
+
readonly selfWindowId: string
|
|
31
|
+
readonly origin: IntentOrigin
|
|
32
|
+
readonly windows: ReadonlyMap<string, Window>
|
|
33
|
+
readonly hasParent: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type LocalIntentHandler<K extends keyof IntentRegistry = keyof IntentRegistry> = (
|
|
37
|
+
message: IntentMessage<K>,
|
|
38
|
+
) => void | Promise<void>
|
|
39
|
+
|
|
40
|
+
export interface IntentRouterController {
|
|
41
|
+
/**
|
|
42
|
+
* Route an inbound intent. `senderWindowId`, when provided, is the trusted
|
|
43
|
+
* windowId the channel authenticated; it overrides the (forgeable)
|
|
44
|
+
* `envelope.sender.windowId` before the pipeline runs.
|
|
45
|
+
*/
|
|
46
|
+
handleInbound(
|
|
47
|
+
origin: IntentOrigin,
|
|
48
|
+
message: IntentMessage,
|
|
49
|
+
senderWindowId?: string,
|
|
50
|
+
): Promise<void>
|
|
51
|
+
dispatchLocal(message: IntentMessage): Promise<void>
|
|
52
|
+
setPolicy(policy: IntentPolicy): void
|
|
53
|
+
onLocal<K extends keyof IntentRegistry>(name: K, handler: LocalIntentHandler<K>): () => void
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type IntentRouterConfig = {
|
|
57
|
+
selfWindowId: string
|
|
58
|
+
pipeline: IntentPipeline
|
|
59
|
+
parent: ParentChannel | null
|
|
60
|
+
children: ChildrenChannelController
|
|
61
|
+
windows: ReadonlyMap<string, Window>
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Default policy:
|
|
66
|
+
* - intent arrives from a child → handle locally
|
|
67
|
+
* - intent arrives from the parent → handle locally
|
|
68
|
+
* - intent initiated locally (self) → send up to parent if we have one,
|
|
69
|
+
* otherwise keep it local
|
|
70
|
+
*/
|
|
71
|
+
function defaultPolicy(): IntentPolicy {
|
|
72
|
+
return (message, ctx) => {
|
|
73
|
+
if (ctx.origin === 'self') {
|
|
74
|
+
return ctx.hasParent ? { kind: 'parent', message } : { kind: 'local', message }
|
|
75
|
+
}
|
|
76
|
+
return { kind: 'local', message }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Return a copy of `message` with a trusted `sender.windowId`. */
|
|
81
|
+
function withSender(message: IntentMessage, windowId: string): IntentMessage {
|
|
82
|
+
return { ...message, envelope: { ...message.envelope, sender: { windowId } } }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function createIntentRouter(config: IntentRouterConfig): IntentRouterController {
|
|
86
|
+
const { pipeline, parent, children, windows, selfWindowId } = config
|
|
87
|
+
let policy: IntentPolicy = defaultPolicy()
|
|
88
|
+
const localHandlers = new Map<string, Set<LocalIntentHandler>>()
|
|
89
|
+
|
|
90
|
+
async function forward(decision: IntentDecision): Promise<void> {
|
|
91
|
+
switch (decision.kind) {
|
|
92
|
+
case 'drop':
|
|
93
|
+
return
|
|
94
|
+
case 'parent':
|
|
95
|
+
if (!parent) return
|
|
96
|
+
parent.send(decision.message)
|
|
97
|
+
return
|
|
98
|
+
case 'child':
|
|
99
|
+
if (!children.has(decision.windowId)) return
|
|
100
|
+
children.send(decision.windowId, decision.message)
|
|
101
|
+
return
|
|
102
|
+
case 'local': {
|
|
103
|
+
const bucket = localHandlers.get(decision.message.envelope.name)
|
|
104
|
+
if (!bucket) return
|
|
105
|
+
for (const handler of bucket) {
|
|
106
|
+
try {
|
|
107
|
+
await handler(decision.message)
|
|
108
|
+
} catch {
|
|
109
|
+
// Handler errors must not interrupt sibling handlers.
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const ctxBase = {
|
|
118
|
+
selfWindowId,
|
|
119
|
+
windows,
|
|
120
|
+
hasParent: parent !== null,
|
|
121
|
+
} as const
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
async handleInbound(origin, message, senderWindowId) {
|
|
125
|
+
// `envelope.sender.windowId` is attacker-controlled wire data: a sandboxed
|
|
126
|
+
// child can claim any id. Overwrite it with the windowId the channel
|
|
127
|
+
// authenticated so capability/rate-limit middleware can't be bypassed by a
|
|
128
|
+
// forged sender. The original message object is left untouched for raw
|
|
129
|
+
// channel listeners; only this routed copy carries the trusted id.
|
|
130
|
+
const inbound = senderWindowId === undefined ? message : withSender(message, senderWindowId)
|
|
131
|
+
await pipeline.run(inbound, async () => {
|
|
132
|
+
const decision = await policy(inbound, { ...ctxBase, origin })
|
|
133
|
+
await forward(decision)
|
|
134
|
+
})
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
async dispatchLocal(message) {
|
|
138
|
+
const stamped = withSender(message, selfWindowId)
|
|
139
|
+
await pipeline.run(stamped, async () => {
|
|
140
|
+
const decision = await policy(stamped, { ...ctxBase, origin: 'self' })
|
|
141
|
+
await forward(decision)
|
|
142
|
+
})
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
setPolicy(next) {
|
|
146
|
+
policy = next
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
onLocal(name, handler) {
|
|
150
|
+
let bucket = localHandlers.get(name as string)
|
|
151
|
+
if (!bucket) {
|
|
152
|
+
bucket = new Set()
|
|
153
|
+
localHandlers.set(name as string, bucket)
|
|
154
|
+
}
|
|
155
|
+
bucket.add(handler as LocalIntentHandler)
|
|
156
|
+
return () => {
|
|
157
|
+
bucket.delete(handler as LocalIntentHandler)
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type { KernelClientAdapter } from '../adapters/kernel-client.adapter'
|
|
2
|
+
import type { DelegationToken } from '../domain/identity/delegation'
|
|
3
|
+
import type { KernelClient } from '../ports/kernel-client'
|
|
4
|
+
|
|
5
|
+
import { identityMethod } from '../domain/identity/path'
|
|
6
|
+
import { createSelfResolver, WHOAMI_PATH } from '../domain/identity/resolve-self'
|
|
7
|
+
|
|
8
|
+
// ─── Base facade (standalone / user credential) ─────────────────────────────
|
|
9
|
+
|
|
10
|
+
export type KernelFacadeConfig = {
|
|
11
|
+
adapter: KernelClientAdapter
|
|
12
|
+
credential?: string
|
|
13
|
+
getCredential?: () => string
|
|
14
|
+
/** Kernel URL — exposed to consumers via `KernelClient.url`. */
|
|
15
|
+
url: string
|
|
16
|
+
/**
|
|
17
|
+
* Explicit identity node to use as the mint subject. When omitted, the mint
|
|
18
|
+
* resolves the AUTHENTICATED caller via `whoami` (so a cookie-fronted GUI mints
|
|
19
|
+
* as the logged-in user, not the kernel root). Set this only to pin a subject.
|
|
20
|
+
*/
|
|
21
|
+
mintIdentity?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface KernelFacadeController extends KernelClient {
|
|
25
|
+
dispose(): void
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createKernelFacade(config: KernelFacadeConfig): KernelFacadeController {
|
|
29
|
+
const getCredential =
|
|
30
|
+
config.getCredential ??
|
|
31
|
+
(() => {
|
|
32
|
+
if (config.credential === undefined) {
|
|
33
|
+
throw new Error('KernelFacade requires credential or getCredential')
|
|
34
|
+
}
|
|
35
|
+
return config.credential
|
|
36
|
+
})
|
|
37
|
+
return buildCredentialFacade({
|
|
38
|
+
adapter: config.adapter,
|
|
39
|
+
getCredential,
|
|
40
|
+
url: config.url,
|
|
41
|
+
mintIdentity: config.mintIdentity,
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ─── Delegation facade (sandboxed mode) ─────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
export type DelegationKernelFacadeConfig = {
|
|
48
|
+
adapter: KernelClientAdapter
|
|
49
|
+
initialToken: DelegationToken
|
|
50
|
+
url: string
|
|
51
|
+
onTokenChange?: (token: DelegationToken) => void
|
|
52
|
+
mintIdentity?: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface DelegationKernelFacadeController extends KernelFacadeController {
|
|
56
|
+
token(): DelegationToken
|
|
57
|
+
setToken(next: DelegationToken): void
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function createDelegationKernelFacade(
|
|
61
|
+
config: DelegationKernelFacadeConfig,
|
|
62
|
+
): DelegationKernelFacadeController {
|
|
63
|
+
let token = config.initialToken
|
|
64
|
+
const base = buildCredentialFacade({
|
|
65
|
+
adapter: config.adapter,
|
|
66
|
+
getCredential: () => token.credential,
|
|
67
|
+
url: config.url,
|
|
68
|
+
mintIdentity: config.mintIdentity,
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
...base,
|
|
73
|
+
token() {
|
|
74
|
+
return token
|
|
75
|
+
},
|
|
76
|
+
setToken(next) {
|
|
77
|
+
if (next.credential === token.credential && next.expiresAt === token.expiresAt) return
|
|
78
|
+
token = next
|
|
79
|
+
config.onTokenChange?.(next)
|
|
80
|
+
},
|
|
81
|
+
// The delegation facade owns its adapter exclusively (created per child in
|
|
82
|
+
// `performChildInit`), so disposing it must tear the adapter down — unlike
|
|
83
|
+
// the standalone facade, where the caller owns the adapter's lifetime.
|
|
84
|
+
dispose() {
|
|
85
|
+
config.adapter.disconnect()
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ─── Shared facade builder ──────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
type FacadeParams = {
|
|
93
|
+
adapter: KernelClientAdapter
|
|
94
|
+
getCredential: () => string
|
|
95
|
+
url: string
|
|
96
|
+
/** Explicit mint subject. When omitted, the authenticated caller is resolved via `whoami`. */
|
|
97
|
+
mintIdentity?: string
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* v1 note: we mint a fresh identity credential (grant-bearing, directly
|
|
102
|
+
* usable). Proper delegation composition (outer envelope via `Cred(...)`)
|
|
103
|
+
* requires SDK v2 — tracked in `.dev/todo/shell-sdk-v2-delegation.md`.
|
|
104
|
+
*/
|
|
105
|
+
function buildCredentialFacade(params: FacadeParams): KernelFacadeController {
|
|
106
|
+
const { adapter, getCredential, mintIdentity, url } = params
|
|
107
|
+
const resolveSelf = createSelfResolver()
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
url,
|
|
111
|
+
|
|
112
|
+
call(path, callParams) {
|
|
113
|
+
return adapter.call(path, callParams, getCredential())
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
async mintDelegation() {
|
|
117
|
+
const subject =
|
|
118
|
+
mintIdentity ?? (await resolveSelf(() => adapter.call(WHOAMI_PATH, {}, getCredential())))
|
|
119
|
+
const result = await adapter.call(
|
|
120
|
+
identityMethod(subject, 'mintIdentityCredential'),
|
|
121
|
+
{},
|
|
122
|
+
getCredential(),
|
|
123
|
+
)
|
|
124
|
+
if (typeof result === 'string') return result
|
|
125
|
+
if (
|
|
126
|
+
result !== null &&
|
|
127
|
+
typeof result === 'object' &&
|
|
128
|
+
typeof (result as { credential?: unknown }).credential === 'string'
|
|
129
|
+
) {
|
|
130
|
+
return (result as { credential: string }).credential
|
|
131
|
+
}
|
|
132
|
+
throw new Error('mintIdentityCredential returned an unexpected shape')
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
authToken() {
|
|
136
|
+
return getCredential()
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
dispose() {
|
|
140
|
+
// adapter lifetime is owned by the caller
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
}
|