@fabrika/builder 0.3.3 → 0.4.1

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.
package/README.md CHANGED
@@ -17,14 +17,15 @@ The current phase is derived from files in `.fabrika/`; it is not stored as a mu
17
17
 
18
18
  ## Usage
19
19
 
20
- Install the public CLI and initialize the builder in an application repository:
20
+ Install the public CLI with this package, which it loads but does not install, and initialize the builder in an
21
+ application repository:
21
22
 
22
23
  ```bash
23
- bun add --global @fabrika/cli
24
+ bun add --global @fabrika/cli @fabrika/builder
24
25
  fabrika builder init
25
26
  ```
26
27
 
27
- Initialization installs the Fabrika skill for Claude Code and Codex, starts the local daemon, and opens the dashboard. The coding agent then drives the phase loop.
28
+ Initialization installs the Fabrika skill and the `fabrika-app` application rules (from `@fabrika/app`) for Claude Code and Codex, starts the local daemon, and opens the dashboard. The coding agent then drives the phase loop.
28
29
 
29
30
  ```text
30
31
  fabrika builder next
@@ -70,6 +71,7 @@ Connected initialization uses `zerops-app` for a new project. A person approves
70
71
  issues/
71
72
  .claude/
72
73
  .agents/skills/fabrika/
74
+ .agents/skills/fabrika-app/
73
75
  .codex/agents/
74
76
  tests/browser/*.test.ts
75
77
  ```
@@ -74,6 +74,12 @@ export async function cmdInit(argv: string[]): Promise<number> {
74
74
  + `${support.written.length} installed, ${support.updated.length} updated, `
75
75
  + `${support.skipped.length} current, ${support.preserved.length} user-edited preserved`,
76
76
  )
77
+ if (support.appSkill?.from === 'builder') {
78
+ console.error(
79
+ `[fabrika] the application has not installed @fabrika/app: installed the builder's fabrika-app rules `
80
+ + `(${support.appSkill.version}); run bun install, then fabrika builder init again for the pinned version`,
81
+ )
82
+ }
77
83
  } else {
78
84
  console.error(`[fabrika] agent support: source dir not found, skipped installation`)
79
85
  }
@@ -1,3 +1,4 @@
1
+ import { APP_AGENT_SKILL_NAME, type AppAgentSkill, installedAppAgentSkill, readAppAgentSkill } from '@fabrika/app/agent-skill'
1
2
  import { createHash } from 'crypto'
2
3
  import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'fs'
3
4
  import { dirname, join, relative, resolve } from 'path'
@@ -21,9 +22,16 @@ function isAgentHost(value: string): value is AgentHost {
21
22
  return value === 'claude' || value === 'codex'
22
23
  }
23
24
 
