@astrale-os/kernel-client 0.6.0-beta.65 → 0.6.0-beta.67

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.
@@ -0,0 +1 @@
1
+ export { createSessionCredentialProvider, type SessionCredential, type SessionCredentialProvider, type SessionCredentialProviderOptions, type SessionCredentialState, } from './provider.js';
@@ -0,0 +1 @@
1
+ export { createSessionCredentialProvider, } from './provider.js';
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Settlement allowance added to the declared delegation TTL to form the refresh threshold. It
3
+ * matches the destination-credential skew routing already applies to a warm entry, so a credential
4
+ * served here cannot expire between admission and delivery.
5
+ */
6
+ export declare const SETTLEMENT_SKEW_MS = 5000;
7
+ /** Total budget for one mint, matching the default Session operation budget. */
8
+ export declare const MINT_TIMEOUT_MS = 30000;
9
+ /** Bounded backoff between anticipation attempts after a failed mint. */
10
+ export declare const BACKOFF_MS: readonly [1000, 2000, 4000];
11
+ /** Steady retry interval once the bounded backoff is exhausted. */
12
+ export declare const RETRY_INTERVAL_MS = 4000;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Settlement allowance added to the declared delegation TTL to form the refresh threshold. It
3
+ * matches the destination-credential skew routing already applies to a warm entry, so a credential
4
+ * served here cannot expire between admission and delivery.
5
+ */
6
+ export const SETTLEMENT_SKEW_MS = 5_000;
7
+ /** Total budget for one mint, matching the default Session operation budget. */
8
+ export const MINT_TIMEOUT_MS = 30_000;
9
+ /** Bounded backoff between anticipation attempts after a failed mint. */
10
+ export const BACKOFF_MS = [1_000, 2_000, 4_000];
11
+ /** Steady retry interval once the bounded backoff is exhausted. */
12
+ export const RETRY_INTERVAL_MS = 4_000;
@@ -0,0 +1,51 @@
1
+ import type { SessionAuth } from '../routing/index.js';
2
+ /** One source credential and the exact instant it stops being accepted. */
3
+ export interface SessionCredential {
4
+ readonly credential: string;
5
+ /** Absolute Unix time in milliseconds. */
6
+ readonly expiresAt: number;
7
+ }
8
+ /** Observable lifecycle of the single credential one provider owns. */
9
+ export type SessionCredentialState = {
10
+ readonly status: 'empty';
11
+ } | {
12
+ readonly status: 'valid';
13
+ readonly credential: SessionCredential;
14
+ } | {
15
+ readonly status: 'refreshing';
16
+ readonly credential?: SessionCredential;
17
+ } | {
18
+ readonly status: 'degraded';
19
+ readonly credential: SessionCredential;
20
+ readonly error: unknown;
21
+ } | {
22
+ readonly status: 'expired';
23
+ readonly error: unknown;
24
+ };
25
+ export interface SessionCredentialProviderOptions {
26
+ /** Delegation TTL every served credential must still cover; also the SessionAuth TTL. */
27
+ readonly ttlSeconds: number;
28
+ /** Acquire one fresh credential; the supplied signal is owned by the provider. */
29
+ mint(signal: AbortSignal): Promise<SessionCredential>;
30
+ /** Caller-held credential admitted as the initial entry; it need only be unexpired. */
31
+ readonly initial?: SessionCredential;
32
+ readonly onChange?: (state: SessionCredentialState) => void;
33
+ }
34
+ /**
35
+ * Caller-owned source credential with a derived refresh threshold and shared minting.
36
+ *
37
+ * The provider is a SessionAuth, so a ClientSession consumes it directly. Anticipation is opt-in:
38
+ * an ephemeral process or a request-scoped worker never holds a pending timer it did not ask for.
39
+ */
40
+ export interface SessionCredentialProvider extends SessionAuth {
41
+ state(): SessionCredentialState;
42
+ /** Serve a credential that still covers the delegation TTL; concurrent callers share one mint. */
43
+ acquire(signal?: AbortSignal): Promise<SessionCredential>;
44
+ /** Mint regardless of the current credential, joining a mint already in flight. */
45
+ refresh(signal?: AbortSignal): Promise<SessionCredential>;
46
+ /** Begin anticipating expiry: threshold timer, bounded backoff, and an expiry watchdog. */
47
+ start(): void;
48
+ /** Stop anticipating; a mint already in flight still settles. */
49
+ stop(): void;
50
+ }
51
+ export declare function createSessionCredentialProvider(options: SessionCredentialProviderOptions): SessionCredentialProvider;
@@ -0,0 +1,229 @@
1
+ import { ClientError } from '../../errors/index.js';
2
+ import { BACKOFF_MS, MINT_TIMEOUT_MS, RETRY_INTERVAL_MS, SETTLEMENT_SKEW_MS } from './limits.js';
3
+ export function createSessionCredentialProvider(options) {
4
+ if (options === null || typeof options !== 'object') {
5
+ throw new TypeError('Session credential provider options are invalid.');
6
+ }
7
+ if (typeof options.mint !== 'function') {
8
+ throw new TypeError('Session credential mint capability is invalid.');
9
+ }
10
+ if (!Number.isSafeInteger(options.ttlSeconds) || options.ttlSeconds < 1) {
11
+ throw new TypeError('Session credential ttlSeconds must be a positive safe integer.');
12
+ }
13
+ if (options.onChange !== undefined && typeof options.onChange !== 'function') {
14
+ throw new TypeError('Session credential change observer is invalid.');
15
+ }
16
+ const { ttlSeconds } = options;
17
+ const mint = options.mint.bind(options);
18
+ const notify = options.onChange?.bind(options);
19
+ // The threshold is the coverage the caller already declared, never a separate guessed constant.
20
+ const marginMs = ttlSeconds * 1_000 + SETTLEMENT_SKEW_MS;
21
+ let current = options.initial === undefined ? undefined : admitInitial(options.initial);
22
+ let state = Object.freeze(current === undefined
23
+ ? { status: 'empty' }
24
+ : { status: 'valid', credential: current });
25
+ let inflight;
26
+ let controller;
27
+ let waiting = 0;
28
+ let anticipating = false;
29
+ let refreshTimer;
30
+ let expiryTimer;
31
+ let attempt = 0;
32
+ return Object.freeze({
33
+ ttlSeconds,
34
+ state: () => state,
35
+ acquire,
36
+ refresh,
37
+ start,
38
+ stop,
39
+ resolve,
40
+ });
41
+ async function resolve(_call, signal) {
42
+ return { credential: (await acquire(signal)).credential };
43
+ }
44
+ function acquire(signal) {
45
+ if (current !== undefined && covers(current))
46
+ return Promise.resolve(current);
47
+ return share(signal);
48
+ }
49
+ function refresh(signal) {
50
+ return share(signal);
51
+ }
52
+ function start() {
53
+ anticipating = true;
54
+ attempt = 0;
55
+ schedule();
56
+ }
57
+ function stop() {
58
+ anticipating = false;
59
+ clearTimers();
60
+ }
61
+ /** Join the mint in flight, or begin one, while following only this caller's cancellation. */
62
+ function share(signal) {
63
+ if (signal?.aborted === true)
64
+ return Promise.reject(signal.reason);
65
+ const pending = inflight ?? begin();
66
+ if (signal === undefined)
67
+ return pending;
68
+ waiting += 1;
69
+ let settled = false;
70
+ return new Promise((complete, fail) => {
71
+ const leave = () => {
72
+ if (settled)
73
+ return;
74
+ settled = true;
75
+ waiting -= 1;
76
+ signal.removeEventListener('abort', cancel);
77
+ // Nobody is waiting and nobody is anticipating: the mint has no beneficiary left.
78
+ if (waiting === 0 && !anticipating)
79
+ controller?.abort(signal.reason);
80
+ };
81
+ const cancel = () => {
82
+ const reason = signal.reason;
83
+ leave();
84
+ fail(reason);
85
+ };
86
+ signal.addEventListener('abort', cancel, { once: true });
87
+ pending.then((credential) => {
88
+ if (settled)
89
+ return;
90
+ settled = true;
91
+ waiting -= 1;
92
+ signal.removeEventListener('abort', cancel);
93
+ complete(credential);
94
+ }, (error) => {
95
+ if (settled)
96
+ return;
97
+ settled = true;
98
+ waiting -= 1;
99
+ signal.removeEventListener('abort', cancel);
100
+ fail(error);
101
+ });
102
+ });
103
+ }
104
+ function begin() {
105
+ publish({ status: 'refreshing', ...(current === undefined ? {} : { credential: current }) });
106
+ const owned = new AbortController();
107
+ controller = owned;
108
+ const signal = AbortSignal.any([owned.signal, AbortSignal.timeout(MINT_TIMEOUT_MS)]);
109
+ const pending = (async () => {
110
+ try {
111
+ const minted = admitMinted(await mint(signal));
112
+ current = minted;
113
+ attempt = 0;
114
+ publish({ status: 'valid', credential: minted });
115
+ if (anticipating)
116
+ schedule();
117
+ return minted;
118
+ }
119
+ catch (error) {
120
+ settleFailure(error);
121
+ throw error;
122
+ }
123
+ finally {
124
+ inflight = undefined;
125
+ controller = undefined;
126
+ }
127
+ })();
128
+ inflight = pending;
129
+ // A rejection reaches every waiter; keep an unattached one from surfacing as unhandled.
130
+ pending.catch(() => undefined);
131
+ return pending;
132
+ }
133
+ function settleFailure(error) {
134
+ if (current !== undefined && current.expiresAt > Date.now()) {
135
+ publish({ status: 'degraded', credential: current, error });
136
+ }
137
+ else {
138
+ current = undefined;
139
+ publish({ status: 'expired', error });
140
+ }
141
+ if (anticipating)
142
+ retry();
143
+ }
144
+ /** Arm the threshold refresh and the expiry watchdog for the credential now held. */
145
+ function schedule() {
146
+ clearTimers();
147
+ if (!anticipating)
148
+ return;
149
+ if (current === undefined) {
150
+ void share().catch(() => undefined);
151
+ return;
152
+ }
153
+ // The threshold itself, with no extra lead: chained providers sharing this rule must not
154
+ // cascade into a mint loop, and the settlement skew already covers the round trip.
155
+ armRefresh(clamp(current.expiresAt - marginMs - Date.now()));
156
+ armExpiry(current);
157
+ }
158
+ function retry() {
159
+ if (refreshTimer !== undefined)
160
+ return;
161
+ const backoff = BACKOFF_MS[attempt];
162
+ attempt = Math.min(attempt + 1, BACKOFF_MS.length);
163
+ armRefresh(backoff ?? RETRY_INTERVAL_MS);
164
+ if (current !== undefined)
165
+ armExpiry(current);
166
+ }
167
+ function armRefresh(delay) {
168
+ refreshTimer = setTimeout(() => {
169
+ refreshTimer = undefined;
170
+ void share().catch(() => undefined);
171
+ }, delay);
172
+ }
173
+ function armExpiry(credential) {
174
+ if (expiryTimer !== undefined)
175
+ clearTimeout(expiryTimer);
176
+ expiryTimer = setTimeout(() => {
177
+ expiryTimer = undefined;
178
+ if (!anticipating || current !== credential)
179
+ return;
180
+ current = undefined;
181
+ publish({ status: 'expired', error: new ClientError('Session credential expired.') });
182
+ }, clamp(credential.expiresAt - Date.now()));
183
+ }
184
+ function clearTimers() {
185
+ if (refreshTimer !== undefined)
186
+ clearTimeout(refreshTimer);
187
+ if (expiryTimer !== undefined)
188
+ clearTimeout(expiryTimer);
189
+ refreshTimer = undefined;
190
+ expiryTimer = undefined;
191
+ }
192
+ function covers(credential) {
193
+ return credential.expiresAt - Date.now() > marginMs;
194
+ }
195
+ function publish(next) {
196
+ state = Object.freeze(next);
197
+ notify?.(state);
198
+ }
199
+ function admitMinted(input) {
200
+ const credential = admitShape(input);
201
+ if (credential === undefined)
202
+ throw new ClientError('Minted session credential is invalid.');
203
+ if (!covers(credential)) {
204
+ throw new ClientError('Minted session credential cannot cover the delegation TTL.');
205
+ }
206
+ return credential;
207
+ }
208
+ }
209
+ function admitInitial(input) {
210
+ const credential = admitShape(input);
211
+ if (credential === undefined || credential.expiresAt <= Date.now()) {
212
+ throw new TypeError('Initial session credential is invalid.');
213
+ }
214
+ return credential;
215
+ }
216
+ function admitShape(input) {
217
+ if (input === null ||
218
+ typeof input !== 'object' ||
219
+ typeof input.credential !== 'string' ||
220
+ input.credential.length === 0 ||
221
+ !Number.isSafeInteger(input.expiresAt)) {
222
+ return undefined;
223
+ }
224
+ return Object.freeze({ credential: input.credential, expiresAt: input.expiresAt });
225
+ }
226
+ /** Keep every scheduled delay inside the platform timer range. */
227
+ function clamp(delay) {
228
+ return Math.min(Math.max(delay, 0), 2_147_483_647);
229
+ }
@@ -1,5 +1,6 @@
1
1
  export type { SessionControlOptions, SessionPolicy } from './context/index.js';
