@astrale-os/cli 1.0.0-beta.27 → 1.0.0-beta.29

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 (108) hide show
  1. package/README.md +59 -3
  2. package/dist/astrale.js +1765 -1057
  3. package/dist/public/connect-core.js +86 -17
  4. package/dist/public/keys/index.js +69 -2
  5. package/dist/public/paths/index.js +67 -0
  6. package/dist/types/connection/session.d.ts +2 -2
  7. package/dist/types/lib/config.d.ts +6 -0
  8. package/dist/types/state/exchange-credentials.d.ts +6 -2
  9. package/dist/types/state/files.d.ts +2 -0
  10. package/dist/types/state/index.d.ts +3 -2
  11. package/dist/types/state/paths.d.ts +2 -0
  12. package/dist/types/state/session-routes.d.ts +10 -0
  13. package/package.json +2 -2
  14. package/src/commands/__tests__/domain-install-operation.test.ts +23 -0
  15. package/src/commands/__tests__/install-identity-override.test.ts +3 -3
  16. package/src/commands/auth/logout.ts +2 -1
  17. package/src/commands/browser.ts +29 -0
  18. package/src/commands/domain/install.ts +19 -11
  19. package/src/commands/get.ts +1 -1
  20. package/src/commands/identity/register.ts +3 -4
  21. package/src/commands/logs.ts +2 -5
  22. package/src/commands/session/analyze.ts +26 -4
  23. package/src/connection/.spec/architecture.md +9 -1
  24. package/src/connection/__tests__/auth.test.ts +1 -0
  25. package/src/connection/__tests__/credential.test.ts +1 -0
  26. package/src/connection/__tests__/exchange.test.ts +34 -10
  27. package/src/connection/__tests__/session.test.ts +5 -1
  28. package/src/connection/__tests__/target.test.ts +1 -0
  29. package/src/connection/exchange.ts +63 -38
  30. package/src/connection/session.ts +8 -1
  31. package/src/identity/__tests__/fixtures/registry-journey.ts +13 -1
  32. package/src/identity/__tests__/registry.test.ts +4 -0
  33. package/src/identity/registry.ts +2 -0
  34. package/src/lib/__tests__/browser-retention.test.ts +212 -0
  35. package/src/lib/__tests__/config.test.ts +27 -0
  36. package/src/lib/__tests__/skills.test.ts +8 -2
  37. package/src/lib/browser-retention.ts +210 -0
  38. package/src/lib/config.ts +12 -1
  39. package/src/program/__tests__/program.test.ts +1 -1
  40. package/src/state/.spec/api.d.ts +21 -2
  41. package/src/state/.spec/architecture.md +18 -4
  42. package/src/state/.spec/layout.ts +1 -0
  43. package/src/state/__tests__/exchange-credentials.test.ts +88 -42
  44. package/src/state/__tests__/files.test.ts +24 -1
  45. package/src/state/__tests__/fixtures/session-route-process.ts +93 -0
  46. package/src/state/__tests__/paths.test.ts +1 -0
  47. package/src/state/__tests__/session-routes.test.ts +138 -0
  48. package/src/state/exchange-credentials.ts +22 -9
  49. package/src/state/files.ts +41 -0
  50. package/src/state/index.ts +3 -1
  51. package/src/state/paths.ts +3 -0
  52. package/src/state/session-routes.ts +34 -0
  53. package/src/telemetry/__tests__/analyze-log.test.ts +38 -0
  54. package/src/telemetry/__tests__/retention.test.ts +267 -0
  55. package/src/telemetry/__tests__/settings.test.ts +102 -0
  56. package/src/telemetry/__tests__/store-scan.test.ts +128 -0
  57. package/src/telemetry/__tests__/trigger.test.ts +33 -4
  58. package/src/telemetry/analyze.ts +24 -2
  59. package/src/telemetry/recorder.ts +6 -3
  60. package/src/telemetry/retention.ts +176 -0
  61. package/src/telemetry/session.ts +18 -12
  62. package/src/telemetry/settings.ts +64 -11
  63. package/src/telemetry/store.ts +92 -9
  64. package/src/telemetry/trigger.ts +16 -28
  65. package/studio/client/dist/assets/index-BFVFs_8x.js +81 -0
  66. package/studio/client/dist/assets/index-DuB9iUdu.css +2 -0
  67. package/studio/client/dist/assets/schema-studio--a0kX3QU.css +1 -0
  68. package/studio/client/dist/assets/schema-studio-DH6oEWME.js +8 -0
  69. package/studio/client/dist/index.html +3 -3
  70. package/studio/package.json +1 -1
  71. package/studio/server/agent/prompts/anchors.test.ts +17 -4
  72. package/studio/server/agent/prompts/anchors.ts +3 -2
  73. package/studio/server/agent/prompts/system.test.ts +2 -3
  74. package/studio/server/agent/prompts/system.ts +3 -3
  75. package/studio/server/agent/run/preparation.ts +1 -1
  76. package/studio/server/api/context.ts +1 -1
  77. package/studio/server/api/deployment.ts +1 -1
  78. package/studio/server/api/views.ts +2 -2
  79. package/studio/server/api/workspace.ts +1 -1
  80. package/studio/server/cache.test.ts +3 -8
  81. package/studio/server/cache.ts +4 -45
  82. package/studio/server/handoff/copy.ts +1 -1
  83. package/studio/server/introspect/canonical-schema.test.ts +154 -128
  84. package/studio/server/introspect/canonical-schema.ts +183 -302
  85. package/studio/server/introspect/core.ts +14 -21
  86. package/studio/server/introspect/extractor.ts +11 -15
  87. package/studio/server/introspect/overlay-tsmorph.test.ts +1 -5
  88. package/studio/server/introspect/overlay-tsmorph.ts +0 -1
  89. package/studio/server/introspect/overlay.ts +3 -24
  90. package/studio/server/introspect/revision.test.ts +24 -28
  91. package/studio/server/introspect/revision.ts +10 -21
  92. package/studio/server/introspect/runtime.test.ts +14 -14
  93. package/studio/server/introspect/runtime.ts +1 -46
  94. package/studio/server/lifecycle.ts +4 -1
  95. package/studio/server/state/documents.test.ts +69 -0
  96. package/studio/server/state/documents.ts +57 -10
  97. package/studio/shared/contracts/schema.ts +10 -26
  98. package/studio/shared/contracts/surface.test.ts +2 -2
  99. package/studio/shared/contracts/workspace.ts +3 -0
  100. package/studio/shared/schema/identity.ts +0 -1
  101. package/viewer/dist/main.js +28 -28
  102. package/studio/client/dist/assets/index-C0FAnwNw.css +0 -2
  103. package/studio/client/dist/assets/index-CpBed0V1.js +0 -81
  104. package/studio/client/dist/assets/schema-studio-BilUNb6Z.css +0 -1
  105. package/studio/client/dist/assets/schema-studio-DJm9guI8.js +0 -8
  106. package/studio/server/introspect/core-extractor.ts +0 -30
  107. package/studio/server/introspect/overlay.test.ts +0 -44
  108. package/studio/server/introspect/source-overlay/annotations.ts +0 -14
@@ -13,6 +13,7 @@ import {
13
13
  profileDirFor,
14
14
  saveSession,
15
15
  } from '../lib/browser'
16
+ import { sweepBrowserProfiles } from '../lib/browser-retention'
16
17
  import { readLocalStatus } from '../lib/local-status'
17
18
  import { fatal, log } from '../lib/log'
18
19
  import { isMachine, output, RAW_OUTPUT_OPTIONS, type RawOutputOpts } from '../lib/output'
@@ -25,6 +26,29 @@ type BrowserOpts = RawOutputOpts & {
25
26
  check?: boolean
26
27
  }
27
28
 
29
+ /**
30
+ * Opportunistic profile retention, the same `git gc --auto` shape the session
31
+ * store uses: no daemon, no cron, it just rides on the command that created the
32
+ * mess. Silent when there is nothing to do, and never fatal — losing a sweep is
33
+ * strictly better than losing the browser command.
34
+ */
35
+ async function reportSweep(machine: boolean): Promise<void> {
36
+ try {
37
+ const swept = await sweepBrowserProfiles()
38
+ if (machine || swept.bytesFreed === 0) return
39
+ const freed = `${(swept.bytesFreed / 1024 / 1024).toFixed(0)} MB`
40
+ const what = [
41
+ swept.removed.length > 0 ? `${swept.removed.length} dormant profile(s) removed` : null,
42
+ swept.purged.length > 0 ? `${swept.purged.length} cache(s) trimmed` : null,
43
+ ]
44
+ .filter(Boolean)
45
+ .join(', ')
46
+ log.dim(`retention: ${what} — ${freed} freed`)
47
+ } catch {
48
+ /* retention must never break the browser command */
49
+ }
50
+ }
51
+
28
52
  const LOGIN_TIMEOUT_MS = 180_000
29
53
  const POLL_INTERVAL_MS = 2500
30
54
 
@@ -125,6 +149,11 @@ Examples:
125
149
  const host = new URL(gui).host
126
150
  await requireAgentBrowser(machine, opts)
127
151
 
