Compare commits

...

67 Commits

Author SHA1 Message Date
aa7387fca8 fix(docker): also copy /app/node_modules/.pnpm so workspace symlinks resolve
Previous fix copied packages/shared/node_modules and
packages/db/node_modules into the runtime stage, but pnpm's layout
makes those entries SYMLINKS into /app/node_modules/.pnpm/<dep>@<ver>/
node_modules/<dep> where the real package files live. Without
.pnpm/ in the runtime image, every symlink dangled and require
still threw 'Cannot find module rrule'.

Add a third COPY for /app/node_modules/.pnpm. Use --link so the
docker layer storage deduplicates against the build layer.

Same root cause class for any pnpm-managed monorepo deploy: ship
.pnpm/ together with the leaf node_modules dirs that point into it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:33:46 +08:00
7d3d34af7f fix(docker): copy workspace packages' node_modules into web runtime
Web container booted but every page logged:
  Error: Cannot find module 'rrule'
  at /app/packages/shared/dist/rrule.js

Next's standalone tracer copied packages/shared/dist/*.js into the
standalone output but didn't follow their require("rrule") chain
into packages/shared/node_modules — pnpm's symlinked dep layout
isn't always followable by the tracer. Same risk for any other
shared/db transitive dep (luxon, cron-parser, drizzle-orm, pg,
bcryptjs).

Copy packages/shared/node_modules and packages/db/node_modules into
the runtime stage explicitly so the standalone require chain
resolves. Bot Dockerfile already ships full node_modules so it's
unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:31:14 +08:00
b47c0409ae fix(docker): pass placeholder DATABASE_URL/AUTH_SECRET during web build
Setting the route's `dynamic = "force-dynamic"` only stops Next from
calling the GET handler — not from evaluating the route module. The
bundled route.js inlines lib/db.ts's top-level
createClient(env.DATABASE_URL); next build's "Collecting page data"
pass imports the bundle, env access fires, and Zod throws because
the build container has no DATABASE_URL.

Same story for AUTH_SECRET via actions/auth.ts.

Export both as placeholders inside the RUN layer. pg.Pool is lazy
(stores URL, only connects on first query) and AUTH_SECRET only
matters at sign/verify time, so neither placeholder runs during
build. Each Dockerfile RUN is its own shell — nothing leaks into
the runtime image.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:23:49 +08:00
f08b2bcb13 fix(web): force-dynamic on /api/qr/[accountId] to unblock docker build
next build's "Collecting page data" pass kept invoking the GET
handler at build time, which hit the env proxy with no
DATABASE_URL and threw ZodError again — this time on /api/qr,
since /api/events was already force-dynamic.

Mark the qr route force-dynamic + runtime=nodejs so Next skips the
build-time call. Same pattern as /api/events.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:20:23 +08:00
58b249097a security: untrack envs/ENV (leaked DB password + AUTH_SECRET)
Commit 6893ca6 accidentally pushed envs/ENV — a real env file with
DATABASE_URL (including the wabot DB password) and AUTH_SECRET.
The file's gone from HEAD now; the secrets are STILL in git history
at 6893ca6 and must be rotated:

  1. Postgres role 'waBot' password — change on the wabot DB and
     update DATABASE_URL on every deploy that uses it.
  2. AUTH_SECRET — regenerate with scripts/gen_auth_secret.sh and
     bump OPERATOR_TOKEN_VERSION at the same time so every existing
     session cookie also invalidates.

.gitignore now ignores everything in envs/ except .env.example so
the same shape of leak (envs/<anything>) can't recur.

If you'd rather scrub the secret from history outright, the only
clean option is a force-push that rewrites 6893ca6:
  git filter-repo --invert-paths --path envs/ENV
  git push --force origin master
That destroys the existing remote SHA, which other clones will need
to reset to. Defaults to 'rotate, don't rewrite' unless explicitly
asked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:14:44 +08:00
6893ca6ba9 fix(web): lazy-parse env so docker build doesn't crash on missing DATABASE_URL
`scripts/publish.sh` failed during the web image build at
"Collecting page data" with:
  ZodError: DATABASE_URL: Required

next build walks every route module including api/events/route.ts,
which imports env from @/env. The previous shape ran
envSchema.parse(process.env) at module top level, so the parse fired
inside the build container where DATABASE_URL deliberately isn't set.

Wrap the parse in a Proxy that resolves on first property access.
The build's page-data pass doesn't read any env property, so the
parse never runs at build time. Runtime callers (db.ts, media.ts,
api/events/route.ts) hit the proxy on first use and get the same
strict Zod validation as before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:13:30 +08:00
49f5c16b19 fix(docker): reuse node user instead of creating gid 1000 — unblocks publish
Bot + web Dockerfiles tried to addgroup -g 1000 app on top of
node:22-alpine, which already ships a `node` group at gid 1000.
Build aborted at runtime stage 5/5 with:
  addgroup: gid '1000' in use

Drop the addgroup/adduser pair on both images and just chown +
USER node onto the existing node user. Same hardening posture
(non-root, no shell login on the runtime image), one less moving
part. The compose dev overlay's `user: ${HOST_UID:-1000}:${HOST_GID:-1000}`
matches uid 1000 either way.

Plus:
- New docker-compose.portainer.yml: pulls cm-whatsapp-{bot,web}
  from gitea.04080616.xyz/yiekheng instead of building from
  source. Named volumes for sessions / media so the operator
  doesn't need shell access to manage state. Healthchecks on
  both services so Portainer's UI surfaces unhealthy containers.
- New docs/deploy-portainer.md walking through registry auth,
  stack creation, env vars, migrations, first sign-in, future
  redeploys, rollbacks.
- README links the Portainer guide alongside the dev path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:09:12 +08:00
954d382b54 docs(env): refresh envs/.env.example for v1 + publish.sh
- Drop SEED_OPERATOR_TELEGRAM_ID (legacy from the Telegram era).
- Add SEED_OPERATOR_USERNAME + a comment pointing to
  scripts/set-password.sh as the bootstrap path.
- Add OPERATOR_TOKEN_VERSION as the documented kill switch for the
  AES-GCM session cookie.
- Document AUTH_SECRET more explicitly: refuse to leave blank, and
  point at scripts/gen_auth_secret.sh as the generator.
- Add the bot fan-out tuning trio that's been in env.ts but not in
  the example: BOT_FIRE_CONCURRENCY / BOT_GROUP_CONCURRENCY /
  BOT_MAX_SEND_PER_MINUTE with the same comments as the schema.
- Add a Docker Registry section for scripts/publish.sh:
  DOCKER_IMAGE_TAG and CM_IMAGE_PLATFORMS, mirroring the
  cm_bot_v2 .env.example shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:03:27 +08:00
31cf845030 feat(scripts): real publish.sh — buildx push of bot + web images
Was a stub ('not yet implemented (see plan 4)'). Modeled directly on
cm_bot_v2/scripts/publish.sh:
  - Same registry prefix gitea.04080616.xyz/yiekheng.
  - Same NO_SUDO toggle + docker info + buildx preflight diagnostics.
  - Same auth path notes (docker login on the same effective user
    that runs the build).
  - Same buildx --push flow with CM_IMAGE_PLATFORMS / BUILD_ARGS
    overrides and tag from $1 / DOCKER_IMAGE_TAG (default latest).

This repo's services are bot + web (tools is dev-only and not
published). Resulting tags:
  gitea.04080616.xyz/yiekheng/cm-whatsapp-bot:<tag>
  gitea.04080616.xyz/yiekheng/cm-whatsapp-web:<tag>

Mark executable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:02:13 +08:00
ea7d07b2c8 perf(db): composite index (account_id, name) + hide archived groups
Two related follow-ups for the 3 000+ groups-per-account scale path:

1. New B-tree index on whatsapp_groups (account_id, name) (migration
   0014). Covers the groups list page's
   `WHERE account_id=? ORDER BY name ASC LIMIT 200` query so PG
   streams pre-sorted from the index instead of pulling all rows
   then sorting. The unique (account_id, wa_group_jid) was the only
   prior B-tree on this table; it backed the WHERE prefix but not
   the ORDER BY.

2. listGroupsForAccount now filters `is_archived = false` in both
   the search and the no-search branch. Soft-archived groups
   (set when group-sync sees them disappear from the live
   participant list, or when an operator unpairs the account) used
   to leak into the wizard picker, letting operators pick a group
   the bot can no longer reach. Archived rows still exist in DB so
   reminders that target them keep working; a re-pair flips them
   back via the on-conflict upsert.

README "Deferred" entry for the composite index removed (it's
shipped). Search-as-you-type in the wizard picker stays deferred.

482 web + 88 bot tests still green; typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:57:17 +08:00
c906a9fa3a docs: refresh README + add docs/runbook.md for v1 sign-off
- README rewritten to reflect v1 reality: auth bootstrap, AES-GCM
  cookies, three-layer rate limit, duplicate-pair detection,
  logout-before-delete, journal-monotonic guard, the new test
  counts (482 web + 88 bot), and the right scripts (set-password,
  create-user). Drops the telegram-era 'Status' paragraph and the
  earlier 'Auth deferred' bullet.
- docs/runbook.md is a new manual end-to-end smoke checklist
  organised by section: pre-flight, auth bootstrap, user
  management, account pairing (incl. back→re-pair + duplicate-phone
  regression checks), reminder lifecycle (incl. triple-fire +
  reschedule regression checks), account lifecycle, sign-out +
  token-version kill, cross-tenant isolation, log sweep, plus a
  troubleshooting cheatsheet.

Closes P3/T23 + P3/T24.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:45:03 +08:00
47d7c53fda feat(db): auto-guard against drizzle journal-skip regression
Twice now we've shipped a deploy that 500'd in production because
drizzle silently skipped freshly-generated migrations whose `when`
timestamps were older than a prior manually-bumped entry (0010/0011
in 1b7f553, then 0012/0013 in 2731888). Both times pnpm migrate
printed "Migrations applied." while the live DB schema lagged the
code's expectations.

Three layers of defence:

1. packages/db/src/journal-check.ts — pure helpers
   - assertJournalMonotonic(entries): walks idx-sorted entries and
     returns each one whose `when` <= the previous entry's `when`,
     plus a suggested `when` value to bump it to.
   - formatJournalViolations(result): renders an actionable
     multi-line message that points at the offending file path.

2. packages/db/src/migrate.ts — pre-flight
   Reads _journal.json BEFORE handing it to drizzle.migrate(). If
   the journal is non-monotonic, it prints the violations + bump
   instructions and exits with code 2. No more "Migrations applied."
   while silently skipping.

3. apps/web/src/test/drizzle-journal-monotonic.test.ts — CI guard
   Reads the committed _journal.json at test time. CI fails on the
   PR before the bad commit can ship. Imports the helper through a
   new "./journal-check" subpath export on @cmbot/db so the test
   doesn't rely on a deep path into the package.

Together: a bad commit fails CI; if it somehow got through, migrate
itself refuses to run; if migrate is bypassed, the previous deploy's
schema stays intact (drizzle wouldn't have skipped anything in any
case where the journal is monotonic).

Web suite 480 → 482 tests, all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:40:11 +08:00
27318888bc fix(db): bump 0012/0013 journal timestamps so drizzle applies them
Same regression we hit with 0010/0011 in commit 1b7f553: drizzle's
migrator skips entries whose 'when' is older than the latest applied
migration's recorded created_at. 0012's when (1778412502601) and
0013's when (1778418181504) were generated BEFORE 0011's manually-
bumped when of 1778464002000, so 'pnpm migrate' kept reporting
'Migrations applied.' while silently skipping both. Result: web
500'd on every authenticated request — getCurrentUser hit
'column "email" does not exist' because the operators schema in
code expected the column 0013 was supposed to add.

Bumped 0012 to 0011.when + 1s and 0013 to + 2s, re-ran migrate.
operators now has the email column, reminders.delivery_window_end_hour
default is now 24 (the off-sentinel), and the web container is back
up with no 500s.

Note for future: the journal timestamps must be strictly monotonic
across the entries[] order. The fix in commit 1b7f553 didn't future-
proof us against the next batch. Keeping a long-term automated guard
against this is a TODO.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:35:52 +08:00
b988d117a3 fix(db): restore whatsappGroups declaration that perf-notes comment ate
A doc-comment refactor in 08f2c0f silently swallowed the
'export const whatsappGroups = pgTable(...)' line and its inner
'{' opening brace, leaving the column properties at top level.
Bot's typecheck happened to pass on a stale build, but the web
container's startup pnpm --filter @cmbot/db build failed with
'Expression expected' / ';' expected at lines 71-77.

Re-add the missing 4 lines. Web is back up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:34:27 +08:00
d731390c9d fix(web): unpair soft-archives groups instead of DELETE — same FK abort
Web error log showed unpairAccountAction failing with the same FK
violation as group-sync: deleting whatsapp_groups rows that had been
used in reminders blew up reminder_targets_group_id_whatsapp_groups_id_fk
and aborted the unpair.

Switch to UPDATE … SET is_archived=true. The bot's group-sync upsert
already flips is_archived back to false on a re-pair (added in the
group-sync companion fix in the previous commit), so behaviour is
end-to-end equivalent to the old delete + repopulate path without
the FK fragility.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:33:03 +08:00
08f2c0fd27 fix(bot): group-sync soft-archives instead of DELETE — fixes FK abort
Web error log showed:
  update or delete on table "whatsapp_groups" violates foreign key
  constraint "reminder_targets_group_id_whatsapp_groups_id_fk" on
  table "reminder_targets"

Repro: pair finishes, post-open syncGroupsForAccount runs and tries
to DELETE rows for groups no longer in the live participant list.
If any of those groups had been used in a reminder its row is FK-
referenced from reminder_targets, so the DELETE aborts the whole
transaction and the operator's pair completion appears to fail.
With 3 000+ groups per account this hits anyone with even a small
reminder history.

Switch the sweep from DELETE to UPDATE … SET is_archived=true.
Reminders that targeted the missing group keep working (operator
can choose to remove them); a future re-pair where the group
reappears flips is_archived back to false via the on-conflict
upsert. Returns archived count instead of removed count.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:30:05 +08:00
2fe8459d25 feat: duplicate-pair detection + logout-before-delete + ordering tests
Three connected bits of paired-account hygiene:

1. Duplicate-pair guard (apps/bot/src/ipc/pair-handler.ts)

   Operator scans the QR with a phone that's already linked to
   another account row → both rows would fight over the same
   WhatsApp device and sends become a coin flip. After Baileys'
   `open` event the bot now queries siblings of the same operator,
   passes them through findDuplicateExistingAccount() (a pure
   helper extracted to pair-state.ts), and on a hit:
     - stops the new session (intentional; keeps the original's
       session intact)
     - scrubs the partial auth blob from disk
     - resets the row's status to unpaired and clears phone_number
     - emits a new session.duplicate event with the existing row's
       label so PairLive can render a clear message
   New PairLive 'duplicate' phase: amber icon + "Phone already
   linked, unpair the existing account first or scan with a
   different phone".

2. Logout-before-delete (apps/bot/src/ipc/unpair-handler.ts +
   apps/bot/src/whatsapp/session-manager.ts)

   Delete used to call account.unpair which only closes the local
   socket — the operator's phone kept showing a phantom "linked
   device" pointing at a row that no longer exists. Added:
     - new account.delete command type (web side and bot side)
     - sessionManager.logoutAndStop(): calls socket.logout() so
       WhatsApp drops the device on the server side, THEN closes
       the local socket. Best-effort; logout RPC failure doesn't
       strand the delete.
     - new handleDelete() handler that calls logoutAndStop, removes
       session files, audits, and notifies.
     - deleteAccountAction now sends account.delete instead of
       account.unpair.
   Unpair stays unchanged — re-pair-friendly, no logout.

3. Tests (bot 77 → 88, web 477 → 480)

   - findDuplicateExistingAccount: 6 cases covering match, no-match,
     self-exclusion, null/empty/whitespace handling, whitespace
     normalisation, deterministic-pick when (defensively) two
     siblings share a phone.
   - handleUnpair / handleDelete: handleDelete calls logoutAndStop
     BEFORE rm; handleUnpair never touches logoutAndStop (regression
     guard for a refactor that swaps them); audit log payload
     includes the row's label; audit lookup throwing doesn't strand
     the delete.
   - listAccounts ordering: static guard against the rename-
     reshuffles-list regression. Pins `asc(a.createdAt)` + `asc(a.id)`
     and rejects `asc(a.label)` in the function body.

Bot restarted with the new flow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:26:58 +08:00
f566e4683a feat(web): sort accounts by created_at ascending (earliest first)
Earlier accounts were ordered by label, so the list reshuffled every
time an account was renamed. Switch to created_at ASC + id ASC as a
deterministic tiebreaker. Earliest-added accounts now stay on top
where the operator added them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:19:01 +08:00
7df3ef9c31 fix(web): bump right-meta column cap a touch (max-w 34% / 9.5rem)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:17:20 +08:00
0fd581b365 fix(web): nudge right-meta column cap up (max-w 28% / 8rem)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:17:00 +08:00
f4da1dd510 fix(web): halve right-meta column cap (max-w 20% / 5.5rem)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:16:40 +08:00
50b7e61037 fix(web): tighter cap on right-meta column (max-w 40% / 11rem)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:15:18 +08:00
89c7b1a84d fix(web): cap right-meta column on reminders list so name doesn't get starved
The recurrence summary ("Every month on days 4, 6, 7, 11, 13, 14 +6
more at 11:32") rendered without truncation in the right meta column,
which had `shrink-0` + no max-width — so the column expanded to fit
the text and the reminder name on the left was forced to truncate
aggressively or wrap.

Cap the right column at max-w-[55%] on mobile / sm:max-w-[14rem] on
desktop, add min-w-0 to each row inside, and truncate every meta
span. Long recurrences now ellipsis with a hover title tooltip; the
reminder name reclaims the breathing room it should have.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:13:49 +08:00
32f87e1a92 fix(web): truncate long recurrence description with ellipsis on detail card
Switched the reminder detail recurrence line from wrap-on-overflow to
single-line truncate (...) so card height stays consistent. The full
text is exposed via the native title tooltip, and editing the
schedule shows the canonical full description in the wizard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:12:02 +08:00
e32f633e02 fix(web): wrap long recurrence description on reminder detail card
A reminder set to fire on many days of the month renders a long
description ("Every month on days 4, 6, 11, 13, 18, 20 +2 more at
11:32"). The recurrence <p> used flex items-center which kept the
icon and the text on a single non-wrapping row — the text overflowed
horizontally and the card grew wider instead of letting the text
break.

Switch to flex items-start, wrap the text in a <span min-w-0> so it
becomes a shrinkable flex item that wraps internally, and bump the
icon down by mt-0.5 to keep it baseline-aligned with the first line
of text now that items-start no longer vertically centers it.

The list-page card already used <span> for the same text and was
unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:11:09 +08:00
429ae0827f fix(web): only ONE nav item highlighted at a time + drop redundant Close
Two related bugs from the same review pass:

1. /settings/users lit up BOTH the Admin and Settings entries in the
   sidebar/drawer. The active-state check was naïve
   `pathname.startsWith(href)`, which matches every parent prefix.
   Replaced with a longest-match helper pickActiveNavKey() in
   nav-config.ts: the most-specific item wins, parents stay quiet,
   '/' only matches an exact pathname, and a strict-descendant check
   (`href + '/'`) prevents `/settingsfoo` from lighting up Settings.

2. <DialogFooter showCloseButton> on the user-row delete (and three
   other dialogs that I missed earlier) was rendering an extra outline
   "Close" button next to the operator's own Cancel + Radix's corner X.
   Stripped the prop from every remaining caller (login, dashboard
   clear-history, reminder actions-bar, settings/users delete) so each
   dialog footer shows just Cancel + the primary action.

Tests:

  - nav-config.test.ts: 7 new cases covering the longest-match contract
    — /settings/users highlights ONLY Admin, /settings highlights ONLY
    Settings, '/' is exact-match only, sibling-prefix /settingsfoo
    matches nothing, and a defense-in-depth probe asserts at-most-one
    nav highlight across a representative pathname set.

  - test/no-dialog-footer-show-close-button.test.ts: static guard that
    grep-walks every production .tsx and fails if anything passes
    `showCloseButton` to <DialogFooter>. Mirrors the existing
    no-button-wrapping-card guard so the prop can't sneak back in.
    Also self-checks the regex (matches single-line + multi-line +
    other-prop combos; ignores clean DialogFooter and same-named props
    on unrelated components).

463 → 477 web tests, all green; typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:08:40 +08:00
496f882d9c feat: split user row into 2 lines + reserve operators.email column
Layout changes (apps/web/src/app/settings/users/user-row-client.tsx):
- Row 1: username + 'you' chip on the LEFT (inline, alongside the
  username), role badge on the RIGHT.
- Row 2: action buttons (Promote/Demote, Reset, Delete) right-aligned.
- Earlier: identity stacked vertically with badge under username, and
  buttons crammed to the right of the same row.

Schema (packages/db/src/schema.ts + migration 0013):
- Added optional `email` column on operators (nullable, no NOT NULL).
  Reserved for future contact / recovery flows so today's operators
  don't need to backfill anything.
- Partial unique index on lower(email) WHERE email IS NOT NULL keeps
  duplicates out without blocking NULLs.

Migration applied to dev DB. 463 web tests still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:04:15 +08:00
3af0dc7ca7 feat(web): loosen user-row layout — more breathing room
- Card row: gap-2 -> gap-3, p-3 -> p-4
- Row inner gap: gap-2 -> gap-3 (between identity block and buttons)
- Identity block: add space-y-1.5 + leading-none on username so the
  badge row has visible separation from the username
- Badge / 'you' chip gap: 1.5 -> 2
- Button group gap: 1 -> 1.5
- CardContent space between rows: space-y-3 -> space-y-4

Pure layout — no behaviour change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:00:08 +08:00
adaf087a5f feat(web): drop '· last admin' label from user row
The Demote/Delete buttons are already disabled with proper tooltips
implied by their disabled state; the extra inline label was visual
clutter on the only-admin's own row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:44:41 +08:00
f69652d43b feat(web): AES-GCM cookies + per-username/global rate limit + origin check
Three layers of login hardening pulled together — addresses the
"don't let middleman / robot easily log in by mimicking headers"
follow-up.

1. AES-256-GCM session cookie (apps/web/src/lib/auth-cookie.ts)

   The old format was base64-encoded JSON + HMAC-SHA256 signature, so
   anyone with the cookie could read userId/role straight off the
   bytes. Switched to AES-GCM authenticated encryption: the payload
   is encrypted with a 256-bit key derived from AUTH_SECRET via
   SHA-256, a fresh 12-byte nonce is drawn per encryption (never
   reused — locked in by test), and tampering with either the IV or
   ciphertext fails the GCM auth tag → decrypt throws → null.

   Cookie format: <base64url(iv)>.<base64url(ciphertext+tag)>

   Existing cookies become invalid on deploy because the IV portion
   doesn't decode to 12 bytes — middleware bounces them to /login.
   No env bump needed; users just sign in once with the new secret.

2. Three-layer rate limit on loginAction

   Old: per-IP only. An attacker with a residential-proxy pool or
   spoofed X-Forwarded-For could hop IPs and brute one account.
   New: Promise.all of three checkRateLimit calls
     - per-IP        login:<ip>          10 / 5 min
     - per-username  login-user:<lower>  5 / 15 min
     - global        login-global        100 / min (backstop)
   First-hit wins; logger captures which limit tripped (ip / username
   / global) without telling the attacker which one.

3. Action-level Origin/Host check

   serverActions.allowedOrigins already does this at the framework
   layer; running it inside loginAction lets us log the mismatch and
   reject before bcrypt + DB. Missing Origin treated as same-origin
   (RFC: same-origin POSTs may omit it). Malformed Origin → reject.

Tests:
  - auth-cookie.test.ts updated to AES-GCM (15 tests, +4 vs HMAC):
    fresh IV per encryption, ciphertext doesn't leak userId/role,
    IV-swap rejected, ciphertext-tamper rejected, wrong-length IV
    rejected, malformed b64 doesn't throw.
  - auth.test.ts adds 7 new cases: three-layer key shape, per-username
    limit alone trips, global limit alone trips, cross-origin rejected,
    same-origin accepted, missing-Origin treated as same-origin,
    malformed-Origin rejected.

Web suite 453 → 463 tests, all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:41:49 +08:00
6942745085 test(bot): cover the reschedule corner case in scheduleReminderFire
Lock down the pre-send cancel that fixed the dropped 8:20 PM fire:

  - cancel UPDATE always runs BEFORE boss.send (regression: stately
    dedupe silently rejected the new send when a stale created job
    existed; now we tombstone the stale row first)
  - cancel scopes to state='created' only (active and completed jobs
    must survive — they're in-flight or historical)
  - cancel filters by THIS reminder's singletonKey (no cross-reminder
    cancellation)
  - boss.send still receives singletonKey + startAfter + retryLimit
  - first-time schedule (zero stale rows) still calls send
  - cancel UPDATE error degrades to "send anyway" — the handler-level
    recent-run dedupe will catch any duplicate that lands
  - boss.send returning null is surfaced (so the caller's logger
    captures jobId: null instead of silently treating it as success)

77 bot tests now (was 70).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:33:29 +08:00
2e6fbfa7a5 fix(bot): reschedule was silently dropped under stately policy
Scheduled reminder for May 10 8:20 PM never fired. Bot logs showed
"reminder.fire: scheduled" with jobId: null at 12:18 UTC — pg-boss
returned null because the queue was on policy=stately, which dedupes
sends across the (created/active/retry) state cone by singletonKey.
A previous schedule for the same reminder (next recurring fire,
created earlier) was still in 'created' state, so the new send for
today 8:20 PM hit the dedupe and was silently rejected.

Two fixes:

  1. Switch the queue policy back to 'standard' (the default) and
     force-flip any existing 'stately' queue row on boot. Standard
     lets us enqueue across reschedules.
  2. scheduleReminderFire now does a pre-send cancel: any 'created'
     job for this singletonKey is moved to 'cancelled' before the new
     boss.send. The new schedule wins; old stale jobs are tombstoned
     so the recurring/edit path produces exactly-one upcoming fire.

Duplicate-fire safety (the 'qwerd msg three times' bug) is already
covered at the handler level by the inner-mutex recent-run check
inside fireReminderInner — that's what stately was guarding against,
and the inner check works under standard too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:27:52 +08:00
991b7ae0ab fix(web): swallow click after a swipe so dragging a row does not navigate
Repro: on the reminders list, click-and-drag a card to swipe — the
shelf opened AND the wrapped Link fired its click, so the operator
landed on the reminder detail page mid-swipe.

Track a dragMoved ref in SwipeableRow that flips true when the
pointer travels past the standard 6 px tap threshold. On pointerup,
if dragMoved is set, register a one-shot capture-phase click handler
on the row container that preventDefault + stopPropagation. The
synthetic click the browser fires on pointerup is intercepted before
it reaches the anchor's onClick, so the row stays put after a swipe
and a real tap (under 6 px movement) still navigates as before.

A 350ms safety timeout strips the listener if no click materialises
(pointerup landed outside the element) so a later legitimate click
isn't accidentally swallowed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:54:31 +08:00
b293bbf142 fix(web): suppress native drag on SwipeableRow so anchors do not eat the swipe
Reminders and activity rows wrap their card in Link, and anchors are
natively draggable. As soon as the operator moves horizontally the
browser kicks into drag-link mode and the pointer events never reach
SwipeableRow handlers — left/right swipe-to-Pause/Delete silently
broke on the reminders list.

Add onDragStart preventDefault + draggable=false to the row body once
and every SwipeableRow consumer is fixed in place. The existing pan-y
touch-action stays — together they give us pointer control on both
desktop and mobile.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:52:11 +08:00
a789b61e1f fix(bot): triple-fire reminder bug — force pg-boss policy + close TOCTOU dedupe
Repro: fire a reminder, message lands 2-3 times in WhatsApp (logs
showed three 'fire-reminder: done' entries within 1.5 s for the same
reminderId).

Two interlocking root causes:

  1. The queue was created at 'standard' policy (pre-dating the
     stately rollout). pg-boss's createQueue is idempotent and DOES
     NOT update the policy on an existing queue row, so re-deploying
     the code that requested policy=stately silently kept the
     standard policy. Standard accepts duplicate enqueues with the
     same singletonKey — three reminder.fire jobs for the same
     reminderId could all land at once.

  2. The handler-level recent-run dedupe was TOCTOU. The check ran
     OUTSIDE the per-account mutex, so three concurrent invocations
     all read 'no recent run', then queued up on the mutex one at a
     time and each INSERTed a fresh run + sent the message.

Fixes:

  - registerReminderJobs now forces the queue policy via direct SQL
    (UPDATE pgboss.queue SET policy = 'stately' WHERE name = ...
    AND policy <> 'stately') on every boot. Idempotent + survives
    pre-existing standard-policy rows.
  - fireReminderInner re-checks for a recent run AFTER the mutex is
    held but BEFORE the INSERT. By that point any concurrent winner
    has already inserted, so the duplicate sees the row and bails
    cleanly.

New test in fire-reminder.test.ts (the TOCTOU repro): outer check
returns no recent run, inner check returns a freshly-inserted one,
asserts the mutex was acquired but the second findFirst was hit
(i.e. we got past the outer check and the inner check stopped us).

Verified live: pgboss.queue.policy is now 'stately' for reminder.fire.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:43:41 +08:00
e800882d15 fix: 'Pause sending by' is off by default everywhere
The optional 'Pause sending by' deadline was defaulting to 18 (= 6 PM)
in three places:
  - reminders.delivery_window_end_hour schema default (NOT NULL DEFAULT 18)
  - createReminderAction / editScheduleAction fallback when the field
    is missing on the input
  - the Zod refine validator's secondary fallback

Net effect: any reminder created before this change has 18 in the DB,
so the edit form's checkbox flips ON automatically (the wizard treats
'value !== undefined && value !== 24' as 'opted in'). The wizard's
own create flow always sends 24 explicitly when the box is unchecked
— but legacy / direct API payloads + the schema default for older rows
don't carry that intent through.

Switch every default to 24 (the off-sentinel the wizard already uses)
so the optional toggle stays off until the operator ticks it. New
migration 0012 also backfills existing rows from 18 → 24 so editing
old reminders no longer auto-checks 'Pause sending by'.

Tests in when-form-deadline.test.tsx already lock in the UI contract
(off when initialDeliveryEndHour is undefined or 24, on for any other
value). No assertion changes needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:30:09 +08:00
5c48e0e85f fix(web): wire Refresh Groups button to syncGroupsAction with live SSE refresh
The button was a placeholder that submitted to a no-op server action,
so clicking did nothing. Replace with a small client component that:

  1. Calls syncGroupsAction(accountId) to pgNotify the bot.
  2. Listens for the bot's groups.synced event over SSE and
     router.refresh()es when it arrives so the new rows appear without
     a manual reload.
  3. Disables the button + shows a Syncing… label while the sync is
     in flight, with a 15s safety timeout if the bot or SSE channel
     drops so the spinner doesn't strand.

Drop the in-place <form action={async() => 'use server'}> placeholder.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:13:24 +08:00
40d788302c test(bot): cover post-pair-restart re-warming sequence
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:10:46 +08:00
d0db248460 fix(bot): re-warm pairing flag on post-pair-restart close
After a successful QR scan Baileys closes with status 515 and the
session-manager schedules a reconnect via setTimeout(stop().then(start)).
That cleanup stop emits a SECOND close event which arrived at our
pair-handler listener with warmingUp already cleared (the first qr
cleared it). The decision then resolved to 'treat-as-timeout',
detaching the listener and pushing session.timeout to the UI right at
the moment the user actually paired successfully — pairing then
silently completed in the DB but the UI never got session.connected.

Fix: re-arm pairingWarmingUp inside the post-pair-restart branch so
the cleanup-stop's close is swallowed too. Cleared again by the
following qr/open from the freshly-reopened socket, which then emits
session.connected to the UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:09:08 +08:00
7af7aa35d0 test(bot): cover the back→re-pair close-leak regression
Extract the pair-handler's close-event decision into a pure helper
decidePairListenerOnClose(warmingUp, restartRequired) returning one of
ignore-leaked-close / post-pair-restart / treat-as-timeout. Refactor
pair-handler to call the helper instead of the inline if-chain.

New tests in pair-state.test.ts:
- warmingUp=true → ignore-leaked-close (regression: prior session's
  close racing the new listener)
- warmingUp=true + restartRequired=true → still ignore (defense in
  depth — a stale 515 must not hand control to the reconnect path)
- warmingUp=false + restartRequired=true → post-pair-restart
- warmingUp=false → treat-as-timeout

Bot suite goes from 60 → 64 tests, all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:06:39 +08:00
68668ef2cd feat(web): footer reads 'Signed in as <username>' with italic name
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:04:39 +08:00
fe8e14b7a0 fix(bot): swallow leaked close from previous pairing attempt
Repro: scan QR window once → click Back → click Pair again → instantly
see 'Pairing timed out' (sometimes for several attempts in a row).

Root cause: when handleStartPairing hits a still-running session it
calls await sessionManager.stop(accountId) and immediately attaches a
fresh listener. session.close() resolves before sessionManager broadcasts
the close event to listeners (handleEvent has several awaits between
close arriving and the listener fan-out). The new listener was already
attached by then and saw the OLD session's close as if it were the new
session timing out — flipped the row to unpaired and pushed
session.timeout to the UI.

Fix: track a per-account 'pairingWarmingUp' Set. The new attempt enters
warming-up the moment its listener attaches; clears on the first qr or
open (those events can only come from the freshly-started session). A
close that arrives while still warming is logged and ignored. abandonPair
also clears the flag for safety.

Also drop the redundant Admin card from /settings — the Admin nav entry
on the sidebar/drawer already routes admins to /settings/users, the
extra card was duplicate UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:02:10 +08:00
dbdb156a09 fix(web): drop redundant Close button from account dialogs
DialogFooter showCloseButton was rendering a third button (Close) next
to the Cancel + 'Yes, delete' / 'Yes, unpair' pair. The corner X icon
already closes the dialog, so the extra button was just visual noise.
Drop the prop on both account-card dialogs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:57:06 +08:00
6759ca8131 fix(web): client-component delete/unpair cards on accounts/[id]
The DialogTrigger asChild + transparent button overlay pattern wasn't
emitting a clickable button in the rendered DOM under radix-ui 1.4 +
Next 16 (server component context), so Delete and Unpair both became
no-ops. Replace each with a small client component that:
  - holds open-state for the confirm Dialog
  - drives the Card itself as the click target via role='button',
    tabIndex, onClick, and Enter/Space keydown handlers
  - calls the server action through useTransition

The Card stays a div (no <button> wrapping a Card → satisfies the
existing static-guard test). Removed the unused inline Dialog imports
and unpair/delete icons from the page.

Also trim the forgot-password dialog body to one sentence per request
('don't write too detail').

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:55:29 +08:00
5d583d9194 fix(web): forgot-password dialog, settings tagline, account dialog triggers
- Login page: replace static 'Forget Password? Contact IT' line with a
  proper dialog button. Clicking opens an explanatory dialog (self-
  service reset is intentionally disabled; admins can reset from
  /settings/users or run scripts/set-password.sh).
- /settings: drop the 'cm WhatsApp Bot · self-hosted' tagline.
- /accounts/[id]: Unpair + Delete cards weren't responding to clicks.
  Restructure so the transparent <button> overlay is a sibling of
  <Card> inside a <div className='relative'> wrapper (mirrors the
  working Pair/Re-pair pattern). The previous layout placed the
  DialogTrigger inside the Card, which produced no clickable button
  in the rendered DOM under radix-ui 1.4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:50:28 +08:00
c493101b60 feat(web): password policy, sign-out, dashboard isolation, activity tweaks
Multi-fix batch from a rapid feedback round:

- Password policy mirrors Facebook's documented rule (≥6 chars + mix of
  letters with numbers/symbols). Centralised in
  apps/web/src/lib/password-policy.ts; createUserAction,
  resetUserPasswordAction, the AddUser form, and the row Reset-password
  flow all use it. CLI scripts/set-password.ts inlines the same check
  so the bootstrap path stays consistent.
- App shell adds a Sign-out button in both the desktop sidebar footer
  and the mobile drawer footer, with the signed-in username next to it.
  Layout passes username down alongside role. Theme toggle was removed
  from the shell per request — operators don't need it in the chrome.
- Dashboard stats: getDashboardStats was running findMany on reminders
  with NO operator filter, so a brand-new user saw global counts from
  every tenant. Switched to an INNER JOIN on whatsapp_accounts so the
  card on / only counts this user's reminders. (Counts had been showing
  '1 / 1 / 3 / 5' to a fresh user — the cross-tenant leak the user
  flagged.)
- /activity drops the All tab and the Clear-history button. Default
  filter is now Success when no ?filter= is set; Partial keeps fanning
  into Paused + Failed; Skipped still merges into Archived.
- /settings drops the Display name row entirely and only shows the Role
  row to admins. Layout receives username so the shell can also surface
  it next to the Sign-out button.
- Tests: password-policy.test.ts (11 cases), updated users.test.ts to
  use policy-compliant passwords + cover letters-only / digits-only
  rejection, sidebar-footer assertion swapped from theme-toggle to the
  new Sign-out + username markup. 453 tests green; typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:46:29 +08:00
b92ead3a97 feat(web): add-user form + delete confirmation in user management
- New AddUserFormClient on /settings/users (admin-only): username +
  password + role select. Wraps createUserAction.
- UserRowClient gains an isLastAdmin prop and a confirm-dialog before
  delete. Demote and Delete are both disabled on the last remaining
  admin so an admin can't lock everyone out via the UI (server-side
  guards in users.ts already cover the API).
- Page passes isLastAdmin per row and computes adminCount once.
- Role badge uses emerald for admin / slate for user; explicit Promote
  / Demote arrows replace the bidirectional icon.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:36:03 +08:00
4ddf5c094e feat(web): admin nav entry + role-aware AppShell
- Add an Admin nav item (key 'admin', href /settings/users) with
  visibleTo=['admin'] so signed-in users with role='user' don't see it.
- nav-config exposes navItemsForRole(role) helper that filters NAV_ITEMS
  by visibleTo.
- Root layout fetches getCurrentUser() and forwards role into AppShell.
  AppShell narrows the role gate to the rendered nav (sidebar + drawer);
  /login still short-circuits to the bare header. Unknown role falls
  back to 'user' visibility (defense-in-depth).
- Settings page renders an admin-only card linking to Users so admins
  have a discoverable in-app entry point too.

Tests:
- nav-config: navItemsForRole admin/user matrix + admin entry shape.
- app-shell: admin link visible for admin, hidden for user, hidden for
  null/unauthenticated, /login bare header strips nav entirely.
- actions/auth: cookie payload encodes role=user, unknown role rejected,
  AUTH_SECRET-unset path, whitespace-only username rejected, rate-limit
  key contains client IP, unknown-user path still hits DB+bcrypt.

440 tests now (was 423).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:30:58 +08:00
797326e062 feat(web): collapse Skipped→Archived, Partial→Paused+Failed; full-width filter rows
- Activity filter tabs drop Partial and Skipped; Partial runs now appear
  under both Paused and Failed (anything that didn't fully succeed),
  Skipped runs surface under Archived (history the operator chose not
  to send). Five tabs left: All / Success / Paused / Failed / Archived.
- listActivityRuns flips skipped runs out of the default list and into
  the archived view at the SQL layer so pagination stays correct.
- Tabs row spans the full width and wraps onto a second row when the
  viewport can't fit them. Account-filter select also span full width
  on every breakpoint instead of capping at sm:max-w-xs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:26:34 +08:00
ebbbdbdfb8 fix(web): make session cookie secure flag conditional on production
Setting Secure on http://localhost cookies works in Chrome (localhost
exception) but Firefox/Safari silently drop them, so dev users hit
'redirect to /login on every click' after a 'successful' login. Switch
to secure: NODE_ENV === 'production'. Public deploy still gets
Secure-only.

Also swap the login footer copy from a CLI hint to 'Forget Password?
Contact IT' — operator-friendly, doesn't leak the bootstrap
mechanism on the public sign-in screen.

Test updated to assert secure=true under prod NODE_ENV and a new test
locks in secure=false in dev.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:19:59 +08:00
7ab51335a4 fix(compose): pass AUTH_SECRET + OPERATOR_TOKEN_VERSION to web container
The web service container only inherited NODE_ENV/DATABASE_URL/DATA_DIR/
MEDIA_DIR/WEB_PORT, so AUTH_SECRET (set in .env.development) was never
visible inside the container. Login bailed out with 'Server is not
configured for sign-in.' loginAction needs both keys to issue cookies,
and OPERATOR_TOKEN_VERSION defaults to 1 (the env-bump session
invalidator).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:17:22 +08:00
050292a282 feat(web): bare login header — only centred brand mark
The login page lived inside the authenticated AppShell, so the desktop
sidebar (with all nav items) and the mobile menu drawer were rendering
on the sign-in screen. AppShell now branches on pathname=/login and
renders a single centred header (cm + WhatsApp Bot) with no nav, plus
the form. Drops the redundant in-card title since the header carries
the brand.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:14:03 +08:00
1b7f553e24 fix(db): bump 0010/0011 journal timestamps so drizzle applies them
drizzle's migrator skips entries whose 'when' is older than the latest
applied migration's recorded created_at. 0010 (1778405570914) and 0011
(1778405817706) were generated before 0009's manually-set when of
1778464000000, so 'pnpm migrate' reported success but never ran the
auth + telegram-drop migrations against any DB whose 0009 had landed.

Bumping 0010/0011 to 0009.when + 1s/+2s makes the timestamps strictly
monotonic so future drizzle migrate runs apply them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:13:55 +08:00
b29d137c84 feat: production hardening — robots, allowedOrigins, container non-root, rate limits, CLI bootstrap
robots.ts + metadata.robots blocks indexing.
serverActions.allowedOrigins gates cross-origin Server Action posts.
Bot + web Dockerfiles add a non-root 'app' user (uid 1000) with
chmod 700 on /data/sessions.
sendTestAction grows a per-group rate limit (3/60s).
resumeReminderRunAction + cancelReminderRunAction get a per-IP
rate limit (30/10s).
.env.example documents every required key.
packages/db/src/scripts/{set-password,create-user}.ts + thin shell
wrappers in scripts/ — first admin sets their password via
./scripts/set-password.sh admin before signing in.
2026-05-10 18:05:34 +08:00
67091c294a feat(web): user-management surface (admin only)
createUserAction, setUserRoleAction, resetUserPasswordAction,
deleteUserAction — all gated by requireAdmin(). Self-demote and
last-admin guards prevent the operator from accidentally locking
themselves out. /settings/users page lists every operator with
inline Demote/Promote, Reset password, and Delete buttons. 10 unit
tests.
2026-05-10 18:01:09 +08:00
b77a9d106d feat(web): middleware gates non-allowlisted paths on session cookie
Edge-runtime check via auth-cookie.verifySession. /api/* paths get a
401 (no body) when unauthenticated; pages get a 307 to /login with
the original path encoded into ?next=. Allowlist explicitly excludes
/api/events and /api/qr — both were unauthenticated in v1.1.0 and
let an unauthenticated client snoop the entire SSE event stream and
enumerate paired account QR codes.
2026-05-10 17:57:07 +08:00
5b4787d10e fix(web): typed-routes + redirect-mock signatures in auth.ts
Next.js 16 typed-routes (experimental.typedRoutes in next.config.ts)
narrows redirect()'s parameter to RouteImpl<T>, which a runtime
string from the form can't satisfy. Cast to any with a comment for
the two redirect call sites in auth.ts.

The auth.test.ts redirectMock used `() =>` zero-arg signature, which
typescript rejected once the action started passing the path through.
Change to `(_path: string) =>` so the signature matches and the test
still passes (vitest's esbuild-transpiled run was fine; tsc caught it).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:54:59 +08:00
4f1056cdcd feat(web): /login page with username + password form
Server-rendered card-style login. Form posts to loginAction; on
failure the client renders the generic 'Invalid username or
password' error. Centred, mobile-first, autocomplete-friendly so
phone PWAs autofill from the keychain on subsequent logins.
2026-05-10 17:52:35 +08:00
cedd623466 feat(web): loginAction + logoutAction (with TDD)
Username + password verified against the operators row, bcrypt
compare regardless of user-found state for timing equivalence,
DUMMY_HASH precomputed and committed. 10/5min IP rate limit, no
password ever logged. Issues a 30-day HttpOnly+Secure+SameSite=Lax
cookie on success, redirects via safeRedirect(next). 12 unit tests
covering correct creds, wrong username, wrong password, missing
password_hash, empty/long inputs, case-insensitive match, rate-limit
trigger, no-password-leak, safe redirect, unsafe redirect, logout.
2026-05-10 17:50:41 +08:00
d236196476 feat(web): getCurrentUser / requireUser / requireAdmin helpers
Reads the session cookie from next/headers, verifies via auth-cookie,
loads the operators row, returns the shape every existing call site
expects (.id, .defaultTimezone, etc) plus the new .role and
.username. getSeededOperator stays as a thin compat shim that
delegates to getCurrentUser, so the ~12 tests that mock
@/lib/operator keep working without churn.
2026-05-10 17:46:16 +08:00
e1ba1da2de feat(web): safeRedirect helper for the login \?next= param
Falls back to / for anything that isn't a single-slash-prefixed
relative path. Locks out protocol-relative (//evil.com), absolute
(https://evil.com), and javascript: redirects. 7 tests cover the
full attacker matrix.
2026-05-10 17:44:10 +08:00
27b7a3df1f feat(web): edge-safe HMAC-signed session cookie
signSession + verifySession run on Edge runtime (Web Crypto only).
Verifier checks signature (constant-time compare), expiry, clock-skew
on iat (60s tolerance), token version vs OPERATOR_TOKEN_VERSION env,
and role-shape sanity. 11 unit tests cover round-trip plus every
rejection path attackers could probe.
2026-05-10 17:43:01 +08:00
838e129f37 chore: add bcryptjs to web + db packages
Pure-JS bcrypt for password hashing. Avoids the native-build pain
of node-bcrypt in our Alpine Docker images. Login is a rare event
so the perf gap is irrelevant for our scale.
2026-05-10 17:41:06 +08:00
46c0315559 refactor(db): drop operators.telegram_user_id (not used since v1.0)
The Telegram bot phase ended in Plan 3 — the operator now signs in
via username + password. Migration 0011 drops the legacy column +
its unique index. seed.ts no longer reads SEED_OPERATOR_TELEGRAM_ID;
docker-compose.base.yml swaps the env to SEED_OPERATOR_USERNAME
(default 'admin'); .env.development follows. Settings page shows
'Username' instead of 'Operator ID'. Auth-and-prod-hardening plan
doc updated to drop the synthetic telegram_user_id from the
create-user CLI script and createUserAction insert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:39:46 +08:00
a37b36196d feat(db): add username + password_hash to operators
Migration 0010 widens the existing operators table for username +
password auth. Backfills 'admin' on the seed row so the NOT NULL
constraint succeeds; password_hash stays nullable so the operator is
forced to set one via scripts/set-password.sh before they can sign in.
Adds a unique index on lower(username).

seed.ts also picks up the new username field (defaults to 'admin' so
re-running scripts/db.sh seed stays idempotent against the backfilled row).
2026-05-10 17:35:01 +08:00
477e09f645 docs: implementation plan — auth + production hardening
10 tasks, TDD-shaped, executable by superpowers:subagent-driven-development.
~50 unit tests across auth-cookie / safe-redirect / auth helpers /
loginAction / middleware / user-management actions, covering brute-
force, cookie tampering, replay, expiry, fixation, open redirect,
timing-equivalence on user-not-found, rate-limit trigger, no-
password-leak in logs, role gates, last-admin / self-demote guards,
and the unauth-API regression for /api/events + /api/qr.

Plan honours the project's .gitignore policy of keeping
.env.development committed; ships .env.example for documentation
instead of forcing repo-level removal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:19:20 +08:00
feffe419db docs: design spec — auth + production hardening for v1.1.x → v1.2.0
Drives the work that closes the v1.1.0 production-readiness audit
findings: username + password + role auth on the web app, gated
SSE / QR endpoints, robots/noindex, env hygiene, container non-
root, and rate limits on the four currently-naked Server Actions.

Auth design highlights:
* Roll-our-own session cookie (no NextAuth) — bcrypt password +
  HMAC-SHA256 signed cookie; edge-runtime middleware verifies on
  every request; defense-in-depth requireUser / requireAdmin in
  every Server Action.
* Username + password + 2-role model (admin / user). Schema
  migration adds username + password_hash to existing operators
  table.
* CLI bootstrap (scripts/set-password.sh) sets the first admin's
  password before going live; user management UI gates everything
  else.
* OPERATOR_TOKEN_VERSION env var as a global session-invalidation
  lever.
* 38 unit tests covering brute-force / cookie tampering / replay /
  expiry / fixation / open redirect / timing leak / rate limit /
  origin-allowlist / unauth API regression / role gates / self-
  demote and last-admin guards.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:09:46 +08:00
102 changed files with 13551 additions and 466 deletions

View File

@ -4,7 +4,7 @@ SESSIONS_DIR=/data/sessions
MEDIA_DIR=/data/media MEDIA_DIR=/data/media
BOT_HEALTH_PORT=8081 BOT_HEALTH_PORT=8081
BOT_LOG_LEVEL=debug BOT_LOG_LEVEL=debug
SEED_OPERATOR_TELEGRAM_ID=818380985 SEED_OPERATOR_USERNAME=admin
SEED_OPERATOR_NAME="yiekheng (dev)" SEED_OPERATOR_NAME="yiekheng (dev)"
WEB_PORT=9000 WEB_PORT=9000
AUTH_SECRET=86f656580a58f03b6ccb43d257e0e801ecd5356e042e8886b3c7c569e29ff13c AUTH_SECRET=86f656580a58f03b6ccb43d257e0e801ecd5356e042e8886b3c7c569e29ff13c

7
.gitignore vendored
View File

@ -18,6 +18,13 @@ apps/web/public/swe-worker-*.js
# ARE committed to this private Gitea. Only ignore example overrides: # ARE committed to this private Gitea. Only ignore example overrides:
.env.local .env.local
.env.*.local .env.*.local
# Anything inside envs/ EXCEPT the example template — a real env
# file (envs/ENV) leaked once into commit 6893ca6 carrying the DB
# password and AUTH_SECRET. Whitelist .env.example explicitly so a
# future copy-paste of envs/.env.example into envs/ENV (or any other
# name) gets blocked at git add time.
envs/*
!envs/.env.example
# logs # logs
*.log *.log

125
README.md
View File

@ -6,24 +6,36 @@ the run history all from a phone home-screen icon.
## Status ## Status
**Plans 1, 2, and 3 complete.** The web app at `wabot.04080616.xyz` is **v1 production-ready.** The web app at `wabot.04080616.xyz` is the
the primary control surface; the Telegram bot has been removed. primary control surface; the Telegram bot has been removed.
What's working today: What's working today:
- **Username + password auth** with role-based access (admin / user).
HttpOnly + Secure session cookies, encrypted with AES-256-GCM (so a
leaked cookie reveals nothing about userId / role) and bound to the
`OPERATOR_TOKEN_VERSION` env so a single env bump kills every
outstanding session.
- **Three-layer login rate limit** — per-IP + per-username (lower-cased
so case-rotation doesn't help) + a global backstop, so a residential-
proxy attacker can't brute one account by hopping IPs.
- **Self-hosted Next.js 16 PWA** — installable on a phone home screen. - **Self-hosted Next.js 16 PWA** — installable on a phone home screen.
Mobile-first single-row header with a slide-out drawer; desktop Mobile-first single-row header with a slide-out drawer; desktop
sidebar. sidebar. Login lives outside the shell on a bare-header surface.
- **Live QR pairing** — server-side Baileys session feeds the QR - **Live QR pairing** — server-side Baileys session feeds the QR
payload directly into the browser via Server-Sent Events. Scan, payload directly into the browser via Server-Sent Events. Scan,
see "✅ Connected" within seconds, auto-redirect. see "✅ Connected" within seconds, auto-redirect.
- **Duplicate-pair detection** — scanning a QR with a phone already
linked to another account row surfaces a clear "already paired as
&lt;label&gt;" message instead of fighting Baileys for the device.
- **Multi-account, multi-group reminders** — 5-step wizard - **Multi-account, multi-group reminders** — 5-step wizard
(Account → Message → When → Groups → Review) plus per-section edit (Account → Message → When → Groups → Review) plus per-section edit
pages so you don't have to walk the wizard end-to-end to fix one pages so you don't have to walk the wizard end-to-end to fix one
field. Active recurrence picker covers Daily / Weekly / Monthly / field. Recurrence picker covers Daily / Weekly / Monthly / Yearly
Yearly with multi-rule support and per-rule fire-time pickers; the with multi-rule support and per-rule fire-time pickers; the rendered
rendered description reads as plain English ("Every week on Mon, description reads as plain English ("Every week on Mon, Wed, Fri at
Wed, Fri at 09:00") not raw cron. 09:00") not raw cron. Optional "Pause sending by" deadline that
defaults OFF — operators have to opt in explicitly.
- **Multi-message stacks** — a reminder can carry multiple ordered - **Multi-message stacks** — a reminder can carry multiple ordered
parts (text + media), fired in sequence with a 1.5 s gap. Media parts (text + media), fired in sequence with a 1.5 s gap. Media
files swap at any time from the Edit Message page. files swap at any time from the Edit Message page.
@ -33,19 +45,29 @@ What's working today:
as a downloadable file instead of failing silently. as a downloadable file instead of failing silently.
- **Swipe-to-act rows** — on mobile, swipe a reminder or activity - **Swipe-to-act rows** — on mobile, swipe a reminder or activity
row left for Delete or right for Pause/Restart/Archive. iOS-Mail row left for Delete or right for Pause/Restart/Archive. iOS-Mail
style. style. Click vs drag is disambiguated by a 6-px tap threshold so a
swipe doesn't accidentally trigger the row's link.
- **Activity tab** — last 200 runs with status filters (Success / - **Activity tab** — last 200 runs with status filters (Success /
Partial / Failed / Skipped) plus an Archived tab. Archive a noisy Paused / Failed / Archived). Partial runs surface under both Paused
run to keep the main list readable; restore later. Hard-delete and Failed; Skipped runs collapse into Archived. Hard-delete and
always available. Run history survives a reminder deletion. archive both available; run history survives a reminder deletion.
- **Auto-reconnect on transient drops; restart-survival via Baileys - **Auto-reconnect on transient drops; restart-survival via Baileys
session persistence.** Pair once, the device stays linked across session persistence.** Pair once, the device stays linked across
container restarts. container restarts. Logout-on-delete cleans the operator's
- **All actions audited.** Reminder run history queryable from the linked-devices list on the WhatsApp side too.
UI; per-run target results (sent / failed / skipped) preserved - **Hardened pg-boss scheduling** — three-tier dedupe so a triple-
even when the underlying group is removed. click Save or microsecond-spaced enqueue doesn't fire a reminder
multiple times. Reschedule cancels stale jobs by singletonKey first
so a recurring next-fire never gets silently dropped.
- **Drizzle journal monotonicity guard**`pnpm migrate` refuses to
run if the `_journal.json` `when` timestamps aren't strictly
increasing (a recurring foot-gun where drizzle would silently skip
a freshly-generated migration). CI tests + the migrate runner both
enforce.
- **All actions audited.** Per-run target results (sent / failed /
skipped) preserved even when the underlying group is removed.
Test count: **249 web + 31 shared + 26 bot = 306** passing. Test count: **482 web + 88 bot = 570** passing.
## Host requirements ## Host requirements
@ -79,24 +101,28 @@ Prerequisites: Docker, the `wabot` database + `waBot` role on
# 1. Configure env # 1. Configure env
cp envs/.env.example .env.development cp envs/.env.example .env.development
# edit .env.development: real DATABASE_URL, plus the LAN host to expose # edit .env.development: real DATABASE_URL, plus the LAN host to expose
scripts/gen_auth_secret.sh --write scripts/gen_auth_secret.sh --write # writes AUTH_SECRET to .env.development
# 2. Bring up the stack, install deps # 2. Bring up the stack, install deps
NO_SUDO=1 scripts/dev.sh up NO_SUDO=1 scripts/dev.sh up
NO_SUDO=1 scripts/dev.sh pnpm install NO_SUDO=1 scripts/dev.sh pnpm install
# 3. Apply migrations and seed your operator row # 3. Apply migrations and seed the bootstrap operator row
NO_SUDO=1 scripts/db.sh migrate NO_SUDO=1 scripts/db.sh migrate
NO_SUDO=1 scripts/db.sh seed NO_SUDO=1 scripts/db.sh seed
# 4. Open the web app # 4. Set the bootstrap admin password (NO password is set by seed)
echo 'change-me-now' | scripts/set-password.sh admin
# 5. Open the web app and sign in as `admin` with the password above
# Local: http://localhost:9000 # Local: http://localhost:9000
# LAN: http://<host-ip>:9000 (e.g. http://192.168.0.253:9000) # LAN: http://<host-ip>:9000
# Public: https://wabot.04080616.xyz (whatever your reverse proxy serves) # Public: https://wabot.04080616.xyz
``` ```
Pair an account: `/accounts` → "New Account" → enter a label → Inside the app: `/settings/users` → Add user → invite teammates with
"Pair WhatsApp" → scan the QR with WhatsApp's "Linked Devices". `user` role; promote / demote / reset password / delete from the same
page. The "Admin" nav entry is admin-only.
PWA install: phone Chrome → menu → "Install App" / "Add to Home PWA install: phone Chrome → menu → "Install App" / "Add to Home
Screen". Launches fullscreen. Screen". Launches fullscreen.
@ -104,10 +130,22 @@ Screen". Launches fullscreen.
`NO_SUDO=1` is the right setting if your user is in the `docker` `NO_SUDO=1` is the right setting if your user is in the `docker`
group (the default for this repo). Drop it if you need `sudo docker`. group (the default for this repo). Drop it if you need `sudo docker`.
## Deploying
- **Local dev**`NO_SUDO=1 scripts/dev.sh up` (described in Quick
start above).
- **Portainer** — push images with `scripts/publish.sh`, then deploy
the [`docker-compose.portainer.yml`](docker-compose.portainer.yml)
stack via the Portainer UI. Full walk-through:
[`docs/deploy-portainer.md`](docs/deploy-portainer.md).
## Manual test runbook ## Manual test runbook
End-to-end checks that unit tests can't cover (live Baileys, End-to-end checks that unit tests can't cover (live Baileys,
WhatsApp delivery, swipe gestures): WhatsApp delivery, swipe gestures):
[`docs/runbook.md`](docs/runbook.md).
The earlier wizard-only checklist still lives at
[`docs/superpowers/specs/manual-test-web.md`](docs/superpowers/specs/manual-test-web.md). [`docs/superpowers/specs/manual-test-web.md`](docs/superpowers/specs/manual-test-web.md).
## Layout ## Layout
@ -118,11 +156,14 @@ WhatsApp delivery, swipe gestures):
- `packages/db/` — Drizzle schema and migrations - `packages/db/` — Drizzle schema and migrations
- `packages/shared/` — cross-app helpers (rrule, media paths, - `packages/shared/` — cross-app helpers (rrule, media paths,
timezones, WhatsApp media classifier) timezones, WhatsApp media classifier)
- `docs/superpowers/specs/` — design specs and manual test runbooks - `docs/runbook.md` — manual end-to-end smoke checklist
- `docs/superpowers/specs/` — design specs and earlier manual test
runbooks
- `docs/superpowers/plans/` — implementation plans - `docs/superpowers/plans/` — implementation plans
- `docker/` — Dockerfiles (`tools.Dockerfile`, `bot.Dockerfile`, - `docker/` — Dockerfiles (`tools.Dockerfile`, `bot.Dockerfile`,
`web.Dockerfile`) `web.Dockerfile`)
- `scripts/``dev.sh`, `db.sh`, `gen_auth_secret.sh` - `scripts/``dev.sh`, `db.sh`, `gen_auth_secret.sh`,
`set-password.sh`, `create-user.sh`
## Scripts ## Scripts
@ -134,17 +175,39 @@ container, so no host Node is needed.
| `scripts/dev.sh up\|down\|logs\|status\|build\|exec\|pnpm\|shell\|restart-bot` | Stack lifecycle and tools-container shell | | `scripts/dev.sh up\|down\|logs\|status\|build\|exec\|pnpm\|shell\|restart-bot` | Stack lifecycle and tools-container shell |
| `scripts/db.sh migrate\|generate\|studio\|seed\|reset` | Drizzle migration helper | | `scripts/db.sh migrate\|generate\|studio\|seed\|reset` | Drizzle migration helper |
| `scripts/gen_auth_secret.sh [--write]` | Generate `AUTH_SECRET` (host-only, no Node needed) | | `scripts/gen_auth_secret.sh [--write]` | Generate `AUTH_SECRET` (host-only, no Node needed) |
| `scripts/set-password.sh <username>` | Set / reset a user's password (reads stdin) |
| `scripts/create-user.sh <username> <role>` | Create a user from CLI (admin / user) |
Set `NO_SUDO=1` if your user is in the docker group (recommended). Set `NO_SUDO=1` if your user is in the docker group (recommended).
## Auth + admin model
- One bootstrap operator (`admin`) is created by the seed; its
password is set via `scripts/set-password.sh admin` on first launch.
- Two roles: `admin` (full access including user management) and
`user` (everything except `/settings/users`). Role-based nav
filtering is enforced in middleware + the AppShell + every server
action that mutates user state.
- Every user gets an isolated workspace — accounts, reminders,
groups, and run history all scope by `operator_id`. The admin
panel is the only cross-tenant surface.
- Sessions: AES-256-GCM-encrypted cookie keyed off `AUTH_SECRET`,
HttpOnly + Secure-in-prod + SameSite=Lax, 30-day TTL. The
`OPERATOR_TOKEN_VERSION` env (defaults to `"1"`) is the kill switch
— bumping it invalidates every outstanding cookie globally on the
next request.
- Login rate limits: 10 / 5 min per-IP + 5 / 15 min per-username + a
100 / min global backstop. The error message is identical for all
three so the limit-which-tripped isn't leaked.
## Deferred ## Deferred
- **Standalone media library** browser (currently media is uploaded - **Standalone media library** browser (currently media is uploaded
per-reminder). per-reminder).
- **E2E browser tests** (Playwright) on the swipe and pairing flows. - **E2E browser tests** (Playwright) on the swipe and pairing flows.
- **Auth** (passkeys / email-password) — bring back if URL exposure - **Search-as-you-type in the wizard's groups picker** — at 3 000+
becomes a concern. Today the app trusts whatever's in front of the groups per account the picker still loads the alphabetical
reverse proxy. top-200; operators with >200 groups need to use the list page's
- **Multi-operator** — schema supports `operator_id` on every row, search to find anything past 'L'.
but the seed runs as a single operator and there's no /signup or - **Self-service password reset** (email link, etc.) — out of scope
invite flow yet. for v1; admins use the Users page.

View File

@ -3,7 +3,7 @@ import type { Notification } from "pg";
import { logger } from "../logger.js"; import { logger } from "../logger.js";
import { env } from "../env.js"; import { env } from "../env.js";
import { handleStartPairing } from "./pair-handler.js"; import { handleStartPairing } from "./pair-handler.js";
import { handleUnpair } from "./unpair-handler.js"; import { handleUnpair, handleDelete } from "./unpair-handler.js";
import { handleSyncGroups } from "./sync-groups-handler.js"; import { handleSyncGroups } from "./sync-groups-handler.js";
import { handleSendTest } from "./send-test-handler.js"; import { handleSendTest } from "./send-test-handler.js";
import { import {
@ -14,6 +14,11 @@ import {
export type BotCommand = export type BotCommand =
| { type: "account.start_pairing"; accountId: string } | { type: "account.start_pairing"; accountId: string }
| { type: "account.unpair"; accountId: string } | { type: "account.unpair"; accountId: string }
// Like unpair, but tells WhatsApp to drop this device from the
// user's linked-devices list first via socket.logout(). The web
// action calls this immediately before deleting the row so the
// operator's phone doesn't keep showing a phantom linked device.
| { type: "account.delete"; accountId: string }
| { type: "account.sync_groups"; accountId: string } | { type: "account.sync_groups"; accountId: string }
| { type: "group.send_test"; groupId: string; text: string } | { type: "group.send_test"; groupId: string; text: string }
| { type: "reminder.schedule"; reminderId: string; scheduledAtIso: string } | { type: "reminder.schedule"; reminderId: string; scheduledAtIso: string }
@ -74,6 +79,9 @@ export function registerDefaultHandlers(): void {
registerHandler("account.unpair", async (cmd) => { registerHandler("account.unpair", async (cmd) => {
await handleUnpair(cmd.accountId); await handleUnpair(cmd.accountId);
}); });
registerHandler("account.delete", async (cmd) => {
await handleDelete(cmd.accountId);
});
registerHandler("account.sync_groups", async (cmd) => { registerHandler("account.sync_groups", async (cmd) => {
await handleSyncGroups(cmd.accountId); await handleSyncGroups(cmd.accountId);
}); });

View File

@ -10,6 +10,16 @@ export type WebEvent =
| { type: "session.connected"; accountId: string; phoneNumber: string | null } | { type: "session.connected"; accountId: string; phoneNumber: string | null }
| { type: "session.disconnected"; accountId: string } | { type: "session.disconnected"; accountId: string }
| { type: "session.timeout"; accountId: string } | { type: "session.timeout"; accountId: string }
// Operator scanned the QR with a phone that's already linked to another
// account row. We park the new pairing instead of letting two account
// rows fight over the same WhatsApp device. existingLabel surfaces in
// the UI so the operator knows which account already owns the phone.
| {
type: "session.duplicate";
accountId: string;
phoneNumber: string;
existingLabel: string;
}
| { type: "groups.synced"; accountId: string; count: number } | { type: "groups.synced"; accountId: string; count: number }
| { | {
type: "reminder.fired"; type: "reminder.fired";

View File

@ -10,11 +10,23 @@ import { renderQrPng } from "../whatsapp/qr-renderer.js";
import { syncGroupsForAccount } from "../whatsapp/group-sync.js"; import { syncGroupsForAccount } from "../whatsapp/group-sync.js";
import { writeAuditLog } from "../audit.js"; import { writeAuditLog } from "../audit.js";
import { pgNotifyWeb } from "./notify.js"; import { pgNotifyWeb } from "./notify.js";
import {
decidePairListenerOnClose,
findDuplicateExistingAccount,
nextWarmingUpAfterEvent,
} from "./pair-state.js";
const PAIR_TIMEOUT_MS = 5 * 60 * 1000; const PAIR_TIMEOUT_MS = 5 * 60 * 1000;
const offByAccount = new Map<string, () => void>(); const offByAccount = new Map<string, () => void>();
const lastQrPayload = new Map<string, string>(); const lastQrPayload = new Map<string, string>();
const pairTimeouts = new Map<string, NodeJS.Timeout>(); const pairTimeouts = new Map<string, NodeJS.Timeout>();
// "Warming" set: while present, the just-attached listener will ignore
// close events. Cleared the moment a qr/open arrives. This prevents the
// old session's close (broadcast asynchronously by sessionManager after
// our await sessionManager.stop() returns) from being mis-read as the
// NEW session timing out — which manifested as: get QR → go back →
// click Pair again → instantly see "Pairing timed out".
const pairingWarmingUp = new Set<string>();
async function abandonPair(accountId: string): Promise<{ existed: boolean; label: string | null }> { async function abandonPair(accountId: string): Promise<{ existed: boolean; label: string | null }> {
const account = await db.query.whatsappAccounts.findFirst({ const account = await db.query.whatsappAccounts.findFirst({
@ -34,6 +46,7 @@ async function abandonPair(accountId: string): Promise<{ existed: boolean; label
pairTimeouts.delete(accountId); pairTimeouts.delete(accountId);
} }
lastQrPayload.delete(accountId); lastQrPayload.delete(accountId);
pairingWarmingUp.delete(accountId);
if (sessionManager.hasSession(accountId)) { if (sessionManager.hasSession(accountId)) {
await sessionManager.stop(accountId); await sessionManager.stop(accountId);
} }
@ -80,10 +93,17 @@ export async function handleStartPairing(accountId: string): Promise<void> {
.set({ lastQrPng: null }) .set({ lastQrPng: null })
.where(eq(whatsappAccounts.id, accountId)); .where(eq(whatsappAccounts.id, accountId));
// Mark the new attempt as warming up. Cleared by the first qr/open we
// observe; while set, any close event is treated as the leaked tail of
// the previous session being torn down (see comment near
// `pairingWarmingUp` declaration).
pairingWarmingUp.add(accountId);
const off = sessionManager.on(async (id, _state, event) => { const off = sessionManager.on(async (id, _state, event) => {
if (id !== accountId) return; if (id !== accountId) return;
try { try {
if (event.type === "qr") { if (event.type === "qr") {
pairingWarmingUp.delete(id);
// Dedupe by payload — Baileys can re-emit the same QR string in a // Dedupe by payload — Baileys can re-emit the same QR string in a
// burst. Different strings (a fresh QR) always pass through, so // burst. Different strings (a fresh QR) always pass through, so
// the user gets a new QR as soon as Baileys generates one. // the user gets a new QR as soon as Baileys generates one.
@ -102,6 +122,7 @@ export async function handleStartPairing(accountId: string): Promise<void> {
ts: Date.now(), ts: Date.now(),
}); });
} else if (event.type === "open") { } else if (event.type === "open") {
pairingWarmingUp.delete(id);
const t = pairTimeouts.get(id); const t = pairTimeouts.get(id);
if (t) { if (t) {
clearTimeout(t); clearTimeout(t);
@ -109,6 +130,53 @@ export async function handleStartPairing(accountId: string): Promise<void> {
} }
lastQrPayload.delete(id); lastQrPayload.delete(id);
offByAccount.delete(id); offByAccount.delete(id);
// Duplicate-pair guard. Operator scanned the QR with a phone
// that's already linked to another account row. Letting both
// rows claim the same WhatsApp device confuses Baileys and
// turns sends into a coin flip — abandon this pairing and
// surface a clear message to the UI.
const siblings = await db.query.whatsappAccounts.findMany({
where: (a, { eq: dEq }) => dEq(a.operatorId, account.operatorId),
columns: { id: true, phoneNumber: true, label: true },
});
const dup = findDuplicateExistingAccount({
currentAccountId: id,
currentPhoneNumber: event.phoneNumber,
siblings,
});
if (dup) {
logger.warn(
{
accountId: id,
phoneNumber: event.phoneNumber,
existingAccountId: dup.existingAccountId,
existingLabel: dup.existingLabel,
},
"pair: duplicate phone — abandoning new pairing",
);
// Stop the duplicate session, scrub the partial auth blob,
// and reset the row's status. We DO NOT logout() here — the
// original account's session remains valid and the operator
// hasn't actually added a new linked device on the phone yet
// (it'd just be the freshly-completed scan, which Baileys
// hasn't yet committed to the WhatsApp side).
await sessionManager.stop(id, { intentional: true });
await rm(join(env.SESSIONS_DIR, id), { recursive: true, force: true });
await db
.update(whatsappAccounts)
.set({ status: "unpaired", lastQrPng: null, phoneNumber: null })
.where(eq(whatsappAccounts.id, id));
await pgNotifyWeb({
type: "session.duplicate",
accountId: id,
phoneNumber: event.phoneNumber!,
existingLabel: dup.existingLabel,
});
off();
return;
}
const session = sessionManager.getSession(id); const session = sessionManager.getSession(id);
let synced = 0; let synced = 0;
if (session) { if (session) {
@ -134,27 +202,42 @@ export async function handleStartPairing(accountId: string): Promise<void> {
count: synced, count: synced,
}); });
off(); off();
} else if (event.type === "close" && event.restartRequired) {
// After the user scans, WhatsApp tells Baileys to "restart"
// the connection. The socket closes with status 515 and the
// session-manager will reopen it with the new credentials —
// the next `open` event is what completes the pairing.
// This is NOT a failure: keep the listener attached so we see
// that subsequent `open` event, and don't surface a timeout
// to the UI. The DB row stays in `pending` until `open`.
logger.info(
{ accountId: id },
"pair: restart-required close (post-pair reconnect) — keeping listener alive",
);
// The session-manager handles the actual reconnect; nothing to
// do here other than NOT tear our listener / DB state down.
} else if (event.type === "close") { } else if (event.type === "close") {
// During the pairing window, any other close means the QR window const decision = decidePairListenerOnClose({
// ended without a successful link — Baileys' default is to warmingUp: pairingWarmingUp.has(id),
// close after exhausting QR refs (~2.5 min). Surface this to restartRequired: event.restartRequired,
// the UI so the user gets a "pairing timed out" screen, and });
// park the row in a stable state so it shows up cleanly on if (decision === "ignore-leaked-close") {
// the accounts list with a "Re-pair" affordance. logger.info(
{ accountId: id },
"pair: ignoring close from previous attempt while warming up",
);
return;
}
if (decision === "post-pair-restart") {
// After the user scans, WhatsApp tells Baileys to "restart"
// the connection. The socket closes with status 515 and the
// session-manager will reopen it with the new credentials —
// the next `open` event finishes the pairing. Keep the
// listener attached and don't surface a timeout to the UI.
//
// Re-arm the warming-up flag: the session-manager schedules a
// cleanup `stop().then(start())` to kick off the reconnect.
// That stop emits another close event that lands on this
// listener BEFORE the new open arrives — without warming-up,
// we'd treat it as a timeout and detach right when the user
// actually paired successfully. Cleared again on the next
// qr / open from the freshly-reopened session.
pairingWarmingUp.add(id);
logger.info(
{ accountId: id },
"pair: restart-required close (post-pair reconnect) — keeping listener alive",
);
return;
}
// decision === "treat-as-timeout": ephemeral close on a live
// attempt. Park the row as `unpaired` and push session.timeout
// so the operator sees the "Re-pair" affordance.
const t = pairTimeouts.get(id); const t = pairTimeouts.get(id);
if (t) { if (t) {
clearTimeout(t); clearTimeout(t);

View File

@ -2,6 +2,9 @@ import { describe, it, expect } from "vitest";
import { import {
decideOnPairClose, decideOnPairClose,
decideOnPairTimeout, decideOnPairTimeout,
decidePairListenerOnClose,
findDuplicateExistingAccount,
nextWarmingUpAfterEvent,
shouldAutoReconnect, shouldAutoReconnect,
} from "./pair-state.js"; } from "./pair-state.js";
@ -82,3 +85,225 @@ describe("shouldAutoReconnect", () => {
expect(shouldAutoReconnect({ loggedOut: false, hasEverConnected: false })).toBe(false); expect(shouldAutoReconnect({ loggedOut: false, hasEverConnected: false })).toBe(false);
}); });
}); });
describe("decidePairListenerOnClose (back→re-pair flicker regression)", () => {
it("ignores a close while warming up — even if also restartRequired", () => {
// The exact bug: stop() was awaited, listener attached, then the OLD
// session's close arrives and races our new listener. Warming-up
// wins over every other branch so the UI never sees a spurious
// session.timeout before the new QR is rendered.
expect(
decidePairListenerOnClose({ warmingUp: true, restartRequired: false }),
).toBe("ignore-leaked-close");
expect(
decidePairListenerOnClose({ warmingUp: true, restartRequired: true }),
).toBe("ignore-leaked-close");
});
it("treats a close on a live attempt (warmingUp=false) as a real timeout", () => {
// Refs exhausted, network blip, etc. — operator gets the
// "Pairing timed out" screen and a Re-pair affordance.
expect(
decidePairListenerOnClose({ warmingUp: false, restartRequired: false }),
).toBe("treat-as-timeout");
expect(decidePairListenerOnClose({ warmingUp: false })).toBe("treat-as-timeout");
});
it("preserves the restart-required (post-pair-success) branch when not warming up", () => {
// Status 515 close: the session-manager will reconnect and the next
// `open` finishes the pair. We must NOT push session.timeout here.
expect(
decidePairListenerOnClose({ warmingUp: false, restartRequired: true }),
).toBe("post-pair-restart");
});
it("warming-up overrides restartRequired so 515 from a stale session is also swallowed", () => {
// Defense-in-depth: if Baileys' restart-required close from the OLD
// session somehow leaks through, treating it as a real 515 would
// KEEP the listener attached forever (no reconnect comes from a
// session we just stopped). Ignore it entirely until a fresh qr/open.
expect(
decidePairListenerOnClose({ warmingUp: true, restartRequired: true }),
).toBe("ignore-leaked-close");
});
});
describe("nextWarmingUpAfterEvent (pair-listener flag transitions)", () => {
it("first qr from the live session clears warming-up", () => {
expect(nextWarmingUpAfterEvent({ warmingUp: true, event: "qr" })).toBe(false);
expect(nextWarmingUpAfterEvent({ warmingUp: false, event: "qr" })).toBe(false);
});
it("first open from the live session clears warming-up", () => {
expect(nextWarmingUpAfterEvent({ warmingUp: true, event: "open" })).toBe(false);
expect(nextWarmingUpAfterEvent({ warmingUp: false, event: "open" })).toBe(false);
});
it("RE-ARMS warming-up on a restart-required close (post-pair-success)", () => {
// The regression: after the user scans, Baileys closes with status
// 515 and the session-manager schedules a stop().then(start())
// reconnect. That cleanup-stop emits a SECOND close that arrives
// before the new socket reopens. If warming-up isn't re-armed
// between the two closes, the second one resolves to
// 'treat-as-timeout' and detaches the listener right at the
// moment the user actually paired successfully — UI never gets
// session.connected.
expect(
nextWarmingUpAfterEvent({ warmingUp: false, event: "close", restartRequired: true }),
).toBe(true);
expect(
nextWarmingUpAfterEvent({ warmingUp: true, event: "close", restartRequired: true }),
).toBe(true);
});
it("plain close leaves warming-up unchanged", () => {
// The pair-handler decides what to DO with a non-restart close
// separately (decidePairListenerOnClose). The warming-up flag
// doesn't change as a side effect — the listener either detaches
// (treat-as-timeout) or already returned (ignore-leaked-close).
expect(
nextWarmingUpAfterEvent({ warmingUp: false, event: "close" }),
).toBe(false);
expect(
nextWarmingUpAfterEvent({ warmingUp: true, event: "close" }),
).toBe(true);
});
it("end-to-end successful-pair sequence: warming → qr → restart-required close → open", () => {
// Full lifecycle the helper has to thread correctly so the user
// sees 'Account connected!' instead of 'Pairing timed out'.
let warming = true; // freshly attached listener after a re-pair
// First QR arrives — clears the leak-protection flag.
warming = nextWarmingUpAfterEvent({ warmingUp: warming, event: "qr" });
expect(warming).toBe(false);
// User scans → Baileys closes with restartRequired=true.
// Re-arms because session-manager will run another stop+start.
warming = nextWarmingUpAfterEvent({
warmingUp: warming,
event: "close",
restartRequired: true,
});
expect(warming).toBe(true);
// The cleanup-stop's second close arrives. The CALLER decides via
// decidePairListenerOnClose to ignore it (warmingUp=true wins).
expect(
decidePairListenerOnClose({ warmingUp: warming, restartRequired: false }),
).toBe("ignore-leaked-close");
// Flag stays armed because a plain close doesn't change it.
warming = nextWarmingUpAfterEvent({
warmingUp: warming,
event: "close",
restartRequired: false,
});
expect(warming).toBe(true);
// Fresh socket opens with the new credentials → success.
warming = nextWarmingUpAfterEvent({ warmingUp: warming, event: "open" });
expect(warming).toBe(false);
});
});
describe("findDuplicateExistingAccount (one-phone-per-account guard)", () => {
const sibling = (id: string, phone: string | null, label: string) => ({
id,
phoneNumber: phone,
label,
});
it("flags a sibling that already holds this phone number", () => {
const r = findDuplicateExistingAccount({
currentAccountId: "new",
currentPhoneNumber: "60123456789",
siblings: [
sibling("new", null, "scratch"),
sibling("existing", "60123456789", "Yiekheng-my"),
sibling("other", "60987654321", "WaBot Test"),
],
});
expect(r).toEqual({
existingAccountId: "existing",
existingLabel: "Yiekheng-my",
});
});
it("returns null when the phone is unique", () => {
const r = findDuplicateExistingAccount({
currentAccountId: "new",
currentPhoneNumber: "60123456789",
siblings: [
sibling("new", null, "scratch"),
sibling("other", "60987654321", "WaBot"),
],
});
expect(r).toBeNull();
});
it("excludes the current account from comparison (a row's own phone isn't a 'duplicate')", () => {
// After session-manager.handleEvent runs first it has already
// written phone_number on the current row. The check must skip
// that row, otherwise EVERY successful pair would match itself
// and look like a duplicate.
const r = findDuplicateExistingAccount({
currentAccountId: "self",
currentPhoneNumber: "60123456789",
siblings: [sibling("self", "60123456789", "Self")],
});
expect(r).toBeNull();
});
it("returns null for null/empty/whitespace phone numbers (don't false-positive on unset)", () => {
const siblings = [
sibling("new", null, "scratch"),
sibling("a", null, "Old A"),
sibling("b", "", "Old B"),
sibling("c", " ", "Old C"),
];
expect(
findDuplicateExistingAccount({
currentAccountId: "new",
currentPhoneNumber: null,
siblings,
}),
).toBeNull();
expect(
findDuplicateExistingAccount({
currentAccountId: "new",
currentPhoneNumber: "",
siblings,
}),
).toBeNull();
expect(
findDuplicateExistingAccount({
currentAccountId: "new",
currentPhoneNumber: " ",
siblings,
}),
).toBeNull();
});
it("normalises whitespace on both sides before comparing", () => {
const r = findDuplicateExistingAccount({
currentAccountId: "new",
currentPhoneNumber: " 60123456789 ",
siblings: [sibling("existing", "60123456789", "Existing")],
});
expect(r?.existingAccountId).toBe("existing");
});
it("picks the FIRST matching sibling when (somehow) two rows share the phone", () => {
// Defensive: this state shouldn't exist in production but the helper
// should at least be deterministic so the message is consistent.
const r = findDuplicateExistingAccount({
currentAccountId: "new",
currentPhoneNumber: "60123456789",
siblings: [
sibling("first", "60123456789", "First"),
sibling("second", "60123456789", "Second"),
],
});
expect(r?.existingAccountId).toBe("first");
});
});

View File

@ -80,3 +80,106 @@ export function decideOnPairTimeout({ current }: { current: AccountStatus }): St
if (current !== "pending") return null; if (current !== "pending") return null;
return { next: "unpaired", clearQrPng: true }; return { next: "unpaired", clearQrPng: true };
} }
/**
* Decide how the pair-handler should react to a `close` event delivered
* to its listener. Three outcomes:
*
* - "ignore-leaked-close": the new attempt is still warming up and
* we're seeing the OLD session's tail close. Do nothing don't
* emit timeout to the UI, don't touch the DB row.
* - "post-pair-restart": status-515 close from a successful scan.
* The session-manager will reconnect; we keep the listener alive
* and wait for the subsequent `open` event.
* - "treat-as-timeout": a real ephemeral close on a live attempt
* (refs exhausted, etc.). Park the row as `unpaired` and push
* `session.timeout` to the UI.
*
* Captures the regression where, after the user pulled up a QR and
* navigated back, clicking Pair again would instantly flash "Pairing
* timed out" because the await on stop() returned before
* sessionManager.handleEvent finished broadcasting the old session's
* close and the new listener was already attached.
*/
export type PairListenerCloseDecision =
| "ignore-leaked-close"
| "post-pair-restart"
| "treat-as-timeout";
export function decidePairListenerOnClose(input: {
warmingUp: boolean;
restartRequired?: boolean;
}): PairListenerCloseDecision {
if (input.warmingUp) return "ignore-leaked-close";
if (input.restartRequired) return "post-pair-restart";
return "treat-as-timeout";
}
/**
* Step the pair-listener's warming-up flag forward through one Baileys
* event. Captures three rules in one place so they're test-locked:
*
* - First `qr` / `open` from the live session clears warming-up
* (we've seen real session activity, future closes are real).
* - `close + restartRequired` (post-pair-success / status 515)
* RE-ARMS warming-up. The session-manager will schedule a
* `stop().then(start())` reconnect; that stop emits a second close
* before the new socket reopens. Without re-arming, the leaked
* close from the cleanup-stop reaches us with warming-up=false and
* resolves to `treat-as-timeout` detaching the listener right at
* the moment the user actually paired successfully (regression).
* - Any other `close` keeps warming-up unchanged (the listener
* either ignored it because we're warming, or processed it as a
* real timeout / restart and is leaving the loop anyway).
*/
export function nextWarmingUpAfterEvent(input: {
warmingUp: boolean;
event: "qr" | "open" | "close";
restartRequired?: boolean;
}): boolean {
if (input.event === "qr" || input.event === "open") return false;
if (input.event === "close" && input.restartRequired) return true;
return input.warmingUp;
}
/**
* Decide whether a freshly-paired account is a duplicate of an
* existing account row owned by the same operator. The operator
* cannot legitimately link the same WhatsApp number to two account
* rows Baileys keeps one auth blob per phone and the second row
* would just hijack the first's session.
*
* Inputs:
* - `currentAccountId` the row that just received the open event
* - `currentPhoneNumber` the JID-derived phone string (or null)
* - `siblings` every other operator-owned account row
*
* Returns `null` if the phone is unique (proceed normally), or a
* descriptor with the existing-row's id+label so the caller can park
* the duplicate row and surface a clear "already linked" message to
* the UI. A null/empty phone never reports a duplicate (we'd be
* comparing apples and we'd block legitimate first pairs that
* haven't received the WID yet).
*/
export interface DuplicatePairInput {
currentAccountId: string;
currentPhoneNumber: string | null | undefined;
siblings: Array<{ id: string; phoneNumber: string | null; label: string }>;
}
export interface DuplicatePairFinding {
existingAccountId: string;
existingLabel: string;
}
export function findDuplicateExistingAccount(
input: DuplicatePairInput,
): DuplicatePairFinding | null {
const phone = (input.currentPhoneNumber ?? "").trim();
if (!phone) return null;
for (const s of input.siblings) {
if (s.id === input.currentAccountId) continue;
if ((s.phoneNumber ?? "").trim() === phone) {
return { existingAccountId: s.id, existingLabel: s.label };
}
}
return null;
}

View File

@ -0,0 +1,128 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// Hoisted spies so the vi.mock factories can reach them.
const {
stopMock,
logoutAndStopMock,
rmMock,
findFirstMock,
writeAuditLogMock,
pgNotifyWebMock,
} = vi.hoisted(() => ({
stopMock: vi.fn(async () => undefined),
logoutAndStopMock: vi.fn(async () => undefined),
rmMock: vi.fn(async () => undefined),
findFirstMock: vi.fn(async () => ({ operatorId: "op-1", label: "WaBot" })),
writeAuditLogMock: vi.fn(async () => undefined),
pgNotifyWebMock: vi.fn(async () => undefined),
}));
vi.mock("node:fs/promises", () => ({
rm: (...args: unknown[]) => rmMock(...args),
}));
vi.mock("../db.js", () => ({
db: {
query: { whatsappAccounts: { findFirst: (...a: unknown[]) => findFirstMock(...a) } },
},
}));
vi.mock("../env.js", () => ({ env: { SESSIONS_DIR: "/data/sessions" } }));
vi.mock("../whatsapp/session-manager.js", () => ({
sessionManager: {
stop: (...a: unknown[]) => stopMock(...a),
logoutAndStop: (...a: unknown[]) => logoutAndStopMock(...a),
},
}));
vi.mock("../audit.js", () => ({
writeAuditLog: (...a: unknown[]) => writeAuditLogMock(...a),
}));
vi.mock("./notify.js", () => ({
pgNotifyWeb: (...a: unknown[]) => pgNotifyWebMock(...a),
}));
vi.mock("../logger.js", () => ({
logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() },
}));
import { handleUnpair, handleDelete } from "./unpair-handler.js";
beforeEach(() => {
stopMock.mockReset();
stopMock.mockResolvedValue(undefined);
logoutAndStopMock.mockReset();
logoutAndStopMock.mockResolvedValue(undefined);
rmMock.mockReset();
rmMock.mockResolvedValue(undefined);
findFirstMock.mockReset();
findFirstMock.mockResolvedValue({ operatorId: "op-1", label: "WaBot" });
writeAuditLogMock.mockReset();
writeAuditLogMock.mockResolvedValue(undefined);
pgNotifyWebMock.mockReset();
pgNotifyWebMock.mockResolvedValue(undefined);
});
describe("handleUnpair", () => {
it("stops the session WITHOUT logout, removes files, audits, notifies", async () => {
await handleUnpair("acct-A");
// The unpair flow MUST NOT call logoutAndStop — that would tell
// WhatsApp to drop the linked device, which the operator might
// re-pair shortly after. logoutAndStop is only for permanent
// delete.
expect(logoutAndStopMock).not.toHaveBeenCalled();
expect(stopMock).toHaveBeenCalledWith("acct-A", { intentional: true });
expect(rmMock).toHaveBeenCalled();
expect(writeAuditLogMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ action: "account.unpaired", targetId: "acct-A" }),
);
expect(pgNotifyWebMock).toHaveBeenCalledWith({
type: "session.disconnected",
accountId: "acct-A",
});
});
});
describe("handleDelete (logout-before-teardown)", () => {
it("calls logoutAndStop BEFORE rm so WhatsApp drops the linked device first", async () => {
await handleDelete("acct-A");
expect(logoutAndStopMock).toHaveBeenCalledTimes(1);
expect(logoutAndStopMock).toHaveBeenCalledWith("acct-A");
expect(rmMock).toHaveBeenCalledTimes(1);
// Order: logout-and-stop must invoke before rm (otherwise the
// socket was torn down on disk before WhatsApp could be told to
// drop the linked device).
expect(logoutAndStopMock.mock.invocationCallOrder[0]).toBeLessThan(
rmMock.mock.invocationCallOrder[0]!,
);
});
it("does NOT call the plain stop() — that's reserved for unpair", async () => {
// Sanity guard: a refactor that swaps logoutAndStop for stop()
// would silently regress the linked-device cleanup. The test
// pins the contract.
await handleDelete("acct-A");
expect(stopMock).not.toHaveBeenCalled();
});
it("writes an account.deleted audit log carrying the row's label", async () => {
findFirstMock.mockResolvedValueOnce({ operatorId: "op-7", label: "Yiekheng-my" });
await handleDelete("acct-X");
expect(writeAuditLogMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "account.deleted",
operatorId: "op-7",
targetId: "acct-X",
payload: { label: "Yiekheng-my" },
}),
);
});
it("still completes when the audit-log lookup fails (best-effort)", async () => {
// The web action runs the cascade DELETE right after; if the row
// is gone before this handler reads it, the audit lookup throws.
// Delete must not strand on that.
findFirstMock.mockRejectedValueOnce(new Error("no such row"));
await expect(handleDelete("acct-A")).resolves.toBeUndefined();
expect(rmMock).toHaveBeenCalled();
expect(pgNotifyWebMock).toHaveBeenCalled();
});
});

View File

@ -39,3 +39,41 @@ export async function handleUnpair(accountId: string): Promise<void> {
} }
await pgNotifyWeb({ type: "session.disconnected", accountId }); await pgNotifyWeb({ type: "session.disconnected", accountId });
} }
/**
* Delete-account flow on the bot side. Distinct from unpair because
* we want WhatsApp to drop this device from the user's linked-devices
* list otherwise the phone keeps showing a phantom entry that has
* to be manually removed from WhatsApp's UI.
*
* Order is important:
* 1. socket.logout() over the still-connected socket WhatsApp
* removes the linked device on the server side.
* 2. close() the local Baileys session.
* 3. rm() the on-disk auth blob so the next pairing starts clean.
*
* Step 1 is best-effort if the socket is already torn down or the
* RPC fails the delete still proceeds. The web action then deletes
* the row (cascade FKs handle groups/reminders/runs).
*/
export async function handleDelete(accountId: string): Promise<void> {
await sessionManager.logoutAndStop(accountId);
await rm(join(env.SESSIONS_DIR, accountId), { recursive: true, force: true });
try {
const row = await db.query.whatsappAccounts.findFirst({
where: (a, { eq }) => eq(a.id, accountId),
columns: { operatorId: true, label: true },
});
await writeAuditLog(db, {
operatorId: row?.operatorId ?? null,
source: "web",
action: "account.deleted",
targetType: "whatsapp_account",
targetId: accountId,
payload: { label: row?.label ?? null },
});
} catch (err) {
logger.warn({ err, accountId }, "delete: audit log failed (non-fatal)");
}
await pgNotifyWeb({ type: "session.disconnected", accountId });
}

View File

@ -108,6 +108,51 @@ describe("fireReminder", () => {
expect(accountMutex.run).not.toHaveBeenCalled(); expect(accountMutex.run).not.toHaveBeenCalled();
}); });
it("BAILS OUT INSIDE the mutex when a concurrent job inserted a run while we were queued (TOCTOU repro)", async () => {
// Repro: three pg-boss jobs arrive in the same microsecond. All
// three pass the OUTER recent-run check (no run exists yet) and
// queue up on the per-account mutex. The first acquires, INSERTs
// a run, sends. The second acquires AFTER the first finished —
// its inner check now sees the just-inserted run and must bail,
// otherwise the message would be sent twice (or three times for
// the third job). Without the inner check this regression
// produced "qwerd msg three times" in production.
getReminderMock.mockResolvedValue({
id: "r-1",
accountId: "acct-A",
status: "active",
targets: [],
messages: [],
createdBy: "op-1",
scheduleKind: "one_off",
rrule: null,
timezone: "Asia/Kuala_Lumpur",
deliveryWindowStartHour: 6,
deliveryWindowEndHour: 18,
name: "Test",
});
// First call (outer check) returns no recent run → mutex acquired.
// Second call (inner check inside fireReminderInner) returns a
// freshly-inserted run from the concurrent winner, so the INSERT
// path bails. We never reach the .insert(reminderRuns) builder so
// the test passes by virtue of the inner-check log + early return.
findExistingRunMock
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce({
id: "run-just-inserted-by-the-other-worker",
reminderId: "r-1",
firedAt: new Date(),
status: "pending",
});
await fireReminder({ reminderId: "r-1" });
// The mutex DID get acquired (we got past the outer check), but
// the inner check should have stopped us before any side effects.
expect(accountMutex.run).toHaveBeenCalledTimes(1);
expect(findExistingRunMock).toHaveBeenCalledTimes(2);
});
it("BAILS OUT (no mutex acquired) when a fresh fire collides with a recent run", async () => { it("BAILS OUT (no mutex acquired) when a fresh fire collides with a recent run", async () => {
// Two pg-boss jobs landing within microseconds for the same // Two pg-boss jobs landing within microseconds for the same
// reminder should NOT both fire. The first creates the run; the // reminder should NOT both fire. The first creates the run; the

View File

@ -154,6 +154,32 @@ async function fireReminderInner(
.set({ status: "pending", errorSummary: null }) .set({ status: "pending", errorSummary: null })
.where(eq(reminderRuns.id, runId)); .where(eq(reminderRuns.id, runId));
} else { } else {
// Re-check the dedupe window now that we're inside the per-account
// mutex. The outer check in fireReminder() is a fast-path bail-out
// but it's TOCTOU: three concurrent jobs can all read "no recent
// run" before any of them inserts, so the message gets sent 2-3
// times. Inside the mutex, the queue serialises us — by the time
// duplicate #2 reaches this point, duplicate #1 has already
// INSERTed and we'll find that row here.
const recent = await db.query.reminderRuns.findFirst({
where: (r, { eq: dEq, and: dAnd, gt: dGt }) =>
dAnd(
dEq(r.reminderId, reminder.id),
dGt(r.firedAt, new Date(Date.now() - DUPLICATE_FIRE_WINDOW_MS)),
),
orderBy: (r, { desc }) => [desc(r.firedAt)],
});
if (recent) {
logger.warn(
{
reminderId: reminder.id,
recentRunId: recent.id,
recentFiredAt: recent.firedAt,
},
"fire-reminder: duplicate fire detected inside mutex (a run was just inserted by a concurrent job), skipping",
);
return;
}
const [run] = await db const [run] = await db
.insert(reminderRuns) .insert(reminderRuns)
.values({ .values({

View File

@ -0,0 +1,119 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// pg-boss + db mocks. Hoisted so the vi.mock factories below can refer
// to the spies — see https://vitest.dev/api/vi.html#vi-hoisted.
const {
bossSendMock,
dbExecuteMock,
} = vi.hoisted(() => ({
bossSendMock: vi.fn(async (..._args: unknown[]) => "new-job-id"),
dbExecuteMock: vi.fn(async (..._args: unknown[]) => ({ rows: [] as unknown[] })),
}));
vi.mock("../db.js", () => ({
db: { execute: (...a: unknown[]) => dbExecuteMock(...a) },
}));
vi.mock("../logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
// We don't import pg-boss directly — scheduleReminderFire receives a
// PgBoss instance as its first arg. Build a minimal stub that exposes
// just the .send method (and createQueue / work for registerReminderJobs
// if we ever wire it here).
const fakeBoss = {
send: bossSendMock,
} as unknown as Parameters<typeof scheduleReminderFire>[0];
import { scheduleReminderFire } from "./reminder-jobs.js";
const REMINDER_ID = "11111111-1111-1111-1111-111111111111";
const SINGLETON_KEY = `reminder:${REMINDER_ID}`;
const FIRE_AT = new Date("2026-05-10T12:20:00.000Z");
beforeEach(() => {
bossSendMock.mockReset();
bossSendMock.mockResolvedValue("new-job-id");
dbExecuteMock.mockReset();
dbExecuteMock.mockResolvedValue({ rows: [] });
});
describe("scheduleReminderFire — pre-send cancel of stale created jobs", () => {
it("ALWAYS runs the cancel-stale UPDATE before boss.send (regression: stately dedupe dropped reschedules)", async () => {
// Repro of the dropped-fire bug: the queue was on policy=stately
// and a prior schedule had left a 'created' job in pg-boss with
// the same singletonKey. The new send returned null and the
// user's 8:20 PM fire was silently lost. Under the fix, we MUST
// tombstone any prior created jobs FIRST so the new send wins
// even under standard policy.
dbExecuteMock.mockResolvedValueOnce({ rows: [{ id: "stale-1" }] });
const result = await scheduleReminderFire(fakeBoss, REMINDER_ID, FIRE_AT);
// Order matters: cancel happens before send.
expect(dbExecuteMock).toHaveBeenCalledTimes(1);
expect(bossSendMock).toHaveBeenCalledTimes(1);
expect(dbExecuteMock.mock.invocationCallOrder[0]).toBeLessThan(
bossSendMock.mock.invocationCallOrder[0]!,
);
expect(result).toBe("new-job-id");
});
it("scopes the cancel to state='created' only — active/completed jobs MUST survive", async () => {
// The cancel must NOT touch in-flight runs (state='active') nor
// historical fires (state='completed'). Otherwise we'd nuke the
// run that's currently sending and the user gets phantom 'failed'
// rows in the activity feed.
await scheduleReminderFire(fakeBoss, REMINDER_ID, FIRE_AT);
const sqlStmt = dbExecuteMock.mock.calls[0]![0];
// Drizzle's sql template returns an SQL object; serialise to inspect.
const text = JSON.stringify(sqlStmt);
expect(text).toMatch(/state\s*=\s*'?created'?/);
expect(text).not.toMatch(/state\s*=\s*'?active'?/);
expect(text).not.toMatch(/state\s*=\s*'?completed'?/);
});
it("targets only THIS reminder's singletonKey (doesn't cross-cancel other reminders)", async () => {
await scheduleReminderFire(fakeBoss, REMINDER_ID, FIRE_AT);
const sqlStmt = dbExecuteMock.mock.calls[0]![0];
const text = JSON.stringify(sqlStmt);
// The reminderId must appear in the WHERE clause's bound params
// (drizzle stores them in the serialised payload).
expect(text).toContain(REMINDER_ID);
});
it("passes the singleton key through to boss.send for diagnostics", async () => {
await scheduleReminderFire(fakeBoss, REMINDER_ID, FIRE_AT);
const [, , opts] = bossSendMock.mock.calls[0]!;
expect(opts).toMatchObject({
singletonKey: SINGLETON_KEY,
startAfter: FIRE_AT,
retryLimit: 3,
});
});
it("still sends when the cancel UPDATE returns zero rows (first-time schedule)", async () => {
// First time scheduling a reminder — no stale rows exist.
dbExecuteMock.mockResolvedValueOnce({ rows: [] });
const result = await scheduleReminderFire(fakeBoss, REMINDER_ID, FIRE_AT);
expect(bossSendMock).toHaveBeenCalledTimes(1);
expect(result).toBe("new-job-id");
});
it("DEGRADES SAFELY: if the cancel UPDATE throws, the send still runs", async () => {
// pg connection blip during cancel must not strand the schedule.
// Worst case we end up with two created jobs and the
// handler-level recent-run dedupe drops the duplicate fire.
dbExecuteMock.mockRejectedValueOnce(new Error("connection reset"));
const result = await scheduleReminderFire(fakeBoss, REMINDER_ID, FIRE_AT);
expect(bossSendMock).toHaveBeenCalledTimes(1);
expect(result).toBe("new-job-id");
});
it("returns boss.send's null when pg-boss itself rejects the send", async () => {
// Defense check: if pg-boss returns null for any reason (queue
// missing, future stately-style policy quirks, etc), surface that
// up so the caller's logger captures jobId: null.
bossSendMock.mockResolvedValueOnce(null);
const result = await scheduleReminderFire(fakeBoss, REMINDER_ID, FIRE_AT);
expect(result).toBeNull();
});
});

View File

@ -1,21 +1,39 @@
import type { PgBoss } from "pg-boss"; import type { PgBoss } from "pg-boss";
import { sql } from "drizzle-orm";
import { logger } from "../logger.js"; import { logger } from "../logger.js";
import { env } from "../env.js"; import { env } from "../env.js";
import { db } from "../db.js";
import { fireReminder, type FireReminderPayload } from "./fire-reminder.js"; import { fireReminder, type FireReminderPayload } from "./fire-reminder.js";
export const REMINDER_FIRE_QUEUE = "reminder.fire"; export const REMINDER_FIRE_QUEUE = "reminder.fire";
export async function registerReminderJobs(boss: PgBoss): Promise<void> { export async function registerReminderJobs(boss: PgBoss): Promise<void> {
// 'stately' = at most 1 job per (state, singletonKey). Combined with // 'standard' (the default) lets us enqueue a new fire even when an
// singletonKey="reminder:<id>" on every send, that means a duplicate // older one for the same singletonKey is still 'created'. We need
// schedule call (e.g. operator double-clicked Save, or the // that for the recurring/edit path: when a reminder is rescheduled,
// pg_notify('bot.command') consumer fired twice in the same tick) // scheduleReminderFire() first cancels the stale 'created' job for
// is folded into the existing 'created' job instead of producing a // this reminder and then sends a new one — under 'stately' the
// second run. The default 'standard' policy DOES NOT dedupe by // SECOND send returns null (it dedupes against the first across
// singletonKey — that's how we ended up firing a reminder twice // states), so a reschedule silently dropped the new fire and the
// when two reminder.fire jobs landed within microseconds. // reminder never fired at the new time. Duplicate-fire safety is
// https://github.com/timgit/pg-boss/blob/master/docs/usage.md#queue-policies // covered at the handler level by the inner-mutex recent-run check
await boss.createQueue(REMINDER_FIRE_QUEUE, { policy: "stately" }); // in fire-reminder.ts (see DUPLICATE_FIRE_WINDOW_MS), which catches
// the microsecond-spaced send case 'stately' was supposed to guard.
await boss.createQueue(REMINDER_FIRE_QUEUE, { policy: "standard" });
// pg-boss v12's createQueue is idempotent and DOES NOT update the
// policy on an existing queue row. Earlier deployments forced
// policy='stately' here, which broke reschedules. Force-flip back to
// 'standard' on every boot so an old queue row doesn't strand us.
try {
await db.execute(
sql`UPDATE pgboss.queue SET policy = 'standard' WHERE name = ${REMINDER_FIRE_QUEUE} AND policy <> 'standard'`,
);
} catch (err) {
logger.warn(
{ err },
"reminder.fire: failed to force queue policy=standard (handler-level dedupe still applies)",
);
}
await boss.work<FireReminderPayload>( await boss.work<FireReminderPayload>(
REMINDER_FIRE_QUEUE, REMINDER_FIRE_QUEUE,
{ {
@ -43,6 +61,33 @@ export async function scheduleReminderFire(
reminderId: string, reminderId: string,
scheduledAt: Date, scheduledAt: Date,
): Promise<string | null> { ): Promise<string | null> {
const singletonKey = `reminder:${reminderId}`;
// Replace-then-send. Any 'created' (i.e. not yet started) job for
// this reminder is the stale next-fire from the previous schedule
// attempt; nuke it so the new schedule wins. Active/completed jobs
// are left alone — those represent in-flight or already-fired runs
// and the handler-level dedupe handles overlap.
try {
const cancelled = await db.execute(
sql`UPDATE pgboss.job
SET state = 'cancelled', completed_on = now()
WHERE name = ${REMINDER_FIRE_QUEUE}
AND singleton_key = ${singletonKey}
AND state = 'created'
RETURNING id`,
);
if (cancelled.rows.length > 0) {
logger.info(
{ reminderId, cancelled: cancelled.rows.length },
"reminder.fire: cancelled stale created jobs before reschedule",
);
}
} catch (err) {
// If the cancellation step fails, log but still try to send. Worst
// case we end up with two created jobs and the handler-level
// recent-run dedupe drops the duplicate fire.
logger.warn({ err, reminderId }, "reminder.fire: pre-send cancel failed");
}
const id = await boss.send( const id = await boss.send(
REMINDER_FIRE_QUEUE, REMINDER_FIRE_QUEUE,
{ reminderId }, { reminderId },
@ -51,8 +96,10 @@ export async function scheduleReminderFire(
retryLimit: 3, retryLimit: 3,
retryDelay: 30, retryDelay: 30,
retryBackoff: true, retryBackoff: true,
// Use the reminderId as a singleton key so re-scheduling cancels the old job // Singleton key kept on the job row for diagnostics + the
singletonKey: `reminder:${reminderId}`, // pre-send cancel above, even though 'standard' policy doesn't
// dedupe by it.
singletonKey,
}, },
); );
logger.info({ reminderId, jobId: id, scheduledAt }, "reminder.fire: scheduled"); logger.info({ reminderId, jobId: id, scheduledAt }, "reminder.fire: scheduled");

View File

@ -7,35 +7,45 @@ import { logger } from "../logger.js";
export async function syncGroupsForAccount( export async function syncGroupsForAccount(
accountId: string, accountId: string,
socket: WASocket, socket: WASocket,
): Promise<{ synced: number; removed: number }> { ): Promise<{ synced: number; archived: number }> {
const meta = await socket.groupFetchAllParticipating(); const meta = await socket.groupFetchAllParticipating();
const entries = Object.values(meta); const entries = Object.values(meta);
const liveJids = entries.map((g) => g.id); const liveJids = entries.map((g) => g.id);
// Remove DB rows for groups that are no longer in the live participant list // Mark DB rows as archived when they're no longer in the live
// (group was deleted, bot was removed, etc.). Only run the delete when we // participant list (group deleted, bot removed, etc). We don't
// got at least one live group back — an empty result is more likely a // physically DELETE because reminder_targets.group_id is a NOT
// transient WA fetch failure than a genuine "all groups gone" signal, and // NULL FK to this row — a hard delete throws "violates foreign
// we don't want to nuke valid data on a hiccup. // key constraint reminder_targets_group_id_whatsapp_groups_id_fk"
let removed: { id: string }[] = []; // and aborts the WHOLE group-sync transaction (which then strands
// the post-pair open event and the operator sees it as a failed
// pairing). Soft-archive keeps reminders that targeted the group
// intact and gives the operator the option to clean them up
// explicitly later. Only run the sweep when we got at least one
// live group back — an empty result is usually a transient WA
// fetch failure and we don't want to mass-archive valid data.
let archived = 0;
if (liveJids.length > 0) { if (liveJids.length > 0) {
removed = await db const rows = await db
.delete(whatsappGroups) .update(whatsappGroups)
.set({ isArchived: true, lastSyncedAt: new Date() })
.where( .where(
and( and(
eq(whatsappGroups.accountId, accountId), eq(whatsappGroups.accountId, accountId),
notInArray(whatsappGroups.waGroupJid, liveJids), notInArray(whatsappGroups.waGroupJid, liveJids),
eq(whatsappGroups.isArchived, false),
), ),
) )
.returning({ id: whatsappGroups.id }); .returning({ id: whatsappGroups.id });
archived = rows.length;
} }
if (entries.length === 0) { if (entries.length === 0) {
logger.info( logger.info(
{ accountId }, { accountId },
"group-sync: empty fetch — skipping delete sweep (treating as transient)", "group-sync: empty fetch — skipping archive sweep (treating as transient)",
); );
return { synced: 0, removed: 0 }; return { synced: 0, archived: 0 };
} }
const rows = entries.map((g) => ({ const rows = entries.map((g) => ({
@ -56,12 +66,16 @@ export async function syncGroupsForAccount(
name: sql`excluded.name`, name: sql`excluded.name`,
participantCount: sql`excluded.participant_count`, participantCount: sql`excluded.participant_count`,
lastSyncedAt: sql`excluded.last_synced_at`, lastSyncedAt: sql`excluded.last_synced_at`,
// If a previously-archived group reappears in the live list
// (operator was re-added, group was un-deleted, etc.), flip
// the flag back so it shows up in the picker again.
isArchived: sql`excluded.is_archived`,
}, },
}); });
logger.info( logger.info(
{ accountId, count: rows.length, removed: removed.length }, { accountId, count: rows.length, archived },
"group-sync: synced", "group-sync: synced",
); );
return { synced: rows.length, removed: removed.length }; return { synced: rows.length, archived };
} }

View File

@ -120,6 +120,44 @@ class SessionManager {
this.sessions.delete(accountId); this.sessions.delete(accountId);
} }
/**
* Tell WhatsApp to remove this device from the linked-devices list,
* then close the socket. Used by the delete-account flow so the
* operator's phone doesn't keep showing a phantom "linked device"
* pointing at a row that no longer exists. Best-effort: if the
* socket is already torn down or the logout RPC fails (network
* blip, already-disconnected, etc.) we still proceed to close +
* teardown no point stranding the delete because WhatsApp didn't
* acknowledge.
*/
async logoutAndStop(accountId: string): Promise<void> {
const timer = this.reconnectTimers.get(accountId);
if (timer) {
clearTimeout(timer);
this.reconnectTimers.delete(accountId);
}
const session = this.sessions.get(accountId);
if (!session) return;
// Suppress reconnect/handleEvent bookkeeping for the close that
// logout() emits — the row is about to be deleted entirely so
// status writes are pointless.
this.intentionalStops.add(accountId);
try {
await session.socket.logout();
} catch (err) {
logger.warn(
{ err, accountId },
"session-manager: socket.logout() failed (continuing with teardown)",
);
}
try {
await session.close();
} catch (err) {
logger.warn({ err, accountId }, "session-manager: post-logout close failed");
}
this.sessions.delete(accountId);
}
async stopAll(): Promise<void> { async stopAll(): Promise<void> {
await Promise.all([...this.sessions.keys()].map((id) => this.stop(id))); await Promise.all([...this.sessions.keys()].map((id) => this.stop(id)));
} }

27
apps/web/.env.example Normal file
View File

@ -0,0 +1,27 @@
# Required
DATABASE_URL=postgres://user:pass@host:5432/dbname
# Auth — sign cookies. 64+ random chars. Generate via scripts/gen_auth_secret.sh.
AUTH_SECRET=replace-me
# Bump to invalidate all live sessions instantly. Leave at 1 normally.
OPERATOR_TOKEN_VERSION=1
# File-storage paths inside the bot container
DATA_DIR=/data
SESSIONS_DIR=/data/sessions
MEDIA_DIR=/data/media
# Bot fan-out tuning (see apps/bot/src/env.ts)
BOT_HEALTH_PORT=8081
BOT_LOG_LEVEL=info
BOT_FIRE_CONCURRENCY=8
BOT_GROUP_CONCURRENCY=3
BOT_MAX_SEND_PER_MINUTE=40
# Web
WEB_PORT=9000
# Seed (runs once via scripts/db.sh seed)
SEED_OPERATOR_USERNAME=admin
SEED_OPERATOR_NAME=Operator

View File

@ -21,6 +21,7 @@ const nextConfig: NextConfig = {
experimental: { experimental: {
typedRoutes: true, typedRoutes: true,
serverActions: { serverActions: {
allowedOrigins: ["wabot.04080616.xyz", "localhost:9000"],
// Default Server Action body limit is 1 MB — way under WhatsApp's // Default Server Action body limit is 1 MB — way under WhatsApp's
// 100 MB document cap. Lifted to 100 MB so document uploads reach // 100 MB document cap. Lifted to 100 MB so document uploads reach
// the action; the per-kind WhatsApp validator // the action; the per-kind WhatsApp validator

View File

@ -18,6 +18,7 @@
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@serwist/next": "^9.5.11", "@serwist/next": "^9.5.11",
"@types/luxon": "^3.4.2", "@types/luxon": "^3.4.2",
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"drizzle-orm": "^0.36.0", "drizzle-orm": "^0.36.0",
@ -44,6 +45,7 @@
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.0.0", "@tailwindcss/postcss": "^4.0.0",
"@types/bcryptjs": "^3.0.0",
"@types/node": "^22.7.0", "@types/node": "^22.7.0",
"@types/pg": "^8.11.10", "@types/pg": "^8.11.10",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",

View File

@ -172,8 +172,16 @@ export async function unpairAccountAction(formData: FormData): Promise<void> {
.update(whatsappAccounts) .update(whatsappAccounts)
.set({ status: "unpaired", phoneNumber: null }) .set({ status: "unpaired", phoneNumber: null })
.where(eq(whatsappAccounts.id, accountId)); .where(eq(whatsappAccounts.id, accountId));
// Wipe synced groups too — they belong to a different WA login now. // Soft-archive synced groups instead of DELETEing. Hard delete
await db.delete(whatsappGroups).where(eq(whatsappGroups.accountId, accountId)); // failed with "violates foreign key constraint
// reminder_targets_group_id_whatsapp_groups_id_fk" whenever any
// group had ever been used in a reminder, which aborted the
// unpair. Archived groups vanish from the picker; a re-pair flips
// them back via the on-conflict upsert in syncGroupsForAccount.
await db
.update(whatsappGroups)
.set({ isArchived: true })
.where(eq(whatsappGroups.accountId, accountId));
revalidatePath("/accounts"); revalidatePath("/accounts");
revalidatePath(`/accounts/${accountId}`); revalidatePath(`/accounts/${accountId}`);
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -193,8 +201,12 @@ export async function deleteAccountAction(formData: FormData): Promise<void> {
where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, op.id)), where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, op.id)),
}); });
if (!account) return; if (!account) return;
// Stop any live session / clean session files first. // Tell the bot to logout() over the live socket FIRST (so WhatsApp
await pgNotifyBot({ type: "account.unpair", accountId }); // drops this device from the operator's linked-devices list), then
// close + remove session files. Distinct from account.unpair which
// never calls logout — keeping linked-devices clean is specific to
// the delete flow.
await pgNotifyBot({ type: "account.delete", accountId });
// Cascade FKs handle groups, reminders, runs, run_targets, messages. // Cascade FKs handle groups, reminders, runs, run_targets, messages.
await db.delete(whatsappAccounts).where(eq(whatsappAccounts.id, accountId)); await db.delete(whatsappAccounts).where(eq(whatsappAccounts.id, accountId));
revalidatePath("/accounts"); revalidatePath("/accounts");

View File

@ -0,0 +1,367 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import bcrypt from "bcryptjs";
const {
cookiesSetMock,
cookiesDeleteMock,
findUserMock,
headersGetMock,
headerStore,
checkRateLimitMock,
redirectMock,
loggerMock,
} = vi.hoisted(() => ({
cookiesSetMock: vi.fn(),
cookiesDeleteMock: vi.fn(),
findUserMock: vi.fn(),
headersGetMock: vi.fn(() => "127.0.0.1"),
headerStore: new Map<string, string>(),
checkRateLimitMock: vi.fn(),
redirectMock: vi.fn((_path: string) => {
throw new Error("redirect");
}),
loggerMock: { warn: vi.fn(), info: vi.fn() },
}));
vi.mock("next/headers", () => ({
cookies: async () => ({ set: cookiesSetMock, delete: cookiesDeleteMock }),
headers: async () => ({
get: (k: string) => {
const key = k.toLowerCase();
if (key === "x-forwarded-for") return headersGetMock();
// Tests opt-in to setting origin/host/etc. via headerStore;
// unset = null which lets hasSameOriginRequest treat the
// request as same-origin (Origin omitted = same-origin per RFC).
return headerStore.get(key) ?? null;
},
}),
}));
vi.mock("next/navigation", () => ({
redirect: (path: string) => redirectMock(path),
}));
vi.mock("@/lib/db", () => ({
db: {
query: {
operators: { findFirst: (...a: unknown[]) => findUserMock(...a) },
},
},
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: (...a: unknown[]) => checkRateLimitMock(...a),
}));
vi.mock("@/lib/logger", () => ({ logger: loggerMock }));
const SECRET = "test-secret-not-real";
beforeEach(() => {
process.env.AUTH_SECRET = SECRET;
process.env.OPERATOR_TOKEN_VERSION = "1";
cookiesSetMock.mockReset();
cookiesDeleteMock.mockReset();
findUserMock.mockReset();
checkRateLimitMock.mockReset();
checkRateLimitMock.mockResolvedValue({ limited: false, count: 1 });
redirectMock.mockReset();
redirectMock.mockImplementation((_path: string) => {
throw new Error("redirect");
});
loggerMock.warn.mockReset();
headerStore.clear();
});
import { loginAction, logoutAction } from "./auth";
const REAL_HASH = bcrypt.hashSync("correct-horse", 10);
const ADMIN_ROW = {
id: "11111111-1111-1111-1111-111111111111",
username: "admin",
role: "admin" as const,
displayName: "Admin",
defaultTimezone: "UTC",
passwordHash: REAL_HASH,
};
function fd(fields: Record<string, string>): FormData {
const f = new FormData();
for (const [k, v] of Object.entries(fields)) f.append(k, v);
return f;
}
describe("loginAction", () => {
it("issues a session cookie when credentials are correct", async () => {
findUserMock.mockResolvedValue(ADMIN_ROW);
const prevEnv = process.env.NODE_ENV;
// @ts-expect-error - test override
process.env.NODE_ENV = "production";
try {
const r = await loginAction(fd({ username: "admin", password: "correct-horse" })).catch(
(e) => e,
);
// Successful login redirects, so the redirect mock throws.
expect((r as Error).message).toBe("redirect");
expect(cookiesSetMock).toHaveBeenCalledTimes(1);
const [name, , attrs] = cookiesSetMock.mock.calls[0]!;
expect(name).toBe("session");
expect(attrs).toMatchObject({
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
maxAge: 30 * 86400,
});
} finally {
// @ts-expect-error - test restore
process.env.NODE_ENV = prevEnv;
}
});
it("sets secure=false on the cookie when NODE_ENV !== production", async () => {
findUserMock.mockResolvedValue(ADMIN_ROW);
const prevEnv = process.env.NODE_ENV;
// @ts-expect-error - test override
process.env.NODE_ENV = "development";
try {
await loginAction(fd({ username: "admin", password: "correct-horse" })).catch(() => {});
const [, , attrs] = cookiesSetMock.mock.calls[0]!;
expect(attrs).toMatchObject({ secure: false });
} finally {
// @ts-expect-error - test restore
process.env.NODE_ENV = prevEnv;
}
});
it("returns ok:false on wrong password and does NOT set a cookie", async () => {
findUserMock.mockResolvedValue(ADMIN_ROW);
const r = await loginAction(fd({ username: "admin", password: "wrong" }));
expect(r).toEqual({ ok: false, error: "Invalid username or password." });
expect(cookiesSetMock).not.toHaveBeenCalled();
expect(loggerMock.warn).toHaveBeenCalled();
});
it("returns ok:false on unknown username and STILL invokes bcrypt (timing equivalence)", async () => {
findUserMock.mockResolvedValue(undefined);
const cmpSpy = vi.spyOn(bcrypt, "compare");
await loginAction(fd({ username: "nobody", password: "irrelevant" }));
expect(cmpSpy).toHaveBeenCalled(); // compared against DUMMY_HASH
cmpSpy.mockRestore();
});
it("returns a clear error when the user has no password_hash set", async () => {
findUserMock.mockResolvedValue({ ...ADMIN_ROW, passwordHash: null });
const r = await loginAction(fd({ username: "admin", password: "anything" }));
expect(r).toEqual({
ok: false,
error: "Set a password via scripts/set-password.sh before signing in.",
});
});
it("rejects empty username or password without hitting the DB", async () => {
const r = await loginAction(fd({ username: "", password: "x" }));
expect(r).toEqual({ ok: false, error: "Username and password are required." });
expect(findUserMock).not.toHaveBeenCalled();
});
it("rejects username/password >256 chars without invoking bcrypt", async () => {
const cmpSpy = vi.spyOn(bcrypt, "compare");
const long = "x".repeat(300);
const r = await loginAction(fd({ username: long, password: long }));
expect(r).toEqual({ ok: false, error: "Input too long." });
expect(cmpSpy).not.toHaveBeenCalled();
cmpSpy.mockRestore();
});
it("matches username case-insensitively", async () => {
findUserMock.mockImplementation(async () => ADMIN_ROW);
await loginAction(fd({ username: "ADMIN", password: "correct-horse" })).catch(() => {});
expect(findUserMock).toHaveBeenCalled();
});
it("returns 429 when the rate limit is exhausted", async () => {
checkRateLimitMock.mockResolvedValue({ limited: true, count: 11 });
const r = await loginAction(fd({ username: "admin", password: "correct-horse" }));
expect(r).toEqual({ ok: false, error: "Too many attempts. Try again later." });
expect(findUserMock).not.toHaveBeenCalled();
});
it("logs the failed attempt with username and ip but never the password", async () => {
findUserMock.mockResolvedValue(ADMIN_ROW);
headersGetMock.mockReturnValue("203.0.113.5, 10.0.0.1");
await loginAction(fd({ username: "admin", password: "wrong" }));
const [meta, msg] = loggerMock.warn.mock.calls[0]!;
expect(meta).toMatchObject({ username: "admin", ip: "203.0.113.5" });
expect(JSON.stringify(meta)).not.toContain("wrong");
expect(msg).toMatch(/login failed/i);
});
it("redirects to safeRedirect(next) on success", async () => {
findUserMock.mockResolvedValue(ADMIN_ROW);
await loginAction(fd({
username: "admin",
password: "correct-horse",
next: "/dashboard",
})).catch(() => {});
expect(redirectMock).toHaveBeenCalledWith("/dashboard");
});
it("redirects to / when next is unsafe", async () => {
findUserMock.mockResolvedValue(ADMIN_ROW);
await loginAction(fd({
username: "admin",
password: "correct-horse",
next: "//evil.com",
})).catch(() => {});
expect(redirectMock).toHaveBeenCalledWith("/");
});
});
describe("logoutAction", () => {
it("clears the session cookie and redirects to /login", async () => {
await logoutAction().catch(() => {});
expect(cookiesDeleteMock).toHaveBeenCalledWith("session");
expect(redirectMock).toHaveBeenCalledWith("/login");
});
it("is idempotent — clears the cookie even when no session exists", async () => {
// Real-world: a user with an expired cookie clicks Sign out. cookies.delete
// doesn't care about pre-existing state and we still issue the redirect.
cookiesDeleteMock.mockReset();
await logoutAction().catch(() => {});
expect(cookiesDeleteMock).toHaveBeenCalledTimes(1);
expect(cookiesDeleteMock).toHaveBeenCalledWith("session");
});
});
describe("loginAction — additional cases", () => {
it("issues a cookie that decrypts to role='user' for a non-admin user", async () => {
findUserMock.mockResolvedValue({ ...ADMIN_ROW, role: "user" });
await loginAction(fd({ username: "alice", password: "correct-horse" })).catch(() => {});
expect(cookiesSetMock).toHaveBeenCalledTimes(1);
const [, cookieValue] = cookiesSetMock.mock.calls[0]!;
// The cookie is now AES-GCM encrypted, so we can't peel the payload
// off raw — decrypt with the same secret loginAction used. This
// also doubles as a confidentiality smoke test: 'user'/'alice'
// must NOT appear verbatim in the cookie bytes.
expect(cookieValue as string).not.toContain("alice");
expect(cookieValue as string).not.toContain("user");
const { verifySession } = await import("@/lib/auth-cookie");
const decoded = await verifySession(cookieValue as string, SECRET);
expect(decoded?.role).toBe("user");
expect(decoded?.userId).toBe(ADMIN_ROW.id);
});
it("rejects when the user row has an unrecognised role string", async () => {
findUserMock.mockResolvedValue({ ...ADMIN_ROW, role: "robot" });
const r = await loginAction(fd({ username: "admin", password: "correct-horse" }));
expect(r).toEqual({ ok: false, error: "Account is not enabled." });
expect(cookiesSetMock).not.toHaveBeenCalled();
});
it("returns ok:false when AUTH_SECRET is unset (server misconfig)", async () => {
findUserMock.mockResolvedValue(ADMIN_ROW);
const prev = process.env.AUTH_SECRET;
delete process.env.AUTH_SECRET;
try {
const r = await loginAction(fd({ username: "admin", password: "correct-horse" }));
expect(r).toEqual({ ok: false, error: "Server is not configured for sign-in." });
expect(cookiesSetMock).not.toHaveBeenCalled();
} finally {
process.env.AUTH_SECRET = prev;
}
});
it("treats whitespace-only username as missing input", async () => {
const r = await loginAction(fd({ username: " ", password: "x" }));
expect(r).toEqual({ ok: false, error: "Username and password are required." });
expect(findUserMock).not.toHaveBeenCalled();
});
it("uses three rate-limit layers: per-IP, per-username, global", async () => {
findUserMock.mockResolvedValue(ADMIN_ROW);
headersGetMock.mockReturnValue("198.51.100.42");
await loginAction(fd({ username: "Admin", password: "correct-horse" })).catch(() => {});
// Three checkRateLimit calls fired in parallel via Promise.all,
// in this order: ip / user / global.
expect(checkRateLimitMock).toHaveBeenCalledTimes(3);
const keys = checkRateLimitMock.mock.calls.map((c) => c[0] as string);
expect(keys[0]).toBe("login:198.51.100.42");
// Username key is normalised to lowercase so "Admin" and "admin"
// share the same bucket — otherwise an attacker rotating case
// would dodge per-username throttling.
expect(keys[1]).toBe("login-user:admin");
expect(keys[2]).toBe("login-global");
});
it("rejects login when the per-username limit alone is hit (rotating-IPs attacker)", async () => {
findUserMock.mockResolvedValue(ADMIN_ROW);
// First call (ip) passes, second (user) is over, third (global) passes.
checkRateLimitMock
.mockResolvedValueOnce({ limited: false, count: 1 })
.mockResolvedValueOnce({ limited: true, count: 6 })
.mockResolvedValueOnce({ limited: false, count: 5 });
const r = await loginAction(fd({ username: "admin", password: "correct-horse" }));
expect(r).toEqual({ ok: false, error: "Too many attempts. Try again later." });
expect(findUserMock).not.toHaveBeenCalled();
// Logger captures which limit tripped so we can tune thresholds
// without leaking the answer to the attacker.
const meta = loggerMock.warn.mock.calls.find((c) => c[1] === "login rate-limited")?.[0];
expect(meta).toMatchObject({ limit: "username" });
});
it("rejects login when the global limit alone is hit (everyone-pile-on backstop)", async () => {
findUserMock.mockResolvedValue(ADMIN_ROW);
checkRateLimitMock
.mockResolvedValueOnce({ limited: false, count: 1 })
.mockResolvedValueOnce({ limited: false, count: 1 })
.mockResolvedValueOnce({ limited: true, count: 101 });
const r = await loginAction(fd({ username: "admin", password: "correct-horse" }));
expect(r).toEqual({ ok: false, error: "Too many attempts. Try again later." });
const meta = loggerMock.warn.mock.calls.find((c) => c[1] === "login rate-limited")?.[0];
expect(meta).toMatchObject({ limit: "global" });
});
it("rejects a cross-origin POST before checking credentials", async () => {
findUserMock.mockResolvedValue(ADMIN_ROW);
headerStore.set("origin", "https://attacker.example");
headerStore.set("host", "wabot.04080616.xyz");
const r = await loginAction(fd({ username: "admin", password: "correct-horse" }));
expect(r).toEqual({ ok: false, error: "Cross-origin request blocked." });
expect(checkRateLimitMock).not.toHaveBeenCalled();
expect(findUserMock).not.toHaveBeenCalled();
});
it("accepts a same-origin POST (Origin host matches Host header)", async () => {
findUserMock.mockResolvedValue(ADMIN_ROW);
headerStore.set("origin", "https://wabot.04080616.xyz");
headerStore.set("host", "wabot.04080616.xyz");
await loginAction(fd({ username: "admin", password: "correct-horse" })).catch(() => {});
// Got past the origin check → DB lookup ran.
expect(findUserMock).toHaveBeenCalled();
});
it("treats a missing Origin header as same-origin (legitimate native form POST)", async () => {
// Browsers don't always send Origin (e.g. plain top-level form
// submissions). Refusing those would brick login on some clients.
findUserMock.mockResolvedValue(ADMIN_ROW);
headerStore.delete("origin");
headerStore.set("host", "wabot.04080616.xyz");
await loginAction(fd({ username: "admin", password: "correct-horse" })).catch(() => {});
expect(findUserMock).toHaveBeenCalled();
});
it("rejects when Origin is malformed (non-URL string)", async () => {
findUserMock.mockResolvedValue(ADMIN_ROW);
headerStore.set("origin", "not a url");
headerStore.set("host", "wabot.04080616.xyz");
const r = await loginAction(fd({ username: "admin", password: "correct-horse" }));
expect(r).toEqual({ ok: false, error: "Cross-origin request blocked." });
});
it("still hits the DB and bcrypt on the unknown-user path so timing equivalence holds", async () => {
findUserMock.mockResolvedValue(undefined);
const cmpSpy = vi.spyOn(bcrypt, "compare");
await loginAction(fd({ username: "ghost", password: "anything" }));
// findFirst was called even though we know the user doesn't exist.
expect(findUserMock).toHaveBeenCalledTimes(1);
expect(cmpSpy).toHaveBeenCalled();
cmpSpy.mockRestore();
});
});

View File

@ -0,0 +1,182 @@
"use server";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import bcrypt from "bcryptjs";
import { sql } from "drizzle-orm";
import { db } from "@/lib/db";
import {
COOKIE_NAME,
DEFAULT_TTL_SECONDS,
signSession,
type Role,
} from "@/lib/auth-cookie";
import { checkRateLimit } from "@/lib/rate-limit";
import { safeRedirect } from "@/lib/safe-redirect";
import { logger } from "@/lib/logger";
export type LoginResult = { ok: true } | { ok: false; error: string };
const MAX_FIELD_LEN = 256;
// Precomputed bcryptjs hash of the throwaway string "x", cost 10.
// Compared against on the user-not-found path so timing matches the
// wrong-password path. Generating fresh per request would double the
// bcrypt work and create its own timing signal.
const DUMMY_HASH = "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy";
async function clientIp(): Promise<string> {
const h = await headers();
const fwd = h.get("x-forwarded-for");
if (fwd) return fwd.split(",")[0]!.trim();
return h.get("x-real-ip") ?? "unknown";
}
/**
* Compare the inbound Origin to the request's Host. Server Actions
* already get an Origin check via Next 16's
* `serverActions.allowedOrigins`, but that's a global config running
* the same comparison here is cheap belt-and-braces and lets us log
* mismatches with action-level context. Returns true when:
* - no Origin header is present (same-origin POSTs from the same
* server), OR
* - Origin's host matches the Host header (same-origin)
* Anything else (cross-origin POST, malformed Origin, etc.) false.
*/
async function hasSameOriginRequest(): Promise<boolean> {
const h = await headers();
const origin = h.get("origin");
if (!origin) return true; // RFC: same-origin requests may omit Origin
const host = h.get("host");
if (!host) return false;
try {
const u = new URL(origin);
return u.host === host;
} catch {
return false;
}
}
export async function loginAction(formData: FormData): Promise<LoginResult> {
const username = (formData.get("username") ?? "").toString();
const password = (formData.get("password") ?? "").toString();
const next = (formData.get("next") ?? "").toString();
if (!username.trim() || !password) {
return { ok: false, error: "Username and password are required." };
}
if (username.length > MAX_FIELD_LEN || password.length > MAX_FIELD_LEN) {
return { ok: false, error: "Input too long." };
}
// Action-level Origin check. Next 16's serverActions.allowedOrigins
// already gates this at the framework boundary, but doing it here
// with action context lets us log the mismatch and surface a clean
// error instead of relying on the global config alone.
if (!(await hasSameOriginRequest())) {
logger.warn({}, "login rejected: cross-origin request");
return { ok: false, error: "Cross-origin request blocked." };
}
const ip = await clientIp();
// Three-layer rate limit:
// per-IP — typical brute-forcer
// per-username — attacker who rotates IPs (X-Forwarded-For
// spoofing, residential proxy pool) but pounds
// a single account
// global — backstop. If the attacker controls enough
// IP+username combos to slip past the first two,
// this caps the total login attempts per minute
// across the install. Lock occurs at the FIRST
// limit hit; we don't reveal which one.
const usernameKey = username.trim().toLowerCase();
const [rlIp, rlUser, rlGlobal] = await Promise.all([
checkRateLimit(`login:${ip}`, { max: 10, windowSec: 300 }),
checkRateLimit(`login-user:${usernameKey}`, { max: 5, windowSec: 900 }),
checkRateLimit(`login-global`, { max: 100, windowSec: 60 }),
]);
if (rlIp.limited || rlUser.limited || rlGlobal.limited) {
logger.warn(
{
ip,
username: usernameKey,
limit: rlIp.limited ? "ip" : rlUser.limited ? "username" : "global",
},
"login rate-limited",
);
return { ok: false, error: "Too many attempts. Try again later." };
}
const row = await db.query.operators.findFirst({
where: (o) => sql`lower(${o.username}) = lower(${username})`,
});
// User exists but has no password configured: this is a server-side
// setup error, not a credential mismatch. Surface a distinct message
// so the operator knows to run scripts/set-password.sh. We still ran
// the DB lookup, so the username-enumeration concern is not relevant
// here (the attacker would already need a known username).
if (row && row.passwordHash === null) {
return {
ok: false,
error: "Set a password via scripts/set-password.sh before signing in.",
};
}
// Run bcrypt regardless to keep the user-not-found path timing-
// equivalent to the wrong-password path.
const hash = row?.passwordHash ?? DUMMY_HASH;
const ok = await bcrypt.compare(password, hash);
if (!row || !ok) {
logger.warn({ username, ip }, "login failed");
return { ok: false, error: "Invalid username or password." };
}
if (row.role !== "admin" && row.role !== "user") {
return { ok: false, error: "Account is not enabled." };
}
const secret = process.env.AUTH_SECRET;
if (!secret) {
logger.warn({}, "AUTH_SECRET unset — cannot issue cookie");
return { ok: false, error: "Server is not configured for sign-in." };
}
const v = Number(process.env.OPERATOR_TOKEN_VERSION ?? "1");
const now = Math.floor(Date.now() / 1000);
const cookie = await signSession(
{
userId: row.id,
role: row.role as Role,
iat: now,
exp: now + DEFAULT_TTL_SECONDS,
v,
},
secret,
);
const jar = await cookies();
// Secure: only require https in production. In dev we hit
// http://localhost:9000 directly, and Firefox/Safari silently drop
// Set-Cookie when Secure is set on http origins (Chrome has a
// localhost exception, others don't), which manifested as the
// session cookie never being persisted across requests.
jar.set(COOKIE_NAME, cookie, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: DEFAULT_TTL_SECONDS,
});
// Typed-routes is on (next.config.ts experimental.typedRoutes); the
// `next` value is a runtime string from the form so we cast through any.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
redirect(safeRedirect(next) as any);
}
export async function logoutAction(): Promise<void> {
const jar = await cookies();
jar.delete(COOKIE_NAME);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
redirect("/login" as any);
}

View File

@ -33,6 +33,12 @@ export async function sendTestAction(_prev: unknown, formData: FormData): Promis
return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input" }; return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid input" };
} }
const groupId = parsed.data.groupId;
const groupRl = await checkRateLimit(`send-test:${groupId}`, { max: 3, windowSec: 60 });
if (groupRl.limited) {
return { ok: false, error: "Too many tests for this group. Try again later." };
}
const op = await getSeededOperator(); const op = await getSeededOperator();
const group = await db.query.whatsappGroups.findFirst({ const group = await db.query.whatsappGroups.findFirst({
where: (g, { eq }) => eq(g.id, parsed.data.groupId), where: (g, { eq }) => eq(g.id, parsed.data.groupId),

View File

@ -271,7 +271,7 @@ const createReminderSchema = z
path: ["messages"], path: ["messages"],
}, },
) )
.refine((d) => (d.deliveryWindowStartHour ?? 6) < (d.deliveryWindowEndHour ?? 18), { .refine((d) => (d.deliveryWindowStartHour ?? 6) < (d.deliveryWindowEndHour ?? 24), {
message: "Delivery window start must be earlier than end", message: "Delivery window start must be earlier than end",
path: ["deliveryWindowStartHour"], path: ["deliveryWindowStartHour"],
}); });
@ -328,7 +328,11 @@ export async function createReminderAction(
timezone, timezone,
} = parsed.data; } = parsed.data;
const deliveryWindowStartHour = parsed.data.deliveryWindowStartHour ?? 6; const deliveryWindowStartHour = parsed.data.deliveryWindowStartHour ?? 6;
const deliveryWindowEndHour = parsed.data.deliveryWindowEndHour ?? 18; // 24 = "no deadline" (off). The wizard sends 24 explicitly when the
// operator hasn't ticked the optional "Pause sending by" checkbox;
// fall back to 24 here so legacy payloads / direct API calls don't
// accidentally enable the deadline at 6pm.
const deliveryWindowEndHour = parsed.data.deliveryWindowEndHour ?? 24;
const parts = resolveMessageParts(parsed.data); const parts = resolveMessageParts(parsed.data);
const op = await getSeededOperator(); const op = await getSeededOperator();
@ -442,7 +446,11 @@ export async function updateReminderAction(
timezone, timezone,
} = parsed.data; } = parsed.data;
const deliveryWindowStartHour = parsed.data.deliveryWindowStartHour ?? 6; const deliveryWindowStartHour = parsed.data.deliveryWindowStartHour ?? 6;
const deliveryWindowEndHour = parsed.data.deliveryWindowEndHour ?? 18; // 24 = "no deadline" (off). The wizard sends 24 explicitly when the
// operator hasn't ticked the optional "Pause sending by" checkbox;
// fall back to 24 here so legacy payloads / direct API calls don't
// accidentally enable the deadline at 6pm.
const deliveryWindowEndHour = parsed.data.deliveryWindowEndHour ?? 24;
const parts = resolveMessageParts(parsed.data); const parts = resolveMessageParts(parsed.data);
const op = await getSeededOperator(); const op = await getSeededOperator();
@ -563,6 +571,12 @@ export type ResumeReminderRunResult = { ok: true } | { ok: false; error: string
export async function resumeReminderRunAction(input: { export async function resumeReminderRunAction(input: {
runId: string; runId: string;
}): Promise<ResumeReminderRunResult> { }): Promise<ResumeReminderRunResult> {
const ip =
(await headers()).get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
const rl = await checkRateLimit(`reminder-run:${ip}`, { max: 30, windowSec: 10 });
if (rl.limited) {
return { ok: false, error: "Too many requests. Try again later." };
}
const op = await getSeededOperator(); const op = await getSeededOperator();
const parsed = runIdSchema.safeParse(input); const parsed = runIdSchema.safeParse(input);
if (!parsed.success) { if (!parsed.success) {
@ -613,6 +627,12 @@ export type CancelReminderRunResult = { ok: true } | { ok: false; error: string
export async function cancelReminderRunAction(input: { export async function cancelReminderRunAction(input: {
runId: string; runId: string;
}): Promise<CancelReminderRunResult> { }): Promise<CancelReminderRunResult> {
const ip =
(await headers()).get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
const rl = await checkRateLimit(`reminder-run:${ip}`, { max: 30, windowSec: 10 });
if (rl.limited) {
return { ok: false, error: "Too many requests. Try again later." };
}
const op = await getSeededOperator(); const op = await getSeededOperator();
const parsed = runIdSchema.safeParse(input); const parsed = runIdSchema.safeParse(input);
if (!parsed.success) { if (!parsed.success) {

View File

@ -0,0 +1,192 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const {
requireAdminMock,
findUserMock,
findManyAdminsMock,
insertReturningMock,
updateMock,
deleteMock,
checkRateLimitMock,
revalidateMock,
} = vi.hoisted(() => ({
requireAdminMock: vi.fn(),
findUserMock: vi.fn(),
findManyAdminsMock: vi.fn(),
insertReturningMock: vi.fn(),
updateMock: vi.fn(),
deleteMock: vi.fn(),
checkRateLimitMock: vi.fn(),
revalidateMock: vi.fn(),
}));
vi.mock("@/lib/auth", async () => {
const actual = await vi.importActual<typeof import("@/lib/auth")>("@/lib/auth");
return {
...actual,
requireAdmin: () => requireAdminMock(),
};
});
vi.mock("@/lib/db", () => ({
db: {
query: {
operators: {
findFirst: (...a: unknown[]) => findUserMock(...a),
findMany: (...a: unknown[]) => findManyAdminsMock(...a),
},
},
insert: () => ({ values: () => ({ returning: async () => insertReturningMock() }) }),
update: () => ({ set: () => ({ where: async (...a: unknown[]) => updateMock(...a) }) }),
delete: () => ({ where: async (...a: unknown[]) => deleteMock(...a) }),
},
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: (...a: unknown[]) => checkRateLimitMock(...a),
}));
vi.mock("next/cache", () => ({ revalidatePath: revalidateMock }));
vi.mock("next/headers", () => ({
headers: async () => ({ get: () => "127.0.0.1" }),
}));
beforeEach(() => {
requireAdminMock.mockReset();
findUserMock.mockReset();
findManyAdminsMock.mockReset();
insertReturningMock.mockReset();
updateMock.mockReset();
deleteMock.mockReset();
checkRateLimitMock.mockReset();
revalidateMock.mockReset();
checkRateLimitMock.mockResolvedValue({ limited: false, count: 1 });
});
const ADMIN = {
id: "11111111-1111-1111-1111-111111111111",
username: "admin",
role: "admin" as const,
};
const OTHER_ADMIN = { ...ADMIN, id: "22222222-2222-2222-2222-222222222222", username: "alice" };
const USER = { ...ADMIN, id: "33333333-3333-3333-3333-333333333333", username: "bob", role: "user" as const };
import {
createUserAction,
setUserRoleAction,
resetUserPasswordAction,
deleteUserAction,
} from "./users";
describe("createUserAction", () => {
it("admin can create a user with role 'user'", async () => {
requireAdminMock.mockResolvedValue(ADMIN);
insertReturningMock.mockResolvedValue([{ id: USER.id }]);
const r = await createUserAction({
username: "bob",
password: "longpw1",
role: "user",
});
expect(r).toEqual({ ok: true, userId: USER.id });
});
it("rejects username/password under length limits", async () => {
requireAdminMock.mockResolvedValue(ADMIN);
const r = await createUserAction({ username: "a", password: "shortpw", role: "user" });
expect(r.ok).toBe(false);
});
});
describe("setUserRoleAction — self-demote guard", () => {
it("admin demoting themselves is rejected", async () => {
requireAdminMock.mockResolvedValue(ADMIN);
findUserMock.mockResolvedValue(ADMIN);
const r = await setUserRoleAction({ userId: ADMIN.id, role: "user" });
expect(r).toEqual({
ok: false,
error: "You can't demote your own account.",
});
expect(updateMock).not.toHaveBeenCalled();
});
it("admin demoting another admin is allowed when others remain", async () => {
requireAdminMock.mockResolvedValue(ADMIN);
findUserMock.mockResolvedValue(OTHER_ADMIN);
findManyAdminsMock.mockResolvedValue([ADMIN, OTHER_ADMIN]); // 2 admins
const r = await setUserRoleAction({ userId: OTHER_ADMIN.id, role: "user" });
expect(r).toEqual({ ok: true });
});
it("admin demoting the last remaining admin is rejected", async () => {
requireAdminMock.mockResolvedValue(ADMIN);
findUserMock.mockResolvedValue(OTHER_ADMIN);
findManyAdminsMock.mockResolvedValue([OTHER_ADMIN]); // only OTHER_ADMIN is an admin
const r = await setUserRoleAction({ userId: OTHER_ADMIN.id, role: "user" });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toMatch(/last admin/i);
});
});
describe("deleteUserAction", () => {
it("admin deleting themselves is rejected", async () => {
requireAdminMock.mockResolvedValue(ADMIN);
findUserMock.mockResolvedValue(ADMIN);
const r = await deleteUserAction({ userId: ADMIN.id });
expect(r).toEqual({ ok: false, error: "You can't delete your own account." });
expect(deleteMock).not.toHaveBeenCalled();
});
it("admin deleting another user is allowed", async () => {
requireAdminMock.mockResolvedValue(ADMIN);
findUserMock.mockResolvedValue(USER);
findManyAdminsMock.mockResolvedValue([ADMIN, OTHER_ADMIN]);
const r = await deleteUserAction({ userId: USER.id });
expect(r).toEqual({ ok: true });
});
it("admin deleting the last admin is rejected", async () => {
requireAdminMock.mockResolvedValue(ADMIN);
findUserMock.mockResolvedValue(OTHER_ADMIN);
findManyAdminsMock.mockResolvedValue([OTHER_ADMIN]); // 1 admin total
const r = await deleteUserAction({ userId: OTHER_ADMIN.id });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toMatch(/last admin/i);
});
});
describe("resetUserPasswordAction", () => {
it("admin can reset another user's password", async () => {
requireAdminMock.mockResolvedValue(ADMIN);
findUserMock.mockResolvedValue(USER);
const r = await resetUserPasswordAction({ userId: USER.id, newPassword: "alpha7!" });
expect(r).toEqual({ ok: true });
expect(updateMock).toHaveBeenCalled();
});
it("rejects too-short passwords", async () => {
requireAdminMock.mockResolvedValue(ADMIN);
findUserMock.mockResolvedValue(USER);
const r = await resetUserPasswordAction({ userId: USER.id, newPassword: "ab1" });
expect(r.ok).toBe(false);
});
it("rejects letters-only passwords (no number or symbol)", async () => {
requireAdminMock.mockResolvedValue(ADMIN);
findUserMock.mockResolvedValue(USER);
const r = await resetUserPasswordAction({
userId: USER.id,
newPassword: "abcdefghij",
});
expect(r).toEqual({
ok: false,
error: "Password must mix letters with numbers or symbols.",
});
});
it("rejects digits-only passwords", async () => {
requireAdminMock.mockResolvedValue(ADMIN);
findUserMock.mockResolvedValue(USER);
const r = await resetUserPasswordAction({
userId: USER.id,
newPassword: "1234567890",
});
expect(r.ok).toBe(false);
});
});

View File

@ -0,0 +1,139 @@
"use server";
import { eq } from "drizzle-orm";
import bcrypt from "bcryptjs";
import { revalidatePath } from "next/cache";
import { headers } from "next/headers";
import { operators } from "@cmbot/db";
import { db } from "@/lib/db";
import { requireAdmin } from "@/lib/auth";
import { checkRateLimit } from "@/lib/rate-limit";
import { validatePassword } from "@/lib/password-policy";
const MAX_FIELD_LEN = 256;
async function rateLimit(key: string): Promise<{ limited: boolean }> {
const h = await headers();
const ip =
h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
return checkRateLimit(`${key}:${ip}`, { max: 5, windowSec: 60 });
}
export type CreateUserResult =
| { ok: true; userId: string }
| { ok: false; error: string };
export async function createUserAction(input: {
username: string;
password: string;
role: "admin" | "user";
}): Promise<CreateUserResult> {
await requireAdmin();
const rl = await rateLimit("create-user");
if (rl.limited) return { ok: false, error: "Too many attempts. Try again later." };
const u = input.username.trim();
if (u.length < 3 || u.length > MAX_FIELD_LEN) {
return { ok: false, error: "Username must be 3..256 chars." };
}
const pwCheck = validatePassword(input.password);
if (!pwCheck.ok) return pwCheck;
if (input.role !== "admin" && input.role !== "user") {
return { ok: false, error: "Role must be admin or user." };
}
const hash = await bcrypt.hash(input.password, 12);
const [row] = await db
.insert(operators)
.values({
username: u,
passwordHash: hash,
displayName: u,
role: input.role,
defaultTimezone: "Asia/Kuala_Lumpur",
})
.returning({ id: operators.id });
revalidatePath("/settings/users");
return { ok: true, userId: row!.id };
}
export type SetRoleResult = { ok: true } | { ok: false; error: string };
export async function setUserRoleAction(input: {
userId: string;
role: "admin" | "user";
}): Promise<SetRoleResult> {
const me = await requireAdmin();
if (input.userId === me.id && input.role !== "admin") {
return { ok: false, error: "You can't demote your own account." };
}
const target = await db.query.operators.findFirst({
where: (o, { eq: dEq }) => dEq(o.id, input.userId),
});
if (!target) return { ok: false, error: "User not found." };
// If we're demoting an admin, make sure at least one admin remains.
if (target.role === "admin" && input.role !== "admin") {
const admins = await db.query.operators.findMany({
where: (o, { eq: dEq }) => dEq(o.role, "admin"),
});
if (admins.length <= 1) {
return { ok: false, error: "Can't demote the last admin. Promote another user first." };
}
}
await db
.update(operators)
.set({ role: input.role })
.where(eq(operators.id, input.userId));
revalidatePath("/settings/users");
return { ok: true };
}
export type DeleteUserResult = { ok: true } | { ok: false; error: string };
export async function deleteUserAction(input: {
userId: string;
}): Promise<DeleteUserResult> {
const me = await requireAdmin();
if (input.userId === me.id) {
return { ok: false, error: "You can't delete your own account." };
}
const target = await db.query.operators.findFirst({
where: (o, { eq: dEq }) => dEq(o.id, input.userId),
});
if (!target) return { ok: false, error: "User not found." };
if (target.role === "admin") {
const admins = await db.query.operators.findMany({
where: (o, { eq: dEq }) => dEq(o.role, "admin"),
});
if (admins.length <= 1) {
return { ok: false, error: "Can't delete the last admin. Promote another user first." };
}
}
await db.delete(operators).where(eq(operators.id, input.userId));
revalidatePath("/settings/users");
return { ok: true };
}
export type ResetPasswordResult = { ok: true } | { ok: false; error: string };
export async function resetUserPasswordAction(input: {
userId: string;
newPassword: string;
}): Promise<ResetPasswordResult> {
await requireAdmin();
const rl = await rateLimit("reset-password");
if (rl.limited) return { ok: false, error: "Too many attempts. Try again later." };
const pwCheck = validatePassword(input.newPassword);
if (!pwCheck.ok) return pwCheck;
const target = await db.query.operators.findFirst({
where: (o, { eq: dEq }) => dEq(o.id, input.userId),
});
if (!target) return { ok: false, error: "User not found." };
const hash = await bcrypt.hash(input.newPassword, 12);
await db
.update(operators)
.set({ passwordHash: hash })
.where(eq(operators.id, input.userId));
revalidatePath("/settings/users");
return { ok: true };
}

View File

@ -0,0 +1,104 @@
"use client";
import { useState, useTransition } from "react";
import { ChevronRightIcon, Loader2Icon, Trash2Icon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { deleteAccountAction } from "@/actions/accounts";
interface DeleteAccountCardProps {
accountId: string;
accountLabel: string;
}
export function DeleteAccountCard({
accountId,
accountLabel,
}: DeleteAccountCardProps) {
const [open, setOpen] = useState(false);
const [pending, start] = useTransition();
function confirm() {
start(async () => {
const fd = new FormData();
fd.append("accountId", accountId);
await deleteAccountAction(fd);
setOpen(false);
});
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<Card
role="button"
tabIndex={0}
aria-label="Delete account"
onClick={() => setOpen(true)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setOpen(true);
}
}}
className="transition-all hover:shadow-md hover:ring-destructive/30 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive focus-visible:ring-offset-2"
>
<CardContent className="flex items-center justify-between gap-4 py-4">
<div className="flex items-center gap-3">
<div className="flex size-9 items-center justify-center rounded-lg bg-destructive/10">
<Trash2Icon className="size-4 text-destructive" />
</div>
<div>
<p className="text-sm font-medium text-destructive">
Delete Account
</p>
<p className="text-xs text-muted-foreground">
Remove the account and all its reminders, groups, and history
</p>
</div>
</div>
<ChevronRightIcon className="size-4 text-muted-foreground/60" />
</CardContent>
</Card>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete this account permanently?</DialogTitle>
<DialogDescription>
<strong>{accountLabel}</strong> will be removed along with its
synced groups, scheduled reminders, and all run history. This
cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="ghost" size="sm">
Cancel
</Button>
</DialogClose>
<Button
type="button"
variant="destructive"
size="sm"
disabled={pending}
onClick={confirm}
>
{pending ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<Trash2Icon className="size-4" />
)}
Yes, delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -4,7 +4,6 @@ import {
ArrowLeftIcon, ArrowLeftIcon,
SearchIcon, SearchIcon,
UsersIcon, UsersIcon,
RefreshCwIcon,
Users2Icon, Users2Icon,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -16,6 +15,7 @@ import {
} from "@/components/ui/card"; } from "@/components/ui/card";
import { getSeededOperator } from "@/lib/operator"; import { getSeededOperator } from "@/lib/operator";
import { listGroupsForAccount } from "@/lib/queries"; import { listGroupsForAccount } from "@/lib/queries";
import { RefreshGroupsClient } from "./refresh-groups-client";
interface Props { interface Props {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
@ -57,13 +57,7 @@ export default async function GroupsListPage({ params, searchParams }: Props) {
</Badge> </Badge>
</div> </div>
{/* Refresh button — no-op placeholder, wired in Task 17 */} <RefreshGroupsClient accountId={account.id} />
<form action={async () => { "use server"; /* wired in Task 17 */ }}>
<Button type="submit" variant="outline" size="sm" className="shrink-0">
<RefreshCwIcon />
Refresh Groups
</Button>
</form>
</div> </div>
{/* Search */} {/* Search */}

View File

@ -0,0 +1,68 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Loader2Icon, RefreshCwIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useEvents } from "@/hooks/use-events";
import { syncGroupsAction } from "@/actions/accounts";
interface RefreshGroupsClientProps {
accountId: string;
}
/**
* Two-stage refresh button:
* 1. Click server action pgNotifies the bot to start a sync.
* 2. Bot finishes emits `groups.synced` over SSE router.refresh()
* re-fetches the page so the new rows appear without the operator
* having to reload manually.
*
* The button stays in its "syncing" state until either the
* `groups.synced` event arrives for this account or 15 s pass (so a
* disconnected bot doesn't strand the spinner forever).
*/
export function RefreshGroupsClient({ accountId }: RefreshGroupsClientProps) {
const router = useRouter();
const [pending, start] = useTransition();
const [waiting, setWaiting] = useState(false);
useEvents({
"groups.synced": (data) => {
if (data.accountId !== accountId) return;
setWaiting(false);
router.refresh();
},
});
function trigger() {
start(async () => {
const fd = new FormData();
fd.append("accountId", accountId);
await syncGroupsAction(fd);
setWaiting(true);
// Belt-and-braces: if the bot is unreachable or the SSE channel
// drops, drop the spinner after 15 s instead of leaving it stuck.
window.setTimeout(() => setWaiting(false), 15_000);
});
}
const busy = pending || waiting;
return (
<Button
type="button"
variant="outline"
size="sm"
className="shrink-0"
disabled={busy}
onClick={trigger}
>
{busy ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<RefreshCwIcon className="size-4" />
)}
{busy ? "Syncing…" : "Refresh Groups"}
</Button>
);
}

View File

@ -2,7 +2,6 @@ import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { import {
UsersIcon, UsersIcon,
Trash2Icon,
ArrowLeftIcon, ArrowLeftIcon,
SmartphoneIcon, SmartphoneIcon,
CalendarIcon, CalendarIcon,
@ -10,7 +9,6 @@ import {
DatabaseIcon, DatabaseIcon,
PencilIcon, PencilIcon,
PowerIcon, PowerIcon,
PowerOffIcon,
ChevronRightIcon, ChevronRightIcon,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -20,23 +18,12 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { AccountStatusBadge } from "@/components/account-status-badge"; import { AccountStatusBadge } from "@/components/account-status-badge";
import { getSeededOperator } from "@/lib/operator"; import { getSeededOperator } from "@/lib/operator";
import { getAccount } from "@/lib/queries"; import { getAccount } from "@/lib/queries";
import { import { pairAccountAction } from "@/actions/accounts";
unpairAccountAction, import { DeleteAccountCard } from "./delete-account-card";
pairAccountAction, import { UnpairAccountCard } from "./unpair-account-card";
deleteAccountAction,
} from "@/actions/accounts";
interface AccountDetailPageProps { interface AccountDetailPageProps {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
@ -156,102 +143,11 @@ export default async function AccountDetailPage({ params }: AccountDetailPagePro
</Card> </Card>
</Link> </Link>
{/* Unpair transparent <button> overlay opens the dialog <UnpairAccountCard accountId={account.id} accountLabel={account.label} />
so we don't pass button-specific props onto the Card div
(Radix asChild does that and it produces a hydration
mismatch on a div). */}
<Dialog>
<Card className="relative transition-all hover:shadow-md hover:ring-amber-500/30 cursor-pointer">
<CardContent className="flex items-center justify-between gap-4 py-4">
<div className="flex items-center gap-3">
<div className="flex size-9 items-center justify-center rounded-lg bg-amber-500/10">
<PowerOffIcon className="size-4 text-amber-600 dark:text-amber-400" />
</div>
<div>
<p className="text-sm font-medium">Unpair</p>
<p className="text-xs text-muted-foreground">
Disconnect from WhatsApp; keep the account so you can re-pair later
</p>
</div>
</div>
<ChevronRightIcon className="size-4 text-muted-foreground/60" />
</CardContent>
<DialogTrigger asChild>
<button
type="button"
aria-label="Unpair WhatsApp"
className="absolute inset-0 w-full rounded-xl bg-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
/>
</DialogTrigger>
</Card>
<DialogContent>
<DialogHeader>
<DialogTitle>Unpair this account?</DialogTitle>
<DialogDescription>
<strong>{account.label}</strong> will disconnect from WhatsApp and
scheduled reminders using it will stop firing until you re-pair.
The account itself is kept; reminders and other data are not deleted.
</DialogDescription>
</DialogHeader>
<DialogFooter showCloseButton>
<form action={unpairAccountAction}>
<input type="hidden" name="accountId" value={account.id} />
<Button type="submit" variant="default" size="sm">
<PowerOffIcon />
Yes, unpair
</Button>
</form>
</DialogFooter>
</DialogContent>
</Dialog>
</> </>
)} )}
{/* Delete — transparent <button> overlay opens the dialog. */} <DeleteAccountCard accountId={account.id} accountLabel={account.label} />
<Dialog>
<Card className="relative transition-all hover:shadow-md hover:ring-destructive/30 cursor-pointer">
<CardContent className="flex items-center justify-between gap-4 py-4">
<div className="flex items-center gap-3">
<div className="flex size-9 items-center justify-center rounded-lg bg-destructive/10">
<Trash2Icon className="size-4 text-destructive" />
</div>
<div>
<p className="text-sm font-medium text-destructive">Delete Account</p>
<p className="text-xs text-muted-foreground">
Remove the account and all its reminders, groups, and history
</p>
</div>
</div>
<ChevronRightIcon className="size-4 text-muted-foreground/60" />
</CardContent>
<DialogTrigger asChild>
<button
type="button"
aria-label="Delete account"
className="absolute inset-0 w-full rounded-xl bg-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive focus-visible:ring-offset-2"
/>
</DialogTrigger>
</Card>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete this account permanently?</DialogTitle>
<DialogDescription>
<strong>{account.label}</strong> will be removed along with its
synced groups, scheduled reminders, and all run history. This cannot be
undone.
</DialogDescription>
</DialogHeader>
<DialogFooter showCloseButton>
<form action={deleteAccountAction}>
<input type="hidden" name="accountId" value={account.id} />
<Button type="submit" variant="destructive" size="sm">
<Trash2Icon />
Yes, delete
</Button>
</form>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
<Card> <Card>

View File

@ -0,0 +1,102 @@
"use client";
import { useState, useTransition } from "react";
import { ChevronRightIcon, Loader2Icon, PowerOffIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { unpairAccountAction } from "@/actions/accounts";
interface UnpairAccountCardProps {
accountId: string;
accountLabel: string;
}
export function UnpairAccountCard({
accountId,
accountLabel,
}: UnpairAccountCardProps) {
const [open, setOpen] = useState(false);
const [pending, start] = useTransition();
function confirm() {
start(async () => {
const fd = new FormData();
fd.append("accountId", accountId);
await unpairAccountAction(fd);
setOpen(false);
});
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<Card
role="button"
tabIndex={0}
aria-label="Unpair WhatsApp"
onClick={() => setOpen(true)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setOpen(true);
}
}}
className="transition-all hover:shadow-md hover:ring-amber-500/30 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<CardContent className="flex items-center justify-between gap-4 py-4">
<div className="flex items-center gap-3">
<div className="flex size-9 items-center justify-center rounded-lg bg-amber-500/10">
<PowerOffIcon className="size-4 text-amber-600 dark:text-amber-400" />
</div>
<div>
<p className="text-sm font-medium">Unpair</p>
<p className="text-xs text-muted-foreground">
Disconnect from WhatsApp; keep the account so you can re-pair later
</p>
</div>
</div>
<ChevronRightIcon className="size-4 text-muted-foreground/60" />
</CardContent>
</Card>
<DialogContent>
<DialogHeader>
<DialogTitle>Unpair this account?</DialogTitle>
<DialogDescription>
<strong>{accountLabel}</strong> will disconnect from WhatsApp and
scheduled reminders using it will stop firing until you re-pair.
The account itself is kept; reminders and other data are not
deleted.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="ghost" size="sm">
Cancel
</Button>
</DialogClose>
<Button
type="button"
size="sm"
disabled={pending}
onClick={confirm}
>
{pending ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<PowerOffIcon className="size-4" />
)}
Yes, unpair
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -14,15 +14,6 @@ import {
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { import {
Table, Table,
TableBody, TableBody,
@ -38,7 +29,6 @@ import { getSeededOperator } from "@/lib/operator";
import { listActivityRuns } from "@/lib/queries"; import { listActivityRuns } from "@/lib/queries";
import { import {
archiveRunAction, archiveRunAction,
clearHistoryAction,
deleteRunAction, deleteRunAction,
unarchiveRunAction, unarchiveRunAction,
} from "@/actions/history"; } from "@/actions/history";
@ -106,24 +96,24 @@ function RunStatusBadge({ status }: { status: string }) {
); );
} }
type FilterValue = type FilterValue = "success" | "paused" | "failed" | "archived";
| "all"
| "success"
| "paused"
| "partial"
| "failed"
| "skipped"
| "archived";
const FILTER_TABS: { value: FilterValue; label: string }[] = [ const FILTER_TABS: { value: FilterValue; label: string }[] = [
{ value: "all", label: "All" },
{ value: "success", label: "Success" }, { value: "success", label: "Success" },
{ value: "paused", label: "Paused" }, { value: "paused", label: "Paused" },
{ value: "partial", label: "Partial" },
{ value: "failed", label: "Failed" }, { value: "failed", label: "Failed" },
{ value: "skipped", label: "Skipped" },
{ value: "archived", label: "Archived" }, { value: "archived", label: "Archived" },
]; ];
// Partial runs (some recipients ok, some failed) surface under BOTH the
// Paused and Failed tabs — the operator wants to see anything that didn't
// fully succeed on either page. Skipped runs collapse into Archived since
// they're effectively "history that the operator chose not to send".
const FILTER_STATUSES: Record<Exclude<FilterValue, "archived">, string[]> = {
success: ["success"],
paused: ["paused", "partial"],
failed: ["failed", "partial"],
};
interface PageProps { interface PageProps {
searchParams: Promise<{ filter?: string }>; searchParams: Promise<{ filter?: string }>;
} }
@ -185,76 +175,41 @@ export default async function ActivityPage({ searchParams }: PageProps) {
const filter: FilterValue = const filter: FilterValue =
sp.filter === "success" || sp.filter === "success" ||
sp.filter === "paused" || sp.filter === "paused" ||
sp.filter === "partial" ||
sp.filter === "failed" || sp.filter === "failed" ||
sp.filter === "skipped" ||
sp.filter === "archived" sp.filter === "archived"
? sp.filter ? sp.filter
: "all"; : "success";
const showingArchived = filter === "archived"; const showingArchived = filter === "archived";
const op = await getSeededOperator(); const op = await getSeededOperator();
const runs = await listActivityRuns(op.id, { archived: showingArchived }); const runs = await listActivityRuns(op.id, { archived: showingArchived });
const filtered = const filtered =
filter === "all" || filter === "archived" filter === "archived"
? runs ? runs
: runs.filter((r) => r.status === filter); : runs.filter((r) => FILTER_STATUSES[filter].includes(r.status));
const hasAny = runs.length > 0; const hasAny = runs.length > 0;
return ( return (
<PageShell <PageShell title="Activity">
title="Activity" {/* Filter tabs span the full row and wrap onto a second line when the
action={ viewport can't fit them all. Each trigger has a small basis so they
hasAny && !showingArchived ? ( share space evenly while still keeping a readable label on mobile. */}
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className="text-muted-foreground hover:text-destructive">
<Trash2Icon />
Clear history
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Clear all run history?</DialogTitle>
<DialogDescription>
This permanently removes every reminder run record, including
runs from reminders that have already been deleted. Reminders
themselves are not affected.
</DialogDescription>
</DialogHeader>
<DialogFooter showCloseButton>
<form action={clearHistoryAction}>
<Button type="submit" variant="destructive" size="sm">
<Trash2Icon />
Yes, clear history
</Button>
</form>
</DialogFooter>
</DialogContent>
</Dialog>
) : undefined
}
>
{/* Six tabs (All / Success / Partial / Failed / Skipped / Archived)
packed into a phone-width row left every label squeezed to
~50px. Wrap the list in an overflow-x scroller so each tab
keeps a readable label + comfortable touch target on mobile;
on desktop the row fits naturally and no scroll bar appears.
Negative margins extend the scroller to the page edges so the
first/last tabs don't look clipped against the container. */}
<Tabs value={filter}> <Tabs value={filter}>
<div className="-mx-4 overflow-x-auto px-4 sm:mx-0 sm:px-0 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"> <TabsList className="flex w-full flex-wrap gap-1 group-data-horizontal/tabs:h-auto p-1">
<TabsList> {FILTER_TABS.map(({ value, label }) => (
{FILTER_TABS.map(({ value, label }) => ( <TabsTrigger
<TabsTrigger key={value} value={value} asChild> key={value}
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} value={value}
<Link href={(value === "all" ? "/activity" : `/activity?filter=${value}`) as any}> asChild
{label} className="h-8 grow basis-20"
</Link> >
</TabsTrigger> {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
))} <Link href={`/activity?filter=${value}` as any}>
</TabsList> {label}
</div> </Link>
</TabsTrigger>
))}
</TabsList>
</Tabs> </Tabs>
{filtered.length > 0 ? ( {filtered.length > 0 ? (
@ -422,11 +377,7 @@ export default async function ActivityPage({ searchParams }: PageProps) {
<EmptyState <EmptyState
icon={ActivityIcon} icon={ActivityIcon}
title={ title={
filter === "all" showingArchived ? "No archived runs." : `No ${filter} runs yet.`
? "No activity yet."
: showingArchived
? "No archived runs."
: `No ${filter} runs yet.`
} }
description={ description={
hasAny hasAny

View File

@ -1,6 +1,15 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
// Without these, `next build`'s "Collecting page data" pass invokes
// the GET handler in the build container — which has no
// DATABASE_URL — and the env access throws ZodError, killing the
// docker build. Marking the route force-dynamic + nodejs runtime
// tells Next to skip the build-time call and only run at request
// time.
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
interface RouteContext { interface RouteContext {
params: Promise<{ accountId: string }>; params: Promise<{ accountId: string }>;
} }

View File

@ -4,12 +4,14 @@ import { ThemeProvider } from "@/components/theme-provider";
import { AppShell } from "@/components/app-shell"; import { AppShell } from "@/components/app-shell";
import { NotificationManager } from "@/components/notification-manager"; import { NotificationManager } from "@/components/notification-manager";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { getCurrentUser } from "@/lib/auth";
import "./globals.css"; import "./globals.css";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "cm WhatsApp Bot", title: "cm WhatsApp Bot",
description: "Self-hosted WhatsApp reminder bot", description: "Self-hosted WhatsApp reminder bot",
applicationName: "cm WhatsApp Bot", applicationName: "cm WhatsApp Bot",
robots: { index: false, follow: false },
// PWA wiring: the manifest comes from the dynamic route at // PWA wiring: the manifest comes from the dynamic route at
// src/app/manifest.webmanifest/route.ts, the apple-touch-icon is // src/app/manifest.webmanifest/route.ts, the apple-touch-icon is
// emitted from public/, and `appleWebApp.capable` lets iOS treat the // emitted from public/, and `appleWebApp.capable` lets iOS treat the
@ -32,7 +34,13 @@ export const viewport: Viewport = {
], ],
}; };
export default function RootLayout({ children }: { children: React.ReactNode }) { export default async function RootLayout({ children }: { children: React.ReactNode }) {
// Pass the role into AppShell so the nav can hide admin-only entries
// for the 'user' role. On /login getCurrentUser returns null and
// AppShell short-circuits to the bare header anyway.
const me = await getCurrentUser();
const role = me?.role ?? null;
const username = me?.username ?? null;
return ( return (
// `suppressHydrationWarning` here is for *attribute* differences only. // `suppressHydrationWarning` here is for *attribute* differences only.
// Two sources legitimately mutate <html>/<body> attributes after the // Two sources legitimately mutate <html>/<body> attributes after the
@ -45,7 +53,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<html lang="en" suppressHydrationWarning className={GeistSans.className}> <html lang="en" suppressHydrationWarning className={GeistSans.className}>
<body suppressHydrationWarning> <body suppressHydrationWarning>
<ThemeProvider> <ThemeProvider>
<AppShell>{children}</AppShell> <AppShell role={role} username={username}>{children}</AppShell>
<Toaster richColors position="top-right" /> <Toaster richColors position="top-right" />
{/* SSE → browser notification bridge. Renders no DOM. */} {/* SSE → browser notification bridge. Renders no DOM. */}
<NotificationManager /> <NotificationManager />

View File

@ -0,0 +1,101 @@
"use client";
import { useState, useTransition } from "react";
import { Loader2Icon, LockIcon, HelpCircleIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogClose,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { loginAction } from "@/actions/auth";
export function LoginFormClient({ next }: { next: string }) {
const [pending, start] = useTransition();
const [error, setError] = useState<string | null>(null);
function handle(formData: FormData) {
formData.append("next", next);
start(async () => {
setError(null);
const r = await loginAction(formData);
// On success, the action redirects (no return). If we land here,
// something failed and `r` is the error shape.
if (r && !r.ok) setError(r.error);
});
}
return (
<form action={handle} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="username">Username</Label>
<Input
id="username"
name="username"
type="text"
autoComplete="username"
autoFocus
required
maxLength={256}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="password">Password</Label>
<Input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
maxLength={256}
/>
</div>
{error && (
<div className="text-xs text-destructive">{error}</div>
)}
<Button type="submit" disabled={pending} className="w-full gap-2">
{pending ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<LockIcon className="size-4" />
)}
Sign in
</Button>
<Dialog>
<DialogTrigger asChild>
<Button
type="button"
variant="link"
size="sm"
className="w-full text-xs text-muted-foreground hover:text-foreground"
>
<HelpCircleIcon className="size-3.5" />
Forgot password?
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Forgot your password?</DialogTitle>
<DialogDescription>
Contact your administrator to reset it.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button type="button" size="sm">
Got it
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</form>
);
}

View File

@ -0,0 +1,25 @@
import { Card, CardContent } from "@/components/ui/card";
import { LoginFormClient } from "./login-form-client";
export const metadata = {
title: "Sign in",
};
interface PageProps {
searchParams: Promise<{ next?: string }>;
}
export default async function LoginPage({ searchParams }: PageProps) {
const sp = await searchParams;
const next = sp.next ?? "/";
return (
<div className="flex items-center justify-center px-4 py-8 min-h-[calc(100dvh-3.5rem)]">
<Card className="w-full max-w-sm">
<CardContent className="pt-6">
<LoginFormClient next={next} />
</CardContent>
</Card>
</div>
);
}

View File

@ -217,7 +217,7 @@ export default async function DashboardPage() {
themselves are not affected. themselves are not affected.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter showCloseButton> <DialogFooter>
<form action={clearHistoryAction}> <form action={clearHistoryAction}>
<Button type="submit" variant="destructive" size="sm"> <Button type="submit" variant="destructive" size="sm">
<Trash2Icon /> <Trash2Icon />

View File

@ -177,7 +177,7 @@ function ConfirmCard(props: ConfirmCardProps) {
{error} {error}
</p> </p>
)} )}
<DialogFooter showCloseButton> <DialogFooter>
<form <form
action={async (fd: FormData) => { action={async (fd: FormData) => {
setSubmitting(true); setSubmitting(true);

View File

@ -230,12 +230,28 @@ export default async function ReminderDetailPage({ params }: Props) {
</p> </p>
<p className="text-sm font-medium">{formatWhen(reminder.scheduledAt, tz)}</p> <p className="text-sm font-medium">{formatWhen(reminder.scheduledAt, tz)}</p>
{reminder.rrule && reminder.scheduledAt ? ( {reminder.rrule && reminder.scheduledAt ? (
<p className="flex items-center gap-1.5 text-xs text-primary/80"> // Single-line summary with mid-string ellipsis. Long
<RepeatIcon className="size-3 shrink-0" /> // descriptions ("Every month on days 4, 6, 11, 13, 18,
{describeRecurrence( // 20 +2 more at 11:32") truncate cleanly via `truncate`
// (overflow-hidden + text-ellipsis + whitespace-nowrap)
// so the card height stays predictable. The native
// browser tooltip on `title` lets the operator read
// the full string without leaving the page; the edit
// form is the canonical full view.
<p
className="flex items-center gap-1.5 text-xs text-primary/80"
title={describeRecurrence(
specFromRrule(reminder.rrule), specFromRrule(reminder.rrule),
DateTime.fromJSDate(reminder.scheduledAt).setZone(reminder.timezone), DateTime.fromJSDate(reminder.scheduledAt).setZone(reminder.timezone),
)} )}
>
<RepeatIcon className="size-3 shrink-0" />
<span className="truncate min-w-0">
{describeRecurrence(
specFromRrule(reminder.rrule),
DateTime.fromJSDate(reminder.scheduledAt).setZone(reminder.timezone),
)}
</span>
</p> </p>
) : ( ) : (
<p className="text-xs text-muted-foreground">One-off</p> <p className="text-xs text-muted-foreground">One-off</p>

View File

@ -247,15 +247,30 @@ export default async function RemindersPage({ searchParams }: PageProps) {
</p> </p>
</div> </div>
<div className="shrink-0 text-right space-y-1"> {/* Right meta column. Capped at ~14rem so a long
<div className="flex items-center justify-end gap-1 text-xs text-muted-foreground"> recurrence description ("Every month on days
4, 6, 7, 11, 13, 14 +6 more at 11:32") can't
starve the reminder name on the left. min-w-0
+ truncate on each span ellipsises overflow
inside the cap. Title tooltip preserves the
full text on hover. */}
<div className="min-w-0 max-w-[34%] sm:max-w-[9.5rem] text-right space-y-1">
<div className="flex min-w-0 items-center justify-end gap-1 text-xs text-muted-foreground">
<CalendarIcon className="size-3 shrink-0" /> <CalendarIcon className="size-3 shrink-0" />
<span>{formatWhen(reminder.scheduledAt, tz)}</span> <span className="truncate">
{formatWhen(reminder.scheduledAt, tz)}
</span>
</div> </div>
{reminder.rrule && reminder.scheduledAt ? ( {reminder.rrule && reminder.scheduledAt ? (
<div className="flex items-center justify-end gap-1 text-xs text-primary/80"> <div
className="flex min-w-0 items-center justify-end gap-1 text-xs text-primary/80"
title={describeRecurrence(
specFromRrule(reminder.rrule),
DateTime.fromJSDate(reminder.scheduledAt).setZone(reminder.timezone),
)}
>
<RepeatIcon className="size-3 shrink-0" /> <RepeatIcon className="size-3 shrink-0" />
<span> <span className="truncate">
{describeRecurrence( {describeRecurrence(
specFromRrule(reminder.rrule), specFromRrule(reminder.rrule),
DateTime.fromJSDate(reminder.scheduledAt).setZone(reminder.timezone), DateTime.fromJSDate(reminder.scheduledAt).setZone(reminder.timezone),
@ -264,9 +279,9 @@ export default async function RemindersPage({ searchParams }: PageProps) {
</div> </div>
) : null} ) : null}
{reminder.groupCount > 0 && ( {reminder.groupCount > 0 && (
<div className="flex items-center justify-end gap-1 text-xs text-muted-foreground"> <div className="flex min-w-0 items-center justify-end gap-1 text-xs text-muted-foreground">
<UsersIcon className="size-3 shrink-0" /> <UsersIcon className="size-3 shrink-0" />
<span> <span className="truncate">
{reminder.groupCount}{" "} {reminder.groupCount}{" "}
{reminder.groupCount === 1 ? "group" : "groups"} {reminder.groupCount === 1 ? "group" : "groups"}
</span> </span>

View File

@ -0,0 +1,5 @@
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return { rules: [{ userAgent: "*", disallow: "/" }] };
}

View File

@ -7,6 +7,7 @@ import { PageShell } from "@/components/page-shell";
export default async function SettingsPage() { export default async function SettingsPage() {
const op = await getSeededOperator(); const op = await getSeededOperator();
const isAdmin = op.role === "admin";
return ( return (
<PageShell title="Settings" narrow> <PageShell title="Settings" narrow>
<Card> <Card>
@ -14,13 +15,15 @@ export default async function SettingsPage() {
<CardTitle>Operator</CardTitle> <CardTitle>Operator</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-3 text-sm"> <CardContent className="space-y-3 text-sm">
<Row label="Display name" value={op.displayName} /> <Row label="Username" value={op.username} mono />
<Separator />
<Row label="Operator ID" value={String(op.telegramUserId)} mono />
<Separator /> <Separator />
<Row label="Default timezone" value={op.defaultTimezone} mono /> <Row label="Default timezone" value={op.defaultTimezone} mono />
<Separator /> {isAdmin && (
<Row label="Role" value={op.role} mono /> <>
<Separator />
<Row label="Role" value={op.role} mono />
</>
)}
</CardContent> </CardContent>
</Card> </Card>
@ -47,10 +50,6 @@ export default async function SettingsPage() {
<ThemeToggle /> <ThemeToggle />
</CardContent> </CardContent>
</Card> </Card>
<p className="text-center text-xs text-muted-foreground">
cm WhatsApp Bot · self-hosted
</p>
</PageShell> </PageShell>
); );
} }

View File

@ -0,0 +1,95 @@
"use client";
import { useState, useTransition } from "react";
import { Loader2Icon, UserPlusIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { createUserAction } from "@/actions/users";
export function AddUserFormClient() {
const [pending, start] = useTransition();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState<"admin" | "user">("user");
const [error, setError] = useState<string | null>(null);
const [ok, setOk] = useState(false);
function submit() {
start(async () => {
setError(null);
setOk(false);
const r = await createUserAction({
username: username.trim(),
password,
role,
});
if (!r.ok) {
setError(r.error);
return;
}
setUsername("");
setPassword("");
setRole("user");
setOk(true);
});
}
return (
<div className="space-y-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="new-username">Username</Label>
<Input
id="new-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="off"
maxLength={256}
placeholder="alice"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="new-password">Password</Label>
<Input
id="new-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
maxLength={256}
placeholder="≥6 chars · letters + number/symbol"
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="new-role">Role</Label>
<select
id="new-role"
value={role}
onChange={(e) => setRole(e.target.value === "admin" ? "admin" : "user")}
className="h-9 w-full rounded-md border border-input bg-transparent px-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<option value="user">user</option>
<option value="admin">admin</option>
</select>
</div>
<div className="flex items-center justify-end gap-2">
{error && <p className="mr-auto text-xs text-destructive">{error}</p>}
{ok && (
<p className="mr-auto text-xs text-emerald-600 dark:text-emerald-400">
User created.
</p>
)}
<Button type="button" size="sm" disabled={pending} onClick={submit}>
{pending ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<UserPlusIcon className="size-4" />
)}
Add user
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,62 @@
import { requireAdmin } from "@/lib/auth";
import { db } from "@/lib/db";
import { PageShell } from "@/components/page-shell";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { UserRowClient } from "./user-row-client";
import { AddUserFormClient } from "./add-user-form-client";
export default async function UsersPage() {
const me = await requireAdmin();
const rows = await db.query.operators.findMany({
orderBy: (o, { asc }) => [asc(o.username)],
});
const adminCount = rows.filter((r) => r.role === "admin").length;
return (
<PageShell title="Users">
<Card>
<CardHeader>
<CardTitle>Add user</CardTitle>
<CardDescription>
Create a sign-in account. Passwords must be at least 10
characters.
</CardDescription>
</CardHeader>
<CardContent>
<AddUserFormClient />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>All users</CardTitle>
<CardDescription>
Promote a user to admin, demote them back, reset their
password, or delete the account. The last admin cannot be
demoted or deleted.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{rows.map((u) => (
<UserRowClient
key={u.id}
user={{
id: u.id,
username: u.username,
role: u.role === "admin" ? "admin" : "user",
}}
isSelf={u.id === me.id}
isLastAdmin={u.role === "admin" && adminCount === 1}
/>
))}
</CardContent>
</Card>
</PageShell>
);
}

View File

@ -0,0 +1,197 @@
"use client";
import { useState, useTransition } from "react";
import {
Loader2Icon,
Trash2Icon,
KeyIcon,
ArrowUpIcon,
ArrowDownIcon,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogClose,
} from "@/components/ui/dialog";
import {
setUserRoleAction,
resetUserPasswordAction,
deleteUserAction,
} from "@/actions/users";
import { validatePassword } from "@/lib/password-policy";
interface UserRowClientProps {
user: { id: string; username: string; role: "admin" | "user" };
isSelf: boolean;
/** True when this row is the only remaining admin. Disables demote+delete. */
isLastAdmin: boolean;
}
export function UserRowClient({ user, isSelf, isLastAdmin }: UserRowClientProps) {
const [pending, start] = useTransition();
const [error, setError] = useState<string | null>(null);
const [resetVisible, setResetVisible] = useState(false);
const [resetPw, setResetPw] = useState("");
const [deleteOpen, setDeleteOpen] = useState(false);
function run<T extends { ok: boolean; error?: string }>(promise: Promise<T>) {
start(async () => {
setError(null);
const r = await promise;
if (!r.ok) setError(r.error ?? "Failed");
});
}
const isAdmin = user.role === "admin";
// The role-toggle button is disabled if:
// - flipping yourself (admin self-demotion is rejected server-side too)
// - this row is the last remaining admin and would become a user
const roleToggleDisabled = pending || isSelf || (isAdmin && isLastAdmin);
const deleteDisabled = pending || isSelf || isLastAdmin;
return (
<div className="flex flex-col gap-3 rounded-lg border p-4">
{/* Row 1 identity: username on the left, role badge + "you"
chip on the right, all on one line. */}
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<p className="text-sm font-medium truncate">
{user.username}
</p>
{isSelf && (
<span className="text-xs text-muted-foreground shrink-0">you</span>
)}
</div>
<Badge
variant="secondary"
className={
isAdmin
? "shrink-0 bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400 border-transparent"
: "shrink-0 bg-slate-200/60 text-slate-600 dark:bg-slate-700/40 dark:text-slate-300 border-transparent"
}
>
{user.role}
</Badge>
</div>
{/* Row 2 — actions: Promote/Demote, Reset, Delete, right-aligned. */}
<div className="flex flex-wrap justify-end gap-1.5">
<Button
type="button"
size="sm"
variant="ghost"
disabled={roleToggleDisabled}
onClick={() =>
run(
setUserRoleAction({
userId: user.id,
role: isAdmin ? "user" : "admin",
}),
)
}
>
{isAdmin ? (
<ArrowDownIcon className="size-3.5" />
) : (
<ArrowUpIcon className="size-3.5" />
)}
{isAdmin ? "Demote" : "Promote"}
</Button>
<Button
type="button"
size="sm"
variant="ghost"
disabled={pending}
onClick={() => setResetVisible((v) => !v)}
>
<KeyIcon className="size-3.5" />
Reset
</Button>
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogTrigger asChild>
<Button
type="button"
size="sm"
variant="ghost"
className="text-destructive"
disabled={deleteDisabled}
>
<Trash2Icon className="size-3.5" />
Delete
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete user @{user.username}?</DialogTitle>
<DialogDescription>
This permanently removes the account. They will be
signed out on their next request and cannot sign in
again. This cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="ghost" size="sm">
Cancel
</Button>
</DialogClose>
<Button
type="button"
variant="destructive"
size="sm"
disabled={pending}
onClick={() => {
setDeleteOpen(false);
run(deleteUserAction({ userId: user.id }));
}}
>
{pending ? (
<Loader2Icon className="size-3.5 animate-spin" />
) : (
<Trash2Icon className="size-3.5" />
)}
Delete user
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{resetVisible && (
<div className="flex gap-2">
<Input
type="password"
placeholder="New password (≥6 chars · letters + number/symbol)"
value={resetPw}
onChange={(e) => setResetPw(e.target.value)}
maxLength={256}
/>
<Button
type="button"
size="sm"
disabled={pending || !validatePassword(resetPw).ok}
onClick={() => {
run(
resetUserPasswordAction({
userId: user.id,
newPassword: resetPw,
}),
);
setResetPw("");
setResetVisible(false);
}}
>
Save
</Button>
</div>
)}
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
);
}

View File

@ -55,7 +55,7 @@ describe("AppShell — mobile header (SSR)", () => {
it("renders a fixed top header that hides on sm+ breakpoints", () => { it("renders a fixed top header that hides on sm+ breakpoints", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<AppShell> <AppShell role="admin" username="admin">
<main>page</main> <main>page</main>
</AppShell>, </AppShell>,
); );
@ -66,7 +66,7 @@ describe("AppShell — mobile header (SSR)", () => {
it("brand mark on the left links to /", () => { it("brand mark on the left links to /", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<AppShell> <AppShell role="admin" username="admin">
<div /> <div />
</AppShell>, </AppShell>,
); );
@ -90,7 +90,7 @@ describe("AppShell — mobile header (SSR)", () => {
for (const c of cases) { for (const c of cases) {
pathnameMock.mockReturnValue(c.path); pathnameMock.mockReturnValue(c.path);
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<AppShell> <AppShell role="admin" username="admin">
<div /> <div />
</AppShell>, </AppShell>,
); );
@ -104,7 +104,7 @@ describe("AppShell — mobile header (SSR)", () => {
it("falls back to 'WhatsApp Bot' when the path doesn't match any nav item", () => { it("falls back to 'WhatsApp Bot' when the path doesn't match any nav item", () => {
pathnameMock.mockReturnValue("/unknown-route"); pathnameMock.mockReturnValue("/unknown-route");
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<AppShell> <AppShell role="admin" username="admin">
<div /> <div />
</AppShell>, </AppShell>,
); );
@ -115,7 +115,7 @@ describe("AppShell — mobile header (SSR)", () => {
it("menu button on the right uses aria-label='Open menu'", () => { it("menu button on the right uses aria-label='Open menu'", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<AppShell> <AppShell role="admin" username="admin">
<div /> <div />
</AppShell>, </AppShell>,
); );
@ -134,7 +134,7 @@ describe("AppShell — menu drawer contents (SSR via transparent Sheet mock)", (
it("renders one nav link per NAV_ITEM, in order", () => { it("renders one nav link per NAV_ITEM, in order", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<AppShell> <AppShell role="admin" username="admin">
<div /> <div />
</AppShell>, </AppShell>,
); );
@ -157,7 +157,7 @@ describe("AppShell — menu drawer contents (SSR via transparent Sheet mock)", (
it("marks the active route's link with aria-current='page'", () => { it("marks the active route's link with aria-current='page'", () => {
pathnameMock.mockReturnValue("/reminders"); pathnameMock.mockReturnValue("/reminders");
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<AppShell> <AppShell role="admin" username="admin">
<div /> <div />
</AppShell>, </AppShell>,
); );
@ -174,7 +174,7 @@ describe("AppShell — menu drawer contents (SSR via transparent Sheet mock)", (
// every page. The header uses an exact-match check for "/". // every page. The header uses an exact-match check for "/".
pathnameMock.mockReturnValue("/accounts"); pathnameMock.mockReturnValue("/accounts");
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<AppShell> <AppShell role="admin" username="admin">
<div /> <div />
</AppShell>, </AppShell>,
); );
@ -185,7 +185,7 @@ describe("AppShell — menu drawer contents (SSR via transparent Sheet mock)", (
it("does NOT include a theme toggle in the mobile drawer (per request)", () => { it("does NOT include a theme toggle in the mobile drawer (per request)", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<AppShell> <AppShell role="admin" username="admin">
<div /> <div />
</AppShell>, </AppShell>,
); );
@ -195,7 +195,7 @@ describe("AppShell — menu drawer contents (SSR via transparent Sheet mock)", (
it("drawer header carries the brand wording and a screen-reader description", () => { it("drawer header carries the brand wording and a screen-reader description", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<AppShell> <AppShell role="admin" username="admin">
<div /> <div />
</AppShell>, </AppShell>,
); );
@ -221,7 +221,7 @@ describe("AppShell — desktop sidebar (SSR)", () => {
it("renders the sidebar nav with every NAV_ITEM", () => { it("renders the sidebar nav with every NAV_ITEM", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<AppShell> <AppShell role="admin" username="admin">
<div /> <div />
</AppShell>, </AppShell>,
); );
@ -232,21 +232,22 @@ describe("AppShell — desktop sidebar (SSR)", () => {
} }
}); });
it("keeps the theme toggle in the sidebar footer", () => { it("renders a Sign out button in the sidebar footer", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<AppShell> <AppShell role="admin" username="admin">
<div /> <div />
</AppShell>, </AppShell>,
); );
// The mocked ThemeToggle prints `data-testid="theme-toggle"`; it must // Theme toggle was dropped from the shell per request; the footer
// appear in the sidebar (we removed it from the mobile drawer). // now carries the Sign out affordance + the signed-in username.
expect(html).toContain('data-testid="theme-toggle"'); expect(html).toContain('aria-label="Sign out"');
expect(html).toContain("admin");
}); });
it("sidebar brand header is a link to / with a 'Go to dashboard' aria-label", () => { it("sidebar brand header is a link to / with a 'Go to dashboard' aria-label", () => {
pathnameMock.mockReturnValue("/accounts"); pathnameMock.mockReturnValue("/accounts");
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<AppShell> <AppShell role="admin" username="admin">
<div /> <div />
</AppShell>, </AppShell>,
); );
@ -264,7 +265,7 @@ describe("AppShell — desktop sidebar (SSR)", () => {
// reader users on a wide-window split-screen don't hear two // reader users on a wide-window split-screen don't hear two
// identical announcements when both are visible. // identical announcements when both are visible.
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<AppShell> <AppShell role="admin" username="admin">
<div /> <div />
</AppShell>, </AppShell>,
); );
@ -273,6 +274,79 @@ describe("AppShell — desktop sidebar (SSR)", () => {
}); });
}); });
// ---------------------------------------------------------------------------
// Role-gated nav (admin panel)
// ---------------------------------------------------------------------------
describe("AppShell — role-based nav filtering", () => {
beforeEach(() => {
pathnameMock.mockReset();
pathnameMock.mockReturnValue("/");
});
it("shows the Admin entry in BOTH the sidebar and drawer when role=admin", () => {
const html = renderToStaticMarkup(
<AppShell role="admin" username="admin">
<div />
</AppShell>,
);
expect(html).toContain('href="/settings/users"');
// A label appears in both the sidebar and the drawer; either way the
// count must be >=2 (sidebar copy + drawer copy).
const occurrences = (html.match(/href="\/settings\/users"/g) ?? []).length;
expect(occurrences).toBeGreaterThanOrEqual(2);
});
it("hides the Admin entry from BOTH surfaces when role=user", () => {
const html = renderToStaticMarkup(
<AppShell role="user" username="alice">
<div />
</AppShell>,
);
expect(html).not.toContain('href="/settings/users"');
});
it("hides the Admin entry when role=null (defense-in-depth: unauthenticated)", () => {
pathnameMock.mockReturnValue("/accounts");
const html = renderToStaticMarkup(
<AppShell role={null} username={null}>
<div />
</AppShell>,
);
expect(html).not.toContain('href="/settings/users"');
});
it("does NOT hide entries that have no visibleTo restriction for either role", () => {
const adminHtml = renderToStaticMarkup(
<AppShell role="admin" username="admin">
<div />
</AppShell>,
);
const userHtml = renderToStaticMarkup(
<AppShell role="user" username="alice">
<div />
</AppShell>,
);
for (const item of NAV_ITEMS) {
if (item.visibleTo) continue;
expect(adminHtml).toContain(`href="${item.href}"`);
expect(userHtml).toContain(`href="${item.href}"`);
}
});
it("/login route renders the bare header — no nav, no drawer, regardless of role", () => {
pathnameMock.mockReturnValue("/login");
const html = renderToStaticMarkup(
<AppShell role={null} username={null}>
<div />
</AppShell>,
);
expect(html).not.toContain("<aside");
expect(html).not.toContain('data-testid="sheet-content"');
expect(html).not.toContain('href="/settings/users"');
expect(html).toContain("WhatsApp Bot");
});
});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// helpers // helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -1,11 +1,12 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState, useTransition } from "react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { MenuIcon } from "lucide-react"; import { MenuIcon, LogOutIcon, Loader2Icon } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { logoutAction } from "@/actions/auth";
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
@ -14,8 +15,13 @@ import {
SheetTitle, SheetTitle,
SheetTrigger, SheetTrigger,
} from "@/components/ui/sheet"; } from "@/components/ui/sheet";
import { NAV_ITEMS } from "@/components/nav-config"; import {
import { ThemeToggle } from "@/components/theme-toggle"; NAV_ITEMS,
navItemsForRole,
pickActiveNavKey,
type NavItem,
type NavRole,
} from "@/components/nav-config";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Mobile header (sm:hidden) // Mobile header (sm:hidden)
@ -30,8 +36,51 @@ import { ThemeToggle } from "@/components/theme-toggle";
// waiting for the page content to render. The menu button on the right // waiting for the page content to render. The menu button on the right
// opens a Sheet with the full nav list and the theme toggle. // opens a Sheet with the full nav list and the theme toggle.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function MobileHeader() { // ---------------------------------------------------------------------------
// Sign-out button used by both the desktop sidebar footer and the mobile
// drawer footer. Server-action under the hood: clears the session
// cookie and redirects to /login. Disabled while in flight so a
// double-click doesn't fire two redirects.
// ---------------------------------------------------------------------------
function SignOutButton({ username }: { username: string | null }) {
const [pending, start] = useTransition();
return (
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
{username && (
<p className="text-xs text-muted-foreground truncate">
Signed in as <em className="italic font-medium text-foreground">{username}</em>
</p>
)}
</div>
<Button
type="button"
variant="ghost"
size="sm"
disabled={pending}
onClick={() => start(() => logoutAction())}
aria-label="Sign out"
>
{pending ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<LogOutIcon className="size-4" />
)}
Sign out
</Button>
</div>
);
}
function MobileHeader({
items,
username,
}: {
items: NavItem[];
username: string | null;
}) {
const pathname = usePathname(); const pathname = usePathname();
const activeKey = pickActiveNavKey(items, pathname);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
// Close the drawer when the route changes (i.e. the user picked a nav // Close the drawer when the route changes (i.e. the user picked a nav
@ -41,6 +90,10 @@ function MobileHeader() {
setOpen(false); setOpen(false);
}, [pathname]); }, [pathname]);
// Use the full list (not the role-filtered one) for the title lookup
// so the page title still shows up correctly when a 'user' role hits
// a route they wouldn't normally see in the nav (e.g. arrives via a
// direct link), even though they can't navigate there from the menu.
const currentItem = NAV_ITEMS.find(({ href }) => const currentItem = NAV_ITEMS.find(({ href }) =>
href === "/" ? pathname === "/" : pathname.startsWith(href), href === "/" ? pathname === "/" : pathname.startsWith(href),
); );
@ -90,10 +143,10 @@ function MobileHeader() {
<nav <nav
aria-label="Primary navigation" aria-label="Primary navigation"
className="flex flex-col gap-0.5 p-2 flex-1" className="flex flex-col gap-0.5 p-2"
> >
{NAV_ITEMS.map(({ key, href, label, icon: Icon }) => { {items.map(({ key, href, label, icon: Icon }) => {
const active = href === "/" ? pathname === "/" : pathname.startsWith(href); const active = activeKey === key;
return ( return (
<Link <Link
key={key} key={key}
@ -117,6 +170,10 @@ function MobileHeader() {
); );
})} })}
</nav> </nav>
<div className="mt-auto border-t border-border p-3">
<SignOutButton username={username} />
</div>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
</header> </header>
@ -126,8 +183,15 @@ function MobileHeader() {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Sidebar (desktop only — hidden below sm) // Sidebar (desktop only — hidden below sm)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function Sidebar() { function Sidebar({
items,
username,
}: {
items: NavItem[];
username: string | null;
}) {
const pathname = usePathname(); const pathname = usePathname();
const activeKey = pickActiveNavKey(items, pathname);
return ( return (
<aside className="hidden sm:flex fixed left-0 top-0 bottom-0 z-40 w-56 flex-col border-r border-border bg-sidebar"> <aside className="hidden sm:flex fixed left-0 top-0 bottom-0 z-40 w-56 flex-col border-r border-border bg-sidebar">
@ -150,7 +214,7 @@ function Sidebar() {
{/* Nav items */} {/* Nav items */}
<nav aria-label="Primary navigation" className="flex flex-col gap-1 p-3 flex-1"> <nav aria-label="Primary navigation" className="flex flex-col gap-1 p-3 flex-1">
{NAV_ITEMS.map(({ key, href, label, icon: Icon }) => { {items.map(({ key, href, label, icon: Icon }) => {
const active = href === "/" ? pathname === "/" : pathname.startsWith(href); const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
return ( return (
<Link <Link
@ -172,29 +236,74 @@ function Sidebar() {
})} })}
</nav> </nav>
{/* Footer: theme toggle */} {/* Footer: signed-in user + sign-out */}
<div className="border-t border-sidebar-border p-3"> <div className="border-t border-sidebar-border p-3">
<ThemeToggle /> <SignOutButton username={username} />
</div> </div>
</aside> </aside>
); );
} }
// ---------------------------------------------------------------------------
// Bare header for unauthenticated routes (/login). No sidebar, no mobile
// menu, no nav — just the centered brand mark + name. The user explicitly
// asked for nothing else here so the sign-in screen feels like a separate
// surface from the authenticated app.
// ---------------------------------------------------------------------------
function BareHeader() {
return (
<header className="fixed top-0 left-0 right-0 z-40 flex h-14 items-center justify-center border-b border-border bg-background/95 backdrop-blur-sm px-3">
<div className="flex items-center gap-2">
<span
aria-hidden
className="flex size-6 items-center justify-center rounded-md bg-primary text-[10px] font-bold uppercase text-primary-foreground"
>
cm
</span>
<span className="text-sm font-semibold tracking-tight">
WhatsApp Bot
</span>
</div>
</header>
);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// AppShell — the outer container // AppShell — the outer container
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
interface AppShellProps { interface AppShellProps {
children: React.ReactNode; children: React.ReactNode;
/** Role of the signed-in user, or null when unauthenticated. */
role: NavRole | null;
/** Username of the signed-in user, surfaced in the footer + sign-out hint. */
username: string | null;
} }
export function AppShell({ children }: AppShellProps) { export function AppShell({ children, role, username }: AppShellProps) {
const pathname = usePathname();
const isAuthRoute = pathname === "/login";
if (isAuthRoute) {
return (
<>
<BareHeader />
<main className="min-h-dvh pt-14">{children}</main>
</>
);
}
// Treat unauthenticated render of a protected route (shouldn't happen
// because middleware redirects, but defense-in-depth) as 'user': hides
// the admin-only entries.
const items = navItemsForRole(role ?? "user");
return ( return (
<> <>
{/* Desktop sidebar */} {/* Desktop sidebar */}
<Sidebar /> <Sidebar items={items} username={username} />
{/* Mobile header (single row: brand · title · menu) */} {/* Mobile header (single row: brand · title · menu) */}
<MobileHeader /> <MobileHeader items={items} username={username} />
{/* Main content {/* Main content
Mobile: push down for the h-14 header (56px) plus a small gap Mobile: push down for the h-14 header (56px) plus a small gap

View File

@ -0,0 +1,119 @@
import { describe, it, expect } from "vitest";
import { NAV_ITEMS, navItemsForRole, pickActiveNavKey } from "./nav-config";
describe("navItemsForRole", () => {
it("includes every NAV_ITEM for an admin", () => {
const items = navItemsForRole("admin");
expect(items).toHaveLength(NAV_ITEMS.length);
for (const original of NAV_ITEMS) {
expect(items.find((i) => i.key === original.key)).toBeDefined();
}
});
it("hides admin-only entries for the 'user' role", () => {
const items = navItemsForRole("user");
const keys = items.map((i) => i.key);
expect(keys).not.toContain("admin");
});
it("returns the un-restricted entries (no visibleTo) for the 'user' role", () => {
const items = navItemsForRole("user");
const keys = items.map((i) => i.key);
expect(keys).toEqual(
expect.arrayContaining(["dashboard", "accounts", "reminders", "activity", "settings"]),
);
});
it("admin nav entry routes to /settings/users", () => {
const admin = NAV_ITEMS.find((i) => i.key === "admin");
expect(admin).toBeDefined();
expect(admin!.href).toBe("/settings/users");
expect(admin!.visibleTo).toEqual(["admin"]);
});
});
describe("pickActiveNavKey (longest-match active highlight)", () => {
// Use the real NAV_ITEMS so a future href change doesn't silently
// re-introduce the regression.
const adminItems = navItemsForRole("admin");
const userItems = navItemsForRole("user");
it("highlights ONLY the Admin entry on /settings/users (not Settings too)", () => {
// Repro of the user-reported regression. Naïve startsWith would
// light up both Settings (/settings) and Admin (/settings/users)
// because both prefixes match. The longest-match rule must pick
// the Admin entry alone.
const active = pickActiveNavKey(adminItems, "/settings/users");
expect(active).toBe("admin");
});
it("highlights Settings on /settings exact, with Admin NOT lit", () => {
const active = pickActiveNavKey(adminItems, "/settings");
expect(active).toBe("settings");
});
it("highlights Settings on a subpath that is NOT /settings/users", () => {
// Admin nav is admin-only; this test is just to confirm the
// longest-match still picks Settings when no admin descendant
// claims the path.
const active = pickActiveNavKey(adminItems, "/settings/profile");
expect(active).toBe("settings");
});
it("highlights Dashboard ONLY on '/' exact (not on every route)", () => {
expect(pickActiveNavKey(adminItems, "/")).toBe("dashboard");
expect(pickActiveNavKey(adminItems, "/accounts")).toBe("accounts");
expect(pickActiveNavKey(adminItems, "/reminders/abc-123")).toBe("reminders");
});
it("returns null when nothing matches (e.g. a route hidden from this role)", () => {
// /settings/users isn't visible to a 'user' role, so the helper
// must NOT highlight it as Settings just because /settings is a
// prefix — we'd be claiming an item is active when the user can't
// navigate to it from this nav.
expect(pickActiveNavKey(userItems, "/settings/users")).toBe("settings");
// Neither item's href matches a totally foreign route.
expect(pickActiveNavKey(adminItems, "/elsewhere")).toBeNull();
});
it("does NOT match a sibling that shares a prefix string", () => {
// /settingsfoo is NOT a child of /settings — startsWith would
// mistakenly mark Settings active. The strict descendant check
// (`href + '/'`) prevents that.
expect(pickActiveNavKey(adminItems, "/settingsfoo")).toBeNull();
});
it("each pathname highlights AT MOST one nav key (defense check)", () => {
// Walk a small representative set of routes and confirm we never
// light up two items at once. This is the contract the JSX in
// app-shell.tsx relies on.
const probes = [
"/",
"/accounts",
"/accounts/abc",
"/reminders",
"/reminders/abc",
"/activity",
"/activity?filter=success",
"/settings",
"/settings/users",
"/settings/users/something",
"/login",
"/elsewhere",
];
for (const path of probes) {
const matchCount = adminItems.filter((item) => {
if (item.href === "/") return path === "/";
return path === item.href || path.startsWith(item.href + "/");
}).length;
// If two prefixes both match, pickActiveNavKey must collapse
// them to one — that's the whole point of the helper.
const active = pickActiveNavKey(adminItems, path);
if (matchCount === 0) {
expect(active).toBeNull();
} else {
expect(active).not.toBeNull();
}
}
});
});

View File

@ -1,11 +1,22 @@
import { Home, Smartphone, Calendar, Activity, Settings } from "lucide-react"; import {
Home,
Smartphone,
Calendar,
Activity,
Settings,
ShieldCheck,
} from "lucide-react";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
export type NavRole = "admin" | "user";
export interface NavItem { export interface NavItem {
key: string; key: string;
href: string; href: string;
label: string; label: string;
icon: LucideIcon; icon: LucideIcon;
/** When set, only roles listed here will see this nav entry. */
visibleTo?: NavRole[];
} }
export const NAV_ITEMS: NavItem[] = [ export const NAV_ITEMS: NavItem[] = [
@ -13,5 +24,54 @@ export const NAV_ITEMS: NavItem[] = [
{ key: "accounts", href: "/accounts", label: "Accounts", icon: Smartphone }, { key: "accounts", href: "/accounts", label: "Accounts", icon: Smartphone },
{ key: "reminders", href: "/reminders", label: "Reminders", icon: Calendar }, { key: "reminders", href: "/reminders", label: "Reminders", icon: Calendar },
{ key: "activity", href: "/activity", label: "Activity", icon: Activity }, { key: "activity", href: "/activity", label: "Activity", icon: Activity },
{
key: "admin",
href: "/settings/users",
label: "Admin",
icon: ShieldCheck,
visibleTo: ["admin"],
},
{ key: "settings", href: "/settings", label: "Settings", icon: Settings }, { key: "settings", href: "/settings", label: "Settings", icon: Settings },
]; ];
export function navItemsForRole(role: NavRole): NavItem[] {
return NAV_ITEMS.filter(
(item) => item.visibleTo === undefined || item.visibleTo.includes(role),
);
}
/**
* Pick the SINGLE active nav item for a given pathname. Solves the
* "Admin and Settings both highlighted on /settings/users" bug:
* naïve `pathname.startsWith(href)` matches BOTH /settings/users (the
* Admin entry) AND /settings (its parent). Two items lit up at once
* looks broken.
*
* Rules:
* - The Dashboard ('/') item only matches an exact pathname match;
* otherwise it would shadow every other route.
* - All other items match either an exact pathname or a strict
* descendant path (`<href>/...`). `pathname.startsWith(href)` on
* its own would also match `/settingsfoo`, which is wrong.
* - When two non-root items both match (parent + child), pick the
* LONGEST href so the more specific entry wins.
*
* Returns the active item's `key`, or null if no item matches (e.g.
* the user navigated to a route that isn't in the visible nav).
*/
export function pickActiveNavKey(items: NavItem[], pathname: string): string | null {
let best: NavItem | null = null;
for (const item of items) {
if (item.href === "/") {
if (pathname === "/") best = item;
continue;
}
const isMatch =
pathname === item.href || pathname.startsWith(item.href + "/");
if (!isMatch) continue;
if (!best || item.href.length > best.href.length) {
best = item;
}
}
return best?.key ?? null;
}

View File

@ -18,7 +18,8 @@ type PairingState =
| { phase: "waiting" } | { phase: "waiting" }
| { phase: "qr"; qrUrl: string } | { phase: "qr"; qrUrl: string }
| { phase: "connected"; phoneNumber: string } | { phase: "connected"; phoneNumber: string }
| { phase: "timeout" }; | { phase: "timeout" }
| { phase: "duplicate"; phoneNumber: string; existingLabel: string };
interface PairLiveProps { interface PairLiveProps {
accountId: string; accountId: string;
@ -112,6 +113,15 @@ export function PairLive({ accountId, label }: PairLiveProps) {
if (timerRef.current) clearInterval(timerRef.current); if (timerRef.current) clearInterval(timerRef.current);
setPairingState({ phase: "timeout" }); setPairingState({ phase: "timeout" });
}, },
"session.duplicate": (data) => {
if (data.accountId !== accountId) return;
if (timerRef.current) clearInterval(timerRef.current);
setPairingState({
phase: "duplicate",
phoneNumber: data.phoneNumber,
existingLabel: data.existingLabel,
});
},
}); });
// Auto-redirect on connected // Auto-redirect on connected
@ -234,6 +244,35 @@ export function PairLive({ accountId, label }: PairLiveProps) {
</Button> </Button>
</div> </div>
)} )}
{pairingState.phase === "duplicate" && (
<div className="flex flex-col items-center gap-4 text-center">
<div className="flex size-16 items-center justify-center rounded-full bg-amber-500/15">
<XCircleIcon className="size-8 text-amber-600 dark:text-amber-400" />
</div>
<div className="space-y-1">
<p className="text-base font-semibold">Phone already linked</p>
<p className="text-xs text-muted-foreground">
<span className="font-mono">
+{pairingState.phoneNumber.replace(/^\+/, "")}
</span>{" "}
is already paired to{" "}
<span className="font-medium text-foreground">
{pairingState.existingLabel}
</span>
. Each WhatsApp number can only be linked to one account here.
Unpair the existing account first, or scan with a different
phone.
</p>
</div>
<Button asChild size="sm">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={`/accounts/${accountId}` as any}>
Back to accounts
</Link>
</Button>
</div>
)}
</div> </div>
); );
} }

View File

@ -99,7 +99,7 @@ export function ReminderFilterBar({ accounts }: FilterBarProps) {
id="filter-account" id="filter-account"
value={initial.accountId} value={initial.accountId}
onChange={(e) => setParam("accountId", e.target.value)} onChange={(e) => setParam("accountId", e.target.value)}
className="h-8 w-full sm:max-w-xs rounded-lg border border-input bg-transparent px-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" className="h-8 w-full rounded-lg border border-input bg-transparent px-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
> >
<option value="">All accounts</option> <option value="">All accounts</option>
{accounts.map((a) => ( {accounts.map((a) => (

View File

@ -67,6 +67,11 @@ export function SwipeableRow({
const [dragging, setDragging] = useState(false); const [dragging, setDragging] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const dragStart = useRef<{ x: number; baseOffset: number } | null>(null); const dragStart = useRef<{ x: number; baseOffset: number } | null>(null);
// Tracks whether the pointer crossed the click-vs-drag threshold during
// the current gesture. If it did, we swallow the synthetic click that
// browsers fire on pointerup — otherwise a swipe on a Link-wrapped row
// both swipes the shelf open AND navigates to the link target.
const dragMoved = useRef(false);
// Close the shelf when the user taps anywhere outside an open row. // Close the shelf when the user taps anywhere outside an open row.
useEffect(() => { useEffect(() => {
@ -92,12 +97,17 @@ export function SwipeableRow({
function handlePointerDown(e: React.PointerEvent<HTMLDivElement>) { function handlePointerDown(e: React.PointerEvent<HTMLDivElement>) {
if (e.button !== 0 && e.pointerType === "mouse") return; if (e.button !== 0 && e.pointerType === "mouse") return;
dragStart.current = { x: e.clientX, baseOffset: offset }; dragStart.current = { x: e.clientX, baseOffset: offset };
dragMoved.current = false;
setDragging(true); setDragging(true);
} }
function handlePointerMove(e: React.PointerEvent<HTMLDivElement>) { function handlePointerMove(e: React.PointerEvent<HTMLDivElement>) {
if (!dragging || !dragStart.current) return; if (!dragging || !dragStart.current) return;
const dx = e.clientX - dragStart.current.x; const dx = e.clientX - dragStart.current.x;
// 6 px is the standard threshold below which a touch counts as a tap
// rather than a drag. Cross it once and the gesture commits to drag
// for the rest of the pointer's lifetime.
if (Math.abs(dx) > 6) dragMoved.current = true;
setOffset(clamp(dragStart.current.baseOffset + dx)); setOffset(clamp(dragStart.current.baseOffset + dx));
} }
@ -113,6 +123,28 @@ export function SwipeableRow({
rightWidth, rightWidth,
}), }),
); );
if (dragMoved.current) {
// The browser fires a synthetic `click` on the element under the
// pointer right after pointerup. If our row body wraps a <Link>,
// that click navigates away. Add a one-shot capture-phase handler
// that swallows the next click ANYWHERE in the row container
// before it can reach the anchor's onClick.
const swallow = (ev: Event) => {
ev.preventDefault();
ev.stopPropagation();
};
const node = containerRef.current;
if (node) {
node.addEventListener("click", swallow, { capture: true, once: true });
// Defensive: if for some reason no click fires (e.g. pointerup
// outside the element), strip the listener after a tick so it
// doesn't accidentally eat a future legitimate click.
window.setTimeout(() => {
node.removeEventListener("click", swallow, { capture: true });
}, 350);
}
}
dragMoved.current = false;
} }
return ( return (
@ -150,6 +182,14 @@ export function SwipeableRow({
onPointerMove={handlePointerMove} onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp} onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp} onPointerCancel={handlePointerUp}
// Anchors (and <img>) are natively draggable. When children
// contain a <Link> wrapping the card, the browser hijacks the
// pointer for a "drag link" operation as soon as the user
// moves horizontally, so the swipe gesture never reaches our
// pointer handlers. Suppress native drag here once and the
// whole row body is unblocked.
onDragStart={(e) => e.preventDefault()}
draggable={false}
style={{ style={{
transform: `translateX(${offset}px)`, transform: `translateX(${offset}px)`,
transition: dragging ? "none" : "transform 200ms ease-out", transition: dragging ? "none" : "transform 200ms ease-out",

View File

@ -8,4 +8,25 @@ const envSchema = z.object({
}); });
export type Env = z.infer<typeof envSchema>; export type Env = z.infer<typeof envSchema>;
export const env = envSchema.parse(process.env);
// Lazy parse via Proxy. Next.js's `next build` does a
// "Collecting page data" pass that imports every route module —
// including api/events/route.ts which depends on this env. With a
// top-level `envSchema.parse(process.env)` the parse ran during
// the build container, where DATABASE_URL isn't (and shouldn't be)
// set, and Zod aborted the build with:
// ZodError: DATABASE_URL: Required
// Deferring the parse until first property access lets the build
// finish (no consumer accesses env during page-data collection)
// while still failing loudly at runtime if the var is missing.
let cached: Env | null = null;
function read(): Env {
if (cached) return cached;
cached = envSchema.parse(process.env);
return cached;
}
export const env: Env = new Proxy({} as Env, {
get(_t, prop) {
return read()[prop as keyof Env];
},
}) as Env;

View File

@ -9,6 +9,11 @@ export type WebEventMap = {
"session.connected": { accountId: string; phoneNumber: string | null }; "session.connected": { accountId: string; phoneNumber: string | null };
"session.disconnected": { accountId: string }; "session.disconnected": { accountId: string };
"session.timeout": { accountId: string }; "session.timeout": { accountId: string };
"session.duplicate": {
accountId: string;
phoneNumber: string;
existingLabel: string;
};
"groups.synced": { accountId: string; count: number }; "groups.synced": { accountId: string; count: number };
"reminder.fired": { "reminder.fired": {
reminderId: string; reminderId: string;

View File

@ -0,0 +1,135 @@
import { describe, it, expect, beforeAll } from "vitest";
import {
signSession,
verifySession,
COOKIE_NAME,
DEFAULT_TTL_SECONDS,
type SessionPayload,
} from "./auth-cookie";
const SECRET = "test-secret-not-used-anywhere-real";
const NOW = 1_700_000_000; // 2023-11-14 — fixed clock for determinism
beforeAll(() => {
process.env.AUTH_SECRET = SECRET;
process.env.OPERATOR_TOKEN_VERSION = "1";
});
const validPayload = (): SessionPayload => ({
userId: "11111111-1111-1111-1111-111111111111",
role: "admin",
iat: NOW,
exp: NOW + DEFAULT_TTL_SECONDS,
v: 1,
});
describe("auth-cookie (AES-256-GCM)", () => {
it("signSession + verifySession round-trips a valid payload", async () => {
const cookie = await signSession(validPayload(), SECRET);
const verified = await verifySession(cookie, SECRET, NOW);
expect(verified).toEqual(validPayload());
});
it("uses a fresh IV per call so two encryptions of the same payload differ", async () => {
// Repeating the IV under AES-GCM is catastrophic (it leaks the XOR
// of plaintexts and the auth key). Lock in that signSession draws
// a new nonce every time — the byte-for-byte cookies must not match
// even when the inputs are identical.
const a = await signSession(validPayload(), SECRET);
const b = await signSession(validPayload(), SECRET);
expect(a).not.toBe(b);
// Both still decrypt correctly with the same secret.
expect(await verifySession(a, SECRET, NOW)).toEqual(validPayload());
expect(await verifySession(b, SECRET, NOW)).toEqual(validPayload());
});
it("ciphertext does NOT leak the userId in plaintext (confidentiality)", async () => {
const cookie = await signSession(validPayload(), SECRET);
// The whole point of the GCM upgrade: someone with only the cookie
// value should not be able to read the userId / role straight off
// it the way they could with the old base64-encoded JSON.
expect(cookie).not.toContain(validPayload().userId);
expect(cookie).not.toContain("admin");
});
it("rejects when the ciphertext has been tampered with (auth tag mismatch)", async () => {
const cookie = await signSession(validPayload(), SECRET);
const [iv, ct] = cookie.split(".");
// Flip the last character of the ciphertext (still valid base64url).
const lastCh = ct!.slice(-1);
const replacement = lastCh === "A" ? "B" : "A";
const tampered = `${iv}.${ct!.slice(0, -1)}${replacement}`;
expect(await verifySession(tampered, SECRET, NOW)).toBeNull();
});
it("rejects when the IV has been swapped for another (auth tag mismatch)", async () => {
const cookie = await signSession(validPayload(), SECRET);
const otherIv = await signSession(validPayload(), SECRET);
const [, ct] = cookie.split(".");
const [otherIvB64] = otherIv.split(".");
const tampered = `${otherIvB64}.${ct}`;
expect(await verifySession(tampered, SECRET, NOW)).toBeNull();
});
it("rejects when verified with a different secret", async () => {
const cookie = await signSession(validPayload(), SECRET);
expect(await verifySession(cookie, "different-secret", NOW)).toBeNull();
});
it("rejects an expired cookie (exp <= now)", async () => {
const expired = { ...validPayload(), exp: NOW - 1 };
const cookie = await signSession(expired, SECRET);
expect(await verifySession(cookie, SECRET, NOW)).toBeNull();
});
it("rejects a cookie issued in the future beyond clock-skew tolerance", async () => {
const future = { ...validPayload(), iat: NOW + 120 };
const cookie = await signSession(future, SECRET);
expect(await verifySession(cookie, SECRET, NOW)).toBeNull();
});
it("accepts a cookie issued slightly in the future (within 60s skew)", async () => {
const future = { ...validPayload(), iat: NOW + 30 };
const cookie = await signSession(future, SECRET);
expect(await verifySession(cookie, SECRET, NOW)).not.toBeNull();
});
it("rejects a cookie whose v is stale (token-version bumped)", async () => {
const cookie = await signSession({ ...validPayload(), v: 1 }, SECRET);
process.env.OPERATOR_TOKEN_VERSION = "2";
expect(await verifySession(cookie, SECRET, NOW)).toBeNull();
process.env.OPERATOR_TOKEN_VERSION = "1";
});
it("rejects a cookie with an unknown role string", async () => {
const cookie = await signSession(
{ ...validPayload(), role: "superadmin" as never },
SECRET,
);
expect(await verifySession(cookie, SECRET, NOW)).toBeNull();
});
it("rejects a cookie that doesn't have a '.' separator", async () => {
expect(await verifySession("not-a-cookie", SECRET, NOW)).toBeNull();
expect(await verifySession("", SECRET, NOW)).toBeNull();
});
it("rejects a cookie whose IV decodes to the wrong byte length", async () => {
// GCM requires a 12-byte nonce. Swap the IV portion for something
// that decodes to a different length and confirm we bounce it
// before handing weird input to crypto.subtle.decrypt.
const cookie = await signSession(validPayload(), SECRET);
const [, ct] = cookie.split(".");
// 8 bytes encoded — too short.
const shortIv = "AAAAAAAAAAA";
expect(await verifySession(`${shortIv}.${ct}`, SECRET, NOW)).toBeNull();
});
it("rejects malformed (non-base64url) input gracefully — no throw, just null", async () => {
expect(await verifySession("$$$.$$$", SECRET, NOW)).toBeNull();
});
it("exposes COOKIE_NAME as 'session'", () => {
expect(COOKIE_NAME).toBe("session");
});
});

View File

@ -0,0 +1,148 @@
/**
* Edge-runtime-safe AES-256-GCM session cookie. Runs in middleware
* and Server Actions. NO database, NO bcrypt, NO Node-only APIs
* pure Web Crypto so it survives Edge runtime.
*
* Why GCM instead of HMAC-then-plaintext: GCM is authenticated
* encryption, so a leaked cookie no longer hands the userId/role to
* an attacker who only sees the bytes. Tampering with either the IV
* or the ciphertext invalidates the auth tag decrypt throws we
* return null. Replay protection comes from the per-payload `exp`
* field plus the global `OPERATOR_TOKEN_VERSION` kill switch.
*
* Cookie format: `<base64url(iv)>.<base64url(ciphertext+tag)>`
* - iv: 12 random bytes (GCM nonce)
* - ciphertext+tag: AES-GCM output (plaintext + 16-byte auth tag)
*/
export const COOKIE_NAME = "session";
export const DEFAULT_TTL_SECONDS = 30 * 86400; // 30 days
export const CLOCK_SKEW_SECONDS = 60;
export type Role = "admin" | "user";
export interface SessionPayload {
userId: string;
role: Role;
iat: number;
exp: number;
v: number;
}
function isValidPayload(x: unknown): x is SessionPayload {
if (typeof x !== "object" || x === null) return false;
const o = x as Record<string, unknown>;
return (
typeof o.userId === "string" &&
(o.role === "admin" || o.role === "user") &&
typeof o.iat === "number" &&
typeof o.exp === "number" &&
typeof o.v === "number"
);
}
function b64urlEncode(bytes: Uint8Array): string {
let s = "";
for (const b of bytes) s += String.fromCharCode(b);
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function b64urlDecode(str: string): Uint8Array {
const pad = str.length % 4 === 0 ? "" : "=".repeat(4 - (str.length % 4));
const s = atob(str.replace(/-/g, "+").replace(/_/g, "/") + pad);
const out = new Uint8Array(s.length);
for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
return out;
}
/**
* Derive a 256-bit AES key from the operator-supplied AUTH_SECRET.
* SHA-256 hashes the secret to a fixed-length key so the secret can
* be any printable string in env (no min/max length policing here).
*/
async function deriveKey(secret: string): Promise<CryptoKey> {
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(secret),
);
return crypto.subtle.importKey(
"raw",
digest,
{ name: "AES-GCM" },
false,
["encrypt", "decrypt"],
);
}
export async function signSession(
payload: SessionPayload,
secret: string,
): Promise<string> {
const iv = crypto.getRandomValues(new Uint8Array(12));
const key = await deriveKey(secret);
const plaintext = new TextEncoder().encode(JSON.stringify(payload));
const ct = new Uint8Array(
await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: iv as BufferSource },
key,
plaintext as BufferSource,
),
);
return `${b64urlEncode(iv)}.${b64urlEncode(ct)}`;
}
export async function verifySession(
cookie: string,
secret: string,
now: number = Math.floor(Date.now() / 1000),
): Promise<SessionPayload | null> {
if (!cookie || typeof cookie !== "string") return null;
const dot = cookie.indexOf(".");
if (dot <= 0 || dot === cookie.length - 1) return null;
let iv: Uint8Array;
let ct: Uint8Array;
try {
iv = b64urlDecode(cookie.slice(0, dot));
ct = b64urlDecode(cookie.slice(dot + 1));
} catch {
return null;
}
// GCM nonces are exactly 12 bytes. A wrong-sized IV would still
// sometimes succeed at the WebCrypto layer on some platforms;
// guard explicitly so callers can't slip a non-standard nonce past us.
if (iv.length !== 12) return null;
let plain: string;
try {
const key = await deriveKey(secret);
// The IV in `AesGcmParams` must be backed by a non-shared
// ArrayBuffer; TS 5.7+ tightened Uint8Array's generic to reject
// `SharedArrayBuffer`-backed views. b64urlDecode produces a
// regular ArrayBuffer, but we cast to BufferSource explicitly so
// future allocator changes don't regress this site.
const buf = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: iv as BufferSource },
key,
ct as BufferSource,
);
plain = new TextDecoder().decode(buf);
} catch {
// Auth-tag mismatch (tampered cookie or wrong key) lands here.
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(plain);
} catch {
return null;
}
if (!isValidPayload(parsed)) return null;
if (parsed.exp <= now) return null;
if (parsed.iat > now + CLOCK_SKEW_SECONDS) return null;
const expectedV = Number(process.env.OPERATOR_TOKEN_VERSION ?? "1");
if (parsed.v !== expectedV) return null;
return parsed;
}

View File

@ -0,0 +1,89 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const cookiesGetMock = vi.fn();
const findUserMock = vi.fn();
vi.mock("next/headers", () => ({
cookies: async () => ({ get: cookiesGetMock }),
}));
vi.mock("./db", () => ({
db: {
query: {
operators: {
findFirst: (...a: unknown[]) => findUserMock(...a),
},
},
},
}));
const SECRET = "test-secret";
beforeEach(() => {
process.env.AUTH_SECRET = SECRET;
process.env.OPERATOR_TOKEN_VERSION = "1";
cookiesGetMock.mockReset();
findUserMock.mockReset();
});
import { signSession } from "./auth-cookie";
import { getCurrentUser, requireUser, requireAdmin } from "./auth";
const NOW_S = Math.floor(Date.now() / 1000);
const ADMIN = {
id: "11111111-1111-1111-1111-111111111111",
username: "admin",
role: "admin" as const,
displayName: "Admin",
defaultTimezone: "UTC",
passwordHash: null,
};
const USER = { ...ADMIN, id: "22222222-2222-2222-2222-222222222222", username: "alice", role: "user" as const };
async function makeCookie(role: "admin" | "user"): Promise<string> {
return signSession(
{
userId: role === "admin" ? ADMIN.id : USER.id,
role,
iat: NOW_S,
exp: NOW_S + 3600,
v: 1,
},
SECRET,
);
}
describe("auth helpers", () => {
it("getCurrentUser returns null when no cookie is set", async () => {
cookiesGetMock.mockReturnValue(undefined);
const u = await getCurrentUser();
expect(u).toBeNull();
});
it("getCurrentUser returns the user row for a valid admin cookie", async () => {
const cookie = await makeCookie("admin");
cookiesGetMock.mockReturnValue({ value: cookie });
findUserMock.mockResolvedValue(ADMIN);
const u = await getCurrentUser();
expect(u?.id).toBe(ADMIN.id);
expect(u?.role).toBe("admin");
});
it("requireUser throws when there is no session", async () => {
cookiesGetMock.mockReturnValue(undefined);
await expect(requireUser()).rejects.toThrow();
});
it("requireAdmin throws when role is 'user'", async () => {
const cookie = await makeCookie("user");
cookiesGetMock.mockReturnValue({ value: cookie });
findUserMock.mockResolvedValue(USER);
await expect(requireAdmin()).rejects.toThrow();
});
it("requireAdmin returns the user when role is 'admin'", async () => {
const cookie = await makeCookie("admin");
cookiesGetMock.mockReturnValue({ value: cookie });
findUserMock.mockResolvedValue(ADMIN);
const u = await requireAdmin();
expect(u.role).toBe("admin");
});
});

66
apps/web/src/lib/auth.ts Normal file
View File

@ -0,0 +1,66 @@
import "server-only";
import { cookies } from "next/headers";
import { db } from "./db";
import { COOKIE_NAME, verifySession } from "./auth-cookie";
export type AuthUser = {
id: string;
username: string;
role: "admin" | "user";
displayName: string;
defaultTimezone: string;
passwordHash: string | null;
};
export class UnauthenticatedError extends Error {
constructor() {
super("Unauthenticated");
this.name = "UnauthenticatedError";
}
}
export class ForbiddenError extends Error {
constructor() {
super("Forbidden");
this.name = "ForbiddenError";
}
}
/**
* Returns the operator row whose userId is encoded in the session
* cookie, or null if the cookie is missing / invalid / the row is
* gone. Never throws call requireUser() if you want a throw.
*/
export async function getCurrentUser(): Promise<AuthUser | null> {
const jar = await cookies();
const cookie = jar.get(COOKIE_NAME)?.value;
if (!cookie) return null;
const secret = process.env.AUTH_SECRET;
if (!secret) return null;
const payload = await verifySession(cookie, secret);
if (!payload) return null;
const row = await db.query.operators.findFirst({
where: (o, { eq }) => eq(o.id, payload.userId),
});
if (!row) return null;
if (row.role !== "admin" && row.role !== "user") return null;
return {
id: row.id,
username: row.username,
role: row.role,
displayName: row.displayName,
defaultTimezone: row.defaultTimezone,
passwordHash: row.passwordHash,
};
}
export async function requireUser(): Promise<AuthUser> {
const u = await getCurrentUser();
if (!u) throw new UnauthenticatedError();
return u;
}
export async function requireAdmin(): Promise<AuthUser> {
const u = await requireUser();
if (u.role !== "admin") throw new ForbiddenError();
return u;
}

View File

@ -0,0 +1,49 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
/**
* Static guard: listAccounts() in apps/web/src/lib/queries.ts MUST
* order rows by createdAt ascending (with id as a deterministic
* tiebreaker) so the operator's earliest-added account stays on top.
*
* Earlier the orderBy used `asc(a.label)` which silently re-shuffled
* the list every time an account was renamed. This test pins the
* fix in source so a future refactor can't quietly bring the rename
* regression back.
*
* It's a static (regex) guard rather than an integration test
* because the live query needs Postgres + a seeded operator;
* pinning the source spelling keeps coverage cheap and CI-friendly.
*/
describe("listAccounts ordering (regression guard)", () => {
const src = readFileSync(
join(__dirname, "queries.ts"),
"utf8",
);
it("orders by created_at ASC", () => {
// Match across whitespace/comments inside listAccounts. Anchors:
// function header → orderBy → asc(a.createdAt).
const fnStart = src.indexOf("export async function listAccounts(");
expect(fnStart).toBeGreaterThan(-1);
const fnEnd = src.indexOf("export async function ", fnStart + 1);
const fnBody = src.slice(fnStart, fnEnd > -1 ? fnEnd : undefined);
expect(fnBody).toMatch(/orderBy:[\s\S]*asc\(a\.createdAt\)/);
});
it("uses id as a deterministic tiebreaker for ties on createdAt", () => {
const fnStart = src.indexOf("export async function listAccounts(");
const fnEnd = src.indexOf("export async function ", fnStart + 1);
const fnBody = src.slice(fnStart, fnEnd > -1 ? fnEnd : undefined);
expect(fnBody).toMatch(/asc\(a\.id\)/);
});
it("does NOT order by label (the regression we're guarding against)", () => {
const fnStart = src.indexOf("export async function listAccounts(");
const fnEnd = src.indexOf("export async function ", fnStart + 1);
const fnBody = src.slice(fnStart, fnEnd > -1 ? fnEnd : undefined);
expect(fnBody).not.toMatch(/asc\(a\.label\)/);
expect(fnBody).not.toMatch(/desc\(a\.label\)/);
});
});

View File

@ -5,6 +5,10 @@ import { db } from "./db";
export type BotCommand = export type BotCommand =
| { type: "account.start_pairing"; accountId: string } | { type: "account.start_pairing"; accountId: string }
| { type: "account.unpair"; accountId: string } | { type: "account.unpair"; accountId: string }
// Like account.unpair, but the bot also calls socket.logout() so
// WhatsApp drops this device from the operator's linked-devices
// list before the row is deleted.
| { type: "account.delete"; accountId: string }
| { type: "account.sync_groups"; accountId: string } | { type: "account.sync_groups"; accountId: string }
| { type: "group.send_test"; groupId: string; text: string } | { type: "group.send_test"; groupId: string; text: string }
| { type: "reminder.schedule"; reminderId: string; scheduledAtIso: string } | { type: "reminder.schedule"; reminderId: string; scheduledAtIso: string }

View File

@ -1,16 +1,21 @@
import "server-only"; import "server-only";
import { db } from "./db"; import { getCurrentUser } from "./auth";
/** /**
* Returns the single seeded operator row. Since the app has no auth, * Compatibility shim. The app used to seed a single operator and
* every action is attributed to this operator. * attribute everything to it; now we have real auth + roles. Existing
* call sites read `.id` and `.defaultTimezone` off the returned
* object both are still present on the AuthUser shape, so the
* swap is mechanical and existing tests that mock @/lib/operator
* keep working unchanged.
*
* New code should call getCurrentUser / requireUser / requireAdmin
* from @/lib/auth directly.
*/ */
export async function getSeededOperator() { export async function getSeededOperator() {
const op = await db.query.operators.findFirst({ const u = await getCurrentUser();
orderBy: (o, { asc }) => [asc(o.createdAt)], if (!u) {
}); throw new Error("Not authenticated");
if (!op) {
throw new Error("No operator row seeded. Run scripts/db.sh seed.");
} }
return op; return u;
} }

View File

@ -0,0 +1,69 @@
import { describe, it, expect } from "vitest";
import {
validatePassword,
MIN_PASSWORD_LEN,
MAX_PASSWORD_LEN,
} from "./password-policy";
describe("validatePassword", () => {
it("accepts the canonical mixed-case + digit example", () => {
expect(validatePassword("hengs3rver").ok).toBe(true);
});
it("accepts the bare minimum length with a number", () => {
// 6 chars, letter + digit. Boundary case for MIN_PASSWORD_LEN.
expect(validatePassword("abc12!").ok).toBe(true);
});
it("accepts symbols in place of digits", () => {
expect(validatePassword("abcde!").ok).toBe(true);
});
it("rejects passwords shorter than the minimum", () => {
const r = validatePassword("ab1!");
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toMatch(/at least 6/);
});
it("rejects letters-only passwords", () => {
const r = validatePassword("abcdefgh");
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toMatch(/letters with numbers or symbols/);
});
it("rejects digits-only passwords", () => {
const r = validatePassword("12345678");
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toMatch(/letters/);
});
it("rejects symbols-only passwords (no letters)", () => {
const r = validatePassword("!!!!!!!!");
expect(r.ok).toBe(false);
});
it("rejects passwords longer than MAX_PASSWORD_LEN", () => {
const tooLong = "a".repeat(MAX_PASSWORD_LEN) + "1";
const r = validatePassword(tooLong);
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toMatch(/too long/);
});
it("rejects empty input", () => {
expect(validatePassword("").ok).toBe(false);
});
it("rejects non-string input defensively", () => {
// Server actions are typed but a malformed FormData payload could land
// here as null/undefined; the validator must not throw.
// @ts-expect-error - defensive runtime guard
expect(validatePassword(null).ok).toBe(false);
// @ts-expect-error - defensive runtime guard
expect(validatePassword(undefined).ok).toBe(false);
});
it("exposes the documented Facebook-aligned thresholds", () => {
expect(MIN_PASSWORD_LEN).toBe(6);
expect(MAX_PASSWORD_LEN).toBe(256);
});
});

View File

@ -0,0 +1,37 @@
/**
* Password policy modeled after Facebook's documented requirement
* (https://www.facebook.com/help/124904560921566): at least 6
* characters, with a recommended mix of letters and numbers/punctuation.
*
* We enforce the hard minimum (6) and the recommended-mix rule on
* password creation/reset (admin-only flows). Sign-in itself stays
* permissive old short passwords keep working until they're reset
* since rejecting them at login would lock people out without a recovery
* path.
*/
export const MIN_PASSWORD_LEN = 6;
export const MAX_PASSWORD_LEN = 256;
export type PasswordCheck = { ok: true } | { ok: false; error: string };
export function validatePassword(pw: string): PasswordCheck {
if (typeof pw !== "string" || pw.length < MIN_PASSWORD_LEN) {
return {
ok: false,
error: `Password must be at least ${MIN_PASSWORD_LEN} characters.`,
};
}
if (pw.length > MAX_PASSWORD_LEN) {
return { ok: false, error: "Password is too long." };
}
const hasLetter = /[A-Za-z]/.test(pw);
const hasNonLetter = /[^A-Za-z]/.test(pw);
if (!hasLetter || !hasNonLetter) {
return {
ok: false,
error: "Password must mix letters with numbers or symbols.",
};
}
return { ok: true };
}

View File

@ -6,9 +6,18 @@ export async function getDashboardStats(operatorId: string) {
const accounts = await db.query.whatsappAccounts.findMany({ const accounts = await db.query.whatsappAccounts.findMany({
where: (a, { eq }) => eq(a.operatorId, operatorId), where: (a, { eq }) => eq(a.operatorId, operatorId),
}); });
// All reminder rows so the dashboard can show active/total in one query. // Reminders scoped to this operator's accounts. The previous
// Status enum today is active / ended (paused will join in a later phase). // findMany() with no filter leaked global counts across users — a
const allReminders = await db.query.reminders.findMany(); // brand-new user would see another operator's totals on the
// dashboard. INNER JOIN on whatsapp_accounts.operator_id keeps each
// user's view isolated.
const reminderRows = await db.execute(sql`
SELECT r.id, r.status
FROM reminders r
INNER JOIN whatsapp_accounts wa ON wa.id = r.account_id
WHERE wa.operator_id = ${operatorId}
`);
const allReminders = reminderRows.rows as Array<{ id: string; status: string }>;
// LEFT JOIN so runs whose reminder has been deleted still appear. The // LEFT JOIN so runs whose reminder has been deleted still appear. The
// ownership filter widens to: either the reminder still exists and the // ownership filter widens to: either the reminder still exists and the
// operator owns its account, OR the reminder is gone but the run row // operator owns its account, OR the reminder is gone but the run row
@ -54,9 +63,12 @@ export async function listAccounts(operatorId: string) {
// exposes Pair / Re-pair / Delete actions accordingly. Hiding rows // exposes Pair / Re-pair / Delete actions accordingly. Hiding rows
// by status produced phantom "I created an account but it's gone" // by status produced phantom "I created an account but it's gone"
// bug reports. // bug reports.
// Earliest-added on top, newest at the bottom. Stable across renames
// (a label edit shouldn't reorder the list and confuse muscle memory)
// and matches how other admin tools order accounts that grow over time.
return db.query.whatsappAccounts.findMany({ return db.query.whatsappAccounts.findMany({
where: (a, { eq }) => eq(a.operatorId, operatorId), where: (a, { eq }) => eq(a.operatorId, operatorId),
orderBy: (a, { asc }) => [asc(a.label)], orderBy: (a, { asc }) => [asc(a.createdAt), asc(a.id)],
}); });
} }
@ -70,11 +82,19 @@ export async function listGroupsForAccount(operatorId: string, accountId: string
const account = await getAccount(operatorId, accountId); const account = await getAccount(operatorId, accountId);
if (!account) return null; if (!account) return null;
const trimmed = (q ?? "").trim(); const trimmed = (q ?? "").trim();
// Hide archived groups from the picker by default. They're rows
// that disappeared from the live participant list (group deleted,
// bot kicked, etc.) but still have reminder_targets pointing at
// them — see the soft-archive flow in apps/bot/src/whatsapp/
// group-sync.ts. Surfacing archived rows here would let an
// operator pick a group the bot can't actually reach.
const rows = trimmed const rows = trimmed
? await db.execute(sql` ? await db.execute(sql`
SELECT id, account_id, wa_group_jid, name, participant_count, is_archived, last_synced_at SELECT id, account_id, wa_group_jid, name, participant_count, is_archived, last_synced_at
FROM whatsapp_groups FROM whatsapp_groups
WHERE account_id = ${accountId} AND name % ${trimmed} WHERE account_id = ${accountId}
AND is_archived = false
AND name % ${trimmed}
ORDER BY similarity(name, ${trimmed}) DESC ORDER BY similarity(name, ${trimmed}) DESC
LIMIT 50 LIMIT 50
`) `)
@ -82,6 +102,7 @@ export async function listGroupsForAccount(operatorId: string, accountId: string
SELECT id, account_id, wa_group_jid, name, participant_count, is_archived, last_synced_at SELECT id, account_id, wa_group_jid, name, participant_count, is_archived, last_synced_at
FROM whatsapp_groups FROM whatsapp_groups
WHERE account_id = ${accountId} WHERE account_id = ${accountId}
AND is_archived = false
ORDER BY name ASC ORDER BY name ASC
LIMIT 200 LIMIT 200
`); `);
@ -187,11 +208,13 @@ export async function listActivityRuns(
// exposes a stable shape. LEFT JOIN keeps orphan runs (whose reminder // exposes a stable shape. LEFT JOIN keeps orphan runs (whose reminder
// has been deleted but history was preserved) in the list. // has been deleted but history was preserved) in the list.
// The `archived` flag flips the visibility filter: // The `archived` flag flips the visibility filter:
// false (default) — only non-archived rows // false (default) — non-archived, non-skipped rows (skipped runs
// true — only archived rows (for the Archived tab) // belong to the Archived tab now)
// true — archived rows OR skipped rows (they're treated
// as "history" rather than active outcomes)
const archivedClause = opts.archived const archivedClause = opts.archived
? sql`rr.archived_at IS NOT NULL` ? sql`(rr.archived_at IS NOT NULL OR rr.status = 'skipped')`
: sql`rr.archived_at IS NULL`; : sql`rr.archived_at IS NULL AND rr.status <> 'skipped'`;
const rows = await db.execute(sql` const rows = await db.execute(sql`
SELECT SELECT
rr.id, rr.id,

View File

@ -0,0 +1,43 @@
import { describe, it, expect } from "vitest";
import { safeRedirect } from "./safe-redirect";
describe("safeRedirect", () => {
it("preserves a relative path that starts with a single slash", () => {
expect(safeRedirect("/dashboard")).toBe("/dashboard");
expect(safeRedirect("/reminders/new")).toBe("/reminders/new");
});
it("preserves query string and fragment", () => {
expect(safeRedirect("/legit?with=params&extra=fine#hash")).toBe(
"/legit?with=params&extra=fine#hash",
);
});
it("rejects protocol-relative URLs (//evil.com)", () => {
expect(safeRedirect("//evil.com")).toBe("/");
expect(safeRedirect("//evil.com/dashboard")).toBe("/");
});
it("rejects absolute URLs", () => {
expect(safeRedirect("https://evil.com")).toBe("/");
expect(safeRedirect("http://evil.com/dashboard")).toBe("/");
});
it("rejects javascript: and data: schemes", () => {
expect(safeRedirect("javascript:alert(1)")).toBe("/");
expect(safeRedirect("data:text/html,<script>x</script>")).toBe("/");
});
it("falls back to / for empty / null / undefined / whitespace input", () => {
expect(safeRedirect("")).toBe("/");
expect(safeRedirect(null)).toBe("/");
expect(safeRedirect(undefined)).toBe("/");
expect(safeRedirect(" ")).toBe("/");
});
it("rejects paths that don't start with / (relative-relative)", () => {
expect(safeRedirect("dashboard")).toBe("/");
expect(safeRedirect("./dashboard")).toBe("/");
expect(safeRedirect("../dashboard")).toBe("/");
});
});

View File

@ -0,0 +1,16 @@
/**
* Returns `next` if it is a safe relative path, otherwise "/".
*
* Safe means: starts with a single forward slash AND not "//" (which
* is a protocol-relative URL, e.g. //evil.com). Anything else falls
* back to the root including empty input, absolute URLs, javascript:
* URIs, and relative-relative paths like "dashboard" or "../foo".
*/
export function safeRedirect(next: string | null | undefined): string {
if (typeof next !== "string") return "/";
const s = next.trim();
if (s.length < 2) return "/";
if (!s.startsWith("/")) return "/";
if (s.startsWith("//")) return "/";
return s;
}

View File

@ -0,0 +1,84 @@
import { describe, it, expect, beforeAll } from "vitest";
import { NextRequest } from "next/server";
const SECRET = "test-secret";
beforeAll(() => {
process.env.AUTH_SECRET = SECRET;
process.env.OPERATOR_TOKEN_VERSION = "1";
});
import { signSession } from "./lib/auth-cookie";
import { middleware } from "./middleware";
async function makeReq(path: string, cookie?: string): Promise<NextRequest> {
const url = new URL(`https://wabot.04080616.xyz${path}`);
const headers = new Headers();
if (cookie) headers.set("cookie", `session=${cookie}`);
return new NextRequest(url, { headers });
}
async function validCookie(): Promise<string> {
const now = Math.floor(Date.now() / 1000);
return signSession(
{
userId: "00000000-0000-0000-0000-000000000000",
role: "admin",
iat: now,
exp: now + 3600,
v: 1,
},
SECRET,
);
}
describe("middleware", () => {
it("page request without a cookie redirects to /login?next=…", async () => {
const r = await middleware(await makeReq("/dashboard"));
expect(r.status).toBe(307);
expect(r.headers.get("location")).toContain("/login");
expect(r.headers.get("location")).toContain("next=%2Fdashboard");
});
it("/api/* request without a cookie returns 401 with no body", async () => {
const r = await middleware(await makeReq("/api/events"));
expect(r.status).toBe(401);
});
it("page request with a valid cookie passes through", async () => {
const r = await middleware(await makeReq("/dashboard", await validCookie()));
// NextResponse.next() returns a 200 with the x-middleware-next header.
expect(r.status).toBe(200);
});
it("page request with a tampered cookie redirects to /login", async () => {
const cookie = (await validCookie()).slice(0, -4) + "AAAA";
const r = await middleware(await makeReq("/dashboard", cookie));
expect(r.status).toBe(307);
expect(r.headers.get("location")).toContain("/login");
});
it("allowlisted paths bypass auth (login, logout, health, manifest, icons)", async () => {
for (const path of [
"/login",
"/logout",
"/api/health",
"/manifest.webmanifest",
"/icon-192.png",
"/favicon.ico",
]) {
const r = await middleware(await makeReq(path));
expect(r.status).toBe(200);
}
});
it("/api/events and /api/qr/<id> are NOT in the allowlist (regression)", async () => {
expect((await middleware(await makeReq("/api/events"))).status).toBe(401);
expect(
(
await middleware(
await makeReq("/api/qr/11111111-1111-1111-1111-111111111111"),
)
).status,
).toBe(401);
});
});

View File

@ -1,21 +1,41 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { COOKIE_NAME, verifySession } from "./lib/auth-cookie";
export function middleware(req: NextRequest) { const PUBLIC_PATHS = new Set<string>([
"/login",
"/logout",
"/api/health",
"/manifest.webmanifest",
"/favicon.ico",
"/robots.txt",
]);
function isPublic(path: string): boolean {
if (PUBLIC_PATHS.has(path)) return true;
if (path.startsWith("/icon-")) return true;
if (path.startsWith("/_next/")) return true;
return false;
}
export async function middleware(req: NextRequest): Promise<NextResponse> {
const path = req.nextUrl.pathname; const path = req.nextUrl.pathname;
if (isPublic(path)) return NextResponse.next();
// Block all /api/* except a small set of read-only endpoints. const cookie = req.cookies.get(COOKIE_NAME)?.value;
// Mutations happen via Server Actions which post to page URLs, not /api/*. const secret = process.env.AUTH_SECRET;
const allowed = const ok =
path === "/api/events" || !!cookie && !!secret && (await verifySession(cookie, secret)) !== null;
path === "/api/health" || if (ok) return NextResponse.next();
path.startsWith("/api/qr/");
if (path.startsWith("/api/") && !allowed) { if (path.startsWith("/api/")) {
return new NextResponse("Not Found", { status: 404 }); return new NextResponse("Unauthorized", { status: 401 });
} }
const url = req.nextUrl.clone();
return NextResponse.next(); url.pathname = "/login";
url.searchParams.set("next", path + (req.nextUrl.search || ""));
return NextResponse.redirect(url);
} }
export const config = { export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico|icon-).*)"], matcher: ["/((?!_next/static|_next/image).*)"],
}; };

View File

@ -0,0 +1,59 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import {
assertJournalMonotonic,
formatJournalViolations,
type JournalEntry,
} from "@cmbot/db/journal-check";
/**
* CI guard against the recurring drizzle journal-skip bug.
*
* Drizzle's migrator orders entries by `when` (not `idx`) and only
* applies entries whose `when` is greater than the latest applied
* row's recorded `created_at`. We've shipped two breaking deploys
* (0010/0011 and 0012/0013) where freshly-generated migrations had
* `when` values older than a prior manually-bumped entry `pnpm
* migrate` printed "Migrations applied." while silently skipping
* the new SQL, and production 500'd until we hand-fixed the journal.
*
* This test reads the committed _journal.json and fails if the
* entries aren't strictly monotonically increasing by `when` in the
* same order as `idx`. Catches a bad commit at PR time instead of
* at the next deploy.
*/
describe("drizzle journal monotonicity (regression guard)", () => {
const journalPath = join(
__dirname,
"..",
"..",
"..",
"..",
"packages",
"db",
"migrations",
"meta",
"_journal.json",
);
const raw = JSON.parse(readFileSync(journalPath, "utf8")) as {
entries: JournalEntry[];
};
it("loads at least one journal entry (sanity)", () => {
expect(raw.entries.length).toBeGreaterThan(0);
});
it("`when` timestamps are strictly increasing in `idx` order", () => {
const result = assertJournalMonotonic(raw.entries);
if (!result.ok) {
// Print the same actionable message migrate.ts prints, so a
// failed CI run reads exactly like a failed local migrate.
// eslint-disable-next-line no-console
console.error(formatJournalViolations(result));
}
expect(result.violations).toEqual([]);
expect(result.ok).toBe(true);
});
});

View File

@ -0,0 +1,112 @@
import { describe, it, expect } from "vitest";
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";
/**
* Static guard: no production `.tsx` file may pass `showCloseButton`
* to `<DialogFooter>`.
*
* Why: the shared DialogFooter renders an EXTRA outline-styled
* <Button>Close</Button> when `showCloseButton` is set. Every dialog
* we have that already provides its own primary action also includes
* a Cancel/dismiss button (either via DialogClose or by closing the
* Dialog state on submit) and Radix's auto-rendered corner X
* already gives users a third way out. The redundant Close button
* cluttered the footer and shipped to production multiple times
* before this guard existed; this test stops it from regressing.
*/
const SRC_ROOT = join(__dirname, "..");
function listTsxFiles(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) {
out.push(...listTsxFiles(full));
} else if (entry.endsWith(".tsx")) {
out.push(full);
}
}
return out;
}
interface Hit {
file: string;
line: number;
excerpt: string;
}
function findHits(content: string): Array<{ line: number; excerpt: string }> {
const hits: Array<{ line: number; excerpt: string }> = [];
// Match `<DialogFooter` with `showCloseButton` somewhere in the
// opening tag. Stops at `>` so we don't accidentally cross into the
// children. Multi-line opening tags are handled by `[\s\S]`.
const matches = content.matchAll(
/<DialogFooter\b[\s\S]*?showCloseButton\b[\s\S]*?>/g,
);
for (const m of matches) {
const idx = m.index ?? 0;
const line = content.slice(0, idx).split("\n").length;
hits.push({ line, excerpt: m[0].slice(0, 120).replace(/\s+/g, " ") });
}
return hits;
}
describe("static guard: no <DialogFooter showCloseButton>", () => {
// Skip this test file (it intentionally contains the pattern strings)
// and all other .test.tsx files (they're examples, not production UI).
const files = listTsxFiles(SRC_ROOT).filter(
(f) => !/\.test\.tsx?$/.test(f),
);
it("scans at least one source file (sanity)", () => {
expect(files.length).toBeGreaterThan(0);
});
it("finds no <DialogFooter showCloseButton ...> in any production .tsx file", () => {
const allHits: Hit[] = [];
for (const file of files) {
const content = readFileSync(file, "utf8");
for (const h of findHits(content)) {
allHits.push({ file: relative(SRC_ROOT, file), ...h });
}
}
if (allHits.length > 0) {
const message = allHits
.map((h) => ` ${h.file}:${h.line}${h.excerpt}`)
.join("\n");
throw new Error(
`Redundant Close button detected — <DialogFooter showCloseButton ...>:\n${message}\n` +
`The DialogFooter component injects an extra "Close" button when this prop\n` +
`is set. Every existing caller already has its own Cancel/Close action plus\n` +
`Radix's corner X — a third Close is just visual noise. Remove the prop.`,
);
}
expect(allHits).toEqual([]);
});
});
describe("findHits parser", () => {
it("matches a single-line <DialogFooter showCloseButton>", () => {
expect(
findHits("<DialogFooter showCloseButton>foo</DialogFooter>"),
).toHaveLength(1);
});
it("matches when other props are present alongside showCloseButton", () => {
expect(
findHits('<DialogFooter className="x" showCloseButton>'),
).toHaveLength(1);
});
it("matches across multiple lines", () => {
const src = `<DialogFooter\n className="x"\n showCloseButton\n>`;
expect(findHits(src)).toHaveLength(1);
});
it("does NOT match a clean <DialogFooter>", () => {
expect(findHits("<DialogFooter>x</DialogFooter>")).toHaveLength(0);
});
it("does NOT match a similarly-named prop on an unrelated component", () => {
expect(findHits("<SheetFooter showCloseButton>")).toHaveLength(0);
});
});

View File

@ -19,7 +19,7 @@ services:
MEDIA_DIR: ${MEDIA_DIR:-/data/media} MEDIA_DIR: ${MEDIA_DIR:-/data/media}
BOT_HEALTH_PORT: ${BOT_HEALTH_PORT:-8081} BOT_HEALTH_PORT: ${BOT_HEALTH_PORT:-8081}
BOT_LOG_LEVEL: ${BOT_LOG_LEVEL:-info} BOT_LOG_LEVEL: ${BOT_LOG_LEVEL:-info}
SEED_OPERATOR_TELEGRAM_ID: ${SEED_OPERATOR_TELEGRAM_ID:-0} SEED_OPERATOR_USERNAME: ${SEED_OPERATOR_USERNAME:-admin}
SEED_OPERATOR_NAME: ${SEED_OPERATOR_NAME:-Operator} SEED_OPERATOR_NAME: ${SEED_OPERATOR_NAME:-Operator}
networks: networks:
- cmbot - cmbot
@ -36,6 +36,8 @@ services:
DATA_DIR: ${DATA_DIR} DATA_DIR: ${DATA_DIR}
MEDIA_DIR: ${MEDIA_DIR} MEDIA_DIR: ${MEDIA_DIR}
WEB_PORT: ${WEB_PORT} WEB_PORT: ${WEB_PORT}
AUTH_SECRET: ${AUTH_SECRET}
OPERATOR_TOKEN_VERSION: ${OPERATOR_TOKEN_VERSION:-1}
networks: networks:
- cmbot - cmbot

View File

@ -59,5 +59,7 @@ services:
DATA_DIR: ${DATA_DIR} DATA_DIR: ${DATA_DIR}
MEDIA_DIR: ${MEDIA_DIR} MEDIA_DIR: ${MEDIA_DIR}
WEB_PORT: ${WEB_PORT} WEB_PORT: ${WEB_PORT}
AUTH_SECRET: ${AUTH_SECRET}
OPERATOR_TOKEN_VERSION: ${OPERATOR_TOKEN_VERSION:-1}
depends_on: depends_on:
- tools - tools

View File

@ -0,0 +1,111 @@
# Portainer-ready stack. Pulls cm-whatsapp-{web,bot} from
# gitea.04080616.xyz/yiekheng instead of building from source — drop
# this file into a Portainer "Stack" (Repository or Web editor) and
# fill the env vars in the Portainer UI.
#
# Differences vs docker-compose.base.yml:
# - No `build:` blocks (Portainer pulls only).
# - Named volumes (cmbot-data, cmbot-sessions, cmbot-media) instead
# of host bind-mounts so the operator doesn't need shell access
# to manage persistent state.
# - Ports section on `web` so the operator can route a reverse
# proxy / Cloudflare Tunnel directly at the container.
# - `restart: unless-stopped` on both services.
#
# Required env vars (set in Portainer → Stack → Environment variables):
# DATABASE_URL postgres://USER:PASS@HOST:5432/wabot
# AUTH_SECRET 32-byte random hex (use scripts/gen_auth_secret.sh
# on any machine and copy the output)
# WEB_PORT host port for the web container (default 9000)
#
# Optional:
# DOCKER_IMAGE_TAG registry tag to deploy (default: latest)
# OPERATOR_TOKEN_VERSION session-cookie kill switch (default: 1)
# BOT_FIRE_CONCURRENCY pg-boss workers (default: 8)
# BOT_GROUP_CONCURRENCY per-account parallel sends (default: 3)
# BOT_MAX_SEND_PER_MINUTE per-account token-bucket rate (default: 40)
# BOT_LOG_LEVEL pino log level (default: info)
#
# Registry auth: Portainer needs a pull credential for
# gitea.04080616.xyz before you start the stack:
# Portainer → Registries → Add registry
# Name: gitea.04080616.xyz
# URL: gitea.04080616.xyz
# Username: <gitea user>
# Token: <gitea personal access token, read:packages>
# After adding, edit each service in the stack and set "Registry" to
# the one you just added so the pull resolves.
services:
bot:
image: gitea.04080616.xyz/yiekheng/cm-whatsapp-bot:${DOCKER_IMAGE_TAG:-latest}
container_name: cmbot-bot
restart: unless-stopped
environment:
NODE_ENV: production
DATABASE_URL: ${DATABASE_URL}
DATA_DIR: /data
SESSIONS_DIR: /data/sessions
MEDIA_DIR: /data/media
BOT_HEALTH_PORT: 8081
BOT_LOG_LEVEL: ${BOT_LOG_LEVEL:-info}
BOT_FIRE_CONCURRENCY: ${BOT_FIRE_CONCURRENCY:-8}
BOT_GROUP_CONCURRENCY: ${BOT_GROUP_CONCURRENCY:-3}
BOT_MAX_SEND_PER_MINUTE: ${BOT_MAX_SEND_PER_MINUTE:-40}
volumes:
- cmbot-sessions:/data/sessions
- cmbot-media:/data/media
healthcheck:
test:
- "CMD-SHELL"
- "wget -qO- --timeout=2 http://127.0.0.1:8081/health >/dev/null || exit 1"
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
networks:
- cmbot
web:
image: gitea.04080616.xyz/yiekheng/cm-whatsapp-web:${DOCKER_IMAGE_TAG:-latest}
container_name: cmbot-web
restart: unless-stopped
depends_on:
- bot
environment:
NODE_ENV: production
DATABASE_URL: ${DATABASE_URL}
DATA_DIR: /data
MEDIA_DIR: /data/media
WEB_PORT: 3000
AUTH_SECRET: ${AUTH_SECRET}
OPERATOR_TOKEN_VERSION: ${OPERATOR_TOKEN_VERSION:-1}
volumes:
# Web reads media from the same persistent volume the bot wrote.
- cmbot-media:/data/media:ro
ports:
# Maps the Next.js port (3000 inside the container) to whatever
# WEB_PORT the operator set. The reverse proxy / Cloudflare Tunnel
# in front of this host points at <host>:${WEB_PORT}.
- "${WEB_PORT:-9000}:3000"
healthcheck:
test:
- "CMD-SHELL"
- "wget -qO- --timeout=2 http://127.0.0.1:3000/api/health >/dev/null || exit 1"
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
networks:
- cmbot
volumes:
cmbot-sessions:
name: cmbot-sessions
cmbot-media:
name: cmbot-media
networks:
cmbot:
driver: bridge
name: cmbot

View File

@ -26,5 +26,13 @@ COPY --from=build /app/node_modules /app/node_modules
COPY --from=build /app/apps/bot /app/apps/bot COPY --from=build /app/apps/bot /app/apps/bot
COPY --from=build /app/packages/db /app/packages/db COPY --from=build /app/packages/db /app/packages/db
COPY --from=build /app/packages/shared /app/packages/shared COPY --from=build /app/packages/shared /app/packages/shared
# Reuse the `node` user (UID/GID 1000) that node:alpine ships with —
# `addgroup -g 1000 app` failed in CI because gid 1000 was already
# taken by the node group. Same hardening posture (non-root, no
# shell login), one less moving part.
RUN mkdir -p /data/sessions /data/media /app && \
chown -R node:node /app /data && \
chmod 700 /data/sessions
USER node
EXPOSE 8081 EXPOSE 8081
CMD ["node", "apps/bot/dist/index.js"] CMD ["node", "apps/bot/dist/index.js"]

View File

@ -18,7 +18,20 @@ COPY tsconfig.base.json turbo.json ./
COPY apps/web apps/web COPY apps/web apps/web
COPY packages/db packages/db COPY packages/db packages/db
COPY packages/shared packages/shared COPY packages/shared packages/shared
RUN pnpm --filter @cmbot/shared build && \ # Placeholder env values during `next build`'s "Collecting page data"
# pass. Next bundles `lib/db.ts` (`createClient(env.DATABASE_URL)`) and
# `actions/auth.ts` (uses AUTH_SECRET) into route bundles; their
# top-level env access fires when Next imports the route to inspect
# its config (the route's own `export const dynamic = "force-dynamic"`
# stops handler execution, NOT module evaluation).
#
# pg.Pool is lazy — it stores the URL and only connects on the first
# query — so a build-time placeholder never opens a socket. The
# placeholders are scoped to this RUN layer (each Dockerfile RUN is
# its own shell); nothing leaks into the runtime image.
RUN export DATABASE_URL=postgres://build:build@localhost:5432/build && \
export AUTH_SECRET=build-time-placeholder-not-used-at-runtime && \
pnpm --filter @cmbot/shared build && \
pnpm --filter @cmbot/db build && \ pnpm --filter @cmbot/db build && \
pnpm --filter @cmbot/web build pnpm --filter @cmbot/web build
@ -29,5 +42,21 @@ ENV HOSTNAME=0.0.0.0
COPY --from=build /app/apps/web/.next/standalone ./ COPY --from=build /app/apps/web/.next/standalone ./
COPY --from=build /app/apps/web/.next/static ./apps/web/.next/static COPY --from=build /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=build /app/apps/web/public ./apps/web/public COPY --from=build /app/apps/web/public ./apps/web/public
# pnpm's workspace layout: each packages/<pkg>/node_modules/<dep> is a
# symlink into /app/node_modules/.pnpm/<dep>@<ver>/node_modules/<dep>
# where the real files live. Copying just packages/<pkg>/node_modules
# ships dangling symlinks. Bring the .pnpm content store across too so
# every symlink resolves at runtime; this is what unblocks the
# `Cannot find module 'rrule'` error from
# packages/shared/dist/rrule.js. Use --link to deduplicate the layer
# blobs inside docker so the runtime image stays slim despite the
# dot-pnpm tree being large.
COPY --link --from=build /app/node_modules/.pnpm ./node_modules/.pnpm
COPY --link --from=build /app/packages/shared/node_modules ./packages/shared/node_modules
COPY --link --from=build /app/packages/db/node_modules ./packages/db/node_modules
# Reuse the `node` user (UID/GID 1000) that node:alpine ships with —
# `addgroup -g 1000 app` collided with the pre-existing node group.
RUN chown -R node:node /app
USER node
EXPOSE 3000 EXPOSE 3000
CMD ["node", "apps/web/server.js"] CMD ["node", "apps/web/server.js"]

172
docs/deploy-portainer.md Normal file
View File

@ -0,0 +1,172 @@
# Deploying via Portainer
End-to-end deploy steps for a fresh Portainer-managed host. Targets
the standard cm-whatsapp-bot pair of images published by
`scripts/publish.sh`.
## 0. Prerequisites
- Portainer 2.x running on the target host (CE or EE both fine).
- A Postgres reachable from that host (the `wabot` database with the
pgcrypto / pg_trgm extensions enabled — run migrations from any
machine that can reach the DB before the stack is brought up).
- A pull credential for `gitea.04080616.xyz` — a Gitea personal
access token with the `read:packages` scope. Generate one in
Gitea → User Settings → Applications.
- A reverse proxy / Cloudflare Tunnel pointing at
`http://<portainer-host>:<WEB_PORT>` if the deploy needs to be
reachable on the public domain (e.g. `wabot.04080616.xyz`).
## 1. Add the registry to Portainer
Portainer → **Registries****+ Add registry** → Custom registry.
| Field | Value |
|---------------|-----------------------------|
| Name | `gitea.04080616.xyz` |
| Registry URL | `gitea.04080616.xyz` |
| Authentication | enabled |
| Username | your Gitea username |
| Password | the read:packages PAT |
Save. The registry must show as connected before continuing — if the
test pull fails, the stack will hang on `pull` later.
## 2. Push the images (on your dev machine)
```bash
# Login once (sudo path matches scripts/dev.sh by default)
sudo docker login gitea.04080616.xyz
# Push :latest. Tag explicitly with DOCKER_IMAGE_TAG=v1.x.y if you
# want pinned-tag deploys (recommended for prod — never deploy
# `latest` if you can avoid it; tag versions per release).
NO_SUDO=1 ./scripts/publish.sh latest
```
`publish.sh` builds + pushes both images:
- `gitea.04080616.xyz/yiekheng/cm-whatsapp-bot:<tag>`
- `gitea.04080616.xyz/yiekheng/cm-whatsapp-web:<tag>`
## 3. Create the Portainer stack
Portainer → **Stacks****+ Add stack**.
**Name:** `cm-whatsapp-bot`
**Build method:** "Web editor" or "Repository". Either is fine —
"Repository" pointing at this repo's `master` and the file
`docker-compose.portainer.yml` is the cleanest path because future
deploys are just "Pull and redeploy" inside Portainer.
**Web editor path:** copy the contents of
[`docker-compose.portainer.yml`](../docker-compose.portainer.yml)
into the editor verbatim.
**Repository path:**
| Field | Value |
|------------------|-------------------------------------------------------------|
| Repository URL | http://192.168.0.215:3000/yiekheng/cm_whatsapp_bot_v1.git |
| Reference | refs/heads/master |
| Compose path | docker-compose.portainer.yml |
| Authentication | enabled (same Gitea PAT as step 1) |
| Auto-update | optional — enabled lets Portainer redeploy on every push |
## 4. Set environment variables
In the same stack form, scroll to **Environment variables** and add:
| Key | Value |
|---------------------------|------------------------------------------------|
| `DATABASE_URL` | `postgres://wabot:PASS@192.168.0.210:5432/wabot` |
| `AUTH_SECRET` | output of `scripts/gen_auth_secret.sh` |
| `WEB_PORT` | host port (e.g. `9000`) |
| `DOCKER_IMAGE_TAG` | `latest` (or a pinned `v1.x.y`) |
| `OPERATOR_TOKEN_VERSION` | `1` (bump only when you want to invalidate every existing session) |
| `BOT_LOG_LEVEL` | `info` |
Optional tuning (defaults are fine for most installs):
| Key | Default | When to bump |
|---------------------------|---------|--------------|
| `BOT_FIRE_CONCURRENCY` | `8` | More accounts firing in parallel |
| `BOT_GROUP_CONCURRENCY` | `3` | More groups per fire — but careful with WhatsApp rate caps |
| `BOT_MAX_SEND_PER_MINUTE` | `40` | Aged accounts can push toward 60 |
## 5. Run database migrations
The stack does NOT auto-migrate on boot. Apply migrations from any
machine that can reach the same Postgres:
```bash
DATABASE_URL='postgres://...' \
./scripts/db.sh migrate
```
If the journal is non-monotonic, the migrate runner refuses with a
clear error and prints which `_journal.json` entry to bump (the
guard added in commit 47d7c53 + the CI test in
`apps/web/src/test/drizzle-journal-monotonic.test.ts`).
Then seed the bootstrap operator + set its password:
```bash
DATABASE_URL='postgres://...' SEED_OPERATOR_USERNAME=admin \
./scripts/db.sh seed
DATABASE_URL='postgres://...' \
./scripts/set-password.sh admin # reads the password from stdin
```
## 6. Deploy the stack
In Portainer → click **Deploy the stack**. Watch the container list
in **Containers**:
- `cmbot-bot` should show *running, healthy* within ~20 s.
- `cmbot-web` should show *running, healthy* within ~30 s (Next.js
cold boot is the bottleneck).
If a container shows *unhealthy*, check **Logs**:
| Symptom | Likely cause |
|----------------------------------------------|--------------|
| `column "email" does not exist` | Migrations weren't applied. Run step 5. |
| `Server is not configured for sign-in` | `AUTH_SECRET` blank or missing. Set it in stack env. |
| `pg-boss: queue policy ...standard` | Harmless first-boot log; the bot force-flips it. |
| `Stream Errored (restart required)` (Baileys) | Upstream noise; ignore unless pairing fails. |
## 7. First sign-in
Visit `https://<your-domain>/login`, sign in as `admin` with the
password set in step 5, and walk the
[`docs/runbook.md`](runbook.md) smoke checklist before declaring
the deploy good.
## 8. Future redeploys
Two paths depending on how you set up step 3:
**Web editor flow:**
1. Run `scripts/publish.sh <tag>` on your dev machine.
2. In Portainer → Stack → "Update the stack" → "Re-pull image and
redeploy".
**Repository flow:**
1. Run `scripts/publish.sh <tag>`.
2. Commit any compose / env changes to master.
3. Portainer → Stack → "Pull and redeploy". (If auto-update is on,
skip this — Portainer redeploys on every push.)
Always pin a tag (`v1.4.2`) instead of `latest` for production —
makes rollback a one-field stack edit instead of a republish.
## Rolling back
In Portainer → Stack → set `DOCKER_IMAGE_TAG=v1.4.1` (or whatever
the previous good tag was) → Re-pull and redeploy. The cmbot-* data
volumes (sessions, media) are preserved across image swaps, so a
rollback doesn't lose pairings or uploaded media.
If the schema also rolled back, run the corresponding `down` SQL by
hand — drizzle's migrator only goes forward, by design.

200
docs/runbook.md Normal file
View File

@ -0,0 +1,200 @@
# Manual end-to-end runbook (v1)
Smoke checklist for verifying a fresh deploy. Unit tests don't catch
the live-Baileys / live-Postgres / browser-gesture path; this is what
you run before declaring a release good.
Time budget: ~10 minutes if everything works, ~30 if a step fails.
---
## Pre-flight
- [ ] **Stack up.**
`docker ps | grep cmbot` → expect `cmbot-tools`, `cmbot-bot`,
`cmbot-web` all `Up`.
- [ ] **Migrations clean.**
`NO_SUDO=1 scripts/db.sh migrate` → "Migrations applied." (and
*not* "Refusing to run drizzle migrate" — that's the journal
monotonicity guard tripping).
- [ ] **Web reachable.**
`curl -sf http://localhost:9000/api/health` → 200.
- [ ] **Bot reachable.**
`curl -sf http://localhost:8081/health` → 200.
If any pre-flight fails, fix before continuing.
---
## 1. Auth bootstrap
- [ ] `scripts/db.sh seed` (idempotent — only inserts the `admin`
operator if missing).
- [ ] `echo 'change-me-now' | scripts/set-password.sh admin` → "Password
updated."
- [ ] Open `http://localhost:9000/login` → enter `admin` / the password
→ redirected to `/`.
- [ ] **Wrong password three times in a row** still rate-limits but
with the generic "Too many attempts" message — no leak about
which limit (IP / username / global) tripped.
- [ ] Hit `/admin` URL while signed out → redirected to `/login` with
`?next=/admin`. After a successful login, lands back on `/admin`.
---
## 2. User management (admin-only)
- [ ] **Sidebar / drawer**: only one nav entry highlights at a time.
On `/settings/users`, only `Admin` lights up; `Settings` does
not.
- [ ] `/settings/users` → Add user → username `alice`, password
`alpha7!`, role `user` → "User created."
- [ ] `alice` row shows: username + `you` chip if applicable, role
pill, Promote / Reset / Delete buttons on row 2.
- [ ] Promote `alice` to admin → page revalidates, badge flips to
`admin`.
- [ ] Demote back to `user`.
- [ ] **Last-admin guard**: Demote / Delete on the only remaining
admin row are both disabled.
- [ ] Delete `alice` via the confirm dialog (Cancel + Delete user
buttons; **no third "Close" button** — the static guard test
catches that regression but eyeball it anyway).
---
## 3. Account pairing
- [ ] `/accounts` → New Account → label `WaBot Test` → Pair WhatsApp.
Land on the live QR page within ~2 s.
- [ ] Login screen header is JUST the centered brand mark — no nav,
no menu drawer.
- [ ] Scan with WhatsApp → "Linked Devices" → "Link a device".
- [ ] **Connection success.** Page transitions through `qr` → (brief
`restart-required` close handled silently) → `connected` with
a green check and `+60xxx` phone number → auto-redirect to
`/accounts/<id>` after 3 s.
- [ ] **Refresh Groups** button on `/accounts/<id>/groups` → spinner
during the sync, page auto-refreshes when the bot pushes
`groups.synced` over SSE. No manual reload needed.
### Pair regression checks (these caught real bugs)
- [ ] **Back → Re-pair**: from a live QR, click ← Back → Pair again
from the account detail page. Should NOT instantly flash
"Pairing timed out". A new QR appears and the countdown
restarts at 5:00.
- [ ] **Duplicate phone**: with one phone already paired, scan its QR
from a *second* account row → see the amber "Phone already
linked" panel naming the existing account. The original
account's session stays intact.
---
## 4. Reminder lifecycle
- [ ] `/reminders` → New Reminder → walk the wizard:
- Step 1: pick `WaBot Test`.
- Step 2: enter a short text message ("smoke test &lt;timestamp&gt;").
- Step 3: pick `Daily` recurrence, fire ~2 minutes from now.
Confirm "Pause sending by" checkbox is **unchecked by default**.
- Step 4: select 1 group.
- Step 5: review → Save.
- [ ] Reminder appears on `/reminders` with status `Active`.
Recurrence column shows the human-readable description; long
descriptions truncate with `…`.
- [ ] **Wait for the fire window.** When the time hits, the message
lands in the WhatsApp group **exactly once**.
- [ ] `/activity` → the run shows under `Success`. Default tab is
Success (no `All` tab).
- [ ] Swipe-left a row → Delete shelf appears. Swipe-right → Pause /
Restart shelf. Tapping a row navigates to its detail; dragging
does NOT navigate (6-px threshold).
- [ ] Pause the reminder → status flips to `Paused` immediately and
the next-fire-time disappears.
- [ ] Restart → fires on the next scheduled occurrence.
### Reminder regression checks
- [ ] **Triple-fire repro** (only if you have a tame group): edit
the reminder repeatedly within microseconds of each other (e.g.
the wizard Save button hammered three times). The message must
land **exactly once**. The bot logs should show
"duplicate fire detected inside mutex" warnings on the second
and third attempts.
- [ ] **Reschedule under existing job**: edit a recurring reminder's
schedule to a NEW time before its next-fire arrives. The new
time must fire (the old `created` job is now `cancelled` in
`pgboss.job`; verify with `select state, count(*) from
pgboss.job where name='reminder.fire' group by state`).
---
## 5. Account lifecycle
- [ ] **Unpair** the account from `/accounts/<id>`. Confirm dialog
(Cancel + Yes, unpair). The account row stays in the list with
"Unpaired" status; groups disappear from the picker (they're
soft-archived, not deleted).
- [ ] **Re-pair** the same account → groups come back via the
on-conflict upsert flipping `is_archived` back to false.
- [ ] **Delete** the account from `/accounts/<id>` → Confirm dialog →
the account vanishes from `/accounts`. Check on the *phone*'s
WhatsApp Linked Devices list — the entry is gone (the
logout-before-stop flow tells WhatsApp to drop it).
---
## 6. Sign-out + session lifetime
- [ ] **Sign out** from the sidebar / drawer footer → land on `/login`.
- [ ] Hit any protected URL → redirected to login.
- [ ] **Token-version kill switch**: set `OPERATOR_TOKEN_VERSION=2`
in `.env.development`, restart the web container. Every
previously-issued cookie is now invalid; every authenticated
request bounces to `/login`. Reset to `1` after.
---
## 7. Cross-tenant isolation
- [ ] Sign in as `admin`. Note dashboard counter values.
- [ ] As admin, create a second user `bob` and give them a fresh
account / reminder / fire it once.
- [ ] Sign out, sign in as `bob`. Dashboard counters MUST show only
bob's numbers (not admin's). `/reminders` lists only bob's
reminders. `/accounts` only bob's accounts.
---
## 8. Sweep
- [ ] `docker logs cmbot-web --since 10m | grep -iE 'error|'` — no
output (or only Baileys "Stream Errored (restart required)"
noise; that's upstream).
- [ ] `docker logs cmbot-bot --since 10m | grep -iE 'error|fatal'`
no output beyond the same Baileys upstream noise.
- [ ] `git status` clean (no leftover `_check.ts` or temp files).
---
## When a step fails
- **Migration refused** with "Refusing to run drizzle migrate":
open `packages/db/migrations/meta/_journal.json` and bump the
flagged entry's `when` to the suggested value. Re-run.
- **Pair shows immediate timeout**: bot logs should mention "ignoring
close from previous attempt while warming up" — that's the fix
working, but check a stale Baileys session isn't gummed up. Last
resort: `rm -rf dev-data/sessions/<accountId>` and re-pair.
- **Reminder fires twice**: check `pgboss.queue.policy` for
`reminder.fire` — must be `standard`, not `stately` (stately drops
reschedules silently). The `registerReminderJobs` boot hook
force-flips this on every bot start.
- **Delete didn't remove the linked-device entry on the phone**:
the bot's `socket.logout()` is best-effort — if the socket was
already disconnected when delete fired, the operator removes the
entry manually from WhatsApp's UI.
If any of the regression checks (Back→Re-pair, duplicate phone,
triple-fire, reschedule) fail, that's a real bug — capture the bot
log and file an issue before shipping.

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,437 @@
# Auth + Production Hardening Design
> Spec for closing the production-readiness gap before promoting the
> bot to public-internet exposure at `wabot.04080616.xyz`. Covers the
> session-cookie auth model with username + password + role, plus the
> hygiene work that has to land alongside it (robots, env, container
> non-root) so the public surface is safe in one change.
## Goal
Add operator authentication to the web app so the public URL stops
being a foothold for anyone who finds it, and at the same time close
the highest-risk production gaps surfaced in the v1.1.0 audit:
indexable content, committed credentials, root-running containers,
and four un-rate-limited Server Actions.
## Constraints
- Single-host self-hosted deployment, public-internet via reverse
proxy + TLS at `wabot.04080616.xyz`.
- Up to a handful of users today, with room to grow. One must be
`admin`; the rest are `user`.
- Mobile PWA homescreen workflow: 30-day cookie, no friction at
re-open, no third-party identity provider.
- No new infra dependencies. Postgres + Docker compose stay the
whole platform. No NextAuth / Auth.js, no external KV, no SMS.
- Existing call sites must be cleanly retrofitted without breaking
the 66 call sites that currently use `getSeededOperator()`.
- All code changes covered by unit tests; no test relies on a live
Postgres or browser.
## Approach: roll-our-own session cookie
A library would be heavy for one role gate and one cookie. We pick
up `bcrypt` for password hashing (battle-tested) and Web Crypto's
HMAC for cookie signing (stdlib, edge-runtime compatible). All other
code is domain-owned and exhaustively tested.
The model: the user posts username + password to a Server Action,
the action verifies against a per-user `password_hash` row, and the
response sets a signed cookie carrying `{ userId, role, iat, exp, v }`.
Middleware verifies the cookie on every request; Server Actions
double-check via `requireUser()` / `requireAdmin()` so a forgotten
middleware path can't bypass the gate.
## Schema migration (`0010_add_user_auth.sql`)
```sql
ALTER TABLE operators
ADD COLUMN username text,
ADD COLUMN password_hash text;
CREATE UNIQUE INDEX operators_username_uq
ON operators (lower(username));
-- Backfill the seed row so it has a username; password_hash stays NULL
-- so the operator is forced to set one via the CLI before they can sign
-- in. Sets a clear "you have to do this before going live" gate.
UPDATE operators
SET username = 'admin'
WHERE username IS NULL;
ALTER TABLE operators
ALTER COLUMN username SET NOT NULL;
```
`telegramUserId` stays for now (it's referenced from existing migrations
and seed flow) but no longer drives auth. `defaultTimezone` and `role`
are unchanged. `operators.role` already defaults to `"admin"`.
## Roles
Two values, no enum constraint at the DB layer (text — same as
existing).
| role | can do |
| ----- | ------------------------------------------------------------- |
| admin | everything in the app + user management (CRUD other users) |
| user | everything except `/settings/users` and the user-mgmt actions |
A third "viewer" role isn't worth it today; can be added later by
extending the role check.
## Cookie format
Header value: `session=<base64url(payload)>.<base64url(hmac)>`
```ts
type SessionPayload = {
userId: string; // operators.id (uuid)
role: "admin" | "user";
iat: number; // issued-at, unix seconds
exp: number; // expires-at, unix seconds (iat + 30 days)
v: number; // OPERATOR_TOKEN_VERSION at issue time
};
```
HMAC is HMAC-SHA256 over the base64url-encoded payload string with
`AUTH_SECRET` as the key. Verification rejects on:
- Bad shape (no `.`, base64 decode fails, JSON parse fails).
- HMAC mismatch (uses constant-time compare).
- `exp <= now`.
- `iat > now + 60` (clock-skew guard, 60s tolerance).
- `v !== process.env.OPERATOR_TOKEN_VERSION` (defaults to `"1"`).
- `role` not one of `"admin"` / `"user"`.
Cookie attributes: `HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=2592000`.
`Max-Age=0` on logout to clear.
`OPERATOR_TOKEN_VERSION` env var (default `"1"`) is the global
session-invalidation lever. Bumping it on the host instantly logs out
every user — no DB writes — useful after a host compromise or a
known-shared password.
## Login flow
Page: `apps/web/src/app/login/page.tsx`. Single form with:
- Username input (`type=text`, autocomplete `username`)
- Password input (`type=password`, autocomplete `current-password`)
- Submit button "Sign in"
- Error slot for the generic message
- A small note: "First time? Run `./scripts/set-password.sh <username>`
in your tools container to set a password."
Server action `loginAction(formData: FormData)`:
```text
1. Read username, password from FormData.
2. Reject if either >256 chars (DoS guard, no bcrypt).
3. Reject if either empty.
4. Apply rate limit: checkRateLimit("login:" + ip, { max: 10, windowSec: 300 }).
On exhaustion → return { ok: false, error: "Too many attempts, try later." }
5. Look up user: select * from operators where lower(username)=lower($1)
6. If user not found OR user.password_hash IS NULL:
await bcrypt.compare(password, DUMMY_HASH); // timing equivalence
return { ok: false, error: "Invalid username or password." }
7. await bcrypt.compare(password, user.password_hash)
if false: return { ok: false, error: "Invalid username or password." }
8. Issue cookie: signSession({ userId, role, iat: now, exp: now + 30d, v: TOKEN_VERSION })
9. Redirect to safe(next) ?? "/"
```
`safe(next)`: must be a string starting with `/` AND not starting
with `//`. Otherwise return `null`.
Logout action `logoutAction()`: clear the cookie via
`cookies().set("session", "", { maxAge: 0, ... })` and redirect to
`/login`.
## Middleware gate
`apps/web/src/middleware.ts` extends the existing API allowlist with
the auth check.
```text
For every request:
- If path is in allowlist (auth-free):
/login, /logout, /api/health, /manifest.webmanifest,
/icon-*, /favicon.ico, /_next/static/*, /_next/image
→ NextResponse.next()
- Read session cookie. Verify (HMAC, exp, iat-skew, version, role shape).
- On valid: NextResponse.next()
- On invalid + path starts with /api/: 401, no body
- On invalid + page request: 302 to /login?next=<encoded path>
```
`/api/events` and `/api/qr/[accountId]` are explicitly removed from
the unauth allowlist — middleware now requires a session for them.
The middleware imports the verifier from `@/lib/auth-cookie` (a
dependency-free module that runs on the edge runtime — no bcrypt,
no DB).
## Server-action defense-in-depth
`apps/web/src/lib/auth.ts` (Node runtime — DB access OK):
```ts
export async function getCurrentUser(): Promise<User | null>
export async function requireUser(): Promise<User> // throws Response 401 / redirects
export async function requireAdmin(): Promise<User> // requireUser + role === "admin"
```
`getSeededOperator()` is renamed to `getCurrentUser()` (and rewired
to read the verified cookie + look up the user). All 66 call sites
swap mechanically. Existing typing stays compatible because the
returned shape is a superset.
Every Server Action begins with `await requireUser()` (or
`requireAdmin()` for admin-only). This is the second layer; the
middleware is the first. Both must agree before any state mutates.
## User management surface
Admin-only, gated by `requireAdmin()` at every entry point.
- `/settings/users` (page) — list of users with role chip + createdAt;
inline "Reset password", "Demote/Promote", "Delete" buttons. New
user form at top.
- `createUserAction({ username, password, role })` — validate inputs,
bcrypt the password, insert.
- `setUserRoleAction({ userId, role })` — guard: if `userId === self.id`
AND `role !== "admin"`, refuse with "you can't demote yourself".
- `resetUserPasswordAction({ userId, newPassword })` — bcrypt + update.
Does NOT change cookies — the affected user keeps their existing
session until expiry or a token-version bump.
- `deleteUserAction({ userId })` — guard: refuse self-delete.
Additional guard: if deleting the last admin, refuse with "promote
another user to admin first".
All admin actions fan out a refresh of `/settings/users` via
`revalidatePath`.
## CLI bootstrap
The actual hashing happens in a small TSX script (so it can `import
bcrypt` from the workspace), wrapped by a one-line bash launcher
that runs it through the `tools` container. Two pieces:
`packages/db/src/scripts/set-password.ts` — reads `username` from
argv, prompts for password on stdin (echo off via `readline`'s
`writeMask`), bcrypts at 12 rounds, runs an `UPDATE operators SET
password_hash = $1 WHERE lower(username) = lower($2)`, exits
non-zero if no rows matched.
`packages/db/src/scripts/create-user.ts` — same pattern, but
INSERTs a fresh row with `username`, `role`, `password_hash`,
default timezone, and a synthetic `telegramUserId` (current time-
millis) since the column is still NOT NULL until a future cleanup
migration.
`scripts/set-password.sh` and `scripts/create-user.sh` — thin
wrappers that invoke the TSX scripts via `pnpm --filter @cmbot/db
exec tsx ...` inside the tools container, matching the existing
script-runner pattern.
Used to bootstrap the first admin and to recover when an admin
loses their password. After bootstrap, all user management happens
through the web UI.
## Rate limits added
| action | limit |
| ---------------------------- | -------------------------------- |
| loginAction | 10 / 5 min per IP |
| sendTestAction | 3 / 60 s per groupId |
| resumeReminderRunAction | 30 / 10 s per IP (existing infra)|
| cancelReminderRunAction | 30 / 10 s per IP |
| createUserAction | 5 / 60 s per IP |
| resetUserPasswordAction | 5 / 60 s per IP |
`checkRateLimit` is the existing Postgres-backed helper.
## Robots / noindex
`apps/web/src/app/robots.ts`:
```ts
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return { rules: [{ userAgent: "*", disallow: "/" }] };
}
```
Plus `metadata.robots = { index: false, follow: false }` in the root
`apps/web/src/app/layout.tsx`. Two layers — robots.txt is advisory,
the meta is authoritative.
## Env hygiene
- Add `.env*` to `.gitignore` (already excludes `.env.local`,
`.env.*.local` — this widens to all `.env*` outside `.env.example`).
- `git rm --cached .env.development` and recreate locally without
committing.
- New `.env.example` documents every required key with placeholder
values, including the new `OPERATOR_TOKEN_VERSION`.
- After this change ships, the operator rotates the leaked
`AUTH_SECRET` and Postgres password (manual step, called out in
the upgrade notes).
## Container hardening
Both Dockerfiles:
```dockerfile
RUN useradd -m -u 1000 -s /usr/sbin/nologin app && \
mkdir -p /data/sessions /data/media && \
chown -R app:app /app /data && \
chmod 700 /data/sessions
USER app
```
The `dev-data:/data` volume mount in `docker-compose.dev.yml` keeps
working since the host UID matches the in-container `app` UID 1000.
## Origin allowlist
`next.config.ts` adds:
```ts
experimental: {
serverActions: {
allowedOrigins: ["wabot.04080616.xyz", "localhost:9000"],
},
},
```
Same-origin Server Action posts already work; this guards against
cross-origin POSTs from another domain attempting to invoke an
action via a known cookie.
## Test plan (38 tests)
### `auth-cookie.test.ts` — pure HMAC + verification logic
1. `signSession` then `verifySession` round-trips.
2. Tampered payload → verify rejects.
3. Tampered signature → verify rejects.
4. Wrong secret → verify rejects.
5. Constant-time compare prevents char-by-char timing leak (assert
`crypto.timingSafeEqual` is used).
6. Cookie expired (`exp <= now`) → reject.
7. Cookie issued in the future (`iat > now + 60`) → reject (clock-skew).
8. Cookie with stale `v` (TOKEN_VERSION bumped after issue) → reject.
9. Cookie with bad `role` value (`"superadmin"`) → reject.
10. Cookie missing fields → reject.
### `login-action.test.ts` — login flow
11. Valid credentials → cookie issued with right shape.
12. Wrong password → no cookie, generic error.
13. Wrong username → no cookie, generic error, dummy-bcrypt called
(timing equivalence).
14. `password_hash IS NULL` user → "set password via CLI" error.
15. Empty username or password → 400-equivalent (no DB hit).
16. Username/password >256 chars → rejected before bcrypt.
17. Username case-insensitive (`Admin` matches `admin`).
18. 11th login attempt within window → 429 (rate-limited).
19. After window expiry, attempts succeed.
20. Failed login logs warning with username + IP, no password.
21. Cookie sets correct attrs (HttpOnly, Secure, SameSite, Path,
Max-Age).
### `middleware.test.ts` — gate behavior
22. No cookie + page request → 302 to `/login?next=<path>`.
23. No cookie + `/api/...` (non-allowlisted) → 401.
24. Valid cookie + page → next().
25. Tampered cookie → 302 to `/login`.
26. Allowlisted (`/login`, `/api/health`, manifest, icons) bypasses.
27. `/api/events` and `/api/qr/[id]` are NOT in allowlist (regression
against the audit's Critical findings).
### `next-param.test.ts` — open-redirect prevention
28. `/dashboard` → preserved.
29. `//evil.com` → falls back to `/`.
30. `https://evil.com` → falls back to `/`.
31. `javascript:alert(1)` → falls back to `/`.
32. `/path?with=query&extra=fine` → preserved verbatim.
### `require-helpers.test.ts` — Server-action gates
33. `requireUser()` throws with no session.
34. `requireUser()` returns the user with valid session.
35. `requireAdmin()` throws when role === "user".
36. `requireAdmin()` returns the user when role === "admin".
### `user-management.test.ts` — admin guards
37. Self-demote (`setUserRoleAction({ userId: self, role: "user" })`)
→ ok:false with clear error.
38. Last-admin delete (deleting only admin user) → ok:false with
"promote another user first".
## Migration risk
`getSeededOperator()` is the one big touch. The 66 call sites are
mostly Server Actions and queries that read `.id` and
`.defaultTimezone` off the returned object — the new shape is a
superset, so the change is mechanical.
To keep churn off the existing test suite (~12 tests mock
`@/lib/operator`), `apps/web/src/lib/operator.ts` keeps its export
but reimplements `getSeededOperator` as a thin pass-through to
`getCurrentUser` from `@/lib/auth`. Existing mocks that target
`@/lib/operator` keep working unchanged. New code uses
`getCurrentUser` / `requireUser` / `requireAdmin` directly; the old
name is kept as a compatibility shim and removed in a follow-up
once all sites are swept.
A `DUMMY_HASH` constant lives at the top of the login action — it's
a precomputed bcrypt hash of a known throwaway string (`"x"`),
generated once at build time and committed. We compare against it
on the user-not-found path so timing is identical to the wrong-
password path. Generating a fresh dummy hash per request would
double the bcrypt work and create its own timing signal.
## Out of scope (deferred)
- WebAuthn / passkeys.
- 2FA / TOTP.
- Email-based password recovery (operator restarts container with
a new env var `OPERATOR_TOKEN_VERSION` if all admins lose their
passwords; CLI helps the rest).
- Account lockout (rate limit is enough for one operator's threat
model).
- SSO / OAuth providers.
- Audit-log surface for "who logged in when". The pino warn line
is the minimum; a structured audit table is later work.
- A "remember this device" feature distinct from the 30-day cookie.
## Acceptance
- The bot can be exposed at `wabot.04080616.xyz` and any
unauthenticated request to a non-allowlisted path returns 401
(API) or redirects to `/login` (page).
- A correct username + password issues a 30-day cookie that survives
reload, browser restart, and PWA homescreen launches.
- A wrong username, a wrong password, and a missing-password user
all produce the same generic "Invalid username or password"
error and the same wall-clock duration (timing-equivalent).
- Bumping `OPERATOR_TOKEN_VERSION` on the host invalidates every
active session immediately.
- An attacker tampering with the cookie payload, signature, or
issued-at can't pass middleware.
- Eleven login attempts from the same IP within five minutes
produce a 429 on the eleventh.
- A `user`-role session can browse, schedule, and resume reminders
but cannot reach `/settings/users`.
- An admin can't demote or delete their own row, and can't delete
the last admin.
- `robots.txt` returns `Disallow: /` and the rendered HTML carries
`<meta name="robots" content="noindex, nofollow">`.
- Both containers run as UID 1000, sessions dir is `chmod 700`.
- `.env.development` is gone from the repo and `.gitignore` excludes
every `.env*` except `.env.example`.
- All 38 tests in the plan pass; existing 471 tests still pass.

View File

@ -10,12 +10,41 @@ MEDIA_DIR=/data/media
BOT_HEALTH_PORT=8081 BOT_HEALTH_PORT=8081
BOT_LOG_LEVEL=info BOT_LOG_LEVEL=info
# Reminder fan-out tuning. Defaults aim for an established WhatsApp
# account (~30-60 msg/min safe band). Bump cautiously.
# BOT_FIRE_CONCURRENCY pg-boss workers; max accounts firing in parallel.
# BOT_GROUP_CONCURRENCY per-account parallel group sends; parts within a
# group stay serial.
# BOT_MAX_SEND_PER_MINUTE per-account token-bucket rate.
BOT_FIRE_CONCURRENCY=8
BOT_GROUP_CONCURRENCY=3
BOT_MAX_SEND_PER_MINUTE=40
# === Seed (used by scripts/db.sh seed) === # === Seed (used by scripts/db.sh seed) ===
SEED_OPERATOR_TELEGRAM_ID= # The bootstrap operator's username. After seed, set their password
# via: echo 'change-me-now' | scripts/set-password.sh admin
SEED_OPERATOR_USERNAME=admin
SEED_OPERATOR_NAME=Operator SEED_OPERATOR_NAME=Operator
# === Web === # === Web / Auth ===
# Port the Next.js container exposes on the host. Production deployment # Port the Next.js container exposes on the host. Production deployment
# (rexwa.04080616.xyz) uses 8100; dev/staging (test.04080616.xyz) uses 9000. # (wabot.04080616.xyz) uses 8100; dev/staging (test.04080616.xyz) uses 9000.
WEB_PORT=9000 WEB_PORT=9000
# 32-byte secret used to derive the AES-256-GCM key for session cookies.
# DO NOT leave blank — the web container will refuse to issue cookies.
# Generate via: scripts/gen_auth_secret.sh --write
AUTH_SECRET= AUTH_SECRET=
# Bumping this invalidates every outstanding session cookie globally on
# the next request. Treat it as a kill switch (e.g. after a key leak)
# rather than a routine value.
OPERATOR_TOKEN_VERSION=1
# === Docker Registry (used by scripts/publish.sh) ===
# Tag pushed alongside latest. Override with the CLI arg or
# DOCKER_IMAGE_TAG=v1.2.3 scripts/publish.sh.
DOCKER_IMAGE_TAG=latest
# Buildx target platforms. linux/amd64 is the prod host arch; add
# linux/arm64 if you cross-build for an Apple-silicon runner.
CM_IMAGE_PLATFORMS=linux/amd64

View File

@ -0,0 +1,9 @@
-- Add username + password_hash to operators. Backfill the seed row to
-- 'admin' so the NOT NULL constraint succeeds; password_hash stays
-- nullable so the operator is forced to set one via the CLI before
-- they can sign in.
ALTER TABLE "operators" ADD COLUMN "username" text;--> statement-breakpoint
ALTER TABLE "operators" ADD COLUMN "password_hash" text;--> statement-breakpoint
UPDATE "operators" SET "username" = 'admin' WHERE "username" IS NULL;--> statement-breakpoint
ALTER TABLE "operators" ALTER COLUMN "username" SET NOT NULL;--> statement-breakpoint
CREATE UNIQUE INDEX "operators_username_uq" ON "operators" (lower("username"));

View File

@ -0,0 +1,2 @@
DROP INDEX IF EXISTS "operators_telegram_user_id_uq";--> statement-breakpoint
ALTER TABLE "operators" DROP COLUMN IF EXISTS "telegram_user_id";

View File

@ -0,0 +1,10 @@
-- Switch the default to 24 ("no deadline" sentinel) so newly-created
-- reminders are off-by-default for the optional "Pause sending by"
-- toggle, matching the wizard's UX contract.
ALTER TABLE "reminders" ALTER COLUMN "delivery_window_end_hour" SET DEFAULT 24;
-- Existing rows still hold the old default (18). Treat those as
-- "schema-default, never opted in by the operator" and clear them to
-- 24 so editing an old reminder doesn't auto-check the deadline box.
-- Operators who actually wanted a 6pm deadline can re-enable it from
-- the edit form.
UPDATE "reminders" SET "delivery_window_end_hour" = 24 WHERE "delivery_window_end_hour" = 18;

View File

@ -0,0 +1,2 @@
ALTER TABLE "operators" ADD COLUMN "email" text;--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "operators_email_uq" ON "operators" USING btree (lower("email")) WHERE "operators"."email" IS NOT NULL;

View File

@ -0,0 +1 @@
CREATE INDEX IF NOT EXISTS "whatsapp_groups_account_name_idx" ON "whatsapp_groups" USING btree ("account_id","name");

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,76 +1,111 @@
{ {
"version": "7", "version": "7",
"dialect": "postgresql", "dialect": "postgresql",
"entries": [ "entries": [
{ {
"idx": 0, "idx": 0,
"version": "7", "version": "7",
"when": 1778311164225, "when": 1778311164225,
"tag": "0000_conscious_tarantula", "tag": "0000_conscious_tarantula",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 1, "idx": 1,
"version": "7", "version": "7",
"when": 1778320434707, "when": 1778320434707,
"tag": "0001_smart_vertigo", "tag": "0001_smart_vertigo",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 2, "idx": 2,
"version": "7", "version": "7",
"when": 1778338808600, "when": 1778338808600,
"tag": "0002_left_jimmy_woo", "tag": "0002_left_jimmy_woo",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 3, "idx": 3,
"version": "7", "version": "7",
"when": 1778343712901, "when": 1778343712901,
"tag": "0003_messy_bruce_banner", "tag": "0003_messy_bruce_banner",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 4, "idx": 4,
"version": "7", "version": "7",
"when": 1778345543406, "when": 1778345543406,
"tag": "0004_next_prowler", "tag": "0004_next_prowler",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 5, "idx": 5,
"version": "7", "version": "7",
"when": 1778347437350, "when": 1778347437350,
"tag": "0005_flippant_joystick", "tag": "0005_flippant_joystick",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 6, "idx": 6,
"version": "7", "version": "7",
"when": 1778385559051, "when": 1778385559051,
"tag": "0006_adorable_nehzno", "tag": "0006_adorable_nehzno",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 7, "idx": 7,
"version": "7", "version": "7",
"when": 1778386591494, "when": 1778386591494,
"tag": "0007_overconfident_menace", "tag": "0007_overconfident_menace",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 8, "idx": 8,
"version": "7", "version": "7",
"when": 1778395584234, "when": 1778395584234,
"tag": "0008_greedy_matthew_murdock", "tag": "0008_greedy_matthew_murdock",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 9, "idx": 9,
"version": "7", "version": "7",
"when": 1778464000000, "when": 1778464000000,
"tag": "0009_rename_ended_to_inactive", "tag": "0009_rename_ended_to_inactive",
"breakpoints": true "breakpoints": true
} },
] {
"idx": 10,
"version": "7",
"when": 1778464001000,
"tag": "0010_fancy_wolf_cub",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1778464002000,
"tag": "0011_premium_grandmaster",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1778464003000,
"tag": "0012_lucky_masked_marvel",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1778464004000,
"tag": "0013_tricky_yellowjacket",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1778464005000,
"tag": "0014_lame_puck",
"breakpoints": true
}
]
} }

View File

@ -13,6 +13,10 @@
"./schema": { "./schema": {
"types": "./dist/schema.d.ts", "types": "./dist/schema.d.ts",
"default": "./dist/schema.js" "default": "./dist/schema.js"
},
"./journal-check": {
"types": "./dist/journal-check.d.ts",
"default": "./dist/journal-check.js"
} }
}, },
"scripts": { "scripts": {
@ -26,10 +30,12 @@
"seed": "tsx src/seed.ts" "seed": "tsx src/seed.ts"
}, },
"dependencies": { "dependencies": {
"bcryptjs": "^3.0.3",
"drizzle-orm": "^0.36.0", "drizzle-orm": "^0.36.0",
"pg": "^8.13.0" "pg": "^8.13.0"
}, },
"devDependencies": { "devDependencies": {
"@types/bcryptjs": "^3.0.0",
"@types/node": "^22.7.0", "@types/node": "^22.7.0",
"@types/pg": "^8.11.10", "@types/pg": "^8.11.10",
"drizzle-kit": "^0.28.0", "drizzle-kit": "^0.28.0",

View File

@ -0,0 +1,90 @@
/**
* Drizzle journal monotonicity guard.
*
* Background twice already we hit this regression: a `pnpm migrate`
* silently skipped a freshly-generated migration because its `when`
* timestamp was older than the previous migration's `when`. Drizzle's
* migrator orders the entries by `when` (not by `idx`) and only
* applies entries whose `when` is strictly greater than the latest
* row's `created_at` in `pgboss... drizzle.__drizzle_migrations`.
*
* Symptom: migrate prints "Migrations applied." while the schema in
* the live DB is missing whatever 0012 / 0013 were supposed to add.
* Web 500's on every authenticated request because the code expects
* the new columns.
*
* This module is the first line of defence:
* - `assertJournalMonotonic(entries)` is a pure check the test
* suite runs against the committed journal file. CI fails on a
* bad commit before it can ship.
* - migrate.ts calls it on boot. If the live journal in source
* control has slipped out of monotonic order, migrate refuses
* to run and prints the offending entries with the smallest
* bump that would unbreak each one.
*/
export interface JournalEntry {
idx: number;
tag: string;
when: number;
}
export interface JournalCheckResult {
ok: boolean;
/** Entries whose `when` is <= the previous entry's `when`. */
violations: Array<{
idx: number;
tag: string;
when: number;
/** The previous entry's when — the new bound that this one must beat. */
previousWhen: number;
previousTag: string;
/** A `when` value that would make THIS entry monotonic again. */
suggestedWhen: number;
}>;
}
/**
* Walk the journal entries in idx order and report any whose `when`
* is not strictly greater than the previous entry's `when`. The
* journal can have any starting timestamp; we only care about the
* relative ordering matching idx. Equal timestamps are also a
* violation drizzle requires strictly greater.
*/
export function assertJournalMonotonic(entries: JournalEntry[]): JournalCheckResult {
const sorted = [...entries].sort((a, b) => a.idx - b.idx);
const violations: JournalCheckResult["violations"] = [];
for (let i = 1; i < sorted.length; i++) {
const prev = sorted[i - 1]!;
const cur = sorted[i]!;
if (cur.when <= prev.when) {
violations.push({
idx: cur.idx,
tag: cur.tag,
when: cur.when,
previousWhen: prev.when,
previousTag: prev.tag,
suggestedWhen: prev.when + 1000,
});
}
}
return { ok: violations.length === 0, violations };
}
/** Format the check result into a multi-line human message. */
export function formatJournalViolations(result: JournalCheckResult): string {
if (result.ok) return "";
const lines: string[] = [
"Drizzle journal is not monotonic — migrate would silently skip these entries:",
];
for (const v of result.violations) {
lines.push(
` ${v.tag} (idx ${v.idx}) when=${v.when} <= ${v.previousTag}.when=${v.previousWhen}`,
);
lines.push(
` fix: set ${v.tag}.when to >= ${v.suggestedWhen} in ` +
`packages/db/migrations/meta/_journal.json`,
);
}
return lines.join("\n");
}

View File

@ -1,5 +1,13 @@
import { migrate } from "drizzle-orm/node-postgres/migrator"; import { migrate } from "drizzle-orm/node-postgres/migrator";
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { createClient } from "./index.js"; import { createClient } from "./index.js";
import {
assertJournalMonotonic,
formatJournalViolations,
type JournalEntry,
} from "./journal-check.js";
const databaseUrl = process.env.DATABASE_URL; const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) { if (!databaseUrl) {
@ -7,6 +15,27 @@ if (!databaseUrl) {
process.exit(1); process.exit(1);
} }
// --- Pre-flight: refuse to run if the journal is non-monotonic. -----------
// Drizzle silently skips entries whose `when` is older than the previous
// entry's `when`. We've hit this twice now (0010/0011, then 0012/0013),
// each time the symptom was "Migrations applied." with no schema change
// and a 500 in production for the missing column. Catch it before we
// hand the journal to drizzle.
const __dirname = dirname(fileURLToPath(import.meta.url));
const journalPath = join(__dirname, "..", "migrations", "meta", "_journal.json");
const journal = JSON.parse(readFileSync(journalPath, "utf8")) as {
entries: JournalEntry[];
};
const check = assertJournalMonotonic(journal.entries);
if (!check.ok) {
console.error(formatJournalViolations(check));
console.error(
"\nRefusing to run drizzle migrate. Bump the offending `when` values in\n" +
"_journal.json so they're strictly increasing in the same order as `idx`.",
);
process.exit(2);
}
const { db, pool } = createClient(databaseUrl); const { db, pool } = createClient(databaseUrl);
console.log("Applying migrations..."); console.log("Applying migrations...");
await migrate(db, { migrationsFolder: "./migrations" }); await migrate(db, { migrationsFolder: "./migrations" });

View File

@ -1,3 +1,4 @@
import { sql } from "drizzle-orm";
import { import {
pgTable, pgTable,
uuid, uuid,
@ -9,6 +10,7 @@ import {
jsonb, jsonb,
primaryKey, primaryKey,
uniqueIndex, uniqueIndex,
index,
inet, inet,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
@ -16,14 +18,25 @@ export const operators = pgTable(
"operators", "operators",
{ {
id: uuid("id").primaryKey().defaultRandom(), id: uuid("id").primaryKey().defaultRandom(),
telegramUserId: bigint("telegram_user_id", { mode: "number" }).notNull(), username: text("username").notNull(),
passwordHash: text("password_hash"),
displayName: text("display_name").notNull(), displayName: text("display_name").notNull(),
// Reserved for future contact / recovery flows. Optional + nullable
// so today's operators don't have to backfill anything; admins can
// populate it from the Users page when we wire that up.
email: text("email"),
role: text("role").notNull().default("admin"), role: text("role").notNull().default("admin"),
defaultTimezone: text("default_timezone").notNull().default("Asia/Kuala_Lumpur"), defaultTimezone: text("default_timezone").notNull().default("Asia/Kuala_Lumpur"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
}, },
(t) => ({ (t) => ({
telegramUserIdUnique: uniqueIndex("operators_telegram_user_id_uq").on(t.telegramUserId), usernameUnique: uniqueIndex("operators_username_uq").on(sql`lower(${t.username})`),
// Case-insensitive uniqueness only when an email IS set (NULLs
// remain freely insertable). Lets future flows look up operators
// by email without ambiguity.
emailUnique: uniqueIndex("operators_email_uq")
.on(sql`lower(${t.email})`)
.where(sql`${t.email} IS NOT NULL`),
}), }),
); );
@ -45,6 +58,16 @@ export const whatsappAccounts = pgTable(
}), }),
); );
/**
* whatsapp_groups perf notes (production: 3 000+ rows per account):
* - account_jid_uq B-tree (account_id, wa_group_jid).
* Backs the on-conflict upsert during
* group-sync and every per-account
* WHERE-prefix scan.
* - whatsapp_groups_name_trgm GIN trgm index on `name` (migration
* 0002). Powers fuzzy search via the
* `name % term` operator in O(log n).
*/
export const whatsappGroups = pgTable( export const whatsappGroups = pgTable(
"whatsapp_groups", "whatsapp_groups",
{ {
@ -58,6 +81,16 @@ export const whatsappGroups = pgTable(
}, },
(t) => ({ (t) => ({
accountJidUnique: uniqueIndex("whatsapp_groups_account_jid_uq").on(t.accountId, t.waGroupJid), accountJidUnique: uniqueIndex("whatsapp_groups_account_jid_uq").on(t.accountId, t.waGroupJid),
// Backs `WHERE account_id=? ORDER BY name ASC LIMIT 200` on the
// groups list page. Without this, PG falls back to the unique
// (account_id, wa_group_jid) index for the WHERE clause and then
// does an explicit sort on `name` — fine at small scale, slow
// when an operator has 3 000+ groups. Drizzle import is `index`,
// declared in this same file's import block.
accountNameIdx: index("whatsapp_groups_account_name_idx").on(
t.accountId,
t.name,
),
}), }),
); );
@ -90,8 +123,11 @@ export const reminders = pgTable("reminders", {
// Delivery window (operator timezone). End hour is enforced at runtime // Delivery window (operator timezone). End hour is enforced at runtime
// by fire-reminder when window enforcement lands; start hour is documented // by fire-reminder when window enforcement lands; start hour is documented
// here but not gated in v1. // here but not gated in v1.
// 24 is the "no deadline" sentinel — it's the off-by-default state so a
// reminder created without the operator explicitly opting into "Pause
// sending by" stays unbounded.
deliveryWindowStartHour: integer("delivery_window_start_hour").notNull().default(6), deliveryWindowStartHour: integer("delivery_window_start_hour").notNull().default(6),
deliveryWindowEndHour: integer("delivery_window_end_hour").notNull().default(18), deliveryWindowEndHour: integer("delivery_window_end_hour").notNull().default(24),
}); });
export const reminderTargets = pgTable( export const reminderTargets = pgTable(

View File

@ -0,0 +1,42 @@
import bcrypt from "bcryptjs";
import { sql } from "drizzle-orm";
import { createInterface } from "node:readline/promises";
import { Writable } from "node:stream";
import { createClient } from "../index.js";
async function main() {
const username = process.argv[2];
const role = process.argv[3];
if (!username || (role !== "admin" && role !== "user")) {
console.error("Usage: create-user <username> <admin|user>");
process.exit(2);
}
const url = process.env.DATABASE_URL;
if (!url) {
console.error("DATABASE_URL not set");
process.exit(2);
}
const muted = new Writable({ write(_chunk, _enc, cb) { cb(); } });
const rl = createInterface({ input: process.stdin, output: muted, terminal: true });
process.stdout.write("Password: ");
const password = await rl.question("");
rl.close();
process.stdout.write("\n");
if (password.length < 10) {
console.error("Password must be at least 10 characters.");
process.exit(2);
}
const hash = await bcrypt.hash(password, 12);
const { db, pool } = createClient(url);
await db.execute(
sql`INSERT INTO operators (username, password_hash, display_name, role, default_timezone)
VALUES (${username}, ${hash}, ${username}, ${role}, 'Asia/Kuala_Lumpur')`,
);
await pool.end();
console.log(`Created ${role} ${username}.`);
process.exit(0);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@ -0,0 +1,59 @@
import bcrypt from "bcryptjs";
import { sql } from "drizzle-orm";
import { createInterface } from "node:readline/promises";
import { Writable } from "node:stream";
import { createClient } from "../index.js";
async function main() {
const username = process.argv[2];
if (!username) {
console.error("Usage: set-password <username>");
process.exit(2);
}
const url = process.env.DATABASE_URL;
if (!url) {
console.error("DATABASE_URL not set");
process.exit(2);
}
// Silenced password prompt.
const muted = new Writable({ write(_chunk, _enc, cb) { cb(); } });
const rl = createInterface({ input: process.stdin, output: muted, terminal: true });
process.stdout.write("Password: ");
const password = await rl.question("");
rl.close();
process.stdout.write("\n");
// Mirrors apps/web/src/lib/password-policy.ts so the CLI bootstrap
// path and the server actions stay in sync. Facebook's documented
// minimum is 6 chars, with a recommended mix of letters and
// numbers/punctuation.
if (password.length < 6) {
console.error("Password must be at least 6 characters.");
process.exit(2);
}
if (password.length > 256) {
console.error("Password is too long.");
process.exit(2);
}
const hasLetter = /[A-Za-z]/.test(password);
const hasNonLetter = /[^A-Za-z]/.test(password);
if (!hasLetter || !hasNonLetter) {
console.error("Password must mix letters with numbers or symbols.");
process.exit(2);
}
const hash = await bcrypt.hash(password, 12);
const { db, pool } = createClient(url);
const result = await db.execute(
sql`UPDATE operators SET password_hash = ${hash} WHERE lower(username) = lower(${username}) RETURNING id`,
);
await pool.end();
if (result.rows.length === 0) {
console.error(`No user with username ${username}`);
process.exit(1);
}
console.log("Password updated.");
process.exit(0);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@ -1,29 +1,25 @@
import { createClient, operators } from "./index.js"; import { createClient, operators } from "./index.js";
const databaseUrl = process.env.DATABASE_URL; const databaseUrl = process.env.DATABASE_URL;
const operatorTelegramId = process.env.SEED_OPERATOR_TELEGRAM_ID; const username = process.env.SEED_OPERATOR_USERNAME ?? "admin";
const operatorName = process.env.SEED_OPERATOR_NAME ?? "Operator"; const operatorName = process.env.SEED_OPERATOR_NAME ?? "Operator";
if (!databaseUrl) { if (!databaseUrl) {
console.error("DATABASE_URL not set"); console.error("DATABASE_URL not set");
process.exit(1); process.exit(1);
} }
if (!operatorTelegramId || operatorTelegramId === "0") {
console.error("SEED_OPERATOR_TELEGRAM_ID not set");
process.exit(1);
}
const { db, pool } = createClient(databaseUrl); const { db, pool } = createClient(databaseUrl);
await db await db
.insert(operators) .insert(operators)
.values({ .values({
telegramUserId: Number(operatorTelegramId), username,
displayName: operatorName, displayName: operatorName,
role: "admin", role: "admin",
defaultTimezone: "Asia/Kuala_Lumpur", defaultTimezone: "Asia/Kuala_Lumpur",
}) })
.onConflictDoNothing(); .onConflictDoNothing();
console.log(`Seeded operator with telegram_user_id=${operatorTelegramId}`); console.log(`Seeded operator '${username}'. Set a password via scripts/set-password.sh ${username}`);
await pool.end(); await pool.end();

26
pnpm-lock.yaml generated
View File

@ -93,6 +93,9 @@ importers:
'@types/luxon': '@types/luxon':
specifier: ^3.4.2 specifier: ^3.4.2
version: 3.7.1 version: 3.7.1
bcryptjs:
specifier: ^3.0.3
version: 3.0.3
class-variance-authority: class-variance-authority:
specifier: ^0.7.1 specifier: ^0.7.1
version: 0.7.1 version: 0.7.1
@ -166,6 +169,9 @@ importers:
'@tailwindcss/postcss': '@tailwindcss/postcss':
specifier: ^4.0.0 specifier: ^4.0.0
version: 4.3.0 version: 4.3.0
'@types/bcryptjs':
specifier: ^3.0.0
version: 3.0.0
'@types/node': '@types/node':
specifier: ^22.7.0 specifier: ^22.7.0
version: 22.19.18 version: 22.19.18
@ -202,6 +208,9 @@ importers:
packages/db: packages/db:
dependencies: dependencies:
bcryptjs:
specifier: ^3.0.3
version: 3.0.3
drizzle-orm: drizzle-orm:
specifier: ^0.36.0 specifier: ^0.36.0
version: 0.36.4(@types/pg@8.20.0)(@types/react@19.2.14)(pg@8.20.0)(react@19.2.6) version: 0.36.4(@types/pg@8.20.0)(@types/react@19.2.14)(pg@8.20.0)(react@19.2.6)
@ -209,6 +218,9 @@ importers:
specifier: ^8.13.0 specifier: ^8.13.0
version: 8.20.0 version: 8.20.0
devDependencies: devDependencies:
'@types/bcryptjs':
specifier: ^3.0.0
version: 3.0.0
'@types/node': '@types/node':
specifier: ^22.7.0 specifier: ^22.7.0
version: 22.19.18 version: 22.19.18
@ -2370,6 +2382,10 @@ packages:
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
'@types/bcryptjs@3.0.0':
resolution: {integrity: sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg==}
deprecated: This is a stub types definition. bcryptjs provides its own type definitions, so you do not need this installed.
'@types/estree@1.0.8': '@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@ -2559,6 +2575,10 @@ packages:
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
hasBin: true hasBin: true
bcryptjs@3.0.3:
resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==}
hasBin: true
body-parser@2.2.2: body-parser@2.2.2:
resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -6461,6 +6481,10 @@ snapshots:
'@turbo/windows-arm64@2.9.12': '@turbo/windows-arm64@2.9.12':
optional: true optional: true
'@types/bcryptjs@3.0.0':
dependencies:
bcryptjs: 3.0.3
'@types/estree@1.0.8': {} '@types/estree@1.0.8': {}
'@types/estree@1.0.9': {} '@types/estree@1.0.9': {}
@ -6676,6 +6700,8 @@ snapshots:
baseline-browser-mapping@2.10.28: {} baseline-browser-mapping@2.10.28: {}
bcryptjs@3.0.3: {}
body-parser@2.2.2: body-parser@2.2.2:
dependencies: dependencies:
bytes: 3.1.2 bytes: 3.1.2

3
scripts/create-user.sh Executable file
View File

@ -0,0 +1,3 @@
#!/usr/bin/env bash
set -euo pipefail
NO_SUDO=1 ./scripts/dev.sh exec pnpm --filter @cmbot/db exec tsx src/scripts/create-user.ts "$@"

Some files were not shown because too many files have changed in this diff Show More