@astrale-os/cli 1.0.0-beta.30 → 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.
Files changed (43) hide show
  1. package/README.md +5 -1
  2. package/dist/astrale.js +178 -57
  3. package/dist/public/connect-core.js +42 -13
  4. package/dist/public/keys/index.js +14 -0
  5. package/dist/public/paths/index.js +14 -0
  6. package/dist/types/connection/auth.d.ts +1 -0
  7. package/dist/types/connection/credential.d.ts +1 -1
  8. package/dist/types/connection/lifetime.d.ts +6 -0
  9. package/dist/types/lib/credential-lifetime.d.ts +3 -0
  10. package/dist/types/lib/idp-session.d.ts +2 -0
  11. package/dist/types/lib/idp.d.ts +1 -1
  12. package/dist/types/state/exchange-credentials.d.ts +1 -1
  13. package/package.json +1 -1
  14. package/src/commands/__tests__/read-commands.test.ts +2 -2
  15. package/src/commands/query.ts +5 -5
  16. package/src/commands/update.ts +3 -2
  17. package/src/connection/.spec/architecture.md +5 -2
  18. package/src/connection/.spec/flows/session.ts +2 -1
  19. package/src/connection/.spec/laws/connection.ts +9 -1
  20. package/src/connection/.spec/layout.ts +1 -0
  21. package/src/connection/__tests__/credential.test.ts +115 -0
  22. package/src/connection/__tests__/exchange.test.ts +94 -9
  23. package/src/connection/auth.ts +20 -4
  24. package/src/connection/credential.ts +29 -8
  25. package/src/connection/exchange.ts +65 -7
  26. package/src/connection/lifetime.ts +25 -0
  27. package/src/graph/.spec/api.d.ts +1 -1
  28. package/src/graph/.spec/laws/documents.ts +7 -7
  29. package/src/graph/__tests__/query.test.ts +12 -5
  30. package/src/graph/query.ts +9 -11
  31. package/src/lib/__tests__/idp-session.driver.ts +10 -3
  32. package/src/lib/__tests__/idp-session.test.ts +25 -0
  33. package/src/lib/__tests__/skills.test.ts +27 -2
  34. package/src/lib/credential-lifetime.ts +24 -0
  35. package/src/lib/idp-session.ts +26 -4
  36. package/src/lib/idp.ts +24 -4
  37. package/src/lib/skills/sync.ts +72 -8
  38. package/src/program/__tests__/program.test.ts +39 -2
  39. package/src/state/__tests__/exchange-credentials.test.ts +112 -5
  40. package/src/state/exchange-credentials.ts +26 -8
  41. package/studio/server/cli-consumers.test.ts +1 -1
  42. package/studio/server/cli.test.ts +2 -2
  43. package/studio/server/views/target.ts +1 -1
@@ -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
+ }
@@ -11,7 +11,7 @@ export function classKey(input: string, label: string): ClassKey
11
11
  /** Untrusted CLI fields used to author or admit one exact Query V6 request. */
