Commit Graph

424 Commits

Author SHA1 Message Date
xarmian 504d348917 feat(attachments): Settings → Storage tab with attachment list (TASK-882) (#303)
* feat(attachments): Settings → Storage tab with attachment list (TASK-882)

Adds the Settings → Storage tab and the underlying list/delete API
endpoints so workspace owners can audit and reclaim attachment bytes.

Backend (TASK-882 needs this — there was no list/delete API yet):
- store.WorkspaceAttachments: paginated list with filter (category,
  attached/unattached, collection_id) + sort allowlist (size, filename,
  created_at — each with desc variant). LEFT JOIN to items + collections
  enriches each row with item_title/slug + collection_slug for the
  "in [[Item]]" link. Hides derived (thumbnail) rows by default — they
  count toward quota but are managed automatically and would clutter
  the user-facing list.
- store.SoftDeleteAttachment: tombstones the row + every variant. Blob
  on disk stays put; orphan GC reclaims past the grace period (TASK-886).
- GET /workspaces/{ws}/attachments — viewer+, returns
  {attachments, total, limit, offset}.
- DELETE /workspaces/{ws}/attachments/{id} — editor+. Refuses to delete
  derived rows directly (returns 400 with derived_attachment code) and
  invalidates the storage-usage cache.

Frontend:
- StorageTab.svelte component (lib/components/settings) with usage bar
  (color thresholds at 80%/100%, override badge), 5-select filter row
  (category, item, collection, sort, page size), attachment list with
  thumbnails (image variants via thumb-sm, emoji icon otherwise), item
  link, MIME, size, date, and per-row delete with confirm() dialog.
  Pagination footer with Prev/Next + "showing X–Y of Z".
- TS api.attachments.list() / delete() + types.
- Wired as a new "Storage" tab on the workspace settings page.

Tests:
- TestListAttachments_Pagination: 3 uploads, default + size-asc sort,
  limit/offset paging.
- TestListAttachments_HidesDerived: synthetic thumbnail row, asserts
  the list excludes parent_id != NULL rows.
- TestDeleteAttachment_HappyPath: upload → delete → list empty →
  storage usage drops to 0 (cache invalidation hook fires) → second
  delete returns 404.
- TestDeleteAttachment_DerivedRefused: thumbnail rows can't be deleted
  directly via the API.

Parent: PLAN-866.

* fix(attachments): collection visibility + category gaps + item ref shape per Codex (round 1)

Three findings from Codex on PR #303 round 1:

P1 — Collection visibility leak. The storage list returned all
workspace attachments without applying per-user collection access,
so a member with collection_access=specific would receive hidden
collections' attachment IDs/filenames/item titles and could then
pull the bytes via the existing download endpoint.

Fixed by threading visibleCollectionIDs(r, workspaceID) through to
the store filter. nil = admin/no restriction; empty slice = zero
visible collections (zero rows by design); explicit set = restrict
i.collection_id IN (...). Orphans (item_id IS NULL) are excluded
for restricted users since their filenames would still leak.

P2 — item_ref shape didn't match the route. The store synthesized
"<collection_slug>/<item_number>" and the UI inserted it verbatim
into the URL, producing /user/ws/tasks/tasks/5. Dropped item_ref
entirely; UI now builds URLs from item_slug + collection_slug
which is the actual route shape.

P2 — Category filter coverage. mimePrefixForCategory only handled
image/video/audio. Selecting Documents/Text/Archive/Other in the
UI silently passed through with no MIME predicate so the list
showed everything. Replaced with mimePredicateForCategory which
emits the right SQL fragment per bucket: prefix LIKE for the type/
buckets, explicit IN list for document/text/archive (mirroring the
allowlist in internal/attachments/mime.go), and a NOT-IN composite
for "other".

Tests:
- TestWorkspaceAttachments_VisibilityFilter: admin sees all 3 rows;
  restricted to one collection sees only that collection's row +
  orphan suppressed; empty visibility yields zero rows.
- TestWorkspaceAttachments_CategoryFilters: image/document/text/
  archive/other each return exactly the matching MIME types.

* fix(attachments): item-level visibility on list + delete per Codex (round 2)

Two more findings from Codex on PR #303 round 2:

1. The list filter used VisibleCollectionIDs alone — but that set
   includes collections containing any item-level grant for the user.
   A guest with one item granted in collection B would still receive
   attachment metadata for every item in collection B. Replaced with
   the (fullCollIDs, grantedItemIDs) tuple from guestResourceFilter so
   the SQL ORs collection-level full access against per-item grants,
   matching how handlers_search / handlers_activity narrow lists.

2. The delete endpoint validated workspace membership but never
   checked the attachment's parent item is visible to the caller.
   An editor with restricted collection access could delete
   attachments in hidden collections by guessing/obtaining the
   attachment ID. Added requireItemVisible after fetching the parent
   item, plus a fallback gate for orphan attachments (item_id IS
   NULL) so restricted users get 404 there as well.

Store-level filter renamed: VisibleCollectionIDs → Restricted +
FullCollectionIDs + GrantedItemIDs. Tests cover the collection-only,
item-grant-only, and zero-visibility paths.

* fix(attachments): allow deleting attachments when parent item is soft-deleted (round 3)

Codex P2 from PR #303 round 3: the storage list intentionally surfaces
attachments whose parent item has been soft-deleted (so the user sees
what's still consuming quota), but the delete handler used GetItem,
which filters soft-deleted out and returned 404 before
SoftDeleteAttachment could run — turning every Delete button on those
rows into a no-op.

Fixed by adding store.GetItemIncludeDeleted (mirroring the existing
GetItemBySlugIncludeDeleted) and switching the delete path to use it.
The visibility check still keys off the (still-set) collection_id, so
soft-deleting an item doesn't escalate access — restricted users still
hit requireItemVisible's 404 if they couldn't see the parent.

Regression test: create item → attach → soft-delete item → list still
returns the row → delete returns 204.

* fix(attachments): list surfaces attachments under soft-deleted parents (round 4)

Codex round-4 finding: WorkspaceAttachments still LEFT JOIN'd items
with AND i.deleted_at IS NULL, so attachments whose parent item was
soft-deleted disappeared from the list — even though the previous
round wired GetItemIncludeDeleted on the delete path. Net effect:
restricted editors with access to that collection couldn't discover
the row in the UI; only full-access users saw it as an orphan-looking
entry.

Fix: drop the deleted_at filter from the JOIN. The collection ACL
predicate (i.collection_id IN ...) now sees the (still-set)
collection_id from the soft-deleted item, so visibility behaves
consistently for live and tombstoned parents. Soft-deleted items
don't escalate access — the collection_id stays put.

UX: response now carries item_deleted=true when the parent is
soft-deleted; the StorageTab renders the title with strike-through
+ a small "deleted" badge instead of a clickable link (which would
404).

Tests:
- store-level: admin/full-access sees the row + ItemDeleted flag,
  restricted-to-correct-collection sees it, restricted-to-other-
  collection does not.
- (existing TestDeleteAttachment_AfterParentSoftDeleted continues
  to pass on the handler side.)
2026-04-29 17:44:12 -04:00
xarmian 335762c2bf feat(attachments): storage usage API + effective-limit computation (TASK-881) (#302)
* feat(attachments): storage usage API + effective-limit computation (TASK-881)

Adds GET /api/v1/workspaces/{ws}/storage/usage returning
{used_bytes, limit_bytes, plan, override_active}. Resolves the effective
limit through the existing three-tier chain (per-user override → platform
setting → hardcoded plan default) and surfaces the override flag for the
upcoming Settings → Storage and admin user-detail UIs.

Implementation:
- store.WorkspaceStorageInfo consolidates SUM(size_bytes) + owner-plan
  resolution in one call; WorkspaceStorageLimit is now a thin wrapper so
  the upload-time quota check and the API path stay consistent.
- Server.storageInfoCache is a 30s TTL memoizer to absorb repeated
  Settings → Storage page loads. Invalidation hooks fire on upload,
  thumbnail derivation, and transform — the ~30s eventual-consistency
  window is bounded by TTL only when invalidation isn't reachable.
- Defensive copy on cache read so a caller mutating the returned struct
  can't poison subsequent reads.
- New CLI command `pad workspace storage` prints "X used of Y (Z%)" with
  IEC units (humanBytes helper) and surfaces the override flag.
- TS api.attachments.storageUsage() + WorkspaceStorageInfo type ready
  for TASK-882's Settings → Storage page consumer.

Tests:
- Store-level: no-owner fallback, free-plan resolution chain, override
  flip, pro-plan override-active visibility, soft-delete exclusion.
- Server-level: empty-workspace happy path, two uploads with cache
  invalidation between, dedicated cache TTL/invalidate/copy-safety test.

Parent: PLAN-866.

* fix(attachments): gate storage usage on viewer+ per Codex review (round 1)

Codex correctly flagged that the storage/usage handler relied solely on
RequireWorkspaceAccess, which admits item-grant guests with
workspaceRole=="guest". Workspace-wide quota numbers (used_bytes, plan,
override status) shouldn't surface to guests — every other workspace-
level read handler uses requireMinRole("viewer") for exactly this case.

Adds the explicit gate + a regression test that calls the handler with
a guest-role context and asserts 403.
2026-04-29 17:05:19 -04:00
xarmian 9e8fec93ff fix(ci): bump golang.org/x/image to v0.39.0 to clear 5 govulncheck CVEs (#300)
govulncheck flagged 5 vulnerabilities reachable through the new
attachments image processor (TASK-878), all in the
golang.org/x/image module that disintegration/imaging pulls in
transitively. We were stuck on the ancient
v0.0.0-20191009234506-e7c1f5e7dbb8 because nothing else explicitly
required a newer version.

  - GO-2026-4815: OOM from malicious IFD offset in tiff (fix v0.38.0)
  - GO-2024-2937: Panic on invalid palette-color images   (fix v0.18.0)
  - GO-2023-1990: Excessive CPU on 0-height tiff images   (fix v0.10.0)
  - GO-2023-1989: Excessive resource consumption in tiff  (fix v0.10.0)
  - GO-2023-1572: DoS via crafted tiff image              (fix v0.5.0)

go get golang.org/x/image@latest landed v0.39.0, which fixes all
five. golang.org/x/text bumped 0.35.0 → 0.36.0 as a transitive
ride-along.

Verification:
  go build ./...                              — clean
  go test ./...                               — pass
  govulncheck ./...                           — "No vulnerabilities found"

This closes the last CI gap: PR #299 (gofmt + race-timeout) cleared
the lint and PostgreSQL race-step failures; this clears the third
red light. Race step on PR #299's merge run finished in 19m36s ✓
under the new 30m cap.
2026-04-29 16:06:25 -04:00
xarmian 756d91acad fix(ci): gofmt + bump race-detector timeout to 30m (#299)
CI on main has been failing since the PLAN-866 attachment work
landed. Two independent issues:

1. gofmt failures (golangci-lint) — seven files in the attachments
   path had trailing-comment alignment that gofmt wanted nudged a
   column. Pure whitespace; ran `gofmt -w` across the affected
   files. golangci-lint's gofmt linter caught it on every PR /
   push since TASK-870 but we hadn't been watching those signals.

   Files cleaned: internal/attachments/{fs_store_test,mime,
   mime_test,processor_test}.go, internal/server/{
   handlers_attachments_download_test,handlers_attachments_transform,
   render/attachments_test}.go.

   Local guard: `gofmt -l ./...` now exits clean.

2. Race-detector tests timed out at 20m on the GitHub-hosted runner.
   Two contributors:
     - PostgreSQL adds latency on every CREATE/DROP plus on the
       bcrypt hash inside auth/bootstrap (~3s per call under -race
       on the runner). Tests that bootstrap a fresh user (e.g.
       TestSessionIPChange_*) pay the full cost each time.
     - The PLAN-866 image-processing tests (thumbnail derivation,
       rotate / crop transform) added ~2-3 minutes of decode/encode
       work on top of the existing suite.

   The previous "20m gives margin without papering over a hang"
   comment was right at the time it was written; we now genuinely
   need more headroom. Bumped to 30m on both the SQLite and
   PostgreSQL race steps. Genuine deadlocks would still trip this
   and produce the goroutine-dump panic — we just stop confusing
   "slow but progressing" with "permanently hung".

   Reference points before / after:
     - TASK-875 main run #294: Go (PostgreSQL) finished in 17m48s ✓
     - TASK-880 main run #298: Go (PostgreSQL) hit 20m timeout ✗
     - Local: my new tests under -race add ~63s on a developer laptop
       (TestThumbnails + TestTransform + TestProcessor combined).

Verification:
  go test ./...             — pass
  go vet ./...              — clean
  gofmt -l (recursively)    — clean
2026-04-29 15:41:12 -04:00
xarmian 3bf1b60365 feat(attachments): editor image crop with aspect presets (TASK-880) (#298)
Adds a drag-to-crop modal on top of the AttachmentImage toolbar
introduced in TASK-879. The /transform endpoint already accepted
the "crop" operation shape from TASK-879 — this PR wires the editor
UI plus the supporting tests.

Editor:
  - attachment-crop-modal.ts (new): pure-DOM crop modal in the same
    style as the existing image lightbox. Returns a Promise that
    resolves to the crop rect in ORIGINAL-IMAGE pixel coordinates
    when the user clicks Apply, or null on cancel / dismiss /
    image-load failure.
    - Image fits to a centered <dialog> via flex layout; backdrop
      click and Esc both cancel cleanly.
    - Crop rectangle starts at 80% of the image, centered. Body is
      a "move" handle; four corner handles resize.
    - Aspect presets: Free, 1:1, 4:3, 16:9. Preset clicks snap the
      current rect to the new ratio while preserving its center;
      subsequent corner drags clamp to the locked ratio.
    - Pointer events (touch + mouse for free) with setPointerCapture
      so drag continues even if the cursor leaves the handle.
    - Coordinate translation: rect in preview-pixel space →
      naturalWidth / offsetWidth scale → original-image pixel
      space. Result is clamped to natural bounds so a fractional-
      rounding overrun doesn't push the rect off-image.

  - attachment-image.ts: extracts swapNodeUuid() helper from
    runRotate so runCrop can share the setNodeMarkup +
    invalidate-old-metadata flow. The toolbar gains a fourth
    button (⌶ Crop…) that opens the modal pointed at the original
    variant. Per-format gating (refreshToolbarState) treats the
    crop button identically to the rotate trio — both go through
    /transform, so a libvips-only format (e.g. WebP on the pure-Go
    build) disables the whole toolbar with the same explanatory
    tooltip.

  - app.css: full styling for the crop modal — header with aspect
    toolbar, image stage with shadow-cutout overlay around the
    crop rect, four corner handles, footer with Cancel + Apply.
    Uses the existing CSS-variable palette so light/dark mode
    track automatically.

Server tests (3 new):
  - TestTransform_CropProducesNewBlobAtRectDimensions: end-to-end
    PNG crop, verify the response dimensions AND that the served
    bytes decode at the same dimensions (guards against an
    encode-pipeline off-by-one).
  - TestTransform_CropClipsToImageBounds: rect that extends past
    the image boundary clips rather than 400ing — the editor's
    rounding can produce rect+1px past natural width/height in
    rare fractional-scale cases, and the processor's Crop
    intersects with image bounds for exactly this reason.
  - TestTransform_CropRejectsBadRect: missing rect, zero width,
    negative xy, rect entirely outside → 400.

Parent: PLAN-866. Closes the editor-side image-tools track on top of
TASK-878 (Processor) and TASK-879 (rotate / transform endpoint).
2026-04-29 15:02:07 -04:00
xarmian f93b0ee4ce feat(attachments): server-side rotate transform + editor toolbar (TASK-879) (#297)
* feat(attachments): server-side rotate transform + editor toolbar (TASK-879)

Adds the POST /transform endpoint and the editor's rotate toolbar
on top of TASK-878's Processor abstraction. Rotation produces a NEW
content-addressed attachment row; the editor swaps the AttachmentImage
node's UUID via setNodeMarkup and the original ages into orphan GC.

Server (internal/server/handlers_attachments_transform.go):
  POST /api/v1/workspaces/{slug}/attachments/{id}/transform with
  body {operation, ...params}. Phase 1 wires the "rotate" branch
  (degrees: 90 | 180 | 270 only — pixel-exact reorderings, no
  resampling, matches what the editor emits). The "crop" branch
  is parsed and validated but the transform path is wired in
  TASK-880; defining the wire format here keeps both PRs aligned.

  Auth: editor+ on the workspace. Cross-workspace and deleted-parent
  probes return 404 (not 403) so the new endpoint can't become a
  side-channel for ID enumeration. Unsupported MIME → 415; oversized
  image → 413; bad params → 400; missing processor → 503. Output
  format follows the same PNG-stays-PNG / else-JPEG policy as the
  thumbnail pipeline so derived blobs deduplicate cleanly.

  Tests (10): rotate 90 swaps WxH, rotate 180 keeps WxH, bad degrees
  → 400, unknown op → 400, non-existent attachment → 404, cross-
  workspace → 404, no processor → 503, derived row has fresh hash +
  inherits workspace/uploader/item, served bytes decode at the new
  dimensions, deleted-parent → 404.

Web client (web/src/lib/api/client.ts + types):
  api.attachments.transform(slug, id, payload) hits the new endpoint
  with a discriminated AttachmentTransformRequest type. New
  api.server.capabilities() reads the public capability profile
  added in TASK-878. Both surface PadApiError on failure so the
  editor can show actionable messages.

Editor:
  - attachment-metadata.ts (new): shared HEAD-probe cache extracted
    from attachment-chip.ts so AttachmentImage's toolbar can probe
    the image's MIME with the same zero-extra-network-cost
    deduplication. Adds mimeToFormat() — maps MIME to the canonical
    short format name the server's Capabilities reports.

  - attachment-chip.ts: swapped to use the shared cache. Behavior
    unchanged.

  - attachment-image.ts: NodeView now wraps the <img> in a
    positioned <span> and lazy-builds a 3-button rotate toolbar
    (rotate left 90°, rotate 180°, rotate right 90°). selectNode
    shows it; deselectNode hides it. On click → calls
    options.transform → setNodeMarkup with the returned UUID at
    getPos(); cached metadata for the OLD UUID is invalidated.

    Per-button gating via refreshToolbarState: empty
    supportedFormats list (degraded build) → all disabled with a
    "this build doesn't have image processing" tooltip. MIME
    probed and not in supportedFormats → disabled with a format-
    specific tooltip ("Image editing for image/webp requires
    libvips"). Otherwise → enabled with the action tooltip.

  - Editor.svelte: configures AttachmentImage with the workspace
    slug, the supportedFormats list (initially empty, populated
    asynchronously after capabilities resolve), and the transform
    callback wired to api.attachments.transform. Errors surface via
    console.error + window.alert — same fallback as the upload
    plugin until a centralized toast system lands.

  - app.css: wrapper + toolbar styles. Toolbar pinned top-right with
    absolute positioning; selected-state ring on the image; disabled
    button state at 40% opacity.

Parent: PLAN-866. Unblocks TASK-880 (crop) — the /transform endpoint
already accepts the crop op shape, the editor's toolbar pattern is
the same, and the supportedFormats gating composes cleanly.

* fix(attachments): rotate attribution + toolbar refresh per Codex review (round 1)

Two findings from the round-1 Codex review:

1. The transform handler set UploadedBy = currentUserOrSystem(r),
   contradicting the comment that said "inherit attribution from
   the parent" and creating an audit-attribution drift whenever a
   user rotated/cropped someone else's upload. Inherit
   parent.UploadedBy instead — same policy as the thumbnail
   pipeline. Added TestTransform_DerivedRowInheritsUploadedByFromParent
   to lock in the contract. Removed the now-unused
   currentUserOrSystem helper.

2. The rotate toolbar's per-format gating could permanently stick
   in "all-disabled" state if the user selected an image before
   the async capabilities fetch resolved. supportedFormats started
   as [] (matching "no processor"), refreshToolbarState ran once
   in that state, and the later mutation of ext.options.
   supportedFormats had no observer to push the change down to
   already-open toolbar DOM. Fix: module-level toolbarRefreshers
   set, populated by each NodeView at ensureToolbar() and torn
   down in destroy(); a new notifyAttachmentImageCapabilitiesChanged()
   export iterates the set and re-runs each toolbar's refresh
   hook. Editor.svelte calls it after the capabilities fetch
   updates ext.options.supportedFormats, so any toolbar opened
   during the in-flight request snaps to its correct state the
   moment caps arrive.

Verification: go test ./internal/server -run TestTransform passes
(11 cases now); npm run check passes with the existing 6 warnings.
2026-04-29 14:54:48 -04:00
xarmian 02be33902f feat(attachments): ImageProcessor interface + pure-Go impl + thumbnail pipeline (TASK-878) (#295)
* feat(attachments): ImageProcessor interface + pure-Go impl + thumbnail pipeline (TASK-878)

Adds the abstraction Phase 1 needs to derive thumbnail variants on
upload, with a pure-Go default implementation that keeps Pad's
single-binary distribution intact (no cgo). The libvips-tagged
build (Phase 2 / Pad Cloud Docker) will replace processor_purego.go
with a vips-backed implementation behind the same Processor
interface — see DOC-865.

internal/attachments/processor.go:
  Processor interface — Decode(io.Reader)→(image.Image, format),
  Resize(img, maxLong), Rotate(img, deg), Crop(img, rect),
  Encode(img, format, w), Capabilities().
  Capabilities struct (image_formats, can_transcode, max_pixels)
  surfaces what the editor needs to gate per-format rotate/crop UI
  on (TASK-879/880). ErrUnsupportedFormat + ErrImageTooLarge are
  separate sentinels so callers can distinguish "format not
  supported" from "image dimensions too big".

internal/attachments/processor_purego.go (//go:build !libvips):
  Uses github.com/disintegration/imaging plus the stdlib decoders.
  Supports PNG/JPEG/GIF/BMP/TIFF for all ops. WebP/AVIF/HEIC
  reach Decode and bounce out via ErrUnsupportedFormat — uploads
  still succeed (the MIME allowlist is the upload gate), but
  thumbnails skip and the editor disables rotate/crop UI per
  Capabilities.

  Memory ceiling: Decode peeks via image.DecodeConfig (header only)
  before allocating any pixel buffer and rejects images whose
  width*height exceeds MaxPixelsDefault (8000² = 64MP). At 4 bytes
  per pixel that caps the decode buffer at ~256 MiB and prevents an
  attacker uploading a forged 100kx100k claim from OOMing the
  server. The forged-CRC test exercises this gate.

internal/server/handlers_attachments_thumbnails.go:
  deriveThumbnails(parentID) runs in goAsync after every image
  upload. Generates thumb-sm (256px long edge) + thumb-md (1024px),
  each as its own attachments row with parent_id pointing at the
  original. Server.Stop() drains the goroutine before SQLite
  closes, so tests can assert post-conditions deterministically.

  Skip cases: parent deleted (race), source format not supported
  (logged at debug), source already smaller than the variant's
  bound, variant already exists (idempotent reruns). Variants
  count toward workspace storage usage — DOC-865 is explicit about
  this and TestThumbnails_CountsTowardWorkspaceUsage proves it.

  Output format policy: PNG inputs stay PNG to preserve transparency;
  everything else encodes as JPEG q=85.

internal/server/handlers_capabilities.go:
  GET /api/v1/server/capabilities returns the Processor's static
  capability profile under {image: {...}}. Public route — the
  editor needs it before login (e.g. shared-item preview surfaces).
  Reports an empty image-formats list when no processor is wired,
  signalling the editor to disable rotate/crop UI rather than
  500-ing the editor mount.

cmd/pad/main.go: wires SetImageProcessor(NewProcessor()) alongside
SetAttachments at startup; logs the supported formats so operators
know whether they're on the pure-Go or libvips build.

Tests:
  - processor_test.go: 12 unit tests covering capability profile,
    decode round-trip for PNG/JPEG/GIF, rejection of unsupported
    formats and oversized images (forged-CRC PNG), resize aspect
    preservation + pass-through for already-small inputs, rotate
    multiples-of-90 + negative + 360-modulo handling, crop with
    bounds clipping + empty-intersection rejection, encode round-
    trip for PNG/JPEG, ThumbnailFormat/Mime/Ext policy.
  - handlers_attachments_thumbnails_test.go: 5 integration tests
    covering thumb-sm + thumb-md generation on PNG/JPEG uploads,
    skip-when-source-already-small, ?variant=thumb-md serving via
    the existing GET handler, workspace usage accounting.
  - handlers_capabilities tests cover the happy path + the
    no-processor degraded path.

Parent: PLAN-866. Closes the thumbnail-fallback gap that TASK-874 /
TASK-876 left open (thumb-md URLs were falling back to original
because no thumbnails existed). Unblocks TASK-879 (rotation tool)
and TASK-880 (crop tool) — both will reuse Processor.Rotate /
Processor.Crop with the same Capabilities-driven UI gating.

* fix(attachments): make /server/capabilities public per Codex review (round 1)

Codex flagged that GET /api/v1/server/capabilities was registered
inside the auth-gated API group but missing from isPublicAPIPath,
so once any user existed the editor's pre-login fetch would 401 —
contradicting the route's "public" register-time intent and breaking
the share-preview surface.

Fix: add the path to isPublicAPIPath. The handler is read-only,
returns a static profile, and has no per-user state, so making it
public has no security implication. Added
TestServerCapabilities_PublicAfterBootstrap as a regression guard:
it bootstraps an admin (so RequireAuth is active) and then fetches
the endpoint with no auth cookie, asserting 200.

* fix(attachments): make -tags libvips compile per Codex review (round 2)

Codex flagged that build tag !libvips on processor_purego.go meant
NewProcessor + the Thumbnail* helpers were absent under
\`go build -tags libvips\`, so cmd/pad/main.go and the thumbnail
handler — which call them unconditionally — broke that build.

Two minimal fixes preserving the documented Phase 2 split:

  1. Move ThumbnailFormat / ThumbnailMime / ThumbnailExt out of the
     tagged file and into processor.go (untagged). They're pure
     format-name policy, not implementation specifics, so both
     backends share the same definitions.

  2. Add processor_libvips.go (//go:build libvips) with a stub
     NewProcessor that panics at runtime with a clear
     "Phase 2 hasn't shipped libvips yet" message. The libvips
     build now compiles; anyone actually instantiating the
     processor under that tag gets a loud failure rather than a
     silent degradation. Phase 2 will replace the body with the
     real govips-v2-backed implementation.

Verified: \`go build ./...\` and \`go build -tags libvips ./...\` both
clean. Existing tests still pass on the default tag.

* fix(attachments): make tests compile under -tags libvips per Codex review (round 3)

Codex flagged that running \`go test -tags libvips ./internal/attachments\`
or \`./internal/server\` panicked through the libvips NewProcessor
stub: processor_test.go and the thumbnail/capability server tests
all called NewProcessor() unconditionally, even though the libvips
build's stub is intentionally panicking until Phase 2 ships the
real implementation.

Three minimal fixes:

  1. Tag processor_test.go !libvips. It tests the pure-Go
     implementation specifically — there's no value in running it
     under libvips, and the stub processor would explode the moment
     NewProcessor() ran.

  2. Tag handlers_attachments_thumbnails_test.go !libvips. Same
     reasoning — these integration tests assert thumbnail
     derivation against a working processor.

  3. Split testServerWithAttachments's processor wiring into two
     build-tagged helper files:
       * testimageprocessor_purego_test.go (//go:build !libvips)
         wires the real pure-Go processor.
       * testimageprocessor_libvips_test.go (//go:build libvips)
         is a no-op so the rest of the server test surface
         (uploads, downloads, auth, etc.) compiles + runs cleanly
         under -tags libvips.

Verification:
  go build ./...                              — OK
  go build -tags libvips ./...                — OK
  go test ./internal/attachments ./internal/server (default)        — pass
  go test -tags libvips ./internal/server -run "TestUpload|TestDownload" — pass

Phase 2 will introduce a real libvips test backend and drop the
!libvips tags on the thumbnail tests.

* fix(attachments): libvips binary boots cleanly per Codex review (round 4)

Codex flagged that the libvips build still crashed at \`pad serve\`
startup: cmd/pad/main.go calls attachments.NewProcessor()
unconditionally, and the libvips stub was panicking — so any
operator who built with -tags libvips today (Phase 2 isn't shipped
yet) lost the entire server, not just image processing.

Two minimal changes:

  1. processor_libvips.go: stop panicking. Return nil + slog.Warn
     instead. Every call site already nil-checks the processor (the
     upload handler skips thumbnail derivation, the capabilities
     endpoint reports a degraded empty formats list), so the
     libvips-tagged binary now has the same runtime profile as a
     self-host build that opted out of image processing entirely
     — uploads succeed, originals display, only derived
     transformations are unavailable. The slog.Warn keeps the
     "this build doesn't have it yet" signal loud.

  2. cmd/pad/main.go: skip srv.SetImageProcessor when NewProcessor
     returns nil, and log a "not wired" message in that branch.
     Distinguishes the wired vs. unwired states cleanly in the
     boot log.

Phase 2 will replace processor_libvips.go's body with the real
govips-v2-backed implementation; main.go's wiring is already shape-
correct for that transition.

Verification:
  go build ./...                — OK
  go build -tags libvips ./...  — OK
  go test ./...                 — pass (74s server tests included)
  go test -tags libvips ./internal/server -run "TestUpload|TestDownload|TestServerCapabilities_Public" — pass
2026-04-29 14:35:49 -04:00
xarmian 7e5b15722f feat(attachments): editor paste + drag-drop upload plugin (TASK-875) (#294)
Tiptap extension that intercepts paste/drop events with files, uploads
each through the attachment API, and replaces the placeholder with the
right node (attachmentImage for image MIMEs, attachmentChip for
everything else) at the position the user dropped.

The plugin's flow:
  1. Detect file payloads in clipboardData.items / dataTransfer.files
     (skip the event when there are none, so plain text paste / cursor
     drag still go through tiptap-markdown's transformPastedText path).
  2. Insert a position-tracked placeholder at the paste/drop position
     via a setMeta transaction. Placeholders are widget decorations
     (zero document width) so they never enter serialized markdown
     even if the user navigates away mid-upload.
  3. Race the network. The plugin's apply() handler maps every
     placeholder position through every intervening transaction, so
     continued editing doesn't strand the spinner.
  4. On success: schema-aware replacement — attachmentImage for
     category=image, attachmentChip otherwise. The placeholder is
     removed in the same transaction.
  5. On error: remove the placeholder and surface the failure via the
     injected onError callback. The upload bytes that did land become
     orphans; orphan-GC reclaims them after the grace period.
  6. If the placeholder has been deleted before the upload completes
     (user cancelled, navigated away, etc.) the upload is dropped
     silently — same orphan-GC outcome.

Multiple files in a single drop fan out as concurrent uploads; each
gets its own placeholder and replaces independently as the network
completes.

Editor.svelte wires:
  - upload  -> api.attachments.upload(workspaceSlug, file)
              (rejects with a clear message when no workspace context)
  - onError -> console.error + window.alert as a minimal fallback
              until a centralized toast system lands.

Styles (app.css) cover the placeholder bubble (dashed border, faded
colour) and a CSS spinner — kept inline via decoration widget DOM so
ProseMirror's selection ignores it (ignoreSelection: true).

Parent: PLAN-866. Closes the editor-input flow on top of TASK-874
(markdown resolver), TASK-876 (image node), TASK-877 (chip node).
2026-04-29 13:41:05 -04:00
xarmian 934794b606 feat(attachments): editor file chip node (TASK-877) (#293)
* feat(attachments): editor file chip node (TASK-877)

Custom Tiptap node `attachmentChip` for non-image `pad-attachment:UUID`
references — Notion-style chip rendering with type icon, filename, and
human-readable size.

Node shape:
  - uuid:     string — the attachments-row UUID
  - filename: string — display name; preserved across save/reload

Markdown round-trip:
  - Serialize: `[filename](pad-attachment:UUID)` — same standard link
    syntax the markdown resolver in TASK-874 understands. `]` and `\`
    in the filename are escaped to keep the link label balanced.
  - Parse: markdown-it's link token produces
    `<a href="pad-attachment:UUID">filename</a>`. Our parseHTML rule
    `a[href^="pad-attachment:"]` runs at priority 1000 to beat
    SafeLink's default mark rule (priority 50), so attachment refs
    become a chip Node instead of a Link Mark on plain text.

Editor display (NodeView):
  - <a class="file-chip"> with icon + name + optional size span
  - Icon: filename-extension heuristic on first paint, upgraded to a
    MIME-based icon once a single HEAD request resolves the canonical
    Content-Type. The HEAD goes against the existing GET handler — no
    new API endpoint required, and Go's net/http strips the body
    automatically for HEAD.
  - Size: rendered from Content-Length once HEAD resolves; hidden
    until then (CSS `:empty { display: none }`).
  - Module-level Promise cache keyed by `${ws}:${uuid}` deduplicates
    repeated chips for the same attachment and survives undo/redo
    without re-fetching.
  - target=_blank + download attribute so a click opens / saves the
    file with its canonical filename.
  - atom: true → Backspace/Delete remove the chip as a single unit.

Styles in app.css (not Editor.svelte) so they reach the read-only
markdown render path too — TASK-874's Go and TS resolvers emit the
same `.file-chip` / `.file-chip-icon` / `.file-chip-name` /
`.file-chip-size` markup.

Parent: PLAN-866. Unblocks TASK-875 — the upload plugin needs a chip
node to insert on successful non-image uploads.

* fix(attachments): chip HEAD route + chip click handler per Codex review (round 1)

Two findings from the round-1 Codex review:

1. chi router does not auto-route HEAD to GET handlers, so the chip's
   metadata HEAD probe was returning 405 and chip size + MIME-refined
   icons never loaded. Fix: register HEAD on the same path/handler;
   http.ServeContent already strips the body for HEAD on the seekable
   path, and the streaming fallback short-circuits before io.Copy so
   future S3-style backends don't burn GetObject bandwidth on HEAD.

   Tests added: HEAD returns 200 with Content-Type + Content-Length
   and an empty body; HEAD cross-workspace returns 404 (not 403) so
   the new endpoint can't become a side-channel for ID enumeration.

2. Editor.svelte installs a global anchor-click suppressor that
   preventDefaults every <a> inside the editor, so the chip looked
   clickable but did nothing in edit mode. Fix: the chip's NodeView
   now attaches an explicit click handler that calls window.open with
   the download URL and stops propagation before the global handler
   runs. Mirrors the AttachmentImage lightbox click pattern.
2026-04-29 13:35:15 -04:00
xarmian f1ce9ca24a feat(attachments): editor inline image node (TASK-876) (#292)
Custom Tiptap node for inline `pad-attachment:UUID` image references.
Stores the attachment UUID (not a backend URL) so item content survives
a storage-backend migration untouched. See DOC-865.

Node shape:
  - uuid: string  — the attachments-row UUID (required)
  - alt:  string  — preserved across save/reload for accessibility

Markdown round-trip:
  - Serialize:  ![alt](pad-attachment:UUID)  via tiptap-markdown's
    addStorage.markdown.serialize, with [/] in the alt text escaped so
    brackets stay balanced.
  - Parse: markdown-it's default image token already produces
    <img src="pad-attachment:UUID" alt="…">, captured by parseHTML
    rule img[src^="pad-attachment:"]. The alternate parseHTML rule
    img[data-attachment-id] catches editor-rendered HTML on copy/paste.

Editor display:
  - addNodeView renders <img class="attachment-image" loading="lazy">
    pointing at /api/v1/workspaces/{ws}/attachments/{id}?variant=thumb-md
    via an injected getDownloadUrl callback (Editor.svelte resolves the
    workspace slug from page.params at mount time, falling back to the
    workspace store).
  - Single-click opens a native <dialog> lightbox with the original-
    resolution variant; multi-click events fall through so users can
    drag-select around the image.
  - atom: true means Backspace/Delete remove the image as a single
    unit and the cursor never lands inside the node.

Lightbox styles live in app.css because the <dialog> is appended to
document.body, outside Editor.svelte's scoped style block.

The configure() default returns the literal `pad-attachment:UUID`
href — sufficient for markdown round-trip in headless / SSR contexts
and a clearly-broken render in any environment that hasn't wired the
URL builder, which is the right signal to fix.

Parent: PLAN-866. Unblocks TASK-875 (the upload plugin needs a node
to insert on success).
2026-04-29 13:22:13 -04:00
xarmian 5af54ddc05 feat(attachments): markdown reference resolver for pad-attachment:UUID (TASK-874) (#291)
* feat(attachments): markdown reference resolver for pad-attachment:UUID (TASK-874)

Add the shared step that translates `pad-attachment:UUID` markdown
references into rendered HTML for image embeds, file chips, and missing
placeholders. Wired into the editor preview path; Go-side helpers seed
the future server-side rendering pipeline (export / shared item view).

TS side (`web/src/lib/markdown/attachments.ts`):
  - Pure helpers: parseAttachmentHref, attachmentDownloadUrl, isImageMime,
    formatAttachmentSize, renderAttachmentImage/Chip/Missing
  - resolveAttachmentImage / resolveAttachmentLink for the marked hooks
  - Image MIME → <img src=...?variant=thumb-md data-attachment-id=...>
  - Non-image MIME (or link syntax) → file chip with download attribute
  - Missing/deleted → "Missing attachment" placeholder span

`web/src/lib/utils/markdown.ts`:
  - renderer.image override (defaulting to marked's standard image when
    href is not pad-attachment:)
  - renderer.link checks for pad-attachment: prefix before the existing
    external/internal-link logic
  - renderMarkdown gains an optional attachmentResolver parameter; the
    resolver is threaded via a per-call module slot (synchronous render)
  - DOMPurify allowlist extended with data-attachment-id, download,
    width, height — ALLOW_DATA_ATTR stays false so only this single
    data-* attribute slips through

Go side (`internal/server/render/attachments.go`):
  - Mirror of the TS API so server-rendered output matches client output
    byte-for-byte for the same input
  - ResolveAttachmentReferences scans markdown source via regex,
    skipping fenced code blocks (backtick + tilde), substitutes both
    image and link forms
  - Comprehensive table-driven tests (24 cases) covering: href parsing,
    URL building, MIME detection, size formatting, image/chip/missing
    rendering, escape safety against script-tag injection in alt /
    filename / display text, fenced-code skip, tilde fences, title
    suffix on link destinations, nil resolver pass-through, no false
    positives on non-attachment URLs, deterministic round-trip

References are stored as opaque `pad-attachment:UUID` so a backend
migration (FS → S3) can rewrite storage_keys without touching item
content. See DOC-865 for the architecture.

Parent: PLAN-866 (Attachments Phase 1).

* fix(attachments): chip label double-escape + escaped-bracket lockstep per Codex review (round 1)

Two findings from the round-1 Codex review:

1. TS chip labels were double-escaped. renderer.link was passing the
   parseInline(tokens) HTML output to resolveAttachmentLink, which feeds
   it into renderAttachmentChip → escapeHtml. A label like
   `[**Report**](pad-attachment:id)` rendered literal
   `&lt;strong&gt;Report&lt;/strong&gt;` instead of plain text. Switched
   to the link token's raw `text` field; markdown emphasis inside chip
   labels now degrades to literal markers (acceptable for filename-style
   labels) and matches what the Go regex extracts.

2. Go regex didn't accept CommonMark `\]` / `\\` escapes inside link/image
   labels, so `[Q1 \] report](pad-attachment:id)` resolved on the TS side
   (marked handles escapes) but stayed literal on the Go side — breaking
   the documented lock-step contract. Updated the regex to accept escaped
   characters inside the alt/text capture, and added unescapeMarkdownText
   to mirror marked's behavior of dropping the backslash before the label
   reaches the render helpers.

Tests added: TestResolveAttachmentReferences_EscapedBrackets covers
image alt, link text, and combined backslash/bracket escapes;
TestUnescapeMarkdownText is the unit-level table for the unescape
helper (including dangling-backslash and non-punctuation pass-through).
2026-04-29 13:09:42 -04:00
xarmian fc1c47f124 feat(attachments): CLI + TypeScript clients + types (TASK-873) (#290)
* feat(attachments): CLI + TypeScript clients + types (TASK-873)

Rounds out the API surface with Go and TS client methods + a
\`pad attachment\` Cobra subcommand for ops debugging.

internal/cli/client.go
  AttachmentUploadResult struct mirrors POST /attachments JSON.
  UploadAttachment streams a multipart file part via io.Pipe — never
  buffers the upload in memory. itemRef is optional. Uses a fresh
  http.Client with a 5-minute timeout per request so a 25 MiB upload
  over a constrained link doesn't trip the package-shared 10s default.
  DownloadAttachment streams the bytes into the caller's writer,
  returning Content-Type + total bytes copied. Optional ?variant=
  parameter for thumbnails (server falls back to original silently
  per TASK-872).

cmd/pad/main.go
  pad attachment upload <item-ref|-> <path> [--filename NAME]
  pad attachment download <id> <out|-> [--variant thumb-sm|thumb-md]

  Item arg accepts an issue ref (TASK-5) or slug; "-" means no parent.
  Out arg "-" streams to stdout (with status messages on stderr) so
  callers can pipe into image viewers etc. Resolves the item via
  GetItem first so a typo'd ref fails fast with a useful error.

  List + delete subcommands intentionally omitted — those endpoints
  ship with TASK-881 (storage usage) and the future GC task. Adding
  client methods that hit 404s would mislead callers; same logic kept
  the upload response's "url" out of TASK-871 until TASK-872 wired GET.

web/src/lib/types/index.ts
  Attachment interface mirroring the Go model (pointer types → optional).
  AttachmentUploadResult interface for the upload response shape.

web/src/lib/api/client.ts
  api.attachments.upload(workspaceSlug, file, itemId?) — multipart
  POST via direct fetch (skips shared request() because that helper
  hard-codes Content-Type: application/json). Carries CSRF, cookies,
  and the same 401 → /login redirect.
  api.attachments.downloadUrl(workspaceSlug, attachmentId, variant?)
  is a pure URL builder so callers can wire <img src> directly without
  going through fetch.

End-to-end smoke verified:
  pad attachment upload TASK-869 /tmp/tiny.png   # uploads PNG
  pad attachment download <id> /tmp/dl.png       # bytes are identical
  cmp /tmp/tiny.png /tmp/dl.png                  # PASS

Verification
  go build ./... — clean
  go vet ./... — clean
  go test ./... — all packages pass
  cd web && npm run build — clean
  make install — server restarts on the new binary

Parent: PLAN-866.

* fix(cli): atomic download — write to temp + rename so a failed download doesn't truncate the destination per Codex review (round 1)

P2: pad attachment download <bad-id> /existing/file used to wipe the
existing file on auth/network/404 errors because os.Create truncated
before the request was even attempted. The bytes were never written
because DownloadAttachment errored out, but the destination was
already 0 bytes — a footgun for anyone running the CLI in scripts.

Fix: for the file-path case, write to a sibling .tmp via os.CreateTemp
in the destination directory, fsync, close, then os.Rename only on
success. Same atomic-write pattern as FSStore.Put. The defer cleans
up the .tmp on any error path.

The stdout case (outPath == "-") is unchanged — bytes already
streamed to stdout can't be rolled back, so any partial write is
just visible to the caller as a short payload.

Verified end-to-end:
  echo X > /tmp/existing.png
  pad attachment download not-a-real-id /tmp/existing.png  # errors
  cat /tmp/existing.png   # still "X" — file untouched

* docs(cli): clarify os.Rename atomic-replace behavior on Windows (Codex round 2 disagreement)

Round 2 flagged this as P2: "os.Rename does not replace an existing
destination on Windows." That is technically incorrect for modern Go.

Verified directly against the Go stdlib source:

  src/internal/syscall/windows/syscall_windows.go:
    func Rename(oldpath, newpath string) error {
      ...
      return MoveFileEx(from, to, MOVEFILE_REPLACE_EXISTING)
    }

MoveFileEx with MOVEFILE_REPLACE_EXISTING atomically replaces an
existing destination on Windows. This has been the behavior since
Go 1.5 (2015), so every version of Go this codebase supports already
gets the desired replace-on-rename semantics on every platform.

Added an inline code comment so future readers don't worry about the
same false alarm. No code-path change.
2026-04-29 12:48:57 -04:00
xarmian 00baf75576 feat(attachments): download/serve API with auth + Range support (TASK-872) (#289)
Adds the GET endpoint that pairs with TASK-871's upload. Streams the
blob from the resolved storage backend with proper headers, Range
support, and cross-workspace defense.

GET /api/v1/workspaces/{slug}/attachments/{attachmentID}
  Optional ?variant=thumb-sm|thumb-md
  - 200 inline render for images / video / audio / PDF / etc.
  - 200 attachment download for HTML / JS / forced-download MIMEs
  - 206 Partial Content on Range requests (video/audio seek)
  - 304 Not Modified on conditional GETs (If-Modified-Since etc.)
  - 400 unknown variant
  - 404 missing attachment OR cross-workspace probe (not 403, to avoid
    leaking existence of attachments in other workspaces)
  - 404 blob_missing if DB row exists but on-disk blob is gone (logs a
    warning since this is a "shouldn't happen" state)
  - 503 if attachments registry not configured

internal/server/handlers_attachments.go
  handleGetAttachment looks up the row, gates cross-workspace via 404,
  optionally swaps to a derived variant via GetAttachmentVariant
  (silent fallback to original when the variant row doesn't exist
  yet — TASK-878 will populate them; this handler shipping today
  doesn't have to wait), resolves the storage backend via Registry,
  and hands off to http.ServeContent when the body satisfies
  io.ReadSeeker. FSStore returns *os.File so that's the common path
  and gets us Range / 206 / conditional GETs for free. Backends
  without Seek (a future S3 streaming reader) fall through to a
  plain io.Copy with no Range support — the contract is "Range works
  when the backend supports it, never breaks correctness".

  Headers:
    Content-Type from att.MimeType (already canonical post-allowlist)
    Content-Disposition: inline | attachment, filename sanitized to
      strip quotes/backslashes/control bytes (header-injection defense
      on top of the upload-time basenaming)
    Cache-Control: private, max-age=3600 (Phase 3 revisits for CDN)
    X-Content-Type-Options: nosniff (browser should never re-sniff;
      we already validated MIME at upload)

  Upload response now includes "url" again — TASK-871 had dropped it
  because the GET handler didn't exist yet. Slug-form path matches
  every other API endpoint.

internal/store/attachments.go
  GetAttachmentVariant(parentID, variant) for the ?variant lookup.

internal/server/server.go
  GET /workspaces/{slug}/attachments/{attachmentID} wired alongside
  the existing POST.

Tests
  Happy-path PNG, HTML force-download, 404 missing, cross-workspace
  404 (NOT 403), Range 206 with bytes 10-29 of an MP4 payload,
  variant fallback to original, unknown variant rejected, derived
  thumb-sm row honored when present, blob-missing 404, and the
  filename sanitizer table.

Verification
  go build ./... — clean
  go vet ./... — clean
  go test ./... — all packages pass
  make install — server restarts on the new binary

Parent: PLAN-866.
2026-04-29 12:34:36 -04:00
xarmian 48b9e18d34 feat(attachments): upload API with MIME sniff + dedupe + quota tracking (TASK-871) (#288)
* feat(attachments): upload API with MIME sniff + dedupe + quota tracking (TASK-871)

Wires the upload endpoint that turns a multipart POST into an
attachments row plus a stored blob. Auth-gated (editor+), per-file
size cap, hash-streaming, MIME allowlist with extension blocklist,
fire-and-forget quota warning.

POST /api/v1/workspaces/{slug}/attachments
  Multipart "file" field. Optional ?item_id=… or form item_id to
  associate at upload time. Returns
    {id, url, mime, size, width?, height?, filename, category, render_mode}.
  Errors: 400 bad multipart, 400 empty file, 401 unauthorized, 403
  insufficient role, 413 over per-file cap, 415 MIME or extension
  rejection, 503 attachments not configured.

internal/attachments/mime.go
  MIMEEntry + RenderMode + Category typed allowlist mirroring DOC-865.
  Default-deny. SniffMIME wraps http.DetectContentType. ValidateUpload
  cross-checks the sniff result against the filename extension and:
  (a) rejects when the extension maps to a *blocked* MIME — covers
      .svg (sniffs as text/xml; .svg ext makes the browser run embedded
      <script>) and .exe family (sniffs vary; extension is unambiguous);
  (b) rejects when the extension maps to an allowed MIME but the
      sniff's category disagrees — the "exe pretending to be png" case.
  Tests cover normalize/lookup/sniff plus happy path, exe-as-png,
  extension mismatch, SVG, .exe-by-extension-alone, text/plain accept,
  HTML force-download.

internal/store/attachments.go
  CreateAttachment / GetAttachment / WorkspaceStorageUsage. Pointer
  scan for nullables; SUM(size_bytes) excludes soft-deleted rows but
  includes derived blobs (thumbnails are real bytes on disk).

internal/server/handlers_attachments.go
  Body capped via http.MaxBytesReader BEFORE ParseMultipartForm spools
  any of it. Streams "file" part into an os.CreateTemp file, sha256ing
  in one io.MultiWriter pass — multi-GB POST never reaches RAM. Sniff
  on first 512 bytes; image dimension probe via stdlib image.DecodeConfig
  (PNG/JPEG/GIF). WebP/AVIF/HEIC accepted but width/height nil — matches
  the "pure-Go gracefully degrades" decision in DOC-865. Calls
  AttachmentStore.Put (which hash-verifies via the dedup fast path) and
  inserts the row. Quota check (CheckLimit + WorkspaceStorageUsage) runs
  in a goroutine — Phase 1 logs only; Phase 2 will enforce.
  Anonymous uploads on a fresh install (RequireWorkspaceAccess grants
  implicit owner without a current user) get uploaded_by="system".

internal/server/server.go
  Server.attachments + attachmentMaxBytes fields and SetAttachments
  setter. Route POST /workspaces/{slug}/attachments wired inside the
  authenticated workspace block.

cmd/pad/main.go
  Boot wiring: NewFSStore(<DataDir>/attachments) → Registry registered
  under "fs" → SetAttachments. PAD_ATTACHMENT_MAX_BYTES env override
  for the per-file cap.

Tests
  internal/server/handlers_attachments_test.go covers:
    happy path PNG (1x1, dimensions resolve to 1×1)
    exe bytes with .png filename → 415
    PNG bytes with .pdf filename → 415 (extension mismatch)
    empty body → 400
    missing file part → 400
    over the size cap → 413
    same content uploaded twice → two rows, same content_hash + storage_key,
      WorkspaceStorageUsage = 2 × bytes (dedupe is at the blob layer,
      not the row layer)
    8 concurrent uploads of identical bytes → all 201, no corruption
    no registry wired → 503

Verification
  go build ./... — clean
  go vet ./... — clean
  go test ./... — all packages pass
  make install — server restarts on the new binary

Parent: PLAN-866.

* fix(attachments): three Codex round-1 findings — drop premature url, accept Office docs, real quota probe

1. Upload response no longer returns "url". TASK-872 wires GET so any
   URL we return today is a 404 — pulling it out keeps clients from
   baking in the broken endpoint.

2. Office Open XML docs (.docx/.xlsx/.pptx) and OpenDocument formats
   (.odt/.ods/.odp) are zipped XML — http.DetectContentType correctly
   sniffs them as application/zip. Previously the validator's
   extension-vs-sniff category check rejected them as
   "mime_extension_mismatch" (archive vs document). Now: when the
   sniffed type is exactly application/zip and the extension maps to
   a document MIME, trust the extension and route to the document
   entry. Plain .zip with the same bytes still routes to archive.
   Test covers all six office/odf extensions plus the plain-zip case.

3. CheckLimit("storage_bytes") returned "unknown workspace feature"
   because featureCount only knows row-counted features (items,
   members, webhooks). The warning path silently dropped every probe.
   Added Store.WorkspaceStorageLimit which does the same three-tier
   resolution (user override → platform setting → hardcoded fallback)
   but returns the limit only — usage is computed separately via the
   existing WorkspaceStorageUsage. Self-hosted/pro plans return -1
   (unlimited). Workspaces without an owner_id (fresh installs and
   legacy rows) also return -1, so a fresh-install upload no longer
   logs "owner not found". Switched maybeWarnStorageQuota to use
   WorkspaceStorageLimit + WorkspaceStorageUsage directly. Now also
   spawned via Server.goAsync so Stop() drains it (BUG-842 hygiene).

Tests
  - TestValidateUpload_AcceptsOfficeOpenXMLAsZipBytes covers all six
    extensions + plain .zip
  - TestUpload_QuotaCheckResolves regression-tests finding 3: both
    storage helpers return non-error after a real upload
  - TestUpload_HappyPathPNG asserts the response no longer carries url

Verification
  go build ./... — clean
  go vet ./... — clean
  go test ./... — all packages pass

* fix(attachments): trim trailing blank line at EOF in mime.go per Codex review (round 2)

Round 2 LOW: git diff --check flagged a "new blank line at EOF" on
internal/attachments/mime.go. Cosmetic but addressed because the
ship-tasks workflow requires zero findings (HIGH/MEDIUM/LOW alike) —
leaving LOWs unfixed compounds across PRs and prevents the loop from
ever converging clean on later work.

* fix(attachments): alias stdlib MIME-sniff quirks per Codex review (round 3)

http.DetectContentType returns names that don't match modern IANA
conventions for two formats on the allowlist:

  audio/wave        → audio/wav        (.wav uploads)
  application/x-gzip → application/gzip (.gz uploads)

Without aliasing, valid uploads of either format hit "mime_not_allowed"
because the allowlist uses canonical names. Added a sniffAliases map
applied inside SniffMIME so allowlist lookups always see the canonical
form. Allowlist stays single-sourced; the fix is one map entry per
quirk we discover.

Tests:
- TestSniffMIME_AliasesStdlibQuirks pins both aliases at the sniff layer
- TestValidateUpload_AcceptsWAV / TestValidateUpload_AcceptsGzip verify
  the end-to-end accept path with real WAV (RIFF/WAVE) and gzip headers
2026-04-29 12:19:06 -04:00
xarmian de4d28d576 feat(attachments): AttachmentStore interface + FSStore (TASK-870) (#287)
* feat(attachments): AttachmentStore interface + FSStore (TASK-870)

Introduces the storage backend abstraction described in DOC-865 and
ships its first concrete implementation. No call sites yet —
TASK-871 (upload API) wires it in.

internal/attachments/store.go
  AttachmentStore interface (Put/Get/Stat/Delete) and ErrNotFound
  sentinel. Put is documented as idempotent — concurrent Puts of the
  same hash converge — and required to verify that the streamed bytes
  actually hash to the supplied value.

internal/attachments/registry.go
  Registry routes "<prefix>:<rest>" keys to the store registered for
  that prefix (Phase 1 = "fs"; Phase 2 will register "s3" alongside).
  Convenience Get/Stat/Delete helpers resolve + forward in one call so
  callers don't have to spell out the two-step pattern everywhere.
  Register panics if the prefix contains ':' since that would make the
  store unreachable.

internal/attachments/fs_store.go
  FSStore writes to <baseDir>/<aa>/<bb>/<full-hash> with the first 4
  hex chars sharding the directory tree two levels deep. Atomic writes:
  stream + hash to a randomized .tmp in the destination dir, fsync,
  then intra-directory rename. The streaming sha256 is verified against
  the supplied hash before the rename, so a mismatch never leaves a
  visible file. Idempotent fast path: if the canonical file already
  exists Put short-circuits (and drains the reader so callers don't get
  a stuck stream). Get returns wrapped ErrNotFound on missing keys;
  Delete on a missing key is a no-op (matches what the orphan GC needs).

Tests cover put/get/stat/delete, hash mismatch, invalid hash format,
idempotency, 16-goroutine concurrent Put of the same hash converging
to one on-disk file with no orphan tmp files, registry routing,
forward-error semantics, and the prefix-with-colon panic.

Parent: PLAN-866.

* fix(attachments): validate hash on every FSStore key + verify on fast path per Codex review (round 1)

Round 1 raised two issues — both real, both fixed.

1. Path traversal in Get/Stat/Delete. extractHash only checked that the
   key began with "fs:" and the suffix was non-empty before passing it
   to pathFor(), which used the suffix as a path component. A key like
   "fs:../../etc/passwd" would escape baseDir for reads/stats/deletes.
   Fix: extractHash now requires the canonical 64-char lowercase-hex
   sha256 form via validHash. Same gate that Put already used; now it
   covers every public method.

2. Idempotent Put fast path skipped hash verification. If the canonical
   target file already existed, Put returned the key without checking
   that the supplied reader's bytes hashed to the supplied hash —
   violating the AttachmentStore.Put contract that implementations MUST
   verify on every call. A buggy upload path could associate the wrong
   bytes with an existing hash and silently succeed. Fix: stream r
   through a hasher when the target exists (no disk I/O), compare
   against the supplied hash, and reject on mismatch.

Also dropped the dead "_short" branch in pathFor — every caller now
goes through validHash.

Tests added:
- TestFSStore_GetStatDeleteRejectBadKeys covers empty/wrong-prefix/empty-
  hash/non-hex/wrong-length/path-traversal/path-separator/uppercase keys
  across all three read methods.
- TestFSStore_PutFastPathStillVerifiesHash confirms the contract holds
  on the fast path: a second Put that lies about the hash is rejected
  with no corruption of the existing file.
2026-04-29 11:44:58 -04:00
xarmian 6461aafd16 feat(store): attachments table + Attachment model (TASK-869) (#286)
Adds the schema groundwork for inline images and file uploads — see
DOC-865 (Attachments — architecture & migration design).

- migrations/047_attachments.sql — SQLite migration. Table + 4 indexes
  (workspace, item, hash, parent). Partial indexes on workspace/item/parent
  match the items table convention. The hash index is full (not partial)
  so dedupe can resurrect a soft-deleted blob if the same bytes are
  re-uploaded without writing a duplicate.
- pgmigrations/026_attachments.sql — Postgres mirror with BIGINT for
  size_bytes; same partial-index pattern.
- internal/models/attachment.go — Go model with all columns. Uses
  pointer types for nullable columns (item_id, width, height, parent_id,
  variant, deleted_at) so JSON omitempty works correctly.

No call sites yet — purely schema groundwork. Verified the migration
runs cleanly on a fresh install and on the live dev DB.

Parent: PLAN-866.
2026-04-29 11:34:35 -04:00
xarmian 2f58193f22 chore(web/connect-modal): point footer + install links at getpad.dev/docs (#285)
Last piece of PLAN-859. The ConnectWorkspaceModal's three footer/install
links were placeholders pointing at GitHub README anchors while
TASK-863's docs page didn't exist yet. That page is now live at
getpad.dev/docs/connect-workspace (pad-web#30 / e472586), so swap the
three URLs to the real docs:

- "Other install options →" → https://getpad.dev/docs#installation
  (broader install matrix: Homebrew + Binary + Docker + Source)
- "Documentation"          → https://getpad.dev/docs/connect-workspace
- "Troubleshooting"        → https://getpad.dev/docs/connect-workspace#troubleshooting

Updated the in-source comment to reflect that the URLs are now the
canonical ones, not placeholders.

This closes out PLAN-859 (web-first onboarding on-ramp): a user who
creates a workspace in the web UI now has a complete in-app + docs
path to connecting that workspace to their local project.
2026-04-29 10:26:15 -04:00
xarmian e5eae5e94e feat(web): persistent dismissible CLI-connect banner on workspace pages (TASK-862) (#284)
* feat(web): persistent dismissible CLI-connect banner on workspace pages (TASK-862)

Final web piece of the web-first onboarding on-ramp from PLAN-859 / IDEA-750.
A slim banner now nudges users to connect their workspace to the CLI on
every workspace page, until they either dismiss it or actually do it.

Server:

- New store method WorkspaceHasCLISource(workspaceID) — backed by
  EXISTS(... WHERE source='cli' AND deleted_at IS NULL), so it's a
  cheap O(1) check that short-circuits on the first match.
- Dashboard payload (GET /workspaces/{ws}/dashboard) gains
  HasCLISource bool (json: has_cli_source).
- Unit tests cover empty workspace, web/skill items don't trip it,
  one cli item flips it on, soft-delete flips it back off, and
  cross-workspace isolation.

Web:

- New <ConnectBanner> Svelte 5 component
  (web/src/lib/components/ConnectBanner.svelte). Self-contained:
  reads dismissed state from localStorage, fetches has_cli_source
  itself, mounts <ConnectWorkspaceModal> internally. Two split
  $effect blocks per CONVE-606 — one for the localStorage sync, one
  for the dashboard fetch — so a workspace change doesn't entangle
  the two reactive lifecycles.
- Banner is hidden while loading (hasCliSource === null) to avoid a
  flash-then-auto-hide on workspaces that already have CLI items.
- Storage key pad-cli-banner-dismissed-${wsSlug} matches the existing
  onboarding-dismissed pattern. Per-browser only; TODO comment in
  source about backing it with a workspace_user_state row if cross-
  device persistence is wanted later.
- Mounted in web/src/routes/[username]/[workspace]/+layout.svelte
  above {@render children()} so it appears on every workspace page
  (dashboard, collection lists, item detail, search, activity, etc.)
  and NOT on console/auth pages (the layout is workspace-scoped).
- DashboardResponse type in web/src/lib/types/index.ts gains
  has_cli_source: boolean.

Smoke-tested against the running server: the field is live in the
dashboard payload and reflects reality (this workspace returns
has_cli_source: true since it has many CLI-sourced items, so the
banner is correctly auto-hidden here).

Test plan:
- go build ./... && go test ./... — all green (incl. new
  TestWorkspaceHasCLISource with 5 sub-cases).
- cd web && npm run build — clean.
- make install — clean, server restarted.
- Svelte MCP autofixer ran on ConnectBanner.svelte — no issues.

Parent: PLAN-859. Driving idea: IDEA-750.

* fix(web/connect-banner): stale-response guard + refetch on modal close (Codex round 1)

Two findings from Codex review on PR #284:

1. Stale-response race: rapid workspace switches could let a slow
   dashboard fetch from workspace A overwrite hasCliSource for
   workspace B after the user navigated. Capture the requested slug
   at fetch time, ignore the response if wsSlug has changed since.

2. Auto-hide didn't work in-session: if a user opened the banner
   modal, copied the command, ran it elsewhere, and closed the modal,
   the banner stayed visible because hasCliSource was stale. Refetch
   when the modal transitions from open → closed (the natural moment
   the user has just connected). Uses $effect.pre with a tracked
   previous value, matching the transition pattern in ShareDialog.

The 'someone ran the CLI from another terminal without ever opening
the modal' edge case is left for a follow-up — would require SSE
item-created subscription, which is heavier than this PR's scope.

* fix(server/items): persist source from auth context on create (Codex round 2)

Codex caught an architectural bug while reviewing the TASK-862 banner
work: items created via the CLI were persisting with source='web'
(the column default) instead of 'cli', because handleCreateItem decoded
ItemCreate from the body — which the CLI doesn't set Source on — and
only consulted actorFromRequest AFTER persisting (for SSE / activity
log emission). Result: TASK-862's has_cli_source dashboard signal
would never flip on for normal CLI usage, so the connect-CLI banner
would never auto-hide for users who actually wired up the CLI.

Fix: in handleCreateItem, backfill input.Source from actorFromRequest
before calling store.CreateItem, but only when the client didn't
explicitly set it (so agents marking themselves as 'skill' still
pass through unchanged).

Test: TestCreateItemSourcePersistedFromAuth covers all three branches
- bearer Authorization header → source=cli (uses bootstrap + a real
  session token in the header since the auth middleware validates
  token format and rejects fake values with 401 before the handler
  runs)
- cookie session, no Authorization → source=web
- explicit source in body wins over auth-derived (e.g. 'skill')

* fix(web/connect-banner): seq counter for same-workspace race (Codex round 3)

Round 3 caught a same-workspace race the slug guard didn't cover: an
in-flight workspace-change fetch that resolves AFTER the modal-close
refetch could overwrite the newer 'true' with the older 'false',
making the banner reappear after the user actually wired up the CLI.

Add a monotonic fetchSeq counter — captured at call time, rechecked
before applying the response. Only the LATEST request's result wins,
regardless of arrival order. The slug guard stays as a second-layer
defense for cross-workspace races.

* fix(web/connect-banner): guard banner keydown to currentTarget (Codex round 4)

Round 4 caught a keyboard-event bubble: pressing Enter or Space on
the dismiss X button also fired the banner-level keydown handler,
so the user would dismiss AND open the modal in one stroke.

Guard the parent handler with `e.target !== e.currentTarget` so it
only reacts to keydown that originated on the banner itself. Tabbing
to the dismiss button + Enter now ONLY dismisses.

* fix(store): visibility-filter has_cli_source query (Codex round 5)

Round 5 caught a P2 information leak: WorkspaceHasCLISource scanned
the entire workspace regardless of caller visibility, so a guest
with grants only on web-sourced items could still see has_cli_source
return true (revealing that CLI items exist somewhere they can't see).
That also produced wrong UX — the banner could auto-hide for guests
who couldn't actually use the CLI.

Extend the query to take optional collectionIDs/itemIDs filters
matching the dashboard's existing visibility model: an item counts
when its collection is in collectionIDs OR its id is in itemIDs
(union — guest item-level grants can expose items in otherwise-
hidden collections). Mirrors ListItems' filtering pattern incl. the
"non-nil empty CollectionIDs = no visibility = short-circuit false"
semantics.

Handler now passes dashCollIDs and dashItemIDs to match the rest of
the dashboard payload's filtering. New TestWorkspaceHasCLISourceVisibility
covers the four cases: unfiltered sees all, visible-coll-only hides
CLI items in hidden collections, item-level grant surfaces a hidden
CLI item, and empty visibility short-circuits to false.
2026-04-29 10:05:34 -04:00
xarmian a28767d323 feat(web): ConnectWorkspaceModal + empty-workspace and avatar surfaces (TASK-861) (#283)
* feat(web): ConnectWorkspaceModal + empty-workspace and avatar surfaces (TASK-861)

Web side of the web-first onboarding on-ramp from PLAN-859 / IDEA-750.
Gives a user who created a workspace via the web UI a one-line copy-paste
to connect that workspace to their local repo, exposed in the two
zero-state surfaces where they'd look for it.

Changes:

- New `<ConnectWorkspaceModal>` Svelte 5 component
  (web/src/lib/components/ConnectWorkspaceModal.svelte). Reusable, no
  host-page coupling. Matches ShareDialog's modal pattern (overlay +
  centered modal, native, open = $bindable(), Escape closes). Props:
  serverUrl, workspaceSlug, workspaceName?. Renders Step 1 (OS-tabbed
  install — macOS/Linux/Windows/Docker, default tab from detected
  platform) and Step 2 (pad init --url ... --workspace ... snippet
  with a copy button on the full snippet). Footer links to docs +
  troubleshooting.
- New web/src/lib/utils/platform.ts — tiny dependency-free OS detection
  helper. SSR-safe (defaults to "macos" with no navigator).
- Mounted in the workspace landing page as a "Connect your local
  project" card directly under <OnboardingChecklist> in the empty-
  workspace .onboarding-wrapper. Modal itself is mounted unconditionally
  at the page root so it survives re-renders of the conditional empty
  state.
- Mounted in TopBar.svelte's user menu (both desktop and mobile
  branches): "Connect a project..." entry between Theme/Cloud-support
  links and the Sign-out divider. Modal lives outside the dropdown so
  it doesn't unmount when the dropdown closes. Both gated on
  workspaceStore.current?.slug since the modal needs a workspace to
  interpolate.

Docs URLs in the modal footer (getpad.dev/docs/install,
getpad.dev/docs/connect-local-project) are placeholders; TASK-863 in
PLAN-859 will publish those pages and we'll wire the final URLs then.

Test plan:
- go build ./... && go test ./... clean
- cd web && npm run build clean
- make install clean, server restarted
- Svelte MCP autofixer ran on all four touched files — no findings

Parent: PLAN-859. Driving idea: IDEA-750.

* fix(web/connect-modal): correct brew tap + point placeholder docs links to README (Codex round 1)

Two findings from Codex review on PR #283:

1. macOS install command was `brew install xarmian/pad/pad`, but the
   actual tap is `PerpetualSoftware/tap/pad` (per README.md and
   skills/INSTALL.md). Users would have hit a failing install.

2. Footer links pointed at `getpad.dev/docs/install` and
   `getpad.dev/docs/connect-local-project` — pages TASK-863 will
   publish but don't exist yet. Until they do, point at the GitHub
   README's #installation and #getting-started anchors so clicks at
   least land somewhere useful instead of 404.

The TASK-863 follow-up will swap these back to the dedicated docs URLs
once the pages ship.

* fix(web/connect-modal): use real install commands from README (Codex round 2)

Round 2 caught that Linux/Windows/Docker commands were fabricated:
- Linux/Windows pointed at a getpad.dev/install.sh that doesn't exist
- Docker used wrong volume mount (/root/.pad vs the image's /data) and
  didn't publish ports

All four tabs now mirror the README's Installation section exactly:
- macOS + Linux: brew install PerpetualSoftware/tap/pad
- Windows: pointer to the GitHub releases page (no first-party one-liner)
- Docker: docker run -p 127.0.0.1:7777:7777 -v pad-data:/data ghcr.io/perpetualsoftware/pad
2026-04-29 09:20:29 -04:00
xarmian a03c96f9b0 feat(cli): pad init --url X --workspace <slug> as web-first cold-start (TASK-860) (#282)
Make `pad init --url <server> --workspace <slug>` a reliable
non-interactive cold-start so the web UI can hand users a single
copy-paste command to connect a workspace they created on the web to
their local project. Keystone CLI work for the web-first onboarding
on-ramp under PLAN-859 (driven by IDEA-750).

Changes:

- `ensureWorkspace` gains a `wsSlug` parameter. When set, it ONLY
  attaches by slug — looks up the workspace via GetWorkspace, links
  the CWD if found, and surfaces a clear "not found on <server>"
  error otherwise. Critically, it never silently falls through to
  creating a new workspace named after the slug.
- Refuses to clobber a CWD that's already linked to a different
  workspace; idempotent re-run when the existing link matches.
- `pad init --url X` on a fresh machine (no config.toml on disk) now
  persists the config so subsequent commands don't need --url.
- When both a positional name and --workspace are supplied, the slug
  wins and we print a Note: line so the override is visible.
- Same wiring applied to `pad workspace init` for consistency.

Tests: 5 new unit tests in cmd/pad/init_test.go cover slug-attach,
not-found error, clobber refusal, idempotent re-run, and that the
legacy name-driven path still works. Smoke-tested end-to-end against
the local server: happy path links, missing slug errors cleanly with
no `.pad.toml` written, clobber blocked, idempotent re-run silent.
2026-04-29 08:58:58 -04:00
xarmian 86a2f3c55b fix(web/editor): copy from table puts plain text only on clipboard (TASK-858) (#281)
* fix(web/editor): copy from table puts plain text only on clipboard (TASK-858)

ProseMirror's default copy serialization for selections inside a table
included the wrapping <table>...</table> in the text/html clipboard
payload. Pasting into rich-text apps (or anywhere that prefers HTML over
plain text) reproduced the table styling when the user just wanted the
cell text.

Add a tableCopyPlugin mirroring the existing codeBlockCopyPlugin
pattern: when the selection lives entirely inside a table, write a
plain-text representation to text/plain and clear text/html. Cut also
deletes the range, same as the code-block plugin.

Behavior:
- Text selection inside a single cell: cell text on text/plain.
- CellSelection (multi-cell drag): tab between cells, newline between
  rows. Pastes correctly into Excel/Sheets/Numbers.
- Selection that spans into/out of the table: falls through to default.

Trade-off (accepted): re-pasting a multi-cell copy into our own editor
yields TSV text, not a reconstructed table. Matches Linear/Notion/Slack.

Fixes BUG-855.

* fix(web/editor): preserve parent Table plugins + selection-aware cut per Codex review (round 1)

Two findings from Codex review of PR #281:

1. Table.extend's addProseMirrorPlugins was returning only [tableCopyPlugin],
   replacing the parent extension's plugins and silently dropping
   columnResizing (negating resizable: true) and tableEditing (cell
   selection / table editing). Now spreads ...(this.parent?.() ?? []) and
   appends tableCopyPlugin.

2. Cut path used tr.delete(from, to) which is unsafe for CellSelection —
   a contiguous document range can include unrelated cells (or row
   structure) between the rectangular cell-selection's endpoints. Switched
   to tr.deleteSelection(), which routes through prosemirror-tables'
   CellSelection.replace override and clears each selected cell's content.
   Still correct for the TextSelection-inside-one-cell case (deletes the
   text range as before).

The codeBlockCopyPlugin's tr.delete(from, to) is intentionally left alone —
that path validates the selection sits inside a single code_block, where
from/to is a flat text range and no structural risk exists.
2026-04-29 00:57:14 -04:00
xarmian cc4f1c16b6 feat(web): let users switch collection inside the Quick Add modal (TASK-857) (#280)
The Quick Add modal previously locked users into the collection they
launched it from. Replace the static `{icon} New {Singular}` header with
a clickable pill that opens a small popover listing every regular
collection in the workspace; selecting one swaps the target collection
without losing the typed title.

Behavior preserved:
- Default collection still comes from the launch entry point (sidebar
  `+`, dashboard buttons, Cmd-N).
- Picker excludes agent collections (conventions, playbooks) via the
  existing `regularCollections` filter.
- If only one regular collection exists, the pill renders as a non-
  interactive label (no caret, no popover).
- `submitQuickAdd` already re-derives default fields and content
  template from the current `quickAddCollection`, so swapping mid-flow
  Just Works.

Keyboard:
- Enter / Space / ArrowDown on the pill opens the picker.
- ArrowUp/Down/Home/End navigate; Enter selects; Esc closes the picker
  only (textarea Esc still closes the modal).

The outside-click handler is kept as its own `$effect` per CONVE-606
(don't combine reactive triggers in a single effect).

Implements IDEA-749.
2026-04-29 00:06:49 -04:00
xarmian eaae76f667 feat(auth): link to /console from CLI auth success state (TASK-856) (#279)
After approving a CLI session at /auth/cli/{code}, the success state
previously dead-ended with "you can close this tab" and no link out.
Adds a primary "Go to your workspaces" CTA linking to /console — the
same destination that / redirects to and that pad-cloud's OAuth flow
lands users on post-login. Universal across self-hosted, Docker, Remote,
and Pad Cloud (which proxies /auth/cli/ to the upstream pad backend
via nginx, no pad-cloud change needed).

The existing "you can close this tab" message stays — some users
(CI runs, headless approvals, teammate's laptop) genuinely just want
to close the tab.

Source: IDEA-848.
Parent: PLAN-833.
2026-04-28 23:33:01 -04:00
xarmian 2b752ba194 feat(release): sign + notarize macOS binaries (IDEA-830) (#278)
* feat(release): sign + notarize macOS binaries (IDEA-830)

Adds Developer ID code-signing and Apple notarization to the release
pipeline so users installing via `brew install perpetualsoftware/tap/pad`
or downloading binaries directly no longer hit Gatekeeper's "cannot
verify the developer" warning.

Uses GoReleaser v2's built-in `notarize:` block (Anchore/Quill backend),
which signs and notarizes in-process from the existing ubuntu-latest
runner — no rcodesign install, no macOS runner needed.

Both the .p12 cert and the .p8 App Store Connect notary key are stored
as base64-encoded repo secrets; Quill decodes them in-process. The
notarize block is gated on `MACOS_CERT_P12` being set, so snapshot
builds, fork PRs, and any context where the cert isn't available skip
cleanly without failing.

Verification plan: tag v0.0.1-rc.3, confirm Action goes green end-to-end,
then on a clean Mac run `brew install perpetualsoftware/tap/pad` and
verify `codesign -dv $(which pad)` shows the Developer ID signature and
`spctl -a -t exec -vv $(which pad)` reports "accepted" with the notary
ticket stapled.

* harden(release): isolate npm build from macOS secrets + pin goreleaser

Addresses Codex review findings on PR #278:

MEDIUM — Apple signing secrets were exposed to the npm web build.
The previous `before.hooks` block ran `npm ci && npm run build` inside
the GoReleaser process, which means npm lifecycle scripts and the
SvelteKit build inherited the Developer ID .p12 cert, cert password,
and .p8 notary key from the goreleaser-action's env. Adding a 5-year
signing cert to that environment meaningfully widened the blast radius
of any npm supply-chain compromise.

Fix: move the web build into a dedicated workflow step before the
goreleaser-action invocation. The MACOS_* secrets are scoped only to
the goreleaser env block, so the npm phase no longer sees them.

LOW — GoReleaser binary version was floated as `~> v2`, while every
third-party Action in this workflow is SHA-pinned per the policy at
the top of the file. With Apple signing credentials now in the env, a
compromised or regressed GoReleaser release would carry meaningful
blast radius. Pinned to v2.15.4 (current latest) so bumps go through
explicit review like the rest of the action pins.

No functional change to the signing/notarization itself — same schema,
same gating, same secrets.

* docs(release): document web/build prereq for local snapshot builds

Addresses second-pass Codex review finding on PR #278.

Removing the goreleaser `before.hooks` web build (done in 6f4a52c to
isolate npm from MACOS_* secrets) means `goreleaser release --snapshot`
from a clean checkout now fails with "embed: no matching files found"
because embed.go declares `//go:embed all:web/build` and there's no
longer a hook that auto-populates that directory.

CI is unaffected (the workflow has a dedicated `Build web UI` step), but
contributors testing goreleaser config locally would hit a confusing
error before reaching the part they wanted to validate.

Doc-only fix: extend the existing comment block at the top of
.goreleaser.yaml to point contributors at `make web` (or the explicit
npm commands) as the prerequisite for local snapshot builds. Note that
`make build` and `make install` already handle this automatically.

* harden(release): add concurrency guard + bump goreleaser timeout

Addresses third-pass Codex review findings on PR #278.

Both findings were LOW (no ship blockers) and pre-existing concerns
that the macOS notarize block makes slightly more visible. Folded into
this PR rather than deferring because both relate directly to the
goreleaser invocation we already touched.

LOW #1 — GoReleaser overall timeout was the default 1h, while the
notarize block now allows up to 20m of Apple notary wait time on top
of build + cosign blob-sign + SBOM + multi-arch docker. On a slow
notary day (or first-cert-use latency), that could come close to or
hit the default ceiling. Bumped `release --clean` to
`release --clean --timeout=2h` for comfortable headroom without
burning Action minutes on the happy path (worker exits as soon as
Apple replies).

LOW #2 — No workflow-level concurrency guard. If two `v*` tags landed
close together (rc.3 then rc.4 within a minute), runs would race on
shared mutable outputs: GHCR `:latest`, the homebrew cask in the
separate tap repo, the GitHub Releases page. Added a top-level
concurrency block that serializes all release runs.

Group is intentionally NOT keyed by `github.ref` — different tag names
share the same mutable infrastructure, so we want all release tags to
serialize, not just repeat pushes of the same tag. cancel-in-progress
is false so a queued tag never aborts a release mid-publish, which
could leave GHCR and the brew tap in inconsistent states.

No functional change to signing/notarization itself.
v0.0.1-rc.3
2026-04-28 20:24:38 -04:00
xarmian 53b5add4e9 fix(store): bump SQLite busy_timeout from 5s to 30s (BUG-853) (#277)
TestSQLiteConcurrentWritersNoBusy intermittently fails on the GitHub-
hosted Go (SQLite) CI job with `database is locked (5) (SQLITE_BUSY)`
under 25 concurrent writers × 5 ops. The test asserts ZERO errors so
that BUG-748's `_txlock=immediate` regression stays pinned — but with
the DSN's busy_timeout at 5s, the unluckiest writer on a slow shared
runner can exceed the timeout: 125 serialized inserts under heavy
contention from sibling test packages add up.

Bumping busy_timeout to 30s gives a 6× margin over the worst observed
CI run and ~50× the normal local p95. Genuine deadlocks don't happen
with WAL + BEGIN IMMEDIATE, so the only thing the higher value costs
is "how long we wait before declaring lock contention pathological".
For Pad's workload, 30s is fine.

Surfaced once BUG-851 (PR #276) cleared the rate-limiter goroutine
leak that had been masking everything else on main.

Verified locally:

    $ go test -count=20 -run TestSQLiteConcurrentWritersNoBusy \
        ./internal/store/
    ok  github.com/PerpetualSoftware/pad/internal/store  6.102s

Note: this is a single-character DSN change plus a doc-comment update;
no code path or contract is altered. Read concurrency (WAL) is
unchanged — we're not touching SetMaxOpenConns.
2026-04-28 17:59:21 -04:00
xarmian 715ec70e94 fix(server): drain ipRateLimiter cleanup goroutines on Stop() (BUG-851) (#276)
NewRateLimiters spawned 9 ipRateLimiter cleanup goroutines per Server,
each in an unbounded `for { time.Sleep(5*time.Minute); ... }` loop with
no exit signal (middleware_ratelimit.go:78-89). Every testServer(t)
call leaked all 9, accumulating across the 210-test internal/server
suite. Under -race the goroutine count + sync overhead pushed the run
past the default 10m timeout, which is why the `Run tests with race
detector` step (gated to main pushes) has been failing on every main
run since the step was added on 2026-04-13.

This is the same flavor as BUG-842 part 2 (request-handler
fire-and-forget goroutines drained via Server.bg WaitGroup). The
rate-limiter case wasn't in BUG-842's scope: those goroutines are
spawned at construction time, not at request time, so they need a
different drain primitive.

Changes:

  - ipRateLimiter gains stopCh + stopOnce + stopWg. cleanup() rewrites
    its loop as a select over stopCh and a 5-minute ticker, deferring
    stopWg.Done(). New Stop() closes stopCh once and waits for the
    cleanup goroutine to return.
  - RateLimiters gains a Stop() that walks all 9 limiters (nil-safe
    via the (*ipRateLimiter).Stop receiver guard).
  - Server.Stop() now also calls s.rateLimiters.Stop() after
    s.bg.Wait(). Test cleanups already call Server.Stop() (added in
    BUG-842), so no test-helper changes needed.
  - New TestServer_Stop_DrainsRateLimiterCleanup pins the contract:
    construct + Stop N servers, assert runtime.NumGoroutine() returns
    to baseline ±3.
  - .github/workflows/ci.yml: bump the -race timeout from the default
    10m to 20m. The full server suite under -race takes ~13m on a dev
    laptop after the leak fix; 20m gives margin without papering over
    an actual hang. Both `Run tests with race detector` (SQLite) and
    `Run tests with race detector against PostgreSQL` are bumped.

Verified locally: go test -race -timeout=1500s ./internal/server/
finishes ok in 776s (12m57s). Without the leak fix, the same command
times out at 600s (10m) with a goroutine dump showing hundreds of
ipRateLimiter.cleanup frames.
2026-04-28 17:20:03 -04:00
xarmian 0fd5d0cdfb fix: green up Go (PostgreSQL) CI (BUG-842) (#275)
* fix(store): swap plainto_tsquery → websearch_to_tsquery for PG FTS (BUG-842)

`TestListItems_FTS_HyphenatedSearchTerm/task-five` has been failing on
every Go (PostgreSQL) CI run because `plainto_tsquery('english',
'task-five')` doesn't match the asciihword lexeme(s) the english parser
produces for an indexed `task-five-distinctive`. The result is that
every PG full-text search for hyphenated terms returns zero rows.

`websearch_to_tsquery` (Postgres 11+) is purpose-built for arbitrary
user input and tokenizes hyphenated terms the same way `to_tsvector`
does for the indexed document, so the query intersects the index
correctly. Swapped in three spots in the postgres dialect — FTSMatch,
FTSSnippet, FTSRank — and updated the caller-side comments that
referenced plainto_tsquery. SQLite path is unchanged: it goes through
items_fts MATCH with sanitizeFTSQuery, never through these methods.

* fix(server): drain background goroutines on Stop() (BUG-842)

`TestAdminBillingStats_SidecarSidecarError_DegradesToLocalOnly` (and
other server tests) have been flaking on the Go (PostgreSQL) CI runner
with `TempDir RemoveAll cleanup: directory not empty`. Root cause:
several request handlers spawned bare `go func() { ... }()` goroutines
that touched the SQLite WAL DB after the test function returned.
testServer's t.Cleanup closed the store but had no way to drain those
goroutines first, so a fire-and-forget WAL write could re-create the
`-wal`/`-shm` files between Close() and t.TempDir's RemoveAll.

Add a Server.bg sync.WaitGroup, a Server.goAsync helper that wraps a
WaitGroup-tracked goroutine, and a Server.Stop() that blocks until
every goAsync closure has finished. Convert the four known
fire-and-forget sites to goAsync:

- middleware_auth.go (TouchUserActivity)
- handlers_auth.go   (password reset email)
- handlers_cloud.go  (stripe_processed_events pruning)
- handlers_members.go (workspace invitation email)

Wire `srv.Stop()` into both testServer (server_test.go) and
newMetricsTestServer (metrics_auth_test.go) so cleanup order is
Stop → Close → TempDir RemoveAll. Add
TestServer_Stop_DrainsBackgroundGoroutines to pin the contract: a
goAsync goroutine must block Stop until it returns.

* fix(store): correct PG FTS hyphenation via OR-combined plainto_tsquery (BUG-842)

The previous attempt swapped plainto_tsquery → websearch_to_tsquery,
which was wrong: websearch_to_tsquery treats `-` as a NEGATION operator
(Google-style), so `task-five` becomes `task & !five` and the search
returns 0 rows for the same reason as before. This commit reverts the
swap and applies the actual fix.

PG's english parser indexes `task-five-distinctive` as
`{task-five-distinct, task, five, distinct}` — the asciihword AND its
parts. plainto_tsquery applied to the partial query `task-five`
produces `task-fiv & task & five`: the stemmed asciihword for the
PARTIAL query (`task-fiv`) is NOT in the vector, so the AND fails.

Replacing the hyphen with a space makes plainto emit `task & five`,
which DOES match — but doing that unconditionally breaks `BUG-842`-
style queries: PG indexes the `-842` suffix as a negative-number
lexeme, so `plainto_tsquery('BUG-842')` matches via `-842`, while
`plainto_tsquery('BUG 842')` searches for `842` and misses.

The fix ORs the two query variants together so the search vector is
matched against either the raw user query OR its hyphen-as-space form.
Both `task-five` (against `task-five-distinctive`) and `BUG-842`
(against `BUG-842 fix the cleanup race`) hit. Verified locally against
postgres:17-alpine via PAD_TEST_POSTGRES_URL — both 10x stress and
race-detector runs are green.

Surfaces:
  - dialect.go: FTSMatch / FTSSnippet / FTSRank now consume TWO
    placeholders each in the PG dialect.
  - items.go: listItemsFTS PG branch + SearchItems PG branch update
    args to pass (raw, sanitized) for every PG `?` placeholder.
  - search.go: SearchItems main / count / facets PG branches updated
    likewise. New sanitizePGFTSQuery helper alongside sanitizeFTSQuery.
  - documents.go: ListDocuments PG branch updated.

Tests:
  - TestListItems_FTS_HyphenatedSearchTerm extended with a `BUG-842`
    case to pin the OR-combined logic — naive hyphen-stripping would
    silently regress this.
  - New TestSanitizePGFTSQuery unit test.

* chore: gofmt 11 files with import-order issues (BUG-842 PR cleanup)

The Go (SQLite) CI job has been failing on `main` (and every PR built
against it) because golangci-lint flags 11 files whose third-party
imports are intermixed with internal imports — the import-grouping
rule that gofmt enforces. None of these were introduced by the
BUG-842 PR; they're pre-existing on main. The PR can't go green
without this cleanup, though, so it's bundled here.

Pure mechanical change — `gofmt -w <files>` only re-orders import
groups; no logic changes. Files touched:

  cmd/pad/configure.go
  cmd/pad/main.go
  internal/cli/format.go
  internal/server/handlers_admin_invitations.go
  internal/server/handlers_admin_users.go
  internal/server/handlers_grants.go
  internal/server/handlers_share_links.go
  internal/server/handlers_stars.go
  internal/server/middleware_auth.go
  internal/store/store.go
  internal/store/store_test.go

After this commit `gofmt -l ./cmd ./internal` returns clean.
2026-04-28 16:21:43 -04:00
xarmian 43b2565afe fix(web): stop infinite recursion in marked link renderer (BUG-849) (#274)
* fix(web): stop infinite recursion in marked link renderer (BUG-849)

The custom link renderer called marked.parseInline on the raw text of a
link's child tokens to render the visible text. For autolinks (bare URLs
that GFM auto-detects as links) the raw text *is* the URL, so the
recursive parseInline re-tokenized it as another autolink and re-entered
the same renderer — stack overflow, browser console spammed with
"Please report this to https://github.com/markedjs/marked", and the
item page rendered as fallback text.

Triggered on any item whose content or comments contained a bare URL,
e.g. HT-786 had a comment with https://manage.maileroo.app.

Use marked's intended API: this.parser.parseInline(tokens) renders the
already-parsed inline tokens directly, no re-tokenization. Required:
- regular function (not arrow) so `this` binds to the Renderer instance
  (marked invokes overrides via override.apply(rendererInstance, args))
- import Renderer for the `this: Renderer` annotation
- escape the title attribute via escapeHtml() at the source instead of
  relying on DOMPurify after the fact

* fix(web): encode href in markdown link renderer (defense-in-depth)

Mirror marked's internal cleanUrl() so the custom link renderer produces
well-formed HTML even when href contains spaces, quotes, or other
URL-unsafe characters — and degrades gracefully to plain text when
encodeURI throws (lone surrogates).

Before: an href like `http://x" onclick="alert(1)` (reachable via marked's
`[x](<...>)` URL-with-spaces syntax) would land in the attribute
verbatim, producing malformed HTML the sanitizer then had to repair.
After: encodeURI turns the quotes into %22, so the intermediate HTML is
already well-formed before DOMPurify runs. The %25 → % round-trip avoids
double-encoding hrefs that already contain percent-encoded bytes
(e.g. %20).

DOMPurify is still the URL-safety authority — javascript:/data: schemes
are stripped by sanitizeMarkdownHtml's ALLOWED_URI_REGEXP. This change
is defense-in-depth plus correctness for the intermediate HTML, matching
the behavior of marked's default renderer.

Flagged in Codex review of #274.
2026-04-28 13:52:43 -04:00
xarmian 7cda0d7896 feat: rebrand to Perpetual Software + new tagline (IDEA-832) (#273)
Migrates from xarmian/pad to PerpetualSoftware/pad across the entire
repo and updates the product subtitle to "Collaborate with your AI
agents".

Go module rename
- go.mod: github.com/xarmian/pad → github.com/PerpetualSoftware/pad
- All Go imports updated across cmd/pad, internal/{cli,server,store,
  models,collections,items,events,metrics,webhooks} (~130 files)
- Test fixtures with the literal repo slug ("xarmian/pad" in JSON
  shapes, SSH/HTTPS git URL strings, workspace_context fixtures)
  also updated, including the secondary repo entry
  (xarmian/pad-web → PerpetualSoftware/pad-web — pad-web was also
  moved to the org per branch context)

Docs / config
- README badges, install instructions, brew tap, Docker image, source
  build path, sponsor link (sponsor link kept as personal @xarmian)
- Subtitle: "Project management for developers and AI agents." →
  "Collaborate with your AI agents." (README, manifests, web layout
  meta, .goreleaser homebrew description)
- CONTRIBUTING.md, SECURITY.md, skills/INSTALL.md
- .goreleaser.yaml: homebrew_casks owner, GHCR image, release github
  owner, cosign cert-identity regex, comments
- .github/workflows/release.yml: tap/release comments
- deploy/k8s/deployment.yaml: container image
- docs/deployment.md: clone URL
- web/static/{site.webmanifest,manifest.json}: description
- web/src/routes/+layout.svelte: meta description + og:description

Brew tap path is PerpetualSoftware/tap/pad (CamelCase, matches
GitHub user case). GHCR image is ghcr.io/perpetualsoftware/pad
(lowercased per GHCR's URL normalization). CODEOWNERS @xarmian and
FUNDING.yml github: xarmian intentionally retained — those are the
personal maintainer / sponsor account, separate from the org repo.

Verification: go build ./..., go test ./... (all pkgs pass), web
build, and make install all clean (TASK-844, TASK-845).
v0.0.1-rc.2
2026-04-28 12:26:39 -04:00
xarmian afe721d202 feat(cli): add Cloud mode to pad init, drop Docker option (TASK-837, TASK-838) (#272)
Merging despite Go (PostgreSQL) red — those failures (TestListItems_FTS_HyphenatedSearchTerm/task-five + TestAdminBillingStats_SidecarSidecarError_DegradesToLocalOnly TempDir cleanup race) are pre-existing on main and tracked in BUG-842.

Codex reviewed in 3 rounds (round 1 clean → round 2 found a real semantic bug → fix → round 3 clean). Tests, vet, and lint all green; remaining check failures are documented pre-existing.
2026-04-28 09:41:50 -04:00
xarmian 0ab6d3ed10 feat(web): signed-in account chip on CLI auth approval + switch-accounts (TASK-836) (#271)
* feat(web): show signed-in account chip on CLI auth approval page (TASK-836)

The CLI auth approval page (/auth/cli/{code}) previously showed only an
"Approve" button with no indication of WHICH account was about to grant
the CLI access. For OAuth users on Pad Cloud — most of whom have
multiple GitHub/Google accounts — wrong-account approval was a silent
footgun, recoverable only by revoking the CLI token after the fact.

This change renders an account chip above the Approve button when the
session is pending, showing:

- The user's avatar (when avatar_url is present)
- Display name (or username fallback if name is empty)
- Email

Below the chip, a "I'm not <Name> — switch accounts" link button calls
api.auth.logout() and navigates to /login?redirect=/auth/cli/{code}, so
after re-login the user lands back on this same approval page (the
login page already validates relative-only redirects to prevent open
redirects).

Graceful degradation: api.auth.me() is wrapped in its own try/catch.
If it fails, currentUser stays null and the chip simply doesn't
render — the Approve flow still works. The Approve button is also
disabled while a switch-accounts call is in flight to avoid
double-action races.

Works for both email/password (self-hosted) and OAuth (Cloud)
sessions because api.auth.me() and api.auth.logout() operate on the
unified pad session regardless of how it was established.

Parent: PLAN-833. Source: IDEA-831 issue #3.

* fix(web): plumb redirect through OAuth login + surface logout failures

Codex round-1 findings on TASK-836:

- MEDIUM: The login page already preserved ?redirect= for password and
  2FA login but the GitHub/Google OAuth buttons were plain anchors with
  hardcoded hrefs. A user clicking "Switch accounts" on the CLI auth
  approval page and then signing in via OAuth would land at /console
  instead of back at /auth/cli/{code}. Added a $derived oauthRedirectQuery
  rune that reuses the existing getRedirectTarget() validation and
  appends ?redirect=<encoded> to both OAuth links when the redirect is
  non-default. Whether pad-cloud's /auth/github and /auth/google handlers
  honor the redirect param is an out-of-tree concern and tracked
  separately if needed; the client side now consistently passes it.

- LOW: handleSwitchAccount silently swallowed logout failures and then
  navigated to /login. If the server didn't actually invalidate the
  session cookie (network/CSRF), login's onMount would see an
  authenticated session and bounce the user right back to the approval
  page — making "switch accounts" appear to be a no-op. The handler now
  surfaces the error in the page error slot and stays on the approval
  page, giving the user a clear next step (retry or close the tab) and
  also resets switchingAccount so the UI isn't stuck in a "Switching..."
  state.

A defensive code check was also added to handleSwitchAccount to mirror
handleApprove's "Missing CLI session code" guard, even though the button
only renders when status === 'pending'.

Parent: PLAN-833.

* fix(web): tighten redirect validation + cover OAuth banner buttons

Codex round-2 findings on TASK-836:

- MEDIUM: getRedirectTarget() accepted protocol-relative URLs (`//host`
  and `/\host`) because the bare `startsWith('/')` check passes for
  both. Browsers and most server-side redirect handlers treat those as
  cross-origin destinations, so a crafted `?redirect=//evil.example`
  could become an open redirect once forwarded through the OAuth
  handler. Now also rejects strings that start with `//` or `/\`. This
  was a pre-existing bug in the password/2FA redirect path; the OAuth
  link change made the surface area worth tightening.
- LOW: The "Use a different GitHub/Google account" banner buttons that
  appear on `oauth_provider_not_linked` errors hardcoded `?force=1` and
  dropped the redirect target. Added a sibling `oauthRedirectAmpQuery`
  derived value (`&redirect=...`) so those links compose properly with
  `?force=1`. When the redirect is the default `/console` it stays
  empty so we don't add redundant query noise.

Both changes live in cmd/pad/... no, in web/src/routes/login/+page.svelte
and don't affect the password / 2FA paths beyond the validation
tightening (which they were already passing through silently).

Parent: PLAN-833.
2026-04-27 23:11:35 -04:00
xarmian afd3b3c5ee feat(cli): explicit cancel + clean SIGINT for pad init prompts (TASK-835) (#270)
* feat(cli): explicit cancel + clean SIGINT for pad init prompts (TASK-835)

The interactive prompts in 'pad init' / 'pad workspace init' previously
relied on Go's default Ctrl+C behavior (terminate with no message) and
offered no in-prompt way to back out. A user who realized mid-init that
they were in the wrong directory had no clean exit and risked partial
state.

This change:

- Adds cmd/pad/cancel.go with errCancelled (sentinel), cancelInit() (the
  canonical "Cancelled." + os.Exit(130) path), and an installable
  SIGINT/SIGTERM handler.
- Template picker accepts c/q/cancel/quit (case-insensitive) and returns
  errCancelled. Prompt text now mentions the cancel option.
- Mode picker (pad configure) gains the same cancel keywords + prompt
  hint.
- Both pad init and pad workspace init RunEs install the signal handler
  and convert any propagated errCancelled into the same cancelInit()
  exit, using a named-return + LIFO defer so the existing body is
  unchanged.
- SilenceErrors + SilenceUsage are set on both init commands so cobra
  doesn't render an "Error: cancelled by user" line on top of our
  friendly message.

State on cancel: the template picker is invoked AFTER step 1
(configure) but BEFORE the workspace is created on the server and
BEFORE .pad.toml is written, so an abort at that prompt leaves no
half-created workspace, no orphan .pad.toml, and no stale credentials.

Tests cover all cancel keyword variants (both via the picker and via
errors.Is on wrapped errors), and verify the prompt surface mentions
the cancel option.

Parent: PLAN-833. Source: IDEA-831 issue #4.

* fix(cli): wire cancellation into all init paths per Codex review

Round 1 findings:

- HIGH: getConfiguredConfig() called os.Exit(1) on errCancelled, bypassing
  the canonical "Cancelled." + 130 exit. It now recognizes the sentinel and
  routes through cancelInit() before falling through to its generic
  Error path.
- HIGH: SilenceErrors+SilenceUsage on the init commands silenced every
  error, hiding real failures (e.g. server connection problems). Removed
  both — cancelInit() never returns to cobra, so the cancellation case
  doesn't need silencing, and real errors print normally again.
- MEDIUM: doBrowserLogin returned `fmt.Errorf("login cancelled")` on
  context cancel, which is not errCancelled. If its inner signal listener
  won the race against the outer init handler on Ctrl+C, the propagated
  error didn't match isCancellation and the command exited 1 with a
  generic message. doBrowserLogin now returns errCancelled directly so
  whichever goroutine wins the race, the exit converges on 130.
- MEDIUM: cancel.go cleanup race — if a signal arrived between init
  completion and the goroutine returning, both sigCh and done could be
  ready and select could pick sigCh, turning a successful run into a
  spurious 130 exit. Added a re-check on done inside the sigCh branch
  so late signals are suppressed once cleanup has run. Also reordered
  cleanup to call signal.Stop before close(done) so no new signals
  enter the buffer during shutdown.
- MEDIUM: promptForValue (the URL prompt for remote/docker mode) still
  treated 'c' as URL input and failed validation. It now recognizes
  c/q/cancel/quit and returns errCancelled, matching the picker and
  mode-prompt behavior.

LOW finding (account-setup prompts) intentionally not addressed: Ctrl+C
already covers them via the outer handler, and explicit keyword
recognition on the password prompt would risk collision with real
passwords. doInteractiveLogin is not on an init path.

Parent: PLAN-833.

* fix(cli): cancel sentinel handling for pad auth configure / pad auth login

Codex round-2 findings:

- MEDIUM: pad auth configure RunE returned errCancelled directly to
  cobra. Now wraps the body with the same isCancellation -> cancelInit()
  deferred check used in pad init, so 'c' at the mode/URL prompt exits
  with the canonical "Cancelled." + 130.
- LOW: pad auth login RunE called doBrowserLogin (which now returns
  errCancelled on signal cancellation). The sentinel was leaking to
  cobra. Added the same deferred check so SIGINT during browser login
  exits 130 with a friendly message regardless of which goroutine wins
  the cancellation race.

Neither command installs the outer SIGINT handler — pad auth login
relies on doBrowserLogin's existing inner listener (avoiding the
double-listener race) and pad auth configure's prompts are short
enough that Go's default Ctrl+C handling for those is acceptable. The
new deferred checks just plug the sentinel-leak holes.

Parent: PLAN-833.
2026-04-27 22:05:45 -04:00
xarmian 189b22825e fix(cli): use cfg.BaseURL()/BrowserURL() in pad init success message + pad open (TASK-834) (#269)
* fix(cli): use cfg.BaseURL() in pad init success message (TASK-834)

The "Or open the web UI at http://localhost:7777" line in
printOnboardingHints was hardcoded, which is wrong for any
non-local connection mode (Remote, Docker, eventual Cloud).
The CLI already knows the configured base URL — it just used
it to talk to the server.

Same hardcoded URL existed in the workspace-onboard skip path
("You can activate conventions from the library: ...").

Both call sites now use cfg.BaseURL(), which yields the correct
URL for every mode:
- Local: http://127.0.0.1:7777 (default host:port)
- Remote/Docker/Cloud: the configured URL (e.g. https://app.getpad.dev)

printOnboardingHints now takes a *config.Config; both call sites
already had cfg in scope.

Parent: PLAN-833 (pad init UX gaps + Pad Cloud onboarding fixes).
Source: IDEA-831 issue #5.

* fix(config): add BrowserURL() that normalizes 0.0.0.0 to 127.0.0.1

Per Codex review (round 1): when local mode runs with --host 0.0.0.0
(bind-all), cfg.BaseURL() returned "http://0.0.0.0:7777" — a bind
address that browsers don't reliably accept.

BrowserURL() behaves like BaseURL() except that when constructing
from host:port, an unspecified bind-all host (empty, "0.0.0.0", "::",
"[::]") is rewritten to "127.0.0.1". Explicit URL configurations
(Remote/Docker/Cloud) are returned unchanged.

The two onboarding-hint call sites updated in the previous commit now
use BrowserURL() so the success message and skip-path show a clickable
URL in every supported configuration. Tests cover loopback, named
hosts, empty/0.0.0.0/::/[::] normalization, and explicit-URL
precedence.

Parent: PLAN-833.

* fix(cli): use BrowserURL() in pad open for bind-all safety

Per Codex review (round 2): the 'pad open' command prints and opens
cfg.BaseURL(), which produces 'http://0.0.0.0:7777' when the local
server is bound bind-all. Same class of bug as the onboarding hint
fix in this PR — switch to cfg.BrowserURL() so the URL is a usable
browser destination.

A second related issue Codex flagged — the server-issued CLI auth URL
in doBrowserLogin (which goes through internal/server/handlers_cli_auth.go
using r.Host) — is a different surface with multiple possible fix
strategies and overlaps with the post-v0.1.0 OAuth-architecture work.
Deferred to TASK-839 with a written-up runbook so it isn't lost.

Parent: PLAN-833.
2026-04-27 21:48:50 -04:00
xarmian 8ae009fa40 feat(admin): add Billing tab and dashboard page (TASK-828) (#267)
* feat(admin): add Billing tab and dashboard page (TASK-828)

Surfaces the Pad Cloud billing metrics in the admin console as a new
tab between "Audit Log" and "Settings". Final piece of PLAN-825.

The page calls GET /api/v1/admin/billing-stats (TASK-827) and renders
six metric cards in a responsive auto-fit grid:

1. MRR              (Stripe-derived; greyed when unavailable)
2. ARR              (Stripe-derived; greyed when unavailable)
3. Active Subs      (Stripe-derived; greyed when unavailable)
4. Customers/Plan   (LOCAL — always real; e.g. "Free: 42 · Pro: 7")
5. New Signups 30d  (LOCAL — always real)
6. Churn 30d        (Stripe-derived; greyed when unavailable;
                     subtitle shows cancelled count)

Two banners drive the degraded-state UX:
- cloud_unreachable=true  → amber warning ("sidecar unreachable, showing
                            local data only")
- stripe_configured=false → blue info banner explaining that Stripe
                            metrics will be zero until STRIPE_SECRET_KEY
                            is set on pad-cloud (the expected pre-launch
                            steady state)

Header carries a Refresh button (re-fetches without unmounting the page)
and an "Open in Stripe Dashboard ↗" external anchor (rel=noopener).
A subtle footer renders "Updated just now" or "Updated N min ago" from
the cache_age_seconds field.

The Billing tab is hidden from the layout's tab list when
adminStore.stats.cloud_mode is false — self-host operators won't see a
tab that always 404s on click. Used $derived(...) for the tabs array so
the tab list reacts to the cloud_mode flag flipping after stats load.

Svelte 5: runes throughout ($state, $derived, $props), single onMount
for the initial fetch, no combined effect-on-effect chains (CONVE-606).
Visual idiom mirrors the existing /console/admin stats-bar (.stat
cards, --bg-secondary background, --radius-lg, value/label sizing).

Validated with the svelte MCP autofixer (clean) and `npm run build`
(clean, page emitted to entries/pages/console/admin/billing).

Closes PLAN-825's UI work.

* fix(admin): add role=status / aria-live=polite to Stripe info banner

Codex round 1 LOW: the warning banner already carries role=alert because
its message is urgent (sidecar unreachable), but the "Stripe not
configured" info banner appears asynchronously after load with no live-
region semantics, so screen readers never announce that the page is in
a degraded state. Add role=status + aria-live=polite so the announcement
is non-interrupting but happens.
v0.0.1-rc.1
2026-04-27 14:52:13 -04:00
xarmian 8e067c19db feat(admin): add /api/v1/admin/billing-stats proxy + cloud client (TASK-827) (#266)
* feat(admin): add /api/v1/admin/billing-stats proxy + cloud client (TASK-827)

New admin endpoint that powers the upcoming Pad Cloud Billing dashboard:
GET /api/v1/admin/billing-stats merges Stripe-derived metrics from pad-cloud
(active subs, MRR, ARR, churn, 30-day cancellations) with locally-computed
aggregates from the users table (customers_by_plan, new_signups_30d in the
last 30 days for plan='pro').

Architecture (PLAN-825 Option B):
- pad-cloud (TASK-826, already merged) hosts the Stripe API access in one
  place; this PR adds the reverse pad → pad-cloud client method.
- Existing internal/billing.CloudClient gains GetBillingMetrics(): GET on
  /admin/metrics/billing with the X-Cloud-Secret header (the same secret
  pad-cloud already validates inbound calls with).
- New CloudSidecar.GetBillingMetrics() interface method keeps the server
  package free of HTTP/Stripe dependencies and lets tests inject fakes.
- Existing fakeSidecar in handlers_account_test.go grows a no-op stub so
  the account-delete tests still satisfy the extended interface.

Degradation contract:
- The endpoint always returns 200. Two booleans tell the UI which fallback
  to render: cloud_unreachable=true (sidecar errored or unwired) and
  stripe_configured=false (sidecar reachable but no STRIPE_SECRET_KEY yet).
- requireCloudMode + requireAdmin gate the route. Self-host gets 404,
  non-admin gets 403.

Web glue:
- Added AdminBillingStats type to web/src/lib/types/index.ts.
- Added api.admin.getBillingStats() to web/src/lib/api/client.ts.
The Billing tab and metric cards land in TASK-828.

Tests:
- Billing package: GetBillingMetrics happy path (verifies method, path,
  X-Cloud-Secret header, Accept header), Stripe-not-configured pass-through,
  non-200 → SidecarError, transport error stays bare, malformed JSON,
  nil/unconfigured client guards.
- Server package: self-host 404, non-admin 403, admin happy path
  (merges local + remote correctly, handles plan="" → "free", filters
  new_signups_30d to plan='pro' AND created_at >30d ago), no-sidecar
  degrades to local-only, transport error degrades, sidecar 5xx degrades,
  stripe_configured=false propagates verbatim with cloud_unreachable=false.

Part of PLAN-825 (Pad Cloud Admin Billing Dashboard).

* fix(admin): address Codex review (round 1) on billing-stats proxy

- Replace handler-side ListUsers walk with store.CountBillingAggregates
  (two scalar SQL queries: COUNT(*) GROUP BY plan + a single COUNT(*)
  for new pro signups). Removes the per-row TOTP decrypt overhead that
  ListUsers performs and bounds CPU/bandwidth as the user table grows.
- Fix misleading TS comment on AdminBillingStats: clarify that "fully
  healthy" requires cloud_unreachable=false AND stripe_configured=true,
  not "both flags false" as previously stated.

Adds TestCountBillingAggregates exercising empty store, mixed plans,
empty-plan → "free" bucketing, and the 30-day cutoff filter for new
pro signups.

* fix(store): GROUP BY normalised plan expression in CountBillingAggregates

Codex round 2 caught a real bug: SELECT projected the COALESCE'd plan but
GROUP BY operated on the raw `plan` column, so users with plan='' and
plan='free' produced two distinct result rows that both scanned as "free"
in Go — the second iteration overwrote the first in CustomersByPlan,
silently underreporting the free-tier count.

Fix: GROUP BY COALESCE(NULLIF(plan, ''), 'free') so the grouping matches
the projection. Test updated: insertWithPlanAndDate now seeds an explicit
'' plan alongside two explicit 'free' rows and asserts the aggregate
rolls them up to 3 — the previous test only used CreateUser which always
inserts the column default ('free') and never exercised the empty-string
path.
2026-04-27 14:42:53 -04:00
xarmian c4b5a36330 feat: warn at startup when shipped FTS triggers are missing (TASK-824) (#265)
* feat(store): warn at startup when shipped FTS triggers are missing (TASK-824)

Defensive follow-up to BUG-822, where the documents_* triggers had
silently drifted off some production DBs and search was broken until
a user noticed. The migration runner had no notion that the triggers
should exist — the only invariant was "this migration ran without
erroring," which is too weak when SQLite's table-rebuild path can leave
auxiliary objects in a different state than the migration intended.

Add a hardcoded list of expected FTS5 triggers (one row per trigger,
naming the table it's attached to) and a one-shot validateFTSInvariants
step at the end of Store.migrate(). Each missing trigger emits a
structured slog.Warn that points the operator at the recovery
migration (046).

Choices:
- SQLite-only. Postgres uses tsvector update functions in pgmigrations
  with a different invariant model.
- Logging-only, no auto-repair. Auto-creating triggers here would mask
  legitimate future removals and obscure the source of truth (the
  migrations directory). The recovery path is a targeted migration
  like 046_restore_documents_fts_triggers.sql.
- Non-fatal. A missing trigger doesn't block startup; the operator
  may have intentionally removed one and just not updated the list
  yet, and we'd rather warn loudly than refuse to boot.

Tests:
- TestStartupInvariants_AllFTSTriggersExist — fresh DB has all 9
  expected triggers (forward-looking guard against future migrations
  that break one).
- TestStartupInvariants_LogsOnMissingTrigger — drop a trigger, run
  validator, capture slog records, assert a warning naming the
  missing trigger was emitted.

Manual verification on the production DB:
- Clean DB (after migration 046): no warnings on startup.
- After manually `DROP TRIGGER documents_ai`: server logs
  `level=WARN msg="FTS trigger missing — ..." trigger=documents_ai
  table=documents` immediately on startup.

* test(store): address Codex review on TASK-824 — bidirectional drift + Record.Clone

Two LOW findings from Codex's first pass:

1. recordCapturingHandler.Handle stored slog.Record values without
   cloning. Records have internal shared state; the documented pattern
   for retaining them is r.Clone() first. Test passed today only
   because nothing mutated the record after Handle, but the helper was
   relying on slog internals.

2. TestStartupInvariants_AllFTSTriggersExist only proved every entry
   in expectedFTSTriggers exists. It didn't catch the inverse: a future
   migration adding a new FTS-style trigger on items/comments/documents
   without also adding it to expectedFTSTriggers, leaving the new
   trigger off the invariant check forever.

Add TestExpectedFTSTriggers_MatchesActual which queries sqlite_master
for every trigger on items/comments/documents and asserts each is in
the expected list. A new trigger that isn't tracked fails this test
with a clear "update the list in store.go" message.

If a future trigger on these tables is legitimately not FTS-related,
the test failure points the developer at this guard and they can
either add it to expectedFTSTriggers or extend the exclusion.
2026-04-27 13:34:10 -04:00
xarmian 4608108acf fix: restore documents_fts triggers + rebuild index (BUG-822) (#264)
* fix(store): restore documents_fts triggers + rebuild index (BUG-822)

Some production DBs ended up missing the documents_ai/au/ad triggers,
even though migration 025 (which rebuilt the documents table for the
doc_type CHECK constraint change) was recorded as applied. Items_fts
and comments_fts triggers were unaffected — issue is isolated to the
documents table-rebuild path.

Without these triggers, INSERT INTO documents never propagates rows
into documents_fts, so newly-created documents are silently invisible
to search. Plain list views still surface them, masking the regression.

Migration 046 is idempotent and safe to apply on any DB:

  1. DROP TRIGGER IF EXISTS for the three documents_* triggers — round-
     trips for DBs that ran 025 cleanly, recovers DBs missing the
     triggers.
  2. CREATE TRIGGER for all three (matching the bodies in 001/025).
  3. INSERT INTO documents_fts(documents_fts) VALUES ('rebuild') to
     repopulate the FTS5 internal index from the current documents
     table — recovers searchability for documents created while the
     triggers were missing.

Postgres path uses a separate tsvector trigger function and is not
affected (only pgmigrations are applied there; this migration lives
in the SQLite migrations directory).

Tests:
- TestMigration046_DocumentsFTSTriggersExist — assert all three
  documents_* triggers exist after migrations run.
- TestCreateDocument_IsSearchableImmediately — regression test for
  the failure mode: create a doc, immediately search by a unique
  title-keyword, assert it's findable.

Manual verification on the production DB:
- Triggers re-appeared after `make install` (migration 046 applied).
- POST /documents with title "BUG822verify distinctive" → immediately
  findable via ?q=BUG822verify (returned 1 result, the new doc).

* test(store): pin the BUG-822 recovery path with a rebuild test

Codex review on the BUG-822 fix flagged that neither existing test would
fail if the `INSERT INTO documents_fts(documents_fts) VALUES('rebuild')`
step were removed from migration 046. The trigger-existence and
post-fix-search-works tests both pass on a clean migration run, but
they don't exercise the historical-recovery half of the migration —
the part that rescues already-broken DBs whose documents were inserted
while the triggers were missing.

Add TestMigration046_RebuildRecoversUnindexedDocs which:
1. Drops the documents_* triggers to simulate the broken state.
2. Inserts a document via the store path — won't reach FTS without
   triggers.
3. Asserts the doc is invisible to ListDocuments (sanity-pinning the
   broken state).
4. Runs just the rebuild step from migration 046.
5. Asserts the previously-unindexed doc is now searchable.

This locks in the recovery contract: removing the rebuild step from
046 will now make this test fail.
2026-04-27 13:04:02 -04:00
xarmian dcf7c1d58e fix(store): apply Tag and Pinned filters in ListDocuments FTS branch (BUG-820) (#263)
The non-FTS path in ListDocuments applies Tag and Pinned filters (lines
31-42), but when params.Query is non-empty the FTS branch rebuilds query
and args from scratch and only re-applies Type and Status — Tag and
Pinned were silently dropped. Result:
`/documents?q=foo&tag=urgent` returned all docs matching foo regardless
of tag, similarly for pinned.

Documents-side analog of BUG-812 (which fixed the equivalent issue on
the items FTS path).

Fix: mirror the Tag (s.dialect.JSONArrayContains on d.tags) and Pinned
(d.pinned = TRUE/FALSE) filter blocks into the FTS branch after the
existing Type/Status blocks. Backend-only — handlers and DocumentListParams
already plumb both params through.

Tests:
- TestListDocuments_FTS_TagFilter — two docs match the search; only one
  has the tag; assert exactly the tagged one returned.
- TestListDocuments_FTS_PinnedFilter — covers both pinned=true and
  pinned=false branches, asserting each narrows correctly.

Manual verification: with two docs `BUG820scratch alpha` (tagged
"urgent", pinned) and `BUG820scratch beta` (untagged, unpinned):
- ?q=BUG820scratch              → 2 docs
- ?q=BUG820scratch&tag=urgent   → 1 doc (alpha)
- ?q=BUG820scratch&pinned=true  → 1 doc (alpha)
- ?q=BUG820scratch&pinned=false → 1 doc (beta)
2026-04-27 12:38:50 -04:00
xarmian 068c208824 fix: sanitize SQLite FTS5 queries + whitespace guards (BUG-818) (#261)
* fix(store): sanitize FTS5 queries in listItemsFTS and SearchItems (BUG-818)

The sanitizeFTSQuery helper in internal/store/search.go wraps each
whitespace-delimited token in double quotes so SQLite FTS5 treats
specials (hyphens, AND/OR/NOT, parens) as literal characters rather
than boolean operators. Store.Search already used it; Store.listItemsFTS
and Store.SearchItems didn't, so any hyphen in `?search=` returned
HTTP 500 with "no such column: <suffix>" — including issue refs like
TASK-5, kebab-case slugs, dates, etc.

Apply sanitizeFTSQuery at the SQLite arg-binding sites in both unfixed
functions. Postgres branches stay unsanitized: plainto_tsquery accepts
arbitrary input safely (matches the existing pattern in Store.Search).

Tests:
- TestListItems_FTS_HyphenatedSearchTerm — exercises the listItems path
  on multiple hyphenated queries via a table-driven sub-test.
- TestSearchItems_HyphenatedQuery — same regression on the SearchItems
  path used by /api/v1/search.
- TestSanitizeFTSQuery — direct unit test covering empty, whitespace-
  only, plain word, hyphenated phrase, multi-token, FTS5 boolean
  operators (AND/OR/NOT), parens, embedded quotes (stripped),
  surrounding whitespace, and unicode.

Manual verification: previously-500 queries now return 200 with results:
  /items?search=match-me   → HTTP 200, 2 items
  /items?search=TASK-5     → HTTP 200, 8 items
  /items?search=pad-cloud  → HTTP 200, 103 items

* fix(store): address Codex review on PR for BUG-818

Codex review caught two extensions to the original BUG-818 fix:

1. MEDIUM — Store.ListDocuments (internal/store/documents.go) had the
   same FTS5 boolean-parser vulnerability as Store.listItemsFTS and
   Store.SearchItems before the original commit. Hyphenated /documents?q=
   queries (e.g. ?q=release-notes-q2) returned HTTP 500 with "no such
   column" the same way. Apply sanitizeFTSQuery in the SQLite branch;
   leave Postgres unchanged.

2. LOW — Whitespace-only queries collapse to empty after FTS sanitization,
   and SQLite FTS5 errors on `MATCH ''` with "syntax error near \"\"".
   Add TrimSpace guards at the routing/entry points:
   - listItems: route to FTS only if TrimSpace(Search) != ""
   - SearchItems: short-circuit to empty results
   - ListDocuments: same routing guard
   - Store.Search: short-circuit to empty results

Tests:
- TestListDocuments_HyphenatedQuery — regression on the documents FTS path
- TestFTS_WhitespaceOnlyQuery_DoesNotCrash — covers all 3 entry points
  (ListItems, SearchItems, ListDocuments) for spaces, tabs, mixed
  whitespace

Manual verification (all 6 endpoints now HTTP 200):
- /workspaces/{ws}/items?search=task-five
- /workspaces/{ws}/items?search=<3 spaces>
- /workspaces/{ws}/documents?q=release-notes
- /workspaces/{ws}/documents?q=<3 spaces>
- /search?q=task-5
- /search?q=<3 spaces>
2026-04-27 12:19:18 -04:00
xarmian 10e17e0ca1 fix(store): apply Tag/ParentID/Assignee/AgentRole/Fields filters in listItemsFTS (BUG-812) (#260)
When `search` is set, ListItems routes through listItemsFTS, which
historically only re-applied CollectionSlug, CollectionIDs, ItemIDs, and
(post-BUG-734) ParentLinkID. Other filter parameters silently dropped:

- Tag
- ParentID (legacy items.parent_id column)
- AssignedUserID
- AgentRoleID (both ID-equality and slug-OR branches)
- Fields (custom-field equality / IN-list)

Result: combining ?search=foo with any of the above returned more items
than the caller asked for. Web UI list filters chained with the search
box, the per-collection filter chips, and any API consumer with the same
combo were all affected.

Fix: mirror the relevant filter blocks from the non-FTS listItems path
into listItemsFTS, preserving isValidFieldKey injection guarding on
field keys.

Tests (internal/store/items_test.go):
- TestListItems_FTS_TagFilter
- TestListItems_FTS_ParentIDFilter
- TestListItems_FTS_AssignedUserFilter
- TestListItems_FTS_AgentRoleFilter (covers both role-ID and role-slug
  branches)
- TestListItems_FTS_FieldFilter (single-value, IN-list, and the
  invalid-key silent-drop)

Out of scope: IncludeArchived parity (FTS hardcodes deleted_at IS NULL),
Sort parity (FTS deliberately sorts by relevance rank), Offset (FTS
honors only Limit). Unrelated to BUG-812; can ship together later if
desired.

Manual verification: with two tasks `Bug812scratch alpha` (priority=high)
and `Bug812scratch beta` (priority=low), `?search=Bug812scratch` returns
both, `?search=Bug812scratch&priority=high` returns only alpha.
2026-04-27 11:12:37 -04:00
xarmian 0bf710eea5 fix: hide item_links pointing to soft-deleted items (BUG-734) (#259)
* fix(store): hide item_links pointing to soft-deleted items (BUG-734)

Item-link queries that JOIN against `items` now also filter on
`deleted_at IS NULL` for both source and target. This prevents
`pad item related`, the lineage breadcrumb, and dashboard enrichment
from surfacing dangling endpoints when one side has been archived.

Affected queries in internal/store/items.go:
- GetItemLinks  (powers `pad item related`, lineage, dashboard)
- GetItemLink   (singular; fixed for consistency)
- GetParentForItem (breadcrumb / lineage; archived parent reads as none)

Other item_links queries already filtered on deleted_at; export.go
deliberately keeps all rows for backup correctness — left unchanged.

The link rows themselves are preserved on disk, so restoring a
soft-deleted item resurrects its relationships automatically.

Tests:
- TestItemLinks_HidesSoftDeletedEndpoints — delete + restore round-trip
  on both source-side and target-side
- TestGetParentForItem_HidesSoftDeletedParent — parent breadcrumb path

Manually verified: PLAN + TASK with `implements` link, soft-delete the
TASK, `pad item related <PLAN>` correctly returns no implementers.

* fix(store): address Codex review findings on PR #259 (BUG-734)

Three follow-ups from Codex's review of the soft-delete filter on item-link
queries:

1. MEDIUM — GetParentMap now JOINs items on both sides and filters on
   deleted_at IS NULL. handlers_dashboard.go uses this map directly to
   detect orphaned tasks (items not present in the map are flagged), so
   without the filter a task whose parent had been soft-deleted would
   silently fail to appear as orphaned.

2. LOW — Revert the deleted_at filter on getItemLink (lowercase, private).
   Its only caller is the post-insert readback in CreateItemLink, which
   means filtering buys nothing user-facing and introduces a delete-race
   window where a successful INSERT returns nil. SetParentLink's readback
   was switched from GetItemLinks to getItemLink for the same reason.
   User-facing surfaces still go through GetItemLinks (plural) and
   GetParentForItem, both of which retain the filter.

3. LOW — Add an explicit comment in export.go documenting that item_links
   are exported in full (including links to soft-deleted items), and why
   that intentionally diverges from the user-facing query behavior.

Tests: TestGetParentMap_ExcludesSoftDeletedEndpoints exercises the
dashboard regression path on both source-side and target-side soft-delete,
plus the restore round-trip.

* fix(store): reject soft-deleted parent in ListItems UUID parent filter (BUG-734)

Codex review on 288283b flagged that ListItems(parent=<UUID>) at items.go:534
runs an EXISTS subquery against item_links without checking whether the
target parent is soft-deleted. Slug/ref input rejects deleted parents
upstream via GetItem/GetItemBySlug, but raw-UUID input bypasses that path
and would still return active children of an archived parent.

Fix: JOIN items into the EXISTS subquery and require deleted_at IS NULL on
the parent.

Test: TestListItems_ParentFilter_RespectsSoftDeletedParent — covers the
delete + restore round-trip on the parent.

* fix(store): apply parent-filter in FTS path so search+parent enforces deleted-parent rejection (BUG-734)

Codex's 3rd review pass on PR #259 caught that listItemsFTS does not
re-apply ParentLinkID. Combining `parent=<UUID>&search=<q>` therefore
silently dropped the parent constraint — and, by extension, the
deleted-parent rejection introduced earlier in this PR.

Fix: replay the same EXISTS-with-deleted_at-IS-NULL predicate in the FTS
branch. Test: TestListItems_ParentFilter_FTS_RespectsSoftDeletedParent
covers the delete + restore round-trip on the search path.

The wider FTS filter-bypass (Tags, AssignedUserID, AgentRoleID, Fields,
ParentID are all silently dropped when search is set) is pre-existing
behavior outside BUG-734's scope; tracked as BUG-812.
2026-04-27 10:57:24 -04:00
xarmian 96b3f68b5a docs(readme): use pad init as the canonical entry point (#258)
* docs(readme): use pad init as the canonical entry point

The Quick Start and Getting Started sections still walked users
through the deprecated multi-step flow (pad auth configure +
pad workspace init + pad agent install), even though pad init is
a single smart command that orchestrates all six setup steps.

Changes:
- Quick Start: 3 commands -> 2 (brew install + pad init).
- Getting Started: collapsed sections 1-3 ("Configure this client",
  "Initialize a workspace", "Install the AI skill") into a single
  "Set up Pad" section that uses pad init.
- Template examples updated from pad workspace init --template X
  to pad init --template X. --list-templates kept as
  pad workspace init --list-templates (the only command that
  supports it today).
- Tagline ("No accounts.") + architecture summary ("no accounts.")
  -> "No accounts required." Pad supports user accounts with
  email/password auth and workspace invitations; the strict claim
  contradicted later sections.
- Removed Pad Cloud directive in the Docker section -- Cloud is
  not released yet, so the README should not direct users to it.
- Replaced full Docker Compose subsection with a one-line pointer
  to docs/deployment.md. Postgres + Redis is an advanced multi-
  instance path; the README should keep its binary-first focus.
- Aligned the pad github CLI reference columns (3 lines were
  off-spec).

* docs(readme): use pad init in the comparison table too (codex nit)

* docs(readme): reframe Authentication section to point local installs at pad init (codex P2)

* docs(readme): scope pad init bootstrap to local mode (codex P2)
2026-04-26 23:04:47 -04:00
xarmian 29f720c996 docs: add real README screenshots (dashboard + board views) (#257)
The README had two TODO placeholders for screenshots that have been
sitting commented-out since the project started. With the launch
imminent, fill them in.

Captures:
- docs/screenshots/dashboard.png — workspace dashboard with Active
  Work cards, Active Plans (v0.2 — Collaboration with progress),
  collection summaries, recent activity.
- docs/screenshots/board.png — tasks board view, four columns
  (Open / In-Progress / Done / Cancelled) with realistic task cards.
- docs/screenshots/list.png — list view (not currently referenced
  from the README, but kept as part of the reproducible asset set).

Reproducibility:

web/e2e/screenshots.spec.ts is a gated Playwright spec (skipped
unless PAD_SCREENSHOTS=1) that uses the existing e2e fixture
infrastructure to:
1. Spin up a fresh pad binary against a clean data dir.
2. Bootstrap an admin + workspace seeded with the startup template.
3. Add a realistic demo dataset (1 active plan, 7 tasks across
   open/in-progress/done with mixed priorities, 2 ideas).
4. Navigate + capture three views at 1440x900.

To regenerate:

  make build
  cd web && PAD_SCREENSHOTS=1 PAD_E2E_PORT=17801 \\
    npx playwright test screenshots --project=desktop-chromium

Notes:

- Table view (?view=table) was originally in scope but the URL
  parser only accepts list/board today; setting via toggle would
  require localStorage manipulation. Three screenshots already
  cover the README's needs; revisit if/when table view becomes
  URL-reachable.
- Dark/light variants were also in scope but the web UI is dark-
  mode-only at present, so the captures are dark-only.

Refs: TASK-673
2026-04-26 20:12:40 -04:00
xarmian e57da62917 chore: modernize goreleaser config + wire homebrew-tap token (#256)
* chore: wire HOMEBREW_TAP_GITHUB_TOKEN into release pipeline

The brews block in .goreleaser.yaml targets the separate xarmian/homebrew-tap
repo. Without a token override, goreleaser falls back to the workflow's
GITHUB_TOKEN — which is scoped to xarmian/pad only and cannot push to the
tap repo. At first real tag time the brew publish step would fail with a
permission error.

- Add `repository.token` to the brews block, referencing
  `{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}`
- Export `HOMEBREW_TAP_GITHUB_TOKEN` from the workflow `secrets` into the
  goreleaser step env, alongside the existing `GITHUB_TOKEN`
- Comment both edits with the rationale + the fine-grained PAT permissions
  the secret needs (`Contents: write` + `Metadata: read` on the tap repo)

The secret itself is created on the human side (HT-780). Snapshot mode
skips publishing so this isn't testable locally — the gate is HT-782's
v0.0.1-rc.1 dress rehearsal.

Refs: TASK-806, TASK-778 (audit), HT-780 (operator step)

* chore: migrate goreleaser deprecations (archives.formats, dockers_v2, homebrew_casks)

Three v2 deprecations were flagged by `goreleaser check` while wiring the
tap token. Migrating them now (instead of filing tech debt) because we're
already touching the file and these are part of the same release pipeline
that ships at v0.1.0 — no point landing a "wire the token" commit that
still trips deprecation warnings on the very next CI run.

Changes:

- archives: `format: tar.gz` + `format_overrides[].format: zip` →
  `formats: ["tar.gz"]` + `format_overrides[].formats: ["zip"]`
  (single-string is still accepted but the list form is the new spec)

- dockers + docker_manifests → dockers_v2:
  Single block with `images:` + `tags:` + `platforms:` collapses the
  prior per-architecture builds plus separate manifest declarations into
  one declaration. buildx + multi-platform are implicit. Snapshot
  validates: amd64 + arm64 images both build, manifest list assembled,
  binary runs inside the cross-built image.

  goreleaser flags dockers_v2 as "experimental and subject to change" —
  it's the documented forward path for v2 and the project is already
  pinned to `version: "~> v2"`, so we're committed to the roadmap.

- Dockerfile.goreleaser: add `ARG TARGETPLATFORM` and update the COPY to
  `${TARGETPLATFORM}/pad`. dockers_v2 organizes pre-built binaries under
  `linux/amd64/pad`, `linux/arm64/pad` etc; buildx populates
  TARGETPLATFORM per platform during the build.

- brews → homebrew_casks: `directory: Formula` → `directory: Casks`.
  The `brews` keyword is fully phased out in v2.10+; goreleaser's
  homebrew_casks now natively handles pre-compiled binaries (which used
  to require workarounds with the old brews block). End-user UX is
  unchanged: `brew install xarmian/tap/pad` works identically because
  modern Homebrew auto-detects whether a tap entry is a formula or a
  cask. Removed the no-op `test:` stanza that doesn't apply to casks.

Validation: `goreleaser check` clean (zero warnings), `goreleaser
release --snapshot --clean --skip=publish,sign,sbom` builds all six
binaries, six archives, one cask, two cross-platform docker images.
`docker run --rm ghcr.io/xarmian/pad:latest-amd64 --version` returns
the snapshot version as expected.

Refs: TASK-806
2026-04-26 16:38:45 -04:00
xarmian 89a5647543 docs: strip 'Hardening for public deployments' from README (#255)
The OSS package defaults to loopback and is positioned as a local-first,
single-user product. A polished operator checklist for self-hosting beyond
loopback competes directly with the Pad Cloud funnel — the multi-user team
segment we want to convert to hosted.

- Strip the entire 'Hardening for public deployments' section from README
  (network boundary, secrets, authentication, observability, CI gates,
  quick checklist — five subheadings).
- Reword the Docker subsection so single-user-on-LAN / Tailscale / home
  VPN reads as a positive supported path. Multi-user team setups get a
  soft handoff to Pad Cloud.
- Move the npm audit + govulncheck CI guidance to CONTRIBUTING.md as a
  Quality Gates subsection — that material is contributor-facing, not
  user-facing, so it stays.
- docs/deployment.md unchanged — multi-user Postgres + K8s recipes still
  exist there for the determined self-hoster, but unpromoted from the
  README.

No new docs/SELF-HOSTING.md created (initially considered) — would have
competed with the hosted-product positioning.

Refs: TASK-777
2026-04-26 16:13:09 -04:00
xarmian a1bbfabf67 fix: topbar overflow drag/drop and dashboard flicker (IDEA-758) (#254)
Series of regressions found while testing the workspace topbar overflow
menu shipped in IDEA-758 / TASK-759:

- Layout collapse: `.workspace-list` had no `flex: 1`, so
  ResizeObserver fed the shrinking content width back into the
  fitting calc and ratcheted down to "active pill only". Wrap pills,
  trigger, and add button in a centered `.workspace-row` that owns
  `flex: 1`; the row's full width now drives the split.
- Trigger position + menu anchoring: trigger now sits next to the
  last visible pill, and the menu opens directly under the trigger
  via a `position: relative` `.overflow-anchor` wrapper.
- Overflow zone not registering as a drop target: switched from
  `pointer-events: none` / `transform: scale(0)` to
  `visibility: hidden` for the closed state. svelte-dnd-action's
  hit-test uses bounding-rect math (not `elementsFromPoint`), and
  `scale(0)` confuses its transform-undoing on percentage origins.
- Pre-mount the menu DOM on mousedown via `dragArmed` so the dndzone
  is registered before drag starts (mid-drag mount isn't picked up).
- Post-drop snap-back: set `dropCooldown = true` synchronously in
  finalize handlers, before flipping `isDragging`, so the resync
  effect doesn't clobber the post-drag zones before the persist
  microtask runs.
- Click-after-drop navigation: `dropClickGuard` swallows the
  synthetic click that fires on the dragged `<a>` after mouseup,
  preventing `goto()` from firing on every drop.
- Dashboard re-fetch flicker: `workspaceStore.setCurrent`'s
  synchronous `workspaces.find(...)` was leaking a reactive dep on
  `workspaceStore.workspaces` into both the workspace `+layout`
  effect and the dashboard `+page` load effect. Wrap both in
  `untrack(...)` so they only re-run on `wsSlug` change.
- Active-pin reject cleanup: rejection paths now call
  `clearCooldownAfterRejection()` so a stuck `dropCooldown` from
  the source-zone finalize doesn't gate sync effects forever.
- A11y: `aria-expanded` on the trigger now uses a `menuVisible`
  derived (`overflowOpen || isDragging || dragArmed`) so it matches
  the visual open state.
- Replace `CHROME_RESERVATION = 72` magic number with named parts
  derived from the actual CSS box model (= 68, was off by 4).
2026-04-25 19:14:58 -04:00
xarmian e5e2bd7b86 chore: flip CI only-new-issues=false + scope lint policy (TASK-771) (#253)
* chore: gate CI on full lint, scoped to checks we enforce (TASK-771)

Flip golangci-lint-action's only-new-issues from true to false so CI
fails on ANY linter finding, not just findings on PR-changed lines.
This catches lint regressions on the next push instead of letting them
drift into main.

The gate flip is paired with a deliberate scope-down of .golangci.yml:

1. errcheck is disabled. The codebase has 325 pre-existing unchecked-
   error sites where the error is intentionally discarded (best-effort
   logging writes, defensive parses with zero-valued fallbacks, etc.).
   Auditing every site is its own project — bigger than IDEA-732 by
   an order of magnitude. Tracked as a follow-up if/when we want the
   safety net back.

2. staticcheck is restricted to the SA* check family (real-bug
   detectors). The ST*/QF*/S* families are stylistic/quick-fix
   suggestions we don't gate CI on yet — they would have re-flooded
   the lint output with capitalized error strings, De Morgan's law
   simplification suggestions, etc., that aren't bug-finding signals.
   Re-enable selectively if the team wants them.

After scoping, the live linters are: govet, ineffassign, staticcheck
(SA*), unused, gofmt — exactly the set that IDEA-732 cleaned up.

Other changes in this PR:

- Drop pull-requests:read permission. It was only required by the
  golangci-lint-action when only-new-issues=true (the action used it
  to fetch PR diff metadata). Not needed any more.
- Update the Run-golangci-lint comment block to explain the new
  policy and reference the IDEA-732 cleanup PRs (#247/#249/#251/#252).
- Replace the SA4017 //lint:ignore directive in cmd/pad/main.go:4631
  with an inline //nolint:staticcheck — the multi-line //lint:ignore
  block was too far from the if statement for staticcheck's
  proximity rule, so the directive wasn't taking effect.
- Apply gofmt -w on three files where post-deletion blank-line
  artifacts had drifted (cmd/pad/main.go imports, two trailing newline
  fix-ups in handlers_items.go and middleware_ratelimit.go).

Verified:
- `golangci-lint run ./...` reports 0 issues.
- `go build ./...` clean.
- `go vet ./...` clean.
- `go test ./...` all pass.

Parent: PLAN-644.

* chore: address Codex round 1 on PR #253 (TASK-771)

Two LOWs from Codex on the gate-flip PR:

1. //nolint:staticcheck was broader than necessary (suppressed any
   future staticcheck diagnostic on the line) and didn't self-report
   when the underlying false positive gets fixed upstream. Codex
   suggested swapping back to a tightly-placed //lint:ignore SA4017.

   I tried that, but golangci-lint v2's staticcheck integration does
   not honour //lint:ignore the way direct staticcheck does — the
   directive was silently no-op'd via golangci-lint while the same
   directive worked when staticcheck was invoked directly.

   So instead of fighting the linter wrapper, sidestep the false
   positive entirely: rewrite the keepalive check from
   `strings.HasPrefix(line, ":")` to `len(line) > 0 && line[0] == ':'`.
   Same observable behaviour for a single-byte ASCII prefix, no
   suppression directive needed at all, no exposure when staticcheck
   eventually fixes the false positive.

2. The new lint-step comment in ci.yml said main is "clean of
   staticcheck SA*/U1000" — but U1000 is reported by the standalone
   `unused` linter in .golangci.yml, not by staticcheck.checks.
   Tighten the comment to attribute each enforced check correctly.

Verified:
- `golangci-lint run ./...` reports 0 issues
- `go test ./cmd/pad/...` passes (the SSE watch loop is exercised by
  reconcile_test.go and the broader integration tests).
2026-04-25 12:36:05 -04:00
xarmian dd381e1066 chore: delete 5 unwired document handlers (TASK-769) (#252)
* chore: delete 5 unwired document handlers (TASK-769)

internal/server/handlers_documents.go had 5 dead HTTP handlers that
were drafted as Documents-v1 extensions but never wired into the
router (server.go:509 already labels Documents itself as "v1, will be
replaced by items in Phase 2"):

- handleQuickSave (POST /documents/quick-save) — title-based upsert
- handleBulkRead (POST /documents/bulk-read) — multi-doc fetch by IDs
- handleGetBacklinks (GET /documents/{id}/backlinks)
- handleGetLinks (GET /documents/{id}/links)
- handleGetContext (GET /documents/context?type=)

Investigation confirmed zero consumers:

- Not registered in setupRouter (`grep -n "QuickSave\|BulkRead\|Backlinks\|GetLinks\|GetContext" server.go` → empty).
- Not used by the SvelteKit frontend (`web/src/`).
- Not used by the CLI (`internal/cli/`).
- Pre-launch repo, no fork or downstream that could be relying on them.

Delete scope is intentionally limited to the HTTP handlers. The
underlying `Store.QuickSave / BulkRead / GetBacklinks / GetLinks /
GetContext` methods stay — they're tested at the store level
(internal/store/store_test.go) and preserve optionality if Phase 2
work needs to revive any of these features. `models.QuickSave` stays
for the same reason.

After this lands, IDEA-732's lint catalog is fully cleared on main
(staticcheck SA* + U1000 returns zero). TASK-771 (flip CI
only-new-issues=false) becomes safe.

Verified:
- `go build ./...` clean
- `go vet ./...` clean
- `go test ./...` all pass
- `staticcheck -checks "SA*,U1000" ./...` clean
- All `import "strings"` etc. still used elsewhere in file

Parent: PLAN-644.

* chore: also delete now-test-only document store helpers (TASK-769)

Codex round 1 on PR #252 flagged that the document-store helpers
retained for "Phase 2 optionality" are now exclusively kept alive by
their own store tests — Store.QuickSave, BulkRead, GetBacklinks,
GetLinks, GetContext are not called by any production code path after
the handler deletions in the previous commit. Same for the
models.QuickSave struct.

Pre-launch with no external consumers, optionality preservation has a
real cost (dead code on main, ongoing test maintenance). When Phase 2
needs any of these capabilities it is cheaper to re-derive them
against the Items model than to drag dead Documents-v1 plumbing
forward. So delete them now.

Removed:
- internal/store/documents.go: QuickSave (38 lines), BulkRead (28),
  GetBacklinks (15), GetLinks (28), GetContext (41).
- internal/models/document.go: QuickSave struct.
- internal/store/store_test.go: TestQuickSave (38 lines), TestBulkRead
  (16), TestDocumentLinking (29), TestContext (23).

Kept:
- TestDocumentLinkRename — exercises UpdateDocument's internal
  link-rewriting path, not any of the deleted helpers.
- GetDocumentByTitle — still used by TestDocumentLinkRename.
- The full CRUD/restore handlers and their store methods — these are
  still wired into setupRouter and have their own coverage.

Verified:
- `go build ./...` clean
- `go vet ./...` clean
- `go test ./...` all pass (TestDocumentLinkRename and the wider doc
  CRUD/version/activity tests still cover the surviving paths).
- `staticcheck -checks "SA*,U1000" ./...` clean
- No new unused imports introduced (links package is still used by
  documents.go for ReplaceTitle in UpdateDocument).

Parent: PLAN-644.

* chore: drop GetDocumentByTitle and refactor TestDocumentLinkRename (TASK-769)

Codex round 2 caught the chain — after deleting QuickSave/BulkRead/
GetBacklinks/GetLinks/GetContext, Store.GetDocumentByTitle was kept
alive by exactly one test (TestDocumentLinkRename), which was
re-fetching by title only because the test ignored the *Document
already returned by createTestDoc.

Use the createTestDoc return value instead, then drop GetDocumentByTitle
from the store. Same idea, cleaner test, one fewer test-only API on
the store. The rename behaviour (the actual thing under test) is
unchanged.

Verified:
- `go build ./...` clean
- `go test ./internal/store` and `./internal/server` pass
- `staticcheck -checks "SA*,U1000" ./...` still clean

Parent: PLAN-644.

* chore: drop now-orphaned links.Extract (TASK-769)

Codex round 3 caught the next link in the chain: after Store.GetLinks
was deleted, links.Extract had no remaining callers — links.ReplaceTitle
is the only Extract-package function still used (by UpdateDocument's
rename rewrite). The linkPattern regex was only used by Extract.

Drop linkPattern, the regexp import, and Extract itself. Leaves
ReplaceTitle and its private string helpers (replaceAll, indexOf)
intact.

The cleanup chain ends here: ReplaceTitle is still wired into a live
production path (Documents-v1 rename), and the supporting helpers
have no other roles to inherit.

Verified:
- `go build ./...` clean
- `go test ./internal/store` and `./internal/server` pass
- `staticcheck -checks "SA*,U1000" ./...` clean

Parent: PLAN-644.
2026-04-25 12:22:53 -04:00
xarmian 3c3251f5af chore: drop dead visibility-filter block in dashboard handler (TASK-765) (#251)
* chore: drop dead visibility-filter block in dashboard handler (TASK-765)

internal/server/handlers_dashboard.go had a 10-line block that built a
visibility-filtered `filtered` collections slice and reassigned it back
to `collections` — but `collections` was never read after the
reassignment, so the filtering had no effect. Staticcheck flagged it
as SA4006 (line 220) + SA4010 (line 217) — same dead block, two
findings.

Investigation confirmed this is leftover refactor scaffolding rather
than a missing-filter bug:

- The summary section (line 236) uses `allItems` from
  `ListItems(workspaceID, {CollectionIDs: dashCollIDs, ItemIDs: dashItemIDs})`.
- Active plans, attention items, suggestions all use the same
  `dashCollIDs`/`dashItemIDs` parameters plus inline
  `isCollectionVisible(child.CollectionID, visibleIDs)` checks.

So visibility is already correctly applied to every dashboard output —
through the `dashCollIDs` / `dashItemIDs` path established earlier at
lines 185-190 — not through the deleted block. The previous comment
("drives the collection-summary section") was misleading; the summary
section reads items, not the `collections` slice.

Replace the dead block with an inline note explaining where visibility
*actually* gets applied so the next reader doesn't reach for the
filter pattern again.

Verified:
- `go build ./...` clean
- `go test ./internal/server/...` all pass (Dashboard tests cover this
  path).
- `staticcheck -checks "SA4006,SA4010" ./...` clean

Parent: PLAN-644.

* docs: tighten dashboard visibility comment per Codex round 1 (TASK-765)

Codex review on PR #251 flagged that my replacement comment claimed
dashCollIDs/dashItemIDs is THE filter path for all dashboard outputs,
but for graph-walking outputs (plan progress, blocked attention,
suggested next) final visibility actually comes from a combination of
the ListItems-param filtering and per-item isCollectionVisible /
isItemVisibleToGuest checks.

Tighten the comment to call out both layers so future readers know
the canonical answer is "dashCollIDs/dashItemIDs PLUS per-item
visibility checks", not "dashCollIDs/dashItemIDs alone".

The MEDIUM finding (dashboard visibility tests don't exercise the
filter path) is a pre-existing coverage gap, not a regression from
this PR. Tracked as its own follow-up task under PLAN-644 rather than
expanding the scope of this cleanup. See the PR body for the link.
2026-04-25 12:02:37 -04:00
xarmian bf5ab5b366 chore: clear staticcheck SA + U1000 findings on main (TASK-764) (#249)
* chore: clear cosmetic staticcheck findings (TASK-764)

Apply zero-behavior-change fixes for 8 staticcheck findings on main:

- SA4023 cmd/pad/main.go:431 — drop always-true `if eventBus != nil`
  guard. eventBus is wrapped in metrics.NewInstrumentedBus a few lines
  above, which returns a concrete *InstrumentedBus that is never nil.
- SA1019 cmd/pad/main.go:3926 — replace deprecated strings.Title with
  golang.org/x/text/cases.Title(language.English).String. golang.org/x/text
  was already an indirect dep; now promoted to direct.
- SA4031 internal/server/handlers_changes.go:130 — delete dead
  `if updatedItems == nil { ... }` block. make([]T, n) always returns
  non-nil; the JSON marshalling already produced [] not null.
- SA9003 cmd/pad/init.go:351 — delete empty if branch and fold its
  intent into the surrounding comment.
- SA9003 internal/server/handlers_dashboard.go:125 — replace empty
  `if err == nil { ... }` branch with `_ = json.Unmarshal(...)` to
  match the sibling settings parse and document the best-effort intent.
- SA4006 internal/cli/format.go:153 — drop the dead initial
  `titlePart := item.Title` (overwritten in both branches below);
  declare titlePart with `var` instead.
- SA4006 internal/store/workspaces.go:70 — drop the dead first call
  to s.uniqueSlug; only the workspace-specific uniqueWorkspaceSlug
  is meaningful (workspace slugs are globally unique, not workspace-
  scoped like collection/item slugs).
- SA4000 internal/store/store_test.go:99 — remove always-true outer
  `if idx := len(connStr) - len(connStr); idx >= 0` and unindent the
  inner '?' query-string split.

go.mod side effects from `go mod tidy` under Go 1.26: golang.org/x/text
moves to direct (used directly now); pquerna/otp, prometheus/client_*
and trustelem/zxcvbn move from indirect to direct (they were already
used directly — Go 1.26's tidy correctly classifies them).

Verified:
- `go build ./...` clean
- `go vet ./...` clean
- `go test ./...` all pass (including the replaceDBName test path)
- `staticcheck -checks "SA1019,SA4000,SA4006,SA4023,SA4031,SA9003"` clean
  except for handlers_dashboard.go:221 (SA4006, dashboard visibility-
  filter dead block — handled in TASK-765)

Parent: PLAN-644.

* fix: clear SA5011 nil-deref in buildReconcileFindings (TASK-764)

extractItemStatus(item.Fields) on the first line of the function would
have panicked on a nil item before the `if item != nil && item.CodeContext
== nil` guard could fire. Staticcheck SA5011 flagged the inconsistency.

Drop the (item != nil) half of the guard — the function now documents
its non-nil contract in the doc comment. All callers (reconcile.go:204
plus three sites in cmd/pad/reconcile_test.go) already pass non-nil,
so this is documentation, not behaviour change.

Verified:
- `go build ./...` clean
- `go test ./cmd/pad/...` passes (the existing reconcile tests cover the
  contract)
- `staticcheck -checks SA5011 ./...` clean

Parent: PLAN-644.

* chore: silence SA4017 false positive in watchCmd SSE loop (TASK-764)

cmd/pad/main.go SSE keepalive branch:

    if strings.HasPrefix(line, ":") {
        continue
    }

Staticcheck SA4017 reports "HasPrefix doesn't have side effects and
its return value is ignored" — but the return value IS used as the
if condition. Two sibling strings.HasPrefix calls earlier in the same
for-loop body (matching "event: " and "data: " prefixes) are not
flagged, which strongly suggests an SSA-analysis quirk specific to
this branch rather than a real defect.

Suppress the finding with a //lint:ignore directive that explains
the false positive in-place. Rewriting to a different form (extract
to a bool var, comma-OK on a synthetic value, etc.) would be uglier
than the suppression comment.

Verified:
- `staticcheck -checks SA4017 ./...` clean
- `go build ./...` clean

Parent: PLAN-644.

* chore: delete dead code flagged by U1000 (TASK-764)

Pre-launch (no external contributors yet) — no consumer fork can be
relying on these unreferenced symbols, so we delete them rather than
carry the maintenance burden into v1.

## Helpers (14 functions, 1 type)

cmd/pad/main.go
- progressBar — never called

internal/cli/format.go
- stripHTMLTags — never called

internal/server/handlers_dashboard_test.go
- updateItem (test helper) — never called from any test

internal/server/handlers_items.go
- publishItemEvent — wrapper over publishItemEventWithName; all 5 call
  sites use the *WithName variant directly.
- resolveRelationFields — never called.
- resolveRelationFieldFiltersForWorkspace, resolveRelationFieldFilters,
  relationFilterKeys, resolveRelationFilterValue — closed loop of dead
  helpers (each one only called by another dead one in the family).
- extractStatus — never called (cmd/pad/reconcile.go has its own copy).

internal/server/handlers_versions.go
- handleGetDiff (HTTP handler) — never wired into setupRouter.
- diffsToChanges, diffChange (type) — only used by handleGetDiff above.
- Removes now-unused imports `strconv` and `dmp` (sergi/go-diff).

internal/server/middleware_ratelimit.go
- writeTooManyRequests — never called; the live ratelimit middleware
  uses a dedicated 429 path with Retry-After-Bucket headers.

internal/server/server.go
- guestVisibleItemIDs — never called. handlers_events.go had a
  comment cross-reference; updated to drop the reference.

## Constants

internal/events/redis_bus.go
- reconnectDelay — never read.

internal/store/api_tokens.go
- defaultTokenExpiryDays — never read.

## Out of scope
The 5 unwired handlers in internal/server/handlers_documents.go are
left alone: they are the subject of TASK-769 (a product decision —
wire up vs. delete — that may want different treatment per handler).

The two SA4006/SA4010 findings on internal/server/handlers_dashboard.go
visibility-filter block are similarly left for TASK-765.

## Verified
- `go build ./...` clean
- `go vet ./...` clean
- `go test ./...` all pass
- `staticcheck -checks "SA*,U1000" ./...` clean except the two TASK-
  765 / TASK-769 follow-ups noted above.

Parent: PLAN-644.

* docs: correct caller name in buildReconcileFindings doc (TASK-764)

Codex round 1 caught: the doc comment named the caller `reconcileSingle`
but the actual function is `reconcileItem` (cmd/pad/reconcile.go:204).
Fix the contract comment so it doesn't go stale on the first git blame.
2026-04-25 11:53:31 -04:00