* feat: add edit button to collection detail page header
Wire up the existing EditCollectionModal to the collection detail page
with a pencil icon button in the header actions bar. Owner-only,
responsive (icon-only on mobile), refreshes page data after saves.
Closes IDEA-494
* fix: navigate to new slug after collection rename in edit modal
When a collection is renamed, the backend regenerates the slug. The
onupdated callback now receives the updated Collection object, so the
collection page can detect a slug change and navigate to the new URL
instead of 404ing on the old slug.
Addresses PR review feedback from #113.
* fix: refresh collectionStore after edit modal update
Ensures the sidebar reflects updated collection name/slug/icon
immediately after editing, matching the settings page behavior.
Addresses P2 review feedback from #113.
Fixed race condition where $effect reset sidebarCollections mid-update
because isDraggingSidebar was cleared before API calls completed.
- Capture reordered array locally; keep drag flag true during updates
- Fire all sort_order updates in parallel with Promise.all
- Wrap in try/finally so drag state always resets on failure
- Guard reset with generation counter to prevent stale finalize from
clobbering a newer drag operation
Fixes BUG-562
* feat: sticky breadcrumb header with copy item ID button
Make the breadcrumb navigation bar sticky so it stays visible when
scrolling on item detail pages. Add a copy-to-clipboard button next
to the item ref (e.g. TASK-5) that shows a "Copied!" tooltip on click.
Uses the existing copyToClipboard utility with non-SSL fallback.
Closes IDEA-501
* fix: lower sticky breadcrumb z-index below mobile header
The mobile layout has a sticky header at z-index 5. Lower the
breadcrumb's z-index to 4 so it slides under the mobile header
instead of overlapping navigation controls.
* fix: offset sticky breadcrumb below mobile header instead of hiding it
Instead of lowering z-index (which hides the breadcrumb behind the
mobile header), offset it with top: 45px on mobile so it stacks
neatly below the mobile nav. Keeps z-index: 10 so the sticky
effect works properly on all screen sizes.
* feat: hide plan settings in non-cloud mode, improve plan overrides UX
1. Gate Plan column, Plan dropdown, and Plan Overrides behind cloud_mode
so self-hosted instances don't see irrelevant billing UI.
2. Replace raw JSON textarea for plan overrides with a structured grid
of labeled number inputs for each known limit (workspaces, items per
workspace, members, API tokens, webhooks). Each field shows "default"
as placeholder and accepts -1 for unlimited.
Addresses IDEA-561
* fix: address codex review — preserve extra overrides, fix colspan, validate integers
- Preserve non-modeled override keys (e.g. storage_bytes) by stashing
them on select and merging them back on save
- Fix colspan: 6 columns in cloud mode, 5 without (was off by one)
- Use Number() + Number.isInteger() instead of parseInt to reject
floats and scientific notation instead of silently truncating
* feat: add web UI for TOTP 2FA setup in user settings
Add a Two-Factor Authentication section to the console settings page
so users can enable/disable TOTP 2FA from the browser. The backend
API already existed (PR #77); this wires up the frontend.
- Add 2FA section to console settings with enable/disable flows
- Enable flow: QR code + manual secret + verification code input
- Recovery codes displayed with copy/download after setup
- Disable flow: password confirmation modal
- Add totp.setup/verify/disable methods to API client
- Add TOTP types (TOTPSetupResponse, TOTPVerifyResponse, etc.)
- Add totp_enabled to User type and /auth/me response
- Add qrcode npm dependency for rendering otpauth:// URIs
Closes TASK-402
* fix: address codex review — separate QR rendering from setup, use clipboard util
- Separate QR code rendering from TOTP setup API call so a QR failure
doesn't abort setup when manual entry is still available
- Use existing copyToClipboard utility with legacy fallback instead of
raw navigator.clipboard.writeText
* feat: add audit log UI for admin console
Replace placeholder with full audit log page: filterable by action type
and date range, paginated table with relative timestamps, color-coded
action badges, parsed metadata details, and IP addresses.
* fix: show Unknown for missing actors, guard against stale filter responses
Show "Unknown" instead of "System" for entries without actor info (e.g.
failed logins). Add a request counter so rapid filter changes discard
stale responses instead of overwriting newer results.
* feat: add invitation management panel for admin console
Platform-wide view of all pending invitations with search, resend, and
revoke. Resend creates a fresh invitation code and sends the email.
New admin endpoints: GET/POST/DELETE for /admin/invitations.
* fix: check email opt-out on resend, abort on stale delete, reload list
Respect unsubscribe preferences before resending invitation emails.
Abort resend if the old invitation was already accepted/revoked
concurrently. Reload the full invitations list after resend since the
row ID changes.
* feat: add user detail panel with workspace memberships
New GET /api/v1/admin/users/{id}/workspaces endpoint returning workspace
name, slug, role, and join date. Frontend loads memberships when a user
row is expanded and displays them as a linked list with role badges.
* fix: scope workspace fetch error/loading to active selection
Gate both the catch and finally blocks with a selectedId check so stale
requests from previously selected users don't wipe workspace data or
clear the loading indicator for the current selection.
* 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.
* 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.
* feat: add admin password reset for other users
New POST /api/v1/admin/users/{id}/reset-password endpoint. When email is
configured, sends a password reset link. Otherwise generates a temporary
password and invalidates existing sessions. Includes audit logging via
new password_reset_by_admin action and frontend UI with confirmation.
* fix: treat session revocation and email send as hard failures
Make session invalidation failure abort the reset instead of silently
continuing, and send the reset email synchronously so delivery failures
are surfaced to the admin caller.
* feat: add admin role management (promote/demote users)
Allow admins to change user roles between admin and member from the
admin console. Includes safety guards to prevent self-demotion and
demoting the last admin, with full audit logging.
* fix: make last-admin demotion guard atomic
Move the admin count check into the SQL UPDATE itself so two concurrent
demotion requests cannot both observe >1 admin and proceed. The
conditional UPDATE only demotes when at least one other admin exists,
eliminating the TOCTOU race.
* feat: refactor admin console into tabbed layout
Split the monolithic admin page into a tabbed layout with sub-pages:
- Users tab (default) — user list, search, plan editing
- Settings tab — email configuration and plan limits
- Invitations tab (placeholder for TASK-559)
- Audit Log tab (placeholder for TASK-560)
Extract shared admin utilities (adminFetch, adminPatch, adminPost,
types, reactive stats store) into lib/stores/admin.svelte.ts.
Part of PLAN-552: Admin Console Enhancement (IDEA-242)
* fix: show error state when admin users API fails
Instead of silently swallowing fetch errors and showing an empty table,
display an error message with a retry button. Addresses PR feedback.
When all items in a collection had terminal statuses (e.g. all bugs
"fixed"), the sidebar showed the total item count instead of 0.
Root cause: ActiveItemCount used `json:"omitempty"`, so a zero value
was omitted from the API response. The sidebar fallback logic then
displayed item_count (total) instead. Additionally, ListCollections
used a hardcoded global terminal status list instead of respecting
each collection's configured terminal_options.
- Remove omitempty from ItemCount/ActiveItemCount so 0 serializes
- Compute active counts per-collection using schema terminal_options
- Show count of 0 in sidebar when collection has items but all are done
* feat: restore `pad init` as smart multi-step entry point
Adds a top-level `pad init` command that detects the current state and
walks through each setup step as needed: configure connection, start
server, bootstrap admin account, authenticate, create/link workspace,
and install/update AI skill files.
Safe to re-run anytime — skips completed steps and shows a status
summary when everything is healthy.
Also refactors `pad workspace init` to use shared helpers and adds a
hint pointing users to `pad init` when prerequisites are missing.
Ref: IDEA-499, PLAN-546
* fix: validate --template flag before workspace creation in pad init
Adds the same preflight template validation that workspace init has,
preventing a partially initialized workspace from being created when
an invalid template name is passed.
Ref: PR #99 review feedback
The db commands were PostgreSQL-only, which made them useless for
self-hosted users on the default SQLite setup. Now both commands
auto-detect the database driver and do the right thing:
- SQLite (default): file copy of ~/.pad/pad.db with WAL/SHM handling
- PostgreSQL: existing pg_dump/psql behavior (unchanged)
Replace the email/password terminal prompt in `pad auth login` with a
browser-based auth flow. The CLI creates a pending session, prints a URL
the user opens in their browser (works for localhost, remote VPS, or
Pad Cloud), and polls until the session is approved.
- Add CLI auth session endpoints (create, poll, approve)
- Add browser approval page at /auth/cli/{code}
- Rewrite `pad auth login` to use browser flow by default
- Keep `pad auth login --interactive` as email/password fallback
- Add login page redirect param support for post-login bounce-back
- Add SQLite and PostgreSQL migrations for cli_auth_sessions table
Closes PLAN-539, IDEA-404
* feat: email unsubscribe for non-transactional emails
Add CAN-SPAM compliant unsubscribe support:
- New email_optouts table (by email address, not user ID) so
uninvited recipients can opt out without an account
- HMAC-signed unsubscribe tokens (derived from Maileroo API key)
so links work without authentication
- GET /api/v1/unsubscribe endpoint with simple HTML confirmation page
- Invitation emails now include unsubscribe footer link
- Welcome emails accept unsubscribe URL parameter
- Before sending invitation emails, check opt-out table and silently
skip opted-out addresses (prevents invite spam)
- Password reset emails are exempt (transactional, user-initiated)
Fixes BUG-256.
* fix: hide "Copy invite link" when code is unrecoverable
For hashed invitations the plaintext code can't be recovered, so the
button was copying a broken URL. Now shows "Sent via email" label
instead. Only shows the copy button when join_url or code is available.
Fixes BUG-255.
The dashboard had two buttons that both called requestQuickAdd() with
no argument, so both created tasks. Now:
- "New Task" explicitly targets the tasks collection
- The second button dynamically targets the first non-system, non-task
collection by sort order (showing its icon and name)
- requestQuickAdd() accepts an optional collection slug, which the
sidebar respects when choosing the target collection
Fixes BUG-498.
When viewing a child item (e.g. TASK-101 under PLAN-10), the breadcrumb
now shows "Home / Plans / PLAN-10 / TASK-101" instead of the flat
"Home / Tasks / TASK-101".
- Add parent_slug and parent_collection_slug fields to Go Item model
- Populate them in both single-item and bulk enrichment paths
- Add corresponding TypeScript types
- Update breadcrumb nav to show parent collection and parent item
when the item has a parent, falling back to the item's own collection
Fixes BUG-516.
* fix: mobile UI bugs — sidebar buttons, avatar, share link copy
- Show sidebar + buttons on touch devices using @media (hover: none)
instead of requiring hover (BUG-537)
- Add user avatar and menu to mobile TopBar header, filling the blank
space next to the workspace selector (BUG-536)
- Wire ShareDialog copy into existing clipboard fallback utility so
share link copy works over HTTP (BUG-513)
* fix(mobile): remove extra right padding on mobile topbar
The .topbar has 72px right padding to clear space for the absolutely-
positioned desktop avatar. On mobile the avatar is in the normal flex
flow, so that padding created a blank gap. Override to var(--space-3).
- Strip apostrophes in slugify() so "Dave's Workspace" becomes
"daves-workspace" instead of "dave-s-workspace" (BUG-517)
- Use replaceState when navigating after item move to avoid polluting
browser history (BUG-538)
- Don't auto-close items when split children are done — splitting work
out doesn't mean the original is complete (BUG-401)
savePlatformSettings used raw fetch which doesn't throw on 4xx/5xx,
so failed saves (CSRF rejection, auth errors) silently showed "Saved".
Now checks resp.ok before reporting success.
Co-Authored-By: Claude <noreply@anthropic.com>
Ensure make test-pg cleans up Docker containers even when tests fail
by capturing the exit code and running cleanup unconditionally. Remove
dead CSS rules from root page after welcome template simplification.
Co-Authored-By: Claude <noreply@anthropic.com>
- Route root (/) to /console for centralized workspace management
- Update TopBar user dropdown with console nav links (workspaces, settings, billing, admin)
- Move account settings (profile, password, tokens) from workspace settings to /console/settings
- Enhance admin page with email configuration UI and CSRF-protected writes
- Add PostgreSQL CI job to GitHub Actions with race detector on main
- Add `make test-pg` for local PostgreSQL testing via docker-compose
- Expand health/ready endpoint with DB connection pool stats
- Increase item number retry limit for high-concurrency environments
- Add concurrent store benchmarks and FTS search quality tests
- Add AGENTS.md for multi-agent development guidance
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>
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>
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)
- 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>
Fix admin limits endpoint returning wrong defaults for pro plan, correct
swapped billing page usage numbers, validate expires_at format in plan
endpoint, and handle errors properly in admin stats endpoint.
Co-Authored-By: Claude <noreply@anthropic.com>
Add the foundation for running Pad as a hosted service at app.getpad.dev.
Same binary in cloud mode with a thin sidecar for OAuth and Stripe.
Cloud mode (PAD_CLOUD=true):
- PAD_CLOUD flag with cloud secret for sidecar communication
- Account-level billing: plan field on users, CheckLimit enforcement
- Free/Pro tiers with configurable limits stored in platform_settings
- Three-tier limit resolution: user overrides → DB defaults → hardcoded fallback
- Plan enforcement on workspace, item, member, webhook, and token creation
Authentication & security:
- OAuth login endpoint (POST /api/v1/auth/oauth-login) with cloud secret gate
- Verified email requirement for OAuth, 2FA bypass protection
- Cloud secret rotation support (comma-separated keys)
- TOTP secret encryption at rest (AES-256-GCM via PAD_ENCRYPTION_KEY)
- Rate limiting on OAuth login endpoint
- Bootstrap disabled in cloud mode
- Password max length enforcement (128 chars)
- Config file written with 0600 permissions
Admin & billing:
- Admin user management API (list, detail, update plan/overrides)
- Configurable plan limits API (GET/PATCH /api/v1/admin/limits)
- Platform stats endpoint
- Admin plan endpoint for sidecar to set user plans
- GDPR: account deletion and data export endpoints
Console UI (cloud mode only):
- /console — workspace list with owned/shared sections
- /console/new — create workspace wizard with slug preview
- /console/settings — profile, password, API tokens
- /console/billing — plan status, upgrade/manage links
- /console/admin — user management, plan overrides, limits editor
- OAuth buttons (GitHub/Google) on login page in cloud mode
Auto-create default workspace on signup in cloud mode.
Migration 035: plan, plan_expires_at, stripe_customer_id, plan_overrides on users.
* 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
* 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.
HIGH:
- SSE endpoint now verifies workspace access for legacy API tokens by
checking tokenWorkspaceID matches the requested workspace. Also uses
resolveWorkspace for user-aware resolution instead of raw slug lookup.
MEDIUM:
- ParentLinkID no longer set on list responses when the parent is in a
hidden collection — previously leaked the hidden parent's UUID even
though title/ref were filtered.
- Dashboard role breakdown (ByRole) now recomputed from visible items
when user has restricted access, preventing hidden collection workload
and assignee leaks.
LOW:
- HasChildren computed from visible grandchildren only when visibility
is restricted, instead of querying all descendants unfiltered.
- handleGetItemLinks and handleListAgentRoles now fail closed (500) on
visibleCollectionIDs errors instead of returning unfiltered data.
HIGH:
- Saved view endpoints (list, create, update, delete) now check
collection visibility. Added requireViewVisible helper that verifies
workspace ownership and collection access for update/delete by view ID.
- Dashboard plan progress computed from visible children only instead of
using GetItemProgress which counts all children. Suggested Next also
filters out tasks from hidden collections.
- Global search initializes allVisibleCollIDs as non-nil empty slice so
zero-visible-collection case correctly returns no results instead of
searching unfiltered.
MEDIUM:
- /plans-progress recomputes progress from visible children when user
has restricted access, matching the per-item progress approach.
- Agent role item_count recomputed from visible items in handler when
visibility is restricted, preventing hidden collection item count leaks.
- Parent filter resolution (?parent=, ?plan=) now checks resolved
parent's collection visibility, returning same not-found error for
hidden parents to prevent existence probing. Added request parameter
to resolveParentFilter.
- UUID parent that doesn't exist now returns 400 immediately instead of
setting parentValue and failing later with FK error after item insert.
LOW:
- Restricted progress computation uses per-collection schemas via
IsTerminalStatus instead of IsTerminalStatusDefault, matching the
unrestricted SQL path's behavior for custom terminal statuses.
HIGH:
- Single-item enrichment (derived_closure, parent_title, parent_ref) now
filters related items by collection visibility. enrichItemForResponse
and deriveItemClosure accept optional visibleIDs to exclude links to
hidden-collection items.
- Dashboard blocker attention skips blockers from hidden collections
instead of leaking their titles and statuses.
- Link deletion now looks up the link, verifies workspace ownership, and
checks that both linked items are in visible collections before
allowing the delete. Added GetItemLinkByID store method.
MEDIUM:
- Workspace activity filters now drop rows with empty CollectionSlug
that have an item reference (unresolved hidden items) instead of
passing them through.
- UUID parent assignment validates parent belongs to the same workspace
before creating cross-workspace links.
LOW:
- GET /members/{userID}/collection-access now requires owner role or
matching user ID, preventing viewers from querying other members'
hidden collection grants.
Close all identified bypass paths in the collection visibility system:
HIGH:
- Add requireItemVisible check to all 15+ item-by-slug handlers (get,
update, delete, restore, move, children, progress, activity, versions,
timeline, comments, links)
- Filter incremental sync (GET /changes) by visible collections with
proper error handling for deleted item lookups
- Fix search to fail closed on visibility errors instead of removing
the collection filter; apply per-workspace filtering in multi-workspace
search path
- Empty CollectionIDs (non-nil but len 0) now returns zero results in
ListItems and Search instead of skipping the filter
- Filter returned item links by linked item visibility; require target
item visibility before creating links
- Block moving items into hidden collections
- Add visibility checks to comment-by-ID routes (delete, reply,
add/remove reaction)
MEDIUM:
- SSE events for replies and reactions now include collection slug so
visibility filtering can scope them; fail closed on visibility error
- Parent/plan resolution in create/update checks resolved parent is in
a visible collection
- Progress endpoints compute from visible children only when user has
restricted access
- Role board reorder checks item visibility before allowing sort changes
- Parent enrichment accepts optional visibility filter to hide parents
from hidden collections
- Add IsSystem: true to Conventions and Playbooks in defaults.go
LOW:
- Child listing handles visibility lookup errors instead of failing open
- GetDeletedItemsWithCollection returns proper errors instead of
swallowing them
- SetMemberCollectionAccess wrapped in transaction with workspace
validation for collection IDs
Add 9 tests covering collection-level visibility, system collection
exemptions, and the VisibleCollectionIDs resolution logic (TASK-488
Phase 2 increment).
Tests:
- VisibleCollectionIDs: all access returns nil, specific access
returns granted + system collections, non-member gets empty list
- SystemCollectionsAlwaysVisible: conventions visible even when not
explicitly granted
- SetMemberCollectionAccess: replace grants, switch back to all
- ListItems filtered by CollectionIDs
- Default collection_access is "all" (D7)
- IsSystem flag on collections
Add indexes to support permission-filtered queries at scale (TASK-486).
- idx_mca_collection: reverse lookup on member_collection_access for
cascade cleanup when collections are deleted
- idx_collections_system: partial index on (workspace_id, is_system)
for fast system collection lookups in VisibleCollectionIDs
- idx_wm_user: index on workspace_members(user_id) for user deletion
cascade and cross-workspace membership queries
Filter SSE events by the connected user's collection visibility so
events about items in hidden collections are never sent (TASK-487).
- Compute visible collection slug set at SSE connection time
- Filter live events: check event.Collection against visible set
- Filter replayed events: same check on missed event replay
- Events without a collection field always pass through
- Admins and "all access" members see everything (nil set = no filter)
Wire collection visibility filtering into all data endpoints so
members with "specific" access only see items in their visible
collections (TASK-414).
Core:
- ItemListParams.CollectionIDs: SQL-level IN() filter on item queries
- SearchParams.CollectionIDs: same for FTS search queries
- visibleCollectionIDs() server helper computes once per request
- isCollectionVisible() for single-item gating checks
Filtered endpoints:
- ListItems / ListCollectionItems: SQL-level collection ID filter
- ListCollections: post-filter by visible set
- Search: collection ID filter on both ref-lookup and FTS branches
- Dashboard: all ListItems calls scoped, activity post-filtered
- Activity feed: post-filtered by collection slug visibility
- Collection items: gate check before listing
Admins and "all access" members see everything (nil = no filter).
The filtering is a no-op until a member's collection_access is set
to "specific" via the management UI (TASK-416).
Add per-member collection visibility controls and mark conventions/
playbooks as system collections (TASK-413 + TASK-415).
Data model:
- workspace_members: new collection_access column ('all' or 'specific')
- New member_collection_access table (workspace_id, user_id, collection_id)
- collections: new is_system column, set for conventions and playbooks
Store methods:
- VisibleCollectionIDs(workspaceID, userID) — returns nil for "all"
access, or specific IDs (including system collections) for "specific"
- SetMemberCollectionAccess/GetMemberCollectionAccess for CRUD
- All collection queries include is_system in SELECT/scan
- Export/import handles is_system field
Default collection definitions:
- conventionsCollection and playbooksCollection set IsSystem=true
- New workspaces get system flag on seed
D7: default collection_access is "all" — absence of restrictions
means full access. System collections always visible to members.