2
2
  export { bindSession, type BoundClientSession, type BoundSessionControls, type BoundSessionSource, } from './bound.js';
3
+ export { createSessionCredentialProvider, type SessionCredential, type SessionCredentialProvider, type SessionCredentialProviderOptions, type SessionCredentialState, } from './credential/index.js';
3
4
  export type { SessionSnapshot } from './discovery/index.js';
4
5
  export type { Auth, PersistedRoute, SessionRouteArtifact, SessionRouteStore, SessionAuth, SessionEnvelopeTransport, SessionRequestOptions, } from './routing/index.js';
5
6
  export { ClientSession, connect, type ClientSessionOptions, type ConnectOptions, } from './session.js';
@@ -1,3 +1,4 @@
1
1
  export { bindSession, } from './bound.js';
2
+ export { createSessionCredentialProvider, } from './credential/index.js';
2
3
  export { ClientSession, connect, } from './session.js';
3
4
  export { reference } from './reference.js';
@@ -1,7 +1,7 @@
1
1
  import type { Path as PathValue, PathLike } from '@astrale-os/kernel-core/path';
2
2
  import type { CallableInputOf, CallableOutputOf, CallableResultOf, Domain, ResolvedFunction, ExecutableMethod } from '@astrale-os/kernel-dsl/v1';
