@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,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
+ }