152
+ // Retention runs BEFORE anything launches, so no profile we touch can be in
153
+ // use by a browser this command started. Profiles held by someone else's
154
+ // live browser are skipped by the sweep itself.
155
+ await reportSweep(machine)
156
+
128
157
  const usingCdp = !!opts.cdp
129
158
  const profile = usingCdp ? null : (opts.profile ?? profileDirFor(host))
130
159
  const target = usingCdp ? { cdp: opts.cdp } : { profile: profile! }
@@ -426,6 +426,7 @@ export async function installDirect(
426
426
  target,
427
427
  host,
428
428
  opts.allowIdentityOverride ?? false,
429
+ isMachine(opts),
429
430
  )
430
431
  } catch (e) {
431
432
  fatal(e, opts)
@@ -451,11 +452,13 @@ export async function installDirect(
451
452
  // aliases the host and the pre-install gate never consented to THAT
452
453
  // origin (lying or unavailable Publication), say so loudly after the fact.
453
454
  if (isIdentityOverride(installed.origin, host) && installed.origin !== consentedOrigin) {
454
- log.warn(
455
- `Installed origin "${installed.origin}" differs from the serving host "${host}" ` +
456
- `and was not confirmed before install (the worker Publication was unavailable). ` +
457
- `Every ${installed.origin}/* call on this instance now routes to ${host}.`,
458
- )
455
+ if (!isMachine(fmtOpts)) {
456
+ log.warn(
457
+ `Installed origin "${installed.origin}" differs from the serving host "${host}" ` +
458
+ `and was not confirmed before install (the worker Publication was unavailable). ` +
459
+ `Every ${installed.origin}/* call on this instance now routes to ${host}.`,
460
+ )
461
+ }
459
462
  }
460
463
  },
461
464
  })
