@astrale-os/sdk 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 (50) hide show
  1. package/README.md +42 -0
  2. package/package.json +101 -0
  3. package/src/auth/authenticate.ts +51 -0
  4. package/src/auth/check.ts +73 -0
  5. package/src/auth/compose.ts +31 -0
  6. package/src/auth/errors.ts +32 -0
  7. package/src/auth/identity.ts +15 -0
  8. package/src/auth/index.ts +11 -0
  9. package/src/auth/kernel-client.ts +107 -0
  10. package/src/auth/resolve.ts +63 -0
  11. package/src/auth/sign.ts +36 -0
  12. package/src/auth/verify.ts +138 -0
  13. package/src/define/index.ts +9 -0
  14. package/src/define/remote-function.ts +96 -0
  15. package/src/define/view.ts +91 -0
  16. package/src/deploy/check.ts +124 -0
  17. package/src/deploy/hash-spec.ts +31 -0
  18. package/src/deploy/index.ts +3 -0
  19. package/src/deploy/meta.ts +25 -0
  20. package/src/dispatch/authorize.ts +29 -0
  21. package/src/dispatch/call-remote.ts +48 -0
  22. package/src/dispatch/dispatcher.ts +257 -0
  23. package/src/dispatch/errors.ts +94 -0
  24. package/src/dispatch/execute.ts +51 -0
  25. package/src/dispatch/identity.ts +148 -0
  26. package/src/dispatch/index.ts +17 -0
  27. package/src/dispatch/resolve.ts +78 -0
  28. package/src/dispatch/self.ts +32 -0
  29. package/src/dispatch/validate.ts +41 -0
  30. package/src/domain/build-spec.ts +127 -0
  31. package/src/domain/contract.ts +41 -0
  32. package/src/domain/define.ts +168 -0
  33. package/src/domain/extend-core.ts +287 -0
  34. package/src/domain/index.ts +4 -0
  35. package/src/index.ts +77 -0
  36. package/src/method/class.ts +148 -0
  37. package/src/method/context.ts +45 -0
  38. package/src/method/index.ts +5 -0
  39. package/src/method/single.ts +133 -0
  40. package/src/server/auxiliary-routes.ts +336 -0
  41. package/src/server/config.ts +85 -0
  42. package/src/server/create.ts +249 -0
  43. package/src/server/handle.ts +37 -0
  44. package/src/server/index.ts +10 -0
  45. package/src/server/jwks.ts +18 -0
  46. package/src/server/require-env.ts +19 -0
  47. package/src/server/serving-url.ts +28 -0
  48. package/src/server/start.ts +37 -0
  49. package/src/server/worker-entry.ts +122 -0
  50. package/src/server/worker-meta.ts +25 -0
