@astrale-os/cli 0.4.0-alpha.13

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 (219) hide show
  1. package/.check-workspace.cjs +40 -0
  2. package/README.md +151 -0
  3. package/dist/astrale.js +59749 -0
  4. package/package.json +90 -0
  5. package/src/command.ts +40 -0
  6. package/src/commands/__tests__/admin-instance.test.ts +73 -0
  7. package/src/commands/__tests__/auth-login.test.ts +178 -0
  8. package/src/commands/__tests__/auth-token.test.ts +223 -0
  9. package/src/commands/__tests__/call.test.ts +72 -0
  10. package/src/commands/__tests__/domain-list.test.ts +74 -0
  11. package/src/commands/__tests__/help-contract.test.ts +136 -0
  12. package/src/commands/__tests__/install-identity-override.test.ts +65 -0
  13. package/src/commands/__tests__/instance-bookmark.test.ts +101 -0
  14. package/src/commands/__tests__/instance-create-hosts.test.ts +29 -0
  15. package/src/commands/__tests__/instance-list-rows.test.ts +63 -0
  16. package/src/commands/__tests__/logs.test.ts +117 -0
  17. package/src/commands/__tests__/ls.test.ts +25 -0
  18. package/src/commands/__tests__/setup-plan.test.ts +61 -0
  19. package/src/commands/admin/status.ts +61 -0
  20. package/src/commands/admin/use.ts +77 -0
  21. package/src/commands/auth/login.ts +82 -0
  22. package/src/commands/auth/logout.ts +50 -0
  23. package/src/commands/auth/status.ts +86 -0
  24. package/src/commands/auth/token.ts +162 -0
  25. package/src/commands/browser.ts +207 -0
  26. package/src/commands/call.ts +300 -0
  27. package/src/commands/describe.ts +182 -0
  28. package/src/commands/domain/install.ts +420 -0
  29. package/src/commands/domain/list.ts +154 -0
  30. package/src/commands/domain/publish.ts +155 -0
  31. package/src/commands/get.ts +60 -0
  32. package/src/commands/identity/create.ts +26 -0
  33. package/src/commands/identity/delete.ts +18 -0
  34. package/src/commands/identity/export.ts +66 -0
  35. package/src/commands/identity/import.ts +101 -0
  36. package/src/commands/identity/list.ts +56 -0
  37. package/src/commands/identity/register.ts +170 -0
  38. package/src/commands/identity/sync.ts +32 -0
  39. package/src/commands/identity/unsync.ts +24 -0
  40. package/src/commands/identity/use.ts +18 -0
  41. package/src/commands/identity/whoami.ts +34 -0
  42. package/src/commands/idp/add.ts +150 -0
  43. package/src/commands/idp/list.ts +57 -0
  44. package/src/commands/idp/refresh.ts +38 -0
  45. package/src/commands/idp/remove.ts +36 -0
  46. package/src/commands/idp/show.ts +29 -0
  47. package/src/commands/instance/active.ts +64 -0
  48. package/src/commands/instance/bookmark.ts +72 -0
  49. package/src/commands/instance/create.ts +69 -0
  50. package/src/commands/instance/delete.ts +72 -0
  51. package/src/commands/instance/forget.ts +26 -0
  52. package/src/commands/instance/list.ts +149 -0
  53. package/src/commands/instance/status.ts +42 -0
  54. package/src/commands/instance/use.ts +210 -0
  55. package/src/commands/logs.ts +347 -0
  56. package/src/commands/ls.ts +229 -0
  57. package/src/commands/query.ts +32 -0
  58. package/src/commands/setup.ts +54 -0
  59. package/src/commands/status.ts +60 -0
  60. package/src/commands/studio.ts +401 -0
  61. package/src/commands/token.ts +77 -0
  62. package/src/commands/update.ts +267 -0
  63. package/src/commands/use.ts +87 -0
  64. package/src/errors.ts +65 -0
  65. package/src/kernel/__tests__/auth.test.ts +77 -0
  66. package/src/kernel/__tests__/errors.test.ts +43 -0
  67. package/src/kernel/__tests__/remote-routing.test.ts +70 -0
  68. package/src/kernel/auth.ts +234 -0
  69. package/src/kernel/ca-fetch.ts +119 -0
  70. package/src/kernel/client.ts +191 -0
  71. package/src/kernel/errors.ts +280 -0
  72. package/src/kernel/expand.ts +217 -0
  73. package/src/kernel/index.ts +14 -0
  74. package/src/kernel/options.ts +22 -0
  75. package/src/kernel/remote-routing.ts +88 -0
  76. package/src/kernel/run.ts +63 -0
  77. package/src/kernel/types.ts +14 -0
  78. package/src/lib/__tests__/admin-target.test.ts +112 -0
  79. package/src/lib/__tests__/binary.test.ts +56 -0
  80. package/src/lib/__tests__/command-dx.test.ts +58 -0
  81. package/src/lib/__tests__/concurrency.test.ts +62 -0
  82. package/src/lib/__tests__/config.test.ts +53 -0
  83. package/src/lib/__tests__/design.test.ts +99 -0
  84. package/src/lib/__tests__/domain-identity.test.ts +60 -0
  85. package/src/lib/__tests__/format.test.ts +22 -0
  86. package/src/lib/__tests__/fs-atomic.test.ts +104 -0
  87. package/src/lib/__tests__/identity.test.ts +79 -0
  88. package/src/lib/__tests__/idp-session.driver.ts +53 -0
  89. package/src/lib/__tests__/idp-session.test.ts +357 -0
  90. package/src/lib/__tests__/idp.test.ts +385 -0
  91. package/src/lib/__tests__/instance-candidates.test.ts +73 -0
  92. package/src/lib/__tests__/instance-target.test.ts +183 -0
  93. package/src/lib/__tests__/instance.test.ts +136 -0
  94. package/src/lib/__tests__/keys.test.ts +129 -0
  95. package/src/lib/__tests__/local-status.test.ts +202 -0
  96. package/src/lib/__tests__/output.test.ts +150 -0
  97. package/src/lib/__tests__/panel.test.ts +40 -0
  98. package/src/lib/__tests__/port.test.ts +44 -0
  99. package/src/lib/__tests__/prompt.test.ts +25 -0
  100. package/src/lib/__tests__/sdk-deps.test.ts +68 -0
  101. package/src/lib/__tests__/self.test.ts +272 -0
  102. package/src/lib/__tests__/studio-server-deps.test.ts +74 -0
  103. package/src/lib/__tests__/table.test.ts +53 -0
  104. package/src/lib/__tests__/update.test.ts +246 -0
  105. package/src/lib/__tests__/use-target.test.ts +56 -0
  106. package/src/lib/__tests__/validation.test.ts +34 -0
  107. package/src/lib/admin-domain.ts +25 -0
  108. package/src/lib/admin-instance.ts +26 -0
  109. package/src/lib/admin-target.ts +217 -0
  110. package/src/lib/binary.ts +131 -0
  111. package/src/lib/browser.ts +150 -0
  112. package/src/lib/command-dx.ts +161 -0
  113. package/src/lib/concurrency.ts +31 -0
  114. package/src/lib/config.ts +45 -0
  115. package/src/lib/domain-identity.ts +49 -0
  116. package/src/lib/env.ts +49 -0
  117. package/src/lib/format.ts +4 -0
  118. package/src/lib/fs-atomic.ts +126 -0
  119. package/src/lib/identity.ts +256 -0
  120. package/src/lib/idp-session.ts +134 -0
  121. package/src/lib/idp.ts +876 -0
  122. package/src/lib/instance-candidates.ts +49 -0
  123. package/src/lib/instance-target.ts +182 -0
  124. package/src/lib/instance.ts +395 -0
  125. package/src/lib/keys.ts +294 -0
  126. package/src/lib/local-status.ts +152 -0
  127. package/src/lib/log.ts +116 -0
  128. package/src/lib/login-flow.ts +164 -0
  129. package/src/lib/meta.ts +86 -0
  130. package/src/lib/output.ts +222 -0
  131. package/src/lib/panel.ts +61 -0
  132. package/src/lib/paths.ts +11 -0
  133. package/src/lib/port.ts +41 -0
  134. package/src/lib/proc.ts +82 -0
  135. package/src/lib/prompt.ts +136 -0
  136. package/src/lib/provision-instance.ts +170 -0
  137. package/src/lib/sdk-deps.ts +104 -0
  138. package/src/lib/self.ts +166 -0
  139. package/src/lib/skills.ts +171 -0
  140. package/src/lib/table.ts +62 -0
  141. package/src/lib/update.ts +315 -0
  142. package/src/lib/use-target.ts +24 -0
  143. package/src/lib/validation.ts +59 -0
  144. package/src/program.ts +200 -0
  145. package/src/registry.ts +59 -0
  146. package/src/setup/__tests__/util.test.ts +29 -0
  147. package/src/setup/engine.ts +83 -0
  148. package/src/setup/render.ts +109 -0
  149. package/src/setup/steps/admin.ts +78 -0
  150. package/src/setup/steps/agent-browser.ts +81 -0
  151. package/src/setup/steps/auth.ts +54 -0
  152. package/src/setup/steps/domain.ts +68 -0
  153. package/src/setup/steps/index.ts +18 -0
  154. package/src/setup/steps/instance.ts +119 -0
  155. package/src/setup/steps/skills-bridge.ts +70 -0
  156. package/src/setup/steps/skills.ts +59 -0
  157. package/src/setup/types.ts +61 -0
  158. package/src/setup/util.ts +34 -0
  159. package/src/test-utils.ts +18 -0
  160. package/studio/client/dist/assets/index-DOwzZAEK.css +1 -0
  161. package/studio/client/dist/assets/index-wtU0Zxhy.js +183 -0
  162. package/studio/client/dist/index.html +13 -0
  163. package/studio/package.json +62 -0
  164. package/studio/server/agent/ask.ts +68 -0
  165. package/studio/server/agent/bridge-mcp.ts +182 -0
  166. package/studio/server/agent/bridge.ts +188 -0
  167. package/studio/server/agent/claude.ts +666 -0
  168. package/studio/server/agent/mock.ts +186 -0
  169. package/studio/server/agent/prompt.ts +202 -0
  170. package/studio/server/agent/registry.ts +29 -0
  171. package/studio/server/agent/runner.ts +484 -0
  172. package/studio/server/agent/schema-map.ts +112 -0
  173. package/studio/server/agent/types.ts +120 -0
  174. package/studio/server/api.ts +574 -0
  175. package/studio/server/cache.ts +138 -0
  176. package/studio/server/detect.ts +81 -0
  177. package/studio/server/domain.ts +70 -0
  178. package/studio/server/index.ts +136 -0
  179. package/studio/server/introspect/anatomy-extras.ts +398 -0
  180. package/studio/server/introspect/anatomy.ts +108 -0
  181. package/studio/server/introspect/bundle.ts +57 -0
  182. package/studio/server/introspect/core-extractor.ts +119 -0
  183. package/studio/server/introspect/core.ts +44 -0
  184. package/studio/server/introspect/diff.ts +133 -0
  185. package/studio/server/introspect/extractor.ts +102 -0
  186. package/studio/server/introspect/hash.ts +21 -0
  187. package/studio/server/introspect/overlay-tsmorph.ts +874 -0
  188. package/studio/server/introspect/overlay.ts +57 -0
  189. package/studio/server/introspect/runtime.ts +99 -0
  190. package/studio/server/introspect/schema-refs.ts +46 -0
  191. package/studio/server/lifecycle.ts +38 -0
  192. package/studio/server/sse.ts +57 -0
  193. package/studio/server/state/baseline.ts +211 -0
  194. package/studio/server/state/catalog.ts +117 -0
  195. package/studio/server/state/comments.ts +321 -0
  196. package/studio/server/state/context.ts +167 -0
  197. package/studio/server/state/copy.ts +156 -0
  198. package/studio/server/state/create.ts +156 -0
  199. package/studio/server/state/documents.ts +70 -0
  200. package/studio/server/state/env.ts +161 -0
  201. package/studio/server/state/git.ts +75 -0
  202. package/studio/server/state/handoff.ts +55 -0
  203. package/studio/server/state/harness-gateway.ts +181 -0
  204. package/studio/server/state/harness-token.ts +0 -0
  205. package/studio/server/state/instance.ts +244 -0
  206. package/studio/server/state/integrations.ts +55 -0
  207. package/studio/server/state/layout.ts +55 -0
  208. package/studio/server/state/settings.ts +39 -0
  209. package/studio/server/state/store.ts +97 -0
  210. package/studio/server/state/updates.ts +63 -0
  211. package/studio/server/state/usage.ts +37 -0
  212. package/studio/server/state/views.ts +138 -0
  213. package/studio/server/state/visibility.ts +33 -0
  214. package/studio/server/watch.ts +81 -0
  215. package/studio/server/workspace-state.ts +26 -0
  216. package/studio/server/workspace-watch.ts +101 -0
  217. package/studio/shared/types.ts +873 -0
  218. package/studio/tsconfig.json +23 -0
  219. package/tsconfig.json +14 -0