25
+ /** Where the installed `fabrika-app` skill came from: the application's own `@fabrika/app`, or the builder's. */
26
+ export interface AppSkillSource {
27
+ from: 'application' | 'builder'
28
+ version: string
29
+ }
30
+
24
31
  export interface InjectResult {
25
32
  sourceDir: string | null
26
33
  hosts: AgentHost[]
34
+ appSkill: AppSkillSource | null
27
35
  written: string[]
28
36
  updated: string[]
29
37
  skipped: string[]
@@ -72,6 +80,7 @@ export function injectAgentSupport(repoRoot: string, opts: InjectOptions = {}):
72
80
  const result: InjectResult = {
73
81
  sourceDir,
74
82
  hosts,
83
+ appSkill: null,
75
84
  written: [],
76
85
  updated: [],
77
86
  skipped: [],
@@ -80,7 +89,11 @@ export function injectAgentSupport(repoRoot: string, opts: InjectOptions = {}):
80
89
  if (!sourceDir) return result
81
90
 
82
91
  const root = resolve(repoRoot)
83
- const desired = renderDesiredFiles(sourceDir, hosts)
92
+ // The application's pinned `@fabrika/app` is the version its code compiles against; before `bun install` there is none.
93
+ const pinned = installedAppAgentSkill(root)
94
+ const appSkill = pinned ?? readAppAgentSkill()
95
+ result.appSkill = { from: pinned === null ? 'builder' : 'application', version: appSkill.version }
96
+ const desired = renderDesiredFiles(sourceDir, hosts, appSkill)
84
97
  const manifestPath = join(root, MANIFEST_RELATIVE_PATH)
85
98
  const manifest = readManifest(manifestPath)
86
99
 
@@ -100,21 +113,25 @@ function normalizeHosts(hosts?: AgentHost[]): AgentHost[] {
100
113
  return [...new Set(selected)].sort()
101
114
  }
102
115
 
103
- function renderDesiredFiles(sourceDir: string, hosts: AgentHost[]): DesiredFile[] {
116
+ function renderDesiredFiles(sourceDir: string, hosts: AgentHost[], appSkill: AppAgentSkill): DesiredFile[] {
104
117
  const skill = readFileSync(join(sourceDir, 'SKILL.md'), 'utf-8')
105
118
  const agents = readAgentSources(join(sourceDir, 'agents'))
106
119
  const resources = readSkillResources(sourceDir)
107
120
  const files: DesiredFile[] = []
108
121
 
109
122
  for (const host of hosts) {
110
- const skillRoot = host === 'claude'
111
- ? join('.claude', 'skills', 'fabrika')
112
- : join('.agents', 'skills', 'fabrika')
123
+ const skillsDir = host === 'claude' ? join('.claude', 'skills') : join('.agents', 'skills')
124
+ const skillRoot = join(skillsDir, 'fabrika')
113
125
  files.push({
114
126
  relativePath: join(skillRoot, 'SKILL.md'),
115
127
  content: host === 'claude' ? renderClaudeSkill(skill) : skill,
116
128
  host,
117
129
  })
130
+ const appSkillRoot = join(skillsDir, APP_AGENT_SKILL_NAME)
131
+ files.push({ relativePath: join(appSkillRoot, 'SKILL.md'), content: appSkill.text, host })
132
+ for (const reference of appSkill.references) {
133
+ files.push({ relativePath: join(appSkillRoot, reference.path), content: reference.text, host })
134
+ }
118
135
  for (const resource of resources) {
119
136
  if (host === 'claude' && resource.relativePath === join('agents', 'openai.yaml')) continue
120
137
  files.push({
@@ -31,6 +31,11 @@ const environmentsSchema = z.object({
31
31
  domain: storedTextSchema.nullable(),
32
32
  publicOrigin: storedTextSchema.nullable(),
33
33
  triggerRef: storedTextSchema.nullable(),
34
+ authoring: z.boolean(),
35
+ protected: z.boolean(),
36
+ publishedRunId: storedTextSchema.nullable(),
37
+ // A Control older than the field lists none.
38
+ deviations: z.array(storedTextSchema).default([]),
34
39
  namespaceId: storedTextSchema.nullable(),
35
40
  provider: storedTextSchema,
36
41
  target: envelopeSchema,
@@ -44,7 +49,7 @@ const runSchema = z.object({
44
49
  env: storedTextSchema,
45
50
  ref: storedTextSchema,
46
51
  commitSha: storedTextSchema.nullable(),
47
- status: z.enum(['pending', 'running', 'succeeded', 'failed']),
52
+ status: z.enum(['pending', 'running', 'awaiting_approval', 'succeeded', 'failed']),
48
53
  })
49
54
 
50
55
  export async function boundedJson(response: Response): Promise<unknown> {
@@ -470,10 +470,13 @@ export class BuilderPlatform {
470
470
  ? 'Deployment succeeded. The configured URL has not been checked for reachability.'
471
471
  : run.status === 'failed'
472
472
  ? 'Platform deployment failed. See the console for details.'
473
+ : run.status === 'awaiting_approval'
474
+ ? 'The deployment changes access on a protected environment and waits for a person to approve it in the console.'
473
475
  : 'Platform deployment is in progress.',
474
476
  }
475
477
  this.storage.saveProject(project)
476
- if (terminal) return
478
+ // A parked run waits for a person, not for the platform; the builder key cannot approve it.
479
+ if (terminal || run.status === 'awaiting_approval') return
477
480
  await this.sleep(3000, signal)
478
481
  }
479
482
  throw new ObservationError('Deployment polling timed out. Inspect the saved run in the platform console.')
@@ -13,6 +13,8 @@ sources:
13
13
  read: "Current Control deployment contract and provider lifecycle; do not infer builder command syntax."
14
14
  - where: "examples/zerops-app/README.md + fabrika.config.ts + zerops.yaml in fabrika-platform"
15
15
  read: "Application-owned source artifacts versus platform-owned deployment and secrets."
16
+ - where: "docs/decisions/0050-approved-access.md in fabrika-platform"
17
+ read: "The access snapshot Control reads from each commit, and which changes wait for a person's approval."
16
18
  ---
17
19
 
18
20
  # Deploy through installed Control
@@ -35,6 +37,12 @@ prerequisites are a handoff to that administrator.
35
37
  - Control and the Zerops provider supply credentials server-side, deliver source
36
38
  and trigger Zerops' build/deploy pipeline. The local coding agent does not need
37
39
  a Zerops account token or an installation provisioning credential.
40
+ - Control reads the contract, `src/db/tables.ts` and the migration list from the
41
+ deployed commit and publishes only that approved snapshot to the proxy and IAM.
42
+ A widening (a new endpoint, caller, role grant, table policy change or migration)
43
+ on a protected environment (`prod` by default) parks the run in
44
+ `awaiting_approval` until a person approves it in the console. The agent cannot
45
+ approve; it reports the parked run and what the change lets someone do.
38
46
  - Follow the Control run to its terminal result and verify its requested revision
39
47
  and public proxy URL. A successful build, a trigger response or a local emulator
40
48
  result alone is not proof of a successful production release.
@@ -9,8 +9,10 @@ recommends: [tailwind, opice]
9
9
  conflicts: [single-worker-spa]
10
10
  status: wip
11
11
  sources:
12
- - where: "examples/zerops-app/src/server.ts + src/app.ts + README.md in fabrika-platform"
13
- read: "Bun backend composition and the limits of the example's local run instructions."
12
+ - where: "examples/zerops-app/fabrika.contract.ts + src/notes/endpoints.ts + src/app.ts + README.md in fabrika-platform"
13
+ read: "The contract's mounts, `serve` over the data layer, the SPA build, and the limits of the example's local run instructions."
14
+ - where: "docs/reference/application-runtime.md (Serving a contract, Testing a contract application) in fabrika-platform"
15
+ read: "The generated entrypoint, the framework variables `boot` needs, `testKit` and the generated tests."
14
16
  - where: "docs/reference/local-development.md + packages/local-stack/CLAUDE.md in fabrika-platform"
15
17
  read: "Real IAM/proxy/Postgres topology, local endpoints and IAM-owned login bypass."
16
18
  - where: "packages/local-stack/src/app-registration.ts in fabrika-platform"
@@ -19,16 +21,28 @@ sources:
19
21
 
20
22
  # Local Bun SPA and API
21
23
 
22
- Apply this to a client-rendered React/Buzola app served with its API by a Bun
23
- process. Use Bun-native asset bundling and Buzola codegen. API namespaces must
24
- return their own errors and 404s; only UI routes receive SPA fallback.
24
+ Apply this to a client-rendered React/Buzola app served with its API by the
25
+ generated Bun entrypoint of a contract application. The contract declares both:
26
+ the SPA as `c.spa(prefix, { dir, access })`, served from the built asset
27
+ directory for signed-in people, and the API as a `c.rpc` mount. An unknown
28
+ procedure under an RPC prefix is 404, never the SPA. Use Bun-native asset bundling
29
+ and Buzola codegen; the SPA's build commands are the only commands the authored
30
+ `zerops.yaml` may add. The SPA reads no `import.meta.env` other than
31
+ `FABRIKA_PUBLIC_*` and calls the API through `createClient(contract, 'api')` of
32
+ `@fabrika/app/client`.
25
33
 
26
34
  ## Local composition to scaffold
27
35
 
28
36
  The development loop needs PostgreSQL, real IAM, a proxy with this application's
29
- gates and a private Bun upstream. Register the application's schema and exact
30
- local proxy return origin with local IAM. The app's configured issuer and audience
31
- must match the tokens IAM mints. Keep the local database and credentials separate
37
+ endpoint table (`contractProxyTable` of the compiled contract) and a private Bun
38
+ upstream started the way the platform starts it: `fabrika db migrate`, then
39
+ `fabrika/entry.generated.ts` (`fabrika app build` writes it) with the framework
40
+ variables `boot` refuses to start without: `FABRIKA_APP_ID`,
41
+ `FABRIKA_APP_DATABASE_URL`, `FABRIKA_IAM_ISSUER`, `FABRIKA_IAM_APP_ID`,
42
+ `FABRIKA_CONTRACT_DIGEST`, a `FABRIKA_PROXY_SECRET` of at least 32 characters that
43
+ the proxy sends, and, for a `c.files` collection, `FABRIKA_APP_STORAGE_*`.
44
+ Register the application's `schemaFromContract` schema and exact local proxy
45
+ return origin with local IAM. Keep the local database and credentials separate
32
46
  from the installed production platform.
33
47
 
34
48
  Use the existing Fabrika local-stack composition as the reference. From the
@@ -37,12 +51,16 @@ services, and `bun run local:status` reports them. It pre-registers the fixture
37
51
  apps, not every new builder project. The public notes fixture is available at
38
52
  `http://notes.fabrika.localhost:18081` through its proxy.
39
53
 
40
- A new app needs its own documented local service wiring and registration; the
41
- example's bare server command does not provide those. Establish that wiring as
42
- the app's foundation work and verify it before reporting a working preview.
43
- If the local runtime cannot reach IAM, or a required registration cannot be made,
44
- report the missing prerequisite rather than adding fake auth or bypassing the
45
- proxy.
54
+ A new app needs its own documented local service wiring and registration. Establish
55
+ that wiring as the app's foundation work and verify it before reporting a working
56
+ preview. If the local runtime cannot reach IAM, or a required registration cannot
57
+ be made, report the missing prerequisite rather than adding fake auth or bypassing
58
+ the proxy.
59
+
60
+ Before the browser loop, run the application's own checks: `bun test` with
61
+ `FABRIKA_TEST_POSTGRES_URL` set (a database test that skips proves nothing),
62
+ `fabrika db test-scaffold` after every contract or table change, and
63
+ `fabrika-check`.
46
64
 
47
65
  ## Preview and acceptance
48
66
 
@@ -11,16 +11,26 @@ status: stable
11
11
  sources:
12
12
  - where: "packages/builder/decisions/technologies/opice.md in fabrika-platform"
13
13
  read: "Scenario seeds metadata and its filename contract."
14
- - where: "examples/zerops-app/src/migrate.ts + src/notes.ts in fabrika-platform"
15
- read: "PostgreSQL access through Bun SQL and repeatable migration behavior."
14
+ - where: "examples/zerops-app/db/migrations/ + src/db/tables.ts in fabrika-platform"
15
+ read: "The application schema, its scope columns, and the table policies a seeded row must satisfy."
16
+ - where: "packages/cli/CLAUDE.md (the `db` area) in fabrika-platform"
17
+ read: "`fabrika db migrate` and its environment: `FABRIKA_APP_ID`, `FABRIKA_APP_DATABASE_URL`, schema `app_<id>`."
16
18
  ---
17
19
 
18
20
  # Named PostgreSQL fixtures
19
21
 
20
22
  Apply this to the preset's local PostgreSQL database. An opice scenario naming
21
- `seeds: ['example']` requires `seeds/example.sql`. Run migrations first, then apply
22
- the named SQL files through the application's PostgreSQL driver or `psql` before
23
- the scenario. Document that local seed command in the scaffold.
23
+ `seeds: ['example']` requires `seeds/example.sql`. Run `fabrika db migrate` first,
24
+ then apply the named SQL files with `psql` against the application's schema
25
+ `app_<id>` (`SET search_path`) before the scenario. Document that local seed
26
+ command in the scaffold. Seeds are a local test tool, not application code: the
27
+ application reaches rows only through its scoped stores, and the checker refuses
28
+ raw SQL and database drivers in it.
29
+
30
+ A seeded row must look like one the data layer wrote: it carries its whole
31
+ containment chain (every scope column of its table policy) and its `createdBy` and
32
+ `version` columns where the policy names them, so the access forms find it in the
33
+ scope the scenario's principal is granted.
24
34
 
25
35
  Use stable fixture IDs and PostgreSQL `INSERT ... ON CONFLICT` semantics so
26
36
  repeated application is safe. Tests that mutate fixtures must restore their
@@ -46,5 +46,5 @@ adopted native Propustka sessions.
46
46
  ## Relationships — why
47
47
 
48
48
  - **conflicts `propustka`** — current Propustka owns native SSO sessions and
49
- in-process path gates; it no longer expects a Cloudflare Access edge.
49
+ request authorization; it no longer expects a Cloudflare Access edge.
50
50
  - **conflicts `auth-password-kv`** — both fill the `authentication` slot. Access = managed SSO; auth-password-kv = app-owned password+session. Pick one.
@@ -1,53 +1,72 @@
1
1
  ---
2
2
  id: zerops-fabrika-auth
3
3
  kind: technology
4
- title: Fabrika IAM with proxy-only enforcement on Zerops
4
+ title: Fabrika IAM with contract enforcement at the proxy on Zerops
5
5
  slot: authz
6
6
  provides: [authentication, authorization, proxy-enforcement]
7
7
  requires: [zerops-platform]
8
8
  recommends: []
9
9
  conflicts: [propustka, cloudflare-access, auth-password-kv]
10
- npm: ["@fabrika/auth@^0.0.4", "@fabrika/auth-core@^0.0.4"]
10
+ npm: ["@fabrika/app@^0.0.4", "@fabrika/db@^0.0.4", "zod@^3.25.76"]
11
11
  status: wip
12
12
  sources:
13
- - where: "packages/auth/CLAUDE.md + src/index.ts + src/iam.ts in fabrika-platform"
14
- read: "Verify-only SDK, real IAM requirements, negative versus unavailable results."
15
- - where: "examples/zerops-app/fabrika.gates.ts + fabrika.schema.ts + src/authz.ts in fabrika-platform"
16
- read: "Proxy gate declarations, app vocabulary and the example's direct JWKS verification."
13
+ - where: "packages/app/skill/SKILL.md in fabrika-platform (the `fabrika-app` skill)"
14
+ read: "The application rules: contract first, access forms, table policies, what the checker refuses, what is not built."
15
+ - where: "examples/zerops-app/src/vocabulary.ts + src/db/tables.ts + src/notes/endpoints.ts + fabrika.contract.ts in fabrika-platform"
16
+ read: "A worked contract: the vocabulary, the `/api` mount, the access forms and the handlers beside them."
17
+ - where: "docs/reference/application-runtime.md (Contracts, Serving a contract) in fabrika-platform"
18
+ read: "What the proxy and `boot` check per request, in order, and what `boot` refuses to start with."
17
19
  - where: "docs/decisions/0022-the-proxy-is-the-only-enforcement-point.md + 0023-one-session-per-host.md in fabrika-platform"
18
- read: "Single gate evaluator, per-host session handoff and application-owned object authorization."
20
+ read: "One evaluator at the proxy, the endpoint table, per-host session handoff."
19
21
  ---
20
22
 
21
- # Fabrika IAM with proxy-only enforcement
23
+ # Fabrika IAM with contract enforcement at the proxy
22
24
 
23
- Declare the application's `AppSchema` vocabulary and ordered `AppGates` in source.
24
- Attach the gates to `target.proxy` in `fabrika.config.ts`. Control reconciles the
25
- schema and registers deployment return origins; the application does not assert
26
- its own trusted return origins through its schema.
25
+ The application declares its whole access surface in `fabrika.contract.ts`:
26
+ `defineContract(vocabulary, (c) => surface)` from `@fabrika/app/contract`. The
27
+ vocabulary is its dimensions (e.g. `workspace` → `note`), exact action names and
28
+ roles that list actions (no `*`). Every endpoint is a procedure under a mount
29
+ (`c.rpc('/api', { callers: ['human', 'machine'] }, …)`) with an access form, plus
30
+ `c.health('/healthz')`, which Zerops requires. There are no other routes,
31
+ middleware, `authenticate` or custom server.
27
32
 
28
- The proxy alone evaluates gates, handles login and the reserved
29
- `/__fabrika/auth/callback` route, and injects `X-Fabrika-Token`. App services remain
30
- private. Never implement another gate evaluator in the app, frontend or Caddy
31
- route configuration.
33
+ `fabrika.config.ts` compiles it with `compileContract(contract, tables)` and hands
34
+ the provider the artifact (`target.contract`), the endpoint table
35
+ (`target.proxy: { upstream, ...contractProxyTable(artifact) }`) and the IAM
36
+ schema (`schema: schemaFromContract(artifact)`). The proxy reads only that
37
+ endpoint table; its manifest parser refuses any other form. Control reads the
38
+ contract from the deployed commit, and a widening on a protected environment
39
+ (`prod` by default) waits for a person to approve it in the console.
32
40
 
33
- The application verifies the injected token's signature, issuer, audience and
34
- expiry against IAM's JWKS. Prefer the `@fabrika/auth` SDK's `createIam` and
35
- `authenticate` contract where its IAM binding is available; read the installed
36
- types when composing the HTTP transport. The public notes example demonstrates
37
- direct JWKS verification with `@fabrika/auth-core` claim parsing. Neither path
38
- trusts an unverified header or gives the app a session-to-token exchange.
41
+ The proxy alone selects the endpoint, handles login and the reserved
42
+ `/__fabrika/auth/callback` route, checks the caller kind and the requirement, and
43
+ injects `X-Fabrika-Token` and `X-Fabrika-Endpoint`. App services stay private.
44
+ Never implement a second enforcement decision in the app, the frontend or a Caddy
45
+ route.
39
46
 
40
- Keep per-object authorization in feature handlers using the verified context and
41
- validated object scope (`can`, `requirePermission`, or the example's `Caller`).
42
- Public-gated routes use an anonymous context with no permissions. Preserve SDK
43
- failure semantics: a decided invalid credential differs from IAM unavailability,
44
- which refuses with 503 rather than pretending the credential was checked.
47
+ The framework, not application code, verifies the injected token (signature,
48
+ issuer, audience `<app>@<env>`, expiry), checks the proxy secret and the endpoint
49
+ header, then the caller kind, the requirement, admission and the strict input.
50
+ Per-object authorization is the access form: `on` (one object, 404 when missing
51
+ or not the caller's), `over` (a list, `ctx.within`), `in` (creation inside a
52
+ scope, 403 when not held). Handlers reach rows only through the stores of
53
+ `src/db/tables.ts` with the `Ref` or `Within` they are handed; they never take an
54
+ owner, user or tenant id from the input and never read `process.env`.
55
+
56
+ On Zerops an endpoint with `creates`, `ctx.members` or `ctx.links` reaches IAM
57
+ through the membership transport the platform writes (`FABRIKA_IAM_RPC_URL`,
58
+ `FABRIKA_IAM_RPC_KEY`). The key only pins the application; the caller's token
59
+ carries the authority, with the proxy's delegation grant for scope creation and
60
+ membership changes.
45
61
 
46
62
  ## Local authentication is the same model
47
63
 
48
64
  Run real IAM and the proxy locally. IAM's own `LOCAL_DEV_LOGIN` mechanism can
49
65
  create a real local session; it is not an application dev flag. Restricted-role
50
66
  tests need real IAM principals and grants, not the bootstrap admin for every
51
- scenario. Never synthesize a caller, mint tokens in the app, add `FakeIamClient`,
52
- or disable gates to make local previews work. Credentials and raw request headers
53
- must not enter logs or committed fixtures.
67
+ scenario. In unit tests, use `testKit` of `@fabrika/app/testing`, which runs the
68
+ production proxy decision and `boot` with a stand-in IAM; never synthesize a
69
+ caller in application code. When a request answers 401 or 403, report the missing
70
+ action or role; never loosen the contract, a role or a table policy to make it
71
+ pass. Credentials and raw request headers must not enter logs or committed
72
+ fixtures.
@@ -7,11 +7,13 @@ provides: [platform, private-services, managed-deploy]
7
7
  requires: [bun, postgres]
8
8
  recommends: []
9
9
  conflicts: [cloudflare]
10
- npm: ["@fabrika/provider-zerops@^0.0.4", "@fabrika/app@^0.0.4"]
10
+ npm: ["@fabrika/provider-zerops@^0.0.4", "@fabrika/app@^0.0.4", "@fabrika/cli@^0.0.4"]
11
11
  status: wip
12
12
  sources:
13
13
  - where: "examples/zerops-app/fabrika.config.ts + zerops.yaml in fabrika-platform"
14
- read: "Current defineApp shape, private runtime, database references and repository-root build descriptor."
14
+ read: "Current defineApp shape with `target.contract`, the private runtime and the locked repository-root descriptor."
15
+ - where: "packages/provider-zerops/src/execution.ts + CLAUDE.md in fabrika-platform"
16
+ read: "The generated build, start script, readiness check and crontab of a contract application."
15
17
  - where: "packages/provider-zerops/src/types.ts + src/index.ts in fabrika-platform"
16
18
  read: "Provider-owned authoring contract and exports; verify installed versions before use."
17
19
  - where: "docs/reference/zerops-platform.md in fabrika-platform"
@@ -25,40 +27,60 @@ Use this for applications deployed by an existing Fabrika installation. Import
25
27
  `@fabrika/provider-zerops`. Keep `fabrika.config.ts` as the app's deploy source of
26
28
  truth: stable `id`, `schema`, `pipeline` and `target` with `platform: 'zerops'`.
27
29
 
28
- ## Application contract
30
+ ## Deploy configuration
29
31
 
30
32
  - Declare services through `target.services(ctx)`. Select the runtime explicitly
31
33
  with `target.deployService` when declaring multiple services. Set
32
34
  `target.zeropsSetup` to the matching setup in repository-root `zerops.yaml`.
33
- - Declare `target.proxy: { upstream, gates }` with the private runtime address.
34
- Set runtime `enableSubdomainAccess: false`; only the namespace proxy has public
35
- routing. A custom domain belongs on the proxy, never the app service.
35
+ - A contract application hands `target.contract` (the compiled
36
+ `fabrika.contract.ts`) and `target.proxy: { upstream, ...contractProxyTable(artifact) }`
37
+ with the private runtime address (port 3000). Set runtime
38
+ `enableSubdomainAccess: false`; only the namespace proxy has public routing. A
39
+ custom domain belongs on the proxy, never the app service.
36
40
  - Leave project placement to Control's environment/namespace registry. Keep
37
41
  provider tokens and concrete project coordinates out of application source.
38
- - Use the public example's Bun adapter (`@fabrika/app/bun`) for the long-running
39
- Fetch application. Serve built SPA assets from the private application; preserve
40
- API errors instead of falling through to `index.html`.
41
- - Commit `zerops.yaml` and the Bun lockfile at the app repository root. Zerops
42
- performs its build; the descriptor declares build/deploy files, start command,
43
- ports and probes. Probe durations are Go duration strings bounded to 10s–1h.
44
- - Run repeatable, concurrency-safe PostgreSQL migrations in `run.initCommands`,
45
- which runs on every container start. A readiness check gates deployment;
46
- `run.healthCheck` is the separate runtime liveness mechanism.
42
+ - The platform owns the setup of a contract application (ADR-0043). The authored
43
+ `zerops.yaml` keeps only `setup`, `build.base`, `build.os` and the SPA's
44
+ `build.buildCommands`; every other key is refused. The platform generates the
45
+ rest: a frozen install, the application's `bun test`, a reinstall, the
46
+ generated entrypoint, `fabrika-check`, `tsc`, the deploy gate on the contract's
47
+ health path, and the `run` section. Its start script runs `fabrika db migrate`,
48
+ `fabrika db codegen --verify` and `fabrika db test-isolation`, then
49
+ `fabrika/entry.generated.ts`; each `c.cron` job becomes a crontab entry
50
+ `fabrika job run <name>`. The build has no database, so the database tests skip
51
+ there and run at container start.
52
+ - Commit the Bun lockfile at the app repository root, and commit what
53
+ `fabrika db codegen` and `fabrika db test-scaffold` write. Never edit a
54
+ generated file (`fabrika/entry.generated.ts`, `src/db/schema.generated.ts`, the
55
+ generated tests) by hand; regenerate it with its command.
47
56
 
48
57
  ## Data and configuration
49
58
 
50
59
  The default public config declares an app-owned PostgreSQL service. A cheap
51
60
  namespace can instead supply the typed shared PostgreSQL binding: consult
52
- `examples/zerops-app/fabrika.cheap.config.ts` and its matching descriptor before
53
- selecting that variant. Preserve the administrator's namespace assignment.
61
+ `examples/zerops-app/fabrika.cheap.config.ts` before selecting that variant.
62
+ Preserve the administrator's namespace assignment. A contract application has
63
+ one PostgreSQL database: the platform writes `FABRIKA_APP_DATABASE_URL` (the TLS
64
+ reference with the database name and `sslmode=require`) and `FABRIKA_APP_ID`, and
65
+ the application's tables live in the schema `app_<id>`. The application names no
66
+ connection string; `@fabrika/app/bun` opens the data layer for it.
54
67
 
55
- Use the provider's PostgreSQL TLS reference including the database name and
56
- `sslmode=require`; do not infer a connection string from a hostname. Service names
57
- must satisfy `^[a-z0-9]{1,25}$` and references may address only owned services and
58
- declared namespace resources.
68
+ Service names must satisfy `^[a-z0-9]{1,25}$` and references may address only
69
+ owned services and declared namespace resources.
59
70
 
60
71
  The provider owns isolation fields. Secret values live at service level in Zerops,
61
72
  not in config, YAML or project-level environment variables. `pipeline.secrets`
62
73
  names requirements; it does not add a deploy-time secret synchronization step.
63
- Control supplies reserved managed variables such as `FABRIKA_IAM_ISSUER`,
64
- `FABRIKA_OPERATIONS_DSN` and `FABRIKA_RELEASE`; do not redeclare them.
74
+ Control supplies the reserved `FABRIKA_*` variables (`FABRIKA_IAM_ISSUER`,
75
+ `FABRIKA_IAM_APP_ID`, `FABRIKA_OPERATIONS_DSN`, `FABRIKA_RELEASE`,
76
+ `FABRIKA_CONTRACT_DIGEST`, `FABRIKA_PROXY_SECRET`, …); do not redeclare them. The
77
+ framework takes every `FABRIKA_*` name out of the process before the application
78
+ loads; handlers get their own variables through `deps`.
79
+
80
+ A `c.files` collection needs one `object-storage` service in `fabrika.config.ts`;
81
+ the provider writes `FABRIKA_APP_STORAGE_*` from it as references Zerops resolves,
82
+ and refuses a second bucket.
83
+
84
+ An endpoint with `creates`, `ctx.members` or `ctx.links` reaches IAM through the
85
+ membership transport: the platform writes `FABRIKA_IAM_RPC_URL` and the
86
+ environment's own `FABRIKA_IAM_RPC_KEY`, and refuses an app config that sets them.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabrika/builder",
3
- "version": "0.3.3",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "description": "Local-first guided application design and agent-assisted implementation.",
6
6
  "license": "MIT",
@@ -47,13 +47,13 @@
47
47
  },
48
48
  "dependencies": {
49
49
  "zod": "^3.25.76",
50
- "@fabrika/annotations": "^0.3.3",
51
- "@fabrika/app": "^0.3.3",
52
- "@fabrika/auth-core": "^0.3.3",
53
- "@fabrika/canvas-kernel": "^0.3.3",
54
- "@fabrika/control-contract": "^0.3.3",
55
- "@fabrika/daemon-kit": "^0.3.3",
56
- "@fabrika/provider-zerops": "^0.3.3",
50
+ "@fabrika/annotations": "^0.4.1",
51
+ "@fabrika/app": "^0.4.1",
52
+ "@fabrika/auth-core": "^0.4.1",
53
+ "@fabrika/canvas-kernel": "^0.4.1",
54
+ "@fabrika/control-contract": "^0.4.1",
55
+ "@fabrika/daemon-kit": "^0.4.1",
56
+ "@fabrika/provider-zerops": "^0.4.1",
57
57
  "preact": "^10.25.0"
58
58
  }
59
59
  }
package/shared/index.ts CHANGED
@@ -30,7 +30,8 @@ export type BuilderPairingState =
30
30
  | { status: 'connected' }
31
31
 
32
32
  export interface BuilderDeploymentState {
33
- status: 'preparing' | 'pending' | 'running' | 'succeeded' | 'failed' | 'cancelled'
33
+ /** `awaiting_approval`: the deploy widens access on a protected environment and waits for a person (ADR-0050). */
34
+ status: 'preparing' | 'pending' | 'running' | 'awaiting_approval' | 'succeeded' | 'failed' | 'cancelled'
34
35
  runId: string | null
35
36
  commitSha: string | null
36
37
  message: string | null
@@ -290,7 +291,7 @@ export const builderPairingStateSchema = z.discriminatedUnion('status', [
290
291
  z.object({ status: z.literal('connected') }).strict(),
291
292
  ])
292
293
  export const builderDeploymentStateSchema = z.object({
293
- status: z.enum(['preparing', 'pending', 'running', 'succeeded', 'failed', 'cancelled']),
294
+ status: z.enum(['preparing', 'pending', 'running', 'awaiting_approval', 'succeeded', 'failed', 'cancelled']),
294
295
  runId: nonemptyStringSchema.nullable(),
295
296
  commitSha: nonemptyStringSchema.nullable(),
296
297
  message: nonemptyStringSchema.nullable(),
@@ -18,6 +18,8 @@ hints. Do not coordinate other issues and do not spawn more agents.
18
18
  1. Read the complete issue body, its dependency outputs, relevant
19
19
  `tests/browser/*.test.ts`, `.fabrika/architecture/tech.md`, and the absolute
20
20
  stack/decision sources supplied by the parent.
21
+ When the app has a `fabrika.contract.ts`, apply the `fabrika-app` skill and
22
+ follow its rules.
21
23
  2. Implement the smallest complete vertical slice required by `goal` and
22
24
  `satisfies`: route, data operation, UI, migration, and seed where applicable.
23
25
  3. Start the app using the selected stack's current run recipe. Apply the
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  id: zerops-app
3
3
  title: "Platform-managed Zerops app — Bun + PostgreSQL + Fabrika IAM"
4
- goal: "Build and verify a local React/Buzola app behind real IAM and proxy gates, then deploy its available Git revision through installed Fabrika Control."
4
+ goal: "Build and verify a local React/Buzola contract application behind real IAM and the proxy's endpoint table, then deploy its available Git revision through installed Fabrika Control."
5
5
  decisions:
6
6
  - zerops-platform
7
7
  - bun
@@ -31,8 +31,10 @@ Read installed contracts and the linked public examples before scaffolding.
31
31
  ## Composition
32
32
 
33
33
  - `zerops-platform` owns provider configuration and private runtime services.
34
- - `zerops-fabrika-auth` supplies IAM token verification and proxy-only gates.
35
- - `zerops-local-app` composes the Bun application, SPA and local IAM/proxy loop.
34
+ - `zerops-fabrika-auth` supplies the contract (`fabrika.contract.ts`), which the
35
+ proxy enforces as an endpoint table and the framework serves through `serve`.
36
+ - `zerops-local-app` composes the generated Bun entrypoint, the SPA mount and the
37
+ local IAM/proxy loop.
36
38
  - `zerops-control-deploy` defines the human/admin/agent deployment boundary.
37
39
  - `zerops-postgres-seeds` preserves opice's named SQL fixture contract on PostgreSQL.
38
40
 
@@ -52,15 +54,23 @@ or choose a replacement destination when prerequisites are missing.
52
54
  ## Local verification
53
55
 
54
56
  1. Install with Bun and typecheck the actual provider config and application.
55
- 2. Start the app's documented local composition: PostgreSQL, IAM, proxy and Bun
56
- runtime. Apply migrations and the scenario's PostgreSQL seeds locally.
57
+ After any change to the contract or `src/db/tables.ts`, run `fabrika db migrate`,
58
+ `fabrika db codegen` and `fabrika db test-scaffold`, and commit what they write.
59
+ 2. Start the app's documented local composition: PostgreSQL, IAM, proxy and the
60
+ generated Bun entrypoint. Apply migrations and the scenario's PostgreSQL seeds
61
+ locally.
57
62
  3. Open the app's **proxy URL**, complete IAM login, and run the targeted opice
58
63
  scenario against that URL. Verify denied access with a real restricted identity.
59
- 4. Run application unit tests, typechecking and its production asset build.
60
- Include the built assets in the root `zerops.yaml` deploy files.
64
+ 4. Run application tests with `FABRIKA_TEST_POSTGRES_URL` set, `fabrika-check`,
65
+ typechecking and the production asset build. The SPA's build commands go in
66
+ `build.buildCommands` of the root `zerops.yaml`; the platform generates every
67
+ other key and deploys the checked build.
61
68
  5. Report the actual local preview URL and checks. Production deployment follows
62
69
  `zerops-control-deploy`; a passing local test is not a successful remote run.
63
70
 
64
- The public notes example supplies the backend/config reference, not a ready-made
65
- SPA or arbitrary-project dev launcher. Scaffold and verify the local composition
71
+ The public notes example (`src/vocabulary.ts`, `src/db/tables.ts`, the `src/notes/` slice,
72
+ `fabrika.contract.ts`, `db/migrations/`, `src/app.ts` and its generated tests, plus an
73
+ `attachments` file collection, a `purge` job and a minimal SPA) supplies the backend/config
74
+ reference, not an arbitrary-project dev launcher. Its `AGENTS.md` carries the
75
+ `fabrika-app` rules. Scaffold and verify the local composition
66
76
  described in `zerops-local-app` before claiming the application runs locally.