diff --git a/alembic/versions/0039_audit_events.py b/alembic/versions/0039_audit_events.py
new file mode 100644
index 0000000..9737b79
--- /dev/null
+++ b/alembic/versions/0039_audit_events.py
@@ -0,0 +1,41 @@
+"""audit_events: the audit log
+
+Revision ID: 0039
+Revises: 0038
+Create Date: 2026-10-08
+
+Credential events were only written to the app log, which is not queryable and is
+gone at the container's log rotation (#2939 §5). This keeps them in the database for
+`audit_retention_days`, for admins to read in Settings → Activity.
+
+## Downgrade
+
+Drops the table and every event in it.
+"""
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy.dialects.postgresql import UUID
+
+revision = "0039"
+down_revision = "0038"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "audit_events",
+ sa.Column("id", UUID(as_uuid=True), primary_key=True),
+ sa.Column("at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
+ sa.Column("event", sa.Text(), nullable=False),
+ sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
+ sa.Column("email", sa.Text(), nullable=True),
+ sa.Column("address", sa.Text(), nullable=True),
+ sa.Column("detail", sa.Text(), nullable=True),
+ )
+ op.create_index("ix_audit_events_at", "audit_events", ["at"])
+
+
+def downgrade() -> None:
+ op.drop_index("ix_audit_events_at", table_name="audit_events")
+ op.drop_table("audit_events")
diff --git a/docs/public-hosting.md b/docs/public-hosting.md
index 3161c0c..f5b6716 100644
--- a/docs/public-hosting.md
+++ b/docs/public-hosting.md
@@ -4,7 +4,7 @@ Inkwell is built to run on a LAN and works fine there with no ceremony. Exposing
it changes the threat model: anyone can now reach the login form, and any account is
one guessed password away from someone's whole note history.
-This is what the app does about that on its own, and the four things it cannot do for
+This is what the app does about that on its own, and the three things it cannot do for
you.
## Do these five things first
@@ -154,6 +154,24 @@ page shows how much an account uses.
`max_attachment_mb` still caps any single file.
+## Activity
+
+**Settings → Activity** lists what has happened to accounts, newest first, with the
+address each came from:
+
+- sign-ins and failed sign-ins;
+- accounts created and sign-ups refused;
+- password changes, resets and reset links;
+- devices linked and unlinked;
+- invites made and revoked.
+
+Events are kept for `audit_retention_days` (Settings → Security, 90 by default; 0
+keeps them forever). Admins only.
+
+A throttled attempt (429) is not listed, so that a flood of them costs no database
+writes. Throttle trips are in the app log, with every event above:
+`docker compose logs app`.
+
## What it does not do
Know these before you decide who gets an account.
@@ -165,11 +183,6 @@ Know these before you decide who gets an account.
- **An admin who forgets their own password**, with email off and no other admin,
still needs a hand on the database.
- **No second factor.** A password is the whole of it.
-- **No audit TABLE.** Credential events — sign-ins, failures, throttle trips, new
- accounts, device tokens issued — are written to the application log and readable
- with `docker compose logs app`, which is enough to see whether anyone is knocking.
- They are not queryable, not retained beyond the container's log rotation, and not
- attributable after the fact.
None of these are hard blockers for an instance whose accounts are you and people you
know. They are the reason not to hand out open registration to strangers.
diff --git a/frontend/src/components/ActivityList.vue b/frontend/src/components/ActivityList.vue
new file mode 100644
index 0000000..15fb1f6
--- /dev/null
+++ b/frontend/src/components/ActivityList.vue
@@ -0,0 +1,128 @@
+
+
+
+
+