mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-21 01:53:33 +00:00
504d348917c2fb8ed2c139bbbc352e07fccae19a
77 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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.)
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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).
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
119e2d8aa2 |
feat(billing): payment-failed email endpoint + template (TASK-712 1 of 2) (#232)
* feat(billing): payment-failed email endpoint + template (TASK-712 1 of 2) Pairs with pad-cloud's invoice.payment_failed webhook handler (shipping next) to give paying users a chance to update their card before dunning exhausts and the subscription cancels. pad owns the Maileroo integration and the user→email mapping; the sidecar forwards the invoice metadata here. Changes: - email.Sender.SendPaymentFailed — new template (HTML + plain). Subject "Your Pad payment couldn't be processed"; body names the amount + next retry date when provided, falls back to generic copy when Stripe omits them, and CTAs to the billing portal so the user can update their card. Transactional (no unsubscribe link) — users who want the emails to stop either fix their card or cancel the subscription. - POST /api/v1/admin/payment-failed — new cloud-secret-gated endpoint (handlers_cloud.go). Accepts stripe_customer_id + optional pre- formatted amount_display + next_retry_display. Looks up the user, sends the email, logs a payment_failed_email_sent audit entry. Returns 200 + email_sent=false with a reason string for every non-error skip (unknown customer, no email on file, Maileroo not configured) so the sidecar never rolls back the Stripe webhook over an email failure. Returns 200 + email_sent=false + reason=send_failed when Maileroo itself errors — still no rollback. - Registered the path in cloudAdminPaths, the server router, and the CloudAdmin rate limiter so the sidecar's calls share the same rate bucket as /plan + /stripe-customer-id. - ActionPaymentFailedEmailSent audit constant for the new entry. - Three focused tests: cus_ prefix validation, unknown-customer 200, and email-not-configured 200. Added an entry to the cloud-mode gate table-driven test to confirm /admin/payment-failed also 404s when cloud mode is off. Parent: PLAN-645 (Pad Cloud Beta Readiness). TASK-712 bullet 3, pad side. pad-cloud's handlePaymentFailed wiring ships in a sibling PR. * fix(billing): audit every outcome; target user ID; add send-path tests (Codex round 1) Addresses PR #232 round 1 findings: MEDIUM — payment-failed handler only wrote an audit row on the actual send attempt, so no_customer / no_email_address / email_not_configured skip paths left no durable trail. Consolidated the audit + response into a single auditAndRespond closure called from every outcome branch, so operators can always reconstruct whether (and why) a customer was notified during dunning reconciliation. MEDIUM — audit UserID was set to actorID, which is empty for sidecar calls. /audit-log?user=<target-user-id> would never surface these events. Now set UserID to targetUser.ID whenever we have one; the no_customer branch still writes a row but with empty UserID (filtered only by action + stripe_customer_id metadata). Moved actor identity into an actor_is_admin metadata field instead. LOW — test coverage was thin: no assertion on the most important contract ("return 200 with reason=send_failed and still record the attempt"), no test of the happy send path, no audit-log assertions. Added email.Sender.SetEndpoint (exported, test-only — comment says so) so tests can point the Sender at a mock Maileroo server, plus three new tests: - TestPaymentFailed_HappyPath_SendsAndAudits - TestPaymentFailed_MailerooError_Returns200_SendFailed_AndAudits - TestPaymentFailed_UnknownCustomer_AuditsWithoutUserID The first two verify audit metadata per outcome; the third proves unknown-customer cases still leave a findable audit row. Thread-safety fix as a side-effect: Send/SendAs were reading s.endpoint outside the sender's RWMutex — fine before the mutable SetEndpoint existed, now a data race. Pulled the endpoint read into the same RLock scope as fromAddr/fromName. * fix: capture admin actor ID + audit-log formatter for payment_failed (Codex round 2) Addresses PR #232 round 2 findings: MEDIUM — auditAndRespond recorded actor_is_admin=true/false but not which admin. For manual operator-triggered calls, that meant the audit trail could not answer "who sent the dunning email?" when multiple admins touched the endpoint. Added admin_actor_id to the metadata whenever the authenticated caller has role=admin. Sidecar calls with no authenticated user still have no admin_actor_id, which correctly distinguishes them from manual admin operations. LOW — web/src/routes/console/admin/audit-log/+page.svelte falls back to "first 3 metadata keys" when no formatter exists for an action, which could hide the important reason/sent fields. Added a dedicated case for payment_failed_email_sent that renders either "sent (cus_...)" or "skipped: <reason> (cus_...)" depending on the outcome, matching the terse display style of the other switch cases. * fix(audit-log): distinguish send_failed from skip; surface admin actor (Codex round 3) Addresses PR #232 round 3 LOWs: - The formatter lumped every sent=false outcome under 'skipped', which conflates a genuine Maileroo delivery failure with a pre-send skip. Now: sent → 'sent (...)'; send_failed → 'send failed (...)'; other reasons → 'skipped (<reason>) (...)'. - admin_actor_id was recorded in metadata but invisible in the UI: the User column shows the target user via a.user_id. Appended 'by admin:<id>' to the formatted string whenever admin_actor_id is present, so manual operator calls are attributable at a glance. Sidecar calls have no admin_actor_id and render without the suffix. * fix(audit-log): register payment_failed_email_sent in action filter dropdown (Codex round 4) The backend emits payment_failed_email_sent and the custom formatter knows how to render it, but the audit-log page's ACTION_TYPES / ACTION_LABELS registry omitted the action, so admins couldn't filter for these events from the dropdown — undercutting the dunning reconciliation workflow this PR is adding. Added 'payment_failed_email_sent' to the ACTION_TYPES list and 'Payment Failed Email' to ACTION_LABELS. |
||
|
|
775dd89fdc |
feat(cloud): add /admin/stripe-event-unmark endpoint (TASK-736 / 1 of 2) (#228)
* feat(cloud): add /admin/stripe-event-unmark endpoint (TASK-736 / 1 of 2) Parent: PLAN-645. Pair with pad-cloud follow-up. * fix(cloud): add processed_at race protection + audit log per Codex review (round 1) |
||
|
|
6cda2da48d |
feat(billing): cancel Stripe customer on account delete (TASK-690) (#227)
* feat(billing): cancel Stripe customer on account delete (TASK-690) Parent: PLAN-645. Pair with pad-cloud PR #12. * fix(billing): abort on all non-200 per Codex review (round 1) * fix(billing): env wiring + docstrings + partial_delete test per Codex review (round 2) * fix(compose): wire cloud env vars from .env per Codex review (round 3) |
||
|
|
0cbadf873b |
feat(server): durable Stripe webhook idempotency endpoint (TASK-696) (#226)
Adds a new cloud-gated admin endpoint that the pad-cloud sidecar uses
to record-or-detect-duplicate Stripe webhook events. Previously the
sidecar tracked processed event IDs in an in-memory map, which lost
state on restart and caused Stripe's 72h retries to re-run handlers.
Changes:
migrations/045 + pgmigrations/025
New stripe_processed_events(event_id PK, processed_at) table +
index on processed_at for the pruning query.
store/stripe_events.go
MarkStripeEventProcessed(eventID) — INSERT ... ON CONFLICT DO
NOTHING; returns alreadyProcessed from RowsAffected. Atomic.
PruneStripeProcessedEvents(maxAge) — DELETE WHERE processed_at < ?.
ShouldPruneStripeEvents() — ~1% random sample via crypto/rand.
server/handlers_cloud.go
handleStripeEventProcessed — POST /api/v1/admin/stripe-event-processed.
Validates cloud_secret, requires event_id with 'evt_' prefix,
returns {event_id, already_processed}. Opportunistically fires
a background prune ~1% of calls (7-day retention window covers
Stripe's 72h retry with a safe margin).
Adds the new path to cloudAdminPaths so the secret-marker gate
accepts X-Cloud-Secret / body-secret auth here too.
server/server.go
Registers POST /api/v1/admin/stripe-event-processed under the
existing requireCloudMode group.
server/middleware_ratelimit.go
Adds the new path to the cloud-admin rate-limit bucket alongside
/admin/plan, /admin/stripe-customer-id, /admin/user-by-customer.
server/cloud_admin_gate_test.go
Adds self-host-404 test case + two new tests:
TestStripeEventProcessed_RecordsAndDetectsDuplicates
TestStripeEventProcessed_ValidatesEventIDPrefix
Design notes in the PR body.
Parent: PLAN-645 (chunk 3).
|
||
|
|
46fa72ca0f |
feat(server): log session IP changes, add optional PAD_IP_CHANGE_ENFORCE=strict (TASK-666) (#191)
* feat(server): log session IP changes, add optional PAD_IP_CHANGE_ENFORCE=strict (TASK-666)
Sessions stored a client IP at creation but never rechecked it. A stolen
cookie could be used from anywhere with no signal to the owner. This
change adds mid-lifetime IP-change detection without breaking legitimate
mobility (mobile roaming, VPN toggles, carrier NAT) by default.
- New audit action ActionSessionIPChanged captures {old_ip, new_ip} in
the audit metadata. Visible via the existing /api/v1/admin/audit-log.
- handleSessionIPChange wired into both SessionAuth (cookies) and
TokenAuth (padsess_ bearer). After UA check passes, compares stored
session IP to clientIP(r). On mismatch:
- log one audit row
- update the stored session IP so we don't spam the log
- strict mode: DeleteSession + 401 "session_ip_changed"
- default mode: let the request through
- Store.UpdateSessionIP lets middleware refresh the recorded IP without
tearing down the session.
- PAD_IP_CHANGE_ENFORCE=strict env var + ip_change_enforce TOML key +
Server.SetIPChangeEnforce setter (case-insensitive, trims whitespace).
- Table-driven tests cover log-only, strict rejection with session
destruction, and setter parsing edge cases.
Parent: PLAN-643 (OSS Security Hardening).
* fix(server): dedupe session-IP-change audit via CAS, handle browser vs API paths per Codex review
Addresses two P2 comments on PR #191:
1. Race: parallel requests after an IP change could each emit
ActionSessionIPChanged before any of them updated the stored IP,
producing duplicate audit rows for a single transition.
- Replace UpdateSessionIP with UpdateSessionIPIfEquals (compare-and-set
on ip_address). Only the request that actually rotates the stored
value logs; concurrent siblings lose the CAS and skip logging.
- New test TestSessionIPChange_CASDedupesRace fires 20 concurrent
requests from the new IP and asserts exactly 1 audit row.
2. Strict-mode 401 on non-API paths:
- In current routing the SPA is mounted on the root router outside
the auth Group, so SessionAuth only fires for /api/* in practice.
The original concern about JSON 401s on browser navigation doesn't
surface today, but defense-in-depth keeps the code forward-safe:
restructure handleSessionIPChange to return a four-state outcome
(Continue / AllowedLogged / Revoked / Terminated) and only write
the JSON 401 on /api/* paths. Revoked + non-API falls through
unauthenticated so a future SPA-in-group configuration would still
render a login screen instead of raw JSON.
- Clear the session cookie (MaxAge=-1) in strict rejection so the
browser stops sending the now-revoked token on the next request.
TestSessionIPChange_StrictClearsCookies verifies the Set-Cookie.
Parent: PLAN-643 (OSS Security Hardening), TASK-666.
* fix(server): strict mode destroys session atomically, never rotate stored IP when destroying (TASK-666)
Addresses Codex P1 on PR #191: previously we rotated the session's stored
ip_address via UpdateSessionIPIfEquals BEFORE attempting DeleteSession.
If the DELETE failed (transient DB error) the row remained alive —
rebound to the attacker's new IP — so follow-up requests saw stored IP
== client IP and passed handleSessionIPChange's "match, no-op" branch.
That silently defeated strict enforcement.
- New Store.DeleteSessionIfExists returns (bool, error) to serve as the
CAS primitive for strict mode: only the caller whose DELETE affected a
row emits the audit entry, and a DB error fails closed (500 — "Unable
to validate session") rather than letting the request through.
- handleSessionIPChange splits into two paths:
* log-only mode: UpdateSessionIPIfEquals for CAS dedup (unchanged)
* strict mode: DeleteSessionIfExists is the CAS; stored IP is NEVER
rotated so any failure leaves the session bound to the OLD IP and
subsequent requests from the new IP still mismatch + still reject.
- TestSessionIPChange_StrictDestroysSessionAtomically regression test
verifies a second request from the new IP with the same token still
fails after the first strict-mode rejection.
Parent: PLAN-643 (OSS Security Hardening).
* fix(server): exempt public API paths from strict IP-change termination (TASK-666)
Addresses Codex P2 on PR #191: SessionAuth runs for every /api/* path,
including public endpoints like /api/v1/auth/login, /api/v1/auth/register,
/api/v1/health, /api/v1/s/* (share links), and /api/v1/plan-limits. In
strict mode, a stale session cookie on those requests was rejected with
a 401 session_ip_changed BEFORE the public handler could run — the user
literally couldn't log back in because their own stale cookie blocked
the login call.
- Extract isPublicAPIPath as a shared helper between RequireAuth and
handleSessionIPChange so they can't drift out of sync.
- handleSessionIPChange strict-mode flow now: destroy session + clear
cookies + audit log (unchanged), then for public API paths return
Revoked so the handler still runs. For authenticated-only API paths
still return Terminated (401). For non-API paths return Revoked for
the SPA fallback.
- Updated TokenAuth Revoked handler to match: pass through unauth on
public paths, 401 on authenticated-only.
- TestSessionIPChange_StrictAllowsPublicAPIPaths regression test:
a stale session cookie on /api/v1/auth/login must NOT produce
session_ip_changed; /api/v1/plan-limits must still return 200.
Parent: PLAN-643 (OSS Security Hardening).
* fix(server): short-circuit SessionAuth on token auth + fix IPv6 clientIP parsing (TASK-666)
Addresses two more Codex comments on PR #191:
P1 — SessionAuth 401'd API-token-authenticated requests:
TokenAuth sets currentUser for user-owned tokens AND tokenWorkspaceID
for legacy workspace-scoped tokens. SessionAuth short-circuited only on
currentUser, so a workspace-scoped-token request that happened to carry
a stale session cookie with a mismatched IP would be rejected by the
IP-change strict path before RequireAuth could honor the token. Extend
the short-circuit to also check tokenWorkspaceID; either signal is
enough to say "token auth already succeeded, skip cookie validation".
P2 — clientIP mangled IPv6 addresses:
clientIP used strings.LastIndex(":") on RemoteAddr. For bare IPv6
addresses like "2001:db8::1" (which TrustedProxyRealIP writes verbatim
from X-Forwarded-For, no brackets/port), that strips the final hextet
to "2001:db8:" — unusable for comparison in the new IP-change audit
path and incorrect for rate-limit keys too. Switch to net.SplitHostPort
which handles both "host:port" and "[ipv6]:port", falling back to the
raw RemoteAddr when no port is present (the trusted-proxy rewrite
case).
Tests:
- TestClientIP_IPv6NotMangled covers IPv4 w/wo port, bracketed IPv6,
bare IPv6 (no port, no brackets), and loopback forms.
- TestSessionAuth_ShortCircuitsOnAPITokenAuth exercises the worst case:
strict mode + valid API token + stale session cookie + new client IP.
Request must succeed (token wins) and NO new session_ip_changed audit
row must appear.
Parent: PLAN-643 (OSS Security Hardening).
* fix(server): canonicalize IPs before session-IP-change comparison (TASK-666)
Addresses Codex P2 on PR #191: raw-string comparison of session.IPAddress
vs clientIP(r) would fire session_ip_changed spuriously when the same
IPv6 address arrived in different valid textual representations (the
trusted-proxy path writes X-Forwarded-For verbatim, and different hops
normalize differently — "2001:0db8::1" vs "2001:db8::1" etc.).
- canonicalIP helper: net.ParseIP + stringify to collapse equivalent
IPv6 forms (compressed vs expanded, case, leading zeros) and IPv4-in-
IPv6 into a single canonical string. Non-parseable inputs pass through
unchanged so debug/malformed values behave predictably.
- handleSessionIPChange compares and logs the canonical forms. The CAS
still passes session.IPAddress (the raw stored value) to the DB — the
compare-and-set is about row identity — but the new IP written in is
the canonical form so future comparisons are stable.
- TestCanonicalIP covers empty, IPv4, shorthand "::1", expanded 8-group
equivalent, mixed-case 2001:DB8::1, fully expanded 2001:0db8:…:0001,
and non-IP fallback.
Parent: PLAN-643 (OSS Security Hardening).
|
||
|
|
a2eaac4a37 |
fix(server): reject CORS wildcard when credentials are on (TASK-664) (#188)
PAD_CORS_ORIGINS accepted any string (including '*') while the CORS middleware ran with AllowCredentials=true unconditionally. Browsers refuse the combination per the Fetch spec, so a typo like PAD_CORS_ORIGINS=* "worked" in curl but failed silently from every real browser — and without an explicit carve-out, an anon cross-origin fetch still rode the victim's cookies when origins were empty. - parseCORSOrigins: explicitly drop '*' with a log warning. When '*' was the ONLY configured origin, fall back to localhost defaults rather than producing an empty allowlist. - corsAllowCredentials: new helper — AllowCredentials=true only when an operator has set PAD_CORS_ORIGINS. Default false keeps a browser on a different origin from piggy-backing cookies on the user's session when no remote origin was expected in the first place. - server.go: wire up corsAllowCredentials(s.corsOrigins) into the cors.Options. Tests: - TestParseCORSOrigins gains three '*'-handling cases (lone '*', mixed, trailing '*'). - TestCorsAllowCredentials covers empty/whitespace default, explicit origins, and tab-only input. Parent: PLAN-643 (OSS Security Hardening). |
||
|
|
baa1f75847 |
fix(server): cap JSON body + header size (TASK-663) (#184)
* fix(server): cap JSON body + header size (TASK-663) decodeJSON called json.NewDecoder(r.Body).Decode(v) with no size limit. Any client could POST a multi-GB JSON blob and watch Pad stream the whole thing into one allocation — a single request could OOM the process. - internal/server/server.go: wrap r.Body in http.MaxBytesReader(..., 2 MB) inside decodeJSON. Every legitimate payload (item, collection, auth, etc.) is well under 100 KB so 2 MB is several orders of magnitude above real traffic. Factor out decodeJSONWithLimit(maxBytes) so future bulk-import endpoints can opt in to a larger cap without removing the wrapper. - internal/server/server.go: set MaxHeaderBytes = 64 KiB on the http.Server (default is 1 MB). Plenty for cookies/auth/CORS while cheaply rejecting header-flood DoS. Test: decode_json_test.go covers the 3 MiB body rejection, a happy path, and a custom-limit override that rejects a 1 MiB body under a 256 KiB cap. Parent: PLAN-643 (OSS Security Hardening). * fix(server): bump workspace import JSON cap to 64 MiB per Codex P1 Codex flagged that handleImportWorkspace inherits the new 2 MiB default cap, but WorkspaceExport contains full collections, items, comments, and item_versions for the workspace — a realistic project backup routinely exceeds 2 MiB, so existing exports stop re-importing. Switch to decodeJSONWithLimit(64 << 20). 64 MiB is multiple orders of magnitude above any realistic single-workspace backup while still far from heap-exhaustion territory. |
||
|
|
c2b67f5a9d |
fix(server): gate cloud admin endpoints via requireCloudMode (TASK-655) (#182)
* fix(server): gate cloud admin endpoints via requireCloudMode (TASK-655) middleware_auth.go:184-189 and middleware_csrf.go:44-48 permanently exempted /api/v1/admin/plan, /admin/stripe-customer-id, and /admin/user-by-customer from RequireAuth and CSRFProtect — by path, not by credential. In self-host mode these endpoints still responded to every anonymous network caller (with "Cloud mode not configured"), confirming their existence and telegraphing that the auth surface was non-standard. Three tightly-coupled changes: 1. Narrow both carve-outs from path-based to credential-based. The new isCloudSecretAuthAttempt(r) helper checks for X-Cloud-Secret header or legacy ?cloud_secret query-param; only requests that present one bypass auth/CSRF. Cookie-based admin callers continue through the normal session + CSRF gate. 2. Wrap the three endpoints in a dedicated requireCloudMode group. Self-host mode → 404, no endpoint-existence disclosure. 3. Admin callers via cookie now properly require CSRF for these endpoints (they previously bypassed), bringing them in line with every other /admin/* endpoint. Tests (cloud_admin_gate_test.go): - TestCloudAdminGate_SelfHost_Returns404 — anon + X-Cloud-Secret in self-host → 404 (requireCloudMode fires). - TestCloudAdminGate_NoCloudSecret_RequiresAuth — cloud mode + no secret → 401 from auth gate (not the old "Cloud mode not configured"). - TestCloudAdminGate_ValidCloudSecret_PassesAuthAndCSRF — sidecar with matching X-Cloud-Secret reaches the handler; neither 401 nor 403. - TestCloudAdminGate_QueryParamSecret_BackwardCompat — legacy ?cloud_secret= on GET still works (TASK-656 removes this next). Parent: PLAN-643 (OSS Security Hardening). * fix(server): scope cloud-secret auth bypass to cloud admin paths per Codex P0 Codex caught a regression in the first cut: isCloudSecretAuthAttempt(r) only checked for the presence of X-Cloud-Secret/?cloud_secret, so setting either header on ANY path (e.g. GET /api/v1/workspaces) would bypass RequireAuth globally. An anonymous attacker could list or create workspaces just by adding one of those markers. Add a cloudAdminPaths whitelist and require the request path to be one of the three cloud admin endpoints before honoring the bypass. Defined as a map so a future /api/v1/... route can't accidentally inherit it. Regression test TestCloudAdminGate_BypassScopedToCloudPaths: - GET /workspaces + X-Cloud-Secret → 401 (not bypass) - GET /workspaces?cloud_secret=x → 401 (not bypass) - POST /workspaces + X-Cloud-Secret → 4xx (CSRF 403 or auth 401) * fix(server): make cloud-secret path gate visible at call sites Codex re-flagged the path scoping on PR #182 — even after the fix, the helper name 'isCloudSecretAuthAttempt' made the path scoping invisible at the call site. Split into two primitives: - isCloudAdminPath(path) — path whitelist check - hasCloudSecretMarker(r) — header/query marker check Both middleware now combine them explicitly: if isCloudAdminPath(path) && hasCloudSecretMarker(r) { ... } Behaviorally identical to the previous fix — tests still show GET /workspaces with X-Cloud-Secret returning 401, POST /workspaces with X-Cloud-Secret returning 403. Just makes the invariant readable in RequireAuth and CSRFProtect without having to jump to the helper. * fix(server): preserve body-cloud_secret auth for sidecar POSTs per Codex P1 Codex caught that POST sidecar calls carrying cloud_secret only in the JSON body (the current pad-cloud sidecar behavior) would fail at RequireAuth/CSRFProtect after this PR — handler-level validation never runs. Breaking deployed sidecars isn't the intent of TASK-655; TASK-656 deprecates body+query cloud_secret in favor of X-Cloud-Secret header exclusively, but that's a separate migration. Add body peek to hasCloudSecretMarker for POST/PUT requests with application/json content-type: - Read up to 64 KB of r.Body into a buffer. - Replace r.Body with an io.NopCloser wrapping the buffer so downstream handlers can still decode the JSON. - Return true if the parsed body has a non-empty cloud_secret field. Parse errors and missing fields → false (request falls through to the normal auth rejection, no permissiveness). The peek only runs when the caller is already hitting a cloud admin path via the explicit isCloudAdminPath() gate at the call sites, so the body-read cost is bounded to three endpoints. Test: TestCloudAdminGate_BodySecret_BackwardCompat posts with cloud_secret in the JSON body and no X-Cloud-Secret header, asserts the request reaches the handler (404 from unknown user_id, not 401/403 from middleware). |
||
|
|
7d3b468fc8 |
feat(server): gate /metrics behind loopback + bearer token (TASK-653) (#180)
cmd/pad/main.go:277 unconditionally registered Prometheus metrics and internal/server/server.go:229 served /metrics with no auth/CSRF. Any caller on the network could read workspace counts, API usage patterns, and (via label enumeration) user/workspace IDs. Three-layer gate: 1. Loopback-only default. No PAD_METRICS_TOKEN configured → /metrics accepts loopback peers only (safe for self-hosters running Prometheus on the same host, which is the common case). Non-loopback peers get 403 with a clear message. 2. Bearer-token mode. PAD_METRICS_TOKEN set → every scrape must send "Authorization: Bearer <token>", compared in constant time. Missing or wrong header → 401 with WWW-Authenticate: Bearer realm="metrics". 3. Rate-limit/logging chain still wraps the endpoint from the outer router.Use calls. Wiring: - internal/config/config.go — MetricsToken field + PAD_METRICS_TOKEN env. - cmd/pad/main.go — plumb cfg.MetricsToken into SetMetricsToken. - .env.example — document PAD_METRICS_TOKEN with openssl-rand hint. - internal/server/server.go — metricsAuth middleware + subtle.ConstantTimeCompare. Tests: metrics_auth_test.go covers loopback allowed, LAN denied, missing/wrong/correct Bearer, non-Bearer scheme rejected, WWW-Authenticate header, and the SetMetrics-absent 404. Parent: PLAN-643 (OSS Security Hardening). |
||
|
|
fc5a54dff7 |
fix(server): read raw TCP peer for loopback check (TASK-662) (#175)
* fix(server): read raw TCP peer for loopback check (TASK-662) TrustedProxyRealIP rewrites r.RemoteAddr when the peer is a trusted proxy. Without additional defense, an attacker reaching a trusted reverse proxy could set X-Forwarded-For: 127.0.0.1 and trick the bootstrap loopback check into accepting them as a local caller — reopening the full-instance-takeover path that TASK-660 closed at the spoof layer. Add CapturePeerAddr middleware that runs BEFORE TrustedProxyRealIP and stashes the untampered r.RemoteAddr in request context. Change requestIsLoopback to read via rawPeerAddr(r) (context-first, with a safe fallback for test paths that skip the middleware). r.RemoteAddr stays the rewritten value for the rate-limiter / audit-log paths that actually want the client's IP. Tests cover: direct loopback → true; direct LAN → false; trusted proxy forwarding spoofed 127.0.0.1 → false; untrusted peer with spoofed XFF=127.0.0.1 → false; and that rawPeerAddr falls back to r.RemoteAddr when CapturePeerAddr is absent. Parent: PLAN-643 (OSS Security Hardening). * fix(server): require loopback peer AND no proxy headers for bootstrap (Codex P1) Codex caught a regression in the initial PR: reading rawPeerAddr(r) made every request through a same-host reverse proxy look loopback, so a Caddy or nginx on 127.0.0.1 forwarding public traffic would let attackers reach the bootstrap endpoint from the internet. Tighten the rule to two independent conditions: 1. The untampered TCP peer is a loopback address. 2. Neither X-Forwarded-For nor X-Real-IP is set. A legitimate local CLI calling Pad directly satisfies both. A reverse proxy forwarding public traffic always sets the forwarding headers, so the presence of either disqualifies the request. The raw-peer check still defeats X-Forwarded-For spoofing from non-loopback attackers, and now also handles the Codex-flagged scenario where a local proxy is trusted or left misconfigured. Tests updated to cover: direct loopback no-headers allowed; loopback peer + XFF rejected; loopback peer + X-Real-IP rejected; IPv6 loopback allowed. |
||
|
|
ec9edef68c |
fix(server): gate RealIP on PAD_TRUSTED_PROXIES (TASK-660) (#173)
Replace the unconditional chimiddleware.RealIP with a middleware that only trusts X-Real-IP / X-Forwarded-For when the direct TCP peer is within a configured CIDR. With the safe default (PAD_TRUSTED_PROXIES unset) proxy headers are ignored entirely — the real TCP peer address is used for rate limiting, the bootstrap loopback check, and audit logs. Why: previously any client could set X-Forwarded-For to bypass per-IP rate limits AND the bootstrap loopback check (handlers_auth.go). On a direct-exposed Docker deploy (see M6, TASK-661) this compounded into a full-takeover chain. Gating RealIP breaks that chain even when the operator forgets to firewall the port. - internal/server/middleware_realip.go — new TrustedProxyRealIP middleware + ParseTrustedProxyCIDRs helper (accepts CIDRs or bare IPs, invalid entries logged+skipped, empty = nil result = no-op middleware). - internal/server/server.go — swap chimiddleware.RealIP for the gated version; add trustedProxyCIDRs field and SetTrustedProxies wiring. - internal/config/config.go — TrustedProxies field + PAD_TRUSTED_PROXIES env var. - cmd/pad/main.go — plumb config to the server. - internal/server/middleware_realip_test.go — covers no-trust default, untrusted peer, trusted peer with X-Real-IP, X-Forwarded-For first entry, and invalid header. Parent: PLAN-643 (OSS Security Hardening). |
||
|
|
204d63151f |
feat(server): strict-dynamic CSP + fail-fast missing index.html (TASK-375) (#172)
Completes the remaining items on the nonce-based CSP work: 1. Add 'strict-dynamic' to script-src. In CSP-L3 browsers this supersedes the 'self' host-list, so a future XSS that injects <script src="//evil"> is blocked even though 'self' is still listed (kept as fallback for older browsers). The SvelteKit bootstrap script already dynamically imports the runtime chunks, which is exactly the pattern strict-dynamic is designed to permit. 2. Fail fast when the embedded index.html can't be read. The previous silent-swallow returned blank HTML to every SPA request, which is a broken build that the operator should notice immediately. Panic at startup so the server refuses to come up with a broken UI. Parent: PLAN-643 (OSS Security Hardening). |
||
|
|
4297689e23 |
fix(server): add script-src-attr 'none' to CSP (TASK-648) (#171)
Inline event handlers (onerror, onload, onclick, …) bypass the script-src directive per CSP spec. Without script-src-attr 'none' an attacker who slips markup past the DOMPurify sanitizer can still execute JavaScript via event attributes — defeating the whole point of the nonce-based script-src. Add 'script-src-attr 'none'' to both CSP headers: - internal/server/middleware_security.go — strict policy for API responses - internal/server/server.go — nonce-based policy for HTML pages Defense-in-depth for TASK-647 (comment markdown sanitizer) and for any future regression in HTML-emitting paths. Parent: PLAN-643 (OSS Security Hardening). |
||
|
|
844e40f0a9 |
feat: add star/unstar API endpoints (#116)
* feat: add star/unstar API endpoints
Add REST API for item starring (PLAN-564, TASK-566):
- POST /workspaces/{ws}/items/{slug}/star — star item (idempotent, 204)
- DELETE /workspaces/{ws}/items/{slug}/star — unstar item (204 or 404)
- GET /workspaces/{ws}/items/{slug}/star — check star status ({"starred": bool})
- GET /workspaces/{ws}/starred — list starred items (?include_terminal=true)
All endpoints are scoped to the authenticated user, check item visibility
via RBAC/grants, and enrich list responses with parent links and refs.
* fix: enforce RBAC visibility filtering on starred items list
Apply the same collection/item grant filtering used by handleListItems
to handleListStarredItems. Without this, guests or restricted members
could see starred items from collections they no longer have access to.
|
||
|
|
56adba4b58 |
feat: add invitation management panel for admin console (#107)
* feat: add invitation management panel for admin console Platform-wide view of all pending invitations with search, resend, and revoke. Resend creates a fresh invitation code and sends the email. New admin endpoints: GET/POST/DELETE for /admin/invitations. * fix: check email opt-out on resend, abort on stale delete, reload list Respect unsubscribe preferences before resending invitation emails. Abort resend if the old invitation was already accepted/revoked concurrently. Reload the full invitations list after resend since the row ID changes. |
||
|
|
86451174ad |
feat: add user detail panel with workspace memberships (#106)
* feat: add user detail panel with workspace memberships
New GET /api/v1/admin/users/{id}/workspaces endpoint returning workspace
name, slug, role, and join date. Frontend loads memberships when a user
row is expanded and displays them as a linked list with role badges.
* fix: scope workspace fetch error/loading to active selection
Gate both the catch and finally blocks with a selectedId check so stale
requests from previously selected users don't wipe workspace data or
clear the loading indicator for the current selection.
|
||
|
|
d968b551b7 |
feat: add account disable/deactivation (#104)
* feat: add account disable/deactivation for admin users Allow admins to soft-disable user accounts without deleting data. Disabled users get a 403 on all authenticated requests, their sessions are invalidated on disable, and they show as visually dimmed with a red "disabled" badge in the admin console. Includes migration for disabled_at column, auth middleware check, disable/enable endpoints with audit logging, and frontend toggle with confirmation dialog. * refactor: auto-discover migrations from embedded filesystem Replace hardcoded migration lists with fs.ReadDir on the embedded FS directories. New migrations are now picked up automatically by filename sort order — no need to manually register them in store.go. * fix: block disabled users at login and capture IDs before async calls Reject disabled accounts in the login handler before session creation, not just in RequireAuth middleware (which exempts auth routes). Also capture selectedId into a local const in all async admin panel functions to prevent stale updates if the selection changes during a request. * fix: enforce disabled check in OAuth and password reset flows, always invalidate sessions Block disabled users in all session-minting paths (OAuth login, password reset) not just password login. Also remove early return for already-disabled users in the disable endpoint so session invalidation always runs, handling retry after partial failure. |
||
|
|
79d7d26a00 |
feat: add admin password reset for other users (#103)
* feat: add admin password reset for other users
New POST /api/v1/admin/users/{id}/reset-password endpoint. When email is
configured, sends a password reset link. Otherwise generates a temporary
password and invalidates existing sessions. Includes audit logging via
new password_reset_by_admin action and frontend UI with confirmation.
* fix: treat session revocation and email send as hard failures
Make session invalidation failure abort the reset instead of silently
continuing, and send the reset email synchronously so delivery failures
are surfaced to the admin caller.
|
||
|
|
7ca0463e70 |
feat: browser-based CLI authentication flow (#97)
Replace the email/password terminal prompt in `pad auth login` with a
browser-based auth flow. The CLI creates a pending session, prints a URL
the user opens in their browser (works for localhost, remote VPS, or
Pad Cloud), and polls until the session is approved.
- Add CLI auth session endpoints (create, poll, approve)
- Add browser approval page at /auth/cli/{code}
- Rewrite `pad auth login` to use browser flow by default
- Keep `pad auth login --interactive` as email/password fallback
- Add login page redirect param support for post-login bounce-back
- Add SQLite and PostgreSQL migrations for cli_auth_sessions table
Closes PLAN-539, IDEA-404
|
||
|
|
1ba9c91992 |
feat: email unsubscribe for non-transactional emails (#96)
* feat: email unsubscribe for non-transactional emails Add CAN-SPAM compliant unsubscribe support: - New email_optouts table (by email address, not user ID) so uninvited recipients can opt out without an account - HMAC-signed unsubscribe tokens (derived from Maileroo API key) so links work without authentication - GET /api/v1/unsubscribe endpoint with simple HTML confirmation page - Invitation emails now include unsubscribe footer link - Welcome emails accept unsubscribe URL parameter - Before sending invitation emails, check opt-out table and silently skip opted-out addresses (prevents invite spam) - Password reset emails are exempt (transactional, user-initiated) Fixes BUG-256. * fix: hide "Copy invite link" when code is unrecoverable For hashed invitations the plaintext code can't be recovered, so the button was copying a broken URL. Now shows "Sent via email" label instead. Only shows the copy button when join_url or code is available. Fixes BUG-255. |
||
|
|
92580905bb |
feat: cloud hardening and security follow-ups (PLAN-503)
Address 11 issues identified during the PLAN-427 security review: Critical/High: - Stripe customer-to-user mapping with indexed lookup (TASK-505) - OAuth provider linking with explicit consent model (TASK-504) - CSRF tokens on admin console mutations (TASK-506) - Rate limiting on cloud admin and OAuth endpoints (TASK-507) Medium: - __Host- cookie prefix for subdomain protection (TASK-510) - Billing portal verifies customer ownership server-side (TASK-515) - Transactional account deletion with rollback (TASK-509) - Streaming data export with 60s timeout (TASK-508) - Migration registration for new columns (TASK-514) Low: - Billing page fetches actual plan limits from API (TASK-511) - Admin user search/filter pushed into SQL with pagination (TASK-512) |
||
|
|
d0518216c5 |
feat: add cloud infrastructure for hosted Pad (PLAN-427)
Add the foundation for running Pad as a hosted service at app.getpad.dev. Same binary in cloud mode with a thin sidecar for OAuth and Stripe. Cloud mode (PAD_CLOUD=true): - PAD_CLOUD flag with cloud secret for sidecar communication - Account-level billing: plan field on users, CheckLimit enforcement - Free/Pro tiers with configurable limits stored in platform_settings - Three-tier limit resolution: user overrides → DB defaults → hardcoded fallback - Plan enforcement on workspace, item, member, webhook, and token creation Authentication & security: - OAuth login endpoint (POST /api/v1/auth/oauth-login) with cloud secret gate - Verified email requirement for OAuth, 2FA bypass protection - Cloud secret rotation support (comma-separated keys) - TOTP secret encryption at rest (AES-256-GCM via PAD_ENCRYPTION_KEY) - Rate limiting on OAuth login endpoint - Bootstrap disabled in cloud mode - Password max length enforcement (128 chars) - Config file written with 0600 permissions Admin & billing: - Admin user management API (list, detail, update plan/overrides) - Configurable plan limits API (GET/PATCH /api/v1/admin/limits) - Platform stats endpoint - Admin plan endpoint for sidecar to set user plans - GDPR: account deletion and data export endpoints Console UI (cloud mode only): - /console — workspace list with owned/shared sections - /console/new — create workspace wizard with slug preview - /console/settings — profile, password, API tokens - /console/billing — plan status, upgrade/manage links - /console/admin — user management, plan overrides, limits editor - OAuth buttons (GitHub/Google) on login page in cloud mode Auto-create default workspace on signup in cloud mode. Migration 035: plan, plan_expires_at, stripe_customer_id, plan_overrides on users. |
||
|
|
94d35509a4 |
feat: share links with hardened security, anonymous access, and analytics (#88)
* feat: share links with hashed tokens and /s/{token} route
Add share_links and share_link_views tables with CRUD API and
anonymous resolution route (TASK-421).
Data model:
- share_links: token_hash (SHA-256), target_type/id, permission,
password_hash, expires_at, max_views, require_auth, view tracking
- share_link_views: per-view records with fingerprint/user tracking
Token security:
- 192-bit entropy (crypto/rand), URL-safe base64 encoding
- SHA-256 hashed at rest, raw token returned only once on creation
- Generic 404 for invalid tokens (no info leakage)
- /api/v1/s/ exempt from auth middleware for anonymous access
API endpoints:
- POST /items/{slug}/share-links — create item share link
- POST /collections/{coll}/share-links — create collection share link
- GET /items/{slug}/share-links — list share links for item
- GET /collections/{coll}/share-links — list for collection
- DELETE /share-links/{id} — revoke share link
- GET /s/{token} — resolve share link, return shared content
D8: Anonymous users are ALWAYS read-only. View count and unique
viewers tracked on each resolution.
* feat: anonymous share page + share link management UI
Add minimal-chrome share link viewer page and share link CRUD in
the share dialog (TASK-422 + TASK-425).
Share page (/s/{token}):
- New SvelteKit route at /s/[token] for anonymous viewing
- Renders item (title, fields, markdown content) or collection
(name, item list) with no app chrome (no sidebar/topbar)
- Handles require_auth links with "Sign in to view" prompt
- Root layout bypasses auth checks for /s/ routes
- "Powered by Pad" footer
Share dialog updates:
- "Share links" section below existing grants
- Create/list/revoke share links for items and collections
- Copy-to-clipboard for share URLs
- Newly created links highlighted with "only shown once" notice
- View count and auth-required badges
API client:
- ShareLink type added
- shareLinks.* methods for CRUD
- share.get(token) for anonymous resolution
* feat: share link constraints + view analytics
Add password protection, expiry, max views, and view history
endpoints for share links (TASK-423 + TASK-424).
Constraints (TASK-423):
- CreateShareLink accepts ShareLinkOptions: password, expires_at,
max_views, require_auth, restrict_to_email
- Password hashed with bcrypt, verified on /s/{token} resolution
- Password-protected links return {require_password: true} prompt
- Expiry and max_views already validated by ValidateShareLink
Analytics (TASK-424):
- GET /share-links/{id}/views returns view history with fingerprint,
user ID, and timestamp
- Response includes total_views, unique_viewers, last_viewed_at
- View history stored per-view in share_link_views table
* fix: harden share links — XSS, access control, data leakage, and UX gaps
- Sanitize rendered markdown with DOMPurify before {@html} injection (XSS)
- Force require_auth=true when restrict_to_email is set (access bypass)
- Reject malformed non-empty JSON bodies with 400 instead of failing open
- Return public DTOs on share endpoints to prevent leaking internal IDs,
creator info, assignees, schemas, and other sensitive fields
- Enforce max_views atomically via conditional UPDATE to prevent races
- Fix collection share rendering: read items from top-level response key
and map ref/status fields correctly
- Add password prompt UI and X-Share-Password header support so
password-protected links can actually be unlocked by the frontend
* fix: follow-up hardening for share links
- Sanitize catch fallback in rendered markdown (XSS edge case if marked throws)
- Remove query-string password fallback; accept only X-Share-Password header
to avoid leaking passwords in logs, browser history, and referrers
- Return 500 on ListItems DB failure instead of swallowing as empty collection
- Normalize restrict_to_email with ToLower/TrimSpace on create and compare
- Fix malformed JSON check for chunked bodies (ContentLength == -1)
by checking for io.EOF instead of ContentLength > 0
- Remove internal share_link.id from public DTO responses
- Use clientIP(r) helper for consistent fingerprinting instead of raw
X-Forwarded-For which is spoofable and includes port in RemoteAddr
- Distinguish DB errors from not-found in share link delete handler
* fix: final hardening pass for share links
- Move auth/email gate before password check to prevent unauthenticated
callers from probing passwords and burning bcrypt CPU
- Wrap view recording (counter increment, unique-viewer accounting, view
insert) in a single transaction so a failed insert rolls back the
consumed view count instead of silently losing it
- Add X-Share-Password to CORS AllowedHeaders so cross-origin
deployments can send the custom header without preflight rejection
- Validate expires_at (RFC3339) and max_views (> 0) on share link
creation; return 400 for invalid constraints instead of creating
immediately-unusable links
- Cap view-history endpoint limit to 1000 to prevent unbounded queries
|
||
|
|
c6d19837c8 |
feat: collection & item grants, guest access, share dialog (PLAN-407 Phase 3) (#87)
* feat: collection and item grants tables + permission resolution
Add grant tables, CRUD operations, and permission resolution for
guest access and member overrides (TASK-417).
Data model:
- collection_grants table (id, collection_id, workspace_id, user_id,
permission, granted_by) with CASCADE on collection/user delete
- item_grants table (same structure, references items)
- Indexes for user/collection/item lookups
Store methods:
- Create/Get/List/Delete for both collection and item grants
- ListUserGrants: all grants for a user across a workspace
- RevokeAllUserGrants: bulk delete for member removal
- ResolveUserPermission: full 5-step resolution per DOC-406
(owner → item grant → collection grant → membership → deny)
API endpoints:
- GET/POST/DELETE /collections/{coll}/grants — collection grant CRUD
- GET/POST/DELETE /items/{slug}/grants — item grant CRUD
- GET /users/{userID}/grants — all grants for a user in workspace
All grant endpoints are owner-only for creation/deletion.
* feat: grant revocation + member removal with grant choice
Update member removal to support D4: owner chooses whether to revoke
all grants when removing a member (TASK-489).
- DELETE /members/{userID}?revoke_grants=true → remove membership AND
all collection/item grants (full removal)
- DELETE /members/{userID} (or revoke_grants=false) → remove membership
but keep grants (user becomes a guest with existing access)
- Audit log records whether grants were revoked
- CASCADE DELETE on collection/item deletion already handles cleanup
(via ON DELETE CASCADE in the grants migration)
* feat: share dialog UI for items and collections + grant types
Add a share dialog component for managing grants on items and
collections, plus TypeScript types and API client methods (TASK-419).
Frontend:
- ShareDialog.svelte: reusable modal for listing/creating/revoking
grants, with email input, permission select, and revoke buttons
- Item detail page: "Share" button in meta-actions (owner-only)
- Collection page: "Share" button in header actions (owner-only)
TypeScript:
- CollectionGrant and ItemGrant types added
- API client: grants.listCollectionGrants, createCollectionGrant,
deleteCollectionGrant, listItemGrants, createItemGrant,
deleteItemGrant, listUserGrants
Guest home screen (TASK-418) deferred — requires layout-level guest
detection which will be implemented when guest routing is built.
* feat: guest access — grants-based workspace access for non-members
Allow authenticated users with grants (but no workspace membership)
to access workspaces as guests (TASK-418).
Backend:
- UserHasGrantsInWorkspace: checks if user has any collection/item
grants in a workspace
- GuestVisibleCollectionIDs: returns collections visible to a guest
via collection grants + collections containing granted items
- RequireWorkspaceAccess: after member-nil check, falls through to
grant check; sets role to "guest" if grants exist
- VisibleCollectionIDs: non-members now checked for guest grants
instead of returning empty
- GetUserWorkspaces: includes guest workspaces (is_guest flag)
- GetWorkspacesBySlugForUser: JOINs on grants tables so workspaces
resolve for guests
- roleLevel: "guest" = 0 (below viewer, blocks role-gated actions)
Frontend:
- Workspace.is_guest field in TypeScript type
- Sidebar: hides Dashboard, Roles, Activity, Settings, and "New
collection" button for guests; shows "Shared with you" header
* feat: wiki-link rendering with locked icon for hidden items
Update wiki-link rendering to show a 🔒 locked icon when the linked
item is in a collection the user can't see (TASK-420).
- renderMarkdown accepts optional visibleCollectionSlugs parameter
- Items in hidden collections render as "🔒 Title" with tooltip
- Unresolved links still render as broken (no change)
- Username param added to renderMarkdown for correct URL construction
- TimelineCommentCard and CommentThread accept username prop
* fix: harden grant security — 9 findings from Codex review
- Item grants no longer leak collection-wide read access; guests with
item-level grants see only their granted items, not the full collection
(GuestVisibleResources two-level filter + ItemIDs in ListItems SQL).
- Edit grants are now enforced: mutating handlers (create/update/delete
items, comments, reactions, links, versions) resolve grant-based
permissions for guests via requireEditPermission + ResolveUserPermission.
- Grant list endpoints restricted to owners (collection/item grants) or
owner-or-self (user grants) to prevent metadata/email enumeration.
- Guests blocked from listing workspace members; invitation details
restricted to owners only.
- Grant deletion scoped to workspace_id to prevent cross-workspace
deletion by guessing grant IDs.
- Member removal now revokes grants by default (opt-out with
?revoke_grants=false) and propagates revocation errors instead of
silently discarding them.
- Guest workspace listing properly propagates DB errors instead of
swallowing them.
- PostgreSQL subquery alias added to UserHasGrantsInWorkspace to fix
silent guest-access failures on Postgres deployments.
* fix: harden item-level grant isolation — 7 findings from Codex re-review
- /changes endpoint now filters by item-level grants so guests with one
item grant no longer receive updates for every item in that collection.
- Search results filtered by item-level grants (new ItemIDs field in
SearchParams) so guests can't discover other items via search.
- Relationship/summary endpoints (item links, children, progress,
activity, dashboard) all apply item-level visibility checks via
isItemVisibleToGuest(), preventing metadata leakage through related
item titles, statuses, and counts.
- Grants now work as member overrides: a viewer with an edit grant can
edit the granted item (requireEditPermission falls back to
ResolveUserPermission for members below editor role).
- handleMoveItem now requires edit permission on the target collection,
not just visibility, preventing guests from moving items into
view-only collections.
- Member removal + grant revocation is now atomic via
RemoveWorkspaceMemberAndRevokeGrants() which wraps both operations
in a single database transaction.
- Guest-access DB errors in middleware now return 500 with slog.Error
instead of being silently collapsed into a 403 forbidden response.
* fix: close remaining grant isolation gaps — 10 findings from Codex round 3
- Workspace token endpoints (create/list/delete) now require owner role,
preventing guests from enumerating or revoking API tokens.
- Legacy document endpoints (list, get, context, bulk-read, backlinks,
links) now require at least viewer role, blocking guests entirely
since documents are outside the grants model.
- Global search no longer relies on workspaceRole() (which is unset
outside RequireWorkspaceAccess); detects guests via IsWorkspaceMember
and applies item-level filtering. Multi-workspace search now uses
GuestVisibleResources for guest workspaces.
- SSE event filtering now checks item IDs for guests with item-level
grants, not just collection slugs, preventing live event leaks.
- Role board passes ItemIDs through RoleBoardParams so guests only
see items they have grants on, not the entire collection.
- VisibleCollectionIDs for members with "specific" collection access
now merges direct grants (collection + item grants), so grant
overrides work for restricted members.
- Plans-progress endpoint filters plan items and children by item-level
grants for guests, preventing one plan grant from exposing all plans.
- Webhook listing now requires owner role since URLs may contain secrets.
- Agent role item counts use item-level filtering for guests.
- Link deletion checks item-level visibility on both endpoints, not
just collection-level.
* fix: close member grant escalation and remaining edge cases — round 4
- Item grants for restricted members no longer escalate to collection-
wide visibility. VisibleCollectionIDs now merges only direct collection
grants (not item-derived collections) into member access. Item-level
filtering (guestResourceFilter, isItemVisibleToGuest, requireItemVisible)
now applies to both guests AND restricted members with item grants,
closing the gap where a member with specific collection access plus
one item grant could see/edit all items in that collection.
- Guests blocked from workspace-level activity feed (/activity) which
exposed audit events (member invites, role changes) with operational
metadata. Requires at least viewer role.
- Global search no longer returns zero results for item-only guests.
Store.Search early-return now checks both CollectionIDs and ItemIDs
are empty before short-circuiting, so item-level grants work in
global (multi-workspace) search.
- UserHasGrantsInWorkspace now excludes item grants on soft-deleted
items, preventing phantom guest access to a workspace shell with
no visible content when the only granted item is archived.
* fix: prevent grant filter from overriding member access, close SSE/dashboard/collection leaks — round 5
- guestResourceFilter now returns nil/nil for members with "all"
collection access, preventing item grants from accidentally replacing
their full visibility. Only guests and members with "specific"
collection access get item-level filtering applied. This fixes a
regression where a normal member receiving one item grant would lose
access to all other items.
- requireItemVisible uses guestResourceFilter (with the same scoping)
instead of raw GuestVisibleResources, so the member-access check is
consistent throughout all code paths.
- SSE event filtering now denies collection-less events (workspace
updates, legacy document events) for guests, preventing metadata
leakage through realtime event payloads.
- Dashboard recent activity filters out workspace-level entries (no
DocumentID) for guests, preventing audit metadata leakage.
- All grant visibility queries (UserHasGrantsInWorkspace,
GuestVisibleCollectionIDs, GuestVisibleResources) now join the
collections table and require deleted_at IS NULL, so grants on
soft-deleted collections no longer provide phantom access.
* fix: make item grants additive for restricted members, close write/search/SSE gaps — round 6
- guestResourceFilter now merges member_collection_access + system
collections + collection grants into fullCollIDs for restricted members,
making item grants additive to existing access. Previously, item grants
replaced the member's normal collections, causing members with one item
grant to lose all their other collection visibility.
- Added ListSystemCollectionIDs store method for system collection lookup.
- Search (both global and workspace-scoped) now applies item-level
filtering for restricted members with item grants, not just guests.
Previously VisibleCollectionIDs included item-granted collections as
full-access, leaking all items in those collections via search.
- SSE event filtering now builds item-level filters for restricted
members with item grants (previously only for non-members/guests),
and merges member collections into the full-access set.
- Role board reorder now uses requireItemVisible + requireEditPermission
per item instead of collection-only visibility check, preventing
restricted editors from reordering items in item-granted collections.
- View create/update/delete now check requireEditPermission on the
collection (via requireViewEditable), not just collection visibility.
- GetUserWorkspaces guest query now joins collections/items tables to
exclude grants on soft-deleted resources, matching the behavior of
UserHasGrantsInWorkspace.
* fix: block guests from legacy doc versions/activity, fix ListItems early return, SSE fail-closed — round 7
- Legacy document version handlers (handleListVersions, handleGetVersion)
and document activity handler (handleListDocumentActivity) now require
at least viewer role, blocking guests from reading version history and
activity for unrelated legacy documents.
- ListItems early return now checks both CollectionIDs and ItemIDs are
empty before short-circuiting, matching the fix already applied to
Search. This fixes item-only guests seeing zero results from /items,
dashboard, role board, and agent-role counts.
- SSE item-grant filtering now fails closed on GuestVisibleResources
errors: installs empty item/collection filter sets instead of falling
through with nil (which would pass all events through).
- Role board reorder removed top-level requireMinRole("editor") so the
per-item grant-aware requireEditPermission checks can run for guests
and viewers with edit grants, consistent with other mutating handlers.
|
||
|
|
973887d5dd |
fix: comprehensive collection visibility enforcement
Close all identified bypass paths in the collection visibility system: HIGH: - Add requireItemVisible check to all 15+ item-by-slug handlers (get, update, delete, restore, move, children, progress, activity, versions, timeline, comments, links) - Filter incremental sync (GET /changes) by visible collections with proper error handling for deleted item lookups - Fix search to fail closed on visibility errors instead of removing the collection filter; apply per-workspace filtering in multi-workspace search path - Empty CollectionIDs (non-nil but len 0) now returns zero results in ListItems and Search instead of skipping the filter - Filter returned item links by linked item visibility; require target item visibility before creating links - Block moving items into hidden collections - Add visibility checks to comment-by-ID routes (delete, reply, add/remove reaction) MEDIUM: - SSE events for replies and reactions now include collection slug so visibility filtering can scope them; fail closed on visibility error - Parent/plan resolution in create/update checks resolved parent is in a visible collection - Progress endpoints compute from visible children only when user has restricted access - Role board reorder checks item visibility before allowing sort changes - Parent enrichment accepts optional visibility filter to hide parents from hidden collections - Add IsSystem: true to Conventions and Playbooks in defaults.go LOW: - Child listing handles visibility lookup errors instead of failing open - GetDeletedItemsWithCollection returns proper errors instead of swallowing them - SetMemberCollectionAccess wrapped in transaction with workspace validation for collection IDs |
||
|
|
0587deba41 |
feat: UI for managing member collection visibility
Add API endpoints and settings UI for managing per-member collection
access (TASK-416).
Backend:
- GET /members/{userID}/collection-access — returns mode + granted IDs
- PUT /members/{userID}/collection-access — sets mode + collection IDs
(owner-only)
Frontend:
- API client: getMemberCollectionAccess, setMemberCollectionAccess
- Settings Members tab: "Manage access" button per member (owner-only)
- Expandable inline panel with all/specific toggle
- Collection checkbox list: non-system collections toggleable, system
collections always checked + disabled with "system" tag
- Save/cancel with optimistic update
|
||
|
|
0fb8042ad2 |
feat: permission-filtered aggregates for all data endpoints
Wire collection visibility filtering into all data endpoints so members with "specific" access only see items in their visible collections (TASK-414). Core: - ItemListParams.CollectionIDs: SQL-level IN() filter on item queries - SearchParams.CollectionIDs: same for FTS search queries - visibleCollectionIDs() server helper computes once per request - isCollectionVisible() for single-item gating checks Filtered endpoints: - ListItems / ListCollectionItems: SQL-level collection ID filter - ListCollections: post-filter by visible set - Search: collection ID filter on both ref-lookup and FTS branches - Dashboard: all ListItems calls scoped, activity post-filtered - Activity feed: post-filtered by collection slug visibility - Collection items: gate check before listing Admins and "all access" members see everything (nil = no filter). The filtering is a no-op until a member's collection_access is set to "specific" via the management UI (TASK-416). |
||
|
|
1cbd7ba204 |
fix: resolve workspace routing regressions from username refactor
- Fix UUID-shaped workspace slugs: resolveWorkspace now falls back to slug-based lookup when a UUID doesn't match any workspace ID - Fix imported workspaces: ImportWorkspace accepts ownerID, handler sets authenticated user as owner and adds workspace membership - Fix generated username collisions: add EnsureUniqueUsername to append suffixes (-2, -3, etc.) when auto-generated usernames already exist - Fix handler-level UUID resolution: workspace CRUD handlers now use getWorkspace helper (reads middleware-resolved ID from context) instead of raw URL params with slug-only store methods |
||
|
|
117221c73d |
feat: auth-scoped workspace resolver with UUID support
Update workspace resolution to support both slugs and UUIDs, with
auth-scoped slug resolution for non-admin users (TASK-412).
Store:
- New GetWorkspacesBySlugForUser(slug, userID) method that finds
workspaces matching a slug where the user is owner or member
Server:
- New resolveWorkspace() method: UUID → direct lookup, slug → auth-scoped
for regular users, global for admins/unauthenticated
- RequireWorkspaceAccess middleware uses resolveWorkspace() and stores
resolved workspace ID in context (ctxResolvedWorkspaceID)
- getWorkspaceID() reads from context (fast path) or resolves directly
(fallback), eliminating redundant database lookups
API URL pattern unchanged — /api/v1/workspaces/{ws}/... where {ws}
now accepts both slug and UUID. CLI and frontend unaffected.
|
||
|
|
b1357799a9 |
feat: username validation, reserved words, and registration flow
Add username support to registration with validation, reserved words, and real-time availability checking (TASK-409). Backend: - ValidateUsername() with format/length/reserved word checks - 35+ reserved usernames (route conflicts, system terms) - GET /auth/check-username endpoint for real-time validation - handleBootstrap auto-generates username from name (D1) - handleRegister accepts optional username, auto-generates if omitted Frontend: - Register page: username field with auto-generation from name - Join/invite page: same username field in register mode - Debounced availability checking (400ms) via /auth/check-username - Inline status indicators (checking/available/taken) - API client: register() accepts username, new checkUsername() method |
||
|
|
1d26c2b542 |
feat: add workspace top bar with drag-to-reorder (#80)
* feat: add workspace top bar with drag-to-reorder Replace the sidebar WorkspaceSwitcher dropdown with a dedicated top bar that provides fast workspace switching and a user menu. Desktop: - Horizontal bar above sidebar + content with workspace icons (colored first-letter circles) and names as real <a> links - Drag-and-drop reorder via svelte-dnd-action - User avatar on right with dropdown (settings, theme toggle, sign out) - "+" button to create new workspaces Mobile: - Full-width fixed bar at top when sidebar opens (above sidebar/backdrop) - Tap workspace to navigate and close sidebar - Reorder button opens full-screen vertical list with drag handles - Sidebar starts below the top bar with adjusted positioning Backend: - Migration 028: add sort_order to workspace_members (per-user ordering) - GET /workspaces now returns workspaces in user's sort order - PUT /workspaces/reorder endpoint for persisting order Sidebar simplified: - Removed WorkspaceSwitcher component, user section, theme toggle - Theme initialization moved to root layout - Cleaner footer with search, settings, and notification bell Implements IDEA-129, relates to IDEA-126. * fix: address codex review findings (P1+P2) - Remove unsupported `direction` option from svelte-dnd-action dndzone - Add Postgres migration 008 for workspace_members.sort_order - Handle sql.ErrNoRows gracefully in reorder endpoint for admins who aren't members of all workspaces - Restore mobile sign-out: add user name + logout button to sidebar footer on mobile (was only in desktop TopBar user menu) |
||
|
|
7ef4506cfa |
fix: replace scattered tab-resume refetches with layered sync system
When the browser tab lost focus and regained it, 5 independent onTabResume callbacks all fired simultaneously, flooding the server with redundant requests. This replaces that pattern with a 4-layer sync architecture: 1. Replay buffer — per-workspace ring buffer stores recent events with monotonic IDs. On SSE reconnect, missed events are replayed via Last-Event-ID so the client is already caught up. 2. Last-Event-ID support — SSE handler reads the header, replays from buffer, or sends sync_required if the gap is too large. 3. Incremental sync — new /changes?since=<ms> endpoint returns only modified/deleted items since a timestamp, including archived items for view consistency. 4. Centralized sync coordinator — single decision tree replaces 5 scattered callbacks. Short absences skip sync entirely, SSE-covered gaps need no API calls, and full refresh is a last resort. Key robustness details: - Global event IDs via Redis INCR for multi-instance safety - Server-time cursors to avoid client clock skew - Safe cursor management (only advances on confirmed sync) - 9 new tests for replay buffer and event ID behavior Fixes BUG-26. |
||
|
|
0dc3f0b61f |
fix: address Codex review findings for TOTP 2FA
- Reject API token auth on 2FA enrollment endpoints (setup, verify, disable) to prevent account takeover via leaked tokens (P1) - Re-read 2FA challenge secret after persisting to handle multi-instance startup race on fresh databases (P2) |
||
|
|
10867b9210 |
fix: persist 2FA challenge key and prevent duplicate TOTP verification
- Persist the 2FA challenge HMAC signing key in platform_settings so tokens survive process restarts and work across multiple instances - Add AND totp_enabled = false to EnableTOTP WHERE clause so concurrent /auth/2fa/verify calls (double-click, multi-tab) cannot both succeed and overwrite each other's recovery codes |
||
|
|
5606b22007 |
fix: address 6 security findings from Codex review of TOTP 2FA
HIGH fixes: - Login-verify no longer accepts bare user_id. Now requires an HMAC-signed, IP-bound, 5-minute challenge token issued during login (prevents password bypass via known user ID + TOTP code) - Recovery codes are SHA-256 hashed before storage; plaintext is returned to the user once and never persisted MEDIUM fixes: - ConsumeRecoveryCode uses a DB transaction to prevent concurrent double-consumption of the same recovery code - EnableTOTP is atomic: WHERE clause requires totp_secret match to prevent TOCTOU race between setup and verify calls - /auth/2fa/login-verify now uses the strict Auth rate limiter (5 req/min/IP) instead of the general API limiter - CLI login detects requires_2fa response and prompts for TOTP code instead of silently saving empty credentials |
||
|
|
0e32645bb5 |
feat: add TOTP two-factor authentication
Backend support for optional TOTP-based 2FA on user accounts:
- POST /auth/2fa/setup — generate TOTP secret, return QR code URI
- POST /auth/2fa/verify — verify code and enable 2FA with recovery codes
- POST /auth/2fa/disable — disable 2FA (requires password confirmation)
- POST /auth/2fa/login-verify — complete login with TOTP or recovery code
- Login returns {requires_2fa: true, user_id} when 2FA is enabled,
requiring a second step via /auth/2fa/login-verify
- 8 recovery codes generated on setup for account recovery
- User model extended with totp_secret, totp_enabled, recovery_codes
- Refactored user queries with shared scanUser/userColumns for DRYness
Implements TASK-169 under PLAN-15 (Pad Cloud: Hardening).
|
||
|
|
ba8e20c697 |
feat: add API token rotation, expiry defaults, and scope enforcement
- New tokens get a default 90-day expiry (configurable via platform
settings: token_default_expiry_days, token_max_lifetime_days)
- POST /api/v1/auth/tokens/{id}/rotate generates a new secret while
preserving token metadata; old secret is immediately invalidated
- X-Token-Expires-Soon and X-Token-Expires-At headers warn when a
token is within 7 days of expiry
- Token scopes are now enforced: "read" restricts to GET/HEAD/OPTIONS,
"write" and "*" allow all methods
- Existing tokens without expiry continue to work (backward compatible)
Implements TASK-170 under PLAN-15 (Pad Cloud: Hardening).
|
||
|
|
30fe60d666 |
feat: session binding, nonce-based CSP, and auth hardening (#75)
* feat: add session binding, nonce-based CSP, and auth hardening Security hardening for Pad Cloud (PLAN-15 / TASK-171): - Bind sessions to User-Agent hash; mismatch invalidates session - Store client IP on session creation for audit trail - Increase bcrypt cost from 10 to 12 - Upgrade invitation codes to 128-bit entropy with hashed storage - Replace CSP unsafe-inline with per-request nonce for SvelteKit scripts - Move SecurityHeaders to main router so SPA gets headers too * fix: enforce session binding on auth cookie fallbacks and fix invitation code uniqueness - Add validateSessionCookie() helper that checks UA binding, replacing raw ValidateSession() calls in handleSessionCheck, handleGetCurrentUser, and handleUpdateCurrentUser that bypassed the new session binding - Store invitation ID in code column instead of empty string to satisfy the NOT NULL UNIQUE constraint (previously broke on second invitation) - Skip code/join_url in invitation listings for hashed invitations where the plaintext is not recoverable |