3
3
  import type { Revision } from '@astrale-os/kernel-dsl/v1/addressing';
4
- import { schema as dslSchema } from '@astrale-os/kernel-dsl/v1';
4
+ import type { MeaningFingerprint } from '@astrale-os/kernel-dsl/v1/schema/fingerprint';
5
5
  import { type Key as KeyType } from '@astrale-os/kernel-dsl/v1/addressing';
6
6
  import type { Binary, Input } from '../client/index.js';
7
7
  declare const REFERENCE_INPUT: unique symbol;
@@ -11,7 +11,7 @@ export interface CallableReference<InputValue = Input, Output = unknown> {
11
11
  /** @deprecated Informational only; dispatch pins `fingerprint` and revision pinning will be removed. */
12
12
  readonly revision: Revision;
13
13
  /** Meaning fingerprint of the callable; a generated reference always carries one. */
14
- readonly fingerprint?: dslSchema.MeaningFingerprint;
14
+ readonly fingerprint?: MeaningFingerprint;
15
15
  readonly callable: KeyType.Callable;
16
16
  readonly target: PathValue;
17
17
  readonly outputMode: 'value' | 'stream' | 'binary';
@@ -22,7 +22,7 @@ export interface InstanceCallableReference<InputValue = Input, Output = unknown>
22
22
  (receiver: PathLike): CallableReference<InputValue, Output>;
23
23
  readonly origin: KeyType.Origin;
24
24
  readonly revision: Revision;
25
- readonly fingerprint: dslSchema.MeaningFingerprint;
25
+ readonly fingerprint: MeaningFingerprint;
26
26
  readonly callable: KeyType.Callable;
27
27
  readonly outputMode: 'value' | 'stream' | 'binary';
28
28
  }
