Commit Graph

564 Commits

Author SHA1 Message Date
dependabot[bot] b780c2a1fe chore(ci)(deps): bump docker/login-action from 3.7.0 to 4.1.0 (#206)
Bumps [docker/login-action](https://github.com/docker/login-action) from 3.7.0 to 4.1.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/c94ce9fb468520275223c153574b00df6fe4bcc9...4907a6ddec9925e35a0a9e82d7399ccc52663121)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-07 23:52:14 -04:00
dependabot[bot] 90dcc3d4ce chore(ci)(deps): bump actions/checkout from 4.3.1 to 6.0.2 (#204)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4.3.1 to 6.0.2.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/34e114876b0b11c390a56381ad16ebd13914f8d5...de0fac2e4500dabe0009e67214ff5f5447ce83dd)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 6.0.2
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-07 23:51:15 -04:00
dependabot[bot] 8b9a4d63d5 chore(ci)(deps): bump actions/setup-go from 5.6.0 to 6.4.0 (#203)
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 5.6.0 to 6.4.0.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/40f1582b2485089dde7abd97c1529aa768e1baff...4a3601121dd01d1626a1e23e37211e3254c1c06c)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-version: 6.4.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-07 23:50:08 -04:00
dependabot[bot] b1f68818f1 chore(ci)(deps): bump goreleaser/goreleaser-action from 6.4.0 to 7.2.1 (#268)
Bumps [goreleaser/goreleaser-action](https://github.com/goreleaser/goreleaser-action) from 6.4.0 to 7.2.1.
- [Release notes](https://github.com/goreleaser/goreleaser-action/releases)
- [Commits](https://github.com/goreleaser/goreleaser-action/compare/e435ccd777264be153ace6237001ef4d979d3a7a...1a80836c5c9d9e5755a25cb59ec6f45a3b5f41a8)

---
updated-dependencies:
- dependency-name: goreleaser/goreleaser-action
  dependency-version: 7.2.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-07 23:47:25 -04:00
dependabot[bot] 1c86165b77 chore(docker)(deps): bump alpine (#248)
Bumps the docker-minor-and-patch group with 1 update in the / directory: alpine.


Updates `alpine` from 3.21 to 3.23

---
updated-dependencies:
- dependency-name: alpine
  dependency-version: '3.23'
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: docker-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-07 23:45:40 -04:00
dependabot[bot] 06f8487d6b chore(deps)(deps): bump the npm-minor-and-patch group across 1 directory with 16 updates (#412)
Bumps the npm-minor-and-patch group with 15 updates in the /web directory:

| Package | From | To |
| --- | --- | --- |
| [@tiptap/core](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/core) | `3.20.4` | `3.22.5` |
| [@tiptap/extension-bubble-menu](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-bubble-menu) | `3.20.4` | `3.22.5` |
| [@tiptap/extension-code-block-lowlight](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-code-block-lowlight) | `3.20.4` | `3.22.5` |
| [@tiptap/extension-link](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-link) | `3.20.4` | `3.22.5` |
| [@tiptap/extension-placeholder](https://github.com/ueberdosis/tiptap/tree/HEAD/packages-deprecated/extension-placeholder) | `3.20.4` | `3.22.5` |
| [@tiptap/extension-table](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-table) | `3.20.4` | `3.22.5` |
| [@tiptap/extension-task-item](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-task-item) | `3.20.4` | `3.22.5` |
| [@tiptap/extension-task-list](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-task-list) | `3.20.4` | `3.22.5` |
| [@tiptap/starter-kit](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/starter-kit) | `3.20.4` | `3.22.5` |
| [@tiptap/suggestion](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/suggestion) | `3.20.4` | `3.22.5` |
| [dompurify](https://github.com/cure53/DOMPurify) | `3.4.1` | `3.4.2` |
| [mermaid](https://github.com/mermaid-js/mermaid) | `11.13.0` | `11.14.0` |
| [@sveltejs/kit](https://github.com/sveltejs/kit/tree/HEAD/packages/kit) | `2.57.1` | `2.59.0` |
| [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte) | `5.54.1` | `5.55.5` |
| [svelte-check](https://github.com/sveltejs/language-tools) | `4.4.5` | `4.4.7` |



Updates `@tiptap/core` from 3.20.4 to 3.22.5
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/core/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.22.5/packages/core)

Updates `@tiptap/extension-bubble-menu` from 3.20.4 to 3.22.5
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/extension-bubble-menu/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.22.5/packages/extension-bubble-menu)

Updates `@tiptap/extension-code-block-lowlight` from 3.20.4 to 3.22.5
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/extension-code-block-lowlight/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.22.5/packages/extension-code-block-lowlight)

Updates `@tiptap/extension-link` from 3.20.4 to 3.22.5
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/extension-link/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.22.5/packages/extension-link)

Updates `@tiptap/extension-placeholder` from 3.20.4 to 3.22.5
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages-deprecated/extension-placeholder/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.22.5/packages-deprecated/extension-placeholder)

Updates `@tiptap/extension-table` from 3.20.4 to 3.22.5
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/extension-table/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.22.5/packages/extension-table)

Updates `@tiptap/extension-task-item` from 3.20.4 to 3.22.5
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.22.5/packages/extension-task-item)

Updates `@tiptap/extension-task-list` from 3.20.4 to 3.22.5
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.22.5/packages/extension-task-list)

Updates `@tiptap/pm` from 3.20.4 to 3.22.5
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/pm/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.22.5/packages/pm)

Updates `@tiptap/starter-kit` from 3.20.4 to 3.22.5
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/starter-kit/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.22.5/packages/starter-kit)

Updates `@tiptap/suggestion` from 3.20.4 to 3.22.5
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/suggestion/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.22.5/packages/suggestion)

Updates `dompurify` from 3.4.1 to 3.4.2
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.1...3.4.2)

Updates `mermaid` from 11.13.0 to 11.14.0
- [Release notes](https://github.com/mermaid-js/mermaid/releases)
- [Commits](https://github.com/mermaid-js/mermaid/compare/mermaid@11.13.0...mermaid@11.14.0)

Updates `@sveltejs/kit` from 2.57.1 to 2.59.0
- [Release notes](https://github.com/sveltejs/kit/releases)
- [Changelog](https://github.com/sveltejs/kit/blob/main/packages/kit/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/kit/commits/@sveltejs/kit@2.59.0/packages/kit)

Updates `svelte` from 5.54.1 to 5.55.5
- [Release notes](https://github.com/sveltejs/svelte/releases)
- [Changelog](https://github.com/sveltejs/svelte/blob/main/packages/svelte/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte/commits/svelte@5.55.5/packages/svelte)

Updates `svelte-check` from 4.4.5 to 4.4.7
- [Release notes](https://github.com/sveltejs/language-tools/releases)
- [Commits](https://github.com/sveltejs/language-tools/compare/svelte-check@4.4.5...svelte-check@4.4.7)

---
updated-dependencies:
- dependency-name: "@tiptap/core"
  dependency-version: 3.22.5
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-bubble-menu"
  dependency-version: 3.22.5
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-code-block-lowlight"
  dependency-version: 3.22.5
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-link"
  dependency-version: 3.22.5
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-placeholder"
  dependency-version: 3.22.5
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-table"
  dependency-version: 3.22.5
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-task-item"
  dependency-version: 3.22.5
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-task-list"
  dependency-version: 3.22.5
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/pm"
  dependency-version: 3.22.5
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/starter-kit"
  dependency-version: 3.22.5
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/suggestion"
  dependency-version: 3.22.5
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: dompurify
  dependency-version: 3.4.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: mermaid
  dependency-version: 11.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@sveltejs/kit"
  dependency-version: 2.59.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: svelte
  dependency-version: 5.55.5
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: svelte-check
  dependency-version: 4.4.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-07 23:44:48 -04:00
dependabot[bot] 77dffac945 chore(deps)(deps): bump the go-minor-and-patch group across 1 directory with 5 updates (#439)
Bumps the go-minor-and-patch group with 5 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [github.com/jackc/pgx/v5](https://github.com/jackc/pgx) | `5.9.1` | `5.9.2` |
| [github.com/mark3labs/mcp-go](https://github.com/mark3labs/mcp-go) | `0.50.0` | `0.52.0` |
| [github.com/redis/go-redis/v9](https://github.com/redis/go-redis) | `9.18.0` | `9.19.0` |
| [github.com/spf13/pflag](https://github.com/spf13/pflag) | `1.0.9` | `1.0.10` |
| [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) | `1.47.0` | `1.50.0` |



Updates `github.com/jackc/pgx/v5` from 5.9.1 to 5.9.2
- [Changelog](https://github.com/jackc/pgx/blob/master/CHANGELOG.md)
- [Commits](https://github.com/jackc/pgx/compare/v5.9.1...v5.9.2)

Updates `github.com/mark3labs/mcp-go` from 0.50.0 to 0.52.0
- [Release notes](https://github.com/mark3labs/mcp-go/releases)
- [Commits](https://github.com/mark3labs/mcp-go/compare/v0.50.0...v0.52.0)

Updates `github.com/redis/go-redis/v9` from 9.18.0 to 9.19.0
- [Release notes](https://github.com/redis/go-redis/releases)
- [Changelog](https://github.com/redis/go-redis/blob/master/RELEASE-NOTES.md)
- [Commits](https://github.com/redis/go-redis/compare/v9.18.0...v9.19.0)

Updates `github.com/spf13/pflag` from 1.0.9 to 1.0.10
- [Release notes](https://github.com/spf13/pflag/releases)
- [Commits](https://github.com/spf13/pflag/compare/v1.0.9...v1.0.10)

Updates `modernc.org/sqlite` from 1.47.0 to 1.50.0
- [Changelog](https://gitlab.com/cznic/sqlite/blob/master/CHANGELOG.md)
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.47.0...v1.50.0)

---
updated-dependencies:
- dependency-name: github.com/jackc/pgx/v5
  dependency-version: 5.9.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-minor-and-patch
- dependency-name: github.com/mark3labs/mcp-go
  dependency-version: 0.52.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-and-patch
- dependency-name: github.com/redis/go-redis/v9
  dependency-version: 9.19.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-and-patch
- dependency-name: github.com/spf13/pflag
  dependency-version: 1.0.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-minor-and-patch
- dependency-name: modernc.org/sqlite
  dependency-version: 1.50.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-07 23:42:02 -04:00
xarmian 14e3e41185 chore: bump go to 1.26.3 + golang.org/x/net to v0.53.0 (TASK-1232) (#438)
Clears the 4 stdlib govulncheck findings that were blocking `make check`
on the previous baseline:

- GO-2026-4982 — meta content URL escaping XSS (html/template)
- GO-2026-4980 — Escaper bypass XSS (html/template)
- GO-2026-4971 — NUL byte panic on Windows (net)
- GO-2026-4918 — HTTP/2 SETTINGS_MAX_FRAME_SIZE infinite loop
                 (net/http) + golang.org/x/net needed v0.53.0

Changes:
- go.mod: `go 1.26.0` → `go 1.26.3` (minimum required Go version).
  GOTOOLCHAIN=auto (the default) makes `go` auto-download 1.26.3 for
  contributors still on an older local binary; CI's setup-go pin
  `go-version: "1.26"` already floats to the latest 1.26.x patch and
  needs no change.
- golang.org/x/net v0.52.0 → v0.53.0 (the GO-2026-4918 fix).
- golang.org/x/crypto v0.49.0 → v0.50.0, golang.org/x/sys v0.42.0 →
  v0.43.0, golang.org/x/term v0.41.0 → v0.42.0 — pulled in as
  cross-module compat partners by `go get golang.org/x/net@v0.53.0`.

Verification:
- govulncheck ./...        → 0 vulnerabilities affecting our code
                             (1 unreached grpc finding GO-2026-4762 is
                             in the import graph only — separate task)
- golangci-lint run        → 0 issues
- go test ./...            → all pass
- cd web && npm run build  → clean
- make check               → fully green for the first time

Implements TASK-1232.
2026-05-07 23:28:12 -04:00
xarmian a829d3a06d fix(web): ItemCard polish — nudge PR badge down, wrap long titles (BUG-1233) (#437)
* fix(web): ItemCard polish — nudge PR badge down, wrap long titles (BUG-1233)

Two small fixes on the item card:

1. PR badge vertical position: top: -6px → 8px. The previous offset
   pushed the badge above the card edge; 8px sits the pill comfortably
   inside the top of the card while keeping the right: -6px overhang
   (sticker-on-edge feel). Cosmetic polish on TASK-1230.

2. Long-title overflow (BUG-1233). Item cards with unbreakable titles
   (URLs, long identifiers, code-snippet titles) used to push the card
   past its container — most visible on board view's narrow columns.

   - `.item-card`  → `min-width: 0` so it can shrink below its intrinsic
     content width when it's a flex/grid child
   - `.card-title` → `overflow-wrap: anywhere; min-width: 0` so any
     character can be a break point when whitespace is absent

   Deliberately NOT setting `overflow: hidden` on `.item-card` — the
   PR badge protrudes at `right: -6px` and would be clipped.

Verification:
- golangci-lint                    -> 0 issues
- go test ./...                    -> all pass
- cd web && npm run build          -> clean
- Manual: long-title item now wraps inside the card on board + list views;
  PR badge still renders correctly at the new position.

* fix(web): reserve top-row right padding for PR badge per Codex review (round 1)

Codex flagged that moving the PR badge from top: -6px to top: 8px
brought it inside the card and on top of the .card-top-row content
(star button, optional collection badge, item ref). On dashboard
active-items panel and role board (both pass showCollection=true) or
items with long refs, the badge could cover that text and intercept
clicks.

Fix: add `class:has-pr={!!pullRequest}` to the card and reserve
padding-right: 52px on .card-top-row whenever a PR badge is present.
Width budget covers a 5-digit PR number worst case plus the badge's
right: -6px protrusion (~50px pill - 6px overhang = 44px inner gap;
rounded up to 52px for breathing room). Non-PR cards keep their full
top-row width.
2026-05-07 23:15:24 -04:00
xarmian e932fd4fcd feat(web): show PR state badge on item cards (TASK-1230) (#436)
Render a state-colored pill badge in the top-right corner of ItemCard
whenever an item has a linked GitHub PR. The badge overlaps the card
border ("sticker on card" feel), shows the PR number, and opens the PR
URL in a new tab on click without navigating to the item.

PR data is read from `item.code_context?.pull_request` which the server
already derives from `fields.github_pr` via ExtractItemCodeContext —
no new types, helpers, or API calls. State colors:

- OPEN    -> var(--accent-green)
- MERGED  -> var(--accent-purple, #8b5cf6)
- CLOSED  -> var(--accent-red, #ef4444)
- DRAFT   -> var(--text-muted)
- default -> var(--text-muted)

Implementation matches the existing star-btn pattern: a <button> (not
nested <a>) with preventDefault + stopPropagation, then window.open
with noopener,noreferrer. Visible in both default and compact card
variants; tooltip on hover surfaces the PR title + state.

Implements [[IDEA-1214]]. Stale-state refresh (badge may show OPEN
after PR is merged) is intentionally out of scope and tracked
separately under IDEA-1214 (sibling task).

Verification:
- golangci-lint run --timeout=5m ./...  -> 0 issues
- go test ./...                          -> all pass
- cd web && npm run build                -> clean
- svelte-check                           -> 0 errors

Note: `make check` also runs govulncheck which flags 4 pre-existing
Go stdlib vulnerabilities (GO-2026-4982 / 4980 / 4971 / 4918) on the
1.26.2 baseline. Tracked under TASK-1232 (Bump Go toolchain to 1.26.3).
Unrelated to this PR.
2026-05-07 22:50:17 -04:00
xarmian d915cc3cf8 feat(cli): per-server credentials in credentials.json with v1 → v2 migration (TASK-1228) (#435)
Implements IDEA-1226. ~/.pad/credentials.json is now a map keyed by
server URL so one developer machine can stay logged in to multiple Pad
instances simultaneously — `apm/` repo on Pad Cloud, `target/` repo on
local, `testing/` repo on staging — without each `pad init --url <other>`
clobbering the previous server's credentials.

## On-disk format

v2 (new):
  {
    "version": 2,
    "credentials": {
      "https://app.getpad.dev":  {"token": "...", "user_id": "...", ...},
      "http://127.0.0.1:7777":   {"token": "...", "user_id": "...", ...}
    }
  }

v1 (legacy, read-only): {"server_url": "...", "token": "...", "user_id": "...", ...}

Reads transparently migrate v1 → v2 in memory; writes always emit v2.
Side-effect-free reads — the on-disk file stays v1 until login/logout/
setup triggers a Save, which is when migration becomes durable. This
keeps `pad <read-only-command>` from rewriting credentials.json on
every invocation just because the binary upgraded.

## API

Replaces the three top-level helpers (LoadCredentials / SaveCredentials /
DeleteCredentials) with a CredentialStore type:

  - LoadStore() (*CredentialStore, error)
  - (s).Get(serverURL) *Credentials       // nil-receiver safe
  - (s).Set(serverURL, *Credentials)
  - (s).Delete(serverURL)
  - (s).Save() error
  - WipeCredentialsFile() error           // file-level — replaces DeleteCredentials

URL canonicalization is built in: trailing slash + surrounding whitespace
are stripped before lookup/store, so http://x:7777 and http://x:7777/
hit the same bucket. Same rule cmd/pad/server_info.go was already
applying via its now-redundant normalizeURL — removed.

No top-level `default` field. The configured server (cfg.BaseURL() from
~/.pad/config.toml or --url) is always the source of truth for "which
server am I targeting" — a separate `default` would create a second
source of truth and the split-brain bugs that follow.

## Behavioral changes

- `pad init --url <other>` against a server you've authed to before now
  reuses the saved credential instead of clobbering it.
- `pad auth logout` removes only the configured server's entry. Other
  servers' tokens stay intact (pre-fix: wiped the whole file).
- `pad auth whoami` reads only the entry matching the configured server.
- Single-server users see no behavior change — one entry, identical
  shape per entry, identical UX.

## Compat shims removed

LoadCredentials / SaveCredentials / DeleteCredentials are deleted
outright (no // Deprecated lifecycle) — they're internal package
helpers with no external API contract. All 10 call sites in cmd/pad/
and internal/cli/ are migrated to the per-server API in this PR.

## Tests

internal/cli/credentials_test.go (15 tests):
- File missing / empty → empty store (callers don't need nil checks)
- v1 format reads + migrates in memory
- v1 with empty token → empty store (no phantom entries)
- v1 migration is durable on first Save (file flips to v2)
- v2 round-trip preserves multiple entries
- Set adds + replaces; mirrors URL into ServerURL field
- Delete keeps siblings (multi-server keystone behavior)
- Delete on absent key is a no-op
- Nil receiver Get/Delete don't panic (NewClientFromURL relies on this)
- URL normalization (trailing slash + whitespace)
- Save preserves all entries across the file boundary
- Save uses 0600 permissions
- WipeCredentialsFile removes the file + is idempotent
- Garbage file errors loudly (so we never silently lose data)

Existing tests unchanged. Full suite + lint + web-check green.

Closes: TASK-1228.
Implements: IDEA-1226.
2026-05-07 20:23:13 -04:00
xarmian ce0be1ed0a fix(auth): TokenAuth falls through invalid Bearer on public API paths (BUG-1227) (#434)
Prior behavior: TokenAuth middleware rejected any invalid/malformed
Bearer with 401 before dispatch — even on paths in isPublicAPIPath
(/api/v1/auth/*, /health, share links, public plan-limits). A stale
credential in ~/.pad/credentials.json (typically left over after wiping
a test DB) made every CLI invocation 401 on the very first
CheckSession() call, INCLUDING the endpoints needed to recover (login,
forgot-password). Users could only fix it by manually deleting their
credentials file.

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

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

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

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

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

Closes: BUG-1227.
Related: IDEA-1226 (per-server credentials — proper design fix; this is
the safety-net fix that complements it).
2026-05-07 20:01:34 -04:00
xarmian dfb67ae64b feat(init): browser-based admin setup in pad init via /setup#token (TASK-1217) (#433)
Wire `pad init`'s admin-creation step (Step 3) to use
cli.RunBrowserBootstrap from TASK-1216 by default, with --cli-prompt
preserving the legacy in-terminal email/name/password prompts. Workspace
creation stays CLI — `pad init` is intrinsically directory-bound (.pad.toml
write, cwd link), and that's what the browser flow can't do.

Default flow on a fresh server in TTY:
  1. Configure (existing)
  2. Start server (existing)
  3. NEW: print /setup#token=<x> deep link, poll until admin is created
  4. NEW: chain doBrowserLogin so the CLI ends up authenticated
  5. Workspace creation (existing template picker, .pad.toml write)
  6. Skill files (existing)

`pad init --cli-prompt` falls back to the pre-TASK-1217 path verbatim:
promptAndBootstrap → saveCredentials → workspace creation. Same behavior
as today for users with broken browser environments (headless box no
SSH tunnel, broken X11, etc.). The flag is a zero-cost hedge per
IDEA-1179 — we don't expect users to need it, but each invocation is a
signal we should rethink.

SIGINT during the polling loop is handled by installInitCancelHandler
(top of the RunE) which calls os.Exit(130) directly — the helper doesn't
need its own signal-aware ctx, so context.Background() is fine.

Helper-call audit: promptAndBootstrap and readPassword are still reached
via the --cli-prompt paths in both `pad auth setup` and `pad init`, plus
readPassword serves doInteractiveLogin. All three keep their callers, so
no helpers are removed in this PR. Both --cli-prompt paths exist by
design as the IDEA-1179 hedge.

Implements: IDEA-1179 (pad init half).
Closes: TASK-1217.
2026-05-07 17:34:44 -04:00
xarmian 51959532ad feat(auth): browser-based pad auth setup via /setup#token deep link (TASK-1216) (#432)
* feat(auth): browser-based pad auth setup via /setup#token deep link (TASK-1216)

`pad auth setup` now hands the operator a deep link into the browser-based
/setup form by default, replacing the in-terminal email/name/password
prompts. The browser flow gives them password-manager support, HTML5
email validation, and the live strength meter at zero CLI cost — the
mechanism (logs-token bootstrap, /setup route, /api/v1/auth/session) was
already shipped by TASK-1167 / PLAN-1166 for the Unraid use case. This
just unifies the local-CLI install path onto the same flow.

New `internal/cli/bootstrap.go::RunBrowserBootstrap`:
  - Reads <DataDir>/.bootstrap-token and prints
    `<BrowserURL>/setup#token=<TOKEN>` with the token in the URL fragment
    (not query) — fragments are scrubbed from the address bar by /setup's
    onMount before paint, so the secret doesn't survive in browser
    history (TASK-1167 F10).
  - Polls /api/v1/auth/session every 2s; returns nil when
    setup_required: false. Internal 5-min timeout uses a separate timer
    (not context.WithTimeout) so caller-ctx cancellation surfaces as
    ctx.Err() instead of being misreported as the helper's own timeout.
  - Idempotent: returns early if setup is already done, without touching
    the token file.
  - Dispatches on session.setup_method — "logs_token" reads the token,
    "open" (PAD_BYPASS_SETUP_TOKEN=true) prints a bare /setup URL,
    "local_cli" / unknown returns an error directing the user to
    --cli-prompt.

`pad auth setup` is rewired to call the helper, then chain doBrowserLogin
so the user ends up authenticated on the CLI — preserving the post-
condition of the legacy --cli-prompt path. Two browser approvals (admin
creation, CLI auth) but each is one click in a browser the operator
already has open.

The legacy TTY path lives on behind --cli-prompt as a zero-cost hedge
per IDEA-1179. Existing promptAndBootstrap / readPassword helpers are
left in place — TASK-1217 will audit whether they can be removed once
pad init is on the new flow too.

Tests in internal/cli/bootstrap_test.go cover: idempotent session check,
logs_token happy path, open mode, missing/empty token error paths,
local_cli + unknown method rejection, internal timeout firing with the
friendly message, caller-ctx cancellation propagating ctx.Err() (not
timeout error). bootstrapPollInterval / bootstrapPollTimeout are vars so
the timeout-branch test can run in 100ms instead of 5min.

Implements: IDEA-1179 (auth-setup half).
Out of scope: pad init integration → TASK-1217.
Out of scope: post-/setup workspace dead-end → IDEA-1215.

* docs(cli): clarify RunBrowserBootstrap caller staging across TASK-1216 / TASK-1217

Codex review (round 1) read the docstring and flagged that `pad init`
isn't on the new helper. That wiring is TASK-1217's scope by design (one
task = one PR per CONVE-2; TASK-1217 has a hard blocked-by link to
TASK-1216). Tighten the docstring to make the staging explicit so a
reader of the diff alone doesn't conclude it's a missing wire-up.
2026-05-07 17:28:29 -04:00
xarmian 954c84d0bf refactor(e2e): extract demo data into shared module (TASK-1201) (#431)
Lift the static "realistic workspace" data out of seedRealisticContent
into web/e2e/lib/demo-data.ts so it can be consumed by pad-remotion (a
sibling repo) without dragging in Playwright as a dependency. Single
source of truth for what a real-feeling Pad demo looks like.

Wire-shape compatibility is preserved exactly:
- demoPlan: same title, status, content
- demoTasks: same 7 tasks in same order, same status/priority/effort,
  same parent-to-plan linkage (now expressed via parentToPlan: boolean
  rather than carrying the plan id inline — the seeder maps it back
  to the freshly-created plan id at post time)
- demoIdeas: same 2 ideas, same fields

Behavior verified by running the gated screenshot spec:
  PAD_SCREENSHOTS=1 npx playwright test e2e/screenshots.spec.ts \
    --project=desktop-chromium
seedRealisticContent runs to completion and the dashboard / board /
list / table screenshots regenerate identically (reverted; not part
of this PR's diff).

demoConventions is also exported (4 representative entries) for the
pad-remotion ContextScene to render ghost-cards. demo-seed.ts itself
doesn't consume it — seedConventions takes caller-supplied input — but
the shape lives here so the shared data module is complete.

The companion pad-remotion file (src/data/demoItems.ts) lands as a
separate PR in PerpetualSoftware/pad-remotion. We chose copy-with-
manual-sync over a path import / symlink because pad-remotion is a
distinct git repo; a CI drift check is a possible follow-up if this
duplication starts to bite.

Parent: PLAN-1198.
2026-05-07 10:32:52 -04:00
xarmian 518c78e512 chore(unraid): remove unraid/ directory (template moved to PerpetualSoftware/unraid-templates) (#430)
The Unraid CA template has its own home now:
https://github.com/PerpetualSoftware/unraid-templates

That repo is required for the new ca.unraid.net/submit portal flow
(needs ca_profile.xml + dedicated repo per submission). Pad has been
submitted and auto-approved pending the next CA build, so the old
pad/unraid/ files have no remaining consumers:

- Docs (pad-web /docs/self-hosting/unraid) already point at the new
  repo's raw URLs (#96, merged 2026-05-06)
- Forum support thread + ca_profile.xml + pad/pad.xml in the templates
  repo all reference the canonical new location
- No CI / GoReleaser / Docker / Make targets touch unraid/ — verified
  with rg before deletion

Refs IDEA-1184, PLAN-1185, TASK-1191.
v0.3.0
2026-05-06 19:24:55 -04:00
xarmian 40352a32e1 feat(auth): PAD_BYPASS_SETUP_TOKEN open-bootstrap escape hatch (#429)
Adds an env-var that lets self-host operators on trusted networks
(Unraid behind a firewall, Tailscale-only deployments, homelabs)
claim the first admin via the web UI without copying a bootstrap
token out of the container logs.

Behavior when PAD_BYPASS_SETUP_TOKEN=true:

- handleBootstrap accepts non-loopback first-admin POSTs without an
  X-Bootstrap-Token header. The UserCount==0 invariant is unchanged,
  so the bypass auto-closes the moment the first admin claims the
  seat (subsequent bootstrap requests get 409 regardless of bypass).
- handleSessionCheck returns setup_method=open so the /setup page
  skips the paste-token UI and renders the form directly.
- Token generation is skipped at startup (no .bootstrap-token file
  written). A distinct WARN-flavored banner makes the open-mode
  trade-off obvious in operator logs.
- Cloud mode (PAD_CLOUD/PAD_MODE=cloud) ignores the flag entirely.
  Three layers of defense: cmd/pad masks the env-var with
  !cfg.IsCloudServer(), Server.openBootstrapEnabled() checks
  !s.cloudMode, and the cloud branch in handleBootstrap never reads
  the bypass field.

Unraid template gets a new "Bypass Setup Token" field (default false,
Display="always") with a description that calls out the trust-the-
network trade-off.

Tests pin all the security-critical contracts: bypass admits non-
loopback, bypass off keeps existing 403, cloud mode hard-ignores,
loopback works either way, post-bootstrap gate stays closed, bypass
wins over logs_token in session payload, cloud mode never advertises
'open' setup method.

Codex review: CLEAN (round 1).
2026-05-06 13:27:12 -04:00
xarmian 693f03be3c fix(auth): emit first-run bootstrap banner to stderr (BUG-1182) (#428)
slog's text handler is contractually one-line-per-record and escapes
literal newlines as `\n`, so the multi-line bootstrap banner rendered
as a single wide line in `docker logs` — exactly the surface where
operators look for the token. Switches the banner to fmt.Fprint to
stderr (real newlines), with a companion slog.Info one-liner so
structured-log aggregators still record the event.

The companion log deliberately does NOT include the URL or token in
its structured fields — those would be parseable as
log-aggregator-extractable values, defeating the URL-fragment design
(TASK-1167 F10) that keeps the token off-server. Operators / agents
that want the token programmatically read the on-disk file at
token_path.

Verified locally: banner now renders the ASCII box with real
newlines, token visible, companion slog line shows token_path
without the URL.

Caught by Dave during the v0.3.0-rc.1 smoke test on a real Unraid box.
v0.3.0-rc.2
2026-05-06 10:29:38 -04:00
xarmian 4c62a27e3b fix(unraid): correct install instructions + add AI category (BUG-1181) (#427)
* fix(unraid): correct install instructions + add AI category (BUG-1181)

The "Add Repository" / "Template Repositories" feature in older Unraid
was removed in 6.10.0-rc1 — the install path documented as Route A
("Apps → Settings → Add Repository → paste URL") doesn't exist on any
modern Unraid. Caught during TASK-1171 smoke-test prep when the
operator couldn't find the option in CA's UI on a current Unraid
install.

Replaces both routes in unraid/README.md with the working sideload
paths:

- Route A → CA Private Folder
  /boot/config/plugins/community.applications/private/perpetualsoftware/pad.xml
  Template appears under CA's "Private" category. Closest experience
  to a CA-approved install — same listing UI, same install form.
- Route B → Docker tab sideload (CA-less fallback)
  /boot/config/plugins/dockerMan/templates-user/my-pad.xml
  Skips CA entirely. Works without the CA plugin installed.

Verified live: dave manually sideloaded via the new Route A; the Pad
card renders with icon + Overview in CA, install form opens cleanly.

Also updates <Category> from "Productivity: Tools:" to "Productivity:
AI:" — Tools was generic and unrelated; AI was added to CA's taxonomy
recently (114 apps in that category) and matches Pad's "agent era"
framing better. Verified syntax against the Unraid Docker Template
Schema wiki: space-separated categories within a single <Category>
element, each "Top:Sub" or "Top:" form.

Sibling pad-web PR fixes the same two routes in
/docs/self-hosting/unraid.

Source for the Template Repositories removal:
https://forums.unraid.net/topic/114809-i-want-to-make-my-own-private-template-repository-but-it-doesnt-work/

* fix(unraid): align pad.xml with dockerMan SAVE serializer (BUG-1181)

Verified against a real dockerMan SAVE round-trip on Unraid 7.x — diff
captured during TASK-1171 smoke test. Migrating structural divergences
back into the canonical template so we stay on Squid's good side per
the wiki's blacklist warning.

Element changes:
- Add <MyMAC/>, <ReadMe/>, <Requires/>, <TailscaleStateDir/> empty
  markers (recent dockerMan emits them all)
- Remove <Description> block — not part of the recognized schema;
  dockerMan strips it on SAVE. <Overview> is the canonical CA-displayed
  text and already covers the same ground.
- Switch 3 empty <Config></Config> blocks (Public URL, Maileroo API
  Key, Email From) to self-closing <Config .../> form — matches
  dockerMan's serializer output.
- Drop BBCode wrapping ([b]Logs[/b] -> Logs) in Overview — dockerMan
  strips BBCode during SAVE, so the formatting was dead weight.
- Remove all XML comments — dockerMan strips them on SAVE round-trip.
  Maintainer rationale moved to unraid/README.md's new "Template
  format conformity" section instead.

Final element order verified element-by-element to match dockerMan's
output: 33 top-level elements, position-aligned.

Cosmetic differences left as-is (XML-equivalent, won't trip Squid):
- Em dashes (literal — vs &#x2014;)
- Line endings inside <Overview> (LF-only vs CRLF as &#13;\n)

unraid/README.md gains a "Template format conformity" section
documenting the wiki guidance, the SAVE-diff verification workflow,
and the specific dockerMan behaviors that bit us (comments stripped,
<Description> dropped, BBCode stripped, self-closing empty elements).
Note: this README itself doesn't ship through CA so the maintainer
context is safe here.

Stacks on the existing BUG-1181 commit (install-route + AI category
fixes) — same theme of "Unraid template + docs correctness for CA
submission".

* fix(unraid): convert em dashes to numeric entities to match dockerMan SAVE output

Last cosmetic diff between our hand-written pad.xml and what dockerMan
emits on a SAVE round-trip. XML-equivalent (both render the same em
dash glyph), but eliminating the visual diff makes future
SAVE-roundtrip checks cleaner and removes any tail risk of CA
treating literal U+2014 differently from the numeric entity.

Three occurrences converted: Overview text + PUID/PGID Config
descriptions. After this, the only remaining diff against a
post-Apply dockerMan SAVE is <Overview> line endings (LF vs CRLF) and
<DateInstalled> (operator-stamped) — both XML-equivalent and
expected-to-differ respectively.

Last cosmetic touch on PR #427.
2026-05-06 10:25:48 -04:00
xarmian 229ba2400f feat(unraid): Community Applications template + README (TASK-1169) (#426)
* feat(unraid): add Community Applications template + README (TASK-1169)

Adds unraid/pad.xml (CA Container v2 schema) and unraid/README.md to
this repo's root. The XML lets Unraid users one-click install Pad from
Community Applications once approved (HT-1175); the README documents
the manual "Add Repository" path and the direct sideload fallback for
early adopters before CA approval lands.

Form fields surfaced (basic): WebUI Port, Appdata path. Advanced:
PUID/PGID (defaults 99/100 — Unraid nobody:users), PAD_LOG_LEVEL,
PAD_URL, PAD_MAILEROO_API_KEY (Mask=true), PAD_EMAIL_FROM,
PAD_EMAIL_FROM_NAME. All Config blocks use single-line attribute form
per CA parser fragility guidance.

README covers: install (CA approved + manual pre-CA), first-run via
docker-logs token + /setup#token=, persistent data layout, paired -C
tar backup recipe (avoids the absolute-path restore footgun), upgrade,
reverse-proxy hint, email setup, and troubleshooting.

HARD MERGE-ORDER DEPENDENCY: this PR must NOT be merged until both
TASK-1167 (PR #424, bootstrap-token flow) and TASK-1168 (PR #425,
PUID/PGID entrypoint shim) are merged. The template references
behaviors those PRs deliver; the README's first-run walkthrough,
PUID/PGID form fields, and chown-on-restart guidance all assume they
are present. CI on this branch passes (XML/Markdown only), but a smoke
test against `:latest` would fail until #424 and #425 merge.

Pinned via 3 rounds of codex pre-implementation design review (3
defects caught: missing PAD_EMAIL_FROM_NAME, multi-line Config attr
form, tar absolute-path footgun). Code-review pass on the diff: only
remaining findings are the dependency-not-yet-merged note above.

Out of scope:
- unraid/icon.png — TASK-1170.
- Forum thread — HT-1174 (template's <Support> field is a PLACEHOLDER
  slug for grep-ability).
- CA submission — HT-1175.
- Smoke test on real Unraid — TASK-1171.
- getpad.dev/docs/install/unraid page — TASK-1172.
- Postgres/Redis variant template (advanced users use docker-compose.yml).

Part of PLAN-1166.

* docs(unraid): align URL references with pad-web's actual /docs/self-hosting/unraid path

PR #93 in pad-web lands the install walkthrough at
/docs/self-hosting/unraid (not /docs/install/unraid as originally
spec'd). Updates the URL referenced in unraid/README.md and the
template's <Overview> field to match. See PR #93's body for the URL
deviation rationale.

Part of TASK-1172. Coordinates with pad-web PR #93.

* feat(unraid): add 256x256 icon for CA listing (TASK-1170)

256×256 RGBA PNG at unraid/icon.png. LANCZOS downsample from the
existing web/static/icon-512.png — Pad's app icon (clipboard +
colored-tile board view). Reused rather than designed afresh so the
CA listing matches what users already see on getpad.dev favicons and
the PWA install icon.

35 KB on disk after Pillow's optimize=True pass. Renders crisply at
the smaller sizes CA shows in the Apps grid.

Removes the TODO(TASK-1170) placeholder comment in pad.xml and the
"Note on the icon" 404-warning section in README.md — both replaced
with brief provenance notes (downsample method, source file, "update
both in lockstep" reminder).

Closes TASK-1170. Stacked on PR #426 because pad.xml's <Icon> URL
points at main, so the icon and the template need to land together
or the listing renders broken.
v0.3.0-rc.1
2026-05-06 08:41:17 -04:00
xarmian 92a4931f44 feat(docker): PUID/PGID entrypoint shim for Unraid + LinuxServer-style hosts (TASK-1168) (#425)
Tiny /bin/sh entrypoint shim that, if invoked as root, reads PUID/PGID
env vars (defaulting to 99/100 — Unraid's nobody:users), remaps the
in-image pad user, chowns /data, and execs the binary via su-exec.
If invoked as non-root (caller passed --user), it just execs directly
— caller knows what they want.

Solves the classic Unraid appdata-ownership-mismatch first-run failure
where the in-image pad user (uid 1000) couldn't write to a host volume
owned by nobody:users (uid 99, gid 100). Reusable on Synology / QNAP /
TrueNAS where the host's appdata user is similarly non-1000.

Behavior changes:
- Container starts as root (USER directive removed). Entrypoint drops
  privileges via su-exec before exec'ing pad — standard PUID/PGID
  pattern. Healthcheck adapts: root → su-exec to pad; non-root →
  direct wget.
- chown -R is always-run (warn-and-continue on per-file failures). A
  shallow stat-only check would silently break pad on a restored
  backup with mixed-ownership inner files.
- Healthcheck start-period bumped 10s → 60s to absorb slow chown -R
  on large attachment stores.
- Compose default 1000/1000 for backward compat with existing deploys
  whose volumes were created under the previous USER pad image.
- Raw `docker run` defaults to 99/100 (Unraid convention).

Validation rejects PUID=0 / PGID=0 (would defeat the unprivileged-user
invariant), empty values, and non-numeric values with clear errors.

Goes through 11 rounds of codex pre-implementation design review,
catching:
- gid bug where groupmod alone leaves /etc/passwd's primary-gid stale
- compose $-interpolation gotcha (needs $$( ) not $())
- getent missing from default alpine BusyBox
- shell ${VAR:-} silently masking explicit empty values
- healthcheck running as root after USER drop
- su-exec failing for --user non-root pass-through

Part of PLAN-1166 (Pad on Unraid — Community Apps launch). Unblocks
TASK-1169 (XML template authoring).
2026-05-06 08:40:33 -04:00
xarmian 05a9665f50 feat(auth): first-run logs-token bootstrap flow (TASK-1167) (#424)
One-time bootstrap token generated on first start with no users in self-host
mode. Token is logged in a banner the operator can grab from `docker logs`,
persists at <DataDir>/.bootstrap-token (mode 0600), and bypasses the
loopback-only gate via the X-Bootstrap-Token header — letting the user
claim the first admin from a remote browser at /setup#token=<x>.

Header-only contract + URL-fragment (browser-only, never transmitted) +
log-redaction middleware keeps the secret out of access logs, proxy logs,
and browser history. Cloud mode unchanged: token never loaded, never
honored. Validate → UserCount-check → CreateUser → consume sequence is
mutex-serialized to prevent concurrent valid-token requests from creating
multiple admins.

Part of PLAN-1166 (Pad on Unraid — Community Apps launch).
2026-05-06 08:40:11 -04:00
xarmian 6c44291e78 fix(layout): let Cmd+F fall through to browser-native find on item views (BUG-986) (#423)
The layout's global keydown handler unconditionally intercepted Cmd+F
and routed it through a `collectionSearchRequested` boolean that only
the collection list page polled. On item / document views (and any
non-collection page) nothing watched the flag, but `e.preventDefault()`
had already blocked the browser's native find — leaving users with no
way to search inside a document.

Invert the model from "always intercept, broadcast a flag" to "only
intercept when a page registers a handler":

- ui.svelte.ts: replace `collectionSearchRequested` with a
  `collectionSearchHandler` registry exposing
  `registerCollectionSearch` / `unregisterCollectionSearch` /
  `triggerCollectionSearch` / `hasCollectionSearchHandler`.
- +layout.svelte: only `e.preventDefault()` and dispatch when
  `uiStore.hasCollectionSearchHandler` is true; otherwise let Cmd+F
  pass through to the browser.
- [collection]/+page.svelte: register the existing
  filters-open + focus-search behaviour in an `$effect` and unregister
  it via the effect's cleanup so it lives only while the page is
  mounted.

Result: collection list view keeps its existing Cmd+F filter-search
shortcut; item views and any other page get the browser's native find
back. `make check` clean (Go tests, lint, web build, svelte-check 0
errors).
v0.2.0
2026-05-05 17:18:09 -04:00
xarmian 50d04944c5 feat(sweep): gate role board + child reorder + run grep verification (TASK-1108) (#422)
* feat(sweep): gate role board + child reorder + run grep verification (TASK-1108)

Final sweep across PLAN-1100 (client-side permission audit). Confirms no
remaining open-coded permission checks and gates the few remaining surfaces
not covered by tasks 1102-1107.

Code-only acceptance grep results:
- `members.find(...)` outside workspaceStore: ZERO matches
- `m.role === 'owner' / 'editor'` open-codes outside permissions.ts: ZERO matches
- `isOwner = $derived(workspaceMembers...)` open-codes: ZERO matches

Surfaces gated in this PR:
- Role board (`/{workspace}/roles`):
  - "+ New" item button gated on canEditAnyItem (owner|editor)
  - "+ Add Role" column owner-only
  - Lane edit button (✎) owner-only, lane drag handle owner-only
  - Lane-header drag (column reorder) owner-only — handlers and draggable
    attribute conditional on isOwner
  - Lane-items dndzone receives dragDisabled: !canEditAnyItem (zone-level
    library limitation, same constraint as TASK-1106)
- ChildItems component (used on item detail to render children):
  - New canEdit prop (default true). Slug page passes canEdit (= canEditItem
    of parent). dndzone receives dragDisabled: !canEdit so non-editors
    can't drag-reorder children.

BUG-984 closure remains gated on HT-1157 (manual three-role smoke test),
which will sign off on real walks as owner / editor / viewer / guest.

Parent: PLAN-1100.

* fix(sweep): empty-state role create gate + grant-aware canEditAnyItem per Codex review (round 1)

Two findings from round 1:

1. Empty-state "Create your first role" button at roles/+page.svelte:530
   wasn't gated. Now wrapped in {#if isOwner}.

2. canEditAnyItem was role-only (owner|editor). Server's reorder handler
   is grant-aware per-item, so a viewer/guest with even one
   CollectionGrant.edit or ItemGrant.edit can legitimately mutate via the
   role board. Helper now ORs in any active edit grant — matches what
   the server enforces.

Parent: PLAN-1100. Refs TASK-1108 PR #422.

* fix(sweep): align role-board create/lane-reorder gates with server per Codex review (round 2)

Two findings from round 2:

1. The role-board "+ New" item flow was gated on canEditAnyItem (any role
   or any edit grant), but server's handleCreateItem requires collection-
   level edit (collection grant or role+visibility), not item-only grant.
   Item-grant-only users would see "+ New" but get a 403 on submit.
   Filter eligibleCollections by workspaceStore.canEditCollection(coll.id)
   and gate the button on eligibleCollections.length > 0.

2. Lane reorder was owner-only, but server's handleRoleBoardLaneReorder
   uses requireMinRole "editor". Editors lost an allowed operation.
   Introduced canReorderLanes (owner | editor) for lane drag handles +
   ondrag handlers; role create/edit/delete remain owner-only.

Parent: PLAN-1100. Refs TASK-1108 PR #422.
2026-05-05 10:08:08 -04:00
xarmian 7f06a9845a feat(comments): gate composer / replies / reactions / delete on canEditItem (TASK-1107) (#421)
Comment timeline previously rendered composer, reply, reaction picker,
and per-reaction toggle for all roles. Server enforces edit per-item on
comment writes; UI now matches.

Note on file paths: TASK-1107's original spec referenced
`web/src/lib/components/comments/CommentThread.svelte`, but that file is
unused anywhere in `web/src/`. Real comment UI lives in:
- `ItemTimeline.svelte` (composer + thread layout)
- `TimelineCommentCard.svelte` (per-comment delete, replies, reactions)

Changes:
- ItemTimeline: imports workspaceStore, accepts itemId + collectionId
  props (optional with safe fallback), derives canEdit reactively. The
  composer is hidden entirely when !canEdit.
- TimelineCommentCard: accepts canEdit prop. Delete / reply / reaction
  picker / reply-comment delete / reply reaction picker all gated.
- Existing reactions still render with counts so read-only viewers see
  who reacted; the chip's onclick is gated and disabled={!canEdit} so
  toggling is blocked for them.
- Slug page passes item.id + item.collection_id so ItemTimeline can
  resolve canEditItem itself (no prop-drilling of canEdit).

Parent: PLAN-1100.
2026-05-05 09:54:07 -04:00
xarmian fe9b76ab93 feat(views): gate drag/archive in ListView/BoardView on canEditCollection (TASK-1106) (#420)
The collection page's ListView and BoardView allowed all roles to drag
items, drag-status-change, reorder groups/columns, and archive groups.
Server enforces edit per-item and per-collection on these mutations
(handlers_items.go, handlers_role_board.go) — UI now matches.

Changes:
- ListView + BoardView accept a `canEdit?: boolean` prop (default true to
  preserve behavior in existing callers).
- ListView: dndzone for groups + intra-group items receives
  `dragDisabled: !canEdit`. Group drag handle and archive-group button
  hidden when !canEdit.
- BoardView: column-cards dndzone receives `dragDisabled: isMobile || !canEdit`.
  Column-header drag (column reorder) gated via `draggable={canEdit}` and
  conditional drag handlers. Column-drag-handle indicator and
  archive-column button hidden when !canEdit.
- Collection page passes `canEdit={canEditThisCollection}` to both views.

Scope note: per-item drag gating (e.g. a guest with ItemGrant.edit on one
item dragging just that one card) is not implemented — svelte-dnd-action
only supports zone-level dragDisabled. Achieving per-item would require
switching to dragHandleZone+dragHandle and shipping an explicit handle UI
for everyone, which is a larger UX change. Server already enforces per-item
edit on the resulting mutations, so no security gap. Documented as a
follow-up if needed.

TableView: excluded from drag/archive scope — no drag handlers to gate.
Status-cell editing is already gated via FieldEditor's readonly prop from
TASK-1105.

Parent: PLAN-1100.
2026-05-05 09:47:55 -04:00
xarmian a8b158829b feat(item-detail): gate write affordances on canEditItem (TASK-1105) (#419)
* feat(item-detail): gate write affordances on canEditItem (TASK-1105)

Item detail page hides title-edit, content editing, FieldEditor inputs,
delete button, and assignment dropdowns when the user lacks edit on this
specific item. Mirrors the server's per-item permission cascade so the UI
cannot show affordances the server would 403.

Per-item gate via workspaceStore.canEditItem(item) — owner → item grant →
collection grant → role + visibility → deny. Handles guests with single
ItemGrant.edit (full edit on that one item, read-only on siblings) and
the precedence regression where ItemGrant.view + CollectionGrant.edit on
the same item resolves to read-only (item grant wins per server cascade).

Changes:
- FieldEditor: new `readonly?: boolean` prop. When true, renders a unified
  display block per field type (select / checkbox / date / number / url /
  text) — same visual language as the editor's idle state, no inputs, no
  dropdowns, no mutation handlers. Documented in the component header.
- RawMarkdownEditor: new `readonly?: boolean` prop, applied to the
  underlying textarea.
- [slug]/+page.svelte: derived canEdit predicate. Title swaps from
  click-to-edit button to plain h1 when read-only. Editor passes
  editable=canEdit; EditorBubbleMenu / EditorLinkPopover only mount when
  editable. RawMarkdownEditor passes readonly. Delete button hidden.
  FieldEditor receives readonly={!canEdit}. Assignment + role dropdowns
  swap to read-only display spans.
- New CSS: .title-readonly (no hover, default cursor),
  .assignment-readonly (matches assignment-select height for layout
  stability when the user gains/loses edit permission).

Parent: PLAN-1100.

* fix(item-detail): gate Editor toolbars + ?new=1 title bypass per Codex review (round 2)

Two read-only escape hatches found by Codex re-review:

1. Editor.svelte mobile toolbar (line 818) and table toolbar (line 846)
   rendered without checking the `editable` prop. tiptap's editor instance
   correctly refuses commands when editable=false, so the buttons would
   no-op, but they still rendered and were visually misleading. Both
   toolbars now gated on `editable`.

2. The slug page's auto-start-title-edit path for ?new=1 didn't check
   canEdit. A read-only user appending ?new=1 would land on the title
   textarea (which the visible-branch gate now hides). Added canEdit to
   the auto-start condition AND to startEditTitle() itself as a defensive
   second line.

Round 1 disagreements stand: Move-to / item-links / ChildItems are
explicitly TASK-1108 sweep scope and intentionally not addressed here.

Parent: PLAN-1100. Refs TASK-1105 PR #419.

* fix(item-detail): exclude BlockDragHandle in read-only + gate Move-to / links per Codex review (round 3)

Three findings from round 3:

1. Editor's BlockDragHandle ProseMirror plugin (registered in Editor's
   extensions list) is not gated by tiptap's `editable` flag — its drag
   handle is injected into the view DOM regardless. A read-only user
   could drag blocks to dispatch transactions through onUpdate. Fix:
   conditionally include the plugin in the extensions array based on
   `editable`.

2 + 3. Move-to button and item-links add/delete affordances. These were
       originally TASK-1108 sweep scope, but Codex re-flagged them in
       round 3 despite the round-2 deferral. Absorbed into TASK-1105
       rather than burn more review rounds — the gating is mechanical
       (a few {#if canEdit} wrappers). TASK-1108 sweep will still grep
       for any remaining open-coded patterns elsewhere.

Parent: PLAN-1100. Refs TASK-1105 PR #419.

* fix(item-detail): re-key Editor on canEdit change so BlockDragHandle reattaches per Codex review (round 4)

Round 3 excluded BlockDragHandle from the editor's extensions array when
editable=false. Round 4 caught the construction-time-only nature of that
gate: on cold/direct navigation /me resolves after the editor mounts, so
canEdit starts false → editor created without BlockDragHandle → /me
resolves → canEdit flips true but the existing $effect only calls
editor.setEditable(true) and does not re-register extensions.

Fix: add canEdit to the {#key} value so the editor is reconstructed when
permission flips. Cost is a brief loss of cursor/scroll position on the
flip — acceptable since the only path that flips canEdit mid-session is
a grant change while the page is open, which is rare.

Same approach is appropriate for any future extension whose registration
is gated on `editable`.

Parent: PLAN-1100. Refs TASK-1105 PR #419.

* fix(item-detail): handle ?new=1 auto-edit reactively for slow /me per Codex review (round 5)

* fix(item-detail): always reassign pendingNewItemEdit per Codex review (round 6)
2026-05-05 09:41:12 -04:00
xarmian d311245654 feat(collection-page): gate item-create affordances on canEditCollection (TASK-1104) (#418)
The collection list page renders multiple "create item" affordances —
header "+ New" button, quick-create input, empty-state CTA, and view-level
buttons via EmptyState — to all roles regardless of whether they can
actually create items in this collection. Server rejects the writes; this
hides the affordances entirely.

Per-collection gate via workspaceStore.canEditCollection(collection.id) —
not the binary canEdit. A viewer with a CollectionGrant.edit on Tasks
sees "+ New" on /tasks but not on /ideas. A guest with only an ItemGrant
sees no create affordance anywhere (server cascade: item grant doesn't
promote to collection-wide write).

Changes:
- Header "+ New" button: hidden when !canEditThisCollection.
- Quick-create input: only renders when both quickCreateOpen AND
  canEditThisCollection (defensive, since openQuickCreate is no longer
  callable through any visible affordance).
- Empty-state-box CTA: hidden when !canEditThisCollection. Message also
  switches from "Create your first ..." to "This collection is empty."
- View-level oncreate prop: undefined when !canEditThisCollection, so
  EmptyState (the shared empty-state component) hides its own create
  button automatically.

Parent: PLAN-1100.
2026-05-05 09:11:47 -04:00
xarmian 0035a9dad9 feat(settings): gate collection management UI to owners (TASK-1103) (#417)
Settings → Collections currently shows "+ Create Collection" and clickable
edit cards to all roles. The server already enforces owner-only on create,
update, and delete (handlers_collections.go:48, :113, :164). UI now matches.

Changes:
- Collection cards remain clickable for owners (open EditCollectionModal);
  for non-owners they render as non-interactive divs with the same content
  visible. The "Edit" hint is hidden for non-owners.
- "+ Create Collection" button hidden entirely for non-owners.
- CreateCollectionModal / EditCollectionModal mount only for owners — a
  non-owner can't reach them via the UI.

Note: TASK-1103 spec floated "create gated to editor+", but the server is
owner-only. Aligned UI to server (server is the security boundary).

Parent: PLAN-1100.
2026-05-05 09:04:28 -04:00
xarmian 3524a5ed92 feat(settings): gate Danger Zone tab + General write affordances on owner role (TASK-1102) (#416)
The presenting symptom of BUG-984: editors and viewers currently see the
Danger Zone tab + workspace name/context/export controls. All of those are
owner-only on the server. Gates them in the UI so the affordances aren't
rendered to begin with.

Changes:
- Tabs are now derived: Danger Zone is filtered out for non-owners. Direct
  URL access to #danger as a non-owner snaps back to General.
- Hash-driven tab restoration deferred to a validTabIds-aware $effect so
  owners deep-linking to #danger don't land on General because /me was
  still in flight at mount time.
- General tab → Name input rendered readonly for non-owners; Save button
  hidden.
- General tab → Context JSON textarea rendered readonly for non-owners;
  Save / Reset / Clear buttons hidden.
- General tab → Export bundle gated to editor+ (canExport).

Theme toggle remains available to all roles (personal preference, not
workspace state).

Owner experience unchanged. Non-owners now see a read-only General tab
with workspace context visible (so they know what they're working in) but
no controls that would 403.

Parent: PLAN-1100.
2026-05-05 08:59:31 -04:00
xarmian 1ff6158468 feat(workspace): expose currentRole + resource-scoped permission helpers (TASK-1101) (#415)
* feat(workspace): expose currentRole + resource-scoped permission helpers (TASK-1101)

Foundation for PLAN-1100 (client-side permission audit). Lands the primitive
that every other task in the plan consumes, with no UI behavior changes.

Server:
  - new GET /api/v1/workspaces/{ws}/me — returns role, collection_access,
    visible_collection_ids (computed via VisibleCollectionIDs /
    GuestVisibleCollectionIDs so it covers system collections, member access,
    direct collection grants, and item-grant collections), plus the user's
    direct collection_grants and item_grants.
  - admins normalize to "owner"; legacy workspace-scoped tokens normalize to
    "editor"; non-members with no grants are rejected upstream by
    RequireWorkspaceAccess and never reach the handler.

Frontend:
  - new $lib/utils/permissions module exporting pure cascade functions:
    canEditWorkspace / canViewCollection / canEditCollection /
    canViewItem / canEditItem.
  - cascade mirrors server's ResolveUserPermission exactly:
        owner → item grant → collection grant → membership role + visibility
    so item grant beats collection grant beats role even when less permissive
    (ItemGrant.view + CollectionGrant.edit on same item → effective view).
  - workspaceStore wraps the pure functions with currentMembership state
    fetched in setCurrent. New getters: currentRole, currentMembership,
    isOwner, canEditWorkspace; new methods: canViewCollection /
    canEditCollection / canViewItem / canEditItem.
  - WorkspaceMembership type added.
  - api.workspaces.me(slug) added.

Refactor:
  - settings/+page.svelte, [collection]/+page.svelte,
    [collection]/[slug]/+page.svelte: drop open-coded role derivation
    (members.find + m.role open-codes), consume workspaceStore.isOwner.
    members.list calls remain — still needed for assignee dropdowns / member
    rows in settings — only the role-derivation path moves to the store.

Tests:
  - server: handlers_me_test.go covers 6 scenarios
    (admin, editor with all-access, viewer with collection grant,
     restricted member, guest with item grant, non-member with no grants).
  - frontend unit tests deferred — web/ has no unit-test runner today.
    Pure-function module makes them trivial to add when the runner lands.
    Cascade is independently covered by store/permissions_test.go and
    store/grants_test.go on the server.

Parent: PLAN-1100.

* fix(workspace): per-item visibility uses strict full-access set + setCurrent race guard per Codex review (round 1)

P1: canViewItem fell back to canViewCollection, which uses the broad nav
    set (visible_collection_ids — includes collections containing
    item-granted items so they appear in nav). This meant a guest with one
    ItemGrant on TASK-5 in Tasks would see canViewItem(any-other-task-in-Tasks)
    return true, while the server only allows direct item grants or full
    collection grants.

    Fix: /me now also returns full_access_collection_ids — the strict set of
    collections in which every item is accessible (collection grants +
    member_collection_access + system collections; item-grant collections
    intentionally excluded). This mirrors guestResourceFilter's fullCollIDs
    in handlers. canViewItem and canEditItem now consult full_access_collection_ids
    on the membership-fallthrough path, NOT the nav set.

    Test added: TestMe_GuestWithItemGrant now asserts the item-grant collection
    is in visible_collection_ids (nav) but NOT in full_access_collection_ids
    (strict). TestMe_RestrictedMember updated to check both sets.

P2: workspaceStore.setCurrent had no guard against stale async /me responses.
    A slow /me for workspace A could clobber a freshly-fetched membership
    for workspace B if the user navigated mid-flight, briefly exposing
    permission-gated UI for the wrong workspace.

    Fix: monotonic membershipSeq counter incremented per setCurrent / create
    call. Each /me response only writes back if its captured token still
    matches at resolution time. Also clears currentMembership immediately on
    setCurrent so helpers don't briefly answer "yes" using the previous
    workspace's grants while /me is in flight.

Parent: PLAN-1100. Refs TASK-1101 PR #415.

* fix(workspace): canEditCollection uses strict full-access set per Codex review (round 2)

Same nav-vs-strict bug pattern as round 1's canViewItem fix, but in
canEditCollection. The editor-membership fallback path previously gated
on canViewCollection (broad nav predicate using visible_collection_ids),
which incorrectly returned true for a restricted editor whose only access
to a collection was an item grant. The collection appears in nav (correct)
but the editor must NOT see collection-wide write affordances like "+ New"
because the server rejects collection-level writes there.

Fix: editor membership fallback now requires either collection_access ===
"all" or the collection to be in full_access_collection_ids.

canEditItem already used full_access_collection_ids on its fallback path
(it was added in round 1) — verified unchanged.

Parent: PLAN-1100. Refs TASK-1101 PR #415.
2026-05-05 08:52:05 -04:00
xarmian 504e22d7bc fix(console): add mobile hamburger menu + scrollable admin tabs (BUG-1118) (#414)
The /console navbar's horizontal pill row crammed/clipped on narrow
viewports, and the admin sub-tab strip wrapped awkwardly. Add a hamburger
menu that toggles a dropdown panel below the navbar on mobile, and make
the admin tab strip horizontally scrollable on the same breakpoint.

Console layout (web/src/routes/console/+layout.svelte):
- Hamburger button (32x32) appears in .nav-left on mobile (<=640px),
  switches to an X when open. Same SVG/sizing as TopBar.svelte's
  .mobile-hamburger so the chrome stays consistent.
- .nav-links becomes a full-width dropdown panel below the navbar when
  open. Visual style mirrors TopBar.svelte's .user-dropdown
  (--bg-secondary, border, --radius-lg, box-shadow, dropdown-in keyframe).
- Closes on link click, Escape, outside-click, and route change.
- Route-change auto-close kept as its own single-purpose $effect per
  CONVE-606.
- a11y: aria-expanded, aria-controls, aria-label on toggle; role=menu
  on panel, role=menuitem on links.
- Desktop layout (>640px) is unchanged.

Admin layout (web/src/routes/console/admin/+layout.svelte):
- On <=640px the .admin-tabs strip becomes overflow-x: auto with
  -webkit-overflow-scrolling: touch, scrollbar-width: none, and
  flex-wrap: nowrap so all tabs are reachable without clipping.
- Tabs stay flex-shrink: 0 + nowrap to keep labels readable.
- Active-tab underline + colors preserved.
2026-05-05 07:22:28 -04:00
xarmian 63d113624c fix(cli): retry password prompt on weak/mismatched passwords (BUG-1155) (#413)
* fix(cli): retry password prompt on weak/mismatched passwords during admin bootstrap (BUG-1155)

`pad auth setup` and `pad init` collected admin credentials with a single-
shot prompt: any rejection — local password mismatch, or server-side weak-
password / length error from validatePasswordStrength — bubbled up and
exited the command. The user had to re-run the whole flow (and in `pad
init`, redo configure + server-start) over a typo.

Replaces promptForAccountDetails() with promptAndBootstrap(client) which
collects email + name once, then loops the password / confirm pair (up to
5 attempts) on:

- local password mismatch
- *cli.APIError from /auth/bootstrap (covers all three messages from
  internal/server/password_strength.go: too short, too long, too weak)

Network failures and other non-API errors still bail immediately.

Both call sites — cmd/pad/main.go (auth setup) and cmd/pad/init.go (init
step 3) — now use the new helper.

* fix(cli): only retry password-strength rejections, not all API errors per Codex review (round 1)

Round 1 retried on every *cli.APIError from /auth/bootstrap, but only
password-strength rejections are fixable by re-prompting the password
pair. The server also emits validation_error for invalid email / missing
name, conflict ("Pad instance has already been initialized"), and
forbidden (non-loopback bootstrap) — re-prompting just the password for
those traps the user in a 5-attempt loop that can never succeed.

Narrows the retry gate to validation_error whose message begins with
"Password" — the three messages emitted by validatePasswordStrength
(internal/server/password_strength.go: too-short, too-long, too-weak).
All other APIError codes and message shapes now fall through to the
fail-fast branch, so the user sees the real reason and can re-run with
the right correction.
2026-05-04 17:56:20 -04:00
xarmian 89e9551ae3 test(store): end-to-end onboarding walkthroughs for scrum + product (TASK-1151) (#410)
Mirrors TestOnboardingFlow_FullWalkthrough_Startup (PR #405) for the
two newly-seeded software-category templates. Two new test fns, same
three-phase shape:

  Phase 1 — Fresh seed:
    - Four onboarding seeds land at the right item_numbers + statuses.
    - Conventions + playbooks present (after the user-facing seeds).
    - Primary entry starts in its initial status (BACK-1=new for scrum;
      FEAT-1=proposed for product) — the gate the dashboard banner
      relies on.

  Phase 2 — Agent walks user through populating real items:
    - Primary's status flips out of initial (signaling engagement).
    - Real workspace activity gets captured in the template's verbs:
        scrum: a sprint, three real backlog items linked to it, one bug
        product: a roadmap commitment, three features under it, one
                 user-feedback item from a sales call
    - Primary flips to terminal (BACK-1 → done; FEAT-1 → shipped) —
      banner hides on next dashboard refresh.

  Phase 3 — Idempotency on re-trigger:
    - User's items remain untouched.
    - Primary stays at terminal status — re-seed must NOT reset to
      initial, which would silently re-show the banner.
    - No duplicate seed items.
    - Conventions + playbooks counts unchanged.

Reuses the helpers added in PR #405 (findItemByTitle, extractStatus,
setItemStatus, countItemsInCollection) — no new helpers needed.

This is the gate task for PLAN-1146. With this merged, scrum + product
now have the same coverage as startup did after PLAN-1131.

Parent: PLAN-1146.
2026-05-04 12:12:51 -04:00
xarmian abf017c4e7 feat(onboarding): make banner + CLI hint template-aware (TASK-1150) (#409)
The IDEA-1 trigger phrase is no longer hardcoded — fresh scrum
workspaces surface "use pad to get BACK-1", product workspaces surface
"use pad to get FEAT-1", and any future template that ships an
agent-onboarding seed declares its primary ref once and gets the
banner / hint for free.

Mechanism:

  1. WorkspaceTemplate gains an OnboardingPrimaryRef string field —
     the canonical declaration of "this template's IDEA-1-style
     primary entry." Set per template that ships the pattern
     (startup → "IDEA-1", scrum → "BACK-1", product → "FEAT-1");
     left empty for hiring/interviewing/demo where the agent-onboarding
     pattern intentionally doesn't apply.

  2. Server: handleGetDashboard identifies the seeded primary by
     walking allItems looking for item_number=1 + source="template"
     + created_by="system" + collection_slug ∈ {ideas, backlog,
     features}. The collection-slug whitelist is what keeps hiring's
     REQ-1 (also seeded with item_number=1 + source=template) from
     being flagged as an onboarding entry — those are example items,
     not agent scripts. The dashboard response gains an
     onboarding_seed field with ref/title/slug/collection_slug/status
     plus a server-computed `active` boolean (true iff status equals
     the schema initial value).

  3. CLI: printOnboardingHints accepts the template name, looks up
     the primary ref via collections.GetTemplate, and prints the
     right "use pad to get X-1" line. Templates without a declared
     primary skip the line entirely (so hiring's pad init success
     doesn't promise a non-existent BACK-1 / IDEA-1).

  4. Web frontend: dashboard reads dashboard.onboarding_seed,
     gates the banner on `active=true`, passes ref/slug/collection
     to OnboardingIdeaBanner. The component renders the trigger
     phrase, copy button, and "Read it first" deep link from those
     props — no more hardcoded IDEA-1.

ensureWorkspace's signature gains a returned templateName so init.go
+ main.go can pass it through to printOnboardingHints. The five
existing test call sites updated.

New tests:

  internal/collections/templates_test.go
    - TestTemplatesDeclareOnboardingPrimaryRef — locks the per-template
      OnboardingPrimaryRef values (and the explicit emptiness of
      hiring/interviewing/demo).

  internal/server/handlers_dashboard_test.go
    - TestDashboardOnboardingSeed_StartupTemplate
    - TestDashboardOnboardingSeed_ScrumTemplate
    - TestDashboardOnboardingSeed_ProductTemplate
    - TestDashboardOnboardingSeed_HiringTemplate (asserts NO seed —
      hiring's REQ-1 is example data, not an onboarding entry)
    - TestDashboardOnboardingSeed_EmptyWorkspace (no template)

Removes the loadIdeaOne race-guard from +page.svelte — the dashboard
poll itself now carries the onboarding_seed.active flag so the banner
state lives entirely in the dashboard response. Drops ~50 lines of
frontend code.

Parent: PLAN-1146.
2026-05-04 12:05:07 -04:00
xarmian 278d051eb0 feat(collections): seed onboarding items + explicit prefixes for scrum + product templates (TASK-1149) (#408)
* feat(collections): seed onboarding items + add explicit prefixes for scrum + product templates (TASK-1149)

Mirrors TASK-1133's pattern (PR #402) for the remaining software-category
templates. After this lands:

  - fresh `pad workspace init --template scrum`   → BACK-1 / SPRINT-2 / BUG-3 / DOC-4
  - fresh `pad workspace init --template product` → FEAT-1 / FB-2 / ROAD-3 / DOC-4

Each is a first-person note from the workspace owner's future self —
agent-invocable via `/pad let's discuss <REF>`, schema-aware terminal
verbs ("mark me done" / "completed" / "shipped" / "archived"), no
"tutorial" / "lesson" language. Bodies pulled verbatim from
DOC-1152 (scrum) and DOC-1153 (product).

Precondition fix: explicit Prefix set on five collections so DerivePrefix
doesn't yield awkward refs:

  Backlog       BACKL  → BACK
  Sprints       SPRIN  → SPRINT
  Features      FEATU  → FEAT
  Feedback      FEEDB  → FB
  Roadmap Items RI     → ROAD

Mirrors hiring template's pattern of explicit prefixes on its custom
collections. Existing scrum/product workspaces (forward-only fix) keep
their derived prefixes — the seeder doesn't migrate.

New tests:

  internal/collections/templates_test.go
    - TestScrumOnboardingItemsOrderAndShape
    - TestProductOnboardingItemsOrderAndShape
    - TestScrumProductTemplatesShipOnboardingSeedItems
    - TestScrumProductTemplatesUseExplicitFriendlyPrefixes (locks
      the prefix-fix precondition)

  internal/store/items_test.go
    - TestSeedCollectionsFromTemplateScrumRefSequence
    - TestSeedCollectionsFromTemplateProductRefSequence
      (Both also assert the prefix lands on each seeded item — drift
       in templates.go would surface here as a test failure pointing
       at the PLAN-1146 prefix precondition.)

Existing onboarding test (TestSeedCollectionsFromTemplateStartupRefSequence)
still passes — startup template untouched.

Parent: PLAN-1146. Source content: DOC-1152, DOC-1153.

* docs(comments): clarify the post-signup hint is wired in TASK-1150, not this PR (Codex review round 1)

Codex flagged that the helper-file + templates.go comments said things
like "the post-signup hint will name BACK-1" — which read as "it does
today" but actually means "it will once TASK-1150 lands." Until that
ships, the dashboard banner and CLI hint still hardcode IDEA-1 from
PR #403, so a fresh scrum/product workspace gets the seeded items but
no UI prompt that names them.

Comments now explicitly call out the in-flight state so readers
between this PR and TASK-1150 know what's wired and what isn't.

No behavior change.
2026-05-04 11:45:33 -04:00
xarmian 8fc0cb3b8b feat(collections): seed onboarding items + explicit prefixes for scrum + product templates (TASK-1149) (#408)
* feat(collections): seed onboarding items + add explicit prefixes for scrum + product templates (TASK-1149)

Mirrors TASK-1133's pattern (PR #402) for the remaining software-category
templates. After this lands:

  - fresh `pad workspace init --template scrum`   → BACK-1 / SPRINT-2 / BUG-3 / DOC-4
  - fresh `pad workspace init --template product` → FEAT-1 / FB-2 / ROAD-3 / DOC-4

Each is a first-person note from the workspace owner's future self —
agent-invocable via `/pad let's discuss <REF>`, schema-aware terminal
verbs ("mark me done" / "completed" / "shipped" / "archived"), no
"tutorial" / "lesson" language. Bodies pulled verbatim from
DOC-1152 (scrum) and DOC-1153 (product).

Precondition fix: explicit Prefix set on five collections so DerivePrefix
doesn't yield awkward refs:

  Backlog       BACKL  → BACK
  Sprints       SPRIN  → SPRINT
  Features      FEATU  → FEAT
  Feedback      FEEDB  → FB
  Roadmap Items RI     → ROAD

Mirrors hiring template's pattern of explicit prefixes on its custom
collections. Existing scrum/product workspaces (forward-only fix) keep
their derived prefixes — the seeder doesn't migrate.

New tests:

  internal/collections/templates_test.go
    - TestScrumOnboardingItemsOrderAndShape
    - TestProductOnboardingItemsOrderAndShape
    - TestScrumProductTemplatesShipOnboardingSeedItems
    - TestScrumProductTemplatesUseExplicitFriendlyPrefixes (locks
      the prefix-fix precondition)

  internal/store/items_test.go
    - TestSeedCollectionsFromTemplateScrumRefSequence
    - TestSeedCollectionsFromTemplateProductRefSequence
      (Both also assert the prefix lands on each seeded item — drift
       in templates.go would surface here as a test failure pointing
       at the PLAN-1146 prefix precondition.)

Existing onboarding test (TestSeedCollectionsFromTemplateStartupRefSequence)
still passes — startup template untouched.

Parent: PLAN-1146. Source content: DOC-1152, DOC-1153.

* docs(comments): clarify the post-signup hint is wired in TASK-1150, not this PR (Codex review round 1)

Codex flagged that the helper-file + templates.go comments said things
like "the post-signup hint will name BACK-1" — which read as "it does
today" but actually means "it will once TASK-1150 lands." Until that
ships, the dashboard banner and CLI hint still hardcode IDEA-1 from
PR #403, so a fresh scrum/product workspace gets the seeded items but
no UI prompt that names them.

Comments now explicitly call out the in-flight state so readers
between this PR and TASK-1150 know what's wired and what isn't.

No behavior change.
2026-05-04 11:41:29 -04:00
xarmian 553a39f09b fix(cli): pad auth setup hint should point at pad init, not a nonexistent IDEA-1 (TASK-1143) (#407)
PR #403 (TASK-1134) added printIdeaOneTriggerHint() to the pad auth
setup success path so freshly-bootstrapped admins would learn about the
seeded onboarding entry point. But pad auth setup only creates the
first admin account — no workspace. IDEA-1 is only seeded when a
startup-template workspace is created (via pad init / pad workspace
init). A user following the original hint immediately would hit
"workspace not found" / "item not found".

Caught by Codex during review of PR #406 (the docs PR for TASK-1138).
TASK-1143 was spawned then to keep PR #406 docs-only; this is the fix.

Reframe (Option 2 from the task spec): keep the hint, but point at the
next concrete action — `pad init` — rather than at IDEA-1. The IDEA-1
trigger phrase still surfaces in `printOnboardingHints`, which runs
after `pad init` / `pad workspace init`. By then the workspace exists
and the trigger phrase resolves correctly.

Renamed `printIdeaOneTriggerHint` → `printPostSetupNextStepsHint`
since the hint no longer names IDEA-1 directly.

Wording matches CLAUDE.md / README — workspace creation precedes the
trigger phrase everywhere.

Parent: PLAN-1131 (follow-up). Origin: Codex review of PR #406 round 1.
2026-05-04 10:44:07 -04:00
xarmian d1fb61097e docs(onboarding): document IDEA-1 trigger phrase across README, CLAUDE.md, and /pad skill (TASK-1138) (#406)
* docs(onboarding): document the IDEA-1 trigger phrase across README, CLAUDE.md, and the /pad skill (TASK-1138)

Make the seeded onboarding entry point (PLAN-1131) discoverable in
every doc surface a fresh user might land on.

README.md
  Quick Start gains a follow-up paragraph after `pad init`. Names the
  trigger phrase verbatim so a copy-paste lands deterministically. Tone
  matches in-product hint copy from PR #403; no "tutorial" / "lesson"
  language.

CLAUDE.md
  Authentication section gets a paragraph after `pad auth setup`
  pointing developers + agents at the same trigger phrase. Also
  enumerates the four seeded refs (IDEA-1 / PLAN-2 / TASK-3 / DOC-4)
  for context, with pointers to the source-of-truth code
  (internal/collections/templates_onboarding.go) and design history
  (PLAN-1131).

skills/pad/SKILL.md
  Adds a bullet under the Onboarding routing section: an explicit
  "use pad to get IDEA-1" trigger and the schema-aware terminal-status
  guidance per collection (Ideas → implemented, Plans → completed,
  Tasks → done, Docs → archived). Frames the seed items as ordinary
  items the agent reads and acts on — no "onboarding mode" — so the
  no-marker / no-skill-detection design from PLAN-1131 stays clean.

pad-web (../pad-web) is intentionally not touched — separate repo per
CONVE-159. Spawned TASK-1142 to pick up the pad-web getting-started
flow as a follow-up.

Parent: PLAN-1131. Origin: IDEA-1128.

* fix(docs): scope the IDEA-1 hint to post-workspace-creation, not bootstrap setup, per Codex review (round 1)

Codex caught that the original wording suggested users could go straight
to `use pad to get IDEA-1` after `pad auth setup`. But `pad auth setup`
only creates the first admin account — no workspace. IDEA-1 is only
seeded when a `startup`-template workspace is created (`pad init` or
`pad workspace init`).

Tightened to call out the precondition explicitly: a startup-template
workspace must exist before the trigger phrase resolves.

Spawned TASK-1143 to fix the matching CLI hint behavior — PR #403's
`printIdeaOneTriggerHint` after `pad auth setup` has the same
imprecision and should either drop the IDEA-1 mention or point users
at `pad init` first. Out of scope for this docs PR.
2026-05-04 10:32:51 -04:00
xarmian de9c87622a test(store): end-to-end onboarding walkthrough — fresh seed → user activity → idempotent re-trigger (TASK-1137) (#405)
Validates the entire arc PLAN-1131 promises, at the store level (the
API surface real workspace creation and real agent activity ultimately
call through). The "agent" steps are stubbed via direct CRUD — the
agent's reasoning is independently locked down by TASK-1136's resource
test, so this layer focuses on the workspace state machine.

Three phases mirror the user's experience:

Phase 1 — Fresh-workspace seed:
  - The four onboarding seeds land at IDEA-1 / PLAN-2 / TASK-3 / DOC-4
    in the right order with the right titles + statuses.
  - Conventions + playbooks land too (after the user-facing seeds).
  - IDEA-1 starts in status=new — the gate the post-signup hint relies
    on for "should I show the dashboard banner?".

Phase 2 — Agent walks user through populating real items:
  - IDEA-1 status flips new → exploring (signaling engagement).
  - One real plan gets created, three tasks under it, one user-supplied
    idea — using the actual user-facing collections.
  - IDEA-1 status flips exploring → implemented (closes the loop;
    dashboard banner hides on next refresh).

Phase 3 — Idempotency on re-trigger (server-startup auto-upgrade or
explicit re-init):
  - User's plan / tasks / idea remain untouched.
  - IDEA-1 status STAYS at `implemented` — re-seeding must NOT reset
    it to `new`, which would silently re-show the banner and confuse
    the user.
  - No duplicate seed items.
  - Conventions + playbooks counts unchanged.

Failure of any of these signals a regression in PLAN-1131's success
criteria. Walk back through the design doc before "fixing" the test.

Three small test helpers also added (findItemByTitle, extractStatus,
setItemStatus, countItemsInCollection) — kept private to the package
and used only by this test, but factored out so the assertions read
cleanly.

Parent: PLAN-1131. Origin: IDEA-1128.
2026-05-04 10:24:19 -04:00
xarmian fc8ad67f0a test(mcp): lock down IDEA-1 onboarding body verbatim across the resource pipeline (TASK-1136) (#404)
The MCP resource pipeline `pad://workspace/{ws}/items/{ref}` already
preserves arbitrary item content via formatItemAsMarkdown — covered by
generic shape tests. This adds a targeted contract test for IDEA-1
specifically, since it's the seeded onboarding entry point that MCP
clients (Claude Desktop, Cursor, Windsurf) hit when an agent is told
"use pad to get IDEA-1".

The test pulls the IDEA-1 body straight from
collections.StartupOnboardingItems(), simulates the JSON envelope that
`pad item show --format json` returns, runs it through readItem, and
asserts:

  - The composed heading "# IDEA-1: <title>" precedes the body.
  - The full body content appears verbatim (substring match — layout
    flexibility preserved for future formatItemAsMarkdown tweaks).
  - Specific sections agents depend on are present:
      * "## What I'd find useful" (behavior contract)
      * "Then mark this idea implemented" (schema-valid terminal status,
        guards round-1 fix on PR #402 from silently regressing to
        "mark me done" which is invalid for the Ideas collection)
      * "## If I've already done this before" (idempotency contract)
      * `pad project dashboard` (code-fenced commands survive)
  - `- **status:** new` field row in metadata.
  - The dispatched CLI args use --format json (NOT --format markdown,
    which only emits the body and would fail other readers' contracts).

No production code changes. The verification confirms the existing
mechanism — same conclusion the task spec anticipated as the likely
outcome ("may turn out to be a no-op").

Parent: PLAN-1131. Origin: IDEA-1128.
2026-05-04 10:17:11 -04:00
xarmian 0a5eb777b9 feat(onboarding): surface IDEA-1 trigger phrase across CLI and web UI (TASK-1134) (#403)
* feat(onboarding): surface IDEA-1 trigger phrase across CLI and web UI (TASK-1134)

Make the seeded onboarding entry point discoverable without prior
knowledge. CONVE-191 calls for full-stack thinking on user-facing
features — this lands on every surface a fresh user might check.

CLI surfaces:
  • `pad auth setup` success message gains a closing hint pointing at
    `use pad to get IDEA-1` in a new agent session. New helper
    printIdeaOneTriggerHint() so future templates can reuse the shape.
  • `printOnboardingHints` (used after `pad init` / workspace creation)
    now leads with the trigger phrase before the existing /pad prompt
    suggestions. IDEA-1 is named because it's the seeded primary entry
    in software-category templates; people-category templates will
    seed REQ-1 / APP-1 etc. and need a template-aware version of this
    hint — tracked under PLAN-1140.

Web UI surfaces:
  • New OnboardingIdeaBanner component renders on the workspace
    dashboard whenever IDEA-1 is in status=new. Shows the trigger
    phrase verbatim with a copy button and a "Read it first" deep link
    into the seeded item itself. Disappears the moment the user (or
    agent) flips IDEA-1 out of `new`.
  • Dashboard fetches IDEA-1 alongside its existing dashboard +
    collections calls (cheap, indexed by ref) and re-checks on every
    poll (default 30s) plus every sync signal so the banner is
    self-correcting.
  • Existing OnboardingChecklist gate (`totalItems === 0`) is left
    alone. It still serves empty / non-templated workspaces; the new
    banner is the templated-workspace surface.

No tests added — both surfaces are pure copy/render. Existing
dashboard + auth-setup tests still pass.

Parent: PLAN-1131. Origin: IDEA-1128.

* fix(onboarding): pin IDEA-1 lookup to exact prefix+number match per Codex review (round 1)

Server-side ResolveItem (via GetItemByRef) falls back from PREFIX-NUMBER
to a number-only lookup when the prefix doesn't match any collection in
the workspace. That fallback exists so an item moved between collections
is still resolvable by its old ref — but it has a bad interaction with
my new dashboard lookup:

  In a non-software-category workspace (hiring, interviewing, …), there
  is no Ideas collection. `api.items.get(ws, 'IDEA-1')` would silently
  return whatever item has item_number=1 — typically REQ-1 (Requisition)
  or APP-1 (Application). If that item happened to have status=new
  (which the seeded Requisition / Application entries do), the dashboard
  would render the IDEA-1 onboarding banner pointing at a /ideas/... URL
  that 404s.

Fix: verify item.collection_prefix === 'IDEA' && item.item_number === 1
before trusting the result. Mismatch (or missing) → ideaOneStatus = null,
banner stays hidden. Software workspaces with a real IDEA-1 still match;
hiring / interviewing / interview-loop-style workspaces stop seeing the
banner entirely.

Caught by Codex on PR #403.

* fix(onboarding): guard IDEA-1 lookup against stale-workspace writes per Codex review (round 2)

Previous round addressed the wrong-collection match. This round fixes a
related race: rapid workspace navigation could let a slow loadIdeaOne()
from workspace A resolve after the user is already on workspace B and
write A's status into B's state, briefly rendering the IDEA-1 banner on
a workspace that doesn't have it.

Two-part fix:

1. The dashboard $effect that triggers load() now resets
   ideaOneStatus = null synchronously when wsSlug changes, so any
   leftover `new` status from the previous workspace can't briefly
   render the banner during the window between navigation and the new
   fetch resolving.

2. loadIdeaOne() now compares its captured slug against the current
   wsSlug at every assignment point (success and error paths). If
   they've diverged, the response is dropped — only the active
   workspace's request can write ideaOneStatus.

Standard "was this still the active request" pattern. No behavior
change for the common case (single-workspace dashboard); the guard
only fires when navigation interleaves with an in-flight fetch.

Caught by Codex on PR #403.
2026-05-04 09:56:55 -04:00
xarmian 96253f18a2 feat(collections): seed IDEA-1/PLAN-2/TASK-3/DOC-4 in startup workspaces (TASK-1133) (#402)
* feat(collections): seed IDEA-1/PLAN-2/TASK-3/DOC-4 in startup workspaces (TASK-1133)

A fresh `pad workspace init --template startup` now seeds four onboarding
items — one per user-facing collection — that any agent can fetch and
meaningfully converse around. The post-signup hint will name IDEA-1
specifically, but PLAN-2 / TASK-3 / DOC-4 are all viable entry points
for `/pad let's discuss <REF>`.

The bodies are first-person notes from the workspace owner's future self
that introduce each collection's purpose by inviting a real conversation
about the user's project — no marker, no skill detection, no schema
fields. Word-audit clean: no "tutorial / lesson / step / walkthrough".
Bodies pulled verbatim from DOC-1139.

Sequence-stability: the existing seeder loop in store.SeedCollectionsFromTemplate
already runs SeedItems before conventions/playbooks, so the workspace-scoped
item_number sequence naturally lands at IDEA-1 / PLAN-2 / TASK-3 / DOC-4.
A dedicated test (TestSeedCollectionsFromTemplateStartupRefSequence) locks
the invariant down — drift means the post-signup hint silently misfires.

Scope: startup template only. Scrum and product templates have different
collection sets (Backlog/Sprints/Bugs and Features/Feedback/Roadmap
respectively) and need their own bodies — tracked as follow-up under
PLAN-1131. People-category templates (hiring, interviewing) are PLAN-1140.

Parent: PLAN-1131. Source content: DOC-1139.

* fix(collections): use schema-valid terminal statuses in onboarding bodies per Codex review (round 1)

The seed bodies told agents to "mark me done" but ideas/plans/docs don't
have a `done` terminal status — the HTTP/MCP update path validates select
options, so an agent following the seeded copy would hit a validation
error instead of completing the seed item.

- IDEA-1: "mark this idea done" → "mark this idea implemented"
  (Ideas terminal: implemented|rejected)
- PLAN-2: "mark me done" → "mark me completed"
  (Plans terminal: completed)
- TASK-3: unchanged — "done" is the canonical terminal for Tasks
- DOC-4: "mark me done" → "archive me"
  (Docs terminal: archived)

Caught by Codex on PR #402. Same hard validation path the rest of the
app honors — the seed copy needs to be schema-aware.
2026-05-04 09:10:31 -04:00
xarmian 95025793b9 feat(mobile): consolidate topbar + search palette UX (IDEA-1121) (#401)
Mobile chrome was previously split: a full <TopBar mobile /> (logo +
switcher + avatar) when the sidebar was open, and a slim inline
.mobile-header (hamburger + switcher) when it was closed. Every
mobile-chrome feature had to be added in two places, and the original
ask — a search button — surfaced the architectural debt.

Consolidated to a single always-rendered mobile chrome:
- TopBar.svelte mobile branch: PadLogo replaced with a hamburger that
  toggles the sidebar; new search-icon button calls openSearch() AND
  onNavigate() so the sidebar closes before navigating to a result
  (caught by Codex review, mirrors the desktop sidebar pattern).
- +layout.svelte: dropped the &&sidebarOpen gate so TopBar always
  renders on mobile; deleted the inline .mobile-header and its CSS;
  added padding-top: var(--topbar-height) on .app-layout via @media
  (max-width: 768px) so content doesn't slide under the fixed bar.
- [collection]/[slug]/+page.svelte: removed the now-stale 45px sticky
  offset that was pushing the breadcrumb below the deleted slim
  header.

Search palette mobile UX (CommandPalette.svelte, all in one
@media (max-width: 768px) block — desktop is byte-identical):
- Full-screen takeover (100dvh, no max-width / shadow / radius) so
  input anchors at top instead of fighting a vertically-centered
  layout against the on-screen keyboard.
- 16px input font to suppress iOS Safari focus-zoom.
- X close button (.mobile-close) replacing the useless 'esc' kbd hint.
- Body-scroll lock effect (overflow: hidden only — touch-action: none
  would have killed child scroll).
- .results pinned as the sole scroll target with flex: 1; min-height: 0
  so the search input stays at the top regardless of result-list size.

Editor toolbar leak fix (Editor.svelte): the .mobile-toolbar (z-index
100) rendered whenever the on-screen keyboard appeared for ANY input
— including the global search palette on a page with a tiptap editor
mounted. Gated the render condition on editorFocused (already tracked
via editor.on('focus')/on('blur')) so the toolbar only appears when
the editor itself is focused.

Refs: IDEA-1121, TASK-1122, TASK-1124
2026-05-03 21:35:17 -04:00
xarmian 40621ff58d feat(metrics): session-id-keyed TTL sweep for mcp_active_sessions (TASK-1120) (#400)
* feat(metrics): session-id-keyed TTL sweep for mcp_active_sessions (TASK-1120)

Replaces the naive +1/-1 active-sessions accounting from TASK-961.
The old logic bumped on JSON-RPC `initialize` and decremented on HTTP
DELETE — but a client that crashed, lost network, or restarted
mid-session never emitted DELETE, so the gauge drifted upward
monotonically until the pad-cloud server restarted.

Approach:

- `internal/server/middleware_mcp_session.go` (new) — mcpSessionTracker
  is an in-memory map keyed by Mcp-Session-Id (the canonical header
  set by mcp-go's StreamableHTTPServer on initialize responses and
  echoed by the client on subsequent requests). Touch updates
  lastSeen on insert + refresh; evict removes; periodic sweep evicts
  entries older than the TTL.
- Gauge is `Set(len(sessions))` via an onChange callback — single
  consistent observation per state-changing op, no risk of gauge
  drifting from map size on a multi-evict sweep.
- Lifecycle: spawned by SetMCPTransport (alongside startMCPAuditWriter),
  shut down from Server.Stop. Idempotent on both sides.
- Configurable via PAD_MCP_SESSION_TTL (default 30m) and
  PAD_MCP_SESSION_SWEEP_INTERVAL (default 5m). cmd/pad calls
  Server.SetMCPSessionTrackerConfig before SetMCPTransport.

Other changes:

- `recordMCPCallMetrics` no longer touches the active-sessions gauge.
  Updated comment + signature kept (callers pass the same args; the
  unused params are explicitly underscored).
- `MCPAuditLog` middleware now calls trackMCPSession after
  next.ServeHTTP — single new line in the audit hot path.
- `TestMCPAudit_BufferFull_DropsAndIncrementsCounter` updated to also
  shut down the new session tracker before bg.Wait(), since
  SetMCPTransport now spawns two goroutines on srv.bg.

Test coverage (16 tests, all green under -race):
- Tracker unit: touch insert/dedup, empty-id no-op, evict
  remove/non-existent, sweep eviction with single onChange,
  nil-onChange safety, concurrent touch/evict, run() clean shutdown.
- Server-side integration: lifecycle happy path (initialize → call →
  DELETE leaves gauge at 0), failed initialize doesn't open,
  no-session-id no-op, nil tracker safety, idempotent start, DELETE
  evicts on any status (transient 5xx on shutdown still counts).
- Regression guard: TestRecordMCPCallMetrics_DoesNotTouchSessionGauge
  pins that the audit-side helper has migrated off the gauge.

Parent: PLAN-943. Follow-up to TASK-961 (PR #398). Closes the
"sessions drift on client crashes" caveat documented in the metric's
help text + the Grafana panel description.

* fix(metrics): emit Mcp-Session-Id + serialize gauge updates per Codex review (round 1)

Two findings from Codex review on PR #400:

1. WithStateLess(true) wired StatelessSessionIdManager whose Generate()
   returns "" — mcp-go never set the Mcp-Session-Id response header
   in production, so the new tracker no-op'd on every initialize and
   the active-sessions gauge stayed at 0.

   Fix: introduce padMCPGenerateOnlySessionIDManager in cmd/pad/main.go.
   Generates a UUID per initialize (so the response carries the
   header — tracker can observe), but Validate accepts ANY incoming
   value (including empty / arbitrary). Preserves the original
   "stateless server, every request stands alone" contract while
   making the session-id observable. Documented why mcp-go's two
   shipped stateless managers don't fit (one breaks observability,
   the other breaks back-compat for clients that never echo the ID).

2. touch / evict / sweep computed `len(sessions)` under the mutex
   then released the lock BEFORE invoking onChange. Two concurrent
   inserts could compute (n=1, n=2) under the lock and then race the
   callback writes — last writer wins on the gauge, leaving it
   permanently inconsistent with the map size.

   Fix: hold the mutex across onChange. Trade-off documented: any
   future onChange that re-enters the tracker would deadlock, but
   that's a clear failure mode rather than silent metric corruption.
   Added TestMCPSessionTracker_OnChangeUnderLock that asserts a
   strictly-monotonic observation sequence under 32-goroutine
   concurrent inserts; passes 5x in a row under -race.
2026-05-03 17:40:18 -04:00
xarmian 1c409c8592 feat(metrics): emit mcp_authz_denials_total{reason=tier_mismatch} (TASK-1119) (#399)
Wire the dispatcher-side scope-deny seam into the
pad_mcp_authz_denials_total counter, completing the denial-reason
vocabulary documented in TASK-961.

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

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

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

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

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

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

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

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

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

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

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

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

Verified with `make check` (golangci-lint + go test ./... + web build).
2026-05-03 16:37:49 -04:00
xarmian d6c0073409 chore(web): point ConnectMCPModal docs link at /mcp/remote (TASK-1117 follow-up) (#397)
The connect-MCP modal had DOCS_HREF set to a temporary fallback at
getpad.dev/docs/mcp because the canonical /mcp/remote landing didn't
exist yet (TODO comment noted that). pad-web PR #80 (TASK-1117) just
shipped /mcp/remote as the proper sibling to /mcp/local. Update the
link target to match.

The previous URL /docs/mcp now 404s on getpad.dev (page <24h old when
moved; redirect explicitly waived per the project owner). This commit
ensures every Pad instance points at the live URL going forward.

Parent: PLAN-1111. Companion to pad-web #80.
2026-05-03 11:49:39 -04:00
xarmian 78ec39daa2 feat(web): add ConnectMCPModal + wire into ConnectBanner (TASK-1115) (#396)
* feat(web): add ConnectMCPModal + wire it into ConnectBanner (TASK-1115)

Ships the Remote MCP onboarding modal that the MCP-mode banner has been
waiting for. With this PR, on any deployment that exposes a public MCP
URL (Pad Cloud + any self-host with PAD_MCP_PUBLIC_URL set), users with
an empty workspace see:

- A "Connect an AI agent — zero install →" banner (TASK-1114)
- Click → ConnectMCPModal with:
  * The canonical MCP URL in a copy-block (sourced from
    authStore.mcpPublicUrl, never hardcoded — works for self-hosted
    deploys too)
  * Four client cards (Claude Desktop, Cursor, Windsurf, ChatGPT) each
    linking to the existing getpad.dev/docs/mcp/<client> page
  * Footer links: Connected agents (in-app), Documentation
    (getpad.dev/docs/mcp — TASK-1117 will swap to /mcp/remote when
    that page lands), and "Prefer the CLI? →" which closes this modal
    and opens the existing CLI install modal

ConnectBanner now mounts BOTH modals with independent open states; the
visibility predicate ORs them so the banner hides during interaction.
The "Prefer the CLI?" cross-link calls a parent callback so the banner
owns both states — ConnectMCPModal never directly mounts the CLI modal.
The transitional `mode === 'cli'` visibility gate from TASK-1114 is
removed (the gate's reason for existing — no MCP modal — is gone).

Validated:
- Svelte autofixer: 0 issues / 0 suggestions on the new component
- make check: 0 errors, 703 files (was 702 — confirms new file is
  picked up by svelte-check)

Parent: PLAN-1111. Depends on TASK-1114 (banner refactor — shipped).

* fix(web): refetch on CLI-modal close regardless of banner mode (Codex round 1)

Codex caught a real bug: Effect C was gated on `mode === 'cli'`, but
the new MCP modal can flip the user to the CLI flow via "Prefer the
CLI? →". In that path, mode stays 'mcp' but the user runs `pad init`
and closes the CLI modal — and Effect C wouldn't refetch, leaving the
banner stale until a route change.

Fix: track `prevCliOpen` specifically and refetch on its true → false
transition, regardless of banner mode. The MCP-modal close transition
is still no-op (correct — user is off in a separate agent client).
2026-05-03 11:10:08 -04:00
xarmian 393d8f1d7d feat(web): ConnectBanner two-mode refactor (CLI / MCP) (TASK-1114) (#395)
* feat(web): ConnectBanner two-mode refactor (CLI / MCP) (TASK-1114)

Adds mode-aware rendering to the connect banner. When the server exposes
a Remote MCP URL via /auth/session.mcp_public_url (Pad Cloud + any
self-host with PAD_MCP_PUBLIC_URL set), the banner renders in MCP mode:

- Plug icon (vs the historical terminal-arrow)
- Copy: "Connect an AI agent to this workspace — zero install →"
- CTA: "Connect" (vs "Get the CLI")

Self-hosted instances without an MCP public URL keep the existing
CLI-mode copy + flow (regression-safe — no behavior change there).

Effect C (refetch on modal close) now runs CLI-mode only. In MCP mode
the user leaves the page entirely — off to Claude Desktop / Cursor /
Windsurf to paste the URL — so refetching the dashboard right after
modal close doesn't help. Effect B (workspace-change refetch) and the
SSE feed catch the first MCP-sourced item on the next visit.

localStorage dismiss key migration: writes now go to
`pad-connect-banner-dismissed-{ws}` (was `pad-cli-banner-dismissed-{ws}`).
Reads OR the new and legacy keys for one release as a soft migration so
existing dismissals carry over without re-pestering. Legacy key is left
in localStorage as harmless dead state — we don't own the cleanup path.

Transitional state: MCP-mode banner currently routes to the existing
ConnectWorkspaceModal as a fallback. TASK-1115 ships ConnectMCPModal
and will swap the binding. Until then, MCP-mode users who click see the
CLI install flow — worse UX than the destination, but coherent (no
broken click). Clearly TODO'd in the markup.

Validated with the Svelte autofixer (0 issues; advisory suggestions
about $effect usage are justified — localStorage reads + async fetches
+ previous-value tracking can't be expressed as $derived).

Parent: PLAN-1111. Depends on TASK-1112 + TASK-1113 (both shipped).

* fix(web): suppress MCP-mode banner until ConnectMCPModal ships per Codex review (round 1)

Codex P1: the MCP-mode copy promises "zero install" but the click still
opens ConnectWorkspaceModal (CLI flow), which is misleading for users
who land in that state.

Fix: gate `visible` on `mode === 'cli'` for now. The mode-detection,
branched copy/icon/CTA, and dismiss-key migration all stay — they're
ready to light up when TASK-1115 mounts the new modal. The transitional
gate is removed in TASK-1115 along with the modal swap.

Net effect this PR: cloud / MCP-exposed deploys see no banner at all
(strictly safer than misleading); self-hosted deploys are unchanged
(same CLI banner + flow).

Codex finding addressed: PR #395 round 1.
2026-05-03 11:00:53 -04:00