@astrale-os/cli 0.8.1-alpha.6 → 0.8.1-alpha.7

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 (44) hide show
  1. package/README.md +3 -2
  2. package/dist/astrale.js +2599 -2703
  3. package/dist/public/connect-core.js +2019 -3050
  4. package/dist/public/keys/index.js +1851 -2885
  5. package/dist/public/paths/index.js +1830 -2872
  6. package/dist/types/connection/auth.d.ts +3 -0
  7. package/dist/types/lib/instance.d.ts +10 -0
  8. package/package.json +8 -8
  9. package/src/commands/__tests__/domain-uninstall.test.ts +53 -0
  10. package/src/commands/__tests__/install-identity-override.test.ts +14 -3
  11. package/src/commands/__tests__/instance-bookmark.test.ts +66 -1
  12. package/src/commands/__tests__/instance-list-rows.test.ts +1 -0
  13. package/src/commands/__tests__/instance-use.test.ts +67 -0
  14. package/src/commands/__tests__/view-build.test.ts +58 -0
  15. package/src/commands/domain/install.ts +6 -5
  16. package/src/commands/domain/uninstall.ts +128 -0
  17. package/src/commands/instance/active.ts +13 -1
  18. package/src/commands/instance/bookmark.ts +26 -3
  19. package/src/commands/instance/list.ts +18 -4
  20. package/src/commands/instance/use.ts +54 -7
  21. package/src/commands/view.ts +28 -16
  22. package/src/connection/.spec/architecture.md +5 -0
  23. package/src/connection/.spec/laws/connection.ts +20 -0
  24. package/src/connection/.spec/layout.ts +1 -0
  25. package/src/connection/__tests__/auth.test.ts +27 -1
  26. package/src/connection/__tests__/ca-fetch.test.ts +8 -1
  27. package/src/connection/__tests__/errors.test.ts +410 -36
  28. package/src/connection/__tests__/exchange.test.ts +46 -5
  29. package/src/connection/__tests__/reasons.test.ts +78 -0
  30. package/src/connection/auth.ts +11 -9
  31. package/src/connection/command.ts +1 -1
  32. package/src/connection/errors.ts +139 -160
  33. package/src/connection/exchange.ts +14 -2
  34. package/src/connection/reasons.ts +179 -0
  35. package/src/lib/__tests__/instance.test.ts +51 -1
  36. package/src/lib/__tests__/view-assets.test.ts +33 -1
  37. package/src/lib/__tests__/view-server.test.ts +68 -0
  38. package/src/lib/ca-fetch.ts +9 -3
  39. package/src/lib/instance.ts +31 -0
  40. package/src/lib/view/assets.ts +16 -2
  41. package/src/program/__tests__/program.test.ts +2 -1
  42. package/src/program/build.ts +2 -1
  43. package/studio/package.json +7 -8
  44. package/viewer/dist/main.js +57 -57
@@ -1,47 +1,42 @@
1
- import { ResponseError } from '@astrale-os/kernel-client'
1
+ import { ResponseError, TransportError } from '@astrale-os/kernel-client'
2
2
  import { describe, expect, test } from 'bun:test'
3
3
 
4
- import { formatKernelError, stripMethodSuffix } from '../errors'
4
+ import { formatKernelError, functionInputIssues, schemaUpgradeHint } from '../errors'
5
5
 
6
6
  const CONFLICT = 4001
7
+ const VALIDATION = 1003
7
8
 
