@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.
- package/README.md +59 -3
- package/dist/astrale.js +1765 -1057
- package/dist/public/connect-core.js +86 -17
- package/dist/public/keys/index.js +69 -2
- package/dist/public/paths/index.js +67 -0
- package/dist/types/connection/session.d.ts +2 -2
- package/dist/types/lib/config.d.ts +6 -0
- package/dist/types/state/exchange-credentials.d.ts +6 -2
- package/dist/types/state/files.d.ts +2 -0
- package/dist/types/state/index.d.ts +3 -2
- package/dist/types/state/paths.d.ts +2 -0
- package/dist/types/state/session-routes.d.ts +10 -0
- package/package.json +2 -2
- package/src/commands/__tests__/domain-install-operation.test.ts +23 -0
- package/src/commands/__tests__/install-identity-override.test.ts +3 -3
- package/src/commands/auth/logout.ts +2 -1
- package/src/commands/browser.ts +29 -0
- package/src/commands/domain/install.ts +19 -11
- package/src/commands/get.ts +1 -1
- package/src/commands/identity/register.ts +3 -4
- package/src/commands/logs.ts +2 -5
- package/src/commands/session/analyze.ts +26 -4
- package/src/connection/.spec/architecture.md +9 -1
- package/src/connection/__tests__/auth.test.ts +1 -0
- package/src/connection/__tests__/credential.test.ts +1 -0
- package/src/connection/__tests__/exchange.test.ts +34 -10
- package/src/connection/__tests__/session.test.ts +5 -1
- package/src/connection/__tests__/target.test.ts +1 -0
- package/src/connection/exchange.ts +63 -38
- package/src/connection/session.ts +8 -1
- package/src/identity/__tests__/fixtures/registry-journey.ts +13 -1
- package/src/identity/__tests__/registry.test.ts +4 -0
- package/src/identity/registry.ts +2 -0
- package/src/lib/__tests__/browser-retention.test.ts +212 -0
- package/src/lib/__tests__/config.test.ts +27 -0
- package/src/lib/__tests__/skills.test.ts +8 -2
- package/src/lib/browser-retention.ts +210 -0
- package/src/lib/config.ts +12 -1
- package/src/program/__tests__/program.test.ts +1 -1
- package/src/state/.spec/api.d.ts +21 -2
- package/src/state/.spec/architecture.md +18 -4
- package/src/state/.spec/layout.ts +1 -0
- package/src/state/__tests__/exchange-credentials.test.ts +88 -42
- package/src/state/__tests__/files.test.ts +24 -1
- package/src/state/__tests__/fixtures/session-route-process.ts +93 -0
- package/src/state/__tests__/paths.test.ts +1 -0
- package/src/state/__tests__/session-routes.test.ts +138 -0
- package/src/state/exchange-credentials.ts +22 -9
- package/src/state/files.ts +41 -0
- package/src/state/index.ts +3 -1
- package/src/state/paths.ts +3 -0
- package/src/state/session-routes.ts +34 -0
- package/src/telemetry/__tests__/analyze-log.test.ts +38 -0
- package/src/telemetry/__tests__/retention.test.ts +267 -0
- package/src/telemetry/__tests__/settings.test.ts +102 -0
- package/src/telemetry/__tests__/store-scan.test.ts +128 -0
- package/src/telemetry/__tests__/trigger.test.ts +33 -4
- package/src/telemetry/analyze.ts +24 -2
- package/src/telemetry/recorder.ts +6 -3
- package/src/telemetry/retention.ts +176 -0
- package/src/telemetry/session.ts +18 -12
- package/src/telemetry/settings.ts +64 -11
- package/src/telemetry/store.ts +92 -9
- package/src/telemetry/trigger.ts +16 -28
- package/studio/client/dist/assets/index-BFVFs_8x.js +81 -0
- package/studio/client/dist/assets/index-DuB9iUdu.css +2 -0
- package/studio/client/dist/assets/schema-studio--a0kX3QU.css +1 -0
- package/studio/client/dist/assets/schema-studio-DH6oEWME.js +8 -0
- package/studio/client/dist/index.html +3 -3
- package/studio/package.json +1 -1
- package/studio/server/agent/prompts/anchors.test.ts +17 -4
- package/studio/server/agent/prompts/anchors.ts +3 -2
- package/studio/server/agent/prompts/system.test.ts +2 -3
- package/studio/server/agent/prompts/system.ts +3 -3
- package/studio/server/agent/run/preparation.ts +1 -1
- package/studio/server/api/context.ts +1 -1
- package/studio/server/api/deployment.ts +1 -1
- package/studio/server/api/views.ts +2 -2
- package/studio/server/api/workspace.ts +1 -1
- package/studio/server/cache.test.ts +3 -8
- package/studio/server/cache.ts +4 -45
- package/studio/server/handoff/copy.ts +1 -1
- package/studio/server/introspect/canonical-schema.test.ts +154 -128
- package/studio/server/introspect/canonical-schema.ts +183 -302
- package/studio/server/introspect/core.ts +14 -21
- package/studio/server/introspect/extractor.ts +11 -15
- package/studio/server/introspect/overlay-tsmorph.test.ts +1 -5
- package/studio/server/introspect/overlay-tsmorph.ts +0 -1
- package/studio/server/introspect/overlay.ts +3 -24
- package/studio/server/introspect/revision.test.ts +24 -28
- package/studio/server/introspect/revision.ts +10 -21
- package/studio/server/introspect/runtime.test.ts +14 -14
- package/studio/server/introspect/runtime.ts +1 -46
- package/studio/server/lifecycle.ts +4 -1
- package/studio/server/state/documents.test.ts +69 -0
- package/studio/server/state/documents.ts +57 -10
- package/studio/shared/contracts/schema.ts +10 -26
- package/studio/shared/contracts/surface.test.ts +2 -2
- package/studio/shared/contracts/workspace.ts +3 -0
- package/studio/shared/schema/identity.ts +0 -1
- package/viewer/dist/main.js +28 -28
- package/studio/client/dist/assets/index-C0FAnwNw.css +0 -2
- package/studio/client/dist/assets/index-CpBed0V1.js +0 -81
- package/studio/client/dist/assets/schema-studio-BilUNb6Z.css +0 -1
- package/studio/client/dist/assets/schema-studio-DJm9guI8.js +0 -8
- package/studio/server/introspect/core-extractor.ts +0 -30
- package/studio/server/introspect/overlay.test.ts +0 -44
- package/studio/server/introspect/source-overlay/annotations.ts +0 -14
package/src/commands/browser.ts
CHANGED
|
@@ -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
|
-
|
|
455
|
-
|
|
456
|
-
`
|
|
457
|
-
|
|
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
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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
|
-
|
|
534
|
+
if (!machine) {
|
|
535
|
+
log.warn(`Identity override consented via --allow-identity-override: ${origin} ← ${host}`)
|
|
536
|
+
}
|
|
529
537
|
return origin
|
|
530
538
|
}
|
|
531
539
|
|
package/src/commands/get.ts
CHANGED
|
@@ -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
|
|
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
|
|
49
|
-
|
|
50
|
-
|
|
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 \
|
package/src/commands/logs.ts
CHANGED
|
@@ -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.
|
|
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 {
|
|
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: [
|
|
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 =
|
|
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
|
|
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
|
|
@@ -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) +
|
|
18
|
-
const SOURCE_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
|
|
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(
|
|
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).
|
|
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
|
|
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 */
|
|
@@ -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 =
|
|
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
|
|
30
|
+
const sourceIdentity = sourceCacheIdentity(sourceToken)
|
|
31
31
|
requireLive(signal)
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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 {
|
|
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
|
package/src/identity/registry.ts
CHANGED
|
@@ -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 {
|