Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
**/.env
**/.env.*
!**/.env.example
config.yml
apps/web/config.yml
code-zero.deployment.yml
Dockerfile
.dockerignore
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,7 @@ test-report.junit.xml
# Local editor tooling; never something a checkout should carry.
/code
/vscode_cli.tar.gz
# The instance file (config.yml) where a deployment keeps it locally, and its old name anywhere.
/config.yml
/apps/web/config.yml
code-zero.deployment.yml
!code-zero.deployment.example.yml
21 changes: 10 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ The CLI parses arguments with [`@bomb.sh/args`](https://github.com/bomb-sh/args)
| `/api/v1/**` | The same router over OpenAPI/REST; interactive docs at `/api/v1/docs`, spec at `/api/v1/openapi.json` |
| `/api/auth/**` | The Better Auth handler (mounted by `@onmax/nuxt-better-auth` from `server/auth.config.ts`) |

`/rpc/**` and `/api/v1/**` are the same `rpcRouter` from [`packages/api`](./packages/api) served over two wire protocols, so authorization behaves identically either way. Reads are open for the dashboard; mutations (`tasks.create`, `approvals.decide`) fail closed. `CODE_ZERO_CONTROL_PLANE_TOKENS` holds comma-separated `name:token` bearer credentials β€” the one half of this policy that is a secret, and so the one half still in the environment. Which execution modes each principal may request is `control_plane.modes` in `code-zero.deployment.yml`; without a grant a principal may only request the non-writable `observe` and `suggest` modes, so `fix` and `autonomous` require an explicit operator grant. Which checkouts `tasks.create` may target is neither a secret nor fixed for the life of the process, so it is the `repository` table an operator edits from the dashboard; with none configured every task creation is refused. The approval actor is the authenticated principal's name, never a wire-supplied value. This bearer-token scheme authorizes the control-plane API and is independent of the Better Auth session that protects the dashboard UI.
`/rpc/**` and `/api/v1/**` are the same `rpcRouter` from [`packages/api`](./packages/api) served over two wire protocols, so authorization behaves identically either way. Reads are open for the dashboard; mutations (`tasks.create`, `approvals.decide`) fail closed. `CODE_ZERO_CONTROL_PLANE_TOKENS` holds comma-separated `name:token` bearer credentials β€” the one half of this policy that is a secret, and so the one half still in the environment. Which execution modes each principal may request is `control_plane.modes` in the instance file, `config.yml`; without a grant a principal may only request the non-writable `observe` and `suggest` modes, so `fix` and `autonomous` require an explicit operator grant. Which checkouts `tasks.create` may target is neither a secret nor fixed for the life of the process, so it is the `repository` table an operator edits from the dashboard; with none configured every task creation is refused. The approval actor is the authenticated principal's name, never a wire-supplied value. This bearer-token scheme authorizes the control-plane API and is independent of the Better Auth session that protects the dashboard UI.

Typed clients infer their shape from the router rather than redeclaring request and response types:

Expand Down Expand Up @@ -206,18 +206,17 @@ off until you turn them on, so a fresh deployment cannot be signed up for by a s
| `NUXT_BETTER_AUTH_SECRET` | yes | – | Session signing secret. Required under this name in production; `BETTER_AUTH_SECRET` is a development-only fallback |
| `DATABASE_URL` | yes | – | Postgres connection string |
| `AUTH_ENABLE_SIGNUP` | no | `false` | Set to `true` to allow self-registration |
| `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` | no | – | Enables the GitHub button when both are set |
| `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` | no | – | Enables the GitHub button when both are set; GitLab (`GITLAB_*`) and Google (`GOOGLE_*`) follow the same rule |
| `NUXT_PUBLIC_SITE_URL` | no | – | Base URL override for a custom domain or deterministic OAuth callbacks; auto-detected from the request otherwise |

`AUTH_DATABASE_URL` is still read when `DATABASE_URL` is unset, so a deployment configured before
the store moved into `packages/database` keeps starting; prefer `DATABASE_URL` for new ones.

Sign-in lives at `/login` and self-registration at `/signup`; the methods each page offers are
derived at build time from the same policy variables `server/auth.config.ts` reads at runtime
(`AUTH_ENABLE_SIGNUP`, `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET`), and no runtime override can
change them. The flip side of that build-time capture is a deployment contract: whenever you change
those policy variables, rebuild the app, or the auth pages will keep advertising the old
capabilities (the server still enforces its own policy either way).
Sign-in lives at `/login` and self-registration at `/signup`. The methods each page offers are
read at runtime from `/api/capabilities`, which the server derives from the same policy variables
`server/auth.config.ts` builds Better Auth from, so changing them needs a restart rather than a
rebuild. Every variable is listed, with its default, in `apps/web/.env.example`; the full policy is
described in the [authentication guide](apps/docs/content/1.guide/11.authentication/1.overview.md).

The interface ships English and Italian through `@nuxtjs/i18n`, with dictionaries split by scope in
`packages/i18n/locales/<locale>/` and shared with the marketing site; each app loads only the
Expand Down Expand Up @@ -422,7 +421,7 @@ also tagged with its full commit SHA, and `vX.Y.Z` tags matching the dashboard v
docker build -t code-zero .
docker run --rm -p 3000:3000 --env-file apps/web/.env \
-v code-zero-data:/app/.data \
-v "$PWD/code-zero.deployment.yml:/app/code-zero.deployment.yml:ro" \
-v "$PWD/config.yml:/app/config.yml:ro" \
code-zero
```

Expand All @@ -437,8 +436,8 @@ environment sets. Apply migrations against `DATABASE_URL` with
root needs `RAILWAY_RUN_UID=0`, since the image runs as the unprivileged `node` user).

The image carries only `.output/`, so the deployment policy has to be supplied: mount
`code-zero.deployment.yml` at `/app/code-zero.deployment.yml` as above, or mount it elsewhere and
point `CODE_ZERO_CONFIG` at it. Without it the process falls back to the closed defaults (no CORS
`config.yml` at `/app/config.yml` as above (see [`config.example.yml`](./config.example.yml)), or
mount it elsewhere and point `CODE_ZERO_CONFIG` at it. Without it the process falls back to the closed defaults (no CORS
origins, no `fix` or `autonomous` grants). The image ships `git` for the host runner but no
container engine, so `runner.isolation: container` is not supported by it: tasks that require
container isolation fail closed instead of running on the host. Deploy the `.output/` bundle on a
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/content/1.guide/10.api/4.protect-endpoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ The authenticated principal's **name** (not the token) is what the system record

## Execution-mode grants

Which execution modes a principal may request is deployment policy, so it lives in `code-zero.deployment.yml` (or the path `CODE_ZERO_CONFIG` names) rather than in the environment beside the token itself:
Which execution modes a principal may request is deployment policy, so it lives in the instance file, `config.yml` (or the path `CODE_ZERO_CONFIG` names), rather than in the environment beside the token itself:

```yaml
control_plane:
Expand Down
39 changes: 31 additions & 8 deletions apps/docs/content/1.guide/11.authentication/1.overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ The dashboard UI is protected by [Better Auth](https://better-auth.com), mounted

Authentication follows the adapter rule at the package level:

- **`packages/auth`** holds the policy: `authBetterAuthOptions` builds the database, policy, and provider options Better Auth needs. It has no HTTP server and no runtime imports, and its `./config` subpath stays free of database dependencies so the login page can read feature flags without bundling one.
- **`packages/auth`** holds the policy: `authBetterAuthOptions` builds the database, policy, and provider options Better Auth needs, and `authCapabilities` derives what the sign-in surface may advertise. It has no HTTP server and no runtime imports, and its `./config` and `./access` subpaths stay free of database dependencies.
- **`packages/database`** owns the store: the Drizzle schema, the connection factory, and the checked-in migrations. It knows nothing about sign-in and must never import `packages/auth` β€” see [Database](/guide/database).
- **`apps/web/server/auth.config.ts`** composes that policy into the running instance. It is the only route in the app that resolves the connection string (through `packages/database`) and the signing secret, and therefore the only process that opens a connection to Postgres.

Expand All @@ -24,16 +24,39 @@ Authentication follows the adapter rule at the package level:

## Closed by default

Registration and GitHub OAuth are off until you turn them on, so a fresh deployment cannot be signed up for by a stranger:
Registration and social sign-in are off until you turn them on, so a fresh deployment cannot be signed up for by a stranger:

- `AUTH_ENABLE_SIGNUP=true` enables self-registration at `/signup` (default `false`); with it off, that page shows a closed state and `/login` hides the link to it;
- GitHub OAuth requires both `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET` β€” see [GitHub OAuth](/guide/authentication/oauth).
- `AUTH_ENABLE_SIGNUP=true` enables self-registration at `/signup` (default `false`); with it off, that page shows a closed state, `/login` hides the link to it, and every other method β€” social providers, magic links, SSO β€” signs in existing accounts only;
- each social provider requires both halves of its credential β€” see [OAuth providers](/guide/authentication/oauth).

::warning
**Rebuild after policy changes**
## Read at runtime

The methods the auth pages offer are derived **at build time** from the same policy variables the server reads at runtime, and no runtime override can change them. Whenever you change those variables, rebuild the app β€” the server still enforces its own policy either way, but `/login` and `/signup` will keep advertising the old capabilities until rebuilt.
::
The policy is read when the server starts, not when the app is built. The server publishes what it serves at `GET /api/capabilities` β€” plain booleans, no credential or origin β€” and `/login`, `/signup`, and the navigation render from it. Changing a variable needs a restart, not a rebuild, and a page can never advertise a method the server does not serve.

## Methods and account security

| Variable | Default | Effect |
| ---------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `AUTH_ENABLE_EMAIL_VERIFICATION` | `false` | Sends a verification link on sign-up; needs a mail transport |
| `AUTH_REQUIRE_EMAIL_VERIFICATION` | `false` | Refuses password sign-in for an unverified address; implies sending the link |
| `AUTH_ENABLE_PASSWORD_RESET` | `false` | Offers a reset link by mail; resetting revokes every other session |
| `AUTH_ENABLE_MAGIC_LINK` | `false` | Passwordless sign-in by mail (single-use, hashed, ten minutes) |
| `AUTH_ENABLE_TWO_FACTOR` | `true` | TOTP with backup codes and 30-day trusted devices, issuer "Code Zero" |
| `AUTH_ENABLE_TWO_FACTOR_EMAIL_OTP` | `false` | Also delivers the second factor as a mailed code |
| `AUTH_ENABLE_PASSKEY` | `true` | WebAuthn passkeys, bound to `AUTH_PASSKEY_ORIGIN` or the site URL; off when neither names an origin |
| `AUTH_ENABLE_API_KEYS` | `false` | Organization API keys for CI: `cz_` prefix, stored hashed, 90-day default lifetime, rate limited; needs organizations. Sent as `Authorization: Bearer cz_…`, a key may submit `observe` and `suggest` tasks |

A capability that needs something it does not have β€” a mail transport, a passkey origin β€” stops the server at startup with a message that names what is missing and never a configured value. Passwords are bounded at 8–128 characters; Better Auth 1.7 rejects a longer one with `PASSWORD_TOO_LONG`.

Two-factor and passkeys are on by default because they add no way in: enrolling either needs an existing session and the account's password, and nothing changes for an account that never opts in.

## Administration

The app-wide `user.role` belongs to Better Auth's admin plugin, which every deployment registers. An account with the `admin` role may ban and unban accounts, impersonate one (sessions are attributed with `impersonated_by` and last an hour), and grant roles; the control plane asks the same plugin whether a signed-in account is an administrator rather than reading the role itself.

## Enterprise single sign-on and provisioning

SAML/OIDC single sign-on (`@better-auth/sso`, with domain verification) and SCIM provisioning (`@better-auth/scim`, managed per organization) are registered only when the deployment's edition grants the `sso` or `scim` entitlement. Without it their routes do not exist. SCIM also needs `SCIM_CREDENTIAL_HASH_SECRET` (at least 32 characters), the key its bearer credentials are stored as HMACs under.

## SSR and sessions

Expand Down
24 changes: 19 additions & 5 deletions apps/docs/content/1.guide/11.authentication/2.oauth.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
---
title: GitHub OAuth
title: OAuth providers
---

GitHub, GitLab, and Google are each offered only when both halves of their credential are set. Each one closes sign-up exactly when `AUTH_ENABLE_SIGNUP` does: with registration off, a provider signs in an account that already exists and never creates one.

## GitHub

The login page offers a GitHub button only when both halves of the credential are set:

```bash
Expand All @@ -24,8 +28,18 @@ The auth module auto-detects the base URL from the incoming request. Set `NUXT_P
NUXT_PUBLIC_SITE_URL=https://code-zero.example.com
```

::warning
**Rebuild after changing credentials**
## GitLab and Google

```bash
GITLAB_CLIENT_ID=…
GITLAB_CLIENT_SECRET=…
# A self-managed instance; leave empty for gitlab.com.
GITLAB_ISSUER=https://gitlab.example.com

GOOGLE_CLIENT_ID=…
GOOGLE_CLIENT_SECRET=…
```

Register the callback URLs `<origin>/api/auth/callback/gitlab` and `<origin>/api/auth/callback/google` with the provider.

The login page derives its sign-in methods at build time. After setting or removing the GitHub credentials, rebuild the app or the button state will be stale (the server still enforces the real policy). See [Authentication overview](/guide/authentication/overview).
::
Credentials are read when the server starts, and the sign-in page learns which providers exist from `/api/capabilities`, so adding or removing one needs a restart, not a rebuild.
17 changes: 17 additions & 0 deletions apps/docs/content/1.guide/11.authentication/3.permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,20 @@ What a run may do to a checkout is decided by the target repository's own `.code
## Organization roles

When [organizations](/guide/organizations) are enabled, membership and roles govern what a signed-in user sees and manages inside the dashboard. Organization authority never extends to the control plane or to repository policy.

| Permission | Owner | Admin | Member |
| ------------------------------------------ | :---: | :---: | :----: |
| `repository: read` | yes | yes | yes |
| `repository: write, configure` | yes | yes | – |
| `task: create` | yes | yes | yes |
| `task: approve, cancel` | yes | yes | – |
| `policy: write` | yes | yes | – |
| `member: invite, remove` | yes | yes | – |
| `apiKey: create, revoke` | yes | yes | – |
| `billing: manage`, delete the organization | yes | – | – |

The roles also carry Better Auth's own organization permissions, which its endpoints, Better Enrollment, and the API-key plugin check; they are granted in step with the table above. Members are invited with enrollment invitations (`AUTH_ENABLE_INVITATIONS`); the organization plugin's own invitation endpoints are not served.

## Administrators

The app-wide `admin` role is decided by Better Auth's admin plugin. Administrators may ban accounts, impersonate one, and grant app-wide roles, and the control plane grants a signed-in administrator its writable modes after asking the same plugin.
6 changes: 4 additions & 2 deletions apps/docs/content/1.guide/12.organizations.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ Enabling organizations requires a working mail transport, because invitations ar

- Organization data (membership, invitations, roles) lives in the Better Auth session store β€” the same Postgres database, declared in `packages/database`'s Drizzle schema.
- Policy constants such as the membership limit and invitation expiry are defined in `packages/auth` and composed into the Better Auth options.
- The dashboard's organization UI lives in `apps/web/modules/organizations/` β€” pages for creating an organization, managing members, and accepting invitations.
- Like every auth capability, the organization feature flags are captured at build time; rebuild after changing them. See [Authentication overview](/guide/authentication/overview).
- Roles are `owner`, `admin`, and `member`, defined once with `createAccessControl` in `packages/auth` β€” see [Permissions](/guide/authentication/permissions).
- Members are invited with Better Enrollment `org-join` invitations (`AUTH_ENABLE_INVITATIONS`), redeemed at `/invite` like every other invitation. The organization plugin's own invitation endpoints are not served, so there is one invitation model.
- The dashboard's organization UI lives in `apps/web/modules/organizations/` β€” pages for creating an organization and managing members.
- Like every auth capability, the organization flags are read at runtime from `/api/capabilities`; a change needs a restart, not a rebuild. See [Authentication overview](/guide/authentication/overview).

Organization authority governs the dashboard UI only β€” it never extends to the control plane or repository policy. See [Permissions](/guide/authentication/permissions).
Loading
Loading