Commit Graph

19 Commits

Author SHA1 Message Date
xarmian 527fa47e0f fix(store): a user read no longer decrypts the TOTP secret; only the second-factor check does (BUG-4019) (#2275)
* fix(store): a user read no longer decrypts the TOTP secret; only the second-factor check does (BUG-4019)

models.User no longer carries the secret. GetUserQ and its siblings, the
member and backlink reads and the mention-recipient check therefore cannot
fail on a secret that will not decrypt. checkSecondFactor loads it through the
new TOTPSecretForVerify after the limiter charge: no secret refuses the TOTP
branch (an empty one never verifies), a decrypt failure logs once without
secret material and fails closed, and the recovery-code branch is untouched.

Claude-Session: https://claude.ai/code/session_01DQDip8AC96dA71t2UCPLTD

* fix(server): the undecryptable-secret path logs once; the read tests reach the row (BUG-4019 r1)

- checkSecondFactor writes its 500 directly: writeInternalError logged the
  same error a second time. T2 counts every ERROR record except the request
  logger's per-request line.
- T1's Stripe lookup now matches the undecryptable user (it looked up an
  unknown customer and could not go red), and ListUsers and SearchUsers are
  pinned to return the row with its 2FA flag.
- The appstore boundary allowlist no longer says GetUserQ decrypts, and
  drops the deleted decryptUserTOTP.
- Two comments no longer claim a single reader of the column.

Claude-Session: https://claude.ai/code/session_01HUKE6m5Ps66HAiDpJ3QMQ8

* fix(server): 2FA on with no secret behind it warns the operator (BUG-4019 r2)

An enabled row whose secret is empty cannot verify a code, so the user is
held to recovery codes; checkSecondFactor now logs one warning naming the
user. T5 asserts it. The door tests assert exactly 500, not any 5xx, and
the test placeholder helper drops its unused argument.

Claude-Session: https://claude.ai/code/session_01HUKE6m5Ps66HAiDpJ3QMQ8

* fix(server): the empty-secret warning claims only what is true at that instant (BUG-4019 r3)

user.TOTPEnabled is the request's snapshot, and a disable landing after it
also reads as no secret, so the warning no longer says 2FA is enabled; it
says there was no secret to verify a code against. T5 asserts the new text.

Claude-Session: https://claude.ai/code/session_01HUKE6m5Ps66HAiDpJ3QMQ8

* test(store,server): a stored secret that decrypts to empty is no secret (BUG-4019)

The decrypted=="" guard in TOTPSecretForVerify was unexercised: the empty
case wrote '' and returned at stored=="" first, and encrypt("") returns ""
rather than sealing it, so dropping the guard left every test green. The
store test now writes a hand-sealed empty plaintext to an enabled row and
expects ErrNoTOTPSecret; a server test refuses a code computed from "" on
that row.

Claude-Session: https://claude.ai/code/session_01HUKE6m5Ps66HAiDpJ3QMQ8

* test(server): the BUG-4019 test header names the 2FA paths, not one check (BUG-4019 r5)

EnableTOTP also decrypts the stored secret (to compare) and fails on one
that will not decrypt, so the header no longer says the second-factor check
is the only thing that fails. TOTPSecretForVerify remains the only reader
that returns the plaintext. Comment only.

