> ## Documentation Index
> Fetch the complete documentation index at: https://recipe.uselettuce.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# RFC 001 self hosted offering

# RFC-001 — Self-hosted Lettuce

**Status:** Draft (foundations PR — LET-15)
**Author:** LET-15
**Decision owner:** Founder
**Related issue:** [LET-15](https://linear.app/.../LET-15)

## TL;DR

Customers want to run Lettuce in their own infrastructure. This RFC scopes
what "self-hosted" means for v1, picks an IP-protection model, picks a
telemetry model, and inventories the cloud-provider assumptions we have
to undo. It explicitly **does not** ship a working self-hosted product;
it lays foundations so follow-up issues (LET-15.1 … LET-15.N) can land
incrementally.

The user prompt was "think about it properly" — this RFC takes that as
permission to scope honestly. Several product decisions are still open
and called out at the bottom for the founder.

***

## 1. What "self-hosted" means in v1

Three flavours, pick one default:

|                       | Customer-cloud (Kubernetes / Compose)      | BYOC managed                                      | Air-gapped                                                          |
| --------------------- | ------------------------------------------ | ------------------------------------------------- | ------------------------------------------------------------------- |
| Where it runs         | Customer's AWS / GCP / Azure / on-prem k8s | Customer cloud, we operate via cross-account role | Customer datacenter, zero egress                                    |
| Updates               | Customer pulls new image tags              | We deploy                                         | Customer downloads tarballs offline                                 |
| Telemetry             | Optional call-home                         | Always-on                                         | Never                                                               |
| Cost to ship v1       | Low                                        | High (control plane)                              | Very high (offline license server, mirror registry, signed bundles) |
| Likely first customer | Mid-market eng team that already has k8s   | Regulated SaaS that wants us responsible          | Defense / finance                                                   |

**Recommendation:** v1 = **customer-cloud Kubernetes + Docker Compose**,
with optional metadata call-home. BYOC and air-gap come later when a
customer is paying for them. Going air-gap on day one would force us
to ship a license server, mirror registry, signed bundles, and offline
docs — none of which we have today.

## 2. IP-protection model

The user's framing: "Need to make sure our IP is not visible, it should
be provided by docker containers / helm charts."

Two options:

### Option A — License-key-gated source in container images (recommended)

Ship the Python source inside Docker images. On boot, the worker /
API verify a signed license key against our public key (bundled in
the image). Without a valid key, the service starts in "trial /
locked" mode and refuses to index repos.

* Pros: minimal infra change; we keep iterating in Python; image
  rebuild = release. Standard B2B precedent: GitLab Premium, Sentry,
  Posthog, Mattermost.
* Cons: source is readable inside the container. Anti-tamper is
  best-effort — a determined customer can patch the binary. Mitigation:
  the license-key check is a deterrent + a contract clause, not a DRM
  guarantee.

### Option B — Compiled / obfuscated binaries

Cython, Nuitka, or rewrite hot paths in Go. Heavy lift, fragile,
slows iteration, increases support load (stack traces become opaque,
debugging customer issues becomes a nightmare).

**Not recommended for v1.** Consider only if a single large customer
demands it under contract.

### Decision: **Option A.**

License key format (sketch): a compact JWT signed with our Ed25519
private key, claims `{iss, sub: license_id, customer_id, tier, seats,
repo_cap, exp, features: ["self-hosted", "sso", ...]}`. Image bundles
the public key. The worker / API verify on boot and re-verify hourly.
Expired keys flip the service into read-only mode after a 14-day grace.

## 3. Telemetry / pricing model

The user named two camps:

### Metadata-only call-home (default)

The worker pings `https://telemetry.uselettuce.dev/v1/heartbeat` once
per day with:

```json theme={null}
{
  "instance_id": "uuid",
  "license_id": "lic_...",
  "version": "1.4.2",
  "repo_count": 47,
  "user_count": 12,
  "seat_count": 12,
  "tokens_saved_24h": 184293,
  "uptime_hours": 412
}
```

No repo names, no source, no user emails. This is what Datadog Self,
Sentry Self, GitLab Premium, Posthog Self all do. It gives us:

* Renewal triggers (we know when a license is about to lapse).
* Product analytics (which versions are in the wild, churn signals).
* Per-seat billing without trusting the customer's reporting.

Customers can disable it (env var + admin UI toggle); when disabled,
the license downgrades to "manual renewal" tier and we contact them
on the calendar instead.

### Pure license key, no call-home

License key encodes tier + seats + repo cap + expiry. Image enforces
locally. Renewal is manual — we re-issue a new key on payment. Zero
data leaves the customer.

This is what regulated customers will demand (defense, finance,
healthcare).

### Decision: ship **both**, default to call-home.

Pricing is **based on the license key** (it encodes the tier and the
seat / repo cap). Call-home is for renewals, product analytics, and
optional usage-based billing. Customers who turn off call-home accept
fixed-tier pricing and manual renewals.

## 4. Cloud-agnostic gap inventory

The user: "Should support cloud agnostic implementation."

What's already portable:

* **Object storage** — `src/codewaze/service/storage.py` uses boto3
  against an S3-compatible endpoint, with MinIO already supported via
  `CODEWAZE_S3_ENDPOINT_URL`. AWS S3, Cloudflare R2, Backblaze B2,
  MinIO, on-prem Ceph all work as-is. **No change needed for v1.**
* **Postgres** — plain `DATABASE_URL`. Works on RDS, Cloud SQL,
  Supabase, plain Postgres. **No change needed.**
* **Job queue** — uses Postgres `LISTEN/NOTIFY` (see
  `src/codewaze/service/jobs.py`), not SQS / PubSub / SB. Portable
  by construction.

What is **not** portable yet — each becomes a follow-up issue:

| Gap                                                    | Where                                                                    | Follow-up issue                                                                                                                                                                                                    |
| ------------------------------------------------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Supabase auth assumed for user JWTs                    | `src/codewaze/service/supabase_auth.py`, `config.py`                     | LET-15.2 abstract `AuthVerifier` (Supabase, OIDC, static JWKS)                                                                                                                                                     |
| Stripe is the only billing path                        | `src/codewaze/service/billing.py`, `metering.py`                         | LET-15.3 license-key billing path (no Stripe) for self-hosted                                                                                                                                                      |
| GitHub App on github.com                               | `src/codewaze/service/providers/github_app.py`, `config.py:github_app_*` | LET-15.4 document customer-side GitHub App registration; allow GHES base URL                                                                                                                                       |
| GitLab uses gitlab.com                                 | `src/codewaze/service/providers/gitlab.py`                               | LET-15.5 GitLab self-hosted base URL config — **shipped (LET-21):** `GITLAB_HOST` (canonical) + `GITLAB_BASE_URL` (legacy) drive OAuth + API + clone routing. See §11.                                             |
| Bitbucket uses bitbucket.org                           | `src/codewaze/service/providers/bitbucket.py`                            | LET-15.6 Bitbucket Data Center base URL config — **partial (LET-22):** URLs honour `BITBUCKET_HOST`/`BITBUCKET_FLAVOR` but the client still speaks Cloud's v2.0 shape; LET-22.1 will land the real DC v1.0 client. |
| No abstraction over secrets backend (env-only)         | everywhere `os.environ.get(...)`                                         | LET-15.7 optional `SecretStore` (env, Vault, AWS SM, GCP SM) — low priority                                                                                                                                        |
| `CODEWAZE_PUBLIC_URL` assumes a single public hostname | `config.py:public_url`, webhook URL builder                              | LET-15.8 **shipped (LET-24)** — `LETTUCE_WEB_URL` + `LETTUCE_MCP_URL` (fallback to `CODEWAZE_PUBLIC_URL`) so split-domain self-hosted can put MCP behind its own ingress                                           |
| Logs go to stdout — no shipper                         | `logging_config.py`                                                      | LET-15.9 optional OpenTelemetry exporter (OTLP)                                                                                                                                                                    |

**Do not refactor these in this PR.** Each gets its own issue. The
fact that storage and queue are already abstracted is a big head start.

## 5. What ships in the self-hosted deliverable

| Component                                                                                                                          | v1 status                                                                                    |
| ---------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| Docker images: `web`, `api` (codewaze-cloud), `worker` (codewaze-worker), `ingestor` (currently lives in worker — may split later) | Reuse existing `Dockerfile` + `web/Dockerfile`; tag and publish under `ghcr.io/uselettuce/*` |
| `docker-compose.yml` for single-node demo + small prod                                                                             | This PR (scaffold)                                                                           |
| Helm chart `lettuce/` with Deployments + Services + Secret + Ingress                                                               | This PR (scaffold only — values stubs, no PVCs / HPA yet)                                    |
| Admin UI screen: enter license key, show status, toggle telemetry                                                                  | LET-15.10 (separate PR)                                                                      |
| `/self-hosted` docs section                                                                                                        | This PR (placeholder) + LET-15.11 (real install guide)                                       |
| `/self-hosted` marketing page                                                                                                      | LET-15.12 — placeholder route only; copy + design is a marketing task                        |
| License key issuance tool (internal CLI)                                                                                           | LET-15.13                                                                                    |
| Telemetry receiver endpoint + dashboard                                                                                            | LET-15.14                                                                                    |

## 6. What stays SaaS-only in v1

* Managed multi-tenancy (self-hosted is single-tenant per deployment).
* Hosted Stripe billing (self-hosted bills via license + invoice).
* Our centrally-managed GitHub App on github.com (self-hosted customers
  register their own App against their GitHub instance — GHES or .com).
* Our Supabase project for user auth (self-hosted brings its own
  OIDC or runs its own Supabase / GoTrue).
* Auto-deploys via Render / Vercel; self-hosted updates by image tag.

## 7. Open product decisions for the founder

1. **Pricing model.** Per-seat with a repo cap (recommended, mirrors
   SaaS), per-LOC, flat tier, or hybrid? Default recommendation:
   per-seat tiers (Team 10, Team 25, Enterprise unlimited) with a
   bundled repo cap proportional to seats.
2. **Minimum self-hosted tier.** Do we sell to teams of 5, or only
   Enterprise (25+)? Self-hosted has higher support cost than SaaS
   — gating at 25 seats keeps the support load sane until we have an
   ops team.
3. **SLA / support model.** Community Slack only? Business-hours
   email? Paid 24x7? Recommendation: business-hours email at the
   Team tier, named CSM + 24x7 at Enterprise.
4. **Air-gap support.** Ship in year 2 once we have a paying
   defense / finance customer, or never? Recommendation: gate behind
   an explicit "Air-gap Enterprise" SKU and a paid PoC, do not build
   speculatively.
5. **Customer GitHub App.** Force the customer to register their own
   App, or offer "Lettuce-managed App" that talks to their GitHub for
   them (would require outbound network from us to their VPC, ugly)?
   Recommendation: customer registers their own App. Document it well.
6. **Telemetry default opt-in vs opt-out.** Recommended: opt-out
   (i.e. on by default, env-var to disable) so we get product
   analytics from the majority. Some legal teams will flip this.
7. **License key revocation.** Online revocation requires call-home
   to be mandatory. If a customer pirates a key, do we (a) accept
   the loss for the offline tier, or (b) refuse to sell an
   offline-capable license below Enterprise? Recommendation: (b).

## 8. Roadmap after this RFC

LET-15.1 Replace placeholder Dockerfiles in `self-hosted/` with the
real per-service builds (reuse root `Dockerfile` for api / worker;
reuse `web/Dockerfile`). Publish to `ghcr.io/uselettuce/*`.

**Build / release supply-chain controls (LET-37).** Every tag-build
image published by `.github/workflows/compiled-image.yml` and
`.github/workflows/publish-images.yml` is signed with **cosign
keyless** (Sigstore OIDC — no private keys to rotate), ships an
**SPDX-JSON SBOM** produced by `syft` (uploaded as a workflow
artifact + attested to the image), and is scanned by `grype` with
SARIF posted to the GitHub Security tab. Verification one-liner +
SBOM-fetch instructions live in
[install.mdx §9 — Supply chain](./install.mdx#9-supply-chain-let-37).
Not yet exercised end-to-end (the `uselettuce` GitHub org does not
exist yet); first real run is on the next tag push after the org is
created.

LET-15.2 Abstract `AuthVerifier` — Supabase, OIDC, static JWKS.

LET-15.3 License-key billing path; bypass Stripe when self-hosted
mode is on.

LET-15.4 Document customer-side GitHub App registration; allow GHES
base URL in config. **Done (LET-20).** See §10 for the customer
runbook and `GITHUB_HOST` env reference.

LET-15.5 GitLab self-hosted base URL config. **Done (LET-21).** See
§11 for the customer runbook and `GITLAB_HOST` env reference.

LET-15.6 Bitbucket Data Center base URL config. **Partial (LET-22).**
Config plumbing only — URLs honour `BITBUCKET_HOST` + `BITBUCKET_FLAVOR`,
but the client still speaks Cloud's REST v2.0 shape against the DC
`/rest/api/1.0` surface. See §13 for the partial-state runbook;
LET-22.1 tracks the real DC REST client.

LET-15.7 Optional `SecretStore` abstraction.

LET-15.8 Split web-facing URL from MCP-facing URL.

LET-15.9 OpenTelemetry exporter for logs / metrics.

LET-15.10 Admin UI: license key entry + status + telemetry toggle.

LET-15.11 Self-hosted install guide (`docs/self-hosted/install.mdx`).

LET-15.12 Marketing page `/self-hosted` (copy + design + form).

LET-15.13 Internal license-issuance CLI (sign Ed25519 JWTs).

LET-15.14 Telemetry receiver endpoint + ops dashboard.

LET-15.15 Bootstrap onboarding (`lettuce init` CLI that pulls images,
generates secrets, writes `.env` + admin user).

## 9. Non-goals (v1)

* Multi-region self-hosted.
* HA Postgres / Redis (customer's problem — we document our
  expectations).
* A managed-control-plane "BYOC" offering.
* Air-gapped install.
* Custom-model bring-your-own-embeddings.
* Re-skinnable / white-label UI.

## 10. GitHub Enterprise Server (GHES) support (LET-20)

Self-hosted Lettuce can talk to GitHub Enterprise Server instead of
github.com — the customer registers their own GitHub App on their GHES
instance and points Lettuce at it. SaaS behaviour is unchanged: with no
GHES env vars set, every code path defaults back to github.com.

### Env vars

| Variable                                                                                                         | Default      | Where used                                                                                                                                                                                                              |
| ---------------------------------------------------------------------------------------------------------------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GITHUB_HOST`                                                                                                    | `github.com` | Backend — drives the REST API base, App install URL, clone URL, and stored canonical git URL. Self-hosted set this to the customer's GHES hostname (e.g. `github.acme.com`).                                            |
| `NEXT_PUBLIC_GITHUB_HOST`                                                                                        | `github.com` | Web — drives placeholder URLs in the add-repo dialog and any "Open in GitHub"-style links. Mirror of `GITHUB_HOST` for the browser bundle. Must be set at build time on the Vercel/Docker build for self-hosted images. |
| `GITHUB_APP_ID`, `GITHUB_APP_PRIVATE_KEY`, `GITHUB_APP_NAME`, `GITHUB_APP_CLIENT_ID`, `GITHUB_APP_CLIENT_SECRET` | none         | The four creds for the App the customer registers on their GHES instance. Same shape as on github.com — just registered against a different host.                                                                       |

The backend composes API calls off these helpers in
`src/codewaze/service/config.py`:

* `github_host()` — returns `github.com` or the configured GHES host.
* `github_api_url()` — `https://api.github.com` on SaaS,
  `https://<host>/api/v3` on GHES (GHES uses the `/api/v3` prefix on
  the host itself; there is no `api.<host>` subdomain).
* `github_web_url()` — `https://<github_host>`, used for install URLs
  (`/apps/<slug>/installations/new`) and the canonical clone URL
  (`https://<host>/owner/repo.git`).

### Customer runbook — register a GitHub App on GHES

1. **Create the App.** Navigate to
   `https://<your-ghes>/settings/apps/new` (org-level Apps live at
   `https://<your-ghes>/organizations/<org>/settings/apps/new`).

2. **Callback URL.** Set to:

   ```
   <LETTUCE_WEB_URL>/api/v1/integrations/github/oauth/callback
   ```

   `LETTUCE_WEB_URL` is the public URL of your Lettuce web app.

3. **Webhook URL.** Set to:

   ```
   <LETTUCE_WEB_URL>/api/v1/integrations/github/webhook
   ```

   Webhook secret: leave empty. Lettuce per-account secrets are
   provisioned at repo-add time and registered on the repo's own hook
   row, not the App-level hook (the App-level hook is unused).

4. **Permissions.** Match the SaaS App exactly:

   | Scope                      | Access       |
   | -------------------------- | ------------ |
   | Repository — Contents      | Read         |
   | Repository — Metadata      | Read         |
   | Repository — Webhooks      | Read & Write |
   | Repository — Pull requests | Read         |
   | Repository — Issues        | Read         |

   "Webhooks: Write" is what lets Lettuce auto-register the per-repo
   push webhook at add-repo time — without it the user has to wire up
   the webhook by hand from the manual-webhook card.

5. **Subscribe to events.** `Push`, `Pull request`, `Issues`.

6. **Where can this App be installed.** "Only on this account" is the
   safer default — opens up to any org if needed later.

7. **After creation:** download the private key (PEM), copy the App
   ID, the App slug (URL path segment), and the OAuth client id +
   client secret. Paste those into the four `GITHUB_APP_*` env vars on
   the backend, and set `GITHUB_HOST=<your-ghes-host>` + `NEXT_PUBLIC_GITHUB_HOST=<your-ghes-host>`.

8. **Install the App** on whichever org's repos Lettuce should index.
   From the Lettuce UI, hit *Add repo → GitHub* — the install URL
   composes off `GITHUB_HOST` and bounces you to the right GHES screen.

## 11. Self-hosted GitLab support (LET-21)

Self-hosted Lettuce can talk to a customer-run GitLab instance instead
of gitlab.com — the customer registers their own GitLab Application
(Admin Area → Applications, or User Settings → Applications for a
personal Lettuce install) and points Lettuce at it. SaaS behaviour is
unchanged: with no GitLab env vars set, every code path defaults back
to gitlab.com.

### Env vars

| Variable                                           | Default      | Where used                                                                                                                                                                                                                                         |
| -------------------------------------------------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GITLAB_HOST`                                      | `gitlab.com` | Backend — drives the OAuth authorize + token URLs, the REST API base, the clone URL, and the install-meta `host`. Self-hosted set this to the customer's GitLab hostname (e.g. `gitlab.acme.com`). Canonical knob.                                 |
| `GITLAB_BASE_URL`                                  | *(unset)*    | Backend — legacy / spec-named override. Accepts a full URL (`https://gitlab.acme.com`) or a bare host. Parsed for its host part; `GITLAB_HOST` wins if both are set. Kept for operators on existing deploys whose env file already names this var. |
| `NEXT_PUBLIC_GITLAB_HOST`                          | `gitlab.com` | Web — drives placeholder URLs in the add-repo dialog and any "Open in GitLab"-style links. Mirror of `GITLAB_HOST` for the browser bundle. Must be set at build time on the Vercel/Docker build for self-hosted images.                            |
| `GITLAB_APP_CLIENT_ID`, `GITLAB_APP_CLIENT_SECRET` | none         | The OAuth Application credentials the customer registers on their GitLab. Same shape as on gitlab.com — just registered against a different host.                                                                                                  |

The backend composes API calls off these helpers in
`src/codewaze/service/config.py`:

* `gitlab_host()` — returns `gitlab.com` or the configured self-hosted
  host. Honours both `GITLAB_HOST` (preferred) and `GITLAB_BASE_URL`
  (legacy / spec-named, full-URL form).
* `gitlab_api_url()` — `https://<host>/api/v4` (GitLab keeps the API
  on the same host for both SaaS and self-hosted, unlike github.com).
* `gitlab_web_url()` — `https://<gitlab_host>`, used for OAuth
  authorize / token endpoints and as the host part of the clone URL.
* `gitlab_base_url()` — backwards-compatible alias for
  `gitlab_web_url()`, kept so older call-sites and stored install
  metadata stay readable.

### Customer runbook — register a GitLab Application on self-hosted

1. **Create the Application.** Navigate to:

   ```
   https://<your-gitlab>/admin/applications/new
   ```

   (Admin Area path. Use `https://<your-gitlab>/-/user_settings/applications`
   instead if Lettuce is a personal install and only needs your user's
   visible projects.)

2. **Name.** `Lettuce` (or whatever you want to show users on the
   OAuth consent screen).

3. **Redirect URI.** Set to:

   ```
   <LETTUCE_PUBLIC_URL>/v1/integrations/gitlab/callback
   ```

   `LETTUCE_PUBLIC_URL` is the public URL of your Lettuce backend
   (`CODEWAZE_PUBLIC_URL`). The OAuth callback lives on the backend,
   not the web app, because the response carries a one-time code that
   needs to be exchanged server-side.

4. **Trusted.** Off (default) — Lettuce shows users the consent screen
   on first connect. Switch to *Trusted* only if your operator policy
   says so; nothing in Lettuce requires it.

5. **Scopes.** Tick exactly:

   | Scope              | Why                                                             |
   | ------------------ | --------------------------------------------------------------- |
   | `api`              | OAuth-bound REST endpoints (projects list, hook register).      |
   | `read_repository`  | Minimum git-over-HTTPS clone scope.                             |
   | `write_repository` | Required to register the per-account push webhook on a project. |

   Skipping `write_repository` makes the auto-webhook-register call
   fail; the repo still gets added but customers have to wire the
   hook up by hand from the manual-webhook card.

6. **After creation:** GitLab shows the *Application ID* and *Secret*
   once. Copy both. Paste them into the backend env:

   ```
   GITLAB_APP_CLIENT_ID=<application id>
   GITLAB_APP_CLIENT_SECRET=<secret>
   GITLAB_HOST=<your-gitlab-host>
   ```

   And on the web build:

   ```
   NEXT_PUBLIC_GITLAB_HOST=<your-gitlab-host>
   ```

7. **Connect from Lettuce.** *Add repo → GitLab* → *Connect GitLab*.
   The install URL composes off `GITLAB_HOST` and bounces the browser
   to the right self-hosted GitLab. After consent, GitLab redirects
   back to the Lettuce backend's `/v1/integrations/gitlab/callback`,
   which swaps the code for a token, persists the install row, and
   sends the user back to `/repos` ready to pick a project.

8. **Picker access.** The picker only shows projects the connecting
   user has at least *Developer* access to (anything lower can't push
   or register webhooks, so adding them would just set the user up to
   fail at clone-time). To see a wider list, give the user a higher
   role on those projects in GitLab.

## 12. SecretStore backends (LET-23 and follow-ups)

Lettuce reads secret-bearing config (DB password, Stripe key, Supabase
service role, GitHub App private key, Anthropic key) through a
`SecretStore` abstraction so operators on managed secret tooling don't
have to dump credentials into env vars. Pick a backend with
`LETTUCE_SECRET_STORE` — default `env` preserves SaaS behaviour.

| `LETTUCE_SECRET_STORE` | Backend                                                                                            | Backend-specific env                                                                                                                                                                                                                                                                                                                                                                          |
| ---------------------- | -------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `env` (default)        | `os.environ` — same as before LET-23.                                                              | —                                                                                                                                                                                                                                                                                                                                                                                             |
| `vault`                | HashiCorp Vault KV v2 (LET-23, requires `pip install codewaze[vault]`).                            | `VAULT_ADDR` (required), one of `VAULT_TOKEN` or `VAULT_ROLE_ID` + `VAULT_SECRET_ID`, `LETTUCE_VAULT_MOUNT` (default `secret`).                                                                                                                                                                                                                                                               |
| `aws`                  | AWS Secrets Manager via boto3 (LET-32).                                                            | `LETTUCE_AWS_SECRETS_REGION` (preferred) or `AWS_REGION`. Credentials come from boto3's standard chain — env vars, shared profile, or the IAM role attached to the EC2 / ECS / EKS task.                                                                                                                                                                                                      |
| `gcp`                  | GCP Secret Manager via google-cloud-secret-manager (LET-33, requires `pip install codewaze[gcp]`). | `LETTUCE_GCP_PROJECT` (preferred) or `GOOGLE_CLOUD_PROJECT` — one MUST be set; there is no global default and we fail fast at startup rather than blast a confusing 404 from the API. Credentials come from Application Default Credentials — `GOOGLE_APPLICATION_CREDENTIALS` env, `gcloud auth application-default login`, GKE / Cloud Run workload identity, or GCE / GKE metadata server. |

Notes on `aws`:

* The store keys map 1:1 to Secrets Manager `SecretId` values. Either
  store one secret per key (e.g. `STRIPE_SECRET_KEY`) or wrap a
  multi-key JSON blob in a `SecretString` and shape an upstream
  loader on top — Lettuce only reads `SecretString`, falling back to
  `SecretBinary` decoded as UTF-8.
* `ResourceNotFoundException` surfaces as `SecretNotFound`; any other
  client error (auth, throttling, network) propagates so callers don't
  silently substitute defaults during an outage.
* No new optional extra: boto3 is already a runtime dep (the S3 client
  uses it), so `aws` is available out of the box.

Notes on `gcp`:

* The store keys map 1:1 to Secret Manager secret names; each `get`
  fetches the `latest` version (`projects/<project>/secrets/<key>/versions/latest`).
  Operators who pin to a specific version should do that upstream
  (e.g. via Terraform), not in Lettuce.
* `google.api_core.exceptions.NotFound` surfaces as `SecretNotFound`;
  any other client error (permission denied, deadline exceeded, network)
  propagates so callers don't silently substitute defaults during an
  outage.
* Authentication uses Application Default Credentials, so all the
  standard GCP credential paths Just Work — `GOOGLE_APPLICATION_CREDENTIALS`
  pointing at a service-account JSON, an interactive `gcloud auth
  application-default login`, GKE workload identity (the recommended
  prod path), Cloud Run / Cloud Functions service identity, and the
  GCE metadata server all resolve through the same chain. The store
  holds no credential state of its own.
* `[gcp]` is an optional extra because `google-cloud-secret-manager`
  pulls grpc + google-auth + protobuf transitives that the SaaS image
  doesn't need and doesn't want as attack surface.

## 13. Bitbucket Data Center support (LET-22) — partial

Self-hosted Lettuce can be **pointed at** a Bitbucket Data Center
instance instead of the public `bitbucket.org` SaaS. Unlike §10 (GHES)
and §11 (GitLab self-hosted), **DC support is partial in this
iteration** — only the URL / host plumbing is wired. The provider client
in `providers/bitbucket.py` still speaks Bitbucket Cloud's REST v2.0
shape; DC exposes the same conceptual surface under v1.0 (different
paths, different request / response JSON, different OAuth flow), so
calls sent against a DC instance in Cloud's shape will mostly 4xx. The
real DC REST v1.0 client is tracked under **LET-22.1**.

What ships in LET-22:

* The `BITBUCKET_HOST` + `BITBUCKET_FLAVOR` env vars + four helpers
  (`bitbucket_flavor`, `bitbucket_host`, `bitbucket_api_url`,
  `bitbucket_web_url`) in `src/codewaze/service/config.py`.
* Every Cloud-vs-DC call site in `providers/bitbucket.py` (OAuth
  authorize URL, token exchange, `/user` lookup, `/workspaces`,
  `/repositories/...`, webhook register, clone URL) routes through the
  helpers — so the host part is honest.
* A loud WARN log on every Bitbucket API call when `BITBUCKET_FLAVOR=dc`,
  explaining that the request/response shapes still need translation
  and pointing at LET-22.1.

What does **not** ship in LET-22 (tracked as LET-22.1):

* Bitbucket Data Center request/response translation (DC's
  `/rest/api/1.0` uses different field names — `slug`/`project.key`/
  `name` on repos, `values`/`isLastPage`/`nextPageStart` pagination,
  etc.).
* DC's distinct OAuth flow (`/plugins/servlet/oauth/...`) and the
  Personal Access Token alternative.
* DC webhook payload parsing (events keyed differently, branch info
  shaped differently than Cloud's `push.changes`).

SaaS behaviour is unchanged: with no `BITBUCKET_FLAVOR` set the default
is `cloud`, every helper resolves to the bitbucket.org URLs, and
existing customers see byte-for-byte the same requests they did before
LET-22.

### Env vars

| Variable                                                 | Default                                    | Where used                                                                                                                            |
| -------------------------------------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- |
| `BITBUCKET_FLAVOR`                                       | `cloud`                                    | Backend — `cloud` (bitbucket.org, REST v2.0) or `dc` (Data Center, REST v1.0). Any other value raises at startup.                     |
| `BITBUCKET_HOST`                                         | `bitbucket.org` (Cloud); **required** (DC) | Backend — the Bitbucket host. On DC there is no sane default, so the helper raises if it's unset when `BITBUCKET_FLAVOR=dc`.          |
| `BITBUCKET_APP_CLIENT_ID`, `BITBUCKET_APP_CLIENT_SECRET` | none                                       | OAuth consumer credentials. Same env-var shape on Cloud and DC; what changes is the URL the customer registered the consumer against. |

The backend composes Bitbucket calls off these helpers:

* `bitbucket_flavor()` — `cloud` or `dc`.
* `bitbucket_host()` — `bitbucket.org` (Cloud) or the configured DC
  hostname.
* `bitbucket_api_url()` — `https://api.bitbucket.org/2.0` on Cloud,
  `https://<host>/rest/api/1.0` on DC.
* `bitbucket_web_url()` — `https://<bitbucket_host>`, used for OAuth
  authorize / token endpoints and as the host part of the clone URL.

### Customer runbook (DC, partial)

The following will get your URLs pointed at the right host, but the API
calls themselves will not yet succeed against a DC instance — the
provider still sends Cloud-v2.0-shaped requests. Wait for LET-22.1
before relying on this in production.

1. On the backend, set:

   ```
   BITBUCKET_FLAVOR=dc
   BITBUCKET_HOST=bitbucket.acme.com
   BITBUCKET_APP_CLIENT_ID=<consumer key from DC>
   BITBUCKET_APP_CLIENT_SECRET=<consumer secret from DC>
   ```

2. Restart the backend. Tail logs and confirm you see the LET-22.1
   warning on every Bitbucket call — that is the expected, documented
   state until LET-22.1 ships.

3. Track LET-22.1 for the real Data Center REST v1.0 client.

***

**Reviewer checklist before this RFC is approved:**

* [ ] Founder has answered the 7 open product decisions in §7.
* [ ] Sales has confirmed the pricing tiers in §7.1.
* [ ] Eng has confirmed the per-service split in §5.
* [ ] Legal has reviewed the telemetry payload in §3.
