GET /api/credshare/teams/:slug/vaults built its response in a loop, asking
the database for a grant row and a secret count once per vault. libSQL is
remote, so each of those is a network round trip, and the endpoint cost
2N+1 of them.
On a team with 176 vaults that is 353 round trips and ~10.6s of server
time. `logicsrc teams pull` resolves the vault id twice -- once planning
the sync, once reading values -- so a pull of a single ten-key vault took
~24s, nearly all of it spent listing vaults the command does not want.
Replaced with one SELECT carrying two correlated subqueries. Both are
covered by existing primary keys (credshare_secrets is keyed
(vault_id, name), credshare_vault_grants (vault_id, user_id)), so the
per-vault work becomes an index probe inside the database instead of a
round trip across the network. No schema or index change.
Measured on a local libSQL seeded to match that team -- 176 vaults, 17
secrets each -- the endpoint goes from 355 round trips to 3, and returns
identical rows.
The response shape is unchanged: hasAccess is still a real boolean rather
than the 0/1 SQLite hands back, and secretCount is still a number.
Tests pin behaviour and cost separately. The behavioural cases pass
against both the old loop and the new query, which is the point -- only
the round-trip count changed. The regression guard asserts the query
count for 3 vaults EQUALS the count for 30 rather than matching a magic
number, so any future rewrite that reintroduces per-vault I/O fails no
matter what the constant part costs. Against the old loop it reports
9 vs 63.
Two sibling endpoints have the same shape -- /teams/:slug/members and
/vaults/:id/grants both call publicKeyFor() per member. Neither is on the
pull path and both scale with member count rather than vault count, so
they are left alone here.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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>
The .btn skin is built for the light page ground (white fill, ink text).
The topnav rail is #101418, so those buttons landed there as stray
light-mode chips -- and ".bar a{color:var(--rail-text)}" outranks ".btn"
on color (0,1,1 vs 0,1,0), so <a class="btn">Settings</a> painted
rail-text on a white fill: 1.08:1, effectively invisible.
Scope a rail variant to .bar: transparent fill, rail-text label, and a
border at 38% rail-text (3.40:1, clearing the 3:1 non-text minimum --
--rail-line is only 1.4:1 and vanishes). Sign in keeps the green accent,
the focus ring moves green -> mint (3.60:1 -> 9.03:1), and .faint/.dim
in the bar resolve to --rail-dim.
Settings and Sign out go 1.08:1 and light-chip-on-dark to 17.20:1.
Body buttons are untouched -- every rule is .bar-scoped.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The "Connect the CLI" card handed out:
LOGICSRC_API=https://app.logicsrc.com logicsrc login
logicsrc teams push <team> prod --env .env
logicsrc teams pull <team> prod --env .env
Two things are wrong with that, and both survived a release.
Since #109 addressed vaults as <team> <project> <env>, push and pull take
three positionals. The hint passes two, so pasting it exits with a missing-
argument error -- the card is not merely stale, it is broken.
The LOGICSRC_API prefix sets the variable to the value the CLI already
defaults to (DEFAULT_API_URL, #107), so on the hosted app it does nothing
while reading like a required step. It is now emitted only when the origin
is not the default, which is the case it exists for: self-hosting.
`--env .env` is dropped for the same reason -- it restates the option's own
default, and sitting next to the new <env> positional it made one flag and
one argument look like the same thing.
Same stale two-argument form fixed in the post-install hint (install.sh) and
the accept-invite message, and in the empty-vault-list prompt on the card.
CLI_HINT moves to src/lib/cli-hint.mjs so a test can assert on the rendered
commands without standing up express and the database, matching how the
other lib-level views are covered. The tests pin the argument count rather
than the prose: restyling the card stays free, dropping an argument does not.
apps/pwa: 13/13 pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two bugs on the dashboard, both fixed by handing appBar/CLI_HINT the request.
Sign-out was broken for everyone. csrfGuard rejects any POST whose _csrf does
not match the mc_csrf cookie, and /auth/logout is a POST that is not on the
exempt list, but the sign-out form carried no hidden field -- every click
answered 403 "bad csrf token". appBar now takes the request rather than the
user, because it needs the token as well as the identity. The field is written
out instead of reusing csrfInput(): html.mjs is the view layer and imports
nothing, and pulling in session.mjs would drag the database driver with it.
The "Connect the CLI" snippet still printed $PUBLIC_ORIGIN, so users on
app.logicsrc.com were told to point LOGICSRC_API at the generated Railway
hostname. #105 added requestOrigin() for exactly this and fixed the device-flow
URLs; the dashboard hint was missed. It now follows the request too, which is
not a hardcode swap -- the same deployment answering on its Railway hostname
still self-describes correctly.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
`logicsrc login --device` told users to open
https://logicsrc-credentials-production.up.railway.app/cli/device even when they
had reached the app on the real domain. /cli/device/code built verification_uri
from `config.origin`, which is a single fixed value read from $PUBLIC_ORIGIN, so
the response was wrong for every hostname except the one that variable happened
to name.
Derive the origin from the request instead: whatever host the CLI called is the
host it gets sent back to. Express honours X-Forwarded-Proto/Host here because
server.mjs sets `trust proxy` behind Railway's TLS terminator.
Deliberately scoped to the two device-flow URLs. The WebAuthn expectedOrigin in
passkey.mjs stays pinned to config.origin — validating a signature against a
host the caller supplied would defeat the check.
Note this fixes which URL is *printed*; the host still has to route to this
service for the link to load.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
`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>
Adds apps/pwa: an Express + libSQL/Turso app that is now the home of team
credential sharing, with the moshcode-style auth stack ported and reskinned to
match logicsrc.com (light theme, Inter, green accent).
apps/pwa
- auth: email/password (scrypt), passkeys (WebAuthn), CoinPay OAuth, cookie
sessions, and lsk_ API keys for the CLI via a loopback OAuth-PKCE flow
(/cli/authorize + /cli/token). Ported from the moshcode PWA.
- credshare API (/api/credshare/*): teams, members, invites, vaults, sealed
grants, ciphertext secrets, audit — authed by session OR Bearer lsk_ key.
Zero-knowledge: only ciphertext + sealed vault keys + public keys stored.
- teams dashboard, accept-invite, and settings (API keys) pages, server-rendered
in the LogicSRC brand (lib/html.mjs).
- migrations (libSQL) 001_auth + 002_credshare, migrate-on-boot; Turso via
TURSO_DATABASE_URL / TURSO_AUTH_TOKEN, or a local file db for dev.
- trimmed moshcode-specific approvals/credits/push/deliver.
CLI
- `logicsrc login` now does browser loopback OAuth-PKCE against the app and
stores an lsk_ token (email-OTP removed); --token for CI. Client repointed.
Distribution
- install.sh (served at logicsrc.com/install.sh) installs the CLI from the
GitHub repo: tarball -> npm install -> `npm run build:cli` -> logicsrc wrapper.
- root build:cli builds only the CLI's workspace chain (skips web/api/next).
Cleanup
- removed the commandboard-api credshare backend (superseded by the PWA) and its
Supabase/Turso stores + libsql dep; commandboard-api tests green (40).
- removed the Next.js /teams page (the PWA is the web UI now).
Verified end-to-end: two accounts register on the PWA, mint lsk_ keys, CLI login
uploads identity keys, owner pushes an encrypted .env, teammate invited ->
accepted -> granted -> pulls the exact file. Server stores ciphertext only.
Full workspace build + tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>