mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-25 11:52:08 +00:00
ca3428fa07061a8eb966f25b5432d2df19dfd77f
22 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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
|
||
|
|
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.
|
||
|
|
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+" |
||
|
|
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. |
||
|
|
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) |
||
|
|
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).
|
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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).
|
||
|
|
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).
|
||
|
|
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] |
||
|
|
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> |
||
|
|
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> |
||
|
|
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"
|
||
|
|
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. |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |