@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
package/src/connection/errors.ts
CHANGED
|
@@ -1,21 +1,24 @@
|
|
|
1
1
|
import chalk from 'chalk'
|
|
2
2
|
|
|
3
|
-
import type { SelfExpansionMeta } from './self'
|
|
4
|
-
|
|
5
3
|
import { AstraleError } from '../errors'
|
|
6
|
-
import {
|
|
4
|
+
import { readLocalStatus, type LocalStatus } from '../lib/local-status'
|
|
7
5
|
import { log } from '../lib/log'
|
|
6
|
+
import {
|
|
7
|
+
functionInputIssues,
|
|
8
|
+
queryInputRepair,
|
|
9
|
+
reasonCode,
|
|
10
|
+
schemaUpgradeDetails,
|
|
11
|
+
schemaUpgradeHint,
|
|
12
|
+
type FunctionInputIssue,
|
|
13
|
+
type QueryInputRepair,
|
|
14
|
+
} from './reasons'
|
|
8
15
|
|
|
9
|
-
|
|
10
|
-
type InvariantError = { code: string; message: string; context?: unknown }
|
|
16
|
+
export { functionInputIssues, schemaUpgradeHint } from './reasons'
|
|
11
17
|
|
|
12
18
|
/**
|
|
13
19
|
* Format and display a kernel client error.
|
|
14
20
|
*
|
|
15
|
-
* Handles
|
|
16
|
-
* @astrale-os/kernel-client: ConnectionError, DisconnectedError,
|
|
17
|
-
* TimeoutError, AuthenticationError, PermissionDeniedError, NotFoundError,
|
|
18
|
-
* KernelError and its subclasses (ValidationError, InvariantViolationError).
|
|
21
|
+
* Handles CLI-local errors plus the current Kernel Client public error families.
|
|
19
22
|
*
|
|
20
23
|
* When `debug` is true, additional diagnostic information (class name, full
|
|
21
24
|
* error chain, attached url/details) is printed after the user-facing line.
|
|
@@ -25,12 +28,10 @@ export async function formatKernelError(
|
|
|
25
28
|
isRaw: boolean,
|
|
26
29
|
urlArg = '',
|
|
27
30
|
debug = false,
|
|
28
|
-
opts: { credential?: string } = {},
|
|
29
31
|
): Promise<void> {
|
|
30
32
|
const url =
|
|
31
33
|
urlArg || (error instanceof Error ? ((error as Error & { url?: string }).url ?? '') : '')
|
|
32
34
|
const localContext = await contextForError(error)
|
|
33
|
-
const credentialExpiration = opts.credential ? decodeJwtExpiration(opts.credential) : null
|
|
34
35
|
// Handle AstraleError (AuthError, etc.) with structured hints
|
|
35
36
|
if (error instanceof AstraleError) {
|
|
36
37
|
if (isRaw) {
|
|
@@ -52,167 +53,60 @@ export async function formatKernelError(
|
|
|
52
53
|
const name = error.name
|
|
53
54
|
|
|
54
55
|
switch (name) {
|
|
55
|
-
case '
|
|
56
|
-
|
|
57
|
-
writeRaw({ error: 'CONNECTION_ERROR', message: error.message, url, context: localContext })
|
|
58
|
-
else {
|
|
59
|
-
log.error(`Could not connect to ${chalk.bold(url || 'kernel')}`)
|
|
60
|
-
log.dim(` ${error.message}`)
|
|
61
|
-
log.dim(' Is the kernel running? Try: astrale status')
|
|
62
|
-
printLocalContext(localContext)
|
|
63
|
-
}
|
|
64
|
-
break
|
|
65
|
-
|
|
66
|
-
case 'DisconnectedError':
|
|
67
|
-
if (isRaw) writeRaw({ error: 'DISCONNECTED', message: error.message })
|
|
68
|
-
else {
|
|
69
|
-
log.error('Connection closed while request was pending')
|
|
70
|
-
log.dim(' The kernel may have been stopped or restarted. Retry the command.')
|
|
71
|
-
}
|
|
72
|
-
break
|
|
73
|
-
|
|
74
|
-
case 'TimeoutError': {
|
|
75
|
-
const timeoutMs = (error as { timeoutMs?: number }).timeoutMs
|
|
76
|
-
if (isRaw) writeRaw({ error: 'TIMEOUT', message: error.message, timeoutMs })
|
|
77
|
-
else {
|
|
78
|
-
log.error(`Request timed out after ${timeoutMs ?? '?'}ms`)
|
|
79
|
-
log.dim(' Try increasing with --timeout')
|
|
80
|
-
}
|
|
81
|
-
break
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
case 'AuthenticationError': {
|
|
85
|
-
const reason = (error as { reason?: string }).reason ?? 'unknown'
|
|
86
|
-
if (isRaw)
|
|
87
|
-
writeRaw({
|
|
88
|
-
error: 'AUTH_ERROR',
|
|
89
|
-
reason,
|
|
90
|
-
message: error.message,
|
|
91
|
-
credential: credentialExpiration,
|
|
92
|
-
context: localContext,
|
|
93
|
-
})
|
|
94
|
-
else {
|
|
95
|
-
log.error(`Authentication failed: ${error.message}`)
|
|
96
|
-
if (reason === 'missing')
|
|
97
|
-
log.dim(' No credential was sent. Run: astrale identity create <name>')
|
|
98
|
-
else if (reason === 'invalid')
|
|
99
|
-
log.dim(' Credential is invalid — check issuer/keypair. Try: astrale identity whoami')
|
|
100
|
-
else if (reason === 'expired') log.dim(' Credential expired — sign a fresh one')
|
|
101
|
-
if (credentialExpiration) {
|
|
102
|
-
const state = credentialExpiration.expired ? 'expired' : 'expires'
|
|
103
|
-
log.dim(` Credential ${state} at ${credentialExpiration.expiresAt}`)
|
|
104
|
-
}
|
|
105
|
-
printLocalContext(localContext)
|
|
106
|
-
}
|
|
107
|
-
break
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
case 'PermissionDeniedError':
|
|
111
|
-
if (isRaw) writeRaw({ error: 'PERMISSION_DENIED', message: error.message })
|
|
112
|
-
else {
|
|
113
|
-
log.error(`Permission denied: ${error.message}`)
|
|
114
|
-
log.dim(' Your identity does not have the required permissions for this operation')
|
|
115
|
-
}
|
|
116
|
-
break
|
|
117
|
-
|
|
118
|
-
case 'NotFoundError': {
|
|
119
|
-
const cleanMsg = stripMethodSuffix(error.message)
|
|
120
|
-
const selfMeta = (error as Error & { expandedFromSelf?: SelfExpansionMeta }).expandedFromSelf
|
|
121
|
-
// kernel-client maps both NOT_FOUND (the node doesn't exist) and
|
|
122
|
-
// METHOD_NOT_FOUND (the method doesn't exist on a real node) to
|
|
123
|
-
// `NotFoundError`. Firing the authenticated-principal hint for the
|
|
124
|
-
// method case is misleading. Gate on the message referencing the
|
|
125
|
-
// expanded id — node lookup errors mention `@<id>` whereas method
|
|
126
|
-
// errors mention the method path.
|
|
127
|
-
const selfHintApplies = selfMeta && error.message.includes(`@${selfMeta.selfId}`)
|
|
128
|
-
if (isRaw) {
|
|
129
|
-
const payload: Record<string, unknown> = { error: 'NOT_FOUND', message: cleanMsg }
|
|
130
|
-
if (selfHintApplies) payload.expandedFromSelf = selfMeta
|
|
131
|
-
writeRaw(payload)
|
|
132
|
-
} else {
|
|
133
|
-
log.error(`Not found: ${cleanMsg}`)
|
|
134
|
-
log.dim(' Check the path/ID and that the instance is booted')
|
|
135
|
-
if (selfHintApplies && selfMeta) {
|
|
136
|
-
const where = selfMeta.slug ? ` on "${selfMeta.slug}"` : ''
|
|
137
|
-
log.dim(
|
|
138
|
-
` @self resolved through authenticated Identity.whoami to @${selfMeta.selfId}${where}.`,
|
|
139
|
-
)
|
|
140
|
-
log.dim(' Check that the requested node path still exists for that principal.')
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
break
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
case 'ValidationError': {
|
|
147
|
-
const errors = (error as { errors?: FieldError[] }).errors ?? []
|
|
148
|
-
if (isRaw) writeRaw({ error: 'VALIDATION_ERROR', message: error.message, details: errors })
|
|
149
|
-
else {
|
|
150
|
-
log.error('Validation Error')
|
|
151
|
-
if (errors.length > 0) {
|
|
152
|
-
for (const e of errors) {
|
|
153
|
-
console.log(chalk.red(` ${e.path.join('.')}: ${e.message} (${chalk.dim(e.code)})`))
|
|
154
|
-
}
|
|
155
|
-
} else {
|
|
156
|
-
// Server often sends details in message but empty errors array
|
|
157
|
-
console.log(chalk.red(` ${error.message}`))
|
|
158
|
-
}
|
|
159
|
-
log.dim(' Use `astrale introspect <path>` to see the expected schema')
|
|
160
|
-
}
|
|
161
|
-
break
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
case 'InvariantViolationError': {
|
|
165
|
-
const errors = (error as { errors?: InvariantError[] }).errors ?? []
|
|
166
|
-
if (isRaw) writeRaw({ error: 'INVARIANT_VIOLATION', message: error.message, details: errors })
|
|
167
|
-
else {
|
|
168
|
-
log.error('Invariant Violation')
|
|
169
|
-
for (const e of errors) {
|
|
170
|
-
console.log(chalk.red(` ${e.code}: ${e.message}`))
|
|
171
|
-
if (e.context) console.log(chalk.dim(` ${JSON.stringify(e.context)}`))
|
|
172
|
-
}
|
|
173
|
-
}
|
|
56
|
+
case 'TransportError':
|
|
57
|
+
presentTransportError(error, isRaw, url, localContext)
|
|
174
58
|
break
|
|
175
|
-
}
|
|
176
59
|
|
|
177
60
|
case 'ResponseError': {
|
|
178
61
|
const code = (error as { readonly code?: unknown }).code
|
|
179
62
|
const reason = (error as { readonly reason?: unknown }).reason
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
63
|
+
const codeOfReason = reasonCode(reason)
|
|
64
|
+
const inputIssues = functionInputIssues(reason)
|
|
65
|
+
const queryRepair = queryInputRepair(reason)
|
|
66
|
+
const upgrade = schemaUpgradeDetails(reason)
|
|
67
|
+
const removalHint = schemaDataRemovalHint(reason)
|
|
68
|
+
const domainAddressNotPublic = codeOfReason === 'SCHEMA_DOMAIN_ADDRESS_NOT_PUBLIC'
|
|
69
|
+
const displayMessage = domainAddressNotPublic
|
|
70
|
+
? 'Expose the Domain through a public HTTPS URL or public tunnel, then retry.'
|
|
71
|
+
: removalHint !== undefined && !isRaw
|
|
72
|
+
? 'Existing business data still uses schema definitions being removed.'
|
|
73
|
+
: error.message
|
|
186
74
|
const hint =
|
|
187
|
-
|
|
75
|
+
codeOfReason === 'FUNCTION_INPUT_INVALID' && (isRaw || inputIssues.length === 0)
|
|
188
76
|
? 'Use `astrale introspect <path>` to see the callable input.'
|
|
189
|
-
: undefined
|
|
77
|
+
: (removalHint ?? (upgrade === undefined ? undefined : schemaUpgradeHint(upgrade)))
|
|
190
78
|
if (isRaw) {
|
|
191
79
|
writeRaw({
|
|
192
80
|
error: 'RESPONSE_ERROR',
|
|
193
81
|
...(code === undefined ? {} : { code }),
|
|
194
|
-
message:
|
|
82
|
+
message: displayMessage,
|
|
195
83
|
...(reason === undefined ? {} : { reason }),
|
|
196
84
|
...(hint === undefined ? {} : { hint }),
|
|
197
85
|
})
|
|
198
86
|
} else {
|
|
199
87
|
log.error(
|
|
200
|
-
|
|
88
|
+
domainAddressNotPublic
|
|
89
|
+
? `${chalk.bold('SCHEMA_DOMAIN_ADDRESS_NOT_PUBLIC')}: ${displayMessage}`
|
|
90
|
+
: removalHint !== undefined
|
|
91
|
+
? `${chalk.bold('DATA_MIGRATION_REQUIRED')}: ${displayMessage}`
|
|
92
|
+
: `${chalk.bold(code === undefined ? 'RESPONSE_ERROR' : `RESPONSE_ERROR(${String(code)})`)}: ${displayMessage}`,
|
|
201
93
|
)
|
|
202
|
-
if (
|
|
94
|
+
if (codeOfReason !== undefined && !domainAddressNotPublic && removalHint === undefined) {
|
|
95
|
+
log.dim(` reason: ${codeOfReason}`)
|
|
96
|
+
}
|
|
97
|
+
presentFunctionInputIssues(inputIssues)
|
|
98
|
+
if (queryRepair !== undefined) presentQueryInputRepair(queryRepair)
|
|
99
|
+
if (upgrade?.expected !== undefined) {
|
|
100
|
+
log.dim(` installed issuer: ${upgrade.expected}`)
|
|
101
|
+
}
|
|
102
|
+
if (upgrade?.actual !== undefined) {
|
|
103
|
+
log.dim(` replacement issuer: ${upgrade.actual}`)
|
|
104
|
+
}
|
|
203
105
|
if (hint !== undefined) log.dim(` ${hint}`)
|
|
204
106
|
}
|
|
205
107
|
break
|
|
206
108
|
}
|
|
207
109
|
|
|
208
|
-
case 'KernelError': {
|
|
209
|
-
const code = (error as { code?: number | string }).code ?? 'UNKNOWN'
|
|
210
|
-
const type = (error as { type?: string }).type ?? 'KERNEL_ERROR'
|
|
211
|
-
if (isRaw) writeRaw({ error: type, code, message: error.message })
|
|
212
|
-
else log.error(`${chalk.bold(`${type}(${code})`)}: ${error.message}`)
|
|
213
|
-
break
|
|
214
|
-
}
|
|
215
|
-
|
|
216
110
|
default: {
|
|
217
111
|
const mapped = mapPublicError(error)
|
|
218
112
|
if (isRaw) {
|
|
@@ -232,9 +126,28 @@ export async function formatKernelError(
|
|
|
232
126
|
if (debug) printDebug(error, url)
|
|
233
127
|
}
|
|
234
128
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
129
|
+
function schemaDataRemovalHint(reason: unknown): string | undefined {
|
|
130
|
+
if (reason === null || typeof reason !== 'object') return undefined
|
|
131
|
+
const value = reason as { readonly code?: unknown; readonly details?: unknown }
|
|
132
|
+
if (value.code !== 'DATA_MIGRATION_REQUIRED') return undefined
|
|
133
|
+
if (value.details === null || typeof value.details !== 'object') return undefined
|
|
134
|
+
|
|
135
|
+
const requirements = (value.details as { readonly requirements?: unknown }).requirements
|
|
136
|
+
if (
|
|
137
|
+
!Array.isArray(requirements) ||
|
|
138
|
+
requirements.length === 0 ||
|
|
139
|
+
!requirements.every(
|
|
140
|
+
(requirement) =>
|
|
141
|
+
requirement !== null &&
|
|
142
|
+
typeof requirement === 'object' &&
|
|
143
|
+
(requirement as { readonly operation?: unknown }).operation === 'remove-facts' &&
|
|
144
|
+
(requirement as { readonly reason?: unknown }).reason === 'destructive-change',
|
|
145
|
+
)
|
|
146
|
+
) {
|
|
147
|
+
return undefined
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return 'Delete this data explicitly, then retry. No data was deleted.'
|
|
238
151
|
}
|
|
239
152
|
|
|
240
153
|
function mapPublicError(error: Error): {
|
|
@@ -273,14 +186,80 @@ function mapPublicError(error: Error): {
|
|
|
273
186
|
hint: 'Pass the Kernel issuer URL (no /invoke suffix), e.g. https://host/kernel/host',
|
|
274
187
|
}
|
|
275
188
|
}
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
189
|
+
return { code: name && name !== 'Error' ? name : 'UNKNOWN', message: error.message }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function presentTransportError(
|
|
193
|
+
error: Error,
|
|
194
|
+
isRaw: boolean,
|
|
195
|
+
url: string,
|
|
196
|
+
context: LocalStatus | undefined,
|
|
197
|
+
): void {
|
|
198
|
+
const phase = transportPhase(error)
|
|
199
|
+
const delivery = transportDelivery(error)
|
|
200
|
+
const code =
|
|
201
|
+
phase === 'connect'
|
|
202
|
+
? 'CONNECTION_ERROR'
|
|
203
|
+
: phase === 'timeout'
|
|
204
|
+
? 'TIMEOUT'
|
|
205
|
+
: phase === 'closed'
|
|
206
|
+
? 'DISCONNECTED'
|
|
207
|
+
: 'TRANSPORT_ERROR'
|
|
208
|
+
if (isRaw) {
|
|
209
|
+
writeRaw({
|
|
210
|
+
error: code,
|
|
279
211
|
message: error.message,
|
|
280
|
-
|
|
281
|
-
|
|
212
|
+
...(url === '' ? {} : { url }),
|
|
213
|
+
...(phase === undefined ? {} : { phase }),
|
|
214
|
+
...(delivery === undefined ? {} : { delivery }),
|
|
215
|
+
...(context === undefined ? {} : { context }),
|
|
216
|
+
})
|
|
217
|
+
return
|
|
282
218
|
}
|
|
283
|
-
|
|
219
|
+
log.error(`${chalk.bold(code)}: ${error.message}`)
|
|
220
|
+
if (url !== '') log.dim(` target: ${url}`)
|
|
221
|
+
if (phase !== undefined) log.dim(` phase: ${phase}`)
|
|
222
|
+
if (phase === 'connect') log.dim(' Check the target and run `astrale status`.')
|
|
223
|
+
else if (phase === 'timeout') log.dim(' Try increasing `--timeout`.')
|
|
224
|
+
else if (delivery === 'unknown') {
|
|
225
|
+
log.dim(' Delivery is unknown; do not automatically retry a mutating call.')
|
|
226
|
+
}
|
|
227
|
+
printLocalContext(context)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function presentFunctionInputIssues(issues: readonly FunctionInputIssue[]): void {
|
|
231
|
+
for (const issue of issues) {
|
|
232
|
+
const location = issue.path === undefined || issue.path === '' ? '<input>' : issue.path
|
|
233
|
+
console.log(chalk.red(` ${location}: ${issue.message} (${chalk.dim(issue.code)})`))
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function presentQueryInputRepair(repair: QueryInputRepair): void {
|
|
238
|
+
if (repair.phase === 'plan') {
|
|
239
|
+
log.dim(` ${repair.path ?? '/'} ${repair.issue}`)
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
if (repair.phase === 'limit') {
|
|
243
|
+
log.dim(` ${repair.path ?? '/'} ${repair.limit} limit ${repair.actual}/${repair.maximum}`)
|
|
244
|
+
return
|
|
245
|
+
}
|
|
246
|
+
log.dim(` ${repair.path} ${repair.phase} input`)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function transportPhase(error: Error): string | undefined {
|
|
250
|
+
const phase = (error as Error & { readonly phase?: unknown }).phase
|
|
251
|
+
return phase === 'connect' ||
|
|
252
|
+
phase === 'send' ||
|
|
253
|
+
phase === 'receive' ||
|
|
254
|
+
phase === 'timeout' ||
|
|
255
|
+
phase === 'closed'
|
|
256
|
+
? phase
|
|
257
|
+
: undefined
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function transportDelivery(error: Error): string | undefined {
|
|
261
|
+
const delivery = (error as Error & { readonly delivery?: unknown }).delivery
|
|
262
|
+
return delivery === 'not-sent' || delivery === 'unknown' ? delivery : undefined
|
|
284
263
|
}
|
|
285
264
|
|
|
286
265
|
function writeRaw(payload: Record<string, unknown>): void {
|
|
@@ -289,7 +268,7 @@ function writeRaw(payload: Record<string, unknown>): void {
|
|
|
289
268
|
|
|
290
269
|
async function contextForError(error: unknown): Promise<LocalStatus | undefined> {
|
|
291
270
|
if (!(error instanceof Error)) return undefined
|
|
292
|
-
if (error.name !== '
|
|
271
|
+
if (error.name !== 'TransportError') return undefined
|
|
293
272
|
return readLocalStatus().catch(() => undefined)
|
|
294
273
|
}
|
|
295
274
|
|
|
@@ -156,6 +156,7 @@ async function exchange(
|
|
|
156
156
|
try {
|
|
157
157
|
admitted = exchangeProtocol.acceptErrorResponse(body)
|
|
158
158
|
} catch (cause) {
|
|
159
|
+
if (!(cause instanceof TypeError)) throw cause
|
|
159
160
|
throw new AstraleError(
|
|
160
161
|
'TOKEN_EXCHANGE_PROTOCOL_ERROR',
|
|
161
162
|
`Token exchange failed with HTTP ${response.status} and an invalid error response.`,
|
|
@@ -165,8 +166,19 @@ async function exchange(
|
|
|
165
166
|
throw new AstraleError(String(admitted.error.code), admitted.error.message)
|
|
166
167
|
}
|
|
167
168
|
requireExchangeResponseHeaders(response)
|
|
168
|
-
|
|
169
|
-
|
|
169
|
+
let exchanged: exchangeProtocol.Response
|
|
170
|
+
let inspected: ReturnType<typeof credential.inspect>
|
|
171
|
+
try {
|
|
172
|
+
exchanged = exchangeProtocol.acceptResponse(body)
|
|
173
|
+
inspected = credential.inspect(exchanged.token)
|
|
174
|
+
} catch (cause) {
|
|
175
|
+
if (!(cause instanceof TypeError)) throw cause
|
|
176
|
+
throw new AstraleError(
|
|
177
|
+
'TOKEN_EXCHANGE_PROTOCOL_ERROR',
|
|
178
|
+
'Token exchange returned an invalid success response.',
|
|
179
|
+
cause.message,
|
|
180
|
+
)
|
|
181
|
+
}
|
|
170
182
|
if (
|
|
171
183
|
inspected.iss !== domainIssuer ||
|
|
172
184
|
inspected.aud !== kernelIssuer ||
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
export type FunctionInputIssue = Readonly<{
|
|
2
|
+
code: string
|
|
3
|
+
path?: string
|
|
4
|
+
message: string
|
|
5
|
+
}>
|
|
6
|
+
|
|
7
|
+
export type QueryInputRepair =
|
|
8
|
+
| Readonly<{ phase: 'decode' | 'input'; path: string }>
|
|
9
|
+
| Readonly<{ phase: 'plan'; issue: string; path?: string }>
|
|
10
|
+
| Readonly<{
|
|
11
|
+
phase: 'limit'
|
|
12
|
+
limit: string
|
|
13
|
+
maximum: number
|
|
14
|
+
actual: number
|
|
15
|
+
path?: string
|
|
16
|
+
}>
|
|
17
|
+
|
|
18
|
+
export type SchemaUpgradeDetails = {
|
|
19
|
+
readonly origin?: string
|
|
20
|
+
readonly issue?: string
|
|
21
|
+
readonly expected?: string
|
|
22
|
+
readonly actual?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const JSON_POINTER = /^(?:\/(?:[^~/]|~[01])*)*$/u
|
|
26
|
+
const MAXIMUM_FUNCTION_ISSUES = 32
|
|
27
|
+
const MAXIMUM_FUNCTION_ISSUE_MESSAGE_LENGTH = 512
|
|
28
|
+
|
|
29
|
+
export function reasonCode(reason: unknown): string | undefined {
|
|
30
|
+
if (!record(reason) || typeof reason.code !== 'string') return undefined
|
|
31
|
+
return stableCode(reason.code) ? reason.code : undefined
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Admit only bounded caller-safe Function input issues established by the Kernel. */
|
|
35
|
+
export function functionInputIssues(reason: unknown): readonly FunctionInputIssue[] {
|
|
36
|
+
if (!reasonWithCode(reason, 'FUNCTION_INPUT_INVALID')) return Object.freeze([])
|
|
37
|
+
const issues = reason.details.issues
|
|
38
|
+
if (!Array.isArray(issues)) return Object.freeze([])
|
|
39
|
+
return Object.freeze(
|
|
40
|
+
issues.slice(0, MAXIMUM_FUNCTION_ISSUES).flatMap((candidate): FunctionInputIssue[] => {
|
|
41
|
+
if (
|
|
42
|
+
!record(candidate) ||
|
|
43
|
+
typeof candidate.code !== 'string' ||
|
|
44
|
+
!stableCode(candidate.code) ||
|
|
45
|
+
typeof candidate.message !== 'string' ||
|
|
46
|
+
candidate.message.length === 0 ||
|
|
47
|
+
candidate.message.length > MAXIMUM_FUNCTION_ISSUE_MESSAGE_LENGTH ||
|
|
48
|
+
candidate.message.normalize('NFC') !== candidate.message ||
|
|
49
|
+
containsControl(candidate.message) ||
|
|
50
|
+
(candidate.path !== undefined && !coordinate(candidate.path))
|
|
51
|
+
) {
|
|
52
|
+
return []
|
|
53
|
+
}
|
|
54
|
+
return [
|
|
55
|
+
Object.freeze({
|
|
56
|
+
code: candidate.code,
|
|
57
|
+
...(candidate.path === undefined ? {} : { path: candidate.path }),
|
|
58
|
+
message: candidate.message,
|
|
59
|
+
}),
|
|
60
|
+
]
|
|
61
|
+
}),
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Admit only public Query-input repair variants; unknown details remain machine-only. */
|
|
66
|
+
export function queryInputRepair(reason: unknown): QueryInputRepair | undefined {
|
|
67
|
+
if (!reasonWithCode(reason, 'QUERY_INPUT_INVALID')) return undefined
|
|
68
|
+
const details = reason.details
|
|
69
|
+
if (details.phase === 'decode' || details.phase === 'input') {
|
|
70
|
+
if (!exact(details, ['phase', 'path']) || !coordinate(details.path)) return undefined
|
|
71
|
+
return Object.freeze({ phase: details.phase, path: details.path })
|
|
72
|
+
}
|
|
73
|
+
if (details.phase === 'plan') {
|
|
74
|
+
const fields = details.path === undefined ? ['phase', 'issue'] : ['phase', 'issue', 'path']
|
|
75
|
+
if (
|
|
76
|
+
!exact(details, fields) ||
|
|
77
|
+
typeof details.issue !== 'string' ||
|
|
78
|
+
!stableCode(details.issue) ||
|
|
79
|
+
(details.path !== undefined && !coordinate(details.path))
|
|
80
|
+
) {
|
|
81
|
+
return undefined
|
|
82
|
+
}
|
|
83
|
+
return Object.freeze({
|
|
84
|
+
phase: 'plan',
|
|
85
|
+
issue: details.issue,
|
|
86
|
+
...(details.path === undefined ? {} : { path: details.path }),
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
if (details.phase !== 'limit') return undefined
|
|
90
|
+
const fields =
|
|
91
|
+
details.path === undefined
|
|
92
|
+
? ['phase', 'limit', 'maximum', 'actual']
|
|
93
|
+
: ['phase', 'limit', 'maximum', 'actual', 'path']
|
|
94
|
+
if (
|
|
95
|
+
!exact(details, fields) ||
|
|
96
|
+
typeof details.limit !== 'string' ||
|
|
97
|
+
!Number.isSafeInteger(details.maximum) ||
|
|
98
|
+
(details.maximum as number) < 0 ||
|
|
99
|
+
!Number.isSafeInteger(details.actual) ||
|
|
100
|
+
(details.actual as number) <= (details.maximum as number) ||
|
|
101
|
+
(details.path !== undefined && !coordinate(details.path))
|
|
102
|
+
) {
|
|
103
|
+
return undefined
|
|
104
|
+
}
|
|
105
|
+
return Object.freeze({
|
|
106
|
+
phase: 'limit',
|
|
107
|
+
limit: details.limit,
|
|
108
|
+
maximum: details.maximum as number,
|
|
109
|
+
actual: details.actual as number,
|
|
110
|
+
...(details.path === undefined ? {} : { path: details.path }),
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Decode bounded recovery guidance while preserving the admitted reason itself elsewhere. */
|
|
115
|
+
export function schemaUpgradeDetails(reason: unknown): SchemaUpgradeDetails | undefined {
|
|
116
|
+
if (!record(reason) || reason.code !== 'SCHEMA_UPGRADE_INCOMPATIBLE') return undefined
|
|
117
|
+
const details = record(reason.details) ? reason.details : {}
|
|
118
|
+
return {
|
|
119
|
+
...(typeof details.origin === 'string' ? { origin: details.origin } : {}),
|
|
120
|
+
...(typeof details.issue === 'string' ? { issue: details.issue } : {}),
|
|
121
|
+
...(typeof details.expected === 'string' ? { expected: details.expected } : {}),
|
|
122
|
+
...(typeof details.actual === 'string' ? { actual: details.actual } : {}),
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function schemaUpgradeHint(details: SchemaUpgradeDetails): string {
|
|
127
|
+
const target = details.origin ?? '<origin>'
|
|
128
|
+
const explanation =
|
|
129
|
+
details.expected !== undefined && details.actual !== undefined
|
|
130
|
+
? 'A replacement cannot change an installed Domain issuer.'
|
|
131
|
+
: 'The replacement changes an immutable part of the installed Domain.'
|
|
132
|
+
return (
|
|
133
|
+
`${explanation} If this change is intentional, first run ` +
|
|
134
|
+
`\`astrale domain uninstall ${target}\`, then install it again. ` +
|
|
135
|
+
'The Kernel refuses uninstall while dependents or business data remain; uninstall never deletes business data.'
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function reasonWithCode(
|
|
140
|
+
input: unknown,
|
|
141
|
+
code: string,
|
|
142
|
+
): input is Readonly<{ code: string; details: Readonly<Record<string, unknown>> }> {
|
|
143
|
+
return (
|
|
144
|
+
record(input) &&
|
|
145
|
+
exact(input, ['code', 'details']) &&
|
|
146
|
+
input.code === code &&
|
|
147
|
+
record(input.details)
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function coordinate(input: unknown): input is string {
|
|
152
|
+
return (
|
|
153
|
+
typeof input === 'string' &&
|
|
154
|
+
input.length <= 1_024 &&
|
|
155
|
+
input.normalize('NFC') === input &&
|
|
156
|
+
JSON_POINTER.test(input)
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function stableCode(input: string): boolean {
|
|
161
|
+
return /^[A-Z][A-Z0-9_]{0,127}$/u.test(input) && input.normalize('NFC') === input
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function containsControl(input: string): boolean {
|
|
165
|
+
for (const character of input) {
|
|
166
|
+
const point = character.codePointAt(0)!
|
|
167
|
+
if (point <= 0x1f || point === 0x7f) return true
|
|
168
|
+
}
|
|
169
|
+
return false
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function record(input: unknown): input is Readonly<Record<string, unknown>> {
|
|
173
|
+
return input !== null && typeof input === 'object' && !Array.isArray(input)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function exact(input: Readonly<Record<string, unknown>>, fields: readonly string[]): boolean {
|
|
177
|
+
const actual = Object.keys(input)
|
|
178
|
+
return actual.length === fields.length && actual.every((field) => fields.includes(field))
|
|
179
|
+
}
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
findBookmarkTrustConflicts,
|
|
5
|
+
InstanceStoreSchema,
|
|
6
|
+
normalizeInstanceKernelUrl,
|
|
7
|
+
sanitizeStore,
|
|
8
|
+
} from '../instance'
|
|
4
9
|
|
|
5
10
|
describe('InstanceStoreSchema', () => {
|
|
6
11
|
test('parses valid store with url', () => {
|
|
@@ -134,3 +139,48 @@ describe('sanitizeStore — read must not rewrite', () => {
|
|
|
134
139
|
expect(changed).toBe(false)
|
|
135
140
|
})
|
|
136
141
|
})
|
|
142
|
+
|
|
143
|
+
describe('bookmark TLS trust collisions', () => {
|
|
144
|
+
test('finds the same normalized URL with a different CA configuration', () => {
|
|
145
|
+
const store = InstanceStoreSchema.parse({
|
|
146
|
+
active: 'stable',
|
|
147
|
+
instances: {
|
|
148
|
+
stable: {
|
|
149
|
+
url: 'https://local.example/kernel/',
|
|
150
|
+
caFile: '/certs/stable.pem',
|
|
151
|
+
},
|
|
152
|
+
alias: {
|
|
153
|
+
url: 'https://local.example/kernel',
|
|
154
|
+
caFile: '/certs/old.pem',
|
|
155
|
+
},
|
|
156
|
+
other: {
|
|
157
|
+
url: 'https://other.example/kernel',
|
|
158
|
+
caFile: '/certs/old.pem',
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
expect(
|
|
164
|
+
findBookmarkTrustConflicts(
|
|
165
|
+
store,
|
|
166
|
+
'stable',
|
|
167
|
+
'https://local.example/kernel',
|
|
168
|
+
'/certs/stable.pem',
|
|
169
|
+
),
|
|
170
|
+
).toEqual([{ name: 'alias', caFile: '/certs/old.pem' }])
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test('treats custom CA versus system trust as a meaningful difference', () => {
|
|
174
|
+
const store = InstanceStoreSchema.parse({
|
|
175
|
+
active: 'custom',
|
|
176
|
+
instances: {
|
|
177
|
+
custom: { url: 'https://local.example', caFile: '/certs/local.pem' },
|
|
178
|
+
system: { url: 'https://local.example' },
|
|
179
|
+
},
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
expect(
|
|
183
|
+
findBookmarkTrustConflicts(store, 'custom', 'https://local.example', '/certs/local.pem'),
|
|
184
|
+
).toEqual([{ name: 'system', caFile: null }])
|
|
185
|
+
})
|
|
186
|
+
})
|