Commit Graph

40 Commits

Author SHA1 Message Date
xarmian d895418ea2 fix(server): gate RequireAuth's cloud-secret bypass on validated session (BUG-1944) (#1112)
Sibling of TASK-1932's CSRFProtect fix: RequireAuth's isCloudAdminPath +
hasCloudSecretMarker bypass fired on marker presence, not validated secret.
Mirror TASK-1932's currentUser(r) == nil gate exactly. Concretely closes a
disabled-admin gap: without the gate, a marker with the wrong secret let
RequireAuth's own user.IsDisabled() check be skipped whenever a session was
present, reaching handlers that trust a resolved admin session as an
alternative to validateCloudSecret. In-handler validation for every
cloudAdminPaths handler is unchanged and remains the independent layer for
the genuine no-session sidecar case.
2026-08-15 19:18:55 -04:00
xarmian bcef802335 fix(security): gate collab WebSocket writes on editor role (TASK-265) (#938)
* fix(security): gate collab WebSocket writes on editor role (TASK-265)

The collab WebSocket (GET /api/v1/collab/{itemID}) is mounted outside
the /{slug} subrouter, so RequireWorkspaceAccess never runs on it.
authorizeCollabAccess gated admission on membership + item visibility
but NOT edit role, so a plain workspace VIEWER was admitted and could
WRITE: every inbound Yjs sync frame persisted to item_yjs_updates
(room.go) and got canonicalized into items.content when a co-present
editor's authorized flush ran. The REST write path blocks viewers via
requireEditPermission; this closes the equivalent gap on the collab
relay.

Fix — non-editors become READ-ONLY participants (not hard-rejected, so
live view + presence stay intact):

- authorizeCollabAccess now returns a collabAccess{canWrite} alongside
  the admission decision. canWrite is computed once via
  store.ResolveUserPermission (the same predicate requireEditPermission
  falls back to): owner/editor membership grants write; a viewer/guest
  gets write only through a collection/item edit grant.
- RoomManager.Join takes a canWrite flag stored per-connection as an
  atomic.Bool. room.go's readLoop drops a read-only conn's inbound sync
  frames (not persisted via AppendYjsUpdate, not rebroadcast); awareness
  (presence) frames still relay so the viewer's cursor stays visible,
  and outbound broadcasts from editors still reach the viewer.
- The handler's periodic revalidation pushes mid-session write-permission
  changes via a new RoomManager.SetConnWritable, so an editor demoted to
  viewer becomes read-only without a reconnect (complements the existing
  CloseConn-on-revocation path).

No SCHEMA_VERSION / DefaultSchemaVersion bump: this is an authorization
/ behavioral change, not a ProseMirror/Y.Doc node-spec change, so the
op-log must not be pruned.

Tests: TestCollabViewerIsReadOnly (viewer admitted 101, receives an
editor's broadcast, but its own sync frame is neither persisted nor
broadcast while the editor's is) and TestAuthorizeCollabAccessCanWrite
(viewer→canWrite=false, editor→canWrite=true). Verified the E2E test
fails with the gate removed.

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

* fix(security): close 4 collab read-only gaps from orchestrator review (TASK-265)

Independent Codex pass on the collab editor-role gate found four gaps:

[P1] Read-only conns were still eligible designated APPLIERS. A viewer
(or an editor demoted mid-session) could be elected to apply an
external content edit; its resulting sync frames were dropped by the
new gate, yet its applier_ack was accepted → ApplyExternalContent
reported success → the PATCH handler skipped its direct-write fallback
→ the external edit was silently lost. Fix: pickApplier now skips
non-writers, and handleControlMessage ignores applier_ack from a conn
whose canWrite is false (belt-and-suspenders so the fallback fires).

[P2] Demotion TOCTOU. readLoop read canWrite=true, then could block on
appendMu and persist AFTER SetConnWritable(false) returned. Fix: the
canWrite check now runs INSIDE the appendMu critical section, and
SetConnWritable stores the flag under the same appendMu — so a frame
racing a demotion is either fully persisted before the flip or dropped.

[P2] Revalidation could run before the conn was registered. The first
jittered tick could fire while Join was still setting up; SetConnWritable
would no-op against the unregistered conn and Join then installed the
stale canWrite=true until a later tick. Fix: Join takes an onRegistered
callback invoked right after addConn; the handler gates the reval loop
on it so the first SetConnWritable always finds the conn.

[P2] canWrite didn't mirror REST for editors/owners. It was computed
purely from ResolveUserPermission, which resolves item/collection
GRANTS before membership role — so an editor/owner holding an
incidental `view` grant was wrongly made read-only. Fix: mirror
requireEditPermission exactly — editor/owner MEMBER short-circuits to
canWrite=true BEFORE grant resolution; viewers/guests still fall back
to ResolveUserPermission so grants can override an insufficient role.

Tests: TestApplyExternalContentSkipsReadOnlyApplier (verified failing
without the pickApplier gate), TestHandleControlMessageIgnoresAckFromReadOnlyConn,
TestCollabDemotionMakesConnReadOnly (mid-session demotion → read-only
without reconnect), and two new TestAuthorizeCollabAccessCanWrite cases
(editor+incidental view grant → true; viewer+edit grant → true).

Gates: build, gofmt, vet, golangci-lint (0 issues), go test
./internal/server/ ./internal/collab/, and go test -race
./internal/collab/ all pass.

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

* fix(security): safe no-applier direct write for read-only-only collab rooms (TASK-265)

Codex round 2 found a P1 introduced by excluding viewers from applier
election: in a room whose only peers are read-only, an external content
update (PATCH) hits ErrNoApplierAvailable, then PruneAndApply refused to
prune because live conns existed (len(r.conns) > 0). After the retry
budget the PATCH handler fell through to an UNLOCKED, UN-PRUNED direct
write — items.content was updated but the stale op-log survived, so a
fresh editor replaying it (or a viewer promoted to editor flushing its
stale in-memory Y.Doc) would silently overwrite the external update.

Fix: PruneAndApply now blocks only on a live WRITER peer — a read-only
peer can never persist, so it doesn't force the unsafe fallback. After
the prune + write succeeds it evicts the read-only peers
(Room.closeReadOnlyConns: WriteControl close frame + Close, concurrency-
safe with writeLoop) so their now-stale Y.Doc can't linger; they
reconnect and lazy-seed from the fresh items.content (their old resume
cursor is below the pruned op-log's MIN → force_refresh). Mixed rooms
(an editor present) are unaffected — the editor is still elected applier
and PruneAndApply is never reached.

Tests: TestPruneAndApplyEvictsReadOnlyRoom (read-only-only room prunes +
evicts, applyFn runs) and TestPruneAndApplyBlockedByLiveWriter (a live
writer still yields ErrRoomActiveDuringPrune).

Gates: build, gofmt, vet, golangci-lint (0 issues), go test
./internal/server/ ./internal/collab/, and go test -race
./internal/collab/ all pass.

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

* fix(security): fence PruneAndApply read-only eviction under appendMu (TASK-265)

Codex round 3 P1: PruneAndApply classified writers, ran applyFn (prune +
write), and evicted read-only conns WITHOUT holding room.appendMu. A
concurrent viewer→editor revalidation could set canWrite=true after the
writer check, append a stale frame during the prune/write, and — now a
writer — evade closeReadOnlyConns, racing the prune and leaving a live
stale Y.Doc that overwrites the external update.

Fix: PruneAndApply now holds room.appendMu across the ENTIRE sequence
(writer classification + applyFn + eviction). appendMu is the same lock
readLoop takes across its canWrite-check+persist and SetConnWritable
takes when flipping canWrite, so a promotion can no longer interleave
with the classification/prune. Lock order is itemLock → appendMu →
room.mu; no path takes room.mu → appendMu, so no inversion.

Also closes the residual "frame already read, blocked on appendMu, then
promoted after release" window: roomConn gains a terminal `evicted`
atomic flag set by closeReadOnlyConns (under appendMu) and checked in
readLoop's persist gate alongside canWrite, so an evicted read-only
conn's in-flight frame is dropped even if a racing revalidation promotes
it in the same instant.

Gates: build, gofmt, vet, golangci-lint (0 issues), go test
./internal/server/ ./internal/collab/, and go test -race
./internal/collab/ all pass.

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

* refactor(collab): descope read-only eviction; keep writer-aware prune guard (TASK-265)

Per orchestrator scope decision, remove the read-only EVICTION machinery
added during review (over-engineering for TASK-265's security goal):
- Room.closeReadOnlyConns and its call in PruneAndApply.
- roomConn.evicted flag and its check in readLoop's persist gate.
- appendMu held across PruneAndApply + the force-close socket I/O.

PruneAndApply reverts to no appendMu / no socket I/O, keeping only the
LOAD-BEARING writer-aware guard: it blocks (ErrRoomActiveDuringPrune)
only on a live WRITER peer, not any conn. An all-viewer room's external
edit therefore still prunes + direct-writes safely (op-log pruned, so a
fresh editor lazy-seeds from the new items.content) instead of erroring.

The residual — a connected read-only peer keeps a possibly-stale Y.Doc
until reconnect/refresh, and a viewer promoted to editor before re-sync
could push stale content — is a low-severity lost-update edge (a
promoted viewer is a legitimate editor), consistent with the pre-existing
direct-write contract. Documented on PruneAndApply and tracked in
BUG-2103 (proposed fix: proactive re-seed/refresh of remaining read-only
peers).

Kept unchanged: the authorizeCollabAccess canWrite editor/owner role
short-circuit, dropping read-only inbound sync frames under appendMu +
the SetConnWritable demotion fence + registration ordering, and the
pickApplier / applier_ack read-only exclusions.

Tests: replace TestPruneAndApplyEvictsReadOnlyRoom with
TestPruneAndApplyAllowsReadOnlyRoom (read-only-only room -> applyFn
runs); keep TestPruneAndApplyBlockedByLiveWriter.

Gates: build, gofmt, vet, golangci-lint (0 issues), go test
./internal/server/ ./internal/collab/, and go test -race
./internal/collab/ all pass.

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

* fix(security): enforce token write-scope + fence prune promotion on collab (TASK-265)

Two logic gaps from the orchestrator's final pass:

[P1] canWrite ignored BEARER-TOKEN SCOPE. The collab upgrade is a GET,
so a read-scoped PAT/OAuth token passes TokenAuth's method-keyed
tokenScopeAllows check, then rode the user's editor role (or the legacy
workspace-token grant) to canWrite=true and could persist Yjs mutations
over the socket — a read-only-principal-writes bypass via token scope
instead of role. REST DOES enforce write-scope (TokenAuth →
tokenScopeAllows blocks read-scoped tokens from PATCH/POST/DELETE); the
collab GET simply slips the method gate. Fix mirrors REST: TokenAuth now
stashes the token scopes (WithTokenScopes, as MCPBearerAuth already
does), and authorizeCollabAccess re-applies the write-capability half —
canWrite is downgraded to read-only when the caller's token scope
doesn't permit writes (http.MethodPost representative verb). Applied to
both the legacy workspace-token path and the member/grant path.
Non-token principals (cookie / CLI session, fresh install) carry empty
scopes → unrestricted → unaffected. Test: an editor with a read /
pad:read token gets canWrite=false; with write / * gets canWrite=true.

[P2] PruneAndApply's writer-scan was not serialized with SetConnWritable,
so a viewer promoted during applyFn could append a stale frame while the
op-log is pruned + content written (persist/prune ordering race, distinct
from BUG-2103's async residual). Fix: hold room.appendMu across the
writer-scan AND applyFn. Safe now that eviction/socket-I/O is gone —
applyFn is a pure store op (PruneYjsUpdatesBefore + UpdateItemWithParentLink,
the only caller) that never re-enters itemLock / appendMu / room.mu, so no
inversion or re-entrant deadlock. Lock order: itemLock → appendMu → room.mu.

Gates: build, gofmt, vet, golangci-lint (0 issues), go test
./internal/server/ ./internal/collab/, and go test -race
./internal/collab/ all pass.

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

* fix(security): honor token write-scope in collab fresh-install branch (TASK-265)

The zero-user (pre-bootstrap) branch of authorizeCollabAccess returned
canWrite=true unconditionally. A legacy workspace token still carries a
scope on a fresh instance, so a read-scoped token could persist Yjs
mutations over the collab GET upgrade — inconsistent with REST, whose
method gate blocks a read token's mutation. Route the branch through
collabTokenWriteScopeAllowed, which returns true for the anonymous
(no-token) setup caller (empty scopes = unrestricted) and false for a
read-scoped token. Adds TestAuthorizeCollabAccessFreshInstallTokenScope.

Found by the orchestrator's independent Codex pass.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-15 01:30:12 -04:00
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 d36f27c29f fix(server): auth-perimeter hardening — B6–B9 from the IDEA-1927 audit (TASK-1932) (#811)
* fix(server): stop autoCreateWorkspace from swallowing member-add errors (B6, TASK-1932)

A failed AddWorkspaceMember after workspace creation used to be silently
discarded, leaving a workspace that's completely unreachable (owner_id
alone grants no access) and invisible in the console forever. Retry once,
then clean up the orphaned workspace and log loudly on continued failure
so on-call can act on it.

* fix(server): fail fast when cloud mode runs without secure cookies (B7, TASK-1932)

SetCloudMode never forced secureCookies on, so PAD_CLOUD=true without
PAD_SECURE_COOKIES was an unenforced ops contract: OAuth's __Host-prefixed
session cookie is silently invisible to pad's own cookie reader without
Secure set, producing a "logged in but appears logged out" failure mode.
Add Config.ValidateCloudSecureCookies and check it at server startup,
next to the existing PAD_CLOUD_SECRET requirement, so the misconfiguration
is a startup error instead of a runtime mystery.

* fix(server): align OAuth session TTL with web session TTL (B9, TASK-1932)

handleOAuthLogin minted a 30-day session while every other web login used
the 7-day webSessionTTL. createAuthSession derives the store session row,
session cookie MaxAge, and CSRF cookie MaxAge all from one ttl argument,
so the longer OAuth cookie outlived its own server-side session — the
browser kept presenting a cookie whose session had already expired,
producing silent 401s. Use webSessionTTL for OAuth logins too.

* fix(server): narrow the /api/v1/auth/* CSRF exemption to anonymous endpoints (B8, TASK-1932)

The CSRF middleware exempted the entire /api/v1/auth/ prefix, which also
covered mutating cookie-authenticated endpoints: PATCH /me, oauth-unlink,
2FA setup/verify/disable, delete-account, token create/delete/rotate, CLI-
session approve, and logout. Replace the prefix bypass with an exact-path
allowlist of the endpoints that are genuinely pre-session (login, register,
bootstrap, password reset, verify-email, resend-verification, 2FA login
challenge, CLI session create) or authenticate purely via a cloud secret
rather than a cookie (oauth-login, oauth-link — never touch the session,
so CSRF isn't a meaningful threat model for them and the sidecar has no
CSRF cookie to send). Everything else now requires the double-submit token
like any other authenticated mutation; the web client already sends it on
every non-GET/HEAD request, so no frontend change is needed.

* docs(server): pin the deliberate CSRF-cookie legacy-fallback asymmetry (TASK-1932)

Codex review (round 1) flagged that SessionAuth falls back from the
__Host-pad_session cookie to the legacy unprefixed name, but the CSRF
cookie lookup has no equivalent fallback — meaning a browser holding
pre-secure-cookies-flip legacy cookies stays authenticated but gets 403'd
on B8's newly CSRF-required endpoints until it re-logs-in.

This asymmetry is deliberate, not a bug: the session cookie's value is an
unguessable secret regardless of which name carries it, but the CSRF
cookie's security property depends on the attacker being unable to set
the cookie itself — an unprefixed name is settable from a sibling
subdomain, which is exactly the hole __Host- exists to close. Restoring
"symmetry" here would silently reopen it. Document the reasoning at the
cookie lookup so a future maintainer doesn't "fix" it, and add a pinning
test that exercises the exact scenario (secureCookies=true, legacy
session + CSRF cookies, CSRF-required endpoint) end to end.

* fix(server): require CSRF for session-authenticated requests to exempt auth paths (TASK-1932)

Codex round 2 found a P1: handleRegister has an admin-session branch (an
already-logged-in admin can create a verified account with no invitation
code), but /api/v1/auth/register was unconditionally CSRF-exempt by path.
A cross-site POST could ride the admin's cookie into that branch with no
CSRF token — the same class of hole as the oauth-unlink case B8 already
closed, just missed because register's other paths are genuinely
anonymous.

Fix generically rather than register-specifically: gate the
authCSRFExemptPaths exemption on currentUser(r) == nil. SessionAuth runs
before CSRFProtect, so a request that resolved to a real session falls
through to the normal double-submit check instead of the early exemption,
while a genuinely anonymous request keeps it. This also covers any future
session-authenticated branch a handler on this list grows, with no
handler changes. Bearer/PAT and cloud-secret (oauth-login/oauth-link)
callers are unaffected — they have their own unconditional exemptions
later in the same function.

* fix(server): require validated Bearer/cloud-secret auth for CSRF exemption (TASK-1932)

Codex round 3 found that CSRFProtect's Bearer and X-Cloud-Secret exemptions
fired on header/marker PRESENCE, not validation. TokenAuth deliberately
falls through (rejectInvalidBearer) instead of 401ing invalid Bearers on
/api/v1/auth/* paths to support CLI-token recovery, so a cross-site request
carrying a victim's real session cookie plus a garbage Bearer header could
ride the cookie past CSRF on any newly-CSRF-required endpoint. The same
presence-only pattern in the X-Cloud-Secret exemption is concretely
exploitable too: handleSetPlan (and similarly-shaped handlers) accept an
admin cookie session as an alternative to the secret, so a garbage
X-Cloud-Secret plus a stolen admin cookie could set an arbitrary user's
plan with no CSRF token at all.

Add ctxValidatedSessionBearer (set by TokenAuth only on successful
ValidateSession for CLI session-bearer tokens) alongside the existing
ctxIsAPIToken, and a combined isValidatedBearerAuth() helper. CSRFProtect
now exempts unconditionally only on validated Bearer auth; an unvalidated
Bearer header or cloud-secret marker is exempt only when no session was
also resolved for the request (currentUser(r) == nil), preserving the
CLI-recovery contract (stale token, no cookie -> 401 from auth, not
csrf_error) while closing the cookie-riding case.

* fix(server): split CSRF auth-exempt allowlist by session sensitivity (TASK-1932)

Codex round 2 gated the entire authCSRFExemptPaths allowlist on
currentUser(r) == nil to close handleRegister's admin-session branch, but
that gate applied to every anonymous endpoint on the list, not just
register. CI's E2E suite caught the regression: the harness bootstraps an
admin (minting a session cookie) then POSTs /login to re-authenticate,
and the ambient cookie stripped /login of its exemption, producing a
spurious 403 csrf_error.

login/bootstrap/forgot-password/reset-password/local-reset/verify-email/
resend-verification/2fa-login-verify/oauth-login/oauth-link/cli-sessions-
create derive their authority entirely from the request body (credentials,
a token, a shared secret), never from the ambient cookie, and pad mints
the CSRF cookie AT LOGIN — a pre-session endpoint categorically cannot
require a token that doesn't exist yet. Split the allowlist:
authCSRFUnconditionalExemptPaths (everything above, exempt regardless of
cookie) and authCSRFSessionGatedExemptPaths (register only, exempt only
when currentUser(r) == nil, since it alone has a session-privileged
admin-account-creation branch). The round-2 security property (admin
session + register + no CSRF -> still blocked) and round-3's Bearer/
cloud-secret validated-vs-present composite are unaffected.
2026-07-04 11:33:18 -04:00
xarmian 111e43d27f fix(server): invitation-preview endpoint + read-only email prefill on /join (BUG-1934) (#803)
On Pad Cloud the /join/[code] page never showed the invited email, so a
mistyped address hit a confusing 403 invitation_email_mismatch. Add a
non-consuming, public, always-200, rate-limited preview endpoint and wire
the join page to prefill the invited email read-only.

- GET /api/v1/invitations/{code}/preview returns {found,email,workspace_name,
  has_account}. Reuses store.GetInvitationByCode (never accepts/consumes the
  invite). Invalid/expired/missing codes and dangling-workspace codes all
  return 200 {found:false} — no 404 status signal (enumeration safety). A
  genuine DB fault still 500s (code-independent, leaks nothing).
- Public/pre-auth: added to isPublicAPIPath (matches only the trailing
  /preview segment, so /accept stays auth-gated).
- Dedicated per-IP rate limiter (20/min, burst 20) wired into the RateLimit
  switch so the endpoint can't be used to enumerate invite codes.
- TS client: api.members.previewInvitation + InvitationPreview type.
- /join page calls preview on mount, prefills + locks the invited email, and
  defaults register-vs-login by has_account. Keeps the mode-switch affordance
  and BUG-1930's register default when preview is unavailable.
- Tests: non-consumption, has_account, always-200 on unknown code, rate limit.

Composes with BUG-1930 (register default). Wave 0 of PLAN-1933 / IDEA-1927 §B5.

Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
2026-07-03 23:47:06 -04:00
xarmian 33e49434ed fix(server): non-fatal UA session binding + sliding session renewal (#727)
Two root causes behind users being logged out:

- UA session binding was unconditional and fatal — any User-Agent change
  (browser/WebView update, DevTools device emulation, mobile rebuild)
  silently de-authenticated the session. Now log-only across all three
  enforcement sites (TokenAuth, SessionAuth, and the validateSessionCookie
  helper used by CLI-auth/account/session-check routes), mirroring the
  default IP-change handling. (BUG-1815)

- Sessions had a fixed absolute TTL with no refresh on activity, so even an
  active user hit the cliff at 7d (web) / 30d (CLI). Adds sliding renewal:
  RenewSessionIfStale extends expires_at when past the half-window threshold,
  capped at created_at + 90d (SessionMaxLifetime), CAS-guarded and only
  reported when RowsAffected confirms the write. The middleware re-issues the
  session + CSRF cookies on renewal. New renew_ttl_seconds column (sqlite +
  pg migrations); legacy rows (0) keep their fixed expiry. (TASK-1816)

Reviewed by Codex (clean). Tests: store + server suites pass.
2026-06-14 21:15:35 -04:00
xarmian f48c99e421 fix(auth): scope admin platform role to cookie session auth (BUG-1616) (#632)
The admin platform role granted owner-level access to every workspace on
every surface, including bearer-borne callers (PATs on /api/v1, CLI
session bearers, PATs/OAuth on /mcp). A user with "All current
workspaces" consent on an MCP client — or a leaked admin token of any
kind — could reach data the admin never joined.

Policy: the admin global bypass now fires only for cookie session auth
(web UI / SPA / /console/admin). Bearer-borne callers fall back to a
strict workspace_members check (membership-only; no guest-grants
fallback either).

Gated four sites in lockstep:

- RequireWorkspaceAccess (internal/server/middleware_auth.go) — covers
  /api/v1/* routes; emits the existing not_a_member MCP authz denial
  metric on bearer-admin denials.
- handleSSE entry (internal/server/handlers_events.go) — adds an
  explicit GetWorkspaceMember check for bearer-borne admin after
  resolveWorkspace's global slug lookup.
- sseSubscriberStillHasAccess (internal/server/handlers_events.go) —
  per-tick revalidation now matches entry-time policy.
- computeSSEVisibility (internal/server/handlers_events.go) —
  bearer-admin gets a real VisibleCollectionIDs filter instead of
  "no filtering"; a bearer-admin who's a member with
  collection_access=specific is now correctly scoped.
- authorizeCollabAccess (internal/server/handlers_collab.go) —
  WebSocket collab upgrade gate; same membership-only stance.

New shared helper isBearerAuth(r) folds two signals (Authorization:
Bearer header OR ctxIsAPIToken stash) so MCP-dispatcher synthesized
requests and CLI session bearers are both covered. Mirrors the dual
check middleware_csrf.go already uses.

Tests (9 total):

- middleware_auth_admin_token_gate_test.go (5) — PAT denied/allowed
  permutations, CLI session-bearer denial, cookie-session bypass
  preserved.
- handlers_admin_bearer_gate_test.go (4) — SSE revalidation,
  visibility filter, collab WebSocket auth.

Companion BUG-1617 (store-layer admin bypass in backlinks visibility)
tracked separately.
2026-05-27 10:09:30 -04:00
xarmian ce0be1ed0a fix(auth): TokenAuth falls through invalid Bearer on public API paths (BUG-1227) (#434)
Prior behavior: TokenAuth middleware rejected any invalid/malformed
Bearer with 401 before dispatch — even on paths in isPublicAPIPath
(/api/v1/auth/*, /health, share links, public plan-limits). A stale
credential in ~/.pad/credentials.json (typically left over after wiping
a test DB) made every CLI invocation 401 on the very first
CheckSession() call, INCLUDING the endpoints needed to recover (login,
forgot-password). Users could only fix it by manually deleting their
credentials file.

The matching IP-change-revoked branch in the same file already had the
right pattern (middleware_auth.go:114-117): when the path is public,
fall through to the handler unauthenticated and let it decide. This
patch mirrors that across the four invalid-Bearer branches:

- Authorization header doesn't start with "Bearer "
- padsess_* token doesn't validate (stale or wiped session)
- pad_* token format wrong (length, prefix)
- pad_* token doesn't match a live API token

Extracted into a small rejectInvalidBearer helper so the policy is
visible in one place. Protected endpoints continue to 401 — the
regression guard in TestTokenAuth_ProtectedPath_StillRejectsInvalidBearer
pins that.

Pre-existing bug; not introduced by TASK-1216 / TASK-1217. The new
bootstrap flows just made it more visible because anyone testing fresh-
install scenarios is likely to wipe DBs and end up with stale creds.

Tests in middleware_auth_public_paths_test.go cover:
- /auth/session with stale padsess_* Bearer → 200 with public payload
- /auth/session with malformed Authorization → 200
- /auth/session with garbage token format → 200
- /auth/session with non-matching pad_* token → 200
- /auth/login with stale Bearer + valid creds → 200 (the actual user-
  visible recovery scenario)
- Protected /workspaces with invalid Bearer → still 401 (regression)

Closes: BUG-1227.
Related: IDEA-1226 (per-server credentials — proper design fix; this is
the safety-net fix that complements it).
2026-05-07 20:01:34 -04:00
xarmian 1c409c8592 feat(metrics): emit mcp_authz_denials_total{reason=tier_mismatch} (TASK-1119) (#399)
Wire the dispatcher-side scope-deny seam into the
pad_mcp_authz_denials_total counter, completing the denial-reason
vocabulary documented in TASK-961.

internal/mcp/dispatch_http.go:
- Add optional OnScopeDenied(method, urlPath) callback on
  HTTPHandlerDispatcher
- Fire it from buildAuthedRequest right before returning the existing
  permission_denied error — same control flow, just observability
  added in front

internal/server/middleware_auth.go:
- Public Server.RecordMCPTierMismatch helper that bumps the counter.
  No MCP-origin context gate (unlike recordMCPAuthzDenial below) —
  the dispatcher is by construction MCP-only, so every invocation is
  inherently MCP-origin.

cmd/pad/main.go:
- Wire dispatcher.OnScopeDenied = srv.RecordMCPTierMismatch alongside
  the existing UserResolver / Lister fields. Safe to attach
  unconditionally — RecordMCPTierMismatch nil-checks metrics
  internally, mirroring the OAuth observer wiring pattern.

Tests:
- Three new dispatcher tests covering OnScopeDenied: fires once with
  the right (method, urlPath) on deny; does NOT fire on allow; nil
  hook is safe.
- Server-side test for RecordMCPTierMismatch: counter increments,
  other denial reasons untouched, nil-metrics safe.

Parent: PLAN-943. Follow-up to TASK-961 (PR #398).
2026-05-03 16:55:30 -04:00
xarmian 98c8b78d06 feat(metrics): MCP + OAuth observability metrics for /mcp (TASK-961) (#398)
Plug MCP traffic and OAuth flow events into pad's existing
internal/metrics Prometheus surface, plus a Grafana dashboard.

Metrics (all under pad_*):
- Counters: mcp_tool_calls_total{user_id,tool,status},
  mcp_authz_denials_total{reason}, oauth_flows_total{stage},
  oauth_token_revocations_total{reason}
- Histograms: mcp_tool_call_duration_seconds{tool},
  oauth_flow_duration_seconds{stage}, oauth_token_ttl_seconds
- Gauges: mcp_active_sessions, oauth_active_tokens (callback collector)

Wiring seams: MCPAuditLog (per-call), MCPBearerAuth (audience denials),
emitMCPAuditDenied (rate-limit denials), RequireWorkspaceAccess (gated
to MCP-origin via context — workspace_not_in_allowlist + not_a_member),
OAuth handlers (per-stage flow events + per-handler latency), and
internal/oauth/storage.go via a new SetRevocationObserver hook so the
OAuth package stays metrics-naive.

Cmd/pad wires both observers via Server.wireOAuthMetricsObserver(),
called from both SetMetrics and SetOAuthServer for order-independence.

Store helpers added (with full test coverage):
- CountActiveOAuthAccessTokens — backs the active-tokens gauge
- OldestAccessTokenIssuedAtByRequestID — backs the TTL observation

Grafana dashboard at monitoring/grafana/mcp.json: 13 panels across MCP
traffic + OAuth flow rows (rate-by-tool, p50/p95/p99 latency, status
breakdown, denial reasons, active sessions, top-10 users, OAuth flow
events by stage, OAuth handler p95, active tokens, revocations by
reason, TTL p50/p95).

Codex review caught one HIGH issue (round 1, fixed in same commit):
the active-tokens collector originally emitted NewInvalidMetric on
provider error, which propagates through Registry.Gather() and fails
the entire /metrics scrape via promhttp's default error handler.
Switched to log + skip-the-sample so a transient SQLite blip drops
ONE gauge for one scrape rather than the whole observability surface.
Added TestRegisterOAuthActiveTokensCollector_ErrorIsScrapeSafe to pin
the contract.

Tests cover increments, histogram bucket placement, callback collector
freshness across mutations + error path, observer hook firing on user-
initiated revocation + rotation + nil-safety, and per-helper unit tests
for the server-side metric emission.

Verified with `make check` (golangci-lint + go test ./... + web build).
2026-05-03 16:37:49 -04:00
xarmian d8b1d98e08 feat(mcp): persistent audit log for /mcp tool calls (TASK-960) (#389)
* feat(mcp): persistent audit log for /mcp tool calls (TASK-960)

Adds a 90-day-retention audit log of every MCP request. Drives the
"last used" + "30-day calls" columns the connected-apps page (TASK-954)
will read, and gives ops + on-call a forensics surface via a new
admin /console/admin/mcp-audit page.

Schema deviation from the spec, documented in migration 049:
the original task body called for `token_id REFERENCES oauth_tokens(id)`
but pad has no `oauth_tokens` table — instead an OAuth grant chain is
identified by `request_id` (preserved across refresh-token rotations,
see migration 048), and PAT-authenticated MCP requests have no OAuth
identity at all. The audit row therefore carries `(token_kind,
token_ref)` — `oauth` + request_id for OAuth, or `pat` + api_tokens.id
for PATs. The connected-apps page in TASK-954 will filter on
token_kind='oauth' to surface third-party connections only.

Pieces:
- internal/store/migrations/049_mcp_audit.sql + pgmigrations/028 — table.
- internal/models/mcp_audit.go — typed entry + 30-day stats DTO.
- internal/store/mcp_audit.go — insert / list-by-user / list-by-connection
  / list-all / per-connection-stats aggregator / 90-day retention sweeper.
- internal/server/middleware_mcp_audit.go — async writer + sweeper +
  middleware that wraps /mcp behind MCPBearerAuth. Hot path is
  non-blocking enqueue with drop-on-overflow + atomic drop counter.
- internal/server/middleware_mcp_auth.go — both PAT + OAuth branches now
  stash WithMCPTokenIdentity so the audit row attributes correctly.
- internal/server/handlers_mcp_audit.go — read endpoints:
  GET /api/v1/connected-apps/{id}/audit (owner-scoped) +
  GET /api/v1/admin/mcp-audit (admin-only).
- web/src/routes/console/admin/mcp-audit/+page.svelte + tab in admin layout.
- Tests cover required-field validation, round-trip, pagination,
  owner-only filtering, last-used + 30-day aggregates, retention sweep,
  body-sniff parser, canonical-JSON arg hashing, buffer-full drop path,
  status-to-result classification, admin gate, DTO field shape.

`make check` clean (lint + go test ./... + svelte-kit build).

Parent: PLAN-943.

* fix(mcp-audit): emit denied row on rate-limit reject per Codex review (round 1)

PR #389 round 1 caught: MCPAuditLog is mounted INSIDE MCPBearerAuth, so
when bearer auth's per-token rate-limit fires (429) it returns before
next.ServeHTTP — and the wrapping audit middleware never sees the
response. classifyMCPResult mapped 401/403/429 with no path that could
actually reach it.

Fix: emitMCPAuditDenied helper called directly from the rate-limit
deny branches of both PAT + OAuth paths. Resolved user + token
identity are already in scope at that point, so the audit row gets
attributed correctly. Pre-auth rejections (no/invalid bearer) stay
un-audited because there's no user to attribute them to and the
audit_trail table covers those auth-event signals already.

Threading: handleMCPPATAuth + handleMCPOAuthAuth now take the entry
timestamp so the denied row carries real latency.

Test: TestMCPAudit_RateLimited_RecordsDeniedRow drives a real PAT
through the rate limiter, drains to 429, and asserts the audit row
lands with status="denied" + error_kind="rate_limited" + the right
tool_name from the request body.
2026-05-02 22:56:10 -04:00
xarmian d01bbf6bf1 feat(oauth): live workspace allow-list + role enforcement (TASK-953) (#377)
Closes the third leg of PLAN-943's OAuth permission model:

  (token capability tier) × (live workspace role) × (consent allow-list)

The first two were already in place — TASK-1027 wired the tier
scope check (pad:read / pad:write / pad:admin via tokenScopeAllows)
and RequireWorkspaceAccess does the live role lookup. This PR adds
the third gate: the workspace-allow-list set at consent time
(TASK-952) actually denies workspaces NOT in the user's selection.

## What's new

- `oauth.Session.AllowedWorkspaces()` / `SetAllowedWorkspaces()` —
  typed accessors on session.Extra. Handle BOTH the in-memory
  []string shape (consent-decide path) AND the JSON-decoded
  []interface{} shape (post-storage round-trip path).
- `WithTokenAllowedWorkspaces` / `TokenAllowedWorkspacesFromContext` —
  context helpers in internal/server with defensive copies so
  callers can't corrupt the per-request token state.
- `MCPBearerAuth` (OAuth path) reads the token's allow-list from
  session.Extra and stashes it in context.
- `RequireWorkspaceAccess` checks the allow-list against the
  resolved workspace's slug. Three behaviours match
  TokenAllowedWorkspacesFromContext's return shapes:
    - nil → no token-level gate (PAT auth, pre-TASK-952 OAuth
      tokens). Standard membership applies.
    - ["*"] → wildcard. Every membership the user has passes.
    - [slug-a, slug-b, ...] → only listed slugs. Anything else
      gets 403 permission_denied BEFORE the membership check.

## Live role + revocation

Membership revocation takes effect immediately. RequireWorkspaceAccess
calls GetWorkspaceMember on every request — if the user lost
membership in workspace X, the token's allow-list including X no
longer helps; the request is rejected at the standard membership
gate. Tested explicitly via TestWorkspaceAllowList_LiveMembershipRevocation.

## Tier × role

The natural intersection of tokenScopeAllows (tier-based HTTP-method
gate) and per-handler role checks (e.g. requireEditPermission) handles
the tier × role table from the PLAN-943 spec:

  - pad:write tier passes tokenScopeAllows for POST.
  - But Viewer role fails requireEditPermission's role check.
  - Net: 403 — tested explicitly via
    TestWorkspaceAllowList_TierTimesRole_WriteByViewer.

## Tests

Unit (no I/O):
- TestTokenAllowedWorkspaceMatches — policy table for the helper.
- TestWithTokenAllowedWorkspaces_DefensiveCopy + 1 reader counterpart.
- TestSession_AllowedWorkspaces_*: setter/getter, nil-clear, defensive
  copy, JSON round-trip ([]string + []interface{} branches),
  wildcard JSON round-trip, not-set, nil-session.

Integration (full chain, real OAuth flow):
- TestWorkspaceAllowList_AllowsListedSlug — listed workspace passes.
- TestWorkspaceAllowList_DeniesUnlistedSlug — unlisted gets 403
  even though user is owner.
- TestWorkspaceAllowList_WildcardAllowsAnyMembership — wildcard
  passes for every membership.
- TestWorkspaceAllowList_LiveMembershipRevocation — token works,
  then membership revoked, then same token denied.
- TestWorkspaceAllowList_PATPathUnaffected — PAT regression: PATs
  don't carry an allow-list, must NOT hit the gate.
- TestWorkspaceAllowList_TierTimesRole_WriteByViewer — pad:write
  tier × Viewer role on POST item → 403.
2026-05-02 14:29:17 -04:00
xarmian 924d82dae4 feat(oauth): MCPBearerAuth OAuth integration + public-info (TASK-1027) — closes TASK-951 (#375)
* feat(oauth): MCPBearerAuth OAuth integration + public-info endpoint (TASK-1027, sub-PR E of TASK-951)

Closes the OAuth server build-out by connecting sub-PRs A-D to the MCP
transport from TASK-950 and shipping the consent-screen support endpoint.

## MCPBearerAuth OAuth path

middleware_mcp_auth.go now branches on token shape:

  - pad_<60-hex>  → existing PAT validation (TASK-950 path)
  - anything else → fosite.IntrospectToken via the new
    internal/oauth.Server.IntrospectToken wrapper (server-side, no
    HTTP roundtrip — pad-cloud is both auth server and resource
    server, so the public /oauth/introspect endpoint is for external
    clients only).

OAuth path validation gates:

  - Token must be active (fosite returns ErrInactiveToken / ErrNotFound
    on revoked / unknown / expired tokens).
  - tokenUse must be access_token; refresh tokens explicitly rejected
    (RFC 6749 §1.5 — refresh tokens aren't bearers for resource calls).
  - Granted audience MUST contain the canonical MCP URL (RFC 8707
    anti-replay; resource-server-side check defends against compromised
    or shared auth servers).
  - Subject must resolve to a real user row.

Successful path stashes user + scopes via WithCurrentUser /
WithTokenScopes. Scopes are translated from fosite's space-separated
form to JSON-array form via oauthScopesToJSON.

## tokenScopeAllows pad:* extension

Extended to recognize the OAuth scope vocabulary alongside PAT scopes:
  - pad:read  ↔ read   (GET/HEAD/OPTIONS only)
  - pad:write ↔ write  (all methods)
  - pad:admin ↔ *      (all methods)

So MCP tool authorization stays uniform regardless of which transport
issued the bearer.

## /api/v1/oauth/clients/{id}/public-info

New read-only endpoint for the consent screen (TASK-952) and the
OAuth-intent banner (TASK-1001, already shipped). Returns four
non-sensitive fields: client_id, client_name, logo_uri, redirect_uris.

  - Auth-required (any logged-in user).
  - Cloud-mode-gated (404s outside cloud).
  - 404 for unknown clients.
  - Whitelisted leak surface — explicit fields, no embedded
    models.OAuthClient, so a future field addition (e.g. a confidential-
    client secret) doesn't accidentally appear here.

## Tests

- TestMCP_OAuthAccessToken_Authenticates — happy path: full flow
  yields a token that authenticates against /mcp.
- TestMCP_OAuthAccessToken_AudienceMismatch_Rejected — RFC 8707
  resource-server check; mints a token, swaps the OAuth server
  for one with a different canonical, confirms 401.
- TestMCP_OAuthRefreshToken_RejectedAtMCP — refresh tokens MUST
  NOT authenticate.
- TestMCP_RevokedOAuthToken_Rejected — revocation takes effect at
  the resource server.
- TestMCP_PATPath_StillWorks — regression for sub-PR D's coexistence
  with the OAuth path.
- TestMCP_OAuthScopeReadOnly_StashesPadReadScope — scope round-trip.
- TestOAuthClientPublicInfo_HappyPath / UnknownClient_404 /
  Unauthenticated_401 / NotMountedOutsideCloudMode — full coverage
  of the new endpoint.
- TestE2E_ClaudeDesktopFlow — simulates the full sequence
  (discovery → DCR → authorize → token → /mcp call) Claude Desktop
  walks on first connect.
- TestTokenScopeAllows extended with pad:* coverage.

## TASK-951 status

Closes TASK-951 when this lands (5/5 sub-PRs done):
- A: schema + storage layer (#370 / 2a00775)
- B: fosite-backed authorization-server constructor (#371 / f6eeee4)
- C: DCR + authorize + token endpoints + populated discovery (#372 / 48776a3)
- D: revoke + introspect endpoints (#373 / 4250fb1)
- E: MCPBearerAuth + public-info (this PR)

* fix(oauth): fail-closed on empty OAuth scopes per Codex review (round 1)

Codex caught a high-severity bug in oauthScopesToJSON: the helper
mapped empty granted scopes to `[]`, which tokenScopeAllows interprets
as the legacy "unrestricted" PAT shape (allow all methods). Combined
with OAuth's RFC 6749 §3.3 rule that the `scope` parameter is
OPTIONAL, this meant a client could:

  1. Run the auth-code flow without requesting scopes.
  2. Get back a token with empty granted_scopes.
  3. Drive write MCP tools because MCPBearerAuth stashed `[]` and
     tokenScopeAllows fell through to the legacy unrestricted path.

Fix: map empty OAuth scopes to JSON `null` instead. tokenScopeAllows
denies on the "scopes == nil" branch (existing TASK-667 behavior),
so the entire write surface is denied for empty-scope OAuth tokens.

In production this path is hard to hit — sub-PR C's DCR handler
defaults registered clients to `pad:read pad:write` when omitted,
and audienceMatchingStrategy enforces canonical-audience matching at
grant time. Defense-in-depth at the resource server is the right
policy regardless.

Test: TestOAuthScopesToJSON_FailClosedOnEmpty asserts both halves of
the contract — the helper produces "null" for empty input, and
tokenScopeAllows denies every method when fed that value.
2026-05-02 13:33:53 -04:00
xarmian 521853e0a1 feat(mcp): mount /mcp Streamable HTTP transport + OAuth discovery (TASK-950) (#369)
* feat(mcp): mount /mcp Streamable HTTP transport + OAuth discovery (TASK-950)

First public cut of pad-cloud as a remote MCP server (PLAN-943). Mounts
the Streamable HTTP transport on /mcp, the RFC 9728 protected-resource
discovery doc on /.well-known/oauth-protected-resource, and a 501 stub
for RFC 8414 auth-server metadata that TASK-951 will fill in.

- internal/server/handlers_mcp.go — Server.SetMCPTransport + chi route
  registration under cloud-mode gate (self-host stays free of MCP
  overhead unless explicitly opted in).
- internal/server/middleware_mcp_auth.go — Bearer auth that produces
  the spec-shape 401 + WWW-Authenticate (resource_metadata pointer)
  MCP clients expect, distinct from /api/v1's JSON-only 401 envelope.
  Reuses the existing PAT (api_tokens) validation path; OAuth-issued
  tokens layer in via this same middleware in TASK-951.
- internal/server/handlers_well_known.go — RFC 9728 discovery doc +
  RFC 8414 stub. URLs come from PAD_MCP_PUBLIC_URL +
  PAD_AUTH_SERVER_URL with request-host fallback for local dev.
- internal/server/handlers_mcp_test.go — 7 tests covering cloud-off
  routes-absent, cloud-on-no-transport routes-absent, discovery doc
  shape, 501 stub, no-token 401+WWW-Authenticate, bad-format-token
  401+WWW-Authenticate, and the valid-PAT happy path with user
  attached to transport context.
- cmd/pad/main.go — wires mcpserver.NewServer + HTTPHandlerDispatcher
  + StreamableHTTPServer in cloud mode, after SetCloudMode.
- internal/config — adds PAD_MCP_PUBLIC_URL and PAD_AUTH_SERVER_URL.

Resources are intentionally skipped in this v1 — they require an
HTTPResourceFetcher equivalent of ExecResourceFetcher and that's a
follow-up task. Tools, prompts, instructions, and meta all flow
through identically to the stdio surface (verified via spike against
mcp-go v0.50.0's StreamableHTTPServer before writing the real PR).

* fix(mcp): enforce PAT scopes on /mcp + WWW-Authenticate fallback per Codex review (round 1)

Two findings from PR #369 round 1:

1. SECURITY: A PAT with scopes ["read"] could drive write MCP tools.
   MCPBearerAuth skipped tokenScopeAllows entirely; the dispatcher's
   synthesized in-process request bypassed TokenAuth's chain-level
   check (because WithCurrentUser was already set), so a read-scoped
   token could POST item create / PATCH update / DELETE silently.

   Fix: stash apiToken.Scopes via server.WithTokenScopes in
   MCPBearerAuth; re-check per synthesized request in
   HTTPHandlerDispatcher.executeRequest using the public
   server.TokenScopeAllows wrapper. Read-scoped tokens can still
   drive read-only tools (their HTTP method is GET) — only writes
   are rejected, with a structured permission_denied envelope.

2. DISCOVERY: writeMCPUnauthorized dropped the WWW-Authenticate header
   when PAD_MCP_PUBLIC_URL was unset. Cloud-mode deploys without that
   env var mounted /mcp but broke the discovery handshake — fresh
   MCP clients rely on the header to find /.well-known/oauth-protected-
   resource.

   Fix: pass *http.Request through to writeMCPUnauthorized, derive
   "https://" + r.Host as the fallback (matches handleOAuthProtected-
   Resource's existing fallback).

Tests:
- handlers_mcp_test.go: TestMCP_NoToken_FallsBackToHostWhenPublicURLUnset
  pins the WWW-Authenticate fallback. TestMCP_ReadScopedPAT_StashesScopes-
  InContext + TestTokenScopeAllows_PublicWrapper pin the scope-stash
  side.
- dispatch_http_test.go: TestHTTPHandlerDispatcher_ScopeEnforcement_*
  pin the dispatcher-side enforcement (read-on-write rejected,
  read-on-read allowed, no-scope-context allows-all).
- recordingHandler updated to handle nil r.Body so the read-only
  GET path can be exercised.

* fix(mcp): move scope check to buildAuthedRequest so bulk-update can't bypass it per Codex review (round 2)

Round 1 enforced scopes in executeRequest, but dispatch_http_project.go's
item bulk-update path constructs each per-item PATCH directly via
buildAuthedRequest + d.Handler.ServeHTTP, skipping executeRequest. Net
result: a PAT with ["read"] scope could still mutate items through
bulk-update even after the round-1 fix.

Move the scope check from executeRequest into buildAuthedRequest so
every synthesized request — main writes, RMW prefetches, bulk-update
per-item PATCHes, link-create POSTs, attachment HEADs — passes
through the same gate uniformly. The check is dropped from
executeRequest to avoid double-checking; buildAuthedRequest is the
universal funnel everything calls.

Reads (GET/HEAD/OPTIONS) under ["read"] scope still pass — bulk-
update's per-item GET prefetch succeeds, the subsequent PATCH fails
at request-build time with permission_denied. The bulk operation
returns successfully with all-errors recorded per ref (the "no abort
on per-item failure" contract is unchanged).

Test: TestHTTPHandlerDispatcher_ScopeEnforcement_BulkUpdateBlockedOnReadScope
spies on the test handler; asserts the PATCH never reaches it under
["read"] scope and that each per-item entry carries permission_denied.
2026-05-02 09:25:25 -04:00
xarmian 02be33902f feat(attachments): ImageProcessor interface + pure-Go impl + thumbnail pipeline (TASK-878) (#295)
* feat(attachments): ImageProcessor interface + pure-Go impl + thumbnail pipeline (TASK-878)

Adds the abstraction Phase 1 needs to derive thumbnail variants on
upload, with a pure-Go default implementation that keeps Pad's
single-binary distribution intact (no cgo). The libvips-tagged
build (Phase 2 / Pad Cloud Docker) will replace processor_purego.go
with a vips-backed implementation behind the same Processor
interface — see DOC-865.

internal/attachments/processor.go:
  Processor interface — Decode(io.Reader)→(image.Image, format),
  Resize(img, maxLong), Rotate(img, deg), Crop(img, rect),
  Encode(img, format, w), Capabilities().
  Capabilities struct (image_formats, can_transcode, max_pixels)
  surfaces what the editor needs to gate per-format rotate/crop UI
  on (TASK-879/880). ErrUnsupportedFormat + ErrImageTooLarge are
  separate sentinels so callers can distinguish "format not
  supported" from "image dimensions too big".

internal/attachments/processor_purego.go (//go:build !libvips):
  Uses github.com/disintegration/imaging plus the stdlib decoders.
  Supports PNG/JPEG/GIF/BMP/TIFF for all ops. WebP/AVIF/HEIC
  reach Decode and bounce out via ErrUnsupportedFormat — uploads
  still succeed (the MIME allowlist is the upload gate), but
  thumbnails skip and the editor disables rotate/crop UI per
  Capabilities.

  Memory ceiling: Decode peeks via image.DecodeConfig (header only)
  before allocating any pixel buffer and rejects images whose
  width*height exceeds MaxPixelsDefault (8000² = 64MP). At 4 bytes
  per pixel that caps the decode buffer at ~256 MiB and prevents an
  attacker uploading a forged 100kx100k claim from OOMing the
  server. The forged-CRC test exercises this gate.

internal/server/handlers_attachments_thumbnails.go:
  deriveThumbnails(parentID) runs in goAsync after every image
  upload. Generates thumb-sm (256px long edge) + thumb-md (1024px),
  each as its own attachments row with parent_id pointing at the
  original. Server.Stop() drains the goroutine before SQLite
  closes, so tests can assert post-conditions deterministically.

  Skip cases: parent deleted (race), source format not supported
  (logged at debug), source already smaller than the variant's
  bound, variant already exists (idempotent reruns). Variants
  count toward workspace storage usage — DOC-865 is explicit about
  this and TestThumbnails_CountsTowardWorkspaceUsage proves it.

  Output format policy: PNG inputs stay PNG to preserve transparency;
  everything else encodes as JPEG q=85.

internal/server/handlers_capabilities.go:
  GET /api/v1/server/capabilities returns the Processor's static
  capability profile under {image: {...}}. Public route — the
  editor needs it before login (e.g. shared-item preview surfaces).
  Reports an empty image-formats list when no processor is wired,
  signalling the editor to disable rotate/crop UI rather than
  500-ing the editor mount.

cmd/pad/main.go: wires SetImageProcessor(NewProcessor()) alongside
SetAttachments at startup; logs the supported formats so operators
know whether they're on the pure-Go or libvips build.

Tests:
  - processor_test.go: 12 unit tests covering capability profile,
    decode round-trip for PNG/JPEG/GIF, rejection of unsupported
    formats and oversized images (forged-CRC PNG), resize aspect
    preservation + pass-through for already-small inputs, rotate
    multiples-of-90 + negative + 360-modulo handling, crop with
    bounds clipping + empty-intersection rejection, encode round-
    trip for PNG/JPEG, ThumbnailFormat/Mime/Ext policy.
  - handlers_attachments_thumbnails_test.go: 5 integration tests
    covering thumb-sm + thumb-md generation on PNG/JPEG uploads,
    skip-when-source-already-small, ?variant=thumb-md serving via
    the existing GET handler, workspace usage accounting.
  - handlers_capabilities tests cover the happy path + the
    no-processor degraded path.

Parent: PLAN-866. Closes the thumbnail-fallback gap that TASK-874 /
TASK-876 left open (thumb-md URLs were falling back to original
because no thumbnails existed). Unblocks TASK-879 (rotation tool)
and TASK-880 (crop tool) — both will reuse Processor.Rotate /
Processor.Crop with the same Capabilities-driven UI gating.

* fix(attachments): make /server/capabilities public per Codex review (round 1)

Codex flagged that GET /api/v1/server/capabilities was registered
inside the auth-gated API group but missing from isPublicAPIPath,
so once any user existed the editor's pre-login fetch would 401 —
contradicting the route's "public" register-time intent and breaking
the share-preview surface.

Fix: add the path to isPublicAPIPath. The handler is read-only,
returns a static profile, and has no per-user state, so making it
public has no security implication. Added
TestServerCapabilities_PublicAfterBootstrap as a regression guard:
it bootstraps an admin (so RequireAuth is active) and then fetches
the endpoint with no auth cookie, asserting 200.

* fix(attachments): make -tags libvips compile per Codex review (round 2)

Codex flagged that build tag !libvips on processor_purego.go meant
NewProcessor + the Thumbnail* helpers were absent under
\`go build -tags libvips\`, so cmd/pad/main.go and the thumbnail
handler — which call them unconditionally — broke that build.

Two minimal fixes preserving the documented Phase 2 split:

  1. Move ThumbnailFormat / ThumbnailMime / ThumbnailExt out of the
     tagged file and into processor.go (untagged). They're pure
     format-name policy, not implementation specifics, so both
     backends share the same definitions.

  2. Add processor_libvips.go (//go:build libvips) with a stub
     NewProcessor that panics at runtime with a clear
     "Phase 2 hasn't shipped libvips yet" message. The libvips
     build now compiles; anyone actually instantiating the
     processor under that tag gets a loud failure rather than a
     silent degradation. Phase 2 will replace the body with the
     real govips-v2-backed implementation.

Verified: \`go build ./...\` and \`go build -tags libvips ./...\` both
clean. Existing tests still pass on the default tag.

* fix(attachments): make tests compile under -tags libvips per Codex review (round 3)

Codex flagged that running \`go test -tags libvips ./internal/attachments\`
or \`./internal/server\` panicked through the libvips NewProcessor
stub: processor_test.go and the thumbnail/capability server tests
all called NewProcessor() unconditionally, even though the libvips
build's stub is intentionally panicking until Phase 2 ships the
real implementation.

Three minimal fixes:

  1. Tag processor_test.go !libvips. It tests the pure-Go
     implementation specifically — there's no value in running it
     under libvips, and the stub processor would explode the moment
     NewProcessor() ran.

  2. Tag handlers_attachments_thumbnails_test.go !libvips. Same
     reasoning — these integration tests assert thumbnail
     derivation against a working processor.

  3. Split testServerWithAttachments's processor wiring into two
     build-tagged helper files:
       * testimageprocessor_purego_test.go (//go:build !libvips)
         wires the real pure-Go processor.
       * testimageprocessor_libvips_test.go (//go:build libvips)
         is a no-op so the rest of the server test surface
         (uploads, downloads, auth, etc.) compiles + runs cleanly
         under -tags libvips.

Verification:
  go build ./...                              — OK
  go build -tags libvips ./...                — OK
  go test ./internal/attachments ./internal/server (default)        — pass
  go test -tags libvips ./internal/server -run "TestUpload|TestDownload" — pass

Phase 2 will introduce a real libvips test backend and drop the
!libvips tags on the thumbnail tests.

* fix(attachments): libvips binary boots cleanly per Codex review (round 4)

Codex flagged that the libvips build still crashed at \`pad serve\`
startup: cmd/pad/main.go calls attachments.NewProcessor()
unconditionally, and the libvips stub was panicking — so any
operator who built with -tags libvips today (Phase 2 isn't shipped
yet) lost the entire server, not just image processing.

Two minimal changes:

  1. processor_libvips.go: stop panicking. Return nil + slog.Warn
     instead. Every call site already nil-checks the processor (the
     upload handler skips thumbnail derivation, the capabilities
     endpoint reports a degraded empty formats list), so the
     libvips-tagged binary now has the same runtime profile as a
     self-host build that opted out of image processing entirely
     — uploads succeed, originals display, only derived
     transformations are unavailable. The slog.Warn keeps the
     "this build doesn't have it yet" signal loud.

  2. cmd/pad/main.go: skip srv.SetImageProcessor when NewProcessor
     returns nil, and log a "not wired" message in that branch.
     Distinguishes the wired vs. unwired states cleanly in the
     boot log.

Phase 2 will replace processor_libvips.go's body with the real
govips-v2-backed implementation; main.go's wiring is already shape-
correct for that transition.

Verification:
  go build ./...                — OK
  go build -tags libvips ./...  — OK
  go test ./...                 — pass (74s server tests included)
  go test -tags libvips ./internal/server -run "TestUpload|TestDownload|TestServerCapabilities_Public" — pass
2026-04-29 14:35:49 -04:00
xarmian 0fd5d0cdfb fix: green up Go (PostgreSQL) CI (BUG-842) (#275)
* fix(store): swap plainto_tsquery → websearch_to_tsquery for PG FTS (BUG-842)

`TestListItems_FTS_HyphenatedSearchTerm/task-five` has been failing on
every Go (PostgreSQL) CI run because `plainto_tsquery('english',
'task-five')` doesn't match the asciihword lexeme(s) the english parser
produces for an indexed `task-five-distinctive`. The result is that
every PG full-text search for hyphenated terms returns zero rows.

`websearch_to_tsquery` (Postgres 11+) is purpose-built for arbitrary
user input and tokenizes hyphenated terms the same way `to_tsvector`
does for the indexed document, so the query intersects the index
correctly. Swapped in three spots in the postgres dialect — FTSMatch,
FTSSnippet, FTSRank — and updated the caller-side comments that
referenced plainto_tsquery. SQLite path is unchanged: it goes through
items_fts MATCH with sanitizeFTSQuery, never through these methods.

* fix(server): drain background goroutines on Stop() (BUG-842)

`TestAdminBillingStats_SidecarSidecarError_DegradesToLocalOnly` (and
other server tests) have been flaking on the Go (PostgreSQL) CI runner
with `TempDir RemoveAll cleanup: directory not empty`. Root cause:
several request handlers spawned bare `go func() { ... }()` goroutines
that touched the SQLite WAL DB after the test function returned.
testServer's t.Cleanup closed the store but had no way to drain those
goroutines first, so a fire-and-forget WAL write could re-create the
`-wal`/`-shm` files between Close() and t.TempDir's RemoveAll.

Add a Server.bg sync.WaitGroup, a Server.goAsync helper that wraps a
WaitGroup-tracked goroutine, and a Server.Stop() that blocks until
every goAsync closure has finished. Convert the four known
fire-and-forget sites to goAsync:

- middleware_auth.go (TouchUserActivity)
- handlers_auth.go   (password reset email)
- handlers_cloud.go  (stripe_processed_events pruning)
- handlers_members.go (workspace invitation email)

Wire `srv.Stop()` into both testServer (server_test.go) and
newMetricsTestServer (metrics_auth_test.go) so cleanup order is
Stop → Close → TempDir RemoveAll. Add
TestServer_Stop_DrainsBackgroundGoroutines to pin the contract: a
goAsync goroutine must block Stop until it returns.

* fix(store): correct PG FTS hyphenation via OR-combined plainto_tsquery (BUG-842)

The previous attempt swapped plainto_tsquery → websearch_to_tsquery,
which was wrong: websearch_to_tsquery treats `-` as a NEGATION operator
(Google-style), so `task-five` becomes `task & !five` and the search
returns 0 rows for the same reason as before. This commit reverts the
swap and applies the actual fix.

PG's english parser indexes `task-five-distinctive` as
`{task-five-distinct, task, five, distinct}` — the asciihword AND its
parts. plainto_tsquery applied to the partial query `task-five`
produces `task-fiv & task & five`: the stemmed asciihword for the
PARTIAL query (`task-fiv`) is NOT in the vector, so the AND fails.

Replacing the hyphen with a space makes plainto emit `task & five`,
which DOES match — but doing that unconditionally breaks `BUG-842`-
style queries: PG indexes the `-842` suffix as a negative-number
lexeme, so `plainto_tsquery('BUG-842')` matches via `-842`, while
`plainto_tsquery('BUG 842')` searches for `842` and misses.

The fix ORs the two query variants together so the search vector is
matched against either the raw user query OR its hyphen-as-space form.
Both `task-five` (against `task-five-distinctive`) and `BUG-842`
(against `BUG-842 fix the cleanup race`) hit. Verified locally against
postgres:17-alpine via PAD_TEST_POSTGRES_URL — both 10x stress and
race-detector runs are green.

Surfaces:
  - dialect.go: FTSMatch / FTSSnippet / FTSRank now consume TWO
    placeholders each in the PG dialect.
  - items.go: listItemsFTS PG branch + SearchItems PG branch update
    args to pass (raw, sanitized) for every PG `?` placeholder.
  - search.go: SearchItems main / count / facets PG branches updated
    likewise. New sanitizePGFTSQuery helper alongside sanitizeFTSQuery.
  - documents.go: ListDocuments PG branch updated.

Tests:
  - TestListItems_FTS_HyphenatedSearchTerm extended with a `BUG-842`
    case to pin the OR-combined logic — naive hyphen-stripping would
    silently regress this.
  - New TestSanitizePGFTSQuery unit test.

* chore: gofmt 11 files with import-order issues (BUG-842 PR cleanup)

The Go (SQLite) CI job has been failing on `main` (and every PR built
against it) because golangci-lint flags 11 files whose third-party
imports are intermixed with internal imports — the import-grouping
rule that gofmt enforces. None of these were introduced by the
BUG-842 PR; they're pre-existing on main. The PR can't go green
without this cleanup, though, so it's bundled here.

Pure mechanical change — `gofmt -w <files>` only re-orders import
groups; no logic changes. Files touched:

  cmd/pad/configure.go
  cmd/pad/main.go
  internal/cli/format.go
  internal/server/handlers_admin_invitations.go
  internal/server/handlers_admin_users.go
  internal/server/handlers_grants.go
  internal/server/handlers_share_links.go
  internal/server/handlers_stars.go
  internal/server/middleware_auth.go
  internal/store/store.go
  internal/store/store_test.go

After this commit `gofmt -l ./cmd ./internal` returns clean.
2026-04-28 16:21:43 -04:00
xarmian 7cda0d7896 feat: rebrand to Perpetual Software + new tagline (IDEA-832) (#273)
Migrates from xarmian/pad to PerpetualSoftware/pad across the entire
repo and updates the product subtitle to "Collaborate with your AI
agents".

Go module rename
- go.mod: github.com/xarmian/pad → github.com/PerpetualSoftware/pad
- All Go imports updated across cmd/pad, internal/{cli,server,store,
  models,collections,items,events,metrics,webhooks} (~130 files)
- Test fixtures with the literal repo slug ("xarmian/pad" in JSON
  shapes, SSH/HTTPS git URL strings, workspace_context fixtures)
  also updated, including the secondary repo entry
  (xarmian/pad-web → PerpetualSoftware/pad-web — pad-web was also
  moved to the org per branch context)

Docs / config
- README badges, install instructions, brew tap, Docker image, source
  build path, sponsor link (sponsor link kept as personal @xarmian)
- Subtitle: "Project management for developers and AI agents." →
  "Collaborate with your AI agents." (README, manifests, web layout
  meta, .goreleaser homebrew description)
- CONTRIBUTING.md, SECURITY.md, skills/INSTALL.md
- .goreleaser.yaml: homebrew_casks owner, GHCR image, release github
  owner, cosign cert-identity regex, comments
- .github/workflows/release.yml: tap/release comments
- deploy/k8s/deployment.yaml: container image
- docs/deployment.md: clone URL
- web/static/{site.webmanifest,manifest.json}: description
- web/src/routes/+layout.svelte: meta description + og:description

Brew tap path is PerpetualSoftware/tap/pad (CamelCase, matches
GitHub user case). GHCR image is ghcr.io/perpetualsoftware/pad
(lowercased per GHCR's URL normalization). CODEOWNERS @xarmian and
FUNDING.yml github: xarmian intentionally retained — those are the
personal maintainer / sponsor account, separate from the org repo.

Verification: go build ./..., go test ./... (all pkgs pass), web
build, and make install all clean (TASK-844, TASK-845).
2026-04-28 12:26:39 -04:00
xarmian c10023ea8f fix(server): deny-by-default whitelist for API token scopes (TASK-667) (#192)
* fix(server): deny-by-default whitelist for API token scopes (TASK-667)

tokenScopeAllows previously fell open on unrecognized scopes and on
unparseable scope JSON. A typo like "read-only" silently granted full
access — exactly the kind of landmine that a fresh token minted by an
admin who misremembers the vocabulary would step on.

New policy (deny-by-default):
- Unparseable JSON → deny + warn (was allow). Data corruption or
  tampering should never fall open.
- Unrecognized scopes → never contribute to allow; all unknowns on a
  given request get a single warning log so operators can spot typos.
- Explicit wildcard "*" and "write" still allow all methods; "read"
  still allows safe methods only.
- Empty scope string and empty JSON array `[]` still allow — these
  represent legacy pre-enforcement rows we don't want to break on
  upgrade.

Test table updated:
- old "unknown scope allows GET/POST" flipped to deny
- new "read-only typo denies GET" regression pin
- new "unknown+write/wildcard still allow" guard rails confirming that
  a recognized allow-granting scope alongside an unknown one still
  grants (unknown is logged, not failing the request)
- old "invalid json allows all" flipped to deny

Parent: PLAN-643 (OSS Security Hardening).

* fix(server): reject JSON null token scopes (TASK-667)

Addresses Codex P2 on PR #192: json.Unmarshal accepts the literal
\`null\` without error and leaves the target slice nil, so "scopes": "null"
would match the legacy empty-array allow-path and grant full access —
bypassing the new deny-by-default intent whenever a client-side
serializer emits null for a missing field.

- Gate the "unrestricted" path on the raw string being "", ["*"],
  [ "*" ], or [] only (with whitespace trimming on the outside). "null"
  no longer slips through.
- Post-unmarshal, any empty slice that wasn't one of those explicit
  allow-forms is logged as "non-array or null scopes; denying" and
  denied.
- New test cases: "json null denies POST" / "json null denies GET".

Parent: PLAN-643 (OSS Security Hardening).

* fix(server): distinguish JSON null from empty array in token scopes (TASK-667)

Addresses Codex P2 on PR #192: the previous raw-string whitelist for
legacy empty-array tokens rejected valid whitespace-padded forms like
\`[ ]\` or \`[\\n]\` that some clients emit. Those decoded to a non-nil
empty slice, so a smarter check works: use the Go json package's
nil-vs-empty distinction.

- scopes == nil → JSON was literal null. Deny + warn (unchanged intent).
- scopes != nil && len == 0 → explicit empty array regardless of
  whitespace. Allow (legacy unrestricted form, as documented).
- scopes has entries → existing whitelist logic.

Empty-string fast path kept for the no-column case; wildcard fast path
now trims whitespace too.

New tests: \`[ ]\`, \`[\\n]\`, \`[\\t]\` empty arrays and \`[ "*" ]\`
wildcard all allow; \`null\` still denies.

Parent: PLAN-643 (OSS Security Hardening).
2026-04-22 11:28:44 -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 c2b67f5a9d fix(server): gate cloud admin endpoints via requireCloudMode (TASK-655) (#182)
* fix(server): gate cloud admin endpoints via requireCloudMode (TASK-655)

middleware_auth.go:184-189 and middleware_csrf.go:44-48 permanently
exempted /api/v1/admin/plan, /admin/stripe-customer-id, and
/admin/user-by-customer from RequireAuth and CSRFProtect — by path, not
by credential. In self-host mode these endpoints still responded to
every anonymous network caller (with "Cloud mode not configured"),
confirming their existence and telegraphing that the auth surface was
non-standard.

Three tightly-coupled changes:

1. Narrow both carve-outs from path-based to credential-based. The new
   isCloudSecretAuthAttempt(r) helper checks for X-Cloud-Secret header
   or legacy ?cloud_secret query-param; only requests that present one
   bypass auth/CSRF. Cookie-based admin callers continue through the
   normal session + CSRF gate.

2. Wrap the three endpoints in a dedicated requireCloudMode group.
   Self-host mode → 404, no endpoint-existence disclosure.

3. Admin callers via cookie now properly require CSRF for these
   endpoints (they previously bypassed), bringing them in line with
   every other /admin/* endpoint.

Tests (cloud_admin_gate_test.go):
- TestCloudAdminGate_SelfHost_Returns404 — anon + X-Cloud-Secret in
  self-host → 404 (requireCloudMode fires).
- TestCloudAdminGate_NoCloudSecret_RequiresAuth — cloud mode + no
  secret → 401 from auth gate (not the old "Cloud mode not configured").
- TestCloudAdminGate_ValidCloudSecret_PassesAuthAndCSRF — sidecar
  with matching X-Cloud-Secret reaches the handler; neither 401 nor
  403.
- TestCloudAdminGate_QueryParamSecret_BackwardCompat — legacy
  ?cloud_secret= on GET still works (TASK-656 removes this next).

Parent: PLAN-643 (OSS Security Hardening).

* fix(server): scope cloud-secret auth bypass to cloud admin paths per Codex P0

Codex caught a regression in the first cut: isCloudSecretAuthAttempt(r)
only checked for the presence of X-Cloud-Secret/?cloud_secret, so
setting either header on ANY path (e.g. GET /api/v1/workspaces) would
bypass RequireAuth globally. An anonymous attacker could list or
create workspaces just by adding one of those markers.

Add a cloudAdminPaths whitelist and require the request path to be one
of the three cloud admin endpoints before honoring the bypass. Defined
as a map so a future /api/v1/... route can't accidentally inherit it.

Regression test TestCloudAdminGate_BypassScopedToCloudPaths:
- GET /workspaces + X-Cloud-Secret → 401 (not bypass)
- GET /workspaces?cloud_secret=x → 401 (not bypass)
- POST /workspaces + X-Cloud-Secret → 4xx (CSRF 403 or auth 401)

* fix(server): make cloud-secret path gate visible at call sites

Codex re-flagged the path scoping on PR #182 — even after the fix, the
helper name 'isCloudSecretAuthAttempt' made the path scoping invisible
at the call site. Split into two primitives:
 - isCloudAdminPath(path) — path whitelist check
 - hasCloudSecretMarker(r)  — header/query marker check

Both middleware now combine them explicitly:
  if isCloudAdminPath(path) && hasCloudSecretMarker(r) { ... }

Behaviorally identical to the previous fix — tests still show
GET /workspaces with X-Cloud-Secret returning 401, POST /workspaces
with X-Cloud-Secret returning 403. Just makes the invariant readable
in RequireAuth and CSRFProtect without having to jump to the helper.

* fix(server): preserve body-cloud_secret auth for sidecar POSTs per Codex P1

Codex caught that POST sidecar calls carrying cloud_secret only in the
JSON body (the current pad-cloud sidecar behavior) would fail at
RequireAuth/CSRFProtect after this PR — handler-level validation never
runs. Breaking deployed sidecars isn't the intent of TASK-655; TASK-656
deprecates body+query cloud_secret in favor of X-Cloud-Secret header
exclusively, but that's a separate migration.

Add body peek to hasCloudSecretMarker for POST/PUT requests with
application/json content-type:
 - Read up to 64 KB of r.Body into a buffer.
 - Replace r.Body with an io.NopCloser wrapping the buffer so
   downstream handlers can still decode the JSON.
 - Return true if the parsed body has a non-empty cloud_secret field.

Parse errors and missing fields → false (request falls through to the
normal auth rejection, no permissiveness). The peek only runs when
the caller is already hitting a cloud admin path via the explicit
isCloudAdminPath() gate at the call sites, so the body-read cost is
bounded to three endpoints.

Test: TestCloudAdminGate_BodySecret_BackwardCompat posts with
cloud_secret in the JSON body and no X-Cloud-Secret header, asserts
the request reaches the handler (404 from unknown user_id, not
401/403 from middleware).
2026-04-21 22:15:52 -04:00
xarmian b3af1acd07 feat: add last active tracking for users (#105)
* feat: add last active tracking for users

Track when users were last active via a throttled update (once per 5
minutes) in the auth middleware. Adds last_active_at column, displays
relative time in admin user list with full timestamp on hover.

* fix: bound last-active goroutine with 3s context timeout

Use a short-lived context for the background TouchUserActivity write
so it gets cancelled under DB pressure, preventing goroutine/connection
buildup from unbounded background work.
2026-04-13 22:19:41 -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 b7808f12a1 fix: address Codex review findings for PR #90 (iteration 2)
Update admin frontend to handle new paginated user list response shape
({ users, total } instead of bare array). Add legacy pad_session cookie
fallback to SessionAuth middleware matching validateSessionCookie. Exempt
/api/v1/plan-limits from RequireAuth so billing page can read limits
without authentication.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-13 01:27:51 +00:00
xarmian 460d213526 fix: address review findings for PR #90 (iteration 1)
Exempt new sidecar endpoints (/admin/stripe-customer-id, /admin/user-by-customer)
from RequireAuth and CSRF middleware. Fix OAuth unlink lockout guard that never
triggered because PasswordHash is always non-empty. Return total count from
admin user list for pagination support.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-13 01:11:04 +00:00
xarmian 92580905bb feat: cloud hardening and security follow-ups (PLAN-503)
Address 11 issues identified during the PLAN-427 security review:

Critical/High:
- Stripe customer-to-user mapping with indexed lookup (TASK-505)
- OAuth provider linking with explicit consent model (TASK-504)
- CSRF tokens on admin console mutations (TASK-506)
- Rate limiting on cloud admin and OAuth endpoints (TASK-507)

Medium:
- __Host- cookie prefix for subdomain protection (TASK-510)
- Billing portal verifies customer ownership server-side (TASK-515)
- Transactional account deletion with rollback (TASK-509)
- Streaming data export with 60s timeout (TASK-508)
- Migration registration for new columns (TASK-514)

Low:
- Billing page fetches actual plan limits from API (TASK-511)
- Admin user search/filter pushed into SQL with pagination (TASK-512)
2026-04-13 01:03:12 +00:00
xarmian e6f123a4c3 fix: address Codex review findings for PR #89 (iteration 2)
- Exempt /admin/plan from RequireAuth and CSRF middleware so the
  pad-cloud sidecar can call it with cloud_secret body auth
- Add X-CSRF-Token header to admin console PATCH requests
- Send plan_overrides as a JSON string (not parsed object) to match
  backend *string decoder expectation
- Restrict confirm-only account deletion to cloud mode to prevent
  password users from bypassing re-auth

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-13 00:54:21 +00:00
xarmian 94d35509a4 feat: share links with hardened security, anonymous access, and analytics (#88)
* feat: share links with hashed tokens and /s/{token} route

Add share_links and share_link_views tables with CRUD API and
anonymous resolution route (TASK-421).

Data model:
- share_links: token_hash (SHA-256), target_type/id, permission,
  password_hash, expires_at, max_views, require_auth, view tracking
- share_link_views: per-view records with fingerprint/user tracking

Token security:
- 192-bit entropy (crypto/rand), URL-safe base64 encoding
- SHA-256 hashed at rest, raw token returned only once on creation
- Generic 404 for invalid tokens (no info leakage)
- /api/v1/s/ exempt from auth middleware for anonymous access

API endpoints:
- POST /items/{slug}/share-links — create item share link
- POST /collections/{coll}/share-links — create collection share link
- GET /items/{slug}/share-links — list share links for item
- GET /collections/{coll}/share-links — list for collection
- DELETE /share-links/{id} — revoke share link
- GET /s/{token} — resolve share link, return shared content

D8: Anonymous users are ALWAYS read-only. View count and unique
viewers tracked on each resolution.

* feat: anonymous share page + share link management UI

Add minimal-chrome share link viewer page and share link CRUD in
the share dialog (TASK-422 + TASK-425).

Share page (/s/{token}):
- New SvelteKit route at /s/[token] for anonymous viewing
- Renders item (title, fields, markdown content) or collection
  (name, item list) with no app chrome (no sidebar/topbar)
- Handles require_auth links with "Sign in to view" prompt
- Root layout bypasses auth checks for /s/ routes
- "Powered by Pad" footer

Share dialog updates:
- "Share links" section below existing grants
- Create/list/revoke share links for items and collections
- Copy-to-clipboard for share URLs
- Newly created links highlighted with "only shown once" notice
- View count and auth-required badges

API client:
- ShareLink type added
- shareLinks.* methods for CRUD
- share.get(token) for anonymous resolution

* feat: share link constraints + view analytics

Add password protection, expiry, max views, and view history
endpoints for share links (TASK-423 + TASK-424).

Constraints (TASK-423):
- CreateShareLink accepts ShareLinkOptions: password, expires_at,
  max_views, require_auth, restrict_to_email
- Password hashed with bcrypt, verified on /s/{token} resolution
- Password-protected links return {require_password: true} prompt
- Expiry and max_views already validated by ValidateShareLink

Analytics (TASK-424):
- GET /share-links/{id}/views returns view history with fingerprint,
  user ID, and timestamp
- Response includes total_views, unique_viewers, last_viewed_at
- View history stored per-view in share_link_views table

* fix: harden share links — XSS, access control, data leakage, and UX gaps

- Sanitize rendered markdown with DOMPurify before {@html} injection (XSS)
- Force require_auth=true when restrict_to_email is set (access bypass)
- Reject malformed non-empty JSON bodies with 400 instead of failing open
- Return public DTOs on share endpoints to prevent leaking internal IDs,
  creator info, assignees, schemas, and other sensitive fields
- Enforce max_views atomically via conditional UPDATE to prevent races
- Fix collection share rendering: read items from top-level response key
  and map ref/status fields correctly
- Add password prompt UI and X-Share-Password header support so
  password-protected links can actually be unlocked by the frontend

* fix: follow-up hardening for share links

- Sanitize catch fallback in rendered markdown (XSS edge case if marked throws)
- Remove query-string password fallback; accept only X-Share-Password header
  to avoid leaking passwords in logs, browser history, and referrers
- Return 500 on ListItems DB failure instead of swallowing as empty collection
- Normalize restrict_to_email with ToLower/TrimSpace on create and compare
- Fix malformed JSON check for chunked bodies (ContentLength == -1)
  by checking for io.EOF instead of ContentLength > 0
- Remove internal share_link.id from public DTO responses
- Use clientIP(r) helper for consistent fingerprinting instead of raw
  X-Forwarded-For which is spoofable and includes port in RemoteAddr
- Distinguish DB errors from not-found in share link delete handler

* fix: final hardening pass for share links

- Move auth/email gate before password check to prevent unauthenticated
  callers from probing passwords and burning bcrypt CPU
- Wrap view recording (counter increment, unique-viewer accounting, view
  insert) in a single transaction so a failed insert rolls back the
  consumed view count instead of silently losing it
- Add X-Share-Password to CORS AllowedHeaders so cross-origin
  deployments can send the custom header without preflight rejection
- Validate expires_at (RFC3339) and max_views (> 0) on share link
  creation; return 400 for invalid constraints instead of creating
  immediately-unusable links
- Cap view-history endpoint limit to 1000 to prevent unbounded queries
2026-04-11 16:40:18 -04:00
xarmian c6d19837c8 feat: collection & item grants, guest access, share dialog (PLAN-407 Phase 3) (#87)
* feat: collection and item grants tables + permission resolution

Add grant tables, CRUD operations, and permission resolution for
guest access and member overrides (TASK-417).

Data model:
- collection_grants table (id, collection_id, workspace_id, user_id,
  permission, granted_by) with CASCADE on collection/user delete
- item_grants table (same structure, references items)
- Indexes for user/collection/item lookups

Store methods:
- Create/Get/List/Delete for both collection and item grants
- ListUserGrants: all grants for a user across a workspace
- RevokeAllUserGrants: bulk delete for member removal
- ResolveUserPermission: full 5-step resolution per DOC-406
  (owner → item grant → collection grant → membership → deny)

API endpoints:
- GET/POST/DELETE /collections/{coll}/grants — collection grant CRUD
- GET/POST/DELETE /items/{slug}/grants — item grant CRUD
- GET /users/{userID}/grants — all grants for a user in workspace

All grant endpoints are owner-only for creation/deletion.

* feat: grant revocation + member removal with grant choice

Update member removal to support D4: owner chooses whether to revoke
all grants when removing a member (TASK-489).

- DELETE /members/{userID}?revoke_grants=true → remove membership AND
  all collection/item grants (full removal)
- DELETE /members/{userID} (or revoke_grants=false) → remove membership
  but keep grants (user becomes a guest with existing access)
- Audit log records whether grants were revoked
- CASCADE DELETE on collection/item deletion already handles cleanup
  (via ON DELETE CASCADE in the grants migration)

* feat: share dialog UI for items and collections + grant types

Add a share dialog component for managing grants on items and
collections, plus TypeScript types and API client methods (TASK-419).

Frontend:
- ShareDialog.svelte: reusable modal for listing/creating/revoking
  grants, with email input, permission select, and revoke buttons
- Item detail page: "Share" button in meta-actions (owner-only)
- Collection page: "Share" button in header actions (owner-only)

TypeScript:
- CollectionGrant and ItemGrant types added
- API client: grants.listCollectionGrants, createCollectionGrant,
  deleteCollectionGrant, listItemGrants, createItemGrant,
  deleteItemGrant, listUserGrants

Guest home screen (TASK-418) deferred — requires layout-level guest
detection which will be implemented when guest routing is built.

* feat: guest access — grants-based workspace access for non-members

Allow authenticated users with grants (but no workspace membership)
to access workspaces as guests (TASK-418).

Backend:
- UserHasGrantsInWorkspace: checks if user has any collection/item
  grants in a workspace
- GuestVisibleCollectionIDs: returns collections visible to a guest
  via collection grants + collections containing granted items
- RequireWorkspaceAccess: after member-nil check, falls through to
  grant check; sets role to "guest" if grants exist
- VisibleCollectionIDs: non-members now checked for guest grants
  instead of returning empty
- GetUserWorkspaces: includes guest workspaces (is_guest flag)
- GetWorkspacesBySlugForUser: JOINs on grants tables so workspaces
  resolve for guests
- roleLevel: "guest" = 0 (below viewer, blocks role-gated actions)

Frontend:
- Workspace.is_guest field in TypeScript type
- Sidebar: hides Dashboard, Roles, Activity, Settings, and "New
  collection" button for guests; shows "Shared with you" header

* feat: wiki-link rendering with locked icon for hidden items

Update wiki-link rendering to show a 🔒 locked icon when the linked
item is in a collection the user can't see (TASK-420).

- renderMarkdown accepts optional visibleCollectionSlugs parameter
- Items in hidden collections render as "🔒 Title" with tooltip
- Unresolved links still render as broken (no change)
- Username param added to renderMarkdown for correct URL construction
- TimelineCommentCard and CommentThread accept username prop

* fix: harden grant security — 9 findings from Codex review

- Item grants no longer leak collection-wide read access; guests with
  item-level grants see only their granted items, not the full collection
  (GuestVisibleResources two-level filter + ItemIDs in ListItems SQL).
- Edit grants are now enforced: mutating handlers (create/update/delete
  items, comments, reactions, links, versions) resolve grant-based
  permissions for guests via requireEditPermission + ResolveUserPermission.
- Grant list endpoints restricted to owners (collection/item grants) or
  owner-or-self (user grants) to prevent metadata/email enumeration.
- Guests blocked from listing workspace members; invitation details
  restricted to owners only.
- Grant deletion scoped to workspace_id to prevent cross-workspace
  deletion by guessing grant IDs.
- Member removal now revokes grants by default (opt-out with
  ?revoke_grants=false) and propagates revocation errors instead of
  silently discarding them.
- Guest workspace listing properly propagates DB errors instead of
  swallowing them.
- PostgreSQL subquery alias added to UserHasGrantsInWorkspace to fix
  silent guest-access failures on Postgres deployments.

* fix: harden item-level grant isolation — 7 findings from Codex re-review

- /changes endpoint now filters by item-level grants so guests with one
  item grant no longer receive updates for every item in that collection.
- Search results filtered by item-level grants (new ItemIDs field in
  SearchParams) so guests can't discover other items via search.
- Relationship/summary endpoints (item links, children, progress,
  activity, dashboard) all apply item-level visibility checks via
  isItemVisibleToGuest(), preventing metadata leakage through related
  item titles, statuses, and counts.
- Grants now work as member overrides: a viewer with an edit grant can
  edit the granted item (requireEditPermission falls back to
  ResolveUserPermission for members below editor role).
- handleMoveItem now requires edit permission on the target collection,
  not just visibility, preventing guests from moving items into
  view-only collections.
- Member removal + grant revocation is now atomic via
  RemoveWorkspaceMemberAndRevokeGrants() which wraps both operations
  in a single database transaction.
- Guest-access DB errors in middleware now return 500 with slog.Error
  instead of being silently collapsed into a 403 forbidden response.

* fix: close remaining grant isolation gaps — 10 findings from Codex round 3

- Workspace token endpoints (create/list/delete) now require owner role,
  preventing guests from enumerating or revoking API tokens.
- Legacy document endpoints (list, get, context, bulk-read, backlinks,
  links) now require at least viewer role, blocking guests entirely
  since documents are outside the grants model.
- Global search no longer relies on workspaceRole() (which is unset
  outside RequireWorkspaceAccess); detects guests via IsWorkspaceMember
  and applies item-level filtering. Multi-workspace search now uses
  GuestVisibleResources for guest workspaces.
- SSE event filtering now checks item IDs for guests with item-level
  grants, not just collection slugs, preventing live event leaks.
- Role board passes ItemIDs through RoleBoardParams so guests only
  see items they have grants on, not the entire collection.
- VisibleCollectionIDs for members with "specific" collection access
  now merges direct grants (collection + item grants), so grant
  overrides work for restricted members.
- Plans-progress endpoint filters plan items and children by item-level
  grants for guests, preventing one plan grant from exposing all plans.
- Webhook listing now requires owner role since URLs may contain secrets.
- Agent role item counts use item-level filtering for guests.
- Link deletion checks item-level visibility on both endpoints, not
  just collection-level.

* fix: close member grant escalation and remaining edge cases — round 4

- Item grants for restricted members no longer escalate to collection-
  wide visibility. VisibleCollectionIDs now merges only direct collection
  grants (not item-derived collections) into member access. Item-level
  filtering (guestResourceFilter, isItemVisibleToGuest, requireItemVisible)
  now applies to both guests AND restricted members with item grants,
  closing the gap where a member with specific collection access plus
  one item grant could see/edit all items in that collection.
- Guests blocked from workspace-level activity feed (/activity) which
  exposed audit events (member invites, role changes) with operational
  metadata. Requires at least viewer role.
- Global search no longer returns zero results for item-only guests.
  Store.Search early-return now checks both CollectionIDs and ItemIDs
  are empty before short-circuiting, so item-level grants work in
  global (multi-workspace) search.
- UserHasGrantsInWorkspace now excludes item grants on soft-deleted
  items, preventing phantom guest access to a workspace shell with
  no visible content when the only granted item is archived.

* fix: prevent grant filter from overriding member access, close SSE/dashboard/collection leaks — round 5

- guestResourceFilter now returns nil/nil for members with "all"
  collection access, preventing item grants from accidentally replacing
  their full visibility. Only guests and members with "specific"
  collection access get item-level filtering applied. This fixes a
  regression where a normal member receiving one item grant would lose
  access to all other items.
- requireItemVisible uses guestResourceFilter (with the same scoping)
  instead of raw GuestVisibleResources, so the member-access check is
  consistent throughout all code paths.
- SSE event filtering now denies collection-less events (workspace
  updates, legacy document events) for guests, preventing metadata
  leakage through realtime event payloads.
- Dashboard recent activity filters out workspace-level entries (no
  DocumentID) for guests, preventing audit metadata leakage.
- All grant visibility queries (UserHasGrantsInWorkspace,
  GuestVisibleCollectionIDs, GuestVisibleResources) now join the
  collections table and require deleted_at IS NULL, so grants on
  soft-deleted collections no longer provide phantom access.

* fix: make item grants additive for restricted members, close write/search/SSE gaps — round 6

- guestResourceFilter now merges member_collection_access + system
  collections + collection grants into fullCollIDs for restricted members,
  making item grants additive to existing access. Previously, item grants
  replaced the member's normal collections, causing members with one item
  grant to lose all their other collection visibility.
- Added ListSystemCollectionIDs store method for system collection lookup.
- Search (both global and workspace-scoped) now applies item-level
  filtering for restricted members with item grants, not just guests.
  Previously VisibleCollectionIDs included item-granted collections as
  full-access, leaking all items in those collections via search.
- SSE event filtering now builds item-level filters for restricted
  members with item grants (previously only for non-members/guests),
  and merges member collections into the full-access set.
- Role board reorder now uses requireItemVisible + requireEditPermission
  per item instead of collection-only visibility check, preventing
  restricted editors from reordering items in item-granted collections.
- View create/update/delete now check requireEditPermission on the
  collection (via requireViewEditable), not just collection visibility.
- GetUserWorkspaces guest query now joins collections/items tables to
  exclude grants on soft-deleted resources, matching the behavior of
  UserHasGrantsInWorkspace.

* fix: block guests from legacy doc versions/activity, fix ListItems early return, SSE fail-closed — round 7

- Legacy document version handlers (handleListVersions, handleGetVersion)
  and document activity handler (handleListDocumentActivity) now require
  at least viewer role, blocking guests from reading version history and
  activity for unrelated legacy documents.
- ListItems early return now checks both CollectionIDs and ItemIDs are
  empty before short-circuiting, matching the fix already applied to
  Search. This fixes item-only guests seeing zero results from /items,
  dashboard, role board, and agent-role counts.
- SSE item-grant filtering now fails closed on GuestVisibleResources
  errors: installs empty item/collection filter sets instead of falling
  through with nil (which would pass all events through).
- Role board reorder removed top-level requireMinRole("editor") so the
  per-item grant-aware requireEditPermission checks can run for guests
  and viewers with edit grants, consistent with other mutating handlers.
2026-04-11 14:46:24 -04:00
xarmian 117221c73d feat: auth-scoped workspace resolver with UUID support
Update workspace resolution to support both slugs and UUIDs, with
auth-scoped slug resolution for non-admin users (TASK-412).

Store:
- New GetWorkspacesBySlugForUser(slug, userID) method that finds
  workspaces matching a slug where the user is owner or member

Server:
- New resolveWorkspace() method: UUID → direct lookup, slug → auth-scoped
  for regular users, global for admins/unauthenticated
- RequireWorkspaceAccess middleware uses resolveWorkspace() and stores
  resolved workspace ID in context (ctxResolvedWorkspaceID)
- getWorkspaceID() reads from context (fast path) or resolves directly
  (fallback), eliminating redundant database lookups

API URL pattern unchanged — /api/v1/workspaces/{ws}/... where {ws}
now accepts both slug and UUID. CLI and frontend unaffected.
2026-04-10 23:29:29 +00:00
xarmian 0dc3f0b61f fix: address Codex review findings for TOTP 2FA
- Reject API token auth on 2FA enrollment endpoints (setup, verify,
  disable) to prevent account takeover via leaked tokens (P1)
- Re-read 2FA challenge secret after persisting to handle multi-instance
  startup race on fresh databases (P2)
2026-04-08 21:36:48 +00:00
xarmian 33f51a2bd6 fix: address Codex review findings for token rotation and scope enforcement
- Preserve backward compatibility for unrecognized token scopes (P1)
- Reject malformed JSON before destructive token rotation (P2)
- Preserve original created_at timestamp when rotating tokens (P3)
2026-04-08 18:50:08 +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 30fe60d666 feat: session binding, nonce-based CSP, and auth hardening (#75)
* feat: add session binding, nonce-based CSP, and auth hardening

Security hardening for Pad Cloud (PLAN-15 / TASK-171):

- Bind sessions to User-Agent hash; mismatch invalidates session
- Store client IP on session creation for audit trail
- Increase bcrypt cost from 10 to 12
- Upgrade invitation codes to 128-bit entropy with hashed storage
- Replace CSP unsafe-inline with per-request nonce for SvelteKit scripts
- Move SecurityHeaders to main router so SPA gets headers too

* fix: enforce session binding on auth cookie fallbacks and fix invitation code uniqueness

- Add validateSessionCookie() helper that checks UA binding, replacing
  raw ValidateSession() calls in handleSessionCheck, handleGetCurrentUser,
  and handleUpdateCurrentUser that bypassed the new session binding
- Store invitation ID in code column instead of empty string to satisfy
  the NOT NULL UNIQUE constraint (previously broke on second invitation)
- Skip code/join_url in invitation listings for hashed invitations where
  the plaintext is not recoverable
2026-04-08 13:58:58 -04:00
xarmian bde15d45ca Rename Phases to Plans, clean up deprecated aliases (#71)
* Rename "Phases" to "Plans" and clean up deprecated phase aliases

Renames the default "Phases" collection to "Plans" across the full stack:
- DB migration renames existing collections in-place (name, slug, prefix PLAN, icon 🗺️)
- Removes all deprecated Phase* backward-compat aliases from models and store
- Removes --phase CLI flag (use --parent instead)
- Updates convention triggers: on-phase-start/complete → on-plan-start/complete
- Updates dashboard API: active_phases → active_plans, /phases-progress → /plans-progress
- Updates all frontend components, types, and documentation

Closes IDEA-124

* Fix CSRF cookie not being cleared on logout

The SessionAuth middleware was re-issuing a CSRF cookie before the
logout handler could clear it, resulting in two Set-Cookie headers.
Skip CSRF re-issue for /api/v1/auth/ paths since auth endpoints
manage their own CSRF cookies (login sets, logout clears).

* Fix migration issues found in Codex review

- P1: Move doc_type UPDATE from migration 024 into 025, which recreates
  the table with the new CHECK constraint first (SQLite enforces CHECK
  on UPDATE, so the old constraint would reject 'plan')
- P1: Add PostgreSQL migration 005 for the collection rename (phases →
  plans) — previously only existed on the SQLite path
- P2: Recreate FTS triggers, indexes, and rebuild FTS after the table
  swap in migration 025 (DROP TABLE drops associated objects in SQLite)

* Fix parent filter field name and sync .agents skill copy

Codex review round 2 findings:
- P1: Parent filter compared against `parent_id` (wrong) instead of
  `parent_link_id` — plan filtering in collection view was broken
- P1: .agents/skills/pad/SKILL.md still had old --phase flags and
  "Phases" references — synced from the updated .claude copy
- P2: Accept legacy 'phase' filter key for backward compat with
  existing saved views that serialized the old key name

* Fix PG migration JSONB casting and add slug collision guards

Codex PR review bot findings:
- P1: PostgreSQL REPLACE/LIKE don't work on JSONB columns — cast
  schema::text and fields::text before string ops, then back to ::jsonb
- P1: If a workspace already has a custom 'plans' collection, the
  rename hits UNIQUE(workspace_id, slug) — added NOT EXISTS guard
  to both SQLite and PostgreSQL migrations
2026-04-07 14:55:23 -04:00
xarmian 063ff92d00 feat: generalized parent/child items with progress tracking (#70)
* feat: generalize parent/child items — any item can have children with progress tracking

Replaces the phases-only task widget with a generalized parent/child system.
Any item (Phase, Idea, Doc, Task, etc.) can now be a parent of child items,
getting automatic progress bars, burndown charts, status grouping, drag-drop
reordering, and recursive expand/collapse up to 3 levels deep.

DB: migrate link_type 'phase' → 'parent' (migration 023)
Store: generalized methods (GetChildItems, GetItemProgress, SetParentLink
  with cycle detection), drop collection filters, per-child terminal status
API: new /items/{slug}/children and /items/{slug}/progress endpoints
Frontend: ChildItems, ChildChart, NestedChildren components replace PhaseTasks
CLI: --parent flag (--phase kept as hidden alias), list/show/changelog updated
Docs: CLAUDE.md, SKILL.md, pad-web updated for parent/child model

Full backward compatibility: old 'phase' link_type, --phase flags, phase_id/
phase_ref JSON fields all still work as deprecated aliases.

Closes PHASE-16 (9 tasks).

* fix: update collection list page to use item_id from phasesProgress response

The TS client return type changed from phase_id to item_id but the
collection list page still referenced p.phase_id, causing svelte-check
type errors in CI.

* fix: address Codex review findings — PG migration, terminal statuses, metrics, CSRF resilience

- Add PostgreSQL migration 003 to rename 'phase' links to 'parent'
- Use schema-defined terminal statuses in GetAllItemProgress instead of hardcoded defaults
- Pass computed terminal statuses from page to ChildItems component
- Only special-case 'parent' field key when not defined in collection schema
- Move MetricsMiddleware before Recoverer so panics are counted
- Always mount ChildItems for SSE subscriptions even with 0 children
- Exclude soft-deleted children from has_children enrichment query
- Re-issue CSRF cookie when session is valid but cookie is missing
- Show actual API error messages in create-item toasts
2026-04-07 09:52:31 -04:00
xarmian e7f4448028 feat: add readiness probe and structured logging (TASK-161)
- Add /health/live (liveness) and /health/ready (readiness with DB check) endpoints
- Add Store.Ping() for database connectivity verification
- Create internal/logging package using stdlib log/slog
- Support PAD_LOG_LEVEL (debug/info/warn/error) and PAD_LOG_FORMAT (text/json) env vars
- Add structured request logging middleware replacing chi's default Logger
- Migrate all log.Printf calls to slog with proper levels and key-value attrs
- Exempt health probe endpoints from auth middleware
2026-04-05 15:02:54 +00:00
xarmian 8aa6481421 PHASE-12: Security Hardening for Pad Cloud (#67)
* feat: enforce RBAC role checks on all mutation endpoints (TASK-150)

Add requireMinRole helper and role enforcement to 30+ mutation handlers.
Viewers are now blocked from all state-changing operations, editors can
mutate items/docs/comments/views but not collections/webhooks/workspace
settings, and only owners can perform administrative operations.

Includes 11 integration tests with real auth covering viewer/editor/owner
access across items, collections, documents, comments, agent roles,
item links, and workspace operations.

* fix: scope search results to user's workspaces (TASK-151)

Search without a ?workspace= param previously returned results from all
workspaces in the database. Now the handler resolves the authenticated
user's workspace memberships and passes their IDs to the store query,
ensuring results only include items from workspaces the user belongs to.

Fresh installs (no users) retain unscoped search for backward compat.
Includes integration test proving cross-workspace isolation.

* fix: add webhook URL validation and SSRF protection (TASK-152)

Webhook creation now validates URLs before accepting them: only HTTP(S)
schemes allowed, embedded credentials rejected, private/reserved IPs
blocked (loopback, RFC1918, link-local, cloud metadata 169.254.169.254),
and hostnames are DNS-resolved to verify they don't point to private IPs.

Defense-in-depth check also added to the dispatcher's deliver function
so existing webhooks with unsafe URLs are blocked at delivery time.

* feat: add CSRF protection with double-submit cookie pattern (TASK-153)

Implements CSRF middleware that validates X-CSRF-Token header matches
the pad_csrf cookie on all state-changing API requests. Bearer token
auth, auth endpoints, and fresh installs are exempt. The frontend
client reads the CSRF cookie and attaches the header on mutations.

* feat: add per-endpoint rate limiting middleware (TASK-154)

Adds IP-based rate limiting for auth endpoints (5/min login, 3/hr
password reset, 5/hr registration) and user-based limits for API
(100/min) and search (30/min). Uses golang.org/x/time/rate with
automatic stale-entry cleanup. Adds chi RealIP middleware for
correct client IP behind proxies. Returns 429 with Retry-After.

* fix: sanitize error responses and remove PII from logs (TASK-155)

Replace all writeError(500, err.Error()) calls with writeInternalError
that logs the real error server-side and returns a generic message to
clients. Remove email addresses, user IDs, and password reset tokens
from log output to prevent PII leakage.

* feat: add security headers, configurable CORS, and secure cookies (TASK-160)

Add SecurityHeaders middleware (CSP, X-Frame-Options, nosniff,
Referrer-Policy, Permissions-Policy). Make CORS origins configurable
via PAD_CORS_ORIGINS env var. Add PAD_SECURE_COOKIES for TLS
deployments (sets Secure flag on session/CSRF cookies and enables
HSTS). Also adds X-CSRF-Token to CORS allowed headers.

* fix: address PR review — lazy router init and trusted IP for rate limits

Fix two issues flagged by Codex:

1. CORS/HSTS config was ignored because setupRouter() ran in New()
   before SetCORSOrigins/SetSecureCookies were called. Now uses
   sync.Once to lazily build the router on first ServeHTTP/Listen.

2. Rate limiter read X-Real-IP directly from untrusted headers,
   allowing clients to spoof IPs. Now uses RemoteAddr only (which
   chimiddleware.RealIP already sanitizes from trusted proxy headers).
2026-04-05 10:26:00 -04:00
xarmian 46447e5504 feat: user management & authentication (Phase 6) (#14)
* feat: add user management database migration and models

Add migration 012_users.sql with users, sessions, and workspace_members
tables. Add user_id columns to api_tokens, items, comments, activities,
item_links, and item_versions for proper user attribution. Create Go
model structs (User, Session, WorkspaceMember) in models/user.go.

* feat: add store layer for users, sessions, and workspace members

Implement CRUD operations for user management:
- users.go: create, get, update, list, validate password (bcrypt)
- sessions.go: create, validate, delete, cleanup expired (SHA-256 hashed tokens)
- workspace_members.go: add/remove members, role management, access checks

Adds golang.org/x/crypto/bcrypt dependency. Includes 16 new tests
covering all store methods, password validation, session lifecycle,
and workspace membership operations.

* feat: rewrite auth system from single-password to user-based

Replace single-password auth with email/password user authentication:
- New endpoints: POST /auth/register, GET /auth/me
- Rewritten: POST /auth/login (email+password), GET /auth/session
  (needs_setup detection), POST /auth/logout (DB session destroy)
- Delete in-memory SessionManager, use DB-backed sessions via store
- New middleware: SessionAuth (cookie→user), RequireAuth (with
  fresh-install passthrough when no users exist)
- Remove Password field from config, PAD_PASSWORD env var, SetPassword()

All 23 existing server tests pass (fresh DBs have no users → passthrough).

* feat: add workspace access control middleware

Add RequireWorkspaceAccess middleware that checks workspace_members for
authenticated users, with fallback for legacy API tokens and fresh
installs (no users → implicit owner). Includes role hierarchy helpers
(workspaceRole, requireRole) for downstream permission checks.
Wire middleware into the /{slug} workspace route group.

* feat: add CLI auth commands and credential storage

Add pad login, pad logout, pad whoami commands with credential
storage in ~/.pad/credentials.json (0600 permissions). Update CLI
HTTP client to auto-attach auth tokens and X-Pad-Agent header on
all requests. Add auth API methods (Login, Register, Logout,
CheckSession, GetCurrentUser). Extend .pad.toml with optional
agent_name field. Add golang.org/x/term for masked password input.

* feat: derive actor/source from auth context in all handlers

Replace hardcoded "user"/"web" actor/source strings with auth-aware
helpers. actorFromRequest() derives actor ("user"/"agent" via
X-Pad-Agent header) and source ("web"/"cli" from auth method).
agentMeta() merges agent name into activity metadata. Update all
item, document, comment, and move handlers to use request-based
logActivity/logActivityWithMeta. Remove hardcoded CreatedBy/Source
from all CLI commands — server now determines these from auth context.

* feat: frontend auth — login, registration, auth guard, user menu

Rewrite login page with email/password fields, add registration page
for first-time setup, update auth guard to handle needs_setup redirect.
Add user menu to sidebar with logout. Update API client with new auth
methods (register, login with email, session with needs_setup flag).

* feat: migrate API tokens from workspace-scoped to user-owned

API tokens now have a user_id owner and optional workspace_id scope.
CreateAPIToken takes userID as first parameter. ValidateToken resolves
the token's user into the request context. TokenAuth middleware now
sets ctxCurrentUser when a user-owned API token is used. Add user-
scoped endpoints: GET/POST/DELETE /auth/tokens. Keep workspace-scoped
token endpoints for backwards compatibility.

* feat: workspace membership, invitations, and role enforcement

Add workspace_invitations table (migration 013) with join codes.
Implement invitation store methods (create, get by code, accept,
list). Add member management handlers: list members + invitations,
invite (auto-adds existing users or creates invitation), remove
member, change role, accept invitation by code. Add API routes
under /workspaces/{slug}/members/* and /invitations/{code}/accept.
Add CLI commands: pad members, pad invite, pad join.

* feat: auth tests and documentation updates

Add comprehensive auth endpoint tests: registration flow (first user
becomes admin), login/logout, validation errors, duplicate email,
auth enforcement (401 after users exist, exempt paths), /me endpoint.
Update CLAUDE.md and README.md to document user-based auth system,
replacing old PAD_PASSWORD references with pad login/members/invite
workflow and role-based access control.

* feat: add members management UI to workspace settings page

Add Members section to settings with: member list (avatar, name,
email, role), role change dropdown (owner only), remove button
(owner only), pending invitations display with join codes, and
invite form with email + role picker. Add members API methods to
the TypeScript client (list, invite, remove, updateRole).

* fix: backfill workspace owners for pre-migration workspaces

Add backfillWorkspaceOwners() that runs on server start. For any
workspace with no members, adds the first admin user as owner.
This handles the migration case where workspaces existed before the
user system — without it, the members list shows empty.

* feat: shareable invite links with /join/[code] page

Replace raw join codes with full shareable URLs. Server generates
join_url using its configured base URL (e.g. https://pad.example.com/
join/a3f8b2c1). New /join/[code] page handles the full flow: checks
auth → shows login/register if needed → accepts invitation → redirects
to workspace. Settings page shows "Copy invite link" button that copies
URL to clipboard. CLI outputs shareable link instead of raw code.

* fix: auto-add workspace creator as owner, integrate auth into pad init

handleCreateWorkspace now adds the authenticated user as owner of the
new workspace immediately — no more relying on the startup backfill.

pad init now checks auth status before making API calls. If no users
exist, prompts to register. If not logged in, prompts to login. After
auth, proceeds with workspace creation normally.

* fix: add join_url to invite response type in API client

* fix: address codex review — invite registration, logout token revocation, workspace scoping

- Allow registration with valid invitation_code (fixes invite flow for new users)
- Revoke Bearer session tokens on logout, not just cookies
- Filter workspace listing to user's memberships (admins see all)
2026-03-28 15:43:09 -04:00
xarmian a30655aa0e feat: add optional password authentication for web UI (#6)
When PAD_PASSWORD is set (env var) or password is configured in
~/.pad/config.toml, the server requires authentication:

Backend:
- SessionManager with HMAC-SHA256 signed cookies (7-day TTL)
- POST /api/v1/auth/login — validates password, sets session cookie
- GET /api/v1/auth/session — returns auth status (exempt from auth)
- POST /api/v1/auth/logout — destroys session, clears cookie
- PasswordAuth middleware gates all API/page requests
- API tokens still work independently (no change to CLI flow)
- Constant-time password comparison + 500ms delay on failure

Frontend:
- Login page at /login with password form and error handling
- Root layout checks auth status before loading app shell
- Global 401 handler in API client redirects to /login
- Login page renders without sidebar/app shell

When no password is configured, everything works exactly as before
(zero-friction localhost). This is a security requirement for any
deployment that exposes the server beyond localhost.
2026-03-28 11:36:10 -04:00
xarmian cf83a60fc2 feat: Add API tokens system for programmatic access
Add a complete API tokens system enabling CI/CD integrations, custom
scripts, and third-party tools to authenticate with the Pad API.

- Migration 011: api_tokens table with hash-based token storage
- Model: APIToken, APITokenCreate, APITokenWithSecret types
- Store: CRUD operations with crypto/rand generation and SHA-256 hashing
- Middleware: Bearer token auth that sets workspace context
- Handlers: POST/GET/DELETE /workspaces/{ws}/tokens endpoints
- CORS: Allow Authorization header for token-based requests
2026-03-28 14:13:50 +00:00