12
12
  export interface QueryCommandInput {
13
13
  readonly sources: readonly string[]
14
- readonly definition?: string
14
+ readonly class?: string
15
15
  readonly ast?: unknown
16
16
  readonly edge?: string
17
17
  readonly direction?: QueryDirection
@@ -1,14 +1,14 @@
1
1
  import { defineLaw } from '@astrale-os/spec/authoring'
2
2
 
3
- export const CLI_GRAPH_QUERY_V5 = defineLaw({
4
- id: 'CLI-GRAPH-QUERY-V5',
3
+ export const CLI_GRAPH_QUERY_V6 = defineLaw({
4
+ id: 'CLI-GRAPH-QUERY-V6',
5
5
  statement:
6
- 'CLI query input becomes exactly one canonical Query V5 document with an explicit finite limit; exact Property ordering and Node or Edge projection profiles are admitted through the canonical AST surface, while legacy versions and unsupported selector combinations fail before a Graph call.',
6
+ 'CLI query input becomes exactly one canonical Query V6 document with an explicit finite limit; --class authors one exact Class source, exact Property ordering and Node or Edge projection profiles are admitted through the canonical AST surface, and legacy versions or unsupported selector combinations fail before a Graph call.',
7
7
  tests: [
8
- { file: '__tests__/query.test.ts', id: 'TEST-CLI-GRAPH-AUTHORS-QUERY-V5' },
9
- { file: '__tests__/query.test.ts', id: 'TEST-CLI-GRAPH-AUTHORS-DEFINITION-QUERY' },
10
- { file: '__tests__/query.test.ts', id: 'TEST-CLI-GRAPH-ADMITS-QUERY-V5-ORDERING' },
11
- { file: '__tests__/query.test.ts', id: 'TEST-CLI-GRAPH-ADMITS-QUERY-V5-PROJECTIONS' },
8
+ { file: '__tests__/query.test.ts', id: 'TEST-CLI-GRAPH-AUTHORS-QUERY-V6' },
9
+ { file: '__tests__/query.test.ts', id: 'TEST-CLI-GRAPH-AUTHORS-CLASS-QUERY' },
10
+ { file: '__tests__/query.test.ts', id: 'TEST-CLI-GRAPH-ADMITS-QUERY-V6-ORDERING' },
11
+ { file: '__tests__/query.test.ts', id: 'TEST-CLI-GRAPH-ADMITS-QUERY-V6-PROJECTIONS' },
12
12
  { file: '__tests__/query.test.ts', id: 'TEST-CLI-GRAPH-REJECTS-LEGACY-QUERY' },
13
13
  ],
14
14
  })
@@ -37,11 +37,11 @@ describe('prepareQuery', () => {
37
37
  })
38
38
  })
39
39
 
40
- /** @evidence TEST-CLI-GRAPH-AUTHORS-DEFINITION-QUERY */
40
+ /** @evidence TEST-CLI-GRAPH-AUTHORS-CLASS-QUERY */
41
41
  test('authors an exact Class source without backend query text', () => {
42
42
  const prepared = prepareQuery({
43
43
  sources: [],
44
- definition: '/:issues.astrale.ai:class.Issue',
44
+ class: '/:issues.astrale.ai:class.Issue',
45
45
  limit: '201',
46
46
  })
47
47
 
@@ -67,7 +67,7 @@ describe('prepareQuery', () => {
67
67
  test('unions positional Paths and one Class source in authored order', () => {
68
68
  const prepared = prepareQuery({
69
69
  sources: ['@note'],
70
- definition: '/:issues.astrale.ai:class.Issue',
70
+ class: '/:issues.astrale.ai:class.Issue',
71
71
  })
72
72
 
73
73
  expect(JSON.parse(JSON.stringify(prepared.ast.source))).toEqual({
@@ -167,7 +167,14 @@ describe('prepareQuery', () => {
167
167
  prepareQuery({ sources: ['/:notes.example.dev:class.Note'], limit: 'all' }),
168
168
  ).toThrow('--limit must be a positive integer')
169
169
  expect(() =>
170
- prepareQuery({ sources: [], definition: '/:notes.example.dev:view.note' }),
171
- ).toThrow('--definition must be one canonical Class Path')
170
+ prepareQuery({
171
+ sources: [],
172
+ ast: {},
173
+ class: '/:notes.example.dev:class.Note',
174
+ }),
175
+ ).toThrow('--ast/--file cannot be combined with sources, --class, --edge, or --direction')
176
+ expect(() => prepareQuery({ sources: [], class: '/:notes.example.dev:view.note' })).toThrow(
177
+ '--class must be one canonical Class Path',
178
+ )
172
179
  })
173
180
  })
@@ -12,7 +12,7 @@ import { classReference } from './class'
12
12
 
13
13
  export interface QueryCommandInput {
14
14
  readonly sources: readonly string[]
15
- readonly definition?: string
15
+ readonly class?: string
16
16
  readonly ast?: unknown
17
17
  readonly edge?: string
18
18
  readonly direction?: QueryDirection
@@ -27,36 +27,34 @@ export interface PreparedQuery {
27
27
 
28
28
  const DEFAULT_LIMIT = 100
29
29
 
30
- /** Admit canonical Query V6 or author the intentionally small Path/Definition/one-edge subset. */
30
+ /** Admit canonical Query V6 or author the intentionally small Path/Class/one-edge subset. */
31
31
  export function prepareQuery(input: QueryCommandInput): PreparedQuery {
32
32
  const limit = positiveInteger(input.limit, '--limit', DEFAULT_LIMIT)
33
33
  if (input.ast !== undefined) {
34
34
  if (
35
35
  input.sources.length > 0 ||
36
- input.definition !== undefined ||
36
+ input.class !== undefined ||
37
37
  input.edge !== undefined ||
38
38
  input.direction !== undefined
39
39
  ) {
40
40
  throw new TypeError(
41
- '--ast/--file cannot be combined with sources, --definition, --edge, or --direction',
41
+ '--ast/--file cannot be combined with sources, --class, --edge, or --direction',
42
42
  )
43
43
  }
44
44
  return withPage(QueryAST.decode(input.ast), limit, input.cursor)
45
45
  }
46
46
 
47
- if (input.sources.length === 0 && input.definition === undefined) {
48
- throw new TypeError(
49
- 'query requires a Path source, --definition, or a canonical Query V6 document',
50
- )
47
+ if (input.sources.length === 0 && input.class === undefined) {
48
+ throw new TypeError('query requires a Path source, --class, or a canonical Query V6 document')
51
49
  }
52
50
  if (input.direction !== undefined && input.edge === undefined) {
53
51
  throw new TypeError('--direction requires --edge')
54
52
  }
55
53
 
56
54
  const paths = input.sources.map((source) => Path.parse(source))
57
- const definition =
58
- input.definition === undefined ? undefined : classReference(input.definition, '--definition')
59
- const nodes = definition === undefined ? paths : [...paths, definition]
55
+ const selectedClass =
56
+ input.class === undefined ? undefined : classReference(input.class, '--class')
57
+ const nodes = selectedClass === undefined ? paths : [...paths, selectedClass]
60
58
  const query: NodeQueryBuilder<unknown> = Query.from({
61
59
  nodes: nodes as [QueryNodeInput, ...QueryNodeInput[]],
62
60
  })
@@ -13,6 +13,9 @@ import { ensureFreshSession } from '../idp-session'
13
13
 
14
14
  const scenario = process.argv[2]
15
15
  const audience = process.env.DRIVER_AUDIENCE || undefined
16
+ const minimumRemainingSeconds = process.env.DRIVER_MINIMUM_SECONDS
17
+ ? Number(process.env.DRIVER_MINIMUM_SECONDS)
18
+ : undefined
16
19
  let orgHintCalls = 0
17
20
  const resolveOrganizationId = async (): Promise<string | undefined> => {
18
21
  orgHintCalls += 1
@@ -25,12 +28,16 @@ function print(result: Record<string, unknown>): void {
25
28
 
26
29
  try {
27
30
  if (scenario === 'ensure') {
28
- const session = await ensureFreshSession('alice', { audience, resolveOrganizationId })
31
+ const session = await ensureFreshSession('alice', {
32
+ audience,
33
+ minimumRemainingSeconds,
34
+ resolveOrganizationId,
35
+ })
29
36
  print({ ok: true, token: accessTokenForAudience(session, audience), orgHintCalls })
30
37
  } else if (scenario === 'ensure-concurrent') {
31
38
  const [a, b] = await Promise.all([
32
- ensureFreshSession('alice', { audience, resolveOrganizationId }),
33
- ensureFreshSession('alice', { audience, resolveOrganizationId }),
39
+ ensureFreshSession('alice', { audience, minimumRemainingSeconds, resolveOrganizationId }),
40
+ ensureFreshSession('alice', { audience, minimumRemainingSeconds, resolveOrganizationId }),
34
41
  ])
35
42
  print({
36
43
  ok: true,
@@ -80,6 +80,31 @@ describe('ensureFreshSession', () => {
80
80
  }
81
81
  })
82
82
 
83
+ test('refreshes at the conservative whole-second lifetime boundary', async () => {
84
+ const server = rotationServer()
85
+ try {
86
+ await writeIdpConfig(server.url)
87
+ const shortToken = unsignedJwt({ aud: AUD, exp: Math.ceil(Date.now() / 1_000) + 200 })
88
+ await writeSession({
89
+ access_token: shortToken,
90
+ // The loose millisecond timestamp appears sufficient. Delegation uses
91
+ // the JWT exp and its conservative second-boundary handoff instead.
92
+ expires_at: new Date(Date.now() + 201_000).toISOString(),
93
+ })
94
+
95
+ const result = await runDriver('ensure', {
96
+ DRIVER_AUDIENCE: AUD,
97
+ DRIVER_MINIMUM_SECONDS: '200',
98
+ })
99
+
100
+ expect(result.ok).toBe(true)
101
+ expect(server.refreshCount()).toBe(1)
102
+ expect(result.token).not.toBe(shortToken)
103
+ } finally {
104
+ await server.stop()
105
+ }
106
+ })
107
+
83
108
  test('refreshes with the client that issued the session instead of the IdP default', async () => {
84
109
  const server = rotationServer()
85
110
  try {