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.
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.
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.
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).
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.
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.
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.
* 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.
* 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)
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.
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).
- 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
- 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
- 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
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
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).
- 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).
* feat: add session binding, nonce-based CSP, and auth hardening
Security hardening for Pad Cloud (PLAN-15 / TASK-171):
- Bind sessions to User-Agent hash; mismatch invalidates session
- Store client IP on session creation for audit trail
- Increase bcrypt cost from 10 to 12
- Upgrade invitation codes to 128-bit entropy with hashed storage
- Replace CSP unsafe-inline with per-request nonce for SvelteKit scripts
- Move SecurityHeaders to main router so SPA gets headers too
* fix: enforce session binding on auth cookie fallbacks and fix invitation code uniqueness
- Add validateSessionCookie() helper that checks UA binding, replacing
raw ValidateSession() calls in handleSessionCheck, handleGetCurrentUser,
and handleUpdateCurrentUser that bypassed the new session binding
- Store invitation ID in code column instead of empty string to satisfy
the NOT NULL UNIQUE constraint (previously broke on second invitation)
- Skip code/join_url in invitation listings for hashed invitations where
the plaintext is not recoverable
Add background, border, and border-radius to board columns for a
boxed card-like container look. Round header top corners to match,
tighten card gap, and bump header padding/weight for consistency
with the roles page lane styling.
Progress bars, child item lists, and parent enrichment all only
counted 'parent' link types. Items connected via 'implements' links
(e.g. tasks implementing a plan) were invisible to the progress
system, showing 0/0 even when all implementing items were done.
Backend: define childLinkTypes ('parent' + 'implements') and update
all 8 SQL queries (GetItemProgress, GetAllItemProgress, GetChildItems,
PopulateHasChildren, GetParentMap, GetParentForItem, and both
terminal-status helpers) to use IN ('parent','implements').
Frontend: remove the standalone "Derived Closure" banner that
duplicated relationship info. Fold closure summary into the matching
relationship group as an inline annotation. Add implements-link
dedup so children shown in ChildItems aren't repeated in Relationships.
Uppercase the input in parseItemRef() so "task-5", "Task-5", and
"TASK-5" all resolve correctly. Fixes CLI lookups, API resolution,
and search matching for item references.
* Rename "Phases" to "Plans" and clean up deprecated phase aliases
Renames the default "Phases" collection to "Plans" across the full stack:
- DB migration renames existing collections in-place (name, slug, prefix PLAN, icon 🗺️)
- Removes all deprecated Phase* backward-compat aliases from models and store
- Removes --phase CLI flag (use --parent instead)
- Updates convention triggers: on-phase-start/complete → on-plan-start/complete
- Updates dashboard API: active_phases → active_plans, /phases-progress → /plans-progress
- Updates all frontend components, types, and documentation
Closes IDEA-124
* Fix CSRF cookie not being cleared on logout
The SessionAuth middleware was re-issuing a CSRF cookie before the
logout handler could clear it, resulting in two Set-Cookie headers.
Skip CSRF re-issue for /api/v1/auth/ paths since auth endpoints
manage their own CSRF cookies (login sets, logout clears).
* Fix migration issues found in Codex review
- P1: Move doc_type UPDATE from migration 024 into 025, which recreates
the table with the new CHECK constraint first (SQLite enforces CHECK
on UPDATE, so the old constraint would reject 'plan')
- P1: Add PostgreSQL migration 005 for the collection rename (phases →
plans) — previously only existed on the SQLite path
- P2: Recreate FTS triggers, indexes, and rebuild FTS after the table
swap in migration 025 (DROP TABLE drops associated objects in SQLite)
* Fix parent filter field name and sync .agents skill copy
Codex review round 2 findings:
- P1: Parent filter compared against `parent_id` (wrong) instead of
`parent_link_id` — plan filtering in collection view was broken
- P1: .agents/skills/pad/SKILL.md still had old --phase flags and
"Phases" references — synced from the updated .claude copy
- P2: Accept legacy 'phase' filter key for backward compat with
existing saved views that serialized the old key name
* Fix PG migration JSONB casting and add slug collision guards
Codex PR review bot findings:
- P1: PostgreSQL REPLACE/LIKE don't work on JSONB columns — cast
schema::text and fields::text before string ops, then back to ::jsonb
- P1: If a workspace already has a custom 'plans' collection, the
rename hits UNIQUE(workspace_id, slug) — added NOT EXISTS guard
to both SQLite and PostgreSQL migrations
* feat: generalize parent/child items — any item can have children with progress tracking
Replaces the phases-only task widget with a generalized parent/child system.
Any item (Phase, Idea, Doc, Task, etc.) can now be a parent of child items,
getting automatic progress bars, burndown charts, status grouping, drag-drop
reordering, and recursive expand/collapse up to 3 levels deep.
DB: migrate link_type 'phase' → 'parent' (migration 023)
Store: generalized methods (GetChildItems, GetItemProgress, SetParentLink
with cycle detection), drop collection filters, per-child terminal status
API: new /items/{slug}/children and /items/{slug}/progress endpoints
Frontend: ChildItems, ChildChart, NestedChildren components replace PhaseTasks
CLI: --parent flag (--phase kept as hidden alias), list/show/changelog updated
Docs: CLAUDE.md, SKILL.md, pad-web updated for parent/child model
Full backward compatibility: old 'phase' link_type, --phase flags, phase_id/
phase_ref JSON fields all still work as deprecated aliases.
Closes PHASE-16 (9 tasks).
* fix: update collection list page to use item_id from phasesProgress response
The TS client return type changed from phase_id to item_id but the
collection list page still referenced p.phase_id, causing svelte-check
type errors in CI.
* fix: address Codex review findings — PG migration, terminal statuses, metrics, CSRF resilience
- Add PostgreSQL migration 003 to rename 'phase' links to 'parent'
- Use schema-defined terminal statuses in GetAllItemProgress instead of hardcoded defaults
- Pass computed terminal statuses from page to ChildItems component
- Only special-case 'parent' field key when not defined in collection schema
- Move MetricsMiddleware before Recoverer so panics are counted
- Always mount ChildItems for SSE subscriptions even with 0 children
- Exclude soft-deleted children from has_children enrichment query
- Re-issue CSRF cookie when session is valid but cookie is missing
- Show actual API error messages in create-item toasts
- Make SSE limit checks atomic with subscription via SubscribeIfAllowed to
prevent TOCTOU races where concurrent requests bypass connection caps
- Replace fmt.Sprintf JSON assembly with json.Marshal (auditMeta helper) in
all audit log call sites to prevent silent JSONB insert failures on
PostgreSQL when metadata contains special characters
- Pass database credentials via PGDATABASE env var instead of pg_dump/psql
command-line args to avoid leaking passwords in ps/proc output
- Fix audit-log query builder to rebind placeholders once after all filters
are appended, preventing duplicate $1 placeholders on PostgreSQL
- Replace per-workspace SSE GaugeVec with a single Gauge to avoid unbounded
Prometheus label cardinality in multi-tenant deployments
- Fix prod Docker Compose: override PAD_REDIS_URL with password and add
authenticated Redis healthcheck when REDIS_PASSWORD is set
P1 fixes:
- Use --dbname=URL for pg_dump/psql so SSL params, timeouts, and
other connection options from PAD_DATABASE_URL are preserved
- Add --clean --if-exists to pg_dump so restores can overwrite an
existing database without duplicate-key errors
P2 fixes:
- Add logAuditEventForUser() to pass explicit user ID for auth events
(login, register, bootstrap, password_reset) where the request
context doesn't yet have the authenticated user
- Change audit log --actor filter to match user_id column instead of
actor type, so filtering by specific user actually works
- pad db backup: wraps pg_dump with --output and --cron flags
- pad db restore: wraps psql with confirmation prompt and --force
- pad db migrate-to-pg: one-time SQLite→PostgreSQL migration using
application-level export/import for all workspace data
- docs/backup.md: comprehensive backup strategy guide covering SQLite,
PostgreSQL, cloud snapshots, and disaster recovery
Extend the activities table to capture IP address and user agent for all
state-changing operations. Add audit events for auth (login, logout,
register, bootstrap, password changes), workspace management (member
invite/remove, role changes), token lifecycle, and admin settings.
- SQLite migration recreates activities table with nullable workspace_id,
new ip_address/user_agent columns, and relaxed CHECK constraints
- PostgreSQL migration adds columns and drops constraints
- New ListAuditLog store method with action/actor/workspace/date filters
- GET /api/v1/audit-log endpoint (admin-only)
- CLI: pad workspace audit-log [--days N] [--actor X] [--action X]
Add configurable global and per-workspace SSE connection limits to
prevent memory exhaustion from unbounded connections. Returns HTTP 429
when limits are reached. Logs warnings at 80% capacity.
Configurable via PAD_SSE_MAX_CONNECTIONS (default 1000) and
PAD_SSE_MAX_PER_WORKSPACE (default 100), or config.toml.
Adds WorkspaceSubscriberCount to EventBus interface for per-workspace
tracking (MemoryBus iterates subscribers, RedisBus uses existing
wsCounts map).
Resolves TASK-165
Instrument the Go server with Prometheus metrics for production
monitoring. Adds HTTP request count/duration/size histograms (by
method, route pattern, status), SSE connection gauges per workspace,
event bus publish/subscriber counts, and database connection pool
stats via callback collector. Go runtime metrics included.
The /metrics endpoint is unauthenticated (standard for Prometheus
scraping), separated from the auth middleware via chi router groups.
Resolves TASK-164
- views.is_default: replace literal 0 with s.dialect.BoolToInt(false)
- webhooks.active: replace literal 1 with s.dialect.BoolToInt(true)
Both columns are BOOLEAN in the PostgreSQL schema; integer literals
cause type errors with pgx.
- Rebind prepared statements in agent role and card reordering
so PostgreSQL receives $1/$2 instead of ? placeholders
- Add JSONArrayContains dialect method: SQLite uses LIKE, PostgreSQL
uses jsonb @> operator — fixes tag filtering on JSONB columns
- Redact Redis credentials from startup log: log addr+db only,
not the full connection URL which may contain passwords
- Sanitize field-filter keys from query params before interpolating
into JSON path expressions — prevents SQL injection via crafted
query parameter names (affects both SQLite and PostgreSQL)
- Fix document search ORDER BY rank on PostgreSQL — the PG query
path doesn't expose a `rank` column; use ts_rank() with DESC
- Fix item search rank ordering in ListItems and SearchItems for
PostgreSQL — ts_rank() needs DESC (higher = more relevant)
- Convert isDiff int→bool in version inserts (items.go, documents.go)
using s.dialect.BoolToInt() for cross-driver compatibility
- Fix boolean parameter writes in export.go ImportWorkspace
(is_default, pinned, is_diff all used int literals)
- Fix webhook CASE expression: return FALSE instead of 0 for
PostgreSQL BOOLEAN active column
- Fix search rank ordering: PostgreSQL ts_rank() uses DESC (higher
= more relevant) vs SQLite bm25() ASC (more negative = better)
- Fix pinned WHERE clauses: use TRUE/FALSE instead of 1/0 for
PostgreSQL BOOLEAN compatibility
P1: Wrap store helper queries (uniqueSlug, uniqueSlugExcluding,
backfillItemNumbers) with s.q() for placeholder rebinding.
P1: Replace boolToInt() with s.dialect.BoolToInt() so pgx receives
native booleans instead of 0/1 integers.
P1: Change boolean scan variables from int to bool to match
PostgreSQL's native boolean type.
P1: Fix FTS table aliases in PostgreSQL search branches.
P1: Make api_tokens.workspace_id nullable for user-scoped tokens.
P2: Move eventBus.Close() before srv.Shutdown() so SSE handlers
drain before the HTTP server shutdown deadline.
The script-src 'self' CSP directive blocked SvelteKit's inline bootstrap
scripts, causing a white screen on mobile browsers which enforce CSP
strictly. Add 'unsafe-inline' as a temporary fix until nonce-based CSP
is implemented (TASK-163).