@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.
- package/README.md +18 -5
- package/dist/astrale.js +2661 -2714
- 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 +9 -9
- package/src/commands/__tests__/domain-install-operation.test.ts +121 -0
- package/src/commands/__tests__/domain-install-owned.test.ts +66 -0
- package/src/commands/__tests__/domain-uninstall.test.ts +53 -0
- package/src/commands/__tests__/install-direct.test.ts +3 -2
- 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 +84 -20
- 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 +483 -33
- 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 +9 -1
- package/src/connection/errors.ts +149 -159
- package/src/connection/exchange.ts +14 -2
- package/src/connection/index.ts +1 -1
- package/src/connection/reasons.ts +193 -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/client/dist/assets/{elk-api-D0cBetPW.js → elk-api-D2xgMJvi.js} +1 -1
- package/studio/client/dist/assets/{index-BQnJ5sgd.css → index-BMdnsIJA.css} +1 -1
- package/studio/client/dist/assets/index-D-vRV8w7.js +8 -0
- package/studio/client/dist/assets/index-LGSWRrk8.js +81 -0
- package/studio/client/dist/index.html +2 -2
- package/studio/package.json +8 -9
- package/studio/server/agent/prompts/anchors.test.ts +74 -0
- package/studio/server/agent/prompts/anchors.ts +85 -15
- package/studio/server/agent/prompts/system.test.ts +12 -0
- package/studio/server/agent/prompts/system.ts +6 -6
- package/studio/server/api.ts +1 -5
- package/studio/server/cache.ts +5 -2
- package/studio/server/domain.test.ts +67 -0
- package/studio/server/domain.ts +29 -6
- package/studio/server/index.ts +1 -3
- package/studio/server/introspect/anatomy-extras.test.ts +104 -1
- package/studio/server/introspect/anatomy-extras.ts +340 -8
- package/studio/server/introspect/anatomy.test.ts +33 -0
- package/studio/server/introspect/anatomy.ts +22 -7
- package/studio/server/introspect/bundle.ts +7 -1
- package/studio/server/introspect/canonical-schema.test.ts +395 -0
- package/studio/server/introspect/canonical-schema.ts +751 -0
- package/studio/server/introspect/core-extractor.ts +30 -10
- package/studio/server/introspect/core.ts +4 -2
- package/studio/server/introspect/diff.test.ts +124 -0
- package/studio/server/introspect/diff.ts +252 -23
- package/studio/server/introspect/extractor.ts +46 -14
- package/studio/server/introspect/overlay-tsmorph.test.ts +164 -1
- package/studio/server/introspect/overlay-tsmorph.ts +381 -106
- package/studio/server/introspect/overlay.test.ts +72 -0
- package/studio/server/introspect/overlay.ts +16 -6
- package/studio/server/introspect/runtime.test.ts +217 -0
- package/studio/server/introspect/runtime.ts +18 -6
- package/studio/server/introspect/schema-refs.test.ts +100 -0
- package/studio/server/introspect/schema-refs.ts +23 -2
- package/studio/server/state/baseline.test.ts +51 -0
- package/studio/server/state/baseline.ts +31 -2
- package/studio/server/state/create.test.ts +37 -0
- package/studio/server/state/create.ts +21 -15
- package/studio/server/state/instance.test.ts +63 -0
- package/studio/server/state/instance.ts +65 -29
- package/studio/server/state/views.test.ts +209 -9
- package/studio/server/state/views.ts +176 -69
- package/studio/server/watch.test.ts +36 -0
- package/studio/server/watch.ts +24 -16
- package/studio/server/workspace-watch.ts +8 -3
- package/studio/shared/types.ts +164 -33
- package/viewer/dist/main.js +57 -57
- package/studio/client/dist/assets/index-Dspir4w7.js +0 -81
- package/studio/client/dist/assets/index-bVD2KJgz.js +0 -8
- package/studio/server/view-dev-server.test.ts +0 -111
- package/studio/server/view-dev-server.ts +0 -372
|
@@ -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 {
|
|
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: () =>
|
|
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,
|
|
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
|
-
})
|
|
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
|
-
|
|
32
|
-
|
|
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('
|
|
36
|
-
|
|
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
|
-
|
|
40
|
-
expect(
|
|
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(
|
|
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
|
|