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
1 change: 0 additions & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
# Anything the image build does not read.

.agents
.claude
.dev-authentik-groups.json
.dev-user.json
Expand Down
3 changes: 0 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,6 @@ yarn-error.log*
# claude
.claude

# skills
/.agents/skills

# vercel
.vercel

Expand Down
1,604 changes: 40 additions & 1,564 deletions CLAUDE.md

Large diffs are not rendered by default.

37 changes: 10 additions & 27 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,7 @@

# Backstage

Internal portal for [Budavári Schönherz Stúdió](https://bsstudio.hu). Source of truth for member
data, syncing to Authentik and the studio website, plus tooling to reconcile the studio mailing
list.
Internal portal for [Budavári Schönherz Stúdió](https://bsstudio.hu). Source of truth for member data, syncing to Authentik, the Active Directory domain behind the studio workstations, the studio website and its Google Group mailing lists, which it also reconciles against the member roster.

[![CI](https://github.com/BSStudio/backstage/actions/workflows/ci.yml/badge.svg)](https://github.com/BSStudio/backstage/actions/workflows/ci.yml)
[![Docker](https://github.com/BSStudio/backstage/actions/workflows/docker-publish.yml/badge.svg)](https://github.com/BSStudio/backstage/actions/workflows/docker-publish.yml)
Expand All @@ -25,8 +23,7 @@ list.
| Auth | Better Auth against Authentik OIDC |
| Quality | Biome, Vitest + Testcontainers, Sentry |

The user interface is in Hungarian. Architecture, data model and conventions are documented in
[CLAUDE.md](CLAUDE.md).
The user interface is in Hungarian. Architecture and data model are documented in [docs/](docs/), conventions in [CLAUDE.md](CLAUDE.md).

## Getting started

Expand All @@ -38,8 +35,7 @@ pnpm dev:setup # writes .env, starts Postgres, generates, migrates and seeds
pnpm dev
```

`pnpm dev:setup` asks for your name and your Authentik `sub` on first run so the seeded database
contains a member row that your SSO login lands on. The ~43 other members are invented.
`pnpm dev:setup` asks for your name and your Authentik `sub` on first run so the seeded database contains a member row that your SSO login lands on. The ~43 other members are invented.

## Commands

Expand All @@ -58,21 +54,13 @@ Environment variables are listed in [.env.example](.env.example).

## Contributing

Branch off `main`, open a PR. Three things are enforced rather than suggested, and all three fail
the build rather than warning:
Branch off `main`, open a PR. Three things are enforced rather than suggested, and all three fail the build rather than warning:

- **Conventional commits.** commitlint runs in the husky `commit-msg` hook and again in CI over the
PR's whole commit range, so an older commit cannot slip through. Subject lowercase, ≤72
characters.
- **100% coverage** — statements, branches, functions and lines, thresholded in
[vitest.config.ts](vitest.config.ts) over `app/`, `lib/` and `types/`. Land tests with the change.
A defensive branch that genuinely cannot be reached takes a
`/* v8 ignore next -- reason */` instead of dragging the number down.
- **Biome**, not ESLint or Prettier. lint-staged formats staged files on commit, so this rarely
needs running by hand.
- **Conventional commits.** commitlint runs in the husky `commit-msg` hook and again in CI over the PR's whole commit range, so an older commit cannot slip through. Subject lowercase.
- **100% coverage** — statements, branches, functions and lines, thresholded in [vitest.config.ts](vitest.config.ts) over `app/`, `lib/` and `types/`. Land tests with the change. A defensive branch that genuinely cannot be reached takes a `/* v8 ignore next -- reason */` instead of dragging the number down.
- **Biome**, not ESLint or Prettier. lint-staged formats staged files on commit, so this rarely needs running by hand.

`pnpm test` needs Docker running — Testcontainers starts a real PostgreSQL. Run `pnpm check` and
`pnpm test` before pushing; CI runs the same two plus commitlint.
`pnpm test` needs Docker running — Testcontainers starts a real PostgreSQL. Run `pnpm check` and `pnpm test` before pushing; CI runs the same two plus commitlint, and BSStudio/.github's shared checks over the workflows and dependency changes.

## Deployment

Expand All @@ -82,11 +70,6 @@ Images are published to `ghcr.io/bsstudio/backstage` on every push to `main` and
docker compose up -d
```

The image runs `prisma migrate deploy` from its entrypoint before starting the server; set
`RUN_MIGRATIONS=false` on any container that must not touch the schema.
The image runs `prisma migrate deploy` from its entrypoint before starting the server; set `RUN_MIGRATIONS=false` on any container that must not touch the schema.

`NEXT_PUBLIC_*` values are frozen into the bundle by `next build`, so the DSN, the version and the
commit hash have to be passed as build args — setting them on the running container has no effect.
The same is true of the Sentry source map upload, which happens at build time. The publish workflow
supplies all of them from repository variables and secrets; see Release engineering in
[CLAUDE.md](CLAUDE.md).
`NEXT_PUBLIC_*` values are frozen into the bundle by `next build`, so the DSN, the version and the commit hash have to be passed as build args — setting them on the running container has no effect. The same is true of the Sentry source map upload, which happens at build time. The publish workflow supplies all of them from repository variables and secrets; see [Release engineering](docs/releases.md).
3 changes: 3 additions & 0 deletions components/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Shared components

`AvatarContext` (`components/avatar-context.tsx`) shares the current avatar URL between the layout-rendered navbar and the member detail page, so an upload does not force a layout re-render. `PortalShell` reads the initial URL itself rather than taking it as a prop; after mount the context is the only thing that changes it. That read makes it an async server component importing `lib/prisma`, so unlike its neighbours in `components/` it cannot be imported from a client component.
5 changes: 5 additions & 0 deletions components/ui/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# UI primitives

The primitives here are registry output and are re-addable with `shadcn add --overwrite`. Anything hand-edited carries a `LOCAL MODIFICATIONS` comment at the top of the file saying what changed and why, so an update can re-apply it — currently `table.tsx` and `pagination.tsx`.

Auditing that claim means re-adding every primitive with the pinned `shadcn` dev dependency into a clean project carrying only `components.json`, `tsconfig.json` and `app/globals.css`, then diffing Biome-formatted output against the repo — files drift from the registry without local intent, and only a diff separates those from real modifications.
29 changes: 29 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# API endpoints

`GET /api/members` — list; `?archived=true` includes archived (default excludes). Includes `leadershipRole`, ordered by status then lastName.

`POST /api/members` — create (leader/admin). Requires `firstName`, `lastName`, `email`, `mobile` (E.164, see [Architectural decisions](decisions.md)); optional `nickname`, `university`, `major`, `dormRoom`. Status defaults to `MEMBER_CANDIDATE_CANDIDATE`, `joinedSemester` from `currentSemester()`. Writes a `TimelineEntry` and an `AuditLog`.

`GET /api/members/[id]` — member with `leadershipRole` and full `timeline` (newest first).

`PATCH /api/members/[id]` — members may edit themselves, leaders/admins anyone. Status **and email** changes require leader/admin. `mobile` may be cleared only on a member with no Authentik account. Timeline entry only on status change.

`DELETE /api/members/[id]` — soft archive (leader/admin): sets `archived` + `archivedAt`. `?removeFromGoogleGroup=true` also takes the member off the mailing list; anything else leaves the address in place. A query parameter because a DELETE body is undefined territory that proxies drop. Archiving is undone by `reactivateMemberAction`, a Server Action with no REST route of its own — nothing outside Backstage reactivates anybody, and DELETE was only ever a route because it is the natural verb for the archive. A second archive answers 200 having changed nothing, except `?removeFromGoogleGroup=true`, which still takes the address off the list — see [Architectural decisions](decisions.md).

`PUT /api/members/[id]/roles` — assign/update a leadership role (leader/admin). `label` + `authentikGroupIds`. Identical label and groups is a no-op returning 200 with no audit entry. New assignment writes `ROLE_ASSIGNED`, an update writes `ROLE_CHANGED`.

`DELETE /api/members/[id]/roles` — remove (leader/admin). Returns `{ removed: true }`; silent no-op when no role exists.

`POST`/`DELETE /api/members/[id]/avatar` — upload/remove (self, or leader/admin).

`GET /api/usernames/suggest?firstName=…&lastName=…` — machine-to-machine only (bearer token, see [Machine-to-machine access](auth.md#machine-to-machine-access)). Returns `{ username }` and nothing else. Read-only: it neither creates nor reserves, so the name can be taken by the time the caller uses it — callers handle the create failure. 30 requests per minute per service account, and a 60-second in-memory cache per name, because every miss fans out to Authentik's user API once per collision candidate.

`/api/carddav` — read-only CardDAV, and **not a route handler**: PROPFIND and REPORT never reach one, so `proxy.ts` serves the whole tree (see [Architectural decisions](decisions.md)). `/.well-known/carddav` 301s to it, `/api/carddav/principal/` is the principal (Apple's `/principals` is an alias for it), and `/api/carddav/addressbook/` holds one `<id>.vcf` per member still around — archived members and plain `ALUMNI` are left out, `ACTIVE_ALUMNI` are not. Reads `APP_URL` to absolutise avatar URLs.

`POST /api/computers/[id]/ping` — a workstation agent's heartbeat, machine-to-machine only (bearer token, see [Machine-to-machine access](auth.md#machine-to-machine-access)). `id` is the agent's configured slug; the body is `{ metadata }`. Answers **201** when the ping registered the machine and 200 afterwards. 10 requests per minute per service account — an agent pings once a minute, so anything near that ceiling is a schedule misconfigured into a loop.

`GET /api/computers/[id]/rdp` — an `.rdp` file for the signed-in member, served as an attachment named after the machine. Addresses the workstation at `<id>.<COMPUTER_RDP_HOST_SUFFIX>` on `COMPUTER_RDP_PORT` and prefills `COMPUTER_RDP_AD_DOMAIN\<their Authentik username>`; the password is never in the file. Answers 404 for a machine that has never pinged and for a deployment whose RDP configuration does not parse, so an instance without a host suffix is a supported configuration. `/computers` fetches it rather than linking to it, so a 404 raises a toast instead of navigating the member onto the route's JSON.

`GET /api/health` — container liveness. Public to the proxy and unauthenticated: it answers `{ status: "ok" }`, or 503 once the database round trip fails.

Admin-area resources (sync jobs, audit log, Google Group), app links and computers deliberately have **no** REST read routes; see [Architectural decisions](decisions.md).
60 changes: 60 additions & 0 deletions docs/auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Auth and permissions

Better Auth with the `genericOAuth` plugin against Authentik's OIDC discovery URL. Handler at `app/api/auth/[...all]/route.ts`, client helper in `lib/auth-client.ts`. The plugin registers Authentik as an ordinary social provider, so login is `signIn.social({ provider: "authentik" })` on the core endpoints — there is no client-side plugin, the redirect URI registered in Authentik is `<origin>/api/auth/callback/authentik`, and PKCE is on.

**The issuer is normalised before anything is appended to it.** Authentik's issuer identifier ends in a slash — `https://…/application/o/backstage/` is what its own discovery document reports — so joining `/.well-known/openid-configuration` onto the raw value yields a double slash, which Authentik answers 404 to: discovery returns nothing and login breaks, while better-auth skips the provider and leaves the rest of the auth routes alive. Both the login config and `lib/api-client-auth.ts` therefore take the value from `authentikIssuer()` (`lib/authentik/issuer.ts`), which drops the trailing slash. The slash-terminated form is still what arrives in an `iss` claim, which is why `requireApiClient` verifies against both spellings.

On login `mapProfileToUser` reads the `groups` claim and derives a role, and carries the Authentik `sub` in an `authentikSub` field; a `databaseHooks.user.create.before` hook promotes that to the user row `id`, so it matches the Member `id`. It also carries `preferred_username` into `authentikUsername`, which is the Authentik username and therefore the AD account name — the only place this app has one, and only for the length of a session. None of these `additionalFields` may carry `input: false` — better-auth strips such fields from the OAuth profile as well, which leaves every login unnamed, `MEMBER`, and keyed on a generated id.

`role`'s field `type` is the `USER_ROLES` list rather than `"string"`: better-auth infers the field's type from a literal array, so `session.user.role` reads as `UserRole` everywhere and no caller asserts it back. Nothing changes at runtime — a literal-array field is still a text column.

**The provider's subject mode must be "Based on the User's UUID"** (Applications → Providers → the Backstage provider → Advanced protocol settings). Authentik's default is a *hashed* user id, which is not the UUID the REST API filters on — so with the default, `sub` matches no Member row and every login creates a duplicate keyed on the hash, while `getUserPk` fails to resolve anything. Nothing in the app can detect this; it is instance configuration.

The handler mounts Better Auth's whole router, so `hooks.before` 404s every path outside `ALLOWED_AUTH_PATHS` (`lib/auth.ts`). Only the OIDC round trip (`/sign-in/social`, `/callback/:id`), `/get-session`, `/sign-out` and `/error` stay reachable; passwords, account linking and `/update-user` — which without `input: false` would let a member set their own `role` — belong to Authentik. A `databaseHooks.user.update.before` returning `false` would not do instead: the adapter then returns nothing and `/update-user` writes the client's values into the session cookie anyway.

`proxy.ts` lets `/login`, `/api/auth`, `/api/usernames`, `/api/health`, `/monitoring` and the workstation agent's ping through, and turns everything else away when no session cookie is present. Only a `GET` or `HEAD` outside `/api/` is redirected to `/login` with the original path as `callbackUrl`; everything else — an API call, a Server Action `POST` on an expired session, a phone probing for a collection — gets a 401 JSON body. The rule is the method rather than the path prefix because only a browser navigation can act on a redirect: anything else resolves with the login HTML under a 200 and cannot tell that apart from a real answer. Its matcher excludes static assets and image extensions — which is why avatar URLs are readable without auth. `/api/usernames` is public to the proxy because it carries a bearer token instead of a session cookie; the route itself does the authenticating. The agent's ping is public for the same reason, but as a pattern rather than a prefix — a `GET /api/computers` added later must not inherit the exemption from its neighbour.

CardDAV is checked ahead of all of that, and by method as well as by path: a WebDAV verb reaches `handleCardDav` wherever it is aimed, because a client hunting for a collection points them at arbitrary paths and cannot read a login page. `OPTIONS` is deliberately not one of them — it is also an ordinary CORS preflight.

The proxy only sees whether a cookie *exists*. `requireAuth()` is what rejects one that is expired or forged, so it is still the check that matters — the proxy only decides what an anonymous caller is told.

`lib/session.ts` resolves the caller in the shape each kind of entry point needs. All of them take a predicate from `lib/permissions.ts` rather than a role list:

| Helper | Returns | For |
| --- | --- | --- |
| `requireAuth()` | `Session` or a 401 `NextResponse` | routes, which early-return the response |
| `requirePermission(allows)` | `Session` or a 401/403 `NextResponse` | routes |
| `sessionActor()`, `permittedActor(allows)` | `Actor \| null` | Server Actions, which answer with a result object |
| `pageActor(allows?)` | `Actor`, or `redirect("/login")` unauthenticated / `redirect("/")` refused | pages, which have nowhere to put a status code |

`toActor(session)` in `lib/permissions.ts` reduces a session to the `{ id, role }` actor services take. It is typed structurally rather than on `Session`, so `lib/permissions.ts` pulls in neither `next/headers` nor better-auth — `components/app-sidebar.tsx` is a client component that imports it, and either would break the browser bundle.

**Every rule lives in `lib/permissions.ts`.** Nothing else compares a role to a literal — the predicates (`canManageMembers`, `canViewAdminArea`, `canAdminister`, `canModifyMember`) drive the UI, and the `ensureCan*` guards enforce it in the services. A route's `requirePermission` only fails the request earlier, before a body is parsed; it is not what makes the call safe.

| Role | Derived from | Can |
| --- | --- | --- |
| `MEMBER` | everyone else | view member list, view/edit own profile |
| `LEADER` | `AUTHENTIK_GROUP_LEADERSHIP` | + edit any member, change status, assign/remove roles, create, archive, reactivate; read the whole admin area — audit log, sync jobs, Google Group reconciliation, app links |
| `ADMIN` | `AUTHENTIK_GROUP_ADMIN` | + retry failed sync jobs, force a full website sync, refresh and annotate the Google Group list, create/edit/reorder/delete app links, delete a computer |

The admin area splits on read vs write: `LEADER` sees every admin page, `ADMIN` is what the mutations on them require.

`resolveUserRole(groups)` in `types/index.ts` implements the mapping.

## Machine-to-machine access

Other studio apps authenticate with Authentik **client credentials** against the same login provider — no second provider, application or redirect URI. One service account per consuming app, added to the group named by `AUTHENTIK_GROUP_API_CLIENTS`; the account's app password is the per-app secret, so revoking access means deleting the account. Authentik's client-credentials flow takes `client_id` plus that username and password, so the provider's own client secret never leaves the server.

`requireApiClient(req, kind)` takes the *kind* of caller, and each kind names its own group: `apiClient` (the default) requires `AUTHENTIK_GROUP_API_CLIENTS`, `computerAgent` requires `AUTHENTIK_GROUP_COMPUTER_AGENTS`. One group would make every workstation token a credential on every machine-to-machine endpoint, and an agent token sits on a machine members can walk up to.

`requireApiClient(req)` (`lib/api-client-auth.ts`) does `jwtVerify` + `createRemoteJWKSet` against `AUTHENTIK_ISSUER` (JWKS at `<issuer>/jwks/`, memoized per issuer), audience `AUTHENTIK_CLIENT_ID`, then requires the API-client group in the `groups` claim. It returns a `NextResponse` on failure like the session helpers do, and the caller identity (`sub`, `preferred_username`) otherwise.

It lives **outside** `lib/session.ts` on purpose. Login and client-credentials tokens share an issuer and an audience, so a member's browser access token is a structurally valid bearer here — group membership is the only boundary. In the shared helpers it would become a valid credential on every route, admin ones included.

## CardDAV access

A phone's CardDAV client cannot fetch an Authentik token, so `/api/carddav` authenticates with HTTP Basic against per-device tokens. `authenticateCardDavToken` hashes what arrives and looks the digest up on a unique index. The **username is ignored**: the token is the whole credential, and checking an address would break every device a member owns the day a leader corrects their email. An archived member's tokens stop working.

Like `lib/api-client-auth.ts`, this sits outside `lib/session.ts` — a different credential on a different boundary, which in the shared helpers would become a valid credential everywhere.

Minting is self-service only. A leader may list and delete another member's devices, so a lost phone can be cut off, but never mint one. Both actions are audited.
Loading
Loading