@astrale-os/cli 0.8.1-alpha.6 → 1.0.0-beta.0

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 (98) hide show
  1. package/README.md +18 -5
  2. package/dist/astrale.js +2661 -2714
  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 +9 -9
  9. package/src/commands/__tests__/domain-install-operation.test.ts +121 -0
  10. package/src/commands/__tests__/domain-install-owned.test.ts +66 -0
  11. package/src/commands/__tests__/domain-uninstall.test.ts +53 -0
  12. package/src/commands/__tests__/install-direct.test.ts +3 -2
  13. package/src/commands/__tests__/install-identity-override.test.ts +14 -3
  14. package/src/commands/__tests__/instance-bookmark.test.ts +66 -1
  15. package/src/commands/__tests__/instance-list-rows.test.ts +1 -0
  16. package/src/commands/__tests__/instance-use.test.ts +67 -0
  17. package/src/commands/__tests__/view-build.test.ts +58 -0
  18. package/src/commands/domain/install.ts +84 -20
  19. package/src/commands/domain/uninstall.ts +128 -0
  20. package/src/commands/instance/active.ts +13 -1
  21. package/src/commands/instance/bookmark.ts +26 -3
  22. package/src/commands/instance/list.ts +18 -4
  23. package/src/commands/instance/use.ts +54 -7
  24. package/src/commands/view.ts +28 -16
  25. package/src/connection/.spec/architecture.md +5 -0
  26. package/src/connection/.spec/laws/connection.ts +20 -0
  27. package/src/connection/.spec/layout.ts +1 -0
  28. package/src/connection/__tests__/auth.test.ts +27 -1
  29. package/src/connection/__tests__/ca-fetch.test.ts +8 -1
  30. package/src/connection/__tests__/errors.test.ts +483 -33
  31. package/src/connection/__tests__/exchange.test.ts +46 -5
  32. package/src/connection/__tests__/reasons.test.ts +78 -0
  33. package/src/connection/auth.ts +11 -9
  34. package/src/connection/command.ts +9 -1
  35. package/src/connection/errors.ts +149 -159
  36. package/src/connection/exchange.ts +14 -2
  37. package/src/connection/index.ts +1 -1
  38. package/src/connection/reasons.ts +193 -0
  39. package/src/lib/__tests__/instance.test.ts +51 -1
  40. package/src/lib/__tests__/view-assets.test.ts +33 -1
  41. package/src/lib/__tests__/view-server.test.ts +68 -0
  42. package/src/lib/ca-fetch.ts +9 -3
  43. package/src/lib/instance.ts +31 -0
  44. package/src/lib/view/assets.ts +16 -2
  45. package/src/program/__tests__/program.test.ts +2 -1
  46. package/src/program/build.ts +2 -1
  47. package/studio/client/dist/assets/{elk-api-D0cBetPW.js → elk-api-D2xgMJvi.js} +1 -1
  48. package/studio/client/dist/assets/{index-BQnJ5sgd.css → index-BMdnsIJA.css} +1 -1
  49. package/studio/client/dist/assets/index-D-vRV8w7.js +8 -0
  50. package/studio/client/dist/assets/index-LGSWRrk8.js +81 -0
  51. package/studio/client/dist/index.html +2 -2
  52. package/studio/package.json +8 -9
  53. package/studio/server/agent/prompts/anchors.test.ts +74 -0
  54. package/studio/server/agent/prompts/anchors.ts +85 -15
  55. package/studio/server/agent/prompts/system.test.ts +12 -0
  56. package/studio/server/agent/prompts/system.ts +6 -6
  57. package/studio/server/api.ts +1 -5
  58. package/studio/server/cache.ts +5 -2
  59. package/studio/server/domain.test.ts +67 -0
  60. package/studio/server/domain.ts +29 -6
  61. package/studio/server/index.ts +1 -3
  62. package/studio/server/introspect/anatomy-extras.test.ts +104 -1
  63. package/studio/server/introspect/anatomy-extras.ts +340 -8
  64. package/studio/server/introspect/anatomy.test.ts +33 -0
  65. package/studio/server/introspect/anatomy.ts +22 -7
  66. package/studio/server/introspect/bundle.ts +7 -1
  67. package/studio/server/introspect/canonical-schema.test.ts +395 -0
  68. package/studio/server/introspect/canonical-schema.ts +751 -0
  69. package/studio/server/introspect/core-extractor.ts +30 -10
  70. package/studio/server/introspect/core.ts +4 -2
  71. package/studio/server/introspect/diff.test.ts +124 -0
  72. package/studio/server/introspect/diff.ts +252 -23
  73. package/studio/server/introspect/extractor.ts +46 -14
  74. package/studio/server/introspect/overlay-tsmorph.test.ts +164 -1
  75. package/studio/server/introspect/overlay-tsmorph.ts +381 -106
  76. package/studio/server/introspect/overlay.test.ts +72 -0
  77. package/studio/server/introspect/overlay.ts +16 -6
  78. package/studio/server/introspect/runtime.test.ts +217 -0
  79. package/studio/server/introspect/runtime.ts +18 -6
  80. package/studio/server/introspect/schema-refs.test.ts +100 -0
  81. package/studio/server/introspect/schema-refs.ts +23 -2
  82. package/studio/server/state/baseline.test.ts +51 -0
  83. package/studio/server/state/baseline.ts +31 -2
  84. package/studio/server/state/create.test.ts +37 -0
  85. package/studio/server/state/create.ts +21 -15
  86. package/studio/server/state/instance.test.ts +63 -0
  87. package/studio/server/state/instance.ts +65 -29
  88. package/studio/server/state/views.test.ts +209 -9
  89. package/studio/server/state/views.ts +176 -69
  90. package/studio/server/watch.test.ts +36 -0
  91. package/studio/server/watch.ts +24 -16
  92. package/studio/server/workspace-watch.ts +8 -3
  93. package/studio/shared/types.ts +164 -33
  94. package/viewer/dist/main.js +57 -57
  95. package/studio/client/dist/assets/index-Dspir4w7.js +0 -81
  96. package/studio/client/dist/assets/index-bVD2KJgz.js +0 -8
  97. package/studio/server/view-dev-server.test.ts +0 -111
  98. package/studio/server/view-dev-server.ts +0 -372