Claude-Session: https://claude.ai/code/session_01HUKE6m5Ps66HAiDpJ3QMQ8
2026-10-21 15:57:29 -04:00
xarmian b572f0739c feat(auth): the bot principal for installed apps: users.kind, kind gates on every sign-in door and credential, people-only counts (TASK-3392) (#1766)
* feat(store): the bot principal: users.kind, CreateAppUserTx, kind gates on every credential mint and claim, people-only counts, install-gated app projection (TASK-3392)

* test(store): record users.kind in the NUL census baseline (store-written, two values) (TASK-3392)

* feat(server): kind gates at the sign-in doors, admin 404 and member 409 for a bot, apps array on the member list, reserved domain refused (TASK-3392)

* docs+web: the members apps array (TS type, CLAUDE.md); people-only admin member count test (TASK-3392)

* test(server): the U3 owner-webhook strip test plants an install (TASK-3392)

* test(server): forgot-password and resend refuse a bot before attempting a mint (TASK-3392)

* test(server): a usable base URL so the resend door would really mint (TASK-3392)

* test(server): the session doors' own kind checks, with the bot already resolved (TASK-3392)

* fix: stored reset/verification tokens, PAT rotation, grants and OAuth bearers refuse a bot (TASK-3392, codex r1)

* docs: SPEC-6 §11 Q2 is decided (separate Apps section, no seat); the switches stay pinned (TASK-3392)

* docs(server): name the two layers in front of CLI approve's kind check (TASK-3392)

* test(appstore): the projection's install gate joins the reviewed door helpers (TASK-3392, rebased on #1765)
2026-10-03 19:41:18 -04:00
xarmian 98871c7e01 fix(auth): a verification link verifies only for its account's session; the mailbox owner can claim (BUG-3382) (#1758)
* fix(auth): a verification link verifies only for its account's session; the mailbox owner can claim (BUG-3382)

Registration mails the verification link to the address, and the address
may belong to someone other than whoever registered it. Clicking it used to
verify the squatter's account in place, keeping the squatter's password and
sessions.

- POST /auth/verify-email now verifies only when the request is signed in as
  the account the token names. Otherwise it answers 409 verify_needs_session
  with {email, can_claim} and leaves the token unspent.
- New POST /auth/verify-email/claim {token}: the mailbox owner claims a
  never-verified account. One transaction spends the token, resets every
  credential (an unusable password, 2FA, sessions, PATs, OAuth, CLI
  handoffs, reset and verification tokens), marks the address verified,
  strips memberships, grants, watches and tabs in workspaces the account
  does not own, and soft-deletes the ones it owns (TASK-3351 ruling 3). An
  account with a billing customer is refused with
  account_claim_needs_support (ruling 4). The answer carries a
  set-password path and the affected workspace names.
- users.credential_epoch (SQLite 110 / PG 084): bumped by a password change,
  a disable and a claim. Every session mint is fenced on the epoch read
  when the credential was checked (login, 2FA via the challenge token, CLI
  approval, reset completion, rotation, OAuth login), so a sign-in checked
  before a claim mints nothing after it (401 credentials_changed).
- The web verify page offers "Sign in to verify" (back to the link), and,
  for an unverified address, a confirmed claim listing what was removed.
- A regression test shows that an unverified account's cookie cannot use
  the cookie-fallback routes.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* fix(auth): fence password writes on the checked epoch; own verify bucket; switch account (BUG-3382)

Codex round 1:
- A password change (PATCH /me) or a reset completion that started before
  a claim could still overwrite the claim's reset. UserUpdate.ExpectedEpoch
  makes the write conditional on the epoch its authority was checked under:
  the current password's read, or the reset token's spend. The spend now
  reads the epoch in the same statement, and a test hook puts a claim
  between the spend and the read-back. A mismatch writes nothing
  (credentials_changed).
- verify-email and its claim move to their own EmailVerify bucket (10/hour),
  so opening the link, refreshing and claiming no longer spends the
  PasswordReset budget the claimant needs to set a password.
- The verify page, signed in as a different account, signs out before
  sending the user to sign in. A plain /login link bounced straight back.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-03 09:48:19 -04:00
xarmian 0929f65705 feat(invitations): a mailbox-only proof on invitation emails verifies the invitee (TASK-3352) (#1757)
* feat(invitations): a mailbox-only proof on invitation emails verifies the invitee (TASK-3352)

BUG-3348 stopped an invitation code from verifying the invitee's address,
because the inviter sees the code; a cloud invitee then had to click a
separate verification email before writing. The invitation email now
carries a second secret that only the mailbox receives.

- CreateInvitation mints a 128-bit proof next to the code and stores only
  its SHA-256 (workspace_invitations.proof_hash, migrations 109 / 083).
  The plaintext is on the returned struct for the email sender only and
  never serialises.
- Both senders (member invite, admin resend, which re-creates the
  invitation and so rotates the proof) email /join/<code>#proof=<proof>:
  in the fragment, so it never reaches request logs or a Referer. The
  invite response's join_url, the members and admin invitation lists and
  copy-link carry the code alone.
- Store.ConsumeInvitationProof spends it in one transaction (user row
  locked first, live accounts only): clear the hash, mark the address
  verified. Single-use and bound to its invitation.
- Accept (`proof` in the body, after a successful accept, unverified
  accounts only; the email match already ties the account to the
  address) and register with an invitation (`invitation_proof`, which
  then skips the separate verification email) spend it. A wrong or spent
  proof is ignored, not refused. The code alone never verifies.
- The join page reads #proof=, keeps it in sessionStorage for the code
  across a sign-in round trip, strips it from the address bar and sends
  it with the accept or signup.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* fix(invitations): an expired invitation's proof verifies nothing (TASK-3352)

Codex review round 1: the consume checked only the invitation id and the
hash, so a request that passed the handler's expiry check and crossed
the expiry before consuming still verified the account (reproduced at
537 ms past expiry). The expiry is now decided inside the consume's own
statement.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* fix(invitations): an empty expiry verifies nothing, and a malformed proof fragment is ignored (TASK-3352)

Codex review round 2:
- expires_at = '' was let through by the consume, though IsExpired reads
  it as expired; it now verifies nothing, like any expired invitation.
- #proof=% made decodeURIComponent throw before the join page's error
  handling, leaving it loading forever; a malformed escape is now no
  proof.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* docs(invitations): name the email-match duty of ConsumeInvitationProof's callers (TASK-3352)

Lead's review nit.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-03 07:59:37 -04:00
xarmian db62001ad4 fix(billing): a plan past its expiry entitles nothing, and Stripe plan writes are ordered (BUG-3356) (#1753)
* fix(billing): a plan past its expiry entitles nothing, and Stripe plan writes are ordered (BUG-3356)

/admin/plan was last-write-wins under the plan-source rule, so two
Stripe-derived writes arriving out of order could leave the older one
standing and restore an entitlement a newer one removed; and
plan_expires_at was stored but never read.

- models.User.EffectivePlan / PlanExpired: an expiry at or before now
  (or one that does not parse) makes the plan free. Every entitlement
  reader uses it: the workspace and user limit checks and workspace
  storage info; the session and account responses report it. Admin
  responses keep the stored plan and add plan_expired.
- Migrations 106 / 080 add users.plan_revision and
  plan_subscription_id. /admin/plan takes optional revision (the
  sidecar's fetch time, unix ms) and subscription_id; a write with a
  revision applies only if it is newer than the stored one, decided in
  the same UPDATE as the source rule. A refusal answers 200
  applied:false with reason "source" or "stale_revision".
- A stripe write with no revision (a sidecar that predates it) still
  applies, unordered, and is logged and counted in
  pad_plan_writes_total{outcome="no_revision"}; a later change refuses
  it once the sidecar sends one.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* fix(billing): bigint revisions, manual writes fence in-flight Stripe writes, expiry-aware billing counts (BUG-3356)

Codex review round 1:
- Revisions are unix ms, beyond int4; Postgres inferred int4 for the
  bare placeholders compared with 0 and refused real values (500).
  Every revision placeholder is now CAST AS BIGINT; a test uses real
  millisecond values and fails on Postgres without the casts.
- An admin downgrade left the stored revision alone, so a Stripe fetch
  taken before it and landing after it restored pro. A manual write now
  raises plan_revision to now (unix ms): a fetch from before the
  operator's decision is refused as stale, one from after still applies.
  Pad and the sidecar share a host, so the two clocks are close; skew
  moves the fence by that much.
- The admin billing counts (customers by plan, new pro signups, the
  admin user list's plan counts) ignored expiry; they count the
  effective plan, decided in Go because a stored RFC3339 value may carry
  an offset and does not sort against now.
- A refusal's reason was read after the UPDATE in a fresh snapshot; the
  row is now read under its lock first and both decisions use it.
- subscription_id is validated at the door (Stripe-shaped, at most 255
  bytes), since the column takes the NUL baseline's exemption.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* fix(billing): a manual write cannot carry a revision, and the admin update validates expiry (BUG-3356)

Codex review round 2:
- /admin/plan accepted a revision on a manual write, which replaced the
  current-time fence with a value the caller chose; a revision is now
  refused (400, and by the store) unless the source is stripe.
- The admin user update stored an expiry without parsing it; under
  expiry enforcement an unparsable one stores pro and grants nothing.
  It is now refused as /admin/plan refuses it.
- The over-length subscription id case used NUL bytes, so JSON
  rejection masked the length check; it is now a valid-shaped id.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-10-02 23:32:54 -04:00
xarmian 249e44d18e feat(billing): record plan_source; a lowering applies only from the source that set the plan (TASK-3295) (#1657)
* feat(billing): record plan_source; a lowering applies only from the source that set the plan (TASK-3295)

PLAN-3291 DR-6. users.plan_source ('manual' | 'stripe') records who set a
user's plan. The rule lives in the store write, not in callers: a write that
lowers the plan to free applies only when its source is the current
plan_source, the plan is already free or blank, or the write is forced.
Every other write applies and takes the source over. It is one conditional
UPDATE, so it is decided atomically against the row as it stands, and a
refused write changes nothing, plan_expires_at included.

- store.SetUserPlan(userID, PlanWrite{Plan, ExpiresAt, Source, Force})
  returns {Applied, Plan, Source}. Source is required and validated, so
  every caller names one; the compiler found all of them.
- POST /api/v1/admin/plan takes an optional `source` (omitted = manual,
  anything but manual/stripe = 400). A refused lowering is a 200 with
  `applied: false` and the plan the user keeps: pad-cloud treats any
  other status as a failure and the Stripe webhook would retry forever.
  Applied writes now also answer `applied: true` and `plan_source`
  (additive). The source is in the audit metadata.
- The admin user update writes manual with force: an operator's choice
  always applies, lowering included.
- plan_source is on models.User (json:"-") and in the four admin user
  responses that carry plan.
- Migrations 101 (SQLite) / 075 (Postgres): column default 'manual', plus a
  backfill to 'stripe' for pro users with a Stripe customer. Without it, an
  already-paying user's Stripe cancellation would be a refused lowering and
  they would keep pro after they stopped paying. Billing is not switched
  on yet, so it should match no rows in prod; it is there so the migration
  is safe either way.
- nulcolumns: users.plan_source recorded as an excluded server enum.

Writers of users.plan, from grep (SET plan / INSERT INTO users /
SetUserPlan): the two SetUserPlan callers above; BackfillUserPlans, which
fills blank rows only and is not a source write, so the source is left
alone; and the two user INSERTs, which write no plan and take the column
defaults.

Skew: a pad without this ignores `source` (json.Unmarshal) and applies the
write, which is today's behaviour, so pad deploys first, then the sidecar.

Tests (SQLite here; Postgres under make test-pg): a 9-case rule table
reading the row back through GetUser; a new user is free/manual; invalid
source and missing user refuse without writing; the migration's own UPDATE
run against a fixture per dialect; and router-level /admin/plan and admin
PATCH/GET tests. Mutants, each BUILD OK and killed by named tests: rule
removed, source not written, force ignored, same-source clause dropped,
admin update unforced, refused write answering 409, omitted source
defaulting to stripe.

Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9

* fix(billing): the startup plan backfill takes plan_source over (TASK-3295)

Codex round 1 on 28c036de (P2): BackfillUserPlans("self-hosted") raises
free and blank plans to self-hosted without touching plan_source, which
bypasses the rule that a non-lowering write takes the source over. A free
user carrying a stripe source would become self-hosted still labelled
stripe. Both backfill branches now write plan_source = manual on every row
they change, and rows they skip keep their source (pinned by
TestBackfillUserPlans_TakesSourceOver).

The same round's P1 (pad-cloud does not send `source`) is the sidecar half
of this unit, which lands in pad-cloud. It also corrects the deploy order
stated in 28c036de: pad-cloud must deploy FIRST. A new pad with an old
sidecar receives a Stripe cancellation as `manual`, and for a user the
migration backfilled to `stripe` that is a refused lowering: they would
keep pro after they stopped paying. A new sidecar with an old pad is
harmless, because the old pad ignores `source`.

Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9
2026-09-29 10:14:38 -04:00
xarmian 6a63fba188 feat(store): add users.email_verified_at column + model plumbing (TASK-1935) (#805)
Wave 1 of PLAN-1933 (email verification). Pure infra — nothing reads the
column until Wave 3, so this is behaviourally a no-op and mergeable early.

- Migration 070 (SQLite) / 048 (Postgres): add nullable email_verified_at
  TEXT, mirroring disabled_at. UNCONDITIONALLY backfill every existing row
  to verified (RFC3339 'Z'-suffixed) so no existing / OAuth / self-host
  account is write-locked on deploy (inverted vs password_set's conditional
  backfill). SQLite ALTER without IF NOT EXISTS; Postgres with it.
- SAFE default = verified (DR-3): CreateUser / CreateOAuthUser write a
  verified timestamp unless UserCreate.Unverified is explicitly requested
  (only the future cloud self-serve branch will set that). A missed call
  site fails SAFE (verified), not write-locked.
- models.User.EmailVerifiedAt + IsEmailVerified() (mirror IsDisabled).
- Update userColumns + BOTH scan sites (scanUser AND the inline SearchUsers
  scan) so the admin user list keeps working.
- Expose derived email_verified bool in sessionUserPayload for a later wave.

Gates: make check + make test-pg both green (dual-dialect verified).

Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
2026-07-04 00:45:48 -04:00
xarmian 0c5ec04fac feat(admin): extend user list with aggregations + sort/filter (TASK-1544) (#599)
* feat(admin): extend user list with aggregations + sort/filter (TASK-1544)

GET /admin/users now returns per-user workspace_count, storage_bytes,
last_write_at, and a computed status pill (disabled / no-workspace /
inactive / active, with documented precedence). Adds sort and filter
knobs so the table can scale beyond the existing fixed offset/limit.

Store layer:

- AdminUserSearchParams gains Role, Sort, Order, ActiveWithinDays,
  HasWorkspaces, Disabled. Pointer types where tri-state ("no filter"
  vs. "filter to false") matters.

- AdminUserListEntry wraps models.User with WorkspaceCount, StorageBytes,
  Status — returned in AdminUserSearchResult.Users.

- SearchUsers SQL rewritten: LEFT JOIN against grouped subqueries so
  one user owning N workspaces with M attachments each still produces
  exactly one row (no aggregation explosion). Both subqueries filter
  deleted_at IS NULL to match WorkspaceStorageUsage's existing
  definition. Allow-listed sort clause prevents injection.

- computeAdminUserStatus exported for unit tests; precedence locked in
  by TestComputeAdminUserStatus.

- TestSearchUsersAggregations covers workspace_count + storage_bytes +
  status across a three-user fixture and each new filter/sort knob.

Model + scanner:

- models.User gains LastWriteAt. userColumns + scanUser updated; the
  legacy callers (GetUser, ListUsers, etc.) inherit the new field for
  free via the shared scanner.

Handler:

- handleAdminListUsers accepts the new params: role, disabled,
  has_workspaces, active_within_days, sort, order. Tri-state bools
  only fire when the query param is present. Response now embeds
  workspace_count / storage_bytes / last_write_at / status.

Part of PLAN-1542. Frontend consumption lands in T1548 (cheap columns)
and T1549 (sort/filter UI).

* fix: address Codex review on TASK-1544

- Tri-state bool parsing in handler now uses strconv.ParseBool — accepts
  the canonical truthy/falsy variants ("True"/"TRUE"/"t"/"1" and the
  parallel falses), and silently ignores garbage values rather than
  treating them as false. Closes the "disabled=TRUE silently means
  enabled-only" surprise.

- SearchUsers count query no longer joins the storage aggregation when
  HasWorkspaces isn't an active filter. The page query still needs both
  joins (the row carries the data), but a typical "give me a count"
  call no longer scans every live attachment. The workspace_count join
  remains conditional on HasWorkspaces filtering.

Status threshold (>30d vs >=30d): the documented spec and impl both say
">30d" — no change.
2026-05-20 16:50:16 -04:00
xarmian 157ca4e88f chore: bump Go toolchain to 1.26 (TASK-763) (#247)
* chore: bump Go toolchain to 1.26 (TASK-763)

Bump Go from 1.25 to 1.26 across all toolchain pins:

- go.mod — go 1.25.0 → go 1.26.0
- Dockerfile — golang:1.25-alpine → golang:1.26-alpine
- .github/workflows/ci.yml — three setup-go steps (Go, Go-Postgres, E2E jobs)
- .github/workflows/release.yml — release pipeline

No `toolchain` directive: the repo is pre-launch with no external
contributors yet, so we set the floor where we want it (hard requirement).

Verified locally before commit:
- golangci-lint v2.11.4 builds and runs under Go 1.26.2 (pinned in CI)
- golang:1.26-alpine and 1.26.2-alpine images present on Docker Hub
- go build ./... clean
- go vet ./... clean
- go test ./... all pass

Parent: PLAN-644 (OSS Repo Hygiene and Launch Polish).

* chore: gofmt -w under Go 1.26 (TASK-763)

Apply Go 1.26's gofmt to the codebase. ~41 files reformatted, all
struct-tag whitespace realignment — no semantic changes. Verified:

- gofmt -l ./cmd ./internal returns empty after
- go build ./... still clean
- go test ./... still passes (run before commit)

Bundling the gofmt diff with the toolchain bump in the same PR because
the formatting drift is a direct consequence of moving from 1.25 to
1.26; splitting them creates a mandatory two-PR ordering for no value.

Parent: PLAN-644.

* docs: bump documented Go floor to 1.26 (TASK-763)

Match go.mod's hard 1.26.0 requirement in the source-build instructions.
Caught by Codex review round 1 on PR #247.

- README.md:158 — "Go 1.25+" → "Go 1.26+"
- CONTRIBUTING.md:9 — "Go 1.25+" → "Go 1.26+"
2026-04-25 11:35:19 -04:00
xarmian 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.
2026-04-21 20:08:05 -04:00
xarmian e328844a1b fix: resolve five open bugs (BUG-585, BUG-586, BUG-588, BUG-589, BUG-590)
BUG-585 — Code-block copy no longer includes ``` fences
  Editor.svelte: ProseMirror plugin overrides copy/cut when the selection
  is inside a code_block node and writes raw textBetween to the clipboard.
  NodeView for non-mermaid code blocks now shows a hover "Copy" button that
  uses the existing copyToClipboard() util (with execCommand fallback).

BUG-586 — Wiki-link picker matches on item ref
  Editor.svelte: getFilteredLinks() now also matches formatItemRef(item),
  so typing [[DOC-535]] finds items by their issue ID. Picker dropdown
  shows the ref as a badge; {#each} key switched to doc.id so duplicate
  titles across collections don't collide.

BUG-588 — Can unlink OAuth provider when password is configured
  Adds a password_set column to track whether a user has a usable
  password vs. the random placeholder hash given to OAuth users.
  CreateUser sets it true, UpdateUser sets it true when a password is
  provided, and ValidatePassword auto-upgrades it on any successful
  email/password login (which transparently upgrades pre-existing users
  who linked OAuth after signing up with a real password — the OAuth
  placeholder hash cannot match user-supplied plaintext, so this is safe).
  handleOAuthUnlink now permits removing the last provider when
  user.HasPassword() is true.

BUG-589 — Pre-auth pages render standalone
  +layout.svelte: isAuthPage now also matches /forgot-password and
  /reset-password/* so those pages don't inherit the authenticated
  sidebar/topbar layout.

BUG-590 — Search no longer crashes with null results
  store.Search() returned a nil Results slice on no-match queries, which
  Go marshals as JSON null; CommandPalette then crashed on results.length.
  Backend now normalizes nil to []SearchResult{} before returning.
  CommandPalette also coalesces resp.results ?? [] on the initial search
  and loadMore paths as belt-and-suspenders hardening.
2026-04-17 03:03:49 +00:00
xarmian b3af1acd07 feat: add last active tracking for users (#105)
* feat: add last active tracking for users

Track when users were last active via a throttled update (once per 5
minutes) in the auth middleware. Adds last_active_at column, displays
relative time in admin user list with full timestamp on hover.

* fix: bound last-active goroutine with 3s context timeout

Use a short-lived context for the background TouchUserActivity write
so it gets cancelled under DB pressure, preventing goroutine/connection
buildup from unbounded background work.
2026-04-13 22:19:41 -04:00
xarmian d968b551b7 feat: add account disable/deactivation (#104)
* feat: add account disable/deactivation for admin users

Allow admins to soft-disable user accounts without deleting data.
Disabled users get a 403 on all authenticated requests, their sessions
are invalidated on disable, and they show as visually dimmed with a
red "disabled" badge in the admin console. Includes migration for
disabled_at column, auth middleware check, disable/enable endpoints
with audit logging, and frontend toggle with confirmation dialog.

* refactor: auto-discover migrations from embedded filesystem

Replace hardcoded migration lists with fs.ReadDir on the embedded FS
directories. New migrations are now picked up automatically by filename
sort order — no need to manually register them in store.go.

* fix: block disabled users at login and capture IDs before async calls

Reject disabled accounts in the login handler before session creation,
not just in RequireAuth middleware (which exempts auth routes). Also
capture selectedId into a local const in all async admin panel functions
to prevent stale updates if the selection changes during a request.

* fix: enforce disabled check in OAuth and password reset flows, always invalidate sessions

Block disabled users in all session-minting paths (OAuth login, password
reset) not just password login. Also remove early return for
already-disabled users in the disable endpoint so session invalidation
always runs, handling retry after partial failure.
2026-04-13 21:56:40 -04:00
xarmian 92580905bb feat: cloud hardening and security follow-ups (PLAN-503)
Address 11 issues identified during the PLAN-427 security review:

Critical/High:
- Stripe customer-to-user mapping with indexed lookup (TASK-505)
- OAuth provider linking with explicit consent model (TASK-504)
- CSRF tokens on admin console mutations (TASK-506)
- Rate limiting on cloud admin and OAuth endpoints (TASK-507)

Medium:
- __Host- cookie prefix for subdomain protection (TASK-510)
- Billing portal verifies customer ownership server-side (TASK-515)
- Transactional account deletion with rollback (TASK-509)
- Streaming data export with 60s timeout (TASK-508)
- Migration registration for new columns (TASK-514)

Low:
- Billing page fetches actual plan limits from API (TASK-511)
- Admin user search/filter pushed into SQL with pagination (TASK-512)
2026-04-13 01:03:12 +00:00
xarmian d0518216c5 feat: add cloud infrastructure for hosted Pad (PLAN-427)
Add the foundation for running Pad as a hosted service at app.getpad.dev.
Same binary in cloud mode with a thin sidecar for OAuth and Stripe.

Cloud mode (PAD_CLOUD=true):
- PAD_CLOUD flag with cloud secret for sidecar communication
- Account-level billing: plan field on users, CheckLimit enforcement
- Free/Pro tiers with configurable limits stored in platform_settings
- Three-tier limit resolution: user overrides → DB defaults → hardcoded fallback
- Plan enforcement on workspace, item, member, webhook, and token creation

Authentication & security:
- OAuth login endpoint (POST /api/v1/auth/oauth-login) with cloud secret gate
- Verified email requirement for OAuth, 2FA bypass protection
- Cloud secret rotation support (comma-separated keys)
- TOTP secret encryption at rest (AES-256-GCM via PAD_ENCRYPTION_KEY)
- Rate limiting on OAuth login endpoint
- Bootstrap disabled in cloud mode
- Password max length enforcement (128 chars)
- Config file written with 0600 permissions

Admin & billing:
- Admin user management API (list, detail, update plan/overrides)
- Configurable plan limits API (GET/PATCH /api/v1/admin/limits)
- Platform stats endpoint
- Admin plan endpoint for sidecar to set user plans
- GDPR: account deletion and data export endpoints

Console UI (cloud mode only):
- /console — workspace list with owned/shared sections
- /console/new — create workspace wizard with slug preview
- /console/settings — profile, password, API tokens
- /console/billing — plan status, upgrade/manage links
- /console/admin — user management, plan overrides, limits editor
- OAuth buttons (GitHub/Google) on login page in cloud mode

Auto-create default workspace on signup in cloud mode.
Migration 035: plan, plan_expires_at, stripe_customer_id, plan_overrides on users.
2026-04-12 17:43:40 +00:00
xarmian d74431fbb3 feat: collection-level visibility + system collections
Add per-member collection visibility controls and mark conventions/
playbooks as system collections (TASK-413 + TASK-415).

Data model:
- workspace_members: new collection_access column ('all' or 'specific')
- New member_collection_access table (workspace_id, user_id, collection_id)
- collections: new is_system column, set for conventions and playbooks

Store methods:
- VisibleCollectionIDs(workspaceID, userID) — returns nil for "all"
  access, or specific IDs (including system collections) for "specific"
- SetMemberCollectionAccess/GetMemberCollectionAccess for CRUD
- All collection queries include is_system in SELECT/scan
- Export/import handles is_system field

Default collection definitions:
- conventionsCollection and playbooksCollection set IsSystem=true
- New workspaces get system flag on seed

D7: default collection_access is "all" — absence of restrictions
means full access. System collections always visible to members.
2026-04-11 01:02:06 +00:00
xarmian f80876a52e feat: add username column to users table
Add username field to the user data model as the foundation for
the multi-user permissions system (PLAN-407, TASK-408).

- SQLite migration 029 and Postgres migration 009 add username column
  with partial unique index (WHERE username != '')
- User, UserCreate, UserUpdate Go structs updated
- Store: CreateUser, UpdateUser, scanUser, userColumns updated
- New GetUserByUsername store method (case-insensitive lookup)
- All auth handler JSON payloads include username field
- WorkspaceMember struct and ListWorkspaceMembers query include username
- TypeScript User type and API client inline types updated

Column is empty string by default; TASK-482 will backfill existing
users and TASK-409 will add validation/registration flow support.
2026-04-10 22:40:07 +00:00
xarmian 0e32645bb5 feat: add TOTP two-factor authentication
Backend support for optional TOTP-based 2FA on user accounts:

- POST /auth/2fa/setup — generate TOTP secret, return QR code URI
- POST /auth/2fa/verify — verify code and enable 2FA with recovery codes
- POST /auth/2fa/disable — disable 2FA (requires password confirmation)
- POST /auth/2fa/login-verify — complete login with TOTP or recovery code
- Login returns {requires_2fa: true, user_id} when 2FA is enabled,
  requiring a second step via /auth/2fa/login-verify
- 8 recovery codes generated on setup for account recovery
- User model extended with totp_secret, totp_enabled, recovery_codes
- Refactored user queries with shared scanUser/userColumns for DRYness

Implements TASK-169 under PLAN-15 (Pad Cloud: Hardening).
2026-04-08 20:30:39 +00:00
xarmian 46447e5504 feat: user management & authentication (Phase 6) (#14)
* feat: add user management database migration and models

Add migration 012_users.sql with users, sessions, and workspace_members
tables. Add user_id columns to api_tokens, items, comments, activities,
item_links, and item_versions for proper user attribution. Create Go
model structs (User, Session, WorkspaceMember) in models/user.go.

* feat: add store layer for users, sessions, and workspace members

Implement CRUD operations for user management:
- users.go: create, get, update, list, validate password (bcrypt)
- sessions.go: create, validate, delete, cleanup expired (SHA-256 hashed tokens)
- workspace_members.go: add/remove members, role management, access checks

Adds golang.org/x/crypto/bcrypt dependency. Includes 16 new tests
covering all store methods, password validation, session lifecycle,
and workspace membership operations.

* feat: rewrite auth system from single-password to user-based

Replace single-password auth with email/password user authentication:
- New endpoints: POST /auth/register, GET /auth/me
- Rewritten: POST /auth/login (email+password), GET /auth/session
  (needs_setup detection), POST /auth/logout (DB session destroy)
- Delete in-memory SessionManager, use DB-backed sessions via store
- New middleware: SessionAuth (cookie→user), RequireAuth (with
  fresh-install passthrough when no users exist)
- Remove Password field from config, PAD_PASSWORD env var, SetPassword()

All 23 existing server tests pass (fresh DBs have no users → passthrough).

* feat: add workspace access control middleware

Add RequireWorkspaceAccess middleware that checks workspace_members for
authenticated users, with fallback for legacy API tokens and fresh
installs (no users → implicit owner). Includes role hierarchy helpers
(workspaceRole, requireRole) for downstream permission checks.
Wire middleware into the /{slug} workspace route group.

* feat: add CLI auth commands and credential storage

Add pad login, pad logout, pad whoami commands with credential
storage in ~/.pad/credentials.json (0600 permissions). Update CLI
HTTP client to auto-attach auth tokens and X-Pad-Agent header on
all requests. Add auth API methods (Login, Register, Logout,
CheckSession, GetCurrentUser). Extend .pad.toml with optional
agent_name field. Add golang.org/x/term for masked password input.

* feat: derive actor/source from auth context in all handlers

Replace hardcoded "user"/"web" actor/source strings with auth-aware
helpers. actorFromRequest() derives actor ("user"/"agent" via
X-Pad-Agent header) and source ("web"/"cli" from auth method).
agentMeta() merges agent name into activity metadata. Update all
item, document, comment, and move handlers to use request-based
logActivity/logActivityWithMeta. Remove hardcoded CreatedBy/Source
from all CLI commands — server now determines these from auth context.

* feat: frontend auth — login, registration, auth guard, user menu

Rewrite login page with email/password fields, add registration page
for first-time setup, update auth guard to handle needs_setup redirect.
Add user menu to sidebar with logout. Update API client with new auth
methods (register, login with email, session with needs_setup flag).

* feat: migrate API tokens from workspace-scoped to user-owned

API tokens now have a user_id owner and optional workspace_id scope.
CreateAPIToken takes userID as first parameter. ValidateToken resolves
the token's user into the request context. TokenAuth middleware now
sets ctxCurrentUser when a user-owned API token is used. Add user-
scoped endpoints: GET/POST/DELETE /auth/tokens. Keep workspace-scoped
token endpoints for backwards compatibility.

* feat: workspace membership, invitations, and role enforcement

Add workspace_invitations table (migration 013) with join codes.
Implement invitation store methods (create, get by code, accept,
list). Add member management handlers: list members + invitations,
invite (auto-adds existing users or creates invitation), remove
member, change role, accept invitation by code. Add API routes
under /workspaces/{slug}/members/* and /invitations/{code}/accept.
Add CLI commands: pad members, pad invite, pad join.

* feat: auth tests and documentation updates

Add comprehensive auth endpoint tests: registration flow (first user
becomes admin), login/logout, validation errors, duplicate email,
auth enforcement (401 after users exist, exempt paths), /me endpoint.
Update CLAUDE.md and README.md to document user-based auth system,
replacing old PAD_PASSWORD references with pad login/members/invite
workflow and role-based access control.

* feat: add members management UI to workspace settings page

Add Members section to settings with: member list (avatar, name,
email, role), role change dropdown (owner only), remove button
(owner only), pending invitations display with join codes, and
invite form with email + role picker. Add members API methods to
the TypeScript client (list, invite, remove, updateRole).

* fix: backfill workspace owners for pre-migration workspaces

Add backfillWorkspaceOwners() that runs on server start. For any
workspace with no members, adds the first admin user as owner.
This handles the migration case where workspaces existed before the
user system — without it, the members list shows empty.

* feat: shareable invite links with /join/[code] page

Replace raw join codes with full shareable URLs. Server generates
join_url using its configured base URL (e.g. https://pad.example.com/
join/a3f8b2c1). New /join/[code] page handles the full flow: checks
auth → shows login/register if needed → accepts invitation → redirects
to workspace. Settings page shows "Copy invite link" button that copies
URL to clipboard. CLI outputs shareable link instead of raw code.

* fix: auto-add workspace creator as owner, integrate auth into pad init

handleCreateWorkspace now adds the authenticated user as owner of the
new workspace immediately — no more relying on the startup backfill.

pad init now checks auth status before making API calls. If no users
exist, prompts to register. If not logged in, prompts to login. After
auth, proceeds with workspace creation normally.

* fix: add join_url to invite response type in API client

* fix: address codex review — invite registration, logout token revocation, workspace scoping

- Allow registration with valid invitation_code (fixes invite flow for new users)
- Revoke Bearer session tokens on logout, not just cookies
- Filter workspace listing to user's memberships (admins see all)
2026-03-28 15:43:09 -04:00