Public: anyone can read this; nobody can change it.
docs: design proposals for issues #2, #3, #4, #5 #9
Check · Review · Land · Live — No review yet.
docs/design-proposals → main 1 commit
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.
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) | #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) | #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) | #4 | BYO upstream IdP (Entra/OIDC) at `mitosis up`, auto-syncing users + teams into Forgejo (the identity hub). | | |
| 19 | | [](./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="" | |
| 87 | BYO_IDP_CLIENT_ID="" | |
| 88 | BYO_IDP_CLIENT_SECRET="" | |
| 89 | : ""; : "" | |
| 90 | : ""; : "" | |
| 91 | : ""; : "" | |
| 92 | : ""; : "" | |
| 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="" DOMAIN="" \ | |
| 100 | \ | |
| 101 | \ | |
| 102 | \ | |
| 103 | \ | |
| 104 | \ | |
| 105 | \ | |
| 106 | \ | |
| 107 | "/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 "" ]; then | |
| 121 | _fork_seal "" "/forgejo/idp-oidc-secret.yaml" forgejo-idp-oidc forgejo "" << | |
| 122 | IDP_ISSUER: "" | |
| 123 | IDP_CLIENT_ID: "" | |
| 124 | IDP_CLIENT_SECRET: "" | |
| 125 | IDP_NAME: "" | |
| 126 | IDP_PROVIDER: "" | |
| 127 | IDP_SCOPES: "" | |
| 128 | IDP_GROUP_CLAIM: "" | |
| 129 | IDP_GROUP_TEAM_MAP: '' | |
| 130 | IDP_ADMIN_GROUP: "" | |
| 131 | IDP_GROUP_TEAM_REMOVAL: "" | |
| 132 | ||
| 133 | git -C "" add -A | |
| 134 | git -C "" -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 "" ] || { echo " (no IDP_ISSUER — skipping upstream IdP wiring)"; return 0; } | |
| 153 | local disco="/.well-known/openid-configuration" | |
| 154 | local extra=() | |
| 155 | [ -n "" ] && extra+=(--admin-group "") | |
| 156 | [ "" = true ] && extra+=(--group-team-map-removal) | |
| 157 | # add-oauth creates; if a source with this name exists, update-oauth instead. | |
| 158 | if kubectl -n "" exec "" -c forgejo -- forgejo admin auth list 2>/dev/null | grep -qiw ""; then | |
| 159 | local id; id= | |
| 160 | kubectl -n "" exec "" -c forgejo -- forgejo admin auth update-oauth --id "" \ | |
| 161 | --key "" --secret "" --auto-discover-url "" \ | |
| 162 | --scopes "" --group-claim-name "" \ | |
| 163 | --group-team-map "" "" | |
| 164 | else | |
| 165 | kubectl -n "" exec "" -c forgejo -- forgejo admin auth add-oauth \ | |
| 166 | --name "" --provider "" \ | |
| 167 | --key "" --secret "" --auto-discover-url "" \ | |
| 168 | --scopes "" --group-claim-name "" \ | |
| 169 | --group-team-map "" "" | |
| 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 "" ]; then | |
| 181 | echo "==> wiring Forgejo as an OIDC relying party to " | |
| 182 | # FGPOD discovered by the runner-register loop (germinate ~368); reuse it. | |
| 183 | fork_wire_idp "" "" \ | |
| 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)). | |
| 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--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 |