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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +3 -2
  2. package/dist/astrale.js +2599 -2703
  3. package/dist/public/connect-core.js +2019 -3050
  4. package/dist/public/keys/index.js +1851 -2885
  5. package/dist/public/paths/index.js +1830 -2872
  6. package/dist/types/connection/auth.d.ts +3 -0
  7. package/dist/types/lib/instance.d.ts +10 -0
  8. package/package.json +8 -8
  9. package/src/commands/__tests__/domain-uninstall.test.ts +53 -0
  10. package/src/commands/__tests__/install-identity-override.test.ts +14 -3
  11. package/src/commands/__tests__/instance-bookmark.test.ts +66 -1
  12. package/src/commands/__tests__/instance-list-rows.test.ts +1 -0
  13. package/src/commands/__tests__/instance-use.test.ts +67 -0
  14. package/src/commands/__tests__/view-build.test.ts +58 -0
  15. package/src/commands/domain/install.ts +6 -5
  16. package/src/commands/domain/uninstall.ts +128 -0
  17. package/src/commands/instance/active.ts +13 -1
  18. package/src/commands/instance/bookmark.ts +26 -3
  19. package/src/commands/instance/list.ts +18 -4
  20. package/src/commands/instance/use.ts +54 -7
  21. package/src/commands/view.ts +28 -16
  22. package/src/connection/.spec/architecture.md +5 -0
  23. package/src/connection/.spec/laws/connection.ts +20 -0
  24. package/src/connection/.spec/layout.ts +1 -0
  25. package/src/connection/__tests__/auth.test.ts +27 -1
  26. package/src/connection/__tests__/ca-fetch.test.ts +8 -1
  27. package/src/connection/__tests__/errors.test.ts +410 -36
  28. package/src/connection/__tests__/exchange.test.ts +46 -5
  29. package/src/connection/__tests__/reasons.test.ts +78 -0
  30. package/src/connection/auth.ts +11 -9
  31. package/src/connection/command.ts +1 -1
  32. package/src/connection/errors.ts +139 -160
  33. package/src/connection/exchange.ts +14 -2
  34. package/src/connection/reasons.ts +179 -0
  35. package/src/lib/__tests__/instance.test.ts +51 -1
  36. package/src/lib/__tests__/view-assets.test.ts +33 -1
  37. package/src/lib/__tests__/view-server.test.ts +68 -0
  38. package/src/lib/ca-fetch.ts +9 -3
  39. package/src/lib/instance.ts +31 -0
  40. package/src/lib/view/assets.ts +16 -2
  41. package/src/program/__tests__/program.test.ts +2 -1
  42. package/src/program/build.ts +2 -1
  43. package/studio/package.json +7 -8
  44. package/viewer/dist/main.js +57 -57
@@ -1,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 { decodeJwtExpiration, readLocalStatus, type LocalStatus } from '../lib/local-status'
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
- type FieldError = { path: string[]; code: string; message: string }
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 AstraleError (CLI-local) and every error class exported by
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 'ConnectionError':
56
- if (isRaw)
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 reasonCode =
181
- reason !== null &&
182
- typeof reason === 'object' &&
183
- typeof (reason as { readonly code?: unknown }).code === 'string'
184
- ? (reason as { readonly code: string }).code
185
- : undefined
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
- reasonCode === 'FUNCTION_INPUT_INVALID'
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: error.message,
82
+ message: displayMessage,
195
83
  ...(reason === undefined ? {} : { reason }),
196
84
  ...(hint === undefined ? {} : { hint }),
197
85
  })
198
86
  } else {
199
87
  log.error(
200
- `${chalk.bold(code === undefined ? 'RESPONSE_ERROR' : `RESPONSE_ERROR(${String(code)})`)}: ${error.message}`,
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 (reasonCode !== undefined) log.dim(` reason: ${reasonCode}`)
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
- /** Strip internal `::methodName` suffixes from paths in error messages (e.g., "/path::listChildren" → "/path") */
236
- export function stripMethodSuffix(msg: string): string {
237
- return msg.replace(/(\/[^"\s:]+)::([a-zA-Z]\w*)/g, '$1')
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
- if (name === 'Error' && /unable to connect/i.test(error.message)) {
277
- return {
278
- code: 'CONNECTION_ERROR',
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
- hint: 'Check --url / -i and that the Kernel is reachable. Try: astrale status',
281
- }
212
+ ...(url === '' ? {} : { url }),
213
+ ...(phase === undefined ? {} : { phase }),
214
+ ...(delivery === undefined ? {} : { delivery }),
215
+ ...(context === undefined ? {} : { context }),
216
+ })
217
+ return
282
218
  }
283
- return { code: name && name !== 'Error' ? name : 'UNKNOWN', message: error.message }
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 !== 'AuthenticationError' && error.name !== 'ConnectionError') return undefined
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
- const exchanged = exchangeProtocol.acceptResponse(body)
169
- const inspected = credential.inspect(exchanged.token)
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 { InstanceStoreSchema, normalizeInstanceKernelUrl, sanitizeStore } from '../instance'
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
+ })