@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.
- package/README.md +3 -2
- package/dist/astrale.js +2599 -2703
- package/dist/public/connect-core.js +2019 -3050
- package/dist/public/keys/index.js +1851 -2885
- package/dist/public/paths/index.js +1830 -2872
- package/dist/types/connection/auth.d.ts +3 -0
- package/dist/types/lib/instance.d.ts +10 -0
- package/package.json +8 -8
- package/src/commands/__tests__/domain-uninstall.test.ts +53 -0
- package/src/commands/__tests__/install-identity-override.test.ts +14 -3
- package/src/commands/__tests__/instance-bookmark.test.ts +66 -1
- package/src/commands/__tests__/instance-list-rows.test.ts +1 -0
- package/src/commands/__tests__/instance-use.test.ts +67 -0
- package/src/commands/__tests__/view-build.test.ts +58 -0
- package/src/commands/domain/install.ts +6 -5
- package/src/commands/domain/uninstall.ts +128 -0
- package/src/commands/instance/active.ts +13 -1
- package/src/commands/instance/bookmark.ts +26 -3
- package/src/commands/instance/list.ts +18 -4
- package/src/commands/instance/use.ts +54 -7
- package/src/commands/view.ts +28 -16
- package/src/connection/.spec/architecture.md +5 -0
- package/src/connection/.spec/laws/connection.ts +20 -0
- package/src/connection/.spec/layout.ts +1 -0
- package/src/connection/__tests__/auth.test.ts +27 -1
- package/src/connection/__tests__/ca-fetch.test.ts +8 -1
- package/src/connection/__tests__/errors.test.ts +410 -36
- package/src/connection/__tests__/exchange.test.ts +46 -5
- package/src/connection/__tests__/reasons.test.ts +78 -0
- package/src/connection/auth.ts +11 -9
- package/src/connection/command.ts +1 -1
- package/src/connection/errors.ts +139 -160
- package/src/connection/exchange.ts +14 -2
- package/src/connection/reasons.ts +179 -0
- package/src/lib/__tests__/instance.test.ts +51 -1
- package/src/lib/__tests__/view-assets.test.ts +33 -1
- package/src/lib/__tests__/view-server.test.ts +68 -0
- package/src/lib/ca-fetch.ts +9 -3
- package/src/lib/instance.ts +31 -0
- package/src/lib/view/assets.ts +16 -2
- package/src/program/__tests__/program.test.ts +2 -1
- package/src/program/build.ts +2 -1
- package/studio/package.json +7 -8
- 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,
|
|
4
|
+
import { formatKernelError, functionInputIssues, schemaUpgradeHint } from '../errors'
|
|
5
5
|
|
|
6
6
|
const CONFLICT = 4001
|
|
7
|
+
const VALIDATION = 1003
|
|
7
8
|
|
|
8
|
-
describe('
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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(
|
|
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
|
-
|
|
178
|
-
{ token: exchanged, expiresAt: EXPIRES_AT },
|
|
179
|
-
|
|
180
|
-
|
|
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
|
+
})
|
package/src/connection/auth.ts
CHANGED
|
@@ -162,7 +162,7 @@ async function resolveIdpAccessToken(
|
|
|
162
162
|
)
|
|
163
163
|
}
|
|
164
164
|
if (e instanceof IdpSessionNoRefreshTokenError) {
|
|
165
|
-
throw
|
|
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
|
|
56
|
+
await formatKernelError(error, isRaw, undefined, opts.debug)
|
|
57
57
|
process.exit(1)
|
|
58
58
|
}
|
|
59
59
|
}
|