@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,257 @@
1
+ /**
2
+ * `SdkDispatcher` — implements `KernelAPI` by running the SDK's pipeline.
3
+ *
4
+ * Built once at server startup from the domain's `CompiledDomain` plus a typed
5
+ * deps container and a private JWK. Per-method identity configs are
6
+ * pre-resolved into a Map so dispatch is O(1) per call.
7
+ *
8
+ * Pipeline per call:
9
+ * resolve method → authenticate → validate → resolve self → execute
10
+ */
11
+
12
+ import type { AuthedKernelAPI, DispatchResult, KernelAPI } from '@astrale-os/kernel-api'
13
+ import type { AuthPolicy } from '@astrale-os/kernel-api/routed'
14
+ import type { CredentialInput, Path } from '@astrale-os/kernel-core'
15
+ import type { BoundMethod, CompiledDomain } from '@astrale-os/kernel-core/domain'
16
+
17
+ import type { RemoteIdentityConfig } from '../auth/identity'
18
+ import type { AnyRemoteHandler } from '../method/single'
19
+ import type { MethodIndex } from './resolve'
20
+
21
+ import { resolveInboundAuth } from '../auth/resolve'
22
+ import { runAuthorize } from './authorize'
23
+ import { makeCallRemote } from './call-remote'
24
+ import { MethodNotFoundError, SdkResultValidationError, SdkValidationError } from './errors'
25
+ import { executeHandler } from './execute'
26
+ import { buildIdentityMap } from './identity'
27
+ import { resolveMethod } from './resolve'
28
+ import { resolveSelf, type SelfResult } from './self'
29
+ import { validateParams, validateResult } from './validate'
30
+
31
+ export type SdkDispatcherConfig<TDeps> = {
32
+ /** The compiled domain — source of the method layout and origin-addressed subs. */
33
+ compiled: CompiledDomain
34
+ /** Pre-built method map: ref → BoundMethod. */
35
+ methods: MethodIndex
36
+ /** Dependency container injected into every handler. */
37
+ deps: TDeps
38
+ /** Private key for signing outbound per-function credentials. */
39
+ privateKey: JsonWebKey
40
+ /**
41
+ * The worker's identity (`iss`) for outbound per-function credentials — its
42
+ * serving URL (e.g. `https://crm.test.com`), DECOUPLED from the domain's
43
+ * addressing `origin`. Matches the `iss` the kernel pins on each node at
44
+ * install (the URL it fetched the domain at).
45
+ */
46
+ issuer: string
47
+ /** The worker's serving URL (`config.url`) — exposed to handlers as `ctx.url`. */
48
+ url: string
49
+ }
50
+
51
+ export class SdkDispatcher<TDeps = unknown> implements KernelAPI {
52
+ private readonly methods: MethodIndex
53
+ private readonly deps: TDeps
54
+ private readonly identities: Map<BoundMethod<AnyRemoteHandler>, RemoteIdentityConfig>
55
+ private readonly issuer: string
56
+ private readonly url: string
57
+ private readonly privateKey: JsonWebKey
58
+
59
+ constructor(config: SdkDispatcherConfig<TDeps>) {
60
+ this.methods = config.methods
61
+ this.deps = config.deps
62
+ this.issuer = config.issuer
63
+ this.url = config.url
64
+ this.privateKey = config.privateKey
65
+ const methodList = Array.from(new Set(config.methods.values()))
66
+ const identityMethods = methodList.filter((bound) => methodAuthPolicy(bound) !== 'public')
67
+ this.identities =
68
+ identityMethods.length > 0
69
+ ? buildIdentityMap(config.compiled, identityMethods, config.privateKey, config.issuer)
70
+ : new Map()
71
+ }
72
+
73
+ async call(
74
+ path: Path | string,
75
+ credential: CredentialInput,
76
+ params: unknown,
77
+ opts?: { self?: string },
78
+ ): Promise<unknown> {
79
+ return this.run(path, credential, params, opts?.self)
80
+ }
81
+
82
+ async stream(
83
+ path: Path | string,
84
+ credential: CredentialInput,
85
+ params: unknown,
86
+ opts?: { self?: string },
87
+ ): Promise<AsyncGenerator<unknown>> {
88
+ const result = await this.run(path, credential, params, opts?.self)
89
+ if (!isAsyncGenerator(result)) {
90
+ throw new Error(`Method "${pathToString(path)}" did not return an async generator`)
91
+ }
92
+ return result
93
+ }
94
+
95
+ /**
96
+ * SDK dispatch never redirects — the SDK server IS the remote destination.
97
+ * Every call resolves locally; we just classify the return as value or stream
98
+ * and hand it back to the kernel-api orchestrator.
99
+ */
100
+ async dispatch(
101
+ path: Path | string,
102
+ credential: CredentialInput,
103
+ params: unknown,
104
+ opts?: { self?: string },
105
+ ): Promise<DispatchResult> {
106
+ const result = await this.run(path, credential, params, opts?.self)
107
+ return isAsyncGenerator(result)
108
+ ? { kind: 'stream', generator: result }
109
+ : { kind: 'value', value: result }
110
+ }
111
+
112
+ as(credential: CredentialInput): AuthedKernelAPI {
113
+ return {
114
+ call: (path, params) => this.call(path, credential, params),
115
+ stream: (path, params) => this.stream(path, credential, params),
116
+ }
117
+ }
118
+
119
+ private async run(
120
+ path: Path | string,
121
+ credential: CredentialInput,
122
+ params: unknown,
123
+ selfRef?: string,
124
+ ): Promise<unknown> {
125
+ const bound = resolveMethod(this.methods, path)
126
+ if (!bound) throw new MethodNotFoundError(pathToString(path))
127
+
128
+ const authPolicy: AuthPolicy = (bound.handler as { auth?: AuthPolicy }).auth ?? 'required'
129
+ const fnIdentity = this.identityFor(bound)
130
+ const { auth, kernel } = await resolveInboundAuth(credential, authPolicy, fnIdentity)
131
+
132
+ if (typeof params !== 'object' || params === null || Array.isArray(params)) {
133
+ throw new SdkValidationError([{ path: [], message: 'Params must be a plain object' }])
134
+ }
135
+ const validation = validateParams(bound.inputSchema, params)
136
+ if (!validation.ok) {
137
+ throw new SdkValidationError(validation.issues as SdkValidationError['issues'])
138
+ }
139
+
140
+ // `undefined` (not `null`) for static methods — matches the `self` type on
141
+ // `RemoteContext` / `MethodImpl`, which is `undefined` for static methods.
142
+ let resolvedSelf: SelfResult | undefined
143
+ if (!bound.isStatic) {
144
+ if (selfRef === undefined || selfRef === null || selfRef === '') {
145
+ throw new SdkValidationError(
146
+ [{ path: ['self'], message: 'Required for non-static method' }],
147
+ `"${bound.owner}.${bound.method}" is an instance method — it needs a target node. ` +
148
+ `Either call it via instance dispatch (e.g. "@<nodeId>::${bound.method}" ` +
149
+ `or "/path/to/node::${bound.method}"), or pass "self" in dispatch opts ` +
150
+ `(e.g. { self: "@<nodeId>" }).`,
151
+ )
152
+ }
153
+ try {
154
+ resolvedSelf = resolveSelf(String(selfRef))
155
+ } catch (err) {
156
+ // A malformed caller-supplied `self` is bad client input, not a server
157
+ // fault — surface it as VALIDATION_ERROR (422), matching the sibling
158
+ // missing-self branch above, instead of INTERNAL_ERROR (500).
159
+ throw new SdkValidationError(
160
+ [
161
+ {
162
+ path: ['self'],
163
+ message: err instanceof Error ? err.message : 'Failed to resolve self',
164
+ },
165
+ ],
166
+ `Invalid "self" target "${selfRef}": ${err instanceof Error ? err.message : 'unparseable path'}`,
167
+ )
168
+ }
169
+ }
170
+
171
+ const handler = bound.handler as {
172
+ execute: (...args: unknown[]) => unknown
173
+ authorize?: (ctx: unknown) => void | Promise<void>
174
+ }
175
+ const handlerParams = validation.data
176
+ const callRemote = makeCallRemote(kernel)
177
+ const ctx = {
178
+ params: handlerParams,
179
+ auth,
180
+ self: resolvedSelf,
181
+ deps: this.deps,
182
+ url: this.url,
183
+ kernel,
184
+ callRemote,
185
+ }
186
+
187
+ if (handler.authorize) await runAuthorize(handler.authorize, ctx)
188
+
189
+ const result = await executeHandler({ handler, ref: bound.ref, ...ctx })
190
+ return validateOutput(bound, result)
191
+ }
192
+
193
+ private identityFor(bound: BoundMethod<AnyRemoteHandler>): RemoteIdentityConfig {
194
+ const identity = this.identities.get(bound)
195
+ if (!identity) {
196
+ if (methodAuthPolicy(bound) === 'public') {
197
+ return { issuer: this.issuer, subject: bound.ref, privateKey: this.privateKey }
198
+ }
199
+ throw new Error(
200
+ `SdkDispatcher: no identity config for "${bound.owner}.${bound.method}" — ` +
201
+ `method is registered in the index but missing from the identity map.`,
202
+ )
203
+ }
204
+ return identity
205
+ }
206
+ }
207
+
208
+ function methodAuthPolicy(bound: BoundMethod<AnyRemoteHandler>): AuthPolicy {
209
+ return (bound.handler as { auth?: AuthPolicy }).auth ?? 'required'
210
+ }
211
+
212
+ function pathToString(path: Path | string): string {
213
+ return typeof path === 'string' ? path : path.raw
214
+ }
215
+
216
+ /**
217
+ * Validate a handler's output against `bound.outputSchema`, mirroring the
218
+ * kernel's dispatcher (`runtime/.../dispatcher.ts` + `events.ts`):
219
+ * - `binary` outputs skip validation (no schema applies).
220
+ * - async-generator outputs (`output: 'stream'`) are wrapped so each chunk
221
+ * is validated as it is yielded.
222
+ * - all other (`value`) outputs are validated once and returned parsed.
223
+ * Returns the synchronous shape (value or generator) so the caller's
224
+ * `dispatch()` can classify it without awaiting a stream to completion.
225
+ */
226
+ function validateOutput(bound: BoundMethod<AnyRemoteHandler>, result: unknown): unknown {
227
+ if (bound.output === 'binary') return result
228
+ if (isAsyncGenerator(result)) return validateOutputStream(bound, result)
229
+ return validateChunk(bound, result)
230
+ }
231
+
232
+ async function* validateOutputStream(
233
+ bound: BoundMethod<AnyRemoteHandler>,
234
+ gen: AsyncGenerator<unknown>,
235
+ ): AsyncGenerator<unknown> {
236
+ for await (const chunk of gen) {
237
+ yield validateChunk(bound, chunk)
238
+ }
239
+ }
240
+
241
+ /** Parse one output value against `bound.outputSchema` or throw a 500-class error. */
242
+ function validateChunk(bound: BoundMethod<AnyRemoteHandler>, value: unknown): unknown {
243
+ const out = validateResult(bound.outputSchema, value)
244
+ if (!out.ok) {
245
+ throw new SdkResultValidationError(out.issues as SdkResultValidationError['issues'], bound.ref)
246
+ }
247
+ return out.data
248
+ }
249
+
250
+ function isAsyncGenerator(value: unknown): value is AsyncGenerator<unknown> {
251
+ return (
252
+ value !== null &&
253
+ value !== undefined &&
254
+ typeof value === 'object' &&
255
+ Symbol.asyncIterator in value
256
+ )
257
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Errors thrown by the dispatch pipeline.
3
+ *
4
+ * Each implements `KernelErrorClassifiable` so `kernel-api/dispatch` can
5
+ * convert them into typed `KernelErrorPayload` values automatically.
6
+ */
7
+
8
+ import type { KernelErrorPayload, KernelErrorClassifiable } from '@astrale-os/kernel-api'
9
+
10
+ import { KERNEL_ERROR_CODES } from '@astrale-os/kernel-api'
11
+
12
+ /** A single Zod issue, normalized to the path/message the payload carries. */
13
+ type ValidationIssue = { path: (string | number)[]; message: string }
14
+
15
+ /** Shape Zod issues into the `data.errors` array of a `KernelErrorPayload`. */
16
+ function toPayloadErrors(issues: ValidationIssue[]): Array<{
17
+ path: (string | number)[]
18
+ code: 'INVALID'
19
+ message: string
20
+ }> {
21
+ return issues.map((i) => ({ path: i.path, code: 'INVALID', message: i.message }))
22
+ }
23
+
24
+ export class MethodNotFoundError extends Error implements KernelErrorClassifiable {
25
+ constructor(method: string) {
26
+ super(`Method not found: ${method}`)
27
+ this.name = 'MethodNotFoundError'
28
+ }
29
+
30
+ toKernelErrorPayload(): KernelErrorPayload {
31
+ return { code: KERNEL_ERROR_CODES.METHOD_NOT_FOUND, message: this.message }
32
+ }
33
+ }
34
+
35
+ export class SdkValidationError extends Error implements KernelErrorClassifiable {
36
+ constructor(
37
+ readonly issues: ValidationIssue[],
38
+ message?: string,
39
+ ) {
40
+ super(message ?? 'Invalid params')
41
+ this.name = 'SdkValidationError'
42
+ }
43
+
44
+ toKernelErrorPayload(): KernelErrorPayload {
45
+ return {
46
+ code: KERNEL_ERROR_CODES.VALIDATION_ERROR,
47
+ message: this.message,
48
+ data: { errors: toPayloadErrors(this.issues) },
49
+ }
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Thrown when a handler's return value (or a stream chunk) fails validation
55
+ * against its `outputSchema`. This is a server/handler fault — the author's
56
+ * code produced data that violates its own declared contract — so it maps to
57
+ * `INTERNAL_ERROR` (→ HTTP 500), NOT the `VALIDATION_ERROR` (422) used for
58
+ * bad caller input. Mirrors the kernel's `ResultValidationError`.
59
+ */
60
+ export class SdkResultValidationError extends Error implements KernelErrorClassifiable {
61
+ constructor(
62
+ readonly issues: ValidationIssue[],
63
+ readonly ref?: string,
64
+ ) {
65
+ super(`Function${ref ? ` "${ref}"` : ''} returned an invalid result`)
66
+ this.name = 'SdkResultValidationError'
67
+ }
68
+
69
+ toKernelErrorPayload(): KernelErrorPayload {
70
+ return {
71
+ code: KERNEL_ERROR_CODES.INTERNAL_ERROR,
72
+ message: this.message,
73
+ data: { errors: toPayloadErrors(this.issues) },
74
+ }
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Thrown by a `RemoteHandler.authorize` hook to deny a call.
80
+ *
81
+ * Wraps any error the hook throws — handlers can throw a plain `Error` and
82
+ * the dispatcher converts it. Already-typed `AuthorizationDeniedError`
83
+ * instances are passed through unchanged so callers can attach hints.
84
+ */
85
+ export class AuthorizationDeniedError extends Error implements KernelErrorClassifiable {
86
+ constructor(message: string, cause?: unknown) {
87
+ super(message, { cause })
88
+ this.name = 'AuthorizationDeniedError'
89
+ }
90
+
91
+ toKernelErrorPayload(): KernelErrorPayload {
92
+ return { code: KERNEL_ERROR_CODES.PERMISSION_DENIED, message: this.message }
93
+ }
94
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Handler execution — pipeline step 4.
3
+ *
4
+ * Calls the resolved handler's `execute` with a fully assembled
5
+ * `RemoteContext`: validated params, auth context, optional bound self,
6
+ * typed deps, and a kernel `BoundClientSessionView`. The handler may return a
7
+ * value, a Promise, or an async generator (for `output: 'stream'`).
8
+ */
9
+
10
+ import type { FnMap } from '@astrale-os/kernel-client'
11
+ import type { BoundClientSessionView } from '@astrale-os/kernel-client/session'
12
+ import type { AuthContext } from '@astrale-os/kernel-core'
13
+
14
+ import type { CallRemoteFn } from './call-remote'
15
+ import type { SelfResult } from './self'
16
+
17
+ // oxlint-disable-next-line no-explicit-any
18
+ type HandlerFn = (...args: any[]) => any
19
+
20
+ export type ExecuteParams = {
21
+ /** Handler may omit `execute` when authored as a stub (spec-only binding). */
22
+ handler: { execute?: HandlerFn }
23
+ /** Namespaced ref of the dispatched method — names the offending method in the
24
+ * stub-leak diagnostic below (the dispatcher passes `bound.ref`). */
25
+ ref?: string
26
+ params: Record<string, unknown>
27
+ auth: AuthContext | null
28
+ self: SelfResult | undefined
29
+ deps: unknown
30
+ url: string
31
+ kernel: BoundClientSessionView<FnMap> | null
32
+ callRemote: CallRemoteFn
33
+ }
34
+
35
+ export async function executeHandler(ctx: ExecuteParams): Promise<unknown> {
36
+ if (typeof ctx.handler.execute !== 'function') {
37
+ throw new Error(
38
+ `executeHandler: handler${ctx.ref ? ` for "${ctx.ref}"` : ''} has no \`execute\` body — ` +
39
+ `this is a binding stub that should have been resolved to a remote redirect upstream.`,
40
+ )
41
+ }
42
+ return ctx.handler.execute({
43
+ params: ctx.params,
44
+ auth: ctx.auth,
45
+ self: ctx.self,
46
+ deps: ctx.deps,
47
+ url: ctx.url,
48
+ kernel: ctx.kernel,
49
+ callRemote: ctx.callRemote,
50
+ })
51
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Per-callable identity — dispatcher runtime + install-time wiring.
3
+ *
4
+ * Two flavors of callable get an identity per the install-time identity
5
+ * binding: Methods on classes/interfaces (sub = MethodPath) and auto-
6
+ * materialized core function nodes — RemoteFunctions and Views (sub =
7
+ * AbsolutePath of the node under `core/{functions,views}/<slug>`).
8
+ *
9
+ * At server startup the method dispatcher and the View / RemoteFunction
10
+ * route mounter pre-compute, for every materialized callable, the
11
+ * `RemoteIdentityConfig` they sign outbound kernel calls with. The kernel
12
+ * then matches an existing function identity instead of provisioning a
13
+ * generic one.
14
+ *
15
+ * **Single source of truth:** every helper here is a thin lens over the
16
+ * canonical `resolveCallables` from `@astrale-os/kernel-core/domain`. The
17
+ * kernel validates install subs against the SAME function — drift = 0
18
+ * by construction. This closes the previous SDK-side gap where
19
+ * `collectMethodPaths` filtered to `own|override` and silently dropped
20
+ * `sealed`/`default` interface methods (which DO materialize as nodes).
21
+ */
22
+
23
+ import type { BoundMethod, CompiledDomain } from '@astrale-os/kernel-core/domain'
24
+
25
+ import { resolveCallables } from '@astrale-os/kernel-core/domain'
26
+
27
+ import type { RemoteIdentityConfig } from '../auth/identity'
28
+ import type { AnyRemoteHandler } from '../method/single'
29
+
30
+ /**
31
+ * For each materialized method node, emit its `MethodPath` (semantic,
32
+ * layout-independent) keyed by the method's namespaced ref. Used to
33
+ * build the install `subs` claim and to look up runtime per-method
34
+ * subjects in `buildIdentityMap`.
35
+ *
36
+ * Includes every method node the kernel materializes — class own/override
37
+ * AND interface own (sealed/default/override). Inherited (non-overriding)
38
+ * class methods reuse the interface's node via a `method_of` edge and
39
+ * are correctly absent here.
40
+ */
41
+ export function collectMethodPaths(compiled: CompiledDomain): Record<string, string> {
42
+ const out: Record<string, string> = {}
43
+ for (const c of resolveCallables(compiled)) {
44
+ if (c.kind === 'method') out[c.ref] = c.sub
45
+ }
46
+ return out
47
+ }
48
+
49
+ /** `{ views, remoteFunctions }` buckets keyed by slug — the shared shape
50
+ * for every per-aux-callable map (paths, identity configs). */
51
+ export type AuxBuckets<T> = {
52
+ views: Record<string, T>
53
+ remoteFunctions: Record<string, T>
54
+ }
55
+
56
+ /**
57
+ * slug → `AbsolutePath.raw` for each auto-materialized aux node
58
+ * (RemoteFunction / View). These nodes live at
59
+ * `/<origin>/core/{functions,views}/<slug>` and have no class+method
60
+ * decomposition that would justify a MethodPath — their identity is their
61
+ * graph position.
62
+ */
63
+ export type AuxIdentityPaths = AuxBuckets<string>
64
+
65
+ export function collectAuxIdentityPaths(compiled: CompiledDomain): AuxIdentityPaths {
66
+ const views: Record<string, string> = {}
67
+ const remoteFunctions: Record<string, string> = {}
68
+ for (const c of resolveCallables(compiled)) {
69
+ if (c.kind !== 'core' || !c.slug) continue
70
+ if (c.className === 'View') {
71
+ views[c.slug] = c.sub
72
+ } else if (c.className === 'Function') {
73
+ // Standalone callables (the former `RemoteFunction`) materialize as the
74
+ // canonical kernel `Function` class.
75
+ remoteFunctions[c.slug] = c.sub
76
+ } else {
77
+ throw new Error(
78
+ `collectAuxIdentityPaths: unrecognised aux callable className "${c.className}" ` +
79
+ `at ${c.ref}. If you've added a new auto-materialized Function class to extendCore, ` +
80
+ `extend this collector + the kernel walker (resolveCallables) to handle it.`,
81
+ )
82
+ }
83
+ }
84
+ return { views, remoteFunctions }
85
+ }
86
+
87
+ /**
88
+ * slug → `RemoteIdentityConfig` for each auto-materialized View /
89
+ * RemoteFunction. Built once at server startup; consumed by
90
+ * `mountAuxiliaryRoutes` so each handler signs outbound `kernel.call(...)`
91
+ * with its own `sub` (the node's AbsolutePath). Mirrors `buildIdentityMap`.
92
+ */
93
+ export type AuxIdentityMap = AuxBuckets<RemoteIdentityConfig>
94
+
95
+ export function buildAuxIdentityMap(
96
+ compiled: CompiledDomain,
97
+ privateKey: JsonWebKey,
98
+ issuer: string,
99
+ ): AuxIdentityMap {
100
+ const paths = collectAuxIdentityPaths(compiled)
101
+ const views: Record<string, RemoteIdentityConfig> = {}
102
+ for (const [slug, subject] of Object.entries(paths.views)) {
103
+ views[slug] = { issuer, subject, privateKey }
104
+ }
105
+ const remoteFunctions: Record<string, RemoteIdentityConfig> = {}
106
+ for (const [slug, subject] of Object.entries(paths.remoteFunctions)) {
107
+ remoteFunctions[slug] = { issuer, subject, privateKey }
108
+ }
109
+ return { views, remoteFunctions }
110
+ }
111
+
112
+ /**
113
+ * Pre-resolve per-method identity configs. Lookup is by BoundMethod instance,
114
+ * so the dispatcher pays O(1) per call instead of re-constructing the config.
115
+ *
116
+ * `iss` = the worker's own **serving URL** (`issuer` arg, e.g.
117
+ * `https://crm.test.com`) — its cryptographic identity, DECOUPLED from the
118
+ * domain's addressing `origin` (a graph slug). The kernel stamps the same
119
+ * value on each function node at install (it pins `iss` to the URL it fetched
120
+ * the domain at) and verifies inbound credentials against that issuer's JWKS
121
+ * (OIDC discovery). The function `sub` stays the origin-addressed MethodPath —
122
+ * `sub` = which function, `iss` = who signs.
123
+ */
124
+ export function buildIdentityMap(
125
+ compiled: CompiledDomain,
126
+ methods: BoundMethod<AnyRemoteHandler>[],
127
+ privateKey: JsonWebKey,
128
+ issuer: string,
129
+ ): Map<BoundMethod<AnyRemoteHandler>, RemoteIdentityConfig> {
130
+ const paths = collectMethodPaths(compiled)
131
+ const out = new Map<BoundMethod<AnyRemoteHandler>, RemoteIdentityConfig>()
132
+ for (const bound of methods) {
133
+ const subject = paths[bound.ref]
134
+ if (!subject) {
135
+ // Under the "drift = 0" invariant every dispatched method's ref is a key
136
+ // in `paths`. A miss means an install/index drift — fail loudly at boot
137
+ // rather than signing with `bound.ref` (a namespaced ref, NOT a
138
+ // MethodPath sub), which would silently fail kernel identity matching at
139
+ // call time. Mirrors `identityFor` / `requireAuxIdentity`.
140
+ throw new Error(
141
+ `buildIdentityMap: method "${bound.ref}" is in the dispatch index but absent ` +
142
+ `from resolveCallables(compiled) — no identity subject to sign with.`,
143
+ )
144
+ }
145
+ out.set(bound, { issuer, subject, privateKey })
146
+ }
147
+ return out
148
+ }
@@ -0,0 +1,17 @@
1
+ export { SdkDispatcher, type SdkDispatcherConfig } from './dispatcher'
2
+ export { resolveMethod, buildMethodIndex, type MethodIndex } from './resolve'
3
+ export {
4
+ validateParams,
5
+ validateResult,
6
+ type ValidationResult,
7
+ type ResultValidationResult,
8
+ } from './validate'
9
+ export { resolveSelf, type SelfResult } from './self'
10
+ export { executeHandler, type ExecuteParams } from './execute'
11
+ export { makeCallRemote, type CallRemoteFn } from './call-remote'
12
+ export {
13
+ AuthorizationDeniedError,
14
+ MethodNotFoundError,
15
+ SdkValidationError,
16
+ SdkResultValidationError,
17
+ } from './errors'
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Method resolution — the index the dispatcher queries on every call.
3
+ *
4
+ * Owns both the index's construction and its lookup contract:
5
+ * - `buildMethodIndex` is called once at server startup.
6
+ * - `resolveMethod` is called once per inbound call.
7
+ *
8
+ * Each method is indexed under its `BoundMethod.ref`
9
+ * (`namespace.Owner.method.name`) and under a short alias `Owner.name`.
10
+ * The short alias is only registered when it is unambiguous — two methods
11
+ * whose owners live in different namespaces (e.g. class vs interface) and
12
+ * share the same local name would collide, and we refuse to register the
13
+ * ambiguous key rather than silently overwriting.
14
+ *
15
+ * Inbound names may arrive as `Path`, full `ref`, short alias, a tree path
16
+ * `/<domain>/<namespace.Owner>/<method>`, or the typed colon-form MethodPath
17
+ * `/:<domain>:<namespace.Owner>:<method>` (what the kernel forwards when a
18
+ * caller addresses the method in `:`-delimited form). All shapes normalize to
19
+ * the same lookup key.
20
+ */
21
+
22
+ import type { Path } from '@astrale-os/kernel-core'
23
+ import type { BoundMethod } from '@astrale-os/kernel-core/domain'
24
+
25
+ import type { AnyRemoteHandler } from '../method/single'
26
+
27
+ export type MethodIndex = Map<string, BoundMethod<AnyRemoteHandler>>
28
+
29
+ const NAMESPACE_PREFIXES = ['class.', 'interface.'] as const
30
+ const METHOD_INFIX = 'method'
31
+
32
+ export function buildMethodIndex(methods: BoundMethod<AnyRemoteHandler>[]): MethodIndex {
33
+ const index: MethodIndex = new Map()
34
+ const shortFormAmbiguous = new Set<string>()
35
+
36
+ for (const m of methods) {
37
+ index.set(m.ref, m)
38
+
39
+ const shortForm = `${m.owner}.${m.method}`
40
+ if (shortFormAmbiguous.has(shortForm)) continue
41
+ const existing = index.get(shortForm)
42
+ if (existing && existing !== m) {
43
+ index.delete(shortForm)
44
+ shortFormAmbiguous.add(shortForm)
45
+ continue
46
+ }
47
+ index.set(shortForm, m)
48
+ }
49
+
50
+ return index
51
+ }
52
+
53
+ export function resolveMethod(
54
+ index: MethodIndex,
55
+ name: Path | string,
56
+ ): BoundMethod<AnyRemoteHandler> | null {
57
+ return index.get(normalizeMethod(name)) ?? null
58
+ }
59
+
60
+ function normalizeMethod(name: Path | string): string {
61
+ const raw = typeof name === 'string' ? name : name.raw
62
+ if (!raw.startsWith('/')) return raw
63
+ // Typed colon-form MethodPath (`/:<domain>:<owner>:<method>`) vs. tree
64
+ // slash-form (`/<domain>/<owner>/<method>`). The owner segment carries its own
65
+ // `.` (e.g. `interface.NoteOps`), so the path separator is unambiguous.
66
+ const separator = raw.startsWith('/:') ? ':' : '/'
67
+ const segments = raw
68
+ .replace(/^\/+/, '')
69
+ .split(separator)
70
+ .filter((s) => s.length > 0)
71
+ if (segments.length < 3) return raw
72
+ const owner = segments[segments.length - 2]!
73
+ const method = segments[segments.length - 1]!
74
+ if (NAMESPACE_PREFIXES.some((p) => owner.startsWith(p))) {
75
+ return `${owner}.${METHOD_INFIX}.${method}`
76
+ }
77
+ return `${owner}.${method}`
78
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Self resolution — pipeline step 3.
3
+ *
4
+ * `_self` is a path reference to the node the non-static method runs on.
5
+ * Currently a thin parse: the caller's string becomes a `Path`, and handlers
6
+ * build recursive paths as `${self.path}::method` (the `Path`'s `toString`
7
+ * yields its raw form, so template literals work unchanged).
8
+ *
9
+ * `resolveSelf` receives the bare self target (`@<id>` or a tree path) — the
10
+ * method ref travels in a separate dispatch channel, so `::method` never
11
+ * reaches here. For the `@<id>` form the parsed `Path` is an `IdPath` which
12
+ * already carries the node id; surfacing it on `SelfResult.id` saves every
13
+ * worker handler from either parsing `self.path.raw` by hand or doing a
14
+ * `Node::get` round-trip just to recover the id of the node it's already
15
+ * operating on.
16
+ *
17
+ * Kept as a distinct step so a future implementation can do a real
18
+ * resolution here (e.g. hydrate a node/accessor) without touching callers.
19
+ */
20
+
21
+ import { IdPath, Path, type NodeId } from '@astrale-os/kernel-core'
22
+
23
+ export type SelfResult = {
24
+ path: Path
25
+ /** Set when `path` is an `IdPath` (i.e. `@<id>::method` calls). */
26
+ id?: NodeId
27
+ }
28
+
29
+ export function resolveSelf(ref: string): SelfResult {
30
+ const path = Path.parse(ref)
31
+ return path instanceof IdPath ? { path, id: path.id } : { path }
32
+ }