@astrale-os/cli 0.8.1-alpha.5 → 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 (88) hide show
  1. package/README.md +4 -3
  2. package/dist/astrale.js +3391 -3163
  3. package/dist/public/connect-core.js +2041 -3066
  4. package/dist/public/keys/index.js +1854 -2895
  5. package/dist/public/paths/index.js +1833 -2882
  6. package/dist/types/connection/auth.d.ts +3 -0
  7. package/dist/types/lib/instance-target.d.ts +2 -0
  8. package/dist/types/lib/instance.d.ts +10 -0
  9. package/dist/types/lib/invocation.d.ts +3 -0
  10. package/dist/types/lib/log.d.ts +6 -5
  11. package/dist/types/lib/output.d.ts +6 -2
  12. package/package.json +8 -8
  13. package/src/commands/__tests__/domain-uninstall.test.ts +53 -0
  14. package/src/commands/__tests__/install-identity-override.test.ts +14 -3
  15. package/src/commands/__tests__/instance-bookmark.test.ts +66 -1
  16. package/src/commands/__tests__/instance-list-rows.test.ts +1 -0
  17. package/src/commands/__tests__/instance-use.test.ts +67 -0
  18. package/src/commands/__tests__/introspect-parse.test.ts +21 -0
  19. package/src/commands/__tests__/logs.test.ts +5 -0
  20. package/src/commands/__tests__/read-commands.test.ts +11 -1
  21. package/src/commands/__tests__/token-ttl.test.ts +21 -0
  22. package/src/commands/__tests__/view-build.test.ts +58 -0
  23. package/src/commands/auth/token.ts +19 -7
  24. package/src/commands/call.ts +18 -43
  25. package/src/commands/domain/install.ts +6 -5
  26. package/src/commands/domain/uninstall.ts +128 -0
  27. package/src/commands/get.ts +23 -5
  28. package/src/commands/identity/create.ts +11 -2
  29. package/src/commands/identity/delete.ts +8 -2
  30. package/src/commands/identity/export.ts +8 -2
  31. package/src/commands/identity/import.ts +11 -2
  32. package/src/commands/identity/sync.ts +8 -2
  33. package/src/commands/identity/unsync.ts +12 -2
  34. package/src/commands/identity/use.ts +8 -2
  35. package/src/commands/identity/whoami.ts +1 -1
  36. package/src/commands/instance/active.ts +13 -1
  37. package/src/commands/instance/bookmark.ts +26 -3
  38. package/src/commands/instance/list.ts +18 -4
  39. package/src/commands/instance/use.ts +54 -7
  40. package/src/commands/introspect.ts +117 -0
  41. package/src/commands/logs.ts +50 -6
  42. package/src/commands/mutate.ts +2 -3
  43. package/src/commands/query.ts +3 -5
  44. package/src/commands/token.ts +35 -8
  45. package/src/commands/update.ts +30 -9
  46. package/src/commands/view.ts +33 -19
  47. package/src/connection/.spec/architecture.md +5 -0
  48. package/src/connection/.spec/laws/connection.ts +20 -0
  49. package/src/connection/.spec/layout.ts +1 -0
  50. package/src/connection/__tests__/auth.test.ts +27 -1
  51. package/src/connection/__tests__/ca-fetch.test.ts +8 -1
  52. package/src/connection/__tests__/credential.test.ts +10 -0
  53. package/src/connection/__tests__/errors.test.ts +430 -36
  54. package/src/connection/__tests__/exchange.test.ts +46 -5
  55. package/src/connection/__tests__/reasons.test.ts +78 -0
  56. package/src/connection/auth.ts +11 -9
  57. package/src/connection/command.ts +1 -1
  58. package/src/connection/credential.ts +3 -0
  59. package/src/connection/errors.ts +195 -154
  60. package/src/connection/exchange.ts +14 -2
  61. package/src/connection/reasons.ts +179 -0
  62. package/src/connection/session.ts +9 -0
  63. package/src/connection/target.ts +1 -0
  64. package/src/graph/__tests__/mutation.test.ts +6 -0
  65. package/src/graph/mutation.ts +13 -0
  66. package/src/identity/__tests__/registry.test.ts +1 -1
  67. package/src/identity/registry.ts +12 -5
  68. package/src/lib/__tests__/command-dx.test.ts +35 -20
  69. package/src/lib/__tests__/instance-target.test.ts +17 -0
  70. package/src/lib/__tests__/instance.test.ts +51 -1
  71. package/src/lib/__tests__/output.test.ts +12 -0
  72. package/src/lib/__tests__/view-assets.test.ts +33 -1
  73. package/src/lib/__tests__/view-server.test.ts +68 -0
  74. package/src/lib/ca-fetch.ts +9 -3
  75. package/src/lib/command-dx.ts +40 -23
  76. package/src/lib/instance-target.ts +18 -2
  77. package/src/lib/instance.ts +31 -0
  78. package/src/lib/invocation.ts +11 -0
  79. package/src/lib/log.ts +17 -9
  80. package/src/lib/output.ts +20 -4
  81. package/src/lib/view/assets.ts +16 -2
  82. package/src/program/__tests__/program.test.ts +4 -1
  83. package/src/program/build.ts +5 -9
  84. package/src/program/options.ts +2 -1
  85. package/src/state/__tests__/identities.test.ts +2 -2
  86. package/src/state/identities.ts +3 -10
  87. package/studio/package.json +7 -8
  88. package/viewer/dist/main.js +57 -57
