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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion .github/renovate.json
Original file line number Diff line number Diff line change
@@ -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
}
]
}
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof getRouter>> } }` 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: "<token>"}` 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.
Expand Down
10 changes: 10 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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/*"
Loading