Public: anyone can read this; nobody can change it.

docs: design proposals for issues #2, #3, #4, #5 #9

opened this view on origin

Check · Review · Land · Live — No review yet. Next: reviewer, no review yet.

Merges on the origin

docs/design-proposals → main 1 commit

  • Merge on the origin. This repository mirrors open-platform/mitosis, which is truth for it. Merge there and the next sync brings back the merge commit it actually made. Open on the origin

A guest

This pull comes from a guest: its preview runs without the app's variables or a forge token until a person who can write here approves it.

This pull waits for a person who can write here to review it; it never lands on its own.

Review

Sign in to review this change.
5 files changed +1441 −0
docs/proposals/README.md added +23
@@ -0,0 +1,23 @@
1 # Design proposals
2
3 From-first-principles design proposals for open platform features, written to be
4 reviewed and approved **before** implementation. Each is anchored in the real
5 code (concrete `file:line` for every seam it proposes touching), shows the actual
6 manifests / tool signatures / code shapes it would add, and is honest about the
7 hard parts, tradeoffs, and open questions.
8
9 These cover the larger / greenfield issues where the right move is to agree on
10 the architecture first. (The two enhancement issues that were tractable to build
11 directly — observability/Grafana #6 and platform agent-work #7 — shipped as PRs
12 rather than proposals.)
13
14 | Proposal | Issue | One line |
15 | -------------------------------------------------------------------------------------------- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------- |
16 | [self-serve-secrets-vault.md](./self-serve-secrets-vault.md) | #2 | Per-app env vars backed by HashiCorp Vault, **alongside** sops (not replacing it); the hard part is single-node unseal without cloud KMS. |
17 | [self-serve-custom-domains.md](./self-serve-custom-domains.md) | #3 | Vercel-style BYO domains: self-serve granting + DNS ownership verification + public TLS, on top of the registry/resolver that already exists. |
18 | [byo-identity-provider-entra-oidc-forgejo.md](./byo-identity-provider-entra-oidc-forgejo.md) | #4 | BYO upstream IdP (Entra/OIDC) at `mitosis up`, auto-syncing users + teams into Forgejo (the identity hub). |
19 | [repo-migration-auto-port.md](./repo-migration-auto-port.md) | #5 | A Forgejo repo migration triggers an agent to conform the repo onto the app-template; bulk supported. |
20
21 **How to use:** read a proposal, comment on its Open questions, and approve the
22 approach (or steer it). Approved proposals become the build plan — each lays out
23 its work as phased, per-repo PRs.
docs/proposals/byo-identity-provider-entra-oidc-forgejo.md added +284
@@ -0,0 +1,284 @@
1 > Design proposal for issue #4 — "bring your identity provider". Status: PROPOSAL for maintainer review **before** implementation. Greenfield: there is no OIDC / `auth_source` / `add-oauth` anywhere in the toolkit or component repos today (verified: `grep -rln 'add-oauth|auth_source|oauth2_client|group-team-map' gitops/ agents/src/ mcp/src/ bin/` is empty except Grafana's own unrelated OAuth in `monitoring/helmrelease.yaml`).
2
3 ## Problem
4
5 Today a germinated platform has exactly one human identity: the `plat` Forgejo admin, with a freshly-minted random password (`fork_regen_secrets` in `bin/lib.sh:241-244`, echoed on the YOUR PLATFORM card at `bin/germinate:497`). Every downstream identity decision keys off Forgejo:
6
7 - **Apps** use Forgejo as their OIDC provider via better-auth `genericOAuth` (`_app-template/src/auth.ts:170-188`: `authorizationUrl = ${FORGEJO_BASE}/login/oauth/authorize`, `tokenUrl`/`userInfoUrl` in-cluster). Forgejo *is* the apps' IdP.
8 - **MCP authz** keys off **Forgejo org membership**: a caller may operate on `owner` iff `caller.login === owner` or `caller.orgs.includes(owner)` (`mcp/src/server.ts:844-846`), and `caller.orgs` is fetched from Forgejo `/users/<login>/orgs` (`server.ts:490-498`).
9
10 So Forgejo is the platform's **identity hub**. The ask in #4 is: let an operator point that hub at their *real* corporate IdP (Microsoft Entra ID, Okta, Keycloak, generic OIDC) at `mitosis up` time, so their **users** can log in with corporate SSO and their **teams/groups** flow through to Forgejo orgs/teams — which means they flow, unchanged, into both app login and MCP authorization.
11
12 The hard constraints this must respect:
13
14 1. **Sovereignty + sealing.** A daughter holds only its own age key. Any IdP client secret must be regenerated/sealed to the fork key and pass `fork_verify_all_sealed` (`lib.sh:325`), the hard gate at `germinate:107`. But the IdP secret is **operator-supplied**, not platform-generated — a new class of secret.
15 2. **One-shot germinate.** No resume (`germinate:18-20`). IdP wiring must land in the existing post-handoff window (where OAuth app registration + runner registration already happen) or be fully declarative in gitops.
16 3. **Identity rewrite safety.** Redirect URIs are domain-bound (`https://git.$DOMAIN/...`); they must be produced from `$DOMAIN`, like every other identity-bound value, and survive `rewrite_identity_domain` (`lib.sh:42-60`).
17 4. **The QA reviewer login breaks if local auth goes away.** The agents dispatcher self-provisions a *local* Forgejo user `qatester` (`dispatcher.ts:2252-2284` → `forgejo.ts:442-453` `POST /admin/users`) and the reviewer browser-drives **Forgejo's local login UI** during OAuth sign-in (`dispatcher.ts:1573-1575` feeds `QA_USER`/`QA_PASS` into the reviewer prompt). If an operator disables local login in favor of pure OIDC, the reviewer can't log in and auto-❌s every PR.
18
19 ## Design (from first principles)
20
21 ### What "sync users + teams" actually means in Forgejo
22
23 Two separate capabilities, and Forgejo only has one of them:
24
25 - **Forgejo as an OIDC *relying party*** to an upstream IdP — implemented and stable. You add an OAuth2 **authentication source** (type `OpenID Connect`, with an auto-discovery URL). Users who click "Sign in with <IdP>" get a Forgejo account auto-created on first login, and their **OIDC `groups` claim is mapped to Forgejo orgs/teams** via a static `group-team-map` JSON, re-evaluated **on every login** (sources: Forgejo CLI docs `forgejo admin auth add-oauth`; Gitea PR go-gitea/gitea#21441 "Map OIDC groups to Orgs/Teams"). Optional `--group-team-map-removal` strips a user from mapped teams they no longer belong to.
26 - **SCIM push-provisioning** (IdP proactively creates/deletes Forgejo users) — **NOT implemented** in Forgejo (open feature request codeberg.org/forgejo/forgejo#8457). So we cannot get "user created in Entra ⇒ instantly exists in Forgejo." Provisioning is **login-driven (JIT)**.
27
28 This is the pivotal architectural fact. **v1 must be login-driven (lazy / just-in-time), not push.** A user appears in Forgejo the first time they SSO in; their teams sync at that same moment and on every subsequent login.
29
30 Two load-bearing constraints of the `group-team-map`:
31
32 1. **Sync is on login, not background.** No cron, no webhook. (Acceptable: people log into git.)
33 2. **The org + team must already exist.** The map *references* existing orgs/teams; Forgejo does **not** auto-create them on login (verified against Gitea/Forgejo docs + PR #21441). So **someone must pre-create the orgs/teams named in the map.** This is the one piece Forgejo won't do for us, and it's where a small sync component earns its place.
34
35 ### The shape of the solution
36
37 ```
38 mitosis up (operator sets IDP_* env)
39 └─ up → germinate (threaded env, like BYO claude creds today)
40 ├─ seal IDP_CLIENT_SECRET into a NEW sovereign secret (lib.sh: fork_regen_secrets)
41 ├─ [post-handoff window] forgejo admin auth add-oauth ... ← Forgejo becomes RP
42 │ --provider openidConnect --auto-discover-url <issuer>/.well-known/openid-configuration
43 │ --group-claim-name groups --group-team-map '<JSON>' --group-team-map-removal
44 └─ [post-handoff window] run the idp-team-bootstrap step:
45 pre-create every org+team named in the group-team-map (Forgejo API)
46 so the login-time mapping has somewhere to land.
47 ```
48
49 Once that's in place, **nothing downstream changes**: a corporate user SSOs into Forgejo → lands in org `acme` team `engineers` → `mcp/src/server.ts:490-498` sees `orgs:["acme"]` → authz at `server.ts:844-846` passes → apps' better-auth (`auth.ts`) sees the same Forgejo identity. The identity hub does its job; the new code only points the hub upstream.
50
51 ### Why imperative (germinate post-handoff) over declarative (gitea.config)
52
53 Forgejo auth sources live in the **database**, not `app.ini`. You can seed them declaratively only via the chart's `gitea.oauth` values block (which runs `forgejo migrate` + an auth-source upsert on the configure init-container). That path exists, but:
54
55 - It would put the IdP **client secret** into the forgejo HelmRelease values (sealed secret + `valuesFrom`), and the forgejo chart's oauth-values plumbing is finicky about update-vs-create semantics across chart versions.
56 - germinate **already** does exactly this class of work imperatively in its post-handoff window: it `kubectl exec`s into the forgejo pod to register the CI runner (`germinate:371`: `forgejo forgejo-cli actions register`), and it registers the platform OAuth *app* over the API (`fork_register_oauth`, `lib.sh:400-414`). Adding `forgejo admin auth add-oauth` there is the **same exec seam, same window**.
57
58 **Recommendation: imperative for v1** (one `kubectl exec ... forgejo admin auth add-oauth`), because it reuses a proven seam and keeps the secret out of helm values. Revisit declarative `gitea.oauth` if we later want the source to survive a DB restore without re-running germinate (see Open Questions). Note: the auth source row lives in postgres (`forgejo-pg`), which **is** backed up (cnpg-backup), so a restore keeps it; a *fresh* germinate re-creates it. That's fine.
59
60 ## Implementation (per-repo, phased PRs)
61
62 ### PR 1 — `plat/mitosis`: env surface + sealed sovereign secret + Forgejo RP wiring
63
64 **The env-var surface** (mirrors the BYO-claude pattern at `germinate:35`, which captures `ANTHROPIC_API_KEY` into a named local then seals it):
65
66 | env var | required | meaning | example |
67 |---|---|---|---|
68 | `IDP_ISSUER` | yes (enables feature) | OIDC issuer URL; auto-discovery is `$IDP_ISSUER/.well-known/openid-configuration` | `https://login.microsoftonline.com/<tenant>/v2.0` |
69 | `IDP_CLIENT_ID` | yes | OAuth2 client (application) ID | `5f3c…` |
70 | `IDP_CLIENT_SECRET` | yes | OAuth2 client secret | (sealed, never echoed) |
71 | `IDP_NAME` | no (`corp-sso`) | display name of the "Sign in with…" button + auth-source name | `Entra` |
72 | `IDP_PROVIDER` | no (`openidConnect`) | Forgejo provider type | `openidConnect` |
73 | `IDP_SCOPES` | no (`openid profile email groups`) | scopes requested | |
74 | `IDP_GROUP_CLAIM` | no (`groups`) | claim that carries group names | `groups` (Entra) / `roles` |
75 | `IDP_GROUP_TEAM_MAP` | no (`{}`) | JSON: IdP group → `{org:[teams]}` | `{"plat-admins":{"plat":["Owners"]},"app-eng":{"acme":["engineers"]}}` |
76 | `IDP_ADMIN_GROUP` | no | IdP group whose members become Forgejo admins | `plat-admins` |
77 | `IDP_GROUP_TEAM_REMOVAL` | no (`true`) | strip users from mapped teams they left | |
78 | `IDP_DISABLE_LOCAL_LOGIN` | no (**`false`**) | turn off Forgejo username/password login | (see QA caveat below) |
79
80 **a) Defaults + capture, `bin/germinate` (~line 35, beside `BYO_ANTHROPIC`):**
81
82 ```bash
83 # BYO upstream IdP (default empty) — when IDP_ISSUER is set, Forgejo is wired as an
84 # OIDC relying party to it and the group-team-map is honored on every login. Captured
85 # into named locals + unset from env so no child process inherits the client secret.
86 BYO_IDP_ISSUER="${IDP_ISSUER:-}"
87 BYO_IDP_CLIENT_ID="${IDP_CLIENT_ID:-}"
88 BYO_IDP_CLIENT_SECRET="${IDP_CLIENT_SECRET:-}"
89 : "${IDP_NAME:=corp-sso}"; : "${IDP_PROVIDER:=openidConnect}"
90 : "${IDP_SCOPES:=openid profile email groups}"; : "${IDP_GROUP_CLAIM:=groups}"
91 : "${IDP_GROUP_TEAM_MAP:={}}"; : "${IDP_GROUP_TEAM_REMOVAL:=true}"
92 : "${IDP_ADMIN_GROUP:=}"; : "${IDP_DISABLE_LOCAL_LOGIN:=false}"
93 unset IDP_CLIENT_SECRET # mirror the BYO-claude unset hygiene
94 ```
95
96 **b) Thread through `bin/up` (~line 119)** — add to the `env … "$HERE/germinate"` handoff, same as `FORK_KEY`:
97
98 ```bash
99 env SEED="$SEED" DOMAIN="$DOMAIN" \
100 ${FORK_KEY:+FORK_KEY="$FORK_KEY"} \
101 ${IDP_ISSUER:+IDP_ISSUER="$IDP_ISSUER"} \
102 ${IDP_CLIENT_ID:+IDP_CLIENT_ID="$IDP_CLIENT_ID"} \
103 ${IDP_CLIENT_SECRET:+IDP_CLIENT_SECRET="$IDP_CLIENT_SECRET"} \
104 ${IDP_NAME:+IDP_NAME="$IDP_NAME"} ${IDP_GROUP_TEAM_MAP:+IDP_GROUP_TEAM_MAP="$IDP_GROUP_TEAM_MAP"} \
105 ${IDP_GROUP_CLAIM:+IDP_GROUP_CLAIM="$IDP_GROUP_CLAIM"} ${IDP_ADMIN_GROUP:+IDP_ADMIN_GROUP="$IDP_ADMIN_GROUP"} \
106 ${IDP_DISABLE_LOCAL_LOGIN:+IDP_DISABLE_LOCAL_LOGIN="$IDP_DISABLE_LOCAL_LOGIN"} \
107 "$HERE/germinate"
108 ```
109
110 Also document in `bin/mitosis` usage (`mitosis:18-20`, next to the `CLAUDE_CODE_OAUTH_TOKEN` line).
111
112 **c) Seal the IdP secret — `bin/lib.sh` `fork_regen_secrets` (after the agents block, ~line 270).** The client secret is operator-supplied (passed through env, like the claude creds at `lib.sh:266-267`). We seal it into a **new** dedicated secret in the forgejo namespace so the auth-source step can `envFrom` it without touching the admin secret:
113
114 ```bash
115 # BYO upstream IdP — sealed only when the operator supplied an issuer. Lives in
116 # the forgejo namespace so the auth-source bootstrap Job can envFrom it. The
117 # client secret is operator-supplied (threaded through env like the claude creds),
118 # NOT platform-generated — but it must still be sealed to the fork key so the
119 # daughter's Flux can decrypt it and fork_verify_all_sealed passes.
120 if [ -n "${IDP_ISSUER:-}" ]; then
121 _fork_seal "$g" "$P/forgejo/idp-oidc-secret.yaml" forgejo-idp-oidc forgejo "$_FORK_LBL_MANAGED" <<EOF
122 IDP_ISSUER: "${IDP_ISSUER}"
123 IDP_CLIENT_ID: "${IDP_CLIENT_ID:-}"
124 IDP_CLIENT_SECRET: "${IDP_CLIENT_SECRET:-}"
125 IDP_NAME: "${IDP_NAME:-corp-sso}"
126 IDP_PROVIDER: "${IDP_PROVIDER:-openidConnect}"
127 IDP_SCOPES: "${IDP_SCOPES:-openid profile email groups}"
128 IDP_GROUP_CLAIM: "${IDP_GROUP_CLAIM:-groups}"
129 IDP_GROUP_TEAM_MAP: '${IDP_GROUP_TEAM_MAP:-{}}'
130 IDP_ADMIN_GROUP: "${IDP_ADMIN_GROUP:-}"
131 IDP_GROUP_TEAM_REMOVAL: "${IDP_GROUP_TEAM_REMOVAL:-true}"
132 EOF
133 git -C "$g" add -A
134 git -C "$g" -c user.email=mitosis@plat -c user.name=mitosis commit -q -m "mitosis fork: seal BYO IdP OIDC secret" || true
135 fi
136 ```
137
138 The caller at `germinate:97-98` already wraps `fork_regen_secrets` in an env prefix for the claude creds; add `IDP_ISSUER`/`IDP_CLIENT_ID`/`IDP_CLIENT_SECRET`/`IDP_GROUP_TEAM_MAP`/… to that same prefix so the function sees them. Because the secret is added to `$GITOPS_DIR` and sealed before `germinate:107`, `fork_verify_all_sealed` covers it for free (it scans every `^sops:` file under `clusters/` — `lib.sh:335`). **No change to the verify gate needed.**
139
140 Add `idp-oidc-secret.yaml` to `platform/forgejo/kustomization.yaml` (PR 2) so Flux keeps it reconciled (it's harmless if the feature is off — it just won't exist, since we only seal it when `IDP_ISSUER` is set; so add it conditionally — see PR 2 note on optional resource).
141
142 **d) New `lib.sh` helper — wire Forgejo as the OIDC relying party.** Reuses the exec seam from `germinate:371`:
143
144 ```bash
145 # fork_wire_idp — add an OIDC authentication source to the daughter Forgejo so
146 # corporate users SSO in and their groups map to orgs/teams. No-op unless the
147 # operator supplied IDP_ISSUER. Runs in germinate's post-handoff window (the real
148 # postgres-backed Forgejo is up). Idempotent: update the source if it already exists.
149 # $1=FORGEJO_NS $2=forgejo pod (admin auth comes from the source's own row)
150 fork_wire_idp() { # $1=ns $2=pod ; reads IDP_* from env
151 local ns="$1" pod="$2"
152 [ -n "${IDP_ISSUER:-}" ] || { echo " (no IDP_ISSUER — skipping upstream IdP wiring)"; return 0; }
153 local disco="${IDP_ISSUER%/}/.well-known/openid-configuration"
154 local extra=()
155 [ -n "${IDP_ADMIN_GROUP:-}" ] && extra+=(--admin-group "$IDP_ADMIN_GROUP")
156 [ "${IDP_GROUP_TEAM_REMOVAL:-true}" = true ] && extra+=(--group-team-map-removal)
157 # add-oauth creates; if a source with this name exists, update-oauth instead.
158 if kubectl -n "$ns" exec "$pod" -c forgejo -- forgejo admin auth list 2>/dev/null | grep -qiw "${IDP_NAME:-corp-sso}"; then
159 local id; id=$(kubectl -n "$ns" exec "$pod" -c forgejo -- forgejo admin auth list 2>/dev/null | awk -v n="${IDP_NAME:-corp-sso}" '$0 ~ n {print $1; exit}')
160 kubectl -n "$ns" exec "$pod" -c forgejo -- forgejo admin auth update-oauth --id "$id" \
161 --key "$IDP_CLIENT_ID" --secret "$IDP_CLIENT_SECRET" --auto-discover-url "$disco" \
162 --scopes "$IDP_SCOPES" --group-claim-name "$IDP_GROUP_CLAIM" \
163 --group-team-map "$IDP_GROUP_TEAM_MAP" "${extra[@]}"
164 else
165 kubectl -n "$ns" exec "$pod" -c forgejo -- forgejo admin auth add-oauth \
166 --name "${IDP_NAME:-corp-sso}" --provider "${IDP_PROVIDER:-openidConnect}" \
167 --key "$IDP_CLIENT_ID" --secret "$IDP_CLIENT_SECRET" --auto-discover-url "$disco" \
168 --scopes "$IDP_SCOPES" --group-claim-name "$IDP_GROUP_CLAIM" \
169 --group-team-map "$IDP_GROUP_TEAM_MAP" "${extra[@]}"
170 fi
171 }
172 ```
173
174 > **Secret-handling caveat (call out in review):** passing `--secret "$IDP_CLIENT_SECRET"` puts the secret on the exec'd process argv. Inside the forgejo pod that's low-risk (it's the daughter's own pod), but cleaner is to read `IDP_*` from the **sealed `forgejo-idp-oidc` secret already mounted in the pod's env** rather than from germinate's shell — i.e. run the `add-oauth` from a tiny Job that `envFrom`s the secret (this is the same Job we need anyway for team bootstrap — see PR 2). **Recommended v1: fold `fork_wire_idp` into the idp-bootstrap Job in PR 2**, and keep the germinate call site as just "wait for the Job to complete." Both shapes are shown; the Job shape is preferred for secret hygiene + idempotent reconcile.
175
176 **e) Call site — `bin/germinate`, post-handoff window** (right after `fork_register_oauth` at `germinate:305`, where the forgejo pod handle `$FGPOD`/`$fp` is already discovered around `germinate:368`):
177
178 ```bash
179 STEP="wire upstream IdP (OIDC relying party + group-team-map)"
180 if [ -n "${IDP_ISSUER:-}" ]; then
181 echo "==> wiring Forgejo as an OIDC relying party to ${IDP_NAME:-corp-sso}"
182 # FGPOD discovered by the runner-register loop (germinate ~368); reuse it.
183 fork_wire_idp "$FORGEJO_NS" "$FGPOD" \
184 && echo " upstream IdP auth source ready (group-team-map honored on login)" \
185 || echo " WARN: IdP wiring failed — operators can re-run forgejo admin auth add-oauth"
186 fi
187 ```
188
189 ### PR 2 — `plat/gitops`: team-bootstrap Job + redirect note + (optional) declarative path
190
191 **The one thing Forgejo won't do: pre-create the orgs/teams in the map.** Model it exactly on `platform/plat-storage/cnpg-backup-setup-job.yaml` (idempotent Job, `envFrom` a secret, runs a script). New file `platform/forgejo/idp-bootstrap-job.yaml`:
192
193 ```yaml
194 # Idempotent bootstrap for the BYO upstream IdP: (1) ensure the OIDC auth source
195 # exists (add/update-oauth), (2) pre-create every org+team named in the
196 # group-team-map so login-time team sync has somewhere to land (Forgejo does NOT
197 # auto-create them). Modeled on cnpg-backup-minio-setup. Re-runnable by Flux.
198 apiVersion: batch/v1
199 kind: Job
200 metadata:
201 name: forgejo-idp-bootstrap
202 namespace: forgejo
203 labels: { plat.sh/managed: "true" }
204 spec:
205 ttlSecondsAfterFinished: 300
206 backoffLimit: 6
207 template:
208 spec:
209 restartPolicy: OnFailure
210 containers:
211 - name: idp-bootstrap
212 image: code.forgejo.org/forgejo/forgejo:11 # ships `forgejo`, curl, jq-free shell
213 envFrom:
214 - secretRef: { name: forgejo-idp-oidc } # IDP_* (sealed, fork key)
215 - secretRef: { name: forgejo-admin } # username/password for API calls
216 command: ["/bin/sh","-c"]
217 args:
218 - |
219 set -eu
220 FJ=http://forgejo-http.forgejo.svc.cluster.local:3000
221 # (1) auth source — add or update. (Run forgejo CLI against the same DB.)
222 # forgejo admin auth add-oauth ... (flags from IDP_* env) [idempotent guard]
223 # (2) pre-create orgs+teams from IDP_GROUP_TEAM_MAP:
224 # for each group → {org:[teams]} create org then each team via API,
225 # ignoring 409/422 already-exists.
226 # POST $FJ/api/v1/orgs {"username":"<org>"}
227 # POST $FJ/api/v1/orgs/<org>/teams {"name":"<team>","permission":"write"}
228 # auth: Basic <username>:<password> from forgejo-admin
229 ```
230
231 > Keeping the *parsing* of `IDP_GROUP_TEAM_MAP` out of this stub deliberately — it needs `jq` (the forgejo image may not ship it; use the `minio/mc`-style sidecar or a `node:22-bookworm` image like the runner uses at `runner-config.yaml:13`). **Implementation note for the engineer:** prefer `image: node:22-bookworm` + a 30-line script (already the platform's CI base image, guaranteed present in the registry mirror) over hand-rolling JSON parsing in `sh`.
232
233 Wire-in: add `idp-bootstrap-job.yaml` and (when present) `idp-oidc-secret.yaml` to `platform/forgejo/kustomization.yaml` (`forgejo/kustomization.yaml:3-19`). **Subtlety:** these resources only exist when the operator supplied `IDP_ISSUER` (the secret is sealed conditionally in PR 1). Two clean options: (a) germinate `sed`-appends them to the kustomization only when sealing the secret (consistent with the cleartext-patch pattern at `germinate:85-129`); or (b) always list them and ship an empty/no-op secret when the feature is off. **Recommend (a)** — don't ship a dead Job into platforms that don't use SSO.
234
235 **Redirect URI:** Forgejo's OIDC RP callback is a *fixed* path on the Forgejo host: `https://git.$DOMAIN/user/oauth2/<source-name>/callback`. The operator registers **that** URL in Entra/Okta as the app's redirect URI. It's derived from `$DOMAIN`, so it's correct after `rewrite_identity_domain` with no special handling (it's a `git.<dom>` token, already covered by `lib.sh:42-60`). **Document this in the YOUR PLATFORM card** (see PR 1 epilogue addition) so the operator knows exactly what to paste into Entra. No app-side redirect change — apps still point at Forgejo (`auth.ts:176`), Forgejo points at Entra; apps are oblivious.
236
237 ### PR 3 — `plat/agents`: keep the QA reviewer working when local login is disabled
238
239 The reviewer browser-drives Forgejo's **local** login form with `QA_USER`/`QA_PASS` (`dispatcher.ts:1573-1575`), and `qatester` is a **local** account (`forgejo.ts:442-453`). Two failure modes and their handling:
240
241 1. **Local login still enabled (default, `IDP_DISABLE_LOCAL_LOGIN=false`).** Nothing breaks: `qatester` keeps logging in via the local form, the OIDC source is *additional*. **This is the recommended v1 default** — wire the upstream IdP but **do not** disable local login. The QA path is untouched.
242
243 2. **Operator sets `IDP_DISABLE_LOCAL_LOGIN=true` (pure SSO).** Now the reviewer can't log in. Handling options, smallest first:
244 - **v1 (recommended): refuse to disable local login if QA creds are sealed**, OR keep a single local-login exception. Forgejo doesn't expose a per-user "allow local login" toggle, so the clean lever is: **keep local login enabled platform-wide but hide it from the UI is not possible** — instead, *don't* set `service.DISABLE_REGISTRATION`/sign-in lockdown for v1. Document that pure-SSO lockdown is a v2 follow-up.
245 - **v2: give the reviewer an OIDC identity.** Create a `qatester` group in the upstream IdP mapped to a throwaway org, and have the reviewer drive the **"Sign in with <IdP>"** button instead of the local form. This requires the operator to provision a service account in *their* IdP — out of scope for a credential we generate, so it's strictly opt-in (`QA_IDP=true` plus operator-supplied `QA_IDP_USER/PASS`). The reviewer prompt template (`dispatcher.ts:1560-1576`) would branch on which login flow to drive.
246
247 **Minimal code change for v1:** in `germinate`, if `IDP_DISABLE_LOCAL_LOGIN=true` **and** no QA-over-OIDC creds are provided, **warn loudly and force-keep local login on** (don't ship a platform whose own reviewer is locked out). The agents repo itself needs no change for v1; the change is a germinate guard + a documented v2 path.
248
249 ## Validation plan
250
251 Build on the existing depth-2 k3d cold-start harness (per MEMORY: `mitosis-prs-and-coldstart-validation.md`), 16GB Mac, colima.
252
253 1. **Unit / lib.** Add a `lib.sh` test that runs `fork_regen_secrets` with `IDP_ISSUER` set and asserts `forgejo/idp-oidc-secret.yaml` exists, decrypts with the fork key, and lists the fork recipient — then runs `fork_verify_all_sealed` and expects pass. Repeat with `IDP_ISSUER` unset and assert the file is **absent** (feature off ships nothing).
254 2. **Mock-IdP cold start.** Stand up a throwaway OIDC provider in-cluster (Keycloak or `dexidp/dex` with a static `groups` claim) reachable at `https://idp.<domain>`; create a client; `IDP_ISSUER=… IDP_CLIENT_ID=… IDP_CLIENT_SECRET=… IDP_GROUP_TEAM_MAP='{"app-eng":{"acme":["engineers"]}}' nix run .#up`. Assert:
255 - `forgejo admin auth list` in the forgejo pod shows the source (`fork_wire_idp` landed).
256 - org `acme` + team `engineers` exist (the bootstrap Job pre-created them).
257 - Browser-login a Dex user in group `app-eng` → Forgejo account auto-created → `GET /users/<u>/orgs` returns `acme`.
258 - **End-to-end authz:** mint a Forgejo PAT for that SSO user, call MCP `list_prs owner=acme` → passes via `server.ts:846` (orgs includes acme). Call with `owner=someone-else` → `ForbiddenError` (`server.ts:847`). This proves group→team→org→authz flows unchanged.
259 3. **QA-path regression.** With local login left enabled (default), confirm the dispatcher still provisions `qatester` (`dispatcher.ts:2252`) and a reviewer run logs in. Then a "Use this template" app build/review completes (the canonical mitosis smoke).
260 4. **Identity-rewrite safety.** Germinate the mock-IdP daughter under a *different* `DOMAIN` than the seed; assert the OIDC source's callback URL is `git.<new-domain>/user/oauth2/.../callback` and `plat-local-ca-tls` survives (`lib.sh:55-57` gate).
261
262 ## Tradeoffs & alternatives considered
263
264 - **Login-driven (JIT) vs SCIM push.** SCIM would give instant create/disable, but Forgejo has no SCIM (codeberg #8457). JIT is the only real option and is fine for a git-centric platform (people log into git). *Accepted limitation:* a user who never logs into Forgejo never exists there, and **deprovisioning is not instant** — disabling someone in Entra stops *new* logins but doesn't kill an existing Forgejo session/PAT. v1 documents this; the existing `admin_revoke_user_access` MCP kill-switch (`server.ts:857`) + Forgejo admin remain the break-glass.
265 - **Imperative `add-oauth` vs declarative `gitea.oauth` helm values.** Declarative survives a from-scratch helm reinstall without re-running germinate, but puts the secret in helm values and fights chart-version drift. Imperative reuses the proven `kubectl exec forgejo …` seam (`germinate:371`) and the source persists in `forgejo-pg` (which is backed up). Chose imperative; revisit if "auth source must survive `helm uninstall`" becomes a requirement.
266 - **Map-driven team pre-creation in a Job vs in germinate shell.** germinate is one-shot and would re-create nothing on drift; a Flux-reconciled Job re-creates orgs/teams if someone deletes them and re-runs on `IDP_GROUP_TEAM_MAP` changes (operator edits the sealed secret + reconciles). Chose the Job (also fixes the secret-on-argv hygiene issue with `fork_wire_idp`).
267 - **Disable local login by default vs keep it.** Disabling matches "pure SSO" but breaks the reviewer (`dispatcher.ts:1573`). Chose **keep local login on by default**; pure-SSO lockdown is opt-in + a v2 follow-up that gives the reviewer an OIDC identity.
268 - **Generic OIDC first vs Entra-specific.** Entra's `groups` claim emits **group object IDs (GUIDs), not names**, unless the app registration is configured for group names or the operator maps GUIDs in `IDP_GROUP_TEAM_MAP`. A generic OIDC/Keycloak/Okta source with human-readable group names is simpler to validate. Chose **generic OIDC as the v1 contract**, with Entra as the headline supported config and a documented "use group names or map the GUIDs" note.
269
270 ## What I'm unsure of / want maintainer input
271
272 - Whether the forgejo chart version in use (`helmrelease.yaml:12` chart `17.0.0`) ships a `forgejo` binary whose `admin auth add-oauth` accepts **all** the flags I listed (`--group-team-map`, `--group-team-map-removal`, `--group-claim-name` are confirmed in current Forgejo CLI docs, but the exact subset varies by version — needs a `forgejo admin auth add-oauth --help` in the running pod to confirm before coding).
273 - Entra group-claim format (GUID vs name) — affects whether `IDP_GROUP_TEAM_MAP` keys are human-readable or GUIDs. Wants a real Entra tenant to validate against.
274
275
276 ## Open questions
277
278 - Which IdPs do we commit to for v1? I recommend generic-OIDC as the contract with Microsoft Entra as the headline tested config (Keycloak/Dex as the in-cluster test double). Okta/Google are 'should work via generic OIDC, untested.' Agree?
279 - Entra emits the `groups` claim as group OBJECT IDs (GUIDs), not names, unless the app registration opts into group names. Do we (a) require operators to put GUIDs in IDP_GROUP_TEAM_MAP, (b) require them to configure Entra for group names, or (c) ship a small GUID→name resolution step? (a) is least code for v1.
280 - Keep Forgejo LOCAL login enabled by default (so the QA reviewer's qatester local account keeps working), and make pure-SSO lockdown (IDP_DISABLE_LOCAL_LOGIN) a documented v2 that also gives the reviewer an OIDC identity? Or is breaking the reviewer under pure-SSO acceptable for v1 with just a warning?
281 - Imperative `forgejo admin auth add-oauth` in germinate's post-handoff window vs a Flux-reconciled bootstrap Job vs declarative `gitea.oauth` helm values. I lean toward the Job (idempotent reconcile + keeps the client secret off the exec argv, and it ALSO does the org/team pre-creation Forgejo won't). Confirm?
282 - Forgejo's group-team-map references EXISTING orgs/teams and does not auto-create them; sync is on-login only (no SCIM, no background job). Are you OK with login-driven JIT provisioning + a bootstrap Job that pre-creates the mapped orgs/teams, accepting that deprovisioning is not instant (handled by the existing admin_revoke_user_access kill-switch + Forgejo admin)?
283 - Should the IdP client secret be a NEW sovereign secret class? It's operator-SUPPLIED (not platform-generated like every other fork_regen_secrets value) yet still sealed to the fork key and gated by fork_verify_all_sealed. Confirm that's the right model vs. leaving it as a post-germinate manual `sops set` like the BYO-claude fallback path.
284 - Confirm the running forgejo chart (17.0.0) binary supports the full flag set (--group-team-map, --group-team-map-removal, --group-claim-name, --admin-group, --auto-discover-url) before implementation — needs a `forgejo admin auth add-oauth --help` in a live pod.
docs/proposals/repo-migration-auto-port.md added +527
@@ -0,0 +1,527 @@
1 # Repo-migration auto-port: conform migrated Forgejo repos onto the app-template
2
3 > Design proposal for maintainer review — **not yet implemented**. Issue #5 (discussion):
4 > *"Forgejo has a nice repo migration interface. when a user triggers a migration, agents
5 > should automatically start migrating it to the app template. bulk migration should be
6 > supported."*
7
8 ## Problem
9
10 Forgejo's repo-migration UI imports a foreign repo (GitHub, GitLab, a bare git URL) into a
11 platform org. Today the platform does **nothing** with it — worse, it actively *filters it
12 out*:
13
14 - The dispatcher's only "new repo" path is `route()` → `maybeBootstrap()` on the
15 `repository`/`created` event (`agents/src/dispatcher.ts:448-450`). `WEBHOOK_EVENTS`
16 already includes `"repository"` (`dispatcher.ts:363-371`), so the event is delivered.
17 - `bootstrap()` then runs a **template-shape gate**: it probes for `charts/app/Chart.yaml`
18 up to 7 times over ~30s (`dispatcher.ts:538-552`) and **early-returns** if absent,
19 parking the repo in `notTemplate` and re-probing one cheap time per sweep forever
20 (`dispatcher.ts:545-552`). A migrated repo has no `charts/app/Chart.yaml`, so it is
21 silently ignored.
22
23 So a migrated repo lands in Forgejo and just sits there. The user's intent — "make this a
24 platform app" — is dropped on the floor.
25
26 **Goal (v1):** when a repo is migrated into a platform org, an agent automatically
27 *conforms* it onto the app-template — provisions the platform substrate (OAuth + better-auth
28 + optional DB) via `create_app`, then ports the foreign source onto the template's contract
29 (`charts/app/`, `.forgejo/workflows/`, `Dockerfile`, the Fastify/TS server shape) — and
30 opens a PR. **Bulk** migrations (many repos at once) are supported without blowing past the
31 runner's concurrency cap. "Conformed" is defined precisely (the template's own `check`
32 smoke gate goes green).
33
34 **Non-goal (v1):** porting arbitrary stacks. The template is Fastify 5 + TypeScript on Node
35 22 (`agents/prompts/builder.md:15-18`; `_app-template/.forgejo/workflows/check.yml`). v1
36 scopes the *automatic* port to **Node/TS-compatible source**; non-Node source (Python/Go/…)
37 is detected and handed to a human with a clear note rather than silently mangled (see
38 [Handling non-Node source](#handling-non-node-source)).
39
40 ---
41
42 ## Design (from first principles)
43
44 ### What is the actual trigger?
45
46 Forgejo fires the **`repository`** webhook event for migrations. Empirically Forgejo's
47 migration completes by *creating the repository* and the `repository` event's `action` is
48 `"created"` (a migration is, to the events layer, a repo coming into existence with content
49 already present — there is no distinct `"migrated"` action in the Gitea/Forgejo 1.22 event
50 schema the dispatcher targets). The dispatcher already receives this event
51 (`dispatcher.ts:448`). **So we do not need a new webhook receiver or HMAC** — we extend the
52 *classification* that happens after the existing `repository`/`created` event, plus the
53 sweep (which catches repos migrated while the dispatcher was down, exactly like it catches
54 created repos today at `dispatcher.ts:2218-2221`).
55
56 > **Honest uncertainty (open question O1):** I could not confirm from the cloned code
57 > whether Forgejo emits `action: "created"` vs. something else (e.g. no event at all for
58 > some migration modes, or a `pull_request`/`push` flurry). The migration is initiated in
59 > the Forgejo UI, not via our API, so there's no code in `mcp/` or `agents/` that observes
60 > it. The **sweep is the safety net**: `listRepos()` (`forgejo.ts:118-129`) enumerates every
61 > repo regardless of how it arrived, so even if the webhook is silent on migration, the next
62 > sweep (≤60s, `SWEEP_INTERVAL_MS`) discovers it. The webhook path is an optimization;
63 > correctness rides on the sweep. This must be validated empirically before we trust the
64 > event (see Validation).
65
66 ### Why a new role, not a reused builder
67
68 The builder (`spawnBuilder`, `dispatcher.ts:879`) assumes a **fresh template clone** —
69 `charts/app`, `src/server.ts`, `src/auth.ts` etc. already present, working out of the box
70 (`prompts/builder.md:13-19`). A migrated repo has the *opposite* starting condition: real
71 foreign source, **no** template scaffolding. The job is not "design a v1 from a name"; it is
72 "preserve this code's behavior while re-expressing it on the template contract." That is a
73 distinct task with a distinct prompt and a distinct success definition. Hence a new **`port`
74 role**: `prompts/port.md` + `spawnPort()` mirroring `spawnBuilder()`.
75
76 ### The substrate gap in `create_app` (the load-bearing hard part)
77
78 The proposal reuses `create_app` as the onboarding mechanism (it is idempotent and already
79 provisions OAuth + better-auth + optional CNPG Postgres + the gitops overlay). But
80 `create_app` was written for the *generate-from-template* path, and on a migrated repo it
81 partially breaks:
82
83 - **`/repos/plat/_app-template/generate` returns 409** ("repo already existed",
84 `mcp/src/server.ts:1056`) because the migrated repo pre-exists. So **the template content
85 is never laid down by `create_app`** — `git_content: true` (server.ts:1047) only applies
86 when the repo is *created* by generate. The migrated repo keeps its foreign tree.
87 *Consequence:* the agent — not `create_app` — must bring `charts/app/`,
88 `.forgejo/workflows/`, `Dockerfile`, and the server shape.
89 - **`waitForCI(owner, name, "v0.1.0", 5*60_000)` (server.ts:1147) will hang/fail** on a
90 foreign repo: there are no `.forgejo/workflows`, so no `check`/build run ever registers
91 for the v0.1.0 tag, and the wait times out. This means calling `create_app` *first* on a
92 raw migrated repo throws before OAuth/gitops are provisioned.
93
94 **Two ways out** (recommend B for v1):
95
96 - **(A)** Make `create_app` tolerant of a no-workflow repo: skip/relax the `waitForCI` step
97 when the repo has no `.forgejo/workflows`. Cleaner long-term but edits the most sensitive
98 MCP tool and weakens a real invariant for every caller.
99 - **(B, recommended)** **Order the port so the template plumbing lands *before*
100 `create_app`.** The port agent's *first* pushed commit adds `.forgejo/workflows/`,
101 `charts/app/`, `Dockerfile`, and a minimal compiling `src/server.ts` (copied verbatim from
102 `_app-template` via the MCP `read_repo` tool against `plat/_app-template`, or fetched from
103 the template clone). Once that commit is on a branch, the dispatcher calls `create_app`
104 (CI now has a `check` workflow to run), then the agent continues porting business logic.
105 This keeps `create_app` untouched and reuses the *exact* template contract as source of
106 truth. The cost: the port flow is agent-driven from commit #1, not provisioned-then-built.
107
108 I recommend **B** for v1 and propose A as a follow-up hardening (it also helps the
109 generate-from-template race where CI is briefly absent). See
110 [Tradeoffs](#tradeoffs--alternatives-considered).
111
112 ### Lifecycle shape
113
114 ```
115 repo migrated into org
116 │
117 │ repository/created webhook ──OR── sweep listRepos()
118 ▼
119 route() → maybeBootstrap(owner, name, desc)
120 │
121 ├─ charts/app/Chart.yaml present? ── yes ──► EXISTING builder/adopt path (unchanged)
122 │
123 └─ no, but repo is non-empty (has a default branch + commits)?
124 │ yes ─────────────────────────────────────────────┐
125 │ no (truly empty / plain non-migrated repo) │
126 ▼ ▼
127 stay in notTemplate (today's behavior) maybePort(owner, name, desc)
128 │
129 skipRepo / already-ported / inflight guards
130 │
131 stack-compatibility probe (Node?)
132 ├─ incompatible ► file issue, stop
133 └─ compatible
134 ▼
135 spawnPort(...) (port role run)
136 │
137 1. branch `port`, commit template plumbing (workflows+chart+Dockerfile+server skeleton)
138 2. dispatcher calls create_app (now CI-capable) → OAuth+DB+gitops
139 3. agent ports foreign code onto src/, makes `check` green
140 4. open/ья update PR `port → main`, drop 🏗️, ship summary
141 ▼
142 EXISTING validator path (maybeReview) — unchanged
143 ```
144
145 The crucial reuse: **once the port branch carries `charts/app/Chart.yaml` and a green
146 `check`, the repo is indistinguishable from a built template app**, so the existing reviewer
147 / preview / auto-merge / release machinery (`maybeReview`, `applyVerdict`, `maybeRelease`)
148 applies verbatim. The port role only has to get the repo *to* that state. **"Conformed" ≡
149 the template's `check.yml` smoke gate passes** (`_app-template/.forgejo/workflows/check.yml`:
150 lint + typecheck + build + `/version`→200, `/`→200, `/api/me`→401, `/openapi.json` has
151 paths). That is the same gate the builder must pass (`prompts/builder.md:18,32`), so the
152 definition is already enforced platform-wide — we inherit it for free.
153
154 ### Dispatch path decision: extend the dispatcher, do NOT add an MCP webhook
155
156 The prompt offers two options. **Recommendation: extend the agents dispatcher.** Reasons,
157 all anchored:
158
159 - The dispatcher *already* owns the Forgejo system webhook (`ensureWebhook`,
160 `dispatcher.ts:2591-2616`) and the `"repository"` event (`WEBHOOK_EVENTS`,
161 `dispatcher.ts:363`). The migration trigger is the same event we already consume — adding
162 a net-new authenticated MCP route (`mcp/src/server.ts`) would mean a *second* webhook
163 subscription, a *second* idempotency store, and a brand-new auth surface.
164 - **There is no inbound webhook route or HMAC verification in `mcp/` today** (confirmed: the
165 MCP `/hook`-style ingress does not exist; the dispatcher's `/hook` (`dispatcher.ts:2704`)
166 is unauthenticated and trusts that only Forgejo can reach `host.k3d.internal:9909` on the
167 cluster network). Building HMAC verification + a `plat/workflows` enqueue for migration
168 would be a large net-new subsystem for a trigger the dispatcher already receives. Out of
169 scope for v1.
170 - Durable/async heavy lifting still lands where it belongs: the **port run itself** is a
171 bounded `claude` subprocess managed by the existing `Runner` queue
172 (`runner.ts:180-219`), exactly like builder/worker/reviewer. We are not introducing
173 long-lived orchestration that would warrant `plat/workflows`.
174
175 > If a future requirement needs migration to be auditable/replayable across daemon restarts
176 > beyond what `state.json` gives us, the MCP-webhook-→-`plat/workflows` design becomes worth
177 > it. Noted as open question O5.
178
179 ### Bulk migrations
180
181 Forgejo's bulk migration creates N repos in a burst. The design handles this for free **as
182 long as we respect the existing back-pressure**:
183
184 - **Concurrency:** `Runner` already caps at `maxConcurrent` (default **2**,
185 `dispatcher.ts:89`; `runner.ts:209-219` `pump()`), and `port` runs go through the same
186 `enqueue()`. A 50-repo bulk migration enqueues 50 port runs that drain 2-at-a-time —
187 *no new concurrency guard is strictly required*. **But** port runs are
188 builder-priority-class work (long, expensive); to keep a bulk migration from starving
189 real builders, give `port` its **own priority below builder** (see Implementation:
190 `PRIORITY`). The queue is already priority+FIFO (`runner.ts:196-198`).
191 - **Idempotency:** add `ported: Record<string, true>` to `State` (`state.ts:10-38`),
192 mirroring `bootstrapped`. `maybePort` checks it synchronously before enqueue (just like
193 `maybeBootstrap` checks `this.state.bootstrapped[key]`, `dispatcher.ts:514`) so webhook +
194 sweep deliveries of the same migrated repo can't double-spawn. Set it *before* spawn (one
195 port per repo ever), the same discipline as `bootstrapped` (`dispatcher.ts:708`).
196 - **In-flight guard:** a `port:${key}` key in the existing `inflight` set
197 (`dispatcher.ts:392`, used by `boot:`/`work:`/`review:`/`release:`), registered
198 synchronously before any `await`, so racing webhook+sweep deliveries can't both pass.
199
200 No special rate limiter needed beyond the above — the queue *is* the rate limiter. (If bulk
201 migrations of hundreds of repos prove to overwhelm Forgejo API calls during classification,
202 a sweep-side batch cap is a cheap follow-up; not v1.)
203
204 ---
205
206 ## Implementation (per-repo, phased PRs)
207
208 All code is in **plat/agents** except Phase 4 (optional MCP hardening). No gitops changes
209 are required — the port lands on the same overlays `create_app` already renders.
210
211 ### Phase 1 — state + classification scaffolding (plat/agents)
212
213 **`src/state.ts`** — add the idempotency map (mirror `bootstrapped`):
214
215 ```ts
216 export interface State {
217 bootstrapped: Record<string, true>;
218 /** "owner/repo" → true once a migrated repo was conformed onto the template (issue #5). */
219 ported: Record<string, true>; // NEW
220 // …existing fields…
221 }
222 // loadState(): add `ported: parsed.ported ?? {},` to the merged defaults (state.ts:52-62)
223 ```
224
225 **`src/dispatcher.ts`** — a non-template-but-non-empty repo is a migration candidate. Today
226 `bootstrap()` early-returns when `charts/app/Chart.yaml` is absent (`dispatcher.ts:544-552`).
227 Branch that early-return into a port decision:
228
229 ```ts
230 // in bootstrap(), replace the `if (!shaped) { … return; }` block:
231 if (!shaped) {
232 // Not template-shaped. Is it a MIGRATED repo (has content) rather than an
233 // empty/plain repo? A migrated repo has a default branch with commits.
234 const migrated = await this.fg
235 .hasFile(owner, name, "") // probe default branch contents (see forgejo note)
236 .catch(() => false);
237 if (migrated) {
238 this.maybePort(owner, name, description);
239 return;
240 }
241 // …existing notTemplate quiet-probe behavior (unchanged)…
242 return;
243 }
244 ```
245
246 > **Forgejo probe note:** `hasFile(owner, name, "charts/app/Chart.yaml")` already exists
247 > (`forgejo.ts:132-140`). For "repo has any content" we need a cheap signal that a default
248 > branch with commits exists. Cleanest is a new `Forgejo.isNonEmpty(owner, repo)` that
249 > GETs `/repos/{owner}/{repo}` and reads `.empty` (Forgejo's repo object exposes an `empty`
250 > boolean) — `!empty` ⇒ migrated/non-empty. This is one typed field add to `forgejo.ts`.
251 > *Do not* rely on `hasFile(..., "")` (the contents API on an empty path is ambiguous);
252 > the `.empty` field is the correct, documented signal.
253
254 Add the `port:` guard + classification entry point next to `maybeBootstrap`
255 (`dispatcher.ts:510-525`):
256
257 ```ts
258 maybePort(owner: string, name: string, description: string): void {
259 const key = `${owner}/${name}`;
260 if (skipRepo(name) || this.state.ported[key] || this.portFailed.has(key)) return;
261 const flight = `port:${key}`;
262 if (this.inflight.has(flight)) return; // webhook+sweep race guard
263 this.inflight.add(flight);
264 void this.port(owner, name, description)
265 .catch((e) => log("error", "port failed", { repo: key, error: e }))
266 .finally(() => this.inflight.delete(flight));
267 }
268 ```
269
270 Add `private readonly portFailed = new Set<string>();` next to `bootstrapFailed`
271 (`dispatcher.ts:396`) — a port that exhausts its retries parks until operator restart,
272 exactly like `bootstrapFailed`.
273
274 **Sweep wiring** — the sweep already calls `maybeBootstrap` for every repo
275 (`dispatcher.ts:2218-2221`); since `maybePort` is reached *through* `bootstrap()`'s
276 not-shaped branch, **no extra sweep loop is needed** — a repo migrated while the daemon was
277 down is rediscovered by the same `listRepos()` pass that feeds `maybeBootstrap`. (Verify:
278 `bootstrap()` runs its shape probe on every sweep for a not-yet-bootstrapped repo, so the
279 port branch fires there too.)
280
281 **Tests** (`dispatcher.test.ts` conventions, pure functions only): add a
282 `classifyPortTarget()` pure helper (compatible-stack detection, see Phase 3) and unit-test
283 it the way `classifyBootstrap` is tested (`dispatcher.test.ts:13-50`).
284
285 ### Phase 2 — the port run (plat/agents)
286
287 **`spawnPort()`** mirrors `spawnBuilder()` (`dispatcher.ts:879-1057`). Key differences:
288
289 ```ts
290 private spawnPort(owner: string, name: string, description: string, portPr = 0): void {
291 const key = `${owner}/${name}`;
292 const prodHost = `${name}-${owner}.${this.cfg.appsDomain}`;
293 const systemPrompt = assemblePrompt(this.promptTemplate("port"), {
294 OWNER: owner, APP: name,
295 DESCRIPTION: description || "(no description — infer from the migrated source)",
296 PROD_URL: `https://${prodHost}`, PROD_HOST: prodHost,
297 BRANCH: "port", PR: String(portPr),
298 GIT_PUBLIC: `https://git.${this.cfg.appsDomain}`,
299 TEMPLATE_REPO: "plat/_app-template", // agent reads the contract from here
300 });
301 this.runner.enqueue({
302 role: "port", // NEW role (see runner.ts change)
303 owner, repo: name, item: "port",
304 model: this.cfg.builderModel, // porting is builder-class work
305 maxTurns: 300, timeoutMs: 50 * 60_000,
306 systemPrompt,
307 userPrompt: `Conform the migrated repo "${name}" onto the platform app-template, ` +
308 `preserving its behavior; build on the \`port\` branch under PR #${portPr}.`,
309 clone: true, checkoutBranch: portPr ? "port" : undefined,
310 onExit: async (result) => { /* mirror builder onExit: fallback summary, drop 🏗️ */ },
311 });
312 }
313 ```
314
315 `port()` (the async orchestrator `maybePort` kicks) does the substrate dance in order **B**:
316
317 1. **Open the construction PR immediately** (mirror `dispatcher.ts:715-747`): create branch
318 `port` off `main`, seed a `PORT_PLAN.md`, open PR `🏗️ port: {{APP}}`. User sees it
319 instantly. (Reuses `createBranch`/`createFile`/`createPull` verbatim — `forgejo.ts:281-324`.)
320 2. **Spawn the port agent** (`spawnPort`). The agent's **first** commit lands the template
321 plumbing on the `port` branch (see prompt step 1 below) — this is what makes `create_app`'s
322 CI wait satisfiable.
323 3. **Provision substrate via `create_app`** — but it must run *after* the agent's plumbing
324 commit is on `main`'s lineage for CI to see a `check` workflow. **Sequencing problem:**
325 `create_app` cuts `v0.1.0` off the **`main`** branch (`server.ts:1129-1147`,
326 `target_commitish` = `main`'s head), and `main` on a migrated repo has **no workflows**.
327 → **v1 resolution:** the port agent's first action is to land template plumbing
328 (`.forgejo/workflows/`, `charts/app/`, `Dockerfile`, skeleton `src/server.ts` +
329 `package.json` + configs) **directly onto `main`** via a `[skip ci]`-free commit
330 *before* business-logic porting, then the dispatcher calls `create_app`. This is the
331 honest cost of reusing `create_app` unchanged. (Alternative A — make `create_app`
332 tolerate no-CI — removes this constraint; see Tradeoffs.)
333
334 > **This sequencing is the riskiest part of the design and the place I'm least sure of**
335 > (open question O2). Landing plumbing on `main` before a PR exists is unusual for this
336 > codebase (everything else builds on a branch). The cleanest correct ordering may actually
337 > be: agent lands plumbing on the `port` branch → dispatcher calls `create_app` with a
338 > `branch` override so v0.1.0 is cut off `port` not `main`. But `create_app` hardcodes
339 > `main` (`server.ts:1046,1130`). So either we (B) commit plumbing to `main` first, or (A)
340 > parametrize `create_app`'s branch + relax `waitForCI`. **I recommend deciding this with
341 > the maintainer before implementation** — it determines whether Phase 4 (MCP) is required
342 > or optional.
343
344 4. Agent ports business logic, iterates `check` green (`get_build`), posts ship summary,
345 drops `🏗️` → hands to the **existing** validator (`maybeReview`), which auto-merges and
346 `maybeRelease` ships it. No new code past the drop-prefix point.
347
348 **`onExit`/resume/idempotency** reuse the builder's patterns: a `portersLive` set mirroring
349 `buildersLive` (`dispatcher.ts:409`) if we want resume-on-crash; for v1 a simpler
350 `portFailed`-park-on-exhaust is acceptable (porting is rarer than building). Set
351 `this.state.ported[key] = true` before spawn (`dispatcher.ts:708` analog).
352
353 ### Phase 3 — stack-compatibility gate + the `port` prompt (plat/agents)
354
355 **Compatibility probe** (pure helper, unit-tested). Before spawning, classify the migrated
356 source. v1 = "is this Node/TS-portable?":
357
358 ```ts
359 export type PortClass = { kind: "node" } | { kind: "incompatible"; lang: string };
360
361 /** Decide whether a migrated repo's stack is auto-portable in v1. */
362 export function classifyPortTarget(signals: {
363 hasPackageJson: boolean;
364 topLevelFiles: string[]; // from listing the default branch root
365 }): PortClass {
366 if (signals.hasPackageJson) return { kind: "node" };
367 const goish = signals.topLevelFiles.some((f) => f === "go.mod");
368 const pyish = signals.topLevelFiles.some((f) =>
369 ["requirements.txt", "pyproject.toml", "Pipfile"].includes(f));
370 if (goish) return { kind: "incompatible", lang: "Go" };
371 if (pyish) return { kind: "incompatible", lang: "Python" };
372 return { kind: "incompatible", lang: "unknown" };
373 }
374 ```
375
376 On `incompatible`, `port()` files **one** issue (reuse `createLabeledIssue`,
377 `forgejo.ts:238-250`) — *not* labeled `agent-work* — explaining the template is
378 Node/TS-only today and a human should port the runtime, then marks `ported[key] = true` so
379 the sweep stops re-triggering. (Honest scope boundary, surfaced to the user, not silent.)
380
381 **`agents/prompts/port.md`** (new). Modeled on `builder.md` but with inverted assumptions.
382 Sketch of the load-bearing sections:
383
384 ```md
385 # Role: Platform Port Agent
386
387 You are conforming a MIGRATED repository **{{OWNER}}/{{APP}}** onto the platform's
388 app-template. Unlike a fresh app, this repo already contains REAL foreign source whose
389 behavior you must PRESERVE while re-expressing it on the template contract. Build on the
390 `{{BRANCH}}` branch under PR #{{PR}}.
391
392 ## The contract you are porting ONTO
393 The template is **{{TEMPLATE_REPO}}**: Fastify 5 + TypeScript (Node 22), TypeBox-schema
394 routes (auto-OpenAPI at /openapi.json), better-auth Forgejo SSO in `src/auth.ts` (works out
395 of the box — do not rewire), Postgres via the `{{APP}}-pg-app` secret, and platform plumbing
396 in `charts/app/`, `Dockerfile`, `.forgejo/workflows/`. Read the template via the `read_repo`
397 MCP tool against `{{TEMPLATE_REPO}}` — copy `charts/app/`, `.forgejo/workflows/`,
398 `Dockerfile`, and the skeleton `src/` files VERBATIM; never hand-author plumbing.
399
400 ## Order of operations (do these in order)
401 1. **Land the template plumbing first** — copy `charts/app/`, `Dockerfile`,
402 `.forgejo/workflows/`, and a minimal COMPILING `src/server.ts`/`package.json`/tsconfig
403 from {{TEMPLATE_REPO}} so `check` can run. Commit + push. (The platform provisions OAuth +
404 DB once this exists.)
405 2. **Port the behavior** — map the foreign app's core flows onto typed Fastify routes +
406 Postgres migrations + the server-rendered UI pattern in `src/homepage.ts`. Preserve data
407 shapes and user-visible behavior; do not invent features.
408 3. **Make `check` green** (lint + typecheck + build + smoke). Poll `get_build`. Red → fix
409 forward, push, repeat. Main is branch-protected; green is non-negotiable.
410 4. **Ship summary + drop `🏗️`** — identical to the builder's hand-off: post a summary
411 comment on PR #{{PR}}, then drop the `🏗️ ` prefix to trigger the validator, then EXIT.
412 Do NOT merge or release.
413
414 ## What to preserve vs. cut
415 - Preserve: the app's actual behavior, data model, routes a user depends on.
416 - Cut/replace: foreign build tooling, foreign CI, foreign auth — the template owns those.
417 - If a flow genuinely cannot be expressed on Node/Fastify, note it in the summary rather
418 than faking it.
419 ```
420
421 **Runner** (`runner.ts`): add `"port"` to the `Role` union (`runner.ts:22`) and
422 `PRIORITY` (`runner.ts:39`). Recommended priority: `{ builder: 0, port: 1, worker: 2,
423 reviewer: 3 }` — port is builder-class but must not starve real builders during a bulk
424 migration. The runner already gives `["reviewer","builder","worker"]` roles a browser
425 (`runner.ts:295`); **add `"port"`** there too (the port agent self-tests its preview just
426 like the builder). `promptTemplate`'s role type (`dispatcher.ts:431`) gains `"port"`.
427
428 ### Phase 4 — (optional / deferred) make `create_app` migration-aware (plat/mcp)
429
430 Only if the maintainer picks **resolution A** (or to harden either way):
431
432 - Relax `waitForCI` (`server.ts:1147`) to **skip** when the target ref has no
433 `.forgejo/workflows` (probe via the existing `read_repo`/contents call), with a `step`
434 noting "CI wait skipped: no workflows yet". This makes `create_app` safe to call on a raw
435 migrated repo and removes the order-B sequencing constraint.
436 - No `authorize()` change is needed: the port path calls **`create_app`**, already on the
437 allowlist (`server.ts:819`) and gated owner==caller||org-member. The dispatcher calls it
438 with the platform admin token (`mcp.ts` / `PLAT_TOKEN`), which is `is_admin` and
439 short-circuits authorize (`server.ts:816`) — same as `bootstrap()` does today
440 (`dispatcher.ts:664`).
441
442 **No new MCP tool, no new authorize() entry, no webhook route, no HMAC.** That is the point
443 of reusing `create_app`.
444
445 ### Phased PR plan
446
447 | PR | Repo | Contents | Depends on |
448 |----|------|----------|-----------|
449 | 1 | plat/agents | `state.ts` `ported` map; `Forgejo.isNonEmpty`; `maybePort`/`portFailed` guards + `port:` inflight key; `classifyPortTarget` + tests. **No spawn yet** (file-an-issue-only on every candidate) so the trigger is observable in isolation. | — |
450 | 2 | plat/agents | `prompts/port.md`; `runner.ts` `Role`/`PRIORITY`/browser; `spawnPort` + `port()` orchestrator (order B); wire `maybePort`→`spawnPort` for `node` class. | PR 1 |
451 | 3 | plat/agents | Bulk hardening: resume-on-crash (`portersLive`) if needed; sweep-side batch cap if bulk load proves heavy; richer compat detection. | PR 2 |
452 | 4 | plat/mcp | (optional) `create_app` `waitForCI` skip-when-no-workflows (resolution A). | independent |
453
454 PR 1 is deliberately a **no-op-but-observable** slice: it lights up the trigger and logs
455 "would port `owner/repo` (class=node)" without running an agent, so we can confirm the
456 migration event/sweep classification on a real bulk migration before any expensive run
457 fires.
458
459 ---
460
461 ## Validation plan
462
463 1. **Trigger fidelity (the big unknown):** on a depth-1 germinated platform, use Forgejo's
464 migration UI to import a small public Node repo into a test org. With PR-1 deployed
465 (observe-only), confirm a log line `would port …` appears — and confirm **whether** it
466 came from the `repository` webhook or only from the sweep (instrument both paths with a
467 `source` field). This decides open question O1.
468 2. **Empty-repo non-regression:** create a plain empty repo (no migration) and confirm it
469 stays in `notTemplate` and is **not** classified as a port target (the `.empty` probe is
470 the discriminator).
471 3. **Single port end-to-end** (PR-2): migrate one Node repo; confirm the `port` PR opens,
472 plumbing commit lands, `create_app` provisions OAuth+DB+gitops (check `list_apps` / the
473 gitops overlay), `check` goes green, validator runs, auto-merge + release fire — i.e. the
474 migrated repo reaches a live prod URL `https://{{name}}-{{owner}}.{{appsDomain}}`.
475 4. **Bulk** (PR-2/3): migrate 5–10 repos in one Forgejo bulk action. Assert: (a)
476 `ported`/`inflight` prevent duplicates, (b) the runner never exceeds `maxConcurrent`
477 (watch `/healthz` `activeRuns`, `dispatcher.ts:2720-2723`), (c) builders are not starved
478 (enqueue a real builder mid-bulk and confirm it runs ahead of pending ports), (d) every
479 repo ends `ported`.
480 5. **Incompatible stack:** migrate a Python repo; assert one explanatory issue is filed, no
481 `agent-work` label, `ported` set, no agent spawned, no re-trigger next sweep.
482 6. **Idempotency across restart:** kill the dispatcher mid-bulk; on restart confirm
483 `ported` (persisted via `saveState`, `state.ts:65-70`) prevents re-porting completed
484 repos and the sweep resumes the rest.
485 7. **`create_app` 409 path:** confirm `create_app` on the now-plumbed migrated repo returns
486 "repo already existed" (server.ts:1056) and still provisions OAuth+gitops without
487 re-generating content.
488
489 ---
490
491 ## Tradeoffs & alternatives considered
492
493 - **Extend dispatcher vs. new MCP webhook + `plat/workflows`.** Chose dispatcher: the
494 `repository` event is already consumed (`dispatcher.ts:363,448`), the sweep already
495 enumerates all repos, and there is **no** inbound MCP webhook/HMAC today to extend cheaply.
496 An MCP route would duplicate idempotency + add an auth surface for zero v1 benefit.
497 Deferred unless durable cross-restart audit of migrations is required (O5).
498 - **New `port` role vs. reusing `builder`.** The builder assumes a working template clone
499 (`prompts/builder.md:13-19`); a migrated repo has the opposite. Overloading the builder
500 prompt with "sometimes there's foreign code, sometimes not" would make both jobs worse.
501 A dedicated prompt + run is cleaner and keeps the builder path untouched.
502 - **Order B (agent lands plumbing first) vs. Order A (make `create_app` CI-tolerant).**
503 B keeps `create_app` — the most sensitive MCP tool — unchanged, at the cost of an unusual
504 "commit plumbing to `main` before the PR" step and a real sequencing risk (O2). A is
505 cleaner conceptually but edits `create_app`'s `waitForCI` invariant for *all* callers.
506 Recommend B for v1, A as follow-up hardening (PR 4). **Maintainer should pick before
507 build.**
508 - **Concurrency: reuse the runner cap vs. a new bulk limiter.** The runner's
509 `maxConcurrent`+priority queue already serializes bulk work (`runner.ts:209`); adding
510 `port` as its own priority class below `builder` is enough. A dedicated bulk rate-limiter
511 is gold-plating deferred to PR 3 only if real bulk load overwhelms the Forgejo API during
512 classification.
513 - **"Conformed" definition.** Defined as the template's `check.yml` smoke gate
514 (`_app-template/.forgejo/workflows/check.yml`) going green — the *same* bar the builder
515 meets — so we inherit the validator/preview/auto-merge pipeline with zero new success
516 criteria. Alternative (a bespoke "port-complete" check) would fragment the definition of
517 "a working app."
518 - **Non-Node source.** v1 detects and defers (files an issue) rather than attempting a
519 runtime swap. Auto-porting Python/Go would mean either a polyglot template (a much larger
520 platform change) or an LLM rewriting the runtime (high risk, unbounded). Scoped out
521 honestly.
522
523 ---
524
525 ## Open questions
526
527 (see `openQuestions` field)
docs/proposals/self-serve-custom-domains.md added +287
@@ -0,0 +1,287 @@
1 # Self-serve custom domains (Vercel-style BYOD)
2
3 > Issue #3 — "users should be able to configure their own domains for apps or orgs similar to Vercel."
4
5 ## Problem
6
7 The platform already has the *resolution half* of bring-your-own-domain fully built. What is missing is the *governance half*: the only way a domain enters the system today is a maintainer hand-editing `clusters/local/domains.yaml`.
8
9 What exists (verified):
10
11 - **A registry**: `clusters/local/domains.yaml` (`mitosis-work/gitops/clusters/local/domains.yaml:16-19`) maps `domain → org`. It is a plain governance file — **not** referenced by any kustomization (`clusters/local/kustomization.yaml:1-4`, `flux-kustomizations.yaml` has no entry for it); Flux never applies it. MCP reads it over the Forgejo **raw** API.
12 - **Pure host logic**: `mcp/src/domains.ts` — `parseDomainRegistry` (`:110`), `hostFromWebsite` (`:39`), `hostOwnedBy` (`:61`, dot-boundary safe), `isRegisteredDomain` (`:78`), `derivePreviewHost` (`:92`).
13 - **The I/O resolver**: `loadDomainRegistry` (`server.ts:1709`, 60s cache, fail-open to empty) + `resolveAppHost` (`server.ts:1767`) which computes the ONE prod host from `repo.website → org.website → platform default` and never throws.
14 - **The single-resolved-host invariant**: that one host feeds Ingress + cert dnsName + `BETTER_AUTH_URL` + OAuth `redirect_uri` + repo website, all from `resolveAppHost`'s return (`overlayManifests` `server.ts:1924`; cert block `:2320-2337`; Ingress block `:2374-2397`; redirect `redirectUrisForHost` `:906`).
15 - **A re-host path**: `renderGitopsOverlay` (`server.ts:2745`) already detects `currentHost !== host` (`:2811`), re-renders the whole overlay at the new host, and re-points the OAuth redirect (`:2828-2853`). So *moving an app to a new domain is already a solved problem* — once the domain is in the registry.
16 - **`create_app(domain:)`** (`server.ts:1063-1077`, tool schema `:4172-4179`): claims a domain at create time, but only if `hostOwnedBy(d, owner, reg)` already passes — i.e. it was *already* in the registry.
17 - **A CoreDNS precedent**: `coredns-custom.yaml:22` already carries `rewrite name exact app.shipits.app traefik...` — the in-cluster split-horizon rewrite for a custom domain.
18 - **A public-edge precedent**: the cloudflared tunnel cutover (`germinate:439-448`, `lib.sh:568-598`) routes `*.<domain> → traefik`.
19
20 **The gap, precisely:** there is no self-serve way to (a) *prove* you own a domain and (b) get the `domain → org` line written into `domains.yaml`, the per-host TLS issued, and the DNS/tunnel wired — without a maintainer. Everything downstream of "the line exists in the registry" already works.
21
22 Two sub-problems the design must solve that the platform has **never** solved before:
23
24 1. **Ownership verification.** `domains.yaml` is the ownership authority, but nothing checks that the org *actually controls* the DNS for a domain before it's written. A self-serve `add_domain` with no proof lets tenant A claim `tenantB.com`. This is the crux, and it's brand new — there is no TXT/CNAME verification code anywhere (`grep` for `resolveTxt`/`node:dns` in `mcp/src` → nothing).
25 2. **Public TLS.** Every cert today uses `plat-local-ca-issuer` (`pki/ca-issuer.yaml`), a private CA. A browser hitting a *real public* custom domain will not trust it. Public domains need ACME (Let's Encrypt). cert-manager is installed (`cert-manager/helmrelease.yaml`) but **no ACME `ClusterIssuer` exists** (`grep acme` over gitops → only app-name comments).
26
27 ## Design (from first principles)
28
29 ### The invariants that constrain everything
30
31 1. **`domains.yaml` is the sole ownership authority.** `resolveAppHost`/`hostOwnedBy` already trust it completely and fail-open to the platform default. Therefore the *only* security-critical write in this whole feature is "append a `domain → org` line." If we gate that write behind real verification, every downstream consumer is automatically safe — no other code needs a trust check.
32
33 2. **The single-resolved-host invariant must hold.** A domain is just an *input* to `resolveAppHost`. We must never let `add_domain` write Ingress/cert/redirect directly; it writes the registry line, and then the existing `create_app`/re-host path recomputes the one host. This is the key architectural decision: **`add_domain` grants capability; it does not deploy.** Deployment stays in the already-correct single-source path.
34
35 3. **Germination must not break.** The identity rewrite (`rewrite_identity_domain`, `lib.sh:42-60`) rewrites only the *seed domain* (`open-platform.sh`) → the daughter domain, dot-escaped. A tenant's custom `acme.com` line in `domains.yaml` is **not** the seed domain, so it survives the rewrite untouched — good and intended (a daughter inherits its parent's registered custom domains as governance data; whether that's desired is an open question, see below). The CA-token survival gate (`:55-57`) is unaffected.
36
37 ### v1 scope decision
38
39 Ship the **grant + verify + register** loop and **public TLS via a DNS-01 ACME issuer**, org-owner gated. Defer: vanity per-app UIs, automatic cloudflared API integration, apex A-record automation, wildcard public certs. Rationale below in Tradeoffs.
40
41 ### The Vercel-style flow
42
43 ```
44 add_domain(owner, domain)
45 → MCP mints a verification token, returns DNS instructions, persists a PENDING claim
46 → tenant adds a TXT record at _platform-challenge.<domain> (+ CNAME/A for routing)
47 verify_domain(owner, domain)
48 → MCP does a live DNS TXT lookup; on match:
49 - append `<domain>: <owner>` to domains.yaml via gitopsPush
50 - render a per-host public Certificate (ACME DNS-01) [if public]
51 - add the CoreDNS split-horizon rewrite + (optionally) note the tunnel rule
52 → now create_app(domain:) / repo.website selection resolves to it
53 ```
54
55 This mirrors Vercel exactly: add domain → see DNS instructions → verify → it goes live. The two-call split (`add_domain` issues the challenge, `verify_domain` checks it) is deliberate — DNS propagation is async and out of our control, so the tenant calls `verify_domain` when ready (or CI polls it).
56
57 ### Where verification state lives
58
59 There is no database in MCP for this. Two honest options:
60
61 - **(A) Deterministic token, no stored state.** The challenge token is `HMAC(PLAT_SECRET, owner + ":" + domain)` truncated — MCP recomputes it in `verify_domain` and never has to persist the pending claim. **This is the v1 recommendation**: it's stateless, survives pod restarts, and needs no new storage. The token leaks no secret (it's domain-scoped and only proves *we issued it*).
62 - (B) A `domain-claims.yaml` PENDING list in gitops. More Vercel-faithful (shows pending domains in a UI) but adds a second gitops write and a reconciliation surface. Deferred.
63
64 With (A), `add_domain` is *pure compute* (returns instructions) and only `verify_domain` writes gitops — which is the right place for the irreversible grant.
65
66 ### TLS: public vs internal
67
68 The wildcard `*.<platform-domain>` cert only covers platform subdomains. A custom apex/subdomain needs its own cert. Two issuers, selected by whether the domain is the platform's own suffix:
69
70 - **Internal / platform-suffix host** → keep `plat-local-ca-issuer` (today's behavior, unchanged).
71 - **Real public custom domain** → a new `letsencrypt-dns01` `ClusterIssuer` using **DNS-01** (not HTTP-01).
72
73 **Why DNS-01 over HTTP-01:** HTTP-01 requires Let's Encrypt to reach `http://<domain>/.well-known/acme-challenge/...` through the public edge *before* the domain is fully cut over — a chicken-and-egg with the tunnel, and it breaks for apex + can't do wildcards. DNS-01 only needs cert-manager to create a `_acme-challenge` TXT record via the DNS provider API, which works before any traffic is routed and supports wildcards. Since the tenant is already proving ownership via DNS TXT, requiring DNS-API access (or a delegated `_acme-challenge` CNAME) is consistent.
74
75 The per-host Certificate is **still rendered by the existing overlay path** (`overlayManifests:2320`) — we only swap `issuerRef.name` based on host class. No new render path.
76
77 ### Org-level vs app-level domains
78
79 `resolveAppHost` already implements both (`:1782-1796`):
80
81 - **App-level**: `repo.website = https://notes.acme.com` → that exact host (per-app override). `create_app(domain:)` sets this.
82 - **Org-level**: `org.website = acme.com` → every app resolves to `<name>.acme.com` automatically.
83
84 So **registering one `acme.com: acme` line enables both** — no separate org-domain vs app-domain machinery is needed. `add_domain` registers the *domain*; the org/app `website` field is the *selection*. We expose `set_app_domain` and `set_org_domain` as thin convenience wrappers that just PATCH the Forgejo `website` and trigger the re-host path. This is the cleanest possible model and it's already 90% built.
85
86 ## Implementation (phased, per-repo)
87
88 ### PR 1 — gitops: public ACME issuer + cert-manager egress (plat/gitops)
89
90 New component `clusters/local/platform/letsencrypt/` with a DNS-01 `ClusterIssuer`. Add to `platform/kustomization.yaml`. It carries a sops secret (DNS provider API token), so if put behind its own Flux Kustomization it needs a `decryption` block — but `platform` already has one (`flux-kustomizations.yaml:38`), so adding it under `platform/` is simplest.
91
92 `clusters/local/platform/letsencrypt/clusterissuer.yaml` (Cloudflare DNS-01 shown; provider is an open question):
93
94 ```yaml
95 apiVersion: cert-manager.io/v1
96 kind: ClusterIssuer
97 metadata:
98 name: letsencrypt-dns01
99 spec:
100 acme:
101 server: https://acme-v02.api.letsencrypt.org/directory
102 email: platform@<platform-domain> # rewritten by rewrite_identity_domain
103 privateKeySecretRef: { name: letsencrypt-dns01-account-key }
104 solvers:
105 - dns01:
106 cloudflare:
107 apiTokenSecretRef:
108 name: cloudflare-dns-api
109 key: api-token
110 ```
111
112 `clusters/local/platform/letsencrypt/cloudflare-dns-api.yaml` — sops-sealed Secret (sops `.sops.yaml` already matches `clusters/.+\.yaml$` / `^(data|stringData)$`, so `germinate fork_regen_secrets` reseals it under the daughter's age key automatically; `fork_verify_all_sealed` gates it — `lib.sh:207-325`). The DNS-API token value is **platform-identity**, set post-Forgejo like the OAuth secrets (`germinate fork_postforgejo_secrets`), not a sovereign random.
113
114 cert-manager has **no NetworkPolicy** today (its namespace isn't default-deny), so ACME egress to `acme-v02.api.letsencrypt.org` and the DNS API works with no netpol change. **Verify this** in the validation plan (a future netpol could break it silently).
115
116 `platform/kustomization.yaml`: add `- letsencrypt`.
117
118 > Staging note: first ship `spec.acme.server: .../acme-staging-v02...` to avoid Let's Encrypt rate limits during validation, then flip to prod.
119
120 ### PR 2 — mcp: `add_domain` / `verify_domain` tools + issuer selection (plat/mcp)
121
122 **2a. Pure helpers in `domains.ts`** (unit-testable, no I/O — mirrors the existing split):
123
124 ```ts
125 // domains.ts — append a verified line, returning the new file text. Idempotent:
126 // if `<domain>: <owner>` already present, returns text unchanged. Refuses to
127 // REASSIGN a domain owned by a different org (returns {conflict: org}).
128 export function addDomainLine(text: string, domain: string, owner: string):
129 { text: string; status: "added" | "exists" | "conflict"; conflictOrg?: string }
130
131 // The DNS label a tenant must publish. Stable, domain-scoped.
132 export function challengeRecordName(domain: string): string // `_platform-challenge.${domain}`
133
134 // True iff `host` is the platform's own suffix (use internal CA) vs a real
135 // external domain (use ACME). platformSuffix comes from APPS_DOMAIN.
136 export function isPlatformHost(host: string, platformSuffix: string): boolean
137 ```
138
139 `addDomainLine` reuses the existing line-parser shape from `parseDomainRegistry` so the file stays in the exact format that parser expects (insert under `domains:`, two-space indent).
140
141 **2b. Verification token (stateless, option A)** in `server.ts`:
142
143 ```ts
144 // HMAC over a server secret so only the platform can mint a valid token; the
145 // tenant publishes it as a TXT value. No persisted claim state needed.
146 function domainChallengeToken(owner: string, domain: string): string {
147 return "plat-verify=" + createHmac("sha256", PLAT_DOMAIN_SECRET)
148 .update(`${owner}:${domain.toLowerCase()}`).digest("base64url").slice(0, 43);
149 }
150 ```
151
152 `PLAT_DOMAIN_SECRET` = a new sovereign secret in `plat-mcp-secrets.yaml` (random, resealed at germination by `fork_regen_secrets` — it's a per-platform random, not an identity value, so it Just Works through the fork path).
153
154 **2c. The two tools** (`server.tool(...)` in `makeServer`, `server.ts:4138+`):
155
156 ```ts
157 server.tool("add_domain",
158 "Begin claiming a custom domain for an org (Vercel-style). Returns the DNS " +
159 "records to publish. Caller must own/belong to <owner>. No effect until verify_domain.",
160 { owner: z.string(), domain: z.string().describe("apex or subdomain, e.g. acme.com or app.acme.com") },
161 async (args) => run("add_domain", args, () => addDomain(caller, args)));
162
163 server.tool("verify_domain",
164 "Verify a pending custom-domain claim by checking its DNS TXT challenge, then " +
165 "register it (writes plat/gitops domains.yaml) and trigger TLS issuance.",
166 { owner: z.string(), domain: z.string() },
167 async (args) => run("verify_domain", args, () => verifyDomain(caller, args)));
168 ```
169
170 `addDomain` (pure, no gitops write):
171 ```ts
172 async function addDomain(caller, {owner, domain}) {
173 const d = hostFromWebsite(domain);
174 if (!d) throw new Error("not a DNS-safe host");
175 const token = domainChallengeToken(owner, d);
176 const apex = d.split(".").slice(-2).join(".");
177 return {
178 domain: d,
179 records: [
180 { type: "TXT", name: challengeRecordName(d), value: token,
181 purpose: "ownership verification" },
182 { type: d === apex ? "A" : "CNAME",
183 name: d, value: d === apex ? "<platform-edge-ip>" : `<platform-domain>.`,
184 purpose: "route traffic to the platform" },
185 { type: "CNAME", name: `_acme-challenge.${d}`,
186 value: `_acme-challenge.${d}.<platform-acme-delegation>`,
187 purpose: "delegate Let's Encrypt DNS-01 (optional, if not using a shared DNS-API token)" },
188 ],
189 next: `Publish the TXT record, then call verify_domain(${owner}, ${d}).`,
190 };
191 }
192 ```
193
194 `verifyDomain` (the only gitops-writing tool):
195 ```ts
196 import { resolveTxt } from "node:dns/promises";
197 async function verifyDomain(caller, {owner, domain}) {
198 const d = hostFromWebsite(domain);
199 const want = domainChallengeToken(owner, d);
200 let txts: string[][];
201 try { txts = await resolveTxt(challengeRecordName(d)); }
202 catch { throw new Error(`no TXT at ${challengeRecordName(d)} yet — DNS may still be propagating`); }
203 const flat = txts.map(parts => parts.join(""));
204 if (!flat.includes(want))
205 throw new Error(`TXT challenge mismatch at ${challengeRecordName(d)}; expected ${want}`);
206
207 // Verified. Append to domains.yaml via the standard gitops clone+push path.
208 const work = mkdtempSync(join(tmpdir(), "plat-mcp-"));
209 sh("git", ["clone","-q",GITOPS_REMOTE,work]);
210 const p = join(work, "clusters/local/domains.yaml");
211 const res = addDomainLine(readFileSync(p,"utf8"), d, owner);
212 if (res.status === "conflict")
213 throw new ForbiddenError(`'${d}' already registered to org '${res.conflictOrg}'`);
214 if (res.status === "added") {
215 writeFileSync(p, res.text);
216 sh("git",["-C",work,"add","clusters/local/domains.yaml"]);
217 sh("git",["-C",work,"-c","user.email=plat@mcp","-c","user.name=plat-mcp",
218 "commit","-q","-m",`domains: register ${d} -> ${owner} (verified TXT, via MCP)\n\nCaller: ${caller.login}`]);
219 gitopsPush(work);
220 }
221 domainRegistryCache = null; // bust the 60s cache (server.ts:1699)
222 audit({ event: "domain.verified", owner, host: d, result: "success" });
223 return { domain: d, owner, registered: res.status,
224 next: `create_app(${owner}, <name>, domain: "${d}") or set the org/app website to use it.` };
225 }
226 ```
227
228 **2d. Issuer selection in the overlay.** Thread an `issuer` into `EnvSpec`/`overlayManifests`, computed once where the host is resolved:
229
230 ```ts
231 const issuer = isPlatformHost(prodHost, APPS_DOMAIN) ? "plat-local-ca-issuer" : "letsencrypt-dns01";
232 ```
233 and in `overlayManifests` cert block (`server.ts:2329`) replace the hardcoded `name: plat-local-ca-issuer` with `name: ${spec.issuer}`. Because the cert is rendered by the *same single-source path*, the re-host path (`renderGitopsOverlay:2856`) recomputes it for free when a live app moves onto a public domain.
234
235 **2e. authorize() allowlist** (`server.ts:818`): add `case "add_domain":` and `case "verify_domain":` to the owner/member-gated block (`:819-853`) so they get the identical `owner === caller.login || caller.orgs.includes(owner)` check. **This is the security gate** — without it the default branch fail-closes (`:864`), but we want them explicitly owner-scoped, not admin-only.
236
237 **2f. CoreDNS split-horizon rewrite.** For in-cluster components to reach the custom host (and to mirror the `app.shipits.app` precedent at `coredns-custom.yaml:22`), `verify_domain` should also append `rewrite name exact <d> traefik.traefik.svc.cluster.local` to `platform/coredns/coredns-custom.yaml` in the same gitops commit. **Honest caveat:** that file is a static `data:` block (not templated), and a naive append per domain will grow unbounded and risks merge races with germination's identity rewrite. **v1 recommendation:** only add the rewrite for the *apex* of a newly-registered domain, and only when an app is actually deployed there (do it in the existing overlay/re-host commit, not in `verify_domain`), keeping `verify_domain`'s write surface to `domains.yaml` alone. This is an open question (below).
238
239 ### PR 3 — mcp: `set_app_domain` / `set_org_domain` convenience wrappers (plat/mcp)
240
241 Thin tools that PATCH `repo.website` / `org.website` (the *selection*, validated against the now-registered domain) and then call `resolveAppHost` + `renderGitopsOverlay` — i.e. reuse the **existing re-host path** (`server.ts:2811`) verbatim. No new deploy logic. Owner-gated identically. This is what makes a *live* app move onto a freshly-verified domain in one call. Could be folded into PR 2; split for review clarity.
242
243 ### PR 4 — mitosis toolkit: germination wiring (plat/mitosis)
244
245 - The new `PLAT_DOMAIN_SECRET` sovereign random + `cloudflare-dns-api` sealed secret flow through `fork_regen_secrets`/`fork_verify_all_sealed` automatically (they match the sops regex) — **verify** no new code is strictly needed, but add them to any explicit secret manifest list the fork path enumerates.
246 - The `letsencrypt` ClusterIssuer `email:` and any `<platform-domain>` token get rewritten by `rewrite_identity_domain` (`lib.sh:42`) since they contain the seed domain — confirm dot-escape safety holds (the `plat-local-ca-tls` survival gate at `:55` already guards the dangerous case).
247 - **Default issuer for daughters:** a purely-local k3d daughter has no public DNS and should keep `plat-local-ca-issuer`. Gate the ACME path behind the same `CUTOVER_TUNNEL`-style opt-in (`germinate:445`) so a local germination doesn't try to ACME-issue against Let's Encrypt and fail. This is the cleanest reuse of the existing public-edge opt-in.
248
249 ## Validation plan
250
251 1. **Unit (bun test, mcp/src):** `domains.test.ts` already exists — extend it. `addDomainLine` (added/exists/conflict, format round-trips through `parseDomainRegistry`), `challengeRecordName`, `isPlatformHost` (platform suffix → true, `acme.com` → false), `domainChallengeToken` determinism. No cluster needed.
252 2. **Verification negative paths:** `verify_domain` with no TXT, wrong TXT, TXT for a *different* org's token → all reject; a domain already owned by another org → `ForbiddenError` conflict.
253 3. **End-to-end on a k3d cold-start (the depth-2 validation flow in MEMORY):**
254 - Pick a real test domain with DNS-API access (or stub `resolveTxt` against a local resolver carrying a `_platform-challenge` TXT).
255 - `add_domain(acme, acme-test.example)` → publish the returned TXT → `verify_domain` → assert the `domains.yaml` line appears via `gitops` and `loadDomainRegistry` (cache busted) now returns it.
256 - `create_app(acme, blog, domain: "acme-test.example")` → assert overlay Ingress host = `acme-test.example`, cert `issuerRef.name = letsencrypt-dns01`, `BETTER_AUTH_URL`/redirect all = `https://acme-test.example` (the single-host invariant).
257 - With staging ACME: assert the `Certificate` goes `Ready` and the served leaf chains to Let's Encrypt staging (`kubectl get certificate -n acme--blog--prod`; `openssl s_client`).
258 - **Re-host:** `set_app_domain` an existing live app onto the new domain → assert `renderGitopsOverlay` took the re-host branch (`currentHost !== host`), OAuth redirect re-pointed, no second OAuth orphan.
259 4. **cert-manager egress:** confirm cert-manager can reach Let's Encrypt + the DNS API with the current (absent) netpol; document that adding a default-deny netpol to `cert-manager` later must include this egress.
260 5. **Germination survival:** germinate a daughter; assert (a) the tenant `acme-test.example` line survives the identity rewrite untouched, (b) `plat-local-ca-tls` survives (`lib.sh:55` gate), (c) the daughter without `CUTOVER_TUNNEL` defaults custom-domain apps to the internal CA and doesn't attempt ACME.
261
262 ## Tradeoffs & alternatives considered
263
264 - **Stateless HMAC token (chosen) vs a `domain-claims.yaml` pending registry.** Stateless needs no storage and survives restarts; the cost is no "pending domains" listing for a UI. Acceptable for v1 — the registry file already isn't a UI.
265 - **DNS-01 ACME (chosen) vs HTTP-01.** DNS-01 needs DNS-API credentials but works before cutover, supports apex + wildcard, and reuses the ownership-proof channel. HTTP-01 is simpler to credential but has the routing chicken-and-egg and no wildcard. We pick DNS-01.
266 - **`add_domain` grants capability, not deployment (chosen).** The alternative — having `add_domain` write Ingress/cert directly — would duplicate the single-source host logic and is the exact drift the BYOD design eliminated. Keeping the write to `domains.yaml` only, and letting `resolveAppHost` recompute, preserves the invariant for free.
267 - **Reuse `create_app`'s re-host path (chosen) vs a new "move domain" code path.** The re-host machinery (`renderGitopsOverlay:2811`, OAuth re-point) already exists and is battle-tested; a new path would re-derive it and risk OAuth orphans.
268 - **TXT verification value as HMAC vs a random UUID.** HMAC needs no storage; a random UUID would. HMAC's only weakness is that anyone who learns `PLAT_DOMAIN_SECRET` can forge tokens — but that secret already guards far more, and the token alone grants nothing without also passing `authorize()`'s owner check.
269 - **Deferred (gold-plating):** automatic cloudflared tunnel-rule API writes (today's tunnel is dashboard-managed, `lib.sh:568`), wildcard public certs, a `list_domains`/`remove_domain` lifecycle, a pending-domains UI, apex A-record IP automation, automatic CoreDNS rewrite per domain.
270
271 ## Risks / things I'm unsure of
272
273 - **Whether daughters should inherit a parent's registered custom domains.** A tenant's `acme.com` line survives germination (it's not the seed domain). For a fork that's a *different* operator, inheriting `acme.com: acme` as governance truth may be wrong — the daughter's `acme` org may be a different entity. May need a germination scrub of non-seed domain lines. Flagged as an open question.
274 - **CoreDNS rewrite growth/merge-race** (PR 2f) — the unbounded static append is the weakest part; v1 minimizes it.
275 - **cert-manager has no netpol today** — relying on that is implicit; a future hardening PR could silently break ACME.
276 - **MCP doing a live `resolveTxt`** adds an outbound DNS dependency to a tool call; needs a sane timeout and the mcp namespace netpol must permit DNS egress (it already resolves Forgejo, so likely fine — verify).
277
278
279 ## Open questions
280
281 - DNS provider for the ACME DNS-01 solver: assume Cloudflare (a shared platform-managed API token), or require each tenant to delegate _acme-challenge via CNAME to a platform-controlled zone (no shared credential, but more tenant setup)? This drives PR 1's secret and PR 2's add_domain instructions.
282 - Should a germinated daughter inherit the parent's registered custom-domain lines in domains.yaml? They survive the seed-domain identity rewrite untouched, but a fork is often a different operator whose 'acme' org may be a different entity — do we scrub non-seed domains at germination, or treat the registry as inherited governance?
283 - Verification state: stateless HMAC token (v1 recommendation, no storage) vs a persisted domain-claims.yaml pending list that a future UI can show. Is a pending-domains listing a near-term requirement?
284 - CoreDNS split-horizon rewrite per custom domain: append to the static coredns-custom.yaml data block (grows unbounded, merge-races germination's rewrite) or only emit it from the app overlay/re-host commit for the apex when an app actually deploys there? v1 leans to the latter — confirm acceptable.
285 - Apex routing: do we standardize on a single platform edge IP (A record) for apexes, or require tenants to use a subdomain (CNAME-able) and reserve apex support for later? Vercel pushes users to CNAME via www + apex redirect; mirror that?
286 - Let's Encrypt rate limits during validation: ship the issuer pointed at acme-staging first and flip to prod after the cold-start E2E passes — agreed as the rollout order?
287 - Lifecycle: do we need remove_domain / list_domains in v1, or is add/verify sufficient (removal = maintainer edits domains.yaml, as today)?
docs/proposals/self-serve-secrets-vault.md added +320
@@ -0,0 +1,320 @@
1 ## Problem
2
3 Issue #2: *"users should be able to configure their own environment variables. Use Vault (HashiCorp) as the backend."*
4
5 Today an app gets env vars from exactly three platform-minted, sops-sealed secrets that the MCP overlay renders into `clusters/local/apps/<owner>/<app>/<env>/` and wires onto the pod via the app chart's `extraEnvFrom` / `envFrom`:
6
7 - `app-auth` — OAuth + better-auth signing (`mcp/src/server.ts:2419-2431`, mounted at `_app-template/charts/app/templates/deployment.yaml:57-59`)
8 - `<name>-pg-app` — CNPG-generated Postgres creds, gated on `database` (`server.ts:2077-2081`)
9 - `<name>-s3` — MinIO creds, gated on `bucket` (`server.ts:2489-2508`, `2082-2086`)
10
11 There is **no path for a user to set their own** `STRIPE_KEY`, `OPENAI_API_KEY`, etc. The two ways they *could* do it today are both wrong: commit it to their repo (plaintext), or ask an admin to hand-edit gitops (doesn't scale, and the value would have to be sops-sealed to a key only the platform holds).
12
13 The platform's existing secret model is **sops-age**: every secret in `clusters/local/**.yaml` is encrypted under the cluster's age recipient (`.sops.yaml` `path_regex clusters/.+\.yaml$`, `encrypted_regex ^(data|stringData)$`), and germinate's sovereignty invariant is built entirely on it — fork mints a fresh age key, **reseals every platform secret** to it (`lib.sh fork_regen_secrets` ~207-317), and `fork_verify_all_sealed` (~325) is a hard gate that refuses to ship a daughter unless every sops file decrypts under the fork key.
14
15 **This proposal does NOT touch that.** Replacing sops with Vault would mean rewriting the entire fork-sealing invariant and the cold-start handoff. Instead Vault sits **alongside** sops as a *user-facing* secret store: platform-internal secrets stay sops-sealed in gitops; **user-supplied app env vars** live in Vault KV and are synced into the app namespace as an ordinary k8s Secret that the existing `extraEnvFrom` contract already consumes — so `_app-template` needs **zero changes**.
16
17 The genuinely hard part, and the bulk of this doc, is **init + unseal of a single-node, self-hosted Vault with no cloud KMS**, done in a way that survives germination and pod restarts without a human typing unseal keys.
18
19 ---
20
21 ## Design (from first principles)
22
23 ### What "self-serve env var" must mean here
24
25 1. A user (org member, gated by the same `authorize()` check every app tool uses — `server.ts:843-852`) calls an MCP tool to set `KEY=VALUE` for their app.
26 2. The value is written to Vault KV v2 at a path scoped to that app: `apps/<org>/<app>`.
27 3. A controller in the app's namespace reads **only that path** and projects it into a k8s `Secret`.
28 4. The app chart's existing `extraEnvFrom` forwards that Secret onto the pod's env.
29 5. The plaintext value is **never** committed to gitops, never sops-sealed, never in the user's repo.
30
31 This cleanly separates **platform identity secrets** (sops, sovereign, regenerated on fork) from **user data secrets** (Vault, mutable at runtime, never in git).
32
33 ### Why a sync-to-k8s-Secret seam (not direct Vault reads from the app)
34
35 The app contract is fixed and dumb on purpose: the pod reads env vars, full stop (`_app-template/charts/app/templates/deployment.yaml:57-68`). We must not require every app to embed a Vault SDK or an agent sidecar. So the platform-side job is: **Vault KV → k8s Secret in `<org>--<app>--<env>` → `extraEnvFrom`.** The app sees a normal Secret.
36
37 ### VSO vs ESO — recommend Vault Secrets Operator (VSO)
38
39 | | Vault Secrets Operator (VSO) | External Secrets Operator (ESO) |
40 |---|---|---|
41 | Vendor alignment | HashiCorp-native; CRD `VaultStaticSecret` maps 1:1 to a KV path | Generic; `SecretStore` + `ExternalSecret` |
42 | k8s-auth | First-class `VaultAuth`/`VaultConnection` CRs | Works, but more glue |
43 | Validated here | chart `hashicorp/vault-secrets-operator 1.4.0`, app 1.4.0, MPL-2.0 (see Validation) | not templated |
44 | Footprint | one Deployment + 10 CRDs | one Deployment + CRDs |
45
46 **Recommendation: VSO.** The issue names HashiCorp explicitly, VSO is the first-party answer, and `VaultStaticSecret` is the exact primitive we need (poll a KV v2 path, write a Secret). ESO is the fallback if VSO's licensing/maintenance ever becomes a concern (it's a CNCF project).
47
48 ### The unseal problem (the hard part), and the chosen strategy
49
50 Vault boots **sealed**: its storage (Raft, here) is encrypted with a master key that is itself Shamir-split into unseal keys, and Vault holds none of them at rest. On every pod start something must feed it unseal key shares. On a self-hosted single node there is **no cloud KMS** to auto-unseal against. The options:
51
52 - **Dev mode (`server.dev.enabled`)** — boots unsealed, in-memory. **Rejected:** loses all data on restart; the issue is about *durable* user secrets. Non-starter.
53 - **Transit auto-unseal** — Vault A auto-unseals Vault B via Transit. **Rejected:** circular (who unseals A?), and doubles the Vault footprint on a 16GB platform.
54 - **Cloud KMS auto-unseal (awskms/gcpckms)** — **Rejected:** the platform is sovereign and self-hosted; there is no cloud dependency by design (mirrors why germinate mints its own age key rather than using cloud KMS).
55 - **Shamir unseal, keys sealed into the platform's existing trust root (sops-age), replayed by an unseal Job.** **Chosen.**
56
57 **Chosen strategy — germinate-driven one-time init, sops-sealed unseal material, Job-driven unseal on every restart:**
58
59 1. **One-time init (germinate or first-boot Job):** after the Vault StatefulSet is `Running` but sealed, run `vault operator init -key-shares=1 -key-threshold=1 -format=json`. (Shares=1 is deliberate for a single-operator sovereign platform — see Tradeoffs; multi-share buys nothing when one operator holds all shares anyway.) This yields one unseal key + a root token, **printed exactly once**.
60 2. **Seal that material into gitops via sops**, reusing the *exact* mechanism fork already uses — write a k8s Secret manifest and `sops --encrypt --in-place` it (the `_fork_seal` helper in `lib.sh`). The unseal key + root token become an ordinary sops-sealed secret `vault-init` in the `vault` namespace. Now it is sovereign: encrypted under the cluster age key, regenerated/owned exactly like every other platform secret.
61 3. **Unseal on every pod start:** a small `vault-unseal` Job (or a `postStart`/init flow) reads the sops-decrypted `vault-init` Secret (Flux decrypts it; it lands as a normal k8s Secret in-cluster) and runs `vault operator unseal <key>`. Because Raft data persists on the PVC, the *same* master key always unseals it. The Job is idempotent: if Vault is already unsealed it's a no-op; a CronJob or a Flux health-check retry covers pod restarts.
62
63 This is honestly the most complex piece of the whole feature, and I'm flagging it as the #1 risk. It introduces a **stateful, must-init-exactly-once** component into an otherwise stateless, declarative platform. The mitigations: init is guarded (check seal status first; never re-init a Vault with existing Raft data — re-init would orphan the data), and the unseal material rides the existing sops trust root so germination handles it for free.
64
65 ### k8s auth + per-app least privilege
66
67 VSO authenticates to Vault using Vault's **Kubernetes auth method**: it presents its ServiceAccount JWT, Vault calls TokenReview (the chart wires the `system:auth-delegator` ClusterRoleBinding — validated below), and issues a short-lived Vault token bound to a **policy**. We bind a policy that reads **only** `apps/data/<org>/<app>` (KV v2 stores data under `.../data/...`), per app, so a compromised app namespace can't read another tenant's secrets. The per-app `VaultAuth`/role is rendered by the MCP overlay alongside the `VaultStaticSecret` (concrete shapes in Implementation).
68
69 ### Reserved-key collisions (must avoid)
70
71 User-set keys land in the **same pod env** as platform keys. The MCP tool MUST reject keys that collide with platform-reserved env, because `extraEnvFrom` ordering would let a user shadow (or be shadowed by) platform values unpredictably. Reserved set, read from the live overlay:
72
73 - `app-auth` keys: `FORGEJO_CLIENT_ID`, `FORGEJO_CLIENT_SECRET`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, `FORGEJO_BASE` (`server.ts:2426-2430`)
74 - `<name>-pg-app` (CNPG discrete keys): `PGUSER`, `PGPASSWORD`, `PGHOST`, `PGPORT`, `PGDATABASE`, `DATABASE_URL`/`*_URI` (CNPG-generated)
75 - `<name>-s3` keys: `S3_ENDPOINT`, `S3_REGION`, `S3_BUCKET`, `S3_ACCESS_KEY`, `S3_SECRET_KEY` (`server.ts:2502-2506`)
76 - Anything starting with `PLAT_`, `MCP_`, `NODE_EXTRA_CA_CERTS` (chart-owned).
77
78 `set_secret` validates `^[A-Z][A-Z0-9_]*$` and rejects the reserved set.
79
80 ---
81
82 ## Implementation (per-repo, phased PRs)
83
84 ### Phase 1 — gitops: the Vault platform component (PR to `plat/gitops`)
85
86 New directory `clusters/local/platform/vault/` mirroring the monitoring component shape (`platform/monitoring/` — namespace + helmrepository + helmrelease + ingress + cert + netpol + kustomization). Files:
87
88 **`vault/namespace.yaml`**
89 ```yaml
90 apiVersion: v1
91 kind: Namespace
92 metadata:
93 name: vault
94 labels:
95 plat.sh/managed: "true"
96 plat.sh/tier: system
97 ```
98
99 **`vault/helmrepository.yaml`**
100 ```yaml
101 apiVersion: source.toolkit.fluxcd.io/v1
102 kind: HelmRepository
103 metadata: { name: hashicorp, namespace: vault }
104 spec:
105 interval: 1h
106 url: https://helm.releases.hashicorp.com
107 ```
108
109 **`vault/helmrelease.yaml`** — the validated values (see Validation; chart 0.33.0, image pinned to `1.18.5`):
110 ```yaml
111 apiVersion: helm.toolkit.fluxcd.io/v2
112 kind: HelmRelease
113 metadata: { name: vault, namespace: vault }
114 spec:
115 interval: 30m
116 chart:
117 spec:
118 chart: vault
119 version: ">=0.33.0 <0.34.0"
120 sourceRef: { kind: HelmRepository, name: hashicorp, namespace: vault }
121 interval: 30m
122 install: { remediation: { retries: 3 } }
123 upgrade: { remediation: { retries: 3 } }
124 timeout: 8m
125 values:
126 global:
127 enabled: true
128 tlsDisable: true # in-cluster only; Traefik terminates edge TLS
129 server:
130 image: { repository: hashicorp/vault, tag: "1.18.5" } # last MPL-2.0 line
131 ha:
132 enabled: true
133 replicas: 1
134 raft:
135 enabled: true
136 setNodeId: true
137 config: |
138 ui = true
139 listener "tcp" {
140 address = "[::]:8200"
141 cluster_address = "[::]:8201"
142 tls_disable = 1
143 }
144 storage "raft" { path = "/vault/data" }
145 service_registration "kubernetes" {}
146 dataStorage: { enabled: true, size: 1Gi, storageClass: local-path }
147 auditStorage: { enabled: false }
148 affinity: "" # single node: drop default anti-affinity
149 resources:
150 requests: { cpu: 50m, memory: 128Mi }
151 limits: { cpu: 500m, memory: 256Mi }
152 injector: { enabled: false } # we use VSO, not the sidecar injector
153 ui: { enabled: true, serviceType: ClusterIP }
154 csi: { enabled: false }
155 ```
156
157 **`vault/ingress.yaml` + `vault/certificate.yaml`** — optional UI at `vault.open-platform.sh`, exactly mirroring `monitoring/ingress.yaml` + `monitoring/certificate.yaml` (Traefik entrypoints, `plat-local-ca-issuer` ClusterIssuer, `vault-platform-tls`). **Recommend deferring the public ingress in v1** — Vault need only be reachable in-cluster by VSO and MCP. Ship cert/ingress in a follow-up.
158
159 **`vault/netpol.yaml`** — default-deny ingress, then allow from `vault-secrets-operator-system` and `mcp` namespaces to port 8200, and intra-namespace (Raft 8201). Mirror `monitoring/netpol.yaml` structure.
160
161 **`vault/unseal-job.yaml`** — idempotent unseal (runs `vault status`; if sealed, `vault operator unseal`), reading the sops-sealed `vault-init` Secret. Plus a `vault-bootstrap` Job that, *only if Vault reports uninitialized*, enables the KV v2 mount `apps/`, enables k8s auth, and writes the VSO role/policy. Gated so it never re-inits an initialized Vault.
162
163 **`vault/vault-init.yaml`** — the sops-sealed Secret carrying `unseal_key` + `root_token`. **Placeholder committed by Phase-1; real values minted by germinate** (see Phase 3), exactly like `plat-mcp-secrets` ships with `PLACEHOLDER` values that fork fills later (`lib.sh` ~270-300).
164
165 **`vault/kustomization.yaml`** — lists the above.
166
167 **Flux wiring (`clusters/local/flux-kustomizations.yaml`):** Vault and VSO each get their **own** `Kustomization` (not folded into `platform`), because VSO ships **10 CRDs** (validated: `vaultstaticsecrets.secrets.hashicorp.com` et al.) that must exist before any app overlay references a `VaultStaticSecret`. Add, mirroring the `monitoring` block (which already carries a `decryption` block for its sops secret):
168
169 ```yaml
170 ---
171 apiVersion: kustomize.toolkit.fluxcd.io/v1
172 kind: Kustomization
173 metadata: { name: vault, namespace: flux-system }
174 spec: { interval: 5m, path: ./clusters/local/platform/vault, prune: true, wait: true, timeout: 8m,
175 decryption: { provider: sops, secretRef: { name: sops-age } },
176 dependsOn: [{ name: cert-manager }], sourceRef: { kind: GitRepository, name: flux-system } }
177 ---
178 apiVersion: kustomize.toolkit.fluxcd.io/v1
179 kind: Kustomization
180 metadata: { name: vault-secrets-operator, namespace: flux-system }
181 spec: { interval: 5m, path: ./clusters/local/platform/vault-secrets-operator, prune: true, wait: true, timeout: 8m,
182 dependsOn: [{ name: vault }], sourceRef: { kind: GitRepository, name: flux-system } }
183 ```
184
185 And the **`apps` Kustomization must gain `dependsOn: { name: vault-secrets-operator }`** so app overlays that render a `VaultStaticSecret` don't apply before its CRD exists (today `apps` dependsOn `platform` + `pki` — `flux-kustomizations.yaml` last block).
186
187 **`clusters/local/platform/vault-secrets-operator/`** — VSO component: namespace, `helmrepository.yaml` (reuse hashicorp), `helmrelease.yaml` (chart `vault-secrets-operator >=1.4.0 <1.5.0`, with `install.crds: CreateReplace`), and a cluster-scoped default `VaultConnection` pointing at `http://vault.vault.svc.cluster.local:8200`.
188
189 ### Phase 2 — mcp: the tool surface (PR to `plat/mcp`)
190
191 **New env keys** in `plat-mcp-secrets` (gitops `mcp/plat-mcp-secrets.yaml`, surfaced via `envFrom: secretRef: plat-mcp-secrets` at `mcp/charts/mcp/templates/deployment.yaml:28`):
192 - `VAULT_ADDR=http://vault.vault.svc.cluster.local:8200`
193 - `VAULT_TOKEN` — a **scoped** Vault token (policy: create/update/read/delete on `apps/data/*` + manage roles), NOT the root token. Minted by the bootstrap Job and sops-set by germinate alongside the other `plat-mcp-secrets` placeholders (`lib.sh` ~270-300 already seals this Secret with `PLACEHOLDER` slots — add three keys there).
194
195 The `mcp` namespace has **no netpol dir** (verified: `platform/mcp/` has no netpol), so MCP egress to Vault is already open.
196
197 **New file `mcp/src/tools/secrets.ts`** — thin Vault KV v2 client over `fetch` (matches the codebase's `fj()` pattern; no new SDK):
198 ```ts
199 // Vault KV v2 path: secret data lives under apps/data/<org>/<app>; metadata under apps/metadata/<org>/<app>
200 const KV_MOUNT = "apps";
201 const vaultPath = (owner: string, name: string) => `${KV_MOUNT}/data/${owner}/${name}`;
202
203 const RESERVED = new Set([
204 "FORGEJO_CLIENT_ID","FORGEJO_CLIENT_SECRET","BETTER_AUTH_SECRET","BETTER_AUTH_URL","FORGEJO_BASE",
205 "PGUSER","PGPASSWORD","PGHOST","PGPORT","PGDATABASE","DATABASE_URL",
206 "S3_ENDPOINT","S3_REGION","S3_BUCKET","S3_ACCESS_KEY","S3_SECRET_KEY",
207 ]);
208 const KEY_RE = /^[A-Z][A-Z0-9_]*$/;
209 function validateKey(k: string) {
210 if (!KEY_RE.test(k)) throw new Error(`invalid env key '${k}' (must match ${KEY_RE})`);
211 if (RESERVED.has(k) || k.startsWith("PLAT_") || k.startsWith("MCP_") || k === "NODE_EXTRA_CA_CERTS")
212 throw new Error(`'${k}' is a platform-reserved env var and cannot be overridden`);
213 }
214 ```
215
216 **Three new `server.tool(...)` registrations** (alongside the others at `server.ts:4138+`), each calling `authorize(caller, tool, args)` first (`server.ts:4096`):
217
218 - `set_secret({ owner, name, key, value })` → validateKey; KV v2 read-modify-write of `apps/<org>/<app>` (merge, don't clobber siblings); returns `{ ok, names: [...] }`.
219 - `list_secrets({ owner, name })` → returns **names only**, never values: `{ names: string[] }`.
220 - `delete_secret({ owner, name, key })` → read-modify-write removing the key.
221
222 **`authorize()` allowlist** (`server.ts:818-867`) — add the three tool names to the owner/org-member-gated `case` block (the same one `create_app` etc. use, `server.ts:819-843`); the default branch fails closed, so they MUST be added or non-admins get `unknown tool`. They reuse the identical `owner === caller.login || caller.orgs.includes(owner)` check (`server.ts:844-852`).
223
224 **`overlayManifests()` (`server.ts:1924`)** — render a per-app `VaultStaticSecret` + a per-app `VaultAuth`, and append the synced Secret to `extraEnvFrom`. The synced Secret name is `<name>-env`. Add a gated block mirroring `dbBlock`/`bucketBlock` (`server.ts:1935`, `2055`), and extend `helmExtraEnvFrom` (`server.ts:2087-2091`):
225
226 ```ts
227 // always rendered (a user can add secrets to any app); VSO writes <name>-env even if empty.
228 const vaultBlock = `---
229 apiVersion: secrets.hashicorp.com/v1beta1
230 kind: VaultAuth
231 metadata: { name: ${name}-auth, namespace: ${ns} }
232 spec:
233 method: kubernetes
234 mount: kubernetes
235 kubernetes:
236 role: ${owner}-${name} # per-app role -> policy reads only apps/data/${owner}/${name}
237 serviceAccount: default
238 audiences: ["vault"]
239 ---
240 apiVersion: secrets.hashicorp.com/v1beta1
241 kind: VaultStaticSecret
242 metadata: { name: ${name}-env, namespace: ${ns} }
243 spec:
244 type: kv-v2
245 mount: apps
246 path: ${owner}/${name}
247 refreshAfter: 60s
248 vaultAuthRef: ${name}-auth
249 destination:
250 name: ${name}-env # the k8s Secret VSO writes; consumed by extraEnvFrom
251 create: true
252 `;
253 ```
254 ```ts
255 const vaultEnvFrom = ` - secretRef:
256 name: ${name}-env
257 optional: true
258 `;
259 const helmExtraEnvFrom =
260 ` extraEnvFrom:
261 ${dbEnvFrom}${bucketEnvFrom}${vaultEnvFrom}`; // always present now
262 ```
263 `optional: true` means the pod still starts before VSO has written the Secret on first deploy. `_app-template` consumes this unchanged (`deployment.yaml:66 {{- with .Values.extraEnvFrom }}`).
264
265 The `vaultBlock` is inserted into the returned manifest string next to `${dbBlock}${bucketBlock}` (`server.ts:2319`). Since VSO writes into the **app** namespace, also add an **egress allow** from the app namespace to the `vault` namespace? — **No:** VSO (in `vault-secrets-operator-system`) reads Vault and writes the Secret via the apiserver; the **app pod never talks to Vault**. So no app-side netpol change is needed. (This is a real advantage of the sync-to-Secret seam over an agent sidecar.)
266
267 **Per-app Vault role/policy provisioning** — when `create_app` first provisions an app, MCP must also create the Vault k8s-auth role `<owner>-<name>` bound to a policy granting read on `apps/data/<owner>/<name>`, scoped to the app's ServiceAccount in `<owner>--<name>--*`. Add this to the create flow (`server.ts` step 7 region, ~1225) via the same `secrets.ts` client (`vault write auth/kubernetes/role/...` + `vault policy write`). Idempotent.
268
269 ### Phase 3 — mitosis: germinate sealing + bootstrap + profile (PR to `plat/mitosis`)
270
271 - **`bin/germinate` ~25-35:** add `: "${VAULT:=0}"` (opt-in, mirroring how `MONITORING`/`full` profile is the toggle). Thread through `up`/`mitosis` usage like the existing `PROFILE` var.
272 - **Lean profile (`lib.sh trim_gitops_lean` ~71-129):** Vault is **opt-in**, so the default lean profile **removes** `platform/vault` + `platform/vault-secrets-operator` and drops their two `flux-kustomizations.yaml` blocks (same as it already `rm -rf`s `monitoring`/`grafana`/`pgadmin` and rewrites `flux-kustomizations.yaml`). When `VAULT=1`, skip that removal.
273 - **Sovereign sealing (`lib.sh fork_regen_secrets` ~207-317):** Vault's unseal material is **generated at init time, not fork time** (you can't pre-seal a key Vault hasn't minted yet). So instead of sealing it in `fork_regen_secrets`, germinate seals it **post-Forgejo**, in the same window where `fork_postforgejo_secrets`/`fork_register_oauth` run (after Flux has stood up the real platform and Vault has come up). New `fork_bootstrap_vault`:
274 1. port-forward `svc/vault` (like the existing `fj_pf` Forgejo port-forward).
275 2. if `vault status` says uninitialized: `vault operator init -key-shares=1 -key-threshold=1 -format=json`.
276 3. `unseal` with the key.
277 4. enable `apps/` KV v2 + `kubernetes` auth + write the MCP-scoped policy/token.
278 5. `_fork_seal` the `vault-init` Secret (`unseal_key`,`root_token`) and `sops set` the three new `plat-mcp-secrets` keys (`VAULT_ADDR`,`VAULT_TOKEN`, and the bootstrap confirmation) — using the **exact** `_fork_seal`/`sops set` mechanics already in `lib.sh`. Then `git commit` + push so Flux reconciles a now-unsealable Vault.
279 6. `fork_verify_all_sealed` (~325) automatically covers the new `vault-init.yaml` since it globs every `clusters/**` sops file — **a real win: the existing gate proves the Vault material is sovereign-sealed for free.**
280
281 This keeps the **whole** sovereignty story intact: a forked daughter mints its **own** Vault, its **own** unseal key, sealed to its **own** age key. No parent Vault state crosses the boundary.
282
283 ### Phase 4 — _app-template: nothing
284
285 The `extraEnvFrom` contract already exists (`charts/app/values.yaml:74`, `deployment.yaml:66`, `worker-deployment.yaml:54`, `migrate-job.yaml:62`). **No PR needed.** This is the design paying off.
286
287 ---
288
289 ## Validation plan
290
291 **Already run (helm available, repo added):**
292
293 - `helm repo add hashicorp https://helm.releases.hashicorp.com` → ok. Chart versions: `vault 0.33.0` (app `2.0.2`), VSO `1.4.0` (app `1.4.0`).
294 - `helm template vault hashicorp/vault --version 0.33.0 -f vault-values.yaml` → **exit 0, no errors.** Rendered: `StatefulSet replicas: 1`, `image: hashicorp/vault:1.18.5`, PVC `storage: 1Gi` / `storageClassName: local-path`, Raft `storage "raft"` + `service_registration "kubernetes"` + `tls_disable = 1` present, **injector Deployment absent** (`vault-agent-injector` count = 0), `ClusterRoleBinding vault-server-binding` present (the `system:auth-delegator` binding k8s-auth needs).
295 - The chart's only `kind: Pod` is `templates/tests/server-test.yaml` carrying `"helm.sh/hook": test` → **Flux never applies test hooks during reconciliation**, so no values change is needed to suppress it. (Confirmed the annotation; `server.tests.enabled=false` is NOT a real chart knob — don't put it in values.)
296 - `helm template vso hashicorp/vault-secrets-operator --version 1.4.0` → exit 0; ships **10 `*.secrets.hashicorp.com` CRDs** incl. `vaultstaticsecrets`, justifying VSO's own `wait: true` Kustomization before `apps`.
297 - `helm show crds hashicorp/vault-secrets-operator --version 1.4.0` → `VaultStaticSecret` is group `secrets.hashicorp.com`, fields `mount`/`path`/`type`/`destination`/`vaultAuthRef` — matches the overlay shape above.
298
299 **To run on a live colima/k3d cluster before merge:**
300
301 1. `MONITORING`-style opt-in germinate with `VAULT=1`; assert `vault-0` pod reaches `Running`, `vault status` shows initialized+unsealed, `fork_verify_all_sealed` passes with `vault-init.yaml` in the set.
302 2. Kill the `vault-0` pod; assert the unseal Job re-unseals it and the `<name>-env` Secret survives (Raft persistence).
303 3. `create_app` an app, `set_secret(FOO=bar)`, assert (a) Vault KV has it, (b) VSO writes `<name>-env` Secret in the app ns, (c) the pod env contains `FOO=bar` after rollout, (d) `list_secrets` returns `["FOO"]` with no value.
304 4. Negative: `set_secret(BETTER_AUTH_SECRET=x)` → rejected; non-org-member caller → `ForbiddenError`; app B's VaultAuth role cannot read app A's path.
305 5. **Capstone:** fold into the depth-2 cold-start (task #10) — original→daughter, daughter germinated with `VAULT=1`, run all of (1)-(4) on the daughter to prove sovereignty.
306
307 ---
308
309 ## Tradeoffs & alternatives considered
310
311 - **Vault vs just more sops.** sops can already hold arbitrary secrets, and a "user-set env" could in principle be a `sops set` into a per-app secret. **Rejected** because (a) it would require a write path into gitops on every `set_secret` (commit + push + Flux reconcile latency, plus the user's plaintext momentarily in the MCP process writing a git commit), and (b) the issue explicitly says Vault. Vault gives a mutable runtime store with audit, versioned KV, and per-path policy that sops-in-git can't.
312 - **Shamir shares=1 vs >1.** Shares>1 (e.g. 3-of-5) is the production HA story, but on a single-operator sovereign platform all shares end up sealed under the *same* age key anyway, so >1 buys no real separation-of-duties — only more material to manage and more ways for the unseal Job to fail. v1: shares=1. A multi-operator deployment can raise it.
313 - **VSO vs ESO** — covered above; VSO chosen for HashiCorp-alignment, ESO is the documented fallback.
314 - **Image pin `1.18.5` (MPL-2.0) vs `2.0.2` (BSL).** Vault relicensed to BSL at 1.15+/2.0. For a self-replicating "grow your own platform" project, shipping a BSL binary in the seed has redistribution implications. v1 pins the **last MPL-2.0 line**. The clean long-term answer may be **OpenBao** (the LF fork of Vault, MPL-2.0, drop-in API-compatible) — VSO and the KV/k8s-auth API are identical. Flagged as an open question, not decided here.
315 - **Sync-to-Secret vs Vault Agent sidecar vs CSI.** Sidecar/CSI would require `_app-template` changes and app-pod→Vault egress. The VSO sync keeps the app contract untouched and the app pod Vault-unaware. Chosen.
316 - **Public Vault UI ingress.** Deferred from v1; in-cluster reachability is all the feature needs. Adds attack surface for no v1 benefit.
317
318 ---
319
320 ## Open questions

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs