@astrale-os/kernel-client 0.6.0-beta.71 → 0.6.0-beta.74

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 (60) hide show
  1. package/dist/auth/credential.d.ts +6 -1
  2. package/dist/auth/credential.js +2 -2
  3. package/dist/client/client.d.ts +4 -2
  4. package/dist/client/client.js +13 -2
  5. package/dist/client/credential.d.ts +4 -2
  6. package/dist/client/credential.js +7 -2
  7. package/dist/client/index.d.ts +1 -1
  8. package/dist/client/index.js +1 -1
  9. package/dist/client/request.d.ts +7 -1
  10. package/dist/client/request.js +31 -6
  11. package/dist/index.d.ts +1 -1
  12. package/dist/schema/introspection.js +4 -0
  13. package/dist/session/admission.d.ts +3 -15
  14. package/dist/session/admission.js +16 -74
  15. package/dist/session/bound.d.ts +10 -8
  16. package/dist/session/bound.js +20 -3
  17. package/dist/session/content.d.ts +30 -0
  18. package/dist/session/content.js +160 -0
  19. package/dist/session/context/operation.d.ts +5 -1
  20. package/dist/session/credential/index.d.ts +1 -1
  21. package/dist/session/credential/index.js +1 -1
  22. package/dist/session/credential/provider.d.ts +5 -0
  23. package/dist/session/credential/provider.js +8 -1
  24. package/dist/session/exchange/domain.d.ts +27 -0
  25. package/dist/session/exchange/domain.js +63 -0
  26. package/dist/session/exchange/exchange.d.ts +54 -0
  27. package/dist/session/exchange/exchange.js +58 -0
  28. package/dist/session/exchange/index.d.ts +5 -0
  29. package/dist/session/exchange/index.js +5 -0
  30. package/dist/session/exchange/issuer.d.ts +18 -0
  31. package/dist/session/exchange/issuer.js +89 -0
  32. package/dist/session/exchange/limits.d.ts +10 -0
  33. package/dist/session/exchange/limits.js +10 -0
  34. package/dist/session/exchange/options.d.ts +29 -0
  35. package/dist/session/exchange/options.js +57 -0
  36. package/dist/session/exchange/presentation.d.ts +18 -0
  37. package/dist/session/exchange/presentation.js +73 -0
  38. package/dist/session/exchange/source.d.ts +34 -0
  39. package/dist/session/exchange/source.js +78 -0
  40. package/dist/session/execution.d.ts +65 -0
  41. package/dist/session/execution.js +35 -0
  42. package/dist/session/index.d.ts +5 -2
  43. package/dist/session/index.js +1 -0
  44. package/dist/session/reference.d.ts +13 -4
  45. package/dist/session/routing/auth.d.ts +10 -0
  46. package/dist/session/routing/auth.js +8 -1
  47. package/dist/session/routing/dispatch.d.ts +12 -1
  48. package/dist/session/routing/dispatch.js +73 -25
  49. package/dist/session/routing/index.d.ts +2 -2
  50. package/dist/session/routing/index.js +1 -1
  51. package/dist/session/routing/request.d.ts +9 -0
  52. package/dist/session/routing/request.js +17 -0
  53. package/dist/session/session.d.ts +26 -3
  54. package/dist/session/session.js +59 -14
  55. package/dist/session/session.typecheck.js +71 -2
  56. package/dist/transport/http/byte-stream.d.ts +2 -1
  57. package/dist/transport/http/byte-stream.js +9 -1
  58. package/dist/transport/http/transport.d.ts +3 -0
  59. package/dist/transport/http/transport.js +42 -11
  60. package/package.json +5 -5
@@ -0,0 +1,160 @@
1
+ import { NodeId } from '@astrale-os/kernel-core/graph/node';
2
+ import { Path } from '@astrale-os/kernel-core/path';
3
+ import { K } from '@astrale-os/kernel-core/schema';
4
+ import { download } from '@astrale-os/kernel-core/schema/kernel/syscalls/references';
5
+ import { PropertyKey } from '@astrale-os/kernel-dsl/v1/addressing';
6
+ import * as artifact from '@astrale-os/kernel-protocol/artifact';
7
+ import { CODES } from '@astrale-os/kernel-protocol/errors';
8
+ import { ClientError, ProtocolError, ResponseError, TransportError } from '../errors/index.js';
9
+ import { admitControlOptions, runSessionOperation } from './context/index.js';
10
+ import { reference } from './reference.js';
11
+ import { requireResultKind } from './routing/index.js';
12
+ const UPLOAD = reference(K, K.functions.upload);
13
+ const DOWNLOAD = Path.project(download);
14
+ const CONTENT_TIMEOUT_MS = 30 * 60 * 1_000;
15
+ const MAXIMUM_ATTEMPTS = 3;
16
+ /** Transfer immutable content over the authenticated Session routing conversation. */
17
+ export function createContentApi(dependencies) {
18
+ async function upload(source, options) {
19
+ const body = admitBody(source);
20
+ const mediaType = admitMediaType(options?.mediaType);
21
+ const operation = dependencies.operation(contentControls(options));
22
+ let last;
23
+ for (let attempt = 0; attempt < MAXIMUM_ATTEMPTS; attempt++) {
24
+ try {
25
+ const result = await dependencies.invoke(UPLOAD, { mediaType }, { body }, operation);
26
+ return uploadResult(result);
27
+ }
28
+ catch (cause) {
29
+ last = cause;
30
+ if (!retryable(cause) || attempt + 1 === MAXIMUM_ATTEMPTS || operation.signal.aborted) {
31
+ throw cause;
32
+ }
33
+ await runSessionOperation(operation, (signal) => delay(200 * 2 ** attempt, signal));
34
+ }
35
+ }
36
+ throw last;
37
+ }
38
+ async function download(location, options) {
39
+ const operation = dependencies.operation(contentControls(options));
40
+ const result = await dependencies.dispatch(DOWNLOAD, admitLocation(location), operation);
41
+ return verifiedDownload(requireResultKind('binary', result).value);
42
+ }
43
+ return Object.freeze({ upload, download });
44
+ }
45
+ function admitLocation(input) {
46
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) {
47
+ throw new TypeError('Content location is invalid.');
48
+ }
49
+ return Object.freeze({ node: NodeId(input.node), property: PropertyKey(input.property) });
50
+ }
51
+ function admitBody(input) {
52
+ if (typeof Blob !== 'undefined' && input instanceof Blob)
53
+ return input;
54
+ if (input instanceof Uint8Array) {
55
+ // Blob snapshots an ArrayBuffer view; a SharedArrayBuffer view needs an owned copy.
56
+ const bytes = input.buffer instanceof ArrayBuffer
57
+ ? input
58
+ : new Uint8Array(input);
59
+ return new Blob([bytes]);
60
+ }
61
+ throw new TypeError('Content upload source must be a Blob, File, or Uint8Array.');
62
+ }
63
+ function admitMediaType(input) {
64
+ if (typeof input !== 'string')
65
+ throw new TypeError('Content upload mediaType is required.');
66
+ return artifact.identify(new Uint8Array(), input).mediaType;
67
+ }
68
+ function contentControls(input) {
69
+ const controls = admitControlOptions(input);
70
+ return Object.freeze({ ...controls, timeoutMs: controls.timeoutMs ?? CONTENT_TIMEOUT_MS });
71
+ }
72
+ function uploadResult(input) {
73
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) {
74
+ throw new ClientError('Content upload returned an invalid receipt.');
75
+ }
76
+ const value = input;
77
+ if (typeof value.receipt !== 'string' ||
78
+ value.receipt.length === 0 ||
79
+ typeof value.expiresAt !== 'string' ||
80
+ !Number.isFinite(Date.parse(value.expiresAt))) {
81
+ throw new ClientError('Content upload returned an invalid receipt.');
82
+ }
83
+ let ref;
84
+ try {
85
+ ref = artifact.accept(value.ref);
86
+ }
87
+ catch (cause) {
88
+ throw new ClientError('Content upload returned an invalid Ref.', { cause });
89
+ }
90
+ return Object.freeze({
91
+ receipt: value.receipt,
92
+ ref,
93
+ expiresAt: value.expiresAt,
94
+ });
95
+ }
96
+ function retryable(cause) {
97
+ return ((cause instanceof TransportError && cause.context.kind === 'invocation') ||
98
+ (cause instanceof ResponseError &&
99
+ (cause.reason?.code === 'BLOB_UNAVAILABLE' ||
100
+ cause.code === CODES.CANCELLED ||
101
+ cause.code === CODES.DEADLINE_EXCEEDED ||
102
+ cause.code === CODES.OUTCOME_UNKNOWN)));
103
+ }
104
+ function delay(ms, signal) {
105
+ return new Promise((resolve, reject) => {
106
+ if (signal.aborted)
107
+ return reject(signal.reason);
108
+ const timer = setTimeout(() => {
109
+ signal.removeEventListener('abort', abort);
110
+ resolve();
111
+ }, ms);
112
+ const abort = () => {
113
+ clearTimeout(timer);
114
+ reject(signal.reason);
115
+ };
116
+ signal.addEventListener('abort', abort, { once: true });
117
+ });
118
+ }
119
+ async function verifiedDownload(input) {
120
+ const raw = input.headers?.['x-astrale-blob-size'];
121
+ if (raw === undefined || !/^(?:0|[1-9][0-9]*)$/u.test(raw)) {
122
+ await cancelBinary(input);
123
+ throw new ProtocolError('Content download omitted its exact content size.');
124
+ }
125
+ const expected = Number(raw);
126
+ if (!Number.isSafeInteger(expected)) {
127
+ await cancelBinary(input);
128
+ throw new ProtocolError('Content download content size is invalid.');
129
+ }
130
+ if (input.body instanceof Uint8Array) {
131
+ if (input.body.byteLength !== expected) {
132
+ throw new ProtocolError('Content download ended before its exact content size.');
133
+ }
134
+ return input;
135
+ }
136
+ return Object.freeze({ ...input, body: exactContentBytes(input.body, expected) });
137
+ }
138
+ async function cancelBinary(input) {
139
+ if (input.body instanceof Uint8Array)
140
+ return;
141
+ try {
142
+ await input.body[Symbol.asyncIterator]().return?.();
143
+ }
144
+ catch {
145
+ // Preserve the admission error after the response stream has been closed.
146
+ }
147
+ }
148
+ async function* exactContentBytes(body, expected) {
149
+ let received = 0;
150
+ for await (const bytes of body) {
151
+ received += bytes.byteLength;
152
+ if (received > expected) {
153
+ throw new ProtocolError('Content download exceeded its exact content size.');
154
+ }
155
+ yield bytes;
156
+ }
157
+ if (received !== expected) {
158
+ throw new ProtocolError('Content download ended before its exact content size.');
159
+ }
160
+ }
@@ -13,7 +13,11 @@ export declare function createSessionOperation(lifecycle: AbortSignal, input: {
13
13
  readonly idempotencyKey?: RequestOptions['idempotencyKey'];
14
14
  } | undefined, defaultTimeoutMs: number): SessionOperation;
