@astrale-os/cli 1.0.0-beta.1 → 1.0.0-beta.2

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 (180) hide show
  1. package/README.md +7 -11
  2. package/dist/astrale.js +3737 -3523
  3. package/dist/public/connect-core.js +396 -392
  4. package/dist/public/keys/index.js +371 -378
  5. package/dist/public/paths/index.js +371 -378
  6. package/dist/types/lib/idp.d.ts +6 -0
  7. package/dist/types/lib/meta.d.ts +2 -2
  8. package/package.json +6 -4
  9. package/src/admin/binding.ts +168 -0
  10. package/src/admin/catalog/__tests__/client.test.ts +2 -2
  11. package/src/admin/catalog/client.ts +4 -9
  12. package/src/admin/instance/__tests__/client.test.ts +2 -2
  13. package/src/admin/instance/client.ts +6 -11
  14. package/src/commands/__tests__/auth-login.test.ts +54 -1
  15. package/src/commands/__tests__/call.test.ts +5 -1
  16. package/src/commands/__tests__/domain-install-operation.test.ts +23 -7
  17. package/src/commands/__tests__/domain-install-owned.test.ts +5 -1
  18. package/src/commands/__tests__/domain-list.test.ts +83 -4
  19. package/src/commands/__tests__/install-identity-override.test.ts +30 -19
  20. package/src/commands/__tests__/studio-descriptor.test.ts +22 -0
  21. package/src/commands/__tests__/update.test.ts +23 -16
  22. package/src/commands/domain/install.ts +11 -15
  23. package/src/commands/domain/list.ts +17 -12
  24. package/src/commands/studio.ts +20 -0
  25. package/src/commands/update.ts +9 -5
  26. package/src/connection/__tests__/errors.test.ts +16 -12
  27. package/src/connection/__tests__/exchange.test.ts +6 -5
  28. package/src/connection/exchange.ts +5 -4
  29. package/src/lib/__tests__/domain-publication.test.ts +144 -0
  30. package/src/lib/__tests__/idp-session.test.ts +22 -0
  31. package/src/lib/__tests__/idp.test.ts +2 -0
  32. package/src/lib/__tests__/instance.test.ts +6 -0
  33. package/src/lib/__tests__/studio-server-deps.test.ts +53 -16
  34. package/src/lib/__tests__/update.test.ts +12 -0
  35. package/src/lib/domain-publication.ts +102 -0
  36. package/src/lib/idp.ts +5 -1
  37. package/src/lib/instance.ts +6 -1
  38. package/src/lib/login-flow.ts +13 -3
  39. package/src/lib/meta.ts +2 -2
  40. package/src/lib/update.ts +4 -4
  41. package/src/program/__tests__/program.test.ts +1 -1
  42. package/studio/client/dist/assets/{elk-api-D2xgMJvi.js → elk-api-lwhFo7vB.js} +1 -1
  43. package/studio/client/dist/assets/index-B-c-smo9.js +8 -0
  44. package/studio/client/dist/assets/index-BckHuAWk.js +81 -0
  45. package/studio/client/dist/assets/index-C4aNQ6dz.css +1 -0
  46. package/studio/client/dist/index.html +2 -2
  47. package/studio/package.json +3 -1
  48. package/studio/server/agent/bridge/stdio.ts +8 -1
  49. package/studio/server/agent/conversation.test.ts +10 -5
  50. package/studio/server/agent/conversation.ts +56 -8
  51. package/studio/server/agent/harness/codex/loadout.ts +18 -21
  52. package/studio/server/agent/harness/gateway/config.ts +20 -18
  53. package/studio/server/agent/harness/selection.ts +9 -1
  54. package/studio/server/agent/prompts/system.ts +3 -2
  55. package/studio/server/agent/prompts/turn.ts +13 -4
  56. package/studio/server/agent/run/completion.ts +2 -2
  57. package/studio/server/agent/run/preparation.ts +6 -5
  58. package/studio/server/agent/run/transcript.test.ts +35 -1
  59. package/studio/server/agent/run/transcript.ts +169 -3
  60. package/studio/server/agent/run/usage.test.ts +24 -0
  61. package/studio/server/agent/run/usage.ts +26 -1
  62. package/studio/server/api/canvas.ts +44 -0
  63. package/studio/server/api/comments.ts +85 -0
  64. package/studio/server/api/context.ts +61 -0
  65. package/studio/server/api/deployment.ts +39 -0
  66. package/studio/server/api/documents.ts +55 -0
  67. package/studio/server/api/domain.ts +59 -0
  68. package/studio/server/api/http.ts +45 -0
  69. package/studio/server/api/project.ts +57 -0
  70. package/studio/server/api/schema.ts +12 -0
  71. package/studio/server/api/views.ts +57 -0
  72. package/studio/server/api/workspace.ts +60 -0
  73. package/studio/server/api.test.ts +147 -0
  74. package/studio/server/api.ts +20 -422
  75. package/studio/server/cache.test.ts +111 -0
  76. package/studio/server/cache.ts +163 -9
  77. package/studio/server/cli-consumers.test.ts +494 -0
  78. package/studio/server/cli.test.ts +88 -0
  79. package/studio/server/cli.ts +240 -0
  80. package/studio/server/client-package.ts +25 -1
  81. package/studio/server/domain.test.ts +30 -2
  82. package/studio/server/domain.ts +14 -2
  83. package/studio/server/environment/dotenv-preview.test.ts +39 -0
  84. package/studio/server/environment/dotenv-preview.ts +27 -0
  85. package/studio/server/environment/files.test.ts +53 -0
  86. package/studio/server/{state/env.ts → environment/files.ts} +7 -48
  87. package/studio/server/{state → handoff}/copy.ts +21 -6
  88. package/studio/server/handoff/service.test.ts +22 -0
  89. package/studio/server/{state/handoff.ts → handoff/service.ts} +21 -14
  90. package/studio/server/instances/active.ts +72 -0
  91. package/studio/server/instances/deploy-record.test.ts +31 -0
  92. package/studio/server/instances/deploy-record.ts +37 -0
  93. package/studio/server/instances/deploy.ts +56 -0
  94. package/studio/server/{state/instance.test.ts → instances/probe.test.ts} +25 -21
  95. package/studio/server/instances/probe.ts +67 -0
  96. package/studio/server/instances/status.test.ts +12 -0
  97. package/studio/server/instances/status.ts +49 -0
  98. package/studio/server/introspect/anatomy/client-tree.ts +57 -0
  99. package/studio/server/introspect/anatomy/env-fields.ts +66 -0
  100. package/studio/server/introspect/anatomy/schema-definition.ts +53 -0
  101. package/studio/server/introspect/anatomy/source.ts +181 -0
  102. package/studio/server/introspect/anatomy/views/legacy.ts +232 -0
  103. package/studio/server/introspect/anatomy/views/routes.ts +105 -0
  104. package/studio/server/introspect/anatomy/views.ts +58 -0
  105. package/studio/server/introspect/anatomy-extras.test.ts +91 -4
  106. package/studio/server/introspect/anatomy-extras.ts +7 -726
  107. package/studio/server/introspect/anatomy.ts +46 -19
  108. package/studio/server/introspect/bundle.ts +15 -6
  109. package/studio/server/introspect/canonical-schema.test.ts +79 -0
  110. package/studio/server/introspect/canonical-schema.ts +80 -18
  111. package/studio/server/introspect/config-preview.test.ts +32 -0
  112. package/studio/server/introspect/config-preview.ts +119 -0
  113. package/studio/server/introspect/diff.test.ts +7 -11
  114. package/studio/server/introspect/diff.ts +28 -37
  115. package/studio/server/introspect/extractor.ts +8 -6
  116. package/studio/server/introspect/hash.ts +5 -2
  117. package/studio/server/introspect/overlay-tsmorph.ts +7 -1305
  118. package/studio/server/introspect/revision.test.ts +54 -0
  119. package/studio/server/introspect/revision.ts +49 -0
  120. package/studio/server/introspect/runtime.test.ts +63 -5
  121. package/studio/server/introspect/runtime.ts +49 -3
  122. package/studio/server/introspect/schema-ir-json.ts +181 -0
  123. package/studio/server/introspect/source-overlay/annotations.ts +14 -0
  124. package/studio/server/introspect/source-overlay/handlers.ts +681 -0
  125. package/studio/server/introspect/source-overlay/kernel-calls.ts +49 -0
  126. package/studio/server/introspect/source-overlay/project.ts +248 -0
  127. package/studio/server/introspect/source-overlay/spans.ts +360 -0
  128. package/studio/server/json.ts +46 -0
  129. package/studio/server/lifecycle.ts +5 -1
  130. package/studio/server/sse.test.ts +26 -0
  131. package/studio/server/sse.ts +4 -1
  132. package/studio/server/state/baseline.test.ts +212 -1
  133. package/studio/server/state/baseline.ts +145 -29
  134. package/studio/server/state/comments.test.ts +66 -1
  135. package/studio/server/state/comments.ts +173 -10
  136. package/studio/server/state/context.ts +34 -2
  137. package/studio/server/state/documents.ts +42 -1
  138. package/studio/server/state/integrations.ts +27 -1
  139. package/studio/server/state/layout.test.ts +34 -0
  140. package/studio/server/state/layout.ts +44 -17
  141. package/studio/server/state/settings.ts +29 -22
  142. package/studio/server/state/store.test.ts +122 -0
  143. package/studio/server/state/store.ts +66 -17
  144. package/studio/server/state/visibility.ts +22 -7
  145. package/studio/server/views/model.test.ts +57 -0
  146. package/studio/server/views/model.ts +66 -0
  147. package/studio/server/views/runtime.ts +40 -0
  148. package/studio/server/views/selection-repository.test.ts +31 -0
  149. package/studio/server/views/selection-repository.ts +60 -0
  150. package/studio/server/views/session.test.ts +73 -0
  151. package/studio/server/views/session.ts +166 -0
  152. package/studio/server/{state/views.test.ts → views/target.test.ts} +8 -159
  153. package/studio/server/views/target.ts +248 -0
  154. package/studio/server/watch.ts +1 -1
  155. package/studio/server/workspace/catalog.test.ts +17 -0
  156. package/studio/server/workspace/catalog.ts +43 -0
  157. package/studio/server/{state → workspace}/create.test.ts +1 -1
  158. package/studio/server/{state → workspace}/create.ts +1 -1
  159. package/studio/server/{state → workspace}/git.ts +1 -1
  160. package/studio/server/workspace/updates.ts +83 -0
  161. package/studio/server/workspace-state.ts +1 -1
  162. package/studio/shared/contracts/README.md +16 -0
  163. package/studio/shared/contracts/agent.ts +270 -0
  164. package/studio/shared/contracts/runtime.ts +50 -0
  165. package/studio/shared/contracts/schema.ts +462 -0
  166. package/studio/shared/contracts/surface.test.ts +319 -0
  167. package/studio/shared/contracts/workspace.ts +274 -0
  168. package/studio/shared/layout.test.ts +116 -0
  169. package/studio/shared/schema/identity.test.ts +58 -0
  170. package/studio/shared/schema/identity.ts +82 -0
  171. package/studio/shared/types.ts +11 -1099
  172. package/studio/tsconfig.json +1 -1
  173. package/viewer/dist/main.js +35 -35
  174. package/studio/client/dist/assets/index-BMdnsIJA.css +0 -1
  175. package/studio/client/dist/assets/index-D-vRV8w7.js +0 -8
  176. package/studio/client/dist/assets/index-LGSWRrk8.js +0 -81
  177. package/studio/server/state/catalog.ts +0 -117
  178. package/studio/server/state/instance.ts +0 -280
  179. package/studio/server/state/updates.ts +0 -63
  180. package/studio/server/state/views.ts +0 -535
