@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.
- package/README.md +5 -1
- package/dist/astrale.js +178 -57
- package/dist/public/connect-core.js +42 -13
- package/dist/public/keys/index.js +14 -0
- package/dist/public/paths/index.js +14 -0
- package/dist/types/connection/auth.d.ts +1 -0
- package/dist/types/connection/credential.d.ts +1 -1
- package/dist/types/connection/lifetime.d.ts +6 -0
- package/dist/types/lib/credential-lifetime.d.ts +3 -0
- package/dist/types/lib/idp-session.d.ts +2 -0
- package/dist/types/lib/idp.d.ts +1 -1
- package/dist/types/state/exchange-credentials.d.ts +1 -1
- package/package.json +1 -1
- package/src/commands/__tests__/read-commands.test.ts +2 -2
- package/src/commands/query.ts +5 -5
- package/src/commands/update.ts +3 -2
- package/src/connection/.spec/architecture.md +5 -2
- package/src/connection/.spec/flows/session.ts +2 -1
- package/src/connection/.spec/laws/connection.ts +9 -1
- package/src/connection/.spec/layout.ts +1 -0
- package/src/connection/__tests__/credential.test.ts +115 -0
- package/src/connection/__tests__/exchange.test.ts +94 -9
- package/src/connection/auth.ts +20 -4
- package/src/connection/credential.ts +29 -8
- package/src/connection/exchange.ts +65 -7
- package/src/connection/lifetime.ts +25 -0
- package/src/graph/.spec/api.d.ts +1 -1
- package/src/graph/.spec/laws/documents.ts +7 -7
- package/src/graph/__tests__/query.test.ts +12 -5
- package/src/graph/query.ts +9 -11
- package/src/lib/__tests__/idp-session.driver.ts +10 -3
- package/src/lib/__tests__/idp-session.test.ts +25 -0
- package/src/lib/__tests__/skills.test.ts +27 -2
- package/src/lib/credential-lifetime.ts +24 -0
- package/src/lib/idp-session.ts +26 -4
- package/src/lib/idp.ts +24 -4
- package/src/lib/skills/sync.ts +72 -8
- package/src/program/__tests__/program.test.ts +39 -2
- package/src/state/__tests__/exchange-credentials.test.ts +112 -5
- package/src/state/exchange-credentials.ts +26 -8
- package/studio/server/cli-consumers.test.ts +1 -1
- package/studio/server/cli.test.ts +2 -2
- 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).
|
|
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: {
|
|
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(
|
|
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:
|
|
402
|
+
exp: expiresAt,
|
|
318
403
|
})}.signature`
|
|
319
404
|
}
|
package/src/connection/auth.ts
CHANGED
|
@@ -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: {
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
39
|
+
const delegatedTtlSeconds = sourceBoundDelegationTtl(resolved, ttlSeconds)
|
|
34
40
|
return Object.freeze({
|
|
35
41
|
credential: resolved,
|
|
36
|
-
...(
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/src/graph/.spec/api.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
4
|
-
id: 'CLI-GRAPH-QUERY-
|
|
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
|
|
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-
|
|
9
|
-
{ file: '__tests__/query.test.ts', id: 'TEST-CLI-GRAPH-AUTHORS-
|
|
10
|
-
{ file: '__tests__/query.test.ts', id: 'TEST-CLI-GRAPH-ADMITS-QUERY-
|
|
11
|
-
{ file: '__tests__/query.test.ts', id: 'TEST-CLI-GRAPH-ADMITS-QUERY-
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
171
|
-
|
|
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
|
})
|
package/src/graph/query.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { classReference } from './class'
|
|
|
12
12
|
|
|
13
13
|
export interface QueryCommandInput {
|
|
14
14
|
readonly sources: readonly string[]
|
|
15
|
-
readonly
|
|
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/
|
|
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.
|
|
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, --
|
|
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.
|
|
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
|
|
58
|
-
input.
|
|
59
|
-
const nodes =
|
|
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', {
|
|
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 {
|