6 Commits

Author SHA1 Message Date
xarmian 0877b260c1 feat(redis): namespace every Redis keyspace from one shared config value (BUG-2724)
Every Redis key and channel Pad uses was flat — pad:events:, pad:event_seq,
pad:watchevents*, pad:session:* — so two Pad installations pointed at one
Redis endpoint cross-feed each other's notifications and merge each other's
session-presence registries. Different logical DB numbers do not help:
Redis pub/sub is not namespaced by DB at all.

The exposure is narrow but real. Delivery is filtered per caller on user
id, and user ids are per-installation UUIDs, so cross-feed needs the same
id in both installations — a CLONED database, such as a staging
environment restored from a production dump. For that case it is a genuine
cross-tenant leak: foreign sessions listed in the picker, and a private
push deliverable across installations.

Fixed the way internal/watchevents' existing ruling demanded: not by one
package growing a prefix the others lack, but through internal/redisns —
one value parsed in cmd/pad/cmd_server.go and passed into all three
constructors. The three cannot drift because there is nothing to drift
from, and the operator rule is stateable in one sentence for every
keyspace.

PAD_REDIS_NAMESPACE defaults to empty, which reproduces the historical
names byte for byte, so an existing deployment keeps addressing its own
replay buffers, counters and presence entries across the upgrade. Tests
assert both directions per keyspace — present under the namespace AND
absent under the historical names — because an implementation that wrote
both would still cross-feed while passing a one-directional test.

Namespaces are validated at startup, and a colon is rejected specifically:
it is Pad's own separator, so namespace "a:events" would build
pad:a:events:<ws> and collide with installation "a"'s channel —
reintroducing the cross-feed through the mechanism meant to fix it.

Names are built through a function rather than assembled from a literal at
each site, and redisns' doc says why: "pad:" also begins Pad's OAuth SCOPE
values (pad:read / pad:write / pad:admin) in four files, so a grep-driven
prefix sweep would break authorization.

Not included, deliberately: hash tags for Redis Cluster. BUG-2724's trail
recommended shipping them alongside on cost-sharing grounds; that premise
is falsified by publishScript, which spans four keys in one EVAL and fails
CROSSSLOT exactly as presence's MGET does. There is no cheap half, and no
cluster client here to exercise tagged keys against, so they would ship
untested by construction. Cluster stays documented as unsupported and the
future unit is named on the trail.

