diff --git a/.github/renovate.json b/.github/renovate.json index 1ff9198c..763f9502 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -1,5 +1,32 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": ["github>mheob/renovate-config"], - "schedule": ["before 2am on Wednesday"] + "packageRules": [ + { + "description": "One weekly window for every update. The preset schedules dependencies, devDependencies and the package manager separately, and its non-major group puts dependencies and devDependencies in one branch; together with the two-hour Wednesday window this file used to set, not one pull request opened between 2026-09-01 and 2026-09-25.", + "matchPackageNames": ["*"], + "schedule": ["before 6am on monday"] + }, + { + "description": "Kodiak merges every pull request whose checks pass — require_automerge_label is false in mheob/.github's .github/.kodiak.toml — so a major would land unreviewed. It never merges a draft, and CI still runs on one: marking the pull request ready for review is the decision to merge.", + "matchUpdateTypes": ["major"], + "draftPR": true + }, + { + "description": "packages/api-client pins TypeScript 6 for its generator, which crashes on 7 (CLAUDE.md). CI never runs the generator, so a bump would pass every check and break the next regeneration.", + "matchFileNames": ["packages/api-client/package.json"], + "matchPackageNames": ["typescript"], + "allowedVersions": "<7" + }, + { + "description": "Held at the versions #91 pinned. router-core 1.171.30 changed interpolatePath's signature in a patch release and @storybook/tanstack-react 10.6.0 still calls the old form, so every story fails. Fixed by storybookjs/storybook#36333, in no release as of 2026-09-25. Remove this rule once Renovate proposes a @storybook/tanstack-react release that carries it, and move the four packages together.", + "matchPackageNames": [ + "@tanstack/react-router", + "@tanstack/react-router-devtools", + "@tanstack/react-router-ssr-query", + "@tanstack/react-start" + ], + "enabled": false + } + ] } diff --git a/CLAUDE.md b/CLAUDE.md index 0df19c3f..a49efd84 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -138,6 +138,7 @@ Tables: `team`, `team_member`, `domain`, `folder`, `tag`, `link`, `link_tag`, `l - **Sentry initialises inside the router factory, not through Sentry's documented Node `--import` hook.** `getRouter` (`apps/web/src/router.tsx`) calls `initSentry(router.isServer)` unconditionally — the server and client bundles share this module, and both reach that line — because Nitro builds the server bundle and Vercel starts it, and neither hands the process a command line to hook `--import` into. `isServer` only tags the event's `serverName` (`web-ssr` vs. unset); the real gate is whether `VITE_SENTRY_DSN` is set. `sendDefaultPii` is deprecated as of SDK v10 in favor of `dataCollection`, and `@sentry/core` resolves every _unset_ field of a partial `dataCollection` object against its permissive `DEFAULTS`, not the off-state, the instant the object is present at all — so `sentryOptions` in `apps/web/src/lib/observability.ts` sets every field explicitly rather than leaving any to inference. - **A team slug is not a route-safe name by itself.** TanStack Router matches a static segment before a dynamic one, so any static child route under `/teams/` permanently shadows the team whose slug matches it. The create form therefore lives at `/new-team`, outside the namespace, and `reservedTeamSlugs` in `apps/api/internal/api/teams.go` refuses the values a future static route would claim. Adding a static child route under `/teams/` means adding its segment to that list in the same change. - **`routeTree.gen.ts` is regenerated by the Vite plugin and nothing else, which is why there is no script for it.** `tanstackStart()` in `apps/web/vite.config.ts` writes the file during `dev` and `build`, and it alone emits the trailing `declare module '@tanstack/react-start' { interface Register { ssr: true; router: Awaited> } }` block — the registration that hands every `useRouter`, `Link`, `useLoaderData` and `createServerFn` in the app its router type. TanStack Router's own CLI (`tsr generate`) is the router's generator, not Start's, and rewrites the file without that block. Losing it raises nothing at all: `Register` falls back to its defaults rather than erroring, so `pnpm typecheck` stays green and the deletion leaves no signal anywhere. It cost a fix round on 2026-09-19 and was found by reading a diff's deletions. The `generate-routes` script and the `@tanstack/router-cli` devDependency were removed on 2026-09-20 for that reason — a generator that quietly produces a subtly wrong file protects nobody by sitting in `package.json` next to a warning. Regenerate through `pnpm --filter @kurze-url/web run build`, or by running the dev server, and do not add the CLI back. +- **Kodiak merges every pull request whose checks pass, and the Renovate config is built around that.** Its configuration is not in this repository: it is `mheob/.github`'s `.github/.kodiak.toml`, shared by all of the maintainer's repositories, and it sets `require_automerge_label = false` with no `blocking_labels` — so no label is needed to merge and none prevents it, and every green pull request lands, Renovate's included. `.github/renovate.json` therefore opens **majors as drafts**: Kodiak never merges a draft, CI still runs on one, and marking it ready for review is the decision to merge. Three other rules there are load-bearing. **One weekly window for every update** — the shared preset `github>mheob/renovate-config` schedules dependencies, devDependencies and the package manager separately and groups non-majors across them, and with the two-hour Wednesday window this repository first set, not one Renovate pull request opened between 2026-09-01 and 2026-09-25; the Dependency Dashboard (#1) kept listing everything as "Awaiting Schedule", which is the symptom to recognise. **TypeScript in `packages/api-client` is capped below 7**, because CI never runs the client generator: a bump there passes every check, merges, and breaks the next `pnpm run generate:api` with nothing pointing back at the cause. **The TanStack Router family is disabled** — `react-router`, `react-router-devtools`, `react-router-ssr-query` and `react-start`, exactly pinned in `apps/web/package.json` — because `router-core` 1.171.30 changed `interpolatePath`'s signature in a patch release and `@storybook/tanstack-react` 10.6.0 still calls the old form, failing every story. The fix is storybookjs/storybook#36333; remove the rule and the pins together once a `@storybook/tanstack-react` release carries it. Moving only the direct dependencies is not enough, either: an in-range sweep lifts `router-core` under Storybook's own caret ranges and leaves two router cores in the tree. `pnpm-workspace.yaml` sets `minimumReleaseAge` to 4320 minutes, the three days the preset gives Renovate, so a manual install holds back exactly what Renovate would. - **Slug-to-team-id resolution happens in the frontend**, in each team route's `beforeLoad`, out of `GET /v1/me`'s membership list — `requireTeamId` in `apps/web/src/routes/_authed.tsx`. It costs no request, and it keeps a second resolution path out of every tenancy-critical query. Route context carries `teamId`; `params` carries `teamSlug`. The `team` cookie holds the slug. - **Every rate limit caps one subject in one window, and that shape cannot defend a free-tier ceiling.** Values settled 2026-09-08 and reasoned through one by one in `apps/api/.env.example`; what matters is the shape, not the numbers. A per-IP, per-user, per-team or per-domain window stops a single runaway client, and a shared monthly quota is spent by subjects the limiter never compares. Two consequences are load-bearing. **The redirect limit does not protect Upstash's 500K commands a month**: one IP staying inside `RATE_LIMIT_REDIRECT_PER_MIN=60` spends the month's budget in 21 hours to 2.9 days, a refused redirect still costs the two GETs the limiter reads, and no NAT-tolerant value changes either fact — the lower bound is set by mobile-carrier egress, Verein offices and mail-provider link scanners all arriving from one address. The spread exists because Upstash does not document whether a Lua `EVAL` counts as one command or as the commands inside it; the path runs `ratelimit.lua` (4) and `redirect_lookup.lua` (2–3), so the limiter is the more expensive of the two. That budget is watched, not enforced, and belongs to the alerting open item. **The invitation limit does protect its quota, because it was given a second, global half**: `RATE_LIMIT_INVITE_GLOBAL_PER_MONTH=200` bounds invitations instance-wide over a rolling 30 days, checked after the per-team hourly cap so a team refused for its own burst never spends instance budget. It exists because sign-in is a magic link with no password fallback and Resend's free tier is 3,000 mails a month across all Supabase auth email — an exhausted quota locks every Verein out with no recovery from inside the app. That path fails closed throughout (0 refuses everything, a Redis error answers 500) and logs a global refusal at error level so it reaches Sentry. `signInWithOtp` in `apps/web` spends the same quota outside this counter, bounded only by Supabase's own limits. Also settled: the two domain-verification axes no longer share a threshold (`RATE_LIMIT_DOMAIN_VERIFY_PER_DOMAIN_PER_HOUR=10`, `RATE_LIMIT_DOMAIN_VERIFY_PER_USER_PER_HOUR=40`), because one person legitimately onboards several domains at once and the shared value made the user axis fire while every domain was still well inside its own. - **A link's password lives on its own route, and both ends of it must invalidate the redirect cache.** `PUT|DELETE /v1/links/{link_id}/password` (`apps/api/internal/api/link_password.go`) is separate from `PATCH` so it gets its own audit actions (`link.password_set`, `link.password_changed`, `link.password_removed`) — the precedent the folders-and-tags decision cites for refusing a tag subresource, which has neither. Only the setter also gets its own rate limit: it alone computes an Argon2id hash, so it alone is a CPU/memory amplifier worth capping, while `DELETE` computes nothing and carries none. `link.Cached` carries `HasPassword`, so a handler that forgets `invalidateLink` leaves a freshly protected link redirecting straight through for up to an hour with a 200, an audit row and a dashboard that all say it worked — or, the other direction, a freshly unprotected link still demanding a password nobody can answer any more; `TestSetLinkPasswordInvalidatesTheRedirectCache` and `TestRemoveLinkPasswordInvalidatesTheRedirectCache` (`apps/api/internal/api/link_password_test.go`) both drive the real redirect handler for exactly that reason, one in each direction. The policy in `apps/api/internal/auth/policy.go` targets predictability rather than strength — 8 characters, 4 distinct, and rejection of anything derived from the link's slug, its destination's hostname, or the Verein's name and slug after German transliteration — because a link password is shared out of band with a group and `Sommerfest26` is the realistic failure, not a short one. Its rules run in a fixed order — length, then repetition, then context, then the common-password list — deliberately not the order the design spec's own numbering implies: context has to come before the common list, because the reverse would make it impossible for any word to ever be both a common-list entry and a context-rule test fixture, a standing trap nobody would remember on the next addition to either list, with a silently-wrong-reason test as the only symptom. `passwort1` is a deliberate exception the policy accepts — 9 characters, 8 distinct, derived from nothing — and `TestValidatePasswordAcceptsAWeakButCompliantPassword` pins it so nobody tightens the policy by accident: it raises the floor, not the whole defense, and the failure-only per-link limit in `verify.go` (`PasswordFailureRateLimitPerHour`) is what bounds the damage from there. The policy is mirrored in `apps/web/src/lib/link-password.ts` for immediate feedback and **will drift**; the API is the enforcement point, and the reason travels as `ErrorDetail{Location: "body.password", Value: ""}` where the token is the Go sentinel's own `Error()` string. Creating a link with a password in one request is deliberately not possible: reopen that when bulk create or import arrives and the two-step window stops being milliseconds. diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0e659a6b..bb618367 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -29,6 +29,16 @@ allowBuilds: overrides: js-yaml@4.2.0: ^4.3.2 +# Three days, in minutes: the window the Renovate preset applies +# (`minimumReleaseAge: "3 days"` in github>mheob/renovate-config:security), so a +# manual `pnpm add` or `pnpm update` holds back what Renovate would. Until +# 2026-09-25 only the exclusion list below existed — an exclusion from a gate +# nothing had set, which pnpm 12's own release notes name as the failure its +# stricter settings check exists to catch. A security fix that is still too +# fresh goes through `--config.minimum-release-age=0` on that one command rather +# than through a change to this value. +minimumReleaseAge: 4320 + # Exclude trusted packages from the age gate minimumReleaseAgeExclude: - "@mheob/*"