@fabrika/builder 0.2.0 → 0.3.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 (51) hide show
  1. package/BUILD-CONTRACT.md +8 -0
  2. package/README.md +9 -0
  3. package/cli/src/bin.ts +35 -2
  4. package/cli/src/build-status.ts +7 -15
  5. package/cli/src/commands/advance.ts +3 -2
  6. package/cli/src/commands/ensure-daemon.ts +3 -3
  7. package/cli/src/commands/gate.ts +4 -10
  8. package/cli/src/commands/init.ts +44 -7
  9. package/cli/src/commands/next.ts +3 -3
  10. package/cli/src/commands/platform.ts +81 -0
  11. package/cli/src/config.ts +4 -11
  12. package/cli/src/context.ts +4 -1
  13. package/cli/src/daemon-client.ts +36 -185
  14. package/cli/src/daemon-lifecycle.ts +63 -142
  15. package/cli/src/derive.ts +4 -8
  16. package/cli/src/errors.ts +1 -0
  17. package/cli/src/index.ts +2 -3
  18. package/cli/src/inject-skills.ts +8 -31
  19. package/cli/src/layout.ts +8 -18
  20. package/cli/src/packet.ts +18 -17
  21. package/cli/src/platform-client.ts +40 -0
  22. package/daemon/src/admission.ts +21 -0
  23. package/daemon/src/bin.ts +12 -14
  24. package/daemon/src/handlers.ts +84 -81
  25. package/daemon/src/index.ts +16 -6
  26. package/daemon/src/paths.ts +5 -6
  27. package/daemon/src/phases.ts +27 -126
  28. package/daemon/src/platform-compiler.ts +21 -0
  29. package/daemon/src/platform-remote.ts +146 -0
  30. package/daemon/src/platform-routes.ts +46 -0
  31. package/daemon/src/platform-source.ts +147 -0
  32. package/daemon/src/platform-storage.ts +222 -0
  33. package/daemon/src/platform.ts +481 -0
  34. package/decisions/README.md +4 -0
  35. package/decisions/patterns/zerops-control-deploy.md +49 -0
  36. package/decisions/patterns/zerops-local-app.md +57 -0
  37. package/decisions/patterns/zerops-postgres-seeds.md +37 -0
  38. package/decisions/technologies/zerops-fabrika-auth.md +53 -0
  39. package/decisions/technologies/zerops-platform.md +64 -0
  40. package/package.json +13 -5
  41. package/shared/index.ts +365 -0
  42. package/{cli/src → shared}/phases.ts +42 -50
  43. package/shared/urls.ts +48 -0
  44. package/skill/SKILL.md +34 -0
  45. package/stacks/README.md +2 -0
  46. package/stacks/zerops-app.md +66 -0
  47. package/ui/dist/assets/client.css +1 -1
  48. package/ui/dist/assets/client.krrpk10y.js +214 -0
  49. package/ui/dist/assets/components.js +57 -57
  50. package/ui/dist/index.html +1 -1
  51. package/ui/dist/assets/client.fhjppkvr.js +0 -214
package/cli/src/layout.ts CHANGED
@@ -1,6 +1,8 @@
1
- import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'fs'
2
- import { join, resolve } from 'path'
1
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
2
+ import { join, resolve } from 'node:path'
3
+ import { type BuilderProjectConfig, parseBuilderProjectConfig } from '../../shared'
3
4
  import { FABRIKA_DIRNAME, hashRepoPath } from './config'
5
+ import { BuilderCliError } from './errors'
4
6
 
5
7
  export const LAYER_DIRS = [
6
8
  'intent',
@@ -14,19 +16,7 @@ export const LAYER_DIRS = [
14
16
 
15
17
  export const EXTERNAL_DIRS = ['tests/browser']
16
18
 
17
- export interface FabrikaConfig {
18
- profile: string
19
- sessionId: string
20
- createdAt: string
21
- }
22
-
23
- function parseConfig(value: unknown): FabrikaConfig | null {
24
- if (typeof value !== 'object' || value === null || Array.isArray(value)) return null
25
- if (!('profile' in value) || !('sessionId' in value) || !('createdAt' in value)) return null
26
- const { profile, sessionId, createdAt } = value
27
- if (typeof profile !== 'string' || typeof sessionId !== 'string' || typeof createdAt !== 'string') return null
28
- return { profile, sessionId, createdAt }
29
- }
19
+ export type FabrikaConfig = BuilderProjectConfig
30
20
 
31
21
  export function fabrikaDir(repoRoot: string): string {
32
22
  return join(resolve(repoRoot), FABRIKA_DIRNAME)
@@ -44,9 +34,9 @@ export function readConfig(repoRoot: string): FabrikaConfig | null {
44
34
  const p = configPath(repoRoot)
45
35
  if (!existsSync(p)) return null
46
36
  try {
47
- return parseConfig(JSON.parse(readFileSync(p, 'utf-8')))
37
+ return parseBuilderProjectConfig(JSON.parse(readFileSync(p, 'utf-8')))
48
38
  } catch {
49
- return null
39
+ throw new BuilderCliError('Invalid .fabrika/config.json; repair it before continuing.')
50
40
  }
51
41
  }
52
42
 
@@ -69,6 +59,7 @@ export function scaffold(
69
59
  const createdDirs: string[] = []
70
60
 
71
61
  const alreadyInitialized = isInitialized(root)
62
+ const existing = readConfig(root)
72
63
 
73
64
  for (const dir of ['.', ...LAYER_DIRS]) {
74
65
  const abs = dir === '.' ? fdir : join(fdir, dir)
@@ -88,7 +79,6 @@ export function scaffold(
88
79
 
89
80
  let config: FabrikaConfig
90
81
  let createdConfig = false
91
- const existing = readConfig(root)
92
82
  if (existing) {
93
83
  config = existing
94
84
  } else {
package/cli/src/packet.ts CHANGED
@@ -1,21 +1,12 @@
1
- import { join } from 'path'
1
+ import { join } from 'node:path'
2
+ import type { BuilderProjectConnection } from '../../shared'
3
+ import { PHASE_COUNT, phaseById, type PhaseId, phaseIndex } from '../../shared/phases'
2
4
  import { resolveStackPointer, type StackPointer } from './catalog'
3
5
  import type { PhaseState } from './derive'
4
- import { PHASE_COUNT, phaseById, type PhaseId, phaseIndex, PHASES } from './phases'
5
6
 
6
7
  export interface RenderOpts {
7
8
  profile?: string
8
- }
9
-
10
- const SHORT_LABEL: Record<PhaseId, string> = {
11
- intake: 'intake',
12
- vision: 'vision',
13
- flows: 'flows',
14
- tech: 'tech',
15
- design: 'design',
16
- skeletons: 'skeletons',
17
- issues: 'issues',
18
- build: 'build',
9
+ connection?: BuilderProjectConnection
19
10
  }
20
11
 
21
12
  const CHECK = '✓' // ✓
@@ -23,8 +14,9 @@ const CHECK = '✓' // ✓
23
14
  export function renderPacket(state: PhaseState, opts: RenderOpts = {}): string {
24
15
  const phase = phaseById(state.current)
25
16
  const lines: string[] = []
17
+ if (opts.connection) lines.push(`Platform: ${JSON.stringify(connectionContext(opts.connection))}`)
26
18
 
27
- const done = state.completed.map((id) => `${SHORT_LABEL[id]} ${CHECK}`).join(', ')
19
+ const done = state.completed.map((id) => `${id} ${CHECK}`).join(', ')
28
20
  const completedSuffix = done ? ` (${done})` : ''
29
21
  lines.push(`## Phase ${phase.number}/${PHASE_COUNT} - ${phase.title}${completedSuffix}`)
30
22
 
@@ -37,7 +29,7 @@ export function renderPacket(state: PhaseState, opts: RenderOpts = {}): string {
37
29
 
38
30
  lines.push(`Goal: ${phase.goal}`)
39
31
 
40
- lines.push(`Skills: ${phase.skills.join(', ')}`)
32
+ if (phase.skills) lines.push(`Skills: ${phase.skills.join(', ')}`)
41
33
 
42
34
  if (phase.gate) {
43
35
  const view = phase.gateView ?? `${phase.id}.jsx`
@@ -85,11 +77,13 @@ export interface PacketJson {
85
77
  completed: PhaseId[]
86
78
  satisfied: boolean
87
79
  stack: StackPointer | null
80
+ platform: ReturnType<typeof connectionContext> | null
88
81
  }
89
82
 
90
83
  export function packetJson(state: PhaseState, opts: RenderOpts = {}): PacketJson {
91
84
  const phase = phaseById(state.current)
92
85
  return {
86
+ platform: opts.connection ? connectionContext(opts.connection) : null,
93
87
  phase: phase.id,
94
88
  index: phaseIndex(phase.id),
95
89
  number: phase.number,
@@ -98,11 +92,18 @@ export function packetJson(state: PhaseState, opts: RenderOpts = {}): PacketJson
98
92
  produces: state.produces,
99
93
  gate: phase.gate,
100
94
  gateView: phase.gateView ?? null,
101
- skills: phase.skills,
95
+ skills: phase.skills ?? [],
102
96
  completed: state.completed,
103
97
  satisfied: state.satisfied,
104
98
  stack: phase.catalog && opts.profile ? resolveStackPointer(opts.profile) : null,
105
99
  }
106
100
  }
107
101
 
108
- export const PHASE_IDS: PhaseId[] = PHASES.map((p) => p.id)
102
+ function connectionContext(connection: BuilderProjectConnection) {
103
+ return {
104
+ controlOrigin: connection.controlOrigin,
105
+ provider: connection.receipt.provider,
106
+ app: connection.receipt.app,
107
+ environment: connection.receipt.environment,
108
+ }
109
+ }
@@ -0,0 +1,40 @@
1
+ import { type BuilderPlatformStatus, builderProjectPath, parseBuilderPlatformStatus } from '../../shared'
2
+ import { BASE_URL } from './config'
3
+ import { BuilderCliError } from './errors'
4
+
5
+ export async function platformRequest(
6
+ sessionId: string,
7
+ suffix: Parameters<typeof builderProjectPath>[1],
8
+ body?: object,
9
+ timeoutMs = 30_000,
10
+ ): Promise<BuilderPlatformStatus> {
11
+ let response: Response
12
+ try {
13
+ response = await fetch(`${BASE_URL}${builderProjectPath(sessionId, suffix)}`, {
14
+ method: body === undefined ? 'GET' : 'POST',
15
+ redirect: 'error',
16
+ headers: body === undefined ? {} : { 'Content-Type': 'application/json' },
17
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
18
+ signal: AbortSignal.timeout(timeoutMs),
19
+ })
20
+ } catch {
21
+ throw new BuilderCliError('Local builder request failed. Check the local dashboard and retry.')
22
+ }
23
+ if (!response.ok) throw new BuilderCliError(`Local builder request refused (HTTP ${response.status}). Check the local dashboard.`)
24
+ try {
25
+ return parseBuilderPlatformStatus(await response.json())
26
+ } catch {
27
+ throw new BuilderCliError('Invalid local builder response.')
28
+ }
29
+ }
30
+
31
+ export function publicPlatformStatus(status: BuilderPlatformStatus) {
32
+ return {
33
+ ...status,
34
+ pairing: status.pairing === null ? null : {
35
+ status: status.pairing.status,
36
+ ...(status.pairing.status === 'pending' ? { expiresAt: status.pairing.expiresAt } : {}),
37
+ },
38
+ deployment: status.deployment === null ? null : { ...status.deployment, message: null },
39
+ }
40
+ }
@@ -0,0 +1,21 @@
1
+ import { BUILDER_DAEMON_HOST } from '../../shared/urls'
2
+
3
+ export function admitRequest(req: Request, url: URL, port: number): Response | null {
4
+ const authority = req.headers.get('host')
5
+ const allowedHostname = url.hostname === BUILDER_DAEMON_HOST || url.hostname === 'localhost'
6
+ const allowedAuthority = authority === `${url.hostname}:${port}` || (port === 80 && authority === url.hostname)
7
+ const expectedPort = port === 80 ? '' : String(port)
8
+ const origin = req.headers.get('origin')
9
+ const site = req.headers.get('sec-fetch-site')
10
+ const safeNavigation = site === 'none' && (req.method === 'GET' || req.method === 'HEAD')
11
+
12
+ if (
13
+ !allowedHostname || !allowedAuthority || url.protocol !== 'http:' || url.port !== expectedPort || url.username !== '' || url.password !== ''
14
+ || (origin !== null && origin !== url.origin)
15
+ || (site !== null && site !== 'same-origin' && !safeNavigation)
16
+ ) {
17
+ return new Response('Forbidden', { status: 403 })
18
+ }
19
+
20
+ return null
21
+ }
package/daemon/src/bin.ts CHANGED
@@ -2,27 +2,25 @@
2
2
 
3
3
  import { mkdirSync, rmSync, writeFileSync } from 'fs'
4
4
  import { resolve } from 'path'
5
+ import { activeDaemonSchema, builderDaemonUrl } from '../../shared'
5
6
  import { startDaemon } from './index'
6
7
  import { PID_FILE, SESSIONS_DIR, TEMP_DIR } from './paths'
7
8
 
8
9
  const port = parseInt(process.env['FABRIKA_PORT'] || '19410', 10)
9
10
 
10
11
  interface ActiveDaemon {
11
- ok?: boolean
12
- sessionsDir?: string
12
+ ok: boolean
13
+ sessionsDir: string
13
14
  }
14
15
 
15
16
  function parseActiveDaemon(value: unknown): ActiveDaemon | null {
16
- if (typeof value !== 'object' || value === null || Array.isArray(value)) return null
17
- const ok = 'ok' in value ? value.ok : undefined
18
- const sessionsDir = 'sessionsDir' in value ? value.sessionsDir : undefined
19
- if (ok !== true || (sessionsDir !== undefined && typeof sessionsDir !== 'string')) return null
20
- return { ok, ...(sessionsDir === undefined ? {} : { sessionsDir }) }
17
+ const result = activeDaemonSchema.safeParse(value)
18
+ return result.success ? result.data : null
21
19
  }
22
20
 
23
21
  async function activeDaemon(): Promise<ActiveDaemon | null> {
24
22
  try {
25
- const res = await fetch(`http://localhost:${port}/health`, {
23
+ const res = await fetch(`${builderDaemonUrl(port)}/health`, {
26
24
  signal: AbortSignal.timeout(1000),
27
25
  })
28
26
  const data: unknown = await res.json()
@@ -34,20 +32,20 @@ async function activeDaemon(): Promise<ActiveDaemon | null> {
34
32
 
35
33
  const active = await activeDaemon()
36
34
  if (active) {
37
- if (active.sessionsDir && resolve(active.sessionsDir) === resolve(SESSIONS_DIR)) {
38
- console.log(`fabrika daemon already running on http://localhost:${port} — not starting a second.`)
35
+ if (resolve(active.sessionsDir) === resolve(SESSIONS_DIR)) {
36
+ console.log(`fabrika daemon already running on ${builderDaemonUrl(port)} — not starting a second.`)
39
37
  process.exit(0)
40
38
  }
41
39
  console.error(
42
- `fabrika daemon on :${port} uses ${active.sessionsDir ?? 'an unknown legacy data root'}; `
40
+ `fabrika daemon on :${port} uses ${active.sessionsDir}; `
43
41
  + `requested ${SESSIONS_DIR}. Stop it before starting this daemon.`,
44
42
  )
45
43
  process.exit(1)
46
44
  }
47
45
 
48
- let server: ReturnType<typeof startDaemon>['server']
46
+ let boundPort: number
49
47
  try {
50
- ;({ server } = startDaemon({ port }))
48
+ ;({ port: boundPort } = startDaemon({ port }))
51
49
  } catch (error: unknown) {
52
50
  console.error(`fabrika daemon failed to bind :${port}: ${error instanceof Error ? error.message : String(error)}`)
53
51
  process.exit(1)
@@ -56,7 +54,7 @@ try {
56
54
  mkdirSync(TEMP_DIR, { recursive: true })
57
55
  writeFileSync(PID_FILE, String(process.pid))
58
56
 
59
- console.log(`fabrika daemon listening on http://localhost:${server.port}`)
57
+ console.log(`fabrika daemon listening on ${builderDaemonUrl(boundPort)}`)
60
58
 
61
59
  function shutdown() {
62
60
  try {
@@ -1,10 +1,14 @@
1
- import { type CanvasScope, compileJsx, type SessionManager, watchSession } from '@fabrika/canvas-kernel/server'
1
+ import { type AssertReadable, type CanvasScope, compileJsx, type SessionManager, watchSession } from '@fabrika/canvas-kernel/server'
2
2
  import type { Route } from '@fabrika/daemon-kit'
3
3
  import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
4
- import { basename, dirname, extname, join, resolve, sep } from 'path'
4
+ import { basename, dirname, extname, join } from 'path'
5
+ import type { z } from 'zod'
6
+ import { builderDaemonUrl, buildEventSchema, planInputSchema } from '../../shared'
5
7
  import type { BuildEvent, BuildStream } from './build-stream'
6
8
  import { COMPILE_TEMP_DIR } from './paths'
7
9
  import { derivePhaseState, type GateOracle } from './phases'
10
+ import { BuilderPlatform } from './platform'
11
+ import { PlatformError } from './platform-storage'
8
12
 
9
13
  function jsonResponse(data: unknown, status = 200): Response {
10
14
  return new Response(JSON.stringify(data), {
@@ -13,10 +17,6 @@ function jsonResponse(data: unknown, status = 200): Response {
13
17
  })
14
18
  }
15
19
 
16
- function isRecord(value: unknown): value is Record<string, unknown> {
17
- return typeof value === 'object' && value !== null && !Array.isArray(value)
18
- }
19
-
20
20
  function errorMessage(error: unknown): string {
21
21
  return error instanceof Error ? error.message : String(error)
22
22
  }
@@ -27,33 +27,9 @@ function routeParam(match: URLPatternResult, name: string): string {
27
27
  return value
28
28
  }
29
29
 
30
- function optionalString(value: unknown): string | undefined {
31
- return typeof value === 'string' ? value : undefined
32
- }
33
-
34
- function isBuildStatus(value: unknown): value is BuildEvent['status'] {
35
- return value === 'queued' || value === 'running' || value === 'passed' || value === 'failed' || value === 'blocked'
36
- || value === 'info'
37
- }
38
-
39
- function parseBuildEvent(value: unknown): BuildEvent | null {
40
- if (!isRecord(value) || !isBuildStatus(value['status'])) return null
41
- const issue = optionalString(value['issue'])
42
- const message = optionalString(value['message'])
43
- const at = optionalString(value['at'])
44
- const data = isRecord(value['data']) ? value['data'] : undefined
45
- return {
46
- status: value['status'],
47
- ...(issue === undefined ? {} : { issue }),
48
- ...(message === undefined ? {} : { message }),
49
- ...(at === undefined ? {} : { at }),
50
- ...(data === undefined ? {} : { data }),
51
- }
52
- }
53
-
54
30
  export function corsHeaders(port: number): Record<string, string> {
55
31
  return {
56
- 'Access-Control-Allow-Origin': `http://localhost:${port}`,
32
+ 'Access-Control-Allow-Origin': builderDaemonUrl(port),
57
33
  'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
58
34
  'Access-Control-Allow-Headers': 'Content-Type',
59
35
  }
@@ -67,6 +43,7 @@ export interface HandlerContext {
67
43
  sessionsDir: string
68
44
  port: number
69
45
  version: string
46
+ platform?: BuilderPlatform
70
47
  }
71
48
 
72
49
  function gateOracleFor(sm: SessionManager): GateOracle {
@@ -89,9 +66,18 @@ interface CanvasSelection {
89
66
  scope: CanvasScope
90
67
  }
91
68
 
92
- function resolveCanvasSelection(body: Record<string, unknown>): CanvasSelection {
93
- const view = typeof body['view'] === 'string' ? body['view'] : null
94
- const directory = typeof body['directory'] === 'string' ? body['directory'] : null
69
+ function readCanvasFile(path: string, assertReadable: AssertReadable): string {
70
+ try {
71
+ assertReadable(path)
72
+ return readFileSync(path, 'utf-8')
73
+ } catch {
74
+ throw new Error('Canvas file access refused')
75
+ }
76
+ }
77
+
78
+ function resolveCanvasSelection(body: z.infer<typeof planInputSchema>, assertReadable: AssertReadable): CanvasSelection {
79
+ const view = body.view ?? null
80
+ const directory = body.directory ?? null
95
81
  if (!!view === !!directory) {
96
82
  throw new Error('Provide exactly one of view or directory')
97
83
  }
@@ -105,7 +91,7 @@ function resolveCanvasSelection(body: Record<string, unknown>): CanvasSelection
105
91
  }
106
92
  const filename = basename(view)
107
93
  return {
108
- files: new Map([[filename, readFileSync(view, 'utf-8')]]),
94
+ files: new Map([[filename, readCanvasFile(view, assertReadable)]]),
109
95
  scope: { kind: 'view', filename },
110
96
  }
111
97
  }
@@ -118,13 +104,15 @@ function resolveCanvasSelection(body: Record<string, unknown>): CanvasSelection
118
104
  if (filenames.length === 0) throw new Error('No .jsx files found in directory')
119
105
  const files = new Map<string, string>()
120
106
  for (const filename of filenames) {
121
- files.set(filename, readFileSync(join(directory, filename), 'utf-8'))
107
+ files.set(filename, readCanvasFile(join(directory, filename), assertReadable))
122
108
  }
123
109
  return { files, scope: { kind: 'directory' } }
124
110
  }
125
111
 
126
112
  export function createHandlers(ctx: HandlerContext): Route[] {
127
113
  const { sessionManager, broadcastPlanUpdate, broadcastRevisionUpdate, buildStream } = ctx
114
+ const platform = ctx.platform ?? new BuilderPlatform((id) => sessionManager.get(id)?.projectRoot)
115
+ const assertReadable: AssertReadable = (path) => platform.storage.assertPublicFile(path)
128
116
 
129
117
  function handleHealth(): Response {
130
118
  return jsonResponse({
@@ -138,10 +126,11 @@ export function createHandlers(ctx: HandlerContext): Route[] {
138
126
  }
139
127
 
140
128
  function handlePhase(_req: Request, url: URL): Response {
141
- const root = url.searchParams.get('root')
142
- if (!root) return jsonResponse({ error: 'Missing ?root=<fabrikaDir>' }, 400)
143
129
  const sessionId = url.searchParams.get('session') ?? ''
144
130
  try {
131
+ const projectRoot = sessionId ? platform.projectRoot(sessionId) : undefined
132
+ const root = url.searchParams.get('root') ?? (projectRoot ? join(projectRoot, '.fabrika') : null)
133
+ if (!root) return jsonResponse({ error: 'Missing ?root=<fabrikaDir>' }, 400)
145
134
  const state = derivePhaseState(root, sessionId, gateOracleFor(sessionManager))
146
135
  return jsonResponse(state)
147
136
  } catch (error: unknown) {
@@ -152,12 +141,12 @@ export function createHandlers(ctx: HandlerContext): Route[] {
152
141
  async function handlePlanPost(req: Request, _url: URL, match: URLPatternResult): Promise<Response> {
153
142
  const sessionId = routeParam(match, 'id')
154
143
  try {
155
- const parsedBody: unknown = await req.json()
156
- if (!isRecord(parsedBody)) return jsonResponse({ error: 'Expected a JSON object' }, 400)
157
- const body = parsedBody
144
+ const parsedBody = planInputSchema.safeParse(await req.json())
145
+ if (!parsedBody.success) return jsonResponse({ error: 'Expected a JSON object' }, 400)
146
+ const body = parsedBody.data
158
147
  let selection: CanvasSelection
159
148
  try {
160
- selection = resolveCanvasSelection(body)
149
+ selection = resolveCanvasSelection(body, assertReadable)
161
150
  } catch (error) {
162
151
  return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 400)
163
152
  }
@@ -177,17 +166,18 @@ export function createHandlers(ctx: HandlerContext): Route[] {
177
166
  )
178
167
  }
179
168
 
180
- const projectRoot = optionalString(body['projectRoot']) ?? process.cwd()
169
+ const boundRoot = platform.projectRoot(sessionId)
170
+ const projectRoot = platform.storage.assertProjectRoot(boundRoot ?? body.projectRoot ?? process.cwd(), true)
181
171
  const isNew = !sessionManager.get(sessionId)
182
- const gate = optionalString(body['gate'])
183
- const label = gate ?? optionalString(body['label'])
184
- const response = optionalString(body['response'])
172
+ const gate = body.gate
173
+ const label = gate ?? body.label
174
+ const response = body.response
185
175
 
186
176
  const compiled = new Map<string, string>()
187
177
  const errors: Record<string, string> = {}
188
178
  await Promise.all(
189
179
  [...canvasFiles.entries()].map(async ([filename, jsx]) => {
190
- const result = await compileJsx(jsx, { projectRoot, tempDir: COMPILE_TEMP_DIR })
180
+ const result = await compileJsx(jsx, { projectRoot, tempDir: COMPILE_TEMP_DIR, assertReadable })
191
181
  if (result.ok) compiled.set(filename, result.js)
192
182
  else errors[filename] = result.error
193
183
  }),
@@ -209,9 +199,9 @@ export function createHandlers(ctx: HandlerContext): Route[] {
209
199
  }
210
200
 
211
201
  broadcastPlanUpdate(sessionId)
212
- watchSession(sessionId, sessionManager, broadcastPlanUpdate)
202
+ watchSession(sessionId, sessionManager, broadcastPlanUpdate, { tempDir: COMPILE_TEMP_DIR, assertReadable })
213
203
 
214
- const browserUrl = `http://localhost:${ctx.port}/s/${sessionId}`
204
+ const browserUrl = `${builderDaemonUrl(ctx.port)}/s/${sessionId}`
215
205
  return jsonResponse({
216
206
  ok: true,
217
207
  browserUrl,
@@ -233,32 +223,42 @@ export function createHandlers(ctx: HandlerContext): Route[] {
233
223
  const revParam = url.searchParams.get('rev')
234
224
  const rev = revParam ? parseInt(revParam, 10) : undefined
235
225
 
236
- let compiled = sessionManager.getCompiled(sessionId, jsxFilename, rev)
237
-
238
- if (!compiled && rev) {
239
- const session = sessionManager.get(sessionId)
240
- if (session) {
241
- const jsx = sessionManager.readRevisionJsx(sessionId, rev, jsxFilename)
242
- if (jsx) {
243
- const result = await compileJsx(jsx, { projectRoot: session.projectRoot, tempDir: COMPILE_TEMP_DIR })
244
- if (result.ok) {
245
- sessionManager.saveCompiled(sessionId, jsxFilename, result.js, rev)
246
- compiled = result.js
247
- }
248
- }
249
- }
226
+ const session = sessionManager.get(sessionId)
227
+ const revision = rev ?? session?.currentRevision
228
+ if (!session || !revision || !session.revisions.some((r) => r.revision === revision && r.canvasFiles.some((f) => f.filename === jsxFilename))) {
229
+ return jsonResponse({ error: 'No compiled canvas' }, 404)
230
+ }
231
+ try {
232
+ const projectRoot = platform.storage.assertProjectRoot(session.projectRoot)
233
+ const jsx = readCanvasFile(sessionManager.getRevisionJsxPath(sessionId, revision, jsxFilename), assertReadable)
234
+ // Stored bundles predate admission and dependencies can change after a successful compile.
235
+ const result = await compileJsx(jsx, { projectRoot, tempDir: COMPILE_TEMP_DIR, assertReadable })
236
+ if (!result.ok) return jsonResponse({ error: 'Canvas compilation failed' }, 400)
237
+ sessionManager.saveCompiled(sessionId, jsxFilename, result.js, revision)
238
+ return new Response(result.js, {
239
+ headers: { 'Content-Type': 'application/javascript', 'Cache-Control': 'no-cache' },
240
+ })
241
+ } catch {
242
+ return jsonResponse({ error: 'Canvas file access refused' }, 403)
250
243
  }
251
-
252
- if (!compiled) return jsonResponse({ error: 'No compiled canvas' }, 404)
253
- return new Response(compiled, {
254
- headers: { 'Content-Type': 'application/javascript', 'Cache-Control': 'no-cache' },
255
- })
256
244
  }
257
245
 
258
246
  function handleMeta(_req: Request, _url: URL, match: URLPatternResult): Response {
259
247
  const sessionId = routeParam(match, 'id')
260
248
  const session = sessionManager.get(sessionId)
261
- if (!session) return jsonResponse({ error: 'Session not found' }, 404)
249
+ if (!session) {
250
+ const project = platform.storage.project(sessionId)
251
+ if (!project) return jsonResponse({ error: 'Session not found' }, 404)
252
+ const config = platform.storage.config(project)
253
+ return jsonResponse({
254
+ projectRoot: project.projectRoot,
255
+ currentRevision: 0,
256
+ revisions: [],
257
+ canvasFiles: [],
258
+ createdAt: config.createdAt,
259
+ updatedAt: config.createdAt,
260
+ })
261
+ }
262
262
  return jsonResponse({
263
263
  projectRoot: session.projectRoot,
264
264
  currentRevision: session.currentRevision,
@@ -271,23 +271,20 @@ export function createHandlers(ctx: HandlerContext): Route[] {
271
271
 
272
272
  function handleArtifact(_req: Request, url: URL, match: URLPatternResult): Response {
273
273
  const sessionId = routeParam(match, 'id')
274
- const session = sessionManager.get(sessionId)
275
- if (!session) return jsonResponse({ error: 'Session not found' }, 404)
274
+ const projectRoot = platform.projectRoot(sessionId)
275
+ if (!projectRoot) return jsonResponse({ error: 'Session not found' }, 404)
276
276
 
277
277
  const rel = url.searchParams.get('path')
278
278
  if (!rel) return jsonResponse({ error: 'Missing ?path=<relative path>' }, 400)
279
279
 
280
- const root = resolve(session.projectRoot)
281
- const target = resolve(root, rel)
282
- if (target !== root && !target.startsWith(root + sep)) {
283
- return jsonResponse({ error: 'Path escapes project root' }, 403)
284
- }
285
280
  try {
281
+ const target = platform.storage.artifact(projectRoot, rel)
282
+ platform.storage.assertPublicFile(target)
286
283
  const st = statSync(target)
287
284
  if (!st.isFile()) return jsonResponse({ error: 'Not a file' }, 404)
288
285
  return jsonResponse({ path: rel, content: readFileSync(target, 'utf-8') })
289
- } catch {
290
- return jsonResponse({ error: 'Artifact not found' }, 404)
286
+ } catch (error) {
287
+ return jsonResponse({ error: 'Artifact not found or outside the project' }, error instanceof PlatformError ? 403 : 404)
291
288
  }
292
289
  }
293
290
 
@@ -317,6 +314,12 @@ export function createHandlers(ctx: HandlerContext): Route[] {
317
314
  const skeletons: unknown[] = []
318
315
  for (const entry of readdirSync(dir).sort()) {
319
316
  if (!/\.test\.ts$/.test(entry)) continue
317
+ try {
318
+ platform.storage.artifact(session.projectRoot, join('tests', 'browser', entry))
319
+ platform.storage.assertPublicFile(join(dir, entry))
320
+ } catch {
321
+ continue
322
+ }
320
323
  const src = readFileSync(join(dir, entry), 'utf-8')
321
324
  const re = /browserTest\s*\(/g
322
325
  let mm: RegExpExecArray | null
@@ -415,9 +418,9 @@ export function createHandlers(ctx: HandlerContext): Route[] {
415
418
  async function handleBuildEvent(req: Request): Promise<Response> {
416
419
  try {
417
420
  const body: unknown = await req.json()
418
- const event = parseBuildEvent(body)
419
- if (event === null) return jsonResponse({ error: 'Invalid or missing status' }, 400)
420
- const ev = buildStream.append(event)
421
+ const event = buildEventSchema.safeParse(body)
422
+ if (!event.success) return jsonResponse({ error: 'Invalid or missing status' }, 400)
423
+ const ev = buildStream.append(event.data)
421
424
  return jsonResponse({ ok: true, event: ev })
422
425
  } catch (error: unknown) {
423
426
  return jsonResponse({ error: errorMessage(error) }, 500)
@@ -1,14 +1,18 @@
1
1
  import { createWebSocketManager, SessionManager, unwatchAll, type WSData } from '@fabrika/canvas-kernel/server'
2
2
  import { dispatch } from '@fabrika/daemon-kit'
3
3
  import { resolve } from 'path'
4
+ import { BUILDER_DAEMON_HOST } from '../../shared/urls'
5
+ import { admitRequest } from './admission'
4
6
  import { BuildStream } from './build-stream'
5
- import { corsHeaders, createHandlers } from './handlers'
7
+ import { createHandlers } from './handlers'
6
8
  import { SESSIONS_DIR } from './paths'
9
+ import { BuilderPlatform, type BuilderPlatformOptions } from './platform'
10
+ import { createPlatformRoutes } from './platform-routes'
7
11
  import { createStaticHandlers } from './static'
8
12
 
13
+ export { type Phase, type PhaseId, PHASES } from '../../shared/phases'
9
14
  export type { BuildEvent } from './build-stream'
10
- export { derivePhaseState, PHASES } from './phases'
11
- export type { Phase, PhaseId, PhaseState } from './phases'
15
+ export { derivePhaseState, type PhaseState } from './phases'
12
16
 
13
17
  export interface GateRecord {
14
18
  name: string
@@ -23,6 +27,7 @@ const VERSION = '0.0.0'
23
27
  export interface StartDaemonOptions {
24
28
  port?: number
25
29
  sessionsDir?: string
30
+ platform?: BuilderPlatformOptions
26
31
  }
27
32
 
28
33
  export interface RunningDaemon {
@@ -40,6 +45,7 @@ export function startDaemon(opts: StartDaemonOptions = {}): RunningDaemon {
40
45
  sessionManager.cleanupStale()
41
46
  const wsManager = createWebSocketManager(sessionManager, { feedbackConsumption: 'external' })
42
47
  const buildStream = new BuildStream()
48
+ const platform = new BuilderPlatform((id) => sessionManager.get(id)?.projectRoot, opts.platform)
43
49
 
44
50
  const portRef = { value: port }
45
51
 
@@ -48,6 +54,7 @@ export function startDaemon(opts: StartDaemonOptions = {}): RunningDaemon {
48
54
  broadcastPlanUpdate: wsManager.broadcastPlanUpdate,
49
55
  broadcastRevisionUpdate: wsManager.broadcastRevisionUpdate,
50
56
  buildStream,
57
+ platform,
51
58
  sessionsDir,
52
59
  get port() {
53
60
  return portRef.value
@@ -55,16 +62,18 @@ export function startDaemon(opts: StartDaemonOptions = {}): RunningDaemon {
55
62
  version: VERSION,
56
63
  }
57
64
 
58
- const routes = [...createHandlers(handlerCtx), ...createStaticHandlers()]
65
+ const routes = [...createPlatformRoutes(platform), ...createHandlers(handlerCtx), ...createStaticHandlers()]
59
66
 
60
67
  const server = Bun.serve<WSData>({
61
68
  port,
62
- hostname: 'localhost',
69
+ hostname: BUILDER_DAEMON_HOST,
63
70
  fetch(req, server) {
64
71
  const url = new URL(req.url)
72
+ const rejection = admitRequest(req, url, portRef.value)
73
+ if (rejection) return rejection
65
74
 
66
75
  if (req.method === 'OPTIONS') {
67
- return new Response(null, { status: 204, headers: corsHeaders(portRef.value) })
76
+ return new Response(null, { status: 204 })
68
77
  }
69
78
 
70
79
  const wsBrowser = url.pathname.match(/^\/ws\/session\/([^/]+)$/)
@@ -113,6 +122,7 @@ export function startDaemon(opts: StartDaemonOptions = {}): RunningDaemon {
113
122
  port: boundPort,
114
123
  sessionManager,
115
124
  stop() {
125
+ platform.stop()
116
126
  try {
117
127
  unwatchAll()
118
128
  } catch {