Commit Graph

213 Commits

Author SHA1 Message Date
xarmian 92580905bb feat: cloud hardening and security follow-ups (PLAN-503)
Address 11 issues identified during the PLAN-427 security review:

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-13 00:54:21 +00:00
xarmian b2ec0a4f55 fix: address review findings for PR #89 (iteration 1)
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>
2026-04-13 00:24:36 +00:00
xarmian d0518216c5 feat: add cloud infrastructure for hosted Pad (PLAN-427)
Add the foundation for running Pad as a hosted service at app.getpad.dev.
Same binary in cloud mode with a thin sidecar for OAuth and Stripe.

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

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

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

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

Auto-create default workspace on signup in cloud mode.
Migration 035: plan, plan_expires_at, stripe_customer_id, plan_overrides on users.
2026-04-12 17:43:40 +00:00
xarmian 94d35509a4 feat: share links with hardened security, anonymous access, and analytics (#88)
* feat: share links with hashed tokens and /s/{token} route

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

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

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

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

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

* feat: anonymous share page + share link management UI

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

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

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

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

* feat: share link constraints + view analytics

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

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

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

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

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

* fix: follow-up hardening for share links

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

* fix: final hardening pass for share links

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

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

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

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

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

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

* feat: grant revocation + member removal with grant choice

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

- Legacy document version handlers (handleListVersions, handleGetVersion)
  and document activity handler (handleListDocumentActivity) now require
  at least viewer role, blocking guests from reading version history and
  activity for unrelated legacy documents.
- ListItems early return now checks both CollectionIDs and ItemIDs are
  empty before short-circuiting, matching the fix already applied to
  Search. This fixes item-only guests seeing zero results from /items,
  dashboard, role board, and agent-role counts.
- SSE item-grant filtering now fails closed on GuestVisibleResources
  errors: installs empty item/collection filter sets instead of falling
  through with nil (which would pass all events through).
- Role board reorder removed top-level requireMinRole("editor") so the
  per-item grant-aware requireEditPermission checks can run for guests
  and viewers with edit grants, consistent with other mutating handlers.
2026-04-11 14:46:24 -04:00
xarmian 873f834454 fix: close remaining visibility bypass paths (round 5)
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.
2026-04-11 03:02:14 +00:00
xarmian 2310d15299 fix: close remaining visibility bypass paths (round 4)
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.
2026-04-11 02:49:13 +00:00
xarmian 1c37493b45 fix: close remaining visibility bypass paths (round 3)
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.
2026-04-11 02:35:15 +00:00
xarmian 973887d5dd fix: comprehensive collection visibility enforcement
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
2026-04-11 02:18:36 +00:00
xarmian 0587deba41 feat: UI for managing member collection visibility
Add API endpoints and settings UI for managing per-member collection
access (TASK-416).

Backend:
- GET /members/{userID}/collection-access — returns mode + granted IDs
- PUT /members/{userID}/collection-access — sets mode + collection IDs
  (owner-only)

Frontend:
- API client: getMemberCollectionAccess, setMemberCollectionAccess
- Settings Members tab: "Manage access" button per member (owner-only)
- Expandable inline panel with all/specific toggle
- Collection checkbox list: non-system collections toggleable, system
  collections always checked + disabled with "system" tag
- Save/cancel with optimistic update
2026-04-11 01:35:51 +00:00
xarmian 3454930099 test: Phase 2 permission resolution test suite
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
2026-04-11 01:27:36 +00:00
xarmian a0f2000968 feat: database indexes for permission tables
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
2026-04-11 01:25:34 +00:00
xarmian 1ee69df394 feat: SSE event permission filtering
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)
2026-04-11 01:23:36 +00:00
xarmian 0fb8042ad2 feat: permission-filtered aggregates for all data endpoints
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).
2026-04-11 01:20:05 +00:00
xarmian d74431fbb3 feat: collection-level visibility + system collections
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.
2026-04-11 01:02:06 +00:00
xarmian 1cbd7ba204 fix: resolve workspace routing regressions from username refactor
- Fix UUID-shaped workspace slugs: resolveWorkspace now falls back to
  slug-based lookup when a UUID doesn't match any workspace ID
- Fix imported workspaces: ImportWorkspace accepts ownerID, handler sets
  authenticated user as owner and adds workspace membership