@@ -1,436 +1,34 @@
1
1
  /**
2
- * api.ts — the JSON API router. Pure read endpoints come from the cache; writes
3
- * go through the allow-listed state modules. Returns null for non-/api paths.
2
+ * Studio JSON API composition root. It owns only the public `/api/` boundary,
3
+ * cross-site mutation guard, workspace-vs-domain addressing, and final 404.
4
+ * Capability modules under `api/` own the route implementations.
4
5
  */
5
- import type { StudioEvent } from '../shared/types'
6
+ import { handleDomainRoute } from './api/domain'
7
+ import { crossSiteBlocked, json, notFound, type Notify } from './api/http'
8
+ import { handleWorkspaceRoute } from './api/workspace'
9
+ import { getDomain } from './domain'
6
10
 
7
- import { handleAgentRoute } from './agent/routes'
8
- import { getBundle, getAnatomy, getCore, invalidate } from './cache'
9
- import { type DomainHandle, allDomains, depsInstalled, getDomain } from './domain'
10
- import { schemaRefs } from './introspect/schema-refs'
11
- import { buildCatalog } from './state/catalog'
12
- import {
13
- addThreadEntry,
14
- deleteComment,
15
- editThreadEntry,
16
- markOrphans,
17
- mergeReply,
18
- readComments,
19
- setStatus,
20
- upsertComment,
21
- } from './state/comments'
22
- import {
23
- addUserContext,
24
- deleteContext,
25
- readContext,
26
- setAutoInclude,
27
- updateContext,
28
- } from './state/context'
29
- import { buildCopyMarkdown } from './state/copy'
30
- import { createDomain } from './state/create'
31
- import {
32
- addDocument,
33
- deleteDocument,
34
- listDocuments,
35
- readDocument,
36
- updateDocument,
37
- } from './state/documents'
38
- import { isEnvName, readEnvModel, writeEnvUpdates } from './state/env'
39
- import { detectGit } from './state/git'
40
- import { refreshAuto } from './state/handoff'
41
- import {
42
- activeInstanceName,
43
- instanceStatus,
44
- listInstances,
45
- runDeploy,
46
- setActiveInstance,
47
- } from './state/instance'
48
- import { deleteIntegration, readIntegrations, upsertIntegration } from './state/integrations'
49
- import { readLayout, resetLayout, saveLayout, setNodePositions } from './state/layout'
50
- import { readSettings, updateSettings } from './state/settings'
51
- import { applyUpdates, getUpdates } from './state/updates'
52
- import { closeViewSession, getViewRuntime, launchViewSession } from './state/views'
53
- import { readVisibility, resetVisibility, saveVisibility } from './state/visibility'
54
-
55
- function json(data: unknown, status = 200): Response {
56
- return new Response(JSON.stringify(data), {
57
- status,
58
- headers: { 'content-type': 'application/json' },
59
- })
60
- }
61
- const notFound = () => json({ error: 'not found' }, 404)
62
- const badReq = (m: string) => json({ error: m }, 400)
63
-
64
- /** The deploy-target INSTANCE name, or null for route-based / unset targets (which have no instance to query). */
65
- function instanceTarget(prodTarget?: string): string | null {
66
- const m = prodTarget?.match(/^instance:\s*(.+)$/)
67
- return m ? m[1].trim() || null : null
68
- }
69
-
70
- /**
71
- * Block cross-site state-changing requests (CSRF defense-in-depth on top of the
72
- * loopback bind). A mutation triggers the local agent (file edits + shell), so a
73
- * malicious page the user visits must NOT be able to POST here. The studio's own
74
- * SPA is same-origin; curl/CLI/the MCP bridge send no Origin/Sec-Fetch-Site.
75
- */
76
- function crossSiteBlocked(req: Request, url: URL): boolean {
77
- if (req.method === 'GET' || req.method === 'HEAD') return false
78
- const sfs = req.headers.get('sec-fetch-site')
79
- if (sfs) return !(sfs === 'same-origin' || sfs === 'none')
80
- const origin = req.headers.get('origin')
81
- if (origin) {
82
- try {
83
- return new URL(origin).host !== url.host
84
- } catch {
85
- return true
86
- }
87
- }
88
- return false // no Origin + no Sec-Fetch-Site → a non-browser client (curl/CLI/MCP)
89
- }
90
-
91
- export type Notify = (e: StudioEvent) => void
11
+ export type { Notify } from './api/http'
92
12
 
