@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,138 @@
1
+ /**
2
+ * Inbound credential verification.
3
+ *
4
+ * Uses kernel-core's credential verification infrastructure
5
+ * (CredentialMethodResolver, MethodRegistry) instead of manual JWT handling.
6
+ * Not tied to JWT — supports any credential method kernel-core provides.
7
+ */
8
+
9
+ import type {
10
+ Attestation,
11
+ CredentialInput,
12
+ Delegation,
13
+ IssuerId,
14
+ VerifiedCredential,
15
+ } from '@astrale-os/kernel-core'
16
+
17
+ import {
18
+ CredentialMethodResolver,
19
+ MethodRegistry,
20
+ verifyAudience,
21
+ verifyCredential,
22
+ } from '@astrale-os/kernel-core'
23
+ import { createLocalJWKSet, createRemoteJWKSet, type JWK } from 'jose'
24
+
25
+ import type { RemoteIdentityConfig } from './identity'
26
+
27
+ import { derivePublicJwk } from '../server/jwks'
28
+ import { canonicalizeServingUrl } from '../server/serving-url'
29
+
30
+ export type VerifiedInbound = {
31
+ /** The full verified credential (iss, sub, aud, claims) */
32
+ verified: VerifiedCredential
33
+ /** Issuer URL (from credential's iss claim) */
34
+ issuer: string
35
+ /** Attestation — proves caller can invoke this function */
36
+ attestation: Attestation
37
+ /** Delegation — scoped caller permissions as kernel-signed credential */
38
+ delegation: Delegation
39
+ }
40
+
41
+ const methodResolver = new CredentialMethodResolver(new MethodRegistry())
42
+
43
+ /**
44
+ * Build the key resolver for one verifying server. Captures `config` so it can
45
+ * short-circuit the server's OWN issuer (`config.issuer`): a self-issued
46
+ * credential is verified against the in-memory public key, never fetched — a
47
+ * Worker can't fetch its own hostname, and it already holds the key. Every other
48
+ * issuer is resolved live via JWKS (`createRemoteJWKSet`).
49
+ */
50
+ function makeResolveKeys(config: RemoteIdentityConfig) {
51
+ // The worker's own canonical iss. STRICT: `config.issuer` is the serving URL
52
+ // by contract (both producers — buildIdentityMap / buildAuxIdentityMap — feed
53
+ // it `canonicalizeServingUrl(config.url)`), so a value that doesn't parse is
54
+ // a construction-time config error. Swallowing it would silently disable
55
+ // self-verification and route self-issued credentials to a JWKS fetch on the
56
+ // worker's own hostname — which Cloudflare forbids — turning a config bug
57
+ // into an opaque per-call failure.
58
+ const selfIssuer = canonicalizeServingUrl(config.issuer)
59
+
60
+ // TODO(cache): Re-add JWKS caching with a short TTL or kid-miss retry.
61
+ // A prior implementation cached `createRemoteJWKSet` per issuer URL
62
+ // indefinitely. jose's internal cache (30s cooldown, 10min max-age) caused
63
+ // stale keys when the issuer restarted — the resolver served old keys and
64
+ // jose refused to refetch within the cooldown window. On CF Workers the
65
+ // module-level Map persisted across requests, making it worse. For now we
66
+ // create a fresh resolver per call (jose deduplicates concurrent fetches).
67
+ return async (issuer: IssuerId, _method: string, _kid?: string) => {
68
+ const url = issuer as string
69
+
70
+ // Self-issued credential (iss == this worker's own serving URL): resolve
71
+ // from the in-memory public key. Never fetch self — Cloudflare forbids a
72
+ // Worker fetching its own hostname, and the published JWKS is this key.
73
+ if (selfIssuer !== undefined) {
74
+ let canonical: string | undefined
75
+ try {
76
+ canonical = canonicalizeServingUrl(url)
77
+ } catch {
78
+ canonical = undefined
79
+ }
80
+ if (canonical === selfIssuer) {
81
+ return createLocalJWKSet({ keys: [derivePublicJwk(config.privateKey) as JWK] })
82
+ }
83
+ }
84
+
85
+ // M-28 fix: a bare-slug iss (`mails.localhost`) makes
86
+ // `new URL('mails.localhost/.well-known/jwks.json')` throw "Invalid
87
+ // URL string". The dispatcher's identity map normalizes the iss it
88
+ // signs with, but inbound creds from older clients may still carry
89
+ // the slug form. Coerce to a URL with a default `https://` scheme
90
+ // (matches kernel.astrale.ai canonical form). If the actual receiver
91
+ // is on http://localhost the receiver-side resolver still works
92
+ // because both endpoints are on localhost — but for prod targets
93
+ // requiring TLS this is the right default.
94
+ const normalized = /^https?:\/\//.test(url) ? url : `https://${url}`
95
+ return createRemoteJWKSet(new URL(`${normalized}/.well-known/jwks.json`))
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Verify an inbound delegation credential using kernel-core's verification.
101
+ *
102
+ * @throws AuthenticationError subclasses from kernel-core on verification failure
103
+ */
104
+ export async function verifyInboundCredential(
105
+ credential: CredentialInput,
106
+ config: RemoteIdentityConfig,
107
+ ): Promise<VerifiedInbound> {
108
+ // Verify using kernel-core's credential verification pipeline
109
+ const verified = await verifyCredential(
110
+ {
111
+ methodResolver,
112
+ resolveKeys: makeResolveKeys(config),
113
+ },
114
+ credential,
115
+ )
116
+
117
+ // Validate audience matches this function's issuer (its serving URL).
118
+ // kernel-core's verifyAudience compares canonically.
119
+ verifyAudience(verified, config.issuer as IssuerId)
120
+
121
+ // Extract attestation and delegation from claims
122
+ const attestation = verified.claims.attestation as Attestation | undefined
123
+ if (!attestation?.expr) {
124
+ throw new Error('Credential missing attestation')
125
+ }
126
+
127
+ const delegation = verified.claims.delegation as Delegation | undefined
128
+ if (!delegation?.credential) {
129
+ throw new Error('Credential missing delegation')
130
+ }
131
+
132
+ return {
133
+ verified,
134
+ issuer: verified.iss as string,
135
+ attestation,
136
+ delegation,
137
+ }
138
+ }
@@ -0,0 +1,9 @@
1
+ export { defineView } from './view'
2
+ export type { ViewDef, ViewRenderContext } from './view'
3
+
4
+ export { defineRemoteFunction } from './remote-function'
5
+ export type {
6
+ AnyRemoteFunctionDef,
7
+ RemoteFunctionContext,
8
+ RemoteFunctionDef,
9
+ } from './remote-function'
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Authoring a standalone remote function — a callable not bound to a class.
3
+ * (The verb keeps the `defineRemoteFunction` name; the node it materializes is
4
+ * the canonical kernel `Function` class, the former distribution `RemoteFunction`.)
5
+ *
6
+ * Each entry in `defineRemoteDomain({ remoteFunctions: { ... } })` becomes:
7
+ * - a graph node at `/${origin}/core/<functionsFolder>/<slug>`, materialized
8
+ * as the kernel `Function` class by `buildCorePath` so kernel discovery /
9
+ * `View.resolve` can list it. The `core/` anchor appears in the GRAPH path only.
10
+ * - a Hono route on the worker at the path implied by `binding`
11
+ * (`/<functionsFolder>/<slug>` POST by default — no `core/` in the URL).
12
+ *
13
+ * The slug = the map key (single source of truth, no duplication).
14
+ *
15
+ * `ref` is auto-derived as `function.<slug>` if omitted — used as
16
+ * `Function.ref` on the graph node and as the dispatch key.
17
+ */
18
+
19
+ import type { AuthPolicy, FunctionBinding } from '@astrale-os/kernel-api/routed'
20
+ import type { FnMap } from '@astrale-os/kernel-client'
21
+ import type { BoundClientSessionView } from '@astrale-os/kernel-client/session'
22
+ import type { AuthContext } from '@astrale-os/kernel-core'
23
+ import type { Context } from 'hono'
24
+ import type { z } from 'zod'
25
+
26
+ import type { CallRemoteFn } from '../dispatch/call-remote'
27
+
28
+ export type RemoteFunctionContext<TParams, TDeps = unknown> = {
29
+ /** Validated params (Zod-checked against `inputSchema`). */
30
+ params: TParams
31
+ /** Hono request context — escape hatch for headers, raw body, etc. */
32
+ c: Context
33
+ /** Resolved auth context. `null` when `auth: 'public'` or absent. */
34
+ auth: AuthContext | null
35
+ /** Typed dependency container injected at server startup. */
36
+ deps: TDeps
37
+ /**
38
+ * `BoundClientSessionView` to the parent kernel, bound to the composed
39
+ * credential `union(delegation, self)` — same shape as `RemoteContext.kernel`
40
+ * for `remoteMethod`. `null` when `auth: 'public'`, or `auth: 'optional'`
41
+ * with no inbound credential.
42
+ */
43
+ kernel: BoundClientSessionView<FnMap> | null
44
+ /**
45
+ * Call another worker's remote method, re-minting the credential for the
46
+ * target's audience. Same contract as {@link RemoteContext.callRemote}: throws
47
+ * on a public/unauthenticated request; needs `USE` on
48
+ * `Identity.mintDelegationCredential` + the target method's grants.
49
+ */
50
+ callRemote: CallRemoteFn
51
+ }
52
+
53
+ export type RemoteFunctionDef<TParams = unknown, TResult = unknown, TDeps = unknown> = {
54
+ /**
55
+ * Canonical callable identity. Auto-derived as `function.<slug>` (where
56
+ * `<slug>` is the map key) when omitted. Stored as `Function.ref` on the
57
+ * graph node and used by the kernel dispatcher to route the call.
58
+ */
59
+ ref?: string
60
+ /** Zod schema for the call's parameters. */
61
+ inputSchema: z.ZodType<TParams>
62
+ /** Zod schema for the call's result. */
63
+ outputSchema: z.ZodType<TResult>
64
+ /**
65
+ * Override the binding (URL + route shape). When absent, SDK defaults to
66
+ * `{ remoteUrl: ${url}/<functionsFolder>/<slug> }`. The HTTP verb (POST
67
+ * for functions) is applied by the worker route mounter at mount time — it is
68
+ * NOT stored on the binding, so the materialized `Function.binding` carries
69
+ * `remoteUrl` only.
70
+ *
71
+ * Use this to bind to a custom host or REST-style path. Host + path
72
+ * placeholders both supported.
73
+ */
74
+ binding?: FunctionBinding
75
+ /** Authentication policy. Defaults to `'required'`. */
76
+ auth?: AuthPolicy
77
+ /** Optional pre-execute authorization. Throw to deny. */
78
+ authorize?: (ctx: RemoteFunctionContext<TParams, TDeps>) => void | Promise<void>
79
+ /** The function body. May be async. */
80
+ execute: (ctx: RemoteFunctionContext<TParams, TDeps>) => TResult | Promise<TResult>
81
+ /** Optional human-readable description. */
82
+ description?: string
83
+ }
84
+
85
+ // oxlint-disable-next-line no-explicit-any
86
+ export type AnyRemoteFunctionDef = RemoteFunctionDef<any, any, any>
87
+
88
+ /**
89
+ * Identity helper for authoring a RemoteFunction. Returns its argument
90
+ * unchanged — `defineRemoteDomain` consumes the typed shape.
91
+ */
92
+ export function defineRemoteFunction<TParams, TResult, TDeps = unknown>(
93
+ def: RemoteFunctionDef<TParams, TResult, TDeps>,
94
+ ): RemoteFunctionDef<TParams, TResult, TDeps> {
95
+ return def
96
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Authoring a `View` — iframe-mountable callable served by the domain worker.
3
+ *
4
+ * Each entry in `defineRemoteDomain({ views: { ... } })` becomes both:
5
+ * - a graph node at `/${origin}/core/<viewsFolder>/<slug>` (auto-materialized
6
+ * by the SDK; the `core/` segment is the universal anchor injected by
7
+ * `buildCorePath`. `<slug>` is the map key, so it lives in exactly one place)
8
+ * - a Hono route on the worker at the path implied by `binding`
9
+ * (`/<viewsFolder>/<slug>` by default — note: no `core/` in the URL).
10
+ *
11
+ * The `core/` segment appears in the GRAPH path only; the URL path that
12
+ * lands in `Function.binding.remoteUrl` is `${url}/<viewsFolder>/<slug>`.
13
+ *
14
+ * The author can override the URL via `binding` — host and/or path
15
+ * placeholders are supported (the kernel's `route` mechanism does the
16
+ * substitution + Hono matching, same as for methods). When `render` is
17
+ * omitted, no worker route is mounted — useful for Views whose iframe
18
+ * lives at an external host.
19
+ */
20
+
21
+ import type { AuthPolicy, FunctionBinding } from '@astrale-os/kernel-api/routed'
22
+ import type { AuthContext } from '@astrale-os/kernel-core'
23
+ import type { EdgeEndpoint } from '@astrale-os/kernel-dsl'
24
+ import type { Context } from 'hono'
25
+
26
+ export type ViewRenderContext<TDeps = unknown> = {
27
+ /** Hono request context — read params, headers, query, etc. */
28
+ c: Context
29
+ /**
30
+ * Placeholders extracted from the request URL (host + path placeholders
31
+ * declared in `binding.remoteUrl` / `binding.route.path`). Empty when
32
+ * the binding has no placeholders.
33
+ */
34
+ params: Record<string, string>
35
+ /** Resolved auth context. `null` when `auth: 'public'` or absent. */
36
+ auth: AuthContext | null
37
+ /** Typed dependency container injected at server startup. */
38
+ deps: TDeps
39
+ }
40
+
41
+ export type ViewDef<TDeps = unknown> = {
42
+ /**
43
+ * Override the binding (URL + route shape). When absent, SDK defaults to
44
+ * `{ remoteUrl: ${url}/<viewsFolder>/<slug> }`. The HTTP verb (GET for
45
+ * views) is applied by the worker route mounter at mount time — it is NOT
46
+ * stored on the binding.
47
+ *
48
+ * Use this to bind to a custom host (e.g. `https://{tenant}.example.com`)
49
+ * or REST-style path. Host + path placeholders both supported.
50
+ */
51
+ binding?: FunctionBinding
52
+ /**
53
+ * Worker-relative path the View's iframe is served from (e.g. `'/ui/note'`),
54
+ * for Views backed by the client SPA instead of a `render`. The spec producer
55
+ * resolves it against the serving url (`binding.remoteUrl = ${url}${mount}`)
56
+ * at materialize time — so the dev never hardcodes a URL. Mutually exclusive
57
+ * with `render`; takes precedence over `binding`.
58
+ */
59
+ mount?: string
60
+ /** Authentication policy. Defaults to `'required'`. */
61
+ auth?: AuthPolicy
62
+ /**
63
+ * Optional pre-render authorization. Runs after auth resolution; throw to
64
+ * deny. SDK wraps as 403.
65
+ */
66
+ authorize?: (ctx: ViewRenderContext<TDeps>) => void | Promise<void>
67
+ /**
68
+ * Render the iframe response. May return a redirect, a proxy, or inline
69
+ * HTML. When omitted, no worker route is mounted — the binding URL
70
+ * points elsewhere (CDN, external service).
71
+ */
72
+ render?: (ctx: ViewRenderContext<TDeps>) => Response | Promise<Response>
73
+ /**
74
+ * Optional target(s) the View attaches to via `view_for` edge(s). Typically
75
+ * `selfOf(SomeClass)` to bind to a class meta-node, or a `CorePath`
76
+ * pointing at a specific instance. Pass an array to attach the same View
77
+ * to multiple targets (one `view_for` edge is materialized per entry).
78
+ */
79
+ viewFor?: EdgeEndpoint | EdgeEndpoint[]
80
+ /** Optional human-readable description. */
81
+ description?: string
82
+ }
83
+
84
+ /**
85
+ * Identity helper for authoring a View. Returns its argument unchanged —
86
+ * `defineRemoteDomain` consumes the typed shape and the author retains
87
+ * full type inference on `render` / `authorize`.
88
+ */
89
+ export function defineView<TDeps = unknown>(def: ViewDef<TDeps>): ViewDef<TDeps> {
90
+ return def
91
+ }
@@ -0,0 +1,124 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import { join } from 'node:path'
3
+
4
+ import type { Meta } from './meta'
5
+
6
+ import { MetaSchema } from './meta'
7
+
8
+ export type DeployCheckOptions = {
9
+ /** Deployed worker URL (e.g. `https://dist.astrale.ai`). */
10
+ url: string
11
+ /**
12
+ * Expected `meta.schemaHash` — the hash of the locally-built `spec.json`
13
+ * (via `hashSpecFile`). REQUIRED to verify schema drift: when the worker
14
+ * advertises a `schemaHash` but this is absent, the check fails loudly rather
15
+ * than silently "passing" an unverified deploy. Per-domain deploy scripts
16
+ * compute it (`hashSpecFile('./spec.json')`) and pass it.
17
+ */
18
+ expectedSchemaHash?: string
19
+ /**
20
+ * Path to the SDK repo used to read `sdkCommit`. If omitted and
21
+ * `workspaceRoot` is provided, defaults to `<workspaceRoot>/sdk`.
22
+ */
23
+ sdkRepoPath?: string
24
+ /** Astrale workspace root — enables auto-resolution of `sdkRepoPath` (`<root>/sdk`). */
25
+ workspaceRoot?: string
26
+ /** Sink for progress output. Defaults to `console.log`. */
27
+ log?: (line: string) => void
28
+ }
29
+
30
+ /**
31
+ * Validate a deployed worker against local state by fetching `/meta` and
32
+ * verifying sdkCommit / schemaHash / JWKS reachability.
33
+ *
34
+ * Throws on any mismatch. Returns `Meta` on success.
35
+ */
36
+ export async function deployCheck(opts: DeployCheckOptions): Promise<Meta> {
37
+ // oxlint-disable-next-line no-console
38
+ const log = opts.log ?? ((line: string) => console.log(line))
39
+ const base = opts.url.replace(/\/+$/, '')
40
+
41
+ log(`# deploy:check ${base}`)
42
+
43
+ const meta = await fetchMeta(base)
44
+ log(` meta: ${JSON.stringify(meta)}`)
45
+ // `meta.iss` is required + non-empty by `MetaSchema`, enforced in `fetchMeta`.
46
+
47
+ await Promise.all([
48
+ checkSdkCommit(meta, opts, log),
49
+ checkSchemaHash(meta, opts, log),
50
+ checkJwks(meta.iss, log),
51
+ ])
52
+
53
+ return meta
54
+ }
55
+
56
+ async function fetchMeta(base: string): Promise<Meta> {
57
+ const res = await fetch(`${base}/meta`)
58
+ if (!res.ok) throw new Error(`GET ${base}/meta → ${res.status}`)
59
+ return MetaSchema.parse(await res.json())
60
+ }
61
+
62
+ async function checkSdkCommit(
63
+ meta: Meta,
64
+ opts: DeployCheckOptions,
65
+ log: (line: string) => void,
66
+ ): Promise<void> {
67
+ if (!meta.sdkCommit) {
68
+ log(' ! meta.sdkCommit absent — skipping sdkCommit check')
69
+ return
70
+ }
71
+ const sdkRepo =
72
+ opts.sdkRepoPath ?? (opts.workspaceRoot ? join(opts.workspaceRoot, 'sdk') : undefined)
73
+ if (!sdkRepo) {
74
+ log(` ! no sdkRepoPath / workspaceRoot — skipping sdkCommit check`)
75
+ return
76
+ }
77
+ const localSha = gitHead(sdkRepo)
78
+ if (!localSha) {
79
+ log(` ! could not read git HEAD from ${sdkRepo} — skipping sdkCommit check`)
80
+ return
81
+ }
82
+ if (!localSha.startsWith(meta.sdkCommit) && !meta.sdkCommit.startsWith(localSha)) {
83
+ throw new Error(`sdkCommit mismatch: deployed=${meta.sdkCommit} local=${localSha} (${sdkRepo})`)
84
+ }
85
+ log(` ✓ sdkCommit ${meta.sdkCommit} matches local HEAD`)
86
+ }
87
+
88
+ async function checkSchemaHash(
89
+ meta: Meta,
90
+ opts: DeployCheckOptions,
91
+ log: (line: string) => void,
92
+ ): Promise<void> {
93
+ if (!meta.schemaHash) return
94
+ // Fail loud: the worker advertises a schemaHash, so a missing expected value
95
+ // means we CANNOT verify drift — never silently "pass" an unverified deploy.
96
+ if (!opts.expectedSchemaHash) {
97
+ throw new Error(
98
+ `worker advertises schemaHash=${meta.schemaHash} but no expectedSchemaHash was provided — ` +
99
+ `cannot verify schema drift (pass expectedSchemaHash, e.g. hashSpecFile('./spec.json'))`,
100
+ )
101
+ }
102
+ if (meta.schemaHash !== opts.expectedSchemaHash) {
103
+ throw new Error(
104
+ `schemaHash mismatch: deployed=${meta.schemaHash} local=${opts.expectedSchemaHash}`,
105
+ )
106
+ }
107
+ log(` ✓ schemaHash ${meta.schemaHash} matches local`)
108
+ }
109
+
110
+ async function checkJwks(iss: string, log: (line: string) => void): Promise<void> {
111
+ const jwksUrl = `${iss.replace(/\/+$/, '')}/.well-known/jwks.json`
112
+ const res = await fetch(jwksUrl)
113
+ if (!res.ok) throw new Error(`GET ${jwksUrl} → ${res.status}`)
114
+ const body = (await res.json()) as { keys?: Array<{ kid?: string }> }
115
+ const keys = body.keys ?? []
116
+ if (keys.length === 0) throw new Error(`${jwksUrl} returned no keys`)
117
+ log(` ✓ JWKS resolves (${keys.length} key${keys.length === 1 ? '' : 's'})`)
118
+ }
119
+
120
+ function gitHead(repoPath: string): string | null {
121
+ const r = spawnSync('git', ['-C', repoPath, 'rev-parse', 'HEAD'], { encoding: 'utf-8' })
122
+ if (r.status !== 0) return null
123
+ return r.stdout.trim()
124
+ }
@@ -0,0 +1,31 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { readFileSync } from 'node:fs'
3
+
4
+ const HASH_ALGORITHM = 'sha256'
5
+
6
+ /**
7
+ * Hash a `spec.json` file deterministically. Only the schema payload
8
+ * (`nodes` + `edges`) is hashed — the `meta` block carries `builtAt`
9
+ * timestamps that would poison drift detection.
10
+ *
11
+ * Returns `sha256:<hex>`.
12
+ */
13
+ export function hashSpecFile(path: string): string {
14
+ const raw = readFileSync(path, 'utf-8')
15
+ const parsed = JSON.parse(raw) as { nodes?: unknown; edges?: unknown }
16
+ const canonical = canonicalJson({ nodes: parsed.nodes ?? [], edges: parsed.edges ?? [] })
17
+ return `${HASH_ALGORITHM}:${createHash(HASH_ALGORITHM).update(canonical).digest('hex')}`
18
+ }
19
+
20
+ function canonicalJson(value: unknown): string {
21
+ if (value === null || typeof value !== 'object') return JSON.stringify(value)
22
+ if (Array.isArray(value)) return '[' + value.map(canonicalJson).join(',') + ']'
23
+ const keys = Object.keys(value as Record<string, unknown>).sort()
24
+ return (
25
+ '{' +
26
+ keys
27
+ .map((k) => JSON.stringify(k) + ':' + canonicalJson((value as Record<string, unknown>)[k]))
28
+ .join(',') +
29
+ '}'
30
+ )
31
+ }
@@ -0,0 +1,3 @@
1
+ export { MetaSchema, type Meta } from './meta'
2
+ export { hashSpecFile } from './hash-spec'
3
+ export { deployCheck, type DeployCheckOptions } from './check'
@@ -0,0 +1,25 @@
1
+ /**
2
+ * `/meta` payload — the contract between `createRemoteServer` (producer)
3
+ * and `deployCheck` (verifier). Both sides parse through `MetaSchema`, so
4
+ * a new field added here flows to both paths and drift is caught at the
5
+ * boundary rather than silently skipped.
6
+ *
7
+ * Scope: **domain workers only**. Kernels rely on OIDC discovery
8
+ * (`/.well-known/openid-configuration`) + JWKS — they have no `/meta`.
9
+ */
10
+
11
+ import { z } from 'zod'
12
+
13
+ export const MetaSchema = z.object({
14
+ /** Base URL where JWKS is published. Always stamped by `createRemoteServer`
15
+ * and required by `deployCheck` to verify JWKS reachability. */
16
+ iss: z.string().min(1),
17
+ /** Short git SHA of the SDK used to build the worker. */
18
+ sdkCommit: z.string().optional(),
19
+ /** Deterministic hash of the compiled spec (`sha256:<hex>`). */
20
+ schemaHash: z.string().optional(),
21
+ /** Local directory name under `kernel/domains/<...>/` — used to auto-resolve the local spec. */
22
+ domainName: z.string().optional(),
23
+ })
24
+
25
+ export type Meta = z.infer<typeof MetaSchema>
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Shared `authorize`-hook runner.
3
+ *
4
+ * Both the method dispatcher (`dispatcher.ts`) and the worker-side aux routes
5
+ * (`server/auxiliary-routes.ts`) let an author deny a call by throwing from an
6
+ * `authorize` hook. They MUST agree on the wire semantics: an already-typed
7
+ * `AuthorizationDeniedError` passes through unchanged (so callers can attach
8
+ * hints), and any other thrown error is wrapped into `AuthorizationDeniedError`
9
+ * (→ `PERMISSION_DENIED` / HTTP 403). Centralised here so the two call sites
10
+ * cannot drift — previously the aux routes awaited `authorize` directly, so a
11
+ * plain `Error` thrown to deny leaked out as a 500 instead of a 403.
12
+ */
13
+
14
+ import { AuthorizationDeniedError } from './errors'
15
+
16
+ export async function runAuthorize<C>(
17
+ hook: (ctx: C) => void | Promise<void>,
18
+ ctx: C,
19
+ ): Promise<void> {
20
+ try {
21
+ await hook(ctx)
22
+ } catch (err) {
23
+ if (err instanceof AuthorizationDeniedError) throw err
24
+ throw new AuthorizationDeniedError(
25
+ err instanceof Error ? err.message : 'Authorization denied',
26
+ err,
27
+ )
28
+ }
29
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * `ctx.callRemote` — call ANOTHER worker's remote method from a handler.
3
+ *
4
+ * A worker's `kernel.call('/dom/class.X/method', …)` signs `aud = <kernel>`,
5
+ * which is correct for a **kernel syscall** (it runs in the kernel) but is
6
+ * rejected by a **remote method** (a Function with `binding.remoteUrl`, i.e.
7
+ * another worker) — the target worker verifies `aud` against its own identity
8
+ * and throws `Credential audience mismatch` at authentication.
9
+ *
10
+ * `callRemote` is now a thin wrapper over the bound kernel session: the kernel
11
+ * resolves a remote-bound path to a redirect carrying the worker's URL and its
12
+ * `iss`, and the session (configured in `bindKernel`) follows it reactively —
13
+ * minting a kernel-signed delegation envelope scoped to that `iss` via the
14
+ * caller's own `@<subject>::mintDelegationCredential`, then dialing the worker.
15
+ * Instance-method `self` is injected by the kernel resolver into the redirect,
16
+ * so a bare `kernel.call(path, params)` is all that's needed here.
17
+ *
18
+ * Prerequisite (authorization, unchanged): the calling Function's identity must
19
+ * hold `USE` on `Identity.mintDelegationCredential` AND the target method's own
20
+ * grants — the session only fixes the audience (authentication), not grants.
21
+ */
22
+
23
+ import type { ClientSessionCallOptions } from '@astrale-os/kernel-client/session'
24
+
25
+ /** Minimal kernel-call surface callRemote needs (the bound view satisfies it). */
26
+ type KernelCaller = {
27
+ call: (method: string, params: unknown, opts?: ClientSessionCallOptions) => Promise<unknown>
28
+ }
29
+
30
+ export type CallRemoteFn = (path: string, params?: Record<string, unknown>) => Promise<unknown>
31
+
32
+ /**
33
+ * Build the `callRemote` helper for one handler invocation. `kernel` is the
34
+ * handler's bound kernel view (or `null` for a public/unauthenticated request,
35
+ * where `callRemote` cannot mint and throws on use).
36
+ */
37
+ export function makeCallRemote(kernel: KernelCaller | null): CallRemoteFn {
38
+ return async (path, params = {}) => {
39
+ if (!kernel) {
40
+ throw new Error(
41
+ 'callRemote requires an authenticated request — no kernel credential (public method?)',
42
+ )
43
+ }
44
+ // The bound session auto-follows the kernel's redirect and mints for the
45
+ // worker's `iss`; a remote-bound path therefore just works as a kernel.call.
46
+ return kernel.call(path, params)
47
+ }
48
+ }