Merge branch 'feat/v147-dbroles' into release/v1.47.0

This commit is contained in:
Marc-André Bombeck committed 2026-11-03 20:52:17 +01:00
commit 775933b7d5
555 files changed
+1382 -2389

No files matched your search

+6
View File
@@ -38,6 +38,12 @@
# echo "POSTGRES_PASSWORD=$(openssl rand -hex 32)" >> .env
# Left commented here so that append does not create a duplicate key. Uncomment
# and set it directly instead if you prefer to edit the file by hand.
# The container runs the app as `healthlog_app`, a database role it creates on
# every start that reads and writes rows but cannot change the schema. The
# migrations keep the DATABASE_URL user. "off" keeps the app on that user, if
# you manage database roles yourself (docs/self-hosting/database-role.md).
# DB_APP_ROLE="off"
# POSTGRES_PASSWORD="generate-with-openssl-rand-hex-32"
+8
View File
@@ -526,6 +526,14 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# Behind such a pooler, set this to 1, true or yes to send no session
# settings at all; the pooler's or the database's own defaults then apply.
# DATABASE_SESSION_OPTIONS_DISABLED="1"
#
# The app runs as `healthlog_app`, a database role the container creates on
# every start: it reads and writes rows but cannot alter or drop tables or
# create roles. Migrations and restores still use the user in DATABASE_URL.
# Where that user may not create roles (a managed database), the start log
# says so and the app uses DATABASE_URL as given. Set "off" if you manage
# the database roles yourself. See docs/self-hosting/database-role.md.
# DB_APP_ROLE="off"
# -----------------------------------------------------------------------------
+2
View File
@@ -257,6 +257,8 @@ COPY --from=builder /app/scripts/pending-migrations.mjs ./scripts/pending-migrat
COPY --from=builder /app/scripts/migrate-deploy.mjs ./scripts/migrate-deploy.mjs
COPY --from=builder /app/scripts/db-dump.mjs ./scripts/db-dump.mjs
COPY --from=builder /app/scripts/db-restore.mjs ./scripts/db-restore.mjs
COPY --from=builder /app/scripts/db-app-role.mjs ./scripts/db-app-role.mjs
COPY --from=builder /app/src/lib/jobs/retired-measurement-indexes.mjs ./src/lib/jobs/retired-measurement-indexes.mjs
COPY --from=builder /app/src/lib/database-backup/dump-core.mjs ./src/lib/database-backup/dump-core.mjs
COPY --from=builder /app/src/lib/auth/argon2-params.mjs ./src/lib/auth/argon2-params.mjs
COPY --from=builder /app/scripts/repair-intake-anomalies.ts ./scripts/repair-intake-anomalies.ts
+5
View File
@@ -81,6 +81,11 @@ x-healthlog-env: &healthlog_env
# 1 sends neither of the two settings above (they ride the libpq `options`
# startup parameter, which PgBouncer refuses by default).
DATABASE_SESSION_OPTIONS_DISABLED: "${DATABASE_SESSION_OPTIONS_DISABLED:-}"
# The app connects as `healthlog_app`, a role the entrypoint creates that
# reads and writes rows but cannot change the schema; the migrations keep
# the owner above. off keeps the app on the owner (for an operator who
# manages database roles). See docs/self-hosting/database-role.md.
DB_APP_ROLE: "${DB_APP_ROLE:-}"
WITHINGS_WEBHOOK_SECRET: "${WITHINGS_WEBHOOK_SECRET:-}"
# Overrides the derived `${NEXT_PUBLIC_APP_URL}/api/withings/callback`;
# set it when the URL registered with Withings differs from the app's own
+16
View File
@@ -107,6 +107,22 @@ NODE_PATH=/opt/prisma-cli/node_modules \
node scripts/migrate-deploy.mjs
echo "HealthLog: Migrations complete."
# ── The app's database role ──────────────────────────────────
# Everything above ran as the owner DATABASE_URL names. The app itself
# connects as `healthlog_app`, which reads and writes rows and runs the queue
# but cannot change the schema (scripts/db-app-role.mjs says what it may and
# may not do, and docs/self-hosting/database-role.md how to opt out). The
# script hands the role's URL over on descriptor 3 only, never on stdout or
# stderr; nothing handed over means it said why and the app stays on the
# owner, as before. The owner's URL is replaced, not kept beside it, so it
# does not reach the app's environment.
app_database_url="$(node scripts/db-app-role.mjs 3>&1 1>&2)" || app_database_url=""
if [ -n "$app_database_url" ]; then
DATABASE_URL="$app_database_url"
export DATABASE_URL
fi
unset app_database_url
# ── Start application ────────────────────────────────────────
echo "HealthLog: Starting application..."
exec "$@"
+16
View File
@@ -140,6 +140,22 @@ An INVALID index whose migration is already recorded applied is not touched at
boot. The boot names it on stderr with `WARNING — index … is INVALID`; rebuild
it with `REINDEX INDEX CONCURRENTLY "<index>";` when the database is quiet.
### The app's database role
The steps above run as the owner `DATABASE_URL` names: the dump, the
migrations and, after them, the setup of `healthlog_app`, the role the app
then connects as ([database-role.md](../self-hosting/database-role.md)). The
app never runs a migration. A migration that creates a table needs nothing
more: the setup grants the role every table it lacks before the app starts.
On the first start of the release that brings the role, the setup hands the
existing `pgboss` tables to it, which takes each table's lock for a moment.
Stop the old container (and a separate worker) before the new one starts, as
for any update with migrations; a lock that does not come within 15 seconds
leaves that start on the owner with a notice, and the next start tries again.
A web container and a worker container that start together wait for each
other on an advisory lock and derive the same password.
### v1.46.0 notes
- **Postgres 18.** The compose file moves the bundled database from
+8
View File
@@ -147,6 +147,14 @@ different.
Sign in. The app's own key check runs at start and refuses to serve on a
key that does not open the data.
The restore runs as the owner `DATABASE_URL` names, and the restored tables
belong to it. The app connects as `healthlog_app`
([database-role.md](../self-hosting/database-role.md)): the start in step 5
grants that role the restored tables and hands it the queue schema before the
app runs. A dump carries grants to `healthlog_app`; `db-restore` leaves them
out (`--no-owner --no-privileges`). Restoring with `pg_restore` by hand, pass
the same two flags, or it fails on a database without the role.
When the dumps are still in the server's `backups` volume (the database
volume was lost, the backups volume was not), step 4 is
`docker compose run --rm db-restore latest`.
+11 -1
View File
@@ -214,7 +214,7 @@ So an index is retired in two halves (v1.43.8, migration 0416, for
exactly as the schema does, and `prisma migrate dev` / `migrate diff` see
no drift and generate nothing.
2. The index name goes into `RETIRED_MEASUREMENT_INDEXES` in
`src/lib/jobs/measurement-maintenance.ts`. Every run of this job, the
`src/lib/jobs/retired-measurement-indexes.mjs`. Every run of this job, the
nightly 03:40 check included, drops each name still present with
`DROP INDEX CONCURRENTLY IF EXISTS`, waiting at most 60 seconds for its
locks. A run that runs out of time leaves the index (possibly marked
@@ -242,6 +242,16 @@ SET lock_timeout = '60s';
DROP INDEX CONCURRENTLY IF EXISTS "measurements_user_id_type_measured_at_idx";
```
When the app connects as its restricted role
([database-role.md](../self-hosting/database-role.md)), the job may vacuum and
rebuild (`MAINTAIN`) but not drop: `DROP INDEX` belongs to the table's owner.
A run then skips the drops, of retired indexes and of the invalid copy a
failed rebuild leaves, names what it found under
`maintenance_drops_left_for_boot` with a warning, and the next start drops
each as the owner with `DROP INDEX CONCURRENTLY`, waiting at most five
seconds. Until then a failed rebuild's copy holds its disk; restart the
container, or drop it by hand as the owner with the statement above.
For the next large index, follow the same two halves.
`src/__tests__/migration-lock-timeout-guard.test.ts` refuses a
`DROP INDEX CONCURRENTLY` in any migration and requires every retired name to
+112
View File
@@ -0,0 +1,112 @@
# The app's database role
The container connects to Postgres under two names. The user in
`DATABASE_URL` (on the bundled compose file, `healthlog`, which the official
Postgres image makes a superuser) owns the schema: the entrypoint uses it for
the dump before an update, the migrations and the restore. The app itself,
web and worker, connects as `healthlog_app`, a role the entrypoint sets up
after the migrations on every start. Nothing to configure: the first start of
a release with this change creates it.
The point is the blast radius of a bug. If a dependency of the app were ever
made to run code inside the container, the database connection it found would
be one that can read and write rows, not one that can drop tables, rewrite the
schema or reach the server's files.
## What the role may and may not do
| May | May not |
| ------------------------------------------------------------------------- | --------------------------------------------------------------- |
| read, insert, update and delete rows in every table of the app's schema | create, alter or drop tables, indexes, types or functions there |
| use and advance its sequences | write `_prisma_migrations` (it may read it) |
| `VACUUM`, `ANALYZE` and `REINDEX` the `measurements` table (`MAINTAIN`) | create roles, schemas or databases |
| own the `pgboss` schema, where the job queue keeps and changes its tables | act as a superuser: no `COPY … PROGRAM`, no `pg_read_file` |
| create temporary tables | become the owner (`SET ROLE`) |
The queue schema is the role's because pg-boss creates and migrates its own
tables, functions and per-queue partitions while the app runs. Nothing of the
app's data lives there. The start also takes `CREATE` on the app's schema
away from `PUBLIC`, which Postgres 15 and newer already do for a new database
but a database first created on an older major still allows.
Its password is derived from `API_TOKEN_HMAC_KEY`, so a separate worker
container ([scaling.md](scaling.md)) arrives at the same one without a new
variable, and it is stored as a SCRAM hash. The owner's URL is replaced, not
kept beside the role's, so it is not in the app's environment.
`docker compose exec app …` still starts with the container's configured
environment, so the operator scripts (`reset-password.mjs` and the others)
run as the owner as before.
Each start re-applies the grants, so a table the new migrations created is
covered before the app starts, and a restored database is put right on the
start after the restore.
## What the start log says
When it is in place:
```text
HealthLog: the app connects as healthlog_app: rows and the queue, no schema changes (DB_APP_ROLE=off turns this off)
```
When it is not, one notice names the reason, and the app runs as the
`DATABASE_URL` user, exactly as every earlier release did:
```text
HealthLog: notice: the app connects as healthlog, the user DATABASE_URL names, not as the restricted role healthlog_app: <reason>. …
```
The reasons:
- **The user may not create roles.** A managed database (RDS, Cloud SQL,
Supabase and the like) or a database server where you gave HealthLog an
account without `CREATEROLE`. Either leave it, or create the split yourself
(below) and set `DB_APP_ROLE=off`.
- **Postgres older than 17.** The maintenance job's `VACUUM` and index rebuild
need the `MAINTAIN` privilege, which Postgres 17 introduced; before it only
the table's owner may run them. The bundled database is Postgres 18; see
[postgres-major-upgrade.md](../ops/postgres-major-upgrade.md) for one you
run yourself.
- **The role cannot log in.** A `pg_hba.conf` that lists users by name needs a
line for `healthlog_app` beside the one for the owner.
- **A role named `healthlog_app` exists that HealthLog did not create.** The
start leaves a role it does not recognise alone.
- **Setting it up failed**, most often a lock it waited 15 seconds for: the
first start hands the existing queue tables to the role, and another
container still using them holds it up. The next start tries again.
None of these stops the start.
## Turning it off
Set `DB_APP_ROLE=off` in `.env` and recreate the container. The app then
connects as the `DATABASE_URL` user. Use it when you manage database roles
yourself, including when `DATABASE_URL` already names a restricted role of
your own; then that role needs what the table above lists, and the
migrations need a user that owns the schema.
The role stays in the database when you turn it off and does no harm. To
remove it, as the owner:
```sql
REASSIGN OWNED BY healthlog_app TO healthlog;
DROP OWNED BY healthlog_app;
DROP ROLE healthlog_app;
```
## Index drops at start
`DROP INDEX` belongs to a table's owner; `MAINTAIN` does not include it. The
measurement maintenance therefore no longer drops a retired index or the
invalid copy a failed rebuild leaves; it names them on its run and the next
start drops them as the owner, waiting at most five seconds for each
([measurement-maintenance.md](../ops/measurement-maintenance.md)).
## Dumps and restores
The nightly dump runs as the role and reads everything: the app's schema
through its grants, the queue schema as its owner. A dump carries the grants
to `healthlog_app`; `db-restore` leaves them and the ownership out
(`--no-owner --no-privileges`), restores as the owner, and the next start
grants again. A restore of your own with `pg_restore` should do the same, or
it fails on a database without the role.
+9
View File
@@ -129,6 +129,15 @@ looks healthy. Deploy both from the same image tag
(`HEALTHLOG_IMAGE_REF` is shared by `app` and the `app-worker` block)
and restart the worker together with the web containers.
## Database role
Both containers run the entrypoint, and both set up `healthlog_app`, the
database role the app connects as
([database-role.md](database-role.md)). They take turns on an advisory lock
and derive the same password from `API_TOKEN_HMAC_KEY`, which the shared
environment block gives both, so a worker that starts after the web container
changes nothing the web container relies on.
## Healthchecks
- `app` (web) healthcheck: `wget /api/health` every 30s.
+648
View File
@@ -0,0 +1,648 @@
#!/usr/bin/env node
/**
* The database role the running app connects as.
*
* The migrations, the dump before them and the restore connect as the owner
* `DATABASE_URL` names. The long-running web and worker processes do not need
* the owner: they read and write rows, run the queue, and maintain one table.
* After the migrations, `docker-entrypoint.sh` runs this script as the owner.
* It ensures a login role, `healthlog_app`, that can do exactly that, and
* hands the entrypoint a `DATABASE_URL` for it; the entrypoint then starts the
* app with that URL in place of the owner's, so the owner's credentials never
* reach the app's environment.
*
* What the role can do:
* - SELECT, INSERT, UPDATE, DELETE on every table of the app's schema, and
* USAGE, SELECT, UPDATE on its sequences. `_prisma_migrations` is SELECT
* only: the app reads the migration history, it never writes it.
* - MAINTAIN on `measurements` (Postgres 17+): the maintenance job's
* `VACUUM (ANALYZE)` and `REINDEX INDEX CONCURRENTLY`.
* - Own the `pgboss` schema and everything in it. pg-boss creates and
* migrates its own tables, functions and per-queue partitions at runtime,
* so it needs DDL there; it is the queue's private schema and nothing of
* the app's data lives in it.
*
* What it cannot do: create, alter or drop anything in the app's schema;
* create schemas, databases or roles; act as a superuser, so no
* `COPY ... FROM PROGRAM`, no `pg_read_file`, no untrusted languages; write
* the migration history. `CREATE` on the app's schema is revoked from PUBLIC
* as well (the default since Postgres 15; a database first created on an
* older major still grants it), where the owner may do so.
*
* Every boot re-applies it, inside one transaction under an advisory lock, so
* a table a new migration created is covered before the app starts, a web and
* a worker container booting together do not race, and a restored database
* (whose objects `pg_restore --no-owner` hands to the owner) is put right.
* Grants only touch what lacks them, so a boot after the first writes
* almost nothing to the catalog.
*
* The password is derived, not stored: HMAC-SHA256 of a fixed label under
* API_TOKEN_HMAC_KEY. Every container of one instance carries that key, so a
* separate worker container derives the same password without a new
* variable. It is set as a SCRAM verifier computed here, so the plaintext
* never travels to the server, where a statement log could keep it. The role
* is marked with a comment; a role of the same name without the mark is not
* HealthLog's, and is left alone.
*
* Fail open, loudly. When the owner cannot do this (a managed or external
* database whose user may not create roles, Postgres before 17, a pg_hba.conf
* that does not let the new role in, a lock that did not come in time), the
* script says why in one notice and the app runs as the owner, as it did
* before. `DB_APP_ROLE=off` skips it for an operator who manages roles
* themselves, including one whose `DATABASE_URL` already names a restricted role.
*
* Why Postgres 17: the maintenance job vacuums and rebuilds the indexes of
* `measurements`, and before the MAINTAIN privilege only the table's owner
* could. Rather than leave that job broken on 16, an older server keeps
* today's behaviour.
*
* Index drops stay with the owner: `DROP INDEX` belongs to the table's owner
* and MAINTAIN does not include it. When the role is in place this script
* also drops, as the owner, the indexes the maintenance job used to drop
* (retired indexes, and the invalid copies an interrupted concurrent rebuild
* leaves), each under a short lock wait, so a held lock delays the boot by
* seconds and the drop waits for the next boot.
*
* Interface: the URL for the role goes to file descriptor 3 and nowhere else;
* every message goes to stderr, and no message carries a password or a URL.
* The entrypoint reads it with `url="$(node scripts/db-app-role.mjs 3>&1 1>&2)"`.
* Without descriptor 3 (a manual run) the role is still ensured and the URL is
* not printed. Exit 0 in every case the app should start; nothing written to
* descriptor 3 means: stay on the owner.
*
* Plain ESM with no imports beyond Node, `pg` and the shared dump module, so
* it runs from the runtime image like the other entrypoint scripts.
*/
import { createHash, createHmac, pbkdf2Sync, randomBytes } from "node:crypto";
import { writeSync } from "node:fs";
import { pathToFileURL } from "node:url";
import { connect } from "../src/lib/database-backup/dump-core.mjs";
import {
CONCURRENT_COPY,
RETIRED_MEASUREMENT_INDEXES,
} from "../src/lib/jobs/retired-measurement-indexes.mjs";
/** The role's name. Cluster-wide, like every Postgres role. */
export const APP_ROLE = "healthlog_app";
/** The mark on a role this script created. */
export const ROLE_COMMENT =
"Managed by HealthLog: the app's least-privilege login (scripts/db-app-role.mjs). Recreated and re-granted on every start.";
/** The schema pg-boss keeps its queue in (its default; HealthLog sets none). */
export const QUEUE_SCHEMA = "pgboss";
/** Tables the role may maintain (VACUUM, ANALYZE, REINDEX). */
export const MAINTAINED_TABLES = Object.freeze(["measurements"]);
/** Tables the role may only read. */
export const READ_ONLY_TABLES = Object.freeze(["_prisma_migrations"]);
/** The label the password is derived under. A new label is a new password. */
const PASSWORD_LABEL = "healthlog database app role v1";
/** The first major with the MAINTAIN privilege. */
export const MIN_SERVER_VERSION_NUM = 170000;
/** How long the setup waits for a lock before staying on the owner. */
const SETUP_LOCK_TIMEOUT = "15s";
/** How long a boot-time index drop waits for its lock. */
export const BOOT_DROP_LOCK_TIMEOUT = "5s";
/** An identifier this script splices into SQL: lower-case catalog names only. */
const IDENTIFIER = /^[a-z_][a-z0-9_]{0,62}$/;
function say(message) {
process.stderr.write(`HealthLog: ${message}\n`);
}
/** Double-quote an identifier after checking its shape. */
function ident(name) {
if (!IDENTIFIER.test(name)) {
throw new Error("refusing to splice an identifier of unexpected shape");
}
return `"${name}"`;
}
/** The role's password: 64 hex characters, the same in every container. */
export function appRolePassword(hmacKey) {
if (!hmacKey) throw new Error("API_TOKEN_HMAC_KEY is not set");
return createHmac("sha256", hmacKey).update(PASSWORD_LABEL).digest("hex");
}
/**
* A SCRAM-SHA-256 verifier in the form `pg_authid.rolpassword` stores
* (RFC 5802/7677; the password is ASCII hex, so SASLprep leaves it as is).
* Postgres takes a string of this shape as already hashed.
*/
export function scramVerifier(
password,
salt = randomBytes(16),
iterations = 4096,
) {
const salted = pbkdf2Sync(password, salt, iterations, 32, "sha256");
const clientKey = createHmac("sha256", salted).update("Client Key").digest();
const storedKey = createHash("sha256").update(clientKey).digest();
const serverKey = createHmac("sha256", salted).update("Server Key").digest();
return `SCRAM-SHA-256$${iterations}:${salt.toString("base64")}$${storedKey.toString("base64")}:${serverKey.toString("base64")}`;
}
/** The owner's URL with the role's name and password: same host, database and parameters. */
export function appRoleUrl(ownerUrl, password) {
const url = new URL(ownerUrl);
url.username = APP_ROLE;
url.password = password;
return url.toString();
}
/** The user a URL names, for the notices. Never the password. */
function userOf(databaseUrl) {
try {
return decodeURIComponent(new URL(databaseUrl).username) || "(default)";
} catch {
return "(unparsable)";
}
}
function errorText(err) {
const code = err?.code;
const message = err instanceof Error ? err.message : String(err);
return typeof code === "string" ? `${code} ${message}` : message;
}
/**
* Whether the connected user can set the role up, and why not when it
* cannot. Read-only.
*
* @param {{ query: Function }} client
* @returns {Promise<{ ok: true, superuser: boolean } | { ok: false, reason: string, detail: string }>}
*/
export async function checkPreconditions(client) {
const { rows } = await client.query(
`SELECT current_setting('server_version_num')::int AS version,
r.rolsuper AS superuser,
r.rolcreaterole AS createrole,
current_user AS me
FROM pg_roles r WHERE r.rolname = current_user`,
);
const me = rows[0];
if (me.me === APP_ROLE) {
return {
ok: false,
reason: "already_app_role",
detail: `DATABASE_URL already names ${APP_ROLE}`,
};
}
if (me.version < MIN_SERVER_VERSION_NUM) {
return {
ok: false,
reason: "server_too_old",
detail: `Postgres ${Math.floor(me.version / 10000)} has no MAINTAIN privilege (17 or newer does)`,
};
}
if (!me.superuser && !me.createrole) {
return {
ok: false,
reason: "cannot_create_roles",
detail: `the database user ${me.me} may not create roles (a managed or external database, or a user without CREATEROLE)`,
};
}
const existing = await client.query(
`SELECT shobj_description(r.oid, 'pg_authid') AS comment,
pg_has_role(r.oid, current_user, 'MEMBER') AS member_of_owner,
r.rolsuper OR r.rolreplication OR r.rolbypassrls AS elevated
FROM pg_roles r WHERE r.rolname = $1`,
[APP_ROLE],
);
const role = existing.rows[0];
if (role && role.comment !== ROLE_COMMENT) {
return {
ok: false,
reason: "foreign_role",
detail: `a role named ${APP_ROLE} exists that HealthLog did not create`,
};
}
if (role?.member_of_owner) {
return {
ok: false,
reason: "role_inherits_owner",
detail: `${APP_ROLE} is a member of ${me.me}, so it would hold the owner's rights`,
};
}
if (role?.elevated && !me.superuser) {
return {
ok: false,
reason: "role_elevated",
detail: `${APP_ROLE} has SUPERUSER, REPLICATION or BYPASSRLS, and only a superuser can take it away`,
};
}
return { ok: true, superuser: me.superuser };
}
/**
* Create or update the role and apply every grant, in one transaction.
* Call it on a client connected as the owner, after the migrations.
*
* @param {{ query: Function }} client
* @param {{ password: string, schema?: string, superuser: boolean }} options
*/
export async function applyAppRole(client, options) {
const schema = options.schema ?? "public";
const role = ident(APP_ROLE);
const app = ident(schema);
const queue = ident(QUEUE_SCHEMA);
// The verifier is a base64/`$`/`:` string; it never contains a quote.
const verifier = scramVerifier(options.password);
if (verifier.includes("'")) throw new Error("unexpected verifier shape");
await client.query("BEGIN");
try {
await client.query(`SET LOCAL lock_timeout = '${SETUP_LOCK_TIMEOUT}'`);
await client.query(
`SELECT pg_advisory_xact_lock(hashtext('healthlog.db-app-role'))`,
);
const { rows: present } = await client.query(
`SELECT 1 FROM pg_roles WHERE rolname = $1`,
[APP_ROLE],
);
// The attributes a superuser alone may set are named only by one; a
// role a CREATEROLE user creates never has them (checked beforehand).
const elevatedOff = options.superuser
? " NOSUPERUSER NOREPLICATION NOBYPASSRLS"
: "";
const attributes = `LOGIN NOCREATEDB NOCREATEROLE NOINHERIT${elevatedOff}`;
if (present.length === 0) {
await client.query(
`CREATE ROLE ${role} ${attributes} PASSWORD '${verifier}'`,
);
} else {
await client.query(
`ALTER ROLE ${role} ${attributes} PASSWORD '${verifier}'`,
);
}
await client.query(
`COMMENT ON ROLE ${role} IS '${ROLE_COMMENT.replace(/'/g, "''")}'`,
);
// A non-superuser owner hands objects to the role only when it may act
// as it (Postgres 16+), and must keep reading what the role owns: the
// dump before a migration reads the queue schema too. The membership
// runs owner -> role, never the other way.
if (!options.superuser) {
await client.query(
`GRANT ${role} TO CURRENT_USER WITH INHERIT TRUE, SET TRUE`,
);
}
const { rows: db } = await client.query(
`SELECT current_database() AS name`,
);
await client.query(
`GRANT CONNECT, TEMPORARY ON DATABASE "${String(db[0].name).replace(/"/g, '""')}" TO ${role}`,
);
// The app's schema: rows, never structure.
await client.query(`GRANT USAGE ON SCHEMA ${app} TO ${role}`);
await client.query(`REVOKE CREATE ON SCHEMA ${app} FROM ${role}`);
const { rows: schemaOwner } = await client.query(
`SELECT pg_has_role(current_user, nspowner, 'USAGE') AS owns
FROM pg_namespace WHERE nspname = $1`,
[schema],
);
if (options.superuser || schemaOwner[0]?.owns) {
await client.query(`REVOKE CREATE ON SCHEMA ${app} FROM PUBLIC`);
} else {
say(
`notice: the schema ${schema} is not the database user's to change; PUBLIC may still create objects in it`,
);
}
const { rows: tables } = await client.query(
`SELECT c.relname AS name
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = $1 AND c.relkind IN ('r', 'p', 'v', 'm', 'f')
AND c.relname <> ALL($3::text[])
AND NOT (has_table_privilege($2, c.oid, 'SELECT')
AND has_table_privilege($2, c.oid, 'INSERT')
AND has_table_privilege($2, c.oid, 'UPDATE')
AND has_table_privilege($2, c.oid, 'DELETE'))`,
[schema, APP_ROLE, READ_ONLY_TABLES],
);
for (const { name } of tables) {
await client.query(
`GRANT SELECT, INSERT, UPDATE, DELETE ON ${app}.${ident(name)} TO ${role}`,
);
}
for (const name of READ_ONLY_TABLES) {
const { rows } = await client.query(
`SELECT to_regclass($1) IS NOT NULL AS present`,
[`${app}.${ident(name)}`],
);
if (!rows[0].present) continue;
await client.query(
`REVOKE INSERT, UPDATE, DELETE, TRUNCATE ON ${app}.${ident(name)} FROM ${role}`,
);
await client.query(`GRANT SELECT ON ${app}.${ident(name)} TO ${role}`);
}
const { rows: sequences } = await client.query(
`SELECT c.relname AS name
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = $1 AND c.relkind = 'S'
AND NOT (has_sequence_privilege($2, c.oid, 'USAGE')
AND has_sequence_privilege($2, c.oid, 'SELECT')
AND has_sequence_privilege($2, c.oid, 'UPDATE'))`,
[schema, APP_ROLE],
);
for (const { name } of sequences) {
await client.query(
`GRANT USAGE, SELECT, UPDATE ON SEQUENCE ${app}.${ident(name)} TO ${role}`,
);
}
// Objects the owner creates later in this schema (the migrations of the
// next update) are granted at creation as well. The per-boot pass above
// is what the app relies on; this covers the time between the two.
await client.query(
`ALTER DEFAULT PRIVILEGES IN SCHEMA ${app} GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${role}`,
);
await client.query(
`ALTER DEFAULT PRIVILEGES IN SCHEMA ${app} GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO ${role}`,
);
for (const name of MAINTAINED_TABLES) {
const { rows } = await client.query(
`SELECT to_regclass($1) IS NOT NULL AS present`,
[`${app}.${ident(name)}`],
);
if (rows[0].present) {
await client.query(
`GRANT MAINTAIN ON ${app}.${ident(name)} TO ${role}`,
);
}
}
// The queue's schema belongs to the role: pg-boss creates its tables,
// functions and partitions there at runtime. On the first boot after an
// update, and after a restore, everything in it still belongs to the
// owner and is handed over once.
await client.query(
`CREATE SCHEMA IF NOT EXISTS ${queue} AUTHORIZATION ${role}`,
);
await client.query(`ALTER SCHEMA ${queue} OWNER TO ${role}`);
await transferQueueObjects(client, role);
await client.query("COMMIT");
} catch (err) {
await client.query("ROLLBACK").catch(() => {});
throw err;
}
}
/** Hand every object in the queue schema the role does not own to it. */
async function transferQueueObjects(client, role) {
const { rows: relations } = await client.query(
`SELECT c.oid::regclass::text AS name, c.relkind AS kind
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = $1
AND c.relowner <> (SELECT oid FROM pg_roles WHERE rolname = $2)
AND c.relkind IN ('r', 'p', 'v', 'm', 'f', 'S')
-- A sequence a column owns follows its table.
AND NOT (c.relkind = 'S' AND EXISTS (
SELECT 1 FROM pg_depend d
WHERE d.classid = 'pg_class'::regclass AND d.objid = c.oid
AND d.deptype IN ('a', 'i')))
ORDER BY c.relkind = 'r' DESC, c.oid`,
[QUEUE_SCHEMA, APP_ROLE],
);
for (const { name, kind } of relations) {
const what =
kind === "S"
? "SEQUENCE"
: kind === "v"
? "VIEW"
: kind === "m"
? "MATERIALIZED VIEW"
: kind === "f"
? "FOREIGN TABLE"
: "TABLE";
// `regclass::text` is the catalog's own quoting of the name.
await client.query(`ALTER ${what} ${name} OWNER TO ${role}`);
}
const { rows: routines } = await client.query(
`SELECT p.oid::regprocedure::text AS signature
FROM pg_proc p
JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname = $1
AND p.proowner <> (SELECT oid FROM pg_roles WHERE rolname = $2)`,
[QUEUE_SCHEMA, APP_ROLE],
);
for (const { signature } of routines) {
await client.query(`ALTER ROUTINE ${signature} OWNER TO ${role}`);
}
// Enums, domains and free-standing composite types; a table's row type
// and an array type follow their owner object.
const { rows: types } = await client.query(
`SELECT t.oid::regtype::text AS name, t.typtype AS kind
FROM pg_type t
JOIN pg_namespace n ON n.oid = t.typnamespace
LEFT JOIN pg_class c ON c.oid = t.typrelid
WHERE n.nspname = $1
AND t.typowner <> (SELECT oid FROM pg_roles WHERE rolname = $2)
AND t.typelem = 0
AND (t.typtype IN ('e', 'd', 'r') OR (t.typtype = 'c' AND c.relkind = 'c'))`,
[QUEUE_SCHEMA, APP_ROLE],
);
for (const { name, kind } of types) {
await client.query(
`ALTER ${kind === "d" ? "DOMAIN" : "TYPE"} ${name} OWNER TO ${role}`,
);
}
}
/**
* Drop, as the owner, the indexes on `measurements` the maintenance job can
* no longer drop as the role: the retired ones and the invalid copies of a
* concurrent rebuild. Each waits at most {@link BOOT_DROP_LOCK_TIMEOUT}; one
* that does not get its lock is left for the next boot.
*
* @param {{ query: Function }} client a connection outside any transaction
* @returns {Promise<{ dropped: string[], left: string[] }>}
*/
export async function dropOwnerOnlyIndexes(client) {
const dropped = [];
const left = [];
const { rows: table } = await client.query(
`SELECT to_regclass('measurements') IS NOT NULL AS present`,
);
if (!table[0].present) return { dropped, left };
const { rows } = await client.query(
`SELECT c.relname AS name, i.indisvalid AS valid
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
WHERE i.indrelid = 'measurements'::regclass`,
);
const targets = rows
.filter(
(r) =>
RETIRED_MEASUREMENT_INDEXES.includes(r.name) ||
(!r.valid && CONCURRENT_COPY.test(r.name)),
)
.map((r) => r.name);
if (targets.length === 0) return { dropped, left };
const { rows: setting } = await client.query(
`SELECT current_setting('lock_timeout') AS value`,
);
try {
await client.query(`SET lock_timeout = '${BOOT_DROP_LOCK_TIMEOUT}'`);
for (const name of targets) {
try {
await client.query(`DROP INDEX CONCURRENTLY IF EXISTS ${ident(name)}`);
dropped.push(name);
} catch (err) {
left.push(name);
say(
`notice: index ${name} on measurements was not dropped (${errorText(err)}); the next start tries again`,
);
}
}
} finally {
await client.query(`SELECT set_config('lock_timeout', $1, false)`, [
setting[0]?.value ?? "0",
]);
}
return { dropped, left };
}
/** Log in as the role once, so a pg_hba.conf that refuses it shows here. */
async function proveLogin(url) {
const client = await connect(url);
try {
const { rows } = await client.query(`SELECT current_user AS me`);
if (rows[0].me !== APP_ROLE) {
throw new Error(`logged in as ${rows[0].me}, not ${APP_ROLE}`);
}
} finally {
await client.end().catch(() => {});
}
}
/** Write the URL to descriptor 3 when the caller opened it. */
function handOver(url) {
try {
writeSync(3, url);
return true;
} catch {
return false;
}
}
/**
* The boot step. Returns the role's URL when the app should use it, or null
* to stay on the owner.
*
* @param {NodeJS.ProcessEnv} env
* @returns {Promise<string | null>}
*/
export async function ensureAppRole(env = process.env) {
const ownerUrl = env.DATABASE_URL;
const mode = (env.DB_APP_ROLE ?? "").trim().toLowerCase();
if (mode === "off" || mode === "false") {
say(
`DB_APP_ROLE=off: the app connects as ${userOf(ownerUrl)}, the user DATABASE_URL names`,
);
return null;
}
if (mode && mode !== "auto" && mode !== "on") {
say(
`notice: DB_APP_ROLE="${mode}" is not auto or off; treating it as auto`,
);
}
if (!ownerUrl) return null;
const user = userOf(ownerUrl);
const fallBack = (detail) => {
say(
`notice: the app connects as ${user}, the user DATABASE_URL names, not as the restricted role ${APP_ROLE}: ${detail}. ` +
"This is how every earlier release ran. " +
`If ${user} is a restricted role you manage yourself, DB_APP_ROLE=off silences this; otherwise see docs/self-hosting/database-role.md.`,
);
return null;
};
let client;
try {
client = await connect(ownerUrl);
} catch (err) {
return fallBack(`could not connect to set it up (${errorText(err)})`);
}
try {
const pre = await checkPreconditions(client);
if (!pre.ok) {
if (pre.reason === "already_app_role") {
say(`DATABASE_URL already names ${APP_ROLE}; nothing to set up`);
return null;
}
return fallBack(pre.detail);
}
const password = appRolePassword(env.API_TOKEN_HMAC_KEY);
const schema = new URL(ownerUrl).searchParams.get("schema") || "public";
try {
await applyAppRole(client, {
password,
schema,
superuser: pre.superuser,
});
} catch (err) {
return fallBack(`setting it up failed (${errorText(err)})`);
}
const url = appRoleUrl(ownerUrl, password);
try {
await proveLogin(url);
} catch (err) {
return fallBack(
`${APP_ROLE} cannot log in (${errorText(err)}); a pg_hba.conf that lists users by name needs a line for it`,
);
}
const { dropped } = await dropOwnerOnlyIndexes(client);
if (dropped.length > 0) {
say(`dropped index ${dropped.join(", ")} on measurements`);
}
say(
`the app connects as ${APP_ROLE}: rows and the queue, no schema changes (DB_APP_ROLE=off turns this off)`,
);
return url;
} catch (err) {
return fallBack(`the check failed (${errorText(err)})`);
} finally {
await client.end().catch(() => {});
}
}
export async function main(env = process.env) {
const url = await ensureAppRole(env);
if (url && !handOver(url)) {
say(
"the role is ready; the URL goes to file descriptor 3, which this run did not open",
);
}
return 0;
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
main().then(
(code) => process.exit(code),
(err) => {
say(`notice: the database role step failed (${errorText(err)})`);
process.exit(0);
},
);
}
+5
View File
@@ -225,6 +225,11 @@
{
"name": "DATABASE_SESSION_OPTIONS_DISABLED",
"purpose": "Set to 1, true or yes to send no session settings (statement timeout, work_mem) as the libpq options startup parameter. Needed behind PgBouncer, which refuses that parameter unless it is in ignore_startup_parameters."
},
{
"name": "DB_APP_ROLE",
"purpose": "auto (default) or off. Auto: after the migrations the entrypoint creates the role healthlog_app, which may read and write rows and run the queue but not change the schema, and starts the app with it in place of the DATABASE_URL user. Off keeps the app on the DATABASE_URL user, for an operator who manages database roles.",
"example": "off"
}
]
},
@@ -46,10 +46,6 @@ vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
// The slim slice now strips disabled-module types before answering, from
@@ -23,9 +23,6 @@ vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/hmac", () => ({ hashToken: vi.fn(() => "h") }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({
checkAuthSurfaceRateLimit: vi.fn().mockResolvedValue({
@@ -3,9 +3,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: { user: { findUnique: vi.fn() } },
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
@@ -37,10 +37,6 @@ vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("next/headers", () => ({
@@ -1959,7 +1959,6 @@
"lib/day/values.ts": 1,
"lib/day/visit-preparation.ts": 1,
"lib/day/wire-schemas.ts": 1,
"lib/db-compat.ts": 1,
"lib/devices/__tests__/revoke.test.ts": 1,
"lib/devices/revoke.ts": 1,
"lib/doctor-report-helpers.ts": 1,
@@ -74,9 +74,11 @@ function entrypointScripts(): string[] {
const entrypoint = readFileSync(join(ROOT, "docker-entrypoint.sh"), "utf8");
return [
...new Set(
[...entrypoint.matchAll(/^\s*node\s+(scripts\/[\w.-]+)/gm)].map(
(m) => m[1],
),
// A line that starts with the call, or a command substitution that
// makes it (`url="$(node scripts/db-app-role.mjs …)"`).
[
...entrypoint.matchAll(/(?:^\s*|\$\(\s*)node\s+(scripts\/[\w.-]+)/gm),
].map((m) => m[1]),
),
].sort();
}
@@ -126,6 +128,7 @@ describe("scripts the entrypoint runs ship in the image", () => {
it("finds the boot scripts it is meant to check", () => {
// An empty match would agree with any Dockerfile.
expect(entrypointScripts()).toContain("scripts/pending-migrations.mjs");
expect(entrypointScripts()).toContain("scripts/db-app-role.mjs");
});
it("copies every boot script and its local imports into the runner stage", () => {
-4
View File
@@ -47,10 +47,6 @@ const ALLOWED: Record<string, { hits: number; why: string }> = {
hits: 1,
why: "pg-boss tables are timestamptz",
},
"src/lib/db-compat.ts": {
hits: 1,
why: "DDL default on a timestamptz column",
},
};
const SQL_CLOCK =
@@ -65,9 +65,6 @@ vi.mock("@/lib/rollups/measurement-read", () => ({
vi.mock("@/lib/analytics/mood-series", () => ({
buildMoodDailySeries: vi.fn(),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -12,9 +12,6 @@ vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -12,9 +12,6 @@ vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -18,9 +18,6 @@ vi.mock("@/lib/auth/audit", () => ({
}));
vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -15,9 +15,6 @@ vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -15,9 +15,6 @@ vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -75,10 +75,6 @@ vi.mock("@/lib/auth/audit", () => ({
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/insights/correlation-patterns", () => ({
PATTERN_FAMILIES: { fixed: "FIXED" },
syncAcceptedPatterns: vi.fn().mockResolvedValue(new Map()),
@@ -58,10 +58,6 @@ vi.mock("@/lib/rate-limit", () => ({
refundRateLimit: vi.fn(async () => undefined),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/hmac", () => ({
hashToken: vi.fn(() => "aa".repeat(32)),
}));
@@ -61,10 +61,6 @@ vi.mock("@/lib/rate-limit", () => ({
refundRateLimit: vi.fn(async () => undefined),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/hmac", () => ({
hashToken: vi.fn((raw: string) => `hashed:${raw}`),
}));
@@ -90,7 +86,6 @@ import {
checkAuthSurfaceRateLimit,
rateLimitHeaders,
} from "@/lib/rate-limit";
import { ensureDbCompatibility } from "@/lib/db-compat";
import { auditLog } from "@/lib/auth/audit";
import { createSession } from "@/lib/auth/session";
@@ -136,7 +131,6 @@ beforeEach(() => {
ip: "203.0.113.1",
} as never);
vi.mocked(rateLimitHeaders).mockReturnValue({} as never);
vi.mocked(ensureDbCompatibility).mockResolvedValue(undefined);
vi.mocked(auditLog).mockResolvedValue(undefined as never);
vi.mocked(createSession).mockResolvedValue("session-id");
});
@@ -52,10 +52,6 @@ vi.mock("@/lib/rate-limit", () => ({
rateLimitHeaders: vi.fn(() => ({})),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/hmac", () => ({
// Return a deterministic hex string that does NOT echo the raw
// value — mirrors the real HMAC-SHA256 shape. The audit row's
@@ -88,7 +84,6 @@ import {
checkAuthSurfaceRateLimit,
rateLimitHeaders,
} from "@/lib/rate-limit";
import { ensureDbCompatibility } from "@/lib/db-compat";
const TYPED_EMAIL = "leaked-identifier@example.com";
@@ -117,7 +112,6 @@ beforeEach(() => {
ip: "1.2.3.4",
} as never);
vi.mocked(rateLimitHeaders).mockReturnValue({} as never);
vi.mocked(ensureDbCompatibility).mockResolvedValue(undefined);
});
describe("POST /api/auth/login — auth.login.failed audit row (H-1)", () => {
@@ -52,10 +52,6 @@ vi.mock("@/lib/rate-limit", () => ({
refundRateLimit: vi.fn(async () => undefined),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/hmac", () => ({ hashToken: vi.fn(() => "h") }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("next/headers", () => ({
-3
View File
@@ -11,7 +11,6 @@ import {
} from "@/lib/api-response";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { beginAccountLoginAttempt } from "@/lib/auth/login-throttle";
import { ensureDbCompatibility } from "@/lib/db-compat";
import { NextRequest, NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
@@ -59,8 +58,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
);
}
await ensureDbCompatibility();
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
-3
View File
@@ -27,7 +27,6 @@ import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { ensureDbCompatibility } from "@/lib/db-compat";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { finishLogin } from "@/lib/auth/login-response";
import {
@@ -59,8 +58,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
}
await ensureDbCompatibility();
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
});
@@ -14,10 +14,6 @@ vi.mock("@/lib/db", () => ({
prisma: { user: { findUnique: vi.fn() } },
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkAuthSurfaceRateLimit: vi.fn(),
rateLimitHeaders: vi.fn(() => ({})),
@@ -16,7 +16,6 @@ import { apiHandler } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { ensureDbCompatibility } from "@/lib/db-compat";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { loadActiveChallenge } from "@/lib/auth/mfa/challenge";
import { createMfaAuthenticationOptions } from "@/lib/auth/mfa/webauthn";
@@ -37,8 +36,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
}
await ensureDbCompatibility();
const parsed = await parseJsonBody(request, mfaWebauthnLoginOptionsSchema, {
maxBytes: 16 * 1024,
message: "Invalid request",
@@ -25,7 +25,6 @@ import {
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { ensureDbCompatibility } from "@/lib/db-compat";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { finishLogin } from "@/lib/auth/login-response";
import {
@@ -57,8 +56,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
}
await ensureDbCompatibility();
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
@@ -12,10 +12,6 @@ vi.mock("@/lib/db", () => ({
prisma: { user: { findUnique: vi.fn() } },
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkAuthSurfaceRateLimit: vi.fn(),
rateLimitHeaders: vi.fn(() => ({})),
-3
View File
@@ -26,7 +26,6 @@ import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { ensureDbCompatibility } from "@/lib/db-compat";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { isCookielessNativeCaller } from "@/lib/auth/native-client";
import { isNativeClientRequest } from "@/lib/auth/issue-token";
@@ -74,8 +73,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
return invalidCode();
}
await ensureDbCompatibility();
// (3) Body — bounded, then the exact wire contract.
const parsed = await parseJsonBody(request, nativeHandoffTokenSchema, {
maxBytes: 16 * 1024,
@@ -9,10 +9,6 @@ vi.mock("@/lib/db", () => ({
prisma: { user: { findUnique: vi.fn() } },
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkAuthSurfaceRateLimit: vi.fn(),
rateLimitHeaders: vi.fn(() => ({})),
@@ -29,7 +29,6 @@ import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { ensureDbCompatibility } from "@/lib/db-compat";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { isCookielessNativeCaller } from "@/lib/auth/native-client";
import { isNativeClientRequest } from "@/lib/auth/issue-token";
@@ -80,8 +79,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
return invalidCode();
}
await ensureDbCompatibility();
// (3) Body — bounded, then the exact wire contract.
const parsed = await parseJsonBody(request, oidcNativeTokenSchema, {
maxBytes: 16 * 1024,
@@ -37,10 +37,6 @@ vi.mock("@/lib/rate-limit", () => ({
checkAuthSurfaceRateLimit: vi.fn(),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/hmac", () => ({
hashToken: vi.fn((raw: string) => `hashed:${raw}`),
}));
@@ -35,10 +35,6 @@ vi.mock("@/lib/rate-limit", () => ({
checkAuthSurfaceRateLimit: vi.fn(),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/hmac", () => ({
hashToken: vi.fn((raw: string) => `hashed:${raw}`),
}));
@@ -31,10 +31,6 @@ vi.mock("@/lib/rate-limit", () => ({
checkAuthSurfaceRateLimit: vi.fn(),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("next/headers", () => ({
@@ -9,7 +9,6 @@ import { recordSignInDevice } from "@/lib/auth/login-alert";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { ensureDbCompatibility } from "@/lib/db-compat";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
import { NextRequest } from "next/server";
import { apiHandler } from "@/lib/api-handler";
@@ -71,8 +70,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
}
await ensureDbCompatibility();
// v1.4.43 — tighter shared bucket on trust-chain misconfig.
const rl = await checkAuthSurfaceRateLimit(
request,
@@ -63,9 +63,6 @@ vi.mock("@/lib/auth/hibp", () => ({
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -72,9 +72,6 @@ vi.mock("@/lib/tz/resolver", () => ({
resolveServerDefaultTimezone: vi.fn().mockResolvedValue("Europe/Berlin"),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
-3
View File
@@ -12,7 +12,6 @@ import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { ensureDbCompatibility } from "@/lib/db-compat";
import { NextRequest, NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
@@ -79,8 +78,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
);
}
await ensureDbCompatibility();
const userCount = await prisma.user.count();
// Check if registration is enabled.
@@ -33,9 +33,6 @@ vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserHealthScore: vi.fn(),
}));
@@ -32,10 +32,6 @@ vi.mock("@/lib/auth/audit", () => ({
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/labs/biomarker-store", () => ({
encryptContextToBytes: vi.fn(() => new Uint8Array([1])),
decryptContextFromBytes: vi.fn(() => "ctx"),
@@ -31,9 +31,6 @@ vi.mock("@/lib/labs/biomarker-store", () => ({
encryptContextToBytes: vi.fn(),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -37,10 +37,6 @@ vi.mock("@/lib/rate-limit", () => ({
checkAuthSurfaceRateLimit: vi.fn(),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
import { POST } from "../route";
import { resolveShareGateState } from "@/lib/clinician-share/resolve-share-token";
import { verifyPassphrase } from "@/lib/clinician-share/passphrase";
@@ -54,10 +54,6 @@ vi.mock("@/lib/logging/transports", () => ({
emitIfSampled: vi.fn(),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/context", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/logging/context")>();
return { ...actual, annotate: vi.fn() };
@@ -48,9 +48,6 @@ vi.mock("@/lib/ai/coach/bytes-codec", () => ({
encryptToBytes: vi.fn((s: string) => new Uint8Array(Buffer.from(`enc:${s}`))),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/context", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/logging/context")>();
return { ...actual, annotate: vi.fn() };
@@ -48,9 +48,6 @@ vi.mock("@/lib/measurement-reminders/dto", () => ({
toMeasurementReminderDto: vi.fn((r: { id: string }) => ({ id: r.id })),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/context", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/logging/context")>();
return { ...actual, annotate: vi.fn() };
@@ -36,10 +36,6 @@ vi.mock("@/lib/auth/audit", () => ({
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -40,10 +40,6 @@ vi.mock("@/lib/auth/audit", () => ({
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -8,10 +8,6 @@ vi.mock("@/lib/auth/audit", () => ({
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -16,10 +16,6 @@ vi.mock("@/lib/auth/audit", () => ({
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -37,10 +37,6 @@ vi.mock("@/lib/auth/audit", () => ({
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/idempotency", () => ({
withIdempotency: (fn: unknown) => fn,
}));
@@ -32,10 +32,6 @@ vi.mock("@/lib/auth/audit", () => ({
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/idempotency", () => ({
withIdempotency: (fn: unknown) => fn,
}));
@@ -30,10 +30,6 @@ vi.mock("@/lib/auth/audit", () => ({
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserCorrelationPatterns: vi.fn(),
}));
@@ -22,10 +22,6 @@ vi.mock("@/lib/auth/audit", () => ({
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/idempotency", () => ({
withIdempotency: (fn: unknown) => fn,
}));
@@ -64,9 +64,6 @@ vi.mock("@/lib/cycle/dto", async (importOriginal) => ({
toMenstrualCycleDTO: vi.fn(() => ({ id: "cycle-1" })),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -41,9 +41,6 @@ vi.mock("@/lib/rate-limit", () => ({
}));
vi.mock("@/lib/cycle/gate", () => ({ requireCycleEnabled: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -41,9 +41,6 @@ vi.mock("@/lib/rate-limit", () => ({
}));
vi.mock("@/lib/cycle/gate", () => ({ requireCycleEnabled: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -36,9 +36,6 @@ vi.mock("@/lib/cycle/day-log-write", () => ({
upsertCycleDayLog: vi.fn(),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -67,10 +67,6 @@ vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(async () => ({ allowed: true })),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
const localeCookie = vi.fn<() => string | undefined>(() => undefined);
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
@@ -27,9 +27,6 @@ vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/cycle/gate", () => ({
requireCycleEnabled: vi.fn(),
}));
@@ -28,9 +28,6 @@ vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -40,9 +40,6 @@ vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/context", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/logging/context")>();
return { ...actual, annotate: vi.fn() };
@@ -22,9 +22,6 @@ vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserDashboardWidgets: vi.fn(),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -26,9 +26,6 @@ vi.mock("@/lib/auth/session", () => ({
vi.mock("@/lib/logging/transports", () => ({
emitIfSampled: vi.fn(),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -18,9 +18,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
// The gate resolver runs for real; only its data sources are stubbed, so the
// module state is driven the way production drives it.
vi.mock("@/lib/db", () => ({
@@ -92,10 +92,6 @@ vi.mock("@/lib/analytics/mood-series", () => ({
})),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -58,10 +58,6 @@ vi.mock("@/lib/logging/transports", () => ({
emitIfSampled: vi.fn(),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/context", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/logging/context")>();
return {
@@ -29,10 +29,6 @@ vi.mock("@/lib/auth/audit", () => ({
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -37,10 +37,6 @@ vi.mock("@/lib/auth/audit", () => ({
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
@@ -98,9 +98,6 @@ vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -72,9 +72,6 @@ vi.mock("@/lib/auth/audit", () => ({
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -96,9 +96,6 @@ vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -76,9 +76,6 @@ vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -46,9 +46,6 @@ vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -55,9 +55,6 @@ vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -68,9 +68,6 @@ vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -48,9 +48,6 @@ vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -133,9 +133,6 @@ vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -27,10 +27,6 @@ vi.mock("@/lib/logging/transports", () => ({
emitIfSampled: vi.fn(),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn(),
}));
@@ -37,9 +37,6 @@ vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -9,10 +9,6 @@ vi.mock("@/lib/auth/audit", () => ({
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -11,9 +11,6 @@ import { NextRequest } from "next/server";
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn(),
isModuleEnabled: vi.fn(),
-3
View File
@@ -13,9 +13,6 @@ import { NextRequest } from "next/server";
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn(),
isModuleEnabled: vi.fn(),
@@ -38,10 +38,6 @@ vi.mock("@/lib/modules/gate", () => ({
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -38,10 +38,6 @@ vi.mock("@/lib/modules/gate", () => ({
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -36,9 +36,6 @@ vi.mock("@/lib/db", () => ({
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -20,9 +20,6 @@ vi.mock("@/lib/db", () => ({
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -47,9 +47,6 @@ vi.mock("@/lib/idempotency", () => ({
fn(...args),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -25,9 +25,6 @@ vi.mock("@/lib/illness/gate", () => ({
requireIllnessEnabled: vi.fn(),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -31,9 +31,6 @@ vi.mock("@/lib/idempotency", () => ({
fn(...args),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -10,9 +10,6 @@ vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -18,9 +18,6 @@ vi.mock("@/lib/logging/signal", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/logging/signal")>()),
emitSignal: vi.fn(),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -11,9 +11,6 @@ vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
Loaded 100 of 555 files, more files were not shown because too many files have changed in this diff. Show more