- Fix generated username collisions: add EnsureUniqueUsername to append
  suffixes (-2, -3, etc.) when auto-generated usernames already exist
- Fix handler-level UUID resolution: workspace CRUD handlers now use
  getWorkspace helper (reads middleware-resolved ID from context) instead
  of raw URL params with slug-only store methods
2026-04-11 00:48:45 +00:00
xarmian 359876ae1c feat: username editing in account settings
Add username editing to the web UI account settings page and
backend profile update handler (TASK-496).

Backend:
- handleUpdateCurrentUser accepts optional username field
- Validates format, reserved words, and uniqueness (skips if unchanged)
- Returns clear errors for taken/invalid/reserved usernames

Frontend (settings page):
- Username field with @ prefix indicator below name
- Debounced real-time availability checking (400ms)
- Status indicators: checking/available/taken
- Save blocked when username is invalid
2026-04-10 23:54:41 +00:00
xarmian 38e6b4e5b1 feat: rewrite web UI routing to /{username}/{workspace}/... pattern
Restructure all workspace-scoped web URLs to include the owner's
username as a prefix (TASK-411).

Route structure:
- Moved web/src/routes/[workspace]/ → [username]/[workspace]/
- All workspace pages extract both username and workspace from URL
- Auth routes (/login, /register, /join, etc.) unchanged

Backend:
- Workspace model adds OwnerUsername field (populated by JOIN)
- All workspace queries JOIN users table for owner_username
- TypeScript Workspace type updated with owner_username

Frontend (24 files updated):
- All route pages: added username derived, updated URL constructions
- Sidebar, TopBar, WorkspaceSwitcher: use owner_username for links
- ItemCard, TableView, ChildItems, NestedChildren: username in links
- CommandPalette, OnboardingChecklist, CreateWorkspaceModal: updated
- Root page redirect includes owner_username
- Wiki-link markdown utility accepts username parameter

What did NOT change:
- API client (client.ts) — still uses workspace slug for API calls
- Go API routes — unchanged
- CLI — unchanged
2026-04-10 23:47:25 +00:00
xarmian 117221c73d feat: auth-scoped workspace resolver with UUID support
Update workspace resolution to support both slugs and UUIDs, with
auth-scoped slug resolution for non-admin users (TASK-412).

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

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

API URL pattern unchanged — /api/v1/workspaces/{ws}/... where {ws}
now accepts both slug and UUID. CLI and frontend unaffected.
2026-04-10 23:29:29 +00:00
xarmian b1357799a9 feat: username validation, reserved words, and registration flow
Add username support to registration with validation, reserved words,
and real-time availability checking (TASK-409).

Backend:
- ValidateUsername() with format/length/reserved word checks
- 35+ reserved usernames (route conflicts, system terms)
- GET /auth/check-username endpoint for real-time validation
- handleBootstrap auto-generates username from name (D1)
- handleRegister accepts optional username, auto-generates if omitted

Frontend:
- Register page: username field with auto-generation from name
- Join/invite page: same username field in register mode
- Debounced availability checking (400ms) via /auth/check-username
- Inline status indicators (checking/available/taken)
- API client: register() accepts username, new checkUsername() method
2026-04-10 23:12:59 +00:00
xarmian 520a7ca27b feat: add owner_id to workspaces with backfill
Add owner_id column to workspaces table and backfill existing
workspaces from membership data (TASK-410 + TASK-480).

- SQLite migration 030 and Postgres migration 010 add owner_id column
  with indexes on (owner_id) and (owner_id, slug)
- Workspace, WorkspaceCreate models updated with OwnerID field
- All workspace SELECT queries include owner_id
- CreateWorkspace INSERT includes owner_id
- handleCreateWorkspace sets owner_id from authenticated user
- backfillWorkspaceOwners extended: sets owner_id using D3 logic
  (earliest owner member → earliest member → first admin)
- TypeScript Workspace type updated

Global UNIQUE(slug) constraint preserved for now; will be replaced
with UNIQUE(owner_id, slug) in TASK-412 when auth-scoped resolution
needs it.
2026-04-10 22:56:32 +00:00
xarmian 0115ec7101 feat: backfill usernames for existing users on startup
Add GenerateUsername() and backfillUsernames() to auto-generate
usernames for existing users who don't have one (TASK-482).

