@astrale-os/cli 1.0.0-beta.28 → 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 +33 -0
- package/dist/astrale.js +1629 -1088
- 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/auth/logout.ts +2 -1
- package/src/commands/browser.ts +29 -0
- 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/browser-retention.ts +210 -0
- package/src/lib/config.ts +12 -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 +88 -1
- package/src/telemetry/analyze.ts +24 -2
- package/src/telemetry/retention.ts +53 -6
- package/src/telemetry/store.ts +5 -0
- package/studio/package.json +1 -1
- package/viewer/dist/main.js +28 -28
|
@@ -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 {
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
mkdtempSync,
|
|
6
|
+
rmSync,
|
|
7
|
+
symlinkSync,
|
|
8
|
+
utimesSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from 'node:fs'
|
|
11
|
+
import { hostname, tmpdir } from 'node:os'
|
|
12
|
+
import { join } from 'node:path'
|
|
13
|
+
|
|
14
|
+
import type { BrowserRetentionBudget } from '../browser-retention'
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
DEFAULT_MAX_CACHE_BYTES,
|
|
18
|
+
DEFAULT_MAX_PROFILE_AGE_DAYS,
|
|
19
|
+
browserRetentionBudget,
|
|
20
|
+
heldByLiveBrowser,
|
|
21
|
+
profileCacheBytes,
|
|
22
|
+
sweepBrowserProfiles,
|
|
23
|
+
} from '../browser-retention'
|
|
24
|
+
|
|
25
|
+
const DAY = 24 * 60 * 60 * 1000
|
|
26
|
+
const BUDGET: BrowserRetentionBudget = { maxCacheBytes: 10_000, maxProfileAgeMs: 30 * DAY }
|
|
27
|
+
|
|
28
|
+
let dir: string
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
dir = mkdtempSync(join(tmpdir(), 'astrale-browser-ret-'))
|
|
32
|
+
delete process.env.ASTRALE_BROWSER_MAX_CACHE_BYTES
|
|
33
|
+
delete process.env.ASTRALE_BROWSER_MAX_PROFILE_AGE_DAYS
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
rmSync(dir, { recursive: true, force: true })
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
type Seed = { idleMs?: number; cacheBytes?: number; lockPid?: number }
|
|
41
|
+
|
|
42
|
+
/** A profile shaped like Chromium's: session state at the root and in Default/,
|
|
43
|
+
* cache in the subdirectories retention is allowed to delete. */
|
|
44
|
+
function seedProfile(name: string, { idleMs = 0, cacheBytes = 0, lockPid }: Seed): string {
|
|
45
|
+
const profile = join(dir, name)
|
|
46
|
+
mkdirSync(join(profile, 'Default'), { recursive: true })
|
|
47
|
+
writeFileSync(join(profile, 'Local State'), '{"os_crypt":{"encrypted_key":"x"}}')
|
|
48
|
+
writeFileSync(join(profile, 'Default', 'Cookies'), 'SQLite format 3\0cookie-data')
|
|
49
|
+
writeFileSync(join(profile, 'Default', 'Preferences'), '{"profile":{}}')
|
|
50
|
+
mkdirSync(join(profile, 'Default', 'Local Storage', 'leveldb'), { recursive: true })
|
|
51
|
+
writeFileSync(join(profile, 'Default', 'Local Storage', 'leveldb', '000003.log'), 'state')
|
|
52
|
+
|
|
53
|
+
if (cacheBytes > 0) {
|
|
54
|
+
mkdirSync(join(profile, 'Default', 'Cache', 'Cache_Data'), { recursive: true })
|
|
55
|
+
writeFileSync(
|
|
56
|
+
join(profile, 'Default', 'Cache', 'Cache_Data', 'f_000001'),
|
|
57
|
+
'x'.repeat(cacheBytes),
|
|
58
|
+
)
|
|
59
|
+
mkdirSync(join(profile, 'Default', 'Code Cache', 'js'), { recursive: true })
|
|
60
|
+
writeFileSync(join(profile, 'Default', 'Code Cache', 'js', 'index'), 'y'.repeat(cacheBytes))
|
|
61
|
+
}
|
|
62
|
+
if (lockPid !== undefined) {
|
|
63
|
+
symlinkSync(`${hostname()}-${lockPid}`, join(profile, 'SingletonLock'))
|
|
64
|
+
}
|
|
65
|
+
const when = new Date(Date.now() - idleMs)
|
|
66
|
+
utimesSync(profile, when, when)
|
|
67
|
+
return profile
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const sessionIntact = (name: string): boolean =>
|
|
71
|
+
existsSync(join(dir, name, 'Default', 'Cookies')) &&
|
|
72
|
+
existsSync(join(dir, name, 'Local State')) &&
|
|
73
|
+
existsSync(join(dir, name, 'Default', 'Preferences')) &&
|
|
74
|
+
existsSync(join(dir, name, 'Default', 'Local Storage', 'leveldb', '000003.log'))
|
|
75
|
+
|
|
76
|
+
const cacheGone = (name: string): boolean =>
|
|
77
|
+
!existsSync(join(dir, name, 'Default', 'Cache')) &&
|
|
78
|
+
!existsSync(join(dir, name, 'Default', 'Code Cache'))
|
|
79
|
+
|
|
80
|
+
describe('age rule', () => {
|
|
81
|
+
test('a dormant profile is removed outright', async () => {
|
|
82
|
+
seedProfile('dormant', { idleMs: 40 * DAY, cacheBytes: 100 })
|
|
83
|
+
const r = await sweepBrowserProfiles({ dir, budget: BUDGET })
|
|
84
|
+
expect(r.removed).toEqual(['dormant'])
|
|
85
|
+
expect(existsSync(join(dir, 'dormant'))).toBe(false)
|
|
86
|
+
expect(r.bytesFreed).toBeGreaterThan(0)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('a recently used profile is kept', async () => {
|
|
90
|
+
seedProfile('fresh', { idleMs: 2 * DAY, cacheBytes: 100 })
|
|
91
|
+
const r = await sweepBrowserProfiles({ dir, budget: BUDGET })
|
|
92
|
+
expect(r.removed).toEqual([])
|
|
93
|
+
expect(sessionIntact('fresh')).toBe(true)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('the age bound is configurable', async () => {
|
|
97
|
+
seedProfile('two-days', { idleMs: 2 * DAY })
|
|
98
|
+
const strict = { ...BUDGET, maxProfileAgeMs: DAY }
|
|
99
|
+
expect((await sweepBrowserProfiles({ dir, budget: strict })).removed).toEqual(['two-days'])
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe('size rule', () => {
|
|
104
|
+
test('a profile under budget is left completely alone', async () => {
|
|
105
|
+
seedProfile('small', { idleMs: DAY, cacheBytes: 1_000 })
|
|
106
|
+
const r = await sweepBrowserProfiles({ dir, budget: BUDGET })
|
|
107
|
+
expect(r.purged).toEqual([])
|
|
108
|
+
expect(cacheGone('small')).toBe(false)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('a profile over budget loses its cache but keeps its session', async () => {
|
|
112
|
+
seedProfile('bloated', { idleMs: DAY, cacheBytes: 20_000 })
|
|
113
|
+
const r = await sweepBrowserProfiles({ dir, budget: BUDGET })
|
|
114
|
+
expect(r.purged).toEqual(['bloated'])
|
|
115
|
+
expect(cacheGone('bloated')).toBe(true)
|
|
116
|
+
// The entire point: the cookie survives, so the user stays signed in.
|
|
117
|
+
expect(sessionIntact('bloated')).toBe(true)
|
|
118
|
+
expect(existsSync(join(dir, 'bloated'))).toBe(true)
|
|
119
|
+
expect(r.bytesFreed).toBeGreaterThanOrEqual(40_000)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('the size bound counts only cache directories, not the whole profile', async () => {
|
|
123
|
+
const profile = seedProfile('measured', { idleMs: DAY, cacheBytes: 3_000 })
|
|
124
|
+
writeFileSync(join(profile, 'Default', 'History'), 'z'.repeat(50_000))
|
|
125
|
+
// 2 cache files of 3 KB each; History is not cache and must not count.
|
|
126
|
+
expect(await profileCacheBytes(profile)).toBe(6_000)
|
|
127
|
+
expect((await sweepBrowserProfiles({ dir, budget: BUDGET })).purged).toEqual([])
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
describe('live-browser guard', () => {
|
|
132
|
+
test('a profile locked by a live process is never touched', async () => {
|
|
133
|
+
// Our own pid is unambiguously alive.
|
|
134
|
+
seedProfile('in-use', { idleMs: 40 * DAY, cacheBytes: 20_000, lockPid: process.pid })
|
|
135
|
+
const r = await sweepBrowserProfiles({ dir, budget: BUDGET })
|
|
136
|
+
expect(r.skipped).toEqual(['in-use'])
|
|
137
|
+
expect(r.removed).toEqual([])
|
|
138
|
+
expect(r.purged).toEqual([])
|
|
139
|
+
expect(existsSync(join(dir, 'in-use'))).toBe(true)
|
|
140
|
+
expect(cacheGone('in-use')).toBe(false)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('a stale lock from a dead process does not protect anything', async () => {
|
|
144
|
+
// Chromium leaves these behind after a crash; they must not pin disk forever.
|
|
145
|
+
seedProfile('crashed', { idleMs: 40 * DAY, lockPid: 999_999 })
|
|
146
|
+
const r = await sweepBrowserProfiles({ dir, budget: BUDGET })
|
|
147
|
+
expect(r.removed).toEqual(['crashed'])
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
test('heldByLiveBrowser reads the symlink without resolving it', () => {
|
|
151
|
+
// The target names a file that does not exist — statting it would throw.
|
|
152
|
+
const live = seedProfile('live', { lockPid: process.pid })
|
|
153
|
+
const dead = seedProfile('dead', { lockPid: 999_999 })
|
|
154
|
+
const none = seedProfile('none', {})
|
|
155
|
+
expect(heldByLiveBrowser(live)).toBe(true)
|
|
156
|
+
expect(heldByLiveBrowser(dead)).toBe(false)
|
|
157
|
+
expect(heldByLiveBrowser(none)).toBe(false)
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
describe('sweep resilience', () => {
|
|
162
|
+
test('a missing browser directory is a no-op, not an error', async () => {
|
|
163
|
+
const gone = join(dir, 'does-not-exist')
|
|
164
|
+
const r = await sweepBrowserProfiles({ dir: gone, budget: BUDGET })
|
|
165
|
+
expect(r).toEqual({ removed: [], purged: [], skipped: [], bytesFreed: 0 })
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
test('stray files beside the profiles are ignored', async () => {
|
|
169
|
+
writeFileSync(join(dir, 'browser.json'), '{}')
|
|
170
|
+
seedProfile('real', { idleMs: 40 * DAY })
|
|
171
|
+
expect((await sweepBrowserProfiles({ dir, budget: BUDGET })).removed).toEqual(['real'])
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
test('several profiles are handled independently in one pass', async () => {
|
|
175
|
+
seedProfile('a-dormant', { idleMs: 40 * DAY })
|
|
176
|
+
seedProfile('b-bloated', { idleMs: DAY, cacheBytes: 20_000 })
|
|
177
|
+
seedProfile('c-fine', { idleMs: DAY, cacheBytes: 100 })
|
|
178
|
+
seedProfile('d-locked', { idleMs: 40 * DAY, lockPid: process.pid })
|
|
179
|
+
const r = await sweepBrowserProfiles({ dir, budget: BUDGET })
|
|
180
|
+
expect(r.removed).toEqual(['a-dormant'])
|
|
181
|
+
expect(r.purged).toEqual(['b-bloated'])
|
|
182
|
+
expect(r.skipped).toEqual(['d-locked'])
|
|
183
|
+
expect(sessionIntact('c-fine')).toBe(true)
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
describe('browserRetentionBudget', () => {
|
|
188
|
+
test('defaults when nothing is configured', async () => {
|
|
189
|
+
expect(await browserRetentionBudget()).toEqual({
|
|
190
|
+
maxCacheBytes: DEFAULT_MAX_CACHE_BYTES,
|
|
191
|
+
maxProfileAgeMs: DEFAULT_MAX_PROFILE_AGE_DAYS * DAY,
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
test('env overrides both bounds', async () => {
|
|
196
|
+
process.env.ASTRALE_BROWSER_MAX_CACHE_BYTES = '4096'
|
|
197
|
+
process.env.ASTRALE_BROWSER_MAX_PROFILE_AGE_DAYS = '3'
|
|
198
|
+
expect(await browserRetentionBudget()).toEqual({
|
|
199
|
+
maxCacheBytes: 4096,
|
|
200
|
+
maxProfileAgeMs: 3 * DAY,
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
test.each([
|
|
205
|
+
['zero', '0'],
|
|
206
|
+
['negative', '-1'],
|
|
207
|
+
['not a number', 'lots'],
|
|
208
|
+
])('a %s env value falls back to the default rather than removing the cap', async (_l, value) => {
|
|
209
|
+
process.env.ASTRALE_BROWSER_MAX_CACHE_BYTES = value
|
|
210
|
+
expect((await browserRetentionBudget()).maxCacheBytes).toBe(DEFAULT_MAX_CACHE_BYTES)
|
|
211
|
+
})
|
|
212
|
+
})
|
|
@@ -23,6 +23,33 @@ describe('AstraleConfigSchema', () => {
|
|
|
23
23
|
})
|
|
24
24
|
})
|
|
25
25
|
|
|
26
|
+
test('retention bounds survive a parse — a read/write cycle must not drop them', () => {
|
|
27
|
+
// zod strips unknown keys, so a bound declared only in its reader would be
|
|
28
|
+
// silently erased by any command that rewrites the config.
|
|
29
|
+
const result = AstraleConfigSchema.parse({
|
|
30
|
+
telemetry: { enabled: true, maxAgeDays: 7, maxBytes: 1_048_576 },
|
|
31
|
+
browser: { maxCacheBytes: 52_428_800, maxProfileAgeDays: 14 },
|
|
32
|
+
})
|
|
33
|
+
expect(result.telemetry).toEqual({ enabled: true, maxAgeDays: 7, maxBytes: 1_048_576 })
|
|
34
|
+
expect(result.browser).toEqual({ maxCacheBytes: 52_428_800, maxProfileAgeDays: 14 })
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test.each([
|
|
38
|
+
['zero', 0],
|
|
39
|
+
['negative', -5],
|
|
40
|
+
['not a number', 'lots'],
|
|
41
|
+
['infinite', Number.POSITIVE_INFINITY],
|
|
42
|
+
])('a %s bound is dropped, and does not take the rest of the config with it', (_l, value) => {
|
|
43
|
+
const result = AstraleConfigSchema.parse({
|
|
44
|
+
issuer: 'https://test.astrale.ai',
|
|
45
|
+
telemetry: { enabled: true, maxAgeDays: value },
|
|
46
|
+
browser: { maxCacheBytes: value },
|
|
47
|
+
})
|
|
48
|
+
expect(result.telemetry.maxAgeDays).toBeUndefined()
|
|
49
|
+
expect(result.browser.maxCacheBytes).toBeUndefined()
|
|
50
|
+
expect(result.issuer).toBe('https://test.astrale.ai')
|
|
51
|
+
})
|
|
52
|
+
|
|
26
53
|
test('rejects non-url issuer', () => {
|
|
27
54
|
expect(() => AstraleConfigSchema.parse({ issuer: 'not-a-url' })).toThrow()
|
|
28
55
|
})
|