8
- describe('stripMethodSuffix', () => {
9
- test('strips ::listChildren from path', () => {
10
- expect(stripMethodSuffix('Path not found: "/nonexistent::listChildren"')).toBe(
11
- 'Path not found: "/nonexistent"',
12
- )
13
- })
14
-
15
- test('strips ::get from path', () => {
16
- expect(stripMethodSuffix('Path not found: "/some/path::get"')).toBe(
17
- 'Path not found: "/some/path"',
18
- )
19
- })
20
-
21
- test('strips any method name', () => {
22
- expect(stripMethodSuffix('"/kernel.astrale.ai/Root::describe"')).toBe(
23
- '"/kernel.astrale.ai/Root"',
24
- )
25
- })
26
-
27
- test('does not strip from non-path strings', () => {
28
- expect(stripMethodSuffix('regular::text')).toBe('regular::text')
29
- })
30
-
31
- test('handles multiple paths in one message', () => {
32
- expect(stripMethodSuffix('"/a/b::get" and "/c/d::list"')).toBe('"/a/b" and "/c/d"')
33
- })
34
-
35
- test('preserves messages without method suffixes', () => {
36
- expect(stripMethodSuffix('No path found')).toBe('No path found')
37
- })
9
+ describe('formatKernelError', () => {
10
+ /** @evidence TEST-CLI-CONNECTION-MAPS-TYPED-TRANSPORT */
11
+ test('maps typed discovery transport evidence without inspecting its cause message', async () => {
12
+ const writes: string[] = []
13
+ const original = process.stderr.write
14
+ process.stderr.write = ((chunk: string | Uint8Array) => {
15
+ writes.push(typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk))
16
+ return true
17
+ }) as typeof process.stderr.write
18
+ const error = new TransportError('Publication discovery request failed.', {
19
+ cause: Object.assign(new Error('connect ECONNREFUSED'), { code: 'ECONNREFUSED' }),
20
+ phase: 'connect',
21
+ delivery: 'unknown',
22
+ }) as TransportError & { url?: string }
23
+ error.url = 'https://localhost:8443/kernel/host'
24
+ try {
25
+ await formatKernelError(error, true)
26
+ } finally {
27
+ process.stderr.write = original
28
+ }
38
29
 
39
- test('does not strip single colon (not a method dispatch)', () => {
40
- expect(stripMethodSuffix('"/a/b:notMethod"')).toBe('"/a/b:notMethod"')
30
+ expect(JSON.parse(writes[0]!)).toMatchObject({
31
+ error: 'CONNECTION_ERROR',
32
+ message: 'Publication discovery request failed.',
33
+ url: 'https://localhost:8443/kernel/host',
34
+ phase: 'connect',
35
+ delivery: 'unknown',
36
+ })
37
+ expect(writes[0]).not.toContain('ECONNREFUSED')
41
38
  })
42
- })
43
39
 
44
- describe('formatKernelError', () => {
45
40
  /** @evidence TEST-CLI-CONNECTION-PRESERVES-PUBLIC-SEMANTIC-REASON */
46
41
  test('preserves a Kernel-admitted semantic reason in machine output', async () => {
47
42
  const writes: string[] = []
@@ -94,6 +89,102 @@ describe('formatKernelError', () => {
94
89
  })
95
90
  })
96
91
 
92
+ test('explains deletion-only schema work without changing the Kernel reason', async () => {
93
+ const writes: string[] = []
94
+ const original = process.stderr.write
95
+ process.stderr.write = ((chunk: string | Uint8Array) => {
96
+ writes.push(typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk))
97
+ return true
98
+ }) as typeof process.stderr.write
99
+ try {
100
+ await formatKernelError(
101
+ new ResponseError(CONFLICT, 'Schema change requires migration.', {
102
+ code: 'DATA_MIGRATION_REQUIRED',
103
+ details: {
104
+ requirements: [
105
+ {
106
+ operation: 'remove-facts',
107
+ reason: 'destructive-change',
108
+ subject: {
109
+ kind: 'definition',
110
+ ref: { origin: 'x.test', kind: 'class', name: 'X' },
111
+ },
112
+ work: { observedFacts: 1 },
113
+ },
114
+ ],
115
+ },
116
+ }),
117
+ true,
118
+ )
119
+ } finally {
120
+ process.stderr.write = original
121
+ }
122
+
123
+ expect(JSON.parse(writes[0]!)).toEqual({
124
+ error: 'RESPONSE_ERROR',
125
+ code: CONFLICT,
126
+ message: 'Schema change requires migration.',
127
+ reason: {
128
+ code: 'DATA_MIGRATION_REQUIRED',
129
+ details: {
130
+ requirements: [
131
+ {
132
+ operation: 'remove-facts',
133
+ reason: 'destructive-change',
134
+ subject: {
135
+ kind: 'definition',
136
+ ref: { origin: 'x.test', kind: 'class', name: 'X' },
137
+ },
138
+ work: { observedFacts: 1 },
139
+ },
140
+ ],
141
+ },
142
+ },
143
+ hint: 'Delete this data explicitly, then retry. No data was deleted.',
144
+ })
145
+ })
146
+
147
+ test('prints an actionable message for deletion-only schema work', async () => {
148
+ const errors: string[] = []
149
+ const hints: string[] = []
150
+ const originalError = console.error
151
+ const originalLog = console.log
152
+ console.error = (...values: unknown[]) => errors.push(values.map(String).join(' '))
153
+ console.log = (...values: unknown[]) => hints.push(values.map(String).join(' '))
154
+ try {
155
+ await formatKernelError(
156
+ new ResponseError(CONFLICT, 'Schema change requires migration.', {
157
+ code: 'DATA_MIGRATION_REQUIRED',
158
+ details: {
159
+ requirements: [
160
+ {
161
+ operation: 'remove-facts',
162
+ reason: 'destructive-change',
163
+ subject: {
164
+ kind: 'definition',
165
+ ref: { origin: 'x.test', kind: 'class', name: 'X' },
166
+ },
167
+ work: { observedFacts: 1 },
168
+ },
169
+ ],
170
+ },
171
+ }),
172
+ false,
173
+ )
174
+ } finally {
175
+ console.error = originalError
176
+ console.log = originalLog
177
+ }
178
+
179
+ expect(errors.join('\n')).toContain('DATA_MIGRATION_REQUIRED')
180
+ expect(errors.join('\n')).toContain(
181
+ 'Existing business data still uses schema definitions being removed.',
182
+ )
183
+ expect(hints.join('\n')).toContain(
184
+ 'Delete this data explicitly, then retry. No data was deleted.',
185
+ )
186
+ })
187
+
97
188
  test('maps SDK class names to stable CLI error codes', async () => {
98
189
  const writes: string[] = []
99
190
  const original = process.stderr.write
@@ -113,4 +204,287 @@ describe('formatKernelError', () => {
113
204
  message: 'Path must have an Id or Domain anchor.',
114
205
  })
115
206
  })
207
+
208
+ /** @evidence TEST-CLI-CONNECTION-PRESENTS-BOUNDED-REPAIRS */
209
+ test('renders callable and Query repair coordinates for humans', async () => {
210
+ const lines: string[] = []
211
+ const originalError = console.error
212
+ const originalLog = console.log
213
+ console.error = (...values: unknown[]) => lines.push(values.join(' '))
214
+ console.log = (...values: unknown[]) => lines.push(values.join(' '))
215
+ try {
216
+ await formatKernelError(
217
+ new ResponseError(VALIDATION, 'Function input is invalid.', {
218
+ code: 'FUNCTION_INPUT_INVALID',
219
+ details: {
220
+ issues: [
221
+ {
222
+ code: 'VALUE_SCHEMA_INSTANCE_INVALID',
223
+ path: '/issuer',
224
+ message: 'Object is missing required property issuer.',
225
+ },
226
+ {
227
+ code: 'VALUE_SCHEMA_INSTANCE_INVALID',
228
+ path: '/operationId',
229
+ message: 'Object is missing required property operationId.',
230
+ },
231
+ {
232
+ code: 'VALUE_SCHEMA_INSTANCE_INVALID',
233
+ path: '/slug',
234
+ message: 'Object is missing required property slug.',
235
+ },
236
+ ],
237
+ },
238
+ }),
239
+ false,
240
+ )
241
+ await formatKernelError(
242
+ new ResponseError(VALIDATION, 'Query input is invalid.', {
243
+ code: 'QUERY_INPUT_INVALID',
244
+ details: {
245
+ phase: 'plan',
246
+ issue: 'QUERY_DEFINITION_NOT_EDGE',
247
+ path: '/steps/0/via/0',
248
+ },
249
+ }),
250
+ false,
251
+ )
252
+ } finally {
253
+ console.error = originalError
254
+ console.log = originalLog
255
+ }
256
+
257
+ const rendered = lines.join('\n')
258
+ expect(rendered).toContain('/issuer: Object is missing required property issuer.')
259
+ expect(rendered).toContain('/operationId: Object is missing required property operationId.')
260
+ expect(rendered).toContain('/slug: Object is missing required property slug.')
261
+ expect(rendered).toContain('/steps/0/via/0 QUERY_DEFINITION_NOT_EDGE')
262
+ })
263
+
264
+ test('explains immutable issuer replacements and preserves the Kernel reason', async () => {
265
+ const writes: string[] = []
266
+ const original = process.stderr.write
267
+ process.stderr.write = ((chunk: string | Uint8Array) => {
268
+ writes.push(typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk))
269
+ return true
270
+ }) as typeof process.stderr.write
271
+ try {
272
+ await formatKernelError(
273
+ new ResponseError(5001, 'Schema operation is not supported.', {
274
+ code: 'SCHEMA_UPGRADE_INCOMPATIBLE',
275
+ details: {
276
+ phase: 'upgrade',
277
+ origin: 'grc.example',
278
+ expected: 'https://old.example',
279
+ actual: 'https://new.example',
280
+ },
281
+ }),
282
+ true,
283
+ )
284
+ } finally {
285
+ process.stderr.write = original
286
+ }
287
+
288
+ expect(JSON.parse(writes[0]!)).toEqual({
289
+ error: 'RESPONSE_ERROR',
290
+ code: 5001,
291
+ message: 'Schema operation is not supported.',
292
+ reason: {
293
+ code: 'SCHEMA_UPGRADE_INCOMPATIBLE',
294
+ details: {
295
+ phase: 'upgrade',
296
+ origin: 'grc.example',
297
+ expected: 'https://old.example',
298
+ actual: 'https://new.example',
299
+ },
300
+ },
301
+ hint: schemaUpgradeHint({
302
+ origin: 'grc.example',
303
+ expected: 'https://old.example',
304
+ actual: 'https://new.example',
305
+ }),
306
+ })
307
+ })
308
+
309
+ test('explains a private Domain source without exposing transport diagnostics', async () => {
310
+ const writes: string[] = []
311
+ const original = process.stderr.write
312
+ process.stderr.write = ((chunk: string | Uint8Array) => {
313
+ writes.push(typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk))
314
+ return true
315
+ }) as typeof process.stderr.write
316
+ try {
317
+ await formatKernelError(
318
+ new ResponseError(1003, 'Function input is invalid.', {
319
+ code: 'SCHEMA_DOMAIN_ADDRESS_NOT_PUBLIC',
320
+ details: { phase: 'publication', requiredScope: 'public' },
321
+ }),
322
+ true,
323
+ )
324
+ } finally {
325
+ process.stderr.write = original
326
+ }
327
+
328
+ expect(JSON.parse(writes[0]!)).toEqual({
329
+ error: 'RESPONSE_ERROR',
330
+ code: 1003,
331
+ message: 'Expose the Domain through a public HTTPS URL or public tunnel, then retry.',
332
+ reason: {
333
+ code: 'SCHEMA_DOMAIN_ADDRESS_NOT_PUBLIC',
334
+ details: { phase: 'publication', requiredScope: 'public' },
335
+ },
336
+ })
337
+ })
338
+
339
+ test('prints the actionable private-source message in interactive output', async () => {
340
+ const errors: string[] = []
341
+ const hints: string[] = []
342
+ const originalError = console.error
343
+ const originalLog = console.log
344
+ console.error = (...values: unknown[]) => errors.push(values.map(String).join(' '))
345
+ console.log = (...values: unknown[]) => hints.push(values.map(String).join(' '))
346
+ try {
347
+ await formatKernelError(
348
+ new ResponseError(1003, 'Function input is invalid.', {
349
+ code: 'SCHEMA_DOMAIN_ADDRESS_NOT_PUBLIC',
350
+ details: { phase: 'publication', requiredScope: 'public' },
351
+ }),
352
+ false,
353
+ )
354
+ } finally {
355
+ console.error = originalError
356
+ console.log = originalLog
357
+ }
358
+
359
+ expect(errors.join('\n')).toContain('SCHEMA_DOMAIN_ADDRESS_NOT_PUBLIC')
360
+ expect(errors.join('\n')).toContain(
361
+ 'Expose the Domain through a public HTTPS URL or public tunnel, then retry.',
362
+ )
363
+ expect(hints).toEqual([])
364
+ })
365
+
366
+ test('preserves structured Function input issues in machine output', async () => {
367
+ const writes: string[] = []
368
+ const original = process.stderr.write
369
+ process.stderr.write = ((chunk: string | Uint8Array) => {
370
+ writes.push(typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk))
371
+ return true
372
+ }) as typeof process.stderr.write
373
+ try {
374
+ await formatKernelError(
375
+ new ResponseError(1003, 'Function input is invalid.', {
376
+ code: 'FUNCTION_INPUT_INVALID',
377
+ details: {
378
+ issues: [
379
+ {
380
+ code: 'INVALID_FORMAT',
381
+ path: '/customer/email',
382
+ message: 'Must be a valid email address.',
383
+ },
384
+ ],
385
+ },
386
+ }),
387
+ true,
388
+ )
389
+ } finally {
390
+ process.stderr.write = original
391
+ }
392
+
393
+ expect(JSON.parse(writes[0]!)).toEqual({
394
+ error: 'RESPONSE_ERROR',
395
+ code: 1003,
396
+ message: 'Function input is invalid.',
397
+ reason: {
398
+ code: 'FUNCTION_INPUT_INVALID',
399
+ details: {
400
+ issues: [
401
+ {
402
+ code: 'INVALID_FORMAT',
403
+ path: '/customer/email',
404
+ message: 'Must be a valid email address.',
405
+ },
406
+ ],
407
+ },
408
+ },
409
+ hint: 'Use `astrale introspect <path>` to see the callable input.',
410
+ })
411
+ })
412
+
413
+ test('prints Kernel-provided Function input messages in interactive output', async () => {
414
+ const errors: string[] = []
415
+ const details: string[] = []
416
+ const originalError = console.error
417
+ const originalLog = console.log
418
+ console.error = (...values: unknown[]) => errors.push(values.map(String).join(' '))
419
+ console.log = (...values: unknown[]) => details.push(values.map(String).join(' '))
420
+ try {
421
+ await formatKernelError(
422
+ new ResponseError(1003, 'Function input is invalid.', {
423
+ code: 'FUNCTION_INPUT_INVALID',
424
+ details: {
425
+ issues: [
426
+ {
427
+ code: 'INVALID_FORMAT',
428
+ path: '/customer/email',
429
+ message: 'Must be a valid email address.',
430
+ },
431
+ ],
432
+ },
433
+ }),
434
+ false,
435
+ )
436
+ } finally {
437
+ console.error = originalError
438
+ console.log = originalLog
439
+ }
440
+
441
+ expect(errors.join('\n')).toContain('Function input is invalid.')
442
+ expect(details.join('\n')).toContain(
443
+ '/customer/email: Must be a valid email address. (INVALID_FORMAT)',
444
+ )
445
+ expect(details.join('\n')).not.toContain('astrale introspect')
446
+ })
447
+
448
+ test('keeps the introspection fallback for legacy Function input issues', async () => {
449
+ const writes: string[] = []
450
+ const original = process.stderr.write
451
+ process.stderr.write = ((chunk: string | Uint8Array) => {
452
+ writes.push(typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk))
453
+ return true
454
+ }) as typeof process.stderr.write
455
+ try {
456
+ await formatKernelError(
457
+ new ResponseError(1003, 'Function input is invalid.', {
458
+ code: 'FUNCTION_INPUT_INVALID',
459
+ details: { issues: [{ code: 'INVALID_FORMAT', path: '/customer/email' }] },
460
+ }),
461
+ true,
462
+ )
463
+ } finally {
464
+ process.stderr.write = original
465
+ }
466
+
467
+ expect(JSON.parse(writes[0]!)).toMatchObject({
468
+ reason: {
469
+ code: 'FUNCTION_INPUT_INVALID',
470
+ details: { issues: [{ code: 'INVALID_FORMAT', path: '/customer/email' }] },
471
+ },
472
+ hint: 'Use `astrale introspect <path>` to see the callable input.',
473
+ })
474
+ })
475
+
476
+ test('rejects malformed public Function issue messages', () => {
477
+ expect(
478
+ functionInputIssues({
479
+ code: 'FUNCTION_INPUT_INVALID',
480
+ details: {
481
+ issues: [
482
+ { code: 'INVALID_FORMAT', path: '/safe', message: 'Safe message.' },
483
+ { code: 'INVALID_FORMAT', path: 'not-a-pointer', message: 'Unsafe path.' },
484
+ { code: 'INVALID_FORMAT', path: '/unsafe', message: 'Unsafe\nmessage.' },
485
+ ],
486
+ },
487
+ }),
488
+ ).toEqual([{ code: 'INVALID_FORMAT', path: '/safe', message: 'Safe message.' }])
489
+ })
116
490
  })
