@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
@@ -145,6 +145,12 @@ async function installFixture(
145
145
  ) {
146
146
  const calls: string[][] = []
147
147
  await installer(sourceRoot, snapshot, home, lockPath, calls)('npx', [])
148
+ const lock = await readLock(lockPath)
149
+ for (const skill of snapshot.skills) {
150
+ lock.skills[skill.name].astraleSourceRevision = snapshot.revision
151
+ lock.skills[skill.name].astraleSourceTree = skill.tree
152
+ }
153
+ await writeFile(lockPath, `${JSON.stringify(lock, null, 2)}\n`)
148
154
  }
149
155
 
150
156
  describe('Astrale skill reconciliation', () => {
@@ -164,9 +170,17 @@ describe('Astrale skill reconciliation', () => {
164
170
  expect(calls).toHaveLength(1)
165
171
  expect(calls[0]).toContain(`astrale-os/cli#${source.snapshot.ref}`)
166
172
  expect(requestedSkills(calls[0])).toEqual(source.snapshot.skills.map((skill) => skill.name))
167
- expect(Object.keys((await readLock(target.lockPath)).skills).sort()).toEqual(
173
+ const installedLock = await readLock(target.lockPath)
174
+ expect(Object.keys(installedLock.skills).sort()).toEqual(
168
175
  source.snapshot.skills.map((skill) => skill.name).sort(),
169
176
  )
177
+ for (const skill of source.snapshot.skills) {
178
+ expect(installedLock.skills[skill.name]).toMatchObject({
179
+ ref: 'main',
180
+ astraleSourceRevision: source.snapshot.revision,
181
+ astraleSourceTree: skill.tree,
182
+ })
183
+ }
170
184
  })
171
185
 
172
186
  test('a coherent older cohort updates every current source skill', async () => {
@@ -398,7 +412,18 @@ describe('Astrale skill reconciliation', () => {
398
412
  lockPath: target.lockPath,
399
413
  resolveSource: async () => source.snapshot,
400
414
  }),
401
- ).toEqual({ status: 'current' })
415
+ ).toEqual({
416
+ status: 'current',
417
+ source: {
418
+ repository: 'astrale-os/cli',
419
+ revision: source.snapshot.revision,
420
+ skills: source.snapshot.skills.map(({ name, tree, path }) => ({
421
+ name,
422
+ tree,
423
+ entrypoint: path,
424
+ })),
425
+ },
426
+ })
402
427
  expect(await filesystemSnapshot(target.root)).toBe(beforeCurrentCheck)
403
428
 
404
429
  await rm(join(target.home, '.agents/skills/astrale-cli'), { recursive: true, force: true })
@@ -0,0 +1,24 @@
1
+ /** Whole seconds safely available after second-boundary rounding and carrier handoff. */
2
+ export function remainingCredentialLifetimeSeconds(
3
+ expiresAtEpochSeconds: number,
4
+ nowEpochSeconds = Math.ceil(Date.now() / 1_000),
5
+ ): number {
6
+ if (!Number.isSafeInteger(expiresAtEpochSeconds) || !Number.isSafeInteger(nowEpochSeconds)) {
7
+ throw new TypeError('Credential expiration and current time must be safe epoch seconds.')
8
+ }
9
+ return expiresAtEpochSeconds - nowEpochSeconds - 1
10
+ }
11
+
12
+ export function credentialLifetimeCovers(
13
+ expiresAtEpochSeconds: number,
14
+ minimumRemainingSeconds: number,
15
+ nowEpochSeconds?: number,
16
+ ): boolean {
17
+ if (!Number.isSafeInteger(minimumRemainingSeconds) || minimumRemainingSeconds < 1) {
18
+ throw new TypeError('Credential minimum lifetime must be a positive safe integer.')
19
+ }
20
+ return (
21
+ remainingCredentialLifetimeSeconds(expiresAtEpochSeconds, nowEpochSeconds) >=
22
+ minimumRemainingSeconds
23
+ )
24
+ }
@@ -36,6 +36,8 @@ export class IdpSessionNoRefreshTokenError extends Error {
36
36
  export type EnsureFreshSessionOptions = {
37
37
  audience?: string
38
38
  organizationId?: string
39
+ /** Minimum source-token lifetime required before the caller starts its operation. */
40
+ minimumRemainingSeconds?: number
39
41
  /**
40
42
  * Org-hint resolver, consulted only when a refresh actually happens.
41
43
  * Defaults to `fetchOrgHint`; injectable for tests.
@@ -63,9 +65,10 @@ export async function ensureFreshSession(
63
65
  identityName: string,
64
66
  opts: EnsureFreshSessionOptions = {},
65
67
  ): Promise<IdpSession> {
68
+ const minimumRemainingMs = minimumLifetimeMs(opts.minimumRemainingSeconds)
66
69
  const session = await readIdpSession(identityName)
67
70
  if (!session) throw new IdpSessionMissingError(identityName)
68
- if (accessTokenForAudience(session, opts.audience)) return session
71
+ if (accessTokenForAudience(session, opts.audience, minimumRemainingMs)) return session
69
72
  if (!session.refresh_token) throw new IdpSessionNoRefreshTokenError(identityName)
70
73
 
71
74
  return withFileLock(idpSessionLockPath(identityName), async () => {
@@ -73,7 +76,7 @@ export async function ensureFreshSession(
73
76
  // already rotated the session — using its result is the whole point.
74
77
  const current = await readIdpSession(identityName)
75
78
  if (!current) throw new IdpSessionMissingError(identityName)
76
- if (accessTokenForAudience(current, opts.audience)) return current
79
+ if (accessTokenForAudience(current, opts.audience, minimumRemainingMs)) return current
77
80
 
78
81
  // Org resolution order: explicit > bookmarked-at-create > router lookup.
79
82
  const bookmarkOrg =
@@ -106,7 +109,13 @@ export async function ensureFreshSession(
106
109
  })
107
110
  }
108
111
  }
109
- const rescued = await rescueAfterInvalidGrant(identityName, current, opts.audience, e)
112
+ const rescued = await rescueAfterInvalidGrant(
113
+ identityName,
114
+ current,
115
+ opts.audience,
116
+ minimumRemainingMs,
117
+ e,
118
+ )
110
119
  if (rescued) return rescued
111
120
  throw e
112
121
  }
@@ -124,11 +133,24 @@ async function rescueAfterInvalidGrant(
124
133
  identityName: string,
125
134
  seen: IdpSession,
126
135
  audience: string | undefined,
136
+ minimumRemainingMs: number,
127
137
  error: unknown,
128
138
  ): Promise<IdpSession | undefined> {
129
139
  if (!(error instanceof OAuthTokenError) || error.code !== 'invalid_grant') return undefined
130
140
  const latest = await readIdpSession(identityName).catch(() => null)
131
141
  if (!latest || latest.updatedAt === seen.updatedAt) return undefined
132
- if (!accessTokenForAudience(latest, audience)) return undefined
142
+ if (!accessTokenForAudience(latest, audience, minimumRemainingMs)) return undefined
133
143
  return latest
134
144
  }
145
+
146
+ function minimumLifetimeMs(input: number | undefined): number {
147
+ const seconds = input ?? 60
148
+ if (
149
+ !Number.isSafeInteger(seconds) ||
150
+ seconds < 1 ||
151
+ seconds > Math.floor(Number.MAX_SAFE_INTEGER / 1_000)
152
+ ) {
153
+ throw new TypeError('IdP session minimum lifetime must be a positive safe integer.')
154
+ }
155
+ return seconds * 1_000
156
+ }
package/src/lib/idp.ts CHANGED
@@ -4,6 +4,7 @@ import { dirname, join } from 'node:path'
4
4
  import { z } from 'zod'
5
5
 
6
6
  import { atomicWrite, IDPS_PATH, IDP_SESSIONS_DIR, paths } from '../state/index'
7
+ import { credentialLifetimeCovers } from './credential-lifetime'
7
8
  import { log } from './log'
8
9
  import { validateName, validateUrl } from './validation'
9
10
 
@@ -620,18 +621,37 @@ export function isSessionExpired(
620
621
  * top-level token's own `aud` claim. Without an `audience`, freshness of the
621
622
  * top-level token is the only requirement.
622
623
  */
623
- export function accessTokenForAudience(session: IdpSession, audience?: string): string | undefined {
624
+ export function accessTokenForAudience(
625
+ session: IdpSession,
626
+ audience?: string,
627
+ minimumRemainingMs = 60_000,
628
+ ): string | undefined {
624
629
  if (audience === undefined) {
625
- return isSessionExpired(session) ? undefined : session.access_token
630
+ return tokenHasMinimumLifetime(session, minimumRemainingMs) ? session.access_token : undefined
626
631
  }
627
632
  const entry = session.tokens?.[audience]
628
- if (entry && !isSessionExpired(entry)) return entry.access_token
629
- if (!isSessionExpired(session) && tokenAudienceMatches(session.access_token, audience)) {
633
+ if (entry && tokenHasMinimumLifetime(entry, minimumRemainingMs)) return entry.access_token
634
+ if (
635
+ tokenHasMinimumLifetime(session, minimumRemainingMs) &&
636
+ tokenAudienceMatches(session.access_token, audience)
637
+ ) {
630
638
  return session.access_token
631
639
  }
632
640
  return undefined
633
641
  }
634
642
 
643
+ /** Prefer the JWT expiration used by delegation; opaque IdP tokens retain their timestamp path. */
644
+ function tokenHasMinimumLifetime(
645
+ value: Pick<IdpSession, 'expires_at'> & { access_token: string },
646
+ minimumRemainingMs: number,
647
+ ): boolean {
648
+ const expiration = decodeTokenClaims(value.access_token)?.exp
649
+ if (typeof expiration === 'number' && Number.isSafeInteger(expiration)) {
650
+ return credentialLifetimeCovers(expiration, Math.ceil(minimumRemainingMs / 1_000))
651
+ }
652
+ return !isSessionExpired(value, minimumRemainingMs)
653
+ }
654
+
635
655
  /**
636
656
  * Fold a freshly minted access token into the per-audience map under every
637
657
  * `aud` it carries, dropping entries that have already expired.
@@ -52,6 +52,11 @@ export type SkillApplyStatus =
52
52
  export type SkillCheckResult = {
53
53
  status: SkillCheckStatus
54
54
  error?: string
55
+ source?: {
56
+ repository: typeof ASTRALE_CLI_SKILL_SOURCE
57
+ revision: string
58
+ skills: Array<{ name: string; tree: string; entrypoint: string }>
59
+ }
55
60
  }
56
61
 
57
62
  export type SkillApplyResult = {
@@ -70,6 +75,8 @@ type SkillLockEntry = {
70
75
  skillFolderHash?: string
71
76
  installedAt?: string
72
77
  updatedAt?: string
78
+ astraleSourceRevision?: string
79
+ astraleSourceTree?: string
73
80
  }
74
81
 
75
82
  type SkillLock = {
@@ -83,6 +90,7 @@ type SkillState = 'absent' | 'current' | 'outdated' | 'unhealthy'
83
90
 
84
91
  type SkillInspection = {
85
92
  state: SkillState
93
+ sourceCurrent: boolean
86
94
  managedNames: string[]
87
95
  managedFolders: string[]
88
96
  }
@@ -297,7 +305,7 @@ async function inspectAstraleSkills(
297
305
  }),
298
306
  )
299
307
  if (managed.length === 0 && expectedPresence.every((present) => !present)) {
300
- return { state: 'absent', managedNames: [], managedFolders: [] }
308
+ return { state: 'absent', sourceCurrent: false, managedNames: [], managedFolders: [] }
301
309
  }
302
310
 
303
311
  const folders = managed.flatMap(([name, entry]) => {
@@ -348,7 +356,7 @@ async function inspectAstraleSkills(
348
356
  }
349
357
  }
350
358
 
351
- const exactCurrent =
359
+ const sourceCurrent =
352
360
  coherent &&
353
361
  managed.length === snapshot.skills.length &&
354
362
  folders.every(({ key, folder, entry }) => {
@@ -360,9 +368,20 @@ async function inspectAstraleSkills(
360
368
  actualHashes.get(folder) === skill.tree
361
369
  )
362
370
  })
371
+ const exactCurrent =
372
+ sourceCurrent &&
373
+ folders.every(({ folder, entry }) => {
374
+ const skill = expected.get(folder)
375
+ return (
376
+ skill !== undefined &&
377
+ entry.astraleSourceRevision === snapshot.revision &&
378
+ entry.astraleSourceTree === skill.tree
379
+ )
380
+ })
363
381
 
364
382
  return {
365
383
  state: exactCurrent ? 'current' : coherent ? 'outdated' : 'unhealthy',
384
+ sourceCurrent,
366
385
  managedNames: managed.map(([name]) => name),
367
386
  managedFolders: [...uniqueFolders],
368
387
  }
@@ -405,6 +424,28 @@ async function installSnapshot(
405
424
  )
406
425
  }
407
426
 
427
+ async function stampAstraleSource(
428
+ snapshot: AstraleSkillSourceSnapshot,
429
+ lockPath: string,
430
+ ): Promise<void> {
431
+ const { lock } = await readSkillLock(lockPath)
432
+ if (!lock) throw new Error('skill installer receipt is unavailable after installation')
433
+ for (const skill of snapshot.skills) {
434
+ const entry = lock.skills[skill.name]
435
+ if (
436
+ !entry ||
437
+ !sourceOwned(entry) ||
438
+ entry.ref !== snapshot.ref ||
439
+ entry.skillPath !== skill.path
440
+ ) {
441
+ throw new Error(`skill installer receipt is incomplete for ${skill.name}`)
442
+ }
443
+ entry.astraleSourceRevision = snapshot.revision
444
+ entry.astraleSourceTree = skill.tree
445
+ }
446
+ await writeSkillLock(lockPath, lock)
447
+ }
448
+
408
449
  async function selectedAgents(home: string, lockPath: string): Promise<string[]> {
409
450
  const { lock } = await readSkillLock(lockPath)
410
451
  const agents = new Set(
@@ -693,13 +734,27 @@ export async function checkAstraleSkills(
693
734
  dependencies.home,
694
735
  dependencies.lockPath,
695
736
  )
737
+ const status =
738
+ inspection.state === 'current'
739
+ ? 'current'
740
+ : inspection.state === 'unhealthy'
741
+ ? 'repair-needed'
742
+ : 'update-available'
696
743
  return {
697
- status:
698
- inspection.state === 'current'
699
- ? 'current'
700
- : inspection.state === 'unhealthy'
701
- ? 'repair-needed'
702
- : 'update-available',
744
+ status,
745
+ ...(status === 'current'
746
+ ? {
747
+ source: {
748
+ repository: ASTRALE_CLI_SKILL_SOURCE,
749
+ revision: snapshot.revision,
750
+ skills: snapshot.skills.map(({ name, tree, path }) => ({
751
+ name,
752
+ tree,
753
+ entrypoint: path,
754
+ })),
755
+ },
756
+ }
757
+ : {}),
703
758
  }
704
759
  } catch (error) {
705
760
  return { status: 'unavailable', error: error instanceof Error ? error.message : String(error) }
@@ -768,6 +823,15 @@ export async function syncAstraleSkills(
768
823
  dependencies.run,
769
824
  knownRetired,
770
825
  )
826
+ const installedInspection = await inspectAstraleSkills(
827
+ snapshot,
828
+ dependencies.home,
829
+ dependencies.lockPath,
830
+ )
831
+ if (!installedInspection.sourceCurrent) {
832
+ throw new Error('installed Astrale skills do not match the resolved source')
833
+ }
834
+ await stampAstraleSource(snapshot, dependencies.lockPath)
771
835
  const verified = await inspectAstraleSkills(
772
836
  snapshot,
773
837
  dependencies.home,
@@ -195,7 +195,7 @@ describe('program composition', () => {
195
195
  'whoami',
196
196
  ])
197
197
  expect(createHash('sha256').update(JSON.stringify(surface)).digest('hex')).toBe(
198
- '092b8f4f737740105c8baed9d01a81604711b5b2719dfcb834eeeac414732c61',
198
+ '91c35b18985ad8606b5f2429fd8860b020a93edccb30dfbaa74d501929a8513b',
199
199
  )
200
200
  })
201
201
 
@@ -464,13 +464,50 @@ describe('help contract — read command split', () => {
464
464
  expect(queryHelp).not.toContain('--children <json>')
465
465
  expect(queryHelp).not.toContain('--edges <json>')
466
466
  expect(queryHelp).toContain('--ast <json>')
467
- expect(queryHelp).toContain('--definition <path>')
467
+ expect(queryHelp).toContain('--class <path>')
468
+ expect(queryHelp).not.toContain('--definition')
468
469
  expect(queryHelp).toContain('--edge <class>')
469
470
  expect(queryHelp).toContain('--direction <direction>')
470
471
  expect(queryHelp).toContain('--limit <n>')
471
472
  expect(queryHelp).toContain('--cursor <token>')
472
473
  expect(queryHelp).not.toContain('--cypher <query>')
473
474
  })
475
+
476
+ test('keeps the removed query --definition flag outside the parsed command surface', async () => {
477
+ const program = await buildProgram()
478
+ const query = allCommands(program).find((command) => command.name() === 'query')
479
+ const parsed = query?.parseOptions(['--definition', '/:notes.example.dev:class.Note'])
480
+
481
+ expect(parsed?.unknown).toContain('--definition')
482
+ expect(query?.options.some((option) => option.attributeName() === 'definition')).toBe(false)
483
+ })
484
+
485
+ test('routes query --class through Commander as the exact Class option', async () => {
486
+ const program = await buildProgram()
487
+ const query = allCommands(program).find((command) => command.name() === 'query')
488
+ let observed: unknown
489
+ query?.action((sources, options) => {
490
+ observed = { sources, class: options.class, limit: options.limit, json: options.json }
491
+ })
492
+
493
+ await program.parseAsync([
494
+ 'node',
495
+ 'astrale',
496
+ 'query',
497
+ '--class',
498
+ '/:notes.example.dev:class.Note',
499
+ '--limit',
500
+ '1',
501
+ '--json',
502
+ ])
503
+
504
+ expect(observed).toEqual({
505
+ sources: [],
506
+ class: '/:notes.example.dev:class.Note',
507
+ limit: '1',
508
+ json: true,
509
+ })
510
+ })
474
511
  })
475
512
 
476
513
  describe('help contract — explicit anonymous authentication', () => {
@@ -40,6 +40,7 @@ describe('exchange credential cache', () => {
40
40
  const resolve = (candidate: typeof first) =>
41
41
  cache.getOrRefresh(
42
42
  candidate,
43
+ 30,
43
44
  async () => {
44
45
  refreshes += 1
45
46
  return entry(candidate, 200)
@@ -69,9 +70,9 @@ describe('exchange credential cache', () => {
69
70
  }
70
71
 
71
72
  const values = await Promise.all([
72
- left.getOrRefresh(candidate, refresh, () => 100),
73
- left.getOrRefresh(candidate, refresh, () => 100),
74
- right.getOrRefresh(candidate, refresh, () => 100),
73
+ left.getOrRefresh(candidate, 30, refresh, () => 100),
74
+ left.getOrRefresh(candidate, 30, refresh, () => 100),
75
+ right.getOrRefresh(candidate, 30, refresh, () => 100),
75
76
  ])
76
77
  expect(new Set(values).size).toBe(1)
77
78
  expect(refreshes).toBe(1)
@@ -83,12 +84,14 @@ describe('exchange credential cache', () => {
83
84
  const candidate = key('https://kernel.example', 'https://domain.example', 'user')
84
85
  await cache.getOrRefresh(
85
86
  candidate,
87
+ 30,
86
88
  async () => entry(candidate, 120),
87
89
  () => 50,
88
90
  )
89
91
  let refreshes = 0
90
92
  await cache.getOrRefresh(
91
93
  candidate,
94
+ 30,
92
95
  async () => {
93
96
  refreshes += 1
94
97
  return entry(candidate, 220)
@@ -100,6 +103,7 @@ describe('exchange credential cache', () => {
100
103
  await expect(
101
104
  cache.getOrRefresh(
102
105
  key('https://other-kernel.example', candidate.domainIssuer, candidate.sourceSubject),
106
+ 30,
103
107
  async () => entry(candidate, 220),
104
108
  () => 100,
105
109
  ),
@@ -108,6 +112,7 @@ describe('exchange credential cache', () => {
108
112
  await expect(
109
113
  cache.getOrRefresh(
110
114
  key(candidate.kernelIssuer, candidate.domainIssuer, 'other-source'),
115
+ 30,
111
116
  async () => entry(candidate, 220),
112
117
  () => 100,
113
118
  ),
@@ -122,6 +127,7 @@ describe('exchange credential cache', () => {
122
127
  await expect(
123
128
  cache.getOrRefresh(
124
129
  malformedCandidate,
130
+ 30,
125
131
  async () => entry(malformedCandidate, 220, malformed),
126
132
  () => 100,
127
133
  ),
@@ -137,6 +143,7 @@ describe('exchange credential cache', () => {
137
143
  for (const candidate of [a, b]) {
138
144
  await cache.getOrRefresh(
139
145
  candidate,
146
+ 30,
140
147
  async () => entry(candidate, 200),
141
148
  () => 100,
142
149
  )
@@ -162,6 +169,7 @@ describe('exchange credential cache', () => {
162
169
  let refreshes = 0
163
170
  await cache.getOrRefresh(
164
171
  candidate,
172
+ 30,
165
173
  async () => {
166
174
  refreshes += 1
167
175
  return entry(candidate, 200)
@@ -173,6 +181,103 @@ describe('exchange credential cache', () => {
173
181
  expect(stored.version).toBe(2)
174
182
  expect(JSON.stringify(stored)).not.toContain('legacy')
175
183
  })
184
+
185
+ test('refreshes a valid cached credential that cannot cover the requested invocation', async () => {
186
+ const cache = new ExchangeCredentialCache(path)
187
+ const candidate = key('https://kernel.example', 'https://domain.example', 'user')
188
+ await cache.getOrRefresh(
189
+ candidate,
190
+ 30,
191
+ async () => entry(candidate, 250),
192
+ () => 100,
193
+ )
194
+ let refreshes = 0
195
+
196
+ await expect(
197
+ cache.getOrRefresh(
198
+ candidate,
199
+ 185,
200
+ async () => {
201
+ refreshes += 1
202
+ return entry(candidate, 300)
203
+ },
204
+ () => 100,
205
+ ),
206
+ ).resolves.toBe(token(candidate, 300))
207
+ expect(refreshes).toBe(1)
208
+
209
+ await expect(
210
+ cache.getOrRefresh(
211
+ candidate,
212
+ 201,
213
+ async () => entry(candidate, 190),
214
+ () => 100,
215
+ ),
216
+ ).rejects.toThrow(/required lifetime/i)
217
+ })
218
+
219
+ test('rejects a long outer token whose carried proof cannot cover the requested invocation', async () => {
220
+ const cache = new ExchangeCredentialCache(path)
221
+ const candidate = key('https://kernel.example', 'https://domain.example', 'user')
222
+
223
+ await expect(
224
+ cache.getOrRefresh(
225
+ candidate,
226
+ 185,
227
+ async () => entry(candidate, 300, undefined, 150),
228
+ () => 100,
229
+ ),
230
+ ).rejects.toThrow(/required lifetime/i)
231
+ })
232
+
233
+ test('serializes concurrent short and long callers without serving a short carrier to the long caller', async () => {
234
+ for (const firstKind of ['short', 'long'] as const) {
235
+ const concurrentPath = join(directory, firstKind, 'credentials.json')
236
+ const firstCache = new ExchangeCredentialCache(concurrentPath)
237
+ const secondCache = new ExchangeCredentialCache(concurrentPath)
238
+ const candidate = key('https://kernel.example', 'https://domain.example', firstKind)
239
+ let releaseFirst!: () => void
240
+ let markFirstStarted!: () => void
241
+ const firstStarted = new Promise<void>((resolve) => {
242
+ markFirstStarted = resolve
243
+ })
244
+ const release = new Promise<void>((resolve) => {
245
+ releaseFirst = resolve
246
+ })
247
+ let refreshes = 0
248
+ const firstMinimum = firstKind === 'short' ? 30 : 185
249
+ const firstExpiration = firstKind === 'short' ? 200 : 300
250
+ const first = firstCache.getOrRefresh(
251
+ candidate,
252
+ firstMinimum,
253
+ async () => {
254
+ refreshes += 1
255
+ markFirstStarted()
256
+ await release
257
+ return entry(candidate, firstExpiration)
258
+ },
259
+ () => 100,
260
+ )
261
+ await firstStarted
262
+ const secondMinimum = firstKind === 'short' ? 185 : 30
263
+ const secondExpiration = firstKind === 'short' ? 300 : 200
264
+ const second = secondCache.getOrRefresh(
265
+ candidate,
266
+ secondMinimum,
267
+ async () => {
268
+ refreshes += 1
269
+ return entry(candidate, secondExpiration)
270
+ },
271
+ () => 100,
272
+ )
273
+ releaseFirst()
274
+
275
+ const [firstValue, secondValue] = await Promise.all([first, second])
276
+ const longValue = firstKind === 'long' ? firstValue : secondValue
277
+ expect(longValue).toBe(token(candidate, 300))
278
+ expect(refreshes).toBe(firstKind === 'short' ? 2 : 1)
279
+ }
280
+ })
176
281
  })
177
282
 
178
283
  function key(
@@ -193,9 +298,10 @@ function entry(
193
298
  candidate: ReturnType<typeof key>,
194
299
  expiresAt: number,
195
300
  malformed?: 'outer-delegation' | 'proof-without-delegation',
301
+ proofExpiresAt = expiresAt,
196
302
  ) {
197
303
  return {
198
- credential: token(candidate, expiresAt, malformed),
304
+ credential: token(candidate, expiresAt, malformed, proofExpiresAt),
199
305
  expiresAt,
200
306
  user: candidate.sourceSubject,
201
307
  sourceIssuer: candidate.sourceIssuer,
@@ -207,13 +313,14 @@ function token(
207
313
  candidate: ReturnType<typeof key>,
208
314
  exp: number,
209
315
  malformed?: 'outer-delegation' | 'proof-without-delegation',
316
+ proofExp = exp,
210
317
  ): string {
211
318
  const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString('base64url')
212
319
  const proof = `${encode({ alg: 'EdDSA', typ: 'JWT' })}.${encode({
213
320
  iss: candidate.kernelIssuer,
214
321
  sub: candidate.sourceSubject,
215
322
  aud: candidate.kernelIssuer,
216
- exp,
323
+ exp: proofExp,
217
324
  ...(malformed === 'proof-without-delegation'
218
325
  ? {}
219
326
  : {
@@ -2,6 +2,7 @@ import { credential, grant } from '@astrale-os/sdk/auth'
2
2
  import { chmod, mkdir, readFile } from 'node:fs/promises'
3
3
  import { dirname } from 'node:path'
4
4
 
5
+ import { credentialLifetimeCovers } from '../lib/credential-lifetime'
5
6
  import { atomicWrite, withFileLock } from './files'
6
7
  import { EXCHANGE_CREDENTIALS_PATH } from './paths'
7
8
 
@@ -37,16 +38,27 @@ export class ExchangeCredentialCache {
37
38
 
38
39
  getOrRefresh(
39
40
  key: exchange.Key,
41
+ minimumRemainingSeconds: number,
40
42
  refresh: () => Promise<exchange.Entry>,
41
43
  now = () => Math.floor(Date.now() / 1_000),
42
44
  ): Promise<string> {
45
+ if (!Number.isSafeInteger(minimumRemainingSeconds) || minimumRemainingSeconds < 1) {
46
+ throw new TypeError('Exchange credential minimum lifetime must be a positive safe integer.')
47
+ }
43
48
  const encoded = encodeKey(key)
44
- const current = this.refreshing.get(encoded)
49
+ const pendingKey = `${encoded}\0${minimumRemainingSeconds}`
50
+ const current = this.refreshing.get(pendingKey)
45
51
  if (current !== undefined) return current
46
- const pending = this.getOrRefreshOnce(key, encoded, refresh, now).finally(() => {
47
- this.refreshing.delete(encoded)
52
+ const pending = this.getOrRefreshOnce(
53
+ key,
54
+ encoded,
55
+ minimumRemainingSeconds,
56
+ refresh,
57
+ now,
58
+ ).finally(() => {
59
+ this.refreshing.delete(pendingKey)
48
60
  })
49
- this.refreshing.set(encoded, pending)
61
+ this.refreshing.set(pendingKey, pending)
50
62
  return pending
51
63
  }
52
64
 
@@ -68,6 +80,7 @@ export class ExchangeCredentialCache {
68
80
  private async getOrRefreshOnce(
69
81
  key: exchange.Key,
70
82
  encoded: string,
83
+ minimumRemainingSeconds: number,
71
84
  refresh: () => Promise<exchange.Entry>,
72
85
  now: () => number,
73
86
  ): Promise<string> {
@@ -76,14 +89,16 @@ export class ExchangeCredentialCache {
76
89
  const store = await readStore(this.path)
77
90
  const changed = scrub(store, now())
78
91
  const cached = store.entries[encoded]
79
- if (cached !== undefined && validEntry(key, cached, now())) {
92
+ if (cached !== undefined && validEntry(key, cached, now(), minimumRemainingSeconds)) {
80
93
  if (changed) await writeStore(this.path, store)
81
94
  return cached.credential
82
95
  }
83
96
 
84
97
  const next = await refresh()
85
- if (!validEntry(key, next, now(), 1)) {
86
- throw new Error('Token exchange returned a credential inconsistent with its cache key.')
98
+ if (!validEntry(key, next, now(), minimumRemainingSeconds)) {
99
+ throw new Error(
100
+ 'Token exchange returned a credential inconsistent with its cache key or required lifetime.',
101
+ )
87
102
  }
88
103
  store.entries[encoded] = Object.freeze({ ...next })
89
104
  await writeStore(this.path, store)
@@ -187,7 +202,10 @@ function validEntry(
187
202
  !Object.hasOwn(inspected.claims, 'delegation') &&
188
203
  proof.iss === key.kernelIssuer &&
189
204
  proof.sub === entry.user &&
190
- proof.aud === key.kernelIssuer
205
+ proof.aud === key.kernelIssuer &&
206
+ typeof proof.claims.exp === 'number' &&
207
+ Number.isSafeInteger(proof.claims.exp) &&
208
+ credentialLifetimeCovers(proof.claims.exp, minimumRemaining, now)
191
209
  )
192
210
  } catch {
193
211
  return false