@@ -0,0 +1,336 @@
1
+ /**
2
+ * Mount worker-side routes for `defineView` and `defineRemoteFunction` entries.
3
+ *
4
+ * Each entry's effective `FunctionBinding` is resolved once at boot (host
5
+ * pattern, path, http verb) and a Hono route is registered that runs the
6
+ * shared SDK auth pipeline (verify inbound credential, optionally enforce /
7
+ * optional / public), Zod validation of input AND output (RemoteFunction
8
+ * only — Views are transport-only), the author's `authorize` hook, and
9
+ * finally `render` / `execute`.
10
+ *
11
+ * Bindings whose host is not a sub-domain of this worker's host are skipped
12
+ * (the graph node was still materialized; the route lives elsewhere).
13
+ */
14
+
15
+ import type { FnMap } from '@astrale-os/kernel-client'
16
+ import type { BoundClientSessionView } from '@astrale-os/kernel-client/session'
17
+ import type { AuthContext } from '@astrale-os/kernel-core'
18
+ import type { Context, Hono } from 'hono'
19
+
20
+ import {
21
+ isKernelErrorClassifiable,
22
+ KERNEL_ERROR_CODES,
23
+ kernelErrorHttpStatus,
24
+ type KernelErrorPayload,
25
+ } from '@astrale-os/kernel-api'
26
+ import {
27
+ isSubdomainOf,
28
+ matchHost,
29
+ compileHostMatcher,
30
+ parseUrlTemplate,
31
+ type FunctionBinding,
32
+ type AuthPolicy,
33
+ } from '@astrale-os/kernel-api/routed'
34
+ import { buildCorsHeaders, type CorsConfig } from '@astrale-os/kernel-server'
35
+
36
+ import type { RemoteIdentityConfig } from '../auth/identity'
37
+ import type { AnyRemoteFunctionDef } from '../define/remote-function'
38
+ import type { ViewDef } from '../define/view'
39
+ import type { CallRemoteFn } from '../dispatch/call-remote'
40
+ import type { AuxIdentityMap } from '../dispatch/identity'
41
+
42
+ import { resolveInboundAuth } from '../auth/resolve'
43
+ import { runAuthorize } from '../dispatch/authorize'
44
+ import { makeCallRemote } from '../dispatch/call-remote'
45
+ import { SdkResultValidationError, SdkValidationError } from '../dispatch/errors'
46
+ import { validateParams, validateResult } from '../dispatch/validate'
47
+
48
+ export type AuxiliaryRoutesConfig<TDeps> = {
49
+ app: Hono
50
+ /** This worker's serving URL — used to filter bindings that point elsewhere. */
51
+ url: string
52
+ views?: Record<string, ViewDef<TDeps>>
53
+ viewBindings?: Record<string, FunctionBinding>
54
+ remoteFunctions?: Record<string, AnyRemoteFunctionDef>
55
+ remoteFunctionBindings?: Record<string, FunctionBinding>
56
+ deps: TDeps
57
+ /**
58
+ * Per-route identity configs (keyed by slug) — one entry per View and
59
+ * one per RemoteFunction. Build via `buildAuxIdentityMap(compiled, key, issuer)`
60
+ * from `sdk/src/dispatch/identity.ts`.
61
+ */
62
+ identities: AuxIdentityMap
63
+ /**
64
+ * CORS policy applied to every mounted route: per-route `app.options(...)`
65
+ * preflight and `Access-Control-Allow-*` headers on success + error
66
+ * responses. Required so callers (always `createRemoteServer`) keep the
67
+ * kernel-envelope and aux-route policies in sync.
68
+ */
69
+ cors: CorsConfig
70
+ }
71
+
72
+ export function mountAuxiliaryRoutes<TDeps>(config: AuxiliaryRoutesConfig<TDeps>): void {
73
+ const {
74
+ app,
75
+ url,
76
+ views,
77
+ viewBindings,
78
+ remoteFunctions,
79
+ remoteFunctionBindings,
80
+ deps,
81
+ identities,
82
+ cors,
83
+ } = config
84
+
85
+ const workerHost = parseUrlTemplate(url).hostPattern
86
+ const corsHeaders = buildCorsHeaders(cors)
87
+
88
+ if (views && viewBindings) {
89
+ for (const [slug, def] of Object.entries(views)) {
90
+ const binding = viewBindings[slug]
91
+ if (!binding || !def.render) continue
92
+ const identity = requireAuxIdentity('view', slug, identities.views[slug])
93
+ mountEntry({
94
+ app,
95
+ binding,
96
+ workerHost,
97
+ defaultMethod: 'GET',
98
+ auth: def.auth,
99
+ identity,
100
+ corsHeaders,
101
+ // Views are transport-only (iframe HTML/redirect) — the kernel client
102
+ // built by `resolveInboundAuth` is intentionally NOT forwarded. Code
103
+ // inside the loaded iframe talks back to the kernel via the shell
104
+ // (WebSocket), not via this worker route.
105
+ run: async ({ c, params, auth }) => {
106
+ if (def.authorize) await runAuthorize(def.authorize, { c, params, auth, deps })
107
+ return def.render!({ c, params, auth, deps })
108
+ },
109
+ })
110
+ }
111
+ }
112
+
113
+ if (remoteFunctions && remoteFunctionBindings) {
114
+ for (const [slug, def] of Object.entries(remoteFunctions)) {
115
+ const binding = remoteFunctionBindings[slug]
116
+ if (!binding) continue
117
+ const identity = requireAuxIdentity('remote function', slug, identities.remoteFunctions[slug])
118
+ mountEntry({
119
+ app,
120
+ binding,
121
+ workerHost,
122
+ defaultMethod: 'POST',
123
+ auth: def.auth,
124
+ identity,
125
+ corsHeaders,
126
+ run: async ({ c, auth, kernel, callRemote }) => {
127
+ const rawBody: unknown = await c.req.json().catch(() => ({}))
128
+ const validation = validateParams(def.inputSchema, rawBody)
129
+ if (!validation.ok) {
130
+ throw new SdkValidationError(validation.issues as SdkValidationError['issues'])
131
+ }
132
+ const ctx = { params: validation.data, c, auth, deps, kernel, callRemote }
133
+ if (def.authorize) await runAuthorize(def.authorize, ctx)
134
+ const result = await def.execute(ctx)
135
+ const outValidation = validateResult(def.outputSchema, result)
136
+ if (!outValidation.ok) {
137
+ throw new SdkResultValidationError(
138
+ outValidation.issues as SdkResultValidationError['issues'],
139
+ def.ref,
140
+ )
141
+ }
142
+ return c.json({ result: outValidation.data })
143
+ },
144
+ })
145
+ }
146
+ }
147
+ }
148
+
149
+ // ── Internal ───────────────────────────────────────────────────────────────
150
+
151
+ /**
152
+ * Every materialized aux callable must have an identity in the install-time
153
+ * `subs` claim — a missing one means the build pipeline passed a compiled
154
+ * domain that doesn't include this callable to `buildAuxIdentityMap()`.
155
+ */
156
+ function requireAuxIdentity(
157
+ kind: 'view' | 'remote function',
158
+ slug: string,
159
+ identity: RemoteIdentityConfig | undefined,
160
+ ): RemoteIdentityConfig {
161
+ if (identity) return identity
162
+ throw new Error(
163
+ `mountAuxiliaryRoutes: no identity registered for ${kind} "${slug}". ` +
164
+ `Pass a compiled domain that includes this ${kind} to buildAuxIdentityMap().`,
165
+ )
166
+ }
167
+
168
+ type RunArgs = {
169
+ c: Context
170
+ params: Record<string, string>
171
+ auth: AuthContext | null
172
+ kernel: BoundClientSessionView<FnMap> | null
173
+ callRemote: CallRemoteFn
174
+ }
175
+
176
+ type MountEntryArgs = {
177
+ app: Hono
178
+ binding: FunctionBinding
179
+ workerHost: string
180
+ defaultMethod: 'GET' | 'POST'
181
+ run: (args: RunArgs) => Promise<Response>
182
+ auth?: AuthPolicy
183
+ identity: RemoteIdentityConfig
184
+ corsHeaders: Record<string, string>
185
+ }
186
+
187
+ const PLACEHOLDER_RE = /\{(\w+)([+*])?\}/g
188
+
189
+ function mountEntry(args: MountEntryArgs): void {
190
+ const { app, binding, workerHost, defaultMethod, run, auth, identity, corsHeaders } = args
191
+
192
+ const remoteUrl = binding.remoteUrl
193
+ if (!remoteUrl) return
194
+
195
+ const parsed = parseUrlTemplate(remoteUrl)
196
+ if (parsed.hostPattern && !isSubdomainOf(parsed.hostPattern, workerHost)) return
197
+
198
+ const fullPath = joinPath(parsed.basePath, binding.route?.path ?? '')
199
+ const honoPath = toHonoPath(fullPath)
200
+ const httpMethod = binding.route?.method ?? defaultMethod
201
+ // `route.method` can be any `HttpMethod` (PUT/PATCH/DELETE/*), but only GET
202
+ // and POST are wired below. Fail loudly at mount time rather than silently
203
+ // registering no handler (which would 404 the real request while the OPTIONS
204
+ // preflight still reports the route exists).
205
+ if (httpMethod !== 'GET' && httpMethod !== 'POST') {
206
+ throw new Error(
207
+ `mountAuxiliaryRoutes: unsupported HTTP method "${httpMethod}" for route "${honoPath}". ` +
208
+ `Aux routes (views / remote functions) support only GET and POST.`,
209
+ )
210
+ }
211
+ // Local-dev requests target literal `localhost`, but bindings reference a
212
+ // logical host (`dist.localhost`) — so only enforce a Host-header match
213
+ // when the binding has actual placeholders to extract.
214
+ const hostMatcher =
215
+ parsed.hostPlaceholders.length > 0 ? compileHostMatcher(parsed.hostPattern) : null
216
+ const pathParamNames = collectPlaceholderNames(fullPath)
217
+
218
+ const handler = async (c: Context): Promise<Response> => {
219
+ // Apply CORS to every response. `c.json(...)` / `c.body(...)` pick up the
220
+ // headers via the Hono context; raw `Response` objects — a View's `render`
221
+ // return, and `errorResponse` in the catch — do NOT, so the final returned
222
+ // Response is also passed through `applyCorsToResponse`.
223
+ applyCorsToContext(c, corsHeaders)
224
+ try {
225
+ let hostParams: Record<string, string> = {}
226
+ if (hostMatcher) {
227
+ const match = matchHost(hostMatcher, c.req.header('host') ?? '')
228
+ if (!match) return c.notFound()
229
+ hostParams = match
230
+ }
231
+
232
+ const pathParams: Record<string, string> = {}
233
+ for (const name of pathParamNames) {
234
+ const value = c.req.param(name)
235
+ if (value !== undefined) pathParams[name] = decodeURIComponent(value)
236
+ }
237
+
238
+ const { auth: resolvedAuth, kernel } = await resolveInboundAuth(
239
+ stripBearerPrefix(c.req.header('authorization') ?? ''),
240
+ auth,
241
+ identity,
242
+ )
243
+
244
+ const response = await run({
245
+ c,
246
+ params: { ...hostParams, ...pathParams },
247
+ auth: resolvedAuth,
248
+ kernel,
249
+ callRemote: makeCallRemote(kernel),
250
+ })
251
+ return applyCorsToResponse(response, corsHeaders)
252
+ } catch (err) {
253
+ return applyCorsToResponse(errorResponse(err), corsHeaders)
254
+ }
255
+ }
256
+
257
+ if (httpMethod === 'GET') app.get(honoPath, handler)
258
+ else if (httpMethod === 'POST') app.post(honoPath, handler)
259
+
260
+ // Per-route preflight — mirrors `createKernelApp`'s per-route
261
+ // `app.options(...)` pattern (kernel/server/app/create.ts:112,145). Avoid
262
+ // a wildcard `app.options('*', ...)`: it would intercept the kernel
263
+ // envelope's own preflights mounted later on this same Hono instance.
264
+ app.options(honoPath, (c) => {
265
+ applyCorsToContext(c, corsHeaders)
266
+ return c.body(null, 204)
267
+ })
268
+ }
269
+
270
+ function applyCorsToContext(c: Context, headers: Record<string, string>): void {
271
+ for (const [name, value] of Object.entries(headers)) c.header(name, value)
272
+ }
273
+
274
+ function applyCorsToResponse(response: Response, headers: Record<string, string>): Response {
275
+ try {
276
+ for (const [name, value] of Object.entries(headers)) response.headers.set(name, value)
277
+ return response
278
+ } catch {
279
+ // Some Responses have immutable headers — notably `Response.redirect(...)`,
280
+ // which a View's `render` is documented to return. Rebuild with a mutable
281
+ // header copy so CORS still applies (status / body / location preserved).
282
+ const merged = new Headers(response.headers)
283
+ for (const [name, value] of Object.entries(headers)) merged.set(name, value)
284
+ return new Response(response.body, {
285
+ status: response.status,
286
+ statusText: response.statusText,
287
+ headers: merged,
288
+ })
289
+ }
290
+ }
291
+
292
+ function collectPlaceholderNames(path: string): string[] {
293
+ return [...path.matchAll(PLACEHOLDER_RE)].map((m) => m[1]!)
294
+ }
295
+
296
+ /**
297
+ * Serialize an error as the canonical kernel error envelope
298
+ * `{ error: { code, message, data } }` with the matching HTTP status. This is
299
+ * the exact shape the routed client (`kernel-client` HttpRoutedTransport.
300
+ * decodeError) parses — it routes on `error.code` and reads `error.data` for
301
+ * field-level detail (a flat `{ error: '<string>' }` body silently degrades to
302
+ * a generic INTERNAL_ERROR client-side). Every SDK error class (AuthMissingError,
303
+ * SdkValidationError, SdkResultValidationError, AuthorizationDeniedError, …) plus
304
+ * the kernel-core errors `resolveInboundAuth` rethrows all implement
305
+ * `toKernelErrorPayload`, so one branch covers them; only a raw non-classifiable
306
+ * Error falls back to 500.
307
+ */
308
+ function errorResponse(err: unknown): Response {
309
+ const payload: KernelErrorPayload = isKernelErrorClassifiable(err)
310
+ ? err.toKernelErrorPayload()
311
+ : {
312
+ code: KERNEL_ERROR_CODES.INTERNAL_ERROR,
313
+ message: err instanceof Error ? err.message : 'Internal error',
314
+ }
315
+ return Response.json({ error: payload }, { status: kernelErrorHttpStatus(payload.code) })
316
+ }
317
+
318
+ function joinPath(a: string, b: string): string {
319
+ if (!b) return a
320
+ if (!a) return b
321
+ const left = a.endsWith('/') ? a.slice(0, -1) : a
322
+ const right = b.startsWith('/') ? b : `/${b}`
323
+ return `${left}${right}` || '/'
324
+ }
325
+
326
+ /** Convert `/foo/{id}` / `/{name+}` / `/{name*}` to Hono syntax. */
327
+ function toHonoPath(path: string): string {
328
+ return path
329
+ .replace(/\{(\w+)\+\}/g, ':$1{.+}')
330
+ .replace(/\{(\w+)\*\}/g, ':$1{.*}')
331
+ .replace(/\{(\w+)\}/g, ':$1')
332
+ }
333
+
334
+ function stripBearerPrefix(value: string): string {
335
+ return value.trim().replace(/^Bearer\s+/i, '')
336
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * `RemoteServerConfig` — input shape for `createRemoteServer`.
3
+ *
4
+ * Identity is per-function: `iss` = the worker's serving URL (`config.url`),
5
+ * `sub` = the function path, signed on each dispatch. No single `subject`.
6
+ */
7
+
8
+ import type { CorsConfig, WsAdapter } from '@astrale-os/kernel-server'
9
+ import type { Context } from 'hono'
10
+ import type { Hono } from 'hono'
11
+
12
+ import type { RemoteDomain } from '../domain/define'
13
+
14
+ export type RemoteServerConfig<TDeps> = {
15
+ /** Domain produced by `defineRemoteDomain(...)`. */
16
+ domain: RemoteDomain
17
+ /** Dependency container passed to every handler as `ctx.deps`. */
18
+ deps: TDeps
19
+ /**
20
+ * Server URL — the serving location AND the worker's JWT issuer identity
21
+ * (`iss`), decoupled from the addressing `origin` slug.
22
+ *
23
+ * The server's public key is published at `<url>/.well-known/jwks.json`
24
+ * so downstream verifiers can validate credentials signed by this server.
25
+ */
26
+ url: string
27
+ /** Private key used to sign outbound credentials. Public form is exposed via JWKS. */
28
+ privateKey: JsonWebKey
29
+ /** Allowed transports. `'http'` is mandatory. `'ws'` is opt-in. Defaults to `['http']`. */
30
+ transports?: readonly ('http' | 'ws')[]
31
+ /**
32
+ * Runtime-specific WS adapter (from `hono/bun`, `@hono/node-ws`, `hono/deno`).
33
+ * Required when `transports` includes `'ws'`.
34
+ */
35
+ ws?: WsAdapter
36
+ /** CORS configuration. Defaults to `{ origin: '*' }`. */
37
+ cors?: CorsConfig
38
+ /** Optional health endpoint path (defaults to `/health`; `false` disables). */
39
+ health?: string | false
40
+ /** Pre-existing Hono app to attach to (for nesting the SDK under a parent router). */
41
+ app?: Hono
42
+ /**
43
+ * Provenance stamped onto the auto-mounted `/meta` endpoint. Typically
44
+ * injected at build time by the bundler so downstream tooling can detect
45
+ * version drift between deployed server and expected schema.
46
+ */
47
+ meta?: {
48
+ sdkCommit?: string
49
+ schemaHash?: string
50
+ domainName?: string
51
+ }
52
+ /**
53
+ * Typed colon-path to a callable the installing kernel calls ONCE as the
54
+ * system identity, immediately after the domain installs. Use it to seed
55
+ * nodes and self-grant. Must be a semantic domain path under this domain's
56
+ * own origin (`/:origin:class.X:seed` / `/:origin:interface.Ops:seed`) — the
57
+ * kernel's origin guard refuses absolute tree paths, which cannot prove
58
+ * their origin from the string alone. Returned verbatim in the install
59
+ * bundle (a routing hint — the signed `graph_hash` already constrains what
60
+ * the callable can be).
61
+ *
62
+ * Example: `/:crm.acme.dev:class.Note:seed`
63
+ */
64
+ postInstall?: string
65
+ /**
66
+ * Cross-domain dependencies by origin. Returned in the install bundle; the
67
+ * kernel verifies each origin is already present on the instance before
68
+ * installing, and refuses with a clear error if one is missing.
69
+ */
70
+ requires?: readonly string[]
71
+ /**
72
+ * Optional private install hook. Throw to deny the install request.
73
+ * The public URL install contract still receives no caller kernel
74
+ * credential; private installs use the bearer token only.
75
+ */
76
+ install?: {
77
+ authorize?: (args: {
78
+ c: Context
79
+ token?: string
80
+ kernelIssuer: string
81
+ nonce: string
82
+ deps: TDeps
83
+ }) => void | Promise<void>
84
+ }
85
+ }
@@ -0,0 +1,249 @@
1
+ /**
2
+ * `createRemoteServer` — the SDK's entry point for running a remote domain.
3
+ *
4
+ * Identity is per-function: the dispatcher signs `iss` = the worker's serving
5
+ * URL (`effectiveIssuer`, decoupled from the addressing `origin`) and `sub` =
6
+ * the origin-addressed function path on each dispatch.
7
+ *
8
+ * Composes:
9
+ * methods ← Map keyed by BoundMethod.ref (built by dispatch/resolve)
10
+ * effectiveIssuer ← config.issuer ?? config.url
11
+ * dispatcher ← SdkDispatcher(compiled, methods, deps, privateKey)
12
+ * jwks ← derivePublicJwk(privateKey), keyed by effectiveIssuer
13
+ * /meta ← provenance endpoint (sdkCommit, schemaHash, domainName)
14
+ * auxiliary routes ← view / remote-function handlers from defineRemoteDomain
15
+ * app ← createKernelApp(dispatcher, contracts, host, jwks, transports, ...)
16
+ * start ← startNodeServer(app, port)
17
+ */
18
+
19
+ import type { JwksKeys } from '@astrale-os/kernel-server'
20
+
21
+ import { deriveAllowedAlgorithms } from '@astrale-os/kernel-core'
22
+ import {
23
+ collectFunctionSubs,
24
+ domainInstallRequestSchema,
25
+ hashInstallGraph,
26
+ } from '@astrale-os/kernel-core/domain'
27
+ import { createKernelApp } from '@astrale-os/kernel-server'
28
+ import { Hono } from 'hono'
29
+ import { importJWK, SignJWT } from 'jose'
30
+
31
+ import type { ViewDef } from '../define'
32
+ import type { RemoteServerConfig } from './config'
33
+ import type { RemoteServer, RemoteServerHandle } from './handle'
34
+
35
+ import { MetaSchema } from '../deploy/meta'
36
+ import { SdkDispatcher } from '../dispatch/dispatcher'
37
+ import { buildAuxIdentityMap } from '../dispatch/identity'
38
+ import { buildMethodIndex } from '../dispatch/resolve'
39
+ import { buildInstallGraph, buildInstallGraphHash } from '../domain/build-spec'
40
+ import { toSdkContract } from '../domain/contract'
41
+ import { materializeRemoteDomain } from '../domain/define'
42
+ import { mountAuxiliaryRoutes } from './auxiliary-routes'
43
+ import { derivePublicJwk } from './jwks'
44
+ import { canonicalizeServingUrl } from './serving-url'
45
+
46
+ export function createRemoteServer<TDeps>(config: RemoteServerConfig<TDeps>): RemoteServer {
47
+ const methods = buildMethodIndex(config.domain.methods)
48
+ // The worker's identity (`iss`) is its full serving URL (base path included,
49
+ // trailing slash stripped) — decoupled from the addressing `origin` slug. One
50
+ // canonical value drives outbound signing, the JWKS issuer, `/meta`, and the
51
+ // install credential. Must equal the URL the kernel fetched the domain at.
52
+ const iss = canonicalizeServingUrl(config.url)
53
+
54
+ // Re-materialize the domain with the real serving url (`iss`) so the aux
55
+ // View/Function `binding.remoteUrl` resolve to this host — the define-time
56
+ // placeholder is discarded. `compiled`/`auxiliary` drive everything below;
57
+ // `iss` is the single source for both bindings and identity.
58
+ const { compiled, auxiliary } = materializeRemoteDomain(config.domain, iss)
59
+
60
+ const dispatcher = new SdkDispatcher<TDeps>({
61
+ compiled,
62
+ methods,
63
+ deps: config.deps,
64
+ privateKey: config.privateKey,
65
+ issuer: iss,
66
+ // The canonicalized serving URL, NOT the raw config.url: `ctx.url` is
67
+ // documented as the worker's `iss` identity, so the two must be one value.
68
+ url: iss,
69
+ })
70
+
71
+ const publicJwk = derivePublicJwk(config.privateKey)
72
+ const jwks: JwksKeys = {
73
+ issuer: iss,
74
+ loadOwnKeys: async () => [publicJwk],
75
+ }
76
+
77
+ // `/meta`'s `schemaHash` is computed from the LIVE domain graph (lazy +
78
+ // cached). `hashInstallGraph` is id-independent/deterministic, so an
79
+ // independent build (a deploy script) produces the SAME hash — `deployCheck`
80
+ // can compare its expected value against this and detect genuine schema drift.
81
+ // An explicit `config.meta.schemaHash` still wins as an override for
82
+ // offline/pinned spec producers, but normal deploys omit it.
83
+ let cachedSchemaHash: Promise<string> | null = null
84
+ const resolveSchemaHash = (): Promise<string> =>
85
+ config.meta?.schemaHash !== undefined
86
+ ? Promise.resolve(config.meta.schemaHash)
87
+ : (cachedSchemaHash ??= buildInstallGraphHash(config.domain, iss))
88
+
89
+ // Register `/meta` on the host app before `createKernelApp` mounts its
90
+ // catch-all routes so the verbatim path wins.
91
+ const hostApp = config.app ?? new Hono()
92
+ const metaBase = {
93
+ iss,
94
+ sdkCommit: config.meta?.sdkCommit,
95
+ domainName: config.meta?.domainName ?? compiled.$.origin,
96
+ }
97
+ hostApp.get('/meta', async (c) =>
98
+ c.json(MetaSchema.parse({ ...metaBase, schemaHash: await resolveSchemaHash() })),
99
+ )
100
+ hostApp.post('/_astrale/install-domain', async (c) => {
101
+ const parsed = domainInstallRequestSchema.safeParse(await c.req.json().catch(() => null))
102
+ if (!parsed.success) {
103
+ return c.json({ error: 'Invalid install request', issues: parsed.error.issues }, 400)
104
+ }
105
+
106
+ const token = bearerToken(c.req.header('authorization'))
107
+ try {
108
+ await config.install?.authorize?.({
109
+ c,
110
+ ...(token ? { token } : {}),
111
+ kernelIssuer: parsed.data.kernelIssuer,
112
+ nonce: parsed.data.nonce,
113
+ deps: config.deps,
114
+ })
115
+ } catch (err) {
116
+ return c.json({ error: 'Install denied', message: (err as Error).message }, 403)
117
+ }
118
+
119
+ // Build the install graph. `buildInstallGraph` re-materializes the domain
120
+ // with the serving url (`iss`), so every `binding.remoteUrl` points at this
121
+ // host — the same single value as the credential's `iss` below. The kernel
122
+ // hashes exactly this graph; no install-time rewrite.
123
+ const graph = buildInstallGraph(config.domain, iss)
124
+ const graphHash = await hashInstallGraph(graph)
125
+ const origin = compiled.$.origin
126
+ // `postInstall` + `requires` ride in BOTH the signed credential claims and
127
+ // the bundle body, and the kernel rejects any bundle field that disagrees
128
+ // with its signed claim — so derive them once and reuse for both.
129
+ const bundleExtras = {
130
+ ...(config.postInstall ? { postInstall: config.postInstall } : {}),
131
+ ...(config.requires && config.requires.length > 0 ? { requires: config.requires } : {}),
132
+ }
133
+ // The credential's `iss` is the worker's serving URL (`iss`, computed above)
134
+ // — the kernel pins it to the URL it fetched the domain at and verifies this
135
+ // credential against that issuer's JWKS.
136
+ const credential = await signInstallCredential({
137
+ privateKey: config.privateKey,
138
+ issuer: iss,
139
+ audience: parsed.data.kernelIssuer,
140
+ nonce: parsed.data.nonce,
141
+ graphHash,
142
+ subs: collectFunctionSubs(compiled),
143
+ ...bundleExtras,
144
+ })
145
+
146
+ return c.json({
147
+ origin,
148
+ graph,
149
+ identity: { credential },
150
+ ...bundleExtras,
151
+ })
152
+ })
153
+
154
+ // Resolved once and shared between the kernel envelope (createKernelApp) and
155
+ // the aux routes (mountAuxiliaryRoutes) so both honor the same policy.
156
+ const cors = config.cors ?? { origin: '*' }
157
+
158
+ // Worker-side wires for defineView / defineRemoteFunction. Mounted before
159
+ // the kernel catch-all. Each aux route gets its own per-slug identity
160
+ // (issuer + key + the aux node's AbsolutePath as subject) so outbound
161
+ // `kernel.call(...)` from a handler signs with that path — matching the
162
+ // identity the install-time `subs` claim registered for the node, the
163
+ // same way Methods work.
164
+ if (auxiliary) {
165
+ const auxIdentities = buildAuxIdentityMap(compiled, config.privateKey, iss)
166
+ mountAuxiliaryRoutes<TDeps>({
167
+ app: hostApp,
168
+ url: auxiliary.url,
169
+ // oxlint-disable-next-line no-explicit-any
170
+ views: config.domain.views as Record<string, ViewDef<TDeps>> | undefined,
171
+ viewBindings: auxiliary.viewBindings,
172
+ remoteFunctions: config.domain.remoteFunctions,
173
+ remoteFunctionBindings: auxiliary.remoteFunctionBindings,
174
+ deps: config.deps,
175
+ identities: auxIdentities,
176
+ cors,
177
+ })
178
+ }
179
+
180
+ const { app } = createKernelApp({
181
+ kernel: dispatcher,
182
+ domain: config.domain.methods.map(toSdkContract),
183
+ host: { url: config.url },
184
+ jwks,
185
+ transports: config.transports,
186
+ cors,
187
+ health: config.health,
188
+ app: hostApp,
189
+ ws: config.ws,
190
+ })
191
+
192
+ return {
193
+ app,
194
+ // The canonical serving URL = the worker's `iss` identity. Exposed so a
195
+ // worker that also seeds `Identity.iss` on graph nodes (e.g. recruitment's
196
+ // self-seed) stamps the SAME canonical value the dispatcher signs with —
197
+ // never the raw env.WORKER_URL — so the kernel's exact-match lookup resolves.
198
+ iss,
199
+ async start(port?: number): Promise<RemoteServerHandle> {
200
+ const nodeStartModule = './start'
201
+ const { startNodeServer } = await import(nodeStartModule)
202
+ return startNodeServer(app, port)
203
+ },
204
+ }
205
+ }
206
+
207
+ function bearerToken(header: string | undefined): string | undefined {
208
+ if (!header) return undefined
209
+ const match = /^Bearer\s+(.+)$/i.exec(header.trim())
210
+ return match?.[1]
211
+ }
212
+
213
+ async function signInstallCredential(args: {
214
+ privateKey: JsonWebKey
215
+ issuer: string
216
+ audience: string
217
+ nonce: string
218
+ graphHash: string
219
+ subs: string[]
220
+ postInstall?: string
221
+ requires?: readonly string[]
222
+ }): Promise<string> {
223
+ const alg = deriveAllowedAlgorithms(args.privateKey)[0]
224
+ if (!alg) {
225
+ throw new Error(
226
+ `createRemoteServer: cannot derive install signing algorithm from JWK (kty=${args.privateKey.kty}).`,
227
+ )
228
+ }
229
+ const kid = (args.privateKey as unknown as Record<string, unknown>).kid
230
+ const header = typeof kid === 'string' ? { alg, kid } : { alg }
231
+ const key = await importJWK(args.privateKey, alg)
232
+
233
+ // `postInstall` + `requires` are signed (not just carried in the bundle) so a
234
+ // MITM can't retarget the system-authority hook or forge dependencies
235
+ return new SignJWT({
236
+ subs: args.subs,
237
+ nonce: args.nonce,
238
+ graph_hash: args.graphHash,
239
+ ...(args.postInstall ? { postInstall: args.postInstall } : {}),
240
+ ...(args.requires ? { requires: args.requires } : {}),
241
+ })
242
+ .setProtectedHeader(header)
243
+ .setIssuer(args.issuer)
244
+ .setSubject(args.issuer)
245
+ .setAudience(args.audience)
246
+ .setIssuedAt()
247
+ .setExpirationTime('10m')
248
+ .sign(key)
249
+ }