Commit Graph

316 Commits

Author SHA1 Message Date
xarmian 1d8b04e279 feat(server): expose password_set on /auth/me + TS User type (#819)
The delete-account UI must branch between a password prompt (self-host or
any user with a password) and a confirm-only flow (OAuth-only users with
no password). The client had no signal for this: oauth_providers is not a
valid proxy since a user can have both a password and linked OAuth.

Add "password_set": user.HasPassword() to the /auth/me response map and
password_set?: boolean to the TS User interface. A handler test asserts
the field for both a password user (true, via bootstrap) and an
OAuth-only user (false, via CreateOAuthUser).

Closes TASK-1957.

Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
2026-07-05 18:44:26 -04:00
xarmian e6eec608e0 feat(web): default agent-connect modal to MCP setup, steer zero-grant users (#817)
Reorder ConnectWorkspaceModal so MCP setup is the default and first tab —
most users landing here have never connected an agent, so the OAuth "fresh
agent" path is their real first step. CLI is second; the claim code moves to
a third "Connect code" tab, reframed as a scoped-grant add-on rather than the
(misleading) "recommended" default it was.

Close the zero-grants dead end: add Store.HasActiveConnectionForUser and
surface has_any_connection on the claim-code endpoint, so a user who opens the
Connect-code tab with no connected agent gets steered to set one up first
instead of a live-looking but unredeemable code. Hide the MCP + code tabs on
self-host deployments without a public MCP URL (both depend on the remote
OAuth server), leaving CLI as the sole, default path there.

Verified: go build ./..., go test ./internal/server/ ./internal/store/,
web npm run check (0 errors), and a Codex review all pass clean.

Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
2026-07-05 12:05:24 -04:00
xarmian 7eff417d83 fix(server): deny restricted owners from escalating collection access (BUG-1925) (#815)
handleSetMemberCollectionAccess gated only on requireRole(r, "owner"), with
no validation of the caller's own visibility. member_collection_access has
no role exclusion (BUG-1920), so a workspace-role owner independently
restricted via collection_access="specific" could PATCH themselves (or any
other member) to mode="all", or grant a collection outside their own
visible set — defeating the entire BUG-1917/1918/1920/1921 visibility
family plus the BUG-1922 export gate in one authenticated request.

requireCallerCanSetCollectionAccess now denies a restricted caller from
setting mode="all" for any target, and validates every requested
collection ID against the caller's own visible set, narrowed to
guestResourceFilter's fullCollIDs (mirroring requireCollectionFullyVisible,
BUG-1920 codex R2) so an item-level grant can't be escalated into
collection-wide access. Unrestricted callers are unaffected.
2026-07-04 14:39:33 -04:00
xarmian 84637ba6cb fix(server): deny account export to restricted owners (BUG-1945) (#814)
handleExportAccount (/auth/export) dumped full collections/items for
every workspace a user owns without checking collection_access,
bypassing BUG-1922's workspace-export gate via a different route. A
restricted owner (collection_access="specific") could exfiltrate
hidden collections through the account-export affordance instead.

Mirror BUG-1922's outright-deny: before any response byte is written,
scan every owned workspace with visibleCollectionIDs and refuse the
whole export with 403 if any owned workspace is restricted, rather
than silently omitting it from an unflagged partial dump.
2026-07-04 14:38:01 -04:00
xarmian b633144009 fix(server): deny workspace export to restricted owners (BUG-1922) (#813)
Workspace export (both the JSON and tar.gz bundle forms of GET
/workspaces/{slug}/export) streamed the full unfiltered workspace
regardless of the caller's collection_access, letting a restricted
owner exfiltrate collections hidden from them. Per Dave's ruling,
export is a backup/portability affordance rather than a
visibility-scoped view, so a restricted caller is denied outright
(403) instead of receiving a filtered subset.
2026-07-04 13:54:08 -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 f34e66254b feat(admin): admin force-verify email override (TASK-1939) (#809)
Wave 4 of PLAN-1933 (DR-7). Adds a web-console-only admin override to
force-verify a locked-out unverified account. No CLI, no MCP (matches
the no-auth-mutation-on-MCP rule).

Server:
- POST /api/v1/admin/users/{userID}/verify-email — admin-only, mirrors
  handleAdminEnableUser. Reuses the existing SetUserEmailVerified store
  method (added in Wave 3b) and audits with the distinct
  ActionEmailVerifiedByAdmin action (separate from the self-serve
  ActionEmailVerified — a force-verify is an operator security action).
  Idempotent (already-verified returns 200 no-op).
- Surface email_verified_at in the admin list + get-user JSON so the
  console knows verified state (Wave 1 only added the store-level scan).

Web:
- adminVerifyEmail client method (api.admin.verifyEmail) confined to the
  admin section of client.ts.
- "Mark email verified" action in the admin user panel (UserSettingsForm),
  shown only when the target user is unverified.
- email_verified_at added to the AdminUser type.

Tests: admin force-verifies an unverified user (flips email_verified_at +
audits ActionEmailVerifiedByAdmin, not the self-serve action) and the
now-verified session is unblocked; non-admin -> 403 with no side-effect.

Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
2026-07-04 04:15:09 -04:00
xarmian 4a7c054223 feat(server): cloud email self-registration + verify-email/resend endpoints (TASK-1938) (#808)
Wave 3b of PLAN-1933 turns ON Pad Cloud email/password self-registration
with mandatory email verification.

- DR-6: relax handleRegister to allow self-serve signup when
  cloudMode && emailConfigured. emailConfigured = s.email != nil AND a
  USABLE public base URL (non-empty, not a 0.0.0.0/:: bind-all host), so
  no unverifiable user is ever created. Self-serve is the ONLY path that
  writes email_verified_at = NULL (UserCreate.Unverified); admin-created
  and invited signups stay verified. Mints + sends a verification email.
- DR-5: POST /auth/verify-email (ConsumeEmailVerification → flips
  email_verified_at → returns fresh user) and POST /auth/resend-verification
  (always-200, enumeration-safe; minting a new token invalidates the prior
  one). Both wired into the rate-limiter path switch (PasswordReset bucket).
- DR-1: handleAcceptInvitation verifies an unverified account on accept
  (email-bound invite proves email control), via new store method
  SetUserEmailVerified.
- DR-11: keep the existing clear 409 on duplicate email at signup.

Session freshness: currentUser is re-read fresh from the DB per request
(ValidateSession → GetUser), so flipping email_verified_at unblocks the
same session's subsequent mutations immediately under RequireVerifiedEmail
(Wave 3a) — no session-row rewrite needed. Test covers verify → same-session
mutation succeeds.

Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
2026-07-04 03:32:48 -04:00
xarmian 31053086ee feat(server): RequireVerifiedEmail enforcement across all mutation perimeters (TASK-1937) (#807)
PLAN-1933 DR-4 (Wave 3a). Enforce, on Pad Cloud only, that an
authenticated-but-unverified user cannot mutate content or mint
credentials. Cloud-only + unauthenticated + verified are all no-ops, so
this is inert in production until Wave 3b starts creating unverified
users; the tests drive the state directly via the store's explicit
UserCreate.Unverified control.

Core rule: block only when cloudMode && currentUser != nil &&
!IsEmailVerified(), on mutating methods (POST/PATCH/PUT/DELETE). Returns
403 email_not_verified. The middleware does NOT inherit CSRFProtect's /
RequireAuth's blanket /api/v1/auth/* exemption — it decides for itself.

Perimeters gated (systematic DR-4 audit — one test each):
- /api/v1 core writes (session AND PAT) — RequireVerifiedEmail method
  gate mounted after RequireAuth (server.go).
- Authenticated /auth/* mutations — token create/rotate/delete, PATCH
  /me, 2FA setup/disable, OAuth link/unlink, and cli-session approve —
  all fall through the method gate (no auth exemption); logout,
  verify-email, resend-verification, delete-account allowlisted.
- Collab WS GET-upgrade — authorizeCollabAccess (a GET the method gate
  can't catch; it persists Yjs edits).
- Remote MCP write path — dispatcher RequireVerifiedEmail hook fired in
  buildAuthedRequest (the single chokepoint every synthesized write
  passes through), wired in cmd/pad/main.go.
- OAuth-provider authorize + authorize/decide — emailUnverifiedBlocked
  checks (mounted outside /api/v1; decide mints the auth code).
- POST /api/v1/import/url — SSRF/abuse surface, method gate.

Carve-outs: POST /api/v1/invitations/{code}/accept stays open for
unverified invitees (DR-1); legacy no-user workspace PATs are
intentionally ungated (currentUser==nil). Self-host (!cloudMode) is a
full no-op.

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

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

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

Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
2026-07-04 01:19:41 -04:00
xarmian 6a63fba188 feat(store): add users.email_verified_at column + model plumbing (TASK-1935) (#805)
Wave 1 of PLAN-1933 (email verification). Pure infra — nothing reads the
column until Wave 3, so this is behaviourally a no-op and mergeable early.

- Migration 070 (SQLite) / 048 (Postgres): add nullable email_verified_at
  TEXT, mirroring disabled_at. UNCONDITIONALLY backfill every existing row
  to verified (RFC3339 'Z'-suffixed) so no existing / OAuth / self-host
  account is write-locked on deploy (inverted vs password_set's conditional
  backfill). SQLite ALTER without IF NOT EXISTS; Postgres with it.
- SAFE default = verified (DR-3): CreateUser / CreateOAuthUser write a
  verified timestamp unless UserCreate.Unverified is explicitly requested
  (only the future cloud self-serve branch will set that). A missed call
  site fails SAFE (verified), not write-locked.
- models.User.EmailVerifiedAt + IsEmailVerified() (mirror IsDisabled).
- Update userColumns + BOTH scan sites (scanUser AND the inline SearchUsers
  scan) so the admin user list keeps working.
- Expose derived email_verified bool in sessionUserPayload for a later wave.

Gates: make check + make test-pg both green (dual-dialect verified).

Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
2026-07-04 00:45:48 -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 ae62c097ca refactor(mcp): consolidate project next/standup/changelog onto REST endpoints (TASK-1916) (#802)
* refactor(mcp): consolidate project next/standup/changelog onto REST endpoints (TASK-1916)

dispatchProjectNext/Standup/Changelog were a second server-side copy of
the next/standup/changelog reshaping contract, written before TASK-1894
shipped dedicated REST endpoints for the same data. Replace the ~200
lines of duplicate reshaping with thin proxies that validate workspace
(preserving the pad_set_workspace hint) and forward to
GET /next|/standup|/changelog, relying on packageHTTPResponse's existing
array-wrap (BUG-985) and the REST handlers' own days-default and
per-status best-effort semantics rather than replicating them.

dispatch_http_slice4.go is deleted; its unrelated dispatchLibraryActivate
moves to dispatch_http_library.go now that the file's other three
methods are gone. KEEP IN SYNC comments across
handlers_project_intel.go/server.go/tests collapse from "three
reproductions" to "CLI + REST, MCP proxies to REST."

* fix(server): pass nav-lenient visibleIDs to changelog's parent enrichment (codex R1 P1, TASK-1916)

handleGetProjectChangelog passed guestResourceFilter's narrowed collIDs
into enrichItemsWithParent instead of the nav-lenient visibleCollectionIDs
set handleListItems uses for the same enrichment call. For a guest whose
granted item's parent lives in an item-grant-only collection (nav-visible
but excluded from the narrowed full-access set), this silently dropped
the parent link fields, causing itemMatchesParentFilter to exclude the
item from ?parent= results even though the guest can otherwise see it.

The root cause predates TASK-1916 (introduced alongside the REST endpoint
in TASK-1894), but this consolidation imports it into MCP wire behavior
via dispatchProjectChangelog's proxy, so it's in scope to fix here.

projectIntelVisibility now returns the unnarrowed visibleCollectionIDs
result (navVisibleIDs) alongside the existing (collIDs, itemIDs) pair;
handleGetProjectChangelog uses navVisibleIDs for enrichItemsWithParent
while keeping collIDs for the list query, mirroring handleListItems'
pattern exactly. handleGetProjectStandup and handleGetProjectNext have no
parallel enrichItemsWithParent call (verified by reading both, and
buildDashboardResponse) so neither needed the same treatment.

Added TestProjectChangelogEndpoint_GuestParentFilter_ItemGrantOnlyCollection,
confirmed to fail against the pre-fix code and pass against the fix.
2026-07-03 22:29:40 -04:00
xarmian f3335adea2 fix(server): filter handleListUserGrants through caller visibility (BUG-1928) (#799)
handleListUserGrants returned a target user's raw collection/item grants
(including collection_id/item_id) to any workspace owner unconditionally,
letting a restricted owner (collection_access="specific") enumerate
hidden-resource IDs — the disclosure half of the primitive BUG-1923's
handlers closed the action half of.

Filter the response through the caller's visibility when caller != target:
collection grants against guestResourceFilter's strict full-access set
(same set requireCollectionFullyVisible narrows to — item-grant-only
collections don't qualify), item grants via a bulk item_id->collection_id
lookup (GetItemCollectionRefs, state-agnostic so soft-deleted parents stay
listed) plus the existing isItemVisibleToGuest set-membership check.
Self-queries and unrestricted callers stay unfiltered, the latter via a
cheap short-circuit.

GetDeletedItemsWithCollection's query had no deleted_at filter despite its
name; renamed the shared implementation to GetItemCollectionRefs and kept
the old name as a wrapper for its existing delta-sync caller.
2026-07-03 20:18:29 -04:00
xarmian 22ac55929a fix(server): gate ID-keyed grant/share-link handlers on target visibility (BUG-1923) (#798)
* Gate ID-keyed grant/share-link delete and view-history handlers on target visibility

handleDeleteCollectionGrant, handleDeleteItemGrant, handleDeleteShareLink,
and handleShareLinkViews operated on a grant/link ID directly with only a
requireMinRole(owner) check — a restricted owner who knew an ID could
revoke a grant or share link (or read its view-history) on a resource
hidden from them by collection_access="specific". Unlike their
slug-resolving create/list siblings (fixed in BUG-1920), these handlers
never resolved the parent item/collection, so the visibility gate never
ran.

Resolve the grant/link's parent before acting: collection-scoped records
go through the strict requireCollectionFullyVisible (no item-grant
promotion, matching the minting/listing gates), item-scoped records
through requireItemVisible. Item lookups use GetItemIncludeDeleted so a
grant/link on a trashed-but-visible item stays revocable, matching
handleListItemGrants' existing ResolveItemIncludeDeleted precedent.

Fixes BUG-1923.

* Fix soft-deleted-collection regression in BUG-1923's visibility gates

Codex round 3 found that the BUG-1923 fix used GetCollection to resolve
the parent collection for collection-scoped grant/share-link operations.
GetCollection filters deleted_at IS NULL, but DeleteCollection soft-deletes
and does NOT cascade-delete collection_grants or share_links — so the
moment a collection was archived, its grants/share-links became
permanently un-revocable and un-inspectable via the API (parent resolution
404'd before the visibility check even ran).

Switch both call sites to the existing GetCollectionAnyState store method
(no deleted_at filter; already used by the open-children guard for the
same reason) instead of adding a new near-duplicate getter. Applied
consistently to both handleDeleteShareLink and handleShareLinkViews via
the shared requireShareLinkTargetVisible helper, so view-history stays
readable for revocation decisions on an archived collection's link, not
just the delete path.

Confirmed via store trace: an unrestricted owner (visibleCollectionIDs ==
nil) is unaffected either way and is the main regression case, now fixed.
A restricted owner's member_collection_access-derived visibility also
survives the parent's soft-delete, since that lookup reads the raw table
with no deleted_at join.
2026-07-03 19:41:57 -04:00
xarmian 0c0a71f96b fix(server): bearer-gate canEditComment's admin bypass (BUG-1919) (#796)
canEditComment's unconditional u.Role == "admin" bypass let a
bearer-authed (PAT/CLI/MCP) platform admin edit or delete any user's
comment in a workspace where they're a member, contradicting the
BUG-1616/1617 bearer-suppression intent (same family as BUG-1917/1918).
Gate the bypass on !isBearerAuth(r), mirroring the idiom already used
in handlers_collab.go's authorizeCollabAccess. Cookie-session admins
keep the existing behavior, including editing empty-user_id legacy
comments; bearer-authed authors can still edit their own comments.
2026-07-03 18:24:36 -04:00
xarmian d78efab167 fix(server): gate collection update/delete on visibility (BUG-1921) (#795)
handleUpdateCollection and handleDeleteCollection resolved a collection
by slug and gated only on requireMinRole("owner"), with no visibility
check afterward. A workspace-role owner independently restricted via
collection_access="specific" (member_collection_access has no role
exclusion, per BUG-1920) could rename, re-schema, or delete a
collection hidden from them.

Adds requireCollectionFullyVisible (added in BUG-1920 for the
share-link/grant minting twins) to both handlers, immediately after
the existing "collection not found" nil-check and after the pre-
existing requireMinRole 403 gate. This is the STRICT full-collection-
access variant, not handleGetCollection's nav-lenient
visibleCollectionIDs+isCollectionVisible check: an item-level grant on
a single item inside a hidden collection must not qualify as authority
to mutate the entire collection record. Restricted owners (session or
bearer) now get 404 on PATCH/DELETE of a hidden collection;
unrestricted owners are unaffected.

Downstream-consumer audit: every other collSlug-resolving handler
(views, artifact import, checkbox/child progress, item list/create,
bulk/single item move, grants, share-links) already gates on
visibility or edit permission. handleUpdateCollection/
handleDeleteCollection were the only ungated collection-record
mutation path.

Claude-Session: https://claude.ai/code/session_01CL1pBjNpPUX6SWkuAuYXHS
2026-07-03 18:12:35 -04:00
xarmian d9b9dd9499 fix(server): gate share-link and grant minting on item/collection visibility (BUG-1920) (#794)
* fix(server): gate share-link and grant minting/listing on item/collection visibility (BUG-1920)

A workspace-role "owner" can be independently restricted via
collection_access="specific" (handleSetMemberCollectionAccess has no
role exclusion), but handleCreateItemShareLink, handleListItemShareLinks,
handleListItemGrants, and handleCreateItemGrant gated only on
requireMinRole("owner") with no visibility check afterward — letting a
restricted owner (any auth class) mint a public share-link token or a
grant for an item in a collection hidden from them, an exfiltration path
since share links are public-read. The collection-level twins
(handleCreateCollectionShareLink, handleListCollectionShareLinks,
handleListCollectionGrants, handleCreateCollectionGrant) had the same gap.

Adds requireItemVisible (existing, bearer-aware post BUG-1917/1918) to
the four item-resolving handlers, and a new requireCollectionVisible
helper (mirroring handleGetCollection's visibleCollectionIDs +
isCollectionVisible idiom) to the four collection-resolving handlers.
Restricted owners (session or bearer) now get 404 minting/listing
share-links or grants for hidden items/collections; unrestricted owners
and non-owners (403 via the pre-existing requireMinRole gate) are
unaffected.

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

* fix(server): require full-collection access for share-link/grant minting (BUG-1920 R2)

Codex R2 caught a gap in the collection-level half of the previous
commit: VisibleCollectionIDs (used by requireCollectionVisible) folds in
collections that are visible ONLY via an item-level grant, "so the
collection appears in navigation" — intentional for handleGetCollection,
but it let a restricted owner holding nothing more than an item grant on
one item inside a hidden collection mint/list a share link or grant for
the ENTIRE collection.

Renames requireCollectionVisible to requireCollectionFullyVisible and,
mirroring reportVisibleCollections' fullCollIDs narrowing
(handlers_reports.go), restricts the acceptable set to full-collection-
access collections (collection grants + member_collection_access +
system collections) whenever the caller holds any item-level grants —
an item-grant-only collection no longer qualifies for collection-wide
minting/listing. handleGetCollection is untouched; its nav-lenient
check is intentional for metadata viewing. Item-level requireItemVisible
call sites are unchanged — an item grant legitimately entitles the
holder to act on that item.

Claude-Session: https://claude.ai/code/session_01CL1pBjNpPUX6SWkuAuYXHS
2026-07-03 16:25:52 -04:00
xarmian da21598f10 fix(server): bearer-gate checkItemVisible's admin bypass (BUG-1918) (#793)
Bearer-authed platform admins who are restricted workspace members can
no longer read, update, delete, or export a hidden collection's item by
direct ref — checkItemVisible's unconditional admin bypass previously
ignored isBearerAuth entirely, letting a bearer admin sidestep
BUG-1917's list-level scoping for anyone who could guess a ref. Cookie
session admins keep the existing unrestricted web-UI affordance.

checkItemVisible gains an isBearer parameter (mirroring the existing
authIsBearer idiom in resolverWorkspaceRole / guestResourceFilterCore)
and gates its admin bypass on !isBearer. requireItemVisible's own
signature is unchanged, so its ~20 call sites (comments, links, stars,
versions, timeline, playbooks, backlinks, storage, artifact-export)
inherit the fix for free; the three direct checkItemVisible callers
(writeItemResolveError, handleBulkItems, resolverItemVisible) are
updated explicitly.
2026-07-03 15:31:17 -04:00
xarmian a7f6fdf099 fix(server): bearer-gate visibleCollectionIDs to close BUG-1917 (#792)
Bearer-authed platform admins (PAT/CLI/OAuth) who are restricted
members of a workspace were unrestricted on dashboard, bootstrap,
items, and graph reads (plus item creation) — the last remaining
gap in the BUG-1616/1617 pattern, where RequireWorkspaceAccess
already suppresses the admin bypass for bearer auth everywhere
else. visibleCollectionIDs now applies the same `!isBearerAuth(r)`
gate as reportVisibleCollections, so a bearer admin who is only a
scoped member is correctly restricted to their membership; a
cookie-session admin keeps the existing unrestricted web UI
affordance. Because this is the shared helper, every consumer
(buildDashboardResponse -> /dashboard and /bootstrap, handleListItems,
handleGetWorkspaceGraph, handleCreateItem's collection check, and
~25 other call sites) is fixed at once.

This also completes TASK-1894's known asymmetry: standup's
blockers/suggested_next sections (sourced from buildDashboardResponse)
are now scoped for bearer admins just like its completed/in_progress
sections already were. Folds the now-redundant
bearerAwareVisibleCollectionIDs into the shared gate.

Claude-Session: https://claude.ai/code/session_01CL1pBjNpPUX6SWkuAuYXHS
2026-07-03 14:40:54 -04:00
xarmian 7c0b13767f feat(server): REST endpoints for project next/standup/changelog + WebMCP wiring (TASK-1894) (#791)
* feat(server): add REST endpoints for project next/standup/changelog

Adds GET /workspaces/{ws}/next, /standup, /changelog — session-authed
reads mirroring `pad project next|standup|changelog --format json`,
reusing buildDashboardResponse + store.ListItems so the browser
WebMCP surface stops returning "not available" for these catalog
actions (TASK-1894). Cross-references the MCP HTTP transport's
existing dispatchProjectNext/Standup/Changelog (dispatch_http_slice4.go)
with KEEP IN SYNC comments at both sites, since this is now a third
reproduction of the same reshaping contract pending a follow-up
consolidation.

* feat(web): wire next/standup/changelog into WebMCP dispatch + api client

Adds client.ts next()/standup()/changelog() methods and replaces the
three "not available in the browser" dispatch.ts stubs with real
handlers now that the backend endpoints exist (TASK-1894). Extracts
DashboardSuggestion as a shared type and adds StandupResponse /
ChangelogResponse types mirroring the Go response shapes.

* fix(server): make projectIntelVisibility bearer-aware (TASK-1894 codex R1)

standup/changelog's own item-list scoping used visibleCollectionIDs, which
has no bearer gate: a platform admin authenticated via a bearer token
(PAT/CLI/OAuth) who is only a restricted member of a workspace got the
unrestricted admin view instead of being scoped to their real membership.
Adds bearerAwareVisibleCollectionIDs, mirroring reportVisibleCollections'
existing BUG-1616/1617 gate, and switches projectIntelVisibility onto it
while preserving its item-level grant handling (which reportVisibleCollections
deliberately drops for aggregate reports).

buildDashboardResponse (and therefore /next, and standup's blockers/
suggested_next sections) is intentionally left ungated in this change —
gating it would break next's parity with dashboard.suggested_next and
diverge it from the CLI and MCP siblings. The resulting asymmetry is
documented inline pending a follow-up fix to buildDashboardResponse itself.

* docs(server): reference BUG-1917 in projectIntelVisibility comments

Replaces the textual placeholder ("the visibleCollectionIDs bearer-gate
bug filed from TASK-1894 review") with the actual bug number now that
it's been filed. Comment-only change, no behavior difference.
2026-07-03 13:51:04 -04:00
xarmian 0a1dbc8c73 perf(test): wire remaining store helpers onto storetest fixture (TASK-1915) (#789)
newPadServer (internal/mcp), testStoreOAuth (internal/oauth), and
newMetricsTestServer (internal/server) were left on the slow
per-call migration path after IDEA-1914/#788 wired testServer and
store's white-box testStore onto storetest.NewSQLite. Switch all
three, and add minimal TestMains to internal/mcp and internal/oauth
to release storetest's process-wide template DB, matching
internal/server's existing TestMain.
2026-07-03 12:17:03 -04:00
xarmian 20544fdd44 perf(test): build the SQLite migration chain once per test binary (IDEA-1914) (#788)
* perf(test): build the SQLite migration chain once per test binary (IDEA-1914)

internal/server's -race suite spent ~30 minutes replaying all 69
migrations + 3 backfills per test (~2.7s each, 622 store-backed tests,
BUG-1913). Add internal/store/storetest, which runs the full migration
chain once into a checkpointed, sidecar-free template DB (sync.Once)
and hands every test a plain file copy opened via store.New. Wire it
into internal/server's testServer/testServer_Stop_DrainsRateLimiterCleanup
and internal/store's own testStore (duplicated inline there — an
import cycle rules out sharing storetest with store's white-box
tests). Postgres-mode tests are untouched.

internal/server -race: 1819s -> 183s.

* fix(test): plug template-dir leak and Cleanup race in storetest fixture

Codex round 2 on IDEA-1914: buildTemplate/buildSQLiteTemplate left the
MkdirTemp'd template dir on disk if store.New/checkpoint/journal_mode
failed after mkdir succeeded — now removed via a disarm-on-success
defer in both mirrored copies. Also guard Cleanup()/removeSQLiteTemplate
against racing an in-flight build+copy with a sync.RWMutex (read-locked
across build+copy, write-locked for removal) in both places.
2026-07-03 10:15:49 -04:00
xarmian e41ed8a236 fix(server): reserve parent/plan schema field keys (TASK-1912) (#786)
* fix(server): reserve parent/plan schema field keys (TASK-1912)

A collection schema field keyed exactly "parent" or "plan" makes the
parent-link extraction sites in handlers_items.go silently skip
fields-JSON extraction, disabling subtask linking with no error
anywhere. Reject newly-added occurrences of these keys on collection
create/update (grandfathering keys already present in a prior schema),
and add them to the web's reserved-key list so authors are steered
away before hitting the 400.

* fix(server): reject empty-string schema on collection PATCH (TASK-1912)

Codex round 2: handleUpdateCollection's validation guard was skipped
whenever input.Schema was a non-nil pointer to "", so a PATCH with
{"schema": ""} stored the empty string verbatim and every later
item-create against that collection 500'd instead of the mutation
being rejected up front. Drop the empty-string carve-out so "" flows
into json.Unmarshal, fails, and returns the existing 400 "Invalid
schema JSON". Omitting the schema field entirely (nil) is unaffected.
2026-07-03 03:27:04 -04:00
xarmian 584ac9a806 fix(web): treat CLI/MCP-created workspaces as agent-connected (BUG-1557) (#781)
`pad init` connects an agent (installs the skill, stores credentials) and
creates a workspace, but the web UI still showed the "connect an agent"
banner and onboarding launchpad. The only signal for "agent connected" was
has_agent_activity — an item existing with source cli/mcp — and a fresh
pad-init workspace has zero items, so the UI nagged to connect an agent the
user already had.

Give the server a truthful signal: a workspace created through an agent
surface already has an agent wired up before it creates its first item. Add
a `source` column to workspaces (web/cli/mcp), attributed authoritatively
server-side from the request auth shape (actorFromRequest) — never from the
request body, so a web client can't spoof "cli" to self-suppress the
prompts. The dashboard ORs source in (cli,mcp) into has_agent_activity when
the cheap item check comes up empty.

- migrations 069 (sqlite) / 047 (postgres): workspaces.source NOT NULL
  DEFAULT '' (legacy rows stay "unknown", never treated as agent-created)
- models.Workspace.Source + WorkspaceCreate.Source (json:"-", server-set)
- thread source through the CreateWorkspace INSERT + all 7 workspace scan
  sites (workspaces.go, workspace_members.go)
- handleCreateWorkspace derives source from actorFromRequest
- OnboardingLaunchpad step 1 collapses to "Agent connected" when the agent
  is already wired up, shifting emphasis to "tell it to set up"

Web modal and cloud-signup auto-create flows are unchanged and still
correctly prompt to connect (source web / empty).

Tests: store source round-trip across reads; dashboard reports
agent-connected for a cli-created workspace with zero items; web-created
stays not-connected until an agent item exists; a web body-spoofed source
is ignored.

Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
2026-07-01 23:25:24 -04:00
xarmian 9b9e2eb26b fix(admin): stop leaking 2FA secret via GET /admin/settings (BUG-1909) (#779)
platform_settings holds admin-managed UI keys alongside server secrets
(2fa_challenge_secret — the 2FA challenge HMAC signing key) and
plan_limits_* rows. handleGetPlatformSettings returned the whole table,
so any admin client received the 2FA signing secret (and limit rows) in
plaintext — a read-side secret exposure (not corruptible; the key isn't
in the write whitelist, but it was fully exposed).

Adopt deny-by-default: introduce adminManagedSettings, the canonical
allowlist of the 8 admin-editable keys, and share it across read and
write so they can't drift. GET now projects only those keys (masking
maileroo_api_key); anything else in platform_settings — the 2FA secret,
plan-limit rows, any future internal secret — is never exposed. PATCH
gates writes on the same set.

Tests: SecretsNotExposed (2fa_challenge_secret and plan_limits_* absent
from GET, raw-body substring check, only allowlisted keys returned) and
SecretNotWritable (PATCH can't overwrite the 2FA secret or write limit
rows). A codebase-wide secret-exposure audit found no other confirmed
leak surfaces.

Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
2026-07-01 17:44:43 -04:00
xarmian 7e5917056a fix(admin): don't persist masked Maileroo API key on email settings save (BUG-1890) (#778)
* fix(admin): don't persist masked Maileroo API key on email settings save (BUG-1890)

The admin settings "Save Email Settings" button PATCHed the whole
platformSettings object. GET /admin/settings returns the Maileroo key
masked (abcd...wxyz for >8 chars, **** otherwise), so saving without
re-typing the key persisted the mask over the real key — silently
breaking email until re-entered.

Two layers:

- Client (+page.svelte): track whether the API-key field was edited
  (apiKeyEdited flag) and scope the PATCH to the email fields this form
  owns (mirrors the TASK-1889 Integrations save). The key is included
  only when the admin actually edited it; an untouched save preserves
  the stored key, and clearing the field still sends "" to disable.

- Server (handlers_admin.go): extract maskAPIKey() as the single source
  of truth for the mask format and skip persisting maileroo_api_key when
  the incoming non-empty value equals the mask of the currently-stored
  key. Best-effort backstop for non-web/old clients; the client fix is
  authoritative.

Tests (handlers_admin_settings_test.go): maskAPIKey unit cases, the
masked-key-not-persisted regression (both long and **** short masks),
real-key-update-wins, and empty-key-clears.

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

* fix(admin): clear Maileroo key when disabling email provider (BUG-1890)

Codex review of the scoped email-save payload found a regression: when
an admin selects Provider "None" without touching the key field, the
scoped payload omitted maileroo_api_key, leaving the stored key. Because
reconfigureEmail keys email enablement off the presence of the API key
and ignores email_provider, "None" no longer disabled email.

Send an explicit empty key whenever the provider isn't Maileroo, so
disabling actually turns email off. The masked-key guard still applies
when the provider is Maileroo and the key was left untouched.

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

* fix(server): tear down live email sender when platform key is cleared (BUG-1890)

Codex review: clearing the Maileroo key (e.g. disabling via provider
"None") wrote the empty key to the DB, but reconfigureEmail's empty-key
branch returned early without clearing the in-memory s.email sender —
so the running process kept sending mail until restart, contradicting
the UI's "disabled" state.

Track whether email was wired from env vars (emailEnvConfigured, set in
SetEmailSender). When platform settings carry no key, reconfigureEmail
now tears down the live sender (s.email = nil, emailAPIKey = "") unless
env config exists — env is the deployment baseline the admin UI doesn't
disable. Tests pin both the teardown and the env-preserved paths.

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

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

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

Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
2026-06-29 13:07:36 -05:00
xarmian 44ee6a4604 fix(server): consistent soft-delete handling across item sub-resources (#771)
* fix(server): consistent soft-delete handling across item sub-resources

The main GET returns archived (soft-deleted) items read-only (200) and
PATCH/DELETE reject them with 409 "archived" (BUG-1791), but every item
sub-resource still resolved through the deleted_at-filtering ResolveItem
and returned a misleading 404 — which broke the archived-item detail
page, since it loads links/progress/timeline/etc. against the archived
slug. Mirror the GET/PATCH policy across the whole item surface: reads
behave like GET (200), writes behave like PATCH (409).

- Read sub-resources (children, progress, activity, backlinks, links GET,
  timeline, comments GET, versions list/get, artifact export, star
  status, item grants GET, share-links GET) now resolve via
  ResolveItemIncludeDeleted + the same requireItemVisible gate -> 200.
- Write sub-resources (create comment, create link, version restore,
  star/unstar, create item grant, create share-link) now route the nil
  case through writeItemResolveError -> 409 "archived" instead of 404.
- Dashboard recent-activity and the workspace activity feed resolve the
  referenced item include-deleted so archived-item activity renders with
  its real title/slug (gated by the same visibility checks) instead of a
  blank "ghost" row; this also stops a deleted-item row from bypassing
  the collection-visibility filter.

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

* fix(server): share-link item handlers had read/write soft-delete treatment swapped

handleCreateItemShareLink (a mutation) was wrongly resolving archived
items include-deleted and 404ing on miss, which let an owner create a
public share link for an archived item — the public resolver excludes
soft-deleted items, so the link 404s immediately. handleListItemShareLinks
(read-only) was wrongly returning 409 archived. Swap them back: create
rejects archived with 409 via writeItemResolveError; list resolves
include-deleted and returns 200. Per Codex review (round 1).

Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
2026-06-27 21:42:52 -05:00
xarmian e4caad2c64 feat(server): expose MCP tool-surface over authed REST endpoint (#764)
Add GET /api/v1/mcp/tool-surface, a session/token-authenticated
same-origin endpoint that serves the MCP catalog descriptor JSON
(the nine env.Catalog tools, their actions, and input schemas) with a
new per-action read_only bool. Backs the Phase 3 browser-side WebMCP
layer (PLAN-1888): the client fetches once and derives readOnlyHint
from the read_only flags without re-deriving the read set in TS.

Wired via the SetMCPTransport injection pattern to avoid the import
cycle: internal/mcp already imports internal/server (dispatch_http.go),
so internal/server cannot import internal/mcp. internal/mcp exports a
cycle-free ToolSurfaceJSON() that builds from the package-global
Catalog plus a co-located readOnlyActions allowlist; cmd/pad/main.go
(which imports both) injects it via Server.SetToolSurfaceHandler before
setupRouter. The route mounts in the authed API group so it inherits
TokenAuth/SessionAuth/CSRFProtect/RequireAuth — NOT the bearer-gated
/mcp infra path — and is available on both cloud and self-host.

The existing actionMetaToolSurface (pad_meta action=tool-surface) now
shares the same serializer, so MCP and REST can't drift; it gains the
additive read_only flag too. No ToolSurfaceVersion bump (DR-7): adding
read_only is additive metadata; names/actions/params are unchanged.

Refs TASK-1891 / PLAN-1888

Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
2026-06-24 22:45:32 -04:00
xarmian 3c0aed55db feat(server): add webmcp_enabled platform setting + session flag (#763)
* chore(docs): correct cloud MCP from "future /mcp endpoint" to live mcp.getpad.dev vhost

The HTTPHandlerDispatcher description called the remote MCP server a
"future /mcp endpoint." It's live: a cloud-mode-gated Streamable HTTP
server mounted on the dedicated mcp.getpad.dev vhost via
SetMCPTransport / registerMCPRoutes. Point at handlers_mcp.go.

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

* feat(server): add webmcp_enabled platform setting + session flag

Introduce the opt-in gate for the browser-side WebMCP surface
(PLAN-1888 Phase 1, DR-6). New webmcp_enabled platform setting,
default off, admin-writable, surfaced to the web client via the
/api/v1/auth/session payload so client tool registration can gate
on it.

- internal/server/handlers_admin.go: add settingWebMCPEnabled to the
  admin-PATCH whitelist (else silently dropped) + serialize a "false"
  default in the GET settings response.
- internal/server/handlers_auth.go: emit webmcp_enabled in the session
  payload via a fail-closed webMCPEnabled() helper (false on unset or
  read error).
- web/src/lib/api/client.ts: add webmcp_enabled?: boolean to AuthSession.
- web/.../console/admin/settings/+page.svelte: Integrations section with
  a WebMCP toggle + security warning copy (Phase 4 admin-warning intent).
- Go tests: admin PATCH persists + non-admin 403; session payload reflects
  stored value with default false.

No migration (platform_settings is an existing kv table).

Refs TASK-1889 / PLAN-1888

Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
2026-06-24 22:28:34 -04:00
xarmian 616a6d2a0a feat(auth): localhost password recovery for locked-out self-host admins (#760)
Add a loopback-only account-recovery path so a self-hosted operator who
forgot their password (with no email provider configured) can recover
without editing the database by hand.

- POST /api/v1/auth/local-reset: loopback-gated, non-cloud, no auth
  required (same trust model as bootstrap). Returns a single-use reset
  link, or a temporary password with {"temp_password": true}.
- pad auth reset-password <email> [--temp-password]: talks to the local
  server over loopback directly (not the configured public URL), so the
  command works on the server host regardless of CLI config. Prints the
  server's shareable reset_url when a public base URL is known.
- Web /forgot-password reads email_configured from the session and shows
  host-recovery instructions instead of a dead "we emailed you a link"
  when no provider is configured.
- forgot-password server log emits the reset path on non-cloud instances
  so operators can also recover straight from the logs.
- Docs: CLAUDE.md + docs/deployment.md recovery sections.

Tests cover the loopback/cloud gates, the shareable reset_url, and both
output modes (reset link + temp password).
2026-06-22 20:58:50 -04:00
xarmian 341cbd373d fix(artifact): normalize import field map so json fields validate (BUG-1883) (#759)
Importing a playbook artifact with an `arguments` array failed:
  field "arguments" must be a JSON object, array, or null
artifact.Decode normalizes arguments to []map[string]any, but ValidateFields'
json case only accepts map[string]any/[]any/nil. handleCreateItem never hits
this because its field map comes from JSON-unmarshalling the request body.

Fix: round-trip the import field map through JSON (marshal→unmarshal) before
createItemChecked, yielding canonical []any/map[string]any — matches the wire
create path, no per-field special-casing. Regression test round-trips a
playbook with arguments through import.

Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
2026-06-22 13:31:45 -04:00
xarmian 4f0984bb15 feat(artifact): server export + import endpoints for playbooks & conventions (#755)
* feat(artifact): server export + import endpoints for playbooks & conventions

Phase 2 of PLAN-1867. Adds:
- GET  /workspaces/{ws}/items/{ref}/export  — item-visibility-gated; encodes a
  playbook/convention item to a Markdown+frontmatter artifact.
- POST /workspaces/{ws}/import-artifact      — editor-gated; byte-capped +
  YAML-bomb-guarded parse, forgiving preprocess (foreign selects blanked,
  invocation_slug de-collided, status forced draft), creates via the shared
  create path.
- Extracts createItemChecked from handleCreateItem so import inherits
  validation / uniqueness / edit-perm / side-effects (no direct store.CreateItem).
- PAD_IMPORT_ARTIFACT_MAX_BYTES env override.

Server validation, coercion, and YAML input limits land at the HTTP boundary
per DR-4/DR-7/DR-8 and the Codex P2 notes (collSlug via shared helper,
item-visibility export auth, byte-cap→node-walk→decode ordering).

Implements TASK-1871, TASK-1872, TASK-1873, TASK-1874.

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

* fix(artifact): enforce item quota + require title on artifact import

Addresses Codex Phase-2 review:
- P1: handleImportArtifact now calls enforcePlanLimit(items_per_workspace)
  before create, matching handleCreateItem — imports can't exceed the plan cap.
- P2: reject empty/whitespace-only artifact titles with 400 (Title is required),
  matching the normal create path.

Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
2026-06-22 11:43:52 -04:00
xarmian fa8064e2b9 feat(onboard): capture workspace intent at creation, warm the onboard run (TASK-1855) (#746)
Intent-as-seed for the onboarding bridge. The workspace `description` column
already existed end-to-end but nothing captured or surfaced it:

- Web: CreateWorkspaceModal gains an optional "What are you tracking?"
  textarea (create-only), sent as `description` on create.
- Bootstrap: AgentBootstrapWorkspace now carries `description` (omitempty,
  additive) so the onboard playbook can read the user's stated intent.
- Onboard playbook: pre-flight reads workspace.description; B1 reflects it
  back ("You mentioned this is for X — let's build around that") instead of
  opening cold with "what is this project?", falling back when absent.

Net effect: a user who types one line at creation gets an onboard interview
that starts warm instead of from zero.

Parent: PLAN-1847 (Phase 3).

Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
2026-06-21 12:25:46 -04:00
xarmian 14614c98f7 feat(auth): surface server version on /auth/session (TASK-1839) (#740)
Add the server build version to both the setup-state and authenticated
/auth/session payloads (same source as /health). The mobile shells call
/auth/session on connect; surfacing version there lets them read it in
the round-trip they already make and warn when a server is below their
minimum supported version, without a second request (IDEA-1826).

Keep the web AuthSession TS type in sync (CONVE-1741).
2026-06-21 00:34:25 -04:00
xarmian 22d901c823 fix(auth): unify first-run setup into one browser handoff (BUG-1843) (#739)
On a fresh instance, `pad init` / `pad auth setup` created the admin
account in the browser and dropped the operator on the console, then
printed a SECOND "authorize the CLI" URL back in the terminal that a
user who'd moved to the browser never saw — forcing a ctrl-C + re-run.

Collapse it into a single browser tab: the CLI mints the pending CLI
auth session up front and hands /setup a validated `next=/auth/cli/<code>`
target, so account creation flows straight into the approval page where
the just-bootstrapped admin approves in one click and the CLI connects.

- internal/cli/bootstrap.go: thread `next` into the /setup URL (query
  before the #token fragment); raise bootstrapPollTimeout to 20m to
  match the setup session TTL.
- cmd/pad/main.go: extract pollAndSaveCLIAuth; runBrowserSetup pre-creates
  the session and polls it; `pad workspace init` drives local setup inline.
- cmd/pad/init.go: `pad init` routes through the unified handoff.
- internal/store + internal/server: grant a setup-specific 20m CLI auth
  session TTL when UserCount==0 so the combined create-account + approve
  window can't expire mid-flow; normal logins keep the 5m default.
- web/src/routes/setup: honor a validated local `next` redirect (open-
  redirect guarded), preserved across the token-fragment scrub.

Reviewed via Codex loop (3 rounds → clean).

Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
2026-06-20 23:51:43 -04:00
xarmian 99b4649bb6 fix(items): surface archived items instead of masking them as missing (BUG-1791) (#733)
A soft-deleted (archived) item still appears in include-archived list
results (all=true) but 404'd on get/update/move and was absent from search
and status-filtered lists — all=true is the only read path that includes
archived rows. With no archived marker in list output and a bare "Item not
found" on get/update, this looked like index/FTS corruption (the report's
diagnosis). It is not: every read path was behaving correctly for an
archived item. The root cause is observability, not a desync.

- scanItems now scans i.deleted_at; all six feeding SELECTs select it
  (ListItems, listItemsFTS x2 dialects, getChildItems, ItemsModifiedSince,
  ListStarredItems). Archived rows in include-archived results now carry
  deleted_at so callers can tell them apart from live rows; the
  deleted_at-filtered paths are unaffected (value stays NULL there).
- GET item resolves include-deleted, returning an archived item read-only
  (200) with its deleted_at marker rather than 404 — an agent can read it
  and see it is archived.
- UPDATE/DELETE/MOVE of an archived ref return a clear 409 "archived"
  (restore first) instead of a bare 404; visibility is enforced exactly as
  the active path so an archived item is never revealed to a caller who
  can't see it.
- CLI shows an (archived) marker in lists and an Archived line in detail.

Tests: store IncludeArchived populates DeletedAt; server GET archived -> 200
with deleted_at, UPDATE/MOVE archived -> 409 "archived". Verified on SQLite
and Postgres (make test-pg).
2026-06-15 14:44:58 -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 3c3016abd9 feat(server): focused neighborhood mode on workspace graph endpoint (TASK-1781) (#718)
* feat(server): focused neighborhood mode on workspace graph endpoint (TASK-1781)

Add ?focus=REF&depth=N to GET /workspaces/{ws}/graph. When focus is set,
BFS-traverse typed edges (undirected) out from the ref up to depth hops
(default 2, clamped to [1,5]) and return only that neighborhood's nodes +
edges. Without focus the whole-workspace behavior is unchanged.

- The focused item is always included, even when terminal (you asked to
  view it); neighbors honor the existing include_terminal filter.
- Neighborhood is intersected with the visibility-filtered item set, so a
  guest can't infer hidden items from dangling edges.
- Node-count cap (maxFocusNodes=200) stops BFS expansion early and sets a
  new GraphResponse.Truncated flag (omitempty — whole-workspace payload
  shape unchanged) so the client can offer expand-on-click.
- An unknown/invisible focus ref returns 404.

Tests: depth bounds + clamping, both-direction traversal, terminal focus
node inclusion, terminal-neighbor filtering, cross-collection typed edges,
unknown ref 404, and truncation.

Parent: PLAN-1780.

* fix(server): preserve true child_count in focus mode per Codex review (round 1)

In focus mode child_count was derived from the depth/cap-filtered edge
set, so a boundary parent whose children fell outside the neighborhood
reported child_count=0. The web UI gates hub-label and children-pill
visibility on child_count > 0, so those would wrongly hide.

Count children over the full visible item set instead (terminal filter
on the child preserved), independent of the focus subgraph. This also
reproduces the whole-workspace semantics exactly. Added a regression
test (focused parent with a child beyond depth still reports count=1).

Parent: PLAN-1780.
2026-06-08 18:13:10 -04:00
xarmian 10a55d1d5c feat(auth): accept provider=apple in cloud oauth-login/link/unlink (TASK-1773) (#714)
* feat(auth): accept provider=apple in cloud oauth-login/link/unlink (TASK-1773)

Sign in with Apple (PLAN-1772, App Store 4.8) needs pad to recognize
'apple' as an OAuth provider. The oauth-login, oauth-link, and
oauth-unlink handlers each hard-rejected anything but github/google;
DRY the triplicated literal into supportedOAuthProviders +
isSupportedOAuthProvider and add apple.

The rest of the path is already provider-agnostic: find-or-create user,
auto-link, the oauth_provider_not_linked gate for existing accounts, and
the verified-email requirement all work unchanged. Storage
(users.oauth_providers) is a free-form JSON array with no DB constraint,
so no migration.

Prerequisite for the pad-cloud /auth/apple/native endpoint (TASK-1774).

* test(auth): cover apple via oauth-link handler (Codex nit)

Prove the shared isSupportedOAuthProvider allowlist is wired through the
link call site, not only oauth-login. oauth-unlink shares the same gate
(unit-tested via TestIsSupportedOAuthProvider).
2026-06-07 22:08:14 -04:00
xarmian 35cc26daaf fix(web): collection cards show real child-item progress; child-progress endpoint (BUG-1509) (#710)
* BUG-1509: show real child-item progress on non-plan collection cards

Backend: extract collectionChildrenProgress helper from handlePlansProgress
and expose it at GET /collections/{collSlug}/child-progress with identical
visibility/guest-grant filtering. handlePlansProgress refactored to delegate
to the shared helper (no duplication). Route registered in the existing
/{collSlug} subrouter alongside checkbox-progress.

Frontend: +page.svelte fetches child-progress + checkbox-progress in parallel
for non-plans collections; per-item merge prefers child-progress (label
"tasks") when total>0, falls back to checkbox counts (label "done"). ItemCard
extended to render progress.label when present. ChildItems.svelte render gate
fixed to include error state so a failed /children fetch surfaces instead of
silently vanishing.

Tests: TestCollectionChildProgress covers happy path (linked children counted
correctly), zero-children items (present with total=0), 404 for unknown
collection, and restricted-member visibility gate (empty response for hidden
collection, not a data leak).

* fix: include_archived on child-progress and progressLabel desync (codex r2)

P1: GetAllItemProgress now accepts includeArchived bool; the parent-row
filter (AND p.deleted_at IS NULL) is conditioned on it, mirroring
CollectionCheckboxProgress. handleCollectionChildrenProgress reads
?include_archived=true and threads it through. handlePlansProgress
hardcodes false — no contract change there. collectionChildProgress()
client method gains opts?: { includeArchived? } with qs() serialisation.
Both call sites in +page.svelte (loadCollection and refreshProgress) now
pass includeArchived to the child-progress fetch.

P2: refreshProgress plans branch now sets progressLabel = 'tasks' so a
sync-triggered refresh after a failed initial plans load renders with the
correct label. progressLabel = 'done' moved inside the non-plans try block
(symmetric with plans) so a thrown fetch leaves the label in whatever
state the previous collection set, not silently desync'd.

Tests: TestCollectionChildProgress extended — archives parentA, confirms
it drops from default response and reappears with include_archived=true.

* fix: thread includeArchived through childrenDoneFiltersForCollection (codex r3)

GetAllItemProgress conditionally drops the p.deleted_at IS NULL parent
filter when includeArchived=true, but the filter-discovery call at the
top of the function — childrenDoneFiltersForCollection — still had the
filter hardcoded. If a child collection's only parent links pointed to
archived parents, that collection was absent from the done-semantics map,
and those children fell back to default status terminals rather than the
collection's configured done field — producing wrong done counts.

Fix: childrenDoneFiltersForCollection gains an includeArchived bool param;
the JOIN on items p conditions p.deleted_at IS NULL on it, exactly mirroring
the main query. GetAllItemProgress passes includeArchived through. The only
other caller of this helper (GetItemProgress via childrenDoneFiltersForParent)
is unaffected — that path is a separate function and never surfaces archived
parents.

Test: TestCollectionChildProgress extended with a "Widgets" collection whose
done field is `state` (terminal: "shipped") — not the default `status` field.
An archived task parent links two widget children (one shipped, one open); no
live task parent links into widgets, so the filter-discovery bug would drop
the collection from the map and produce done=0. The test asserts done=1 and
was verified to fail on the pre-fix code.
2026-06-06 11:14:23 -04:00
xarmian 1bd3e52230 feat(web): graph SSE live layer — glow/pulse on touched nodes (TASK-1736) (#704)
* feat(web): graph SSE live layer — glow/pulse on touched nodes (TASK-1736)

The graph now feels alive while agents work: item events from the
workspace SSE stream flash the touched node toward white and fade it
back over 45s (a lazy 2s prune interval animates the decay and stops
itself when idle). Structural events (created/archived/restored) and
item_updated fold into one trailing-debounced refetch (1.5s) through
the existing loadGraph stale-token path; comment_created is glow-only.
New items arrive glowing via a pending-uuid stash resolved when the
refetch lands. Pulse composes before focus-mode dimming so touched
nodes still flicker subtly in the dimmed crowd. Selection clears when
the selected item leaves the payload (archived under focus mode).

Events correlate via a uuid→ref bridge rebuilt per payload — the
graph endpoint now emits each node's item UUID alongside the ref.

Parent: PLAN-1730.

* fix(web): refetch graph on sync_required per Codex review (round 1)

items_bulk_updated and replay-buffer gaps route through onSyncRequired,
not onItemEvent — the graph stayed stale after bulk archive/move/assign
until the next single-item event. Fold both into the existing debounced
refetch.
2026-06-05 19:26:46 -04:00
xarmian 77dcd07ecd feat(web): graph search fly-to + collection/status/role filters (TASK-1735) (#703)
* feat(web): graph search fly-to + collection/status/role filters (TASK-1735)

Toolbar grows a type-ahead search (ref/title over the post-filter node
list; ArrowUp/Down + Enter picks, Escape closes without stealing the
page's deselect) that routes through the existing selectNode() — same
camera fly-to, highlight, and detail card as a click. Client-side
filters subset the rendered graph: collection chips with palette dots,
status chips, and a role select (hidden when no node carries a role;
the graph endpoint now emits the assigned agent-role slug per node).
Edges survive only when both endpoints do; counts read "X of Y" while
filtered. Workspace switch resets filters; show-completed doesn't.
Filter changes deselect so a vanished node can't strand focus mode.

New GraphToolbar.svelte owns the presentational toolbar; the page owns
authoritative filter state (CONVE-1688 discipline unchanged).

Parent: PLAN-1730.

* fix(web): close graph search dropdown on blur per Codex review (round 1)

The dropdown opened on focus/input but only closed on pick or Escape,
leaving stale results floating over the canvas after clicking away.
The result buttons already pick on mousedown+preventDefault, so the
input never blurs mid-pick — a plain onblur close is safe.

* fix(web): gate search Escape on dropdown visibility per Codex review (round 2)

Escape in a focused-but-empty search now falls through to the
page-level deselect instead of being swallowed by the searchOpen flag.
2026-06-05 19:14:45 -04:00
xarmian 93220845a0 feat(server): workspace graph endpoint — nodes + typed edges (TASK-1731) (#699)
* feat(server): workspace graph endpoint — nodes + typed edges (TASK-1731)

GET /api/v1/workspaces/{ws}/graph returns the whole workspace as
{nodes, edges} in one call, feeding the 3D graph view (PLAN-1730).
Nodes carry ref/title/collection/status/is_terminal/child_count/
updated_at; edges are typed (parent | blocks | implements | related |
wiki-link), with wiki-link edges sourced from the PLAN-1593 reverse
index, deduped per pair, self-links dropped.

Default response is active items only; ?include_terminal=true returns
the full history. Visibility follows the dashboard model (collection
visibility + guest item-level grants), and edges are filtered to the
visible node set so hidden items can't be inferred from dangling
endpoints.

Parent: PLAN-1730.

* fix(server): normalize graph edge types to advertised vocabulary per Codex review (round 1)

item_links can carry split_from / supersedes / wiki_link beyond the
documented enum. Map stored types to the hyphenated graph vocabulary
(wiki_link → wiki-link, split_from → split-from), dedupe (source,
target, type) so a stored wiki_link row and a parsed [[...]] mention
of the same pair emit once, and document the full edge enum. Unknown
future link types pass through rather than being dropped.

* fix(store): close graph edge enum against unknown link types per Codex review (round 2)

Route stored link types through models.NormalizeItemLinkType; values
it rejects (possible via the import path — no DB CHECK on
item_links.link_type) degrade to 'related' instead of leaking
undocumented edge types past the advertised vocabulary.
2026-06-05 18:12:56 -04:00
xarmian 3704cc2c9f fix(store): cast jsonb metadata to text for Postgres LIKE + gofmt (BUG-1702) (#693)
The status-transition backfill query used `a.metadata LIKE '%→%'`, but
activities.metadata is jsonb on Postgres where LIKE (~~) is undefined,
failing TestBackfillStatusTransitions(_SeedSeqBelowHop) and erroring in
any Postgres deployment. Cast to ::text on Postgres (dialect-guarded),
matching AttachmentReferenced. Also gofmt comment.go + the share-links
test that were tripping golangci-lint.
2026-06-02 09:39:22 -04:00
xarmian 72d8963c4c fix(timeline): resolve collab-snapshot diffs + collapse autosave bursts (BUG-1612) (#691)
Item timelines showed two collab-snapshot problems:

1. Artifacts: the timeline endpoint (ListItemVersionsBeforeTime) served
   diff versions unresolved, so TimelineVersionCard fed raw diff-match-patch
   patch text into DiffView. Add GET /items/{slug}/versions/{versionID}
   (handleGetItemVersion -> Store.GetItemVersionResolved) and have the card
   lazily fetch resolved content the first time a diff version is expanded.

2. Clutter: every ~5s web-editor autosave flushes a collab-snapshot version.
   buildTimeline now collapses uninterrupted collab-snapshot bursts (within
   10 min, no intervening event) to their newest entry, and the source badge
   renders as "Autosave" instead of the raw slug.

Adds TestCollapseAutosaveBursts. Known limitation (accepted): collapse is
page-local, so a 150+ cross-actor autosave chain can leak one row per
"Load more" page — gated by the 1h version throttle, degrades gracefully.
2026-06-01 21:25:42 -04:00
xarmian ae8173b42d fix(server): gate ref-resolver admin bypass on bearer auth (BUG-1618) (#690)
* fix(server): gate ref-resolver admin bypass on bearer auth (BUG-1618)

resolverWorkspaceRole returned "owner" for any platform admin regardless
of auth surface, so a bearer-borne admin (PAT / CLI / MCP) could probe
the existence of refs in workspaces they never joined via the /-/r/ 302
redirect — leaking workspace + ref existence plus the owner username and
collection slug in the redirect target.

Site 1 (real fix): thread isBearerAuth(r) into resolverWorkspaceRole and
gate the admin branch on !authIsBearer; the workspace-owner check stays
unconditional. Bearer-admins fall through to the member-then-grants check
(membership-only stance, matching BUG-1616/1617). Cookie-session admins
keep the owner bypass so the web-UI affordance is preserved. Added
TestRefResolver_AdminBearer_404OnNonMemberWorkspace (bearer -> 404) and
TestRefResolver_AdminCookie_StillRedirects (cookie -> 302).

Site 2 (audit, no logic change): the workspace sort-order bulk-update's
silent-skip needs no auth gate — UpdateWorkspaceSortOrder is scoped to
the caller's own workspace_members row, so a non-member PATCH touches
zero rows (no cross-ws write or leak), and handleListWorkspaces has been
membership-only for all authenticated users including admins since
BUG-982. Rewrote the stale comment to record both facts.

Parent: BUG-1617. Sibling: BUG-1616.

* fix(server): deny bearer-admin grant fallback in resolver per Codex review (round 1)

A bearer admin who isn't a member but holds a stray collection/item grant
got "guest" from resolverWorkspaceRole, then checkItemVisible's own
`user.Role == "admin"` bypass returned visible — reopening full resolver
access + 302 URL leakage the BUG-1618 fix was meant to close. Add the
membership-only guard (return "" for bearer-admin non-members before the
grant fallback), matching RequireWorkspaceAccess and the SSE/collab
sibling gates. New regression test TestRefResolver_AdminBearer_404EvenWithGrant.
2026-05-31 12:26:57 -04:00
xarmian a5c7fc986e fix(attachments): grant-aware upload auth so share-link editors can attach (BUG-1661) (#688)
handleUploadAttachment gated on requireMinRole("editor") — a workspace-level
check — but the editor and comment composer offer the paste/drop upload
affordance based on grant-aware edit permission. A grant-based editor (guest
with an item/collection edit grant via a share link, no workspace editor role)
could type/post but hit 403 on upload.

Server: read ?item_id early (before spooling the body); when present and
resolvable, authorize via requireEditPermission against the item's grant chain,
else fall back to requireMinRole("editor") for free-floating uploads (new-item
creation, storage settings). Reordered the nil/getWorkspaceID checks above auth.

Client: upload() now also sends item_id as a query param so the server can
authorize before spooling. Threaded the item UUID through Editor.svelte (both
mount sites) and CommentEditor.svelte (ItemTimeline composer + the 3
TimelineCommentCard composers via comment.item_id).

Test: TestUpload_GrantBasedEditorCanAttach — guest with an item edit grant gets
201 with ?item_id and 403 without it (confirms the editor-role fallback didn't
widen access).
2026-05-31 11:39:02 -04:00
xarmian be53856223 feat(share): include saved views in collection share payload (TASK-1681) (#682)
* feat(share): include saved views in collection share payload (TASK-1681)

Expose the collection's saved views on the public /s/{token} payload so the
read-only view switcher (TASK-1682) can render and toggle them. Fetched via
Store.ListViews (ordered by sort_order) and projected to a public shape
under collection.views — name, slug, view_type, config (parsed object),
is_default, sort_order — with internal UUIDs and timestamps stripped.
Always emits an array (never null); empty when the collection has no saved
views, so the switcher falls back to settings.default_view.

Extends the SharePayload TS type with PublicShareView + an optional
collection.views array (additive) for TASK-1682 to consume.

Parent: PLAN-1677.

* fix(share): pin distinct view sort_order in test per Codex review (round 1)

CreateView inserts sort_order=0 and now() is second-granularity, so the two
test views could tie on (sort_order, created_at) and SQL could return either
order, flaking the position-based assertion. Set explicit sort_order 0/1 and
assert on it.

Parent: PLAN-1677.
2026-05-31 01:15:38 -04:00