mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-27 14:57:15 +00:00
dbbbefda75d4fc4aaeac7bdaecd65eab772f00a9
8 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
a630f867f4 |
fix(cli): a credentials save stamps its own file's mtime before it truncates, so the empty-file memo holds off Linux (BUG-3838) (#2203)
* fix(cli): a credentials save sets the mtime before it truncates, so the empty-file memo holds off Linux (BUG-3838) BUG-3829's emptyConfirmed memo assumed a save's truncate moves an already empty file's mtime. That was measured on ext4 only; POSIX moves mtime on ftruncate only when the size changes, and macOS and Windows were never measured. On such a platform a process that had memoized an empty file could answer empty during another process's unlocked save and lose an entry. writeFileInPlace0600 now sets the mtime itself (os.Chtimes, best effort like the chmod) before the truncate, so the memo no longer depends on the truncate's side effect. loadStoreLocked also reads once more when the stat after its last empty read shows a body (hunter r6 on BUG-3829). Claude-Session: https://claude.ai/code/session_012QxKpjqXTpU84auY5zZ8FR * fix(cli): on Windows the save touches its own handle, not the path; the stalled-save test releases on failure (BUG-3838) Codex r1 could not establish that os.Chtimes works on an open file on Windows. It does not, safely: UtimesNano opens the path again with only FILE_SHARE_WRITE, so it fails against a reader holding the file and, while open, makes a concurrent reader's open fail. touchCredentialsFile now uses SetFileTime on the handle the save already holds (GENERIC_WRITE includes FILE_WRITE_ATTRIBUTES); other platforms keep os.Chtimes by path. The test's stalled save is released and waited for in cleanup, so a failure before the release leaves no goroutine in the seam (codex r1 Low). Claude-Session: https://claude.ai/code/session_012QxKpjqXTpU84auY5zZ8FR * test(cli): the stalled-save test's wait for the write is bounded (BUG-3838) A save that failed after its release left the read seam spinning until the global test timeout (hunter r2). It now gives up after five seconds and fails; cleanup reports the save's error. Claude-Session: https://claude.ai/code/session_012QxKpjqXTpU84auY5zZ8FR * fix(cli): on Unix the save's touch goes through its descriptor; the stalled-save test bounds its wait for the truncate (BUG-3838) Codex r3 Medium: os.Chtimes by path stamps whatever the path names when it runs, so a path replaced or a symlink retargeted after the open could take the stamp while the file being truncated kept its old mtime. On Linux, the BSDs and Darwin the touch is now unix.Futimes on the open descriptor (Linux resolves it through /proc/self/fd), falling back to the path only when that fails. TestBUG3838_TouchStampsTheOpenedFile retargets the symlink after the open and checks which file moved. Codex r3 Low: the test waited on the seam's truncate signal without a bound; it now fails if the save returns first or never stalls. Claude-Session: https://claude.ai/code/session_012QxKpjqXTpU84auY5zZ8FR * fix(cli): the Unix touch has no path fallback (BUG-3838) Codex r4 Medium (hunter r4 L2): when the descriptor touch failed, the os.Chtimes(path) fallback re-resolved the path and could stamp a file other than the one the save opened. A failed descriptor touch now leaves the save relying on its truncate, which moves an empty file's mtime on Linux, the one shipped Unix where futimes can fail for want of /proc. Claude-Session: https://claude.ai/code/session_012QxKpjqXTpU84auY5zZ8FR |
||
|
|
1d08594dc0 |
fix(cli): a locked credentials load re-reads an empty file before believing it (BUG-3829) (#2195)
* fix(cli): a locked credentials load re-reads an empty file before believing it (BUG-3829)
A writer that waits out credentialsLockWait saves without the lock,
truncating in place before it writes. A loader holding the lock read that
truncate as an empty store on its first read, and an UpdateStore landing
there saved its one change over every other server's entry.
loadStoreLocked now re-reads an empty file with the bound the unlocked load
already uses (10 x 20ms). One that stays empty is genuinely empty (a crash
between a save's truncate and its write) and loads as an empty store, so
login still works over it; the unlocked path keeps refusing. A genuinely
empty file pays the bound once per process: the confirmed file state
(same file, same mtime) is remembered, and a save that touches the file
moves its mtime and is re-read again.
Residual, in the code comment too: an unlocked writer that stalls longer
than the bound between its truncate and its write is still read as empty.
TestBUG3828_LockedEmptyFileIsEmpty's premise ("under the lock nothing can
be mid-save") is what this bug falsifies; it now expects the re-reads.
Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
* fix(cli): the confirmed-empty memo only remembers a file emptied 2s or more before (BUG-3829)
Codex r1 (Medium): on a filesystem with coarse timestamps, a save landing in
the same tick as the confirmation could leave the mtime unchanged, so the
memo would match the save's truncate and skip its re-read. A later save is
stamped no earlier than the confirmation, so remembering only a file whose
mtime is two seconds older than that rules the collision out. A file
emptied more recently pays the re-read bound on each load until it ages.
Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
* test(cli): a credentials file with a future mtime is never remembered as empty (BUG-3829)
Codex r2 (Medium): a future mtime reads as recent to the age check, so such
a file pays the re-read bound on every load. That is the safe direction,
re-reading rather than trusting it, so the policy is now stated in the
comment and pinned by a test instead of changed.
Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
* fix(cli): the confirmed-empty memo records only an empty file state (BUG-3829)
Codex r4 (Medium): the stat that records a confirmed-empty file follows the
empty read, so a save completing in between could be recorded, and a later
truncate keeping its mtime could match it. set now requires size 0.
The memo comment now states the measured fact the hunter questioned: a
truncate moves the mtime even of an already-empty file (ext4, Linux 6.17).
Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
|
||
|
|
e17f17f513 |
fix(cli): an empty credentials file read without the lock is unknown, and UpdateStore will not save over it (BUG-3828) (#2187)
* fix(cli): an empty credentials file read without the lock is unknown, and UpdateStore will not save over it (BUG-3828) Save truncates credentials.json in place before it writes, under the exclusive lock. A caller that could not take the lock (a holder past credentialsLockWait, a filesystem without flock) read an empty file as an empty store, and UpdateStore then saved its one change over it, wiping every other server's credentials once the other save finished. withCredentialsLock now tells its callback whether it holds the lock. Without it, an empty file is re-read up to 10 times, 20ms apart. One that stays empty is errCredentialsEmptyUnlocked: LoadStore answers no credentials, as before (a read is only a read), and UpdateStore refuses and saves nothing. Under the lock, empty still means empty. TestBUG3649_ReaderNeverSeesAPartialFile paces its writer and checks that every read took the shared lock, so it measures the locked path it names. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr * test(cli): the BUG-3828 pins count re-reads through a read hook instead of timing them Codex r1: the re-read test could pass without a re-read when its goroutine was delayed past the fixed 60ms write, and the locked-path test used a 2s wall-clock bound that a contended runner could exceed. readCredentialsFile is now a variable; the tests count reads and fill the file on a given read. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr * test(cli): the BUG-3828 pins overlap a real truncate-then-write save Codex final round: the re-read test filled an empty file rather than overlapping a save, and a second hook layered on the first counted every read twice. midSave now truncates a full file at one read and writes it back at another; the overlap is tested for an update that recovers, an update that refuses (and leaves the save in progress untouched), and a read. Each test installs one hook. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr |
||
|
|
22dcce6e6c |
fix(cli): a failed stat fails the credentials write instead of skipping the truncate (BUG-3810) (#2181)
writeFileInPlace0600 read a failed f.Stat() as "not a regular file" and skipped the truncate, so a shorter body left the old tail behind and credentials.json was corrupt. It now returns the stat error before writing anything. statOpenFile is the test seam. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr |
||
|
|
a99a040cf3 |
fix(cli): credentials.json is replaced atomically at 0600 and updated under a lock (BUG-3648, BUG-3649) (#2170)
* fix(cli): credentials.json is replaced atomically at 0600 and updated under a lock (BUG-3648, BUG-3649) [WIP: no Codex round yet]
Save used os.WriteFile, which applies 0600 only when it creates the file
(BUG-3648) and truncates the live file first, so a concurrent reader could
see an empty store or half a document (BUG-3649). Save now writes a 0600
temp file in the same directory, syncs it and renames it over
credentials.json, and creates ~/.pad 0700.
The four load-modify-save callers (device login, saveCredentials, logout,
2FA session rotation) go through the new cli.UpdateStore, which holds a
cross-process lock on credentials.json.lock (flock on unix, LockFileEx on
windows, none elsewhere) across load, mutate and save. Logout makes its
server call outside the lock and re-reads the file under it to delete.
Tests: credentials_bug3648_test.go, plus a guard that fails on any
LoadStore-then-Save outside UpdateStore. Mutants C1-C4 and the guard's
counterfactuals are recorded on BUG-3649.
Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
* fix(cli): the credentials lock file is tightened to 0600, and a wipe takes the lock (BUG-3648, BUG-3649) [WIP]
Codex round 1 Lows: OpenFile's mode applies only on creation, so an existing
lock file kept a loose mode; WipeCredentialsFile removed the file outside
the lock, so a concurrent UpdateStore could rename it back.
Test: an existing 0666 lock file reads 0600 after UpdateStore.
Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
* test(cli): a credentials wipe waits for the credentials lock (BUG-3649)
Lead ruling on Codex round 1 L1: hold the lock, start the wipe, assert it
has not returned and the file is still there, release, assert it finishes.
Dropping the wipe's lock reddens it 3/3.
Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
* docs(cli): UpdateStore callbacks must not call WipeCredentialsFile (BUG-3649)
Codex round 2 (Medium): a wipe inside an UpdateStore callback would wait on
the lock that UpdateStore holds. No caller does this (the wipe has no
production caller); both doc comments now say so.
Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
* fix(cli): tightening the credentials lock file is best effort (BUG-3649)
Both pilot bug-hunters (Haiku and Sonnet, blind): fchmod on a lock file
another uid owns returns EPERM, which failed every UpdateStore and so
every login. The lock file holds no secret, and the token's protection
is credentials.json's own 0600 temp-plus-rename, so the chmod error is
ignored and the lock is still taken. The owner-case tightening test
stays. (Lead ruling: accepted.)
Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
* docs(cli): the best-effort chmod covers a writable foreign-owned lock file, not a sudo-created one (BUG-3649)
Both pilot hunters on
|
||
|
|
d915cc3cf8 |
feat(cli): per-server credentials in credentials.json with v1 → v2 migration (TASK-1228) (#435)
Implements IDEA-1226. ~/.pad/credentials.json is now a map keyed by
server URL so one developer machine can stay logged in to multiple Pad
instances simultaneously — `apm/` repo on Pad Cloud, `target/` repo on
local, `testing/` repo on staging — without each `pad init --url <other>`
clobbering the previous server's credentials.
## On-disk format
v2 (new):
{
"version": 2,
"credentials": {
"https://app.getpad.dev": {"token": "...", "user_id": "...", ...},
"http://127.0.0.1:7777": {"token": "...", "user_id": "...", ...}
}
}
v1 (legacy, read-only): {"server_url": "...", "token": "...", "user_id": "...", ...}
Reads transparently migrate v1 → v2 in memory; writes always emit v2.
Side-effect-free reads — the on-disk file stays v1 until login/logout/
setup triggers a Save, which is when migration becomes durable. This
keeps `pad <read-only-command>` from rewriting credentials.json on
every invocation just because the binary upgraded.
## API
Replaces the three top-level helpers (LoadCredentials / SaveCredentials /
DeleteCredentials) with a CredentialStore type:
- LoadStore() (*CredentialStore, error)
- (s).Get(serverURL) *Credentials // nil-receiver safe
- (s).Set(serverURL, *Credentials)
- (s).Delete(serverURL)
- (s).Save() error
- WipeCredentialsFile() error // file-level — replaces DeleteCredentials
URL canonicalization is built in: trailing slash + surrounding whitespace
are stripped before lookup/store, so http://x:7777 and http://x:7777/
hit the same bucket. Same rule cmd/pad/server_info.go was already
applying via its now-redundant normalizeURL — removed.
No top-level `default` field. The configured server (cfg.BaseURL() from
~/.pad/config.toml or --url) is always the source of truth for "which
server am I targeting" — a separate `default` would create a second
source of truth and the split-brain bugs that follow.
## Behavioral changes
- `pad init --url <other>` against a server you've authed to before now
reuses the saved credential instead of clobbering it.
- `pad auth logout` removes only the configured server's entry. Other
servers' tokens stay intact (pre-fix: wiped the whole file).
- `pad auth whoami` reads only the entry matching the configured server.
- Single-server users see no behavior change — one entry, identical
shape per entry, identical UX.
## Compat shims removed
LoadCredentials / SaveCredentials / DeleteCredentials are deleted
outright (no // Deprecated lifecycle) — they're internal package
helpers with no external API contract. All 10 call sites in cmd/pad/
and internal/cli/ are migrated to the per-server API in this PR.
## Tests
internal/cli/credentials_test.go (15 tests):
- File missing / empty → empty store (callers don't need nil checks)
- v1 format reads + migrates in memory
- v1 with empty token → empty store (no phantom entries)
- v1 migration is durable on first Save (file flips to v2)
- v2 round-trip preserves multiple entries
- Set adds + replaces; mirrors URL into ServerURL field
- Delete keeps siblings (multi-server keystone behavior)
- Delete on absent key is a no-op
- Nil receiver Get/Delete don't panic (NewClientFromURL relies on this)
- URL normalization (trailing slash + whitespace)
- Save preserves all entries across the file boundary
- Save uses 0600 permissions
- WipeCredentialsFile removes the file + is idempotent
- Garbage file errors loudly (so we never silently lose data)
Existing tests unchanged. Full suite + lint + web-check green.
Closes: TASK-1228.
Implements: IDEA-1226.
|
||
|
|
89db556e29 | feat(server): add info command for TASK-134 (#52) | ||
|
|
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)
|