@@ -10,6 +10,7 @@ export default defineLayout({
10
10
  'errors.ts',
11
11
  'exchange.ts',
12
12
  'index.ts',
13
+ 'reasons.ts',
13
14
  'self.ts',
14
15
  'session.ts',
15
16
  'target.ts',
@@ -3,7 +3,9 @@ import { describe, expect, test } from 'bun:test'
3
3
  import type { Identity } from '../../identity/index'
4
4
  import type { AstraleConfig } from '../../lib/config'
5
5
 
6
- import { resolveKeyIdentityAuthOptions } from '../auth'
6
+ import { IdpAudienceMismatchError } from '../../lib/idp'
7
+ import { IdpSessionNoRefreshTokenError } from '../../lib/idp-session'
8
+ import { classifyNoRefreshTokenError, resolveKeyIdentityAuthOptions } from '../auth'
7
9
 
8
10
  const config: AstraleConfig = {
9
11
  issuer: 'https://unregistered.invalid',
@@ -100,3 +102,27 @@ describe('resolveKeyIdentityAuthOptions', () => {
100
102
  })
101
103
  })
102
104
  })
105
+
106
+ describe('classifyNoRefreshTokenError', () => {
107
+ test('preserves expiry when the non-refreshable session already targets the required audience', () => {
108
+ const expired = new IdpSessionNoRefreshTokenError('alice')
109
+
110
+ expect(
111
+ classifyNoRefreshTokenError('https://kernel.example', 'https://kernel.example', expired),
112
+ ).toBe(expired)
113
+ })
114
+
115
+ test('reports a real audience mismatch when the source audience differs', () => {
116
+ const result = classifyNoRefreshTokenError(
117
+ 'https://child.example',
118
+ 'https://manager.example',
119
+ new IdpSessionNoRefreshTokenError('alice'),
120
+ )
121
+
122
+ expect(result).toBeInstanceOf(IdpAudienceMismatchError)
123
+ expect(result).toMatchObject({
124
+ requested: 'https://child.example',
125
+ actual: 'https://manager.example',
126
+ })
127
+ })
128
+ })
@@ -29,7 +29,10 @@ describe('connection CA fetch', () => {
29
29
  hostname: '127.0.0.1',
30
30
  port: 0,
31
31
  tls: { cert: certificate, key },
32
- fetch: () => new Response('trusted'),
32
+ fetch: (request) =>
33
+ new URL(request.url).pathname === '/cached'
34
+ ? new Response(null, { status: 304, headers: { etag: 'retained' } })
35
+ : new Response('trusted'),
33
36
  })
