@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
package/README.md ADDED
@@ -0,0 +1,42 @@
1
+ # SDK
2
+
3
+ Astrale Remote Domain SDK — define and deploy domains as standalone Hono servers.
4
+
5
+ [![CI](https://github.com/astrale-os/sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/astrale-os/sdk/actions/workflows/ci.yml)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ npm install @astrale-os/sdk
12
+ ```
13
+
14
+ ## Development
15
+
16
+ ### Prerequisites
17
+
18
+ - Node.js 22+
19
+ - pnpm 10+
20
+
21
+ ### Setup
22
+
23
+ The SDK is consumed as a submodule of the main Astrale workspace:
24
+
25
+ ```bash
26
+ git clone --recursive https://github.com/astrale-os/workspace.git
27
+ cd workspace
28
+ pnpm install
29
+ ```
30
+
31
+ ### Commands
32
+
33
+ ```bash
34
+ pnpm typecheck # Type check
35
+ pnpm test # Run tests
36
+ pnpm lint # Lint
37
+ pnpm format # Format
38
+ ```
39
+
40
+ ## License
41
+
42
+ MIT License — see [LICENSE](LICENSE) for details.
package/package.json ADDED
@@ -0,0 +1,101 @@
1
+ {
2
+ "name": "@astrale-os/sdk",
3
+ "version": "0.1.0",
4
+ "description": "Astrale Remote Domain SDK - Define and deploy domains as standalone Hono servers",
5
+ "keywords": [
6
+ "astrale",
7
+ "domain",
8
+ "hono",
9
+ "remote",
10
+ "sdk"
11
+ ],
12
+ "license": "MIT",
13
+ "author": "Astrale",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/astrale-os/sdk.git"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "src"
21
+ ],
22
+ "type": "module",
23
+ "main": "./dist/index.js",
24
+ "types": "./dist/index.d.ts",
25
+ "exports": {
26
+ ".": {
27
+ "types": "./dist/index.d.ts",
28
+ "import": "./dist/index.js"
29
+ },
30
+ "./server": {
31
+ "types": "./dist/server/index.d.ts",
32
+ "import": "./dist/server/index.js"
33
+ },
34
+ "./deploy": {
35
+ "types": "./dist/deploy/index.d.ts",
36
+ "import": "./dist/deploy/index.js"
37
+ }
38
+ },
39
+ "publishConfig": {
40
+ "registry": "https://npm.pkg.github.com"
41
+ },
42
+ "dependencies": {
43
+ "@astrale-os/kernel-api": ">=0.4.0 <1.0.0",
44
+ "@astrale-os/kernel-client": ">=0.1.0 <1.0.0",
45
+ "@astrale-os/kernel-core": ">=0.3.0 <1.0.0",
46
+ "@astrale-os/kernel-dsl": ">=0.1.0 <1.0.0",
47
+ "@astrale-os/kernel-server": ">=0.4.0 <1.0.0",
48
+ "hono": "^4.6.20",
49
+ "jose": "^6.1.3",
50
+ "zod": "^4.3.6"
51
+ },
52
+ "devDependencies": {
53
+ "@astrale-os/kernel-host": ">=0.4.0 <1.0.0",
54
+ "@astrale-os/kernel-test": ">=0.4.0 <1.0.0",
55
+ "@astrale-os/ox": ">=0.1.0 <1.0.0",
56
+ "@astrale/commitlint-config": "npm:@jsr/astrale__commitlint-config@~2.0.0",
57
+ "@astrale/typescript-config": "npm:@jsr/astrale__typescript-config@~1.1.0",
58
+ "@commitlint/cli": "~20.3.1",
59
+ "@commitlint/config-conventional": "~20.3.1",
60
+ "@hono/node-server": "^1.19.12",
61
+ "@types/node": "^22.0.0",
62
+ "@typescript/native-preview": "latest",
63
+ "husky": "~9.1.7",
64
+ "lint-staged": "~16.2.7",
65
+ "oxfmt": "latest",
66
+ "oxlint": "latest",
67
+ "typescript": "~6.0.1-rc",
68
+ "vitest": "^3.0.0"
69
+ },
70
+ "peerDependencies": {
71
+ "@hono/node-server": "^1.19.0"
72
+ },
73
+ "peerDependenciesMeta": {
74
+ "@hono/node-server": {
75
+ "optional": true
76
+ }
77
+ },
78
+ "lint-staged": {
79
+ "*.{js,cjs,mjs,ts,tsx}": [
80
+ "oxlint --fix",
81
+ "oxfmt --write"
82
+ ],
83
+ "*.{json,yml,yaml}": [
84
+ "oxfmt --write"
85
+ ]
86
+ },
87
+ "engines": {
88
+ "node": ">=22"
89
+ },
90
+ "scripts": {
91
+ "preinstall": "node .check-workspace.cjs",
92
+ "build": "tsgo",
93
+ "typecheck": "tsgo --noEmit",
94
+ "test": "vitest run --config __tests__/vitest.config.ts",
95
+ "test:watch": "vitest --config __tests__/vitest.config.ts",
96
+ "lint": "oxlint .",
97
+ "lint:fix": "oxlint --fix .",
98
+ "format": "pnpm exec oxfmt --write .",
99
+ "format:check": "pnpm exec oxfmt --check ."
100
+ }
101
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Authentication orchestrator.
3
+ *
4
+ * Verifies an inbound credential, extracts the delegation it carries,
5
+ * and binds a `BoundClientSessionView` for outbound kernel calls. Delegates
6
+ * the real work to `verify`, `compose` + `sign` (inside `kernel-client`),
7
+ * and returns a generic `Authenticated` result plus the bound view.
8
+ */
9
+
10
+ import type { FnMap } from '@astrale-os/kernel-client'
11
+ import type { BoundClientSessionView } from '@astrale-os/kernel-client/session'
12
+ import type { Authenticated, CredentialInput } from '@astrale-os/kernel-core'
13
+
14
+ import { IdentityId, selfGrant } from '@astrale-os/kernel-core'
15
+
16
+ import type { RemoteIdentityConfig } from './identity'
17
+
18
+ import { bindKernel } from './kernel-client'
19
+ import { verifyInboundCredential } from './verify'
20
+
21
+ export type AuthenticateResult = {
22
+ authenticated: Authenticated
23
+ kernel: BoundClientSessionView<FnMap> | null
24
+ }
25
+
26
+ /**
27
+ * Verifies an inbound credential and binds a call-back kernel view. The `sub`
28
+ * claim on the outbound credential is the function's own identity, taken from
29
+ * `config.subject` (the function node's path), so the kernel matches an
30
+ * existing function identity instead of provisioning a generic one.
31
+ */
32
+ export async function authenticateRequest(
33
+ credential: CredentialInput,
34
+ config: RemoteIdentityConfig,
35
+ ): Promise<AuthenticateResult> {
36
+ const { verified, issuer, attestation, delegation } = await verifyInboundCredential(
37
+ credential,
38
+ config,
39
+ )
40
+
41
+ const authenticated: Authenticated = {
42
+ credential: { raw: credential, verified },
43
+ grant: selfGrant(IdentityId(verified.sub)),
44
+ attestation,
45
+ delegation,
46
+ }
47
+
48
+ const kernel = await bindKernel(delegation, issuer, config)
49
+
50
+ return { authenticated, kernel }
51
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Permission-check helpers for `RemoteHandler.authorize` hooks.
3
+ *
4
+ * The kernel already enforces `has_perm` independently — these helpers just
5
+ * give worker authors a one-line ergonomic way to fail-fast in `authorize`
6
+ * (so the dispatch never even calls `execute`) instead of letting the
7
+ * downstream `kernel.call` raise a less specific error mid-flight.
8
+ *
9
+ * Pattern:
10
+ *
11
+ * ```ts
12
+ * remoteMethod(WorkerSchema, 'Project', 'addMember', {
13
+ * remoteUrl: BASE_URL,
14
+ * authorize: async ({ self, auth, kernel }) => {
15
+ * await assertPerm(kernel, self.path.raw, auth.principal, EDIT)
16
+ * },
17
+ * execute: async (ctx) => { ... },
18
+ * })
19
+ * ```
20
+ *
21
+ * Helpers throw `AuthorizationDeniedError` so the dispatch wrapper can
22
+ * surface them to the client as `PERMISSION_DENIED` cleanly.
23
+ */
24
+
25
+ import type { FnMap } from '@astrale-os/kernel-client'
26
+ import type { BoundClientSessionView } from '@astrale-os/kernel-client/session'
27
+ import type { IdentityId } from '@astrale-os/kernel-core'
28
+
29
+ import { SHARE } from '@astrale-os/kernel-core'
30
+
31
+ import { AuthorizationDeniedError } from '../dispatch/errors'
32
+
33
+ export { ALL, EDIT, READ, SHARE, USE } from '@astrale-os/kernel-core'
34
+
35
+ /**
36
+ * Throws `AuthorizationDeniedError` if `principal` lacks `requiredBits` on
37
+ * `target`. `requiredBits` is a bitmask — pass `READ | EDIT` to require both.
38
+ *
39
+ * Implementation: calls `@<principal>::checkPerm` on the kernel (the
40
+ * `checkPerm` syscall lives on the Identity, not the target). Cheap; adds
41
+ * one round-trip to the dispatch path.
42
+ */
43
+ export async function assertPerm(
44
+ kernel: BoundClientSessionView<FnMap> | null,
45
+ target: string,
46
+ principal: IdentityId | null | undefined,
47
+ requiredBits: number,
48
+ ): Promise<void> {
49
+ if (!kernel) {
50
+ throw new AuthorizationDeniedError('No kernel client — cannot verify permissions')
51
+ }
52
+ if (!principal) {
53
+ throw new AuthorizationDeniedError('No authenticated principal')
54
+ }
55
+ const ok = (await kernel.call(`@${principal}::checkPerm`, {
56
+ node: target,
57
+ perms: requiredBits,
58
+ })) as boolean
59
+ if (!ok) {
60
+ throw new AuthorizationDeniedError(
61
+ `Permission denied on "${target}" — required bits=${requiredBits} for principal "${principal}"`,
62
+ )
63
+ }
64
+ }
65
+
66
+ /** Shortcut for "caller has SHARE bit on target" (the closest thing to "owns it"). */
67
+ export async function requireOwnership(
68
+ kernel: BoundClientSessionView<FnMap> | null,
69
+ target: string,
70
+ principal: IdentityId | null | undefined,
71
+ ): Promise<void> {
72
+ await assertPerm(kernel, target, principal, SHARE)
73
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Outbound grant expression building.
3
+ *
4
+ * Builds a composed grant expression for kernel calls:
5
+ * grant = union(credential(delegationJWT), self)
6
+ *
7
+ * The kernel resolves this by verifying the delegation JWT (kernel-signed)
8
+ * to get the caller's scoped identity, and resolving self to the function's
9
+ * identity. Union means either identity's permissions work.
10
+ */
11
+
12
+ import type { Delegation } from '@astrale-os/kernel-core'
13
+
14
+ import {
15
+ createUnresolvedGrant,
16
+ unresolvedCredential,
17
+ unresolvedSelf,
18
+ unresolvedUnion,
19
+ } from '@astrale-os/kernel-core'
20
+
21
+ /**
22
+ * Build the grant expression that unions the caller's delegated access
23
+ * with the function's own identity.
24
+ *
25
+ * @param delegation - Delegation extracted from the inbound credential
26
+ * @returns The unresolved grant object with version and expression
27
+ */
28
+ export function buildComposedGrant(delegation: Delegation) {
29
+ const expr = unresolvedUnion(unresolvedCredential(delegation.credential), unresolvedSelf())
30
+ return { grant: createUnresolvedGrant(expr) }
31
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Errors thrown by the auth bridge.
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
+ export class AuthMissingError extends Error implements KernelErrorClassifiable {
13
+ constructor() {
14
+ super('Missing credentials')
15
+ this.name = 'AuthMissingError'
16
+ }
17
+
18
+ toKernelErrorPayload(): KernelErrorPayload {
19
+ return { code: KERNEL_ERROR_CODES.AUTH_MISSING, message: this.message }
20
+ }
21
+ }
22
+
23
+ export class AuthInvalidError extends Error implements KernelErrorClassifiable {
24
+ constructor(message: string, cause?: unknown) {
25
+ super(message, { cause })
26
+ this.name = 'AuthInvalidError'
27
+ }
28
+
29
+ toKernelErrorPayload(): KernelErrorPayload {
30
+ return { code: KERNEL_ERROR_CODES.AUTH_INVALID, message: this.message }
31
+ }
32
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Identity configuration for a remote domain server.
3
+ *
4
+ * The issuer must be reachable via JWKS at `<issuer>/.well-known/jwks.json`
5
+ * (or registered in the kernel's trust store). The private key is used to
6
+ * sign outbound composed credentials.
7
+ */
8
+ export type RemoteIdentityConfig = {
9
+ /** This service's issuer (URL where its JWKS is served, or a registered ID) */
10
+ issuer: string
11
+ /** This service's subject identifier (e.g. "task-service") */
12
+ subject: string
13
+ /** Private key for signing outbound credentials */
14
+ privateKey: JsonWebKey
15
+ }
@@ -0,0 +1,11 @@
1
+ export type { RemoteIdentityConfig } from './identity'
2
+ export type { VerifiedInbound } from './verify'
3
+ export type { AuthenticateResult } from './authenticate'
4
+ export { authenticateRequest } from './authenticate'
5
+ export { resolveInboundAuth, buildAuthContext, type ResolvedAuth } from './resolve'
6
+ export { verifyInboundCredential } from './verify'
7
+ export { buildComposedGrant } from './compose'
8
+ export { signCredential } from './sign'
9
+ export { bindKernel } from './kernel-client'
10
+ export { AuthMissingError, AuthInvalidError } from './errors'
11
+ export { assertPerm, requireOwnership, READ, EDIT, USE, SHARE, ALL } from './check'
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Call-back kernel client.
3
+ *
4
+ * Every authenticated inbound request produces a `BoundClientSessionView` the
5
+ * handler uses to call back into the parent kernel. The view is bound to
6
+ * a composed credential (`union(delegation, self)`) so the kernel
7
+ * enforces both the caller's scoped identity and the function's own.
8
+ *
9
+ * The connection pool and schema registry are cached per kernel URL and reused
10
+ * across requests. The `ClientSession` itself is per-request: it carries a
11
+ * delegation mint bound to the calling function's own subject (`config.subject`)
12
+ * so a remote-bound call (one that redirects to another worker) mints a
13
+ * worker-scoped credential for the audience the kernel puts on the redirect
14
+ * (`CallRedirection.iss`) — the worker→worker dance, done reactively instead of
15
+ * the old proactive `lookupRemoteBinding` resolve-then-dial.
16
+ */
17
+
18
+ import type { Delegation } from '@astrale-os/kernel-core'
19
+
20
+ import { KernelClient, SchemaRegistry, type FnMap } from '@astrale-os/kernel-client'
21
+ import { ClientPool } from '@astrale-os/kernel-client/pool'
22
+ import { ClientSession, type BoundClientSessionView } from '@astrale-os/kernel-client/session'
23
+
24
+ import type { RemoteIdentityConfig } from './identity'
25
+
26
+ import { buildComposedGrant } from './compose'
27
+ import { signCredential } from './sign'
28
+
29
+ const DELEGATION_TTL_SECONDS = 3600
30
+
31
+ // Shared per kernel URL — the expensive, identity-agnostic state. Sessions are
32
+ // NOT shared (each binds a subject-specific delegation mint), but the pool
33
+ // (connections) and registry (learned schemas) are reused across them.
34
+ const pools = new Map<string, ClientPool<FnMap>>()
35
+ const registries = new Map<string, SchemaRegistry>()
36
+
37
+ function getRegistry(url: string): SchemaRegistry {
38
+ let registry = registries.get(url)
39
+ if (!registry) {
40
+ registry = new SchemaRegistry()
41
+ registries.set(url, registry)
42
+ }
43
+ return registry
44
+ }
45
+
46
+ function getPool(url: string): ClientPool<FnMap> {
47
+ const cached = pools.get(url)
48
+ if (cached) return cached
49
+ const registry = getRegistry(url)
50
+ const pool = new ClientPool<FnMap>({
51
+ clientFactory: (u) => new KernelClient<FnMap>({ url: u, schema: registry }),
52
+ })
53
+ pools.set(url, pool)
54
+ return pool
55
+ }
56
+
57
+ /**
58
+ * Build a `BoundClientSessionView` that signs outbound calls as the composed
59
+ * identity (the caller's delegation unioned with this function's own
60
+ * identity). Remote-bound calls auto-follow the kernel's redirect and mint a
61
+ * worker-scoped delegation via `@<subject>::mintDelegationCredential`.
62
+ */
63
+ export async function bindKernel(
64
+ delegation: Delegation,
65
+ kernelUrl: string,
66
+ config: RemoteIdentityConfig,
67
+ ): Promise<BoundClientSessionView<FnMap>> {
68
+ const { grant } = buildComposedGrant(delegation)
69
+ const composed = await signCredential(
70
+ { grant },
71
+ {
72
+ issuer: config.issuer,
73
+ subject: config.subject,
74
+ audience: kernelUrl,
75
+ privateKey: config.privateKey,
76
+ },
77
+ )
78
+
79
+ // Self-reference in the mint closure is lazy — it only fires on a delegation
80
+ // cache miss while following a redirect, long after construction.
81
+ const session: ClientSession<FnMap> = new ClientSession<FnMap>({
82
+ default: kernelUrl,
83
+ schema: getRegistry(kernelUrl),
84
+ pool: getPool(kernelUrl),
85
+ delegation: {
86
+ // `@<subject>::mintDelegationCredential` satisfies the syscall's
87
+ // `self.id === auth.principal` invariant (composed's subject IS the
88
+ // principal). `skipDelegation` keeps this mint from re-entering itself —
89
+ // it targets the kernel (same origin), so no delegation is needed.
90
+ mint: async (audience) => {
91
+ const envelope = await session.call(
92
+ `@${config.subject}::mintDelegationCredential`,
93
+ { audience, delegation: { kind: 'identity', self: true }, ttl: DELEGATION_TTL_SECONDS },
94
+ { credential: composed, skipDelegation: true },
95
+ )
96
+ if (typeof envelope !== 'string') {
97
+ throw new Error(
98
+ `mintDelegationCredential returned ${typeof envelope}, expected a credential string`,
99
+ )
100
+ }
101
+ return { credential: envelope, ttl: DELEGATION_TTL_SECONDS }
102
+ },
103
+ ttl: DELEGATION_TTL_SECONDS,
104
+ },
105
+ })
106
+ return session.as(composed)
107
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Shared inbound-credential resolution.
3
+ *
4
+ * Consumed by both `SdkDispatcher` (kernel envelope) and `mountAuxiliaryRoutes`
5
+ * (View / RemoteFunction routes). Centralises the auth-policy three-way
6
+ * (`'required'` / `'optional'` / `'public'`) and the wrap of underlying
7
+ * verification errors into canonical `AuthMissingError` / `AuthInvalidError`.
8
+ */
9
+
10
+ import type { AuthPolicy } from '@astrale-os/kernel-api/routed'
11
+ import type {
12
+ AuthContext,
13
+ Authenticated,
14
+ CredentialInput,
15
+ IdentityId,
16
+ } from '@astrale-os/kernel-core'
17
+
18
+ import { isKernelErrorClassifiable } from '@astrale-os/kernel-api'
19
+
20
+ import type { AuthenticateResult } from './authenticate'
21
+ import type { RemoteIdentityConfig } from './identity'
22
+
23
+ import { authenticateRequest } from './authenticate'
24
+ import { AuthInvalidError, AuthMissingError } from './errors'
25
+
26
+ export type ResolvedAuth = {
27
+ auth: AuthContext | null
28
+ kernel: AuthenticateResult['kernel']
29
+ }
30
+
31
+ export async function resolveInboundAuth(
32
+ credential: CredentialInput,
33
+ policy: AuthPolicy | undefined,
34
+ identity: RemoteIdentityConfig,
35
+ ): Promise<ResolvedAuth> {
36
+ const effective = policy ?? 'required'
37
+
38
+ if (effective === 'public') return { auth: null, kernel: null }
39
+ if (effective === 'optional' && !credential) return { auth: null, kernel: null }
40
+ if (effective === 'required' && !credential) throw new AuthMissingError()
41
+
42
+ try {
43
+ const result = await authenticateRequest(credential, identity)
44
+ return { auth: buildAuthContext(result.authenticated), kernel: result.kernel }
45
+ } catch (err) {
46
+ // kernel-core auth errors (UntrustedIssuerError, TrustPolicyDeniedError, …)
47
+ // already self-classify with a discriminating `data.type`. Rethrow them
48
+ // unchanged so that classification survives to the wire; only wrap
49
+ // genuinely unclassified errors into the generic AuthInvalidError.
50
+ if (isKernelErrorClassifiable(err)) throw err
51
+ throw new AuthInvalidError(err instanceof Error ? err.message : 'Authentication failed', err)
52
+ }
53
+ }
54
+
55
+ export function buildAuthContext(authenticated: Authenticated): AuthContext {
56
+ return {
57
+ credential: authenticated.credential,
58
+ principal: authenticated.credential.verified.sub as unknown as IdentityId,
59
+ grant: authenticated.grant!,
60
+ attestation: authenticated.attestation,
61
+ delegation: authenticated.delegation,
62
+ }
63
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * JWT signing utilities for outbound composed credentials.
3
+ */
4
+
5
+ import { deriveAllowedAlgorithms } from '@astrale-os/kernel-core'
6
+ import { SignJWT, importJWK } from 'jose'
7
+
8
+ /**
9
+ * Sign a credential JWT with the given claims and identity config.
10
+ */
11
+ export async function signCredential(
12
+ claims: Record<string, unknown>,
13
+ config: {
14
+ issuer: string
15
+ subject: string
16
+ audience: string
17
+ privateKey: JsonWebKey
18
+ /** JWT lifetime as a jose-compatible string (e.g. '60s', '5m', '1h'). Default: '60s'. */
19
+ ttl?: string
20
+ },
21
+ ): Promise<string> {
22
+ const alg = deriveAllowedAlgorithms(config.privateKey)[0]
23
+ if (!alg) {
24
+ throw new Error(`Cannot derive algorithm from JWK: kty=${config.privateKey.kty}`)
25
+ }
26
+ const key = await importJWK(config.privateKey, alg)
27
+
28
+ return new SignJWT(claims)
29
+ .setProtectedHeader({ alg })
30
+ .setIssuer(config.issuer)
31
+ .setSubject(config.subject)
32
+ .setAudience(config.audience)
33
+ .setIssuedAt()
34
+ .setExpirationTime(config.ttl ?? '60s')
35
+ .sign(key)
36
+ }