Working notes so we don't forget. Legend: ✅ done · 🔜 todo · 🤔 needs decision.
- P0 security: CSP
unsafe-evalscoped to dev; auth-callback error normalization viaObject.hasOwn; Topbar feedbackaria-label. - P1 authz: owner-gated endpoints (sync/billing/trial) now authorize via live GitHub admin (
requireLiveOrgAdmin, fail-closed + self-heal) — closes the bootstrap deadlock (#4) and the stale-DB-role over-authorization. - P2 UX: ConnectOrgModal now lists connectable orgs and calls
POST /orgs/connect(was install-only / dead-end #3). - P3 DX: dashboard API URL defaults to localhost in dev;
make doctor(scripts/doctor.sh);CONTRIBUTING.md;.env.exampleclarifications. - P4 polish:
migrate.tsconsole lint (eslint-disable for the CLI script);unclaimedMemberIdhelper centralizes thegithub:sentinel.
refreshCachedOrgRoleconflates "GitHub unreachable" with "no longer a member" (both →null). Owner-gated actions fail closed (correct), butGET /:orgfalls back to the cached role for a removed-from-GitHub user (view-only staleness; Keyway membership is a DB cache revoked by sync/webhook, not this read). Heavy lift: havegetOrgMembershipForCurrentUserdistinguish 404 (not a member → revoke) from network/5xx (transient → keep cached).- CSP
'unsafe-inline'in prodscript-src: bigger XSS-hardening win thanunsafe-eval, but needs nonce-based CSP (non-trivial with Next.js bootstrap). Separate task. - ConnectOrgModal
needs_install/contact_adminstatuses: the modal only rendersreadyorgs; partition by status so members of not-yet-installed orgs get an actionable message (data already on the API). admin→ownermapping in the other ~6 sites (vaults.routes.ts,github.routes.ts,github.provider.ts, …): migrate tokeywayRoleFromGitHub.exposure.routes.tsstill authorizes off DBorg_role(not live GitHub) — intentional? lower-stakes reads — but document the inconsistency or align it.doctor.shrequired-env list is a manual mirror ofconfig/index.ts(Zod) — soft-fails on drift; add a cross-ref comment or a backend--check-envmode.getOrganizationDetailsembeddedmembers[].orgRolestays DB-stale while the caller's role self-heals — add a comment that the asymmetry is intentional (members page reads the live roster).
make devpreflight / honest banner: editing the large existingdevtarget unsupervised is risky;make doctorcovers the diagnostic need.- Webhook forwarding wiring: inherently runtime/external (GitHub App webhook URL + tunnel); documented in
CONTRIBUTING.mdrather than hard-wired. listOrgMembersWithAppreturns[]on error: changing error semantics affects the webhook sync caller; low value, deferred.getOrganizationDetailsembeddedmembers[].orgRolestill DB-stale: low; the members page reads the live roster.- Test mocks → shared
tests/helpers/mocks.ts: heavy refactor, low value, regression risk; deferred.
- migrate.ts wait-for-db retry loop → PR #17 (
fix/migrate-wait-for-db), CI green, not merged. - Org members overlay:
getOrganizationMembersWithGitHubreads the live GitHub roster + overlays Keyway status (onKeyway,joinedAt, "Not on Keyway" badges);findInstallationForOrg; DB-only fallback with observable warning. - Role from GitHub (not stale DB) in the members overlay + dedup by id.
githubMemberCount→ dashboard card shows "1/7 on Keyway".⚠️ see High-1 below.- Members link card on the org detail page (was orphaned).
- #1
listOrgMembersdedup at source (fixes webhook sync too). - #2a
listUserOrganizationswarns instead of silently defaulting role tomember. - #2b
/connectreads role authoritatively via installation token. - Self-heal of the caller's role from live GitHub in
GET /:org. - Docs
organizations.md: removed false "syncs when someone accesses a vault" + added the 2 real conditions.
Verified: type-check (backend + dashboard) ✅ · 951 unit tests ✅. Integration-db tests need keyway_test (disrupted locally by the native→Docker pg switch; CI is fine).
✅ The 3 High items below were addressed after the review:
- Private-member disclosure → roster now uses the caller's token (
getOrganizationMembersWithGitHub(org, accessToken)→listOrgMembers(accessToken, login)).- N×3 fan-out → Option C: live count removed from the org-list card; the real count stays on the Members page only.
- Forge guard →
dbByForgeIdnow filtersforgeType === "github"(+ test).findInstallationForOrgremoved (unused).Remaining: the Medium/Low/Nits below.
- The members roster is fetched with the App installation token (Members:Read), which returns all org members incl. those with private GitHub membership; the route only checks the caller is a member (not admin), and that check is against the stale Keyway DB membership, not live GitHub.
- Nuance (my assessment): on GitHub, any org member already sees concealed members, so this is not a straight leak for a legit current member. The real risk is narrower: Keyway authorizes via a stale DB cache (someone removed from the GitHub org but still in
organization_memberswould still see the roster) + uses an elevated token for a user-facing read. - Fix: fetch the roster with the caller's user token (
listOrgMembers(request.accessToken, login)) so GitHub's per-user visibility/authorization applies automatically. Removes both the stale-cache risk and the token escalation. (Alt: gate full roster behind a live admin check.) - Files:
organization.service.tsgetOrganizationMembersWithGitHub;organizations.routes.tsmembers route.
getOrganizationsForUsernow doesPromise.allover all the user's orgs, each →findInstallationForOrg+ install-token mint + 2 paginated/memberscalls. A user in 15 orgs = ~45+ GitHub calls on one page load, no cache/timeout/concurrency cap → latency + GitHub secondary rate limits.- Fix (pick one): (1) persist
github_member_counton the org row, refreshed by the member-sync/webhook path, read from DB here (0 extra calls — preferred); (2) cache per-org with a short TTL; (3) at minimum addp-limit+ per-call timeout. - The members page already shows the authoritative live roster, so the list-page count should not pay a live cost. Reconsider whether to keep the live "1/7" on the card as-is.
dbByForgeIdis keyed onforgeUserId(text) across a multi-forge table, looked up viaString(gm.id). Correct for GitHub today, but mis-attributesonKeywayif a non-GitHub member row exists or ids collide as strings.- Fix: filter
dbMemberstouser.forgeType === "github"(or include forgeType in the key). Add a mixed-forge test.
- Self-heal write-on-GET: acceptable (idempotent upsert), but only heals the caller's own role. Other owner-gated endpoints (
isOrganizationOwnerat sync/billing/etc.) still trust the DBorg_role→ stale until the user hitsGET /:org. Confirm those call sites are safe. - Count
nullconflates empty-org vs no-permission:members.length === 0→null. With Members:Read,0is a legit answer. Distinguish success-with-0 from failure (root:listOrgMembersWithAppswallows errors into[]).
listOrgMembersWithAppswallows errors into[]→ callers can't tell empty from failed. Returnnullon error (both new callers try/catch already).#2awarning fires for legit pending/non-active memberships too → log noise; considerdebug.getOrganizationDetailsstill returns embeddedmembers[].orgRolefrom stale DB (top-levelroleis healed, embedded isn't).`github:${id}`sentinel is a load-bearing format string asserted in tests → extract a shared constant.members/page.tsxfallback copy uses an inline typographic apostrophe → use’/ constant.
- dedup path (duplicate ids from degraded
?role=), mixed-forge DB row, empty-org-vs-permission-failure.
- #3 ConnectOrgModal never calls
connect—handleConnectOrg(→POST /orgs/connect) is wired in the page but the modal only shows an "Install" CTA. In prod the webhook creates the org; locally there's no path → had to call/orgs/connectfrom the browser console. Add an "after install / detected installation → connect" affordance. - #4 Member sync is owner-only → bootstrap deadlock — a user mis-recorded as
membercan't trigger the sync that would fix their role. Mitigated by the self-heal, but the structure remains. Consider: allow a user to refresh their own role anytime, or derive owner authz from live GitHub.
The real fix for a night of pain: no coherent "local dev" profile, and two undocumented entry points.
- Local dev profile prebaked (
docker-compose.override.ymlor.env.local.example) setting the 5 vars we had to find by hand:DOMAIN=keyway.local,CADDYFILE=./Caddyfile,DATABASE_URL=…@db:5432/keyway,CRYPTO_SERVICE_URL=crypto:50051,GITHUB_APP_NAME=<real slug>
- One documented entry point —
make docker/Caddy is the real one; clarify or retiremake dev(native), which needs a separate localhost env profile + localhost callback. make doctor— checks env present, DB/crypto reachable, dashboard→local, domain, GitHub callback. Would have diagnosed tonight in 2s.- Dashboard default —
NEXT_PUBLIC_KEYWAY_API_URLshould default tohttp://localhost:3000whenNODE_ENV !== production(currently hardcoded to prodapi.keyway.sh,dashboard/lib/env.ts). GITHUB_APP_NAMEdefaultkeyway-app→ 404 for any self-hoster who doesn't override (backend/src/config/index.ts).make devlies — prints "All services running!" even when the backend crashed on env validation. Preflight + honest banner.- Webhook forwarding for local (smee/cloudflared) so
installation/ org-membership events reach the backend (org creation + member sync depend on them). - Get machine-specific config out of the
devvault —DATABASE_URLetc. point at Railway/docker hosts; akeyway pull -e devwill clobber local overrides. - Test infra:
tests/setup.tshardcodespostgresql://test:test@localhost:5432/test; integration-db usespostgresql://localhost:5432/keyway_testas the OS user. Fragile across pg setups — make it configurable / provision in the local profile.
- PR-A org members roster (overlay + dedup + role-from-GitHub + count* + Members UI + tests). *count needs High-1 decision first.
- PR-B org role correctness (self-heal
GET /:org+ #2a/#2b). - PR-C docs (
organizations.md). - PR #17 migrate wait-for-db (already up).
- Then: #3, #4, and the Tier 1 items as their own PRs.
organization.service.ts and organizations.routes.ts are entangled with pre-existing security-audit changes → PR extraction needs hunk-level git add -p, not file-level.