93
13
  export async function handleApi(req: Request, url: URL, notify: Notify): Promise<Response | null> {
94
14
  const path = url.pathname
95
15
  if (!path.startsWith('/api/')) return null
96
16
  if (crossSiteBlocked(req, url)) return json({ error: 'cross-site request blocked' }, 403)
97
17
 
98
- if (path === '/api/workspace') {
99
- const out = await Promise.all(
100
- allDomains().map(async (h) => {
101
- const bundle = await getBundle(h.id)
102
- return {
103
- id: h.id,
104
- origin: bundle?.overlay.origin || h.origin || h.id,
105
- path: h.root,
106
- schemaDir: h.schemaDirName,
107
- depsInstalled: depsInstalled(h.root),
108
- hasGit: detectGit(h.root).hasGit,
109
- configFile: h.configFile,
110
- }
111
- }),
112
- )
113
- return json(out)
114
- }
115
-
116
- // Scaffold a brand-new domain into the workspace (the one write that ADDS a domain).
117
- if (path === '/api/workspace/create' && req.method === 'POST') {
118
- const body = await req.json().catch(() => ({}))
119
- const name = String(body.name ?? '')
120
- const result = await createDomain(name, await activeInstanceName())
121
- if (result.ok) notify({ type: 'workspace', domains: allDomains().map((d) => d.id) })
122
- return json(result) // 200 with {ok:false,error} on failure — the SPA reads the result shape
123
- }
124
-
125
- if (path === '/api/catalog') {
126
- return json(buildCatalog(allDomains().map((h) => ({ origin: h.origin ?? h.id, id: h.id }))))
127
- }
18
+ const workspace = await handleWorkspaceRoute(req, path, notify)
19
+ if (workspace) return workspace
128
20
 
129
- // ── instances (GLOBAL — the active instance is CLI-owned, not per-domain) ──
130
- if (path === '/api/instances' && req.method === 'GET') return json(await listInstances())
131
- if (path === '/api/instances/use' && req.method === 'POST') {
132
- const body = await req.json().catch(() => ({}))
133
- const name = String(body.name ?? '').trim()
134
- if (!name) return badReq('name is required')
135
- return json(await setActiveInstance(name))
136
- }
137
-
138
- // /api/domain/:id/...
139
- const m = path.match(/^\/api\/domain\/([^/]+)(\/.*)?$/)
140
- if (!m) return notFound()
141
- const id = decodeURIComponent(m[1])
142
- const rest = m[2] ?? ''
21
+ const match = path.match(/^\/api\/domain\/([^/]+)(\/.*)?$/)
22
+ if (!match) return notFound()
23
+ const id = decodeURIComponent(match[1])
143
24
  const handle = getDomain(id)
144
25
  if (!handle) return notFound()
145
- const root = handle.root
146
-
147
- // ── context documents (handled BEFORE the json body parse — uploads are multipart) ──
148
- if (rest === '/context/documents' && req.method === 'GET') return json(listDocuments(root))
149
- if (rest === '/context/documents' && req.method === 'POST') {
150
- const form = await req.formData()
151
- const added = []
152
- for (const value of form.getAll('files')) {
153
- if (value instanceof File)
154
- added.push(
155
- addDocument(root, value.name, value.type, new Uint8Array(await value.arrayBuffer())),
156
- )
157
- }
158
- return json(added)
159
- }
160
- const rawDoc = rest.match(/^\/context\/documents\/([^/]+)\/raw$/)
161
- if (rawDoc && req.method === 'GET') {
162
- const d = readDocument(root, decodeURIComponent(rawDoc[1]))
163
- if (!d) return notFound()
164
- return new Response(Bun.file(d.abs), { headers: { 'content-type': d.meta.type } })
165
- }
166
-
167
- const body = req.method === 'POST' ? await req.json().catch(() => ({})) : {}
168
-
169
- if (rest === '/context/documents/delete' && req.method === 'POST')
170
- return json({ ok: deleteDocument(root, body.id) })
171
- if (rest === '/context/documents/update' && req.method === 'POST') {
172
- const meta = updateDocument(root, body.id, new TextEncoder().encode(String(body.content ?? '')))
173
- return meta ? json(meta) : notFound()
174
- }
175
-
176
- // ── schema bundle ──
177
- if (rest === '/bundle') return json(await getBundle(id, url.searchParams.has('fresh')))
178
- if (rest === '/anatomy') return json(await getAnatomy(id, url.searchParams.has('fresh')))
179
-
180
- // ── update staleness (CLI + SDK deps, via `astrale update --check --json`) ──
181
- if (rest === '/updates' && req.method === 'GET') return json(await getUpdates(root))
182
- // ── apply the update (the "Update now" button) — runs `astrale update --yes` ──
183
- if (rest === '/updates/apply' && req.method === 'POST') return json(await applyUpdates(root))
184
26
 
185
- // ── core (genesis) data ──
186
- if (rest === '/core') return json(await getCore(id, url.searchParams.has('fresh')))
187
-
188
- // ── views: CLI-owned resolution, identity, publication, and Shell session ──
189
- if (rest === '/views/sessions/close' && req.method === 'POST') {
190
- return json(await closeViewSession(String(body.sessionId ?? '')))
191
- }
192
-
193
- const viewRuntimeM = rest.match(/^\/views\/([^/]+)\/runtime$/)
194
- if (viewRuntimeM && req.method === 'GET') {
195
- const slug = decodeURIComponent(viewRuntimeM[1])
196
- if (!/^[a-z][a-z0-9-]*$/.test(slug)) return badReq('invalid view slug')
197
- const anatomy = await getAnatomy(id)
198
- const view = anatomy?.views.find((candidate) => candidate.slug === slug)
199
- if (!view) return notFound()
200
- const bundle = await getBundle(id)
201
- const origin = bundle?.overlay.origin || anatomy?.overview.origin
202
- if (!origin) return badReq('domain origin is unavailable')
203
- return json(
204
- await getViewRuntime(root, origin, view, bundle, readSettings(root).viewProbeTimeoutMs),
205
- )
206
- }
207
-
208
- const viewSessionM = rest.match(/^\/views\/([^/]+)\/session$/)
209
- if (viewSessionM && req.method === 'POST') {
210
- const slug = decodeURIComponent(viewSessionM[1])
211
- if (!/^[a-z][a-z0-9-]*$/.test(slug)) return badReq('invalid view slug')
212
- const anatomy = await getAnatomy(id)
213
- const view = anatomy?.views.find((candidate) => candidate.slug === slug)
214
- if (!view) return notFound()
215
- const bundle = await getBundle(id)
216
- const origin = bundle?.overlay.origin || anatomy?.overview.origin
217
- if (!origin) return badReq('domain origin is unavailable')
218
- return json(
219
- await launchViewSession(
220
- root,
221
- origin,
222
- view,
223
- bundle,
224
- body,
225
- readSettings(root).viewProbeTimeoutMs,
226
- ),
227
- )
228
- }
229
-
230
- // ── instance / deploy ──
231
- if (rest === '/instance') {
232
- const target = instanceTarget((await getAnatomy(id))?.overview.prodTarget)
233
- const bundle = await getBundle(id)
234
- return json(
235
- await instanceStatus(
236
- handle,
237
- target,
238
- bundle?.overlay.origin ?? null,
239
- bundle?.schemaHash ?? null,
240
- ),
241
- )
242
- }
243
- if (rest === '/instance/deploy' && req.method === 'POST') {
244
- const bundle = await getBundle(id)
245
- const result = await runDeploy(handle, bundle?.schemaHash ?? null)
246
- return json(result)
247
- }
248
-
249
- // ── comments ──
250
- if (rest === '/comments') {
251
- if (req.method === 'GET') {
252
- const bundle = await getBundle(id)
253
- // Valid schema targets come from the IR (authoritative), not source spans —
254
- // so inherited / span-less members are never falsely orphaned. Spans are
255
- // unioned in only as a belt-and-suspenders for refs the IR walk might miss.
256
- const valid = new Set<string>(bundle ? schemaRefs(bundle) : [])
257
- for (const k of Object.keys(bundle?.overlay.sourceSpans ?? {})) valid.add(k)
258
- const store = readComments(root)
259
- for (const c of store.comments)
260
- for (const a of c.anchorRefs) if (a.kind !== 'schema') valid.add(a.ref)
261
- return json(markOrphans(root, [...valid]))
262
- }
263
- if (body.action === 'create') {
264
- const bundle = await getBundle(id)
265
- const c = upsertComment(root, {
266
- anchors: body.anchors ?? [],
267
- anchorRefs: body.anchorRefs ?? [],
268
- text: body.text,
269
- firstRole: body.firstRole,
270
- type: body.type,
271
- options: body.options,
272
- schemaVersion: bundle?.schemaHash,
273
- })
274
- notify({ type: 'comments', domainId: id })
275
- return json(c)
276
- }
277
- if (body.action === 'reply') {
278
- const c = addThreadEntry(root, body.id, body.entry)
279
- notify({ type: 'comments', domainId: id })
280
- return c ? json(c) : notFound()
281
- }
282
- if (body.action === 'edit') {
283
- const c = editThreadEntry(root, body.id, body.entryId, String(body.text ?? ''))
284
- notify({ type: 'comments', domainId: id })
285
- return c ? json(c) : notFound()
286
- }
287
- if (body.action === 'status') {
288
- const c = setStatus(root, body.id, body.status, body.closeNote)
289
- notify({ type: 'comments', domainId: id })
290
- return c ? json(c) : notFound()
291
- }
292
- if (body.action === 'delete') {
293
- const ok = deleteComment(root, body.id)
294
- notify({ type: 'comments', domainId: id })
295
- return json({ ok })
296
- }
297
- return badReq('unknown comments action')
298
- }
299
- if (rest === '/comments/merge' && req.method === 'POST') {
300
- const bundle = await getBundle(id)
301
- try {
302
- const result = mergeReply(root, bundle?.schemaHash ?? '', String(body.text ?? ''), {
303
- dedupeAuthorText: true,
304
- })
305
- notify({ type: 'comments', domainId: id })
306
- return json(result)
307
- } catch (e: any) {
308
- return badReq(String(e?.message ?? e))
309
- }
310
- }
311
-
312
- const agentResponse = await handleAgentRoute({ req, url, rest, body, handle, notify })
313
- if (agentResponse) return agentResponse
314
-
315
- // ── context ──
316
- if (rest === '/context') {
317
- if (req.method === 'GET') return json(readContext(root))
318
- if (body.action === 'add') return notifyJson(notify, id, 'comments', addUserContext(root, body))
319
- if (body.action === 'update') {
320
- const it = updateContext(root, body.id, body)
321
- return it ? json(it) : notFound()
322
- }
323
- if (body.action === 'delete') return json({ ok: deleteContext(root, body.id) })
324
- if (body.action === 'include') {
325
- const it = setAutoInclude(root, body.id, body.include)
326
- return it ? json(it) : notFound()
327
- }
328
- return badReq('unknown context action')
329
- }
330
-
331
- // ── integrations ──
332
- if (rest === '/integrations') {
333
- const detected = (await getAnatomy(id))?.detectedIntegrations ?? []
334
- if (req.method === 'GET') return json(readIntegrations(root, detected))
335
- if (body.action === 'upsert') return json(upsertIntegration(root, body))
336
- if (body.action === 'delete') return json({ ok: deleteIntegration(root, body.id) })
337
- return badReq('unknown integrations action')
338
- }
339
-
340
- // ── settings (per-domain power-user overrides) ──
341
- if (rest === '/settings') {
342
- if (req.method === 'GET') return json(readSettings(root))
343
- if (body.action === 'update') {
344
- const next = updateSettings(root, body.settings ?? {})
345
- invalidate(id, 'anatomy') // integrationsDir changes what detection scans
346
- notify({ type: 'anatomy-diff', domainId: id })
347
- return json(next)
348
- }
349
- return badReq('unknown settings action')
350
- }
351
-
352
- // ── env vars editor: read/parse + WRITE `.env.<env>` (the read-only studio's one
353
- // sanctioned domain-file writer), reconciled against env.ts's Env contract ──
354
- if (rest === '/env') {
355
- const envName = req.method === 'GET' ? url.searchParams.get('env') : body.env
356
- if (!isEnvName(envName)) return badReq('env must be "dev" or "prod"')
357
- if (req.method === 'GET') return json(readEnvModel(root, envName))
358
- if (req.method === 'POST') {
359
- if (!body.updates || typeof body.updates !== 'object')
360
- return badReq('updates object required')
361
- const updates: Record<string, string | null> = {}
362
- for (const [k, v] of Object.entries(body.updates as Record<string, unknown>)) {
363
- if (!/^[A-Za-z_]\w*$/.test(k)) continue // ignore bad keys
364
- updates[k] = v === null ? null : String(v)
365
- }
366
- try {
367
- const model = writeEnvUpdates(root, envName, updates)
368
- notify({ type: 'anatomy-diff', domainId: id }) // env.ts/.env changed — nudge consumers
369
- return json(model)
370
- } catch (e: any) {
371
- return badReq(String(e?.message ?? e))
372
- }
373
- }
374
- return badReq('GET or POST')
375
- }
376
-
377
- // ── graph layout (persisted manual positions) ──
378
- if (rest === '/layout') {
379
- const bundle = await getBundle(id)
380
- if (req.method === 'GET') return json(readLayout(root))
381
- if (body.action === 'set')
382
- return json(setNodePositions(root, body.positions ?? {}, bundle?.schemaHash))
383
- if (body.action === 'save')
384
- return json(saveLayout(root, body.positions ?? {}, bundle?.schemaHash))
385
- if (body.action === 'reset') {
386
- resetLayout(root)
387
- return json({ ok: true })
388
- }
389
- return badReq('unknown layout action')
390
- }
391
-
392
- // ── canvas visibility (persisted hide-set, inherited-edge toggle, interface nodes) ──
393
- if (rest === '/visibility') {
394
- if (req.method === 'GET') return json(readVisibility(root))
395
- if (body.action === 'set')
396
- return json(
397
- saveVisibility(root, {
398
- hidden: body.hidden ?? {},
399
- showInheritedEdges: body.showInheritedEdges ?? true,
400
- materializedInterfaces: body.materializedInterfaces ?? {},
401
- }),
402
- )
403
- if (body.action === 'reset') return json(resetVisibility(root))
404
- return badReq('unknown visibility action')
405
- }
406
-
407
- // ── copy payload ──
408
- if (rest === '/copy-payload' && req.method === 'POST') {
409
- await refreshAuto(handle)
410
- const bundle = await getBundle(id)
411
- const ctx = readContext(root)
412
- const open = readComments(root).comments.filter((c) => c.status === 'open')
413
- const md = buildCopyMarkdown({
414
- origin: bundle?.overlay.origin ?? id,
415
- root,
416
- schemaHash: bundle?.schemaHash ?? '',
417
- openComments: open,
418
- userContext: ctx.user,
419
- autoContext: body.includeAuto ? ctx.auto.filter((a) => a.includeInHandoff) : [],
420
- documents: listDocuments(root),
421
- })
422
- return json({ markdown: md, openComments: open.length })
423
- }
424
-
425
- return notFound()
426
- }
427
-
428
- function notifyJson(
429
- notify: Notify,
430
- id: string,
431
- type: StudioEvent['type'],
432
- data: unknown,
433
- ): Response {
434
- notify({ type, domainId: id } as StudioEvent)
435
- return json(data)
27
+ return handleDomainRoute({
28
+ req,
29
+ url,
30
+ rest: match[2] ?? '',
31
+ handle,
32
+ notify,
33
+ })
436
34
  }