@@ -0,0 +1,385 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+
3
+ import {
4
+ accessTokenForAudience,
5
+ classifyRefreshFailure,
6
+ decodeTokenClaims,
7
+ identityNameFromClaims,
8
+ IdpClientConfigSchema,
9
+ IdpSessionSchema,
10
+ isSessionExpired,
11
+ issuerFromToken,
12
+ normalizeTokenResponse,
13
+ OAuthTokenError,
14
+ OidcMetadataSchema,
15
+ postForm,
16
+ subjectFromToken,
17
+ tokenExpiresAt,
18
+ tokenAudienceMatches,
19
+ withCachedToken,
20
+ workosClientIdFromEnv,
21
+ workosAuthKitMetadata,
22
+ builtinIdpConfig,
23
+ type IdpSession,
24
+ } from '../idp'
25
+
26
+ describe('IdP schemas and token helpers', () => {
27
+ test('parses WorkOS/AuthKit-style discovery metadata', () => {
28
+ const metadata = OidcMetadataSchema.parse({
29
+ issuer: 'https://example.authkit.app',
30
+ authorization_endpoint: 'https://example.authkit.app/oauth2/authorize',
31
+ token_endpoint: 'https://example.authkit.app/oauth2/token',
32
+ device_authorization_endpoint: 'https://example.authkit.app/oauth2/device_authorization',
33
+ jwks_uri: 'https://example.authkit.app/oauth2/jwks',
34
+ response_types_supported: ['code'],
35
+ grant_types_supported: ['authorization_code', 'urn:ietf:params:oauth:grant-type:device_code'],
36
+ custom_field: 'kept',
37
+ })
38
+
39
+ expect(metadata.issuer).toBe('https://example.authkit.app')
40
+ expect(metadata.device_authorization_endpoint).toContain('/device_authorization')
41
+ expect(metadata.custom_field).toBe('kept')
42
+ })
43
+
44
+ test('builds WorkOS AuthKit CLI Auth metadata for api-hosted device flow', () => {
45
+ const metadata = workosAuthKitMetadata('https://api.workos.com', 'client_123')
46
+
47
+ expect(metadata.issuer).toBe('https://api.workos.com')
48
+ expect(metadata.device_authorization_endpoint).toBe(
49
+ 'https://api.workos.com/user_management/authorize/device',
50
+ )
51
+ expect(metadata.token_endpoint).toBe('https://api.workos.com/user_management/authenticate')
52
+ expect(metadata.jwks_uri).toBe('https://api.workos.com/sso/jwks/client_123')
53
+ })
54
+
55
+ test('builds built-in WorkOS IdP config from env client id', () => {
56
+ const config = builtinIdpConfig('workos', undefined, {
57
+ WORKOS_CLIENT_ID: 'client_123',
58
+ })
59
+
60
+ expect(config?.entry.builtIn).toBe(true)
61
+ expect(config?.client.client_id).toBe('client_123')
62
+ expect(config?.client.token_response).toBe('workos-authkit')
63
+ expect(config?.client.token_request_format).toBe('json')
64
+ })
65
+
66
+ test('builds built-in WorkOS IdP config with the public alpha client id by default', () => {
67
+ const config = builtinIdpConfig('workos', undefined, {})
68
+
69
+ expect(config?.entry.builtIn).toBe(true)
70
+ expect(config?.client.client_id).toBe('client_01KC29HEGD7B40TV2C4QZ436BG')
71
+ })
72
+
73
+ test('reads WorkOS client id from WORKOS_CLIENT_ID before VITE_WORKOS_CLIENT_ID', () => {
74
+ expect(
75
+ workosClientIdFromEnv({
76
+ WORKOS_CLIENT_ID: 'client_primary',
77
+ VITE_WORKOS_CLIENT_ID: 'client_vite',
78
+ }),
79
+ ).toBe('client_primary')
80
+ expect(workosClientIdFromEnv({ VITE_WORKOS_CLIENT_ID: 'client_vite' })).toBe('client_vite')
81
+ expect(workosClientIdFromEnv({})).toBe('client_01KC29HEGD7B40TV2C4QZ436BG')
82
+ })
83
+
84
+ test('stores client secret env names, not secret material', () => {
85
+ const client = IdpClientConfigSchema.parse({
86
+ client_id: 'client_123',
87
+ client_secret_env: 'WORKOS_CLIENT_SECRET',
88
+ redirect_uris: ['http://127.0.0.1:8787/callback'],
89
+ scope: 'openid profile email offline_access',
90
+ public: false,
91
+ workos_application_id: 'app_123',
92
+ workos_application_type: 'oauth',
93
+ })
94
+
95
+ expect(client.client_secret_env).toBe('WORKOS_CLIENT_SECRET')
96
+ expect(JSON.stringify(client)).not.toContain('sk_')
97
+ })
98
+
99
+ test('parses cached sessions with claims but redacts nothing into schema', () => {
100
+ const session = IdpSessionSchema.parse({
101
+ identity: 'alice',
102
+ idp: 'workos',
103
+ issuer: 'https://example.authkit.app',
104
+ subject: 'user_123',
105
+ access_token: 'opaque-token',
106
+ refresh_token: 'refresh-token',
107
+ token_type: 'Bearer',
108
+ scope: 'openid profile email',
109
+ expires_at: '2030-01-01T00:00:00.000Z',
110
+ claims: { email: 'alice@example.com' },
111
+ updatedAt: '2024-01-01T00:00:00.000Z',
112
+ })
113
+
114
+ expect(session.claims?.email).toBe('alice@example.com')
115
+ expect(session.refresh_token).toBe('refresh-token')
116
+ })
117
+
118
+ test('derives expiry timestamp from expires_in', () => {
119
+ const expiresAt = tokenExpiresAt({ expires_in: 60 })
120
+ expect(expiresAt).toBeString()
121
+ expect(new Date(expiresAt!).getTime()).toBeGreaterThan(Date.now())
122
+ })
123
+
124
+ test('derives expiry timestamp from WorkOS camelCase and JWT claims', () => {
125
+ const camelCaseExpiresAt = tokenExpiresAt({ expiresIn: 60 })
126
+ expect(camelCaseExpiresAt).toBeString()
127
+ expect(new Date(camelCaseExpiresAt!).getTime()).toBeGreaterThan(Date.now())
128
+
129
+ const jwtExpiresAt = tokenExpiresAt({
130
+ access_token: unsignedJwt({ exp: 1893456000 }),
131
+ })
132
+ expect(jwtExpiresAt).toBe('2030-01-01T00:00:00.000Z')
133
+ })
134
+
135
+ test('decodes JWT claims for identity naming, subject, and issuer', () => {
136
+ const jwt = unsignedJwt({
137
+ iss: 'https://example.authkit.app',
138
+ sub: 'user_123',
139
+ email: 'Alice Smith@example.com',
140
+ })
141
+ const claims = decodeTokenClaims(jwt)
142
+
143
+ expect(claims?.sub).toBe('user_123')
144
+ expect(identityNameFromClaims(claims, 'fallback')).toBe('Alice-Smith-example.com')
145
+ expect(subjectFromToken({ access_token: jwt }, 'fallback')).toBe('user_123')
146
+ expect(issuerFromToken({ access_token: jwt }, 'fallback')).toBe('https://example.authkit.app')
147
+ })
148
+
149
+ test('matches string and array JWT audiences', () => {
150
+ expect(
151
+ tokenAudienceMatches(
152
+ unsignedJwt({ aud: 'https://kernel.example.com' }),
153
+ 'https://kernel.example.com',
154
+ ),
155
+ ).toBe(true)
156
+ expect(
157
+ tokenAudienceMatches(
158
+ unsignedJwt({ aud: ['api', 'https://kernel.example.com'] }),
159
+ 'https://kernel.example.com',
160
+ ),
161
+ ).toBe(true)
162
+ expect(
163
+ tokenAudienceMatches(unsignedJwt({ aud: 'unknown/api' }), 'https://kernel.example.com'),
164
+ ).toBe(false)
165
+ })
166
+
167
+ test('detects expired sessions with skew', () => {
168
+ expect(isSessionExpired({ expires_at: '2000-01-01T00:00:00.000Z' })).toBe(true)
169
+ expect(isSessionExpired({ expires_at: '2999-01-01T00:00:00.000Z' })).toBe(false)
170
+ expect(isSessionExpired({})).toBe(false)
171
+ })
172
+
173
+ test('detects expired sessions from cached JWT exp when expires_at is missing', () => {
174
+ expect(isSessionExpired({ access_token: unsignedJwt({ exp: 946684800 }) })).toBe(true)
175
+ expect(isSessionExpired({ access_token: unsignedJwt({ exp: 32503680000 }) })).toBe(false)
176
+ })
177
+
178
+ test('normalizes WorkOS camelCase token response fields', () => {
179
+ const token = normalizeTokenResponse({
180
+ accessToken: 'access',
181
+ idToken: 'id',
182
+ refreshToken: 'refresh',
183
+ user: { id: 'user_123' },
184
+ })
185
+
186
+ expect(token.access_token).toBe('access')
187
+ expect(token.id_token).toBe('id')
188
+ expect(token.refresh_token).toBe('refresh')
189
+ expect(subjectFromToken(token, 'fallback')).toBe('user_123')
190
+ })
191
+
192
+ test('parses sessions with and without the per-audience tokens map', () => {
193
+ const base = {
194
+ identity: 'alice',
195
+ idp: 'workos',
196
+ issuer: 'https://example.authkit.app',
197
+ subject: 'user_123',
198
+ access_token: 'opaque-token',
199
+ updatedAt: '2024-01-01T00:00:00.000Z',
200
+ }
201
+
202
+ expect(IdpSessionSchema.parse(base).tokens).toBeUndefined()
203
+
204
+ const withMap = IdpSessionSchema.parse({
205
+ ...base,
206
+ tokens: {
207
+ 'https://kernel.example.com': {
208
+ access_token: 'aud-token',
209
+ expires_at: '2999-01-01T00:00:00.000Z',
210
+ extra: 'kept',
211
+ },
212
+ },
213
+ unknown_key: 'kept',
214
+ })
215
+ expect(withMap.tokens?.['https://kernel.example.com']?.access_token).toBe('aud-token')
216
+ expect((withMap as Record<string, unknown>).unknown_key).toBe('kept')
217
+ })
218
+ })
219
+
220
+ describe('accessTokenForAudience', () => {
221
+ const session = (overrides: Partial<IdpSession>): IdpSession => ({
222
+ identity: 'alice',
223
+ idp: 'workos',
224
+ issuer: 'https://example.authkit.app',
225
+ subject: 'user_123',
226
+ access_token: 'top-level',
227
+ updatedAt: '2024-01-01T00:00:00.000Z',
228
+ ...overrides,
229
+ })
230
+
231
+ test('without an audience, returns the top-level token while fresh', () => {
232
+ expect(accessTokenForAudience(session({ expires_at: '2999-01-01T00:00:00.000Z' }))).toBe(
233
+ 'top-level',
234
+ )
235
+ expect(
236
+ accessTokenForAudience(session({ expires_at: '2000-01-01T00:00:00.000Z' })),
237
+ ).toBeUndefined()
238
+ })
239
+
240
+ test('prefers a fresh per-audience map entry', () => {
241
+ const s = session({
242
+ access_token: unsignedJwt({ aud: 'https://other.example.com' }),
243
+ expires_at: '2999-01-01T00:00:00.000Z',
244
+ tokens: {
245
+ 'https://kernel.example.com': {
246
+ access_token: 'aud-token',
247
+ expires_at: '2999-01-01T00:00:00.000Z',
248
+ },
249
+ },
250
+ })
251
+
252
+ expect(accessTokenForAudience(s, 'https://kernel.example.com')).toBe('aud-token')
253
+ })
254
+
255
+ test('ignores an expired map entry and falls back to the top-level aud claim', () => {
256
+ const jwt = unsignedJwt({ aud: 'https://kernel.example.com' })
257
+ const s = session({
258
+ access_token: jwt,
259
+ expires_at: '2999-01-01T00:00:00.000Z',
260
+ tokens: {
261
+ 'https://kernel.example.com': {
262
+ access_token: 'stale',
263
+ expires_at: '2000-01-01T00:00:00.000Z',
264
+ },
265
+ },
266
+ })
267
+
268
+ expect(accessTokenForAudience(s, 'https://kernel.example.com')).toBe(jwt)
269
+ expect(accessTokenForAudience(s, 'https://elsewhere.example.com')).toBeUndefined()
270
+ })
271
+
272
+ test('matches array aud claims on the top-level token', () => {
273
+ const jwt = unsignedJwt({ aud: ['api', 'https://kernel.example.com'] })
274
+ const s = session({ access_token: jwt, expires_at: '2999-01-01T00:00:00.000Z' })
275
+
276
+ expect(accessTokenForAudience(s, 'https://kernel.example.com')).toBe(jwt)
277
+ })
278
+ })
279
+
280
+ describe('withCachedToken', () => {
281
+ test('caches a minted token under every aud it carries and prunes expired entries', () => {
282
+ const jwt = unsignedJwt({ aud: ['a', 'b'] })
283
+ const tokens = withCachedToken(
284
+ {
285
+ stale: { access_token: 'old', expires_at: '2000-01-01T00:00:00.000Z' },
286
+ fresh: { access_token: 'kept', expires_at: '2999-01-01T00:00:00.000Z' },
287
+ },
288
+ jwt,
289
+ '2999-01-01T00:00:00.000Z',
290
+ )
291
+
292
+ expect(Object.keys(tokens ?? {}).sort()).toEqual(['a', 'b', 'fresh'])
293
+ expect(tokens?.a?.access_token).toBe(jwt)
294
+ })
295
+
296
+ test('returns undefined for an aud-less opaque token with no prior entries', () => {
297
+ expect(withCachedToken(undefined, 'opaque', undefined)).toBeUndefined()
298
+ })
299
+ })
300
+
301
+ describe('OAuth token errors', () => {
302
+ test('postForm surfaces the OAuth error code as a typed error', async () => {
303
+ const server = Bun.serve({
304
+ port: 0,
305
+ fetch: () =>
306
+ Response.json(
307
+ { error: 'invalid_grant', error_description: 'Refresh token already exchanged.' },
308
+ { status: 400 },
309
+ ),
310
+ })
311
+ try {
312
+ const promise = postForm(
313
+ `http://${server.hostname}:${server.port}/token`,
314
+ new URLSearchParams(),
315
+ )
316
+ await expect(promise).rejects.toBeInstanceOf(OAuthTokenError)
317
+ const error = await promise.catch((e) => e as OAuthTokenError)
318
+ expect(error.code).toBe('invalid_grant')
319
+ expect(error.status).toBe(400)
320
+ expect(error.message).toContain('Refresh token already exchanged.')
321
+ } finally {
322
+ await server.stop(true)
323
+ }
324
+ })
325
+
326
+ test('postForm turns a non-JSON proxy error body into an OAuthTokenError, not a SyntaxError', async () => {
327
+ const server = Bun.serve({
328
+ port: 0,
329
+ fetch: () => new Response('<html>Bad Gateway</html>', { status: 502 }),
330
+ })
331
+ try {
332
+ const error = await postForm(
333
+ `http://${server.hostname}:${server.port}/token`,
334
+ new URLSearchParams(),
335
+ ).catch((e) => e as OAuthTokenError)
336
+ expect(error).toBeInstanceOf(OAuthTokenError)
337
+ expect(error.status).toBe(502)
338
+ } finally {
339
+ await server.stop(true)
340
+ }
341
+ })
342
+
343
+ test('classifies refresh failures: dead grant vs transient vs unknown', () => {
344
+ expect(classifyRefreshFailure(new OAuthTokenError({ code: 'invalid_grant' }))).toBe(
345
+ 'session-ended',
346
+ )
347
+ expect(classifyRefreshFailure(new OAuthTokenError({ status: 503 }))).toBe('transient')
348
+ expect(classifyRefreshFailure(new OAuthTokenError({ status: 429 }))).toBe('transient')
349
+ expect(classifyRefreshFailure(new TypeError('fetch failed'))).toBe('transient')
350
+ const abort = new Error('aborted')
351
+ abort.name = 'AbortError'
352
+ expect(classifyRefreshFailure(abort)).toBe('transient')
353
+ expect(classifyRefreshFailure(new OAuthTokenError({ code: 'invalid_client' }))).toBe('unknown')
354
+ expect(classifyRefreshFailure(new Error('weird'))).toBe('unknown')
355
+ })
356
+
357
+ test('org-membership rejection is org-rejected, never session-ended', () => {
358
+ // The live failure (fresh-user flow, 2026-06-11): the router served a
359
+ // stale org for a just-created instance; WorkOS refused the scope with
360
+ // "User is not a member of the organization." — and the CLI told the
361
+ // user to re-login, which can never fix it. Membership rejection must
362
+ // classify distinctly regardless of the OAuth error code it rides on.
363
+ expect(
364
+ classifyRefreshFailure(
365
+ new OAuthTokenError({
366
+ code: 'invalid_grant',
367
+ description: 'User is not a member of the organization.',
368
+ }),
369
+ ),
370
+ ).toBe('org-rejected')
371
+ expect(
372
+ classifyRefreshFailure(
373
+ new OAuthTokenError({ description: 'user is NOT a member of the organization' }),
374
+ ),
375
+ ).toBe('org-rejected')
376
+ })
377
+ })
378
+
379
+ function unsignedJwt(payload: Record<string, unknown>): string {
380
+ return [base64url({ alg: 'none', typ: 'JWT' }), base64url(payload), ''].join('.')
381
+ }
382
+
383
+ function base64url(value: Record<string, unknown>): string {
384
+ return Buffer.from(JSON.stringify(value)).toString('base64url')
385
+ }
@@ -0,0 +1,73 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+
3
+ import type { InstanceInfo } from '../admin-instance'
4
+ import type { InstanceStore } from '../instance'
5
+
6
+ import { collectInstanceCandidates, describeInstanceCandidate } from '../instance-candidates'
7
+
8
+ const store: InstanceStore = {
9
+ active: 'demo',
10
+ instances: {
11
+ demo: { url: 'https://demo.eu.astrale.ai/api', kind: 'bookmark' },
12
+ other: { url: 'https://other.example.com/api', kind: 'bookmark', slug: 'oth' },
13
+ },
14
+ }
15
+
16
+ const managedDemo: InstanceInfo = {
17
+ id: 'demo',
18
+ slug: 'demo',
19
+ url: 'https://demo.eu.astrale.ai',
20
+ region: 'eu',
21
+ }
22
+
23
+ describe('collectInstanceCandidates', () => {
24
+ test('bookmark and managed at the same URL collapse into one candidate', () => {
25
+ const candidates = collectInstanceCandidates('demo', store, [managedDemo])
26
+ expect(candidates).toHaveLength(1)
27
+ expect(candidates[0]).toMatchObject({ source: 'bookmark', key: 'demo' })
28
+ })
29
+
30
+ test('bookmark and managed at different URLs are both candidates', () => {
31
+ const conflicting: InstanceInfo = { ...managedDemo, url: 'https://demo.us.astrale.ai' }
32
+ const candidates = collectInstanceCandidates('demo', store, [conflicting])
33
+ expect(candidates).toHaveLength(2)
34
+ expect(candidates.map((c) => c.source)).toEqual(['bookmark', 'managed'])
35
+ })
36
+
37
+ test('multiple managed instances with the same slug are all candidates', () => {
38
+ const a: InstanceInfo = { id: 'x1', slug: 'twin', url: 'https://twin.eu.astrale.ai' }
39
+ const b: InstanceInfo = { id: 'x2', slug: 'twin', url: 'https://twin.us.astrale.ai' }
40
+ const candidates = collectInstanceCandidates('twin', store, [a, b])
41
+ expect(candidates).toHaveLength(2)
42
+ expect(candidates.every((c) => c.source === 'managed')).toBe(true)
43
+ })
44
+
45
+ test('managed instances with a different slug are ignored', () => {
46
+ const unrelated: InstanceInfo = { id: 'z', slug: 'zeta', url: 'https://zeta.eu.astrale.ai' }
47
+ expect(collectInstanceCandidates('demo', store, [unrelated])).toHaveLength(1)
48
+ })
49
+
50
+ test('bookmark resolution honours slug aliases', () => {
51
+ const candidates = collectInstanceCandidates('oth', store, [])
52
+ expect(candidates).toHaveLength(1)
53
+ expect(candidates[0]).toMatchObject({ source: 'bookmark', key: 'other' })
54
+ })
55
+
56
+ test('unknown name yields no candidates', () => {
57
+ expect(collectInstanceCandidates('missing', store, [])).toHaveLength(0)
58
+ })
59
+ })
60
+
61
+ describe('describeInstanceCandidate', () => {
62
+ test('labels carry source and URL', () => {
63
+ const [bookmark] = collectInstanceCandidates('demo', store, [])
64
+ expect(describeInstanceCandidate(bookmark!)).toContain('(bookmark)')
65
+ expect(describeInstanceCandidate(bookmark!)).toContain('https://demo.eu.astrale.ai/api')
66
+
67
+ const [managed] = collectInstanceCandidates('twin', store, [
68
+ { id: 'x', slug: 'twin', url: 'https://twin.eu.astrale.ai', region: 'eu' },
69
+ ])
70
+ expect(describeInstanceCandidate(managed!)).toContain('(managed)')
71
+ expect(describeInstanceCandidate(managed!)).toContain('eu')
72
+ })
73
+ })
@@ -0,0 +1,183 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+
3
+ import type { AstraleConfig } from '../config'
4
+ import type { InstanceStore } from '../instance'
5
+
6
+ import { DEFAULT_CONFIG } from '../config'
7
+ import {
8
+ couldBeConfiguredAdminInstance,
9
+ isManagedInstanceNotFound,
10
+ resolveInstanceTarget,
11
+ } from '../instance-target'
12
+
13
+ const store: InstanceStore = {
14
+ active: 'active-bookmark',
15
+ instances: {
16
+ 'active-bookmark': {
17
+ url: 'https://active.example.com/api',
18
+ issuer: 'https://active-issuer.example.com',
19
+ defaultIdentity: 'active-id',
20
+ },
21
+ admin: {
22
+ url: 'https://bookmarked-admin.example.com/api',
23
+ issuer: 'https://bookmarked-admin-issuer.example.com',
24
+ },
25
+ },
26
+ }
27
+
28
+ const emptyStore: InstanceStore = {
29
+ active: '',
30
+ instances: {},
31
+ }
32
+
33
+ const directAdminConfig: AstraleConfig = {
34
+ ...DEFAULT_CONFIG,
35
+ admin: {
36
+ name: 'admin',
37
+ url: 'https://admin.eu.astrale.ai/api',
38
+ issuer: 'https://admin.eu.astrale.ai/api',
39
+ },
40
+ }
41
+
42
+ describe('resolveInstanceTarget', () => {
43
+ test('bookmark names win over configured admin and managed lookup', async () => {
44
+ const managedCalls: string[] = []
45
+
46
+ await expect(
47
+ resolveInstanceTarget(
48
+ { source: 'name', name: 'admin' },
49
+ {
50
+ config: directAdminConfig,
51
+ instances: store,
52
+ managed: async (slug) => {
53
+ managedCalls.push(slug)
54
+ throw new Error('should not run')
55
+ },
56
+ },
57
+ ),
58
+ ).resolves.toMatchObject({
59
+ name: 'admin',
60
+ source: 'bookmark',
61
+ url: 'https://bookmarked-admin.example.com/api',
62
+ })
63
+ expect(managedCalls).toEqual([])
64
+ })
65
+
66
+ test('configured admin resolves before managed lookup', async () => {
67
+ const managedCalls: string[] = []
68
+
69
+ await expect(
70
+ resolveInstanceTarget(
71
+ { source: 'name', name: 'admin' },
72
+ {
73
+ config: directAdminConfig,
74
+ instances: emptyStore,
75
+ managed: async (slug) => {
76
+ managedCalls.push(slug)
77
+ throw new Error('should not run')
78
+ },
79
+ },
80
+ ),
81
+ ).resolves.toMatchObject({
82
+ name: 'admin',
83
+ source: 'admin',
84
+ url: 'https://admin.eu.astrale.ai/api',
85
+ issuer: 'https://admin.eu.astrale.ai/api',
86
+ })
87
+ expect(managedCalls).toEqual([])
88
+ })
89
+
90
+ test('active resolves through the same named-instance path', async () => {
91
+ await expect(
92
+ resolveInstanceTarget({ source: 'active' }, { config: DEFAULT_CONFIG, instances: store }),
93
+ ).resolves.toMatchObject({
94
+ name: 'active-bookmark',
95
+ source: 'bookmark',
96
+ defaultIdentity: 'active-id',
97
+ })
98
+ })
99
+
100
+ test('managed slug resolves through injected Instance.info lookup', async () => {
101
+ await expect(
102
+ resolveInstanceTarget(
103
+ { source: 'name', name: 'bryan' },
104
+ {
105
+ config: DEFAULT_CONFIG,
106
+ instances: emptyStore,
107
+ managed: async (slug) => ({
108
+ id: 'node-1',
109
+ slug,
110
+ url: `https://${slug}.eu.astrale.ai`,
111
+ }),
112
+ },
113
+ ),
114
+ ).resolves.toEqual({
115
+ name: 'bryan',
116
+ source: 'managed',
117
+ url: 'https://bryan.eu.astrale.ai/api',
118
+ issuer: 'https://bryan.eu.astrale.ai/api',
119
+ })
120
+ })
121
+
122
+ test('unknown slugs surface the original instance-not-found error', async () => {
123
+ const notFound = new Error('Path not found: "/admin/instances/missing"')
124
+ notFound.name = 'NotFoundError'
125
+
126
+ await expect(
127
+ resolveInstanceTarget(
128
+ { source: 'name', name: 'missing' },
129
+ {
130
+ config: DEFAULT_CONFIG,
131
+ instances: emptyStore,
132
+ managed: async () => {
133
+ throw notFound
134
+ },
135
+ },
136
+ ),
137
+ ).rejects.toThrow('Instance "missing" is not bookmarked')
138
+ })
139
+
140
+ test('url targets do not receive an instance name by default', async () => {
141
+ await expect(
142
+ resolveInstanceTarget(
143
+ { source: 'url', url: 'https://kernel.example.com/api' },
144
+ { config: DEFAULT_CONFIG },
145
+ ),
146
+ ).resolves.toEqual({
147
+ source: 'url',
148
+ url: 'https://kernel.example.com/api',
149
+ issuer: 'https://kernel.example.com/api',
150
+ name: undefined,
151
+ })
152
+ })
153
+ })
154
+
155
+ describe('instance target helpers', () => {
156
+ test('recognizes direct and bookmarked admin config names', () => {
157
+ expect(couldBeConfiguredAdminInstance('admin', directAdminConfig)).toBe(true)
158
+ expect(
159
+ couldBeConfiguredAdminInstance('ops-admin', {
160
+ ...DEFAULT_CONFIG,
161
+ admin: { instance: 'ops-admin' },
162
+ }),
163
+ ).toBe(true)
164
+ expect(couldBeConfiguredAdminInstance('bryan', directAdminConfig)).toBe(false)
165
+ })
166
+
167
+ test('treats wrapped remote Instance.info misses as managed lookup not-found', () => {
168
+ const wrapped = new Error('Path not found: "/admin/instances/admin"') as Error & {
169
+ data?: unknown
170
+ }
171
+ wrapped.name = 'KernelError'
172
+ wrapped.data = { type: 'NotFoundError' }
173
+
174
+ expect(isManagedInstanceNotFound(wrapped)).toBe(true)
175
+ })
176
+
177
+ test('does not hide non-not-found managed lookup failures', () => {
178
+ const auth = new Error('permission denied')
179
+ auth.name = 'PermissionDeniedError'
180
+
181
+ expect(isManagedInstanceNotFound(auth)).toBe(false)
182
+ })
183
+ })