Database and the write-only audit log
The API needs PostgreSQL. Which version and where it runs (managed service or your own server) is up to you; the schema uses the built-in gen_random_uuid() and sha256() functions, so you need PostgreSQL 13 or newer. (Development and tests use an embedded PostgreSQL 18.)
What’s stored
Section titled “What’s stored”| Where | What |
|---|---|
| Core tables | Organizations, users (password hashes for owners and super admins), sessions, one-time email tokens (only their hashes), processed Stripe event IDs, signing requests, recipients and fields |
envelope_files |
The PDF of every document sent for signature, and the signed PDF once complete, stored in the database |
audit.events |
The activity log |
Files opened from or saved to OneDrive and Teams are not stored. Nothing is deleted automatically: signing requests and their PDFs are kept until you remove them.
Migrations
Section titled “Migrations”Migrations are plain SQL files in apps/api/migrations/core (application tables) and apps/api/migrations/audit (the activity log). Applied migrations are recorded in a schema_migrations table in each database.
npm run migrate -w @pdfeditor/api- Development: the API applies pending migrations every time it starts.
- Production: run
npm run migrateas a release step, or setMIGRATE_ON_START=true.
Hardening the audit log
Section titled “Hardening the audit log”By default the activity log is written through the main connection. For production, give the API credentials that can only insert into the log, and only read from it.
-
Decide where the log lives: in the main database (simplest), or in a separate PostgreSQL database for stronger separation.
-
Run the migrations as the owner of each database. If the audit log is separate:
Terminal window AUDIT_DATABASE_URL=postgres://owner:...@audit-db/pdfeditor_audit \npm run migrate -w @pdfeditor/api -
As the owner of the audit database, run
apps/api/sql/audit-roles.sqlafter replacing its placeholder passwords. It creates:audit_writer: may onlyINSERTintoaudit.events(and use its sequence),audit_reader: may onlySELECTfrom it and run the verification functions,
and revokes everything else on the
auditschema fromPUBLIC.Terminal window psql "postgres://owner:...@audit-db/pdfeditor_audit" -f apps/api/sql/audit-roles.sql -
Configure the API:
Terminal window AUDIT_DATABASE_URL=postgres://audit_writer:...@audit-db/pdfeditor_auditAUDIT_READ_DATABASE_URL=postgres://audit_reader:...@audit-db/pdfeditor_audit -
For future migrations, run
npm run migratewithAUDIT_ADMIN_DATABASE_URLset to an owner connection for the audit database.
How the write-only log works
Section titled “How the write-only log works”- Append-only by trigger.
UPDATE,DELETEandTRUNCATEonaudit.eventsraise audit.events is append-only, whoever runs them, as long as the triggers are in place. - Hash chain. A
BEFORE INSERTtrigger takes an advisory lock, reads the previous row’s hash, and sets the row’sid, timestamp (clock_timestamp()),prev_hashandhash. The hash is SHA-256 over the previous hash and all the row’s fields, so the chain order is the commit order even with concurrent writers. The first row links to 64 zeros. The trigger isSECURITY DEFINER, so the insert-only role can chain rows without being able to read the table. - Values set by the database. The API can’t choose an event’s ID, time or hashes; the trigger overwrites them.
- Verification.
audit.verify_chain()recomputes every row’s hash and compares eachprev_hashwith the previous row, returning rows that fail as hash mismatch (row was modified) or chain broken (row missing or reordered). Super admins run it with Verify integrity, orPOST /api/admin/audit/verify. See Audit-log integrity.
Backups
Section titled “Backups”Back up the main database (it holds every document sent for signature) and the audit database, if separate. The backup method and schedule are your decision; test restores. SESSION_SECRET should be kept with your secrets, not in backups of the database.