@@ -158,9 +158,44 @@ describe('Domain token exchange', () => {
158
158
  code: 'TOKEN_EXCHANGE_PROTOCOL_ERROR',
159
159
  })
160
160
  })
161
+
162
+ test('rejects success responses without no-store or with malformed fields', async () => {
163
+ const exchanged = token(DOMAIN, KERNEL, 'user-1', EXPIRES_AT)
164
+ const cache = () => new ExchangeCredentialCache(join(directory, crypto.randomUUID()))
165
+ const resolver = (fetch: Fetch) =>
166
+ createExchangeCredentialResolver(
167
+ TARGET,
168
+ { resolve: async () => SOURCE_TOKEN },
169
+ fetch,
170
+ 5_000,
171
+ cache(),
172
+ )
173
+
174
+ await expect(
175
+ resolver(exchangeFetch(exchanged, { cacheControl: false })).resolve(
176
+ KERNEL,
177
+ new AbortController().signal,
178
+ ),
179
+ ).rejects.toMatchObject({
180
+ code: 'TOKEN_EXCHANGE_PROTOCOL_ERROR',
181
+ message: 'Token exchange response is missing Cache-Control: no-store.',
182
+ })
183
+ await expect(
184
+ resolver(exchangeFetch(exchanged, { body: { token: 7, expiresAt: 'soon' } })).resolve(
185
+ KERNEL,
186
+ new AbortController().signal,
187
+ ),
188
+ ).rejects.toMatchObject({
189
+ code: 'TOKEN_EXCHANGE_PROTOCOL_ERROR',
190
+ message: 'Token exchange returned an invalid success response.',
191
+ })
192
+ })
161
193
  })
162
194
 
163
- function exchangeFetch(exchanged: string): Fetch {
195
+ function exchangeFetch(
196
+ exchanged: string,
197
+ options: { readonly body?: unknown; readonly cacheControl?: boolean } = {},
198
+ ): Fetch {
164
199
  return async (input, init) => {
165
200
  const url = String(input)
166
201
  if (url === INVOCATION) {
@@ -174,11 +209,17 @@ function exchangeFetch(exchanged: string): Fetch {
174
209
  )
175
210
  }
176
211
  if (url.endsWith('/.well-known/openid-configuration')) return jsonResponse(configuration(true))
177
- return jsonResponse(
178
- { token: exchanged, expiresAt: EXPIRES_AT },
179
- 200,
180
- 'application/vnd.astrale+json',
212
+ const response = new Response(
213
+ JSON.stringify(options.body ?? { token: exchanged, expiresAt: EXPIRES_AT }),
214
+ {
215
+ status: 200,
216
+ headers: {
217
+ 'content-type': 'application/vnd.astrale+json',
218
+ ...(options.cacheControl === false ? {} : { 'cache-control': 'no-store' }),
219
+ },
220
+ },
181
221
  )
222
+ return response
182
223
  }
183
224
  }
184
225
 
@@ -0,0 +1,78 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+
3
+ import { functionInputIssues, queryInputRepair } from '../reasons'
4
+
5
+ describe('public reason presentation admission', () => {
6
+ /** @evidence TEST-CLI-CONNECTION-ADMITS-BOUNDED-REASONS */
7
+ test('admits bounded public callable issues and filters unsafe additions', () => {
8
+ expect(
9
+ functionInputIssues({
10
+ code: 'FUNCTION_INPUT_INVALID',
11
+ details: {
12
+ issues: [
13
+ {
14
+ code: 'VALUE_SCHEMA_INSTANCE_INVALID',
15
+ path: '/issuer',
16
+ message: 'Object is missing required property issuer.',
17
+ },
18
+ {
19
+ code: 'VALUE_SCHEMA_INSTANCE_INVALID',
20
+ path: '/slug',
21
+ message: 'Object is missing required property slug.',
22
+ },
23
+ {
24
+ code: 'VALUE_SCHEMA_INSTANCE_INVALID',
25
+ path: '/unsafe',
26
+ message: 'private\ndiagnostic',
27
+ },
28
+ ],
29
+ },
30
+ }),
31
+ ).toEqual([
32
+ {
33
+ code: 'VALUE_SCHEMA_INSTANCE_INVALID',
34
+ path: '/issuer',
35
+ message: 'Object is missing required property issuer.',
36
+ },
37
+ {
38
+ code: 'VALUE_SCHEMA_INSTANCE_INVALID',
39
+ path: '/slug',
40
+ message: 'Object is missing required property slug.',
41
+ },
42
+ ])
43
+ })
44
+
45
+ test('admits exact Query repair variants and rejects unbounded additions', () => {
46
+ expect(
47
+ queryInputRepair({
48
+ code: 'QUERY_INPUT_INVALID',
49
+ details: {
50
+ phase: 'plan',
51
+ issue: 'QUERY_DEFINITION_NOT_EDGE',
52
+ path: '/steps/0/via/0',
53
+ },
54
+ }),
55
+ ).toEqual({
56
+ phase: 'plan',
57
+ issue: 'QUERY_DEFINITION_NOT_EDGE',
58
+ path: '/steps/0/via/0',
59
+ })
60
+ expect(
61
+ queryInputRepair({
62
+ code: 'QUERY_INPUT_INVALID',
63
+ details: { phase: 'limit', limit: 'steps', maximum: 4, actual: 5 },
64
+ }),
65
+ ).toEqual({ phase: 'limit', limit: 'steps', maximum: 4, actual: 5 })
66
+ expect(
67
+ queryInputRepair({
68
+ code: 'QUERY_INPUT_INVALID',
69
+ details: {
70
+ phase: 'plan',
71
+ issue: 'QUERY_DEFINITION_NOT_EDGE',
72
+ path: '/steps/0/via/0',
73
+ provider: 'private',
74
+ },
75
+ }),
76
+ ).toBeUndefined()
77
+ })
78
+ })
@@ -162,7 +162,7 @@ async function resolveIdpAccessToken(
162
162
  )
163
163
  }
164
164
  if (e instanceof IdpSessionNoRefreshTokenError) {
165
- throw new Error(wrongAudienceHint(identityName, identity, audience))
165
+ throw classifyNoRefreshTokenError(audience, identity.audience, e)
166
166
  }
167
167
  // An audience mismatch means the session is healthy but the IdP won't
168
168
  // mint this audience — re-login is futile, so propagate it verbatim for
@@ -182,6 +182,16 @@ async function resolveIdpAccessToken(
182
182
  return token
183
183
  }
184
184
 
185
+ export function classifyNoRefreshTokenError(
186
+ requestedAudience: string,
187
+ sourceAudience: string | undefined,
188
+ error: IdpSessionNoRefreshTokenError,
189
+ ): IdpSessionNoRefreshTokenError | IdpAudienceMismatchError {
190
+ return sourceAudience !== undefined && sourceAudience !== requestedAudience
191
+ ? new IdpAudienceMismatchError(requestedAudience, sourceAudience)
192
+ : error
193
+ }
194
+
185
195
  /** A refresh attempt failed for a reason that re-login will NOT fix. */
186
196
  export class IdpRefreshTransientError extends Error {
187
197
  constructor(message: string) {
@@ -224,11 +234,3 @@ function refreshFailureError(identityName: string, identity: Identity, cause: un
224
234
  )
225
235
  }
226
236
  }
227
-
228
- function wrongAudienceHint(identityName: string, identity: Identity, audience: string): string {
229
- return (
230
- `IdP token for "${identityName}" was not minted for target audience ${audience}, ` +
231
- 'and the cached session cannot be refreshed. ' +
232
- `Run: astrale auth login --name ${identityName} --idp ${identity.idp ?? '<idp>'} --audience ${audience}`
233
- )
234
- }
@@ -53,7 +53,7 @@ export async function runKernelCommand<T>(input: {
53
53
  }
54
54
  } catch (error) {
55
55
  if (!isRaw && spin) spin.fail(`${label} failed`)
56
- await formatKernelError(error, isRaw, undefined, opts.debug, { credential: opts.creds })
56
+ await formatKernelError(error, isRaw, undefined, opts.debug)
57
57
  process.exit(1)
58
58
  }
59
59
  }