@@ -1,6 +1,6 @@
1
1
  import { Path } from '@astrale-os/kernel-core/path';
2
- import { schema as dslSchema } from '@astrale-os/kernel-dsl/v1';
3
2
  import { callableAddress, Key, patterns, } from '@astrale-os/kernel-dsl/v1/addressing';
3
+ import { callableFingerprint } from '@astrale-os/kernel-dsl/v1/schema/fingerprint';
4
4
  const FINGERPRINT_PATTERN = /^sha256:[0-9a-f]{64}$/u;
5
5
  export function reference(domain, callable) {
6
6
  if (Key.origin(callable.key) !== domain.origin)
@@ -10,7 +10,7 @@ export function reference(domain, callable) {
10
10
  const common = Object.freeze({
11
11
  origin: domain.origin,
12
12
  revision: domain.revision,
13
- fingerprint: dslSchema.callableFingerprint(callable),
13
+ fingerprint: callableFingerprint(callable),
14
14
  callable: callable.key,
15
15
  outputMode: callable.output.mode,
16
16
  });
@@ -1,5 +1,5 @@
1
1
  import { Path } from '@astrale-os/kernel-core/path';
2
- import { schema as dslSchema } from '@astrale-os/kernel-dsl/v1';
2
+ import { callableFingerprint } from '@astrale-os/kernel-dsl/v1/schema/fingerprint';
3
3
  import { Client } from '../client/index.js';
4
4
  import { SessionError } from '../errors/index.js';
5
5
  import { createDomainBinding } from '../graph/domain/binding.js';
@@ -178,7 +178,7 @@ export class ClientSession {
178
178
  return this.invoke({
179
179
  origin: owner.origin,
180
180
  revision: owner.revision,
181
- fingerprint: dslSchema.callableFingerprint(method),
181
+ fingerprint: callableFingerprint(method),
182
182
  callable: method.key,
183
183
  target: Path.instanceMethod(Path.id(node.id), method.key),
184
184
  outputMode: method.output.mode,
@@ -2,7 +2,7 @@ import { NodeId } from '@astrale-os/kernel-core/graph/node';
2
2
  import { Path } from '@astrale-os/kernel-core/path';
3
3
  import { K, KernelSchema } from '@astrale-os/kernel-core/schema';
4
4
  import { defineSchema, func, method, nodeClass, schema, valueSchema, } from '@astrale-os/kernel-dsl/v1';
5
- import { ClientSession, reference } from './index.js';
5
+ import { ClientSession, createSessionCredentialProvider, reference } from './index.js';
6
6
  void new ClientSession(options);
7
7
  void auth;
8
8
  void control;
@@ -12,6 +12,15 @@ void request;
12
12
  void snapshot;
13
13
  const observedPublication = session.publication(control);
14
14
  void observedPublication;
15
+ const credentials = createSessionCredentialProvider({
16
+ ttlSeconds: 60,
17
+ mint: async () => minted,
18
+ });
19
+ // A credential provider is a Session authentication resolver; no adapter stands between them.
20
+ const credentialAuth = credentials;
21
+ void new ClientSession({ ...options, auth: credentialAuth });
22
+ // @ts-expect-error A minted credential must carry its own absolute expiry.
23
+ void createSessionCredentialProvider({ ttlSeconds: 60, mint: async () => minted.credential });
15
24
  const dynamicReference = reference(dynamicDomain, dynamicMethod)(Path.id(NodeId('dynamic-node')));
16
25
  void session.invoke(dynamicReference, dynamicInput);
17
26
  const dynamicInputIsClientInput = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrale-os/kernel-client",
3
- "version": "0.6.0-beta.65",
3
+ "version": "0.6.0-beta.67",
4
4
  "description": "Universal Client — protocol-first, transport-agnostic",
5
5
  "keywords": [
6
6
  "astrale",
@@ -65,14 +65,14 @@
65
65
  "access": "public"
66
66
  },
67
67
  "dependencies": {
68
- "@astrale-os/kernel-core": "0.9.0-beta.50",
69
- "@astrale-os/kernel-dsl": "0.2.0-beta.35",
70
- "@astrale-os/kernel-protocol": "0.5.0-beta.56",
68
+ "@astrale-os/kernel-core": "0.9.0-beta.51",
69
+ "@astrale-os/kernel-dsl": "0.2.0-beta.36",
70
+ "@astrale-os/kernel-protocol": "0.5.0-beta.57",
71
71
  "@tanstack/pacer": "0.20.1",
72
72
  "msgpackr": "^2.0.5"
73
73
  },
74
74
  "devDependencies": {
75
- "@astrale-os/kernel-server": "0.5.0-beta.58",
75
+ "@astrale-os/kernel-server": "0.5.0-beta.59",
76
76
  "@astrale/typescript-config": "npm:@jsr/astrale__typescript-config@~1.1.0",
77
77
  "@types/node": "^22.0.0",
78
78
  "typescript": "7.0.2",