Renaming is a CUTOVER for the buses (the seq and epoch keys carry
Last-Event-ID meaning, so connected clients resync) and free for presence
(90s TTL). Both stated in docs/deployment.md and at the constructors.

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X
2026-08-22 01:41:31 +00:00
xarmian 2945ee27dd feat(server): add WebSocket handler at /api/v1/collab/{itemID} (TASK-1254) (#452)
* feat(server): add WebSocket handler at /api/v1/collab/{itemID} (TASK-1254)

WebSocket entry point for Yjs-based collaborative editing on a
single item under PLAN-1248. Bare-bones in this PR by design:
upgrade + log connect/disconnect + drain reads. Protocol logic
(forwarding to OpBus, persisting to op-log, awareness fan-out)
arrives in TASK-1255 (room manager).

Authorisation mirrors RequireWorkspaceAccess but keyed on the
item's workspace ID rather than a {slug} URL param — the WS URL
only carries itemID. Implementation re-uses the same access
ladder:

  fresh-install escape hatch (no users)
    → grant
  legacy workspace-scoped API token, no user
    → grant if token's workspace matches the item's workspace
  OAuth token allow-list (TASK-953)
    → reject when workspace not on consented list
  authenticated user
    → admin OR member OR has guest grants

User is re-fetched from the store on each upgrade (not trusted
from session-context cache) so a mid-session admin demotion or
member removal closes the upgrade path immediately. Mirrors
sseSubscriberStillHasAccess. Periodic per-connection
revalidation lives in TASK-1256.

Route registered alongside SSE (outside the jsonContentType
middleware group, but inside the auth middleware chain). Promotes
github.com/gorilla/websocket from indirect to direct dep and
bumps to v1.5.3 (latest stable; v1.5.0 was already in
go.mod transitively via another package).

Tests cover:
- fresh-install escape hatch grants the upgrade
- bootstrapped server rejects unauthenticated upgrade with 401
- non-member with valid session is rejected with 403
  (NOT 401 — confirms the access path runs after auth, not before)
- unknown item surfaces as 404 (not 401/403 leak)
- empty itemID segment doesn't match the route

Test infrastructure note: dialCollab takes an explicit User-Agent
because pad's session-binding middleware hashes the UA at
CreateSession time and re-checks on every request — the dialer
must match what was stored, otherwise the cookie is rejected
before the workspace check fires (and we'd see a misleading 401
where 403 was expected).

Parent: PLAN-1248. Phase 1 — Backend foundation.

* style: gofmt handlers_collab_test.go per Codex review (round 1)

* fix(server): SetReadLimit + nginx upgrade headers for collab WS per Codex review (round 2)

P-MEDIUM #1: handleCollab.ReadMessage had no per-message size cap, so an
authenticated client could send an arbitrarily large frame and force
unbounded server-side buffering — the HTTP body limit applied by the
auth chain doesn't apply once the connection is upgraded. Set
SetReadLimit(1 MiB), generous for keystroke-rate Yjs ops and large
enough for a typical initial-sync state. ReadMessage returns an error
when exceeded, which the existing read loop handles as a normal close.

P-MEDIUM #2: deploy/nginx.conf routed /api/v1/collab/ through the
default `location /` block, which sets `Connection ""` (cleared so HTTP
keepalive works) — that strips the Upgrade header, so WebSocket
upgrades silently fail behind the documented nginx deployment. Add a
dedicated location block with proxy_set_header Upgrade $http_upgrade /
Connection "upgrade", same 24h read/send timeouts as SSE so an idle
editor tab does not get cut off mid-session.

* fix(server): enforce per-item visibility in collab WS upgrade per Codex review (round 3)

P2: authorizeCollabAccess granted upgrade to any workspace member or
guest-with-grants without checking whether THIS specific item was
visible to that user. A restricted member (collection_access=specific)
or a guest with grants on item A could upgrade /api/v1/collab/{itemID}
for an item B in a different collection — they'd see live edits to a
document they have no right to read.

Restructure the access ladder:

1. Workspace-level gate stays as-is: "any access at all?" If no
   membership AND no grants → 403 (unchanged).
2. Item-level visibility check added on top, mirroring requireItemVisible
   without depending on middleware-set request context (the WS path
   doesn't go through RequireWorkspaceAccess):
     - VisibleCollectionIDs nil → "all" access → grant.
     - Item's collection in the visible set → grant.
     - Item-level grant on this exact item → grant (covers guests
       given access to a single item rather than a whole collection).
     - Else → 404, mirroring requireItemVisible's "don't leak
       existence" pattern.

Admin path returns nil before this check, so no change there.
Legacy workspace-scoped API tokens grant editor-equivalent access
on workspace match (predates the grants design); that branch is
untouched since legacy tokens don't have a user identity to scope
per-item grants against.

Test added: TestCollabUpgradeRejectsRestrictedMemberForeignCollection
— member with specific access to collA tries to upgrade for an item
in collB → 404. Existing 5 tests still pass.

* fix(server): strict per-item visibility check + sibling-grant test per Codex review (round 4)

P1 (round 4): VisibleCollectionIDs is broader than full-collection
access — it includes collections "anchored" by an item-level grant
(so the nav can still surface the parent collection of a granted
item). Round 3's check treated every visible collection as full
access; a guest with grant `item:A` could upgrade
/api/v1/collab/{B} for a sibling B in the same collection.

Tighten by mirroring guestResourceFilter / requireItemVisible:

  1. Coarse stage stays — collection must be in the visible set.
  2. NEW strict stage when the user has item-level grants:
     (a) full collection grant on this collection → grant
     (b) member's "specific" access list including this collection
         → grant
     (c) item grant on THIS exact item → grant
     Else → 404 (the visible-set hit was anchored by a sibling's
     grant, not by full collection access).

When the user has NO item grants, the coarse-only check is
sufficient — visibility came from full collection access (member's
"specific" list, full collection grant, or "all" access).

Test added: TestCollabUpgradeRejectsGuestWithSiblingItemGrantOnly
— guest with item:A grant tries to upgrade for sibling B in the
same collection → 404 (the bug being regression-tested) AND verifies
the granted item A still upgrades cleanly to 101 Switching Protocols.
2026-05-08 14:36:47 -04:00
xarmian 7cda0d7896 feat: rebrand to Perpetual Software + new tagline (IDEA-832) (#273)
Migrates from xarmian/pad to PerpetualSoftware/pad across the entire
repo and updates the product subtitle to "Collaborate with your AI
agents".

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

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

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

Verification: go build ./..., go test ./... (all pkgs pass), web
build, and make install all clean (TASK-844, TASK-845).
2026-04-28 12:26:39 -04:00
xarmian 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.
2026-04-22 20:23:14 -04:00
xarmian 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.
2026-04-22 08:45:56 -04:00
xarmian af2755d5af docs: add deployment documentation, Docker Compose, and K8s manifests
Provide production-ready deployment configurations:
- docker-compose.yml: Pad + PostgreSQL + Redis single-command setup
- docker-compose.prod.yml: production overlay with resource limits
- deploy/k8s/: Kubernetes manifests (deployment, service, ingress, HPA)
- deploy/Caddyfile: Caddy reverse proxy with auto-TLS
- deploy/nginx.conf: nginx config with SSE-friendly proxy settings
- docs/deployment.md: environment variable reference, architecture
  diagram, quick start, production checklist
2026-04-06 01:58:04 +00:00