@astrale-os/cli 1.0.0-beta.31 → 1.0.0-beta.32

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.
@@ -31,8 +31,11 @@ current Grant for routing. Connection itself persists no credential or route; th
31
31
  owner persists exchanged source credentials and the separate Kernel Client route artifact. A valid exchanged credential is selected by the
32
32
  authenticated source issuer and subject before any live `whoami`; cache misses alone resolve the
33
33
  registered Kernel User and perform delegation plus Domain exchange.
34
- Exchange authority is bounded to five minutes and never outlives the current source credential,
35
- matching the CLI route-age ceiling without forcing an unrelated two-minute refresh cycle.
34
+ Exchange and destination-carrier authority cover the selected command timeout plus one bounded
35
+ receipt margin, never outlive the current source credential, and retain the existing one-minute
36
+ floor for short commands. A cached or freshly exchanged credential that cannot cover that lifetime
37
+ is refreshed or rejected before destination dispatch, so a long durable mutation does not first
38
+ discover expired callback authority after its provider effect commits.
36
39
 
37
40
  Every ClientSession receives the CLI-owned `state/session-routes` representation capability. Kernel
38
41
  Client still owns route keying, admission, expiry, and one safe stale/miss recovery; Connection does
@@ -24,8 +24,9 @@ declare const resolveSourceCredential: (
24
24
  ) => Promise<string>
25
25
  /** Bind source authority without learning or minting destination credentials. */
