@astrale-os/cli 1.0.0-beta.0 → 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 +3769 -3549
  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 +5 -3
  13. package/src/admin/instance/client.ts +9 -27
  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 +73 -0
  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 +39 -13
  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
@@ -0,0 +1,59 @@
1
+ import type { DomainHandle } from '../domain'
2
+ import type { DomainRouteContext, Notify } from './http'
3
+
4
+ /** Domain-scoped route composition and the multipart/JSON body boundary. */
5
+ import { handleAgentRoute } from '../agent/routes'
6
+ import { handleCanvasRoute } from './canvas'
7
+ import { handleCommentRoute } from './comments'
8
+ import { handleContextRoute } from './context'
9
+ import { handleDeploymentRoute } from './deployment'
10
+ import { handleDocumentMutation, handleDocumentTransport } from './documents'
11
+ import { notFound } from './http'
12
+ import { handleProjectRoute } from './project'
13
+ import { handleSchemaRoute } from './schema'
14
+ import { handleViewRoute } from './views'
15
+
16
+ export async function handleDomainRoute(input: {
17
+ req: Request
18
+ url: URL
19
+ rest: string
20
+ handle: DomainHandle
21
+ notify: Notify
22
+ }): Promise<Response> {
23
+ const { req, url, rest, handle, notify } = input
24
+
25
+ const documentTransport = await handleDocumentTransport(req, rest, handle.root)
26
+ if (documentTransport) return documentTransport
27
+
28
+ const body = req.method === 'POST' ? await req.json().catch(() => ({})) : {}
29
+ const context: DomainRouteContext = { req, url, rest, body, handle, notify }
30
+
31
+ const documentMutation = handleDocumentMutation(context)
32
+ if (documentMutation) return documentMutation
33
+
34
+ const schema = await handleSchemaRoute(context)
35
+ if (schema) return schema
36
+
37
+ const deployment = await handleDeploymentRoute(context)
38
+ if (deployment) return deployment
39
+
40
+ const view = await handleViewRoute(context)
41
+ if (view) return view
42
+
43
+ const comment = await handleCommentRoute(context)
44
+ if (comment) return comment
45
+
46
+ const agent = await handleAgentRoute({ req, url, rest, body, handle, notify })
47
+ if (agent) return agent
48
+
49
+ const contextResponse = await handleContextRoute(context)
50
+ if (contextResponse) return contextResponse
51
+
52
+ const project = await handleProjectRoute(context)
53
+ if (project) return project
54
+
55
+ const canvas = await handleCanvasRoute(context)
56
+ if (canvas) return canvas
57
+
58
+ return notFound()
59
+ }
@@ -0,0 +1,45 @@
1
+ /** Internal HTTP contract shared by Studio API route modules. */
2
+ import type { StudioEvent } from '../../shared/types'
3
+ import type { DomainHandle } from '../domain'
4
+
5
+ export type Notify = (event: StudioEvent) => void
6
+
7
+ export interface DomainRouteContext {
8
+ req: Request
9
+ url: URL
10
+ rest: string
11
+ body: any
12
+ handle: DomainHandle
13
+ notify: Notify
14
+ }
15
+
16
+ export function json(data: unknown, status = 200): Response {
17
+ return new Response(JSON.stringify(data), {
18
+ status,
19
+ headers: { 'content-type': 'application/json' },
20
+ })
21
+ }
22
+
23
+ export const notFound = () => json({ error: 'not found' }, 404)
24
+ export const badRequest = (message: string) => json({ error: message }, 400)
25
+
26
+ /**
27
+ * Block cross-site state-changing requests. Same-origin browser requests and
28
+ * non-browser clients without Origin/Sec-Fetch-Site remain allowed.
29
+ */
30
+ export function crossSiteBlocked(req: Request, url: URL): boolean {
31
+ if (req.method === 'GET' || req.method === 'HEAD') return false
32
+ const site = req.headers.get('sec-fetch-site')
33
+ if (site) return !(site === 'same-origin' || site === 'none')
34
+ const origin = req.headers.get('origin')
35
+ if (origin) {
36
+ try {
37
+ // Same-origin includes the scheme as well as host and effective port.
38
+ // Comparing only `host` would accept an HTTPS origin for an HTTP Studio.
39
+ return new URL(origin).origin !== url.origin
40
+ } catch {
41
+ return true
42
+ }
43
+ }
44
+ return false
45
+ }
@@ -0,0 +1,57 @@
1
+ /** Domain project configuration routes: integrations, settings, and env files. */
2
+ import { getAnatomy, invalidate } from '../cache'
3
+ import { isEnvName, readEnvModel, writeEnvUpdates } from '../environment/files'
4
+ import { deleteIntegration, readIntegrations, upsertIntegration } from '../state/integrations'
5
+ import { readSettings, updateSettings } from '../state/settings'
6
+ import { badRequest, json, type DomainRouteContext } from './http'
7
+
8
+ export async function handleProjectRoute(context: DomainRouteContext): Promise<Response | null> {
9
+ const { req, url, rest, body, handle, notify } = context
10
+ const id = handle.id
11
+ const root = handle.root
12
+
13
+ if (rest === '/integrations') {
14
+ const detected = (await getAnatomy(id))?.detectedIntegrations ?? []
15
+ if (req.method === 'GET') return json(readIntegrations(root, detected))
16
+ if (body.action === 'upsert') return json(upsertIntegration(root, body))
17
+ if (body.action === 'delete') return json({ ok: deleteIntegration(root, body.id) })
18
+ return badRequest('unknown integrations action')
19
+ }
20
+
21
+ if (rest === '/settings') {
22
+ if (req.method === 'GET') return json(readSettings(root))
23
+ if (body.action === 'update') {
24
+ const next = updateSettings(root, body.settings ?? {})
25
+ invalidate(id, 'anatomy')
26
+ notify({ type: 'anatomy-diff', domainId: id })
27
+ return json(next)
28
+ }
29
+ return badRequest('unknown settings action')
30
+ }
31
+
32
+ if (rest === '/env') {
33
+ const envName = req.method === 'GET' ? url.searchParams.get('env') : body.env
34
+ if (!isEnvName(envName)) return badRequest('env must be "dev" or "prod"')
35
+ if (req.method === 'GET') return json(readEnvModel(root, envName))
36
+ if (req.method === 'POST') {
37
+ if (!body.updates || typeof body.updates !== 'object') {
38
+ return badRequest('updates object required')
39
+ }
40
+ const updates: Record<string, string | null> = {}
41
+ for (const [key, value] of Object.entries(body.updates as Record<string, unknown>)) {
42
+ if (!/^[A-Za-z_]\w*$/.test(key)) continue
43
+ updates[key] = value === null ? null : String(value)
44
+ }
45
+ try {
46
+ const model = writeEnvUpdates(root, envName, updates)
47
+ notify({ type: 'anatomy-diff', domainId: id })
48
+ return json(model)
49
+ } catch (error: any) {
50
+ return badRequest(String(error?.message ?? error))
51
+ }
52
+ }
53
+ return badRequest('GET or POST')
54
+ }
55
+
56
+ return null
57
+ }
@@ -0,0 +1,12 @@
1
+ /** Canonical schema, anatomy, and genesis inspection routes. */
2
+ import { getAnatomy, getBundle, getCore } from '../cache'
3
+ import { json, type DomainRouteContext } from './http'
4
+
5
+ export async function handleSchemaRoute(context: DomainRouteContext): Promise<Response | null> {
6
+ const { rest, url, handle } = context
7
+ const fresh = url.searchParams.has('fresh')
8
+ if (rest === '/bundle') return json(await getBundle(handle.id, fresh))
9
+ if (rest === '/anatomy') return json(await getAnatomy(handle.id, fresh))
10
+ if (rest === '/core') return json(await getCore(handle.id, fresh))
11
+ return null
12
+ }
@@ -0,0 +1,57 @@
1
+ /** CLI-resolved View target discovery and Shell session routes. */
2
+ import { getAnatomy, getBundle } from '../cache'
3
+ import { readSettings } from '../state/settings'
4
+ import { getViewRuntime } from '../views/runtime'
5
+ import { closeViewSession, launchViewSession } from '../views/session'
6
+ import { badRequest, json, notFound, type DomainRouteContext } from './http'
7
+
8
+ const VALID_SLUG = /^[a-z][a-z0-9-]*$/
9
+
10
+ export async function handleViewRoute(context: DomainRouteContext): Promise<Response | null> {
11
+ const { req, rest, body, handle } = context
12
+ const id = handle.id
13
+ const root = handle.root
14
+
15
+ if (rest === '/views/sessions/close' && req.method === 'POST') {
16
+ return json(await closeViewSession(String(body.sessionId ?? '')))
17
+ }
18
+
19
+ const runtimeMatch = rest.match(/^\/views\/([^/]+)\/runtime$/)
20
+ if (runtimeMatch && req.method === 'GET') {
21
+ const slug = decodeURIComponent(runtimeMatch[1])
22
+ if (!VALID_SLUG.test(slug)) return badRequest('invalid view slug')
23
+ const anatomy = await getAnatomy(id)
24
+ const view = anatomy?.views.find((candidate) => candidate.slug === slug)
25
+ if (!view) return notFound()
26
+ const bundle = await getBundle(id)
27
+ const origin = bundle?.overlay.origin || anatomy?.overview.origin
28
+ if (!origin) return badRequest('domain origin is unavailable')
29
+ return json(
30
+ await getViewRuntime(root, origin, view, bundle, readSettings(root).viewProbeTimeoutMs),
31
+ )
32
+ }
33
+
34
+ const sessionMatch = rest.match(/^\/views\/([^/]+)\/session$/)
35
+ if (sessionMatch && req.method === 'POST') {
36
+ const slug = decodeURIComponent(sessionMatch[1])
37
+ if (!VALID_SLUG.test(slug)) return badRequest('invalid view slug')
38
+ const anatomy = await getAnatomy(id)
39
+ const view = anatomy?.views.find((candidate) => candidate.slug === slug)
40
+ if (!view) return notFound()
41
+ const bundle = await getBundle(id)
42
+ const origin = bundle?.overlay.origin || anatomy?.overview.origin
43
+ if (!origin) return badRequest('domain origin is unavailable')
44
+ return json(
45
+ await launchViewSession(
46
+ root,
47
+ origin,
48
+ view,
49
+ bundle,
50
+ body,
51
+ readSettings(root).viewProbeTimeoutMs,
52
+ ),
53
+ )
54
+ }
55
+
56
+ return null
57
+ }
@@ -0,0 +1,60 @@
1
+ /** Workspace-wide routes that do not require a DomainHandle. */
2
+ import { getBundle } from '../cache'
3
+ import { allDomains, depsInstalled } from '../domain'
4
+ import { activeInstanceName, listInstances, setActiveInstance } from '../instances/active'
5
+ import { buildCatalog } from '../workspace/catalog'
6
+ import { createDomain } from '../workspace/create'
7
+ import { detectGit } from '../workspace/git'
8
+ import { badRequest, json, type Notify } from './http'
9
+
10
+ export async function handleWorkspaceRoute(
11
+ req: Request,
12
+ path: string,
13
+ notify: Notify,
14
+ ): Promise<Response | null> {
15
+ if (path === '/api/workspace') {
16
+ const domains = await Promise.all(
17
+ allDomains().map(async (handle) => {
18
+ const bundle = await getBundle(handle.id)
19
+ return {
20
+ id: handle.id,
21
+ origin: bundle?.overlay.origin || handle.origin || handle.id,
22
+ path: handle.root,
23
+ schemaDir: handle.schemaDirName,
24
+ depsInstalled: depsInstalled(handle.root),
25
+ hasGit: detectGit(handle.root).hasGit,
26
+ configFile: handle.configFile,
27
+ }
28
+ }),
29
+ )
30
+ return json(domains)
31
+ }
32
+
33
+ if (path === '/api/workspace/create' && req.method === 'POST') {
34
+ const body = await req.json().catch(() => ({}))
35
+ const result = await createDomain(String(body.name ?? ''), await activeInstanceName())
36
+ if (result.ok) notify({ type: 'workspace', domains: allDomains().map((domain) => domain.id) })
37
+ return json(result)
38
+ }
39
+
40
+ if (path === '/api/catalog') {
41
+ return json(
42
+ buildCatalog(
43
+ allDomains().map((handle) => ({
44
+ origin: handle.origin ?? handle.id,
45
+ id: handle.id,
46
+ })),
47
+ ),
48
+ )
49
+ }
50
+
51
+ if (path === '/api/instances' && req.method === 'GET') return json(await listInstances())
52
+ if (path === '/api/instances/use' && req.method === 'POST') {
53
+ const body = await req.json().catch(() => ({}))
54
+ const name = String(body.name ?? '').trim()
55
+ if (!name) return badRequest('name is required')
56
+ return json(await setActiveInstance(name))
57
+ }
58
+
59
+ return null
60
+ }
@@ -0,0 +1,147 @@
1
+ import { afterEach, expect, test } from 'bun:test'
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ import { handleApi } from './api'
7
+ import { registerDomain, unregisterDomain } from './domain'
8
+
9
+ const roots: string[] = []
10
+ const domainIds: string[] = []
11
+
12
+ afterEach(() => {
13
+ while (domainIds.length) unregisterDomain(domainIds.pop()!)
14
+ while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
15
+ })
16
+
17
+ function fixture() {
18
+ const root = mkdtempSync(join(tmpdir(), 'studio-api-contract-'))
19
+ roots.push(root)
20
+ mkdirSync(join(root, 'schema'))
21
+ writeFileSync(join(root, 'astrale.config.ts'), 'export default {}\n')
22
+ writeFileSync(join(root, 'domain.ts'), 'export default {}\n')
23
+ writeFileSync(join(root, 'schema/index.ts'), 'export const Test = {}\n')
24
+ const handle = registerDomain(root)!
25
+ domainIds.push(handle.id)
26
+ return handle
27
+ }
28
+
29
+ async function route(path: string, init?: RequestInit) {
30
+ const url = new URL(`http://127.0.0.1${path}`)
31
+ return handleApi(new Request(url, init), url, () => {})
32
+ }
33
+
34
+ test('router ignores non-api paths and returns the stable JSON 404 for unknown API paths', async () => {
35
+ expect(await route('/assets/app.js')).toBeNull()
36
+
37
+ const response = await route('/api/unknown')
38
+ expect(response?.status).toBe(404)
39
+ expect(response?.headers.get('content-type')).toBe('application/json')
40
+ expect(await response?.json()).toEqual({ error: 'not found' })
41
+ })
42
+
43
+ test('router blocks cross-site mutations before route dispatch but permits same-origin requests', async () => {
44
+ const blocked = await route('/api/unknown', {
45
+ method: 'POST',
46
+ headers: { origin: 'https://malicious.example' },
47
+ })
48
+ expect(blocked?.status).toBe(403)
49
+ expect(await blocked?.json()).toEqual({ error: 'cross-site request blocked' })
50
+
51
+ const sameOrigin = await route('/api/unknown', {
52
+ method: 'POST',
53
+ headers: { origin: 'http://127.0.0.1' },
54
+ })
55
+ expect(sameOrigin?.status).toBe(404)
56
+ })
57
+
58
+ test('cross-site protection compares the complete origin and leaves mutation state unchanged', async () => {
59
+ const handle = fixture()
60
+ const contextPath = `/api/domain/${encodeURIComponent(handle.id)}/context`
61
+ const mutation = {
62
+ method: 'POST',
63
+ headers: {
64
+ 'content-type': 'application/json',
65
+ // The host matches Studio, but the scheme does not: this is not same-origin.
66
+ origin: 'https://127.0.0.1',
67
+ },
68
+ body: JSON.stringify({ action: 'add', title: 'Injected', body: 'Must not persist' }),
69
+ }
70
+
71
+ const blocked = await route(contextPath, mutation)
72
+ expect(blocked?.status).toBe(403)
73
+ expect(await blocked?.json()).toEqual({ error: 'cross-site request blocked' })
74
+
75
+ const wrongPort = await route(contextPath, {
76
+ ...mutation,
77
+ headers: { ...mutation.headers, origin: 'http://127.0.0.1:3000' },
78
+ })
79
+ expect(wrongPort?.status).toBe(403)
80
+
81
+ const unchanged = await route(contextPath)
82
+ expect(await unchanged?.json()).toEqual({ user: [], auto: [] })
83
+
84
+ const allowed = await route(contextPath, {
85
+ ...mutation,
86
+ headers: { ...mutation.headers, origin: 'http://127.0.0.1' },
87
+ })
88
+ expect(allowed?.status).toBe(200)
89
+ const stored = (await (await route(contextPath))?.json()) as { user: unknown[] }
90
+ expect(stored.user).toHaveLength(1)
91
+ })
92
+
93
+ test('domain dispatch preserves context success, validation, and unknown-domain responses', async () => {
94
+ const handle = fixture()
95
+ const base = `/api/domain/${encodeURIComponent(handle.id)}`
96
+
97
+ const context = await route(`${base}/context`)
98
+ expect(context?.status).toBe(200)
99
+ expect(await context?.json()).toEqual({ user: [], auto: [] })
100
+
101
+ const invalid = await route(`${base}/context`, {
102
+ method: 'POST',
103
+ headers: { 'content-type': 'application/json' },
104
+ body: JSON.stringify({ action: 'invalid' }),
105
+ })
106
+ expect(invalid?.status).toBe(400)
107
+ expect(await invalid?.json()).toEqual({ error: 'unknown context action' })
108
+
109
+ const missing = await route('/api/domain/not-registered/context')
110
+ expect(missing?.status).toBe(404)
111
+ expect(await missing?.json()).toEqual({ error: 'not found' })
112
+ })
113
+
114
+ test('workspace dispatch preserves catalog responses and instance request validation', async () => {
115
+ const handle = fixture()
116
+
117
+ const catalog = await route('/api/catalog')
118
+ expect(catalog?.status).toBe(200)
119
+ const origins = (await catalog?.json()).map((entry: { origin: string }) => entry.origin)
120
+ expect(origins[0]).toBe('kernel.astrale.ai')
121
+ expect(origins).toContain(handle.id)
122
+
123
+ const invalidInstance = await route('/api/instances/use', {
124
+ method: 'POST',
125
+ headers: { 'content-type': 'application/json' },
126
+ body: JSON.stringify({ name: ' ' }),
127
+ })
128
+ expect(invalidInstance?.status).toBe(400)
129
+ expect(await invalidInstance?.json()).toEqual({ error: 'name is required' })
130
+ })
131
+
132
+ test('multipart document upload remains ahead of JSON parsing and raw download keeps its media type', async () => {
133
+ const handle = fixture()
134
+ const base = `/api/domain/${encodeURIComponent(handle.id)}`
135
+ const form = new FormData()
136
+ form.append('files', new File(['contract body'], 'contract.txt', { type: 'text/plain' }))
137
+
138
+ const uploaded = await route(`${base}/context/documents`, { method: 'POST', body: form })
139
+ expect(uploaded?.status).toBe(200)
140
+ const documents = (await uploaded?.json()) as Array<{ id: string; type: string }>
141
+ expect(documents).toHaveLength(1)
142
+
143
+ const raw = await route(`${base}/context/documents/${encodeURIComponent(documents[0]!.id)}/raw`)
144
+ expect(raw?.status).toBe(200)
145
+ expect(raw?.headers.get('content-type')).toBe('text/plain;charset=utf-8')
146
+ expect(await raw?.text()).toBe('contract body')
147
+ })