mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-10 23:15:40 +00:00
d915cc3cf8
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.
259 lines
8.7 KiB
Go
259 lines
8.7 KiB
Go
package cli
|
|
|
|
// Per-server credentials store (TASK-1228 / IDEA-1226).
|
|
//
|
|
// `~/.pad/credentials.json` previously held a single login blob:
|
|
//
|
|
// {"server_url": "...", "token": "padsess_...", "user_id": "...", ...}
|
|
//
|
|
// That works for one Pad instance per developer machine, but breaks the
|
|
// real workflow of one developer on multiple instances (apm/ → cloud,
|
|
// target/ → local, testing/ → staging). Switching among them with
|
|
// `pad init --url <other>` would clobber the single entry, so going back
|
|
// to a previously-authed server meant logging in again.
|
|
//
|
|
// v2 keys credentials by canonical server URL:
|
|
//
|
|
// {
|
|
// "version": 2,
|
|
// "credentials": {
|
|
// "https://app.getpad.dev": {"token": "...", "user_id": "...", ...},
|
|
// "http://127.0.0.1:7777": {"token": "...", "user_id": "...", ...},
|
|
// "https://pad-staging:7777": {"token": "...", "user_id": "...", ...}
|
|
// }
|
|
// }
|
|
//
|
|
// Reads transparently migrate v1 → v2 in memory; the next Save writes v2.
|
|
// We deliberately do NOT save-on-read so reads stay side-effect free —
|
|
// the migration becomes durable on the first login/logout/setup.
|
|
//
|
|
// Single-server users see no behavior change: one entry, same shape per
|
|
// entry, identical UX.
|
|
//
|
|
// No top-level `default` field. The configured server URL
|
|
// (cfg.BaseURL() from ~/.pad/config.toml or --url) is always the
|
|
// authoritative answer for "which server am I targeting" — a separate
|
|
// `default` would create a second source of truth and the split-brain
|
|
// bugs that follow. The v2 schema can be extended later if a use case
|
|
// shows up; the current map shape is forward compatible.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// credentialsVersion is what new saves write. Bump and add a migration
|
|
// branch in LoadStore when the on-disk shape changes.
|
|
const credentialsVersion = 2
|
|
|
|
// Credentials is the per-server login blob. The `server_url` JSON tag is
|
|
// kept (with omitempty) so v1 files still parse during the migration
|
|
// path; in v2 the URL is the map key and the field is redundant — Set
|
|
// mirrors the key into the field so consumers reading the value alone
|
|
// still see a consistent ServerURL.
|
|
type Credentials struct {
|
|
ServerURL string `json:"server_url,omitempty"`
|
|
Token string `json:"token"`
|
|
UserID string `json:"user_id"`
|
|
Email string `json:"email"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
// CredentialStore is the on-disk shape of `~/.pad/credentials.json` (v2).
|
|
// Keyed by canonical server URL (trailing slash + surrounding whitespace
|
|
// stripped — see normalizeServerURL).
|
|
type CredentialStore struct {
|
|
Version int `json:"version"`
|
|
Credentials map[string]*Credentials `json:"credentials"`
|
|
}
|
|
|
|
// CredentialsPath returns ~/.pad/credentials.json.
|
|
func CredentialsPath() (string, error) {
|
|
homeDir, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "", fmt.Errorf("get home directory: %w", err)
|
|
}
|
|
return filepath.Join(homeDir, ".pad", "credentials.json"), nil
|
|
}
|
|
|
|
// credentialsPath is retained for internal call sites.
|
|
func credentialsPath() (string, error) {
|
|
return CredentialsPath()
|
|
}
|
|
|
|
// LoadStore reads the credentials file and returns a usable
|
|
// CredentialStore. Returns an empty (but non-nil) store if the file
|
|
// doesn't exist — that's not an error, just "nothing logged in
|
|
// anywhere." Migrates v1 single-blob format to v2 in memory; v1 files
|
|
// stay v1 on disk until the first Save (which always writes v2).
|
|
//
|
|
// LoadStore is the only intended way to read credentials. Internal
|
|
// callers that need a single server's entry should call
|
|
// LoadStore().Get(url).
|
|
func LoadStore() (*CredentialStore, error) {
|
|
path, err := credentialsPath()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return newEmptyStore(), nil
|
|
}
|
|
return nil, fmt.Errorf("read credentials: %w", err)
|
|
}
|
|
|
|
if len(strings.TrimSpace(string(data))) == 0 {
|
|
return newEmptyStore(), nil
|
|
}
|
|
|
|
// Detect format. v1 has a top-level `token` string; v2 has a
|
|
// top-level `credentials` object. Probe with a generic map so a
|
|
// garbage file fails loudly here rather than silently parsing as
|
|
// "empty v2."
|
|
var probe map[string]json.RawMessage
|
|
if err := json.Unmarshal(data, &probe); err != nil {
|
|
return nil, fmt.Errorf("parse credentials: %w", err)
|
|
}
|
|
if _, hasToken := probe["token"]; hasToken {
|
|
return migrateV1(data)
|
|
}
|
|
return parseV2(data)
|
|
}
|
|
|
|
// migrateV1 reads a v1 single-blob file and returns an in-memory v2 store
|
|
// keyed by the legacy ServerURL. A v1 file with an empty server_url is
|
|
// treated as "no usable credentials" (returns an empty store) — that's
|
|
// the only safe interpretation, since we have no key to file the entry
|
|
// under. The on-disk file is unchanged; callers triggering Save (login,
|
|
// logout, setup) will write v2.
|
|
func migrateV1(data []byte) (*CredentialStore, error) {
|
|
var legacy Credentials
|
|
if err := json.Unmarshal(data, &legacy); err != nil {
|
|
return nil, fmt.Errorf("parse v1 credentials: %w", err)
|
|
}
|
|
store := newEmptyStore()
|
|
if normalizeServerURL(legacy.ServerURL) != "" && legacy.Token != "" {
|
|
store.Set(legacy.ServerURL, &legacy)
|
|
}
|
|
return store, nil
|
|
}
|
|
|
|
// parseV2 unmarshals a v2 file. Tolerates a missing or zero version
|
|
// field and a nil credentials map by normalizing both — anything else
|
|
// (malformed JSON, wrong types) surfaces as an error.
|
|
func parseV2(data []byte) (*CredentialStore, error) {
|
|
var store CredentialStore
|
|
if err := json.Unmarshal(data, &store); err != nil {
|
|
return nil, fmt.Errorf("parse v2 credentials: %w", err)
|
|
}
|
|
if store.Credentials == nil {
|
|
store.Credentials = map[string]*Credentials{}
|
|
}
|
|
if store.Version == 0 {
|
|
store.Version = credentialsVersion
|
|
}
|
|
return &store, nil
|
|
}
|
|
|
|
func newEmptyStore() *CredentialStore {
|
|
return &CredentialStore{
|
|
Version: credentialsVersion,
|
|
Credentials: map[string]*Credentials{},
|
|
}
|
|
}
|
|
|
|
// Get returns the credential for the given server URL, or nil if none
|
|
// exists. URL is canonicalized (trailing slash + whitespace stripped)
|
|
// before lookup. Nil-receiver safe — callers can write
|
|
// `store.Get(url)` without a prior nil check on store.
|
|
func (s *CredentialStore) Get(serverURL string) *Credentials {
|
|
if s == nil || s.Credentials == nil {
|
|
return nil
|
|
}
|
|
return s.Credentials[normalizeServerURL(serverURL)]
|
|
}
|
|
|
|
// Set adds or replaces the credential for the given server URL. The
|
|
// canonical URL is mirrored into the value's ServerURL field so a
|
|
// caller reading the credential standalone still sees a consistent
|
|
// URL. Passing a nil credential is a no-op (use Delete to remove).
|
|
func (s *CredentialStore) Set(serverURL string, c *Credentials) {
|
|
if c == nil {
|
|
return
|
|
}
|
|
if s.Credentials == nil {
|
|
s.Credentials = map[string]*Credentials{}
|
|
}
|
|
key := normalizeServerURL(serverURL)
|
|
c.ServerURL = key
|
|
s.Credentials[key] = c
|
|
}
|
|
|
|
// Delete removes the credential for the given server URL. No-op if the
|
|
// entry isn't present, or if the receiver is nil.
|
|
func (s *CredentialStore) Delete(serverURL string) {
|
|
if s == nil || s.Credentials == nil {
|
|
return
|
|
}
|
|
delete(s.Credentials, normalizeServerURL(serverURL))
|
|
}
|
|
|
|
// Save writes the store to disk in v2 format with mode 0600. Always
|
|
// writes the current version constant — even if the store was loaded
|
|
// from v1, this is the migration moment.
|
|
func (s *CredentialStore) Save() error {
|
|
path, err := credentialsPath()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
|
return fmt.Errorf("create credentials directory: %w", err)
|
|
}
|
|
|
|
s.Version = credentialsVersion
|
|
if s.Credentials == nil {
|
|
s.Credentials = map[string]*Credentials{}
|
|
}
|
|
|
|
data, err := json.MarshalIndent(s, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("marshal credentials: %w", err)
|
|
}
|
|
|
|
if err := os.WriteFile(path, data, 0600); err != nil {
|
|
return fmt.Errorf("write credentials: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// WipeCredentialsFile removes ~/.pad/credentials.json entirely. Distinct
|
|
// from CredentialStore.Delete (which removes a single server's entry)
|
|
// — used by tests and any future "pad auth wipe" that wants a clean
|
|
// slate. Silently succeeds if the file is already absent.
|
|
func WipeCredentialsFile() error {
|
|
path, err := credentialsPath()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
|
return fmt.Errorf("delete credentials: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// normalizeServerURL canonicalizes a server URL for use as a credential
|
|
// key. Trailing slash and surrounding whitespace are stripped; nothing
|
|
// else is touched (no scheme normalization, no port-canonicalization
|
|
// — those are deliberate, since differently-spelled URLs may legitimately
|
|
// reach different servers). Mirrors normalizeURL in cmd/pad/server_info.go
|
|
// so the two stay consistent.
|
|
func normalizeServerURL(u string) string {
|
|
return strings.TrimRight(strings.TrimSpace(u), "/")
|
|
}
|