From 3b3c47f29e10e1ec34437d237ad0d3d36b972bdd Mon Sep 17 00:00:00 2001 From: mindsers Date: Tue, 1 Sep 2026 15:56:42 +0200 Subject: [PATCH 1/5] fix(auth): send the user to the page after login, not its loader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signing in again after a session expired dropped the user on a raw loader payload or a 404 instead of the page they were on. React Router's single fetch means a client-side navigation requests `/programs.data`, not `/programs`. That is the request that trips `verifySession`, and `extractRedirectTo` captured the pathname verbatim, so the guard built `/login?redirectTo=%2Fprograms.data` and the login action redirected the browser to the loader endpoint. `normalizeRedirectPath` strips the decoration, mirroring React Router's own `getNormalizedPath` (lib/server-runtime/urls.ts), which is internal and cannot be imported: the root route decorates as `/_.data` rather than `/.data`, and `_routes` is an implementation detail that must not survive into a user-visible URL. Applied in two places on purpose. `safeRedirectUrl` is the funnel every target passes through — including one supplied by the query string — so a stale bookmark or a hand-edited `?redirectTo=` cannot reintroduce the landing. `extractRedirectTo` normalises at the source too, so the `?redirectTo=` the user actually sees in the address bar is a page. The destination is preserved rather than dropped to `/`: someone whose session expires on the programme list still ends up there. Verified by reproducing in the browser first — login with `?redirectTo=%2Fprograms.data` landed on `/programs.data` with a 404 — then confirming the same flow now lands on `/programs`. The regression tests were watched failing against the previous implementation (10 of them) before the fix was restored. --- .../server/session.server.test.ts | 20 ++++++ .../authentication/server/session.server.ts | 9 ++- app/shared/utils/safe-redirect.server.test.ts | 64 ++++++++++++++++++- app/shared/utils/safe-redirect.server.ts | 40 +++++++++++- 4 files changed, 130 insertions(+), 3 deletions(-) diff --git a/app/features/authentication/server/session.server.test.ts b/app/features/authentication/server/session.server.test.ts index 36819a7d..f9fd6460 100644 --- a/app/features/authentication/server/session.server.test.ts +++ b/app/features/authentication/server/session.server.test.ts @@ -176,6 +176,26 @@ describe('verifySession', () => { expect(response.headers.get('Location')).toBe('/login?redirectTo=%2Fterritories%2F1%3Fx%3D2') }) + // Regression: the request that trips the guard is usually a single-fetch one, so the captured + // path used to be `/territories/1.data` and signing back in landed the user on the loader + // endpoint instead of the page. + it('nettoie le suffixe single-fetch du redirectTo', async () => { + setSession({}) + + const response = await getRedirectResponse(() => + verifySession(makeRequest('http://localhost/territories/1.data?_routes=routes%2Fterritories')), + ) + expect(response.headers.get('Location')).toBe('/login?redirectTo=%2Fterritories%2F1') + }) + + it('ramène la racine décorée à /', async () => { + setSession({}) + + const response = await getRedirectResponse(() => verifySession(makeRequest('http://localhost/_.data'))) + // buildLoginRedirectUrl drops a redirectTo of '/' entirely — nothing to preserve. + expect(response.headers.get('Location')).toBe('/login') + }) + it("redirige vers /login sans redirectTo si le subdomain ne correspond pas à l'assemblée de l'utilisateur", async () => { setSession({ userId: '42', sessionEpoch: '0' }) vi.mocked(db.userAccount.findUnique).mockResolvedValue(fakeUser as never) diff --git a/app/features/authentication/server/session.server.ts b/app/features/authentication/server/session.server.ts index f40fd309..3f2eedab 100644 --- a/app/features/authentication/server/session.server.ts +++ b/app/features/authentication/server/session.server.ts @@ -6,6 +6,7 @@ import { resolveCongregation, resolveCongregationFromRequest } from '~/shared/do import { unscopedDb } from '~/shared/infra/db.server' import logger from '~/shared/infra/logger.server' import { getSessionSecrets } from '~/shared/utils/env.server' +import { normalizeRedirectPath } from '~/shared/utils/safe-redirect.server' type SessionData = { userId: string @@ -78,10 +79,16 @@ async function redirectToLogin( }) } +// Where to send the user back to once they have signed in again. +// +// Normalised because the request that trips the guard is very often NOT a document request: a +// client-side navigation fetches `/programs.data`, and capturing that verbatim sends the user +// to the loader endpoint after login instead of the page. Cleaned here as well as inside +// safeRedirectUrl so the `?redirectTo=` the user actually sees in the address bar is a page. function extractRedirectTo(request: Request): string { try { const url = new URL(request.url) - return `${url.pathname}${url.search}` + return normalizeRedirectPath(`${url.pathname}${url.search}`) } catch { logger.warn(`verifySession: unable to parse request.url (${request.url})`) return '/' diff --git a/app/shared/utils/safe-redirect.server.test.ts b/app/shared/utils/safe-redirect.server.test.ts index 96a680ad..274f96bd 100644 --- a/app/shared/utils/safe-redirect.server.test.ts +++ b/app/shared/utils/safe-redirect.server.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { safeRedirectUrl } from './safe-redirect.server' +import { normalizeRedirectPath, safeRedirectUrl } from './safe-redirect.server' describe('safeRedirectUrl', () => { it('returns a valid relative path unchanged', () => { @@ -46,3 +46,65 @@ describe('safeRedirectUrl', () => { expect(safeRedirectUrl(null, '/login')).toBe('/login') }) }) + +// Regression: a client-side navigation fetches `/programs.data`, not `/programs`. The session +// guard captured that pathname verbatim, so signing in again dropped the user on the loader +// endpoint — a raw payload or a 404 — instead of the page they were on. +describe('normalizeRedirectPath', () => { + it('strips the single-fetch suffix from a page path', () => { + expect(normalizeRedirectPath('/programs.data')).toBe('/programs') + }) + + it('strips it from a nested path', () => { + expect(normalizeRedirectPath('/settings/congregation/templates.data')).toBe('/settings/congregation/templates') + }) + + // The URL this was actually reported from. + it('strips it from the organigram path', () => { + expect(normalizeRedirectPath('/congregation/roles/organigram.data')).toBe('/congregation/roles/organigram') + }) + + // The root route decorates as `/_.data`, not `/.data` — stripping only `.data` would leave + // `/_`, which is not a route either. + it('maps the decorated root back to the root path', () => { + expect(normalizeRedirectPath('/_.data')).toBe('/') + }) + + it('does not leave a trailing slash on a nested decorated path', () => { + expect(normalizeRedirectPath('/settings/_.data')).toBe('/settings') + }) + + it('drops the _routes single-fetch parameter', () => { + expect(normalizeRedirectPath('/programs.data?_routes=routes%2Fprograms')).toBe('/programs') + }) + + it('keeps real query parameters while dropping _routes', () => { + expect(normalizeRedirectPath('/programs.data?from=2026-09-01&_routes=x')).toBe('/programs?from=2026-09-01') + }) + + it('leaves an ordinary page path untouched', () => { + expect(normalizeRedirectPath('/territories')).toBe('/territories') + }) + + // `.data` only decorates the end of the pathname; a route segment that merely contains the + // word must survive intact. + it('does not touch a path that merely contains "data"', () => { + expect(normalizeRedirectPath('/exports/data')).toBe('/exports/data') + }) +}) + +describe('safeRedirectUrl — single-fetch targets', () => { + it('normalises a .data target rather than sending the user to the loader endpoint', () => { + expect(safeRedirectUrl('/programs.data', '/')).toBe('/programs') + }) + + // The query string is attacker-reachable, so the funnel has to clean it too — not just the + // guard that builds the target. + it('normalises a hand-edited redirectTo', () => { + expect(safeRedirectUrl('/settings/_.data?_routes=root', '/')).toBe('/settings') + }) + + it('still refuses an absolute URL that ends in .data', () => { + expect(safeRedirectUrl('https://evil.com/steal.data', '/')).toBe('/') + }) +}) diff --git a/app/shared/utils/safe-redirect.server.ts b/app/shared/utils/safe-redirect.server.ts index b89c7793..f5921f65 100644 --- a/app/shared/utils/safe-redirect.server.ts +++ b/app/shared/utils/safe-redirect.server.ts @@ -1,12 +1,50 @@ import logger from '~/shared/infra/logger.server' +/** + * Strips React Router's single-fetch decoration off a path. + * + * A client-side navigation does not fetch `/programs`, it fetches `/programs.data`. When the + * session guard bounces such a request it captures that pathname verbatim, so the login page + * is handed `?redirectTo=/programs.data` and sends the browser to the loader endpoint after a + * successful sign-in — the user lands on raw loader output, or a 404, instead of the page they + * were on. + * + * The rules mirror React Router's own `getNormalizedPath` (lib/server-runtime/urls.ts), which + * is internal and cannot be imported: the root route decorates as `/_.data` rather than + * `/.data`, and `_routes` is a single-fetch implementation detail that must not survive into a + * user-visible URL. + */ +const ROOT_DATA_SUFFIX = /_\.data$/ +const DATA_SUFFIX = /\.data$/ + +export function normalizeRedirectPath(url: string): string { + const [rawPath = '', rawSearch] = url.split('?') + + let pathname = rawPath.endsWith('/_.data') ? rawPath.replace(ROOT_DATA_SUFFIX, '') : rawPath.replace(DATA_SUFFIX, '') + // `/_.data` strips to `/`, but a nested `/settings/_.data` strips to `/settings/` — trailing + // slashes are not how routes are addressed here, so drop it unless the path IS the root. + if (pathname.length > 1 && pathname.endsWith('/')) pathname = pathname.slice(0, -1) + if (pathname === '') pathname = '/' + + if (rawSearch == null) return pathname + + const params = new URLSearchParams(rawSearch) + params.delete('_routes') + const search = params.toString() + return search ? `${pathname}?${search}` : pathname +} + /** * Validates that a redirect URL is a safe relative path. * Prevents open redirect attacks via attacker-controlled URLs (e.g., Referer header). * Logs a warning when a non-empty url is rejected — useful for spotting phishing probes. + * + * Normalisation happens here rather than only at the call site that builds the target: this is + * the funnel every redirect target passes through, including one supplied by the query string, + * so a stale bookmark or a hand-edited `?redirectTo=` cannot reintroduce the `.data` landing. */ export function safeRedirectUrl(url: string | null, fallback: string): string { - if (url?.startsWith('/') && !url.startsWith('//')) return url + if (url?.startsWith('/') && !url.startsWith('//')) return normalizeRedirectPath(url) if (url != null && url.length > 0) { logger.warn(`safeRedirectUrl: rejected potentially unsafe redirect target (${url})`) } From 0ebb396dc2c701b4618383bf219f87a269419b61 Mon Sep 17 00:00:00 2001 From: mindsers Date: Tue, 1 Sep 2026 19:19:30 +0200 Subject: [PATCH 2/5] fix(auth): stop normalizeRedirectPath rewriting URLs it should leave alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review of the previous commit. The helper's return value goes straight into a Location header, so anything it rewrites is a URL the user is actually sent to — and it was rewriting four things it had no business touching: - `url.split('?')` discarded everything after a SECOND '?', so `/search?q=a?b` was truncated to `/search?q=a`. A literal '?' inside a query value is common enough that browsers pass it through unencoded. This was a regression: the previous implementation returned the target unchanged. - Round-tripping the query through URLSearchParams rewrote `%20` as `+` and turned a bare `?flag` into `?flag=`. - The trailing-slash strip ran on every path, so `/programs/` became `/programs` even though nothing had been decorated. All three came from transforming unconditionally. It now splits on the first '?' only, rewrites the pathname only when it actually ends in `.data`, and rebuilds the query only when `_routes` is actually present. The four cases are pinned as tests, and the full route tree was re-checked: all 197 paths still round-trip through the decoration. --- app/shared/utils/safe-redirect.server.test.ts | 20 ++++++++++++++ app/shared/utils/safe-redirect.server.ts | 27 ++++++++++++++----- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/app/shared/utils/safe-redirect.server.test.ts b/app/shared/utils/safe-redirect.server.test.ts index 274f96bd..ee5cda41 100644 --- a/app/shared/utils/safe-redirect.server.test.ts +++ b/app/shared/utils/safe-redirect.server.test.ts @@ -91,6 +91,26 @@ describe('normalizeRedirectPath', () => { it('does not touch a path that merely contains "data"', () => { expect(normalizeRedirectPath('/exports/data')).toBe('/exports/data') }) + + // The four below are the reason this does the least possible: the value flows straight into a + // Location header, so anything it rewrites is a URL the user is actually sent to. An earlier + // draft split on every '?' and round-tripped the query through URLSearchParams, which + // truncated, re-encoded and reshaped destinations that had nothing wrong with them. + it('keeps a literal question mark inside a query value', () => { + expect(normalizeRedirectPath('/search?q=a?b')).toBe('/search?q=a?b') + }) + + it('does not re-encode a query string it has no reason to touch', () => { + expect(normalizeRedirectPath('/search?q=a%20b')).toBe('/search?q=a%20b') + }) + + it('leaves a valueless query key alone', () => { + expect(normalizeRedirectPath('/search?flag')).toBe('/search?flag') + }) + + it('does not strip a trailing slash from an undecorated path', () => { + expect(normalizeRedirectPath('/programs/')).toBe('/programs/') + }) }) describe('safeRedirectUrl — single-fetch targets', () => { diff --git a/app/shared/utils/safe-redirect.server.ts b/app/shared/utils/safe-redirect.server.ts index f5921f65..e49fbd04 100644 --- a/app/shared/utils/safe-redirect.server.ts +++ b/app/shared/utils/safe-redirect.server.ts @@ -18,17 +18,32 @@ const ROOT_DATA_SUFFIX = /_\.data$/ const DATA_SUFFIX = /\.data$/ export function normalizeRedirectPath(url: string): string { - const [rawPath = '', rawSearch] = url.split('?') + // Split on the FIRST '?' only. `url.split('?')` discards everything after a second one, and a + // literal '?' inside a query value is common enough that browsers pass it through unencoded — + // truncating it would silently lose part of the destination. + const queryAt = url.indexOf('?') + const rawPath = queryAt === -1 ? url : url.slice(0, queryAt) + const rawSearch = queryAt === -1 ? null : url.slice(queryAt + 1) - let pathname = rawPath.endsWith('/_.data') ? rawPath.replace(ROOT_DATA_SUFFIX, '') : rawPath.replace(DATA_SUFFIX, '') - // `/_.data` strips to `/`, but a nested `/settings/_.data` strips to `/settings/` — trailing - // slashes are not how routes are addressed here, so drop it unless the path IS the root. - if (pathname.length > 1 && pathname.endsWith('/')) pathname = pathname.slice(0, -1) - if (pathname === '') pathname = '/' + // Only a decorated path is rewritten. Touching every path would quietly turn this into a + // general URL normaliser and start "fixing" trailing slashes that were never the problem. + let pathname = rawPath + if (rawPath.endsWith('.data')) { + pathname = rawPath.endsWith('/_.data') ? rawPath.replace(ROOT_DATA_SUFFIX, '') : rawPath.replace(DATA_SUFFIX, '') + // `/_.data` strips to `/`, but a nested `/settings/_.data` strips to `/settings/` — trailing + // slashes are not how routes are addressed here, so drop it unless the path IS the root. + if (pathname.length > 1 && pathname.endsWith('/')) pathname = pathname.slice(0, -1) + if (pathname === '') pathname = '/' + } if (rawSearch == null) return pathname + // Rebuild the query string only when there is something to remove. Round-tripping through + // URLSearchParams rewrites `%20` as `+` and turns a bare `?flag` into `?flag=` — gratuitous + // edits to a URL the user is about to be sent to. const params = new URLSearchParams(rawSearch) + if (!params.has('_routes')) return `${pathname}?${rawSearch}` + params.delete('_routes') const search = params.toString() return search ? `${pathname}?${search}` : pathname From 6d7f0bfc5ac58898933bb4bd7192c624c0af2a26 Mon Sep 17 00:00:00 2001 From: mindsers Date: Tue, 1 Sep 2026 19:43:45 +0200 Subject: [PATCH 3/5] fix(congregation): resolve the single-tenant congregation deterministically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `getBrandingName` and the locale fallback both pick "the congregation" in single-tenant mode with an unordered `findFirst`, which asks Postgres for any row. Single-tenant assumes exactly one, but a stale import or a leftover test fixture makes that false, and the app then brands itself with whichever row the planner reached first — an answer that can differ between requests against unchanged data. Ordering by id makes the pick stable. It does not make two congregations in a single-tenant install correct; it makes the symptom legible instead of intermittent. The two existence checks that also use an unordered `findFirst` (setup-first-account, seed) are deliberately left alone: they ask "is there any congregation at all", where the row identity is irrelevant. Found while debugging a login page branded "Roles Other 1788220863373". --- app/shared/domain/congregation.server.test.ts | 27 ++++++++++++++++++- app/shared/domain/congregation.server.ts | 5 ++++ app/shared/utils/locale.server.test.ts | 15 +++++++++++ app/shared/utils/locale.server.ts | 5 +++- 4 files changed, 50 insertions(+), 2 deletions(-) diff --git a/app/shared/domain/congregation.server.test.ts b/app/shared/domain/congregation.server.test.ts index 575b4094..cd55983f 100644 --- a/app/shared/domain/congregation.server.test.ts +++ b/app/shared/domain/congregation.server.test.ts @@ -12,7 +12,9 @@ vi.mock('react-router', () => ({ }), })) -const { resolveCongregation, resolveCongregationFromRequest, getPlatformName } = await import('./congregation.server') +const { getBrandingName, resolveCongregation, resolveCongregationFromRequest, getPlatformName } = await import( + './congregation.server' +) const { unscopedDb: db } = await import('~/shared/infra/db.server') beforeEach(() => { @@ -235,3 +237,26 @@ describe('resolveCongregationFromRequest', () => { expect(result).toBeNull() }) }) + +// Single-tenant mode assumes one congregation, and `findFirst` with no ordering asks Postgres +// for "any row" — with a second one present (a stale import, a leftover test fixture) it can +// return either, so the login page ends up branded with whichever one the planner reached +// first, and the answer can change between requests. +describe('getBrandingName — deterministic single-tenant pick', () => { + const singleTenant = { name: 'La Bonne Assemblée', displayName: null } + + it('asks for a deterministic congregation rather than any row', async () => { + vi.mocked(db.congregation.findFirst).mockResolvedValue(singleTenant as never) + + await getBrandingName() + + const call = vi.mocked(db.congregation.findFirst).mock.calls[0][0] + expect(call?.orderBy).toEqual({ id: 'asc' }) + }) + + it('still returns the congregation name it found', async () => { + vi.mocked(db.congregation.findFirst).mockResolvedValue(singleTenant as never) + + await expect(getBrandingName()).resolves.toBe('La Bonne Assemblée') + }) +}) diff --git a/app/shared/domain/congregation.server.ts b/app/shared/domain/congregation.server.ts index a6147d68..98cf5c20 100644 --- a/app/shared/domain/congregation.server.ts +++ b/app/shared/domain/congregation.server.ts @@ -134,8 +134,13 @@ export async function getBrandingName(request?: Request): Promise { }) } } else { + // Ordered because "the congregation" has to be the same one every time. Single-tenant mode + // assumes exactly one row, but a stale import or a leftover test fixture makes that false, + // and an unordered findFirst then brands the app with whichever row the planner reached + // first — differently from one request to the next. congregation = await unscopedDb.congregation.findFirst({ select: { name: true, displayName: true }, + orderBy: { id: 'asc' }, }) } diff --git a/app/shared/utils/locale.server.test.ts b/app/shared/utils/locale.server.test.ts index dcb2c87c..65a6c0c2 100644 --- a/app/shared/utils/locale.server.test.ts +++ b/app/shared/utils/locale.server.test.ts @@ -128,3 +128,18 @@ describe('resolveLocaleFromRequest', () => { expect(result).toBe('fr') }) }) + +// Same defect as getBrandingName: the single-tenant fallback means "the congregation", and an +// unordered findFirst can hand back a different one from one request to the next. +describe('resolveLocaleFromRequest — deterministic single-tenant fallback', () => { + it('asks for a deterministic congregation rather than any row', async () => { + vi.mocked(getSession).mockResolvedValue({ get: () => undefined } as never) + vi.mocked(resolveCongregationFromRequest).mockResolvedValue(null as never) + vi.mocked(db.congregation.findFirst).mockResolvedValue({ locale: 'en' } as never) + + await resolveLocaleFromRequest(new Request('http://localhost/')) + + const call = vi.mocked(db.congregation.findFirst).mock.calls[0][0] + expect(call?.orderBy).toEqual({ id: 'asc' }) + }) +}) diff --git a/app/shared/utils/locale.server.ts b/app/shared/utils/locale.server.ts index 48f2ccb0..3a846816 100644 --- a/app/shared/utils/locale.server.ts +++ b/app/shared/utils/locale.server.ts @@ -34,10 +34,13 @@ export async function resolveLocaleFromRequest(request: Request): Promise Date: Tue, 1 Sep 2026 19:43:49 +0200 Subject: [PATCH 4/5] test: refuse to run integration tests against a non-test database MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The integration suite writes into, and in places truncates, whatever database it is pointed at. Nothing stopped that being a development one, and that is how a working database ends up holding a pile of leftover `Roles Other …` congregations — which then surface in the app itself, because single-tenant mode assumes one congregation row. The control plane has carried this rail for a while; this is the main app catching up. CI already names its database `unitae_test`, so nothing changes there. Checking DB_URL alone would not have been enough, and finding that out is most of the value here. Most integration files build their client from `DB_RUNTIME_URL ?? DB_URL` so RLS is exercised as the non-superuser role, while the migration suites use DB_URL because a migration runs as the schema owner. DB_RUNTIME_URL is typically exported from a shell profile and left pointing at development, so `DB_URL=…/unitae_test` looks safe while nearly every write still lands in the dev database. Both are checked, and they must name the same database — two different test databases would split the writes and tear down only one. The check runs from `setupFiles`, not from a shared helper, because these suites each construct their own PrismaClient; there is no single module they all pass through. Verified by pointing DB_URL at a test database with DB_RUNTIME_URL still on the dev one and watching it refuse, then running all 70 files / 440 tests green against a properly configured unitae_test. --- CLAUDE.md | 1 + app/tests/assert-test-database.test.ts | 92 ++++++++++++++++++++++++++ app/tests/assert-test-database.ts | 75 +++++++++++++++++++++ app/tests/integration-setup.ts | 6 ++ app/tests/vitest.config.integration.ts | 3 + docs/development/testing.md | 36 ++++++++++ 6 files changed, 213 insertions(+) create mode 100644 app/tests/assert-test-database.test.ts create mode 100644 app/tests/assert-test-database.ts create mode 100644 app/tests/integration-setup.ts diff --git a/CLAUDE.md b/CLAUDE.md index a065f0a3..8e9a8659 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -414,6 +414,7 @@ LOGIN_RATE_LIMIT_GLOBAL_MAX="100" # Max failed login attempts instance-wide | `Cannot find module '~/database/generated/...'` | Run `pnpm prisma generate` | | Lint errors after schema changes | Run `pnpm build:format` to auto-fix most; check for new `biome-ignore` needs | | Integration tests fail with RLS errors | Ensure `DB_RUNTIME_URL` points to the `unitae_app` role (not superuser) | +| `Refusing to run integration tests: …must carry a "test" token` | The suite truncates data, so it only runs against a disposable database. Point **both** `DB_URL` and `DB_RUNTIME_URL` at e.g. `unitae_test` — `DB_RUNTIME_URL` is often exported from a shell profile and left on `unitae_dev`. See [Testing](docs/development/testing.md#the-database-these-run-against) | | `prisma migrate dev` hangs in terminal | Use `prisma migrate diff --script` + manual migration + `prisma migrate deploy` | | Session not persisting in dev | Check `UNITAE_SESSION_SECRET` is set; cookie lifetime is 8h in dev, 1h in prod | | Port already in use | `lsof -i :5173` (dev server) or `lsof -i :9090` (worker health) | diff --git a/app/tests/assert-test-database.test.ts b/app/tests/assert-test-database.test.ts new file mode 100644 index 00000000..725175cb --- /dev/null +++ b/app/tests/assert-test-database.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest' +import { assertIntegrationDatabases, assertTestDatabase, isTestDatabaseName } from './assert-test-database' + +// The integration suite writes into, and in places truncates, whatever database DB_URL names. +// Nothing stopped it being pointed at a development database, and pointing it at one is how a +// working dev database ends up holding a pile of `Roles Other 1788220863373` fixtures. +describe('isTestDatabaseName', () => { + it('accepts the name CI uses', () => { + expect(isTestDatabaseName('unitae_test')).toBe(true) + }) + + it.each(['test', 'test_unitae', 'unitae-test', 'unitae_test_2', 'my-test-db'])('accepts %s', name => { + expect(isTestDatabaseName(name)).toBe(true) + }) + + it('refuses the development database', () => { + expect(isTestDatabaseName('unitae_dev')).toBe(false) + }) + + it.each(['unitae', 'production', 'unitae_prod'])('refuses %s', name => { + expect(isTestDatabaseName(name)).toBe(false) + }) + + // Anchored on purpose: a bare substring match would wave through anything merely containing + // the letters, and `latest` is a plausible database name. + it.each(['latest', 'greatest', 'contest', 'testing'])('refuses %s — token must be delimited', name => { + expect(isTestDatabaseName(name)).toBe(false) + }) +}) + +describe('assertTestDatabase', () => { + it('passes for a test database', () => { + expect(() => assertTestDatabase('postgresql://u:p@localhost:5432/unitae_test')).not.toThrow() + }) + + it('names the offending database so the fix is obvious', () => { + expect(() => assertTestDatabase('postgresql://u:p@localhost:5432/unitae_dev')).toThrow('unitae_dev') + }) + + it('refuses rather than silently allowing an unset DB_URL', () => { + expect(() => assertTestDatabase(undefined)).toThrow() + }) + + it('refuses a URL it cannot parse', () => { + expect(() => assertTestDatabase('not a url')).toThrow() + }) +}) + +// Guarding DB_URL alone is not enough: most integration files build their client from +// `DB_RUNTIME_URL ?? DB_URL`, so a runtime URL left pointing at a development database sends +// every write there while DB_URL looks perfectly safe. That combination is exactly how a dev +// database gets polluted without anyone doing anything obviously wrong. +describe('assertIntegrationDatabases', () => { + const testUrl = 'postgresql://unitae:unitae@localhost:5432/unitae_test' + const devUrl = 'postgresql://unitae_app:unitae_app@localhost:5432/unitae_dev' + + it('passes when only DB_URL is set and names a test database', () => { + expect(() => assertIntegrationDatabases({ dbUrl: testUrl })).not.toThrow() + }) + + it('passes when both name the same test database', () => { + expect(() => + assertIntegrationDatabases({ + dbUrl: testUrl, + runtimeUrl: 'postgresql://unitae_app:unitae_app@localhost:5432/unitae_test', + }), + ).not.toThrow() + }) + + it('refuses a runtime URL pointing at a development database', () => { + expect(() => assertIntegrationDatabases({ dbUrl: testUrl, runtimeUrl: devUrl })).toThrow('DB_RUNTIME_URL') + }) + + it('names the offending database in the runtime case too', () => { + expect(() => assertIntegrationDatabases({ dbUrl: testUrl, runtimeUrl: devUrl })).toThrow('unitae_dev') + }) + + // Both carry a "test" token, so neither trips the name check on its own — but the suite would + // split its writes across two databases and clean up only one of them. + it('refuses two different test databases', () => { + expect(() => + assertIntegrationDatabases({ + dbUrl: testUrl, + runtimeUrl: 'postgresql://unitae_app:unitae_app@localhost:5432/other_test', + }), + ).toThrow('same database') + }) + + it('still refuses a bad DB_URL', () => { + expect(() => assertIntegrationDatabases({ dbUrl: devUrl })).toThrow('DB_URL') + }) +}) diff --git a/app/tests/assert-test-database.ts b/app/tests/assert-test-database.ts new file mode 100644 index 00000000..d71c0e51 --- /dev/null +++ b/app/tests/assert-test-database.ts @@ -0,0 +1,75 @@ +// Safety rail for the integration suite. +// +// Integration tests write into, and in places truncate, whatever database DB_URL names. Nothing +// stopped that being a development database, and pointing it at one is how a working dev +// database ends up holding a pile of leftover `Roles Other 1788220863373` fixtures — which then +// surface in the app itself, because single-tenant mode assumes one congregation row. +// +// The control plane has carried the same rail for a while; this is the main app catching up. +// CI already names its database `unitae_test`, so nothing there changes. + +// Anchored on the delimiters rather than a bare substring: `latest` and `greatest` contain the +// letters without being test databases. +const TEST_DB_NAME = /(^|[_-])test([_-]|$)/i + +export function isTestDatabaseName(name: string): boolean { + return TEST_DB_NAME.test(name) +} + +const LEADING_SLASH = /^\// + +function databaseName(url: string, varName: string): string { + try { + return new URL(url).pathname.replace(LEADING_SLASH, '') + } catch { + throw new Error(`Refusing to run integration tests: ${varName} is not a valid URL (${url}).`) + } +} + +/** + * Throws unless `url` names a disposable test database. + * + * Takes the URL rather than reading `process.env` so the rule is testable without mutating the + * environment, and so the failure message can name what it actually saw. + */ +export function assertTestDatabase(url: string | undefined, varName = 'DB_URL'): void { + if (!url) { + throw new Error(`Refusing to run integration tests: ${varName} is not set. Point it at a test database.`) + } + + const name = databaseName(url, varName) + if (!isTestDatabaseName(name)) { + throw new Error( + `Refusing to run integration tests: they truncate data, and the ${varName} database name must carry a "test" ` + + `token (got "${name}"). Point ${varName} at a disposable test database, e.g. unitae_test.`, + ) + } +} + +/** + * Throws unless every database the integration suite can reach is a disposable test one. + * + * DB_URL alone is not the whole story: most integration files build their Prisma client from + * `DB_RUNTIME_URL ?? DB_URL` so that RLS is exercised as the non-superuser role, and the + * migration suites use DB_URL because a migration runs as the schema owner. A runtime URL left + * pointing at a development database therefore sends nearly every write there while DB_URL looks + * entirely safe — which is precisely how a working database ends up full of test fixtures. + */ +export function assertIntegrationDatabases(env: { dbUrl?: string; runtimeUrl?: string }): void { + assertTestDatabase(env.dbUrl, 'DB_URL') + + if (!env.runtimeUrl) return + assertTestDatabase(env.runtimeUrl, 'DB_RUNTIME_URL') + + // Both are test databases but not the SAME one: the suite would split its writes across two + // and tear down only one, leaving the other to accumulate exactly the rows this guard exists + // to prevent. + const primary = databaseName(env.dbUrl as string, 'DB_URL') + const runtime = databaseName(env.runtimeUrl, 'DB_RUNTIME_URL') + if (primary !== runtime) { + throw new Error( + `Refusing to run integration tests: DB_URL and DB_RUNTIME_URL must point at the same database ` + + `(got "${primary}" and "${runtime}").`, + ) + } +} diff --git a/app/tests/integration-setup.ts b/app/tests/integration-setup.ts new file mode 100644 index 00000000..db30deff --- /dev/null +++ b/app/tests/integration-setup.ts @@ -0,0 +1,6 @@ +import { assertIntegrationDatabases } from './assert-test-database' + +// Runs once per integration test file, before anything constructs a Prisma client. It lives in +// the vitest config rather than in each suite because these tests each build their own client — +// there is no single module they all go through to guard. +assertIntegrationDatabases({ dbUrl: process.env.DB_URL, runtimeUrl: process.env.DB_RUNTIME_URL }) diff --git a/app/tests/vitest.config.integration.ts b/app/tests/vitest.config.integration.ts index cb2e82a9..8a23235f 100644 --- a/app/tests/vitest.config.integration.ts +++ b/app/tests/vitest.config.integration.ts @@ -11,6 +11,9 @@ export default defineConfig({ globals: false, environment: 'node', include: ['app/**/*.integration.test.ts'], + // Refuses to run against anything but a disposable test database — these suites truncate. + // See app/tests/assert-test-database.ts. + setupFiles: [resolve(import.meta.dirname, 'integration-setup.ts')], testTimeout: 30_000, // Integration files share one database, and migration tests execute real migration SQL, which // is global by nature — an INSERT referencing Member takes FOR KEY SHARE on the rows it points diff --git a/docs/development/testing.md b/docs/development/testing.md index b5c5bf7f..deff7786 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -103,6 +103,42 @@ PostgreSQL must be running (start with `docker compose -f docker/docker-compose. - Path alias: `~` → `./app` - Extended timeout: 30s +### The database these run against + +Integration tests write into — and in places truncate — the database they are pointed at, so the +suite refuses to start unless **every** URL it can reach names a disposable test database. The +check lives in `app/tests/assert-test-database.ts` and runs from `setupFiles`. + +Both variables are checked, and they must name the same database: + +| Variable | Used by | Why it is checked | +|---|---|---| +| `DB_URL` | migration suites (a migration runs as the schema owner) | the obvious one | +| `DB_RUNTIME_URL` | everything else, via `DB_RUNTIME_URL ?? DB_URL` | RLS is only exercised as the non-superuser role | + +`DB_RUNTIME_URL` is the one that bites. It is often exported from a shell profile and left +pointing at the development database, so `DB_URL=…/unitae_test pnpm test:integration` looks safe +while nearly every write still lands in development. That is how a working database ends up full +of `Roles Other 1788220863373` fixtures, which then surface in the app itself because +single-tenant mode assumes exactly one congregation row. + +A database name qualifies when it carries a `test` token delimited by start, end, `_` or `-`, so +`unitae_test` and `my-test-db` pass while `latest` and `unitae_dev` do not. CI already uses +`unitae_test`, so nothing changes there. + +Setting one up locally, mirroring what CI does: + +```bash +createdb unitae_test # or: docker exec psql -U unitae -d postgres -c 'CREATE DATABASE unitae_test' +export DB_URL="postgresql://unitae:unitae@localhost:5432/unitae_test" +export DB_RUNTIME_URL="postgresql://unitae_app:@localhost:5432/unitae_test" +pnpm prisma migrate deploy && pnpm prisma db seed +pnpm test:integration +``` + +Omitting `DB_RUNTIME_URL` entirely is allowed — the suite then connects as the superuser — but the +RLS suites fail, because row-level security does not apply to the table owner. + ### Patterns Integration tests use real Prisma queries with RLS scoping: From 3a3fdb59b63ff219117912671cdc5c908eb4fa50 Mon Sep 17 00:00:00 2001 From: mindsers Date: Tue, 1 Sep 2026 22:13:27 +0200 Subject: [PATCH 5/5] build(deps): pin mysql2 above the credential-leak advisory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GHSA-3f6p-5ww8-9rcr was published while this branch was open and fails CI's `pnpm audit --audit-level=high` step, which had passed on the previous commit twenty minutes earlier. It blocks every PR in the repo, not just this one — nothing here touches dependencies. mysql2 arrives only as a transitive dependency of prisma, which ships drivers for every database it supports. This app talks to PostgreSQL through @prisma/adapter-pg and never loads the MySQL driver, so the advisory is not reachable here — but the audit gate reasons about the dependency tree, not about which branches execute. An override is the smaller move than holding the branch for a prisma release that bumps it, and follows the `pkg@` convention the fourteen entries already there use. Resolves 3.15.3 -> 3.24.2; the audit step then exits 0 with only the pre-existing moderate finding, which is below the threshold. Typecheck, lint, unit, integration and a production build all pass on the new resolution. Drop this once prisma ships a version carrying the patched driver itself. --- package.json | 1 + pnpm-lock.yaml | 81 +++++++++++++------------------------------------- 2 files changed, 22 insertions(+), 60 deletions(-) diff --git a/package.json b/package.json index c0787ca6..6a404744 100644 --- a/package.json +++ b/package.json @@ -138,6 +138,7 @@ "find-my-way@<9.7.0": ">=9.7.0", "form-data@<4.0.6": ">=4.0.6", "hono@<4.12.27": ">=4.12.27", + "mysql2@<3.22.0": ">=3.22.0", "tmp@<0.2.6": ">=0.2.6", "uuid@<11.1.1": ">=11.1.1", "valibot@<1.4.2": ">=1.4.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff47aa7f..676e8256 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,7 @@ overrides: find-my-way@<9.7.0: '>=9.7.0' form-data@<4.0.6: '>=4.0.6' hono@<4.12.27: '>=4.12.27' + mysql2@<3.22.0: '>=3.22.0' tmp@<0.2.6: '>=0.2.6' uuid@<11.1.1: '>=11.1.1' valibot@<1.4.2: '>=1.4.2' @@ -59,7 +60,7 @@ importers: version: 7.9.1 '@prisma/client': specifier: 7.10.0 - version: 7.10.0(prisma@7.10.0(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(better-sqlite3@12.8.0)(magicast@0.5.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(typescript@6.0.3) + version: 7.10.0(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(better-sqlite3@12.8.0)(magicast@0.5.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(typescript@6.0.3) '@react-pdf/renderer': specifier: ^4.8.1 version: 4.8.1(react@19.2.8) @@ -216,7 +217,7 @@ importers: version: 2.1.10 prisma: specifier: ^7.10.0 - version: 7.10.0(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(better-sqlite3@12.8.0)(magicast@0.5.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3) + version: 7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(better-sqlite3@12.8.0)(magicast@0.5.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3) shadcn: specifier: ^4.19.0 version: 4.19.0(typescript@6.0.3) @@ -492,28 +493,24 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [musl] '@biomejs/cli-linux-arm64@2.5.3': resolution: {integrity: sha512-ksx1KWeyYW18ILL04msF/J4ZBtBDN33znYK8Z/aNv/vlBVxL9/g3mGP+omgHJKy4+KWbK87vcmmpmurfNjSgiA==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [glibc] '@biomejs/cli-linux-x64-musl@2.5.3': resolution: {integrity: sha512-O/yU9YKRUiHhmcjF2f38PSjseVk3G4VLWYc0G2HWpzdBVREV6G8IGWIVEFf7MFPfWIzNUIvPsEjeAZQIOgnLcQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [musl] '@biomejs/cli-linux-x64@2.5.3': resolution: {integrity: sha512-yMkJtilsgvILDcVkh187aVLTb64xYsrxYajx5kym+r1ULkO5HUOfu9AYKLGQbOVLwJtT2utNw7hhFNg+17mUYA==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [glibc] '@biomejs/cli-win32-arm64@2.5.3': resolution: {integrity: sha512-cX5z+GYwRcqEok0AH3KSfQGgqYd0Nomfp6Fbe1uiTtELE38hdH2k842wQ9wLNaF/JJ7r4rjJQ4VR+ce+fRmQbw==} @@ -1036,35 +1033,30 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@napi-rs/canvas-linux-arm64-musl@1.0.6': resolution: {integrity: sha512-zXeVWNFpj720HZln0bfAs+1/nP3Wy6Z5hUw60UJu+cvgips5jZtuX2+EjDHFk98q3BY35GaE1TQLwE9S244vPA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@napi-rs/canvas-linux-riscv64-gnu@1.0.6': resolution: {integrity: sha512-kvy1Ypq4ODEI2WRNGvs588qTMcWCjlEIuZRyW8JXpiBnMUirgxQukIDFVDQEcrLlHP5cqZ9cvvfOkBJ7koOktA==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] - libc: [glibc] '@napi-rs/canvas-linux-x64-gnu@1.0.6': resolution: {integrity: sha512-czHnfLgSCl48iluS6bNrytGYhsbSTUuIvMucAwO0mPEj1uhkHk6SUiRI0xoqv0PSFzpGFfSnQvFpm7vV+tDO+A==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@napi-rs/canvas-linux-x64-musl@1.0.6': resolution: {integrity: sha512-afsg6GyfUx8qZzjsywjimXUEWcSWETY5F80hmMb2FWVSXkRbor8IpftY2gXnasq+Y42Xug/76qTHwqvti7HRTw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@napi-rs/canvas-win32-arm64-msvc@1.0.6': resolution: {integrity: sha512-byzha357piy0wgK6IZOQtU4plpt94f6CxHFS7LhJpWYrDWWT/AitQNxRMfCNNaQHZXUB3BwNhQSfH2RpiZ0WaQ==} @@ -2122,42 +2114,36 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.2.5': resolution: {integrity: sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.2.5': resolution: {integrity: sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.2.5': resolution: {integrity: sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.2.5': resolution: {integrity: sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-musl@1.2.5': resolution: {integrity: sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@rolldown/binding-openharmony-arm64@1.2.5': resolution: {integrity: sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw==} @@ -2277,28 +2263,24 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.3.3': resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.3.3': resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.3.3': resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.3.3': resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==} @@ -2543,61 +2525,51 @@ packages: resolution: {integrity: sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==} cpu: [arm64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.12.2': resolution: {integrity: sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==} cpu: [arm64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': resolution: {integrity: sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==} cpu: [loong64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-loong64-musl@1.12.2': resolution: {integrity: sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==} cpu: [loong64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': resolution: {integrity: sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==} cpu: [ppc64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': resolution: {integrity: sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==} cpu: [riscv64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': resolution: {integrity: sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==} cpu: [riscv64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': resolution: {integrity: sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==} cpu: [s390x] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.12.2': resolution: {integrity: sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==} cpu: [x64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.12.2': resolution: {integrity: sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==} cpu: [x64] os: [linux] - libc: [musl] '@unrs/resolver-binding-openharmony-arm64@1.12.2': resolution: {integrity: sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==} @@ -4654,56 +4626,48 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-gnu@1.33.0: resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-arm64-musl@1.33.0: resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-gnu@1.33.0: resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-linux-x64-musl@1.33.0: resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -4972,9 +4936,11 @@ packages: resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} engines: {node: ^20.17.0 || >=22.9.0} - mysql2@3.15.3: - resolution: {integrity: sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==} + mysql2@3.24.2: + resolution: {integrity: sha512-l9kXeKGwd6VCbSjmpO/bLWb+YCYpbvE4whte8ec6InXebvCNyEEydyRCnRU+TSHNqRLJ8B+Tk1uWb+vMCJgJzA==} engines: {node: '>= 8.0'} + peerDependencies: + '@types/node': '>= 8' named-placeholders@1.1.6: resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} @@ -5757,9 +5723,6 @@ packages: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} - seq-queue@0.0.5: - resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==} - serve-static@2.2.1: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} @@ -5888,15 +5851,15 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} + sql-escaper@1.5.1: + resolution: {integrity: sha512-4toX5E1fQbBrpfXidaHnF0669nkAdETeIPTs2SUjxxD7RRIs9ICG4gtpmfc68JCEKehsdwLFqBu9VlQqZ1P1gg==} + engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'} + sqlite-wasm-kysely@0.3.0: resolution: {integrity: sha512-TzjBNv7KwRw6E3pdKdlRyZiTmUIE0UttT/Sl56MVwVARl/u5gp978KepazCJZewFUnlWHz9i3NQd4kOtP/Afdg==} peerDependencies: kysely: '*' - sqlstring@2.3.3: - resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} - engines: {node: '>= 0.6'} - stable-hash-x@0.2.0: resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==} engines: {node: '>=12.0.0'} @@ -7522,11 +7485,11 @@ snapshots: '@prisma/client-runtime-utils@7.10.0': {} - '@prisma/client@7.10.0(prisma@7.10.0(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(better-sqlite3@12.8.0)(magicast@0.5.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(typescript@6.0.3)': + '@prisma/client@7.10.0(prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(better-sqlite3@12.8.0)(magicast@0.5.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3))(typescript@6.0.3)': dependencies: '@prisma/client-runtime-utils': 7.10.0 optionalDependencies: - prisma: 7.10.0(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(better-sqlite3@12.8.0)(magicast@0.5.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3) + prisma: 7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(better-sqlite3@12.8.0)(magicast@0.5.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3) typescript: 6.0.3 '@prisma/config@7.10.0(magicast@0.5.4)': @@ -11460,17 +11423,16 @@ snapshots: mute-stream@3.0.0: optional: true - mysql2@3.15.3: + mysql2@3.24.2(@types/node@26.2.0): dependencies: + '@types/node': 26.2.0 aws-ssl-profiles: 1.1.2 - denque: 2.1.0 generate-function: 2.3.1 iconv-lite: 0.7.3 long: 5.3.2 lru.min: 1.1.4 named-placeholders: 1.1.6 - seq-queue: 0.0.5 - sqlstring: 2.3.3 + sql-escaper: 1.5.1 named-placeholders@1.1.6: dependencies: @@ -11849,18 +11811,19 @@ snapshots: dependencies: parse-ms: 4.0.0 - prisma@7.10.0(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(better-sqlite3@12.8.0)(magicast@0.5.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3): + prisma@7.10.0(@types/node@26.2.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(better-sqlite3@12.8.0)(magicast@0.5.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@6.0.3): dependencies: '@prisma/config': 7.10.0(magicast@0.5.4) '@prisma/dev': 0.24.17(typescript@6.0.3) '@prisma/engines': 7.10.0 '@prisma/studio-core': 0.33.0(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - mysql2: 3.15.3 + mysql2: 3.24.2(@types/node@26.2.0) postgres: 3.4.7 optionalDependencies: better-sqlite3: 12.8.0 typescript: 6.0.3 transitivePeerDependencies: + - '@types/node' - '@types/react' - '@types/react-dom' - magicast @@ -12335,8 +12298,6 @@ snapshots: transitivePeerDependencies: - supports-color - seq-queue@0.0.5: {} - serve-static@2.2.1: dependencies: encodeurl: 2.0.0 @@ -12535,13 +12496,13 @@ snapshots: split2@4.2.0: {} + sql-escaper@1.5.1: {} + sqlite-wasm-kysely@0.3.0(kysely@0.28.17): dependencies: '@sqlite.org/sqlite-wasm': 3.48.0-build4 kysely: 0.28.17 - sqlstring@2.3.3: {} - stable-hash-x@0.2.0: {} stack-trace@0.0.10: {}