mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-21 01:53:33 +00:00
b165e5fe7aaa9ca2a6824f02b990fdb7fe0dffd8
364 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
b165e5fe7a |
fix(server): summarise structured field changes in activity feed (BUG-748) (#236)
* fix(server): summarise structured field changes in activity feed (BUG-748)
The activity-feed `metadata.changes` string is built by `diffFields()` in
`handlers_documents.go`, which used `fmt.Sprintf("%v", val)` to stringify
each old/new value. For structured fields (implementation_notes,
decision_log, or any other slice/map value in item.fields) Go's default
formatting dumps the raw map repr — e.g.
implementation_notes: → [map[created_at:2026-04-23T... details:Code audit
on 2026-04-23 found Phases 1, 2, and most of Phase 3 already implemented:
- **Phase 1a** ... created_by:user summary:Phases 1-3 verified shipped]]
Activity cards on the item detail page surfaced this verbatim, leaking
internal field shape into the UI.
Replace the bare `%v` with a `formatChangeValue` helper:
- Primitives (string/number/bool): unchanged Go default formatting.
- Slices: counted summary. Known fields get domain-specific phrasing
(`(1 note)` / `(N notes)` for implementation_notes, `(1 entry)` /
`(N entries)` for decision_log); unknown fields fall back to
`(N items)`.
- Maps/objects: `(object)` placeholder.
- nil: empty string.
This is a backend-only change. The frontend `TimelineActivityCard.svelte`
keeps splitting on `→` exactly as before, so the contract is unchanged
beyond the value-formatting.
Companion to PR #235 (frontend `.prose` class fix on TimelineCommentCard).
Together they close BUG-748 — markdown content was unrenderable both
in plain timeline comments AND in activity-feed change pills that
referenced structured field updates.
Tests: 9 new cases in handlers_documents_test.go covering primitives,
added/removed fields, implementation_notes single + plural, decision_log
single + plural, generic slice fallback, object fallback, invalid JSON,
and nil safety. All green.
Verified: go build ./..., go vet ./..., go test ./..., web/npm run build
all clean.
* fix(server): compare values, not display strings, in diffFields (Codex round 1)
Codex flagged two MEDIUM regressions in PR #236 round 1:
1. Object-valued fields (e.g. `convention`, `github_pr`) all stringify to
the same `(object)` label, so an in-place edit produced
oldStr == newStr == "(object)" and `diffFields()` silently dropped the
change from `metadata.changes` — the activity card stopped recording
that the field had been edited.
2. Same problem for slice fields when length is unchanged: replacing one
`implementation_note` with a different one (`{"summary":"original"}`
→ `{"summary":"revised"}`) collapsed both sides to `(1 note)` and
the change vanished from the activity log.
Switch the equality check from string-on-display to `reflect.DeepEqual`
on the raw decoded values. The display strings still go through
`formatChangeValue()` so the activity card stays clean (`(1 note) → (1
note)` for a same-cardinality replacement is coarse but correct — the
user knows something changed and can drill in via the timeline). For
truly identical values the entry is omitted, so no false positives.
`reflect.DeepEqual` is correct for the types `json.Unmarshal` into
`map[string]any` produces: nil, bool, float64, string, []any,
map[string]any.
New tests:
- TestDiffFieldsSameCardinalityArrayChangeStillReported
- TestDiffFieldsObjectMutationStillReported
Each also asserts the no-op case (identical input on both sides emits
nothing).
Verified: go build ./..., go vet ./..., go test -count=1 ./... all green.
|
||
|
|
190d589afe |
fix(web): render markdown in timeline comments via .prose class (BUG-748) (#235)
* fix(web): render markdown in timeline comments via .prose class (BUG-748)
TimelineCommentCard tagged comment + reply bodies with `markdown-body`,
a class with no rules anywhere in the codebase. The global
`* { margin: 0; padding: 0 }` reset in app.css then stripped list
padding, heading margins, code-block backgrounds, blockquote borders,
and table styling — so any comment containing markdown (bullet lists,
headings, fenced code, quotes) rendered as run-on text without its
visual structure.
Switch both bodies to the existing `.prose` class (same one used by
the item-detail content view), and override `max-width: none` in the
scoped style so comments still fill the timeline column instead of
shrinking to the 960px content width that .prose pins for long-form
item bodies.
Comments are sanitized through DOMPurify in renderMarkdown (TASK-647);
this change is purely styling.
Verified: web/npm run build clean, go test ./... green.
* fix(web): explicit font-family + table overflow on comment-body (Codex round 1)
Address two LOW findings from Codex review of #235:
1. `.prose` pins `font-family: var(--font-content)`. The scoped
`.comment-body, .reply-body` rule didn't override font-family, so
comments inherited the .prose font. Currently identical to --font-ui,
but make the relationship explicit (`font-family: inherit`) so a
future divergence between --font-ui and --font-content doesn't
silently change comment typography.
2. `.prose table { width: 100% }` plus padded cells can produce a wider-
than-column table inside the indented `.reply-card` (which sits
inside `.replies` with an extra padding-left + border-left, so its
inner width is significantly narrower than a top-level comment).
Add `overflow-x: auto` to .comment-body/.reply-body so wide tables
scroll horizontally instead of overflowing the card.
Verified: web/npm run build clean, go test ./... green.
|
||
|
|
7478d013cb |
feat(auth): surface ?error= and ?linked= on login + settings (TASK-741) (#234)
* feat(auth): surface ?error= and ?linked= on login + settings (TASK-741)
Before this change, pad-cloud's OAuth redirects with ?error=... and
?linked=... query params were silently ignored. A user who unlinked
GitHub and then hit "Sign in with GitHub" would land on a clean form
with no explanation of why their OAuth didn't work — classic silent
failure.
### Login page (/login)
- readOAuthErrorFromQuery() parses ?error= and the optional ?provider=
hint on mount.
- Five recognised codes map to actionable banners:
* oauth_provider_not_linked — the core recovery path: "That
<Provider> account isn't linked to a Pad account. Sign in with
your password below, or retry with a different account." with
"Use a different GitHub account" / "Use a different Google
account" CTAs wired to /auth/{github,google}?force=1 (shipped in
pad-cloud PR #21). If ?provider is not present, both CTAs render
so the user picks.
* oauth_failed — generic retry prompt.
* no_email — "verify your email with the provider" guidance.
* too_many_attempts — rate-limit language (no client-side Retry-After
countdown; the pad-cloud redirect doesn't carry that info).
* account_disabled — "contact an administrator", no retry.
- Unknown codes fall back to a safe generic message so a future code
never breaks the page.
- After rendering, ?error / ?provider stripped via
history.replaceState so refresh / back-button doesn't re-show.
- Dismiss button on the banner for users who want to clear it
before retrying.
### Settings page (/console/settings)
- readOAuthQueryStatus() on mount handles the three link-flow error
codes and the two success flags:
* ?linked=github / ?linked=google → providerMsg success toast
* ?error=not_logged_in → session-expired guidance
* ?error=email_mismatch → identity-mismatch fix-up
* ?error=link_failed → generic retry prompt
* Unknown → generic fallback
- Same history.replaceState cleanup.
### Why this is a beta blocker
A legit user who unlinks a provider can become silently un-loginable
with no UI path back. Shipping PLAN-645 to beta operators without
this makes every provider-unlink a support ticket.
Parent: PLAN-645. Depends on TASK-742 (?force=1, already merged) for
the "Use a different account" CTAs to actually work. Optional
?provider= hint will be a small pad-cloud follow-up (handler today
emits ?error= only).
* fix(settings): make provider msg/error live regions for screen readers (Codex round 1)
Addresses PR #234 Codex MEDIUM. The settings page's provider-section
banners (providerMsg / providerError) were plain <p> elements, so the
readOAuthQueryStatus() result on mount was silent to screen-reader
users — unlike the login page's oauth-banner which already had
role/aria-live. Added role='status' + aria-live='polite' to the
success element and role='alert' + aria-live='assertive' to the
error element so both get announced on mount and on subsequent
unlink/link form actions.
|
||
|
|
3f58e0badc |
feat(billing): plan comparison matrix on /console/billing (TASK-712) (#233)
* feat(billing): plan comparison matrix on /console/billing (TASK-712) Replaces the single-column Usage section with a side-by-side Free vs Pro comparison table. Before: users saw their own plan's limits but had no visible reason to upgrade — the Upgrade CTA linked to checkout without any explanation of what Pro actually changes. Now: every field from PlanLimits is rendered for both tiers in one table, the current plan's column is highlighted, and a secondary Upgrade CTA lives directly beneath the comparison for Free users. Changes on /console/billing: - PlanLimits interface extended with webhooks + automated_backups so the UI renders every field the server advertises (DefaultFreeLimits and DefaultProLimits in internal/store/limits.go both expose them). - New formatBytes helper — renders storage_bytes in the natural unit (500 MB for Free, 10 GB for Pro) rather than raw byte counts. - New formatCompareCell helper — 0 → "—" (reads as "not included" for Webhooks / Automated backups on the Free tier); -1 → "Unlimited"; undefined → "…" while limits are loading; storage → formatBytes; anything else → locale-formatted integer. - Comparison table component: scoped <th> headers for accessibility, a "Current" tag next to the user's plan column, subtle accent-blue wash on every cell in the current plan's column. Rows driven by a static COMPARE_ROWS array keyed on LimitKey so TypeScript enforces that every column references a real PlanLimits field. - Mobile-friendly padding at the 480px breakpoint. Parent: PLAN-645 (Pad Cloud Beta Readiness). TASK-712 bullet 4 — the last bullet of the umbrella. TASK-712 can close after this lands. * fix(billing): match server's negative→unlimited, don't collapse 0, fix formatBytes boundary + badge contrast (Codex round 1) Addresses PR #233 round 1 findings: 1. formatCompareCell collapsed every 0 to "—" to read as "not included". Admin-configured plan limits are arbitrary integers (see /console/admin/settings), so a legitimate zero — "storage_bytes = 0", "workspaces = 0", "api_tokens = 0" — misrepresented as a placeholder. Removed the 0-case; zero now renders as the literal "0". "—" readability on the Free tier's 0-valued webhooks/automated_backups is a small loss compared to the correctness win. 2. formatCompareCell only treated exactly -1 as "Unlimited", but internal/store/limits.go enforces ANY negative value as unlimited (checkLimit returns Allowed=true for limit < 0). A stored -2 would behave unlimited server-side while the billing table showed "-2 B". Changed the check to "value < 0" to match server semantics. 3. formatBytes rounded at each unit tier, so values just below a unit boundary (1,048,575 bytes → "1024 KB", 1,073,741,823 → "1024 MB") overflowed the displayed value. Rewrote to use "bump" thresholds (bumpMB = MB - KB/2, bumpGB = GB - MB/2): a value that would round-display as 1024 of the smaller unit is instead shown as "1.0" of the next unit. Extracted the value/unit rendering into formatUnit() so the tier thresholds stay readable. 4. .current-tag on the comparison table header used accent-blue text on an 18%-alpha accent-blue wash, landing around 3.5-3.9:1 in either theme — below the 4.5:1 target for 0.7rem text. Switched to solid accent-blue background with #fff text, which stays comfortably above 4.5:1 across both themes. |
||
|
|
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. |
||
|
|
69e0b2017a |
feat(billing): confirm-upgrade polling on /console/billing (TASK-712) (#231)
* feat(billing): confirm-upgrade polling on /console/billing (TASK-712) Stripe Checkout redirects back to /console/billing?checkout=success the moment the user finishes paying, but pad-cloud's checkout.session.completed webhook is asynchronous — it needs a beat to land, authenticate against pad's /admin/plan endpoint, and flip the user's plan to "pro". Before this change, the returning user saw the Free plan with the "Upgrade to Pro" button and had to refresh manually before the app caught up. Changes on /console/billing: - Detects ?checkout=success on mount. Runs a single fresh authStore.load() first — if the webhook is already in, skip straight to the confirmed state. Otherwise start polling authStore.load() every 2s for up to 30s. - Four states: idle (default), checking (spinner + "Confirming your upgrade…"), confirmed (green check + "welcome to Pro!"), timeout (yellow, payment went through + support contact). - On confirm, clears the ?checkout=success query via history.replaceState so a page reload does not re-enter the polling branch. - onDestroy stops the interval — no dangling timers after navigation. - Reduced-motion users see a static spinner frame per prefers-reduced-motion. - Banner has role="status" aria-live="polite" so screen readers announce state changes. Reuses authStore's existing inflight-coalescing + generation guard (shipped with PR #229), so concurrent polls share a single /auth/session fetch and a post-logout navigation cannot resurrect a stale plan value. Parent: PLAN-645 (Pad Cloud Beta Readiness). TASK-712 bullet 2. Bullet 3 (failed-payment email) ships next; bullet 4 (plan matrix) later. * fix(billing): destroyed guard, parallel tasks, plan-reconcile banner (Codex round 1) Addresses PR #231 review findings: HIGH — onMount's awaits could race with onDestroy: a late authStore.load() or plan-limits fetch finishing after the user navigated away would still mutate upgradeStatus/limits, and startUpgradeConfirmation could even install a setInterval on a destroyed component. Added a 'destroyed' flag set in onDestroy and checked after every await; stopPolling also runs on teardown and inside pollForUpgrade's post-await guard for belt-and- braces. MEDIUM — startUpgradeConfirmation was sequenced behind the plan-limits fetch. A slow /plan-limits request would delay the 'checking' banner and the first authStore.load() refresh, defeating the purpose of the PR. Split them: onMount is now synchronous, kicks off startUpgradeConfirmation and loadPlanLimits in parallel as fire-and-forget promises, each with its own destroyed-guarded error handling. LOW — upgradeStatus latched 'confirmed' independently of the current plan value. If plan transitioned away from 'pro' for any reason after the banner appeared, it would stay stuck showing the success message. Render the confirmed banner only while upgradeStatus === 'confirmed' AND isPro so the banner fades out automatically if the plan reconciles down. |
||
|
|
cf00eeba84 |
feat(web): add Support + Status links on auth pages and user menu (TASK-713) (#230)
Implements TASK-713 bullets 1+2 for Pad Cloud: a support@getpad.dev mailto and a https://status.getpad.dev link users can reach before signing in and from inside the app. Discord link deferred — spawn an HT follow-up once the server URL is known. Changes: - New SupportFooter.svelte — Support · Status row, gated on cloudMode, styled consistently with LegalFooter (underlined, focus-visible outline). Rendered below LegalFooter on login, register, and forgot-password. - TopBar user dropdown (desktop + mobile branches): Support and Status entries between the theme toggle and Sign out, grouped by a divider, gated on cloudMode so self-hosted installs do not advertise links that are not theirs to offer. Bullets 3+4 of TASK-713 (admin impersonation, MRR/churn dashboard) remain out of scope for Chunk 1 per the PLAN-645 audit decision; they will be tracked in a follow-up plan after beta launch. Parent: PLAN-645 (Pad Cloud Beta Readiness). Second PR in Chunk 1. |
||
|
|
cf3e64caf4 |
feat(web): add legal footer + consent notice on auth pages (TASK-714) (#229)
* feat(web): add legal footer + consent notice on auth pages (TASK-714) Pad Cloud (cloudMode=true) needs visible Terms / Privacy / Sub-processors links so Stripe Live-mode compliance is defensible and users know what they're agreeing to. Self-hosted installs (cloudMode=false) don't need these — the legal docs at getpad.dev are Perpetual Software LLC's TOS for the hosted service, not the user's own instance. Changes: - New LegalFooter.svelte component: renders Terms · Privacy · Sub-processors links to https://getpad.dev/{terms,privacy,subprocessors}. Gated on a cloudMode prop so self-hosted installs see nothing. - login, register, forgot-password pages: render <LegalFooter> below the card. Each page already fetches session; register/forgot-password now persist session.cloud_mode into a local $state so the footer can be reused consistently. - register page adds a "By creating an account, you agree to Terms and Privacy Policy" consent notice directly under the Create account button (only when cloudMode=true). This is the signup touchpoint for Stripe Live mode. - Auth-page containers switched to flex-direction: column so the card and footer stack cleanly centered. Cookie banner intentionally deferred: the privacy policy already asserts "strictly-necessary cookies only, no banner required" and app.getpad.dev only sets first-party session/CSRF cookies. Stripe Checkout runs on billing.stripe.com (separate origin) so its cookies don't apply here. Parent: PLAN-645 (Pad Cloud Beta Readiness). This covers the main launch-blocking legal bullet (Stripe Live mode compliance); the pad-web marketing site already hosts the actual legal content. * fix(web): read cloudMode from authStore; add link affordance (Codex round 1) Addresses PR #229 review findings: MEDIUM — register and forgot-password pages were fetching session via api.auth.session() specifically to derive cloudMode, duplicating the root layout's authStore.load() and adding a silent failure path (if the extra request failed, the legal footer/consent would vanish even on Pad Cloud). Switched to authStore.cloudMode in both pages. register still calls api.auth.session() for its pre-existing setup_required + authenticated checks, but no longer reads cloud_mode from that call. forgot-password no longer needs onMount at all — its only addition was the cloudMode fetch. LOW — legal footer links had no affordance until hover: muted color, no underline, nothing for keyboard users. Added persistent subtle underline (1px, 2px offset) and :focus-visible outline so the links are discoverable for touch and keyboard users. Also: login page intentionally left untouched. It has a pre-existing local cloudMode state used for OAuth buttons + sign-up link + legal footer; unifying it with authStore is a separate refactor and outside this PR's scope. * fix(auth): add authStore.ensureLoaded for post-logout auth nav (Codex round 2) Addresses PR #229 round 2 finding: MEDIUM — After logout, authStore.session is cleared and the root layout doesn't re-run onMount on SPA navigation, so subsequent visits to /register or /forgot-password would read authStore.cloudMode=false and silently hide the legal footer/consent on Pad Cloud. Fix: add authStore.ensureLoaded() which returns the cached session when present or fetches it otherwise. register's onMount now routes its pre-existing session fetch through ensureLoaded (so the same call populates authStore for downstream components like LegalFooter). forgot-password calls ensureLoaded() on mount — cheap no-op when the store is already populated, one fetch when it's been cleared. This keeps the single-source-of-truth benefit from round 1 while handling the logout-then-navigate case Codex flagged. * fix(auth): coalesce concurrent session loads (Codex round 3) Addresses PR #229 round 3 finding: MEDIUM — authStore.ensureLoaded() did a bare 'if (session)' check, so a hard page load that fired both the root layout's authStore.load() and the page's ensureLoaded() could issue two /auth/session requests. If one succeeded and the other failed, the later catch path (session = null) would overwrite the good session, leaving cloudMode=false after a successful fetch. Fix: add an inflight Promise in auth.svelte.ts. load() returns the inflight promise when one exists; the then/catch/finally chain runs exactly once per fetch and clears inflight in finally. ensureLoaded() keeps its cached-session short-circuit and otherwise delegates to load(), so concurrent callers all await the same underlying request. * fix(auth): guard stale session fetches with generation counter (Codex round 4) Addresses PR #229 round 4 findings: MEDIUM — clear() did not invalidate a pending inflight load, so a pre-logout /auth/session call could resolve after logout and resurrect the logged-out user's session. Subsequent ensureLoaded() calls would also attach to that stale promise. LOW — inflight was only cleared in the promise's finally, so a permanently-hanging fetch wedged loading=true and every subsequent load()/ensureLoaded() returned the same never-settling promise. Fix: introduce a 'generation' counter that bumps on clear(). load() captures the current generation at fetch start and only writes session / clears loading / clears inflight when the generation is still current. clear() now also drops the inflight reference and resets loading=false, so the next ensureLoaded() fires a fresh request and the UI is not left hanging. Late callbacks from pre-logout fetches still resolve in the background but cannot mutate authStore state. |
||
|
|
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).
|
||
|
|
c8601a2031 |
test(e2e): Playwright smoke test infrastructure + 2 dashboard tests (TASK-689) (#225)
* test(e2e): Playwright smoke test infrastructure + 2 dashboard tests (TASK-689) Option A of TASK-689: land the test infrastructure and a minimal smoke test on both mobile and desktop viewports. Broader flow coverage (board view drag, item detail, comments, mobile hamburger, BottomSheet regression guard) is tracked as TASK-733. Infrastructure -------------- - web/playwright.config.ts: two projects (desktop-chromium, mobile- chromium via Pixel 7), reporter list+html, trace/video/screenshot retained on failure, webServer that wipes + recreates the data dir then runs the pad binary. Paths anchored to the config file's directory so runs are cwd-invariant. - web/e2e/global-setup.ts: bootstraps admin via POST /auth/bootstrap, logs in, creates the e2e workspace, mints a user-scoped API token, and persists the token + resolved admin username to fixture.json. - web/e2e/fixtures.ts: extends base test so every BrowserContext automatically gets Authorization: Bearer <token>. Uses a token rather than a session cookie because sessions are User-Agent bound in middleware_auth.go and a node-minted session would be rejected by a Chromium UA. Tests ----- - web/e2e/dashboard.spec.ts: a logged-in user lands on the seeded workspace, no login form is rendered, and the workspace name appears on the page. Runs in both project viewports. CI -- - New `e2e` job in .github/workflows/ci.yml: builds web UI + binary, installs Playwright chromium with OS deps, runs the suite, and uploads the HTML report as an artifact on failure. Timeout capped at 10 minutes (suite itself runs in ~4s today). Local run (in mcr.microsoft.com/playwright:v1.59.1-noble): 2 passed in 4.1s. Parent: PLAN-644. Follow-up: TASK-733 for broader flow coverage (Option B in the original ship plan). * fix(e2e): persist server-returned workspace slug instead of the constant (TASK-689) Addresses Codex P2 on PR #225. When Playwright's `reuseExistingServer: true` (local dev), a re-run of globalSetup hits `POST /api/v1/workspaces` against a DB that already has `e2e`. The server uniquifies the slug (`e2e` → `e2e-2` → …) and returns the uniquified value, but the old code wrote `WORKSPACE_SLUG` (the constant) to fixture.json. Tests then navigated to /e2e-admin/e2e — which might still exist from a previous run with stale state — instead of /e2e-admin/e2e-2, missing regressions in freshly-seeded content. Fix: read `slug` back from the workspace-create response and use that when writing fixture.json. Local re-runs now always point at the workspace this run actually created. Parent: PLAN-644. * fix(e2e): cross-platform webServer bootstrap via Node wrapper (TASK-689) Addresses Codex P2 on PR #225: `rm -rf && mkdir -p && pad server start` in webServer.command is POSIX-only. Windows contributors on cmd.exe or PowerShell can't run `npm run test:e2e` at all — the e2e suite becomes Linux/macOS-only, defeating the "CI parity" goal. Fix: extract the wipe-and-exec logic into web/e2e/run-pad.mjs. Node's fs.rmSync / mkdirSync / child_process.spawn are uniform across platforms, and the wrapper forwards SIGTERM/SIGINT so Playwright's teardown still cleanly kills the child on suite exit. Local re-run in mcr.microsoft.com/playwright:v1.59.1-noble: 2 passed. Parent: PLAN-644. |
||
|
|
9f0f168c6b |
chore(release): cosign signing + SBOM + SLSA provenance (TASK-684) (#224)
Matches the security-posture promise in SECURITY.md and README. Every GA release will now publish: - Signed checksums.txt (Sigstore keyless cosign via GitHub OIDC) - Signed container manifests for ghcr.io/xarmian/pad (cosign sign) - SPDX SBOM (.spdx.json) next to each archive (syft via goreleaser sboms: section) - SLSA v1 build provenance attestation for every archive via actions/attest-build-provenance .goreleaser.yaml: - Add sboms: (artifacts: archive, SPDX JSON) - Add signs: for checksum artifact with --output-certificate / --output-signature - Add docker_signs: for container manifests - Fix pre-existing v2 incompatibility in before.hooks (map-with-cmd form is rejected in v2; switched to the plain-string form required by the v2 schema — the existing config never validated) .github/workflows/release.yml: - Grant id-token: write (required for cosign keyless OIDC) and attestations: write (required by attest-build-provenance) - Install cosign (sigstore/cosign-installer v3.10.1) before goreleaser - Install syft (anchore/sbom-action/download-syft v0.18.0) before goreleaser - Add actions/attest-build-provenance (v4.1.0) step after goreleaser to mint SLSA provenance for all built archives All new actions pinned to 40-char SHAs per the existing pinning convention. Verified with: docker run goreleaser/goreleaser:v2.15.4 check # config valid yq -e '.' .github/workflows/release.yml # YAML valid Parent: PLAN-644. |
||
|
|
e116b53be5 |
docs: add Go Report Card, GHCR, Sponsors badges to README (TASK-688) (#223)
Grouped README polish to give the repo the public-OSS texture reviewers expect: - Go Report Card — reinforces code-quality signal once the repo is public (goreportcard.com auto-indexes Go repos). - GHCR container image — links directly to the package page; uses a static shields.io badge (GHCR doesn't expose a pulls endpoint the way Docker Hub does, so we avoid the unreliable third-party pull services). - GitHub Sponsors — shows live sponsor count; complements the FUNDING.yml Sponsor button added in TASK-679. CI, Release, and License badges kept as-is. Parent: PLAN-644. |
||
|
|
062eef41b2 |
docs: architecture guide + full .env.example + gitattributes + Makefile note (TASK-687) (#222)
Grouped nice-to-haves called out in the pre-launch audit. 1. docs/architecture.md — new contributor-focused architecture doc. CLAUDE.md covers the same ground but is agent-oriented; this is the human companion. Covers backend layout, request flow, frontend / data model / CLI↔daemon model / agent integration / testing. 2. .env.example — extended to document every PAD_* variable in docs/deployment.md (core, database, real-time events, security, email). Existing Postgres/Redis + encryption secrets kept at the top; new variables grouped by concern with inline comments and safe defaults commented out. 3. .gitattributes — normalize LF line endings repo-wide, mark binary assets, and flag web/build + web/.svelte-kit as generated so they don't pollute GitHub linguist stats or PR diffs. 4. Makefile — CAUTION comment on `make install` noting that the `killall -9 pad` step is system-wide; anyone else's pad daemon on the same machine gets killed too. Designed for single-developer local setups; not for shared hosts. Parent: PLAN-644. |
||
|
|
5b14c2e35f |
fix(a11y): BottomSheet tabindex + roles page label associations (TASK-685) (#221)
Closes the four a11y warnings svelte-check surfaces today.
- BottomSheet.svelte: the <div role="dialog"> needs tabindex so screen
readers can focus it programmatically. Add tabindex="-1" — activates
when explicitly focused without putting it in the tab order (matches
the ARIA APG dialog pattern).
- roles/+page.svelte: three <label> elements for Icon & Name,
Description, and Tools had no associated control. Give each target
<input> a stable id (role-name, role-description, role-tools) and
point each <label for={id}>. The dialog renders a single instance at
a time so hardcoded ids are safe.
svelte-check before: 10 warnings (4 target + 6 pre-existing)
svelte-check after: 6 warnings (pre-existing only; no regressions)
Parent: PLAN-644.
|
||
|
|
04db0c1a67 |
chore: add .editorconfig, golangci-lint, pre-commit (TASK-682) (#220)
* chore: add .editorconfig, golangci-lint, pre-commit (TASK-682) Prevents style churn from first-time external contributors by codifying the project's formatting and lint rules into shared config. Changes: - .editorconfig: tabs for code, 2-space for YAML/JSON/Markdown, LF everywhere, UTF-8; Makefile overrides enforce tab (syntactic). - .golangci.yml: enables gofmt, govet, errcheck, ineffassign, staticcheck, unused. Scoped to Go sources; excludes web/, docs/, deploy/, skills/. - .pre-commit-config.yaml: repo-local hygiene (trailing whitespace, EOF, YAML/JSON checks, merge-conflict markers, 500KB file cap, LF line endings), Go formatting (go-fmt, go-imports), and prettier for YAML/JSON/Markdown only (Svelte intentionally excluded — no Svelte prettier config yet). - CI: wires golangci-lint into .github/workflows/ci.yml via the official pinned action (v6.5.2 → SHA 55c2c144...). Uses only-new-issues: true so this PR is not blocked by the 17 pre-existing findings on main, which are tracked as IDEA-732 and will flip to strict enforcement after they're resolved. Verified: - golangci-lint run with this config locally; config parses and --new-from-rev=HEAD is clean - All YAML parses via yq - go build/vet/test + web build all green Parent: PLAN-644. Follow-up: IDEA-732 (fix legacy lint findings, flip to strict mode). * fix(ci): upgrade golangci-lint to v2 for Go 1.25 support (TASK-682) Per Codex review on PR #220: golangci-lint v1.x (including v1.64.8 which I originally pinned) is capped at Go 1.24 support. Running v1 binaries against Go 1.25 source can silently drop/misreport findings, defeating the purpose of a lint gate. Switch to: - Action: golangci/golangci-lint-action@v9.2.0 (pinned SHA 1e7e51e7...) - Lint version: v2.11.4 (latest stable v2) - Config rewritten to v2 YAML format (version: "2", linters.default, formatters section for gofmt, exclusions.paths) Verified: - `golangci-lint config verify` clean - `golangci-lint run --new-from-rev=HEAD` reports 0 issues on the current diff (still well under the safety cap since we're using only-new-issues: true) - Full run against main surfaces the expected legacy findings, which remain tracked in IDEA-732 Parent: PLAN-644. * fix(ci): anchor golangci-lint exclusion paths + add PR read perm (TASK-682) Addresses two follow-up comments from Codex on PR #220: P1 — golangci-lint v2 uses regex path matching for exclusions, so the bare patterns "web" and "skills" would also match unrelated Go files whose path contains those substrings (e.g. "internal/websocket"). Anchor with a leading "^" and trailing "/" so only the intended directory trees are skipped. P2 — The workflow already grants "contents: read" at top level, but golangci-lint-action with only-new-issues: true also fetches PR diff metadata from the GitHub API; the action docs list "pull-requests: read" as required for that path. Add it explicitly so the action doesn't fall back to scanning all code and defeating the purpose of scoped issue reporting. Parent: PLAN-644. |
||
|
|
be9a96ba21 |
chore: run containers as non-root + harden K8s securityContext (TASK-680) (#219)
First thing external reviewers flag on a public repo: "why does this image run as root?". Fixes both Dockerfiles and the K8s deployment. Dockerfiles (Dockerfile + Dockerfile.goreleaser): - Add a non-login uid:1000 "pad" user via adduser - chown /data so the app can write its SQLite DB as the unprivileged user - Declare USER pad before the ENTRYPOINT deploy/k8s/deployment.yaml: - Pod-level securityContext: runAsNonRoot, runAsUser/Group 1000, fsGroup 1000 (so the emptyDir volume is group-writable), seccompProfile RuntimeDefault - Container-level securityContext: allowPrivilegeEscalation false, readOnlyRootFilesystem true, drop ALL capabilities Verified: - docker build succeeds; `docker inspect ... Config.User` = "pad" - Container running as uid 1000 serves /api/v1/health successfully - Container runs with --read-only rootfs + writable /data volume with no runtime errors (server only writes to /data, never /tmp) - deploy/k8s/deployment.yaml parses via yq; securityContext block structurally correct Parent: PLAN-644. |
||
|
|
2b413f5d95 |
chore: add HEALTHCHECK to both Dockerfiles (TASK-681) (#218)
Before: neither Dockerfile declared HEALTHCHECK. docker-compose.yml added it at compose level, so "docker run ghcr.io/xarmian/pad:latest" had no health signal for Docker/Kubernetes/Swarm. Adds HEALTHCHECK to Dockerfile and Dockerfile.goreleaser probing GET /api/v1/health (served by internal/server/server.go:353). Uses wget from busybox (already present in alpine:3.21) so no extra apk install is needed. Interval 30s / timeout 5s / start-period 10s / retries 3 — conservative defaults safe for low-traffic single-user instances. Verified the built image reports the expected HEALTHCHECK via `docker inspect` and that the probe succeeds against a live container. Parent: PLAN-644. |
||
|
|
cc2135490c |
chore: remove duplicate padicon.png at repo root (TASK-683) (#217)
The root copy is a byte-identical duplicate of web/static/padicon.png. Only the web/static copy is served (referenced via "/padicon.png" as og:image in web/src/routes/+layout.svelte). Removing 213KB of dead weight from the repo root. Verified no references to "./padicon.png" elsewhere in the codebase. Parent: PLAN-644. |
||
|
|
844761cff4 |
chore: add community health files (TASK-679) (#216)
Adds three community health files flagged by the GitHub repo scan: - CODE_OF_CONDUCT.md: Contributor Covenant v2.1 verbatim, with the enforcement contact set to conduct@getpad.dev (matches the domain used for security@getpad.dev in SECURITY.md). - .github/CODEOWNERS: default catch-all routing review requests to @xarmian so PRs auto-request the maintainer. - .github/FUNDING.yml: enables the Sponsor button on the repo via github: xarmian; other platforms left commented for future. Parent: PLAN-644. |
||
|
|
8d33dcbfc8 |
chore(ci): add Dependabot configuration (TASK-678) (#202)
Add .github/dependabot.yml covering four ecosystems:
- gomod (root) — Go modules for cmd/pad + internal/*
- npm (/web) — SvelteKit frontend dependencies
- github-actions (root) — pairs with TASK-677 SHA pinning
- docker (root) — both Dockerfile and Dockerfile.goreleaser
Strategy:
- Weekly schedule on Monday 06:00 PT (off-hours, avoids PR flood
on weekdays)
- Minor + patch updates grouped per ecosystem → one PR/week
- Major version bumps get their own PRs so breaking changes are
reviewed in isolation
- open-pull-requests-limit: 5 per ecosystem (3 for docker) keeps
backlog manageable
- Commit prefixes follow the conventional-commit style the project
already uses (chore(deps) / chore(ci) / chore(docker))
Pairs with TASK-677: for SHA-pinned Actions, Dependabot updates both
the commit SHA and the trailing '# vX.Y.Z' comment in one PR, so the
human-readable version label stays in sync.
Parent: PLAN-644.
|
||
|
|
1052be7282 |
security(ci): pin all GitHub Actions to commit SHAs (TASK-677) (#201)
Every third-party Action in .github/workflows/ was using a floating tag (@v4, @v5, @v6). A compromised maintainer — or a tag that gets re-pointed at a malicious commit — could execute attacker code in CI with contents:write, packages:write, and the GHCR token in scope. Release.yml is especially exposed: a compromised step there could publish tampered binaries to GitHub Releases and GHCR. All 12 'uses:' references now pin to a 40-char commit SHA with a trailing '# vX.Y.Z' comment (the comment is what humans read during review; the SHA is what GitHub actually resolves): actions/checkout@34e114876b # v4.3.1 actions/setup-go@40f1582b24 # v5.6.0 actions/setup-node@49933ea528 # v4.4.0 docker/setup-buildx-action@8d2750c68a # v3.12.0 docker/login-action@c94ce9fb46 # v3.7.0 goreleaser/goreleaser-action@e435ccd777 # v6.4.0 Version bumps: pin to the newest release within the same major that was previously in use (so behavior stays the same — no major version jumps hidden inside a security PR). Dependabot (incoming in TASK-678) will track the commit-pinned refs and open PRs that update both the SHA and the version comment together. Parent: PLAN-644. |
||
|
|
9909d7b7c6 |
fix(web): resolve 9 svelte-check errors blocking CI (TASK-674) (#200)
svelte-check was reporting 9 errors on main, blocking the CI gate.
All fixed:
1. EditCollectionModal: make `open` prop bindable ($bindable()). This
unblocks `bind:open={editCollectionOpen}` in two call sites:
- routes/[username]/[workspace]/[collection]/+page.svelte:1153
- routes/[username]/[workspace]/[collection]/[slug]/+page.svelte:1101
2. [slug]/+page.svelte: narrow `item` inside callback-bound expressions:
- Line 684 (.find closure) now uses a local @const for the slug
rather than re-reading item.parent_collection_slug inside the
callback (TS cannot narrow across the closure).
- Line 744 star toggle handler now short-circuits on item presence,
so both `item.slug` and `item.id` are safe.
3. auth/cli/[code]/+page.svelte: guard against `$page.params.code`
being `undefined` in both onMount and handleApprove.
4. console/settings/+page.svelte: add @types/qrcode dev dependency
so the dynamic `import('qrcode')` calls have proper typings.
`cd web && npx svelte-check` now reports 0 errors (warnings were
out of scope — addressed separately in TASK-685). `go build/vet/test`
and `cd web && npm run build` are green.
Parent: PLAN-644.
|
||
|
|
ac744fce2b |
fix(docs): replace 'pad serve' with 'pad server start' (TASK-675) (#199)
The 'pad serve' command does not exist in this binary — its canonical name has been 'pad server start' for some time. Users following the systemd example in docs/deployment.md would get a non-starting service today. Six real references fixed: - cmd/pad/main.go:5714 — migrate-to-pg help text - cmd/pad/main.go:5795 — 'Next steps' instruction - docs/backup.md:81,94 — Postgres migration walkthrough - docs/deployment.md:116 — binary launch example - docs/deployment.md:164 — systemd ExecStart Repo-wide grep is now clean of 'pad serve' outside gitignored v1-archive/ and .pad/ (local workspace data). README.md was already correct. Parent: PLAN-644. |
||
|
|
32dbf7b682 |
chore(release): generate changelog via GoReleaser (TASK-676) (#198)
* chore(release): generate changelog via GoReleaser (TASK-676)
The hand-maintained CHANGELOG.md had drifted — it still referenced
'Phases' (renamed to Plans in migration 024) and 'pad status' /
'pad next' (moved to 'pad project dashboard' / 'pad project next').
Rather than rewrite it (it would drift again), delete it and let
GoReleaser auto-generate release notes from conventional commits.
.goreleaser.yaml changelog block upgraded to:
- use GitHub's API for richer formatting ('use: github')
- group by feat/fix/perf/refactor/other with ordered sections
- exclude merge-commit noise on top of the existing docs/test/ci/chore
filters
Release notes from now on come from GitHub Releases (populated by
GoReleaser on tag push), which keeps changelog truth in one place.
Parent: PLAN-644.
* fix(release): anchor changelog group regexes to work with use: github
With changelog.use: github, GoReleaser prefixes entries with the commit
hash (e.g. `abc1234: feat(...): ...`), so regexes anchored at `^feat`
never match — every entry would fall into "Other changes". Prepend
`^.*?` to each group regex so the prefix is allowed.
Per Codex review on PR #198.
|
||
|
|
bcd095eefa |
chore(repo): remove personal RTK AGENTS.md from root (TASK-672) (#197)
AGENTS.md at repo root was personal RTK (Rust Token Killer) dev-tool config — off-topic for the public repo. Delete it and add AGENTS.md to .gitignore so contributors can keep a local copy without committing it. Parent: PLAN-644. |
||
|
|
0495098254 |
fix(docker): correct goreleaser image CMD (TASK-671) (#196)
Dockerfile.goreleaser had CMD ["serve"] but the pad binary exposes its HTTP server under `pad server start` (no "serve" command exists). As a result, `docker run ghcr.io/xarmian/pad:latest` exited immediately with "unknown command \"serve\"". Align with Dockerfile which already uses CMD ["server", "start"]. Parent: PLAN-644. |
||
|
|
4f298db4d0 |
docs(readme): add 'Hardening for public deployments' section + govulncheck CI gate (PLAN-643 exit) (#195)
* docs(readme): add 'Hardening for public deployments' + govulncheck CI gate (PLAN-643 exit criteria) Closes the last two exit criteria of PLAN-643 (OSS Security Hardening): - README.md gains a full "Hardening for public deployments" section walking operators through the network boundary (bind addr, TLS, trusted proxies), secrets (PAD_ENCRYPTION_KEY, token scopes, bootstrap window), auth hardening (PAD_IP_CHANGE_ENFORCE, password strength UI messaging, PAD_CORS_ORIGINS), observability (PAD_METRICS_ TOKEN, audit-log shipping), and a deploy-day checklist. Cross- references every relevant env var documented elsewhere. - CI workflow gains a govulncheck step on the Go job, mirroring the existing `npm audit --audit-level=high --omit=dev` gate on the web job. Locally `govulncheck ./...` reports "No vulnerabilities found", so the first run on main should pass. Exit criteria for PLAN-643: [x] All CRITICAL + HIGH + MEDIUM findings closed and verified [x] `npm audit --audit-level=high --production` clean in web/ [x] CSP denies inline event handlers (script-src-attr 'none') [x] Docker default compose publishes to 127.0.0.1 only [x] README has a "Hardening for public deployments" section [x] `govulncheck ./...` clean * fix(ci): pin govulncheck to v1.2.0 instead of @latest (PLAN-643) Addresses Codex P2 on PR #195: tracking @latest on every CI run makes the gate non-deterministic — a future upstream release could change behavior or require a newer Go toolchain than the workflow's pinned `go-version: 1.25` and break unrelated PRs. Pin to the currently- released v1.2.0 (Go 1.26.2 toolchain) and update intentionally. * docs(readme): recommend pinned govulncheck install in hardening section Follows Codex P2 on PR #195: the earlier commit pinned the CI workflow to v1.2.0 but the README's hardening-checklist bullet still told operators to install @latest. Teams copying that into their own CI would re-introduce the non-determinism the pin was meant to fix. Update the docs to recommend a pinned tag (matching the workflow's v1.2.0) and note that the pin should be bumped intentionally. |
||
|
|
69262c3b53 |
feat(server): periodically revalidate SSE subscriber membership (TASK-670) (#194)
* feat(server): periodically revalidate SSE subscriber membership (TASK-670)
handleSSE checked workspace access only at connection time. A removed
member kept receiving live events until they manually disconnected —
or, more commonly, indefinitely, because browser EventSource auto-
reconnects and the replay buffer filled any gaps. An owner who revoked
access had no way to stop the leak without restarting the server.
- New 60s membership revalidation ticker inside the SSE select loop.
- Store.sseSubscriberStillHasAccess mirrors RequireWorkspaceAccess's
access matrix: fresh install bypass, admin role, direct membership,
guest grants, legacy workspace-scoped API token. DB errors fail
OPEN (keep connection) so a transient blip doesn't bounce every
open tab; membership-absent fails CLOSED.
- On revocation we send the client a well-known {type:"unauthorized"}
event with a human-readable reason BEFORE closing the stream, so
frontend EventSource handlers can route to login / dismiss the
workspace instead of tight-looping to reconnect.
- sseMembershipRevalInterval is a package-level var so tests can
shrink it; pinned to the 30-300s reasonable range.
- Unit test exercises every branch: admin, active member, outsider,
removed member, guest-grant (skipped when default collections aren't
seeded), unauthenticated, legacy token scoped to same workspace, and
legacy token scoped to a different workspace.
Parent: PLAN-643 (OSS Security Hardening).
* fix(web): handle server-emitted 'unauthorized' SSE event in client (TASK-670)
Addresses Codex P2 on PR #194: the server emits `{type:"unauthorized"}`
before closing a revoked stream, but the Svelte SSE service only listened
for "connected", "sync_required", and item events. Without a handler,
the default `EventSource.onerror` would auto-reconnect indefinitely on
the next /api/v1/events request — exactly the tight-loop the server
event was meant to prevent.
- New 'unauthorized' SSEStatus so surrounding UI can react (e.g.
redirect to workspace list or show a "revoked" toast).
- Dedicated listener: on unauthorized, set status to 'unauthorized',
close the EventSource explicitly (this prevents browser auto-
reconnect), and null out currentWorkspace so a later connect()
doesn't treat the closed connection as "already connected".
Parent: PLAN-643 (OSS Security Hardening).
* fix(server): recompute SSE visibility on each revalidation tick (TASK-670)
Addresses Codex P1 on PR #194: previous revision only rebuilt the
filter maps at connect time, so a user whose scope was NARROWED
mid-stream (role downgraded to viewer, collection access tightened
to "specific", item grants revoked) kept receiving events from
collections they no longer had access to. Revocation-of-membership
was caught, but scope-tightening was not.
- Extract the filter-map computation into a new sseVisibility struct
+ (*Server).computeSSEVisibility method. Same logic as before,
just reentrant so it can be re-run on a live connection.
- Store the snapshot in a local `vis` variable captured by the
sseEventVisible closure (reads the CURRENT snapshot, so the next
event dispatched after a tick sees the new permissions).
- On every revalidation tick where the subscriber still has access,
call computeSSEVisibility again and reassign `vis`. The cost is
one GetCollection + one GuestVisibleResources + friends per tick
per connection — acceptable at the 60s cadence.
- New TestComputeSSEVisibility_ReflectsCurrentGrants verifies that
a second call after membership revocation returns a different
snapshot (isGuest flip), pinning the regression.
Parent: PLAN-643 (OSS Security Hardening).
* fix(server): jitter first SSE revalidation tick to avoid stampedes (TASK-670)
Addresses Codex P2 on PR #194: the earlier comment promised jitter but
the implementation wired a plain time.NewTicker(revalInterval). Every
stream then revalidated on a cadence tied to its connect time, which
synchronizes whenever a wave of clients connects close together (post-
deploy reconnect storm, login wave, cron-driven dashboard refresh).
The resulting periodic :00/:60 DB load spike is the exact anti-pattern
the comment warned about.
- Swap the Ticker for a Timer. First fire is delayed by a random
uniform [0, revalInterval) window using math/rand so connect-time
coincidence doesn't translate to revalidation-time coincidence.
- After the first fire, Timer.Reset(revalInterval) re-arms at the
regular cadence — the jitter from connect-time is persistent for
the lifetime of the connection, no need to re-jitter every tick.
- math/rand is fine here: this is load-spreading, not a security
primitive, so a deterministic-at-boot PRNG is acceptable.
Parent: PLAN-643 (OSS Security Hardening).
* fix(server): re-fetch user during SSE revalidation to catch admin demotion (TASK-670)
Addresses Codex P1 on PR #194: sseSubscriberStillHasAccess early-
returned on currentUser(r).Role == "admin", but currentUser(r) is the
user snapshot cached in request context at SSE connect time. An admin
demoted mid-stream via /api/v1/admin/users/{userID} would keep the
admin short-circuit forever — the exact "admin forever" bug the
revalidation loop was meant to close.
- Re-fetch the user via s.store.GetUser(cachedUser.ID) at the start of
each revalidation pass so role changes, disabled flags, and account
deletions take effect on the next tick.
- User deleted → revoke.
- User disabled (IsDisabled) → revoke. Previously a disabled admin's
stream also leaked.
- All downstream checks (admin short-circuit, membership lookup, grant
check) use the fresh copy.
Tests:
- TestSSESubscriberStillHasAccess_AdminDemotion: bootstrap admin, hand
it to the request context, then demote to "member" in the DB and
verify access flips to false. Without the fresh fetch, this test
passes even though the real system leaks — pins the regression.
Parent: PLAN-643 (OSS Security Hardening).
* fix(server): use fresh user for SSE visibility computation too (TASK-670)
Addresses Codex P1 on PR #194 (companion to the previous commit):
computeSSEVisibility called visibleCollectionIDs(r, ...), which reads
currentUser(r).Role — the cached snapshot placed in request context
when the SSE connection opened. A global admin demoted to "member"
mid-stream while keeping workspace membership would keep the admin
short-circuit forever — visibleCollectionIDs returned nil (all access)
based on the stale Role="admin", so events from collections outside
the user's new collection_access="specific" scope would keep flowing.
- computeSSEVisibility now re-fetches the user via s.store.GetUser
before computing visibility. Transient DB errors fall back to the
cached snapshot so a blip doesn't accidentally widen visibility.
- The admin short-circuit (visibleIDs nil) now comes from the fresh
user.Role, so demotion immediately trips the "no, actually filter"
path on the next revalidation tick.
Tests:
- TestComputeSSEVisibility_DemotedAdminGetsFilter: set up a global
admin who is a workspace member with collection_access="specific"
and NO granted collections. Before demotion the admin gets nil
(unrestricted). Demote to "member" → the snapshot must flip to a
non-nil visibleSlugSet (system collections only). The cached-role
bug would keep returning nil here.
Parent: PLAN-643 (OSS Security Hardening).
|
||
|
|
a86cfb7cff |
feat(server): zxcvbn password strength check at registration / rotation / reset (TASK-669) (#193)
* feat(server): zxcvbn password strength check at registration / rotation / reset (TASK-669)
Previously all three entrypoints (bootstrap, register, password change,
password reset) only enforced 8 <= len <= 128. Top-of-breach-list
entries like "password", "password123", "qwerty1234", and "letmein1"
all passed that filter and could silently end up hashed into a real
account.
- New validatePasswordStrength helper wraps github.com/trustelem/zxcvbn
with:
* length guardrails (8-128) kept as cheap early exits
* user-input context (email, name) passed into the scorer so
Alice+"Alice2026" gets penalized as email-derived
* minimum score 2 (OWASP-recommended floor, "adequate for online
attack scenarios")
* empty context strings filtered — zxcvbn treats "" as a banned
substring which would incorrectly weaken every password
- Wired into all four validation points in handlers_auth.go:
bootstrap, register, PATCH /auth/me (password change), reset-password.
- Test suite uses a strong canonical password now
("correct-horse-battery-staple") so bootstrapFirstUser + login flows
don't fight the new check.
- Password_strength_test.go covers: length extremes, the RockYou
top-100 (password, 123456, qwerty, iloveyou, letmein1, …),
email-derived + name-derived patterns, and three acceptable
passphrases.
Parent: PLAN-643 (OSS Security Hardening).
* fix(server): use pending name/username as strength-check context in PATCH /auth/me (TASK-669)
Addresses Codex P2 on PR #193: a PATCH that changed BOTH name and
password used the OLD user.Name as the zxcvbn user-input context, so
a caller could rename themselves to Zaphod + set password "zaphodzaphod"
in one request and slip the identity-derived penalty.
- When input.Name/input.Username are set in the PATCH, use those
pending values (not user.Name / user.Username) as the context for
validatePasswordStrength. Email stays as user.Email — email change
has its own flow and confirmation, not inline here.
- TestPasswordChange_RejectsPasswordDerivedFromPendingName pins the
fix with an integration-level regression test.
- TestValidatePasswordStrength_ContextPenalizesDerivedPasswords pins
the underlying unit behavior (context string actually tips the
score) so a future library swap can't silently regress.
Parent: PLAN-643 (OSS Security Hardening).
* fix(server): identity-aware reset strength check + username context on registration (TASK-669)
Addresses two Codex comments on PR #193:
P2 — reset handler ran a context-less strength check because
ConsumePasswordReset was atomic and gave us the user only after the
token was burned. That made /auth/reset-password enforce a weaker
policy than bootstrap/register/rotation and opened an identity-derived-
password bypass on the primary recovery endpoint.
- New Store.LookupPasswordReset is a read-only validation that returns
the user without consuming the token. handleResetPassword now does
two-phase: lookup → strength-check with full context (email, name,
username) → consume. On strength rejection the token is NOT burned
so the user can try again on the same reset link instead of having
to request another email.
P3 — registration strength check only passed email and name, not the
caller-supplied username. Identity-derived passwords keyed on the
username alone slipped past the zxcvbn user-input penalty.
- Added input.Username as the fourth context arg to
validatePasswordStrength in /auth/register.
Tests:
- TestPasswordReset_UsesIdentityContext: weak identity-derived password
rejected; same token then accepts a strong one (token preserved).
- TestRegister_IncludesUsernameInStrengthContext: username passed to
strength check penalizes username-derived passwords.
Parent: PLAN-643 (OSS Security Hardening).
|
||
|
|
c10023ea8f |
fix(server): deny-by-default whitelist for API token scopes (TASK-667) (#192)
* fix(server): deny-by-default whitelist for API token scopes (TASK-667) tokenScopeAllows previously fell open on unrecognized scopes and on unparseable scope JSON. A typo like "read-only" silently granted full access — exactly the kind of landmine that a fresh token minted by an admin who misremembers the vocabulary would step on. New policy (deny-by-default): - Unparseable JSON → deny + warn (was allow). Data corruption or tampering should never fall open. - Unrecognized scopes → never contribute to allow; all unknowns on a given request get a single warning log so operators can spot typos. - Explicit wildcard "*" and "write" still allow all methods; "read" still allows safe methods only. - Empty scope string and empty JSON array `[]` still allow — these represent legacy pre-enforcement rows we don't want to break on upgrade. Test table updated: - old "unknown scope allows GET/POST" flipped to deny - new "read-only typo denies GET" regression pin - new "unknown+write/wildcard still allow" guard rails confirming that a recognized allow-granting scope alongside an unknown one still grants (unknown is logged, not failing the request) - old "invalid json allows all" flipped to deny Parent: PLAN-643 (OSS Security Hardening). * fix(server): reject JSON null token scopes (TASK-667) Addresses Codex P2 on PR #192: json.Unmarshal accepts the literal \`null\` without error and leaves the target slice nil, so "scopes": "null" would match the legacy empty-array allow-path and grant full access — bypassing the new deny-by-default intent whenever a client-side serializer emits null for a missing field. - Gate the "unrestricted" path on the raw string being "", ["*"], [ "*" ], or [] only (with whitespace trimming on the outside). "null" no longer slips through. - Post-unmarshal, any empty slice that wasn't one of those explicit allow-forms is logged as "non-array or null scopes; denying" and denied. - New test cases: "json null denies POST" / "json null denies GET". Parent: PLAN-643 (OSS Security Hardening). * fix(server): distinguish JSON null from empty array in token scopes (TASK-667) Addresses Codex P2 on PR #192: the previous raw-string whitelist for legacy empty-array tokens rejected valid whitespace-padded forms like \`[ ]\` or \`[\\n]\` that some clients emit. Those decoded to a non-nil empty slice, so a smarter check works: use the Go json package's nil-vs-empty distinction. - scopes == nil → JSON was literal null. Deny + warn (unchanged intent). - scopes != nil && len == 0 → explicit empty array regardless of whitespace. Allow (legacy unrestricted form, as documented). - scopes has entries → existing whitelist logic. Empty-string fast path kept for the no-column case; wildcard fast path now trims whitespace too. New tests: \`[ ]\`, \`[\\n]\`, \`[\\t]\` empty arrays and \`[ "*" ]\` wildcard all allow; \`null\` still denies. Parent: PLAN-643 (OSS Security Hardening). |
||
|
|
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).
|
||
|
|
0a24078554 |
fix(server): derive CLI auth URL scheme from r.TLS, gate X-Forwarded-Proto on trusted proxies (TASK-665) (#190)
handleCreateCLIAuthSession previously accepted X-Forwarded-Proto from any
client to pick the URL scheme, letting an attacker forge https:// in the
terminal link printed by `pad auth login` on plain-HTTP self-host
deployments. Low-impact phishing (the user clicks in their own terminal),
but the safe default is to ignore unauthenticated proxy headers.
- Factor out cliAuthScheme(r, trustedCIDRs) with explicit precedence:
1. r.TLS != nil -> "https"
2. peer in PAD_TRUSTED_PROXIES -> use X-Forwarded-Proto (first value,
case-insensitive, must be "http" or "https")
3. otherwise -> "http"
- Use rawPeerAddr so the check works even after TrustedProxyRealIP has
rewritten r.RemoteAddr.
- Table-driven tests cover TLS, untrusted-peer spoofing, trusted-peer
forwarding, chained/case-insensitive/garbage X-Forwarded-Proto values.
Parent: PLAN-643 (OSS Security Hardening).
|
||
|
|
6f468d37b6 |
fix(config): auto-generate PAD_ENCRYPTION_KEY on first run (TASK-668) (#189)
* fix(config): auto-generate PAD_ENCRYPTION_KEY on first run (TASK-668)
store/encryption.go silently accepted an empty key and stored TOTP
seeds in plaintext; cmd/pad/main.go only logged a WARN. Operators who
never saw the warning (or saw it and ignored it) ran for months with
sensitive data at rest in the clear.
Change: encryption is now mandatory. Resolution order inside
Config.EnsureEncryptionKey:
1. PAD_ENCRYPTION_KEY env var (EncryptionKeySource = "env").
2. encryption_key in config.toml (source = "config").
3. <DataDir>/encryption.key file (source = "file").
4. Generate a fresh 32-byte AES-256 key, persist it to the file
above with 0600 permissions, continue (source = "generated").
Generation step never fails silently — mkdir + write errors propagate
out of main.go and abort startup.
main.go:
- drop the "if cfg.EncryptionKey != '' { enable } else { warn }" fork.
- call cfg.EnsureEncryptionKey(), fail startup on error, log at WARN
when a key is freshly generated so operators notice the new file.
Tests (internal/config/encryption_key_test.go):
- generates when missing (file permissions 0600, 32-byte key).
- loads existing file (strips trailing newline).
- respects already-configured values (no file write).
- idempotent across restarts (same key across two Config objects
sharing a DataDir).
Parent: PLAN-643 (OSS Security Hardening).
* fix(config): refuse to auto-generate key in clustered deployments per Codex P1
Codex caught that auto-generating a per-process key on a Postgres-
backed multi-replica deployment would give each replica its own key —
cross-instance decryption of shared DB rows would fail with GCM auth
errors.
Change: EnsureEncryptionKey now takes an allowGenerate bool. main.go
passes (dbDriver != 'postgres'): single-instance SQLite deployments
get the zero-config auto-generation path; Postgres deployments must
set PAD_ENCRYPTION_KEY explicitly. Operators who DO share a volume
across replicas can pre-seed the file and it still loads (the
generate step is the only thing gated).
Tests:
- TestEnsureEncryptionKey_RefusesToGenerateWhenClustered — allowGenerate=false
+ no existing file → error, no file written.
- TestEnsureEncryptionKey_ClusteredWithPreSeededFileStillLoads — the
file path works in clustered mode when the file is already present.
- Existing idempotency test updated to exercise the mixed case (first
boot generates, second boot loads with allowGenerate=false).
* fix(config): atomic encryption key file creation per Codex P2
Codex caught that the check-then-write sequence for encryption.key had
a race: two processes starting together could both pass the os.ReadFile
IsNotExist check, generate different keys, and race the write.
Whichever process wrote first would end up with an in-memory key that
no longer matched the persisted file, and future restarts of THAT
process would decrypt with the 'wrong' key.
Switch to os.OpenFile with O_CREATE|O_EXCL: on EEXIST we re-read the
file and converge on whichever key won the race. Every racing process
ends up with the same key or a clear startup error.
Test: TestEnsureEncryptionKey_ConcurrentStartIsRaceSafe fires 16
goroutines at a shared DataDir and asserts they all observe the same
key. Also runs clean under -race.
* fix(config): fully-written key guaranteed via temp+hardlink per Codex P2
Codex caught that O_CREATE|O_EXCL + ReadFile-on-EEXIST still had a
window where a loser could read an empty/partial file between the
winner's create and its first write. Hex/length validation would then
fail startup with a confusing error.
Switch to temp-file + os.Link:
1. Write the full key to a uniquely-named temp file (fully closed).
2. os.Link(temp, keyPath) atomically creates the final file as a
hardlink to the complete temp inode. EEXIST means a loser; the
file they'd read is another process's already-complete temp.
3. defer os.Remove(tmpPath) cleans up in every path.
The race-safety test now also covers the 'read partial' case
implicitly — if any goroutine loaded an empty/partial key the hex
decode in main.go would fail in production; the test asserts all 16
goroutines observe the same non-empty key.
* fix(config): reject world/group-readable encryption.key per Codex P2
Codex flagged that the file-load path blindly accepted any mode on
encryption.key. On a multi-user host, a pre-seeded file chmod'd to
0644 would hand the AES key to every local user, defeating the whole
purpose of encrypting TOTP seeds at rest.
Stat the file and reject any mode where group or other bits are set
(0077 mask). Error message points the operator at the fix (chmod 600).
Skipped on Windows where Unix permission bits aren't enforced.
Test: TestEnsureEncryptionKey_RejectsWorldReadableFile pre-seeds the
file at 0644 and verifies startup fails with the chmod hint.
* fix(config): always allow key auto-gen; warn on Postgres per Codex P1
Codex caught that gating auto-generation on 'not postgres' broke the
first-boot experience for every Postgres deployment that wasn't already
provisioning PAD_ENCRYPTION_KEY — which includes our own
docker-compose.yml and deploy/k8s/configmap.yaml. Server would exit
with 'encryption key required' before even starting.
Revert the gate: EnsureEncryptionKey(true) always, for every driver.
In exchange, log a WARN specifically on Postgres when we generate a
key, pointing operators at the multi-replica concern.
Trade-off accepted: single-instance Postgres just works; multi-replica
operators get a visible warning and clear failure mode (GCM auth
errors on first cross-replica read) if they don't act on it. Better
than a startup crash for the single-replica majority.
* fix(config): Postgres requires explicit PAD_ENCRYPTION_KEY; provision it in deployments
Codex was right twice — both concerns are real, and this commit
resolves them together:
1. Restore the Postgres gate: EnsureEncryptionKey(false) when
dbDriver == "postgres". Multi-replica deployments must share a
key; auto-generating per pod would fail cross-replica decryption.
2. Update the shipped Postgres deployments to provision a shared
PAD_ENCRYPTION_KEY so first-boot works out of the box:
- docker-compose.yml: PAD_ENCRYPTION_KEY via ${VAR:?err} shell
substitution (fails "docker compose up" with a clear message
if missing, matching the POSTGRES_PASSWORD pattern).
- .env.example: document PAD_ENCRYPTION_KEY as REQUIRED on
Postgres with an "openssl rand -hex 32" hint.
- deploy/k8s/secret.yaml: add PAD_ENCRYPTION_KEY with a
CHANGE_ME placeholder, explain why the replicas: 2 deployment
requires a shared key.
SQLite deployments continue to auto-generate on first boot (the
TASK-668 happy path), so single-user installs stay zero-config.
|
||
|
|
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). |
||
|
|
e73196f590 |
fix(server): constant-time compare for CSRF token validation (TASK-659) (#187)
* fix(server): constant-time compare for CSRF token validation (TASK-659) The CSRF middleware compared the cookie and header tokens with Go's == operator, which short-circuits on the first byte mismatch. An attacker who can observe response timing can binary-search for the matching token prefix byte by byte — theoretically useful against a local attacker with precise timing, less so against remote attackers but still a hygiene fix. - middleware_csrf.go: switch to subtle.ConstantTimeCompare. Also explicitly check length equality first, because ConstantTimeCompare returns 0 for mismatched lengths and an earlier Go == check would leak a timing signal about "how many leading bytes matched before the length diverged." Existing CSRF tests (FreshInstallExempt, LoginSetsCSRFCookie, LogoutClearsCSRFCookie, AllMutationMethodsBlocked) continue to pass — the only semantic change is timing-safety on validation. Note on the task's HMAC binding suggestion: binding the CSRF token to the session via HMAC is tracked as a follow-up. It requires a stable server-side HMAC key (similar to the 2FA challenge secret), platform- settings persistence, and a session-cookie-dependent setCSRFCookie signature — larger change than this PR is scoped for. Parent: PLAN-643 (OSS Security Hardening). * fix(server): length-check CSRF as strings before allocating per Codex P2 Codex caught that converting both tokens to []byte up-front forces an allocation proportional to the attacker-controlled X-CSRF-Token header on every failing request — a mild DoS/GC-pressure vector. Compare string lengths first (no allocation), short-circuit on mismatch, and only convert to []byte when lengths match. The allocated path then runs subtle.ConstantTimeCompare for the timing-safe comparison. * fix(server): reject off-size CSRF tokens before allocating per Codex P2 Codex caught that the length-match check still allowed attacker- controlled equally-sized tokens of any size (up to MaxHeaderBytes) to trigger the []byte allocation pair. Since CSRF tokens are always csrfTokenLen*2 hex chars (64 bytes), we can safely reject any length that doesn't match the expected fixed size before allocating anything. - middleware_csrf.go: add expectedLen := csrfTokenLen * 2 (hex), reject any cookie/header whose length != expectedLen before converting to []byte. The subsequent subtle.ConstantTimeCompare then operates on fixed-size 64-byte copies. - middleware_csrf_test.go + handlers_auth_test.go: bump all test fixture tokens to 64 hex chars so the fixed-length validation accepts them. The test-only tokens are arbitrary hex (not generated by the real generator) — they just have to match the shape. |
||
|
|
169b79380d |
feat(auth): bigger recovery codes + per-challenge attempt limit (TASK-658) (#186)
* feat(auth): bigger recovery codes + per-challenge attempt limit (TASK-658) generateRecoveryCodes produced 4 bytes of randomness (32 bits) encoded as hex — below the NIST SP 800-63B floor for backup authenticators and grindable online at a few thousand attempts per second. The 2FA verify endpoint also had no per-challenge-token limit on recovery attempts, so a captured challenge could be used to fuzz the entire recovery-code space before the 5-minute expiry. Changes: - handlers_2fa.go: generateRecoveryCodes now emits 10 bytes (80 bits) of entropy encoded as unpadded base32 — 16 chars of [A-Z2-7]. Base32 avoids the 0/O, 1/I/l ambiguity that would bite users typing from a printed backup. 80 bits ≈ 2^80 ≈ 1.2 * 10^24, well above any online grinding budget. - handlers_2fa.go: handleTOTPLoginVerify now rate-limits recovery-code attempts per-challenge-token. Key = "rc:" + SHA-256 of the challenge (so the limiter map never stores the raw HMAC token). Burst of 6 — enough for a user who mistypes a dash or two, nothing more. - middleware_ratelimit.go: new RecoveryCode *ipRateLimiter in the RateLimiters struct, configured at 6/hour burst 6. Test: TestGenerateRecoveryCodes_EntropyShape asserts the 16-char base32 shape and that 8 codes generated in one batch are all distinct (a smoke check on the entropy source). Parent: PLAN-643 (OSS Security Hardening). * fix(auth): normalize recovery code input before hashing per Codex P1 Codex caught that base32 codes are uppercase but users entering them from a mobile keyboard or copy-pasting with dashes would fail the hash comparison, locking out legitimate users and burning per- challenge attempt slots for every typo. Add normalizeRecoveryCode(): strips whitespace and dashes, uppercases the result. handleTOTPLoginVerify runs user input through it before calling store.ConsumeRecoveryCode. Generated codes are already uppercase base32, so the normalization is a no-op for correctly typed codes but catches every common formatting mistake. Test: TestNormalizeRecoveryCode covers lowercase, dashes, whitespace, newlines, and empty input. * fix(auth): legacy lowercase-hex recovery code fallback per Codex P1 Codex caught a backward-compat break: pre-TASK-658 codes were generated via hex.EncodeToString (lowercase), but normalization now uppercases before hashing — so a user with the legacy stored hash typing their exact code is rejected and eventually locked out. After the normalized consume attempt fails, retry once with the raw trimmed input so the original lowercase hex form still validates. No extra rate-limit slot — the limiter.Allow() was already charged. New codes generated post-fix are uppercase base32, so the normalized attempt succeeds on the first try and the fallback is a no-op. |
||
|
|
2e21e8f534 |
fix(server): escape unsubscribe page via html/template + add strict CSP (TASK-657) (#185)
handleUnsubscribe piped email addresses through fmt.Sprintf straight
into an HTML string. If the Maileroo email validation ever regressed
to allow characters like '<', '>', or '"', the unsubscribe page would
reflect them into attribute context — a stored/reflected XSS surface
even on this single-purpose utility page.
- Switch to html/template which auto-escapes every {{.Field}} interpolation.
- Add a strict CSP (default-src 'none', script-src 'none', etc.),
Referrer-Policy: no-referrer, and X-Content-Type-Options: nosniff
to every response from this handler. The page needs none of those
sources anyway — only its own inline styles — so denying everything
else is defense in depth for any future regression.
Tests (handlers_unsubscribe_test.go):
- TestUnsubscribePage_EscapesUserInput feeds `"><script>alert('xss')</script>`
as an "email" and verifies the rendered body contains the escaped form
but not the raw tag.
- TestUnsubscribePage_SetsStrictCSP verifies the CSP directives and
nosniff header are present on every render path.
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. |
||
|
|
f23113ab76 |
fix(server): drop cloud_secret query-param fallback (TASK-656) (#183)
handleGetUserByCustomerID accepted ?cloud_secret= for GET sidecar calls. Query values land in access logs — our StructuredLogger records path + raw query, and any fronting reverse proxy typically logs the same. A log file compromise therefore became a compromise of the cloud trust boundary. Remove the fallback in two places: 1. handleGetUserByCustomerID — only checks X-Cloud-Secret header now (or admin auth via cookie/token). Comment explains why the convenience fallback was removed. 2. hasCloudSecretMarker — no longer honors ?cloud_secret on the auth/CSRF bypass path. Header or body-only for POSTs. Sidecars must send Authorization via the X-Cloud-Secret header. Pad Cloud deployment needs to be updated in lockstep; release notes should call this out. Tests: - TestCloudAdminGate_QueryParamSecret_Rejected flips the prior backward-compat test: ?cloud_secret on /user-by-customer now returns 401 (was 404 pre-fix). - TestCloudAdminGate_HeaderSecret_StillAuthenticates confirms the header form still reaches the handler on the same endpoint. Parent: PLAN-643 (OSS Security Hardening). |
||
|
|
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). |
||
|
|
3544e42de1 |
chore(web): npm audit fix + CI audit gate (TASK-654) (#181)
web/package-lock.json had 11 advisories (1 low, 1 moderate, 9 high) before this PR: @sveltejs/kit (redirect/body-size), cookie<0.7.0, dompurify<=3.3.3, vite 7.0.0-7.3.1, lodash-es, picomatch, chevrotain, etc. All required a mix of `npm audit fix` and targeted upgrades. Changes: - web/package.json: upgrade @sveltejs/kit to ^2.57.1. Add `overrides` map pinning cookie to ^0.7.2 (upstream @sveltejs/kit@2.57.1 still ships cookie@0.6.0 which is LOW severity but trivially fixable). - web/package-lock.json: regenerated via `npm install` + `npm audit fix`. - .github/workflows/ci.yml: add `npm audit --audit-level=high --omit=dev` step after `npm ci`. Fails the build on any HIGH+ advisory in production deps; dev-only issues stay informational so CI isn't held hostage by unfixable upstream chevrotain/vite dev-server advisories. `npm audit --audit-level=high --omit=dev` now reports 0 vulnerabilities locally. `npm run build` and `go test ./...` remain green. Parent: PLAN-643 (OSS Security Hardening). |
||
|
|
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). |
||
|
|
ae1df43438 |
fix(auth): rotate sessions on password change, TOTP off, OAuth unlink (TASK-652) (#179)
* fix(auth): rotate sessions on password change, TOTP off, OAuth unlink (TASK-652)
handleUpdateCurrentUser previously only updated the password — an
attacker who already stole a session cookie could continue using it
forever even after the owner "rotated" their password. Same issue on
the two other credential-surface-mutating endpoints: disabling 2FA
(handleTOTPDisable) and unlinking an OAuth provider (handleOAuthUnlink).
Extract rotateSessionsAfterCredentialChange:
1. store.DeleteUserSessions(userID) — kills every existing session.
2. Mint a fresh session for the caller via store.CreateSession.
3. Set the new session cookie + CSRF cookie so the caller stays
logged in and doesn't have to re-auth on the current tab.
Call the helper from all three handlers. Best-effort on the delete
step — if it fails we log and still mint a new cookie so the caller
isn't stranded.
Test: TestPasswordChange_InvalidatesOtherSessions establishes two
sessions, changes the password from one, and asserts that (a) a new
session cookie is set, (b) the OTHER session token is 401, and (c)
the original caller token is also 401 (replaced by the fresh one).
Parent: PLAN-643 (OSS Security Hardening).
* fix(auth): return fresh token for Bearer callers after rotation per Codex P2
Codex caught that rotateSessionsAfterCredentialChange only reissued
the caller's session via Set-Cookie. CLI / API clients that authenticate
with 'Authorization: Bearer padsess_...' would be locked out on the
next request after any credential change.
Change the helper to return the new token string. Each handler
(handleUpdateCurrentUser, handleTOTPDisable, handleOAuthUnlink) now
includes the fresh token in its JSON response body so Bearer-only
clients can update their stored credential. Cookie-based clients
continue to pick up the new session transparently via Set-Cookie.
|
||
|
|
d86211fcdc |
feat(auth): per-email login rate limiter (TASK-651) (#178)
* feat(auth): per-email login rate limiter (TASK-651) handleLogin is rate-limited per-IP (5/min, in middleware_ratelimit.go), which is effective against a single attacker but useless against a botnet rotating source IPs to spray one victim's password reset email. Add a second limiter keyed on the lowercased email, 10 attempts/hour burst 10. Consumed inside handleLogin on every attempt (success or failure) — a legitimate user remembers their password within 1-2 tries and never hits the limit, but an attacker pounding one account from 50 IPs is locked out after 10 attempts regardless of where those attempts originate. The blocked attempt is logged to the audit log as ActionLoginFailed with reason=email_rate_limited so admins can see which accounts are being sprayed. Tests: - TestHandleLogin_PerEmailRateLimit exhausts the email limit from 10 distinct IPs, then verifies a fresh-IP attempt against the same email gets 429 while a different email from another fresh IP still gets the ordinary 401. - TestHandleLogin_EmailCaseInsensitive verifies the limiter key is normalized — alternating MIXED@/mixed@/Mixed@ all count against the same bucket. Parent: PLAN-643 (OSS Security Hardening). * fix(auth): retain AuthEmail buckets for 2h per Codex P1 Codex caught that ipRateLimiter's cleanup evicts inactive keys after 30 min, which defeats the 10/hour AuthEmail budget: an attacker bursts 10, waits ~30 min for eviction, bursts another 10 — 20 guesses/hour, not 10. Make retention per-config, and set AuthEmail's to 2 hours (≥ 2x the refill window) so the bucket survives the natural pause between spraying rounds. Per-IP limiters keep the 30-min default since their refill is sub-minute. * fix(auth): bound AuthEmail bucket keys by plausibility per Codex P1 Codex caught that the 2-hour retention window on AuthEmail creates a memory-DoS vector — a distributed attacker can POST many long garbage 'email' strings to /api/v1/auth/login and grow the limiter map without bound, since each call inserts a new bucket before any email validation. Add isPlausibleEmail() pre-filter: reject >254 chars (RFC 5321 cap) and strings without an '@' in the interior. Only plausible emails get a bucket; garbage still gets 401 from the password check below but never makes it into the map. Test: TestHandleLogin_ImplausibleEmail_NoBucketCreated hammers the endpoint with 500-char garbage from many IPs and verifies the AuthEmail map never holds a key starting with that garbage pattern. TestIsPlausibleEmail covers empty, missing @, leading/trailing @, over 254, unicode local part. |
||
|
|
0657880d14 |
fix(auth): bind invitation acceptance to invitee email (TASK-650) (#177)
handleRegister and handleAcceptInvitation previously accepted any authenticated/creatable account as the invitee. If an attacker learned the invitation URL (email forwarding, shared screenshot, guessed code) they could register a brand-new account at their own address and claim the workspace seat, or sign into an existing account and attach the invitation to it. Add a case-insensitive strings.EqualFold check between the invitee email (inv.Email) and: - the signup form's Email field in handleRegister, before creating the account; and - the authenticated user's Email in handleAcceptInvitation. Mismatch returns 403 invitation_email_mismatch with a clear message pointing the user at the intended address. EqualFold normalizes the casing mismatch against the store's own ToLower() at create time. Parent: PLAN-643 (OSS Security Hardening). |
||
|
|
33b3f21a2c |
feat(auth): expire workspace invitations after 14 days (TASK-649) (#176)
* feat(auth): expire workspace invitations after 14 days (TASK-649)
A workspace invite code lives forever until accepted. A leaked code —
email forwarding, stale screenshot, git history — lets any attacker who
registers the invitee's email claim the workspace seat months or years
later.
Introduce a 14-day default expiry:
- New migration (SQLite 044 + Postgres 024) adds expires_at TEXT to
workspace_invitations with an index, backfilling existing rows to
created_at + 14 days so old codes also age out.
- Store CreateInvitation sets expires_at = now + InvitationTTL;
GetInvitation/GetInvitationByCode/ListWorkspaceInvitations read and
populate ExpiresAt. Legacy rows with NULL expires_at are treated as
non-expiring (backward compat for codes created before the migration).
- Model gains ExpiresAt *time.Time and an IsExpired() helper, nil-safe.
- handleAcceptInvitation returns 410 Gone "expired" for expired codes.
- handleRegister (invitation path) returns 410 Gone with the same
message so the signup flow surfaces expiry distinctly from "invalid
code".
Tests: models.TestWorkspaceInvitation_IsExpired covers nil/past/future
plus a nil-receiver safety check.
Parent: PLAN-643 (OSS Security Hardening).
* fix(store): backfill invitation expires_at in RFC3339 per Codex P1
Codex caught that the first cut of migration 044 (SQLite) and 024 (Postgres)
emitted space-separated timestamp strings, which parseTime silently rejects —
legacy invitations would all show up as zero-time ExpiresAt and be treated
as already-expired right after upgrade.
- SQLite: switch to strftime('%Y-%m-%dT%H:%M:%SZ', created_at, '+14 days').
- Postgres: use to_char(..., 'YYYY-MM-DD"T"HH24:MI:SS"Z"').
Add regression tests:
- TestCreateInvitation_SetsExpiresAt — fresh invitations get expiry ~14d out.
- TestMigration044_BackfillProducesRFC3339 — inserts a legacy row with NULL
expires_at, applies the same backfill expression as the migration, and
verifies the round-tripped ExpiresAt is non-zero, parses correctly, and
is ~InvitationTTL after created_at.
* fix(store): drop AT TIME ZONE cast in PG backfill per Codex P2
Codex flagged that '(timestamp + INTERVAL) AT TIME ZONE UTC' yields a
timestamptz, and to_char(timestamptz, ...) renders using the session's
TimeZone — on a non-UTC Postgres instance, legacy invitations get
offset-shifted values mislabeled with a 'Z' suffix.
created_at is already stored as UTC text, so casting it to a naive
timestamp and doing the interval math without further conversion is
both correct and tz-independent. to_char on a plain timestamp uses the
stored value as-is and the hardcoded 'Z' suffix labels it accurately.
|
||
|
|
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. |
||
|
|
5fffee2b9e |
fix(docker): bind to 127.0.0.1 + require POSTGRES_PASSWORD (TASK-661) (#174)
* fix(docker): bind to 127.0.0.1 + require POSTGRES_PASSWORD (TASK-661)
A fresh Docker install previously published 7777 on 0.0.0.0 with a
hardcoded pad:pad Postgres credential. The bootstrap endpoint is
reachable until the first admin is created, so this combination lets
anyone who can route to the host claim the instance — and with M5's
X-Forwarded-For spoof (fixed in TASK-660) chained with the loopback
bootstrap check, it became a full takeover.
Changes:
- docker-compose.yml: publish "127.0.0.1:7777:7777" by default, with a
PAD_BIND_ADDR override for operators who intentionally want LAN
access. Require POSTGRES_PASSWORD via ${VAR:?err} so docker compose
refuses to start when it's unset — can't silently inherit a weak
default credential.
- docker-compose.prod.yml: drop the "change-me-in-production"
placeholder; require the same env var as the base file.
- .env.example: new file documenting POSTGRES_PASSWORD (required),
PAD_BIND_ADDR, REDIS_PASSWORD, PAD_CLOUD_SECRET, PAD_ENCRYPTION_KEY,
PAD_TRUSTED_PROXIES with generation instructions.
- README.md: add a Docker Compose section covering the .env workflow
and the loopback-default → LAN override.
Parent: PLAN-643 (OSS Security Hardening).
* fix(docker): use libpq keyword=value DSN to avoid URI-encoding the Postgres password per Codex P1
Passwords produced by 'openssl rand -base64' often include '/', '+', or ':'
which are reserved in URI userinfo. Injecting them into postgres://user:PASS@...
breaks sql.Open. Switch PAD_DATABASE_URL to the libpq keyword=value form
(host=... password=... dbname=...) where the password is parsed as a single
token regardless of special characters.
Also teach pgDbnameFromURL to parse both DSN shapes so 'pad db backup/restore'
still shows the correct database name in its confirmation prompt.
|