Commit graph

9 commits

Author SHA1 Message Date
e4723f31b1 feat(credentials): manage team members and rotate invites
Some checks are pending
CI / build (push) Waiting to run
test / test (push) Waiting to run
2026-08-04 19:16:02 +00:00
1e4e9def70
feat(credentials): encrypt the vault at rest (#121)
Some checks failed
CI / build (push) Has been cancelled
test / test (push) Has been cancelled
The vault holds the target's prior values so a bad rotation can be undone
— the one place raw credentials touch disk. It was written as plain JSON
at mode 0600, and a permission bit is all that was protecting it. That
stops another user on the same box. It does nothing about a backup, a
synced home directory, a lifted disk, or any process running as the
owner, and those are the cases where a credential store is worth reading.

Each run is now sealed to this machine's identity: a fresh DEK per write,
the payload sealed with it, the DEK sealed to the identity public key. So
the file opens with the secret key in identity.json and nothing else. The
key names go inside the ciphertext along with the values — knowing that
an endpoint holds STRIPE_LIVE_KEY is worth something by itself.

A DEK per run rather than one for the store: reusing a key would make a
single compromise open every rollback ever captured, and there is nothing
to gain by it, since the wrapped DEK travels in the file.

Plaintext vaults written before this still open. Refusing them would
strand the rollback data they exist to hold, and a vault reader that
cannot read yesterday's vault takes away the thing the vault is for.

The store's vault methods become async, which the three callers in the
engine already sat inside async functions to accommodate.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:55:38 -07:00
36236eb1a3
fix(credentials): one vault per user, in the config dir (#119)
The credential store resolved its base directory against process.cwd().
Running the CLI from inside a git checkout wrote `.logicsrc/credentials`
into that repo's working tree — a directory containing `vault/`, the one
place raw credential values touch disk — untracked, unignored, and one
`git add -A` from being committed. Two such directories were sitting in
unrelated repos on the machine this was found on.

A per-directory store is also the wrong shape for what the store is for.
It is the record of what was rotated and what the prior values were, and
a record that forks per project folder is several records that disagree.
There is one user, one identity, one vault.

Everything now hangs off a single logicsrcHome(): $LOGICSRC_HOME, else
$XDG_CONFIG_HOME/logicsrc, else ~/.config/logicsrc. The credential store,
the identity and the CLI config all read it rather than each deriving
their own answer — three separate derivations is how the vault ended up
somewhere the config never was.

~/.logicsrc is migrated rather than abandoned. It holds the X25519 secret
key, and losing that loses access to every team vault the member was ever
given, so it is moved on first use; a move that fails says so on stderr
instead of leaving someone silently logged out with a key still on disk
somewhere they were not told about. If the new directory already exists
it wins and the old one is left untouched, because two directories both
claiming to be the identity is how a login writes one and a read finds
the other.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:53:17 -07:00
6f23dbdb0f
feat(credentials): re-key team vaults, and reach sh1pt's vault as a provider (#117)
Two additions to Credential Sharing.

`logicsrc credentials rotate` (alias `logicsrc secrets rotate`) re-keys a
team vault: fresh DEK, re-sealed to the members who keep access, every
secret re-encrypted under it. Values do not change, so nothing that
consumes them breaks; what changes is that every wrapped key issued
before the rotation is dead. --active (the default) keeps only active
members and revokes the rest -- the "someone left" rotation. --all keeps
everyone who holds access, for plain hygiene. Dry run by default, like
`sync`.

The DEK is recoverable ONLY through the grants, so a half-applied
rotation makes a vault permanently unreadable by everyone. The whole next
state therefore goes to the server in one request and commits in one
transaction (new db.batch helper). The server also requires every
submitted fingerprint to equal the stored one: it cannot see values, but
it can prove a re-key did not swap any. Rotations that would leave the
caller ungranted, grant nobody, or cover the wrong secret count are
rejected before anything is written. GET /vaults/:id/grants now returns
publicKey and status so a client can re-seal in one pass instead of N+1
user lookups, and revocation finally deletes the grant row rather than
leaving one that reports access it no longer confers.

The sh1pt adapter is the fifth provider. It is the only one driven
through a CLI rather than HTTP, because sh1pt publishes
`sh1pt secret set|get|list|rm` as the interface to its vault and
documents no REST endpoint. Values go over the child's stdin, never argv
-- a secret in argv is readable by any user on the host via ps. Since
`sh1pt secret get` needs interactive confirmation it cannot be scripted,
so the adapter is write-only for values like github-secrets: a sync
target, never a source, no value-restoring rollback.

Tests drive a real fake sh1pt binary rather than a mocked execFile, which
is how the hang surfaced: with nothing to pipe, stdin was left open and
any subcommand that reads it would wait forever. It is now always closed.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 18:41:03 -07:00
7c9796ae51
fix(cli): default logicsrc login to app.logicsrc.com (#107)
The default was the apex, which runs the marketing app -- so every path the CLI
needs (/cli/device/code, /cli/device/token, /cli/authorize, /cli/token,
/api/me) returns 404 there. Point it at the host that actually serves them.

app.logicsrc.com is now a custom domain on the credentials service with a valid
certificate, verified live: /cli/device/code returns 200 and issues a real user
code, /api/me returns 401 rather than 404, which is routing working correctly
for an unauthenticated request.

The apex can forward these paths instead -- that is what the rewrites in
apps/logicsrc-web/next.config.ts do -- but that needs a second service deployed
to be true, while this needs nothing beyond the domain that already exists. The
rewrites stay useful as a convenience; they are no longer load-bearing.

$LOGICSRC_API still overrides, unchanged.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 11:07:49 -07:00
0510d86f85
fix(cli): default logicsrc login to logicsrc.com (#104)
The default API origin was the generated Railway hostname
(logicsrc-credentials-production.up.railway.app), which leaked deployment
infrastructure into every login prompt and stored identity. Point it at the
production domain instead.

NOTE: logicsrc.com does not currently serve the credentials app's CLI routes —
/cli/device/code, /cli/device/token, /cli/authorize, /cli/token, and /api/me
live in apps/pwa (src/routes/cli.mjs), while the apex serves apps/logicsrc-web.
At time of writing all of those return 404 on logicsrc.com and 200 on the
Railway origin, so login will fail until the apex (or a subdomain) is pointed
at the pwa service. $LOGICSRC_API overrides the default in the meantime.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 07:23:36 -07:00
cf475f0f4c fix(cli): point logicsrc login at the real app + add device-code login
Some checks are pending
CI / build (push) Waiting to run
test / test (push) Waiting to run
`logicsrc login` defaulted to http://localhost:4010 — a dev origin that
doesn't exist on an installed machine, so the printed authorize URL went
nowhere. It now defaults to the hosted credentials app (apps/pwa), reads
the documented $LOGICSRC_API, and only reuses a stored apiUrl once that
identity has actually completed a login (which is how machines got stuck
pointing at localhost). Note logicsrc.com is the marketing site and has
no /cli routes.

The loopback flow is also unusable over SSH: redirect_uri is
http://127.0.0.1:<port>/callback, which resolves to the *browser's*
machine, not the CLI's. Added a device-authorization flow — the CLI
prints a short user_code, the human approves it from any browser:

  POST /cli/device/code   mint device_code + user_code (10 min TTL)
  GET  /cli/device        approve page (login required; typo-tolerant)
  POST /cli/device        approve/deny (CSRF-guarded browser form)
  POST /cli/device/token  CLI polls -> lsk_ API key

device_code is stored sha256-hashed, single-use, with authorization_pending
/ slow_down / access_denied / expired_token poll semantics. The CLI picks
the flow automatically (SSH/CI/no-DISPLAY -> device), with --device/--web
to force it and a fallback to loopback against servers without /cli/device.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 18:07:49 +00:00
f057589d66 feat(credential-sharing): end-to-end-encrypted team credential sharing
Adds a `team` credential provider + team/member management so teammates can
share secrets by email instead of passing .env files over chat. Fully E2E:
the server only ever stores ciphertext, per-member sealed vault keys, and
public keys — it never sees a plaintext value or the vault DEK.

Plugin (@logicsrc/plugin-credential-sharing)
- crypto.ts: X25519 identity keys, per-vault DEK (secretbox), DEK sealed to
  each member's pubkey (crypto_box_seal), value encrypt/decrypt (libsodium)
- identity.ts: local ~/.logicsrc/identity.json (0600) holding the device key
  + API token; never uploads the secret key
- client.ts: typed /api/credshare client
- providers/team.ts: `team:<slug>/<vault>` CredentialProvider (inspect,
  readValues=decrypt, write=encrypt, rollback); fingerprints match env so
  env<->team diffs line up
- fixes latent libsodium-wrappers ESM load bug (createRequire) here + in
  github-secrets

Server (commandboard-api /api/credshare)
- zero-knowledge router: email-code auth, keys, teams, members, invites,
  vaults, sealed grants, ciphertext secrets, audit; membership authz in app
- CredShareStore abstraction: in-memory (dev/tests) + Supabase (prod)
- Resend email transport for login codes + invites (no-op -> echoes locally)
- supabase migration: credshare_* tables, deny-by-default RLS

CLI
- real `logicsrc login` (email code -> token + key upload)
- `logicsrc teams create/list/invite/accept/members/vaults/grant/push/pull`

Web (logicsrc.com/teams + /teams/accept)
- management surface only (browser holds no private key, never decrypts):
  login, view teams/members/vaults, invite, accept

Tests: crypto round-trip, server contract (invite->accept->push->grant->pull
+ authz boundaries), and a real HTTP+client+crypto E2E asserting the server
never holds plaintext. Full workspace build + tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 13:11:32 +00:00
cf73fe5af2 feat(credential-sharing): implement the Credential Sharing OpenSpec (M1-M3)
New @logicsrc/plugin-credential-sharing: a provider-neutral secret-sync engine
with env/.env, Doppler, Railway, and GitHub Secrets adapters behind one
CredentialProvider contract.

- engine: inspect -> diff -> plan -> approve -> sync -> rollback -> audit/export
- dry-run is the default for sync; --approve writes; destructive changes gated
- fingerprint-based diffs (salted SHA-256); raw values never printed or stored in
  plans/runs/audit; rollback pre-image kept in a 0600 .logicsrc vault (gitignored)
- github-secrets is write-only for values (sealed-box via libsodium), so it cannot
  be a sync source or value-restoring rollback target
- CLI: real `logicsrc credentials <providers|inspect|diff|plan|approve|sync|
  rollback|audit|export>` (replaces the prior stub)
- 4 JSON schemas registered in @logicsrc/validators
- flip logicsrc.com/credential-sharing band from coming-soon to available
- 37 tests pass; full env->env lifecycle verified; artifacts schema-validate

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 15:24:30 +00:00