15
15
  export declare function remainingSessionTime(operation: SessionOperation): number;
16
- export declare function sessionTransportOptions(operation: SessionOperation, idempotencyKey?: RequestOptions['idempotencyKey']): RequestOptions;
16
+ export declare function sessionTransportOptions(operation: SessionOperation): {
17
+ readonly signal: AbortSignal;
18
+ readonly timeoutMs: number;
19
+ };
20
+ export declare function sessionTransportOptions(operation: SessionOperation, idempotencyKey: RequestOptions['idempotencyKey']): RequestOptions;
17
21
  /** Settle independently of a caller capability that ignores its supplied AbortSignal. */
18
22
  export declare function runSessionOperation<Result>(operation: SessionOperation, task: (signal: AbortSignal) => Result | PromiseLike<Result>): Promise<Result>;
19
23
  export declare function requireSessionOperation(operation: SessionOperation): void;
@@ -1 +1 @@
1
- export { createSessionCredentialProvider, type SessionCredential, type SessionCredentialProvider, type SessionCredentialProviderOptions, type SessionCredentialState, } from './provider.js';
1
+ export { createSessionCredentialProvider, delegationCoverageMs, type SessionCredential, type SessionCredentialProvider, type SessionCredentialProviderOptions, type SessionCredentialState, } from './provider.js';
@@ -1 +1 @@
1
- export { createSessionCredentialProvider, } from './provider.js';
1
+ export { createSessionCredentialProvider, delegationCoverageMs, } from './provider.js';
@@ -48,4 +48,9 @@ export interface SessionCredentialProvider extends SessionAuth {
48
48
  /** Stop anticipating; a mint already in flight still settles. */
49
49
  stop(): void;
50
50
  }
51
+ /**
52
+ * Remaining lifetime a credential must exceed to carry a delegation of `ttlSeconds`: the TTL itself
53
+ * plus the settlement skew, so it cannot expire between admission and delivery.
54
+ */
55
+ export declare function delegationCoverageMs(ttlSeconds: number): number;
51
56
  export declare function createSessionCredentialProvider(options: SessionCredentialProviderOptions): SessionCredentialProvider;
@@ -1,5 +1,12 @@
1
1
  import { ClientError } from '../../errors/index.js';
2
2
  import { BACKOFF_MS, MINT_TIMEOUT_MS, RETRY_INTERVAL_MS, SETTLEMENT_SKEW_MS } from './limits.js';
