@astrale-os/kernel-client 0.6.0-beta.21 → 0.6.0-beta.22

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.
@@ -1,7 +1,7 @@
1
1
  export type { SessionControlOptions, SessionPolicy } from './context/index.js';
2
2
  export { bindSession, type BoundClientSession, type BoundSessionControls, type BoundSessionSource, } from './bound.js';
3
3
  export type { SessionSnapshot } from './discovery/index.js';
4
- export type { Auth, SessionAuth, SessionEnvelopeTransport, SessionRequestOptions, } from './routing/index.js';
4
+ export type { Auth, PersistedRoute, SessionRouteArtifact, SessionRouteStore, SessionAuth, SessionEnvelopeTransport, SessionRequestOptions, } from './routing/index.js';
5
5
  export { ClientSession, connect, type ClientSessionOptions, type ConnectOptions, } from './session.js';
6
6
  export type { CallableReference, InputOfReference, InstanceCallableReference, OutputOfReference, } from './reference.js';
7
7
  export { reference } from './reference.js';
@@ -1,4 +1,4 @@
1
- import type { invocation } from '@astrale-os/kernel-protocol';
1
+ import { invocation } from '@astrale-os/kernel-protocol';
2
2
  import type { EffectiveAuth } from './auth.js';
3
3
  import type { SchemaExpectation } from './expectation.js';