34
37
 
35
38
  const requests: Array<{ input: RequestInfo | URL; init?: RequestInit }> = []
@@ -45,6 +48,10 @@ describe('connection CA fetch', () => {
45
48
  expect(await httpsResponse.text()).toBe('trusted')
46
49
  expect(httpsResponse.url).toBe(httpsUrl)
47
50
  expect(httpsResponse.redirected).toBe(false)
51
+ const cachedResponse = await scoped(`https://127.0.0.1:${server.port}/cached`)
52
+ expect(cachedResponse.status).toBe(304)
53
+ expect(cachedResponse.body).toBeNull()
54
+ expect(cachedResponse.headers.get('etag')).toBe('retained')
48
55
  await expect(
49
56
  scoped('http://localhost:8080/invoke', init).then((value) => value.text()),
50
57
  ).resolves.toBe('fallback')
@@ -1,47 +1,82 @@
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
- })
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
+ }
30
29
 
31
- test('handles multiple paths in one message', () => {
32
- expect(stripMethodSuffix('"/a/b::get" and "/c/d::list"')).toBe('"/a/b" and "/c/d"')
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')
33
38
  })
34
39
 
35
- test('preserves messages without method suffixes', () => {
36
- expect(stripMethodSuffix('No path found')).toBe('No path found')
37
- })
40
+ test('retains operation recovery only for outcome-unknown transport failure', async () => {
41
+ const writes: string[] = []
42
+ const original = process.stderr.write
43
+ process.stderr.write = ((chunk: string | Uint8Array) => {
44
+ writes.push(typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk))
45
+ return true
46
+ }) as typeof process.stderr.write
47
+ try {
48
+ await formatKernelError(
49
+ new TransportError('Request timed out.', {
50
+ cause: new Error('timeout'),
51
+ phase: 'timeout',
52
+ delivery: 'unknown',
53
+ }),
54
+ true,
55
+ undefined,
56
+ false,
57
+ {
58
+ recovery: {
59
+ operation: '4a4c9a18-50f6-4d84-a7b7-2d83e3e45dc8',
60
+ retry:
61
+ 'astrale domain install https://crm.test --direct --operation 4a4c9a18-50f6-4d84-a7b7-2d83e3e45dc8',
62
+ },
63
+ },
64
+ )
65
+ } finally {
66
+ process.stderr.write = original
67
+ }
38
68
 
39
- test('does not strip single colon (not a method dispatch)', () => {
40
- expect(stripMethodSuffix('"/a/b:notMethod"')).toBe('"/a/b:notMethod"')
69
+ expect(writes).toHaveLength(1)
70
+ expect(JSON.parse(writes[0]!)).toMatchObject({
71
+ error: 'TIMEOUT',
72
+ phase: 'timeout',
73
+ delivery: 'unknown',
74
+ operation: '4a4c9a18-50f6-4d84-a7b7-2d83e3e45dc8',
75
+ retry:
76
+ 'astrale domain install https://crm.test --direct --operation 4a4c9a18-50f6-4d84-a7b7-2d83e3e45dc8',
77
+ })
41
78
  })
42
- })
43
79
 