@@ -512,20 +515,25 @@ async function ensureIdentityOverrideConsent(
512
515
  url: string,
513
516
  host: string,
514
517
  allow: boolean,
518
+ machine: boolean,
515
519
  ): Promise<string | undefined> {
516
520
  const origin = await probeDeclaredOrigin(url)
517
521
  if (origin === undefined) {
518
- log.warn(
519
- `Could not read a declared origin from ` +
520
- `${new URL('/.well-known/astrale/domain.json', url).href} ` +
521
- `skipping the pre-install identity check (the installed origin is verified after install).`,
522
- )
522
+ if (!machine) {
523
+ log.warn(
524
+ `Could not read a declared origin from ` +
525
+ `${new URL('/.well-known/astrale/domain.json', url).href} ` +
526
+ `skipping the pre-install identity check (the installed origin is verified after install).`,
527
+ )
528
+ }
523
529
  return undefined
524
530
  }
525
531
  if (!isIdentityOverride(origin, host)) return undefined
526
532
 
527
533
  if (allow) {
528
- log.warn(`Identity override consented via --allow-identity-override: ${origin} ← ${host}`)
534
+ if (!machine) {
535
+ log.warn(`Identity override consented via --allow-identity-override: ${origin} ← ${host}`)
536
+ }
529
537
  return origin
530
538
  }
531
539
 
@@ -46,7 +46,7 @@ export default {
46
46
  description: 'Get one canonical node by Path or ID',
47
47
  afterHelpText: `
48
48
  Behavior:
49
- Resolves one exact Kernel V2 Path and prints the canonical Node
49
+ Resolves one exact Kernel Path and prints the canonical Node
50
50
  { id, class, props }. Missing and authorization-masked nodes remain
51
51
  intentionally indistinguishable. @self is expanded before dispatch.
52
52
 
@@ -45,10 +45,9 @@ Behavior:
45
45
  its result and stores the same target-bound registration. The callable owns
46
46
  authorization; the CLI never receives installed Domain authority.
47
47
 
48
- Kernel V2 Nodes have opaque identity and no caller-assigned storage path, so
49
- the historical --path option no longer exists. The CLI binds the key proof
50
- to the exact provision fingerprint and target Kernel audience, then caches
51
- the returned (issuer, subject) for subsequent calls.
48
+ The Kernel assigns each Node ID; callers do not choose a storage path. The
49
+ CLI binds the key proof to the exact provision fingerprint and target Kernel
50
+ audience, then caches the returned (issuer, subject) for subsequent calls.
52
51
 
53
52
  Example:
54
53
  $ astrale identity register alice --class /:accounts.example:class.User \
@@ -341,11 +341,8 @@ Behavior:
341
341
  Calls the public Kernel journal syscall and emits its { records, cursor }
342
342
  page. Topic selection is exact or prefix-based; cursors and timestamps are
343
343
  opaque strings owned by the journal backend. --follow reuses one Client Session
344
- and advances only with the returned cursor. Machine follow output is NDJSON:
345
- one complete admitted record per line; YAML follow is unsupported.
346
-
347
- Historical event-glob lowering and the application-specific services-domain
348
- log buffer are not part of the Kernel V2 journal contract.
344
+ and advances only with the returned cursor. With --json, follow output is
345
+ NDJSON with one complete admitted record per line; YAML follow is unsupported.
349
346
 
350
347
  Examples:
351
348
  $ astrale logs -i staging --limit 50
@@ -4,13 +4,23 @@ import type { CommandDefinition } from '../../program/index'
4
4
 
5
5
  import { log } from '../../lib/log'
6
6
  import { analyzeSession } from '../../telemetry/analyze'
7
- import { listSessions } from '../../telemetry/store'
7
+ import { sweepStore } from '../../telemetry/retention'
8
+ import { scanSessions } from '../../telemetry/store'
8
9
  import { restampLock, releaseLock } from '../../telemetry/trigger'
9
10
 
10
11
  export default {
11
12
  name: 'analyze',
12
13
  description: 'Analyze a recorded session for DX frictions (dry-run: writes report.md)',
13
- arguments: [{ name: 'id', description: 'Session id (default: most recent closed, unanalyzed)' }],
14
+ arguments: [
15
+ // Optional: the action resolves the most recent closed, unanalyzed session
16
+ // when no id is given, which is how a human is meant to invoke this. The
17
+ // opportunistic trigger always passes an explicit id.
18
+ {
19
+ name: 'id',
20
+ description: 'Session id (default: most recent closed, unanalyzed)',
21
+ required: false,
22
+ },
23
+ ],
14
24
  options: [
15
25
  { flags: '--file', description: 'File cleared findings as issues on the admin tracker' },
16
26
  { flags: '--model <model>', description: 'Model for the analyzer pass' },
@@ -22,10 +32,10 @@ export default {
22
32
  opts: { file?: boolean; model?: string; force?: boolean; auto?: boolean },
23
33
  ) => {
24
34
  if (opts.auto) restampLock()
35
+ let target = id
25
36
  try {
26
- let target = id
27
37
  if (!target) {
28
- const candidate = listSessions().find((s) => s.closed && (opts.force || !s.analyzed))
38
+ const candidate = scanSessions().find((s) => s.closed && (opts.force || !s.analyzed))
29
39
  if (!candidate) {
30
40
  if (!opts.auto) log.dim('Nothing to analyze — no closed, unanalyzed session.')
31
41
  return
@@ -45,6 +55,18 @@ export default {
45
55
  }
46
56
  } finally {
47
57
  if (opts.auto) releaseLock()
58
+ // The size bound is enforced here, not on the CLI's critical path:
59
+ // measuring every file of every session is only affordable in this
60
+ // process, and this is precisely where the store just grew. The session
61
+ // just analyzed is protected — it is the freshest evidence on disk.
62
+ try {
63
+ const swept = sweepStore({ protect: new Set(target === undefined ? [] : [target]) })
64
+ if (!opts.auto && swept.removed.length > 0) {
65
+ log.dim(`retention: removed ${swept.removed.length} session(s)`)
66
+ }
67
+ } catch {
68
+ /* retention must never fail the command that triggered it */
69
+ }
48
70
  }
49
71
  },
50
72
  } satisfies CommandDefinition
@@ -28,7 +28,15 @@ selection, and the single safe stale-route recovery. The CLI does not reproduce
28
28
  discover destination identity. `SessionAuth.resolve(call, signal)` returns source-Kernel authority and
29
29
  ClientSession supplies an audience-free Delegate with omitted attenuation, preserving the exact
30
30
  current Grant for routing. Connection itself persists no credential or route; the separate state
31
- owner persists only exchanged source credentials.
31
+ owner persists exchanged source credentials and the separate Kernel Client route artifact. A valid exchanged credential is selected by the
32
+ authenticated source issuer and subject before any live `whoami`; cache misses alone resolve the
33
+ registered Kernel User and perform delegation plus Domain exchange.
34
+ Exchange authority is bounded to five minutes and never outlives the current source credential,
35
+ matching the CLI route-age ceiling without forcing an unrelated two-minute refresh cycle.
36
+
37
+ Every ClientSession receives the CLI-owned `state/session-routes` representation capability. Kernel
38
+ Client still owns route keying, admission, expiry, and one safe stale/miss recovery; Connection does
39
+ not reproduce routing or add an Admin-only bypass.
32
40
 
33
41
  `--anonymous` deliberately suppresses ambient and bookmark-default identities by omitting the
34
42
  `SessionAuth` capability. It cannot be combined with `--as` or `--creds`; contradictory selections fail
@@ -15,6 +15,7 @@ const config: AstraleConfig = {
15
15
  kernelIssuer: 'https://admin.eu.astrale.ai/api',
16
16
  },
17
17
  telemetry: { enabled: true },
18
+ browser: {},
18
19
  }
19
20
 
20
21
  describe('resolveKeyIdentityAuthOptions', () => {
@@ -18,6 +18,7 @@ const config: AstraleConfig = {
18
18
  issuer: 'https://cli.example',
19
19
  admin: { name: 'admin', url: SOURCE, kernelIssuer: SOURCE },
20
20
  telemetry: { enabled: false },
21
+ browser: {},
21
22
  }
22
23
 
23
24
  describe('connection credential', () => {
@@ -14,13 +14,8 @@ const DOMAIN = issuer.accept('https://admin.example')
14
14
  const TARGET = { url: `${KERNEL}/api`, kernelIssuer: KERNEL, domainIssuer: DOMAIN }
15
15
  const INVOCATION = `${KERNEL}/invoke`
16
16
  const EXPIRES_AT = Math.floor(Date.now() / 1_000) + 500
17
- const SOURCE_EXPIRES_AT = Math.floor(Date.now() / 1_000) + 120
18
- const SOURCE_TOKEN = token(
19
- issuer.accept('https://workos.example'),
20
- KERNEL,
21
- 'user-1',
22
- SOURCE_EXPIRES_AT,
23
- )
17
+ const SOURCE_EXPIRES_AT = Math.floor(Date.now() / 1_000) + 600
18
+ const SOURCE_TOKEN = sourceToken('user-1')
24
19
  let directory: string
25
20
 
26
21
  beforeEach(async () => {
@@ -33,7 +28,7 @@ afterEach(async () => {
33
28
 
34
29
  describe('Domain token exchange', () => {
35
30
  /** @evidence TEST-CLI-EXCHANGE-WHOAMI-DELEGATE-EXCHANGE-CACHE */
36
- test('runs the exact User to Kernel to Domain journey and reuses the bound token', async () => {
31
+ test('runs the exact User to Kernel to Domain journey once and reuses it before whoami', async () => {
37
32
  const observed: Array<{
38
33
  url: string
39
34
  init: RequestInit | undefined
@@ -91,7 +86,7 @@ describe('Domain token exchange', () => {
91
86
  await expect(resolver.resolve(KERNEL, new AbortController().signal)).resolves.toBe(exchanged)
92
87
 
93
88
  const kernelRequests = observed.filter((entry) => entry.url === INVOCATION)
94
- expect(kernelRequests).toHaveLength(3)
89
+ expect(kernelRequests).toHaveLength(2)
95
90
  expect(kernelRequests[0]!.body).toMatchObject({
96
91
  credential: SOURCE_TOKEN,
97
92
  call: { input: {} },
@@ -107,13 +102,32 @@ describe('Domain token exchange', () => {
107
102
  })
108
103
  const delegatedTtl = kernelRequests[1]!.body!.call.input.ttlSeconds
109
104
  expect(delegatedTtl).toBeGreaterThan(0)
110
- expect(delegatedTtl).toBeLessThan(120)
105
+ expect(delegatedTtl).toBeGreaterThanOrEqual(295)
106
+ expect(delegatedTtl).toBeLessThanOrEqual(300)
111
107
  expect(sourceAudiences).toEqual([KERNEL, KERNEL])
112
108
  expect(
113
109
  observed.filter((entry) => entry.url.endsWith('/.well-known/astrale/token')),
114
110
  ).toHaveLength(1)
115
111
  })
116
112
 
113
+ test('rejects a source credential without a stable cache identity before network I/O', async () => {
114
+ let fetches = 0
115
+ const resolver = createExchangeCredentialResolver(
116
+ TARGET,
117
+ { resolve: async () => sourceToken(undefined) },
118
+ async () => {
119
+ fetches += 1
120
+ throw new Error('network must remain untouched')
121
+ },
122
+ 5_000,
123
+ new ExchangeCredentialCache(join(directory, 'credentials.json')),
124
+ )
125
+ await expect(resolver.resolve(KERNEL, new AbortController().signal)).rejects.toMatchObject({
126
+ code: 'TOKEN_EXCHANGE_SOURCE_INVALID',
127
+ })
128
+ expect(fetches).toBe(0)
129
+ })
130
+
117
131
  /** @evidence TEST-CLI-EXCHANGE-NO-LEGACY-FALLBACK */
118
132
  test('fails closed when issuer discovery does not advertise exchange', async () => {
119
133
  const fetch: Fetch = async (input, init) => {
@@ -293,3 +307,13 @@ function token(iss: string, aud: string, user: string, exp: number): string {
293
307
  grant: { v: 1, expr: { kind: 'identity', credential: proof } },
294
308
  })}.signature`
295
309
  }
310
+
311
+ function sourceToken(subject: string | undefined): string {
312
+ const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString('base64url')
313
+ return `${encode({ alg: 'EdDSA', typ: 'JWT' })}.${encode({
314
+ iss: 'https://workos.example',
315
+ ...(subject === undefined ? {} : { sub: subject }),
316
+ aud: KERNEL,
317
+ exp: SOURCE_EXPIRES_AT,
318
+ })}.signature`
319
+ }
@@ -1,6 +1,7 @@
1
1
  import type { AuthApi } from '@astrale-os/sdk/auth'
2
2
  import type { GraphApi } from '@astrale-os/sdk/client'
3
3
  import type { ClientSession } from '@astrale-os/sdk/client/session'
4
+ import type { SessionRouteStore } from '@astrale-os/sdk/client/session'
4
5
 
5
6
  import { issuer } from '@astrale-os/sdk/auth'
6
7
  import { describe, expect, test } from 'bun:test'
@@ -23,6 +24,7 @@ const config: AstraleConfig = {
23
24
  kernelIssuer: 'https://admin.example',
24
25
  },
25
26
  telemetry: { enabled: false },
27
+ browser: {},
26
28
  }
