mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
Merge branch 'feat/v147-dbroles' into release/v1.47.0
This commit is contained in:
commit
775933b7d5
555 files changed
+1382
-2389
No files matched your search
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 "$@"
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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(() => ({})),
|
||||
|
||||
@@ -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 })),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
Reference in new issue
Block a user