3
+ /**
4
+ * Remaining lifetime a credential must exceed to carry a delegation of `ttlSeconds`: the TTL itself
5
+ * plus the settlement skew, so it cannot expire between admission and delivery.
6
+ */
7
+ export function delegationCoverageMs(ttlSeconds) {
8
+ return ttlSeconds * 1_000 + SETTLEMENT_SKEW_MS;
9
+ }
3
10
  export function createSessionCredentialProvider(options) {
4
11
  if (options === null || typeof options !== 'object') {
5
12
  throw new TypeError('Session credential provider options are invalid.');
@@ -17,7 +24,7 @@ export function createSessionCredentialProvider(options) {
17
24
  const mint = options.mint.bind(options);
18
25
  const notify = options.onChange?.bind(options);
19
26
  // The threshold is the coverage the caller already declared, never a separate guessed constant.
20
- const marginMs = ttlSeconds * 1_000 + SETTLEMENT_SKEW_MS;
27
+ const marginMs = delegationCoverageMs(ttlSeconds);
21
28
  let current = options.initial === undefined ? undefined : admitInitial(options.initial);
22
29
  let state = Object.freeze(current === undefined
23
30
  ? { status: 'empty' }
@@ -0,0 +1,27 @@
1
+ import type { IssuerId } from '@astrale-os/kernel-core/auth/issuer';
2
+ import type { invocation } from '@astrale-os/kernel-protocol';
3
+ import { Path } from '@astrale-os/kernel-core/path';
4
+ import { Key } from '@astrale-os/kernel-dsl/v1/addressing';
5
+ import type { SessionOperation } from '../context/index.js';
6
+ import type { EffectiveAuth } from '../routing/index.js';
7
+ import type { ExchangeDependencies } from './exchange.js';
8
+ /** Installed issuers of the Domains one Session reaches. */
9
+ export interface Installations {
10
+ issuerOf(origin: Key.Origin, source: EffectiveAuth, operation: SessionOperation): Promise<IssuerId | undefined>;
11
+ clear(): void;
12
+ }
13
+ /**
14
+ * The origin of the Domain whose declaration a Call executes, or undefined when the target names no
15
+ * Domain. An instance Method belongs to its qualified Class, never to its receiver's Domain.
16
+ */
17
+ export declare function declaringOrigin(target: invocation.Call['target']): Key.Origin | undefined;
18
+ export declare function callableOrigin(path: Path): Key.Origin | undefined;
19
+ export declare function isKernelOrigin(origin: Key.Origin): boolean;
20
+ /** The installed remote issuer of one Domain, read as the source caller; undefined when local. */
21
+ export declare function installedIssuer(dependencies: Pick<ExchangeDependencies, 'kernel' | 'source' | 'limits'>, origin: Key.Origin, source: EffectiveAuth, operation: SessionOperation): Promise<IssuerId | undefined>;
22
+ /**
23
+ * Remember each origin's installed issuer for the Session route age. The issuer is installation
24
+ * state, not authority, so one inspection serves every source credential; concurrent readers share
25
+ * it while each keeps its own cancellation and deadline.
26
+ */
27
+ export declare function createInstallations(dependencies: ExchangeDependencies): Installations;
@@ -0,0 +1,63 @@
1
+ import { targetPath } from '@astrale-os/kernel-core/callable';
2
+ import { Path } from '@astrale-os/kernel-core/path';
3
+ import { identity } from '@astrale-os/kernel-core/schema/kernel/syscalls/references';
4
+ import { Key } from '@astrale-os/kernel-dsl/v1/addressing';
5
+ import { createSchema } from '../../schema/index.js';
6
+ import { runSessionOperation } from '../context/index.js';
7
+ import { sourceCaller } from './source.js';
8
+ const KERNEL_ORIGIN = identity.whoami.owner.origin;
9
+ /**
10
+ * The origin of the Domain whose declaration a Call executes, or undefined when the target names no
11
+ * Domain. An instance Method belongs to its qualified Class, never to its receiver's Domain.
12
+ */
13
+ export function declaringOrigin(target) {
14
+ return callableOrigin(Path.parse(targetPath(target)));
15
+ }
16
+ export function callableOrigin(path) {
17
+ const last = path.ast.steps.at(-1);
18
+ if (last?.kind === 'method' && last.dispatch === 'instance')
19
+ return Key.origin(last.class);
20
+ return path.ast.anchor.kind === 'domain' ? path.ast.anchor.origin : undefined;
21
+ }
22
+ export function isKernelOrigin(origin) {
23
+ return origin === KERNEL_ORIGIN;
24
+ }
25
+ /** The installed remote issuer of one Domain, read as the source caller; undefined when local. */
26
+ export async function installedIssuer(dependencies, origin, source, operation) {
27
+ const caller = sourceCaller(dependencies, source, operation);
28
+ const domain = await createSchema(caller.dispatch, dependencies.limits).inspect(origin);
29
+ const remote = domain.publication;
30
+ return remote === null || remote.identity.issuer === dependencies.kernel
31
+ ? undefined
32
+ : remote.identity.issuer;
33
+ }
34
+ /**
35
+ * Remember each origin's installed issuer for the Session route age. The issuer is installation
36
+ * state, not authority, so one inspection serves every source credential; concurrent readers share
37
+ * it while each keeps its own cancellation and deadline.
38
+ */
39
+ export function createInstallations(dependencies) {
40
+ /** An inspection in flight never expires; a settled one lives for the route age. */
41
+ const entries = new Map();
42
+ return Object.freeze({
43
+ issuerOf(origin, source, operation) {
44
+ const held = entries.get(origin);
45
+ const { issuer } = held === undefined || held.expiresAt <= Date.now() ? inspect(origin, source) : held;
46
+ return runSessionOperation(operation, () => issuer);
47
+ },
48
+ clear: () => entries.clear(),
49
+ });
50
+ function inspect(origin, source) {
51
+ const issuer = installedIssuer(dependencies, origin, source, dependencies.operation());
52
+ const entry = { issuer, expiresAt: Infinity };
53
+ entries.set(origin, entry);
54
+ // Both reactions are attached, so a rejection that reaches no waiter is never unhandled.
55
+ issuer.then(() => {
56
+ entry.expiresAt = Date.now() + dependencies.policy.maximumRouteAgeMs;
57
+ }, () => {
58
+ if (entries.get(origin) === entry)
59
+ entries.delete(origin);
60
+ });
61
+ return entry;
62
+ }
63
+ }
@@ -0,0 +1,54 @@
1
+ import type { MintedCredential, UnresolvedIdentityExpr } from '@astrale-os/kernel-core/auth';
2
+ import type { IssuerId } from '@astrale-os/kernel-core/auth/issuer';
3
+ import type { Client } from '../../client/index.js';
4
+ import type { SchemaOptions } from '../../schema/schema.js';
5
+ import type { Fetch } from '../../transport/http/index.js';
6
+ import type { SessionOperation, SessionPolicy } from '../context/index.js';
7
+ import type { SessionCredential } from '../credential/index.js';
8
+ import type { EffectiveAuth, SessionAuth } from '../routing/index.js';
9
+ import type { ExchangeRequest } from './options.js';
10
+ /** Everything an exchange reaches: the Session's source Kernel and authority, and the Domain legs. */
11
+ export interface ExchangeDependencies {
12
+ readonly kernel: IssuerId;
13
+ /** Unauthenticated Client for the source Kernel invocation endpoint. */
14
+ readonly source: () => Client;
15
+ /** The Session's source authority, when the Session has one. */
16
+ readonly auth?: SessionAuth;
17
+ readonly fetch: Fetch;
18
+ readonly policy: Readonly<SessionPolicy>;
19
+ readonly limits: SchemaOptions;
20
+ readonly maximumResponseBytes: number;
21
+ /** Open one operation for work shared by several Calls; it fails once the Session closes. */
22
+ readonly operation: (signal?: AbortSignal) => SessionOperation;
23
+ }
24
+ /** One Domain-issued Kernel credential and the delegation the Domain exchanged for it. */
25
+ export interface DomainCredential extends SessionCredential {
26
+ readonly delegation: MintedCredential;
27
+ }
28
+ export interface DomainExchangeRequest {
29
+ readonly issuer: IssuerId;
30
+ /** Requested delegation lifetime; the source credential's remaining lifetime bounds it. */
31
+ readonly ttlSeconds: number;
32
+ readonly attenuation: UnresolvedIdentityExpr;
33
+ /**
34
+ * Lifetime the returned credential must exceed. A source that bounds the delegation within it is
35
+ * exhausted rather than exchanged.
36
+ */
37
+ readonly coverageMs?: number;
38
+ }
39
+ /** The attenuation that delegates the source caller's own identity. */
40
+ export declare const SELF_ATTENUATION: Readonly<{
41
+ readonly kind: 'identity';
42
+ readonly self: true;
43
+ }>;
44
+ /**
45
+ * Delegate the source caller to one Domain issuer and redeem that delegation at the issuer's
46
+ * advertised endpoint for the Domain's credential to the source Kernel.
47
+ */
48
+ export declare function exchangeDomainCredential(dependencies: ExchangeDependencies, source: EffectiveAuth, request: DomainExchangeRequest, operation: SessionOperation): Promise<DomainCredential>;
49
+ /**
50
+ * One Domain credential for a named Domain issuer, as `session.exchange` returns it. When the
51
+ * request asks for more than the Session delegation, the credential covers that delegation, and the
52
+ * source is renewed once if the current one can no longer feed it.
53
+ */
54
+ export declare function acquireDomainCredential(dependencies: ExchangeDependencies, request: ExchangeRequest, operation: SessionOperation): Promise<SessionCredential>;
@@ -0,0 +1,58 @@
1
+ import { credentialApi, IDENTITY_DELEGATE_PATH } from '../../auth/credential.js';
2
+ import { identityApi } from '../../auth/identity.js';
3
+ import { delegationCoverageMs } from '../credential/index.js';
4
+ import { resolveAuth } from '../routing/index.js';
5
+ import { redeem, tokenEndpoint } from './issuer.js';
6
+ import { delegationTtl, isAuthority, renewing, sourceCaller, SourceExhausted } from './source.js';
7
+ /** The attenuation that delegates the source caller's own identity. */
8
+ export const SELF_ATTENUATION = Object.freeze({ kind: 'identity', self: true });
9
+ /**
10
+ * Delegate the source caller to one Domain issuer and redeem that delegation at the issuer's
11
+ * advertised endpoint for the Domain's credential to the source Kernel.
12
+ */
13
+ export async function exchangeDomainCredential(dependencies, source, request, operation) {
14
+ const caller = sourceCaller(dependencies, source, operation);
15
+ const [endpoint, principal] = await Promise.all([
16
+ tokenEndpoint(dependencies, request.issuer, operation),
17
+ identityApi(caller.call)
18
+ .whoami()
19
+ .then(({ id }) => id),
20
+ ]);
21
+ const ttlSeconds = delegationTtl(source.credential, request.ttlSeconds, request.coverageMs);
22
+ const delegation = await credentialApi(caller.call).delegate(principal, {
23
+ audience: request.issuer,
24
+ ttlSeconds,
25
+ attenuation: request.attenuation,
26
+ });
27
+ const credential = await redeem(dependencies, request.issuer, endpoint, delegation, operation);
28
+ // A Domain credential never outlives its source, so only a fresher source can lengthen it.
29
+ const bySource = ttlSeconds < request.ttlSeconds;
30
+ if (bySource && credential.expiresAt - Date.now() <= (request.coverageMs ?? 0)) {
31
+ throw new SourceExhausted();
32
+ }
33
+ return Object.freeze({ ...credential, delegation });
34
+ }
35
+ /**
36
+ * One Domain credential for a named Domain issuer, as `session.exchange` returns it. When the
37
+ * request asks for more than the Session delegation, the credential covers that delegation, and the
38
+ * source is renewed once if the current one can no longer feed it.
39
+ */
40
+ export async function acquireDomainCredential(dependencies, request, operation) {
41
+ const { auth } = dependencies;
42
+ const call = {
43
+ target: IDENTITY_DELEGATE_PATH,
44
+ input: { audience: request.issuer, ttlSeconds: request.ttlSeconds },
45
+ };
46
+ const coverageMs = auth === undefined ? 0 : delegationCoverageMs(auth.ttlSeconds);
47
+ const exchange = (source) => exchangeDomainCredential(dependencies, source, {
48
+ issuer: request.issuer,
49
+ ttlSeconds: request.ttlSeconds,
50
+ attenuation: source.delegate?.attenuation ?? SELF_ATTENUATION,
51
+ ...(request.ttlSeconds * 1_000 > coverageMs ? { coverageMs } : {}),
52
+ }, operation);
53
+ const source = await resolveAuth(auth, call, operation);
54
+ const exchanged = auth === undefined || !isAuthority(source)
55
+ ? await exchange(source)
56
+ : await renewing(auth, call, source, operation, exchange);
57
+ return Object.freeze({ credential: exchanged.credential, expiresAt: exchanged.expiresAt });
58
+ }
@@ -0,0 +1,5 @@
1
+ export { callableOrigin, installedIssuer } from './domain.js';
2
+ export { acquireDomainCredential, exchangeDomainCredential, SELF_ATTENUATION, type DomainCredential, type DomainExchangeRequest, type ExchangeDependencies, } from './exchange.js';
3
+ export { admitExchangeOptions, admitExchangeRequest, type ExchangeRequest, type SessionExchangeOptions, type SessionExchangeRequest, } from './options.js';
4
+ export { createSessionExchange, type SessionExchange } from './presentation.js';
5
+ export { isAuthority } from './source.js';
@@ -0,0 +1,5 @@
1
+ export { callableOrigin, installedIssuer } from './domain.js';
2
+ export { acquireDomainCredential, exchangeDomainCredential, SELF_ATTENUATION, } from './exchange.js';
3
+ export { admitExchangeOptions, admitExchangeRequest, } from './options.js';
4
+ export { createSessionExchange } from './presentation.js';
5
+ export { isAuthority } from './source.js';
@@ -0,0 +1,18 @@
1
+ import type { MintedCredential } from '@astrale-os/kernel-core/auth';
2
+ import type { IssuerId } from '@astrale-os/kernel-core/auth/issuer';
3
+ import type { SessionOperation } from '../context/index.js';
4
+ import type { SessionCredential } from '../credential/index.js';
5
+ import type { ExchangeDependencies } from './exchange.js';
6
+ type IssuerDependencies = Pick<ExchangeDependencies, 'kernel' | 'fetch' | 'policy' | 'maximumResponseBytes'>;
7
+ /**
8
+ * The token exchange endpoint a Domain issuer advertises. Endpoint policy applies here, so a denied
9
+ * endpoint is never returned and no delegation is minted for it.
10
+ */
11
+ export declare function tokenEndpoint(dependencies: IssuerDependencies, domain: IssuerId, operation: SessionOperation): Promise<string>;
12
+ /**
13
+ * Present a delegation at a Domain's token endpoint and admit the returned credential as provider
14
+ * evidence: its issuer must be that Domain, its audience the source Kernel, and its signed expiry
15
+ * the reported one.
16
+ */
17
+ export declare function redeem(dependencies: IssuerDependencies, domain: IssuerId, endpoint: string, delegation: MintedCredential, operation: SessionOperation): Promise<SessionCredential>;
18
+ export {};
@@ -0,0 +1,89 @@
1
+ import { inspect } from '@astrale-os/kernel-core/auth/credential';
2
+ import { codecs, issuer } from '@astrale-os/kernel-protocol';
3
+ import { ProtocolError } from '@astrale-os/kernel-protocol/errors';
4
+ import { ClientError } from '../../errors/index.js';
5
+ import { allowEndpoint, runSessionOperation } from '../context/index.js';
6
+ import { boundedResponse, requireExactResponse } from '../discovery/index.js';
7
+ /**
8
+ * The token exchange endpoint a Domain issuer advertises. Endpoint policy applies here, so a denied
9
+ * endpoint is never returned and no delegation is minted for it.
10
+ */
11
+ export async function tokenEndpoint(dependencies, domain, operation) {
12
+ const configuration = issuer.acceptConfiguration(await fetchJson(dependencies, new URL(issuer.paths(domain).configuration, domain).href, operation), domain);
13
+ const endpoint = configuration.token_exchange_endpoint;
14
+ if (endpoint === undefined) {
15
+ throw new ClientError('The installed Domain does not advertise token exchange.');
16
+ }
17
+ allowEndpoint(endpoint, dependencies.policy);
18
+ return endpoint;
19
+ }
20
+ /**
21
+ * Present a delegation at a Domain's token endpoint and admit the returned credential as provider
22
+ * evidence: its issuer must be that Domain, its audience the source Kernel, and its signed expiry
23
+ * the reported one.
24
+ */
25
+ export async function redeem(dependencies, domain, endpoint, delegation, operation) {
26
+ const exchanged = issuer.exchange.acceptResponse(await fetchJson(dependencies, endpoint, operation, delegation));
27
+ let claims;
28
+ try {
29
+ claims = inspect(exchanged.token);
30
+ }
31
+ catch (cause) {
32
+ throw new ClientError('Domain token exchange returned an invalid credential.', { cause });
33
+ }
34
+ if (claims.iss !== domain ||
35
+ claims.aud !== dependencies.kernel ||
36
+ claims.claims.exp !== exchanged.expiresAt) {
37
+ throw new ClientError('Domain token exchange returned an inconsistent credential.');
38
+ }
39
+ const expiresAt = exchanged.expiresAt * 1_000;
40
+ if (expiresAt <= Date.now()) {
41
+ throw new ClientError('Domain token exchange returned an expired credential.');
42
+ }
43
+ return Object.freeze({ credential: exchanged.token, expiresAt });
44
+ }
45
+ async function fetchJson(dependencies, url, operation, credential) {
46
+ allowEndpoint(url, dependencies.policy);
47
+ const stage = credential === undefined ? 'discovery' : 'token exchange';
48
+ return runSessionOperation(operation, async (signal) => {
49
+ const init = {
50
+ method: credential === undefined ? 'GET' : 'POST',
51
+ headers: {
52
+ accept: credential === undefined ? 'application/json' : issuer.exchange.MEDIA_TYPE,
53
+ ...(credential === undefined ? {} : { authorization: `Bearer ${credential}` }),
54
+ },
55
+ credentials: 'omit',
56
+ cache: 'no-store',
57
+ referrerPolicy: 'no-referrer',
58
+ redirect: 'manual',
59
+ signal,
60
+ };
61
+ const response = await dependencies.fetch(url, init);
62
+ try {
63
+ requireExactResponse(response, url, 'Domain token exchange');
64
+ if (!response.ok) {
65
+ const failure = credential === undefined ? undefined : await errorOf(response, signal);
66
+ const status = `Domain ${stage} returned HTTP ${response.status}.`;
67
+ throw failure === undefined
68
+ ? new ClientError(status)
69
+ : new ClientError(`${status} ${failure.payload.code}: ${failure.message}`, {
70
+ cause: failure,
71
+ });
72
+ }
73
+ return codecs.json.decode(await boundedResponse(response, dependencies.maximumResponseBytes, signal));
74
+ }
75
+ finally {
76
+ // Also release unread bodies rejected by URL, status, or declared-size validation.
77
+ void response.body?.cancel().catch(() => undefined);
78
+ }
79
+ });
80
+ async function errorOf(response, signal) {
81
+ try {
82
+ const body = await boundedResponse(response, dependencies.maximumResponseBytes, signal);
83
+ return new ProtocolError(issuer.exchange.acceptErrorResponse(codecs.json.decode(body)).error);
84
+ }
85
+ catch {
86
+ return undefined;
87
+ }
88
+ }
89
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Delegation lifetime requested for each Domain credential when the caller names none. Short enough
3
+ * that a leaked Domain credential is worth little, long enough that a script re-exchanges each
4
+ * Domain a few times an hour rather than once per call.
5
+ */
6
+ export declare const DEFAULT_EXCHANGE_TTL_SECONDS: number;
7
+ /** Lifetime kept below the source credential, which the Kernel refuses to let a delegation reach. */
8
+ export declare const ISSUANCE_MARGIN_SECONDS = 5;
9
+ /** Source credentials whose Domain credentials one Session retains at once. */
10
+ export declare const MAXIMUM_EXCHANGE_SOURCES = 8;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Delegation lifetime requested for each Domain credential when the caller names none. Short enough
3
+ * that a leaked Domain credential is worth little, long enough that a script re-exchanges each
4
+ * Domain a few times an hour rather than once per call.
5
+ */
6
+ export const DEFAULT_EXCHANGE_TTL_SECONDS = 5 * 60;
7
+ /** Lifetime kept below the source credential, which the Kernel refuses to let a delegation reach. */
8
+ export const ISSUANCE_MARGIN_SECONDS = 5;
9
+ /** Source credentials whose Domain credentials one Session retains at once. */
10
+ export const MAXIMUM_EXCHANGE_SOURCES = 8;
@@ -0,0 +1,29 @@
1
+ import { type IssuerId } from '@astrale-os/kernel-core/auth/issuer';
2
+ import type { SessionControlOptions } from '../context/index.js';
3
+ import type { SessionAuth } from '../routing/index.js';
4
+ /** Present each Domain callable with its declaring Domain's credential instead of the source one. */
5
+ export interface SessionExchangeOptions {
6
+ /**
7
+ * Delegation lifetime requested for each Domain credential. The source credential's remaining
8
+ * lifetime and the Domain's own ceiling may shorten it.
9
+ */
10
+ readonly ttlSeconds?: number;
11
+ }
12
+ /** One explicit exchange through a named Domain issuer. */
13
+ export interface SessionExchangeRequest extends SessionControlOptions {
14
+ /** Requested delegation lifetime; the source credential and the Domain may shorten it. */
15
+ readonly ttlSeconds?: number;
16
+ }
17
+ /** An admitted explicit exchange. */
18
+ export interface ExchangeRequest {
19
+ readonly issuer: IssuerId;
20
+ readonly ttlSeconds: number;
21
+ readonly control: Readonly<SessionControlOptions>;
22
+ }
23
+ /**
24
+ * Admit exchange options against the Session auth they extend. Every Domain credential must still
25
+ * cover the Session delegation, so a request that could never cover it is rejected.
26
+ */
27
+ export declare function admitExchangeOptions(input: SessionExchangeOptions | undefined, auth: SessionAuth | undefined): Readonly<Required<SessionExchangeOptions>> | undefined;
28
+ /** Admit an explicit exchange: the canonical absolute issuer URL of the named Domain and its lifetime. */
29
+ export declare function admitExchangeRequest(domain: string, input: SessionExchangeRequest | undefined): ExchangeRequest;
@@ -0,0 +1,57 @@
1
+ import { accept as acceptIssuer } from '@astrale-os/kernel-core/auth/issuer';
2
+ import { admitControlOptions } from '../context/index.js';
3
+ import { delegationCoverageMs } from '../credential/index.js';
4
+ import { DEFAULT_EXCHANGE_TTL_SECONDS } from './limits.js';
5
+ /**
6
+ * Admit exchange options against the Session auth they extend. Every Domain credential must still
7
+ * cover the Session delegation, so a request that could never cover it is rejected.
8
+ */
9
+ export function admitExchangeOptions(input, auth) {
10
+ if (input === undefined)
11
+ return undefined;
12
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) {
13
+ throw new TypeError('Session exchange options are invalid.');
14
+ }
15
+ if (Reflect.ownKeys(input).some((key) => key !== 'ttlSeconds')) {
16
+ throw new TypeError('Session exchange options contain unexpected fields.');
17
+ }
18
+ if (auth === undefined)
19
+ throw new TypeError('Session exchange requires Session auth.');
20
+ const ttlSeconds = admitTtlSeconds(input.ttlSeconds);
21
+ if (ttlSeconds * 1_000 <= delegationCoverageMs(auth.ttlSeconds)) {
22
+ throw new TypeError('Session exchange ttlSeconds must exceed the Session auth ttlSeconds.');
23
+ }
24
+ return Object.freeze({ ttlSeconds });
25
+ }
26
+ /** Admit an explicit exchange: the canonical absolute issuer URL of the named Domain and its lifetime. */
27
+ export function admitExchangeRequest(domain, input) {
28
+ if (input !== undefined &&
29
+ (input === null || typeof input !== 'object' || Array.isArray(input))) {
30
+ throw new TypeError('Session exchange request is invalid.');
31
+ }
32
+ const ttlSeconds = admitTtlSeconds(input?.ttlSeconds);
33
+ const control = admitControlOptions(input === undefined
34
+ ? undefined
35
+ : {
36
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
37
+ ...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }),
38
+ });
39
+ return Object.freeze({ issuer: admitDomainIssuer(domain), ttlSeconds, control });
40
+ }
41
+ function admitTtlSeconds(input) {
42
+ const ttlSeconds = input ?? DEFAULT_EXCHANGE_TTL_SECONDS;
43
+ if (!Number.isSafeInteger(ttlSeconds) || ttlSeconds < 1) {
44
+ throw new TypeError('Session exchange ttlSeconds must be a positive safe integer.');
45
+ }
46
+ return ttlSeconds;
47
+ }
48
+ function admitDomainIssuer(input) {
49
+ try {
50
+ const issuer = acceptIssuer(input);
51
+ void new URL(issuer);
52
+ return issuer;
53
+ }
54
+ catch (cause) {
55
+ throw new TypeError('Domain issuer is invalid.', { cause });
56
+ }
57
+ }