27
29
 
28
30
  const context: ConnectionContext = Object.freeze({
@@ -37,11 +39,13 @@ describe('connection session', () => {
37
39
  /** @evidence TEST-CLI-CONNECTION-PINS-SOURCE-ISSUER */
38
40
  test('pins the selected canonical Kernel issuer without a transport escape hatch', async () => {
39
41
  const auth = { ttlSeconds: 3_600, resolve: async () => ({ credential: 'credential' }) }
40
- const options = createClientSessionOptions(target, globalThis.fetch, auth, 2_500)
42
+ const routeStore: SessionRouteStore = { read: () => undefined, write: () => undefined }
43
+ const options = createClientSessionOptions(target, globalThis.fetch, auth, 2_500, routeStore)
41
44
 
42
45
  expect(options.kernel).toBe(target.kernelIssuer)
43
46
  expect(options).not.toHaveProperty('url')
44
47
  expect(options).not.toHaveProperty('sourceIssuer')
48
+ expect(options.routeStore).toBe(routeStore)
45
49
  })
46
50
 
47
51
  /** @evidence TEST-CLI-CONNECTION-OMITS-EXPLICIT-ANONYMOUS-CREDENTIAL */
@@ -15,6 +15,7 @@ const config: AstraleConfig = {
15
15
  domainIssuer: 'https://admin-domain.example',
16
16
  },
17
17
  telemetry: { enabled: false },
18
+ browser: {},
18
19
  }
19
20
 
20
21
  const instances: InstanceStore = {
@@ -11,7 +11,7 @@ import type { ConnectionTarget } from './target'
11
11
  import { AstraleError } from '../errors'
12
12
  import { ExchangeCredentialCache } from '../state/exchange-credentials'
13
13
 
14
- const EXCHANGE_TTL_SECONDS = 120
14
+ const EXCHANGE_TTL_SECONDS = 5 * 60
15
15
  const MAXIMUM_RESPONSE_BYTES = 256 * 1024
16
16
 
17
17
  /** Exchange exact authenticated User authority for a Domain bearer bound to this Kernel. */
@@ -27,53 +27,78 @@ export function createExchangeCredentialResolver(
27
27
  async resolve(kernelIssuer: IssuerId, signal: AbortSignal): Promise<string> {
28
28
  requireLive(signal)
29
29
  const sourceToken = await source.resolve(kernelIssuer, signal)
30
- const delegationTtlSeconds = delegationLifetime(sourceToken)
30
+ const sourceIdentity = sourceCacheIdentity(sourceToken)
31
31
  requireLive(signal)
32
32
 
33
- const client = new Client({ url: `${kernelIssuer}/invoke`, fetch, timeoutMs })
34
- try {
35
- const authenticated = client.as(sourceToken)
36
- const auth = createAuth(async (path, input, options) => {
37
- const result = await authenticated.call(call(path, input), {
38
- ...options,
39
- delegate: { ttlSeconds: delegationTtlSeconds },
40
- })
41
- return result.value
42
- })
43
- const user = await auth.whoami({ signal })
44
- const key = Object.freeze({
33
+ return await cache.getOrRefresh(
34
+ Object.freeze({
45
35
  kernelIssuer,
46
36
  domainIssuer: target.domainIssuer,
47
- user: user.id,
48
- })
49
- return await cache.getOrRefresh(key, async () => {
50
- let envelope: string | undefined
51
- for (let attempt = 0; attempt < 3; attempt += 1) {
52
- try {
53
- envelope = await auth.delegate(
54
- user.id,
55
- {
56
- audience: target.domainIssuer,
57
- ttlSeconds: delegationTtlSeconds,
58
- attenuation: { kind: 'identity', self: true },
59
- },
60
- { signal },
61
- )
62
- break
63
- } catch (cause) {
64
- if (attempt === 2 || !unknownFunctionOutcome(cause)) throw cause
37
+ sourceIssuer: sourceIdentity.issuer,
38
+ sourceSubject: sourceIdentity.subject,
39
+ }),
40
+ async () => {
41
+ const delegationTtlSeconds = delegationLifetime(sourceToken)
42
+ const client = new Client({ url: `${kernelIssuer}/invoke`, fetch, timeoutMs })
43
+ try {
44
+ const authenticated = client.as(sourceToken)
45
+ const auth = createAuth(async (path, input, options) => {
46
+ const result = await authenticated.call(call(path, input), {
47
+ ...options,
48
+ delegate: { ttlSeconds: delegationTtlSeconds },
49
+ })
50
+ return result.value
51
+ })
52
+ const user = await auth.whoami({ signal })
53
+ let envelope: string | undefined
54
+ for (let attempt = 0; attempt < 3; attempt += 1) {
55
+ try {
56
+ envelope = await auth.delegate(
57
+ user.id,
58
+ {
59
+ audience: target.domainIssuer,
60
+ ttlSeconds: delegationTtlSeconds,
61
+ attenuation: { kind: 'identity', self: true },
62
+ },
63
+ { signal },
64
+ )
65
+ break
66
+ } catch (cause) {
67
+ if (attempt === 2 || !unknownFunctionOutcome(cause)) throw cause
68
+ }
65
69
  }
70
+ if (envelope === undefined) throw new Error('Token delegation returned no credential.')
71
+ return {
72
+ ...(await exchange(target.domainIssuer, kernelIssuer, envelope, fetch, signal)),
73
+ user: user.id,
74
+ sourceIssuer: sourceIdentity.issuer,
75
+ sourceSubject: sourceIdentity.subject,
76
+ }
77
+ } finally {
78
+ client.close()
66
79
  }
67
- if (envelope === undefined) throw new Error('Token delegation returned no credential.')
68
- return exchange(target.domainIssuer, kernelIssuer, envelope, fetch, signal)
69
- })
70
- } finally {
71
- client.close()
72
- }
80
+ },
81
+ )
73
82
  },
74
83
  })
75
84
  }
76
85
 
86
+ function sourceCacheIdentity(sourceToken: string): { issuer: string; subject: string } {
87
+ const inspected = credential.inspect(sourceToken)
88
+ if (
89
+ typeof inspected.iss !== 'string' ||
90
+ inspected.iss.length === 0 ||
91
+ typeof inspected.sub !== 'string' ||
92
+ inspected.sub.length === 0
93
+ ) {
94
+ throw new AstraleError(
95
+ 'TOKEN_EXCHANGE_SOURCE_INVALID',
96
+ 'The source identity credential has no stable issuer and subject.',
97
+ )
98
+ }
99
+ return Object.freeze({ issuer: inspected.iss, subject: inspected.sub })
100
+ }
101
+
77
102
  function unknownFunctionOutcome(cause: unknown): boolean {
78
103
  if (cause === null || typeof cause !== 'object') return false
79
104
  const error = cause as { readonly code?: unknown; readonly reason?: unknown }
@@ -1,6 +1,10 @@
1
1
  import type { AuthApi } from '@astrale-os/sdk/auth'
2
2
  import type { GraphApi } from '@astrale-os/sdk/client'
3
- import type { ClientSessionOptions, SessionAuth } from '@astrale-os/sdk/client/session'
3
+ import type {
4
+ ClientSessionOptions,
5
+ SessionAuth,
6
+ SessionRouteStore,
7
+ } from '@astrale-os/sdk/client/session'
4
8
 
5
9
  import { createAuth } from '@astrale-os/sdk/auth'
6
10
  import { call } from '@astrale-os/sdk/client'
@@ -21,6 +25,7 @@ import { fetchWithCaFile } from '../lib/ca-fetch'
21
25
  import { readConfig } from '../lib/config'
22
26
  import { log } from '../lib/log'
23
27
  import { isMachine } from '../lib/output'
28
+ import { SESSION_ROUTE_STORE } from '../state/session-routes'
24
29
  import { bindCredentialIdentity } from './auth'
25
30
  import { createCliCredential, validateCredentialSelection } from './credential'
26
31
  import { resolveAdminConnectionTarget, resolveConnectionTarget } from './target'
@@ -165,11 +170,13 @@ export function createClientSessionOptions(
165
170
  fetch: NonNullable<ClientSessionOptions['fetch']>,
166
171
  auth: SessionAuth | undefined,
167
172
  timeoutMs: number,
173
+ routeStore: SessionRouteStore = SESSION_ROUTE_STORE,
168
174
  ): ClientSessionOptions {
169
175
  return {
170
176
  kernel: target.kernelIssuer,
171
177
  fetch,
172
178
  ...(auth === undefined ? {} : { auth }),
179
+ routeStore,
173
180
  policy: {
174
181
  maximumRouteAgeMs: MAXIMUM_ROUTE_AGE_MS,
175
182
  ...(new URL(target.url).protocol === 'http:' ? { allowInsecureHttp: true } : {}),
@@ -1,5 +1,8 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
2
+ import { dirname } from 'node:path'
3
+
1
4
  import { fileExists, keypairPaths } from '../../../keys/index'
2
- import { KEYS_DIR } from '../../../state/index'
5
+ import { EXCHANGE_CREDENTIALS_PATH, KEYS_DIR, SESSION_ROUTES_PATH } from '../../../state/index'
3
6
  import {
4
7
  createIdentity,
5
8
  exportIdentity,
@@ -30,6 +33,13 @@ try {
30
33
  } catch (error) {
31
34
  selectedDeletion = error instanceof Error ? error.message : String(error)
32
35
  }
36
+ await mkdir(dirname(SESSION_ROUTES_PATH), { recursive: true })
37
+ await mkdir(dirname(EXCHANGE_CREDENTIALS_PATH), { recursive: true })
38
+ await writeFile(SESSION_ROUTES_PATH, '{"version":1,"entries":{"confidential":{}}}\n')
39
+ await writeFile(
40
+ EXCHANGE_CREDENTIALS_PATH,
41
+ '{"version":2,"entries":{"confidential":{"credential":"bearer"}}}\n',
42
+ )
33
43
  await deleteIdentity('bob')
34
44
  await deleteIdentity('alice-alias')
35
45
  await upsertIdpIdentity('workos', {
@@ -49,5 +59,7 @@ console.log(
49
59
  selectedDeletion,
50
60
  aliceKey: await fileExists(keypairPaths('alice', KEYS_DIR).privatePath),
51
61
  bobKey: await fileExists(keypairPaths('bob', KEYS_DIR).privatePath),
62
+ routeCache: await fileExists(SESSION_ROUTES_PATH),
63
+ exchangeCache: JSON.parse(await readFile(EXCHANGE_CREDENTIALS_PATH, 'utf8')),
52
64
  }),
53
65
  )
@@ -47,6 +47,8 @@ describe('identity registry journey', () => {
47
47
  selectedDeletion: string
48
48
  aliceKey: boolean
49
49
  bobKey: boolean
50
+ routeCache: boolean
51
+ exchangeCache: unknown
50
52
  }
51
53
  expect(result.store.default).toBe('alice')
52
54
  expect(Object.keys(result.store.identities).sort()).toEqual(['alice', 'workos'])
@@ -60,6 +62,8 @@ describe('identity registry journey', () => {
60
62
  expect(result.selectedDeletion).toContain('Cannot delete the default identity')
61
63
  expect(result.aliceKey).toBe(true)
62
64
  expect(result.bobKey).toBe(false)
65
+ expect(result.routeCache).toBe(false)
66
+ expect(result.exchangeCache).toEqual({ version: 2, entries: {} })
63
67
 
64
68
  const persisted = JSON.parse(await readFile(join(root, 'identities.json'), 'utf-8')) as {
65
69
  version?: unknown
@@ -10,6 +10,7 @@ import {
10
10
  type IdentityStore,
11
11
  type Registration,
12
12
  ExchangeCredentialCache,
13
+ SESSION_ROUTE_STORE,
13
14
  } from '../state/index'
14
15
 
15
16
  export type { Identity, IdentityStore, Registration } from '../state/index'
@@ -68,6 +69,7 @@ export async function deleteIdentity(name: string): Promise<void> {
68
69
  return { next: { ...store, identities }, value: undefined }
69
70
  })
70
71
  await new ExchangeCredentialCache().clear()
72
+ SESSION_ROUTE_STORE.clear()
71
73
  }
72
74
 
73
75
  function hasAnotherKeyIdentity(store: IdentityStore, name: string, subject: string): boolean {