26
26
  function createConnectionAuth(target: ConnectionTarget, options: ConnectionOptions): SessionAuth {
27
+ const ttlSeconds = Math.max(60, Math.ceil(resolveTimeoutMs(options.timeout) / 1_000) + 5)
27
28
  return {
28
- ttlSeconds: 3_600,
29
+ ttlSeconds,
29
30
  async resolve(_call: Call, signal: AbortSignal) {
30
31
  return {
31
32
  credential: await resolveSourceCredential(target, options, target.issuer, signal),
@@ -135,12 +135,20 @@ export const CLI_CONNECTION_TERMINAL_CLOSE = defineLaw({
135
135
  export const CLI_CONNECTION_TIMEOUT = defineLaw({
136
136
  id: 'CLI-CONNECTION-TIMEOUT',
137
137
  statement:
138
- 'The CLI accepts only a positive integer timeout before constructing a Client Session and applies it to both source-Auth and Session operations.',
138
+ 'The CLI accepts only a positive integer timeout before constructing a Client Session, applies it to source-Auth and Session operations, and requires exchanged and destination-carrier authority to cover that timeout plus the bounded receipt margin before destination dispatch.',
139
139
  tests: [
140
140
  {
141
141
  file: '__tests__/session.test.ts',
142
142
  id: 'TEST-CLI-CONNECTION-REJECTS-INVALID-TIMEOUT-BEFORE-OPEN',
143
143
  },
144
+ {
145
+ file: '__tests__/credential.test.ts',
146
+ id: 'TEST-CLI-CONNECTION-CARRIER-COVERS-COMMAND-TIMEOUT',
147
+ },
148
+ {
149
+ file: '__tests__/exchange.test.ts',
150
+ id: 'TEST-CLI-EXCHANGE-REJECTS-INSUFFICIENT-LIFETIME',
151
+ },
144
152
  ],
145
153
  })
146
154
 
@@ -11,6 +11,7 @@ export default defineLayout({
11
11
  'failure/',
12
12
  'exchange.ts',
13
13
  'index.ts',
14
+ 'lifetime.ts',
14
15
  'reasons.ts',
15
16
  'self.ts',
16
17
  'session.ts',
@@ -3,9 +3,13 @@ import type { SessionAuth } from '@astrale-os/sdk/client/session'
3
3
  import { issuer, type IssuerId } from '@astrale-os/sdk/auth'
4
4
  import { Path } from '@astrale-os/sdk/graph/path'
5
5
  import { describe, expect, test } from 'bun:test'
6
+ import { mkdtemp, rm } from 'node:fs/promises'
7
+ import { tmpdir } from 'node:os'
8
+ import { join } from 'node:path'
6
9
 
7
10
  import type { AstraleConfig } from '../../lib/config'
8
11
 
12
+ import { persistKeypair, signAs } from '../../keys/index'
9
13
  import { bindCredentialIdentity } from '../auth'
10
14
  import { createCliCredential, createConnectionCredential } from '../credential'
11
15
 
@@ -77,6 +81,117 @@ describe('connection credential', () => {
77
81
  expect(resolved.delegate?.ttlSeconds).toBeLessThan(120)
78
82
  })
79
83
 
84
+ /** @evidence TEST-CLI-CONNECTION-CARRIER-COVERS-COMMAND-TIMEOUT */
85
+ test('covers a long command deadline with one destination carrier', async () => {
86
+ const expiresAt = Math.ceil(Date.now() / 1_000) + 300
87
+ const auth = createConnectionCredential(SOURCE, { resolve: async () => token(expiresAt) }, 185)
88
+
89
+ await expect(auth.resolve(TARGET_CALL, new AbortController().signal)).resolves.toMatchObject({
90
+ credential: token(expiresAt),
91
+ delegate: { ttlSeconds: 185 },
92
+ })
93
+ expect(auth.ttlSeconds).toBe(185)
94
+ })
95
+
96
+ test('real local-key credentials cover the supported long-operation carrier', async () => {
97
+ const directory = await mkdtemp(join(tmpdir(), 'astrale-carrier-key-'))
98
+ try {
99
+ await persistKeypair('alice', { keysDir: directory })
100
+ const source = await signAs('alice', directory, {
101
+ issuer: SOURCE,
102
+ audience: SOURCE,
103
+ })
104
+ const auth = createConnectionCredential(SOURCE, { resolve: async () => source }, 185)
105
+
106
+ await expect(auth.resolve(TARGET_CALL, new AbortController().signal)).resolves.toMatchObject({
107
+ credential: source,
108
+ delegate: { ttlSeconds: 185 },
109
+ })
110
+ } finally {
111
+ await rm(directory, { recursive: true, force: true })
112
+ }
113
+ })
114
+
115
+ test('real five-minute local-key credentials reject a 600-second carrier before dispatch', async () => {
116
+ const directory = await mkdtemp(join(tmpdir(), 'astrale-carrier-ceiling-key-'))
117
+ try {
118
+ await persistKeypair('alice', { keysDir: directory })
119
+ const source = await signAs('alice', directory, {
120
+ issuer: SOURCE,
121
+ audience: SOURCE,
122
+ })
123
+ const auth = createConnectionCredential(SOURCE, { resolve: async () => source }, 605)
124
+
125
+ await expect(auth.resolve(TARGET_CALL, new AbortController().signal)).rejects.toMatchObject({
126
+ code: 'CREDENTIAL_LIFETIME_INSUFFICIENT',
127
+ })
128
+ } finally {
129
+ await rm(directory, { recursive: true, force: true })
130
+ }
131
+ })
132
+
133
+ test('derives every destination carrier lifetime from the selected CLI timeout', () => {
134
+ for (const target of [
135
+ { url: `${SOURCE}/invoke`, kernelIssuer: SOURCE },
136
+ {
137
+ url: `${SOURCE}/invoke`,
138
+ kernelIssuer: SOURCE,
139
+ domainIssuer: issuer.accept('https://admin.example'),
140
+ },
141
+ ]) {
142
+ const auth = createCliCredential(target, {}, config, globalThis.fetch, 180_000)
143
+ expect(auth?.ttlSeconds).toBe(185)
144
+ }
145
+ })
146
+
147
+ test('rejects a long command before dispatch when its source bearer is too short', async () => {
148
+ const expiresAt = Math.ceil(Date.now() / 1_000) + 120
149
+ const auth = createConnectionCredential(SOURCE, { resolve: async () => token(expiresAt) }, 185)
150
+
151
+ await expect(auth.resolve(TARGET_CALL, new AbortController().signal)).rejects.toMatchObject({
152
+ code: 'CREDENTIAL_LIFETIME_INSUFFICIENT',
153
+ })
154
+ })
155
+
156
+ test('rejects an inspectable short or expired bearer before a default command dispatch', async () => {
157
+ for (const expiresAt of [
158
+ Math.ceil(Date.now() / 1_000) + 30,
159
+ Math.ceil(Date.now() / 1_000) - 30,
160
+ ]) {
161
+ const auth = createConnectionCredential(SOURCE, {
162
+ resolve: async () => token(expiresAt),
163
+ })
164
+ await expect(auth.resolve(TARGET_CALL, new AbortController().signal)).rejects.toMatchObject({
165
+ code: 'CREDENTIAL_LIFETIME_INSUFFICIENT',
166
+ })
167
+ }
168
+ })
169
+
170
+ test('rejects a too-short explicit Domain bearer before exchange or destination I/O', async () => {
171
+ let fetches = 0
172
+ const expiresAt = Math.ceil(Date.now() / 1_000) + 120
173
+ const auth = createCliCredential(
174
+ {
175
+ url: `${SOURCE}/invoke`,
176
+ kernelIssuer: SOURCE,
177
+ domainIssuer: issuer.accept('https://admin.example'),
178
+ },
179
+ { creds: token(expiresAt) },
180
+ config,
181
+ async () => {
182
+ fetches += 1
183
+ throw new Error('network must remain untouched')
184
+ },
185
+ 180_000,
186
+ )
187
+ if (auth === undefined) throw new Error('expected authenticated credential')
188
+
189
+ await expect(auth.resolve(TARGET_CALL, new AbortController().signal)).rejects.toMatchObject({
190
+ code: 'CREDENTIAL_LIFETIME_INSUFFICIENT',
191
+ })
192
+ expect(fetches).toBe(0)
193
+ })
194
+
80
195
  /** @evidence TEST-CLI-CONNECTION-USES-RAW-SOURCE-CREDENTIAL */
81
196
  test('binds explicit CLI credentials to source-Kernel auth only', async () => {
82
197
  const auth = createCliCredential(
@@ -101,9 +101,7 @@ describe('Domain token exchange', () => {
101
101
  },
102
102
  })
103
103
  const delegatedTtl = kernelRequests[1]!.body!.call.input.ttlSeconds
104
- expect(delegatedTtl).toBeGreaterThan(0)
105
- expect(delegatedTtl).toBeGreaterThanOrEqual(295)
106
- expect(delegatedTtl).toBeLessThanOrEqual(300)
104
+ expect(delegatedTtl).toBe(75)
107
105
  expect(sourceAudiences).toEqual([KERNEL, KERNEL])
108
106
  expect(
109
107
  observed.filter((entry) => entry.url.endsWith('/.well-known/astrale/token')),
@@ -173,6 +171,87 @@ describe('Domain token exchange', () => {
173
171
  })
174
172
  })
175
173
 
174
+ test('requests and retains a carrier that covers a supported long command deadline', async () => {
175
+ const expiresAt = Math.floor(Date.now() / 1_000) + 250
176
+ const exchanged = token(DOMAIN, KERNEL, 'user-1', expiresAt)
177
+ const observed: Record<string, any>[] = []
178
+ const base = exchangeFetch(exchanged, { expiresAt })
179
+ const fetch: Fetch = async (input, init) => {
180
+ if (String(input) === INVOCATION) {
181
+ observed.push(JSON.parse(await new Response(init?.body).text()))
182
+ }
183
+ return base(input, init)
184
+ }
185
+ const resolver = createExchangeCredentialResolver(
186
+ TARGET,
187
+ { resolve: async () => sourceToken('user-1', expiresAt + 50) },
188
+ fetch,
189
+ 180_000,
190
+ new ExchangeCredentialCache(join(directory, 'long-credential.json')),
191
+ )
192
+
193
+ await expect(resolver.resolve(KERNEL, new AbortController().signal)).resolves.toBe(exchanged)
194
+ expect(observed[1]!.call.input.ttlSeconds).toBe(200)
195
+ })
196
+
197
+ /** @evidence TEST-CLI-EXCHANGE-REJECTS-INSUFFICIENT-LIFETIME */
198
+ test('rejects a too-short Domain exchange credential before destination dispatch', async () => {
199
+ const expiresAt = Math.floor(Date.now() / 1_000) + 150
200
+ const exchanged = token(DOMAIN, KERNEL, 'user-1', expiresAt)
201
+ const resolver = createExchangeCredentialResolver(
202
+ TARGET,
203
+ { resolve: async () => sourceToken('user-1', expiresAt + 150) },
204
+ exchangeFetch(exchanged, { expiresAt }),
205
+ 180_000,
206
+ new ExchangeCredentialCache(join(directory, 'short-credential.json')),
207
+ )
208
+
209
+ await expect(resolver.resolve(KERNEL, new AbortController().signal)).rejects.toMatchObject({
210
+ code: 'TOKEN_EXCHANGE_LIFETIME_INSUFFICIENT',
211
+ })
212
+ })
213
+
214
+ test('rejects a long outer Domain token whose carried Kernel proof is too short', async () => {
215
+ const outerExpiresAt = Math.floor(Date.now() / 1_000) + 250
216
+ const proofExpiresAt = Math.floor(Date.now() / 1_000) + 150
217
+ const exchanged = token(DOMAIN, KERNEL, 'user-1', outerExpiresAt, proofExpiresAt)
218
+ const resolver = createExchangeCredentialResolver(
219
+ TARGET,
220
+ { resolve: async () => sourceToken('user-1', outerExpiresAt + 50) },
221
+ exchangeFetch(exchanged, { expiresAt: outerExpiresAt }),
222
+ 180_000,
223
+ new ExchangeCredentialCache(join(directory, 'short-proof.json')),
224
+ )
225
+
226
+ await expect(resolver.resolve(KERNEL, new AbortController().signal)).rejects.toMatchObject({
227
+ code: 'TOKEN_EXCHANGE_LIFETIME_INSUFFICIENT',
228
+ })
229
+ })
230
+
231
+ test('rejects the real 300-second Domain ceiling for a 600-second command before destination dispatch', async () => {
232
+ const expiresAt = Math.floor(Date.now() / 1_000) + 300
233
+ const exchanged = token(DOMAIN, KERNEL, 'user-1', expiresAt)
234
+ const observed: string[] = []
235
+ const base = exchangeFetch(exchanged, { expiresAt })
236
+ const fetch: Fetch = async (input, init) => {
237
+ observed.push(String(input))
238
+ return base(input, init)
239
+ }
240
+ const resolver = createExchangeCredentialResolver(
241
+ TARGET,
242
+ { resolve: async () => sourceToken('user-1', expiresAt + 700) },
243
+ fetch,
244
+ 600_000,
245
+ new ExchangeCredentialCache(join(directory, 'domain-ceiling.json')),
246
+ )
247
+
248
+ await expect(resolver.resolve(KERNEL, new AbortController().signal)).rejects.toMatchObject({
249
+ code: 'TOKEN_EXCHANGE_LIFETIME_INSUFFICIENT',
250
+ })
251
+ expect(observed.filter((url) => url === INVOCATION)).toHaveLength(2)
252
+ expect(observed.filter((url) => url.endsWith('/.well-known/astrale/token'))).toHaveLength(1)
253
+ })
254
+
176
255
  test('rejects success responses without no-store or with malformed fields', async () => {
177
256
  const exchanged = token(DOMAIN, KERNEL, 'user-1', EXPIRES_AT)
178
257
  const cache = () => new ExchangeCredentialCache(join(directory, crypto.randomUUID()))
@@ -237,7 +316,11 @@ describe('Domain token exchange', () => {
237
316
 
238
317
  function exchangeFetch(
239
318
  exchanged: string,
240
- options: { readonly body?: unknown; readonly cacheControl?: boolean } = {},
319
+ options: {
320
+ readonly body?: unknown
321
+ readonly cacheControl?: boolean
322
+ readonly expiresAt?: number
323
+ } = {},
241
324
  ): Fetch {
242
325
  return async (input, init) => {
243
326
  const url = String(input)
@@ -253,7 +336,9 @@ function exchangeFetch(
253
336
  }
254
337
  if (url.endsWith('/.well-known/openid-configuration')) return jsonResponse(configuration(true))
255
338
  const response = new Response(
256
- JSON.stringify(options.body ?? { token: exchanged, expiresAt: EXPIRES_AT }),
339
+ JSON.stringify(
340
+ options.body ?? { token: exchanged, expiresAt: options.expiresAt ?? EXPIRES_AT },
341
+ ),
257
342
  {
258
343
  status: 200,
259
344
  headers: {
@@ -290,13 +375,13 @@ function jsonResponse(value: unknown, status = 200, contentType = 'application/j
290
375
  })
291
376
  }
292
377
 
293
- function token(iss: string, aud: string, user: string, exp: number): string {
378
+ function token(iss: string, aud: string, user: string, exp: number, proofExp = exp): string {
294
379
  const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString('base64url')
295
380
  const proof = `${encode({ alg: 'EdDSA', typ: 'JWT' })}.${encode({
296
381
  iss: aud,
297
382
  sub: user,
298
383
  aud,
299
- exp,
384
+ exp: proofExp,
300
385
  delegation: { v: 1, expr: { kind: 'identity', id: user } },
301
386
  })}.signature`
302
387
  return `${encode({ alg: 'EdDSA', typ: 'JWT' })}.${encode({
@@ -308,12 +393,12 @@ function token(iss: string, aud: string, user: string, exp: number): string {
308
393
  })}.signature`
309
394
  }
310
395
 
311
- function sourceToken(subject: string | undefined): string {
396
+ function sourceToken(subject: string | undefined, expiresAt = SOURCE_EXPIRES_AT): string {
312
397
  const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString('base64url')
313
398
  return `${encode({ alg: 'EdDSA', typ: 'JWT' })}.${encode({
314
399
  iss: 'https://workos.example',
315
400
  ...(subject === undefined ? {} : { sub: subject }),
316
401
  aud: KERNEL,
317
- exp: SOURCE_EXPIRES_AT,
402
+ exp: expiresAt,
318
403
  })}.signature`
319
404
  }
@@ -47,7 +47,12 @@ export async function bindCredentialIdentity<Options extends ConnectionOptions>(
47
47
  * registration for the target instance, use that target-issued `(iss, sub)`.
48
48
  */
49
49
  export async function resolveCredential(
50
- opts: { as?: string; creds?: string; defaultIdentity?: string },
50
+ opts: {
51
+ as?: string
52
+ creds?: string
53
+ defaultIdentity?: string
54
+ minimumRemainingSeconds?: number
55
+ },
51
56
  config: AstraleConfig,
52
57
  audience: string = config.issuer,
53
58
  registrationKey?: string,
@@ -69,7 +74,12 @@ export async function resolveCredential(
69
74
  resolvedIdentity = identity
70
75
  resolvedName = identityName
71
76
  if ((identity.source ?? 'key') === 'idp')
72
- return await resolveIdpAccessToken(identityName, identity, audience)
77
+ return await resolveIdpAccessToken(
78
+ identityName,
79
+ identity,
80
+ audience,
81
+ opts.minimumRemainingSeconds,
82
+ )
73
83
  return await signAs(
74
84
  identity.subject,
75
85
  KEYS_DIR,
@@ -81,7 +91,12 @@ export async function resolveCredential(
81
91
  resolvedIdentity = identity
82
92
  resolvedName = identity.name
83
93
  if ((identity.source ?? 'key') === 'idp') {
84
- return await resolveIdpAccessToken(identity.name, identity, audience)
94
+ return await resolveIdpAccessToken(
95
+ identity.name,
96
+ identity,
97
+ audience,
98
+ opts.minimumRemainingSeconds,
99
+ )
85
100
  }
86
101
 
87
102
  return await signAs(
@@ -164,10 +179,11 @@ async function resolveIdpAccessToken(
164
179
  identityName: string,
165
180
  identity: Identity,
166
181
  audience: string,
182
+ minimumRemainingSeconds?: number,
167
183
  ): Promise<string> {
168
184
  let resolved
169
185
  try {
170
- resolved = await ensureFreshSession(identityName, { audience })
186
+ resolved = await ensureFreshSession(identityName, { audience, minimumRemainingSeconds })
171
187
  } catch (e) {
172
188
  if (e instanceof IdpSessionMissingError) {
173
189
  throw new Error(
@@ -7,8 +7,10 @@ import type { AstraleConfig } from '../lib/config'
7
7
  import type { ConnectionOptions, ConnectionTarget } from './target'
8
8
 
9
9
  import { AstraleError } from '../errors'
10
+ import { remainingCredentialLifetimeSeconds } from '../lib/credential-lifetime'
10
11
  import { resolveCredential } from './auth'
11
12
  import { createExchangeCredentialResolver } from './exchange'
13
+ import { exchangeCredentialTtlSeconds, invocationCredentialTtlSeconds } from './lifetime'
12
14
  import { registrationKeyForTarget } from './target'
13
15
 
14
16
  const DELEGATION_TTL_SECONDS = 60
@@ -21,32 +23,46 @@ export interface SourceCredentialResolver {
21
23
  export function createConnectionCredential(
22
24
  expectedSourceIssuer: IssuerId,
23
25
  source: SourceCredentialResolver,
26
+ ttlSeconds = DELEGATION_TTL_SECONDS,
24
27
  ): SessionAuth {
28
+ if (!Number.isSafeInteger(ttlSeconds) || ttlSeconds < 1) {
29
+ throw new TypeError('Connection credential ttlSeconds must be a positive safe integer.')
30
+ }
25
31
  const resolveSource = source.resolve.bind(source)
26
32
  return Object.freeze({
27
- ttlSeconds: DELEGATION_TTL_SECONDS,
33
+ ttlSeconds,
28
34
  async resolve(
29
35
  _call: Parameters<SessionAuth['resolve']>[0],
30
36
  signal: Parameters<SessionAuth['resolve']>[1],
31
37
  ) {
32
38
  const resolved = await resolveSource(expectedSourceIssuer, signal)
33
- const ttlSeconds = sourceBoundDelegationTtl(resolved)
39
+ const delegatedTtlSeconds = sourceBoundDelegationTtl(resolved, ttlSeconds)
34
40
  return Object.freeze({
35
41
  credential: resolved,
36
- ...(ttlSeconds === undefined ? {} : { delegate: { ttlSeconds } }),
42
+ ...(delegatedTtlSeconds === undefined
43
+ ? {}
44
+ : { delegate: { ttlSeconds: delegatedTtlSeconds } }),
37
45
  })
38
46
  },
39
47
  })
40
48
  }
41
49
 
42
50
  /** Never request a destination carrier that could outlive its current source bearer. */
43
- function sourceBoundDelegationTtl(input: string): number | undefined {
51
+ function sourceBoundDelegationTtl(input: string, requestedTtlSeconds: number): number | undefined {
44
52
  try {
45
53
  const expiresAt = credential.inspect(input).claims.exp
46
54
  if (typeof expiresAt !== 'number' || !Number.isSafeInteger(expiresAt)) return undefined
47
- const remaining = expiresAt - Math.ceil(Date.now() / 1_000) - 1
48
- return Math.max(1, Math.min(DELEGATION_TTL_SECONDS, remaining))
49
- } catch {
55
+ const remaining = remainingCredentialLifetimeSeconds(expiresAt)
56
+ if (remaining < requestedTtlSeconds) {
57
+ throw new AstraleError(
58
+ 'CREDENTIAL_LIFETIME_INSUFFICIENT',
59
+ 'The selected credential cannot cover the requested command timeout.',
60
+ `Use a fresh identity session or a shorter --timeout; ${Math.max(0, remaining)} seconds remain but ${requestedTtlSeconds} are required.`,
61
+ )
62
+ }
63
+ return Math.max(1, Math.min(requestedTtlSeconds, remaining))
64
+ } catch (cause) {
65
+ if (cause instanceof AstraleError) throw cause
50
66
  // Preserve opaque explicit credentials; the Kernel remains their authority.
51
67
  return undefined
52
68
  }
@@ -66,6 +82,10 @@ export function createCliCredential(
66
82
  ...(options.as === undefined ? {} : { as: options.as }),
67
83
  ...(options.creds === undefined ? {} : { creds: options.creds }),
68
84
  ...(target.defaultIdentity === undefined ? {} : { defaultIdentity: target.defaultIdentity }),
85
+ minimumRemainingSeconds:
86
+ target.domainIssuer === undefined
87
+ ? invocationCredentialTtlSeconds(timeoutMs)
88
+ : exchangeCredentialTtlSeconds(timeoutMs),
69
89
  })
70
90
  const source: SourceCredentialResolver = {
71
91
  async resolve(audience, signal) {
@@ -89,7 +109,8 @@ export function createCliCredential(
89
109
  fetch,
90
110
  timeoutMs,
91
111
  )
92
- return createConnectionCredential(target.kernelIssuer, effective)
112
+ const ttlSeconds = invocationCredentialTtlSeconds(timeoutMs)
113
+ return createConnectionCredential(target.kernelIssuer, effective, ttlSeconds)
93
114
  }
94
115
 
95
116
  /** Reject contradictory explicit credential selections before identity or network access. */
@@ -2,16 +2,17 @@ import type { IssuerId } from '@astrale-os/sdk/auth'
2
2
  import type { Fetch } from '@astrale-os/sdk/client'
3
3
 
4
4
  import { createAuth } from '@astrale-os/sdk/auth'
5
- import { credential, exchange as exchangeProtocol } from '@astrale-os/sdk/auth'
5
+ import { credential, exchange as exchangeProtocol, grant } from '@astrale-os/sdk/auth'
6
6
  import { call, Client } from '@astrale-os/sdk/client'
7
7
 
8
8
  import type { SourceCredentialResolver } from './credential'
9
9
  import type { ConnectionTarget } from './target'
10
10
 
11
11
  import { AstraleError } from '../errors'
12
+ import { remainingCredentialLifetimeSeconds } from '../lib/credential-lifetime'
12
13
  import { ExchangeCredentialCache } from '../state/exchange-credentials'
14
+ import { cachedCredentialTtlSeconds, exchangeCredentialTtlSeconds } from './lifetime'
13
15
 
14
- const EXCHANGE_TTL_SECONDS = 5 * 60
15
16
  const MAXIMUM_RESPONSE_BYTES = 256 * 1024
16
17
 
17
18
  /** Exchange exact authenticated User authority for a Domain bearer bound to this Kernel. */
@@ -23,6 +24,8 @@ export function createExchangeCredentialResolver(
23
24
  cache = new ExchangeCredentialCache(),
24
25
  ): SourceCredentialResolver {
25
26
  requireExchangeTransport(target)
27
+ const cacheTtlSeconds = cachedCredentialTtlSeconds(timeoutMs)
28
+ const exchangeTtlSeconds = exchangeCredentialTtlSeconds(timeoutMs)
26
29
  return Object.freeze({
27
30
  async resolve(kernelIssuer: IssuerId, signal: AbortSignal): Promise<string> {
28
31
  requireLive(signal)
@@ -37,8 +40,9 @@ export function createExchangeCredentialResolver(
37
40
  sourceIssuer: sourceIdentity.issuer,
38
41
  sourceSubject: sourceIdentity.subject,
39
42
  }),
43
+ cacheTtlSeconds,
40
44
  async () => {
41
- const delegationTtlSeconds = delegationLifetime(sourceToken)
45
+ const delegationTtlSeconds = delegationLifetime(sourceToken, exchangeTtlSeconds)
42
46
  const client = new Client({ url: `${kernelIssuer}/invoke`, fetch, timeoutMs })
43
47
  try {
44
48
  const authenticated = client.as(sourceToken)
@@ -69,7 +73,14 @@ export function createExchangeCredentialResolver(
69
73
  }
70
74
  if (envelope === undefined) throw new Error('Token delegation returned no credential.')
71
75
  return {
72
- ...(await exchange(target.domainIssuer, kernelIssuer, envelope, fetch, signal)),
76
+ ...(await exchange(
77
+ target.domainIssuer,
78
+ kernelIssuer,
79
+ envelope,
80
+ cacheTtlSeconds,
81
+ fetch,
82
+ signal,
83
+ )),
73
84
  user: user.id,
74
85
  sourceIssuer: sourceIdentity.issuer,
75
86
  sourceSubject: sourceIdentity.subject,
@@ -107,7 +118,7 @@ function unknownFunctionOutcome(cause: unknown): boolean {
107
118
  return (error.reason as { readonly code?: unknown }).code === 'FUNCTION_OUTCOME_UNKNOWN'
108
119
  }
109
120
 
110
- function delegationLifetime(sourceToken: string): number {
121
+ function delegationLifetime(sourceToken: string, requiredTtlSeconds: number): number {
111
122
  const expiresAt = credential.inspect(sourceToken).claims.exp
112
123
  if (typeof expiresAt !== 'number' || !Number.isSafeInteger(expiresAt)) {
113
124
  throw new AstraleError(
@@ -115,20 +126,28 @@ function delegationLifetime(sourceToken: string): number {
115
126
  'The source identity credential has no valid expiration.',
116
127
  )
117
128
  }
118
- const remaining = expiresAt - Math.ceil(Date.now() / 1_000) - 1
129
+ const remaining = remainingCredentialLifetimeSeconds(expiresAt)
119
130
  if (!Number.isSafeInteger(remaining) || remaining < 1) {
120
131
  throw new AstraleError(
121
132
  'TOKEN_EXCHANGE_SOURCE_EXPIRED',
122
133
  'The source identity credential has no lifetime available for token exchange.',
123
134
  )
124
135
  }
125
- return Math.min(EXCHANGE_TTL_SECONDS, remaining)
136
+ if (remaining < requiredTtlSeconds) {
137
+ throw new AstraleError(
138
+ 'TOKEN_EXCHANGE_SOURCE_LIFETIME_INSUFFICIENT',
139
+ 'The source credential cannot cover the requested command timeout.',
140
+ `Refresh the identity session or use a shorter --timeout; ${remaining} seconds remain but ${requiredTtlSeconds} are required.`,
141
+ )
142
+ }
143
+ return requiredTtlSeconds
126
144
  }
127
145
 
128
146
  async function exchange(
129
147
  domainIssuer: IssuerId,
130
148
  kernelIssuer: IssuerId,
131
149
  envelope: string,
150
+ requiredTtlSeconds: number,
132
151
  fetch: Fetch,
133
152
  signal: AbortSignal,
134
153
  ): Promise<{ readonly credential: string; readonly expiresAt: number }> {
@@ -225,9 +244,48 @@ async function exchange(
225
244
  'Token exchange returned a credential inconsistent with the requested Domain and Kernel.',
226
245
  )
227
246
  }
247
+ const remaining = effectiveExchangeLifetime(inspected, exchanged.expiresAt)
248
+ if (remaining < requiredTtlSeconds) {
249
+ throw new AstraleError(
250
+ 'TOKEN_EXCHANGE_LIFETIME_INSUFFICIENT',
251
+ 'The Domain exchange credential cannot cover the requested command timeout.',
252
+ `The Domain issuer returned ${Math.max(0, remaining)} seconds but ${requiredTtlSeconds} are required. Use a shorter --timeout or update the Domain execution service.`,
253
+ )
254
+ }
228
255
  return Object.freeze({ credential: exchanged.token, expiresAt: exchanged.expiresAt })
229
256
  }
230
257
 
258
+ /** The outer Domain bearer and its carried Kernel proof must both survive the operation. */
259
+ function effectiveExchangeLifetime(
260
+ inspected: ReturnType<typeof credential.inspect>,
261
+ outerExpiresAt: number,
262
+ ): number {
263
+ let proofExpiresAt: number
264
+ try {
265
+ const carried = grant.acceptUnresolved(inspected.claims.grant).expr
266
+ if (
267
+ carried.kind !== 'identity' ||
268
+ !('credential' in carried) ||
269
+ typeof carried.credential !== 'string'
270
+ ) {
271
+ throw new TypeError('Domain credential does not carry an identity proof.')
272
+ }
273
+ const value = credential.inspect(carried.credential).claims.exp
274
+ if (typeof value !== 'number' || !Number.isSafeInteger(value)) {
275
+ throw new TypeError('Domain credential carries an identity proof without an expiration.')
276
+ }
277
+ proofExpiresAt = value
278
+ } catch (cause) {
279
+ if (!(cause instanceof TypeError)) throw cause
280
+ throw new AstraleError(
281
+ 'TOKEN_EXCHANGE_PROTOCOL_ERROR',
282
+ 'Token exchange returned an invalid carried identity proof.',
283
+ cause.message,
284
+ )
285
+ }
286
+ return remainingCredentialLifetimeSeconds(Math.min(outerExpiresAt, proofExpiresAt))
287
+ }
288
+
231
289
  async function fetchExchange(
232
290
  fetch: Fetch,
233
291
  input: string,
@@ -0,0 +1,25 @@
1
+ const MINIMUM_CREDENTIAL_TTL_SECONDS = 60
2
+ const INVOCATION_RECEIPT_MARGIN_SECONDS = 5
3
+ const CACHE_HANDOFF_MARGIN_SECONDS = 5
4
+ const TOKEN_EXCHANGE_SETTLEMENT_MARGIN_SECONDS = 15
5
+
6
+ /** Cover the complete command deadline while preserving the existing short-command floor. */
7
+ export function invocationCredentialTtlSeconds(timeoutMs: number): number {
8
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) {
9
+ throw new TypeError('Invocation credential timeout must be a positive safe integer.')
10
+ }
11
+ return Math.max(
12
+ MINIMUM_CREDENTIAL_TTL_SECONDS,
13
+ Math.ceil(timeoutMs / 1_000) + INVOCATION_RECEIPT_MARGIN_SECONDS,
14
+ )
15
+ }
16
+
17
+ /** Leave the final carrier lifetime intact after source delegation and issuer exchange settle. */
18
+ export function exchangeCredentialTtlSeconds(timeoutMs: number): number {
19
+ return invocationCredentialTtlSeconds(timeoutMs) + TOKEN_EXCHANGE_SETTLEMENT_MARGIN_SECONDS
20
+ }
21
+
22
+ /** Refresh a cached token before the final Session carrier can cross a second boundary. */
23
+ export function cachedCredentialTtlSeconds(timeoutMs: number): number {
24
+ return invocationCredentialTtlSeconds(timeoutMs) + CACHE_HANDOFF_MARGIN_SECONDS
25
+ }