@@ -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
+ }
@@ -19,12 +19,20 @@ import {
19
19
  import { AstraleError } from '../errors'
20
20
  import { fetchWithCaFile } from '../lib/ca-fetch'
21
21
  import { readConfig } from '../lib/config'
22
+ import { log } from '../lib/log'
23
+ import { isMachine } from '../lib/output'
22
24
  import { createCliCredential, validateCredentialSelection } from './credential'
23
25
  import { resolveAdminConnectionTarget, resolveConnectionTarget } from './target'
24
26
 
25
27
  const DEFAULT_TIMEOUT_MS = 30_000
26
28
  const MAXIMUM_ROUTE_AGE_MS = 5 * 60_000
27
29
 
30
+ function warnMissingExplicitTarget(options: ConnectionOptions, target: ConnectionTarget): void {
31
+ if (options.instance !== undefined || options.url !== undefined) return
32
+ if (!isMachine(options)) return
33
+ log.warn(`No -i/--url; using ${target.slug ?? target.url}`)
34
+ }
35
+
28
36
  export interface ConnectionContext {
29
37
  readonly session: ClientSession
30
38
  readonly graph: GraphApi
@@ -55,6 +63,7 @@ export async function withClientSession<Value>(
55
63
  const target = await resolveConnectionTarget(options, config, {
56
64
  managed: (slug) => lookupManagedInstance(slug, options),
57
65
  })
66
+ warnMissingExplicitTarget(options, target)
58
67
  return runResolvedClientSession(target, timeoutMs, options, config, action, openConnection)
59
68
  }
60
69
 
@@ -17,6 +17,7 @@ export interface ConnectionOptions {
17
17
  readonly as?: string
18
18
  readonly creds?: string
19
19
  readonly anonymous?: boolean
20
+ readonly ci?: boolean
20
21
  }
21
22
 
22
23
  export interface AdminConnectionOptions extends ConnectionOptions {
@@ -42,4 +42,10 @@ describe('prepareMutation', () => {
42
42
  /Legacy PatchData/,
43
43
  )
44
44
  })
45
+
46
+ test('rejects an empty operations list in CLI language', () => {
47
+ expect(() => prepareMutation({ preconditions: [], operations: [] })).toThrow(
48
+ 'Mutation V3 requires at least one operation',
49
+ )
50
+ })
45
51
  })
@@ -9,10 +9,23 @@ export function prepareMutation(input: unknown): MutationASTValue {
9
9
  'Legacy PatchData { nodes, edges } is not Mutation V3. Author { preconditions, operations } or a canonical astrale.graph.mutation/v3 document.',
10
10
  )
11
11
  }
12
+ if (hasEmptyOperations(input)) {
13
+ throw new TypeError('Mutation V3 requires at least one operation')
14
+ }
12
15
  if (isCanonicalCandidate(input)) return MutationAST.decode(input)
13
16
  return MutationAST.create(input as MutationInput)
14
17
  }
15
18
 
19
+ function hasEmptyOperations(input: unknown): boolean {
20
+ return (
21
+ input !== null &&
22
+ typeof input === 'object' &&
23
+ !Array.isArray(input) &&
24
+ Array.isArray((input as { operations?: unknown }).operations) &&
25
+ (input as { operations: unknown[] }).operations.length === 0
26
+ )
27
+ }
28
+
16
29
  function isCanonicalCandidate(input: unknown): boolean {
17
30
  return (
18
31
  input !== null &&
@@ -49,7 +49,7 @@ describe('identity registry journey', () => {
49
49
  bobKey: boolean
50
50
  }
51
51
  expect(result.store.default).toBe('alice')
52
- expect(Object.keys(result.store.identities).sort()).toEqual(['alice', 'manager', 'workos'])
52
+ expect(Object.keys(result.store.identities).sort()).toEqual(['alice', 'workos'])
53
53
  expect(result.store.identities.alice.registrations?.production).toEqual({
54
54
  iss: 'https://kernel.example',
55
55
  sub: 'node-alice',
@@ -1,3 +1,4 @@
1
+ import { AstraleError } from '../errors'
1
2
  import { persistKeypair, removeKeypair } from '../keys/index'
2
3
  import { deleteIdpSession } from '../lib/idp'
3
4
  import { validateName } from '../lib/validation'
@@ -40,8 +41,12 @@ export async function createIdentity(
40
41
  kid: generated.kid,
41
42
  issuer: options.issuer,
42
43
  }
44
+ const hasDefault = store.default !== '' && store.identities[store.default] !== undefined
43
45
  return {
44
- next: { ...store, identities: { ...store.identities, [name]: identity } },
46
+ next: {
47
+ default: hasDefault ? store.default : name,
48
+ identities: { ...store.identities, [name]: identity },
49
+ },
45
50
  value: identity,
46
51
  }
47
52
  })
@@ -83,12 +88,14 @@ export async function setDefault(name: string): Promise<void> {
83
88
 
84
89
  export async function getDefault(): Promise<Identity & { readonly name: string }> {
85
90
  const store = await readIdentities()
86
- const identity = store.identities[store.default]
87
- if (!identity) {
88
- throw new Error(
89
- `Default identity "${store.default}" not found. Run: astrale identity create ${store.default}`,
91
+ if (store.default === '' || store.identities[store.default] === undefined) {
92
+ throw new AstraleError(
93
+ 'NO_IDENTITY',
94
+ 'No default identity.',
95
+ 'Run: astrale identity create <name>',
90
96
  )
91
97
  }
98
+ const identity = store.identities[store.default]!
92
99
  return { ...identity, name: store.default }
93
100
  }
94
101
 
@@ -11,6 +11,20 @@ import { collectCommandCatalog, renderCommanderError } from '../command-dx'
11
11
  const ANSI_RE = new RegExp(String.fromCharCode(27) + '\\[[0-9;]*m', 'g')
12
12
  const stripAnsi = (s: string): string => s.replace(ANSI_RE, '')
13
13
 
14
+ function dx(
15
+ program: Awaited<ReturnType<typeof buildProgram>>,
16
+ error: CommanderError,
17
+ argv: string[],
18
+ ) {
19
+ const rendered = JSON.parse(stripAnsi(renderCommanderError(program, error, argv))) as {
20
+ error: string
21
+ message: string
22
+ detail: string
23
+ }
24
+ expect(rendered.error).toBe('USAGE_ERROR')
25
+ return rendered
26
+ }
27
+
14
28
  describe('command DX suggestions', () => {
15
29
  test('collects command paths from the registered program tree', async () => {
16
30
  const program = await buildProgram()
@@ -24,6 +38,7 @@ describe('command DX suggestions', () => {
24
38
  expect(usages).toContain('update')
25
39
  expect(usages).toContain('query [sources...]')
26
40
  expect(usages).toContain('get <target>')
41
+ expect(usages).toContain('introspect <target>')
27
42
  expect(usages).toContain('status')
28
43
  expect(usages).not.toContain('ls <source>')
29
44
  expect(usages).not.toContain('describe <target>')
@@ -36,13 +51,13 @@ describe('command DX suggestions', () => {
36
51
  'commander.excessArguments',
37
52
  'error: too many arguments. Expected 0 arguments but got 2.',
38
53
  )
39
- const rendered = stripAnsi(renderCommanderError(program, error, ['delete', 'demo-system']))
54
+ const rendered = dx(program, error, ['delete', 'demo-system'])
40
55
 
41
- expect(rendered).toContain('Unknown command: astrale delete demo-system')
42
- expect(rendered).toContain('"delete" is available under:')
43
- expect(rendered).toContain('astrale identity delete <name>')
44
- expect(rendered).toContain('astrale instance delete <id>')
45
- expect(rendered).not.toContain('too many arguments')
56
+ expect(rendered.message).toContain('Unknown command: astrale delete demo-system')
57
+ expect(rendered.detail).toContain('"delete" is available under:')
58
+ expect(rendered.detail).toContain('astrale identity delete <name>')
59
+ expect(rendered.detail).toContain('astrale instance delete <id>')
60
+ expect(rendered.detail).not.toContain('too many arguments')
46
61
  })
47
62
 
48
63
  test('points retired describe at get', async () => {
@@ -52,23 +67,23 @@ describe('command DX suggestions', () => {
52
67
  'commander.unknownCommand',
53
68
  "error: unknown command 'describe'",
54
69
  )
55
- const rendered = stripAnsi(renderCommanderError(program, error, ['describe', '@note']))
70
+ const rendered = dx(program, error, ['describe', '@note'])
56
71
 
57
- expect(rendered).toContain('Unknown command: astrale describe @note')
58
- expect(rendered).toContain('astrale describe` was removed')
59
- expect(rendered).toContain('astrale get <target>')
60
- expect(rendered).not.toContain('Did you mean:')
72
+ expect(rendered.message).toContain('Unknown command: astrale describe @note')
73
+ expect(rendered.detail).toContain('astrale describe` was removed')
74
+ expect(rendered.detail).toContain('astrale get <target>')
75
+ expect(rendered.detail).not.toContain('Did you mean:')
61
76
  })
62
77
 
63
78
  test('points retired ls at query', async () => {
64
79
  const program = await buildProgram()
65
80
  const error = new CommanderError(1, 'commander.unknownCommand', "error: unknown command 'ls'")
66
- const rendered = stripAnsi(renderCommanderError(program, error, ['ls', '@note']))
81
+ const rendered = dx(program, error, ['ls', '@note'])
67
82
 
68
- expect(rendered).toContain('Unknown command: astrale ls @note')
69
- expect(rendered).toContain('astrale ls` was removed')
70
- expect(rendered).toContain('astrale query <source> --edge <class>')
71
- expect(rendered).not.toContain('Did you mean:')
83
+ expect(rendered.message).toContain('Unknown command: astrale ls @note')
84
+ expect(rendered.detail).toContain('astrale ls` was removed')
85
+ expect(rendered.detail).toContain('astrale query <source> --edge <class>')
86
+ expect(rendered.detail).not.toContain('Did you mean:')
72
87
  })
73
88
 
74
89
  test('suggests nearest command for typo paths', async () => {
@@ -78,10 +93,10 @@ describe('command DX suggestions', () => {
78
93
  'commander.excessArguments',
79
94
  'error: too many arguments. Expected 0 arguments but got 2.',
80
95
  )
81
- const rendered = stripAnsi(renderCommanderError(program, error, ['indetityl', 'ist']))
96
+ const rendered = dx(program, error, ['indetityl', 'ist'])
82
97
 
83
- expect(rendered).toContain('Unknown command: astrale indetityl ist')
84
- expect(rendered).toContain('Did you mean:')
85
- expect(rendered).toContain('astrale identity list')
98
+ expect(rendered.message).toContain('Unknown command: astrale indetityl ist')
99
+ expect(rendered.detail).toContain('Did you mean:')
100
+ expect(rendered.detail).toContain('astrale identity list')
86
101
  })
87
102
  })
@@ -225,4 +225,21 @@ describe('isManagedInstanceNotFound: kernel InternalKernelError wrap', () => {
225
225
  expect(caught).toBeInstanceOf(AstraleError)
226
226
  expect((caught as AstraleError).code).toBe('INSTANCE_NOT_FOUND')
227
227
  })
228
+
229
+ test('maps Admin token-exchange failure to INSTANCE_NOT_FOUND', async () => {
230
+ const managed = async () => {
231
+ throw new AstraleError(
232
+ 'TOKEN_EXCHANGE_SOURCE_INVALID',
233
+ 'The source identity credential has no valid expiration.',
234
+ )
235
+ }
236
+ const opts = {
237
+ config: DEFAULT_CONFIG,
238
+ instances: { instances: {} },
239
+ managed,
240
+ } as unknown as Parameters<typeof resolveInstanceTarget>[1]
241
+ await expect(
242
+ resolveInstanceTarget({ source: 'name', name: 'ghost' }, opts),
243
+ ).rejects.toMatchObject({ code: 'INSTANCE_NOT_FOUND' })
244
+ })
228
245
  })
@@ -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
+ })
@@ -112,6 +112,18 @@ describe('denoise', () => {
112
112
  })
113
113
  expect(out).toEqual({ id: 'x', props: { name: 'n' } })
114
114
  })
115
+
116
+ test('strips qualified property keys whose leaf is schema', () => {
117
+ const out = denoise({
118
+ props: {
119
+ 'kernel.astrale.ai:class.Domain.property.schema': { huge: true },
120
+ 'kernel.astrale.ai:class.Domain.property.origin': 'kernel.astrale.ai',
121
+ },
122
+ })
123
+ expect(out).toEqual({
124
+ props: { 'kernel.astrale.ai:class.Domain.property.origin': 'kernel.astrale.ai' },
125
+ })
126
+ })
115
127
  })
116
128
 
117
129
  describe('presentList', () => {
@@ -1,5 +1,14 @@
1
1
  import { afterEach, describe, expect, test } from 'bun:test'
2
- import { mkdtemp, mkdir, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises'
2
+ import {
3
+ mkdtemp,
4
+ mkdir,
5
+ readFile,
6
+ realpath,
7
+ rm,
8
+ symlink,
9
+ utimes,
10
+ writeFile,
11
+ } from 'node:fs/promises'
3
12
  import { tmpdir } from 'node:os'
4
13
  import { dirname, join, relative } from 'node:path'
5
14
  import { pathToFileURL } from 'node:url'
@@ -61,6 +70,29 @@ describe('viewer asset resolution', () => {
61
70
  expect(await readFile(join(dist, 'main.js'), 'utf8')).toContain('viewer ready')
62
71
  })
63
72
 
73
+ test('rebuilds stale viewer assets in a source checkout', async () => {
74
+ const root = await mkdtemp(join(tmpdir(), 'astrale-view-stale-'))
75
+ temporaryDirectories.push(root)
76
+ const module = join(root, 'src', 'lib', 'view', 'assets.ts')
77
+ const source = join(root, 'viewer')
78
+ const dist = join(source, 'dist')
79
+
80
+ await mkdir(dirname(module), { recursive: true })
81
+ await mkdir(dist, { recursive: true })
82
+ await writeFile(module, '')
83
+ await writeFile(join(source, 'main.ts'), 'document.body.textContent = "fresh viewer"\n')
84
+ await writeFile(join(source, 'index.html'), '<!doctype html><body>fresh</body>\n')
85
+ await writeFile(join(dist, 'main.js'), 'document.body.textContent = "stale viewer"\n')
86
+ await writeFile(join(dist, 'index.html'), '<!doctype html><body>stale</body>\n')
87
+ const future = new Date(Date.now() + 2_000)
88
+ await utimes(join(source, 'main.ts'), future, future)
89
+
90
+ await ensureViewerAssets(pathToFileURL(module).href, join(root, 'bin', 'astrale'))
91
+
92
+ expect(await readFile(join(dist, 'main.js'), 'utf8')).toContain('fresh viewer')
93
+ expect(await readFile(join(dist, 'index.html'), 'utf8')).toContain('fresh')
94
+ })
95
+
64
96
  test('uses the bundled module location when invoked through a global bin symlink', async () => {
65
97
  const root = await mkdtemp(join(tmpdir(), 'astrale-view-bundle-'))
66
98
  temporaryDirectories.push(root)
@@ -0,0 +1,68 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { once } from 'node:events'
3
+
4
+ import type { ViewServeConfig } from '../view/session'
5
+
6
+ import { findFreePort } from '../port'
7
+ import { startViewServer } from '../view/server'
8
+
9
+ const digest = (character: string) => `sha256:${character.repeat(64)}` as const
10
+ const target = (value: string) => value as ViewServeConfig['session']['view']['target']
11
+ const issuer = (value: string) => value as ViewServeConfig['session']['view']['route']['issuer']
12
+ const revision = (character: string) =>
13
+ digest(character) as ViewServeConfig['session']['view']['route']['revision']
14
+
15
+ describe('view session server credentials', () => {
16
+ /** @evidence TEST-CLI-PLAIN-VIEW-RECEIVES-NO-CREDENTIAL */
17
+ test('refuses to mint a token for a handshake-none View', async () => {
18
+ const nonce = 'plain-view'
19
+ const port = await findFreePort(48_000, 200)
20
+ if (port === null) throw new Error('test port window exhausted')
21
+ const config = {
22
+ session: {
23
+ id: 'v-plain',
24
+ pid: 0,
25
+ port,
26
+ nonce,
27
+ pageUrl: `http://127.0.0.1:${port}/`,
28
+ view: {
29
+ target: target('/:example.test'),
30
+ route: {
31
+ key: 'example.test:view.public',
32
+ declaration: { target: { kind: 'domain' }, auth: 'required' },
33
+ href: 'https://example.test/ui/public',
34
+ handshake: 'none',
35
+ issuer: issuer('https://example.test'),
36
+ etag: digest('a'),
37
+ revision: revision('b'),
38
+ },
39
+ },
40
+ createdAt: '2026-08-20T00:00:00.000Z',
41
+ },
42
+ kernel: { creds: 'must-not-be-used' },
43
+ proxy: {
44
+ kernelUrl: 'https://kernel.test',
45
+ issuer: 'https://kernel.test',
46
+ direct: true,
47
+ },
48
+ idleMs: 60_000,
49
+ } satisfies ViewServeConfig
50
+ const server = startViewServer(config)
51
+ await once(server, 'listening')
52
+
53
+ try {
54
+ const response = await fetch(`http://127.0.0.1:${port}/s/${nonce}/token`, {
55
+ method: 'POST',
56
+ })
57
+
58
+ expect(response.status).toBe(403)
59
+ expect(await response.json()).toEqual({
60
+ error: 'plain views have no Astrale credential privilege',
61
+ })
62
+ } finally {
63
+ await new Promise<void>((resolve, reject) => {
64
+ server.close((error) => (error ? reject(error) : resolve()))
65
+ })
66
+ }
67
+ })
68
+ })
@@ -3,6 +3,7 @@ import type { IncomingHttpHeaders, request as httpRequest } from 'node:http'
3
3
  import { Buffer } from 'node:buffer'
4
4
  import { readFileSync } from 'node:fs'
5
5
  import { request as httpsRequest } from 'node:https'
6
+ import { rootCertificates } from 'node:tls'
6
7
 
7
8
  /** Create a Fetch capability whose HTTPS requests trust one CLI-selected CA file. */
8
9
  export function fetchWithCaFile(
@@ -32,7 +33,9 @@ function fetchWithNode(url: URL, init: RequestInit | undefined, ca: Buffer): Pro
32
33
  {
33
34
  method: init?.method ?? 'GET',
34
35
  headers: headersInitToRecord(init?.headers),
35
- ca,
36
+ // `ca` replaces Node's default trust set. Retain public roots because one
37
+ // Client Session may reach both a private Kernel and a public Domain issuer.
38
+ ca: [...rootCertificates, ca],
36
39
  },
37
40
  (response) => {
38
41
  const chunks: Buffer[] = []
@@ -40,8 +43,11 @@ function fetchWithNode(url: URL, init: RequestInit | undefined, ca: Buffer): Pro
40
43
  chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)),
41
44
  )
42
45
  response.on('end', () => {
43
- const result = new Response(Buffer.concat(chunks), {
44
- status: response.statusCode ?? 0,
46
+ const status = response.statusCode ?? 0
47
+ const body =
48
+ status === 204 || status === 205 || status === 304 ? null : Buffer.concat(chunks)
49
+ const result = new Response(body, {
50
+ status,
45
51
  statusText: response.statusMessage,
46
52
  headers: responseHeaders(response.headers),
47
53
  })