@@ -0,0 +1,111 @@
1
+ import { expect, test } from 'bun:test'
2
+
3
+ import { decodeBundleCacheEntry } from './cache'
4
+
5
+ function entry(): Record<string, unknown> {
6
+ return {
7
+ version: 4,
8
+ key: 'cache-key',
9
+ futureEntryField: { version: 5 },
10
+ bundle: {
11
+ domainId: 'notes',
12
+ renderFingerprint: 'render-hash',
13
+ schemaMode: 'legacy',
14
+ extractedBy: 'runtime-bun',
15
+ depsInstalled: true,
16
+ ir: {
17
+ version: 'legacy',
18
+ domain: 'notes.example.dev',
19
+ types: {},
20
+ interfaces: {},
21
+ classes: {},
22
+ imports: {},
23
+ functions: {},
24
+ },
25
+ overlay: {
26
+ origin: 'notes.example.dev',
27
+ requires: [],
28
+ crossDomainImports: [],
29
+ mixins: [],
30
+ handlerLinks: [],
31
+ sourceSpans: {},
32
+ annotations: [],
33
+ },
34
+ importedInterfaces: {},
35
+ error: null,
36
+ extractedAt: '2026-08-20T00:00:00.000Z',
37
+ futureBundleField: true,
38
+ },
39
+ }
40
+ }
41
+
42
+ test('bundle cache admits known structure while ignoring future fields', () => {
43
+ const decoded = decodeBundleCacheEntry(entry())
44
+
45
+ expect(decoded).toMatchObject({
46
+ version: 4,
47
+ key: 'cache-key',
48
+ bundle: { domainId: 'notes', schemaMode: 'legacy' },
49
+ })
50
+ expect(decoded && 'futureEntryField' in decoded).toBe(false)
51
+ expect(decoded && 'futureBundleField' in decoded.bundle).toBe(false)
52
+ })
53
+
54
+ function canonicalEntry(): Record<string, unknown> {
55
+ const value = entry()
56
+ const bundle = value.bundle as Record<string, unknown>
57
+ bundle.schemaMode = 'canonical-admitted'
58
+ bundle.schemaRevision = `sha256:${'a'.repeat(64)}`
59
+ bundle.ir = {
60
+ format: 'astrale.dsl',
61
+ version: 'v1',
62
+ domain: 'notes.example.dev',
63
+ types: {},
64
+ interfaces: {},
65
+ classes: {},
66
+ imports: {},
67
+ functions: {},
68
+ }
69
+ bundle.schemaRoot = {
70
+ format: 'astrale.dsl',
71
+ version: 'v1',
72
+ origin: 'notes.example.dev',
73
+ }
74
+ return value
75
+ }
76
+
77
+ test('canonical-admitted cache entries require their root and a valid revision', () => {
78
+ expect(decodeBundleCacheEntry(canonicalEntry())).toBeDefined()
79
+
80
+ const missingRoot = canonicalEntry()
81
+ delete (missingRoot.bundle as Record<string, unknown>).schemaRoot
82
+ expect(decodeBundleCacheEntry(missingRoot)).toBeUndefined()
83
+
84
+ const invalidRoot = canonicalEntry()
85
+ const invalidRootBundle = invalidRoot.bundle as Record<string, unknown>
86
+ invalidRootBundle.schemaRoot = { canonical: true }
87
+ expect(decodeBundleCacheEntry(invalidRoot)).toBeUndefined()
88
+
89
+ const missingRevision = canonicalEntry()
90
+ delete (missingRevision.bundle as Record<string, unknown>).schemaRevision
91
+ expect(decodeBundleCacheEntry(missingRevision)).toBeUndefined()
92
+
93
+ const invalidRevision = canonicalEntry()
94
+ const invalidRevisionBundle = invalidRevision.bundle as Record<string, unknown>
95
+ invalidRevisionBundle.schemaRevision = 'sha256:not-a-revision'
96
+ expect(decodeBundleCacheEntry(invalidRevision)).toBeUndefined()
97
+ })
98
+
99
+ test('bundle cache rejects corrupt nested business shapes', () => {
100
+ const corruptIr = entry()
101
+ const bundle = corruptIr.bundle as Record<string, unknown>
102
+ const ir = bundle.ir as Record<string, unknown>
103
+ ir.classes = { Broken: null }
104
+ expect(decodeBundleCacheEntry(corruptIr)).toBeUndefined()
105
+
106
+ const corruptOverlay = entry()
107
+ const secondBundle = corruptOverlay.bundle as Record<string, unknown>
108
+ const overlay = secondBundle.overlay as Record<string, unknown>
109
+ overlay.handlerLinks = [{ owner: 'Thing' }]
110
+ expect(decodeBundleCacheEntry(corruptOverlay)).toBeUndefined()
111
+ })