Skip to content

feat: account activity log and signed-in devices - #50

Merged
leviFrosty merged 4 commits into
mainfrom
account-activity
Oct 18, 2026
Merged

leviFrosty merged 4 commits into
mainfrom
account-activity

Conversation

@leviFrosty

@leviFrosty leviFrosty commented Oct 16, 2026 •

Copy link
Copy Markdown
Owner

Summary

The account now has a security history the User can see in the app, along with its list of signed-in devices (witness-work ADR 0028). It records high-level actions that matter for security: which devices signed in, sessions that ended, backups deleted or downloaded, the account moved to a new phone, and suspensions. It doesn't record ordinary views.

Stacked on #49. Review that PR first; this diff is only the activity log. App PR: leviFrosty/witness-work#777.

Routes (Bearer)

Route What
GET /accounts/v1/activity?limit=&before= The account's entries, newest first, with a nextCursor
GET /accounts/v1/sessions Live sessions; current marks the caller's
POST /accounts/v1/sessions/revoke { sessionId }: sign a device out

Revoke is a POST, so the session id stays out of the URL. A session that belongs to another account, or doesn't exist, is 404 not_found. All three routes count against the read edge tier and work for suspended accounts.

What's recorded

Kind When
account.created Registration
session.opened A device signs in (attested session/open)
session.signed_out DELETE /sessions/current
session.revoked Another device signed this one out
session.replaced The 10-session cap ended the oldest
session.refresh_reused A replaced refresh token came back after its grace minute. The app flags it. Never recorded for forged or malformed tokens.
handoff.sent The account key was sent to a new phone
backup.deleted One slot, or all of them
backup.downloaded Once per slot, session, and day
account.suspended / account.reinstated Status only, never the reason
buddies.inbox_linked / buddies.deleted A new inbox link; Buddies deleted

Each entry is written in the same D1 batch as its action wherever the action is in D1. Backup records before the R2 change. Handoff and buddies.deleted record after their Durable Object accepted the change, and a failed write is reported through captureFailure.

Privacy

  • Entries never include IP addresses, location, user agents, content, or anything about buddies. Writer ops log nothing, so nothing ties one account to another account's inbox.
  • Each entry carries its session's platform and the sealed device label the app sends with session/open. The server can't read the label; the app decrypts the device name.
  • The hourly cron deletes entries older than 400 days.
  • Migration: migrations/accounts/0002_account-activity.sql (table account_activity). The tag deploy applies it.

Testing

Run on d798ec4 (rebased onto #49's 1b825cc):

  • pnpm test: 1,053 passed.
  • pnpm exec tsc --noEmit: clean.
  • Both dry-runs build.
  • verify-ww-api loop: doctor ok; pnpm test:e2e 77 passed with 3 skipped, including the new activity.e2e.test.ts; pnpm fuzz:buddies ok.
  • A unit test sends three forged refresh tokens (right session id, bad MAC). Each gets 401, nothing is logged, and the session still refreshes afterwards.
  • src/test/memoryD1.ts: failQueries now throws synchronously from rows(), the way a failing statement inside a real D1 batch does. Before, a failure inside a batch was silently skipped.
  • Dev worker: migration 0002 applied to ww-accounts-dev, and this branch deployed to ww-proxy-dev. The activity, accounts, and bearer Buddies e2e suites run against the deployed worker: 24 passed, 5 skipped. The skips are the tests that need the local launcher's KV or caller IPs.

Open questions

  1. Signing a device out isn't final: a device that still holds the account key can open a new session.
  2. Revoking another device needs only a bearer token, with no account-key step-up.
  3. Device names in the label are model names (for example "iPhone 16 Pro").
  4. A handoff entry names only the old phone. The new phone appears as its own session.opened.
  5. The privacy policy should mention the activity history.

Every security-relevant account action writes an entry to a new
account_activity table (witness-work ADR 0028), in the same D1 batch as
the action where it is one: account created, session opened, signed out,
revoked from another device, replaced by the 10-session cap, ended by a
reused refresh token, handoff key sent, backup slot deleted or downloaded,
and suspension changes (status only). Entries copy the session's sealed
device label so they outlive the session row, and the hourly cron prunes
them after 400 days.

New bearer routes: GET /accounts/v1/activity (cursor pages, newest first),
GET /accounts/v1/sessions (live sessions, the caller's marked), and
POST /accounts/v1/sessions/revoke { sessionId } (POST keeps the id out of
the URL).
buddies.inbox_linked is written in linkInbox's own D1 batch, only when
the link is new (an account that loses a race logs nothing), naming the
bearer session that linked it. buddies.deleted is recorded at the route
once the inbox DO accepted inbox/delete: the token's account and session
for a bearer request, the inbox's linked account for a legacy one. Never
in the DO's hot path, and writer ops log nothing, so nothing ties a
writing account to another account's inbox.
@leviFrosty
leviFrosty changed the base branch from account-sessions to main October 18, 2026 07:15
@leviFrosty
leviFrosty merged commit 09abf14 into main Oct 18, 2026
1 of 2 checks passed
leviFrosty added a commit that referenced this pull request Oct 18, 2026
- feat: Supporter status on the server account, kept by RevenueCat webhooks (#51)
- feat: account activity log and signed-in devices (#50)
- feat: attested account sessions with bearer tokens for Buddies and Notes Import (#49)
- feat: move attestation to /attestation/v1 and add readable link paths (#47)
- feat: use readable payload and signature names in signed envelopes (#46)
- docs: trim one-time setup steps from the agent guide (#48)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant