Commit Graph

22 Commits

Author SHA1 Message Date
xarmian 3f69b76b06 feat(security): enforce session UA binding under strict mode (TASK-2056) (#912)
Session IP/User-Agent binding was log-only by default, so a stolen
session token granted durable any-origin access. IP-change enforcement
already existed behind PAD_IP_CHANGE_ENFORCE=strict; this extends the
same single toggle to also enforce the User-Agent-hash binding.

When strict enforce is ON, a request whose client IP OR User-Agent hash
no longer matches the session's stored binding now revokes the session
(DeleteSessionIfExists) and rejects the request (401 for API,
revoked-passthrough for public/browser paths), killing the stolen token.
When enforce is OFF (default), behavior is unchanged: UA mismatch is
logged (slog only, no new audit row) and the request proceeds, so
existing self-host users see no behavior change and routine client churn
(browser/WebView updates, DevTools emulation, mobile-app rebuilds) is
tolerated.

The UA hash is stable within a real session, so UA-mismatch enforce
carries fewer false positives than IP enforce (mobile roaming, VPN
toggles, carrier NAT) — documented in the handler comment. Adds the
ActionSessionUAChanged audit action, emitted only in strict mode.

No DB migration: reuses the existing IPChangeEnforce config flag and the
existing session store primitives.

Claude-Session: https://claude.ai/code/session_015yuBJQYfDj95cgX3DaD8SF
2026-07-10 23:32:22 -04:00
xarmian c846cff4fd feat(project): agent-accessible activity feed (pad project activity + MCP action) (#877)
* feat(project): agent-accessible activity feed (pad project activity + MCP action)

Add a non-streaming, bounded activity query so agents can catch up on
what other agents/users changed since they last worked — the query
counterpart to the live `pad project watch` SSE stream.

- CLI: `pad project activity [--limit N] [--actor user|agent] [--since DATE]`
  backed by the existing GET /workspaces/{ws}/activity feed.
- MCP: `pad_project.activity` action (passThrough) + cloud HTTP route.
- Extend the activity endpoint with a server-side `since` date filter
  (handler parse + store SQL clause) so limit/actor/since behave
  identically across CLI, stdio MCP, and cloud HTTP transports.
- Bump ToolSurfaceVersion 0.11 -> 0.12 (drift guard, README, CLAUDE.md,
  instructions.md) and add a SKILL.md querying-guidance line.

Tests: store since-filter test, HTTP dispatch test, catalog action test.

Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS

* fix(mcp): mark pad_project.activity read-only in tool surface

Add activity to readOnlyActions so the serialized MCP tool surface emits
read_only:true (missing entries default to write). Spot-check it in
tool_surface_test.go to guard against regression.

Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
2026-07-08 16:26:41 -04:00
xarmian b0eeef16ce feat(store): email_verification_tokens + SendEmailVerification + token reaper (TASK-1936) (#806)
Wave 2 of PLAN-1933 — verification-token infrastructure (pure infra; no
endpoint consumes it until Wave 3).

- Migration 071 (SQLite) / 049 (Postgres): email_verification_tokens table,
  cloning the password_resets shape (id/user_id FK/token_hash/expires_at/
  used_at/created_at + token_hash + user_id indexes), per-dialect created_at
  default.
- Store email_verification.go: 256-bit crypto/rand token, padver_ prefix,
  SHA-256-at-rest, non-destructive Lookup, atomic UPDATE...RETURNING Consume.
  Deltas from password_resets (DR-2): 24h TTL, keep invalidate-prior-on-mint
  (resend burns the old link), consume side-effect sets users.email_verified_at
  (RFC3339-with-Z, same format Wave 1's migration used) in one transaction —
  no password reset, no session mint.
- Email SendEmailVerification: clones SendPasswordReset, "1 hour" -> "24 hours".
- Token reaper (DR-5): lifecycle-safe background sweep (mirrors orphanGC/opLogGC
  — self-registers on Server.bg, context-cancellable via stop channel, started
  only from cmd/pad/main.go so unit tests don't leak goroutines) calling the
  four previously-unwired CleanExpired* methods (email verifications, password
  resets, sessions, CLI auth sessions) hourly. Adds CleanExpiredEmailVerifications.
- Audit consts ActionEmailVerified + ActionEmailVerifiedByAdmin.

Gates: make check + make test-pg green (store + migration on both dialects).

Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
2026-07-04 01:19:41 -04:00
xarmian 915f7e66c5 fix(web): show issue ID and status pills in activity log (BUG-1748) (#776)
Activity rows (the dedicated Activity page and the dashboard's Recent
Activity list) showed only the item title, never the issue ID. Add the
ref (e.g. BUG-1748) as a leading monospace badge on both surfaces.

The ref rides on the per-row item lookup that already runs to populate
the title, so there are no new DB queries — enrichActivities and the
dashboard recent-activity builder now also copy item.Ref after
ComputeRef(). New item_ref field on models.Activity, DashboardActivity,
and the TS Activity / recent_activity types.

The Activity page now renders field changes as structured pills
("status: open → fixing") instead of a raw string, via a new shared
parseFieldChanges util that also replaces the private copy in
TimelineActivityCard.

Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
2026-06-29 13:07:36 -05:00
xarmian d3a543db6f feat(attachments): admin per-user storage quota override UI (TASK-883) (#304)
* feat(attachments): admin per-user storage quota override UI (TASK-883)

Surfaces the storage_bytes plan_overrides key in the admin user-detail
page so operators can lift or tighten an individual user's quota
without poking at JSON via the API directly.

Frontend (console/admin/+page.svelte):
- Dedicated "Storage quota override" input below the existing
  overrides grid. Storage is byte-counted, not row-counted, so a
  number input forcing the admin to type 536870912 for 512MB
  would be hostile. Accepts:
    • "10 GB" / "500MB" / "1.5 GB" (IEC shorthand)
    • "1024" (raw bytes)
    • "-1" (unlimited)
    • "" (clear → falls back to plan default)
- Live parse preview ("= 10.0 GB (10,737,418,240 bytes)") so the
  admin can verify the unit was understood.
- "Reset to plan default" button clears the field; save commits
  the absence as a removed override key.
- Pre-fills with the current effective override formatted in the
  largest exact unit so a previously-set "10 GB" doesn't reload as
  "10737418240".

Backend:
- ActionPlanOverridesChanged audit constant.
- handleAdminUpdateUser now logs an audit event with old/new
  override JSONs whenever plan_overrides is patched. Lets operators
  correlate a mysteriously-allowed upload with the override that
  enabled it.

Tests:
- TestAdminUpdateUser_StorageOverrideRoundTrip: PATCH with
  storage_bytes:1073741824 → GET shows the new override → audit
  feed contains plan_overrides_changed event → clearing the
  override removes it.
- TestAdminUpdateUser_NonAdminForbidden: member-role user cannot
  PATCH another user's plan_overrides (regression guard for the
  audit-log path).

Parent: PLAN-866. The Settings → Storage page (TASK-882) reflects
the new effective limit immediately after save because both call
the same WorkspaceStorageInfo helper.

* fix(admin): parse plan_overrides JSON on read, clear via empty string per Codex (round 1)

Two related bugs in the admin user-detail page that Codex caught
in PR #304 round 1:

1. The save path sent JSON null when every override field was
   blank, but the Go handler uses a *string and JSON null decodes
   to a nil pointer — the handler's existing nil-vs-non-nil branch
   then skips the update, meaning "Reset to plan default" reported
   success without actually clearing the override. Fixed by
   sending "" (empty string) which routes through
   SetUserPlanOverrides("") and clears the column.

2. The form-populate path treated u.plan_overrides as an object
   while the API actually returns the raw column value as a JSON
   string. So `'storage_bytes' in ov` was checking string indices
   on a literal '{"storage_bytes":1073741824}' string, returning
   false, and any user with stored overrides loaded a blank form.
   This was a pre-existing bug in the workspaces / api_tokens /
   etc. fields too — fixed for all of them by parsing the JSON in
   parsePlanOverrides() before reading keys, with a defensive
   "future-proof" branch in case the API ever switches to a
   decoded object.

TS type for AdminUser.plan_overrides updated to `string | null`
to match the actual API contract.

Backend regression test added (TestAdminUpdateUser_OmittedOverrides
Preserved) that pins the other half of the contract: PATCH with
plan_overrides absent must NOT clear the column. The test was
straightforward to add because the existing test infrastructure
(bootstrapFirstUser, doRequestWithCookie) already covers the
admin auth path.
2026-04-29 17:56:19 -04:00
xarmian 157ca4e88f chore: bump Go toolchain to 1.26 (TASK-763) (#247)
* chore: bump Go toolchain to 1.26 (TASK-763)

Bump Go from 1.25 to 1.26 across all toolchain pins:

- go.mod — go 1.25.0 → go 1.26.0
- Dockerfile — golang:1.25-alpine → golang:1.26-alpine
- .github/workflows/ci.yml — three setup-go steps (Go, Go-Postgres, E2E jobs)
- .github/workflows/release.yml — release pipeline

No `toolchain` directive: the repo is pre-launch with no external
contributors yet, so we set the floor where we want it (hard requirement).

Verified locally before commit:
- golangci-lint v2.11.4 builds and runs under Go 1.26.2 (pinned in CI)
- golang:1.26-alpine and 1.26.2-alpine images present on Docker Hub
- go build ./... clean
- go vet ./... clean
- go test ./... all pass

Parent: PLAN-644 (OSS Repo Hygiene and Launch Polish).

* chore: gofmt -w under Go 1.26 (TASK-763)

Apply Go 1.26's gofmt to the codebase. ~41 files reformatted, all
struct-tag whitespace realignment — no semantic changes. Verified:

- gofmt -l ./cmd ./internal returns empty after
- go build ./... still clean
- go test ./... still passes (run before commit)

Bundling the gofmt diff with the toolchain bump in the same PR because
the formatting drift is a direct consequence of moving from 1.25 to
1.26; splitting them creates a mandatory two-PR ordering for no value.

Parent: PLAN-644.

* docs: bump documented Go floor to 1.26 (TASK-763)

Match go.mod's hard 1.26.0 requirement in the source-build instructions.
Caught by Codex review round 1 on PR #247.

- README.md:158 — "Go 1.25+" → "Go 1.26+"
- CONTRIBUTING.md:9 — "Go 1.25+" → "Go 1.26+"
2026-04-25 11:35:19 -04:00
xarmian 119e2d8aa2 feat(billing): payment-failed email endpoint + template (TASK-712 1 of 2) (#232)
* feat(billing): payment-failed email endpoint + template (TASK-712 1 of 2)

Pairs with pad-cloud's invoice.payment_failed webhook handler (shipping
next) to give paying users a chance to update their card before dunning
exhausts and the subscription cancels. pad owns the Maileroo integration
and the user→email mapping; the sidecar forwards the invoice metadata
here.

Changes:

- email.Sender.SendPaymentFailed — new template (HTML + plain). Subject
  "Your Pad payment couldn't be processed"; body names the amount +
  next retry date when provided, falls back to generic copy when Stripe
  omits them, and CTAs to the billing portal so the user can update
  their card. Transactional (no unsubscribe link) — users who want the
  emails to stop either fix their card or cancel the subscription.

- POST /api/v1/admin/payment-failed — new cloud-secret-gated endpoint
  (handlers_cloud.go). Accepts stripe_customer_id + optional pre-
  formatted amount_display + next_retry_display. Looks up the user,
  sends the email, logs a payment_failed_email_sent audit entry.
  Returns 200 + email_sent=false with a reason string for every
  non-error skip (unknown customer, no email on file, Maileroo not
  configured) so the sidecar never rolls back the Stripe webhook over
  an email failure. Returns 200 + email_sent=false + reason=send_failed
  when Maileroo itself errors — still no rollback.

- Registered the path in cloudAdminPaths, the server router, and the
  CloudAdmin rate limiter so the sidecar's calls share the same rate
  bucket as /plan + /stripe-customer-id.

- ActionPaymentFailedEmailSent audit constant for the new entry.

- Three focused tests: cus_ prefix validation, unknown-customer 200,
  and email-not-configured 200. Added an entry to the cloud-mode gate
  table-driven test to confirm /admin/payment-failed also 404s when
  cloud mode is off.

Parent: PLAN-645 (Pad Cloud Beta Readiness). TASK-712 bullet 3, pad
side. pad-cloud's handlePaymentFailed wiring ships in a sibling PR.

* fix(billing): audit every outcome; target user ID; add send-path tests (Codex round 1)

Addresses PR #232 round 1 findings:

MEDIUM — payment-failed handler only wrote an audit row on the actual
send attempt, so no_customer / no_email_address / email_not_configured
skip paths left no durable trail. Consolidated the audit + response
into a single auditAndRespond closure called from every outcome
branch, so operators can always reconstruct whether (and why) a
customer was notified during dunning reconciliation.

MEDIUM — audit UserID was set to actorID, which is empty for sidecar
calls. /audit-log?user=<target-user-id> would never surface these
events. Now set UserID to targetUser.ID whenever we have one; the
no_customer branch still writes a row but with empty UserID (filtered
only by action + stripe_customer_id metadata). Moved actor identity
into an actor_is_admin metadata field instead.

LOW — test coverage was thin: no assertion on the most important
contract ("return 200 with reason=send_failed and still record the
attempt"), no test of the happy send path, no audit-log assertions.
Added email.Sender.SetEndpoint (exported, test-only — comment says
so) so tests can point the Sender at a mock Maileroo server, plus
three new tests:
  - TestPaymentFailed_HappyPath_SendsAndAudits
  - TestPaymentFailed_MailerooError_Returns200_SendFailed_AndAudits
  - TestPaymentFailed_UnknownCustomer_AuditsWithoutUserID
The first two verify audit metadata per outcome; the third proves
unknown-customer cases still leave a findable audit row.

Thread-safety fix as a side-effect: Send/SendAs were reading s.endpoint
outside the sender's RWMutex — fine before the mutable SetEndpoint
existed, now a data race. Pulled the endpoint read into the same
RLock scope as fromAddr/fromName.

* fix: capture admin actor ID + audit-log formatter for payment_failed (Codex round 2)

Addresses PR #232 round 2 findings:

MEDIUM — auditAndRespond recorded actor_is_admin=true/false but not
which admin. For manual operator-triggered calls, that meant the audit
trail could not answer "who sent the dunning email?" when multiple
admins touched the endpoint. Added admin_actor_id to the metadata
whenever the authenticated caller has role=admin. Sidecar calls with
no authenticated user still have no admin_actor_id, which correctly
distinguishes them from manual admin operations.

LOW — web/src/routes/console/admin/audit-log/+page.svelte falls back
to "first 3 metadata keys" when no formatter exists for an action,
which could hide the important reason/sent fields. Added a dedicated
case for payment_failed_email_sent that renders either "sent (cus_...)"
or "skipped: <reason> (cus_...)" depending on the outcome, matching
the terse display style of the other switch cases.

* fix(audit-log): distinguish send_failed from skip; surface admin actor (Codex round 3)

Addresses PR #232 round 3 LOWs:

- The formatter lumped every sent=false outcome under 'skipped', which
  conflates a genuine Maileroo delivery failure with a pre-send skip.
  Now: sent → 'sent (...)'; send_failed → 'send failed (...)'; other
  reasons → 'skipped (<reason>) (...)'.
- admin_actor_id was recorded in metadata but invisible in the UI: the
  User column shows the target user via a.user_id. Appended
  'by admin:<id>' to the formatted string whenever admin_actor_id is
  present, so manual operator calls are attributable at a glance.
  Sidecar calls have no admin_actor_id and render without the suffix.

* fix(audit-log): register payment_failed_email_sent in action filter dropdown (Codex round 4)

The backend emits payment_failed_email_sent and the custom formatter
knows how to render it, but the audit-log page's ACTION_TYPES /
ACTION_LABELS registry omitted the action, so admins couldn't filter
for these events from the dropdown — undercutting the dunning
reconciliation workflow this PR is adding. Added 'payment_failed_email_sent'
to the ACTION_TYPES list and 'Payment Failed Email' to ACTION_LABELS.
2026-04-24 01:50:08 -04:00
xarmian 775dd89fdc feat(cloud): add /admin/stripe-event-unmark endpoint (TASK-736 / 1 of 2) (#228)
* feat(cloud): add /admin/stripe-event-unmark endpoint (TASK-736 / 1 of 2)

Parent: PLAN-645. Pair with pad-cloud follow-up.

* fix(cloud): add processed_at race protection + audit log per Codex review (round 1)
2026-04-23 20:26:02 -04:00
xarmian 46fa72ca0f feat(server): log session IP changes, add optional PAD_IP_CHANGE_ENFORCE=strict (TASK-666) (#191)
* feat(server): log session IP changes, add optional PAD_IP_CHANGE_ENFORCE=strict (TASK-666)

Sessions stored a client IP at creation but never rechecked it. A stolen
cookie could be used from anywhere with no signal to the owner. This
change adds mid-lifetime IP-change detection without breaking legitimate
mobility (mobile roaming, VPN toggles, carrier NAT) by default.

- New audit action ActionSessionIPChanged captures {old_ip, new_ip} in
  the audit metadata. Visible via the existing /api/v1/admin/audit-log.
- handleSessionIPChange wired into both SessionAuth (cookies) and
  TokenAuth (padsess_ bearer). After UA check passes, compares stored
  session IP to clientIP(r). On mismatch:
    - log one audit row
    - update the stored session IP so we don't spam the log
    - strict mode: DeleteSession + 401 "session_ip_changed"
    - default mode: let the request through
- Store.UpdateSessionIP lets middleware refresh the recorded IP without
  tearing down the session.
- PAD_IP_CHANGE_ENFORCE=strict env var + ip_change_enforce TOML key +
  Server.SetIPChangeEnforce setter (case-insensitive, trims whitespace).
- Table-driven tests cover log-only, strict rejection with session
  destruction, and setter parsing edge cases.

Parent: PLAN-643 (OSS Security Hardening).

* fix(server): dedupe session-IP-change audit via CAS, handle browser vs API paths per Codex review

Addresses two P2 comments on PR #191:

1. Race: parallel requests after an IP change could each emit
   ActionSessionIPChanged before any of them updated the stored IP,
   producing duplicate audit rows for a single transition.
   - Replace UpdateSessionIP with UpdateSessionIPIfEquals (compare-and-set
     on ip_address). Only the request that actually rotates the stored
     value logs; concurrent siblings lose the CAS and skip logging.
   - New test TestSessionIPChange_CASDedupesRace fires 20 concurrent
     requests from the new IP and asserts exactly 1 audit row.

2. Strict-mode 401 on non-API paths:
   - In current routing the SPA is mounted on the root router outside
     the auth Group, so SessionAuth only fires for /api/* in practice.
     The original concern about JSON 401s on browser navigation doesn't
     surface today, but defense-in-depth keeps the code forward-safe:
     restructure handleSessionIPChange to return a four-state outcome
     (Continue / AllowedLogged / Revoked / Terminated) and only write
     the JSON 401 on /api/* paths. Revoked + non-API falls through
     unauthenticated so a future SPA-in-group configuration would still
     render a login screen instead of raw JSON.
   - Clear the session cookie (MaxAge=-1) in strict rejection so the
     browser stops sending the now-revoked token on the next request.
     TestSessionIPChange_StrictClearsCookies verifies the Set-Cookie.

Parent: PLAN-643 (OSS Security Hardening), TASK-666.

* fix(server): strict mode destroys session atomically, never rotate stored IP when destroying (TASK-666)

Addresses Codex P1 on PR #191: previously we rotated the session's stored
ip_address via UpdateSessionIPIfEquals BEFORE attempting DeleteSession.
If the DELETE failed (transient DB error) the row remained alive —
rebound to the attacker's new IP — so follow-up requests saw stored IP
== client IP and passed handleSessionIPChange's "match, no-op" branch.
That silently defeated strict enforcement.

- New Store.DeleteSessionIfExists returns (bool, error) to serve as the
  CAS primitive for strict mode: only the caller whose DELETE affected a
  row emits the audit entry, and a DB error fails closed (500 — "Unable
  to validate session") rather than letting the request through.
- handleSessionIPChange splits into two paths:
    * log-only mode: UpdateSessionIPIfEquals for CAS dedup (unchanged)
    * strict mode: DeleteSessionIfExists is the CAS; stored IP is NEVER
      rotated so any failure leaves the session bound to the OLD IP and
      subsequent requests from the new IP still mismatch + still reject.
- TestSessionIPChange_StrictDestroysSessionAtomically regression test
  verifies a second request from the new IP with the same token still
  fails after the first strict-mode rejection.

Parent: PLAN-643 (OSS Security Hardening).

* fix(server): exempt public API paths from strict IP-change termination (TASK-666)

Addresses Codex P2 on PR #191: SessionAuth runs for every /api/* path,
including public endpoints like /api/v1/auth/login, /api/v1/auth/register,
/api/v1/health, /api/v1/s/* (share links), and /api/v1/plan-limits. In
strict mode, a stale session cookie on those requests was rejected with
a 401 session_ip_changed BEFORE the public handler could run — the user
literally couldn't log back in because their own stale cookie blocked
the login call.

- Extract isPublicAPIPath as a shared helper between RequireAuth and
  handleSessionIPChange so they can't drift out of sync.
- handleSessionIPChange strict-mode flow now: destroy session + clear
  cookies + audit log (unchanged), then for public API paths return
  Revoked so the handler still runs. For authenticated-only API paths
  still return Terminated (401). For non-API paths return Revoked for
  the SPA fallback.
- Updated TokenAuth Revoked handler to match: pass through unauth on
  public paths, 401 on authenticated-only.
- TestSessionIPChange_StrictAllowsPublicAPIPaths regression test:
  a stale session cookie on /api/v1/auth/login must NOT produce
  session_ip_changed; /api/v1/plan-limits must still return 200.

Parent: PLAN-643 (OSS Security Hardening).

* fix(server): short-circuit SessionAuth on token auth + fix IPv6 clientIP parsing (TASK-666)

Addresses two more Codex comments on PR #191:

P1 — SessionAuth 401'd API-token-authenticated requests:
TokenAuth sets currentUser for user-owned tokens AND tokenWorkspaceID
for legacy workspace-scoped tokens. SessionAuth short-circuited only on
currentUser, so a workspace-scoped-token request that happened to carry
a stale session cookie with a mismatched IP would be rejected by the
IP-change strict path before RequireAuth could honor the token. Extend
the short-circuit to also check tokenWorkspaceID; either signal is
enough to say "token auth already succeeded, skip cookie validation".

P2 — clientIP mangled IPv6 addresses:
clientIP used strings.LastIndex(":") on RemoteAddr. For bare IPv6
addresses like "2001:db8::1" (which TrustedProxyRealIP writes verbatim
from X-Forwarded-For, no brackets/port), that strips the final hextet
to "2001:db8:" — unusable for comparison in the new IP-change audit
path and incorrect for rate-limit keys too. Switch to net.SplitHostPort
which handles both "host:port" and "[ipv6]:port", falling back to the
raw RemoteAddr when no port is present (the trusted-proxy rewrite
case).

Tests:
- TestClientIP_IPv6NotMangled covers IPv4 w/wo port, bracketed IPv6,
  bare IPv6 (no port, no brackets), and loopback forms.
- TestSessionAuth_ShortCircuitsOnAPITokenAuth exercises the worst case:
  strict mode + valid API token + stale session cookie + new client IP.
  Request must succeed (token wins) and NO new session_ip_changed audit
  row must appear.

Parent: PLAN-643 (OSS Security Hardening).

* fix(server): canonicalize IPs before session-IP-change comparison (TASK-666)

Addresses Codex P2 on PR #191: raw-string comparison of session.IPAddress
vs clientIP(r) would fire session_ip_changed spuriously when the same
IPv6 address arrived in different valid textual representations (the
trusted-proxy path writes X-Forwarded-For verbatim, and different hops
normalize differently — "2001:0db8::1" vs "2001:db8::1" etc.).

- canonicalIP helper: net.ParseIP + stringify to collapse equivalent
  IPv6 forms (compressed vs expanded, case, leading zeros) and IPv4-in-
  IPv6 into a single canonical string. Non-parseable inputs pass through
  unchanged so debug/malformed values behave predictably.
- handleSessionIPChange compares and logs the canonical forms. The CAS
  still passes session.IPAddress (the raw stored value) to the DB — the
  compare-and-set is about row identity — but the new IP written in is
  the canonical form so future comparisons are stable.
- TestCanonicalIP covers empty, IPv4, shorthand "::1", expanded 8-group
  equivalent, mixed-case 2001:DB8::1, fully expanded 2001:0db8:…:0001,
  and non-IP fallback.

Parent: PLAN-643 (OSS Security Hardening).
2026-04-22 10:43:22 -04:00
xarmian d968b551b7 feat: add account disable/deactivation (#104)
* feat: add account disable/deactivation for admin users

Allow admins to soft-disable user accounts without deleting data.
Disabled users get a 403 on all authenticated requests, their sessions
are invalidated on disable, and they show as visually dimmed with a
red "disabled" badge in the admin console. Includes migration for
disabled_at column, auth middleware check, disable/enable endpoints
with audit logging, and frontend toggle with confirmation dialog.

* refactor: auto-discover migrations from embedded filesystem

Replace hardcoded migration lists with fs.ReadDir on the embedded FS
directories. New migrations are now picked up automatically by filename
sort order — no need to manually register them in store.go.

* fix: block disabled users at login and capture IDs before async calls

Reject disabled accounts in the login handler before session creation,
not just in RequireAuth middleware (which exempts auth routes). Also
capture selectedId into a local const in all async admin panel functions
to prevent stale updates if the selection changes during a request.

* fix: enforce disabled check in OAuth and password reset flows, always invalidate sessions

Block disabled users in all session-minting paths (OAuth login, password
reset) not just password login. Also remove early return for
already-disabled users in the disable endpoint so session invalidation
always runs, handling retry after partial failure.
2026-04-13 21:56:40 -04:00
xarmian 79d7d26a00 feat: add admin password reset for other users (#103)
* feat: add admin password reset for other users

New POST /api/v1/admin/users/{id}/reset-password endpoint. When email is
configured, sends a password reset link. Otherwise generates a temporary
password and invalidates existing sessions. Includes audit logging via
new password_reset_by_admin action and frontend UI with confirmation.

* fix: treat session revocation and email send as hard failures

Make session invalidation failure abort the reset instead of silently
continuing, and send the reset email synchronously so delivery failures
are surfaced to the admin caller.
2026-04-13 20:59:35 -04:00
xarmian d0518216c5 feat: add cloud infrastructure for hosted Pad (PLAN-427)
Add the foundation for running Pad as a hosted service at app.getpad.dev.
Same binary in cloud mode with a thin sidecar for OAuth and Stripe.

Cloud mode (PAD_CLOUD=true):
- PAD_CLOUD flag with cloud secret for sidecar communication
- Account-level billing: plan field on users, CheckLimit enforcement
- Free/Pro tiers with configurable limits stored in platform_settings
- Three-tier limit resolution: user overrides → DB defaults → hardcoded fallback
- Plan enforcement on workspace, item, member, webhook, and token creation

Authentication & security:
- OAuth login endpoint (POST /api/v1/auth/oauth-login) with cloud secret gate
- Verified email requirement for OAuth, 2FA bypass protection
- Cloud secret rotation support (comma-separated keys)
- TOTP secret encryption at rest (AES-256-GCM via PAD_ENCRYPTION_KEY)
- Rate limiting on OAuth login endpoint
- Bootstrap disabled in cloud mode
- Password max length enforcement (128 chars)
- Config file written with 0600 permissions

Admin & billing:
- Admin user management API (list, detail, update plan/overrides)
- Configurable plan limits API (GET/PATCH /api/v1/admin/limits)
- Platform stats endpoint
- Admin plan endpoint for sidecar to set user plans
- GDPR: account deletion and data export endpoints

Console UI (cloud mode only):
- /console — workspace list with owned/shared sections
- /console/new — create workspace wizard with slug preview
- /console/settings — profile, password, API tokens
- /console/billing — plan status, upgrade/manage links
- /console/admin — user management, plan overrides, limits editor
- OAuth buttons (GitHub/Google) on login page in cloud mode

Auto-create default workspace on signup in cloud mode.
Migration 035: plan, plan_expires_at, stripe_customer_id, plan_overrides on users.
2026-04-12 17:43:40 +00:00
xarmian 0e32645bb5 feat: add TOTP two-factor authentication
Backend support for optional TOTP-based 2FA on user accounts:

- POST /auth/2fa/setup — generate TOTP secret, return QR code URI
- POST /auth/2fa/verify — verify code and enable 2FA with recovery codes
- POST /auth/2fa/disable — disable 2FA (requires password confirmation)
- POST /auth/2fa/login-verify — complete login with TOTP or recovery code
- Login returns {requires_2fa: true, user_id} when 2FA is enabled,
  requiring a second step via /auth/2fa/login-verify
- 8 recovery codes generated on setup for account recovery
- User model extended with totp_secret, totp_enabled, recovery_codes
- Refactored user queries with shared scanUser/userColumns for DRYness

Implements TASK-169 under PLAN-15 (Pad Cloud: Hardening).
2026-04-08 20:30:39 +00:00
xarmian ba8e20c697 feat: add API token rotation, expiry defaults, and scope enforcement
- New tokens get a default 90-day expiry (configurable via platform
  settings: token_default_expiry_days, token_max_lifetime_days)
- POST /api/v1/auth/tokens/{id}/rotate generates a new secret while
  preserving token metadata; old secret is immediately invalidated
- X-Token-Expires-Soon and X-Token-Expires-At headers warn when a
  token is within 7 days of expiry
- Token scopes are now enforced: "read" restricts to GET/HEAD/OPTIONS,
  "write" and "*" allow all methods
- Existing tokens without expiry continue to work (backward compatible)

Implements TASK-170 under PLAN-15 (Pad Cloud: Hardening).
2026-04-08 18:00:43 +00:00
xarmian 872f08aa84 feat: add compliance audit trail with IP/UA tracking
Extend the activities table to capture IP address and user agent for all
state-changing operations. Add audit events for auth (login, logout,
register, bootstrap, password changes), workspace management (member
invite/remove, role changes), token lifecycle, and admin settings.

- SQLite migration recreates activities table with nullable workspace_id,
  new ip_address/user_agent columns, and relaxed CHECK constraints
- PostgreSQL migration adds columns and drops constraints
- New ListAuditLog store method with action/actor/workspace/date filters
- GET /api/v1/audit-log endpoint (admin-only)
- CLI: pad workspace audit-log [--days N] [--actor X] [--action X]
2026-04-06 01:55:06 +00:00
xarmian 481527de02 fix: server-side timeline pagination and reaction toggle (#57)
* fix: server-side timeline pagination and reaction toggle (#55, #56)

Issue #55: Replace in-memory pagination with cursor-based approach.
The /timeline endpoint now accepts `before` (RFC3339 timestamp) and
`limit` params, fetching a small window from each source (comments,
activities, versions) instead of loading everything into memory.
Frontend gets a "Load more" button that passes the oldest entry's
timestamp as the cursor.

Issue #56: Plumb current user ID to reaction toggle. ItemTimeline
fetches the auth session on mount to get the user ID, passes it to
TimelineCommentCard. toggleReaction now checks if the user already
reacted (by matching reaction.user_id) and calls onRemoveReaction
to un-react. Own reaction chips are visually highlighted.

* fix: address review findings for PR #57 (iteration 1)

- Use <= with ID tie-breaker in cursor queries to prevent skipping
  entries at timestamp boundaries; use consistent RFC3339 format
- Treat orphaned replies (parent on different page) as top-level
  entries instead of silently dropping them
- Deduplicate by ID on "Load more" to handle boundary overlap
- SSE reload now prepends new entries and updates existing ones
  instead of clobbering all paginated state
- Parse before cursor with RFC3339Nano fallback for sub-second
  precision
- Fix dangling doc comment on ListItemVersions

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: add global auth store, remove per-component session fetch

Create authStore (web/src/lib/stores/auth.svelte.ts) following the
same pattern as workspaceStore. The root layout populates it on mount.
ItemTimeline now reads currentUserId via $derived(authStore.userId)
instead of making a separate api.auth.session() call on every mount.

* fix: address review findings for PR #57 (iteration 2)

- Add beforeID tie-breaker to all cursor queries to prevent infinite
  Load More loop when entries share the same timestamp
- Over-fetch per source (limit*3) to avoid skipping filtered entries
- SSE refresh now detects deleted entries and removes them from the
  first-page window instead of keeping stale data
- Auth store re-throws on fetch errors so layout can distinguish
  "not authenticated" from "server unreachable"
- Sidebar reads from authStore instead of making redundant session fetch

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: address review findings for PR #57 (iteration 3)

- Add ID tie-breaker to buildTimeline sort to match SQL cursor ordering
  (prevents same-second entries from being skipped on Load More)
- Refresh authStore after login so Sidebar and reaction toggle have
  correct user identity without requiring a page reload
- SSE merge now tracks first-page IDs explicitly to detect deletions
  without incorrectly removing entries from older pages that share
  boundary timestamps

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-03 09:39:40 -04:00
xarmian 998716ae49 feat: unified item timeline with comment-on-update, threading, and reactions (#54)
* feat: unified item timeline with comment-on-update, threading, and reactions (IDEA-115)

Replace the separate Comments section and Version History modal on the
item detail page with a single chronological timeline that interleaves
comments, activities, and content versions.

Key changes:
- Add --comment flag to `pad item update` so agents/users can explain
  status changes inline (creates a comment linked to the activity)
- Add threaded replies (parent_id on comments) with inline reply UI
- Add emoji reactions on comments (new comment_reactions table)
- New /timeline API endpoint merges comments, activities, and versions
  server-side with dedup and collapsing of rapid edits
- New Svelte timeline components: ItemTimeline, TimelineCommentCard,
  TimelineActivityCard, TimelineVersionCard, ReactionPicker
- Remove Implementation Notes and Decision Log inputs from web UI
- Update skill docs to encourage --comment on status changes

* fix: address review findings for PR #54 (iteration 1)

- Use ListItemVersions instead of ListVersions in timeline endpoint
  so item content history renders correctly
- Add workspace validation to reply and reaction handlers to prevent
  cross-workspace comment mutation
- Raise activity cap from 500 to 10000 to avoid silently truncating
  long timelines
- Fix toggleReaction to always POST (idempotent) instead of incorrectly
  matching other users' reactions for DELETE
- Await onReply promise before clearing draft to prevent duplicate
  submissions and lost drafts on failure
- Register reaction_added/reaction_removed in SSE ITEM_EVENTS so
  reactions from other sessions appear in real-time

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: address review findings for PR #54 (iteration 2)

- Fix nil pointer dereference in timeline handler when item not found
- Store empty string instead of NULL for reaction user_id so UNIQUE
  constraint works correctly in SQLite
- Add workspace validation to handleDeleteComment (cross-workspace
  deletion was possible)
- Fix SKILL.md duplicate numbering (4. appeared twice)
- Remove || true debug artifacts from reaction conditionals
- Replace SvelteMap with plain Map in non-reactive groupReactions
- Use != null checks for timeline API params to handle offset=0
- Log warning on comment creation failure during item update

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-03 07:25:56 -04:00
xarmian 4003772538 feat: show user names in activity feeds and real-time events (#17)
Activity entries now display the authenticated user's name instead of
generic "user"/"You" labels. The user_id (already present in the
activities table from migration 012) is persisted when logging activity
and joined against the users table when querying, so actor_name flows
through the API without extra lookups. Falls back to the actor field
value when no user is associated (e.g. pre-auth activities or deleted
users).

Backend:
- Activity model gains UserID and ActorName fields
- Store queries LEFT JOIN users to populate actor_name
- logActivityWithMeta now records currentUserID on every activity
- Dashboard API includes actor_name in recent_activity
- SSE Event struct carries ActorName for real-time toasts
- Item-level activity endpoint added (GET /items/{slug}/activity)

Frontend:
- Activity page shows user name badge (green) instead of "web"
- Dashboard recent activity shows user name inline
- ActivityFeed component displays user name instead of "You"
- SSE toast notifications show user name instead of "CLI"
2026-03-28 19:11:42 -04:00
xarmian 52c8348361 fix: enrich activity log entries with item titles and collection info (#1)
The activity list endpoint returned raw entries without item context,
causing the activity page to show bare "Created"/"Updated" verbs.
The dashboard already enriched entries via GetItem() lookups — now the
activity handler does the same, and the frontend reads top-level fields
with metadata fallback.
2026-03-28 10:51:36 -04:00
xarmian 86722d7607 feat: Add webhooks, saved views, enhanced dashboard, and dependencies backend
Webhooks:
- Full subsystem with HMAC-SHA256 signing, event filtering, auto-disable
  after 10 failures, test delivery endpoint
- Migration 010_webhooks.sql, model, store CRUD, dispatcher with tests
- Wired into item create/update/delete/move and comment create handlers

Dashboard API:
- active_items: Returns actual in-progress items with refs and priorities
- active_item_count on collections (excludes terminal statuses)
- Blocked item detection in attention (via dependency links)
- isDoneStatus expanded to include cancelled/rejected/fixed/implemented

Saved Views:
- Store CRUD, API handlers, routes for per-collection saved views
- View config stores filters, sort, view_type

Activity:
- Source filtering support (web/cli/agent) in activity list endpoint
2026-03-28 13:55:06 +00:00
xarmian 6daa8eb68b Add move item between collections with field migration
Full-stack feature: move items between collections (e.g., idea → task)
with automatic field migration.

Backend:
- Field migration engine (items/migrate.go) maps matching fields,
  handles type conversions, drops incompatible fields, applies defaults
- Store method updates collection_id and assigns new item_number
- POST /api/v1/workspaces/{ws}/items/{slug}/move endpoint
- Activity logging with "moved" action and from/to metadata
- 6 migration unit tests covering type matching, conversion, and edge cases

CLI:
- pad move <slug> <target-collection> [--field key=value ...]
- Accepts singular collection names (task, idea, bug, etc.)

Web UI:
- "Move to..." dropdown on item detail page
- Shows all collections except current with icons
- Redirects to the item's new URL after move

Field migration rules:
- Same type: transfer directly (validate select options)
- Compatible types (text↔url, number→text, select→text): auto-convert
- Incompatible types: drop silently
- Missing required target fields: apply defaults or error
2026-03-28 05:01:40 +00:00
xarmian 81579847c6 Initial release
Pad — project management for developers and AI agents.
Single Go binary with embedded SvelteKit web UI, SQLite storage,
CLI, and Claude Code /pad skill integration.

https://getpad.dev
2026-03-26 01:52:36 +00:00