From 494f809018188cd6aa6c827fa301390f99963952 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliv=C3=A9r=20Kecskem=C3=A9ty?= Date: Fri, 13 Nov 2026 21:51:49 +0100 Subject: [PATCH 1/8] docs: move the reference sections out of claude.md --- CLAUDE.md | 1505 +---------------------------------------- README.md | 8 +- docs/api.md | 29 + docs/auth.md | 60 ++ docs/data-model.md | 57 ++ docs/decisions.md | 147 ++++ docs/dev-data.md | 15 + docs/integrations.md | 101 +++ docs/observability.md | 36 + docs/releases.md | 34 + docs/structure.md | 74 ++ docs/testing.md | 9 + 12 files changed, 580 insertions(+), 1495 deletions(-) create mode 100644 docs/api.md create mode 100644 docs/auth.md create mode 100644 docs/data-model.md create mode 100644 docs/decisions.md create mode 100644 docs/dev-data.md create mode 100644 docs/integrations.md create mode 100644 docs/observability.md create mode 100644 docs/releases.md create mode 100644 docs/structure.md create mode 100644 docs/testing.md diff --git a/CLAUDE.md b/CLAUDE.md index 01c63ea..762b474 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,6 +12,19 @@ no public traffic. This file describes the system **as it exists**. +The architecture is documented in [docs/](docs/), one file per topic. Before building a new feature, read the docs for the area it touches and follow the existing patterns. + +- [structure.md](docs/structure.md) — where code lives, and which files each common kind of change touches. +- [data-model.md](docs/data-model.md) — the Prisma models, membership status, the semester format, dates and the studio time zone. +- [auth.md](docs/auth.md) — login, roles and permissions, machine-to-machine and CardDAV access. +- [api.md](docs/api.md) — the REST routes. +- [integrations.md](docs/integrations.md) — Authentik, the website, Google Workspace and Active Directory, the sync jobs that write to them, and Google Group reconciliation. +- [observability.md](docs/observability.md) — Sentry, PII scrubbing, release tagging and structured logs. +- [releases.md](docs/releases.md) — the image, migrations, build args, workflows, Renovate and the GitHub-side settings. +- [testing.md](docs/testing.md) — the test setup and what coverage includes. +- [decisions.md](docs/decisions.md) — why things are the way they are; read it before changing an established pattern. +- [dev-data.md](docs/dev-data.md) — what `pnpm db:seed` writes, and how to test against a dev Authentik. + --- ## Rules @@ -56,1496 +69,6 @@ Breaking these causes real damage, not style nits. Environment variables: see `.env.example`. It is the complete list and is kept in sync. -### Dev data - -`pnpm db:seed` wipes the dev database and writes ~43 invented members (Hungarian names, none -real), leadership roles, an `AuthentikGroup` registry, per-member timeline and audit history, and -sync jobs including two FAILED and four SKIPPED ones so `/admin/sync-jobs`, its retry button and -every status badge have something to show. Every member carries a `WEBSITE` push; the skipped -website and Active Directory jobs carry the not-configured reason, which is what a local `.env` -without a webhook token or the `AD_*` settings actually produces. One `WEBSITE_FULL_SYNC` audit -entry gives `/admin/audit` that action and its null target to render. Semesters are relative to -`currentSemester()`, and member ids are the local prefix plus a hash of the seeded email, so member -URLs survive a reseed. - -The first run asks for your name and your Authentik `sub` and stores the answers in -`.dev-user.json` (gitignored). That row is created with your `sub` as its `id`, so logging in -through SSO lands on it instead of creating a second record. - -The seeded `AuthentikGroup` UUIDs are invented, so a default seed cannot touch a real instance. -To test group sync against a dev Authentik, write the real UUIDs to `.dev-authentik-groups.json` -(gitignored, `[{ "displayName": "Főszerkesztő", "authentikGroupId": "" }, …]`): entries -whose `displayName` matches a seeded group replace its UUID, the rest are added to the registry. -Real Authentik identifiers stay out of the repo, same as the group UUIDs in `.env`. Only *your* -member row resolves in Authentik: it is seeded with your `sub`, while every invented member carries -a local-prefixed id, so assigning *them* a role produces `SKIPPED` jobs and never calls anything. Assign -the role to yourself. For the same reason the seeded Authentik `CREATE_USER` and the FAILED -Authentik job both belong to your row — it is the only one a real retry can reach. - -Three `CardDAVToken` rows are seeded: two on your row — one that has synced and one that never -has, so both states of the "last used" column render — and one on another member, so the table is -not empty when a leader opens someone else's profile. Your first device's password is the fixed -`dev-carddav-token`, printed at the end of a seed, so the endpoint can be exercised with `curl` -without clicking through the UI. - -Eight `AppLink` rows are seeded, three of them featured so the dashboard chip row has something -to show, between them using all eight accents, and one with no description so that branch renders -too. Each gets an `APP_LINK_CREATED` audit entry, which is what puts the new action badges and -the `targetLabel` column on `/admin/audit` without having to create an app first. - -Four `Computer` rows cover every state a card renders: `nle4` in use, `nle6` signed in but -locked (which the portal reads as free), `nle3` online but reporting no occupancy at all — -what an agent whose session lookup failed sends — and `nle7` offline on an older agent -version. A -`COMPUTER_DELETED` audit entry for a retired `NLE5` gives `/admin/audit` the new action to render. - -The scripts refuse to run against a database whose host is not local unless passed `--force`. - ---- - -## Repository structure - -- `app/(portal)/` — authenticated pages (members, apps, computers, admin, dashboard) -- `app/api/` — REST routes: `members/`, `members/[id]/{roles,avatar}`, `usernames/suggest`, - `computers/[id]/ping`, `auth/[...all]` -- `app/avatars/[...path]/route.ts` — serves avatar bytes from whichever storage backend is active -- `lib/services/` — business logic (`members.ts`, `sync-jobs.ts`, `usernames.ts`, - `google-group.ts`, `audit.ts`, `app-links.ts`, `calendar.ts`, `computers.ts`, `website.ts`). - All real work - happens here. `member-schemas.ts`, `google-group-schemas.ts`, `app-link-schemas.ts` and - `computer-schemas.ts` hold the Zod schemas, - split out so client forms can import them; - `pagination.ts` holds the page size and the skip/take arithmetic the paginated admin lists share -- `lib/actions/` — Server Actions; thin wrappers around services. `result.ts` owns the - `ActionResult` shape they all return, the two failure constants and `mapActionError` -- `lib/authentik/` — Authentik REST client (`client`, `users`, `groups`), plus `issuer.ts`, - which owns the one spelling of `AUTHENTIK_ISSUER` every path is joined onto -- `lib/website/` — `webhook.ts`, the whole client for the website: one bearer POST of the - member push contract — see The website -- `lib/active-directory/` — `config.ts` (the `AD_*` settings and a member's DN) and `client.ts` - (one bound LDAPS connection per call: create an account, replace attributes) — see Active - Directory -- `lib/google/` — the Google clients sharing one signer: `client.ts` (token minting + transport, - `googleFetch` for an absolute URL, `googleRequest` for a Cloud Identity path), `groups.ts` - (membership operations), `calendar.ts` (the studio calendar read) -- `lib/sync/` — `executor.ts` + per-target - `authentik/{operations,orchestrators,attributes,group-mapping}.ts`, - `google/{operations,orchestrators}.ts`, `website/{operations,orchestrators,payload}.ts` and - `active-directory/{attributes,operations,orchestrators}.ts` -- `lib/storage/` + `lib/avatar-storage.ts` — avatar storage facade and local/S3 backends -- `lib/http.ts` — the timeout every outgoing request carries, and what a rejection that never - became an HTTP answer says -- `lib/errors.ts` — typed error hierarchy + `mapServiceError` -- `lib/api-response.ts` — `syncJson` / `syncJsonResource`, the answer every mutating route - returns -- `lib/toast.ts` — `toastSync`, the success-or-partial-failure toast every mutation raises -- `lib/permissions.ts` — every role rule: `can*` predicates for the UI, `ensureCan*` guards that - throw `ForbiddenError` for services -- `lib/api-client-auth.ts` — bearer verification for machine-to-machine callers, one Authentik - group per kind of caller -- `lib/carddav/` — the read-only CardDAV endpoint: `paths.ts` (URL shapes, recognised without - loading the rest), `handler.ts` (method dispatch + Basic auth), `vcard.ts`, `xml.ts`. Served from - `proxy.ts`, not `app/api/` — see Architectural decisions -- `lib/rate-limit.ts` — in-memory fixed-window rate limiter. State is per process, so every limit - it enforces is per replica; the single-replica deployment is what makes that correct -- `lib/rdp.ts` — the `.rdp` file `/computers` hands out, and the three environment values that - shape it. Separate from `lib/computers.ts` because client components import that one and - would carry the environment read into the browser bundle -- `lib/observability/` — `sentry.ts` (shared init options), `scrub.ts` (PII redaction), - `capture.ts` (capture helpers), `logger.ts` (structured logging) -- `lib/members.ts`, `lib/audit.ts`, `lib/nav-labels.ts`, `lib/sync-jobs.ts`, - `lib/google-group.ts`, `lib/app-links.ts`, `lib/calendar.ts`, `lib/computers.ts` — display - helpers + Hungarian - labels. `lib/app-links.ts` also owns the icon name → lucide component map and the per-accent - tile classes; `lib/calendar.ts` owns every Hungarian date and time string the dashboard - renders, and the split between the two kinds of formatter that needs — see Dates and the - studio zone -- `components/form.tsx` — TanStack Form `useAppForm` plus the field/submit components every form - is built from -- `components/` — shared pieces above the primitives: `page-nav.tsx` (admin list pagination), - `status-badge.tsx` (`StatusBadge` / `ArchivedBadge`), `archive-dialog.tsx` (the archive - confirmation and its mailing-list checkbox), `app-link-card.tsx` (one row on /apps), - `computer-card.tsx` (one workstation on /computers), `auto-refresh.tsx` (re-runs a page's - server components on a timer), `portal-shell.tsx` (the authenticated frame) -- `components/dashboard/` — the widgets `/` composes, one file per source: `calendar.tsx` - (`HeroEvent` + `UpcomingEvents`, two server components sharing one `cache()`d read), - `profile-card.tsx` (which also exports the member read the page's greeting reuses), - `quick-links.tsx`, and `computers-card.tsx` -- `components/ui/` — shadcn/ui primitives -- `scripts/` — dev tooling run with `tsx`: `dev-setup.ts`, `seed-dev.ts` (+ `seed-data.ts` roster, - `dev-user.ts` identity prompt, `dev-groups.ts` Authentik group UUIDs), `reset-db.ts`, - `authentik-contract.ts` + its `authentik-contract.json` snapshot, and the three credential - probes, `google-group-probe.ts`, `google-calendar-probe.ts` and `active-directory-probe.ts`, - which read the configured group, calendar and members OU through the real clients. Everything - here runs on a developer's own machine against their `.env`, and nothing here is shipped -- `workstation-agent/` — the studio workstation agent: `backstage-agent.ps1` (the ping loop), - `install-agent.ps1` (the scheduled task, the ACL and the DPAPI-encrypted credential), - `bootstrap.ps1` (the one-liner that fetches the other two at the latest release tag), and their - own `README.md`. Top level rather than under `scripts/` because it is not tooling for this - repository: it is Windows PowerShell fetched over HTTP onto machines that never clone this, and - its paths are part of a published install URL -- `tests/` — mirrors the source layout; `setup.ts` spins Testcontainers Postgres -- `proxy.ts` — route protection (Next.js 16 convention), and the CardDAV endpoint, which cannot - live in a route handler -- `instrumentation.ts`, `instrumentation-client.ts`, `sentry.{server,edge}.config.ts` — Sentry - bootstrap (Next.js 16 conventions) -- `prisma/schema.prisma` — single schema, client generated to `app/generated/prisma/` -- `Dockerfile`, `docker-entrypoint.sh`, `docker-compose.yml` — production image and stack; - `docker-compose.dev.yml` is just the dev Postgres -- `.github/workflows/` — `ci.yml`, `docker-publish.yml`, `authentik-contract.yml`, all sharing the - `.github/actions/setup` composite: `pnpm/setup`, which reads the pnpm version from - `packageManager` and is pointed at `.nvmrc` for the runtime. Named rather than left to the - action's own detection, which falls back through `.node-version`, `.nvmrc` and - `.tool-versions` and defers to `devEngines.runtime` — so adding any of those would silently - move CI onto another Node. It installs too: `require-lockfile` is what makes a *missing* - lockfile fail the job, which a frozen install does not — pnpm resolves from the registry, - writes one and exits 0. It needs pnpm 11 or newer. `.github/renovate.json` for dependency - updates - ---- - -## Where to make a change - -**Add or change a Member field** -1. `prisma/schema.prisma` → `pnpm db:migrate` -2. `CreateMemberSchema` / `UpdateMemberSchema` in `lib/services/member-schemas.ts` (re-exported - from `members.ts`) — the forms validate against the `*FormSchema` variants derived there -3. Should it reach Authentik? Add to `AUTHENTIK_SYNCED_FIELDS` *and* `buildAuthentikAttributes()` - (`lib/sync/authentik/attributes.ts`) — the attribute set is sent wholesale, see below -4. Should it reach the website? Only if the contract names it — it is theirs, not ours. - Then `WebsiteMember` (`lib/website/webhook.ts`), `buildWebsiteMember` - (`lib/sync/website/payload.ts`) and, if a change to it should trigger a push, - `WEBSITE_SYNCED_FIELDS` -5. Should it reach Active Directory? Almost certainly not — an AD account carries the names and - the enabled flag and nothing else, see Active Directory. If it does, - `buildDirectoryAttributes` (`lib/sync/active-directory/attributes.ts`) and - `ACTIVE_DIRECTORY_SYNCED_FIELDS` -6. Should it reach a synced phone? Almost certainly not — a vCard carries contact details only, - see Architectural decisions. If it does, `VCardMember` and `renderVCard` - (`lib/carddav/vcard.ts`) *and* the projection in `listCardDavMembers` -7. UI: `members/new/page.tsx`, `members/[id]/member-edit-sheet.tsx`, table `columns.tsx` -8. Tests: `tests/services/members.test.ts` - -**Add a sync operation** -1. Low-level call in `lib/authentik/*` (then see below), `lib/google/groups.ts` or - `lib/active-directory/client.ts` -2. Register the handler in `lib/sync//operations.ts` — it receives - `(payload, memberId, prisma, jobId)` and resolves external IDs itself at execute time — - `jobId` is there for a target whose endpoint takes an idempotency key -3. Orchestrator in `lib/sync//orchestrators.ts`: hand `runSyncJob` the row to write, plus - a skip reason if the target has a condition no retry could fix -4. Call the orchestrator from `lib/services/`, and turn the results into `syncErrors` with - `collectSyncErrors` -5. A new operation name also needs a `SyncOperation` enum value + migration - -**Add or change an Authentik API call** — any new endpoint, query parameter or request/response -field in `lib/authentik/*` has to be mirrored into the contract check, or it goes unwatched. -1. The call itself in `lib/authentik/{client,users,groups}.ts` -2. Add or amend its entry in `CONTRACT` (`scripts/authentik-contract.ts`) — one object per - `method` + `path`, listing the `query` parameters, `request` fields and `response` fields the - client actually touches. Nothing derives this from the source, so a call with no entry passes - the check silently forever -3. `pnpm authentik:contract --update`, and commit the regenerated - `scripts/authentik-contract.json` with the change — a stale snapshot fails the next PR that - touches `lib/authentik/**` -4. Removing a call means removing its `CONTRACT` entry too, otherwise the check guards a field - nobody reads - -**Add an API route** — keep it a thin adapter: `requireAuth()` or -`requirePermission()` from `lib/session.ts`, `toActor(session)` for the service call, -`syncJson(body, syncErrors)` for the answer — or `syncJsonResource(key, resource, syncErrors)` -when the body is the resource itself — and `mapServiceError` around failures. No business -logic, and no role list of its own — the predicate comes from `lib/permissions.ts` and the service -guards itself regardless. - -**Add something the workstation agent reports** -1. Collect it in `Get-Metadata` (`workstation-agent/backstage-agent.ps1`), inside its own `try`, so a - machine that cannot answer still pings -2. Add the field to `ComputerMetadataSchema` (`lib/services/computer-schemas.ts`) — unknown keys - are stripped, so an agent reporting a field the schema does not name writes nothing -3. Render it from `lib/computers.ts` and `components/computer-card.tsx` -4. Report a fact, not a conclusion. The portal decides what a fact means, so changing that - decision is not a reinstall on every workstation -5. Old agents keep running: every field is optional, and absent has to stay distinguishable from - an empty value. `loggedInUser` is the worked example — omitted when the session lookup - failed, `null` when it ran and found nobody, and only the second means the machine is free - -**Add a portal page** — `app/(portal)/…/page.tsx` exporting `metadata` and, if it reads data, -opening with `pageActor()` — or `pageActor()` when a role gates it; a Hungarian label in -`lib/nav-labels.ts` (used by both sidebar and breadcrumbs); and a sidebar entry in -`components/app-sidebar.tsx`. - -**Add a dashboard widget** — one file in `components/dashboard/`, exporting a server component -that **reads its own data**. `app/(portal)/page.tsx` composes and never queries on a widget's -behalf, so a feature arrives with its card instead of the page growing a read per source. A -widget that talks to something off our own network goes in its own `` with a -`Skeleton`, so the rest of the page does not wait for it. Two widgets over one source share a -`cache()`d reader rather than querying twice (`readCalendar`, `readOwnProfile`). - ---- - -## Data model - -Prisma client generated to `app/generated/prisma/`, using the `@prisma/adapter-pg` driver adapter -over a raw `pg` connection. - -**Member** — central record. `id` is the member's **Authentik user UUID**: the OIDC `sub` claim at -login and the `uuid` the sync layer filters `/core/users/` on to resolve a pk. There is no separate -`authentikId` — `id` serves both, which is why Authentik's provider **subject mode must be "Based -on the User's UUID"** (see Auth). - -Members with no Authentik account — imported alumni, nobody the app created — carry an id prefixed -with `LOCAL_MEMBER_ID_PREFIX` instead (`localMemberId()` / `hasAuthentikAccount()` in -`types/index.ts`). The prefix is the *only* record that the account is missing, and unlike the -random UUID it replaces it cannot be mistaken for a real one. Its value is a meaningless token -rather than a descriptive word because member ids reach the public site inside avatar URLs — refer -to the constant, not the literal. Giving such a member an account later means rewriting the id to -the new UUID; Prisma's required relations default to `onUpdate: Cascade`, so a single -`UPDATE "Member" SET id = …` carries every child row with it. - -No Backstage code runs in that procedure, so nothing writes the AD `CREATE_USER` job either, and -the member's next edit sends Authentik a DN for an account that does not exist. Insert the job -`createMember` would have written, then retry it from `/admin/sync-jobs`, which offers retry on a -`PENDING` row. `id` and `updatedAt` are filled by Prisma rather than the database, so the SQL -supplies them: - -```sql -INSERT INTO "SyncJob" (id, target, operation, "memberId", payload, "updatedAt") -VALUES (gen_random_uuid()::text, 'ACTIVE_DIRECTORY', 'CREATE_USER', '', - '{"username": ""}', now()); -``` - -**LeadershipRole** — only *active* roles, one per member (1:1 via `@unique` on `memberId`). Holds a -free-text `label` and an array of `authentikGroupIds`. When a role ends the row is deleted and a -`TimelineEntry` snapshot is written instead. Archiving the member is one of the ways it ends — see -Architectural decisions. - -**AuthentikGroup** — registry of known Authentik groups powering the role-assignment checklist. -Populated manually by admins. The Authentik UUID is the primary key — no separate cuid. It is a -constraint and not just a source for the checklist: `assignRole` refuses an id the registry does -not name, or a leader could grant any group in the instance, `AUTHENTIK_GROUP_ADMIN` included. - -**TimelineEntry** — human-readable history: status promotions, role changes, archival, reactivation. - -**AuditLog** — field-level diff log, `{ field: { old, new } }` JSON. Written on every mutation. -A status change and a field update in the same request produce **separate** entries -(`STATUS_CHANGED` + `MEMBER_UPDATED`). `targetId` is nullable because `GOOGLE_GROUP_SYNCED` -records a read of the mailing list and `WEBSITE_FULL_SYNC` a push of the whole roster, neither of -which is about a single member. `targetLabel` names what -an entry is about when that is not a member either — an app link, say. It is a snapshot rather -than a relation, so the log still reads correctly after the row it names is renamed or deleted; -an `APP_LINK_UPDATED` entry stores the name the link goes by *after* the change. - -**SyncJob** — one row per external call, against Authentik, Active Directory, the website or a -Google Group. -PENDING → IN_PROGRESS → SUCCESS | FAILED, plus `SKIPPED` for a call that was never attempted -(see Sync architecture). `memberId` is a required FK. Failed -jobs surface at `/admin/sync-jobs` and are individually retryable, and so is a row left -`PENDING` or `IN_PROGRESS` — execution is synchronous, so one outliving its request was -interrupted. `SUCCESS` and `SKIPPED` are the two that are finished and refuse a retry. - -**GoogleGroupEntry** — one row per address on the mailing list, rebuilt from a live read. -`matchStatus` is either derived by matching the address against `Member.email` (`MATCHED`, -`ARCHIVED_ON_LIST`, `UNKNOWN`) or set by hand (`SECONDARY_EMAIL` — a member's other address; -`KNOWN_ADDRESS` — no member behind it and that is fine, another list or an external contact). -The two hand-set states survive a refresh, everything else is recomputed, and notes survive -either way. `KNOWN_ADDRESS` requires a note: with no member link it is the only record of what -the address is. - -**AppLink** — one of the studio's other applications, listed on `/apps`. Config rather than -member data: no relations, and nothing here is derived from anything else. `icon` is a lucide -name from the curated list in `types/index.ts`, `accent` is an enum of eight presets rather than -a colour string, `featured` is what the dashboard filters on and `/apps` ignores, and `sortOrder` -is renumbered contiguously on every move. - -**Computer** — one studio workstation, created by its own agent's first ping rather than by an -admin. `id` is the slug the agent config carries (`nle4`), not a generated key — it is what the -ping URL names. There is no display name column: it is the slug uppercased. `agentSub` records -the service account that claimed the id, and a different one pinging it is refused. `metadata` is -whatever the agent last reported — OS, load, signed-in user, whether the screen is locked — -validated on the way in and re-parsed on the way out, so a row written by an older agent still -renders. Status is derived, not stored: online means a ping inside `COMPUTER_ONLINE_WINDOW_MS`. - -**CardDAVToken** — one row per device a member set up for contact sync. `tokenHash` is the SHA-256 -of a 256-bit random token; the token itself is shown once and stored nowhere. `label` names the -device, `lastUsedAt` moves at most once every five minutes. Deleting the row revokes the device. - -### Membership status - -```prisma -enum MembershipStatus { - MEMBER_CANDIDATE_CANDIDATE // stúdiós-jelölt jelölt - MEMBER_CANDIDATE // stúdiós-jelölt - MEMBER // stúdiós - ACTIVE_ALUMNI // aktív öregtag - ALUMNI // öregtag -} -``` - -`ACTIVE_ALUMNI` and `ALUMNI` map to the **same** Authentik group and the **same** mailing list, and -carry no permission difference. The one place the two part ways is the CardDAV address book, which -carries `ACTIVE_ALUMNI` and drops `ALUMNI` (see Architectural decisions); everywhere else the -distinction is a badge. `ALUMNI_STATUSES` and `isAlumniStatus()` in `types/index.ts` are the shared -predicate; the members and alumni pages keep their own filters because they enumerate different -concepts. - -### Semester format - -One string: `"2025/2026/1"` (autumn) or `"2025/2026/2"` (spring) — start year / end year / semester -number. Sorts correctly as a string. Helpers in `types/index.ts`: `parseSemester`, `formatSemester` -(`"2025 ősz"`) and `currentSemester`. Nothing validates the format — only `currentSemester()` -ever writes one. - -### Dates and the studio zone - -The container runs on UTC and the studio does not, so nothing derives a calendar date from the -server's clock. `STUDIO_TIME_ZONE` and `civilDate(instant, zone?)` in `types/index.ts` resolve an -instant to the date it falls on **at the studio**; `addDays`, `startOfWeek` (Monday, as a Hungarian -calendar starts) and `daysBetween` are whole-day arithmetic over the `"YYYY-MM-DD"` strings that -produces, done in UTC so no zone shift can move them. - -Rendering splits along the same line. An instant is formatted **in the studio's zone**, because -that is the clock on the wall — `formatTimestamp` and `formatTimestampDate` in `types/index.ts` -for a stored one, `lib/calendar.ts`'s `clock` for an event. A `"YYYY-MM-DD"` is already a civil -date with no zone of its own, so it is parsed and formatted **as UTC** — putting it through -Budapest would shift it a day. Getting this backwards moves a Monday all-day event into the -previous week, which is exactly the bug the split exists to prevent. - ---- - -## 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 -`/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. - -`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 `/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. - ---- - -## 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); 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. - -`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). 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). `/.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 `.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). `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 `.` -on `COMPUTER_RDP_PORT` and prefills `COMPUTER_RDP_AD_DOMAIN\`; 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. - ---- - -## External integrations - -### Authentik - -Three independent uses: - -1. **Login (OIDC)** — `AUTHENTIK_ISSUER`, `AUTHENTIK_CLIENT_ID`, `AUTHENTIK_CLIENT_SECRET` -2. **User/group management (REST)** — `lib/authentik/*`, using `AUTHENTIK_URL`, - `AUTHENTIK_API_TOKEN` -3. **Machine-to-machine tokens** — client credentials against the *same* provider as login, no - extra config beyond `AUTHENTIK_GROUP_API_CLIENTS` - -Group **names** (matched against the OIDC groups claim): `AUTHENTIK_GROUP_LEADERSHIP`, -`AUTHENTIK_GROUP_ADMIN` for role resolution, `AUTHENTIK_GROUP_API_CLIENTS` for API clients and -`AUTHENTIK_GROUP_COMPUTER_AGENTS` for workstation agents. - -Group **UUIDs** (used by the sync layer): `AUTHENTIK_GROUP_CANDIDATE_CANDIDATE`, -`AUTHENTIK_GROUP_CANDIDATE`, `AUTHENTIK_GROUP_MEMBER`, `AUTHENTIK_GROUP_ALUMNI` (covers both alumni -statuses), and `AUTHENTIK_GROUP_LEADERSHIP_UUID` — note this is a *different* variable from -`AUTHENTIK_GROUP_LEADERSHIP`, which holds a name. - -**Username derivation.** First Hungarian letter of the first name + full last name, lowercase, -diacritics stripped, hyphens removed: `Kovács János → jkovacs`, `Csaba Nagy → csnagy`. -`deriveUsername()` in `types/index.ts`, collisions resolved by `findAvailableUsername()` -(`lib/authentik/users.ts`). Not overridable — there is no stored username field. - -Both are paired in `lib/services/usernames.ts`: `resolveAvailableUsername()` (uncached, used by -`createAuthentikUser`) and `suggestUsername()` (cached, used by the suggestion endpoint). Member -creation and other studio apps therefore take the same path. - -**Contract drift is watched, not tested.** `pnpm authentik:contract` -(`scripts/authentik-contract.ts`) downloads goauthentik's OpenAPI spec, extracts the shape of -exactly what the `CONTRACT` list names, and diffs it against `scripts/authentik-contract.json`. -No running Authentik and no credentials are involved, so it runs anywhere. It reads the spec from -`main` rather than a release tag on purpose: `main` runs ahead of the deployed version, so a field -rename surfaces here before the instance is upgraded into it. `AUTHENTIK_SCHEMA_URL` points it at -a local file or a pinned release instead. - -`.github/workflows/authentik-contract.yml` runs it weekly, on `workflow_dispatch`, and on PRs -touching `lib/authentik/**` or the script. A PR run signals by going red; a scheduled run has -nobody watching, so it opens an issue instead, deduplicated by title against the open ones. - -The check only sees what `CONTRACT` lists — see *Add or change an Authentik API call* above. -Nothing links it to `lib/authentik/*` automatically. It also covers only the `/api/v3` REST -surface: the OIDC endpoints behind `lib/auth.ts` and `lib/api-client-auth.ts` -(`.well-known/openid-configuration`, `/jwks/`) are not in Authentik's spec at all. - -### The website (members webhook) - -`WEBSITE_WEBHOOK_URL` and `WEBSITE_WEBHOOK_TOKEN`. One endpoint, one verb: a bearer `POST` of -members, either as targeted operations or as a whole-roster `replace`. The contract is **theirs**, -generated from their own route and published at `docs/api/members-webhook.openapi.yaml` in -[kir-dev/bss-stack](https://github.com/kir-dev/bss-stack). Read it there rather than keeping a copy -here, which can only go stale. - -The token is issued on the website's side and shown there once; it is a client identifier and a -secret joined by a dot, and revoking access means revoking the client there. - -`membershipStatus` is our `MembershipStatus` verbatim, which is why nothing maps between the two. -The contract carries **no email and no mobile**, so neither is a reason to push and neither ever -goes over the wire. - -`x-bss-delivery-id` is an idempotency key, and the sync layer passes the `SyncJob` id: a push that -landed but never answered comes back as a duplicate instead of applying twice, and a push the -endpoint *rejected* leaves the key unclaimed, so the same job can retry once the payload is fixed. - -### Google Workspace (Cloud Identity) - -`GOOGLE_SERVICE_ACCOUNT_KEY` (the downloaded JSON key, base64-encoded), `GOOGLE_GROUP_EMAIL`, and -the optional `GOOGLE_ALUMNI_GROUP_EMAIL`. No `googleapis` dependency: `lib/google/client.ts` signs -a JWT with `jose`, exchanges it at `oauth2.googleapis.com`, and caches the token **per scope**, so -a read path never carries write authority. - -**The Admin SDK Directory API is not usable here** — it answers only to a Workspace admin, and the -university owns the domain. Cloud Identity accepts a service account that holds the **MANAGER** -role on the group itself, with no domain-wide delegation, which is the whole reason this -integration exists at all. - -What that role can reach was established by probing, not from docs: `groups:lookup`, -`memberships.list`, `memberships.create` and `memberships.delete` all work, while -`memberships:lookup` is refused on both scopes — it resolves an address against the *directory*, -where group-manager rights do not reach. So `removeGroupMember` finds the membership name in the -listing instead of looking it up. Restoring that call would mean granting the service account the -Workspace **Groups Administrator** role, which needs a Super Admin and buys one saved request. - -The service account is itself a member of the group; the reconciliation read filters its own -address out, or it would sit there as a permanent `UNKNOWN`. - -**The calendar is a second, unrelated grant on the same account.** `GOOGLE_CALENDAR_ID` names it, -`lib/google/calendar.ts` reads it on `calendar.readonly`, and the per-scope token cache keeps that -token apart from the group ones. None of the Cloud Identity story above applies: sharing the -calendar with the service account's address at **"See all event details"** is the entire setup, and -no Workspace admin is involved. Leaving `GOOGLE_CALENDAR_ID` unset drops the widget instead of -failing, so an instance without a calendar is a supported configuration. - -A share that never landed reads as **404, not 403** — the account cannot see the calendar at all, -so the API answers as if it did not exist. `scripts/google-calendar-probe.ts` prints what the -account can actually read and says as much on a 404. - -Three shapes of the events feed cost a probe to establish and none of them is guessable: - -- **An all-day `end.date` is exclusive.** A single day on the 4th arrives as the 4th to the 5th, - so `endDate` steps a day back from it. Read literally, every all-day event looks a day longer - than it is. -- **The API returns anything *overlapping* the window,** so a multi-day event under way arrives - dated before `timeMin`. Its own start date is therefore not a safe grouping key — see the hero - under Architectural decisions. -- **`singleEvents=true` is what expands a recurrence** into one row per occurrence, and - `orderBy=startTime` is only accepted alongside it. Without them the weekly meetings render once, - forever, as the series' first instance. - -The request manager writes a `További információk a felkéréskezelőben.` line -into the description of every event it creates, which is where an event's `url` comes from — the -first anchor in the description, and only when it is `http(s)`. - -### Active Directory - -`AD_LDAP_URL`, `AD_BIND_DN`, `AD_BIND_PASSWORD`, `AD_USERS_OU` and `AD_UPN_SUFFIX`, plus the -optional `AD_LDAP_CA_CERT` and `AD_LDAP_TLS_VERIFY`. The domain exists for logging in to the studio -workstations, at the console and over RDP, and for nothing else — so a member's account carries -the username, the UPN, the names and whether it is enabled. Leaving any of the five required -values empty records every AD job as `SKIPPED`. - -**Backstage owns the account; Authentik owns the password.** Authentik cannot create accounts in an -on-prem AD — its LDAP source imports in the other direction — but that source's password writeback -sets the AD password of any Authentik user carrying a `distinguishedName` attribute, whether or not -the source imported them. So Backstage creates, renames, disables and enables the account over -LDAPS, and every password a member sets in Authentik — enrollment, recovery, a change — lands in AD -through the DN `buildAuthentikAttributes` carries whenever AD is configured. Writeback checks the -password against AD's complexity policy first and fails the Authentik change if AD refuses, so the -two cannot diverge. - -The account's CN is the member id and its `sAMAccountName` the Authentik username: the DN is -`CN=,` (`memberDistinguishedName`), and the UPN `@`. -`userAccountControl` is `NORMAL_ACCOUNT | DONT_EXPIRE_PASSWORD` (66048), plus `ACCOUNTDISABLE` -(66050) for an archived member. `DONT_EXPIRE_PASSWORD` is load-bearing: an expiry would force a -change at the Windows logon screen, which never reaches Authentik. - -AD accepts `unicodePwd` only over an encrypted connection, so the URL must be `ldaps://`. The DC's -certificate is self-signed: `AD_LDAP_CA_CERT` pins it (base64-encoded PEM, replacing the system -trust store rather than adding to it, and it has to name the host in the URL), or -`AD_LDAP_TLS_VERIFY=false` switches verification off, which wins over a pinned certificate. -`scripts/active-directory-probe.ts` binds with the configured values and lists the OU. - -The domain-side and Authentik-side setup is instance configuration nothing in the app can detect, -like Authentik's subject mode, and it lives on the studio wiki: the two delegated service -accounts; the LDAP source, with writeback on and user and group sync off; `LDAPBackend` removed -from every password stage, without which every wrong Authentik password is also a failed AD bind -counting toward lockout; the "Remove Change Password" GPO; a finite lockout duration, since an -Authentik reset does not unlock an AD account; and an `adm-` or `svc-` prefix on every account made -by hand, since Authentik's username collision check sees only Authentik. - ---- - -## Sync architecture - -Every external mutation creates a `SyncJob` row and executes it **synchronously**: `runSyncJob` -writes the row and hands it to `executeSyncJob` (`lib/sync/executor.ts`). Failures persist as -`FAILED` rows at `/admin/sync-jobs`, retried manually via `retrySyncJobAction`. There is no -background worker. - -Service code calls **orchestrators**, never handlers directly. An orchestrator decides only what -the row says and whether the call should be skipped; `runSyncJob` writes and runs it, so "the row -exists before anything is attempted" is stated once rather than per target. -`createAuthentikUser` is the one exception: it runs *before* the Member exists -(its `sub` becomes the Member `id`), so it bypasses the job row and the service fabricates a -SUCCESS row afterwards. - -Handlers receive `(payload, memberId, prisma, jobId)` and resolve external identifiers **at execute -time** — -Authentik via `getUserPk(memberId)`. Resolving late rather than baking IDs into the payload is -what makes retry work: a job that failed because Authentik could not resolve the member succeeds -on retry once the account is fixed. A missing link throws, so it lands as a visible `FAILED` row -instead of being silently skipped. - -**Members with no Authentik account are skipped, not failed.** Every Authentik orchestrator goes -through one choke point (`runAuthentikJob`, `lib/sync/authentik/orchestrators.ts`) that checks -`hasAuthentikAccount(memberId)` and passes `runSyncJob` a skip reason when it is false. A prefixed -id has no user to resolve a pk from, so unlike an account Authentik fails to resolve, no admin -action could ever make the job succeed. The row is still written, -as `SKIPPED` with `result: { reason }` — an id wrongly carrying the prefix would otherwise stop -syncing silently forever. No handler runs, `attempts` stays 0, nothing reaches Sentry. - -Every FAILED job is also reported to Sentry from inside `executeSyncJob` — see Observability. - -API routes return HTTP **207** with `{ ..., syncErrors: string[] }` when the DB write succeeded but -a sync step failed; `syncJson` and `syncJsonResource` (`lib/api-response.ts`) are what choose -between that and the normal answer, and services build the list with `collectSyncErrors`. The UI -shows a warning toast, not an error. - -Authentik specifics: `REACTIVATE_USER` is the reverse of `DEACTIVATE_USER` — the account goes -back to `is_active` and the `users` path. `UPDATE_USER` rebuilds `attributes` from the member row -at execute time whenever its payload carries them, because Authentik replaces the set wholesale -and a retried snapshot could predate newer values or the DN. The payload's `attributes` are the -record of what was asked, not what is sent. - -Website specifics: one operation, `SYNC_MEMBER` on the `WEBSITE` target, with an empty payload. -The handler reads the member row at execute time and sends an `upsert` or — when the row is -archived — an `archive`, so a retry pushes what is true *now*. `orchestrateSyncWebsiteMember` is -the choke point and skips when the webhook is unconfigured. Every member mutation calls it: -create, update, archive, reactivate, both batch operations, both role operations, and both avatar -paths. - -Google Group specifics: `ADD_TO_GROUP` / `REMOVE_FROM_GROUP` on the `GOOGLE_GROUP` target, with -`{ email, groupEmail }` as the payload — the list is in the payload because there are two of them. -This target deliberately **does not** resolve its identifier at execute time: the address *is* what -the group holds, so re-deriving it from the member row would let a retry act on an address the job -was never about. `runGoogleGroupJob` is the choke point and passes the same kind of skip reason -when credentials or the target list are unconfigured. - -Only three things touch the lists automatically: creating a member adds the address to the main -list; a status change **into** alumni adds it to the alumni list (once — the two alumni statuses -share one list); archiving offers an optional removal, a checkbox in `ArchiveDialog` that -defaults to off and reaches the REST route as `?removeFromGoogleGroup=true`. Everything else is -manual on purpose — an email change leaves the old address on the list (the edit sheet says so), -nobody is removed on becoming alumni, leaving alumni does not undo the membership, and reactivating -an archived member does not put back an address the archive took off. The reconciliation page is -where those leftovers surface, and the archive dialog's checkbox says as much. - -Active Directory specifics: `CREATE_USER` and `SYNC_MEMBER` on the `ACTIVE_DIRECTORY` target. -`CREATE_USER` carries `{ username }`, because Backstage stores no username and the one -`createAuthentikUser` settled on cannot be re-derived later; everything else both handlers read -from the member row at execute time, so a create retried after an archive makes the account -disabled. AD answers code 68 for a taken DN, `sAMAccountName` or UPN alike, so the client counts it -as success only when a base search finds the DN — otherwise another account holds the name and the -job fails. `SYNC_MEMBER` replaces the names and the whole `userAccountControl`, so it is also the -disable and the enable. `runActiveDirectoryJob` is the choke point: it skips a member with no -Authentik account first, then an unconfigured AD. `createMember` creates the account and, with AD -configured, sends Authentik a second `UPDATE_USER` carrying the DN, which names the member id -`createAuthentikUser` only returns; `updateMember` syncs a first or last name change; archiving, -the batch archive and reactivation sync the enabled flag. A `CREATE_USER` skipped for missing -configuration stays skipped once AD is configured, and the member's next edit sends the DN anyway, -which locks them out of Authentik password sets; insert the job by hand, as for an `x_` member. - ---- - -## Google Group reconciliation - -`/admin/google-group`. "Lista frissítése" reads the group live and rebuilds `GoogleGroupEntry`: -addresses no longer on the list are deleted, matched ones are linked to their member, hand-set -states and notes survive. There is no upload step — that was the pre-API design. - -Each refresh writes a `GOOGLE_GROUP_SYNCED` audit entry carrying -`{ addresses: { old, new } }`, and that entry **is** the "last read" timestamp the page shows. -Nothing else records when a read happened, and a column would duplicate the same fact. - -Two tables: the group's addresses (sortable by address or state, state ordered by how much -attention a row needs — `UNKNOWN` first, `MATCHED` last) and members no address on the list -resolves to. Alumni and archived members sit behind a checkbox in the second one, since they are -expected to be absent and would bury the rows worth acting on. - -Leaders read the page; refreshing and annotating are admin-only, enforced in the service and the -Server Action rather than by hiding the buttons. - ---- - -## Observability - -Sentry (`@sentry/nextjs`), errors only — no tracing, no session replay. There is no background -worker and no alerting elsewhere, so this is the only thing that tells anyone a sync broke. - -`tracesSampleRate` is deliberately **absent** rather than `0`. Sentry's `hasSpansEnabled()` treats -`0` as "tracing on, sample nothing", which still builds spans and attaches `sentry-trace` / -`baggage` headers to every outgoing call. - -**Bootstrap.** `instrumentation.ts` `register()` imports `sentry.server.config.ts` or -`sentry.edge.config.ts` depending on `NEXT_RUNTIME`; the browser gets `instrumentation-client.ts`. -All three call `Sentry.init(sentryInitOptions())` from `lib/observability/sentry.ts`, so the three -runtimes cannot drift. That module stays free of server-only imports — it ends up in the client -bundle. - -**Disabled without a DSN.** `NEXT_PUBLIC_SENTRY_DSN` unset ⇒ `enabled: false`, so dev machines and -the test suite never phone home. It is a `NEXT_PUBLIC_` value, so `next build` freezes it into the -bundle: the CI image build has to supply it, setting it on the running container is too late. - -**What gets captured.** - -| Source | Where | -| --- | --- | -| FAILED `SyncJob` | `captureSyncJobFailure` in `executeSyncJob` | -| Unhandled service errors behind a 500 | `captureServiceError` in `mapServiceError` | -| Server render errors and rethrowing Server Actions | `onRequestError` in `instrumentation.ts` | -| Client render errors | `app/global-error.tsx` | - -`onRequestError` is server-side only, so a React render error in the browser reaches Sentry solely -through `global-error.tsx`. That file replaces the root layout when it renders, so it carries its -own ``, stylesheet import and next-themes-compatible theme resolution. - -A sync failure is tagged `sync.target`, `sync.operation`, `sync.job_id` and `member.id` — the alert -names the member and the system without anyone opening `/admin/sync-jobs`. The job **payload is -never attached**; it holds an email and a mobile number. - -**Typed errors are not incidents.** `NotFoundError`, `ForbiddenError` and `ValidationError` are -control flow. They are filtered in three places: `isExpectedError` (by `name`, not `instanceof`, so -`lib/observability/sentry.ts` stays independent of `lib/errors.ts`, which imports `capture.ts`), -and again in `beforeSend` by exception type as a backstop. - -**PII scrubbing** (`lib/observability/scrub.ts`) runs on every outgoing event. Emails and phone -numbers are redacted out of the message, exception values, `extra`, `contexts`, breadcrumbs and the -request URL; sensitive keys (`email`, `mobile`, `password`, `token`, `payload`, …) are dropped -wholesale; `user` is reduced to `id` + `username`; request headers, cookies and bodies are dropped -entirely. Tags are deliberately **not** scrubbed — every tag value is written by our own code, and -the phone matcher would otherwise eat identifiers that are long runs of digits. - -**And nothing is collected that the scrubber would only have to drop.** The SDK collects every -`dataCollection` category by default, so the restrictive baseline is spelled out rather than -inherited — `scrub.ts` is the second line of defence, not the only one. `userInfo`, `cookies` and -`httpHeaders` are off and `httpBodies` is none; `urlQueryParams` keeps the query string but denies -any key containing `name`, which is how a member's own name arrives at -`/api/usernames/suggest?firstName=…&lastName=…` — deny terms match as case-insensitive substrings, so -the one term covers every spelling. `databaseQueryData` is pinned for a different reason: it reaches -nothing while tracing is off, and the pin is what keeps enabling tracing from shipping the values -Prisma binds. The remaining span-scoped categories (`queues`, `genAI`, `graphQL`) have no integration -behind them at all and are left alone. - -**Release tagging.** Every version string has to parse as semver in Sentry, or the release gets no -ordering, no `release.version` filter and no regression detection. Release tags stay `vX.Y.Z`, but -the version drops the `v`, which Sentry's parser refuses. A commit carrying a release tag builds as -`X.Y.Z`; anything else as `X.Y.Z.+`, or `…+.` off `main`, -the branch reduced to `[0-9A-Za-z-]`. The distance goes in the fourth, *revision* number because -Sentry compares that numerically but a prerelease as plain text, where `dev.10` sorts below -`dev.9`; the build code after `+` takes no part in the ordering. Only `vX.Y.Z` tags count, so a prerelease -tag never resets the count, and only along the first-parent line, so neither does a tag on a merged -branch. A working tree with uncommitted -changes never builds as the bare tag. `next.config.ts` derives this locally and the publish -workflow in CI, which passes it in as `NEXT_PUBLIC_APP_VERSION` — the two are written to match and -nothing ties them together. - -The release name is `backstage@`: release names are shared across the whole Sentry -organization, so the project slug keeps them apart. `next.config.ts` builds it once into -`NEXT_PUBLIC_SENTRY_RELEASE`, which `sentryInitOptions` sets on every runtime and `withSentryConfig` -uploads source maps under, so the two cannot name different releases. Source map upload switches -itself off unless `SENTRY_AUTH_TOKEN` is present — that secret belongs to the Docker build, not to -a local `pnpm build`. - -**Client events tunnel through `/monitoring`.** `tunnelRoute` in `next.config.ts` makes the browser -POST envelopes to our own origin instead of `sentry.io`, which ad blockers filter by hostname. -The route is generated by the Sentry plugin, so it exists only in a real build — and it is in -`publicPaths` in `proxy.ts`, because an error on the login page has no session cookie yet and a -redirect would drop the POST body. - -**Structured logging** (`lib/observability/logger.ts`) writes one JSON object per line to -stdout/stderr. `/api/usernames/suggest` logs the calling service account (`sub` + service account -name) with the outcome on every path — service account names are not PII, so they are logged raw. -The calendar logs `calendar_read_failed` when Google will not answer — deliberately a warning -rather than a Sentry event, see Architectural decisions — and `calendar_page_cap_reached` when a -read stops at `MAX_PAGES`, which at studio scale means something is wrong rather than that a real -limit was reached. `POST /api/computers/[id]/ping` logs the calling agent and the outcome on every -path, `registered` marking a machine's first ping and a rejected body carrying the `details` -tree naming the field that failed — nobody reads the 400 the agent gets back. Nothing else -logs through it yet. - ---- - -## Release engineering - -**Image.** Multi-stage `Dockerfile` on `node:24-alpine` producing the Next.js standalone server, -running as a fixed non-root UID (65532) so k8s `runAsNonRoot` can verify it. `output: "standalone"` -in `next.config.ts` is what makes the runtime stage possible; the traced bundle plus `.next/static` -and `public/` is all the runner gets. - -**Migrations run from the entrypoint**, not a separate Job — one replica, one deploy. -`RUN_MIGRATIONS=false` opts a container out. The standalone bundle contains only what the server -traced, so the image carries its own copy of the Prisma CLI, npm-installed at build time from the -versions in `package.json` so it cannot drift from the generated client. `prisma.config.ts` is -copied next to that `node_modules` tree because its `dotenv` and `prisma/config` imports have to -resolve somewhere, and the config's relative migrations path then points at the copy beside it. -Do not try to shrink that tree by deleting `@prisma/studio-core` or `@prisma/dev`: `build/cli.js` -requires both eagerly and the CLI stops loading at all. - -**Build args vs secrets.** Every `NEXT_PUBLIC_*` value is a plain build arg — -`NEXT_PUBLIC_SENTRY_DSN`, `NEXT_PUBLIC_SENTRY_ENVIRONMENT`, `NEXT_PUBLIC_APP_VERSION` and -`NEXT_PUBLIC_COMMIT_HASH`. A DSN is public, the rest name the build or the environment, and -`next build` freezes all of them into the bundle, so setting any of them on the running container -reports nothing at all. `NEXT_PUBLIC_SENTRY_RELEASE` is the exception: `next.config.ts` derives it -from the version and it is not an input. An empty -`NEXT_PUBLIC_SENTRY_ENVIRONMENT` falls back to `NODE_ENV`, which is what an unconfigured image -reports as. `SENTRY_ORG` and `SENTRY_PROJECT` are build args too: they only address the upload -target, and without them `withSentryConfig` has nowhere to send source maps. `SENTRY_AUTH_TOKEN` -is the only real secret and comes in as a BuildKit secret mount; a build arg would land in the -image history. The publish workflow derives the version from the ref it is building and its -distance to the last release tag — hence the full-history checkout — and the hash from the commit, -and reads `NEXT_PUBLIC_SENTRY_DSN`, `NEXT_PUBLIC_SENTRY_ENVIRONMENT`, `SENTRY_ORG` and -`SENTRY_PROJECT` from repository *variables*, the token from a repository *secret*. - -**Workflows** (`.github/workflows/`), actions pinned to a patch version, mirroring -[BSStudio/request-manager](https://github.com/BSStudio/request-manager): - -| Workflow | When | What | -| --- | --- | --- | -| `ci.yml` | PR, push to `main` | Biome + tsc, Vitest with coverage → Codecov, commitlint over the PR range | -| `docker-publish.yml` | PR (build only), push to `main`, `v*.*.*` tags | Buildx → `ghcr.io/bsstudio/backstage`, provenance attestation | -| `authentik-contract.yml` | Weekly cron, dispatch, PRs touching `lib/authentik/**` | Diffs Authentik's published spec against `scripts/authentik-contract.json` | - -`ci.yml` carries no `paths` filter on purpose: it is what `main` is protected on, and a required -check that never runs on a docs-only PR blocks that PR instead of passing it. Both node jobs run -`prisma generate` first — `app/generated/prisma/` is gitignored and `tsc` resolves through it. - -The contract check reads goauthentik's `main` rather than a release, so a field rename shows up -before the studio instance is upgraded into it. A scheduled run that drifts opens an issue -(deduplicated by title); on a PR the red job is the notification. The snapshot is refreshed by hand -with `pnpm authentik:contract --update` once the change is understood. - -**Renovate** (`.github/renovate.json`). Non-major npm and GitHub Actions groups and the monthly -lockfile refresh automerge; everything else waits for a human. Majors are separate PRs, which is -what a Next.js major wants to be: a PR that says a version landed and that nobody merges on -sight, because it is a migration rather than a bump (see `AGENTS.md`). The config file does -nothing on its own: the Renovate GitHub App has to be installed on the repository. -`schedule:weekends` opens PRs across both days, `automergeSchedule` merges them Monday 04:00–08:00. -The gap is the review pass: by Monday `schedule:weekends` has stopped opening new PRs, so the set -that merges is the set that was reviewed. An out-of-schedule run still processes branches that -already have a PR — only branch *creation* is skipped — which is what leaves the Monday window -reachable at all. That pairing is also why `platformAutomerge` is **off**: GitHub's own auto-merge -is enqueued when the PR is created and fires the moment checks go green, which ignores -`automergeSchedule` entirely. Renovate merges these itself instead, and it waits for CI on its own -rather than relying on branch protection. Vulnerability alerts opt back out of both schedules. - -**GitHub-side settings.** None of this lives in the repository, all of it is assumed by the -automation, and all of it is set once: - -| Kind | Name | Without it | -| --- | --- | --- | -| Secret | `CODECOV_TOKEN` | the coverage upload fails the `Test` job (`fail_ci_if_error`) | -| Secret | `SENTRY_AUTH_TOKEN` | no source map upload, so production stack traces stay minified | -| Variable | `NEXT_PUBLIC_SENTRY_DSN` | the built image reports nothing to Sentry at all | -| Variable | `SENTRY_ORG` / `SENTRY_PROJECT` | source map upload has no target | - -Branch protection on `main` requires exactly `ci.yml`'s three jobs — `Lint and typecheck`, `Test`, -`Commit messages`. The Docker and contract jobs must **not** be required: both filter on paths, and -a required check that never reports blocks the PR forever instead of passing it. "Require branches -to be up to date" stays off, or every Renovate PR stalls — `rebaseWhen: "conflicted"` will not -rebase a merely stale branch. - -Also repository-level: squash-only merging (see Renovate above), the -Renovate and Codecov apps installed, Issues enabled with the assignee named in -`authentik-contract.yml` holding repository access, and the `ghcr.io/bsstudio/backstage` package's -access linked back to this repository so deployment hosts can pull it. - ---- - -## Testing - -Vitest + Testcontainers (real PostgreSQL). Service-layer tests are integration tests against the -container; routes and actions get smoke tests for auth and error mapping only. - -- `tests/setup.ts` — starts the container in `beforeAll`, migrates, truncates all tables in - `afterEach`, stops in `afterAll`. Exports `getTestPrisma()` and `mockPrisma()`. -- `tests/helpers.ts` — `mockSession(overrides)` / `mockNoSession()` replace `@/lib/session` for - route tests. `mockAuthApi(getSession)` mocks `next/headers` and `@/lib/auth` *underneath* it - instead, so action and session tests run the real helpers and genuinely exercise the role - predicates. `mockWebsiteOrchestrators()` and `mockGoogleGroupOrchestrators()` stub the external - fan-out and return their mocks, so a test can fail one call and assert on it. Each factory is - annotated with the module it replaces, because `vi.doMock` swaps one wholesale: an orchestrator a - factory leaves out is `undefined` at the call site, and the annotation turns that into a - typecheck failure in the helper. -- Route tests use `vi.resetModules()` + `vi.doMock()` + dynamic `import()` to swap in the test - database and session before each test. - -Coverage includes `app/**/*.ts`, `lib/**/*.ts`, `types/**/*.ts` and, named one by one because -a root glob would pull in every config file, `proxy.ts` and `instrumentation.ts` — route -protection and the Sentry bootstrap are not wiring. It excludes `app/generated/**`, -`app/api/auth/**`, and the config/wiring files `lib/auth.ts`, `lib/auth-client.ts`, -`lib/prisma.ts`, `lib/utils.ts`. The 100% figure is enforced, not just documented: -`coverage.thresholds` in `vitest.config.ts` fails `pnpm test:coverage` — and so CI — on a drop. - ---- - -## Architectural decisions - -Why things are the way they are. The *what* is in the code. - -**Service layer.** Business logic lives in `lib/services/`. Routes and Server Actions are both thin -adapters over the same service, so behaviour cannot drift between the HTTP and RSC paths. Typed -errors (`NotFoundError`, `ForbiddenError`, `ValidationError`) flow through `mapServiceError`. - -**Authorisation is the service's job, not the caller's.** A service function that takes an `Actor` -guards on it. The alternative — checking in the route and the action and trusting the service — -held for a while and left six mutations whose `actor` was only ever written to the audit row, -looking like a guard from the outside. Callers may still refuse early, but nothing depends on it. - -One caller has to: `POST`/`DELETE /api/members/[id]/avatar` write to storage *before* calling the -service, so `authorizeAvatarChange` refuses first. A guard that only fires inside the service would -let an unauthorised caller move bytes and be told no afterwards. - -**Authentik attributes are replaced, not merged.** `PATCH /core/users/{pk}/` overwrites the whole -`attributes` object, so `buildAuthentikAttributes(member)` always sends the full managed set -(`first_name`, `last_name`, `mobile`, `avatar_url`, and `distinguishedName` when AD is -configured). Trade-off: attributes set by hand in the Authentik admin UI get wiped on the next -update. Acceptable for a managed fleet. - -`avatar_url` is absolutised against `APP_URL` on the way out, while the DB and the UI keep the -`/avatars/…` path. Authentik renders the attribute verbatim and has no placeholder for our origin, -so a path there would resolve against the Authentik host. - -**Status change adds before removing.** `orchestrateStatusChange` issues `ADD_TO_GROUP` before -`REMOVE_FROM_GROUP`. If ADD fails nothing changed; if REMOVE fails the user is briefly in both -groups, which a retry fixes. The reverse order risks leaving a user in **no** group. - -**"Has an Authentik account" is encoded in the id, not in a column.** The alternative was an -`authentikUserId` or a `hasAuthentikAccount` field. Neither pays for itself: `Member.id` must -*already* equal the Authentik UUID for login and for pk resolution, so a member who gains an account -needs their id rewritten either way — a column would not save that, only duplicate a fact the id -already carries, where the two can drift. The prefix also keeps the check pure, which matters -because orchestrators receive a bare `memberId` and would otherwise have to read the member row on -every sync. Cost: ids are not uniformly UUIDs, and the prefix shows up in member URLs — and, once -avatars are embedded on the public website, in `/avatars/-square.webp` too. That is why the -token is meaningless rather than descriptive. It does not hide the id itself: an avatar URL -publishes the member's Authentik user UUID either way. Fixing *that* means minting avatar -filenames independently of the member id, which is not done. - -**No background retry queue.** A failed sync reports to Sentry, somebody looks, and it is retried by -hand from `/admin/sync-jobs`. At ~30 members the failure rate does not justify a worker, a queue -table or the operational surface of pg-boss or a CronJob. Revisit only if manual retries become -routine. - -**A new member's username is shown once.** `createMember` returns the name `createAuthentikUser` -settled on after its collision loop alongside the member, and the create form shows it on its -success screen with a copy button. Nothing stores it, so that screen is the only place it is ever -visible — and the welcome email carrying it is written by hand. - -**Avatar upload and removal are separate audit actions.** The avatar URL is deterministic from the -member UUID, so a replace would be invisible in the diff without dedicated `AVATAR_UPLOADED` / -`AVATAR_REMOVED` actions. `removeMemberAvatar` no-ops when there is nothing to remove. - -That same determinism is why both paths sync Authentik only when `avatarUrl` actually changes -(`syncAvatarUrl` in `lib/services/members.ts`): replacing an image leaves the URL identical, so the -attribute push would be a `SyncJob` row and an Authentik call that change nothing. The audit entry -is still written — that is what records the replace. - -**Avatars are cached like Gravatar, not versioned.** The route answers `max-age=300` with -`stale-while-revalidate` and `stale-if-error`, and Cloudflare caches it at the edge. A content hash -in the URL would let the cache run for a year, but it would make every replace a URL change, and so -a push to Authentik and the website — the thing the determinism above exists to avoid. A stable URL -with a five-minute lifetime is what Gravatar does for the same reason: everyone sees a new image -within minutes, and nothing downstream is told. The uploader sees it at once, because -`member-avatar.tsx` appends its own `?v=` — only on that page, so the rest of the portal waits out -the lifetime like everyone else. `stale-if-error` is the reliability half: the public website -hotlinks these URLs, and the edge keeps serving the last copy for a week while Backstage or the -bucket is down. A 404 is `no-store`, or a removal followed by a quick re-upload would keep -answering from a cached miss. - -The edge half is instance configuration nothing in the app can detect, like Authentik's subject -mode. A Cache Rule on `/avatars/*` sets *Eligible for cache*, edge TTL from the origin header, and -browser TTL **Respect origin** — the setting the rule exists for, because the zone's Browser Cache -TTL rewrites any origin `max-age` shorter than itself and browsers would hold a replaced avatar for -hours. The cache key keeps its query string, or the uploader's `?v=` would be served the old copy. - -**Leadership role → group sync.** Assigning a role adds the member to the common Leadership group -(`AUTHENTIK_GROUP_LEADERSHIP_UUID`) **plus** any role-specific `authentikGroupIds`; `removeRole` -removes both. Updating an existing role keeps the Leadership membership and only diffs the -role-specific groups. - -**Archiving ends the leadership position; reactivating does not hand it back.** The -`LeadershipRole` row used to survive an archive, which left the profile rendering a title nothing -backed and forced reactivation to choose: re-add the Authentik groups, or leave the portal claiming -a position it does not grant. `archiveMember` and `batchArchive` therefore call `removeRole`, which -already deletes the row, snapshots it to the timeline and takes back both the common Leadership -group and the role-specific ones. A returning member is given a position again by hand, which is -the rarer case and the one a leader should decide deliberately. - -**Archiving and reactivation are idempotent, not refused.** `reactivateMember` returns a member -who is not archived unchanged rather than throwing, and `archiveMember` does the same for one -already archived — the same shape as `PUT /roles` treating an identical role as a no-op. A stale -tab or a double click would otherwise move `archivedAt`, or write a second `MEMBER_REACTIVATED` -audit entry and a second round of sync jobs. - -Reactivation re-adds the status group even though archiving never removed it, because the -Authentik account may have been tidied up by hand in between and `add_user` is idempotent. That -is cleanup; the leadership groups are a decision, which is why they are not in the same list. - -The archive keeps one exception: `?removeFromGoogleGroup=true` runs on an already archived member. -The checkbox defaults to off and the archive button is gone once a member is archived, so a leader -who left it unchecked would otherwise have no way back to the removal but the reconciliation page. - -**No REST routes for admin resources.** Sync jobs, the audit log and the Google Group -reconciliation are read through their services from server components; mutations go through Server -Actions. No external consumer exists, so an HTTP API would be surface area for nothing. - -App links follow the same rule for a different reason: `/apps` is a page every member reads, not -an admin resource, but nothing outside Backstage consumes the list either. `GET /api/apps` is the -one route worth adding the day another studio app wants to render the same launcher. - -Computers too, and the write route does not imply the read one: the agent has to POST from off the -server, but nothing outside Backstage reads machine status, so `/computers` reads the service -directly. `GET /api/computers` arrives the day something does — a lobby display, say. -`GET /api/computers/[id]/rdp` is not that route arriving early: it hands one caller a file rather -than the list, and it is a route at all only because a browser download needs a URL to follow to a -`Content-Disposition` — the same reason `app/avatars/[...path]/route.ts` is one. - -**Restricted reads go through a guarded service, never Prisma in the page.** Next.js is explicit -that a layout cannot gate its segments — they render regardless and land in the RSC payload — so -there is no one place to put a check for a group of pages, and the check belongs next to the data -instead (`node_modules/next/dist/docs/01-app/02-guides/authentication.md`, *Layouts and auth -checks*). Every portal page opens with `pageActor()`, but that is authentication rather than the -data guard: it validates a cookie the proxy only checked the existence of, and sends the wrong role -home. What makes restricted data safe is `listAuditLogs`, `listSyncJobs`, `listAuthentikGroups` -and `getGoogleGroupReconciliation` refusing an actor who may not see them. - -**The website gets one sync operation, not four.** Every other target has a verb per kind of -change; the webhook takes the member's whole record and works out for itself whether that is a -create, an update or a restore, so `SYNC_MEMBER` is the honest name and the `IngestResult` it -returns is what the job records. The handler also reads the member row at execute time rather than -carrying a snapshot in the payload, which is what makes a retry safe: a failed push retried after -the member was archived would otherwise put them back on the public site. Once the row decides, -a second operation name could only contradict what was sent. - -Cost: `/admin/sync-jobs` shows the same operation for every website row, and the payload column -shows `{}`. The `result` carries the detail instead. - -**A full sync is an audit entry, not a SyncJob.** `SyncJob.memberId` is a required FK and a whole -roster push is about no single member — the same reason `GOOGLE_GROUP_SYNCED` is an audit entry. -Like that one it names what the admin did, and the counts in `diff` say what came of it. The entry -is written *after* the push, so a refused one leaves no record claiming it happened, and each click -gets a fresh delivery id rather than a replayable one: re-syncing an unchanged roster is a no-op, so -a second click wanting to re-sync should. - -It uses `replace` mode, which archives on the website anything the list leaves out. That is how an -archived member is dropped, and it also means anyone the website holds that Backstage has never -known about goes with them — which is the point of a repair tool with an authoritative source, and -why the confirmation says so. The button is **hidden** when no webhook is configured rather than -offered and refused, the way an unconfigured `.rdp` download is dropped; the Google Group refresh -is the other way round only because that page is *about* that integration, where a log of sync jobs -is not. The service refuses regardless, since the Server Action stays reachable on its own. - -**The mailing list is reconciled, not synced.** Backstage writes to the group in exactly three -narrow cases (see Sync architecture) and otherwise only reads it. A full two-way sync would have to -decide what an address with no member means, and the honest answer is "a human knows" — so an email -change deliberately leaves the stale address in place and the reconciliation page reports it, rather -than the app guessing that the old one should go. - -**`KNOWN_ADDRESS` exists so `UNKNOWN` can reach zero.** Without a state meaning *decided, no member*, -every permanently-fine address — another list, an external contact — stays `UNKNOWN` forever, and -nobody can tell a reviewed row from an unreviewed one. The value is in the state, not the note; the -note is mandatory anyway because with no member link it is the only thing identifying the address. - -**The AD password is Authentik's, not Backstage's.** Backstage must never see a member's password, -and Authentik already runs enrollment and recovery; what it cannot do is create the AD account. So -each side does the half it can: Backstage creates the account with a random password nobody is -told — AD will not enable an account without one — and the member's next password set in -Authentik replaces it. Authentik's hashes cannot be copied across, so an account has no password -anyone knows until its member next sets one in Authentik. - -**The DN is sent before the AD account is known to exist.** `createMember` sends it alongside the -AD create, not after it succeeds, and every later update carries it. That locks a member whose AD -create failed out of every Authentik password set — the complexity check base-searches the DN and -requires an entry — until the job is retried. Accepted because onboarding mail goes out only once a -batch's jobs succeeded, so nobody is sent to set a password on an account that is not there. -Sending it only on success would need a record of that success to send on every later update. - -**The CN is the member id, not the username.** The DN is then derivable from the id with nothing -stored, the way the avatar URL is, and a rename never moves the entry. - -**Avatar storage is backend-agnostic.** `lib/avatar-storage.ts` is the facade: it validates member -ID shape, 5MB size and WebP magic bytes, then delegates to a backend implementing `AvatarStorage` -(`lib/storage/{types,local,s3,factory}.ts`), chosen once from `AVATAR_STORAGE=local|s3` and cached. -Both store the same logical key `-.webp` — local under `./storage/avatars/`, S3 under -`s3:///avatars/`. Migrating means copying files and flipping the env var; stored URLs -(`/avatars/`) stay valid. Local storage sits **outside `public/`** deliberately: static -serving would shadow `app/avatars/[...path]/route.ts` and break the S3 path. - -**Forms validate against the service's own schemas.** `lib/services/member-schemas.ts` exists only -so a client component can import a Zod schema without dragging Prisma and the sync layer into the -browser bundle — `members.ts` re-exports it, so service and API callers are unaffected. The forms -use `*FormSchema` variants (`CreateMemberSchema.required()` and friends) because a form always -submits every field as a present string, while an API caller may send a partial payload. Validation -messages therefore live in the schema and are Hungarian; they surface both inline in the form and -in a 400 body. - -**Email changes are leader/admin only.** Everything else on a member's own profile is -self-service. The email is not: it syncs to Authentik and reaches the OIDC `email` claim, which the -studio's other applications consume as an identity attribute, so an address Backstage has not -verified is not ours to set unilaterally. The guard lives in `updateMember`, so the API route and -the Server Action are both covered; the edit sheet hides the field via `canChangeEmail` rather than -relying on that. Self-service returns once the address can be confirmed before it reaches -Authentik. - -**A phone number is mandatory wherever there is an Authentik account, and is stored in one -spelling.** The number is a managed Authentik attribute, which the studio's other applications read -off the directory the same way they read the email claim — as an identity attribute they consume -rather than validate. One canonical spelling is the whole point: a number written four ways across -the systems this syncs to is a number nothing downstream can match on, and E.164 is the only format -that carries a country code unambiguously. Sometimes-absent is the same problem in a different -shape, since an attribute that may be missing is one every consumer has to special-case. - -Members with no Authentik account are the exception, because nothing reads attributes they do not -have. The prefixed id is what decides, and `updateMemberSchema(memberId)` and -`editMemberFormSchema(memberId)` in `member-schemas.ts` both resolve it through -`hasAuthentikAccount`, so the service and the edit sheet cannot disagree about who may go without -one. - -Separators are stripped rather than rejected — a pasted `+36 30 123 4567` is the same number — but -a missing country code is **not** guessed. Members carry Slovak and Romanian numbers as well as -Hungarian ones, so mapping a leading `06` to `+36` would turn a Slovak `0903…` into a valid-looking -Hungarian one: a wrong number that passes every check downstream, which is worse than the error the -member sees instead. The pattern excludes a leading zero after the `+` for the same reason — no -country code starts with one. - -**Save is disabled until something changes.** Edit forms subscribe to TanStack Form's -`isDefaultValue`, so reverting an edit re-disables the button. The role checkbox group keeps its -value sorted for the same reason — the comparison is order-sensitive, and unchecking then -rechecking a box would otherwise read as a change. Field errors stay hidden until a field is -blurred or a submit is attempted — forms register the schema on `onChange` **only**, and the field -components re-run that validator from their blur handler so a field the user tabbed straight past -still reports. Registering the same schema on `onBlur` as well looks equivalent and is not: every -validation cause keeps its own slot in `errorMap` and `meta.errors` flattens all of them, so the -field renders two messages at once and keeps the stale one until that cause fires again. - -**The edit sheet's forms live inside `SheetContent`.** Radix unmounts it on close, so both forms -remount with the current member data on every open — no reset effect, and a discarded edit cannot -survive a reopen. - -**CardDAV is served from the proxy, not a route handler.** Next answers 405 to any method outside -its seven known ones before a route handler runs, and CardDAV needs PROPFIND and REPORT — the proxy -is the only place in the request path that sees them. It defaults to the Node.js runtime in Next 16, -so Prisma works there; the import is dynamic and `isCardDavPath` lives in its own module, so no -other request pays for the handler's module graph. - -**`skipTrailingSlashRedirect` is on for CardDAV's sake.** WebDAV collection URLs end in a slash, and -Next's own trailing-slash redirect fires ahead of the proxy — `/api/carddav/` answered 308 and a -client following the hrefs the server advertises never arrived. Every other path keeps the -behaviour, reinstated in `proxy.ts` from a plain `URL`: `NextURL` records the trailing slash on -construction and puts it back on serialisation, which redirected the path to itself. - -**Device tokens are SHA-256, not bcrypt.** The token is 256 random bits with no low-entropy -structure to stretch, so a slow KDF buys nothing against brute force and costs a derivation on every -sync poll — a phone re-authenticates constantly. A plain digest also makes verification a -unique-index hit rather than a scan over the member's tokens. - -**A vCard carries contact details only.** Name, nickname, email, mobile, avatar. Membership status, -joined semester, dorm room, university and leadership role stay out: a phone's address book has no -use for them, and they would sit unencrypted on every synced device and in whatever backup it feeds. -A test asserts those properties stay absent. - -**The address book drops `ALUMNI` and keeps `ACTIVE_ALUMNI`.** The alumni roster grows by roughly ten -a semester and never shrinks, so syncing it would bury every member's contacts under people the -studio has not seen in years. `ACTIVE_ALUMNI` exists to mark an alumnus still around, which is -exactly who is worth carrying — so this is the one place where the difference between the two -statuses does something rather than label a badge. - -**Apple clients will not send Basic credentials over cleartext HTTP.** Established by testing, not -from documentation: against `http://:3000` an iPhone completes discovery, receives a -well-formed `WWW-Authenticate: Basic` challenge, sends no `Authorization` header at all, and reports -the password as incorrect. `curl` authenticates against the same endpoints and gets its 207. CardDAV -therefore needs a TLS origin the device trusts; there is nothing to fix in the handler. - -**An app link's URL is checked for its protocol, not just its shape.** `javascript:alert(1)` is a -structurally valid URL, and the card renders `url` straight into an `href`. `z.url({ protocol: -/^https?$/ })` in `app-link-schemas.ts` is the only thing keeping it out; a plain `z.url()` accepts -it. A test asserts the refusal. - -**The accent is an enum of eight presets, not a colour.** An admin picks from a fixed row of -swatches, so nothing free-text reaches the CSS and no choice can fight the ground — the presets -share a lightness and chroma, only the hue moves. The class strings are written out per accent in -`APP_LINK_TILE_CLASS` rather than interpolated, because Tailwind only ships classes it can see in -the source. - -**The icon picker is a grid, the member picker is a combobox.** Searching works when you already -know the name you want, which is true of a member and false of an icon — choosing one means -browsing what exists. Thirty-two glyphs fit in one popover grid with nothing to scroll, which also -sidesteps shadcn's `CommandList` hiding its own scrollbar and reading as a list of eight. - -**Ordering is two arrows, not a `sortOrder` field.** The number is an implementation detail; an -admin thinks in terms of what sits above what. `moveAppLink` renumbers every row from the resulting -order rather than swapping two values, so a list that arrived with gaps comes out contiguous. - -**The calendar is fetched and rendered, not embedded.** An iframe of a public Google calendar is -one line of markup, but it drops Google's own chrome, locale and theme into the middle of the -portal and none of the three is ours to change. Fetching means the events are Hungarian, match the -rest of the UI, and can be reshaped into a hero plus four weeks rather than a month grid nobody -asked for. The cost is a credential and a sharing step, and the Google Group integration had -already paid both — the calendar rides the service account that was there anyway. - -**A calendar that will not answer is not an incident.** `getDashboardCalendar` catches the read, -logs a warning and returns `status: "unavailable"`, which the widget renders as a line of text. -Sentry is deliberately not involved: every member loads this page, so a merely slow calendar would -report an outage on every refresh, and the person looking at the dashboard has already been told. -A failure is also retried sooner than a success is refreshed — one minute against five — so that -thirty dashboards do not each wait out the same timeout, and a share that has just been fixed -appears while somebody is still looking. The cache is a single process-wide slot, like -`lib/rate-limit.ts`: correct because the deployment is one replica, and merely one read per -replica if it ever is not. - -**The hero leads with the shortest event under way, not the longest.** A festival week and the -party inside it are both running, and the week is the one that sorts first and says least — -"egész nap", against a start date days in the past. So `getDashboardCalendar` separates what has -begun from what has not and ranks the running ones by duration: a seven-hour party is a more -specific answer to "what is happening" than the week containing it. Everything else running -becomes a `Közben` line and the soonest unstarted event an `Utána` line, so one card answers all -three questions. A multi-day event carries its span and its remaining days wherever it appears — -the one thing a reader cannot act on is an event that never says when it ends. - -That ranking also buys an invariant: **every running event belongs to the hero, so nothing the -week list receives is dated before today.** `groupIntoWeeks` relies on it — it buckets on the -event's own date with no clamp, and an event dated into a week already gone would silently -vanish. Anything that lets a running event reach the list has to restore that clamp. - -**A calendar description is untrusted HTML.** The `url` on an event is the first anchor in a -description written by whoever created the event, and it lands in an `href` — so only `http(s)` -survives, for the same reason `z.url({ protocol: /^https?$/ })` guards an app link. A plain "first -link wins" would make `javascript:` clickable on the dashboard. A test asserts the refusal. - -**The dashboard gets chips, `/apps` gets rows.** The home page is a shortcut bar, and a second copy -of the card grid would compete with the calendar it now sits beside, and with machine status once -that lands. The chips are monochrome for the same reason — the accent is what identifies an app in -the catalogue, and the shortcut bar is not the catalogue. - -**A workstation registers itself.** The alternative was an admin creating each `Computer` row and -copying its id into the agent config. The machine is the only thing that knows it exists, and a -row created by hand is a second place to keep in sync for no gain at three workstations. Cost: -any agent may create a row under any id, so a typo in one config leaves a stray machine on the -page until an admin deletes it — which is why the id has a shape at all. Deleting is not -permanent; the machine comes back on its agent's next ping. - -**The first service account to claim a computer id owns it.** Every agent token sits on a machine -members have physical access to, so without this a credential lifted off one workstation could -rewrite any other machine's status. Re-pairing a machine means deleting it from `/computers` -first, which is the rare case paying for the common one. - -**One Authentik group per kind of machine-to-machine caller.** Login and client-credentials tokens -share an issuer and an audience, so group membership is the only boundary — and with a single -API-client group, a token lifted off a workstation would also be a credential on -`/api/usernames/suggest`. `requireApiClient` therefore takes the caller kind and each names its -own group. The default keeps existing callers unchanged. - -**The agent reports facts; the portal decides what they mean.** A locked machine counts as free, -but the agent sends `loggedInUser` and `locked` rather than the conclusion. Changing what counts -as free is then a portal change instead of a reinstall on three workstations. That is also why -`metadata` is JSON rather than columns, and why every field in it is optional with absent -distinguishable from empty: an old agent keeps working. - -**Occupancy comes from the session table, not `Win32_ComputerSystem.UserName`.** That property -names the console session, and an RDP client leaves the console locked behind it — so on a machine -being worked on remotely it reported nobody, while a machine-wide `Get-Process LogonUI` saw the -console's own lock screen and called the machine free. Both halves of the old signal failed on the -same case, and the studio works over RDP. The agent enumerates sessions through `wtsapi32` -instead: `WTSActive` is a session attached to a display, local or remote, and LogonUI is matched -by session id rather than machine-wide. `quser` prints the same table, but its `STATE` column is -localised where the API's connect state is a numeric enum. A session that is signed in but not -active — a disconnected RDP session, most often — reports as `locked`, which is the same thing the -portal already treats as free. - -**Local and RDP sessions are still not told apart in the portal.** The session table names the -station, and the agent throws it away: the portal's question is whether somebody is there, not how -they got there, and it is a distinction Windows already warns about at sign-in. - -**Status is a tinted card, not a value in a row.** Members open both surfaces with one question — -which machine can I sit at — and a label-and-value row answers it in the same grey at the far edge -of the card, so free and occupied look identical until read. `computerVerdict` reduces status plus -metadata to a tone and a Hungarian label, the tone colours the whole card ground, and the machine -name and the verdict sit in one block. Colour is what carries the answer at a glance and the label -is what carries it for everyone else, so neither stands alone. It lives in `lib/computers.ts` -rather than in the two components because the dashboard and `/computers` must not be able to -disagree about what free means. - -**Pages that draw from the clock refresh themselves with `router.refresh()`, not a polling -client.** `/computers` and the dashboard both resolve online, offline, "N perce" and the hero's -countdown *at render time*, so a tab left open does not merely miss new pings — it goes on -asserting readings it drew hours ago. `AutoRefresh` re-runs the server components every -`COMPUTER_REFRESH_MS`, which keeps the read in `listComputers` behind `pageActor()` and needs no -`GET /api/computers`; SWR and TanStack both want a Route Handler, which is the surface that -decision above declines to add. It stops while the tab is hidden and refreshes once on the way -back, because a hidden tab has nobody reading it and a returning one is stale by however long it -was away. - -A refresh re-runs the whole route, so the dashboard pays for its other widgets to update machine -status. That is affordable rather than free: the profile and app-link reads are small, and the -calendar is served from its own process-wide cache rather than calling Google. Machine status is -simply the fastest-moving thing on the page, so it sets the cadence. The interval has to stay -under `COMPUTER_ONLINE_WINDOW_MS` — a page redrawing slower than a machine can fall out of the -window would keep calling it online — and a test asserts that. - -**An offline machine renders no gauges and no occupancy.** Those are its last readings rather than -its current ones, and a stale bar reads exactly like a live one. Offline collapses to the name, -the badge, when it last pinged and which agent it is running — everything still true. - -**An offline machine still offers its `.rdp` file.** A silent agent says the agent is silent; it -says nothing about whether the workstation answers RDP. Withholding the download would be the -portal asserting something it does not know, which is the same line `computerVerdict` walks when -a missing `loggedInUser` reads as `Online` rather than `Szabad`. - -**The Authentik username is read off the login claim, never stored.** The `.rdp` file names an AD -account, and AD carries the same username as Authentik — so the download needed a name this app -had deliberately never kept. A column would duplicate a fact Authentik owns, the way an -`authentikUserId` would duplicate `Member.id`, and nothing would write it back after a rename in -the Authentik admin UI. Querying `/core/users/` for it works — `results[].username` is already in -`CONTRACT` — and costs a round trip plus a second cache beside `suggestUsername`'s. Neither is -needed: `preferred_username` arrives in the OIDC profile next to the `sub` already taken, so -`mapProfileToUser` carries it into the session and `overrideUserInfo` re-maps it on every -sign-in. A member with no Authentik account cannot sign in at all, so the case that would need a -fallback never reaches the file. - -**Where the workstations answer is configuration, not repository content.** The public DNS suffix, -the port and the AD domain are three environment values, and `.env.example` ships the suffix and -the domain **empty**, naming `example.hu` only in the comment above them: this repository is -public and none of the three is ours to publish. So a fresh `pnpm dev:setup` has the download -switched off until somebody fills the suffix in. They never reach the client either — the page -asks `isRdpConfigured()` for a boolean and the file is assembled in the route, so no hostname -lands in an RSC payload. A suffix that is unset — or a configuration `rdpConfig()` refuses at all, -an unusable `COMPUTER_RDP_PORT` included — drops the button rather than failing, the way an unset -`GOOGLE_CALENDAR_ID` drops the calendar widget. That is why `isRdpConfigured()` runs the same -parse the route does instead of testing the suffix for presence: a value only one of the two -rejects would render a button that 500s on every click. - -**The file prefills a name and never a secret.** `prompt for credentials:i:1` is the whole of the -authentication story — a password in an `.rdp` file would be a credential sitting in a downloads -folder. The username is dropped rather than escaped when it does not match -`/^[A-Za-z0-9._-]+$/`, because the file is line-oriented and a CRLF in that value would inject -settings of its own. - -**The agent is Windows PowerShell 5.1 under a SYSTEM scheduled task.** 5.1 is a Windows component -present on every box at a fixed path; PowerShell 7 is a separately installed app that would become -a second thing to maintain on the machines least worth babysitting, and `powershell.exe` is -findable by SYSTEM in a way `pwsh.exe` is not. SYSTEM is what makes the agent independent of who -is signed in and out of reach of a standard user. A local administrator can still stop it, and the -README says so rather than implying otherwise. - -**Uninstalling and reinstalling the agent kill the running process explicitly.** -`Unregister-ScheduledTask` removes the task definition and leaves the `powershell.exe` it started -running, and `-DisallowHardTerminate` — there so a member cannot stop the agent — means asking the -task to stop may not end it either. The agent is an endless loop holding its id, its URL and its -token in memory, so a survivor keeps pinging from a machine with nothing installed on it, and a -reinstall stacks a second one alongside reporting a different id. The installer matches on the -install path *and* an owner of `S-1-5-18`: an administrator who pasted a snippet naming that path -has it in their own shell's command line, and matching on text alone kills the console the -uninstall is being typed into. - -**Studio leaders history lives on the wiki.** Not an in-app page — the sidebar links to -`https://wiki.bsstudio.hu/doc/studiovezetok-AdWWlRMuAI`. Edits are rare, pre-2010 entries lack -contact details, and the wiki already provides editing and audit. - --- ## Conventions @@ -1574,7 +97,7 @@ contact details, and the wiki already provides editing and audit. Fields are `` + a field component, submit buttons go inside ``. `SelectField` for a handful of options, `ComboboxField` (Popover + cmdk) once the list is long enough to need typing — but only when the reader knows what they are looking for, see the icon - grid under Architectural decisions — its filter replaces cmdk's default fuzzy scoring, which matches any name + grid under [Architectural decisions](docs/decisions.md) — its filter replaces cmdk's default fuzzy scoring, which matches any name carrying the query's letters in order, with an accent-insensitive substring match. A field's `hint` renders a note under the input and joins `aria-describedby` - Every portal page exports `metadata` (or `generateMetadata`) for a descriptive Hungarian title diff --git a/README.md b/README.md index 53f6861..f363b2e 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,8 @@ 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 @@ -88,5 +88,5 @@ The image runs `prisma migrate deploy` from its entrypoint before starting the s `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). +supplies all of them from repository variables and secrets; see +[Release engineering](docs/releases.md). diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..c5b657e --- /dev/null +++ b/docs/api.md @@ -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 `.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 `.` on `COMPUTER_RDP_PORT` and prefills `COMPUTER_RDP_AD_DOMAIN\`; 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). diff --git a/docs/auth.md b/docs/auth.md new file mode 100644 index 0000000..e870323 --- /dev/null +++ b/docs/auth.md @@ -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 `/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. + +`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 `/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. diff --git a/docs/data-model.md b/docs/data-model.md new file mode 100644 index 0000000..68612f8 --- /dev/null +++ b/docs/data-model.md @@ -0,0 +1,57 @@ +# Data model + +Prisma client generated to `app/generated/prisma/`, using the `@prisma/adapter-pg` driver adapter over a raw `pg` connection. + +**Member** — central record. `id` is the member's **Authentik user UUID**: the OIDC `sub` claim at login and the `uuid` the sync layer filters `/core/users/` on to resolve a pk. There is no separate `authentikId` — `id` serves both, which is why Authentik's provider **subject mode must be "Based on the User's UUID"** (see [Auth](auth.md)). + +Members with no Authentik account — imported alumni, nobody the app created — carry an id prefixed with `LOCAL_MEMBER_ID_PREFIX` instead (`localMemberId()` / `hasAuthentikAccount()` in `types/index.ts`). The prefix is the *only* record that the account is missing, and unlike the random UUID it replaces it cannot be mistaken for a real one. Its value is a meaningless token rather than a descriptive word because member ids reach the public site inside avatar URLs — refer to the constant, not the literal. Giving such a member an account later means rewriting the id to the new UUID; Prisma's required relations default to `onUpdate: Cascade`, so a single `UPDATE "Member" SET id = …` carries every child row with it. + +No Backstage code runs in that procedure, so nothing writes the AD `CREATE_USER` job either, and the member's next edit sends Authentik a DN for an account that does not exist. Insert the job `createMember` would have written, then retry it from `/admin/sync-jobs`, which offers retry on a `PENDING` row. `id` and `updatedAt` are filled by Prisma rather than the database, so the SQL supplies them: + +```sql +INSERT INTO "SyncJob" (id, target, operation, "memberId", payload, "updatedAt") +VALUES (gen_random_uuid()::text, 'ACTIVE_DIRECTORY', 'CREATE_USER', '', + '{"username": ""}', now()); +``` + +**LeadershipRole** — only *active* roles, one per member (1:1 via `@unique` on `memberId`). Holds a free-text `label` and an array of `authentikGroupIds`. When a role ends the row is deleted and a `TimelineEntry` snapshot is written instead. Archiving the member is one of the ways it ends — see [Architectural decisions](decisions.md). + +**AuthentikGroup** — registry of known Authentik groups powering the role-assignment checklist. Populated manually by admins. The Authentik UUID is the primary key — no separate cuid. It is a constraint and not just a source for the checklist: `assignRole` refuses an id the registry does not name, or a leader could grant any group in the instance, `AUTHENTIK_GROUP_ADMIN` included. + +**TimelineEntry** — human-readable history: status promotions, role changes, archival, reactivation. + +**AuditLog** — field-level diff log, `{ field: { old, new } }` JSON. Written on every mutation. A status change and a field update in the same request produce **separate** entries (`STATUS_CHANGED` + `MEMBER_UPDATED`). `targetId` is nullable because `GOOGLE_GROUP_SYNCED` records a read of the mailing list and `WEBSITE_FULL_SYNC` a push of the whole roster, neither of which is about a single member. `targetLabel` names what an entry is about when that is not a member either — an app link, say. It is a snapshot rather than a relation, so the log still reads correctly after the row it names is renamed or deleted; an `APP_LINK_UPDATED` entry stores the name the link goes by *after* the change. + +**SyncJob** — one row per external call, against Authentik, Active Directory, the website or a Google Group. PENDING → IN_PROGRESS → SUCCESS | FAILED, plus `SKIPPED` for a call that was never attempted (see [Sync architecture](integrations.md#sync-architecture)). `memberId` is a required FK. Failed jobs surface at `/admin/sync-jobs` and are individually retryable, and so is a row left `PENDING` or `IN_PROGRESS` — execution is synchronous, so one outliving its request was interrupted. `SUCCESS` and `SKIPPED` are the two that are finished and refuse a retry. + +**GoogleGroupEntry** — one row per address on the mailing list, rebuilt from a live read. `matchStatus` is either derived by matching the address against `Member.email` (`MATCHED`, `ARCHIVED_ON_LIST`, `UNKNOWN`) or set by hand (`SECONDARY_EMAIL` — a member's other address; `KNOWN_ADDRESS` — no member behind it and that is fine, another list or an external contact). The two hand-set states survive a refresh, everything else is recomputed, and notes survive either way. `KNOWN_ADDRESS` requires a note: with no member link it is the only record of what the address is. + +**AppLink** — one of the studio's other applications, listed on `/apps`. Config rather than member data: no relations, and nothing here is derived from anything else. `icon` is a lucide name from the curated list in `types/index.ts`, `accent` is an enum of eight presets rather than a colour string, `featured` is what the dashboard filters on and `/apps` ignores, and `sortOrder` is renumbered contiguously on every move. + +**Computer** — one studio workstation, created by its own agent's first ping rather than by an admin. `id` is the slug the agent config carries (`nle4`), not a generated key — it is what the ping URL names. There is no display name column: it is the slug uppercased. `agentSub` records the service account that claimed the id, and a different one pinging it is refused. `metadata` is whatever the agent last reported — OS, load, signed-in user, whether the screen is locked — validated on the way in and re-parsed on the way out, so a row written by an older agent still renders. Status is derived, not stored: online means a ping inside `COMPUTER_ONLINE_WINDOW_MS`. + +**CardDAVToken** — one row per device a member set up for contact sync. `tokenHash` is the SHA-256 of a 256-bit random token; the token itself is shown once and stored nowhere. `label` names the device, `lastUsedAt` moves at most once every five minutes. Deleting the row revokes the device. + +## Membership status + +```prisma +enum MembershipStatus { + MEMBER_CANDIDATE_CANDIDATE // stúdiós-jelölt jelölt + MEMBER_CANDIDATE // stúdiós-jelölt + MEMBER // stúdiós + ACTIVE_ALUMNI // aktív öregtag + ALUMNI // öregtag +} +``` + +`ACTIVE_ALUMNI` and `ALUMNI` map to the **same** Authentik group and the **same** mailing list, and carry no permission difference. The one place the two part ways is the CardDAV address book, which carries `ACTIVE_ALUMNI` and drops `ALUMNI` (see [Architectural decisions](decisions.md)); everywhere else the distinction is a badge. `ALUMNI_STATUSES` and `isAlumniStatus()` in `types/index.ts` are the shared predicate; the members and alumni pages keep their own filters because they enumerate different concepts. + +## Semester format + +One string: `"2025/2026/1"` (autumn) or `"2025/2026/2"` (spring) — start year / end year / semester number. Sorts correctly as a string. Helpers in `types/index.ts`: `parseSemester`, `formatSemester` (`"2025 ősz"`) and `currentSemester`. Nothing validates the format — only `currentSemester()` ever writes one. + +## Dates and the studio zone + +The container runs on UTC and the studio does not, so nothing derives a calendar date from the server's clock. `STUDIO_TIME_ZONE` and `civilDate(instant, zone?)` in `types/index.ts` resolve an instant to the date it falls on **at the studio**; `addDays`, `startOfWeek` (Monday, as a Hungarian calendar starts) and `daysBetween` are whole-day arithmetic over the `"YYYY-MM-DD"` strings that produces, done in UTC so no zone shift can move them. + +Rendering splits along the same line. An instant is formatted **in the studio's zone**, because that is the clock on the wall — `formatTimestamp` and `formatTimestampDate` in `types/index.ts` for a stored one, `lib/calendar.ts`'s `clock` for an event. A `"YYYY-MM-DD"` is already a civil date with no zone of its own, so it is parsed and formatted **as UTC** — putting it through Budapest would shift it a day. Getting this backwards moves a Monday all-day event into the previous week, which is exactly the bug the split exists to prevent. diff --git a/docs/decisions.md b/docs/decisions.md new file mode 100644 index 0000000..e283c97 --- /dev/null +++ b/docs/decisions.md @@ -0,0 +1,147 @@ +# Architectural decisions + +Why things are the way they are. The *what* is in the code. + +**Service layer.** Business logic lives in `lib/services/`. Routes and Server Actions are both thin adapters over the same service, so behaviour cannot drift between the HTTP and RSC paths. Typed errors (`NotFoundError`, `ForbiddenError`, `ValidationError`) flow through `mapServiceError`. + +**Authorisation is the service's job, not the caller's.** A service function that takes an `Actor` guards on it. The alternative — checking in the route and the action and trusting the service — held for a while and left six mutations whose `actor` was only ever written to the audit row, looking like a guard from the outside. Callers may still refuse early, but nothing depends on it. + +One caller has to: `POST`/`DELETE /api/members/[id]/avatar` write to storage *before* calling the service, so `authorizeAvatarChange` refuses first. A guard that only fires inside the service would let an unauthorised caller move bytes and be told no afterwards. + +**Authentik attributes are replaced, not merged.** `PATCH /core/users/{pk}/` overwrites the whole `attributes` object, so `buildAuthentikAttributes(member)` always sends the full managed set (`first_name`, `last_name`, `mobile`, `avatar_url`, and `distinguishedName` when AD is configured). Trade-off: attributes set by hand in the Authentik admin UI get wiped on the next update. Acceptable for a managed fleet. + +`avatar_url` is absolutised against `APP_URL` on the way out, while the DB and the UI keep the `/avatars/…` path. Authentik renders the attribute verbatim and has no placeholder for our origin, so a path there would resolve against the Authentik host. + +**Status change adds before removing.** `orchestrateStatusChange` issues `ADD_TO_GROUP` before `REMOVE_FROM_GROUP`. If ADD fails nothing changed; if REMOVE fails the user is briefly in both groups, which a retry fixes. The reverse order risks leaving a user in **no** group. + +**"Has an Authentik account" is encoded in the id, not in a column.** The alternative was an `authentikUserId` or a `hasAuthentikAccount` field. Neither pays for itself: `Member.id` must *already* equal the Authentik UUID for login and for pk resolution, so a member who gains an account needs their id rewritten either way — a column would not save that, only duplicate a fact the id already carries, where the two can drift. The prefix also keeps the check pure, which matters because orchestrators receive a bare `memberId` and would otherwise have to read the member row on every sync. Cost: ids are not uniformly UUIDs, and the prefix shows up in member URLs — and, once avatars are embedded on the public website, in `/avatars/-square.webp` too. That is why the token is meaningless rather than descriptive. It does not hide the id itself: an avatar URL publishes the member's Authentik user UUID either way. Fixing *that* means minting avatar filenames independently of the member id, which is not done. + +**No background retry queue.** A failed sync reports to Sentry, somebody looks, and it is retried by hand from `/admin/sync-jobs`. At ~30 members the failure rate does not justify a worker, a queue table or the operational surface of pg-boss or a CronJob. Revisit only if manual retries become routine. + +**A new member's username is shown once.** `createMember` returns the name `createAuthentikUser` settled on after its collision loop alongside the member, and the create form shows it on its success screen with a copy button. Nothing stores it, so that screen is the only place it is ever visible — and the welcome email carrying it is written by hand. + +**Avatar upload and removal are separate audit actions.** The avatar URL is deterministic from the member UUID, so a replace would be invisible in the diff without dedicated `AVATAR_UPLOADED` / `AVATAR_REMOVED` actions. `removeMemberAvatar` no-ops when there is nothing to remove. + +That same determinism is why both paths sync Authentik only when `avatarUrl` actually changes (`syncAvatarUrl` in `lib/services/members.ts`): replacing an image leaves the URL identical, so the attribute push would be a `SyncJob` row and an Authentik call that change nothing. The audit entry is still written — that is what records the replace. + +**Avatars are cached like Gravatar, not versioned.** The route answers `max-age=300` with `stale-while-revalidate` and `stale-if-error`, and Cloudflare caches it at the edge. A content hash in the URL would let the cache run for a year, but it would make every replace a URL change, and so a push to Authentik and the website — the thing the determinism above exists to avoid. A stable URL with a five-minute lifetime is what Gravatar does for the same reason: everyone sees a new image within minutes, and nothing downstream is told. The uploader sees it at once, because `member-avatar.tsx` appends its own `?v=` — only on that page, so the rest of the portal waits out the lifetime like everyone else. `stale-if-error` is the reliability half: the public website hotlinks these URLs, and the edge keeps serving the last copy for a week while Backstage or the bucket is down. A 404 is `no-store`, or a removal followed by a quick re-upload would keep answering from a cached miss. + +The edge half is instance configuration nothing in the app can detect, like Authentik's subject mode. A Cache Rule on `/avatars/*` sets *Eligible for cache*, edge TTL from the origin header, and browser TTL **Respect origin** — the setting the rule exists for, because the zone's Browser Cache TTL rewrites any origin `max-age` shorter than itself and browsers would hold a replaced avatar for hours. The cache key keeps its query string, or the uploader's `?v=` would be served the old copy. + +**Leadership role → group sync.** Assigning a role adds the member to the common Leadership group (`AUTHENTIK_GROUP_LEADERSHIP_UUID`) **plus** any role-specific `authentikGroupIds`; `removeRole` removes both. Updating an existing role keeps the Leadership membership and only diffs the role-specific groups. + +**Archiving ends the leadership position; reactivating does not hand it back.** The `LeadershipRole` row used to survive an archive, which left the profile rendering a title nothing backed and forced reactivation to choose: re-add the Authentik groups, or leave the portal claiming a position it does not grant. `archiveMember` and `batchArchive` therefore call `removeRole`, which already deletes the row, snapshots it to the timeline and takes back both the common Leadership group and the role-specific ones. A returning member is given a position again by hand, which is the rarer case and the one a leader should decide deliberately. + +**Archiving and reactivation are idempotent, not refused.** `reactivateMember` returns a member who is not archived unchanged rather than throwing, and `archiveMember` does the same for one already archived — the same shape as `PUT /roles` treating an identical role as a no-op. A stale tab or a double click would otherwise move `archivedAt`, or write a second `MEMBER_REACTIVATED` audit entry and a second round of sync jobs. + +Reactivation re-adds the status group even though archiving never removed it, because the Authentik account may have been tidied up by hand in between and `add_user` is idempotent. That is cleanup; the leadership groups are a decision, which is why they are not in the same list. + +The archive keeps one exception: `?removeFromGoogleGroup=true` runs on an already archived member. The checkbox defaults to off and the archive button is gone once a member is archived, so a leader who left it unchecked would otherwise have no way back to the removal but the reconciliation page. + +**No REST routes for admin resources.** Sync jobs, the audit log and the Google Group reconciliation are read through their services from server components; mutations go through Server Actions. No external consumer exists, so an HTTP API would be surface area for nothing. + +App links follow the same rule for a different reason: `/apps` is a page every member reads, not an admin resource, but nothing outside Backstage consumes the list either. `GET /api/apps` is the one route worth adding the day another studio app wants to render the same launcher. + +Computers too, and the write route does not imply the read one: the agent has to POST from off the server, but nothing outside Backstage reads machine status, so `/computers` reads the service directly. `GET /api/computers` arrives the day something does — a lobby display, say. `GET /api/computers/[id]/rdp` is not that route arriving early: it hands one caller a file rather than the list, and it is a route at all only because a browser download needs a URL to follow to a `Content-Disposition` — the same reason `app/avatars/[...path]/route.ts` is one. + +**Restricted reads go through a guarded service, never Prisma in the page.** Next.js is explicit that a layout cannot gate its segments — they render regardless and land in the RSC payload — so there is no one place to put a check for a group of pages, and the check belongs next to the data instead (`node_modules/next/dist/docs/01-app/02-guides/authentication.md`, *Layouts and auth checks*). Every portal page opens with `pageActor()`, but that is authentication rather than the data guard: it validates a cookie the proxy only checked the existence of, and sends the wrong role home. What makes restricted data safe is `listAuditLogs`, `listSyncJobs`, `listAuthentikGroups` and `getGoogleGroupReconciliation` refusing an actor who may not see them. + +**The website gets one sync operation, not four.** Every other target has a verb per kind of change; the webhook takes the member's whole record and works out for itself whether that is a create, an update or a restore, so `SYNC_MEMBER` is the honest name and the `IngestResult` it returns is what the job records. The handler also reads the member row at execute time rather than carrying a snapshot in the payload, which is what makes a retry safe: a failed push retried after the member was archived would otherwise put them back on the public site. Once the row decides, a second operation name could only contradict what was sent. + +Cost: `/admin/sync-jobs` shows the same operation for every website row, and the payload column shows `{}`. The `result` carries the detail instead. + +**A full sync is an audit entry, not a SyncJob.** `SyncJob.memberId` is a required FK and a whole roster push is about no single member — the same reason `GOOGLE_GROUP_SYNCED` is an audit entry. Like that one it names what the admin did, and the counts in `diff` say what came of it. The entry is written *after* the push, so a refused one leaves no record claiming it happened, and each click gets a fresh delivery id rather than a replayable one: re-syncing an unchanged roster is a no-op, so a second click wanting to re-sync should. + +It uses `replace` mode, which archives on the website anything the list leaves out. That is how an archived member is dropped, and it also means anyone the website holds that Backstage has never known about goes with them — which is the point of a repair tool with an authoritative source, and why the confirmation says so. The button is **hidden** when no webhook is configured rather than offered and refused, the way an unconfigured `.rdp` download is dropped; the Google Group refresh is the other way round only because that page is *about* that integration, where a log of sync jobs is not. The service refuses regardless, since the Server Action stays reachable on its own. + +**The mailing list is reconciled, not synced.** Backstage writes to the group in exactly three narrow cases (see [Sync architecture](integrations.md#sync-architecture)) and otherwise only reads it. A full two-way sync would have to decide what an address with no member means, and the honest answer is "a human knows" — so an email change deliberately leaves the stale address in place and the reconciliation page reports it, rather than the app guessing that the old one should go. + +**`KNOWN_ADDRESS` exists so `UNKNOWN` can reach zero.** Without a state meaning *decided, no member*, every permanently-fine address — another list, an external contact — stays `UNKNOWN` forever, and nobody can tell a reviewed row from an unreviewed one. The value is in the state, not the note; the note is mandatory anyway because with no member link it is the only thing identifying the address. + +**The AD password is Authentik's, not Backstage's.** Backstage must never see a member's password, and Authentik already runs enrollment and recovery; what it cannot do is create the AD account. So each side does the half it can: Backstage creates the account with a random password nobody is told — AD will not enable an account without one — and the member's next password set in Authentik replaces it. Authentik's hashes cannot be copied across, so an account has no password anyone knows until its member next sets one in Authentik. + +**The DN is sent before the AD account is known to exist.** `createMember` sends it alongside the AD create, not after it succeeds, and every later update carries it. That locks a member whose AD create failed out of every Authentik password set — the complexity check base-searches the DN and requires an entry — until the job is retried. Accepted because onboarding mail goes out only once a batch's jobs succeeded, so nobody is sent to set a password on an account that is not there. Sending it only on success would need a record of that success to send on every later update. + +**The CN is the member id, not the username.** The DN is then derivable from the id with nothing stored, the way the avatar URL is, and a rename never moves the entry. + +**Avatar storage is backend-agnostic.** `lib/avatar-storage.ts` is the facade: it validates member ID shape, 5MB size and WebP magic bytes, then delegates to a backend implementing `AvatarStorage` (`lib/storage/{types,local,s3,factory}.ts`), chosen once from `AVATAR_STORAGE=local|s3` and cached. Both store the same logical key `-.webp` — local under `./storage/avatars/`, S3 under `s3:///avatars/`. Migrating means copying files and flipping the env var; stored URLs (`/avatars/`) stay valid. Local storage sits **outside `public/`** deliberately: static serving would shadow `app/avatars/[...path]/route.ts` and break the S3 path. + +**Forms validate against the service's own schemas.** `lib/services/member-schemas.ts` exists only so a client component can import a Zod schema without dragging Prisma and the sync layer into the browser bundle — `members.ts` re-exports it, so service and API callers are unaffected. The forms use `*FormSchema` variants (`CreateMemberSchema.required()` and friends) because a form always submits every field as a present string, while an API caller may send a partial payload. Validation messages therefore live in the schema and are Hungarian; they surface both inline in the form and in a 400 body. + +**Email changes are leader/admin only.** Everything else on a member's own profile is self-service. The email is not: it syncs to Authentik and reaches the OIDC `email` claim, which the studio's other applications consume as an identity attribute, so an address Backstage has not verified is not ours to set unilaterally. The guard lives in `updateMember`, so the API route and the Server Action are both covered; the edit sheet hides the field via `canChangeEmail` rather than relying on that. Self-service returns once the address can be confirmed before it reaches Authentik. + +**A phone number is mandatory wherever there is an Authentik account, and is stored in one spelling.** The number is a managed Authentik attribute, which the studio's other applications read off the directory the same way they read the email claim — as an identity attribute they consume rather than validate. One canonical spelling is the whole point: a number written four ways across the systems this syncs to is a number nothing downstream can match on, and E.164 is the only format that carries a country code unambiguously. Sometimes-absent is the same problem in a different shape, since an attribute that may be missing is one every consumer has to special-case. + +Members with no Authentik account are the exception, because nothing reads attributes they do not have. The prefixed id is what decides, and `updateMemberSchema(memberId)` and `editMemberFormSchema(memberId)` in `member-schemas.ts` both resolve it through `hasAuthentikAccount`, so the service and the edit sheet cannot disagree about who may go without one. + +Separators are stripped rather than rejected — a pasted `+36 30 123 4567` is the same number — but a missing country code is **not** guessed. Members carry Slovak and Romanian numbers as well as Hungarian ones, so mapping a leading `06` to `+36` would turn a Slovak `0903…` into a valid-looking Hungarian one: a wrong number that passes every check downstream, which is worse than the error the member sees instead. The pattern excludes a leading zero after the `+` for the same reason — no country code starts with one. + +**Save is disabled until something changes.** Edit forms subscribe to TanStack Form's `isDefaultValue`, so reverting an edit re-disables the button. The role checkbox group keeps its value sorted for the same reason — the comparison is order-sensitive, and unchecking then rechecking a box would otherwise read as a change. Field errors stay hidden until a field is blurred or a submit is attempted — forms register the schema on `onChange` **only**, and the field components re-run that validator from their blur handler so a field the user tabbed straight past still reports. Registering the same schema on `onBlur` as well looks equivalent and is not: every validation cause keeps its own slot in `errorMap` and `meta.errors` flattens all of them, so the field renders two messages at once and keeps the stale one until that cause fires again. + +**The edit sheet's forms live inside `SheetContent`.** Radix unmounts it on close, so both forms remount with the current member data on every open — no reset effect, and a discarded edit cannot survive a reopen. + +**CardDAV is served from the proxy, not a route handler.** Next answers 405 to any method outside its seven known ones before a route handler runs, and CardDAV needs PROPFIND and REPORT — the proxy is the only place in the request path that sees them. It defaults to the Node.js runtime in Next 16, so Prisma works there; the import is dynamic and `isCardDavPath` lives in its own module, so no other request pays for the handler's module graph. + +**`skipTrailingSlashRedirect` is on for CardDAV's sake.** WebDAV collection URLs end in a slash, and Next's own trailing-slash redirect fires ahead of the proxy — `/api/carddav/` answered 308 and a client following the hrefs the server advertises never arrived. Every other path keeps the behaviour, reinstated in `proxy.ts` from a plain `URL`: `NextURL` records the trailing slash on construction and puts it back on serialisation, which redirected the path to itself. + +**Device tokens are SHA-256, not bcrypt.** The token is 256 random bits with no low-entropy structure to stretch, so a slow KDF buys nothing against brute force and costs a derivation on every sync poll — a phone re-authenticates constantly. A plain digest also makes verification a unique-index hit rather than a scan over the member's tokens. + +**A vCard carries contact details only.** Name, nickname, email, mobile, avatar. Membership status, joined semester, dorm room, university and leadership role stay out: a phone's address book has no use for them, and they would sit unencrypted on every synced device and in whatever backup it feeds. A test asserts those properties stay absent. + +**The address book drops `ALUMNI` and keeps `ACTIVE_ALUMNI`.** The alumni roster grows by roughly ten a semester and never shrinks, so syncing it would bury every member's contacts under people the studio has not seen in years. `ACTIVE_ALUMNI` exists to mark an alumnus still around, which is exactly who is worth carrying — so this is the one place where the difference between the two statuses does something rather than label a badge. + +**Apple clients will not send Basic credentials over cleartext HTTP.** Established by testing, not from documentation: against `http://:3000` an iPhone completes discovery, receives a well-formed `WWW-Authenticate: Basic` challenge, sends no `Authorization` header at all, and reports the password as incorrect. `curl` authenticates against the same endpoints and gets its 207. CardDAV therefore needs a TLS origin the device trusts; there is nothing to fix in the handler. + +**An app link's URL is checked for its protocol, not just its shape.** `javascript:alert(1)` is a structurally valid URL, and the card renders `url` straight into an `href`. `z.url({ protocol: /^https?$/ })` in `app-link-schemas.ts` is the only thing keeping it out; a plain `z.url()` accepts it. A test asserts the refusal. + +**The accent is an enum of eight presets, not a colour.** An admin picks from a fixed row of swatches, so nothing free-text reaches the CSS and no choice can fight the ground — the presets share a lightness and chroma, only the hue moves. The class strings are written out per accent in `APP_LINK_TILE_CLASS` rather than interpolated, because Tailwind only ships classes it can see in the source. + +**The icon picker is a grid, the member picker is a combobox.** Searching works when you already know the name you want, which is true of a member and false of an icon — choosing one means browsing what exists. Thirty-two glyphs fit in one popover grid with nothing to scroll, which also sidesteps shadcn's `CommandList` hiding its own scrollbar and reading as a list of eight. + +**Ordering is two arrows, not a `sortOrder` field.** The number is an implementation detail; an admin thinks in terms of what sits above what. `moveAppLink` renumbers every row from the resulting order rather than swapping two values, so a list that arrived with gaps comes out contiguous. + +**The calendar is fetched and rendered, not embedded.** An iframe of a public Google calendar is one line of markup, but it drops Google's own chrome, locale and theme into the middle of the portal and none of the three is ours to change. Fetching means the events are Hungarian, match the rest of the UI, and can be reshaped into a hero plus four weeks rather than a month grid nobody asked for. The cost is a credential and a sharing step, and the Google Group integration had already paid both — the calendar rides the service account that was there anyway. + +**A calendar that will not answer is not an incident.** `getDashboardCalendar` catches the read, logs a warning and returns `status: "unavailable"`, which the widget renders as a line of text. Sentry is deliberately not involved: every member loads this page, so a merely slow calendar would report an outage on every refresh, and the person looking at the dashboard has already been told. A failure is also retried sooner than a success is refreshed — one minute against five — so that thirty dashboards do not each wait out the same timeout, and a share that has just been fixed appears while somebody is still looking. The cache is a single process-wide slot, like `lib/rate-limit.ts`: correct because the deployment is one replica, and merely one read per replica if it ever is not. + +**The hero leads with the shortest event under way, not the longest.** A festival week and the party inside it are both running, and the week is the one that sorts first and says least — "egész nap", against a start date days in the past. So `getDashboardCalendar` separates what has begun from what has not and ranks the running ones by duration: a seven-hour party is a more specific answer to "what is happening" than the week containing it. Everything else running becomes a `Közben` line and the soonest unstarted event an `Utána` line, so one card answers all three questions. A multi-day event carries its span and its remaining days wherever it appears — the one thing a reader cannot act on is an event that never says when it ends. + +That ranking also buys an invariant: **every running event belongs to the hero, so nothing the week list receives is dated before today.** `groupIntoWeeks` relies on it — it buckets on the event's own date with no clamp, and an event dated into a week already gone would silently vanish. Anything that lets a running event reach the list has to restore that clamp. + +**A calendar description is untrusted HTML.** The `url` on an event is the first anchor in a description written by whoever created the event, and it lands in an `href` — so only `http(s)` survives, for the same reason `z.url({ protocol: /^https?$/ })` guards an app link. A plain "first link wins" would make `javascript:` clickable on the dashboard. A test asserts the refusal. + +**The dashboard gets chips, `/apps` gets rows.** The home page is a shortcut bar, and a second copy of the card grid would compete with the calendar it now sits beside, and with machine status once that lands. The chips are monochrome for the same reason — the accent is what identifies an app in the catalogue, and the shortcut bar is not the catalogue. + +**A workstation registers itself.** The alternative was an admin creating each `Computer` row and copying its id into the agent config. The machine is the only thing that knows it exists, and a row created by hand is a second place to keep in sync for no gain at three workstations. Cost: any agent may create a row under any id, so a typo in one config leaves a stray machine on the page until an admin deletes it — which is why the id has a shape at all. Deleting is not permanent; the machine comes back on its agent's next ping. + +**The first service account to claim a computer id owns it.** Every agent token sits on a machine members have physical access to, so without this a credential lifted off one workstation could rewrite any other machine's status. Re-pairing a machine means deleting it from `/computers` first, which is the rare case paying for the common one. + +**One Authentik group per kind of machine-to-machine caller.** Login and client-credentials tokens share an issuer and an audience, so group membership is the only boundary — and with a single API-client group, a token lifted off a workstation would also be a credential on `/api/usernames/suggest`. `requireApiClient` therefore takes the caller kind and each names its own group. The default keeps existing callers unchanged. + +**The agent reports facts; the portal decides what they mean.** A locked machine counts as free, but the agent sends `loggedInUser` and `locked` rather than the conclusion. Changing what counts as free is then a portal change instead of a reinstall on three workstations. That is also why `metadata` is JSON rather than columns, and why every field in it is optional with absent distinguishable from empty: an old agent keeps working. + +**Occupancy comes from the session table, not `Win32_ComputerSystem.UserName`.** That property names the console session, and an RDP client leaves the console locked behind it — so on a machine being worked on remotely it reported nobody, while a machine-wide `Get-Process LogonUI` saw the console's own lock screen and called the machine free. Both halves of the old signal failed on the same case, and the studio works over RDP. The agent enumerates sessions through `wtsapi32` instead: `WTSActive` is a session attached to a display, local or remote, and LogonUI is matched by session id rather than machine-wide. `quser` prints the same table, but its `STATE` column is localised where the API's connect state is a numeric enum. A session that is signed in but not active — a disconnected RDP session, most often — reports as `locked`, which is the same thing the portal already treats as free. + +**Local and RDP sessions are still not told apart in the portal.** The session table names the station, and the agent throws it away: the portal's question is whether somebody is there, not how they got there, and it is a distinction Windows already warns about at sign-in. + +**Status is a tinted card, not a value in a row.** Members open both surfaces with one question — which machine can I sit at — and a label-and-value row answers it in the same grey at the far edge of the card, so free and occupied look identical until read. `computerVerdict` reduces status plus metadata to a tone and a Hungarian label, the tone colours the whole card ground, and the machine name and the verdict sit in one block. Colour is what carries the answer at a glance and the label is what carries it for everyone else, so neither stands alone. It lives in `lib/computers.ts` rather than in the two components because the dashboard and `/computers` must not be able to disagree about what free means. + +**Pages that draw from the clock refresh themselves with `router.refresh()`, not a polling client.** `/computers` and the dashboard both resolve online, offline, "N perce" and the hero's countdown *at render time*, so a tab left open does not merely miss new pings — it goes on asserting readings it drew hours ago. `AutoRefresh` re-runs the server components every `COMPUTER_REFRESH_MS`, which keeps the read in `listComputers` behind `pageActor()` and needs no `GET /api/computers`; SWR and TanStack both want a Route Handler, which is the surface that decision above declines to add. It stops while the tab is hidden and refreshes once on the way back, because a hidden tab has nobody reading it and a returning one is stale by however long it was away. + +A refresh re-runs the whole route, so the dashboard pays for its other widgets to update machine status. That is affordable rather than free: the profile and app-link reads are small, and the calendar is served from its own process-wide cache rather than calling Google. Machine status is simply the fastest-moving thing on the page, so it sets the cadence. The interval has to stay under `COMPUTER_ONLINE_WINDOW_MS` — a page redrawing slower than a machine can fall out of the window would keep calling it online — and a test asserts that. + +**An offline machine renders no gauges and no occupancy.** Those are its last readings rather than its current ones, and a stale bar reads exactly like a live one. Offline collapses to the name, the badge, when it last pinged and which agent it is running — everything still true. + +**An offline machine still offers its `.rdp` file.** A silent agent says the agent is silent; it says nothing about whether the workstation answers RDP. Withholding the download would be the portal asserting something it does not know, which is the same line `computerVerdict` walks when a missing `loggedInUser` reads as `Online` rather than `Szabad`. + +**The Authentik username is read off the login claim, never stored.** The `.rdp` file names an AD account, and AD carries the same username as Authentik — so the download needed a name this app had deliberately never kept. A column would duplicate a fact Authentik owns, the way an `authentikUserId` would duplicate `Member.id`, and nothing would write it back after a rename in the Authentik admin UI. Querying `/core/users/` for it works — `results[].username` is already in `CONTRACT` — and costs a round trip plus a second cache beside `suggestUsername`'s. Neither is needed: `preferred_username` arrives in the OIDC profile next to the `sub` already taken, so `mapProfileToUser` carries it into the session and `overrideUserInfo` re-maps it on every sign-in. A member with no Authentik account cannot sign in at all, so the case that would need a fallback never reaches the file. + +**Where the workstations answer is configuration, not repository content.** The public DNS suffix, the port and the AD domain are three environment values, and `.env.example` ships the suffix and the domain **empty**, naming `example.hu` only in the comment above them: this repository is public and none of the three is ours to publish. So a fresh `pnpm dev:setup` has the download switched off until somebody fills the suffix in. They never reach the client either — the page asks `isRdpConfigured()` for a boolean and the file is assembled in the route, so no hostname lands in an RSC payload. A suffix that is unset — or a configuration `rdpConfig()` refuses at all, an unusable `COMPUTER_RDP_PORT` included — drops the button rather than failing, the way an unset `GOOGLE_CALENDAR_ID` drops the calendar widget. That is why `isRdpConfigured()` runs the same parse the route does instead of testing the suffix for presence: a value only one of the two rejects would render a button that 500s on every click. + +**The file prefills a name and never a secret.** `prompt for credentials:i:1` is the whole of the authentication story — a password in an `.rdp` file would be a credential sitting in a downloads folder. The username is dropped rather than escaped when it does not match `/^[A-Za-z0-9._-]+$/`, because the file is line-oriented and a CRLF in that value would inject settings of its own. + +**The agent is Windows PowerShell 5.1 under a SYSTEM scheduled task.** 5.1 is a Windows component present on every box at a fixed path; PowerShell 7 is a separately installed app that would become a second thing to maintain on the machines least worth babysitting, and `powershell.exe` is findable by SYSTEM in a way `pwsh.exe` is not. SYSTEM is what makes the agent independent of who is signed in and out of reach of a standard user. A local administrator can still stop it, and the README says so rather than implying otherwise. + +**Uninstalling and reinstalling the agent kill the running process explicitly.** `Unregister-ScheduledTask` removes the task definition and leaves the `powershell.exe` it started running, and `-DisallowHardTerminate` — there so a member cannot stop the agent — means asking the task to stop may not end it either. The agent is an endless loop holding its id, its URL and its token in memory, so a survivor keeps pinging from a machine with nothing installed on it, and a reinstall stacks a second one alongside reporting a different id. The installer matches on the install path *and* an owner of `S-1-5-18`: an administrator who pasted a snippet naming that path has it in their own shell's command line, and matching on text alone kills the console the uninstall is being typed into. + +**Studio leaders history lives on the wiki.** Not an in-app page — the sidebar links to `https://wiki.bsstudio.hu/doc/studiovezetok-AdWWlRMuAI`. Edits are rare, pre-2010 entries lack contact details, and the wiki already provides editing and audit. diff --git a/docs/dev-data.md b/docs/dev-data.md new file mode 100644 index 0000000..b5f2d5d --- /dev/null +++ b/docs/dev-data.md @@ -0,0 +1,15 @@ +# Dev data + +`pnpm db:seed` wipes the dev database and writes ~43 invented members (Hungarian names, none real), leadership roles, an `AuthentikGroup` registry, per-member timeline and audit history, and sync jobs including two FAILED and four SKIPPED ones so `/admin/sync-jobs`, its retry button and every status badge have something to show. Every member carries a `WEBSITE` push; the skipped website and Active Directory jobs carry the not-configured reason, which is what a local `.env` without a webhook token or the `AD_*` settings actually produces. One `WEBSITE_FULL_SYNC` audit entry gives `/admin/audit` that action and its null target to render. Semesters are relative to `currentSemester()`, and member ids are the local prefix plus a hash of the seeded email, so member URLs survive a reseed. + +The first run asks for your name and your Authentik `sub` and stores the answers in `.dev-user.json` (gitignored). That row is created with your `sub` as its `id`, so logging in through SSO lands on it instead of creating a second record. + +The seeded `AuthentikGroup` UUIDs are invented, so a default seed cannot touch a real instance. To test group sync against a dev Authentik, write the real UUIDs to `.dev-authentik-groups.json` (gitignored, `[{ "displayName": "Főszerkesztő", "authentikGroupId": "" }, …]`): entries whose `displayName` matches a seeded group replace its UUID, the rest are added to the registry. Real Authentik identifiers stay out of the repo, same as the group UUIDs in `.env`. Only *your* member row resolves in Authentik: it is seeded with your `sub`, while every invented member carries a local-prefixed id, so assigning *them* a role produces `SKIPPED` jobs and never calls anything. Assign the role to yourself. For the same reason the seeded Authentik `CREATE_USER` and the FAILED Authentik job both belong to your row — it is the only one a real retry can reach. + +Three `CardDAVToken` rows are seeded: two on your row — one that has synced and one that never has, so both states of the "last used" column render — and one on another member, so the table is not empty when a leader opens someone else's profile. Your first device's password is the fixed `dev-carddav-token`, printed at the end of a seed, so the endpoint can be exercised with `curl` without clicking through the UI. + +Eight `AppLink` rows are seeded, three of them featured so the dashboard chip row has something to show, between them using all eight accents, and one with no description so that branch renders too. Each gets an `APP_LINK_CREATED` audit entry, which is what puts the new action badges and the `targetLabel` column on `/admin/audit` without having to create an app first. + +Four `Computer` rows cover every state a card renders: `nle4` in use, `nle6` signed in but locked (which the portal reads as free), `nle3` online but reporting no occupancy at all — what an agent whose session lookup failed sends — and `nle7` offline on an older agent version. A `COMPUTER_DELETED` audit entry for a retired `NLE5` gives `/admin/audit` the new action to render. + +The scripts refuse to run against a database whose host is not local unless passed `--force`. diff --git a/docs/integrations.md b/docs/integrations.md new file mode 100644 index 0000000..a532c96 --- /dev/null +++ b/docs/integrations.md @@ -0,0 +1,101 @@ +# External integrations + +## Authentik + +Three independent uses: + +1. **Login (OIDC)** — `AUTHENTIK_ISSUER`, `AUTHENTIK_CLIENT_ID`, `AUTHENTIK_CLIENT_SECRET` +2. **User/group management (REST)** — `lib/authentik/*`, using `AUTHENTIK_URL`, `AUTHENTIK_API_TOKEN` +3. **Machine-to-machine tokens** — client credentials against the *same* provider as login, no extra config beyond `AUTHENTIK_GROUP_API_CLIENTS` + +Group **names** (matched against the OIDC groups claim): `AUTHENTIK_GROUP_LEADERSHIP`, `AUTHENTIK_GROUP_ADMIN` for role resolution, `AUTHENTIK_GROUP_API_CLIENTS` for API clients and `AUTHENTIK_GROUP_COMPUTER_AGENTS` for workstation agents. + +Group **UUIDs** (used by the sync layer): `AUTHENTIK_GROUP_CANDIDATE_CANDIDATE`, `AUTHENTIK_GROUP_CANDIDATE`, `AUTHENTIK_GROUP_MEMBER`, `AUTHENTIK_GROUP_ALUMNI` (covers both alumni statuses), and `AUTHENTIK_GROUP_LEADERSHIP_UUID` — note this is a *different* variable from `AUTHENTIK_GROUP_LEADERSHIP`, which holds a name. + +**Username derivation.** First Hungarian letter of the first name + full last name, lowercase, diacritics stripped, hyphens removed: `Kovács János → jkovacs`, `Csaba Nagy → csnagy`. `deriveUsername()` in `types/index.ts`, collisions resolved by `findAvailableUsername()` (`lib/authentik/users.ts`). Not overridable — there is no stored username field. + +Both are paired in `lib/services/usernames.ts`: `resolveAvailableUsername()` (uncached, used by `createAuthentikUser`) and `suggestUsername()` (cached, used by the suggestion endpoint). Member creation and other studio apps therefore take the same path. + +**Contract drift is watched, not tested.** `pnpm authentik:contract` (`scripts/authentik-contract.ts`) downloads goauthentik's OpenAPI spec, extracts the shape of exactly what the `CONTRACT` list names, and diffs it against `scripts/authentik-contract.json`. No running Authentik and no credentials are involved, so it runs anywhere. It reads the spec from `main` rather than a release tag on purpose: `main` runs ahead of the deployed version, so a field rename surfaces here before the instance is upgraded into it. `AUTHENTIK_SCHEMA_URL` points it at a local file or a pinned release instead. + +`.github/workflows/authentik-contract.yml` runs it weekly, on `workflow_dispatch`, and on PRs touching `lib/authentik/**` or the script. A PR run signals by going red; a scheduled run has nobody watching, so it opens an issue instead, deduplicated by title against the open ones. + +The check only sees what `CONTRACT` lists — see *Add or change an Authentik API call* in [Where to make a change](structure.md#where-to-make-a-change). Nothing links it to `lib/authentik/*` automatically. It also covers only the `/api/v3` REST surface: the OIDC endpoints behind `lib/auth.ts` and `lib/api-client-auth.ts` (`.well-known/openid-configuration`, `/jwks/`) are not in Authentik's spec at all. + +## The website (members webhook) + +`WEBSITE_WEBHOOK_URL` and `WEBSITE_WEBHOOK_TOKEN`. One endpoint, one verb: a bearer `POST` of members, either as targeted operations or as a whole-roster `replace`. The contract is **theirs**, generated from their own route and published at `docs/api/members-webhook.openapi.yaml` in [kir-dev/bss-stack](https://github.com/kir-dev/bss-stack). Read it there rather than keeping a copy here, which can only go stale. + +The token is issued on the website's side and shown there once; it is a client identifier and a secret joined by a dot, and revoking access means revoking the client there. + +`membershipStatus` is our `MembershipStatus` verbatim, which is why nothing maps between the two. The contract carries **no email and no mobile**, so neither is a reason to push and neither ever goes over the wire. + +`x-bss-delivery-id` is an idempotency key, and the sync layer passes the `SyncJob` id: a push that landed but never answered comes back as a duplicate instead of applying twice, and a push the endpoint *rejected* leaves the key unclaimed, so the same job can retry once the payload is fixed. + +## Google Workspace (Cloud Identity) + +`GOOGLE_SERVICE_ACCOUNT_KEY` (the downloaded JSON key, base64-encoded), `GOOGLE_GROUP_EMAIL`, and the optional `GOOGLE_ALUMNI_GROUP_EMAIL`. No `googleapis` dependency: `lib/google/client.ts` signs a JWT with `jose`, exchanges it at `oauth2.googleapis.com`, and caches the token **per scope**, so a read path never carries write authority. + +**The Admin SDK Directory API is not usable here** — it answers only to a Workspace admin, and the university owns the domain. Cloud Identity accepts a service account that holds the **MANAGER** role on the group itself, with no domain-wide delegation, which is the whole reason this integration exists at all. + +What that role can reach was established by probing, not from docs: `groups:lookup`, `memberships.list`, `memberships.create` and `memberships.delete` all work, while `memberships:lookup` is refused on both scopes — it resolves an address against the *directory*, where group-manager rights do not reach. So `removeGroupMember` finds the membership name in the listing instead of looking it up. Restoring that call would mean granting the service account the Workspace **Groups Administrator** role, which needs a Super Admin and buys one saved request. + +The service account is itself a member of the group; the reconciliation read filters its own address out, or it would sit there as a permanent `UNKNOWN`. + +**The calendar is a second, unrelated grant on the same account.** `GOOGLE_CALENDAR_ID` names it, `lib/google/calendar.ts` reads it on `calendar.readonly`, and the per-scope token cache keeps that token apart from the group ones. None of the Cloud Identity story above applies: sharing the calendar with the service account's address at **"See all event details"** is the entire setup, and no Workspace admin is involved. Leaving `GOOGLE_CALENDAR_ID` unset drops the widget instead of failing, so an instance without a calendar is a supported configuration. + +A share that never landed reads as **404, not 403** — the account cannot see the calendar at all, so the API answers as if it did not exist. `scripts/google-calendar-probe.ts` prints what the account can actually read and says as much on a 404. + +Three shapes of the events feed cost a probe to establish and none of them is guessable: + +- **An all-day `end.date` is exclusive.** A single day on the 4th arrives as the 4th to the 5th, so `endDate` steps a day back from it. Read literally, every all-day event looks a day longer than it is. +- **The API returns anything *overlapping* the window,** so a multi-day event under way arrives dated before `timeMin`. Its own start date is therefore not a safe grouping key — see the hero under [Architectural decisions](decisions.md). +- **`singleEvents=true` is what expands a recurrence** into one row per occurrence, and `orderBy=startTime` is only accepted alongside it. Without them the weekly meetings render once, forever, as the series' first instance. + +The request manager writes a `További információk a felkéréskezelőben.` line into the description of every event it creates, which is where an event's `url` comes from — the first anchor in the description, and only when it is `http(s)`. + +## Active Directory + +`AD_LDAP_URL`, `AD_BIND_DN`, `AD_BIND_PASSWORD`, `AD_USERS_OU` and `AD_UPN_SUFFIX`, plus the optional `AD_LDAP_CA_CERT` and `AD_LDAP_TLS_VERIFY`. The domain exists for logging in to the studio workstations, at the console and over RDP, and for nothing else — so a member's account carries the username, the UPN, the names and whether it is enabled. Leaving any of the five required values empty records every AD job as `SKIPPED`. + +**Backstage owns the account; Authentik owns the password.** Authentik cannot create accounts in an on-prem AD — its LDAP source imports in the other direction — but that source's password writeback sets the AD password of any Authentik user carrying a `distinguishedName` attribute, whether or not the source imported them. So Backstage creates, renames, disables and enables the account over LDAPS, and every password a member sets in Authentik — enrollment, recovery, a change — lands in AD through the DN `buildAuthentikAttributes` carries whenever AD is configured. Writeback checks the password against AD's complexity policy first and fails the Authentik change if AD refuses, so the two cannot diverge. + +The account's CN is the member id and its `sAMAccountName` the Authentik username: the DN is `CN=,` (`memberDistinguishedName`), and the UPN `@`. `userAccountControl` is `NORMAL_ACCOUNT | DONT_EXPIRE_PASSWORD` (66048), plus `ACCOUNTDISABLE` (66050) for an archived member. `DONT_EXPIRE_PASSWORD` is load-bearing: an expiry would force a change at the Windows logon screen, which never reaches Authentik. + +AD accepts `unicodePwd` only over an encrypted connection, so the URL must be `ldaps://`. The DC's certificate is self-signed: `AD_LDAP_CA_CERT` pins it (base64-encoded PEM, replacing the system trust store rather than adding to it, and it has to name the host in the URL), or `AD_LDAP_TLS_VERIFY=false` switches verification off, which wins over a pinned certificate. `scripts/active-directory-probe.ts` binds with the configured values and lists the OU. + +The domain-side and Authentik-side setup is instance configuration nothing in the app can detect, like Authentik's subject mode, and it lives on the studio wiki: the two delegated service accounts; the LDAP source, with writeback on and user and group sync off; `LDAPBackend` removed from every password stage, without which every wrong Authentik password is also a failed AD bind counting toward lockout; the "Remove Change Password" GPO; a finite lockout duration, since an Authentik reset does not unlock an AD account; and an `adm-` or `svc-` prefix on every account made by hand, since Authentik's username collision check sees only Authentik. + +## Sync architecture + +Every external mutation creates a `SyncJob` row and executes it **synchronously**: `runSyncJob` writes the row and hands it to `executeSyncJob` (`lib/sync/executor.ts`). Failures persist as `FAILED` rows at `/admin/sync-jobs`, retried manually via `retrySyncJobAction`. There is no background worker. + +Service code calls **orchestrators**, never handlers directly. An orchestrator decides only what the row says and whether the call should be skipped; `runSyncJob` writes and runs it, so "the row exists before anything is attempted" is stated once rather than per target. `createAuthentikUser` is the one exception: it runs *before* the Member exists (its `sub` becomes the Member `id`), so it bypasses the job row and the service fabricates a SUCCESS row afterwards. + +Handlers receive `(payload, memberId, prisma, jobId)` and resolve external identifiers **at execute time** — Authentik via `getUserPk(memberId)`. Resolving late rather than baking IDs into the payload is what makes retry work: a job that failed because Authentik could not resolve the member succeeds on retry once the account is fixed. A missing link throws, so it lands as a visible `FAILED` row instead of being silently skipped. + +**Members with no Authentik account are skipped, not failed.** Every Authentik orchestrator goes through one choke point (`runAuthentikJob`, `lib/sync/authentik/orchestrators.ts`) that checks `hasAuthentikAccount(memberId)` and passes `runSyncJob` a skip reason when it is false. A prefixed id has no user to resolve a pk from, so unlike an account Authentik fails to resolve, no admin action could ever make the job succeed. The row is still written, as `SKIPPED` with `result: { reason }` — an id wrongly carrying the prefix would otherwise stop syncing silently forever. No handler runs, `attempts` stays 0, nothing reaches Sentry. + +Every FAILED job is also reported to Sentry from inside `executeSyncJob` — see [Observability](observability.md). + +API routes return HTTP **207** with `{ ..., syncErrors: string[] }` when the DB write succeeded but a sync step failed; `syncJson` and `syncJsonResource` (`lib/api-response.ts`) are what choose between that and the normal answer, and services build the list with `collectSyncErrors`. The UI shows a warning toast, not an error. + +Authentik specifics: `REACTIVATE_USER` is the reverse of `DEACTIVATE_USER` — the account goes back to `is_active` and the `users` path. `UPDATE_USER` rebuilds `attributes` from the member row at execute time whenever its payload carries them, because Authentik replaces the set wholesale and a retried snapshot could predate newer values or the DN. The payload's `attributes` are the record of what was asked, not what is sent. + +Website specifics: one operation, `SYNC_MEMBER` on the `WEBSITE` target, with an empty payload. The handler reads the member row at execute time and sends an `upsert` or — when the row is archived — an `archive`, so a retry pushes what is true *now*. `orchestrateSyncWebsiteMember` is the choke point and skips when the webhook is unconfigured. Every member mutation calls it: create, update, archive, reactivate, both batch operations, both role operations, and both avatar paths. + +Google Group specifics: `ADD_TO_GROUP` / `REMOVE_FROM_GROUP` on the `GOOGLE_GROUP` target, with `{ email, groupEmail }` as the payload — the list is in the payload because there are two of them. This target deliberately **does not** resolve its identifier at execute time: the address *is* what the group holds, so re-deriving it from the member row would let a retry act on an address the job was never about. `runGoogleGroupJob` is the choke point and passes the same kind of skip reason when credentials or the target list are unconfigured. + +Only three things touch the lists automatically: creating a member adds the address to the main list; a status change **into** alumni adds it to the alumni list (once — the two alumni statuses share one list); archiving offers an optional removal, a checkbox in `ArchiveDialog` that defaults to off and reaches the REST route as `?removeFromGoogleGroup=true`. Everything else is manual on purpose — an email change leaves the old address on the list (the edit sheet says so), nobody is removed on becoming alumni, leaving alumni does not undo the membership, and reactivating an archived member does not put back an address the archive took off. The reconciliation page is where those leftovers surface, and the archive dialog's checkbox says as much. + +Active Directory specifics: `CREATE_USER` and `SYNC_MEMBER` on the `ACTIVE_DIRECTORY` target. `CREATE_USER` carries `{ username }`, because Backstage stores no username and the one `createAuthentikUser` settled on cannot be re-derived later; everything else both handlers read from the member row at execute time, so a create retried after an archive makes the account disabled. AD answers code 68 for a taken DN, `sAMAccountName` or UPN alike, so the client counts it as success only when a base search finds the DN — otherwise another account holds the name and the job fails. `SYNC_MEMBER` replaces the names and the whole `userAccountControl`, so it is also the disable and the enable. `runActiveDirectoryJob` is the choke point: it skips a member with no Authentik account first, then an unconfigured AD. `createMember` creates the account and, with AD configured, sends Authentik a second `UPDATE_USER` carrying the DN, which names the member id `createAuthentikUser` only returns; `updateMember` syncs a first or last name change; archiving, the batch archive and reactivation sync the enabled flag. A `CREATE_USER` skipped for missing configuration stays skipped once AD is configured, and the member's next edit sends the DN anyway, which locks them out of Authentik password sets; insert the job by hand, as for an `x_` member. + +## Google Group reconciliation + +`/admin/google-group`. "Lista frissítése" reads the group live and rebuilds `GoogleGroupEntry`: addresses no longer on the list are deleted, matched ones are linked to their member, hand-set states and notes survive. There is no upload step — that was the pre-API design. + +Each refresh writes a `GOOGLE_GROUP_SYNCED` audit entry carrying `{ addresses: { old, new } }`, and that entry **is** the "last read" timestamp the page shows. Nothing else records when a read happened, and a column would duplicate the same fact. + +Two tables: the group's addresses (sortable by address or state, state ordered by how much attention a row needs — `UNKNOWN` first, `MATCHED` last) and members no address on the list resolves to. Alumni and archived members sit behind a checkbox in the second one, since they are expected to be absent and would bury the rows worth acting on. + +Leaders read the page; refreshing and annotating are admin-only, enforced in the service and the Server Action rather than by hiding the buttons. diff --git a/docs/observability.md b/docs/observability.md new file mode 100644 index 0000000..6738d6f --- /dev/null +++ b/docs/observability.md @@ -0,0 +1,36 @@ +# Observability + +Sentry (`@sentry/nextjs`), errors only — no tracing, no session replay. There is no background worker and no alerting elsewhere, so this is the only thing that tells anyone a sync broke. + +`tracesSampleRate` is deliberately **absent** rather than `0`. Sentry's `hasSpansEnabled()` treats `0` as "tracing on, sample nothing", which still builds spans and attaches `sentry-trace` / `baggage` headers to every outgoing call. + +**Bootstrap.** `instrumentation.ts` `register()` imports `sentry.server.config.ts` or `sentry.edge.config.ts` depending on `NEXT_RUNTIME`; the browser gets `instrumentation-client.ts`. All three call `Sentry.init(sentryInitOptions())` from `lib/observability/sentry.ts`, so the three runtimes cannot drift. That module stays free of server-only imports — it ends up in the client bundle. + +**Disabled without a DSN.** `NEXT_PUBLIC_SENTRY_DSN` unset ⇒ `enabled: false`, so dev machines and the test suite never phone home. It is a `NEXT_PUBLIC_` value, so `next build` freezes it into the bundle: the CI image build has to supply it, setting it on the running container is too late. + +**What gets captured.** + +| Source | Where | +| --- | --- | +| FAILED `SyncJob` | `captureSyncJobFailure` in `executeSyncJob` | +| Unhandled service errors behind a 500 | `captureServiceError` in `mapServiceError` | +| Server render errors and rethrowing Server Actions | `onRequestError` in `instrumentation.ts` | +| Client render errors | `app/global-error.tsx` | + +`onRequestError` is server-side only, so a React render error in the browser reaches Sentry solely through `global-error.tsx`. That file replaces the root layout when it renders, so it carries its own ``, stylesheet import and next-themes-compatible theme resolution. + +A sync failure is tagged `sync.target`, `sync.operation`, `sync.job_id` and `member.id` — the alert names the member and the system without anyone opening `/admin/sync-jobs`. The job **payload is never attached**; it holds an email and a mobile number. + +**Typed errors are not incidents.** `NotFoundError`, `ForbiddenError` and `ValidationError` are control flow. They are filtered in three places: `isExpectedError` (by `name`, not `instanceof`, so `lib/observability/sentry.ts` stays independent of `lib/errors.ts`, which imports `capture.ts`), and again in `beforeSend` by exception type as a backstop. + +**PII scrubbing** (`lib/observability/scrub.ts`) runs on every outgoing event. Emails and phone numbers are redacted out of the message, exception values, `extra`, `contexts`, breadcrumbs and the request URL; sensitive keys (`email`, `mobile`, `password`, `token`, `payload`, …) are dropped wholesale; `user` is reduced to `id` + `username`; request headers, cookies and bodies are dropped entirely. Tags are deliberately **not** scrubbed — every tag value is written by our own code, and the phone matcher would otherwise eat identifiers that are long runs of digits. + +**And nothing is collected that the scrubber would only have to drop.** The SDK collects every `dataCollection` category by default, so the restrictive baseline is spelled out rather than inherited — `scrub.ts` is the second line of defence, not the only one. `userInfo`, `cookies` and `httpHeaders` are off and `httpBodies` is none; `urlQueryParams` keeps the query string but denies any key containing `name`, which is how a member's own name arrives at `/api/usernames/suggest?firstName=…&lastName=…` — deny terms match as case-insensitive substrings, so the one term covers every spelling. `databaseQueryData` is pinned for a different reason: it reaches nothing while tracing is off, and the pin is what keeps enabling tracing from shipping the values Prisma binds. The remaining span-scoped categories (`queues`, `genAI`, `graphQL`) have no integration behind them at all and are left alone. + +**Release tagging.** Every version string has to parse as semver in Sentry, or the release gets no ordering, no `release.version` filter and no regression detection. Release tags stay `vX.Y.Z`, but the version drops the `v`, which Sentry's parser refuses. A commit carrying a release tag builds as `X.Y.Z`; anything else as `X.Y.Z.+`, or `…+.` off `main`, the branch reduced to `[0-9A-Za-z-]`. The distance goes in the fourth, *revision* number because Sentry compares that numerically but a prerelease as plain text, where `dev.10` sorts below `dev.9`; the build code after `+` takes no part in the ordering. Only `vX.Y.Z` tags count, so a prerelease tag never resets the count, and only along the first-parent line, so neither does a tag on a merged branch. A working tree with uncommitted changes never builds as the bare tag. `next.config.ts` derives this locally and the publish workflow in CI, which passes it in as `NEXT_PUBLIC_APP_VERSION` — the two are written to match and nothing ties them together. + +The release name is `backstage@`: release names are shared across the whole Sentry organization, so the project slug keeps them apart. `next.config.ts` builds it once into `NEXT_PUBLIC_SENTRY_RELEASE`, which `sentryInitOptions` sets on every runtime and `withSentryConfig` uploads source maps under, so the two cannot name different releases. Source map upload switches itself off unless `SENTRY_AUTH_TOKEN` is present — that secret belongs to the Docker build, not to a local `pnpm build`. + +**Client events tunnel through `/monitoring`.** `tunnelRoute` in `next.config.ts` makes the browser POST envelopes to our own origin instead of `sentry.io`, which ad blockers filter by hostname. The route is generated by the Sentry plugin, so it exists only in a real build — and it is in `publicPaths` in `proxy.ts`, because an error on the login page has no session cookie yet and a redirect would drop the POST body. + +**Structured logging** (`lib/observability/logger.ts`) writes one JSON object per line to stdout/stderr. `/api/usernames/suggest` logs the calling service account (`sub` + service account name) with the outcome on every path — service account names are not PII, so they are logged raw. The calendar logs `calendar_read_failed` when Google will not answer — deliberately a warning rather than a Sentry event, see [Architectural decisions](decisions.md) — and `calendar_page_cap_reached` when a read stops at `MAX_PAGES`, which at studio scale means something is wrong rather than that a real limit was reached. `POST /api/computers/[id]/ping` logs the calling agent and the outcome on every path, `registered` marking a machine's first ping and a rejected body carrying the `details` tree naming the field that failed — nobody reads the 400 the agent gets back. Nothing else logs through it yet. diff --git a/docs/releases.md b/docs/releases.md new file mode 100644 index 0000000..6cc9ba2 --- /dev/null +++ b/docs/releases.md @@ -0,0 +1,34 @@ +# Release engineering + +**Image.** Multi-stage `Dockerfile` on `node:24-alpine` producing the Next.js standalone server, running as a fixed non-root UID (65532) so k8s `runAsNonRoot` can verify it. `output: "standalone"` in `next.config.ts` is what makes the runtime stage possible; the traced bundle plus `.next/static` and `public/` is all the runner gets. + +**Migrations run from the entrypoint**, not a separate Job — one replica, one deploy. `RUN_MIGRATIONS=false` opts a container out. The standalone bundle contains only what the server traced, so the image carries its own copy of the Prisma CLI, npm-installed at build time from the versions in `package.json` so it cannot drift from the generated client. `prisma.config.ts` is copied next to that `node_modules` tree because its `dotenv` and `prisma/config` imports have to resolve somewhere, and the config's relative migrations path then points at the copy beside it. Do not try to shrink that tree by deleting `@prisma/studio-core` or `@prisma/dev`: `build/cli.js` requires both eagerly and the CLI stops loading at all. + +**Build args vs secrets.** Every `NEXT_PUBLIC_*` value is a plain build arg — `NEXT_PUBLIC_SENTRY_DSN`, `NEXT_PUBLIC_SENTRY_ENVIRONMENT`, `NEXT_PUBLIC_APP_VERSION` and `NEXT_PUBLIC_COMMIT_HASH`. A DSN is public, the rest name the build or the environment, and `next build` freezes all of them into the bundle, so setting any of them on the running container reports nothing at all. `NEXT_PUBLIC_SENTRY_RELEASE` is the exception: `next.config.ts` derives it from the version and it is not an input. An empty `NEXT_PUBLIC_SENTRY_ENVIRONMENT` falls back to `NODE_ENV`, which is what an unconfigured image reports as. `SENTRY_ORG` and `SENTRY_PROJECT` are build args too: they only address the upload target, and without them `withSentryConfig` has nowhere to send source maps. `SENTRY_AUTH_TOKEN` is the only real secret and comes in as a BuildKit secret mount; a build arg would land in the image history. The publish workflow derives the version from the ref it is building and its distance to the last release tag — hence the full-history checkout — and the hash from the commit, and reads `NEXT_PUBLIC_SENTRY_DSN`, `NEXT_PUBLIC_SENTRY_ENVIRONMENT`, `SENTRY_ORG` and `SENTRY_PROJECT` from repository *variables*, the token from a repository *secret*. + +**Workflows** (`.github/workflows/`), actions pinned to a patch version, mirroring [BSStudio/request-manager](https://github.com/BSStudio/request-manager): + +| Workflow | When | What | +| --- | --- | --- | +| `ci.yml` | PR, push to `main` | Biome + tsc, Vitest with coverage → Codecov, commitlint over the PR range | +| `docker-publish.yml` | PR (build only), push to `main`, `v*.*.*` tags | Buildx → `ghcr.io/bsstudio/backstage`, provenance attestation | +| `authentik-contract.yml` | Weekly cron, dispatch, PRs touching `lib/authentik/**` | Diffs Authentik's published spec against `scripts/authentik-contract.json` | + +`ci.yml` carries no `paths` filter on purpose: it is what `main` is protected on, and a required check that never runs on a docs-only PR blocks that PR instead of passing it. Both node jobs run `prisma generate` first — `app/generated/prisma/` is gitignored and `tsc` resolves through it. + +The contract check reads goauthentik's `main` rather than a release, so a field rename shows up before the studio instance is upgraded into it. A scheduled run that drifts opens an issue (deduplicated by title); on a PR the red job is the notification. The snapshot is refreshed by hand with `pnpm authentik:contract --update` once the change is understood. + +**Renovate** (`.github/renovate.json`). Non-major npm and GitHub Actions groups and the monthly lockfile refresh automerge; everything else waits for a human. Majors are separate PRs, which is what a Next.js major wants to be: a PR that says a version landed and that nobody merges on sight, because it is a migration rather than a bump (see [AGENTS.md](../AGENTS.md)). The config file does nothing on its own: the Renovate GitHub App has to be installed on the repository. `schedule:weekends` opens PRs across both days, `automergeSchedule` merges them Monday 04:00–08:00. The gap is the review pass: by Monday `schedule:weekends` has stopped opening new PRs, so the set that merges is the set that was reviewed. An out-of-schedule run still processes branches that already have a PR — only branch *creation* is skipped — which is what leaves the Monday window reachable at all. That pairing is also why `platformAutomerge` is **off**: GitHub's own auto-merge is enqueued when the PR is created and fires the moment checks go green, which ignores `automergeSchedule` entirely. Renovate merges these itself instead, and it waits for CI on its own rather than relying on branch protection. Vulnerability alerts opt back out of both schedules. + +**GitHub-side settings.** None of this lives in the repository, all of it is assumed by the automation, and all of it is set once: + +| Kind | Name | Without it | +| --- | --- | --- | +| Secret | `CODECOV_TOKEN` | the coverage upload fails the `Test` job (`fail_ci_if_error`) | +| Secret | `SENTRY_AUTH_TOKEN` | no source map upload, so production stack traces stay minified | +| Variable | `NEXT_PUBLIC_SENTRY_DSN` | the built image reports nothing to Sentry at all | +| Variable | `SENTRY_ORG` / `SENTRY_PROJECT` | source map upload has no target | + +Branch protection on `main` requires exactly `ci.yml`'s three jobs — `Lint and typecheck`, `Test`, `Commit messages`. The Docker and contract jobs must **not** be required: both filter on paths, and a required check that never reports blocks the PR forever instead of passing it. "Require branches to be up to date" stays off, or every Renovate PR stalls — `rebaseWhen: "conflicted"` will not rebase a merely stale branch. + +Also repository-level: squash-only merging (see Renovate above), the Renovate and Codecov apps installed, Issues enabled with the assignee named in `authentik-contract.yml` holding repository access, and the `ghcr.io/bsstudio/backstage` package's access linked back to this repository so deployment hosts can pull it. diff --git a/docs/structure.md b/docs/structure.md new file mode 100644 index 0000000..8b24ec8 --- /dev/null +++ b/docs/structure.md @@ -0,0 +1,74 @@ +# Repository structure + +- `app/(portal)/` — authenticated pages (members, apps, computers, admin, dashboard) +- `app/api/` — REST routes: `members/`, `members/[id]/{roles,avatar}`, `usernames/suggest`, `computers/[id]/ping`, `auth/[...all]` +- `app/avatars/[...path]/route.ts` — serves avatar bytes from whichever storage backend is active +- `lib/services/` — business logic (`members.ts`, `sync-jobs.ts`, `usernames.ts`, `google-group.ts`, `audit.ts`, `app-links.ts`, `calendar.ts`, `computers.ts`, `website.ts`). All real work happens here. `member-schemas.ts`, `google-group-schemas.ts`, `app-link-schemas.ts` and `computer-schemas.ts` hold the Zod schemas, split out so client forms can import them; `pagination.ts` holds the page size and the skip/take arithmetic the paginated admin lists share +- `lib/actions/` — Server Actions; thin wrappers around services. `result.ts` owns the `ActionResult` shape they all return, the two failure constants and `mapActionError` +- `lib/authentik/` — Authentik REST client (`client`, `users`, `groups`), plus `issuer.ts`, which owns the one spelling of `AUTHENTIK_ISSUER` every path is joined onto +- `lib/website/` — `webhook.ts`, the whole client for the website: one bearer POST of the member push contract — see [The website](integrations.md#the-website-members-webhook) +- `lib/active-directory/` — `config.ts` (the `AD_*` settings and a member's DN) and `client.ts` (one bound LDAPS connection per call: create an account, replace attributes) — see [Active Directory](integrations.md#active-directory) +- `lib/google/` — the Google clients sharing one signer: `client.ts` (token minting + transport, `googleFetch` for an absolute URL, `googleRequest` for a Cloud Identity path), `groups.ts` (membership operations), `calendar.ts` (the studio calendar read) +- `lib/sync/` — `executor.ts` + per-target `authentik/{operations,orchestrators,attributes,group-mapping}.ts`, `google/{operations,orchestrators}.ts`, `website/{operations,orchestrators,payload}.ts` and `active-directory/{attributes,operations,orchestrators}.ts` +- `lib/storage/` + `lib/avatar-storage.ts` — avatar storage facade and local/S3 backends +- `lib/http.ts` — the timeout every outgoing request carries, and what a rejection that never became an HTTP answer says +- `lib/errors.ts` — typed error hierarchy + `mapServiceError` +- `lib/api-response.ts` — `syncJson` / `syncJsonResource`, the answer every mutating route returns +- `lib/toast.ts` — `toastSync`, the success-or-partial-failure toast every mutation raises +- `lib/permissions.ts` — every role rule: `can*` predicates for the UI, `ensureCan*` guards that throw `ForbiddenError` for services +- `lib/api-client-auth.ts` — bearer verification for machine-to-machine callers, one Authentik group per kind of caller +- `lib/carddav/` — the read-only CardDAV endpoint: `paths.ts` (URL shapes, recognised without loading the rest), `handler.ts` (method dispatch + Basic auth), `vcard.ts`, `xml.ts`. Served from `proxy.ts`, not `app/api/` — see [Architectural decisions](decisions.md) +- `lib/rate-limit.ts` — in-memory fixed-window rate limiter. State is per process, so every limit it enforces is per replica; the single-replica deployment is what makes that correct +- `lib/rdp.ts` — the `.rdp` file `/computers` hands out, and the three environment values that shape it. Separate from `lib/computers.ts` because client components import that one and would carry the environment read into the browser bundle +- `lib/observability/` — `sentry.ts` (shared init options), `scrub.ts` (PII redaction), `capture.ts` (capture helpers), `logger.ts` (structured logging) +- `lib/members.ts`, `lib/audit.ts`, `lib/nav-labels.ts`, `lib/sync-jobs.ts`, `lib/google-group.ts`, `lib/app-links.ts`, `lib/calendar.ts`, `lib/computers.ts` — display helpers + Hungarian labels. `lib/app-links.ts` also owns the icon name → lucide component map and the per-accent tile classes; `lib/calendar.ts` owns every Hungarian date and time string the dashboard renders, and the split between the two kinds of formatter that needs — see [Dates and the studio zone](data-model.md#dates-and-the-studio-zone) +- `components/form.tsx` — TanStack Form `useAppForm` plus the field/submit components every form is built from +- `components/` — shared pieces above the primitives: `page-nav.tsx` (admin list pagination), `status-badge.tsx` (`StatusBadge` / `ArchivedBadge`), `archive-dialog.tsx` (the archive confirmation and its mailing-list checkbox), `app-link-card.tsx` (one row on /apps), `computer-card.tsx` (one workstation on /computers), `auto-refresh.tsx` (re-runs a page's server components on a timer), `portal-shell.tsx` (the authenticated frame) +- `components/dashboard/` — the widgets `/` composes, one file per source: `calendar.tsx` (`HeroEvent` + `UpcomingEvents`, two server components sharing one `cache()`d read), `profile-card.tsx` (which also exports the member read the page's greeting reuses), `quick-links.tsx`, and `computers-card.tsx` +- `components/ui/` — shadcn/ui primitives +- `scripts/` — dev tooling run with `tsx`: `dev-setup.ts`, `seed-dev.ts` (+ `seed-data.ts` roster, `dev-user.ts` identity prompt, `dev-groups.ts` Authentik group UUIDs), `reset-db.ts`, `authentik-contract.ts` + its `authentik-contract.json` snapshot, and the three credential probes, `google-group-probe.ts`, `google-calendar-probe.ts` and `active-directory-probe.ts`, which read the configured group, calendar and members OU through the real clients. Everything here runs on a developer's own machine against their `.env`, and nothing here is shipped +- `workstation-agent/` — the studio workstation agent: `backstage-agent.ps1` (the ping loop), `install-agent.ps1` (the scheduled task, the ACL and the DPAPI-encrypted credential), `bootstrap.ps1` (the one-liner that fetches the other two at the latest release tag), and their own `README.md`. Top level rather than under `scripts/` because it is not tooling for this repository: it is Windows PowerShell fetched over HTTP onto machines that never clone this, and its paths are part of a published install URL +- `tests/` — mirrors the source layout; `setup.ts` spins Testcontainers Postgres +- `proxy.ts` — route protection (Next.js 16 convention), and the CardDAV endpoint, which cannot live in a route handler +- `instrumentation.ts`, `instrumentation-client.ts`, `sentry.{server,edge}.config.ts` — Sentry bootstrap (Next.js 16 conventions) +- `prisma/schema.prisma` — single schema, client generated to `app/generated/prisma/` +- `Dockerfile`, `docker-entrypoint.sh`, `docker-compose.yml` — production image and stack; `docker-compose.dev.yml` is just the dev Postgres +- `.github/workflows/` — `ci.yml`, `docker-publish.yml`, `authentik-contract.yml`, all sharing the `.github/actions/setup` composite: `pnpm/setup`, which reads the pnpm version from `packageManager` and is pointed at `.nvmrc` for the runtime. Named rather than left to the action's own detection, which falls back through `.node-version`, `.nvmrc` and `.tool-versions` and defers to `devEngines.runtime` — so adding any of those would silently move CI onto another Node. It installs too: `require-lockfile` is what makes a *missing* lockfile fail the job, which a frozen install does not — pnpm resolves from the registry, writes one and exits 0. It needs pnpm 11 or newer. `.github/renovate.json` for dependency updates + +## Where to make a change + +**Add or change a Member field** +1. `prisma/schema.prisma` → `pnpm db:migrate` +2. `CreateMemberSchema` / `UpdateMemberSchema` in `lib/services/member-schemas.ts` (re-exported from `members.ts`) — the forms validate against the `*FormSchema` variants derived there +3. Should it reach Authentik? Add to `AUTHENTIK_SYNCED_FIELDS` *and* `buildAuthentikAttributes()` (`lib/sync/authentik/attributes.ts`) — the attribute set is sent wholesale, see [Architectural decisions](decisions.md) +4. Should it reach the website? Only if the contract names it — it is theirs, not ours. Then `WebsiteMember` (`lib/website/webhook.ts`), `buildWebsiteMember` (`lib/sync/website/payload.ts`) and, if a change to it should trigger a push, `WEBSITE_SYNCED_FIELDS` +5. Should it reach Active Directory? Almost certainly not — an AD account carries the names and the enabled flag and nothing else, see [Active Directory](integrations.md#active-directory). If it does, `buildDirectoryAttributes` (`lib/sync/active-directory/attributes.ts`) and `ACTIVE_DIRECTORY_SYNCED_FIELDS` +6. Should it reach a synced phone? Almost certainly not — a vCard carries contact details only, see [Architectural decisions](decisions.md). If it does, `VCardMember` and `renderVCard` (`lib/carddav/vcard.ts`) *and* the projection in `listCardDavMembers` +7. UI: `members/new/page.tsx`, `members/[id]/member-edit-sheet.tsx`, table `columns.tsx` +8. Tests: `tests/services/members.test.ts` + +**Add a sync operation** +1. Low-level call in `lib/authentik/*` (then see below), `lib/google/groups.ts` or `lib/active-directory/client.ts` +2. Register the handler in `lib/sync//operations.ts` — it receives `(payload, memberId, prisma, jobId)` and resolves external IDs itself at execute time — `jobId` is there for a target whose endpoint takes an idempotency key +3. Orchestrator in `lib/sync//orchestrators.ts`: hand `runSyncJob` the row to write, plus a skip reason if the target has a condition no retry could fix +4. Call the orchestrator from `lib/services/`, and turn the results into `syncErrors` with `collectSyncErrors` +5. A new operation name also needs a `SyncOperation` enum value + migration + +**Add or change an Authentik API call** — any new endpoint, query parameter or request/response field in `lib/authentik/*` has to be mirrored into the contract check, or it goes unwatched. +1. The call itself in `lib/authentik/{client,users,groups}.ts` +2. Add or amend its entry in `CONTRACT` (`scripts/authentik-contract.ts`) — one object per `method` + `path`, listing the `query` parameters, `request` fields and `response` fields the client actually touches. Nothing derives this from the source, so a call with no entry passes the check silently forever +3. `pnpm authentik:contract --update`, and commit the regenerated `scripts/authentik-contract.json` with the change — a stale snapshot fails the next PR that touches `lib/authentik/**` +4. Removing a call means removing its `CONTRACT` entry too, otherwise the check guards a field nobody reads + +**Add an API route** — keep it a thin adapter: `requireAuth()` or `requirePermission()` from `lib/session.ts`, `toActor(session)` for the service call, `syncJson(body, syncErrors)` for the answer — or `syncJsonResource(key, resource, syncErrors)` when the body is the resource itself — and `mapServiceError` around failures. No business logic, and no role list of its own — the predicate comes from `lib/permissions.ts` and the service guards itself regardless. + +**Add something the workstation agent reports** +1. Collect it in `Get-Metadata` (`workstation-agent/backstage-agent.ps1`), inside its own `try`, so a machine that cannot answer still pings +2. Add the field to `ComputerMetadataSchema` (`lib/services/computer-schemas.ts`) — unknown keys are stripped, so an agent reporting a field the schema does not name writes nothing +3. Render it from `lib/computers.ts` and `components/computer-card.tsx` +4. Report a fact, not a conclusion. The portal decides what a fact means, so changing that decision is not a reinstall on every workstation +5. Old agents keep running: every field is optional, and absent has to stay distinguishable from an empty value. `loggedInUser` is the worked example — omitted when the session lookup failed, `null` when it ran and found nobody, and only the second means the machine is free + +**Add a portal page** — `app/(portal)/…/page.tsx` exporting `metadata` and, if it reads data, opening with `pageActor()` — or `pageActor()` when a role gates it; a Hungarian label in `lib/nav-labels.ts` (used by both sidebar and breadcrumbs); and a sidebar entry in `components/app-sidebar.tsx`. + +**Add a dashboard widget** — one file in `components/dashboard/`, exporting a server component that **reads its own data**. `app/(portal)/page.tsx` composes and never queries on a widget's behalf, so a feature arrives with its card instead of the page growing a read per source. A widget that talks to something off our own network goes in its own `` with a `Skeleton`, so the rest of the page does not wait for it. Two widgets over one source share a `cache()`d reader rather than querying twice (`readCalendar`, `readOwnProfile`). diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..716f900 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,9 @@ +# Testing + +Vitest + Testcontainers (real PostgreSQL). Service-layer tests are integration tests against the container; routes and actions get smoke tests for auth and error mapping only. + +- `tests/setup.ts` — starts the container in `beforeAll`, migrates, truncates all tables in `afterEach`, stops in `afterAll`. Exports `getTestPrisma()` and `mockPrisma()`. +- `tests/helpers.ts` — `mockSession(overrides)` / `mockNoSession()` replace `@/lib/session` for route tests. `mockAuthApi(getSession)` mocks `next/headers` and `@/lib/auth` *underneath* it instead, so action and session tests run the real helpers and genuinely exercise the role predicates. `mockWebsiteOrchestrators()` and `mockGoogleGroupOrchestrators()` stub the external fan-out and return their mocks, so a test can fail one call and assert on it. Each factory is annotated with the module it replaces, because `vi.doMock` swaps one wholesale: an orchestrator a factory leaves out is `undefined` at the call site, and the annotation turns that into a typecheck failure in the helper. +- Route tests use `vi.resetModules()` + `vi.doMock()` + dynamic `import()` to swap in the test database and session before each test. + +Coverage includes `app/**/*.ts`, `lib/**/*.ts`, `types/**/*.ts` and, named one by one because a root glob would pull in every config file, `proxy.ts` and `instrumentation.ts` — route protection and the Sentry bootstrap are not wiring. It excludes `app/generated/**`, `app/api/auth/**`, and the config/wiring files `lib/auth.ts`, `lib/auth-client.ts`, `lib/prisma.ts`, `lib/utils.ts`. The 100% figure is enforced, not just documented: `coverage.thresholds` in `vitest.config.ts` fails `pnpm test:coverage` — and so CI — on a drop. From 46d414c4453fe2eed9b6fbfc8e6d6772c1e53535 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliv=C3=A9r=20Kecskem=C3=A9ty?= Date: Fri, 13 Nov 2026 22:58:23 +0100 Subject: [PATCH 2/8] docs: trim claude.md to rules, commands and conventions --- CLAUDE.md | 121 +++++++++++++++++------------------------------------- 1 file changed, 38 insertions(+), 83 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 762b474..8e753f8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,17 +2,13 @@ # Backstage -Internal portal for a university TV studio (BSS). Source of truth for member data, syncing to -Authentik (identity provider), the Active Directory domain behind the studio workstations, the -studio's public website and its Google Group mailing lists, which it also reconciles against the -member roster. Also the studio's landing page. +Internal portal for a university TV studio (BSS). Source of truth for member data, syncing to Authentik (identity provider), the Active Directory domain behind the studio workstations, the studio's public website and its Google Group mailing lists, which it also reconciles against the member roster. Also the studio's landing page. -Scale: ~30 active members, growing alumni list (~10 per semester). Single deployment, no tenancy, -no public traffic. +Scale: ~30 active members, growing alumni list (~10 per semester). Single deployment, no tenancy, no public traffic. -This file describes the system **as it exists**. +> Spotify has an unrelated open-source project also called Backstage (backstage.io). No shared code and nothing published under `@backstage/*` — anything about plugins, catalogs or software templates is theirs, not this. -The architecture is documented in [docs/](docs/), one file per topic. Before building a new feature, read the docs for the area it touches and follow the existing patterns. +The architecture is documented in [docs/](docs/), one file per topic, describing the system **as it exists**. Before building a new feature, read the docs for the area it touches and follow the existing patterns. - [structure.md](docs/structure.md) — where code lives, and which files each common kind of change touches. - [data-model.md](docs/data-model.md) — the Prisma models, membership status, the semester format, dates and the studio time zone. @@ -25,35 +21,22 @@ The architecture is documented in [docs/](docs/), one file per topic. Before bui - [decisions.md](docs/decisions.md) — why things are the way they are; read it before changing an established pattern. - [dev-data.md](docs/dev-data.md) — what `pnpm db:seed` writes, and how to test against a dev Authentik. ---- +## Before calling something done -## Rules - -Breaking these causes real damage, not style nits. +```bash +pnpm check # biome check + tsc --noEmit +pnpm test:coverage # Docker must be running: Testcontainers spins a real PostgreSQL +``` -1. **`pnpm`, never `npm`/`yarn`.** Lockfile is `pnpm-lock.yaml`. -2. **This is Next.js 16, not 15.** APIs and file conventions differ from most training data — read - the relevant guide under `node_modules/next/dist/docs/` before writing framework code. Route - protection is `proxy.ts`, *not* `middleware.ts`. -3. **Never edit `app/generated/`** — Prisma output. Change `prisma/schema.prisma` and regenerate. -4. **Biome, not ESLint/Prettier.** `next lint` is disabled; `pnpm lint` is `biome check`. - lint-staged formats on commit, so running `--write` by hand is usually unnecessary. -5. **Conventional commits** — enforced by commitlint. Subject lowercase, ≤72 chars. -6. **All user-facing text is Hungarian.** Code identifiers and enum values stay English. -7. **Coverage is at 100%** (statements/branches/functions/lines) for everything in the coverage - include set. Land tests with the change. Genuinely unreachable defensive branches get a - `/* v8 ignore next -- reason */` rather than dragging the number down. -8. **Docker must be running** for tests — Testcontainers spins a real PostgreSQL. +CI runs the same two. ---- - -## Commands +### Commands | Command | What it does | | --- | --- | | `pnpm dev` | Dev server | | `pnpm dev:setup` | One-command onboarding: `.env`, Postgres container, generate, migrate, seed | -| `pnpm check` | `biome check` + `tsc --noEmit` — run before declaring done | +| `pnpm check` | `biome check` + `tsc --noEmit` | | `pnpm typecheck` | Types only | | `pnpm lint` | Biome only (no writes) | | `pnpm test` | Full suite (needs Docker) | @@ -69,62 +52,34 @@ Breaking these causes real damage, not style nits. Environment variables: see `.env.example`. It is the complete list and is kept in sync. ---- +## Commits and branches + +Conventional commits — enforced by commitlint. Subject lowercase, ≤72 chars. PRs are squash-merged: the PR title becomes the commit on `main`, so it follows the same convention. + +## Rules + +Breaking these causes real damage, not style nits. -## Conventions +1. **This is Next.js 16, not 15.** APIs and file conventions differ from most training data — read the relevant guide under `node_modules/next/dist/docs/` before writing framework code. Route protection is `proxy.ts`, *not* `middleware.ts`. +2. **Never edit `app/generated/`** — Prisma output. Change `prisma/schema.prisma` and regenerate. +3. **Biome, not ESLint/Prettier.** `next lint` is disabled; `pnpm lint` is `biome check`. lint-staged formats on commit, so running `--write` by hand is usually unnecessary. +4. **All user-facing text is Hungarian.** Code identifiers and enum values stay English. +5. **Coverage is at 100%** (statements/branches/functions/lines) for everything in the coverage include set. Land tests with the change. Genuinely unreachable defensive branches get a `/* v8 ignore next -- reason */` rather than dragging the number down. -- Hungarian for anything a user sees; English for identifiers and enum values -- **Comments are minimal and explain *why*, never *what*.** Code is expected to read on its own — - name things instead of narrating them. A comment earns its place only when the reason is not - recoverable from the code: a non-obvious constraint, a trade-off, a workaround for someone else's - bug. No module-header docblocks restating the filename, no JSDoc that repeats the signature, no - step-by-step commentary on straightforward control flow. The same holds for `.env.example` and - config files — describe what a value does, never general advice the reader already knows -- Zod `.trim()` on every string input; empty strings become `null` in the service layer so optional - fields can be cleared +### Conventions + +- Zod `.trim()` on every string input; empty strings become `null` in the service layer so optional fields can be cleared - `archived: false` is the default filter on member queries -- Leadership roles are deleted when they end, archiving their holder included — history survives - in `TimelineEntry` +- Leadership roles are deleted when they end, archiving their holder included — history survives in `TimelineEntry` - 207 responses carry `syncErrors`; the UI shows a warning toast, never an error -- Toasts (sonner) for every mutation — `toast.error()` for a refusal, and - `toastSync(message, syncErrors)` (`lib/toast.ts`) for a success, which downgrades itself to a - warning naming the failures when a sync step did not land -- Per-button loading spinners, never one global spinner — pages with several actions must show - feedback on the button that was clicked. Each form carries its own `isSubmitting`; where several - actions share one `useTransition` (`members-table.tsx`) a `pendingAction` discriminator says - which button is busy -- Forms are TanStack Form via `useAppForm` (`components/form.tsx`) — no raw `FormData` reads. - Fields are `` + a field component, submit buttons go inside ``. - `SelectField` for a handful of options, `ComboboxField` (Popover + cmdk) once the list is long - enough to need typing — but only when the reader knows what they are looking for, see the icon - grid under [Architectural decisions](docs/decisions.md) — its filter replaces cmdk's default fuzzy scoring, which matches any name - carrying the query's letters in order, with an accent-insensitive substring match. A field's - `hint` renders a note under the input and joins `aria-describedby` +- Toasts (sonner) for every mutation — `toast.error()` for a refusal, and `toastSync(message, syncErrors)` (`lib/toast.ts`) for a success, which downgrades itself to a warning naming the failures when a sync step did not land +- Per-button loading spinners, never one global spinner — pages with several actions must show feedback on the button that was clicked. Each form carries its own `isSubmitting`; where several actions share one `useTransition` (`members-table.tsx`) a `pendingAction` discriminator says which button is busy +- Forms are TanStack Form via `useAppForm` (`components/form.tsx`) — no raw `FormData` reads. Fields are `` + a field component, submit buttons go inside ``. `SelectField` for a handful of options, `ComboboxField` (Popover + cmdk) once the list is long enough to need typing — but only when the reader knows what they are looking for, see the icon grid under [Architectural decisions](docs/decisions.md) — its filter replaces cmdk's default fuzzy scoring, which matches any name carrying the query's letters in order, with an accent-insensitive substring match. A field's `hint` renders a note under the input and joins `aria-describedby` - Every portal page exports `metadata` (or `generateMetadata`) for a descriptive Hungarian title -- Non-login form fields carry `autoComplete="off"` + `data-1p-ignore` + `data-lpignore="true"` to - suppress password-manager autofill -- `components/ui/` primitives 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 -- `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 - ---- - -## Design tokens - -Light + dark via `next-themes`. Tokens in `app/globals.css` map BSS brand blue (`#005baa`) to the -shadcn `--primary`. Geist sans + mono. Status badges use `text-status-*` / `bg-status-*` utilities — -class map in `lib/members.ts`, markup in `components/status-badge.tsx`. - -> Spotify has an unrelated open-source project also called Backstage (backstage.io). No shared code -> and nothing published under `@backstage/*` — anything about plugins, catalogs or software -> templates is theirs, not this. +- Non-login form fields carry `autoComplete="off"` + `data-1p-ignore` + `data-lpignore="true"` to suppress password-manager autofill +- `components/ui/` primitives 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 +- `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 + +### Design tokens + +Light + dark via `next-themes`. Tokens in `app/globals.css` map BSS brand blue (`#005baa`) to the shadcn `--primary`. Geist sans + mono. Status badges use `text-status-*` / `bg-status-*` utilities — class map in `lib/members.ts`, markup in `components/status-badge.tsx`. From ba24ee04c648016d3aa3185b2ab59dc062208384 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliv=C3=A9r=20Kecskem=C3=A9ty?= Date: Fri, 13 Nov 2026 23:02:16 +0100 Subject: [PATCH 3/8] docs: fix stale claims about commits, pins and required checks --- CLAUDE.md | 2 +- README.md | 3 +-- docs/releases.md | 11 ++++++----- docs/structure.md | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8e753f8..6b83f23 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,7 +54,7 @@ Environment variables: see `.env.example`. It is the complete list and is kept i ## Commits and branches -Conventional commits — enforced by commitlint. Subject lowercase, ≤72 chars. PRs are squash-merged: the PR title becomes the commit on `main`, so it follows the same convention. +Conventional commits — enforced by commitlint. Subject lowercase. PRs are squash-merged: the PR title becomes the commit on `main`, so it follows the same convention. ## Rules diff --git a/README.md b/README.md index f363b2e..ce228d4 100644 --- a/README.md +++ b/README.md @@ -62,8 +62,7 @@ Branch off `main`, open a PR. Three things are enforced rather than suggested, a 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. + 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 diff --git a/docs/releases.md b/docs/releases.md index 6cc9ba2..529d51a 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -6,19 +6,20 @@ **Build args vs secrets.** Every `NEXT_PUBLIC_*` value is a plain build arg — `NEXT_PUBLIC_SENTRY_DSN`, `NEXT_PUBLIC_SENTRY_ENVIRONMENT`, `NEXT_PUBLIC_APP_VERSION` and `NEXT_PUBLIC_COMMIT_HASH`. A DSN is public, the rest name the build or the environment, and `next build` freezes all of them into the bundle, so setting any of them on the running container reports nothing at all. `NEXT_PUBLIC_SENTRY_RELEASE` is the exception: `next.config.ts` derives it from the version and it is not an input. An empty `NEXT_PUBLIC_SENTRY_ENVIRONMENT` falls back to `NODE_ENV`, which is what an unconfigured image reports as. `SENTRY_ORG` and `SENTRY_PROJECT` are build args too: they only address the upload target, and without them `withSentryConfig` has nowhere to send source maps. `SENTRY_AUTH_TOKEN` is the only real secret and comes in as a BuildKit secret mount; a build arg would land in the image history. The publish workflow derives the version from the ref it is building and its distance to the last release tag — hence the full-history checkout — and the hash from the commit, and reads `NEXT_PUBLIC_SENTRY_DSN`, `NEXT_PUBLIC_SENTRY_ENVIRONMENT`, `SENTRY_ORG` and `SENTRY_PROJECT` from repository *variables*, the token from a repository *secret*. -**Workflows** (`.github/workflows/`), actions pinned to a patch version, mirroring [BSStudio/request-manager](https://github.com/BSStudio/request-manager): +**Workflows** (`.github/workflows/`), actions pinned to a commit SHA with the version in a comment, mirroring [BSStudio/request-manager](https://github.com/BSStudio/request-manager): | Workflow | When | What | | --- | --- | --- | | `ci.yml` | PR, push to `main` | Biome + tsc, Vitest with coverage → Codecov, commitlint over the PR range | +| `checks.yml` | PR, push to `main` | BSStudio/.github's shared checks: actionlint and zizmor over the workflows, dependency review on PRs | | `docker-publish.yml` | PR (build only), push to `main`, `v*.*.*` tags | Buildx → `ghcr.io/bsstudio/backstage`, provenance attestation | | `authentik-contract.yml` | Weekly cron, dispatch, PRs touching `lib/authentik/**` | Diffs Authentik's published spec against `scripts/authentik-contract.json` | -`ci.yml` carries no `paths` filter on purpose: it is what `main` is protected on, and a required check that never runs on a docs-only PR blocks that PR instead of passing it. Both node jobs run `prisma generate` first — `app/generated/prisma/` is gitignored and `tsc` resolves through it. +`ci.yml` and `checks.yml` carry no `paths` filter on purpose: they are what `main` is protected on, and a required check that never runs on a docs-only PR blocks that PR instead of passing it. Both node jobs run `prisma generate` first — `app/generated/prisma/` is gitignored and `tsc` resolves through it. The contract check reads goauthentik's `main` rather than a release, so a field rename shows up before the studio instance is upgraded into it. A scheduled run that drifts opens an issue (deduplicated by title); on a PR the red job is the notification. The snapshot is refreshed by hand with `pnpm authentik:contract --update` once the change is understood. -**Renovate** (`.github/renovate.json`). Non-major npm and GitHub Actions groups and the monthly lockfile refresh automerge; everything else waits for a human. Majors are separate PRs, which is what a Next.js major wants to be: a PR that says a version landed and that nobody merges on sight, because it is a migration rather than a bump (see [AGENTS.md](../AGENTS.md)). The config file does nothing on its own: the Renovate GitHub App has to be installed on the repository. `schedule:weekends` opens PRs across both days, `automergeSchedule` merges them Monday 04:00–08:00. The gap is the review pass: by Monday `schedule:weekends` has stopped opening new PRs, so the set that merges is the set that was reviewed. An out-of-schedule run still processes branches that already have a PR — only branch *creation* is skipped — which is what leaves the Monday window reachable at all. That pairing is also why `platformAutomerge` is **off**: GitHub's own auto-merge is enqueued when the PR is created and fires the moment checks go green, which ignores `automergeSchedule` entirely. Renovate merges these itself instead, and it waits for CI on its own rather than relying on branch protection. Vulnerability alerts opt back out of both schedules. +**Renovate** (`.github/renovate.json`) extends [BSStudio/renovate-config](https://github.com/BSStudio/renovate-config) with the `weekly` window and `approve-merges`: its README says when PRs open, what merges unattended and what waits for an approval, and each of this repo's own rules carries its reason in `description`. Majors are separate PRs, which is what a Next.js major wants to be: a PR that says a version landed and that nobody merges on sight, because it is a migration rather than a bump (see [AGENTS.md](../AGENTS.md)). `approve-merges` merges through GitHub auto-merge, which waits only for required checks, so it rests on the ruleset below; `CODEOWNERS` leaves workflows, actions and the lockfile without an owner, so their updates merge without an approval. The config file does nothing on its own: the Renovate GitHub App has to be installed on the repository. **GitHub-side settings.** None of this lives in the repository, all of it is assumed by the automation, and all of it is set once: @@ -29,6 +30,6 @@ The contract check reads goauthentik's `main` rather than a release, so a field | Variable | `NEXT_PUBLIC_SENTRY_DSN` | the built image reports nothing to Sentry at all | | Variable | `SENTRY_ORG` / `SENTRY_PROJECT` | source map upload has no target | -Branch protection on `main` requires exactly `ci.yml`'s three jobs — `Lint and typecheck`, `Test`, `Commit messages`. The Docker and contract jobs must **not** be required: both filter on paths, and a required check that never reports blocks the PR forever instead of passing it. "Require branches to be up to date" stays off, or every Renovate PR stalls — `rebaseWhen: "conflicted"` will not rebase a merely stale branch. +The `main` ruleset requires exactly five checks: `ci.yml`'s `Lint and typecheck`, `Test` and `Commit messages`, and `checks.yml`'s `Checks / Workflows` and `Checks / Dependencies`. It also requires code owner review and keeps approvals across pushes, as `approve-merges` expects. The Docker and contract jobs must **not** be required: both filter on paths, and a required check that never reports blocks the PR forever instead of passing it. "Require branches to be up to date" stays off, or every Renovate PR stalls — `rebaseWhen: "conflicted"` will not rebase a merely stale branch. -Also repository-level: squash-only merging (see Renovate above), the Renovate and Codecov apps installed, Issues enabled with the assignee named in `authentik-contract.yml` holding repository access, and the `ghcr.io/bsstudio/backstage` package's access linked back to this repository so deployment hosts can pull it. +Also repository-level: squash-only merging with the PR title as the commit message, auto-merge allowed (for `approve-merges`), the Renovate and Codecov apps installed, Issues enabled with the assignee named in `authentik-contract.yml` holding repository access, and the `ghcr.io/bsstudio/backstage` package's access linked back to this repository so deployment hosts can pull it. diff --git a/docs/structure.md b/docs/structure.md index 8b24ec8..f3347af 100644 --- a/docs/structure.md +++ b/docs/structure.md @@ -33,7 +33,7 @@ - `instrumentation.ts`, `instrumentation-client.ts`, `sentry.{server,edge}.config.ts` — Sentry bootstrap (Next.js 16 conventions) - `prisma/schema.prisma` — single schema, client generated to `app/generated/prisma/` - `Dockerfile`, `docker-entrypoint.sh`, `docker-compose.yml` — production image and stack; `docker-compose.dev.yml` is just the dev Postgres -- `.github/workflows/` — `ci.yml`, `docker-publish.yml`, `authentik-contract.yml`, all sharing the `.github/actions/setup` composite: `pnpm/setup`, which reads the pnpm version from `packageManager` and is pointed at `.nvmrc` for the runtime. Named rather than left to the action's own detection, which falls back through `.node-version`, `.nvmrc` and `.tool-versions` and defers to `devEngines.runtime` — so adding any of those would silently move CI onto another Node. It installs too: `require-lockfile` is what makes a *missing* lockfile fail the job, which a frozen install does not — pnpm resolves from the registry, writes one and exits 0. It needs pnpm 11 or newer. `.github/renovate.json` for dependency updates +- `.github/workflows/` — `ci.yml`, `docker-publish.yml`, `authentik-contract.yml` and `checks.yml` (BSStudio/.github's shared checks). `ci.yml` and `authentik-contract.yml` share the `.github/actions/setup` composite: `pnpm/setup`, which reads the pnpm version from `packageManager` and is pointed at `.nvmrc` for the runtime. Named rather than left to the action's own detection, which falls back through `.node-version`, `.nvmrc` and `.tool-versions` and defers to `devEngines.runtime` — so adding any of those would silently move CI onto another Node. It installs too: `require-lockfile` is what makes a *missing* lockfile fail the job, which a frozen install does not — pnpm resolves from the registry, writes one and exits 0. It needs pnpm 11 or newer. `.github/renovate.json` for dependency updates ## Where to make a change From f9afdc2239a96ea408ac7379cfc0c27c86361199 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliv=C3=A9r=20Kecskem=C3=A9ty?= Date: Fri, 13 Nov 2026 23:09:08 +0100 Subject: [PATCH 4/8] docs: move folder-specific rules next to their code --- CLAUDE.md | 2 -- components/CLAUDE.md | 3 +++ components/ui/CLAUDE.md | 5 +++++ workstation-agent/CLAUDE.md | 9 +++++++++ 4 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 components/CLAUDE.md create mode 100644 components/ui/CLAUDE.md create mode 100644 workstation-agent/CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md index 6b83f23..4349d73 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,8 +77,6 @@ Breaking these causes real damage, not style nits. - Forms are TanStack Form via `useAppForm` (`components/form.tsx`) — no raw `FormData` reads. Fields are `` + a field component, submit buttons go inside ``. `SelectField` for a handful of options, `ComboboxField` (Popover + cmdk) once the list is long enough to need typing — but only when the reader knows what they are looking for, see the icon grid under [Architectural decisions](docs/decisions.md) — its filter replaces cmdk's default fuzzy scoring, which matches any name carrying the query's letters in order, with an accent-insensitive substring match. A field's `hint` renders a note under the input and joins `aria-describedby` - Every portal page exports `metadata` (or `generateMetadata`) for a descriptive Hungarian title - Non-login form fields carry `autoComplete="off"` + `data-1p-ignore` + `data-lpignore="true"` to suppress password-manager autofill -- `components/ui/` primitives 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 -- `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 ### Design tokens diff --git a/components/CLAUDE.md b/components/CLAUDE.md new file mode 100644 index 0000000..264765f --- /dev/null +++ b/components/CLAUDE.md @@ -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. diff --git a/components/ui/CLAUDE.md b/components/ui/CLAUDE.md new file mode 100644 index 0000000..94befa6 --- /dev/null +++ b/components/ui/CLAUDE.md @@ -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. diff --git a/workstation-agent/CLAUDE.md b/workstation-agent/CLAUDE.md new file mode 100644 index 0000000..2050575 --- /dev/null +++ b/workstation-agent/CLAUDE.md @@ -0,0 +1,9 @@ +# Workstation agent + +The scripts run on Windows PowerShell 5.1, not PowerShell 7 (see [Architectural decisions](../docs/decisions.md)). Syntax and parameters added in 7 — `??`, `?:`, `&&` and `||` between commands, `ForEach-Object -Parallel`, `ConvertFrom-Json -AsHashtable` — break on the workstations. + +`pnpm check`, the test suite and CI do not cover this folder. Before calling a change done, parse every script with Windows PowerShell 5.1 (`powershell.exe`, not `pwsh`); each must report 0 errors: + +```powershell +powershell.exe -NoProfile -Command 'foreach ($f in Get-ChildItem workstation-agent\*.ps1) { $e = $null; [void][System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$e); "{0}: {1} errors" -f $f.Name, $e.Count }' +``` From d6931c86025ab1e7386b3f6c306269add0df08c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliv=C3=A9r=20Kecskem=C3=A9ty?= Date: Fri, 13 Nov 2026 23:10:25 +0100 Subject: [PATCH 5/8] chore: drop the unused agent skills lock --- .dockerignore | 1 - .gitignore | 3 --- skills-lock.json | 41 ----------------------------------------- 3 files changed, 45 deletions(-) delete mode 100644 skills-lock.json diff --git a/.dockerignore b/.dockerignore index 8fb2afb..a5e9980 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,5 @@ # Anything the image build does not read. -.agents .claude .dev-authentik-groups.json .dev-user.json diff --git a/.gitignore b/.gitignore index 3c859f6..9fbdeb3 100644 --- a/.gitignore +++ b/.gitignore @@ -47,9 +47,6 @@ yarn-error.log* # claude .claude -# skills -/.agents/skills - # vercel .vercel diff --git a/skills-lock.json b/skills-lock.json deleted file mode 100644 index 3740347..0000000 --- a/skills-lock.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "version": 1, - "skills": { - "better-auth-best-practices": { - "source": "better-auth/skills", - "sourceType": "github", - "skillPath": "better-auth/best-practices/SKILL.md", - "computedHash": "9ab075b5061be2a5f299c10505667345cc1ec76e8de4120901cfd586643e776f" - }, - "frontend-design": { - "source": "anthropics/skills", - "sourceType": "github", - "skillPath": "skills/frontend-design/SKILL.md", - "computedHash": "516bd2154eb843a8240e43d5b285229129853114ad7075a5e141e1c08e408c84" - }, - "prisma-database-setup": { - "source": "prisma/skills", - "sourceType": "github", - "skillPath": "prisma-database-setup/SKILL.md", - "computedHash": "0d6f44ac34b1ed3f8ed2970ecf90483d0f658fd1d204458f62bfefa28f31b095" - }, - "shadcn": { - "source": "shadcn/ui", - "sourceType": "github", - "skillPath": "skills/shadcn/SKILL.md", - "computedHash": "f0fdd03f4755cbb3b57e20fe6fd4a97c00d34e6780187b6559316986e8d848db" - }, - "tailwind-design-system": { - "source": "wshobson/agents", - "sourceType": "github", - "skillPath": "plugins/frontend-mobile-development/skills/tailwind-design-system/SKILL.md", - "computedHash": "f54f39ce967af3a4bba1f662c3600f0253467508d5fbbc7c545c67e102da2253" - }, - "vercel-react-best-practices": { - "source": "vercel-labs/agent-skills", - "sourceType": "github", - "skillPath": "skills/react-best-practices/SKILL.md", - "computedHash": "ca7b0c0c6e5f2750043f7f0cd72d16ac4e2abc48f9b5500d047a4b77a2506212" - } - } -} From 5df5c803b9da6e11e6e6fdab41c2953d7a839f8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliv=C3=A9r=20Kecskem=C3=A9ty?= Date: Fri, 13 Nov 2026 23:11:57 +0100 Subject: [PATCH 6/8] docs: unwrap readme paragraphs --- README.md | 36 ++++++++++----------------------- workstation-agent/README.md | 40 ++++++++++--------------------------- 2 files changed, 21 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index ce228d4..c9296cf 100644 --- a/README.md +++ b/README.md @@ -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 and the studio website, plus tooling to reconcile the studio mailing list. [![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) @@ -25,8 +23,7 @@ list. | Auth | Better Auth against Authentik OIDC | | Quality | Biome, Vitest + Testcontainers, Sentry | -The user interface is in Hungarian. Architecture and data model are documented in -[docs/](docs/), conventions 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 @@ -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 @@ -58,20 +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. -- **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. ## Deployment @@ -81,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](docs/releases.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). diff --git a/workstation-agent/README.md b/workstation-agent/README.md index adb4d11..983e275 100644 --- a/workstation-agent/README.md +++ b/workstation-agent/README.md @@ -1,8 +1,6 @@ # Workstation agent -A heartbeat for one editing station. It pings `POST /api/computers//ping` every minute and -`/computers` renders what it reports: OS, CPU, memory, disk and who is using it, at the machine or -over RDP. +A heartbeat for one editing station. It pings `POST /api/computers//ping` every minute and `/computers` renders what it reports: OS, CPU, memory, disk and who is using it, at the machine or over RDP. Windows PowerShell 5.1, which every Windows box already has. Nothing to install but the scripts here. @@ -10,11 +8,9 @@ Windows PowerShell 5.1, which every Windows box already has. Nothing to install One service account per machine, so a stolen credential is scoped to that machine. -1. **Directory → Users → Create service account**, e.g. `nle4-agent`. Keep the generated app - password — it is shown once, and it is the whole credential. +1. **Directory → Users → Create service account**, e.g. `nle4-agent`. Keep the generated app password — it is shown once, and it is the whole credential. 2. Add the account to the group named by `AUTHENTIK_GROUP_COMPUTER_AGENTS`. -3. Nothing else. The agent authenticates against the same provider Backstage logs in through, - so there is no second application, provider or redirect URI. +3. Nothing else. The agent authenticates against the same provider Backstage logs in through, so there is no second application, provider or redirect URI. ## Install @@ -24,9 +20,7 @@ From an **elevated** PowerShell on the workstation: irm https://raw.githubusercontent.com/BSStudio/backstage/main/workstation-agent/bootstrap.ps1 | iex ``` -It installs the latest release, then asks for the Backstage URL, the Authentik issuer and client -id, the service account and its app password, and the computer id — which defaults to the -machine's own name lowercased (`NLE4` → `nle4`). +It installs the latest release, then asks for the Backstage URL, the Authentik issuer and client id, the service account and its app password, and the computer id — which defaults to the machine's own name lowercased (`NLE4` → `nle4`). To pass answers instead of being asked, the pipe has to become a script block: @@ -39,24 +33,17 @@ To pass answers instead of being asked, the pipe has to become a script block: `-Ref` installs a specific tag or branch, for testing an unreleased version. -The machine appears on `/computers` within a minute of the first ping. Nothing has to be created -in the portal first — the first ping is what registers it. +The machine appears on `/computers` within a minute of the first ping. Nothing has to be created in the portal first — the first ping is what registers it. ## What the installer sets up -Everything lands in `C:\ProgramData\BSS\backstage-agent\`, readable only by SYSTEM and -Administrators. Revoke a machine by deleting its service account in Authentik. +Everything lands in `C:\ProgramData\BSS\backstage-agent\`, readable only by SYSTEM and Administrators. Revoke a machine by deleting its service account in Authentik. -It runs as SYSTEM under a scheduled task named `BSS Backstage Agent`, so it starts before anyone -logs on, survives logoff, and a standard user cannot stop it. It restarts itself on a crash, and a -trigger every five minutes picks it back up if it is killed outright. +It runs as SYSTEM under a scheduled task named `BSS Backstage Agent`, so it starts before anyone logs on, survives logoff, and a standard user cannot stop it. It restarts itself on a crash, and a trigger every five minutes picks it back up if it is killed outright. -The version installed is reported with every ping, so an out-of-date workstation is visible from -the portal. +The version installed is reported with every ping, so an out-of-date workstation is visible from the portal. -One workstation cannot impersonate another: the first service account to ping a given id owns it, -and a different account pinging the same id is refused. Re-pairing means deleting the machine from -`/computers` first. +One workstation cannot impersonate another: the first service account to ping a given id owns it, and a different account pinging the same id is refused. Re-pairing means deleting the machine from `/computers` first. ## Check on it @@ -65,9 +52,7 @@ Get-ScheduledTask -TaskName 'BSS Backstage Agent' Get-WinEvent -FilterHashtable @{LogName='Application'; ProviderName='BackstageAgent'} -MaxEvents 10 ``` -A healthy agent logs once at startup and stays quiet. A failing one logs the first failure of a -run and then goes quiet too, so a weekend outage does not fill the log — it logs again when pings -recover, naming how many were missed. +A healthy agent logs once at startup and stays quiet. A failing one logs the first failure of a run and then goes quiet too, so a weekend outage does not fill the log — it logs again when pings recover, naming how many were missed. ## When it will not ping @@ -86,7 +71,4 @@ recover, naming how many were missed. & ([scriptblock]::Create((irm https://raw.githubusercontent.com/BSStudio/backstage/main/workstation-agent/bootstrap.ps1))) -Uninstall ``` -Kills the running agent — unregistering the task alone leaves it looping with its id and its token -in memory — then removes the task, the folder and the event source. Delete the service account in -Authentik to revoke the credential, and delete the machine from `/computers` — otherwise it sits -there offline forever. +Kills the running agent — unregistering the task alone leaves it looping with its id and its token in memory — then removes the task, the folder and the event source. Delete the service account in Authentik to revoke the credential, and delete the machine from `/computers` — otherwise it sits there offline forever. From 62381957d10d27fb5725a5aa957e30e7b826e89b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliv=C3=A9r=20Kecskem=C3=A9ty?= Date: Fri, 13 Nov 2026 23:13:29 +0100 Subject: [PATCH 7/8] docs: record why better auth runs without a database --- docs/decisions.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/decisions.md b/docs/decisions.md index e283c97..222007f 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -16,6 +16,8 @@ One caller has to: `POST`/`DELETE /api/members/[id]/avatar` write to storage *be **"Has an Authentik account" is encoded in the id, not in a column.** The alternative was an `authentikUserId` or a `hasAuthentikAccount` field. Neither pays for itself: `Member.id` must *already* equal the Authentik UUID for login and for pk resolution, so a member who gains an account needs their id rewritten either way — a column would not save that, only duplicate a fact the id already carries, where the two can drift. The prefix also keeps the check pure, which matters because orchestrators receive a bare `memberId` and would otherwise have to read the member row on every sync. Cost: ids are not uniformly UUIDs, and the prefix shows up in member URLs — and, once avatars are embedded on the public website, in `/avatars/-square.webp` too. That is why the token is meaningless rather than descriptive. It does not hide the id itself: an avatar URL publishes the member's Authentik user UUID either way. Fixing *that* means minting avatar filenames independently of the member id, which is not done. +**Better Auth runs without a database.** `lib/auth.ts` sets no `database:` and the schema has no `User`, `Session`, `Account` or `Verification` model. Authentik is the identity source of truth and a `Member` row keyed on the Authentik `sub` is the user record, so Better Auth only runs the OIDC flow: with no server-side store it keeps the session in an encrypted (JWE) cookie and its own user rows in a per-process memory adapter. A missing adapter or missing auth tables is not a bug, and an audit tool reporting one is a false positive. + **No background retry queue.** A failed sync reports to Sentry, somebody looks, and it is retried by hand from `/admin/sync-jobs`. At ~30 members the failure rate does not justify a worker, a queue table or the operational surface of pg-boss or a CronJob. Revisit only if manual retries become routine. **A new member's username is shown once.** `createMember` returns the name `createAuthentikUser` settled on after its collision loop alongside the member, and the create form shows it on its success screen with a copy button. Nothing stores it, so that screen is the only place it is ever visible — and the welcome email carrying it is written by hand. From 6c8ea2acedfa1c8756ca8563ffedcedca8541d4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliv=C3=A9r=20Kecskem=C3=A9ty?= Date: Fri, 13 Nov 2026 23:25:40 +0100 Subject: [PATCH 8/8] docs: list every required ci check and the update-user hook trap --- CLAUDE.md | 2 +- README.md | 4 ++-- docs/auth.md | 2 +- docs/releases.md | 2 +- workstation-agent/CLAUDE.md | 2 ++ 5 files changed, 7 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4349d73..7ae6a9b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,7 +28,7 @@ pnpm check # biome check + tsc --noEmit pnpm test:coverage # Docker must be running: Testcontainers spins a real PostgreSQL ``` -CI runs the same two. +CI runs the same two, plus commitlint over the PR's commits and BSStudio/.github's shared checks: actionlint and zizmor over `.github/workflows/`, and a review of dependency changes. All of them are required to merge (see [releases.md](docs/releases.md)). ### Commands diff --git a/README.md b/README.md index c9296cf..6cb065c 100644 --- a/README.md +++ b/README.md @@ -6,7 +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) @@ -60,7 +60,7 @@ Branch off `main`, open a PR. Three things are enforced rather than suggested, a - **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 diff --git a/docs/auth.md b/docs/auth.md index e870323..1f4fefc 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -10,7 +10,7 @@ On login `mapProfileToUser` reads the `groups` claim and derives a role, and car **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. +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. diff --git a/docs/releases.md b/docs/releases.md index 529d51a..7f28d3e 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -15,7 +15,7 @@ | `docker-publish.yml` | PR (build only), push to `main`, `v*.*.*` tags | Buildx → `ghcr.io/bsstudio/backstage`, provenance attestation | | `authentik-contract.yml` | Weekly cron, dispatch, PRs touching `lib/authentik/**` | Diffs Authentik's published spec against `scripts/authentik-contract.json` | -`ci.yml` and `checks.yml` carry no `paths` filter on purpose: they are what `main` is protected on, and a required check that never runs on a docs-only PR blocks that PR instead of passing it. Both node jobs run `prisma generate` first — `app/generated/prisma/` is gitignored and `tsc` resolves through it. +`ci.yml` and `checks.yml` carry no `paths` filter on purpose: they are what `main` is protected on, and a required check that never runs on a docs-only PR blocks that PR instead of passing it. `ci.yml`'s `Lint and typecheck` and `Test` run `prisma generate` first — `app/generated/prisma/` is gitignored and `tsc` resolves through it. The contract check reads goauthentik's `main` rather than a release, so a field rename shows up before the studio instance is upgraded into it. A scheduled run that drifts opens an issue (deduplicated by title); on a PR the red job is the notification. The snapshot is refreshed by hand with `pnpm authentik:contract --update` once the change is understood. diff --git a/workstation-agent/CLAUDE.md b/workstation-agent/CLAUDE.md index 2050575..e8d0ff9 100644 --- a/workstation-agent/CLAUDE.md +++ b/workstation-agent/CLAUDE.md @@ -7,3 +7,5 @@ The scripts run on Windows PowerShell 5.1, not PowerShell 7 (see [Architectural ```powershell powershell.exe -NoProfile -Command 'foreach ($f in Get-ChildItem workstation-agent\*.ps1) { $e = $null; [void][System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$e); "{0}: {1} errors" -f $f.Name, $e.Count }' ``` + +Parsing catches 7-only syntax, not 7-only parameters, which fail only when the line runs: run any command you change under `powershell.exe` too.