44
- describe('formatKernelError', () => {
45
80
  /** @evidence TEST-CLI-CONNECTION-PRESERVES-PUBLIC-SEMANTIC-REASON */
46
81
  test('preserves a Kernel-admitted semantic reason in machine output', async () => {
47
82
  const writes: string[] = []
@@ -94,6 +129,102 @@ describe('formatKernelError', () => {
94
129
  })
95
130
  })
96
131
 
132
+ test('explains deletion-only schema work without changing the Kernel reason', async () => {
133
+ const writes: string[] = []
134
+ const original = process.stderr.write
135
+ process.stderr.write = ((chunk: string | Uint8Array) => {
136
+ writes.push(typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk))
137
+ return true
138
+ }) as typeof process.stderr.write
139
+ try {
140
+ await formatKernelError(
141
+ new ResponseError(CONFLICT, 'Schema change requires migration.', {
142
+ code: 'DATA_MIGRATION_REQUIRED',
143
+ details: {
144
+ requirements: [
145
+ {
146
+ operation: 'remove-facts',
147
+ reason: 'destructive-change',
148
+ subject: {
149
+ kind: 'definition',
150
+ ref: { origin: 'x.test', kind: 'class', name: 'X' },
151
+ },
152
+ work: { observedFacts: 1 },
153
+ },
154
+ ],
155
+ },
156
+ }),
157
+ true,
158
+ )
159
+ } finally {
160
+ process.stderr.write = original
161
+ }
162
+
163
+ expect(JSON.parse(writes[0]!)).toEqual({
164
+ error: 'RESPONSE_ERROR',
165
+ code: CONFLICT,
166
+ message: 'Schema change requires migration.',
167
+ reason: {
168
+ code: 'DATA_MIGRATION_REQUIRED',
169
+ details: {
170
+ requirements: [
171
+ {
172
+ operation: 'remove-facts',
173
+ reason: 'destructive-change',
174
+ subject: {
175
+ kind: 'definition',
176
+ ref: { origin: 'x.test', kind: 'class', name: 'X' },
177
+ },
178
+ work: { observedFacts: 1 },
179
+ },
180
+ ],
181
+ },
182
+ },
183
+ hint: 'Delete this data explicitly, then retry. No data was deleted.',
184
+ })
185
+ })
186
+
187
+ test('prints an actionable message for deletion-only schema work', async () => {
188
+ const errors: string[] = []
189
+ const hints: string[] = []
190
+ const originalError = console.error
191
+ const originalLog = console.log
192
+ console.error = (...values: unknown[]) => errors.push(values.map(String).join(' '))
193
+ console.log = (...values: unknown[]) => hints.push(values.map(String).join(' '))
194
+ try {
195
+ await formatKernelError(
196
+ new ResponseError(CONFLICT, 'Schema change requires migration.', {
197
+ code: 'DATA_MIGRATION_REQUIRED',
198
+ details: {
199
+ requirements: [
200
+ {
201
+ operation: 'remove-facts',
202
+ reason: 'destructive-change',
203
+ subject: {
204
+ kind: 'definition',
205
+ ref: { origin: 'x.test', kind: 'class', name: 'X' },
206
+ },
207
+ work: { observedFacts: 1 },
208
+ },
209
+ ],
210
+ },
211
+ }),
212
+ false,
213
+ )
214
+ } finally {
215
+ console.error = originalError
216
+ console.log = originalLog
217
+ }
218
+
219
+ expect(errors.join('\n')).toContain('DATA_MIGRATION_REQUIRED')
220
+ expect(errors.join('\n')).toContain(
221
+ 'Existing business data still uses schema definitions being removed.',
222
+ )
223
+ expect(hints.join('\n')).toContain(
224
+ 'Delete this data explicitly, then retry. No data was deleted.',
225
+ )
226
+ })
227
+
97
228
  test('maps SDK class names to stable CLI error codes', async () => {
98
229
  const writes: string[] = []
99
230
  const original = process.stderr.write
@@ -113,4 +244,323 @@ describe('formatKernelError', () => {
113
244
  message: 'Path must have an Id or Domain anchor.',
114
245
  })
115
246
  })
247
+
248
+ /** @evidence TEST-CLI-CONNECTION-PRESENTS-BOUNDED-REPAIRS */
249
+ test('renders callable and Query repair coordinates for humans', async () => {
250
+ const lines: string[] = []
251
+ const originalError = console.error
252
+ const originalLog = console.log
253
+ console.error = (...values: unknown[]) => lines.push(values.join(' '))
254
+ console.log = (...values: unknown[]) => lines.push(values.join(' '))
255
+ try {
256
+ await formatKernelError(
257
+ new ResponseError(VALIDATION, 'Function input is invalid.', {
258
+ code: 'FUNCTION_INPUT_INVALID',
259
+ details: {
260
+ issues: [
261
+ {
262
+ code: 'VALUE_SCHEMA_INSTANCE_INVALID',
263
+ path: '/issuer',
264
+ message: 'Object is missing required property issuer.',
265
+ },
266
+ {
267
+ code: 'VALUE_SCHEMA_INSTANCE_INVALID',
268
+ path: '/operationId',
269
+ message: 'Object is missing required property operationId.',
270
+ },
271
+ {
272
+ code: 'VALUE_SCHEMA_INSTANCE_INVALID',
273
+ path: '/slug',
274
+ message: 'Object is missing required property slug.',
275
+ },
276
+ ],
277
+ },
278
+ }),
279
+ false,
280
+ )
281
+ await formatKernelError(
282
+ new ResponseError(VALIDATION, 'Query input is invalid.', {
283
+ code: 'QUERY_INPUT_INVALID',
284
+ details: {
285
+ phase: 'plan',
286
+ issue: 'QUERY_DEFINITION_NOT_EDGE',
287
+ path: '/steps/0/via/0',
288
+ },
289
+ }),
290
+ false,
291
+ )
292
+ } finally {
293
+ console.error = originalError
294
+ console.log = originalLog
295
+ }
296
+
297
+ const rendered = lines.join('\n')
298
+ expect(rendered).toContain('/issuer: Object is missing required property issuer.')
299
+ expect(rendered).toContain('/operationId: Object is missing required property operationId.')
300
+ expect(rendered).toContain('/slug: Object is missing required property slug.')
301
+ expect(rendered).toContain('/steps/0/via/0 QUERY_DEFINITION_NOT_EDGE')
302
+ })
303
+
304
+ test('explains immutable issuer replacements and preserves the Kernel reason', async () => {
305
+ const writes: string[] = []
306
+ const original = process.stderr.write
307
+ process.stderr.write = ((chunk: string | Uint8Array) => {
308
+ writes.push(typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk))
309
+ return true
310
+ }) as typeof process.stderr.write
311
+ try {
312
+ await formatKernelError(
313
+ new ResponseError(5001, 'Schema operation is not supported.', {
314
+ code: 'SCHEMA_UPGRADE_INCOMPATIBLE',
315
+ details: {
316
+ phase: 'upgrade',
317
+ origin: 'grc.example',
318
+ issue: 'issuer-changed',
319
+ installedIssuer: 'https://old.example',
320
+ replacementIssuer: 'https://new.example',
321
+ },
322
+ }),
323
+ true,
324
+ )
325
+ } finally {
326
+ process.stderr.write = original
327
+ }
328
+
329
+ expect(JSON.parse(writes[0]!)).toEqual({
330
+ error: 'RESPONSE_ERROR',
331
+ code: 5001,
332
+ message: 'Schema operation is not supported.',
333
+ reason: {
334
+ code: 'SCHEMA_UPGRADE_INCOMPATIBLE',
335
+ details: {
336
+ phase: 'upgrade',
337
+ origin: 'grc.example',
338
+ issue: 'issuer-changed',
339
+ installedIssuer: 'https://old.example',
340
+ replacementIssuer: 'https://new.example',
341
+ },
342
+ },
343
+ hint: schemaUpgradeHint({
344
+ origin: 'grc.example',
345
+ issue: 'issuer-changed',
346
+ installedIssuer: 'https://old.example',
347
+ replacementIssuer: 'https://new.example',
348
+ }),
349
+ })
350
+ })
351
+
352
+ test('prints both issuers and the recovery command for an incompatible replacement', async () => {
353
+ const errors: string[] = []
354
+ const details: string[] = []
355
+ const originalError = console.error
356
+ const originalLog = console.log
357
+ console.error = (...values: unknown[]) => errors.push(values.map(String).join(' '))
358
+ console.log = (...values: unknown[]) => details.push(values.map(String).join(' '))
359
+ try {
360
+ await formatKernelError(
361
+ new ResponseError(5001, 'Schema operation is not supported.', {
362
+ code: 'SCHEMA_UPGRADE_INCOMPATIBLE',
363
+ details: {
364
+ phase: 'upgrade',
365
+ origin: 'grc.example',
366
+ issue: 'issuer-changed',
367
+ installedIssuer: 'https://old.example',
368
+ replacementIssuer: 'https://new.example',
369
+ },
370
+ }),
371
+ false,
372
+ )
373
+ } finally {
374
+ console.error = originalError
375
+ console.log = originalLog
376
+ }
377
+
378
+ expect(errors.join('\n')).toContain('RESPONSE_ERROR(5001)')
379
+ expect(details.join('\n')).toContain('reason: SCHEMA_UPGRADE_INCOMPATIBLE')
380
+ expect(details.join('\n')).toContain('installed issuer: https://old.example')
381
+ expect(details.join('\n')).toContain('replacement issuer: https://new.example')
382
+ expect(details.join('\n')).toContain('astrale domain uninstall grc.example')
383
+ })
384
+
385
+ test('explains a private Domain source without exposing transport diagnostics', async () => {
386
+ const writes: string[] = []
387
+ const original = process.stderr.write
388
+ process.stderr.write = ((chunk: string | Uint8Array) => {
389
+ writes.push(typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk))
390
+ return true
391
+ }) as typeof process.stderr.write
392
+ try {
393
+ await formatKernelError(
394
+ new ResponseError(1003, 'Function input is invalid.', {
395
+ code: 'SCHEMA_DOMAIN_ADDRESS_NOT_PUBLIC',
396
+ details: { phase: 'publication', requiredScope: 'public' },
397
+ }),
398
+ true,
399
+ )
400
+ } finally {
401
+ process.stderr.write = original
402
+ }
403
+
404
+ expect(JSON.parse(writes[0]!)).toEqual({
405
+ error: 'RESPONSE_ERROR',
406
+ code: 1003,
407
+ message: 'Expose the Domain through a public HTTPS URL or public tunnel, then retry.',
408
+ reason: {
409
+ code: 'SCHEMA_DOMAIN_ADDRESS_NOT_PUBLIC',
410
+ details: { phase: 'publication', requiredScope: 'public' },
411
+ },
412
+ })
413
+ })
414
+
415
+ test('prints the actionable private-source message in interactive output', async () => {
416
+ const errors: string[] = []
417
+ const hints: string[] = []
418
+ const originalError = console.error
419
+ const originalLog = console.log
420
+ console.error = (...values: unknown[]) => errors.push(values.map(String).join(' '))
421
+ console.log = (...values: unknown[]) => hints.push(values.map(String).join(' '))
422
+ try {
423
+ await formatKernelError(
424
+ new ResponseError(1003, 'Function input is invalid.', {
425
+ code: 'SCHEMA_DOMAIN_ADDRESS_NOT_PUBLIC',
426
+ details: { phase: 'publication', requiredScope: 'public' },
427
+ }),
428
+ false,
429
+ )
430
+ } finally {
431
+ console.error = originalError
432
+ console.log = originalLog
433
+ }
434
+
435
+ expect(errors.join('\n')).toContain('SCHEMA_DOMAIN_ADDRESS_NOT_PUBLIC')
436
+ expect(errors.join('\n')).toContain(
437
+ 'Expose the Domain through a public HTTPS URL or public tunnel, then retry.',
438
+ )
439
+ expect(hints).toEqual([])
440
+ })
441
+
442
+ test('preserves structured Function input issues in machine output', async () => {
443
+ const writes: string[] = []
444
+ const original = process.stderr.write
445
+ process.stderr.write = ((chunk: string | Uint8Array) => {
446
+ writes.push(typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk))
447
+ return true
448
+ }) as typeof process.stderr.write
449
+ try {
450
+ await formatKernelError(
451
+ new ResponseError(1003, 'Function input is invalid.', {
452
+ code: 'FUNCTION_INPUT_INVALID',
453
+ details: {
454
+ issues: [
455
+ {
456
+ code: 'INVALID_FORMAT',
457
+ path: '/customer/email',
458
+ message: 'Must be a valid email address.',
459
+ },
460
+ ],
461
+ },
462
+ }),
463
+ true,
464
+ )
465
+ } finally {
466
+ process.stderr.write = original
467
+ }
468
+
469
+ expect(JSON.parse(writes[0]!)).toEqual({
470
+ error: 'RESPONSE_ERROR',
471
+ code: 1003,
472
+ message: 'Function input is invalid.',
473
+ reason: {
474
+ code: 'FUNCTION_INPUT_INVALID',
475
+ details: {
476
+ issues: [
477
+ {
478
+ code: 'INVALID_FORMAT',
479
+ path: '/customer/email',
480
+ message: 'Must be a valid email address.',
481
+ },
482
+ ],
483
+ },
484
+ },
485
+ hint: 'Use `astrale introspect <path>` to see the callable input.',
486
+ })
487
+ })
488
+
489
+ test('prints Kernel-provided Function input messages in interactive output', async () => {
490
+ const errors: string[] = []
491
+ const details: string[] = []
492
+ const originalError = console.error
493
+ const originalLog = console.log
494
+ console.error = (...values: unknown[]) => errors.push(values.map(String).join(' '))
495
+ console.log = (...values: unknown[]) => details.push(values.map(String).join(' '))
496
+ try {
497
+ await formatKernelError(
498
+ new ResponseError(1003, 'Function input is invalid.', {
499
+ code: 'FUNCTION_INPUT_INVALID',
500
+ details: {
501
+ issues: [
502
+ {
503
+ code: 'INVALID_FORMAT',
504
+ path: '/customer/email',
505
+ message: 'Must be a valid email address.',
506
+ },
507
+ ],
508
+ },
509
+ }),
510
+ false,
511
+ )
512
+ } finally {
513
+ console.error = originalError
514
+ console.log = originalLog
515
+ }
516
+
517
+ expect(errors.join('\n')).toContain('Function input is invalid.')
518
+ expect(details.join('\n')).toContain(
519
+ '/customer/email: Must be a valid email address. (INVALID_FORMAT)',
520
+ )
521
+ expect(details.join('\n')).not.toContain('astrale introspect')
522
+ })
523
+
524
+ test('keeps the introspection fallback for legacy Function input issues', async () => {
525
+ const writes: string[] = []
526
+ const original = process.stderr.write
527
+ process.stderr.write = ((chunk: string | Uint8Array) => {
528
+ writes.push(typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk))
529
+ return true
530
+ }) as typeof process.stderr.write
531
+ try {
532
+ await formatKernelError(
533
+ new ResponseError(1003, 'Function input is invalid.', {
534
+ code: 'FUNCTION_INPUT_INVALID',
535
+ details: { issues: [{ code: 'INVALID_FORMAT', path: '/customer/email' }] },
536
+ }),
537
+ true,
538
+ )
539
+ } finally {
540
+ process.stderr.write = original
541
+ }
542
+
543
+ expect(JSON.parse(writes[0]!)).toMatchObject({
544
+ reason: {
545
+ code: 'FUNCTION_INPUT_INVALID',
546
+ details: { issues: [{ code: 'INVALID_FORMAT', path: '/customer/email' }] },
547
+ },
548
+ hint: 'Use `astrale introspect <path>` to see the callable input.',
549
+ })
550
+ })
551
+
552
+ test('rejects malformed public Function issue messages', () => {
553
+ expect(
554
+ functionInputIssues({
555
+ code: 'FUNCTION_INPUT_INVALID',
556
+ details: {
557
+ issues: [
558
+ { code: 'INVALID_FORMAT', path: '/safe', message: 'Safe message.' },
559
+ { code: 'INVALID_FORMAT', path: 'not-a-pointer', message: 'Unsafe path.' },
560
+ { code: 'INVALID_FORMAT', path: '/unsafe', message: 'Unsafe\nmessage.' },
561
+ ],
562
+ },
563
+ }),
564
+ ).toEqual([{ code: 'INVALID_FORMAT', path: '/safe', message: 'Safe message.' }])
565
+ })
116
566
  })
@@ -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