4
4
  export interface LearnedRoute {
@@ -6,15 +6,36 @@ export interface LearnedRoute {
6
6
  readonly credential?: string;
7
7
  readonly expiresAt: number;
8
8
  }
9
- /** Confidential, process-local warm routing state owned by exactly one ClientSession. */
9
+ export interface PersistedRoute extends LearnedRoute {
10
+ /** HMAC over this entry and its exact authority partition. */
11
+ readonly integrity: string;
12
+ }
13
+ export interface SessionRouteArtifact {
14
+ readonly version: 1;
15
+ readonly entries: Readonly<Record<string, PersistedRoute>>;
16
+ }
17
+ /** Caller-owned persistence for confidential route artifacts. */
18
+ export interface SessionRouteStore {
19
+ read(): unknown;
20
+ write(artifact: SessionRouteArtifact): void;
21
+ }
22
+ /** Confidential warm routing state owned by exactly one ClientSession. */
10
23
  export declare class RouteCache {
11
24
  private readonly maximumAgeMs;
12
25
  private readonly maximumSize;
13
26
  private readonly entries;
14
- constructor(maximumAgeMs: number, maximumSize?: number);
27
+ private readonly keyTokens;
28
+ private readonly store?;
29
+ constructor(maximumAgeMs: number, maximumSize?: number, store?: SessionRouteStore);
15
30
  key(source: string, call: invocation.Call, auth: EffectiveAuth, expected?: SchemaExpectation): Promise<string>;
16
- get(key: string, now?: number): LearnedRoute | undefined;
17
- learn(key: string, route: invocation.Route, credential: string | undefined, now?: number): LearnedRoute;
31
+ get(key: string, sourceCredential?: string, now?: number): Promise<LearnedRoute | undefined>;
32
+ learn(key: string, route: invocation.Route, credential: string | undefined, sourceCredential?: string, now?: number): Promise<LearnedRoute>;
18
33
  delete(key: string): void;
34
+ /** Clear this Session's memory without deleting caller-owned persistent state. */
19
35
  clear(): void;
36
+ private invalidate;
37
+ private reserve;
38
+ private restore;
39
+ private restoreInput;
40
+ private persist;
20
41
  }
@@ -1,12 +1,17 @@
1
+ import { invocation } from '@astrale-os/kernel-protocol';
1
2
  import { DEFAULT_MAXIMUM_ROUTES } from '../context/index.js';
2
- /** Confidential, process-local warm routing state owned by exactly one ClientSession. */
3
+ /** Confidential warm routing state owned by exactly one ClientSession. */
3
4
  export class RouteCache {
4
5
  maximumAgeMs;
5
6
  maximumSize;
6
7
  entries = new Map();
7
- constructor(maximumAgeMs, maximumSize = DEFAULT_MAXIMUM_ROUTES) {
8
+ keyTokens = new Map();
9
+ store;
10
+ constructor(maximumAgeMs, maximumSize = DEFAULT_MAXIMUM_ROUTES, store) {
8
11
  this.maximumAgeMs = maximumAgeMs;
9
12
  this.maximumSize = maximumSize;
13
+ this.store = store === undefined ? undefined : captureSessionRouteStore(store);
14
+ this.restore();
10
15
  }
11
16
  async key(source, call, auth, expected) {
12
17
  const credential = auth.credential === undefined ? 'anonymous' : await digestText(auth.credential);
@@ -19,19 +24,31 @@ export class RouteCache {
19
24
  expected === undefined ? null : [expected.origin, expected.revision],
20
25
  ]);
21
26
  }
22
- get(key, now = Date.now()) {
23
- const entry = this.entries.get(key);
24
- if (entry === undefined)
27
+ async get(key, sourceCredential, now = Date.now()) {
28
+ const cached = this.entries.get(key);
29
+ if (cached === undefined)
25
30
  return undefined;
26
- if (entry.expiresAt <= now) {
27
- this.entries.delete(key);
31
+ const token = this.keyTokens.get(key);
32
+ if (cached.learned.expiresAt <= now) {
33
+ this.invalidate(key);
28
34
  return undefined;
29
35
  }
36
+ if (cached.integrity !== undefined) {
37
+ const matches = sourceCredential !== undefined &&
38
+ (await matchesIntegrity(sourceCredential, key, cached.sealed ?? cached.learned, cached.integrity));
39
+ if (this.keyTokens.get(key) !== token || this.entries.get(key) !== cached) {
40
+ return undefined;
41
+ }
42
+ if (!matches) {
43
+ this.invalidate(key);
44
+ return undefined;
45
+ }
46
+ }
30
47
  this.entries.delete(key);
31
- this.entries.set(key, entry);
32
- return entry;
48
+ this.entries.set(key, cached);
49
+ return cached.learned;
33
50
  }
34
- learn(key, route, credential, now = Date.now()) {
51
+ async learn(key, route, credential, sourceCredential, now = Date.now()) {
35
52
  const credentialExpiry = credential === undefined ? undefined : jwtExpiry(credential);
36
53
  const expiresAt = Math.min(now + this.maximumAgeMs, credentialExpiry ?? Number.POSITIVE_INFINITY);
37
54
  const entry = Object.freeze({
@@ -44,22 +61,197 @@ export class RouteCache {
44
61
  return entry;
45
62
  if (expiresAt <= now)
46
63
  return entry;
64
+ const token = this.reserve(key);
65
+ let integrity;
66
+ if (this.store !== undefined && sourceCredential !== undefined) {
67
+ try {
68
+ integrity = await routeIntegrity(sourceCredential, key, entry);
69
+ }
70
+ catch {
71
+ // Integrity support is a persistence optimization; the learned route remains usable in memory.
72
+ }
73
+ }
74
+ if (this.keyTokens.get(key) !== token) {
75
+ return entry;
76
+ }
47
77
  this.entries.delete(key);
48
78
  while (this.entries.size >= this.maximumSize) {
49
79
  const oldest = this.entries.keys().next().value;
50
80
  if (oldest === undefined)
51
81
  break;
52
82
  this.entries.delete(oldest);
83
+ this.keyTokens.delete(oldest);
53
84
  }
54
- this.entries.set(key, entry);
85
+ this.entries.set(key, Object.freeze({
86
+ learned: entry,
87
+ ...(integrity === undefined ? {} : { sealed: entry, integrity }),
88
+ }));
89
+ this.persist();
55
90
  return entry;
56
91
  }
57
92
  delete(key) {
58
- this.entries.delete(key);
93
+ this.invalidate(key);
59
94
  }
95
+ /** Clear this Session's memory without deleting caller-owned persistent state. */
60
96
  clear() {
97
+ this.keyTokens.clear();
61
98
  this.entries.clear();
62
99
  }
100
+ invalidate(key) {
101
+ this.keyTokens.delete(key);
102
+ if (this.entries.delete(key))
103
+ this.persist();
104
+ }
105
+ reserve(key) {
106
+ const token = Object.freeze({});
107
+ this.keyTokens.set(key, token);
108
+ return token;
109
+ }
110
+ restore(now = Date.now()) {
111
+ if (this.store === undefined)
112
+ return;
113
+ try {
114
+ this.restoreInput(this.store.read(), now);
115
+ }
116
+ catch {
117
+ this.keyTokens.clear();
118
+ this.entries.clear();
119
+ }
120
+ }
121
+ restoreInput(input, now) {
122
+ if (input === undefined)
123
+ return;
124
+ if (typeof input !== 'object' || input === null || Array.isArray(input))
125
+ return;
126
+ const artifact = input;
127
+ if (artifact.version !== 1 ||
128
+ typeof artifact.entries !== 'object' ||
129
+ artifact.entries === null ||
130
+ Array.isArray(artifact.entries)) {
131
+ return;
132
+ }
133
+ let count = 0;
134
+ for (const key in artifact.entries) {
135
+ if (!Object.hasOwn(artifact.entries, key))
136
+ continue;
137
+ count += 1;
138
+ if (count > this.maximumSize) {
139
+ this.keyTokens.clear();
140
+ this.entries.clear();
141
+ return;
142
+ }
143
+ const value = Reflect.get(artifact.entries, key);
144
+ const entry = admitStoredEntry(value, now, this.maximumAgeMs);
145
+ if (entry === undefined)
146
+ continue;
147
+ this.reserve(key);
148
+ this.entries.set(key, entry);
149
+ }
150
+ }
151
+ persist() {
152
+ if (this.store === undefined)
153
+ return;
154
+ const entries = Object.fromEntries([...this.entries].flatMap(([key, cached]) => cached.integrity === undefined
155
+ ? []
156
+ : [
157
+ [
158
+ key,
159
+ Object.freeze({
160
+ ...(cached.sealed ?? cached.learned),
161
+ integrity: cached.integrity,
162
+ }),
163
+ ],
164
+ ]));
165
+ try {
166
+ this.store.write(Object.freeze({ version: 1, entries: Object.freeze(entries) }));
167
+ }
168
+ catch {
169
+ // A cache-store outage must not turn a valid invocation into a product failure.
170
+ }
171
+ }
172
+ }
173
+ function captureSessionRouteStore(input) {
174
+ if (input === null ||
175
+ typeof input !== 'object' ||
176
+ typeof input.read !== 'function' ||
177
+ typeof input.write !== 'function') {
178
+ throw new TypeError('Session route store capability is invalid.');
179
+ }
180
+ const read = input.read;
181
+ const write = input.write;
182
+ return Object.freeze({
183
+ read: () => read.call(input),
184
+ write: (artifact) => write.call(input, artifact),
185
+ });
186
+ }
187
+ function admitStoredEntry(input, now, maximumAgeMs) {
188
+ try {
189
+ if (typeof input !== 'object' || input === null || Array.isArray(input))
190
+ return undefined;
191
+ const value = input;
192
+ if ((value.credential !== undefined && typeof value.credential !== 'string') ||
193
+ typeof value.expiresAt !== 'number' ||
194
+ !Number.isSafeInteger(value.expiresAt) ||
195
+ typeof value.integrity !== 'string' ||
196
+ !/^[A-Za-z0-9_-]{43}$/u.test(value.integrity)) {
197
+ return undefined;
198
+ }
199
+ const credentialExpiry = value.credential === undefined ? undefined : jwtExpiry(value.credential);
200
+ if (value.credential !== undefined && credentialExpiry === undefined)
201
+ return undefined;
202
+ const sealed = Object.freeze({
203
+ route: invocation.acceptRoute(value.route),
204
+ ...(value.credential === undefined ? {} : { credential: value.credential }),
205
+ expiresAt: value.expiresAt,
206
+ });
207
+ const expiresAt = Math.min(value.expiresAt, now + maximumAgeMs, credentialExpiry ?? Number.POSITIVE_INFINITY);
208
+ if (expiresAt <= now)
209
+ return undefined;
210
+ return Object.freeze({
211
+ learned: Object.freeze({
212
+ route: sealed.route,
213
+ ...(sealed.credential === undefined ? {} : { credential: sealed.credential }),
214
+ expiresAt,
215
+ }),
216
+ sealed,
217
+ integrity: value.integrity,
218
+ });
219
+ }
220
+ catch {
221
+ return undefined;
222
+ }
223
+ }
224
+ async function routeIntegrity(sourceCredential, key, learned) {
225
+ if (globalThis.crypto?.subtle === undefined) {
226
+ throw new TypeError('Persistent Session route integrity requires Web Crypto HMAC support.');
227
+ }
228
+ const algorithm = Object.freeze({ name: 'HMAC', hash: 'SHA-256' });
229
+ const cryptographicKey = await globalThis.crypto.subtle.importKey('raw', new TextEncoder().encode(sourceCredential), algorithm, false, ['sign']);
230
+ const signature = await globalThis.crypto.subtle.sign(algorithm, cryptographicKey, new TextEncoder().encode(integrityMaterial(key, learned)));
231
+ return encodeBase64Url(new Uint8Array(signature));
232
+ }
233
+ async function matchesIntegrity(sourceCredential, key, learned, expected) {
234
+ try {
235
+ return (await routeIntegrity(sourceCredential, key, learned)) === expected;
236
+ }
237
+ catch {
238
+ return false;
239
+ }
240
+ }
241
+ function integrityMaterial(key, learned) {
242
+ return JSON.stringify({
243
+ version: 1,
244
+ key,
245
+ route: learned.route,
246
+ ...(learned.credential === undefined ? {} : { credential: learned.credential }),
247
+ expiresAt: learned.expiresAt,
248
+ });
249
+ }
250
+ function encodeBase64Url(input) {
251
+ let binary = '';
252
+ for (const byte of input)
253
+ binary += String.fromCharCode(byte);
254
+ return globalThis.btoa(binary).replace(/\+/gu, '-').replace(/\//gu, '_').replace(/=+$/gu, '');
63
255
  }
64
256
  function canonicalDelegate(input) {
65
257
  return JSON.stringify({
@@ -3,7 +3,7 @@ import { acceptCall, dispatchTrace, isAdmittedMissError, isAdmittedStaleRouteErr
3
3
  import { ClientError, ResponseError } from '../../errors/index.js';
4
4
  import { runSessionOperation, sessionTransportOptions, allowEndpoint } from '../context/index.js';
5
5
  import { resolveAuth } from './auth.js';
6
- import { selectEnvelope } from './envelope.js';
6
+ import { selectEnvelope, selectEnvelopeTarget } from './envelope.js';
7
7
  export class SessionDispatcher {
8
8
  state;
9
9
  constructor(state) {
@@ -24,7 +24,16 @@ export class SessionDispatcher {
24
24
  cacheKey,
25
25
  ...(expected === undefined ? {} : { expected }),
26
26
  });
27
- const learned = this.state.cache.get(cacheKey);
27
+ let learned = await this.state.cache.get(cacheKey, auth.credential);
28
+ if (learned !== undefined) {
29
+ try {
30
+ requireCachedRoute(learned, source, expected, this.state.envelopeTransport, this.state.websocketAvailable, this.state.policy);
31
+ }
32
+ catch {
33
+ this.state.cache.delete(cacheKey);
34
+ learned = undefined;
35
+ }
36
+ }
28
37
  if (learned !== undefined) {
29
38
  return this.dispatchDestinationWithRecovery(learned, dispatch, options, operation);
30
39
  }
@@ -50,7 +59,7 @@ export class SessionDispatcher {
50
59
  throw new ClientError('Redirect issuer differs from the session-pinned source Kernel identity.');
51
60
  }
52
61
  requireExpectedPublication(redirect.route, state.expected);
53
- const learned = this.state.cache.learn(state.cacheKey, redirect.route, redirect.credential);
62
+ const learned = await this.state.cache.learn(state.cacheKey, redirect.route, redirect.credential, state.auth.credential);
54
63
  return this.dispatchDestinationWithRecovery(learned, state, options, operation, redirect.trace);
55
64
  }
56
65
  async dispatchDestinationWithRecovery(learned, state, options, operation, trace) {
@@ -98,6 +107,13 @@ export class SessionDispatcher {
98
107
  return result;
99
108
  }
100
109
  }
110
+ function requireCachedRoute(learned, source, expected, envelopeTransport, websocketAvailable, policy) {
111
+ if (learned.route.via.issuer !== source) {
112
+ throw new ClientError('Learned route issuer differs from the session-pinned source Kernel.');
113
+ }
114
+ requireExpectedPublication(learned.route, expected);
115
+ selectEnvelopeTarget(learned.route.endpoint, envelopeTransport, websocketAvailable, policy);
116
+ }
101
117
  function isRouteCompatibilityConflict(input) {
102
118
  if (!(input instanceof ResponseError))
103
119
  return false;
@@ -11,3 +11,5 @@ export interface EnvelopeSelection {
11
11
  readonly destination: ClientTarget;
12
12
  }
13
13
  export declare function selectEnvelope(endpoints: publication.InvocationEndpoints, preference: SessionEnvelopeTransport, websocketAvailable: boolean, pool: ClientPoolCapability, policy: Readonly<SessionPolicy>): EnvelopeSelection;
14
+ /** Resolve and admit one route endpoint without acquiring a transport Client. */
15
+ export declare function selectEnvelopeTarget(endpoints: publication.InvocationEndpoints, preference: SessionEnvelopeTransport, websocketAvailable: boolean, policy: Readonly<SessionPolicy>): ClientTarget;
@@ -1,6 +1,19 @@
1
1
  import { ClientError } from '../../errors/index.js';
2
2
  import { allowEndpoint } from '../context/index.js';
3
3
  export function selectEnvelope(endpoints, preference, websocketAvailable, pool, policy) {
4
+ const destination = selectEnvelopeTarget(endpoints, preference, websocketAvailable, policy);
5
+ const client = pool.clientFor(destination);
6
+ if (client === null ||
7
+ typeof client !== 'object' ||
8
+ typeof client.dispatch !== 'function' ||
9
+ typeof client.as !== 'function' ||
10
+ typeof client.close !== 'function') {
11
+ throw new TypeError('Session client pool capability returned an invalid Client.');
12
+ }
13
+ return Object.freeze({ client, destination });
14
+ }
15
+ /** Resolve and admit one route endpoint without acquiring a transport Client. */
16
+ export function selectEnvelopeTarget(endpoints, preference, websocketAvailable, policy) {
4
17
  let destination;
5
18
  if (preference === 'websocket') {
6
19
  if (endpoints.websocket === undefined) {
@@ -23,13 +36,5 @@ export function selectEnvelope(endpoints, preference, websocketAvailable, pool,
23
36
  allowEndpoint(endpoints.http, policy);
24
37
  destination = Object.freeze({ transport: 'http', url: endpoints.http });
25
38
  }
26
- const client = pool.clientFor(destination);
27
- if (client === null ||
28
- typeof client !== 'object' ||
29
- typeof client.dispatch !== 'function' ||
30
- typeof client.as !== 'function' ||
31
- typeof client.close !== 'function') {
32
- throw new TypeError('Session client pool capability returned an invalid Client.');
33
- }
34
- return Object.freeze({ client, destination });
39
+ return destination;
35
40
  }
@@ -1,6 +1,6 @@
1
1
  export { admitSessionAuth, resolveAuth, type Auth, type EffectiveAuth, type SessionAuth, } from './auth.js';
2
2
  export type { SchemaExpectation } from './expectation.js';
3
- export { RouteCache, type LearnedRoute } from './cache.js';
3
+ export { RouteCache, type LearnedRoute, type PersistedRoute, type SessionRouteArtifact, type SessionRouteStore, } from './cache.js';
4
4
  export { requireResultKind, SessionDispatcher, type SessionDispatcherOptions } from './dispatch.js';
5
5
  export { type ClientPoolCapability, type EnvelopeSelection } from './envelope.js';
6
6
  export { admitEnvelopeTransport, admitRequestOptions, type SessionEnvelopeTransport, type SessionRequestOptions, } from './request.js';
@@ -1,5 +1,5 @@
1
1
  export { admitSessionAuth, resolveAuth, } from './auth.js';
2
- export { RouteCache } from './cache.js';
2
+ export { RouteCache, } from './cache.js';
3
3
  export { requireResultKind, SessionDispatcher } from './dispatch.js';
4
4
  export {} from './envelope.js';
5
5
  export { admitEnvelopeTransport, admitRequestOptions, } from './request.js';
@@ -11,7 +11,7 @@ import type { WebSocketConstructor } from '../transport/websocket/index.js';
11
11
  import type { SessionControlOptions, SessionPolicy } from './context/index.js';
12
12
  import type { SessionSnapshot } from './discovery/index.js';
13
13
  import type { CallableReference, InputOfReference, OutputOfReference } from './reference.js';
14
- import type { SessionAuth, SessionEnvelopeTransport, SessionRequestOptions } from './routing/index.js';
14
+ import type { SessionRouteStore, SessionAuth, SessionEnvelopeTransport, SessionRequestOptions } from './routing/index.js';
15
15
  import type { ViewCatalog } from './views/index.js';
16
16
  export interface ClientSessionOptions {
17
17
  /** Exact canonical source Kernel identity; its invocation and discovery endpoints are derived. */
@@ -21,6 +21,8 @@ export interface ClientSessionOptions {
21
21
  readonly pool?: Pick<ClientPoolType, 'clientFor'>;
22
22
  readonly websocket?: WebSocketConstructor;
23
23
  readonly auth?: SessionAuth;
24
+ /** Optional caller-owned persistence for bounded confidential route artifacts. */
25
+ readonly routeStore?: SessionRouteStore;
24
26
  readonly policy: SessionPolicy;
25
27
  readonly envelopeTransport?: SessionEnvelopeTransport;
26
28
  readonly onWarning?: (warning: ClientWarning) => void;
@@ -77,7 +77,7 @@ export class ClientSession {
77
77
  requireOpen: () => this.requireOpen(),
78
78
  acquisitionOperation: () => this.operation(undefined),
79
79
  });
80
- this.routes = new RouteCache(policy.maximumRouteAgeMs);
80
+ this.routes = new RouteCache(policy.maximumRouteAgeMs, undefined, options.routeStore);
81
81
  this.dispatcher = new SessionDispatcher({
82
82
  kernel: this.kernel,
83
83
  sourceEndpoint,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrale-os/kernel-client",
3
- "version": "0.6.0-beta.21",
3
+ "version": "0.6.0-beta.22",
4
4
  "description": "Universal Client — protocol-first, transport-agnostic",
5
5
  "keywords": [
6
6
  "astrale",