Repository navigation
Conversation
Chronos now serves a single oRPC contract owned by @filcdev/api instead of Hono routes with per-request zod validation: - packages/api holds the contract (one procedure per endpoint, with the filcRoute metadata mergen's generated Kotlin client reads), the wire schemas, the shared error map and the typed client factory. - Chronos binds handlers to that contract with implement(appContract), so a missing, extra or mistyped procedure fails tsc instead of 404ing. - iris and kiosk consume the contract through createApiClient; iris also moves onto TanStack Start SSR with a per-request client and router. - The response envelope is gone: payloads are the value, and errors carry the machine-readable code from the shared error map. - better-auth, the aegis door-lock WebSocket and the unsubscribe HTML pages stay outside the contract and are dispatched from src/index.ts. Gates: bun lint, bun typecheck (8/8), bun run build (3/3).
apps/chronos/src/routes/<feature>/ and the feature-private helpers under
src/utils/<feature>/ become one folder per module:
src/modules/<feature>/
<handlers>.ts # from src/routes/<feature>/
_router.ts # unchanged: the plain literal src/router.ts assembles
utils/ # from src/utils/<feature>/, when the feature had any
schema.ts # from src/database/schema/<feature>.ts
src/database/schema keeps authentication, authorization and api-keys: they
are the identity and RBAC tables every other schema has a foreign key to,
so they stay shared rather than belonging to one module. The notification
engine stays in src/utils/notifications because four features dispatch
through it.
Verified: no route imported another feature's routes, and every feature's
utils were touched only by that feature (plus cron.ts, which the module
manifests will replace).
Gates: bun lint, bun typecheck.
`kioskHeartbeatResponseSchema` was a discriminatedUnion on `registered`, but
two of its three branches are `registered: z.literal(true)` and differ only in
`enabled`. Zod rejects that at parse time, so *every* heartbeat — registered
or not — threw "Duplicate discriminator value" and the endpoint answered 500.
A plain union validates the same three shapes, discriminating by declared
order. Verified against a live server: an unknown box gets `{"registered":
false}`, an enabled box its config, a disabled box its name and kind without
one. The generated OpenAPI document is byte-identical, so mergen's client is
unaffected.
The feature folders existed, but everything a feature owns beyond its routes
was registered by hand in four shared files: `database/index.ts` spread every
schema, `utils/cron.ts` listed every job, and the notification engine kept a
second copy of every handler's audience resolver and preference key in two
records keyed by the same string union. Adding a notification meant editing
five places that had nothing to do with each other.
Now a module's `_module.ts` (`satisfies Module`) declares its `jobs` and its
`notifications`, `modules/index.ts` lists them, and both `utils/cron.ts` and
the notification bootstrap read that list. A handler carries its own
`getAudience` and `preferenceKey`, so the engine's `audienceResolvers` and
`preferenceKeys` records are gone — what a notification says and who receives
it now live with the feature that raises it.
Tables deliberately stay out of the manifest: `src/database/index.ts`
constructs `db` at module load, so it must not import a module's handlers and
through them the engine that queries `db`. They get their own leaf list in
`modules/schemas.ts`, which drizzle.config.ts globs alongside the shared
identity/RBAC schema.
The routers are unchanged on purpose — `src/router.ts` still spells every
feature out so `base.router({...})` keeps failing to compile when a procedure
is missing or mistyped.
Verified on a live server against Postgres: 3 cron jobs and 8 notification
handlers registered, exactly as before, with the same preference gates
(cohort_reselection_required correctly ungated); all three heartbeat response
shapes, the 104-path OpenAPI document and /api/health unchanged.
Both server-side call sites built the API origin by parsing the request object: `new URL(getRequest().url).origin`. Two problems with that. `getRequest()` returns the h3 request, whose url carries the origin the *server* saw. Behind the platform proxy that is the internal origin, so a server render sent its API calls back to itself instead of out to Chronos — and iris's own server.ts answers /api/* with a 404, which the loaders' `prefetch` swallows. The result is a shell with no data and a browser that has to redo the work: content that only appears after a refresh. The accessor for this is `getRequestUrl()`, which returns a URL already and resolves the origin from `x-forwarded-host` / `x-forwarded-proto`. Note that `xForwardedHost` defaults to *false*, so the host — the half that matters behind the proxy — needs asking for explicitly; verified with a probe that a plain request resolves to `http://localhost:3000` and one carrying `X-Forwarded-Host: filc.petrik.hu` to `https://filc.petrik.hu`. Second, `fetchSession` now returns null instead of throwing when the lookup fails. It runs in the root route's `beforeLoad`, so a throw there fails the whole render rather than one query. Verified against the production build: a request whose resolved origin is unreachable used to answer 500 with "Something went wrong!", and now answers 200 with the page rendered. Both `new URL()` calls are gone from the SSR path. `requestOrigin` is wrapped in the file's existing `createIsomorphicFn` idiom, because `@tanstack/react-start/server` is denied in the client bundle and the build's import-protection plugin rejects the import outside a `.server()` branch.
Two bugs stacked, both invisible from the page and only visible in the data.
The previous commit built the server-side API url as
`${requestOrigin() ?? apiBaseUrl}/rpc`, dropping the `/api` segment whenever
an origin was available — so every server-render request went to `/rpc/...`
instead of `/api/rpc/...` and 404'd. `prefetch` swallows a failed
`ensureQueryData`, so the failure was silent: the query cache stayed empty,
nothing was hydrated, and the browser started cold.
With the path fixed, the payload still carried no query state. The cause is
the SSR query integration: `@tanstack/react-router-ssr-query` (newest release
1.167.3, and no longer published in step with the router) implements the
pre-1.169 dehydrate contract, pushing queries into a ReadableStream the router
used to drain. The installed router awaits `router.options.dehydrate()` and
serialises its return value as `dehydratedData` — that package's hook returns
undefined, so the payload shipped nothing.
Replaced with a local `setupSsrQuery` that dehydrates to a returned value and
hydrates on the client through `router.options.hydrate`, which the router
still calls. Only queries settled by the end of the render are dehydrated;
one arriving later is simply fetched by the browser. The package's redirect
handling is not carried over because nothing in the app throws redirects.
Verified in dev against a seeded timetable: the SSR payload now carries
`queryKey`/`dataUpdatedAt` for the four prefetched queries, the page renders
the timetable server-side, and the browser issues zero API calls on load.
Gates: bun lint, bun typecheck (8/8), bun run build (3/3).
Bumps the toolchain (bun 1.4.2, turbo 2.11.7, biome 2.5.15), the frontend runtime (react 19.3, vite 8.3.3, sentry 11, tailwind 4.3.3) and better-auth 1.7.7, and moves every version that more than one workspace pinned on its own into the root `catalog`: @tsconfig/strictest (8 workspaces), @types/react, react/react-dom, tailwindcss, lucide-react, dayjs, recharts, sonner, next-themes, @vitejs/plugin-react, vite and the @TanStack pair that had drifted apart. One known gap, kept as-is: bun does not resolve `catalog:` in `peerDependencies` — it writes the literal string into the lockfile rather than the range — so `@filcdev/{auth,navigator-3d,ui}` now declare `react: "catalog:"` as a peer. The two `react` peer entries are left untouched pending a decision; every `dependencies` entry resolves correctly. Also picks up the turborepo agent-guidance block it re-adds to AGENTS.md before repository-scoped commands.
…roles `account.issuer` and its composite unique index came from a better-auth version whose account model carried an issuer; 1.7.7 does not define the field outside the JWT plugin, so the column was dead weight that no insert filled and no query read. `accountId` alone stays unique per provider through the remaining index. `user.roles` gains `default(['user'])` so a row inserted without roles — better-auth's own sign-up path — is a normal user instead of violating the not-null constraint. Migration 0022 is generated, not hand-written: `drizzle-kit generate` reports "No schema changes, nothing to migrate" against it, so the snapshot and the schema agree.
…aultPii Sentry v11 replaces `sendDefaultPii` with per-category `dataCollection` defaults, and those default to collecting: cookies, request bodies, user info and genAI/GraphQL payloads all now flow unless each is disabled. Simply dropping the old option, which the v11 upgrade did, silently turned collection back on for both apps. Every category is therefore pinned explicitly to the v10 behaviour the option used to give, in chronos and iris alike; anything unlisted keeps its permissive default, which is noted at the call site. Iris's replay comment loses its `sendDefaultPii` reference so it points at the baseline that now enforces the same rule.
`createI18n` built a fresh instance on every server render — a new ResourceStore over both locale bundles, translator, and lazily built `Intl` caches, all discarded after one request. Instances are now keyed by language. A single shared one was never an option, because `changeLanguage` mutates in place and would leak the first request's language into every later render; keyed per language the language is fixed at creation and server renders only read from it. The cookie's value is normalized before it becomes a key, so a caller cannot grow the map with arbitrary values, and a hit additionally requires `cached.language === lng` — in the browser a switch moves the instance off the language it was created under. `supportedLngs` is spread into the options rather than passed as the `as const` tuple, which i18next would otherwise hold and could mutate.
The global stylesheet was imported by `src/client.tsx`, the browser entry. TanStack Start discovers a route's dev CSS by crawling the module graph of the matched routes' files, and the entry is not a route, so `GET /@tanstack-start/styles.css?routes=…` answered 200 with an empty body: the `<link>` HeadContent rendered was blank and the page stayed unstyled until the client bundle ran and Vite injected the CSS — a multi-second flash of unstyled content on every dev load. Production was unaffected, because manifest CSS discovery reads the build graph. Importing from the root route is the documented placement for app-wide CSS and puts the stylesheet in the graph the dev crawl walks. Verified with JS disabled on a cold dev server: the endpoint returns the full bundle and first paint carries the tokens, with the same CSS asset hash in the production build as before.
The browser branch built the client with the relative `url: '/api/rpc'`, and oRPC's link codec constructs a `URL` from that value: the browser constructor rejects a relative input outright, so every browser-side call threw `TypeError: Failed to construct 'URL': Invalid URL` before a request was ever made. The server render was unaffected — it already resolved an absolute url from `requestOrigin()` — which meant the page shipped server-rendered data and then failed on the first client-side query, including the timetable-scoped ones. The url is now resolved per call from `window.location.origin`, which is correct in both deployments: the dev Vite proxy and the platform proxy put iris and Chronos behind one origin. Verified in the browser against the running dev stack — the client's own timetable, cohort and period calls return data where they previously threw, the timetable renders without its error state, and client-side navigation loads.
…te range Closes #282. The export menu offered CSV and PDF only, sent no date range, and the exports mixed untranslated headers with a mangled date boundary. Four defects, fixed together because they share the same two call sites: - Excel joins CSV and PDF, via a lazy `write-excel-file/browser` import so the ~30 kB parser stays out of the initial bundle. - The range picker's `to` was built with `toISOString()` on a local midnight, which is the previous day in CEST: the last day of every range was silently dropped. `use-export-range.ts` formats both bounds as `YYYY-MM-DD` and supplies the localized label, so the two export pages share one definition instead of each deriving its own. - The CSV reader split on commas and corrupted the quoted multi-value fields the backend emits ("matek, fizika" columns and embedded newlines); it now parses quoted fields, and the flags are split out of the component to keep cognitive complexity in bounds. - Column headers and the range label were hardcoded English; they are keys in both locale trees now, along with the new menu entries. The PDF export additionally embeds the Outfit latin and latin-ext subsets: the standard-14 fonts are WinAnsi-only, so "Teljes időszak" exported as "Teljes idQszak" and "Őzike" as "Pzike". Both subsets are required — latin-ext alone has no ASCII — and the `.woff` form, because `fontkit` cannot subset the variable `.woff2`.
`apps/iris/server.ts` reimplemented what the framework-adjacent runner already does: hand-rolled static serving of `dist/client`, a `/api` guard, and a port read. `srvx serve --prod -s ../client dist/server/server.js` is the shape the TanStack Start docs give for a Vite build on Node/Bun, and it serves the same two things (the client directory and the built server entry) with ETag and Brotli, which the custom file did not do. Deleted `server.ts` and its `tsconfig` exclusion; `start` is now the srvx command. The `/api` guard it carried is not needed: Traefik routes `/api` to Chronos ahead of iris, so a server render's self-fetch never lands back on this process. The Docker release stage needed fixing to match, and it was already broken before this change: it copied only `dist` and `server.ts`, but the built server bundle keeps `react`/`react-dom`/`@tanstack/*` external, so both the old and the new entrypoint failed with `Cannot find module 'react-dom/server'`. The stage now copies both `node_modules` levels at their original relative paths — entries under `apps/iris/node_modules` are symlinks into the root `node_modules/.bun` store, so flattening them breaks resolution — and runs `dist` from `apps/iris/` so the bundle's upward module walk finds them. The entrypoint invokes srvx by path rather than `bun x`, which would fetch it from the network at container start. Verified with a real `docker build` and the built image behind a Traefik-shaped proxy: SSR 200 rendering the timetable, `/healthz` 200, hashed assets 200 with ETag, and a browser load with the theme tokens applied and no error state. `bun lint`, `bun typecheck` (8/8) and `bun run build` (3/3) pass. Note: the release image now carries the installed dependency tree, so it is larger than before; slimming it needs a production-only install.
…s plugin The `api_key` table, its scrypt hashing and the `/users/me/api-keys` oRPC procedures were a reimplementation of what `@better-auth/api-key` already does, and they carried three defects the plugin does not have: a synchronous `scryptSync` (32.7 ms per validation, blocking the event loop on a single-process runtime), a `prefix` column that was literally the first 8 characters of the secret while the schema called it non-secret, and a `key_hash` index that could never be used because validation recomputed the hash in memory. The plugin owns the `apikey` table now. It hashes with SHA-256, enforces expiry and an enable flag, rate-limits per key, and — registered with `enableSessionForAPIKeys` — turns a valid key into a session, so `resolveCaller` needs no API-key branch at all and the guards below it keep working unchanged. Two things the plugin does not do, added here: - It reads only `x-api-key`. The aegis door-lock firmware sends `Authorization: Bearer`, so `customAPIKeyGetter` accepts both. - It *throws* on a key that is present but unusable (`Invalid API key.`, `API Key is disabled`) instead of answering null. Uncaught, that turned every request carrying a stale key into a 500; both call sites now treat a failed key as "not this credential". The Drizzle schema mirrors the plugin's own declaration rather than being derived, because the plugin writes the table itself. Two details are load-bearing and were found by running it: the table must be in the adapter's `schema` map (better-auth's schema check rejects every call otherwise), and `id` must be a `uuid` with a DB default — the plugin does not declare `id`, so core injects it and expects the database to generate it under `generateId: 'uuid'`. `apps/chronos/tsconfig.json` loses `declaration`/`emitDeclarationOnly`: they are dead config under `noEmit: true`, and they forced tsc to name the plugin's internal `SchemaCheck` type (TS2883) in a declaration it never emits. Verified against the running server: create, `x-api-key` and Bearer authentication, verify, list, disable, delete; an invalid key and an anonymous call both answer 401 rather than 500.
better-auth's own api-key endpoints are session-scoped, so they can only
ever answer for the caller's own keys — an admin screen needs all of them.
These three procedures read the plugin's `apikey` table directly:
- `GET /admin/api-keys` lists every key joined with its owner, with the
same limit/offset/search paging the users list uses, plus an `ownerId`
filter.
- `PATCH /admin/api-keys/{keyId}` flips `enabled`.
- `DELETE /admin/api-keys/{keyId}` revokes any key.
Deleting deliberately does not go through the plugin's `deleteApiKey`,
which refuses a key the calling session does not own; revoking someone
else's key is the point of the screen.
The handler never selects the hashed `key` column, and the join casts
`reference_id` to text because the plugin stores it as `text` while
`user.id` is a `uuid` — Postgres rejects the comparison otherwise.
Two surfaces, both new: - Settings gains an "API keys" card: the signed-in user's keys in a table (name, identifying `start`, created, expiry, last used, enabled), with create, rename, enable/disable and delete. Creating a key reveals the raw secret exactly once, behind a warning, with a copy button — the dialog refuses to dismiss itself until the user acknowledges it, because no later read can return that value. - `/admin/api-keys` lists every user's keys with the owner joined, paging, search over owner and key name, stat cards, and the same enable/disable and delete actions, gated on `users:manage`. The user surface calls better-auth's client plugin (those endpoints are outside the oRPC contract by design); the admin surface calls the new admin procedures, because the plugin cannot see other users' keys. Both hook modules follow the repo's shape — queries plus per-operation mutations with toasts, invalidation and `onSaved`. `useCopyToClipboard` is new: the repo had no clipboard helper, and copying a value the user can never see again must not fail silently, so the hook owns the toast as well as the write and falls back to a selection-based copy where the Clipboard API is unavailable over plain HTTP. Verified in a browser against a live stack: the settings card renders and create/list/rename/disable/delete all work through the page's own client, with the raw key returned only by create and absent from list rows; the admin page renders the owner, prefix, dates and actions, and its search and owner filters return the right counts.
|
Important Review skippedToo many files! This PR contains 485 files, which is 385 over the limit of 100. To get a review, reduce the PR to 100 files or fewer by splitting it into smaller PRs or changing its base branch. Upgrade to a paid plan to raise the limit. Usage-priced reviews support at most 300 files. ⚙️ Run configuration
⛔ Files ignored due to path filters (2)
📒 Files selected for processing (485)
You can disable this status message by setting the
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
The settings dialog and the `/settings` page duplicated the same preference controls, and the API-key table needed ~900px — more than either surface had — so it scrolled sideways on desktop and mobile alike. One dialog now owns every setting, in the sidebar-in-dialog shape: a section rail on the left, the active pane on the right, and on a phone the pane replaces the rail with a back button. It is mounted once at the app root behind a small store, so the profile menu and the notification viewer's "change cohort" button open the same instance instead of mounting their own. The `/settings` route and the duplicate my-groups card are gone. The API-key list is a list, not a table: a row shows the name, state, prefix and the three timestamps that matter, and rename/enable/delete moved into an actions menu. Nothing overflows at any width.
The public timetable's control bar was a showcase of unrelated styles: three button groups, a bespoke week pill, a print button in two different sizes, and a view toggle that only existed in the URL. It is now one bar built from a single segmented-control primitive (`ToggleGroup`), so every control shares one box and reads as the same object. - The filter, week and view controls are segments of one control language. `ToggleGroup` is a Base UI toggle group, so a single-choice group is a real radio group for keyboard and screen-reader users instead of buttons that merely look selected. - The week selector keeps a colour cue for A/B — as a tint matching the grid's week badges, not the saturated fill it had. - On a phone the bar is two rows: the entry type as icons with its picker, then week, timetable and print. Nothing scrolls sideways. - The loading placeholder is shaped like the timetable it stands in for; the old `h-8 w-64` strip read as a second, empty toolbar. Its borders use `border-border` — a bare `border` resolves to `currentColor`, which drew a white outline around the whole card. The view choice moved to a new Appearance settings pane, with the theme, and became a real preference: a module-level store so flipping it re-renders the timetable behind the dialog with no reload, a cookie so it applies before the first paint (and to a signed-out visitor), and the stored preference so it follows the account. It is no longer a `?view=` search param. Also: the navbar's language selector is gone (language lives in General), the API-key list is a list rather than a seven-column table that could not fit, and `<html>` carries `suppressHydrationWarning` — next-themes sets `class` and `color-scheme` on it before hydration, which React warned about on every load.
|
LGTM! merge and deploy |
|
Szép munka @nemvince , elég komoly átalakítás lett! 😄 Az új API-s megoldás és a modulárisabb felépítés szerintem jó irány. Átnézve a változtatásokat, egy dolgot azért még érdemes lenne megnézni merge előtt: API-kulcsok: A 0023-as migráció törli a régi api_key táblát. Ha jól látom, a meglévő kulcsok nem kerülnek át az új táblába. Ez nem fog problémát okozni az éles rendszerben, például a beléptetőknél vagy más API-t használó klienseknél? Illetve az Androidos klienssel is érdemes lenne egy teljes tesztet futtatni, mert az API válaszformátuma is változott, és jó lenne biztosra menni, hogy a régebbi verziókkal sem lesz gond. @Dasa122 meg tudnád nézni? |
✅ Preview is up
Posted by filc-deployer. The preview database is its own container and is |
Ezek csak a user kulcsok, szerintem nincs eleg user hogy nagy baj legyen, max a power user/deveinknek kell uj kulcsot csinalnia, ez konnyebb mint full mas rendszerbe migralni a regi kulcsokat
Felpusholtam erre is egy branchet, pls check out! |
|
@nemvince nem uptodate a branch.. lebuildelt amúgy, de kéne egy frissebb rész. |

Summary
Replaces the Hono + hono-openapi backend and the hand-rolled client plumbing with a
typed oRPC contract shared by every app, restructures Chronos into self-contained
modules, and rebuilds Iris' settings and timetable controls.
Closes #282
Closes #272
Closes #390
Changes
Stack: Hono → oRPC
@filcdev/api(a procedure per endpoint) replaces Hono routeswith per-request zod validation.
implement(appContract)— a missing, extra ormistyped procedure now fails
tscinstead of 404ing at runtime.createApiClient; OpenAPI is generated from thecontract (
bun run openapi:generate), so mergen's Kotlin client still works.{ success, data }envelope is gone: payloads are the value, errors carry amachine-readable
codefrom one shared error map.the contract, dispatched from
src/index.ts.Chronos: modules
src/routes/<feature>/+src/utils/<feature>/→ onesrc/modules/<feature>/folder per feature (handlers,
_router.ts,schema.ts, private utils)._module.ts), so adding afeature no longer means editing the cron/notification bootstrap by hand.
@filcdev/authpackage: the better-auth user fields, session types and thebrowser client, shared by Chronos and the apps.
Iris: SSR + client
client; the SSR origin resolves through the proxy-aware accessor.
srvxover the build output — the customserver.tsisgone.
hydration, global stylesheet imported from the root route, i18next instance
cached per language.
Features
surfaces, RADIUS authorization, UniFi integration, FreeRADIUS deployment
(
apps/radius/), and a one-shot PetrikWiFi import script.new user and admin screens plus the admin procedures over every user's keys.
Iris: settings + toolbar
/settingspage were the same controls twice → onedialog with sections (General / Appearance / Groups / Notifications / API Keys),
mounted once at the app root.
real preference now (instant, no reload; remembered across devices) instead of a
?view=param.ToggleGroupprimitive —one control language, two rows on mobile, no sideways scrolling.
Chores
tailwind 4.3.3, better-auth 1.7.7) and shared versions folded into the root
catalog.sendDefaultPii.0022).Verification
bun run lintbun run typecheck(8/8)bun run build(3/3) — routers, server entrypoints and bundler config all changedenandhu0022,0024)