- Derives username from display name (lowercase, hyphens for special chars)
- Falls back to email local part if name produces empty result
- Handles collisions by appending -2, -3, etc.
- Idempotent: skips users who already have a username
- Runs on both SQLite and Postgres startup paths
2026-04-10 22:50:25 +00:00
xarmian f80876a52e feat: add username column to users table
Add username field to the user data model as the foundation for
the multi-user permissions system (PLAN-407, TASK-408).

- SQLite migration 029 and Postgres migration 009 add username column
  with partial unique index (WHERE username != '')
- User, UserCreate, UserUpdate Go structs updated
- Store: CreateUser, UpdateUser, scanUser, userColumns updated
- New GetUserByUsername store method (case-insensitive lookup)
- All auth handler JSON payloads include username field
- WorkspaceMember struct and ListWorkspaceMembers query include username
- TypeScript User type and API client inline types updated

Column is empty string by default; TASK-482 will backfill existing
users and TASK-409 will add validation/registration flow support.
2026-04-10 22:40:07 +00:00
xarmian 4326ab5e04 Merge pull request #84 from xarmian/fix/timeline-duplicate-field-key
fix: timeline duplicate field key crash
2026-04-10 16:57:29 -04:00
xarmian bc8c7090bc fix: use index key for timeline activity changes to avoid duplicate field keys
Activity entries can have the same field changed multiple times (e.g.
status: active → completed; status: completed → planned). The {#each}
block was keyed by change.field which caused each_key_duplicate errors
in Svelte. Switch to array index key since duplicate fields are valid.
2026-04-10 20:57:09 +00:00
xarmian ced8428802 Merge pull request #83 from xarmian/fix/resolve-old-refs-after-move
fix: resolve old item refs after collection move
2026-04-10 16:39:25 -04:00
xarmian 2c9c7e8552 fix: resolve old item refs after move via number-only fallback
After moving an item between collections, the old PREFIX-NUMBER ref
(e.g. PLAN-382) would 404 because the prefix no longer matches.
Since item numbers are now workspace-unique, GetItemByRef falls back
to a number-only lookup when the exact prefix+number doesn't match.

This means PLAN-382 still resolves to the item even after it became
TASK-382, preventing frontend 404 cascades during collection moves.
2026-04-10 20:39:04 +00:00
xarmian 11d1534162 Merge pull request #82 from xarmian/feat/workspace-global-item-numbering
feat: workspace-global item numbering
2026-04-10 16:34:57 -04:00
xarmian a203df3863 feat: workspace-global item numbering with automatic migration
Switch item_number from per-collection to per-workspace scope so items
keep their number when moved between collections (IDEA-42 → BUG-42).

- CreateItem: counter query scopes to workspace_id instead of collection_id
- MoveItem: preserves item_number, only updates collection_id and fields
- One-time migration: detects old (collection_id, item_number) index,
  renumbers all items per-workspace in a single transaction, swaps to
  new UNIQUE(workspace_id, item_number) index. Fully transactional —
  rolls back on failure, retries on next startup.
- Import: assigns fresh sequential numbers instead of using exported
  values, fixing compatibility with old per-collection exports.
- Concurrent create safety: retry loop on unique constraint violation
  (up to 3 attempts) for parallel inserts in the same workspace.

Closes IDEA-330 (formerly IDEA-118).
2026-04-10 20:34:34 +00:00
xarmian 7432ffb1ec fix: emoji picker dropdown escapes dialog overflow clipping
Portal the dropdown into the nearest <dialog> element so it stays in
the browser's top layer, and temporarily set overflow:visible on the
dialog while the picker is open so it isn't clipped.
2026-04-10 20:06:19 +00:00
xarmian cabb552faf feat: add EmojiPickerButton and replace plain-text emoji inputs
Create a reusable EmojiPickerButton component that wraps EmojiPicker
in a compact dropdown toggle. Replace the plain <input type="text">
emoji fields in quick action icons (EditCollectionModal) and role
icons (roles page) with the new picker for a consistent UX.
2026-04-10 19:24:46 +00:00
xarmian 44f249994e feat: add Pad logo wordmark to topbar
Add PadLogo component to the left side of the workspace top bar.
Shows "Pad" in bold accent-blue; includes a hidden "Cloud" badge
variant for future Pad Cloud branding (TASK-205). Reserve left/right
padding in the topbar so workspace buttons never overlap the logo
or user menu.
2026-04-10 19:20:39 +00:00
xarmian 50975e7b91 UI housekeeping: editor stability, sidebar UX, board DnD, quick-add (#81)
* fix: sidebar + button for new collections, desktop sidebar reopen affordance, auto-resizing title editor

- Add "+" button next to Collections header in sidebar to open CreateCollectionModal (IDEA-130)
- Show a chevron tab at the left edge when sidebar is hidden on desktop so users can reopen it without knowing the keyboard shortcut (IDEA-131)
- Switch item title editor from single-line input to auto-resizing textarea so long titles are fully visible while editing (BUG-27)

* fix: reduce editor save jitter by increasing debounce and wiring up SSE guards

Three root causes for BUG-25 (page jumps, stutters, lost keystrokes while typing):

1. Debounce too short (500ms → 1200ms): fast typists frequently pause
   ~500ms between words, triggering saves mid-thought
2. Item page never updated editorStore.lastSaveTime or dirty flag, so
   the SSE handler's 2-second guard never activated — every self-triggered
   save caused an SSE item_updated → re-fetch → store update cycle
3. saveStatus was set to 'saving' on every keystroke (before debounce),
   causing unnecessary re-renders; now only set when save actually fires

Also sets collectionStore.activeItem from the item page so the SSE
handler's active-item guard works correctly.

* fix: suppress timeline refresh during active content editing

The ItemTimeline's SSE handler was re-fetching the entire timeline on
every item_updated event, including self-triggered content saves. This
caused visible re-rendering/shakiness in the timeline section each time
the debounce fired. Now skips item_updated events within 3 seconds of
the last editor save, matching the existing SSE guard pattern.

* fix: eliminate spurious network requests from self-triggered SSE events

Each content save was causing 3 network requests instead of 1: the save
itself, plus /collections and /children re-fetches triggered by the SSE
item_updated event echoing back from our own save.

- Workspace layout SSE handler: skip all side-effects (loadCollections,
  item refetch) for self-triggered content saves using editorStore.dirty
  and lastSaveTime guards
- ChildItems SSE handler: skip item_updated events from self-saves since
  content edits can't affect children
- Timeline SSE handler: remove item_updated from relevant events entirely
  (version diffs appear on next natural refresh); debounce remaining
  events to prevent rate-limit errors from SSE replay on reconnect

* feat: collapsible topbar, sidebar close buttons, quick-add modal, rate limit bump

- Topbar: centered workspace list, collapsible via chevron button or
  Cmd-\, hover-reveal expand tab when hidden, persisted to localStorage
- Sidebar: added close button in footer for independent hiding
- Cmd-\ now toggles both sidebar and topbar together
- Quick-add: sidebar + buttons and "New Item" button open a modal with
  auto-resizing textarea for title input instead of creating "Untitled"
  items (IDEA-132, IDEA-133)
- Dashboard "New Idea"/"New Task" buttons now link to /new form page
- API rate limit bumped from 100/min to 600/min — more appropriate for
  a local-first tool with SSE-driven UI cascading refreshes

* fix: board view drag-and-drop snap-back and re-render cascade

Root cause: isDragging was set to false before the async onStatusChange
API call, triggering a reactive $effect that overwrote columnData with
stale positions — item snapped back to the original column then bounced
to the new one. Additionally, handleReorder fired per-item API calls
that each triggered SSE events, causing cascading re-renders.

- Add dropCooldown flag that freezes columnData for 2s after a drop,
  preventing the $effect from overwriting the visual state while API
  calls and SSE events settle
- Only persist sort_order for items whose order actually changed
- On failed moves: skip reorder, immediately drop cooldown so the
  original state restores cleanly to the correct position
- handleStatusChange re-throws on failure so BoardView can distinguish
  success from failure

* feat: add theme toggle and quick-add modal to sidebar

- Light/dark mode toggle button in sidebar footer row (sun/moon icon),
  to the left of the notification bell (IDEA-134)
- Quick-add modal with auto-resizing textarea for title input, triggered
  from per-collection + buttons and "New Item" button (IDEA-132, IDEA-133)
- Removed old "Untitled" item creation flow from sidebar

* fix: remove all /new page references, use quick-add modal and inline create

- Empty collection "Create" button now opens the inline quick-create
  input instead of navigating to /new (BUG-29)
- Cmd-N opens the sidebar quick-add modal (defaults to active collection
  or Tasks) instead of navigating to /new
- Dashboard "New Idea"/"New Task" buttons trigger quick-add modal
- Onboarding checklist links go to collection pages instead of /new
- Cleaned up dead quickCreate function and unused imports from dashboard

* chore: remove dead /new page route

All item creation now goes through the sidebar quick-add modal or
collection page inline create. The /new form page is no longer
referenced anywhere.
2026-04-10 11:33:12 -04:00
xarmian 1d26c2b542 feat: add workspace top bar with drag-to-reorder (#80)
* feat: add workspace top bar with drag-to-reorder

Replace the sidebar WorkspaceSwitcher dropdown with a dedicated top bar
that provides fast workspace switching and a user menu.

Desktop:
- Horizontal bar above sidebar + content with workspace icons (colored
  first-letter circles) and names as real <a> links
- Drag-and-drop reorder via svelte-dnd-action
- User avatar on right with dropdown (settings, theme toggle, sign out)
- "+" button to create new workspaces

Mobile:
- Full-width fixed bar at top when sidebar opens (above sidebar/backdrop)
- Tap workspace to navigate and close sidebar
- Reorder button opens full-screen vertical list with drag handles
- Sidebar starts below the top bar with adjusted positioning

Backend:
- Migration 028: add sort_order to workspace_members (per-user ordering)
- GET /workspaces now returns workspaces in user's sort order
- PUT /workspaces/reorder endpoint for persisting order

Sidebar simplified:
- Removed WorkspaceSwitcher component, user section, theme toggle
- Theme initialization moved to root layout
- Cleaner footer with search, settings, and notification bell

Implements IDEA-129, relates to IDEA-126.

* fix: address codex review findings (P1+P2)

- Remove unsupported `direction` option from svelte-dnd-action dndzone
- Add Postgres migration 008 for workspace_members.sort_order
- Handle sql.ErrNoRows gracefully in reorder endpoint for admins who
  aren't members of all workspaces
- Restore mobile sign-out: add user name + logout button to sidebar
  footer on mobile (was only in desktop TopBar user menu)
2026-04-10 01:22:49 -04:00
xarmian fbf2b60ce7 Merge pull request #79 from xarmian/fix/tab-resume-sync
fix: replace scattered tab-resume refetches with layered sync system
2026-04-10 01:08:32 -04:00
xarmian 7ef4506cfa fix: replace scattered tab-resume refetches with layered sync system
When the browser tab lost focus and regained it, 5 independent
onTabResume callbacks all fired simultaneously, flooding the server
with redundant requests. This replaces that pattern with a 4-layer
sync architecture:

1. Replay buffer — per-workspace ring buffer stores recent events with
   monotonic IDs. On SSE reconnect, missed events are replayed via
   Last-Event-ID so the client is already caught up.

2. Last-Event-ID support — SSE handler reads the header, replays from
   buffer, or sends sync_required if the gap is too large.

3. Incremental sync — new /changes?since=<ms> endpoint returns only
   modified/deleted items since a timestamp, including archived items
   for view consistency.

4. Centralized sync coordinator — single decision tree replaces 5
   scattered callbacks. Short absences skip sync entirely, SSE-covered
   gaps need no API calls, and full refresh is a last resort.

Key robustness details:
- Global event IDs via Redis INCR for multi-instance safety
- Server-time cursors to avoid client clock skew
- Safe cursor management (only advances on confirmed sync)
- 9 new tests for replay buffer and event ID behavior

Fixes BUG-26.
2026-04-10 04:15:43 +00:00
xarmian 4117d94b5b Merge pull request #78 from xarmian/fix/plan-view-crashes
fix: resolve plan view crash and startup migration error
2026-04-09 10:49:27 -04:00
xarmian 9d90308e20 fix: resolve plan view crash from duplicate children and tiptap link conflict
Three fixes:

1. GetChildItems returned duplicate rows when an item was linked to a parent
   via multiple link types (e.g. both "parent" and "implements"), causing
   Svelte's {#each} to throw each_key_duplicate. Added SELECT DISTINCT.

2. StarterKit v3.20.4 now includes Link by default, conflicting with our
   custom SafeLink extension. Disabled StarterKit's built-in link.

3. Migration runner now tolerates "duplicate column name" errors on
   ALTER TABLE ADD COLUMN, making migrations idempotent when partially
   applied (e.g. server crash mid-migration).
2026-04-09 14:49:03 +00:00
xarmian 239ce19d1f ci: reduce CI usage by ~60-70% with optimized workflow
- Add concurrency groups to cancel superseded in-progress runs
- Move race detector to main-only (saves ~9.5 min per PR run)
- Merge go-vet into go job (eliminates separate VM)
- Remove redundant full-build job (release.yml handles real builds)
- Add binary build+verify as steps in go job for smoke testing
2026-04-09 00:27:55 +00:00
xarmian 3f29055f5c Merge feat/totp-two-factor-auth: TOTP two-factor authentication (#77) 2026-04-08 21:55:56 +00:00
xarmian 0dc3f0b61f fix: address Codex review findings for TOTP 2FA
- Reject API token auth on 2FA enrollment endpoints (setup, verify,
  disable) to prevent account takeover via leaked tokens (P1)
- Re-read 2FA challenge secret after persisting to handle multi-instance
  startup race on fresh databases (P2)
2026-04-08 21:36:48 +00:00
xarmian 10867b9210 fix: persist 2FA challenge key and prevent duplicate TOTP verification
- Persist the 2FA challenge HMAC signing key in platform_settings so
  tokens survive process restarts and work across multiple instances
- Add AND totp_enabled = false to EnableTOTP WHERE clause so concurrent
  /auth/2fa/verify calls (double-click, multi-tab) cannot both succeed
  and overwrite each other's recovery codes
2026-04-08 20:30:39 +00:00
xarmian 1ac0abc305 fix: resolve 2FA Codex review findings (Postgres bools, recovery code race, web login flow)
- Use dialect.BoolToInt() for totp_enabled updates instead of hardcoded
  1/0 integers that fail on PostgreSQL BOOLEAN columns
- Add optimistic locking to ConsumeRecoveryCode to prevent double-spend
  under concurrent requests
- Add 2FA challenge step to web login and join pages so browser login
  works for accounts with TOTP enabled
2026-04-08 20:30:39 +00:00
xarmian 5606b22007 fix: address 6 security findings from Codex review of TOTP 2FA
HIGH fixes:
- Login-verify no longer accepts bare user_id. Now requires an
  HMAC-signed, IP-bound, 5-minute challenge token issued during login
  (prevents password bypass via known user ID + TOTP code)
- Recovery codes are SHA-256 hashed before storage; plaintext is
  returned to the user once and never persisted

MEDIUM fixes:
- ConsumeRecoveryCode uses a DB transaction to prevent concurrent
  double-consumption of the same recovery code
- EnableTOTP is atomic: WHERE clause requires totp_secret match to
  prevent TOCTOU race between setup and verify calls
- /auth/2fa/login-verify now uses the strict Auth rate limiter
  (5 req/min/IP) instead of the general API limiter
- CLI login detects requires_2fa response and prompts for TOTP code
  instead of silently saving empty credentials
2026-04-08 20:30:39 +00:00
xarmian 0e32645bb5 feat: add TOTP two-factor authentication
Backend support for optional TOTP-based 2FA on user accounts:

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

Implements TASK-169 under PLAN-15 (Pad Cloud: Hardening).
2026-04-08 20:30:39 +00:00
xarmian beb2afd7f4 Merge feat/api-token-rotation-expiry: API token rotation, expiry defaults, and scope enforcement 2026-04-08 18:50:12 +00:00
xarmian 33f51a2bd6 fix: address Codex review findings for token rotation and scope enforcement
- Preserve backward compatibility for unrecognized token scopes (P1)
- Reject malformed JSON before destructive token rotation (P2)
- Preserve original created_at timestamp when rotating tokens (P3)
2026-04-08 18:50:08 +00:00
xarmian ba8e20c697 feat: add API token rotation, expiry defaults, and scope enforcement
- New tokens get a default 90-day expiry (configurable via platform
  settings: token_default_expiry_days, token_max_lifetime_days)
- POST /api/v1/auth/tokens/{id}/rotate generates a new secret while
  preserving token metadata; old secret is immediately invalidated
- X-Token-Expires-Soon and X-Token-Expires-At headers warn when a
  token is within 7 days of expiry
- Token scopes are now enforced: "read" restricts to GET/HEAD/OPTIONS,
  "write" and "*" allow all methods
- Existing tokens without expiry continue to work (backward compatible)

Implements TASK-170 under PLAN-15 (Pad Cloud: Hardening).
2026-04-08 18:00:43 +00:00