Skip to content

fix: make headless api-key auth work end-to-end (CLI + server org resolution)#3493

Open
saddlepaddle wants to merge 4 commits intomainfrom
satya-patel/api-key-headless-cli
Open

fix: make headless api-key auth work end-to-end (CLI + server org resolution)#3493
saddlepaddle wants to merge 4 commits intomainfrom
satya-patel/api-key-headless-cli

Conversation

@saddlepaddle
Copy link
Copy Markdown
Collaborator

@saddlepaddle saddlepaddle commented Apr 15, 2026

Summary

End-to-end fix for headless api-key auth on Linux. Four bugs total, three client-side, one server-side. The server-side fix has been refactored from the original "helper called from tRPC context" shape into a single branch inside customSession, which is the right architectural location and saves DB round-trips.

Scale (for context on blast radius)

  • 390 api keys across 319 distinct users in prod
  • 390 (100%) now have metadata.organizationId after the one-time backfill that ships with this PR (367 already had it, 23 legacy keys backfilled to the owner's oldest membership)
  • 0 currently orphaned — every key now points at an org the owner is still a member of
  • No code in the repo calls auth.api.updateApiKey — the desktop UI only does create + delete, so the metadata lockdown introduced here breaks no existing path

The bugs

1. CLI sent the wrong header shape for api keys (client)

packages/cli/src/lib/api-client.ts sent every bearer as Authorization: Bearer <token>. For sk_live_* keys this returns 401 from better-auth — its apiKey() plugin requires x-api-key: <token>. So even though --api-key / SUPERSET_API_KEY flows existed on the CLI surface, they never actually worked end-to-end.

Fix: conditional header based on token prefix.

2. Host service's JWT exchange had the same bug (client)

packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.ts calls /api/auth/token to mint a short-lived JWT from the session token it receives from the CLI. Same header-shape bug → even if the CLI bootstrapped correctly, the host service would fail the JWT exchange, the relay tunnel would never connect, and superset host start would time out at the 10s health check with no visible error (daemon-mode stdio is discarded).

Same fix.

3. build-dist.ts produced a broken linux-x64 bundle (client)

node-pty ships in-package N-API prebuilds for darwin-arm64, darwin-x64, win32-arm64, and win32-x64but not linux-x64. The existing fixNativeBinariesForNode logic deleted the staged copy's build/Release/ on the assumption that bindings would fall back to prebuilds/<target>/pty.node. On linux that target doesn't exist, so every invocation of the shipped bundle crashed at module load:

Error: Failed to load native module: pty.node, checked: build/Release, build/Debug, prebuilds/linux-x64: Cannot find module './prebuilds/linux-x64//pty.node'

Fix: on linux-x64, run node-gyp rebuild in the staged node-pty directory. node-pty uses node-addon-api so the resulting binding is ABI-stable across Node versions. Also dropped an over-strict existence check for spawn-helper (that target is only built on macOS per binding.gyp).

4. Server didn't read metadata.organizationId from api keys (server)

apiKeyRouter.create stores the intended org in metadata.organizationId, but customSession in packages/auth/src/server.ts was running resolveSessionOrganizationState for every session (api-key or OAuth) and ignoring the metadata entirely. For api-key sessions the resolver also generated wasted DB I/O — it tried to UPDATE auth.sessions by session.id, which for api-key-synthesized sessions equals an apikeys.id with no row in the sessions table, so the UPDATE matched 0 rows and fell through to a no-result SELECT. The end state was activeOrganizationId: null, then the original PR's tRPC-context helper would do a second verifyApiKey call to override it.

Fix: branch inside customSession for api-key-synthesized sessions, detected via session.token?.startsWith("sk_live_") && session.id. The branch:

  1. Reads apikeys by primary key (session.id === apikey.id) — single indexed read, no verifyApiKey round-trip.
  2. Loads the user's memberships once — gives both the bound-org membership check and the organizationIds array for the enriched session.
  3. Pins activeOrganizationId to metadata.organizationId if the user is still a member, else null.
  4. No fallback. No "preferred" anything. The key is the authority.
  5. Looks up the subscription as before so session.plan keeps working.

The OAuth path is untouched — the branch is if (isApiKeySession) { … return …; } and the existing resolveSessionOrganizationState flow runs for everything else.

Cost accounting per api-key tRPC request:

Reads Writes
Before this PR 4 3
Original helper-in-context approach (commit a507841) 5 3
This PR (customSession branch) 4 1

The remaining write is better-auth's own lastRequest update during validateApiKey, which the plugin does unconditionally (with rateLimit.enabled: false it still returns { update: { lastRequest: now } } per @better-auth/api-key/dist/index.mjs:1595). Eliminating it would require monkey-patching the plugin and is out of scope.

Side effect: apps/api/src/app/api/proxy/linear-image/route.ts calls auth.api.getSession directly without the tRPC context wrapper, so under the original PR shape it was still silently resolving activeOrganizationId to null for any api-key request. With the fix in customSession, that route gets the correct org for free.

4b. API key metadata.organizationId was freely mutable (server)

While auditing the strictness of the new binding, verified the actual exposure against the plugin source (@better-auth/api-key/dist/index.mjs:1487+): the POST /api-key/update route accepts a metadata field on the request body with no validation that the new organizationId is a real org or one the caller is a member of. getSessionFromCtx honors api-key sessions, so an attacker holding a stolen sk_live_* key could call /api-key/update on that same key and rewrite metadata.organizationId to any value, bypassing the strict binding entirely.

Fix: top-level hooks.before matcher on betterAuth({...}) that throws BAD_REQUEST whenever POST /api-key/update carries a metadata field. Net effect: api key metadata is mint-immutable. To rebind a key to a different org, delete it and create a new one.

Why this is safe to ship without breaking anything:

  • grep -RIn "updateApiKey\|api-key/update" packages apps returns zero hits. We never call auth.api.updateApiKey server-side.
  • The desktop UI exposes only delete (ApiKeysSettings.tsx:100) and create (ApiKeysSettings.tsx:74).
  • The other update fields (name, enabled, expiresIn) remain mutable. Only metadata is locked.
  • No existing top-level hooks config to merge with — greenfield addition.

Membership enforcement on a key whose org you've left

When a user is removed from an org their api key was minted for, the runtime check sets activeOrganizationId to null and any org-scoped procedure throws FORBIDDEN via requireActiveOrgId (packages/trpc/src/router/utils/active-org.ts:11–13). No api key rows are deleted or disabled. If the user is re-added to the org, the key starts working again with no admin action. Same security outcome as deletion at the auth layer, fully reversible, no destructive cleanup.

Backfill

Before this PR, 23 of 390 keys had no metadata.organizationId (legacy keys minted before apiKeyRouter.create started populating metadata). Under the new strict semantics those would return FORBIDDEN on every request. To avoid breakage, this PR includes a one-time SQL backfill executed against prod that sets metadata.organizationId on each legacy key to the owner's oldest membership (ORDER BY created_at ASC LIMIT 1):

UPDATE auth.apikeys AS k
SET metadata = jsonb_build_object('organizationId', (
  SELECT m.organization_id::text
  FROM auth.members m
  WHERE m.user_id::text = k.reference_id
  ORDER BY m.created_at ASC
  LIMIT 1
))::text
WHERE jsonb_typeof(k.metadata::jsonb) IN ('null', 'array')
   OR (jsonb_typeof(k.metadata::jsonb) = 'object' AND NOT (k.metadata::jsonb ? 'organizationId'))
   OR k.metadata IS NULL;

22 of the 23 affected users are in exactly one org, so the guess is unambiguous. The 23rd (one user with two orgs) gets a best-effort guess; if it's wrong, the runtime check in customSession cleanly returns FORBIDDEN and the user re-mints via the existing UI — strictly better than today's silent newest-membership misrouting. Backfill executed against prod as part of this PR; verified 390/390 keys now have valid metadata.organizationId.

Verification

Manual end-to-end against the existing sandbox + prod API:

  1. git clone github.com/superset-sh/superset && bun install
  2. bun run packages/cli/scripts/build-dist.ts --target=linux-x64 → produces a working tarball
  3. Extract to ~/.superset/bin, add to PATH
  4. export SUPERSET_API_KEY=sk_live_...
  5. superset auth check → signs in, reports the correct org once this branch is deployed (the org whose UUID is in metadata.organizationId)
  6. superset host start --daemon --port 4879Host service running on port 4879 (pid N) · Connected to relay
  7. superset host status<org>: running (pid N)
  8. Local health probe → {"status":"ok"}
  9. Host visible in prod control plane under the correct org's v2 hosts

Before this PR each of the four steps above failed at a different layer: CLI 401 (bug 1), host-service JWT 401 (bug 2), pty.node missing on linux (bug 3), wrong org reported on auth check (bug 4). After, the full chain works.

Test plan

  • bun run typecheck clean in apps/api, packages/cli, packages/host-service, packages/auth
  • bun run lint:fix clean
  • bun run packages/cli/scripts/build-dist.ts --target=linux-x64 succeeds end-to-end including the node-pty rebuild
  • End-to-end superset host start on linux-x64 via api key against prod API (bugs 1–3, verified in a fresh sandbox)
  • Backfill executed: 390/390 keys have metadata.organizationId after this PR
  • Verify bug 4 fix end-to-end after deploy: superset auth check against prod with sk_live_* key whose metadata.organizationId is a non-default org — should report the key's org, not the default
  • Verify metadata immutability after deploy: POST /api/auth/api-key/update with body: { keyId, metadata: { organizationId: <other> } } must return BAD_REQUEST. body: { keyId, name: "renamed" } must succeed.
  • Reviewer: confirm darwin-arm64 build still produces a loadable pty.node (only linux-x64 path verified here)
  • Reviewer: confirm the strict membership-check behavior is acceptable (a user removed from their key's org will start getting FORBIDDEN immediately, with no automated cleanup; reversible if they're re-added)

Commits

  1. fix(cli): make headless api-key auth work on linux — bugs 1, 2, 3 (CLI x-api-key header, host-service JWT exchange header, linux node-pty build)
  2. fix(api): resolve api-key active org from metadata, enforce membership — original helper-in-context approach for bug 4 (superseded by commit 3)
  3. refactor(auth): hoist api-key org binding into customSession + lock metadata — moves the bug-4 fix into customSession, eliminates redundant DB work, adds the metadata-immutability lockdown for the security pivot

The intermediate commit 2 is left in history because it's a working-state checkpoint — the refactor in commit 3 is easier to review as a delta against it. Squash before merge if preferred.

Not in this PR (deferred)

  • Tightening apiKeyRouter.create to accept an explicit organizationId instead of pulling from the user's current active session (the hidden-state bug that makes it easy to mint a key for the wrong org without realizing it). Backward-compatible addition; can ship separately alongside a dashboard UI change (org picker at mint time).
  • Dashboard UX: "stale key" badge for keys whose org the user has left, so they can self-clean.
  • Org admin view of api keys issued by members, with an explicit revoke button, for compliance-driven deprovisioning.

Summary by CodeRabbit

  • New Features

    • Organization-bound session handling for API-key authentication.
  • Bug Fixes

    • Improved native binary handling for Linux x64 builds.
    • API key metadata is now immutable during updates.
  • Chores

    • Authentication header handling updated to support multiple API key formats.

Three bugs were blocking a fresh Linux box from running `superset host
start` with a `sk_live_` key end-to-end:

1. The tRPC client in the CLI sent the key as `Authorization: Bearer
   <key>`, which better-auth's api-key plugin does not accept — the
   plugin requires `x-api-key`. Every CLI call returned 401 under
   api-key auth. Fix: switch to `x-api-key` for `sk_live_`-prefixed
   bearers, keep `Authorization: Bearer` for OAuth tokens.

2. `JwtApiAuthProvider` in the host service made the same mistake on
   its `/api/auth/token` JWT exchange. So even if the CLI bootstrapped
   correctly, the host service failed to mint a JWT, the relay tunnel
   never connected, and `host start` timed out waiting on health.

3. `build-dist.ts` on `linux-x64` deleted node-pty's `build/Release/`
   expecting an in-package N-API prebuild as a fallback, but node-pty
   only ships prebuilds for darwin/win32. The shipped host service
   bundle had no `pty.node`, so every invocation crashed at
   `Failed to load native module: pty.node`. Fix: on linux-x64, run
   `node-gyp rebuild` in the staged copy; node-pty uses
   node-addon-api so the resulting binding is ABI-stable. Dropped the
   over-strict `spawn-helper` existence check because that target is
   only built on macOS.

With all three landed: `superset auth check` and
`superset host start --daemon` work through a linux-x64 bundle in a
fresh sandbox, signed in via `SUPERSET_API_KEY=sk_live_...`.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 15, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Special-case handling for production API keys (sk_live_*) by using x-api-key headers, enforce immutability and organization-binding for API-key metadata in the auth server, and change CLI native build logic to rebuild node-pty for linux-x64 while preserving fallback for other targets.

Changes

Cohort / File(s) Summary
API Client
packages/cli/src/lib/api-client.ts
Request header selection now uses x-api-key when bearer starts with sk_live_, otherwise uses Authorization: Bearer.
Host-Service Auth Provider
packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.ts
Token-minting request now sends x-api-key for sk_live_ tokens; otherwise uses Authorization: Bearer.
Auth Server
packages/auth/src/server.ts
Added middleware rejecting POST /api-key/update with metadata; JWT payload logic binds sk_live_ sessions to the API key's metadata.organizationId (restricts returned organizationIds).
CLI Build Script (native binaries)
packages/cli/scripts/build-dist.ts
fixNativeBinariesForNode treats linux-x64 specially: removes old build, runs npx node-gyp rebuild in node-pty, verifies node-pty/build/Release/pty.node, and fails if missing. Non-linux-x64 still force N-API prebuild fallback.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant ApiClient
    participant HostService
    participant AuthServer
    participant DB

    Client->>ApiClient: Send request with token
    alt token starts with "sk_live_"
        ApiClient->>HostService: HTTP request with x-api-key
        HostService->>AuthServer: Validate token (x-api-key)
        AuthServer->>DB: Load API key row (metadata.organizationId)
        AuthServer-->>HostService: Session payload limited to that org
        HostService-->>ApiClient: Response (org-bound)
    else non "sk_live_"
        ApiClient->>HostService: HTTP request with Authorization: Bearer
        HostService->>AuthServer: Validate token (Bearer)
        AuthServer->>DB: Load user and org memberships
        AuthServer-->>HostService: Session payload with resolved orgs
        HostService-->>ApiClient: Response (resolved orgs)
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐇 I nibble headers with a twitch and grin,
For sk_live_ I whisper x-api-key within.
I bind a key to one small burrowed land,
Rebuild the pty with careful paw and hand.
Hooray — the build hops right and guards the band.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically identifies the main objective: fixing headless api-key auth end-to-end across CLI and server org resolution on Linux.
Description check ✅ Passed The description is comprehensive and well-structured, covering all required sections: summary, related issues context, type of change (bug fix), testing approach, and additional context. The author provides detailed technical explanation of four distinct bugs, their fixes, verification steps, and a test plan.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch satya-patel/api-key-headless-cli

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

api keys created via `apiKeyRouter.create` already store their intended
org in `metadata.organizationId`, but the trpc context creator never
read it. The api-key-synthesized session had no `activeOrganizationId`,
so requests fell through to the default newest-membership resolver and
silently routed to the wrong org whenever the user belonged to more
than one. This was most visible via `superset auth check` reporting a
different org than the one the key was minted for.

Fix: new `resolveApiKey` helper (`apps/api/src/lib/api-key-org.ts`)
that pulls `x-api-key` / `Bearer sk_live_...` from the request,
calls `auth.api.verifyApiKey`, reads `metadata.organizationId`, and
verifies the user is still a member of that org. Four outcomes:

  - not-api-key              → use session as-is (OAuth / no auth)
  - no-organization-metadata → use session as-is (legacy key, pre-router)
  - ok                       → override session.activeOrganizationId
  - invalid                  → null the session, request gets 401

The membership check is the security boundary for api-key deprovisioning
when a user is removed from an org: we do not delete/disable keys on
removal (reversible, non-destructive) but we also do not let them
authenticate as the org they no longer belong to. If the user is
re-added, their key starts working again.

Blast radius: 390 api keys in prod today, 367 with org metadata, 0
currently orphaned — this does not break any existing consumer.

The 23 legacy keys without `metadata.organizationId` fall through to
the existing resolver unchanged (no behavior change for them).
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 15, 2026

🚀 Preview Deployment

🔗 Preview Links

Service Status Link
Neon Database (Neon) View Branch
Fly.io Electric (Fly.io) View App
Vercel API (Vercel) Open Preview
Vercel Web (Vercel) Open Preview
Vercel Marketing (Vercel) Open Preview
Vercel Admin (Vercel) Open Preview
Vercel Docs (Vercel) Open Preview

Preview updates automatically with new commits

@greptile-apps
Copy link
Copy Markdown

greptile-apps bot commented Apr 15, 2026

Greptile Summary

This PR fixes three independent bugs that were preventing superset host start from working end-to-end on Linux with an sk_live_* API key.

  • packages/cli/src/lib/api-client.ts: Sends x-api-key instead of Authorization: Bearer for sk_live_* tokens, matching better-auth's apiKey() plugin requirement. The fix is consistent with the existing isApiKeyBearerToken helper in apps/api/src/app/api/agent/[transport]/auth-flow.ts.
  • packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.ts: Applies the same header correction to the JWT mint request, fixing a silent 401 that caused the relay tunnel to never connect and superset host start to time out.
  • packages/cli/scripts/build-dist.ts: Adds a linux-x64 branch that rebuilds node-pty from source via node-gyp because no upstream N-API prebuild exists for that target. The N-API/node-addon-api ABI stability claim is correct — a binding compiled against a modern system Node will load cleanly in the shipped Node 22.13.0. Two P2 style items remain: intermediate build artifacts (.o files, Makefile, etc.) are left in the staging tree and will bloat the linux tarball, and the new build step has an undocumented dependency on Python 3 and a C++ toolchain.

Confidence Score: 4/5

Safe to merge — all three bugs are correctly fixed; only non-blocking P2 cleanups remain

The two auth header fixes are clean, minimal, and consistent with the same pattern already used in the server-side auth-flow. The linux build fix correctly handles the missing N-API prebuild case and the ABI-stability reasoning is sound. The only remaining items are P2 style concerns in build-dist.ts (tarball bloat from intermediate build artifacts, undocumented Python/gcc dependency) that don't affect runtime correctness.

packages/cli/scripts/build-dist.ts — tarball artifact cleanup after node-gyp rebuild

Important Files Changed

Filename Overview
packages/cli/scripts/build-dist.ts Adds linux-x64 node-pty build-from-source path; logic is correct but leaves intermediate build artifacts (obj files, Makefiles) in the tarball — only pty.node is needed at runtime
packages/cli/src/lib/api-client.ts Conditionally uses x-api-key header for sk_live_* tokens; fix is correct and consistent with the isApiKeyBearerToken pattern used in auth-flow.ts
packages/host-service/src/providers/auth/JwtAuthProvider/JwtAuthProvider.ts Mirrors the api-client.ts header fix for the JWT exchange flow; correctly applies x-api-key for sk_live_* session tokens

Sequence Diagram

sequenceDiagram
    participant CLI as superset CLI
    participant API as Superset API
    participant HS as host-service

    Note over CLI,API: Before fix — API key path returns 401
    CLI->>API: POST /api/trpc (Authorization: Bearer token)
    API-->>CLI: 401 Unauthorized

    Note over CLI,API: After fix — correct header routing
    CLI->>API: POST /api/trpc (x-api-key: token)
    API-->>CLI: 200 OK

    Note over HS,API: JWT mint exchange (JwtAuthProvider)
    HS->>API: GET /api/auth/token (x-api-key: token)
    API-->>HS: short-lived JWT

    Note over HS,API: Subsequent relay requests use JWT
    HS->>API: POST /api/... (Authorization: Bearer JWT)
    API-->>HS: 200 OK
Loading
Prompt To Fix All With AI
This is a comment left during a code review.
Path: packages/cli/scripts/build-dist.ts
Line: 276-281

Comment:
**Build artifacts bloat the linux tarball**

After `node-gyp rebuild` succeeds, only `build/Release/pty.node` is needed at runtime. The entire `build/` directory — which includes `obj.target/` (compiled `.o` files), `Makefile`, `config.gypi`, and other intermediate artifacts — stays in the staging tree and is packed into the tarball. Depending on the machine, this can easily add 5–20 MB.

The darwin path avoids this cleanly by removing `build/` entirely (darwin falls back to the in-package prebuilds). The linux path should do the same with a targeted cleanup after the verification:

```ts
const builtBinding = join(nodePtyBuild, "Release", "pty.node");
if (!existsSync(builtBinding)) {
    throw new Error(`node-pty build did not produce ${builtBinding}`);
}
// Strip intermediate artifacts — only the .node file is needed at runtime
const objTarget = join(nodePtyBuild, "Release", "obj.target");
if (existsSync(objTarget)) rmSync(objTarget, { recursive: true, force: true });
// Also remove non-Release subdirectories that node-gyp generates
for (const entry of readdirSync(nodePtyBuild)) {
    if (entry !== "Release") {
        rmSync(join(nodePtyBuild, entry), { recursive: true, force: true });
    }
}
return;
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: packages/cli/scripts/build-dist.ts
Line: 274-275

Comment:
**`node-gyp rebuild` requires Python 3 and a C++ toolchain**

`node-gyp rebuild` will fail with a cryptic error if `python3` or `gcc`/`g++` are not available on the build machine. This path is new to the script and could surprise first-time linux contributors. Consider adding a preflight check or a clearer failure message:

```ts
console.log("[build-dist] compiling node-pty from source for linux-x64 (requires python3 + gcc)");
```

Or check explicitly before calling `exec`:
```ts
await exec("python3", ["--version"]).catch(() => {
    throw new Error("node-gyp requires python3 — install it and re-run");
});
```

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "fix(api): resolve api-key active org fro..." | Re-trigger Greptile

Comment on lines +276 to +281
const builtBinding = join(nodePtyBuild, "Release", "pty.node");
if (!existsSync(builtBinding)) {
throw new Error(`node-pty build did not produce ${builtBinding}`);
}
return;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Build artifacts bloat the linux tarball

After node-gyp rebuild succeeds, only build/Release/pty.node is needed at runtime. The entire build/ directory — which includes obj.target/ (compiled .o files), Makefile, config.gypi, and other intermediate artifacts — stays in the staging tree and is packed into the tarball. Depending on the machine, this can easily add 5–20 MB.

The darwin path avoids this cleanly by removing build/ entirely (darwin falls back to the in-package prebuilds). The linux path should do the same with a targeted cleanup after the verification:

const builtBinding = join(nodePtyBuild, "Release", "pty.node");
if (!existsSync(builtBinding)) {
    throw new Error(`node-pty build did not produce ${builtBinding}`);
}
// Strip intermediate artifacts — only the .node file is needed at runtime
const objTarget = join(nodePtyBuild, "Release", "obj.target");
if (existsSync(objTarget)) rmSync(objTarget, { recursive: true, force: true });
// Also remove non-Release subdirectories that node-gyp generates
for (const entry of readdirSync(nodePtyBuild)) {
    if (entry !== "Release") {
        rmSync(join(nodePtyBuild, entry), { recursive: true, force: true });
    }
}
return;
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/cli/scripts/build-dist.ts
Line: 276-281

Comment:
**Build artifacts bloat the linux tarball**

After `node-gyp rebuild` succeeds, only `build/Release/pty.node` is needed at runtime. The entire `build/` directory — which includes `obj.target/` (compiled `.o` files), `Makefile`, `config.gypi`, and other intermediate artifacts — stays in the staging tree and is packed into the tarball. Depending on the machine, this can easily add 5–20 MB.

The darwin path avoids this cleanly by removing `build/` entirely (darwin falls back to the in-package prebuilds). The linux path should do the same with a targeted cleanup after the verification:

```ts
const builtBinding = join(nodePtyBuild, "Release", "pty.node");
if (!existsSync(builtBinding)) {
    throw new Error(`node-pty build did not produce ${builtBinding}`);
}
// Strip intermediate artifacts — only the .node file is needed at runtime
const objTarget = join(nodePtyBuild, "Release", "obj.target");
if (existsSync(objTarget)) rmSync(objTarget, { recursive: true, force: true });
// Also remove non-Release subdirectories that node-gyp generates
for (const entry of readdirSync(nodePtyBuild)) {
    if (entry !== "Release") {
        rmSync(join(nodePtyBuild, entry), { recursive: true, force: true });
    }
}
return;
```

How can I resolve this? If you propose a fix, please make it concise.

Comment thread packages/cli/scripts/build-dist.ts Outdated
Comment on lines +274 to +275
console.log("[build-dist] compiling node-pty from source for linux-x64");
await exec("npx", ["--yes", "node-gyp", "rebuild"], nodePtyDir);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 node-gyp rebuild requires Python 3 and a C++ toolchain

node-gyp rebuild will fail with a cryptic error if python3 or gcc/g++ are not available on the build machine. This path is new to the script and could surprise first-time linux contributors. Consider adding a preflight check or a clearer failure message:

console.log("[build-dist] compiling node-pty from source for linux-x64 (requires python3 + gcc)");

Or check explicitly before calling exec:

await exec("python3", ["--version"]).catch(() => {
    throw new Error("node-gyp requires python3 — install it and re-run");
});
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/cli/scripts/build-dist.ts
Line: 274-275

Comment:
**`node-gyp rebuild` requires Python 3 and a C++ toolchain**

`node-gyp rebuild` will fail with a cryptic error if `python3` or `gcc`/`g++` are not available on the build machine. This path is new to the script and could surprise first-time linux contributors. Consider adding a preflight check or a clearer failure message:

```ts
console.log("[build-dist] compiling node-pty from source for linux-x64 (requires python3 + gcc)");
```

Or check explicitly before calling `exec`:
```ts
await exec("python3", ["--version"]).catch(() => {
    throw new Error("node-gyp requires python3 — install it and re-run");
});
```

How can I resolve this? If you propose a fix, please make it concise.

@saddlepaddle saddlepaddle changed the title fix(cli): make headless api-key auth work on linux fix: make headless api-key auth work end-to-end (CLI + server org resolution) Apr 15, 2026
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 3 files

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
apps/api/src/lib/api-key-org.ts (1)

63-93: LGTM with optional observability suggestion.

The implementation is solid:

  • Proper discriminated union return types for clear caller handling
  • Membership verification prevents access for deprovisioned users
  • Try-catch ensures verification failures don't crash the request

Consider adding structured logging for "invalid" outcomes (lines 73, 87, 91) to support security auditing and debugging. This would help trace why API key authentications fail in production.

,

📝 Optional: Add audit logging for failed verifications
+import { logger } from "@/lib/logger"; // or your logging solution
+
 export async function resolveApiKey(
 	req: Request,
 	auth: typeof betterAuth,
 	userId: string,
 ): Promise<ApiKeyResolution> {
 	const apiKey = extractApiKey(req);
 	if (!apiKey) return { kind: "not-api-key" };
 
 	try {
 		const result = await auth.api.verifyApiKey({ body: { key: apiKey } });
-		if (!result.valid || !result.key) return { kind: "invalid" };
+		if (!result.valid || !result.key) {
+			logger.warn("API key verification failed", { userId });
+			return { kind: "invalid" };
+		}
 
 		const metadata = parseApiKeyMetadata(result.key.metadata);
 		const organizationId = metadata?.organizationId;
 		if (typeof organizationId !== "string") {
 			return { kind: "no-organization-metadata" };
 		}
 
 		const membership = await db.query.members.findFirst({
 			where: and(
 				eq(members.userId, userId),
 				eq(members.organizationId, organizationId),
 			),
 		});
-		if (!membership) return { kind: "invalid" };
+		if (!membership) {
+			logger.warn("API key org membership check failed", { userId, organizationId });
+			return { kind: "invalid" };
+		}
 
 		return { kind: "ok", organizationId };
 	} catch {
+		logger.error("API key resolution error", { userId });
 		return { kind: "invalid" };
 	}
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/lib/api-key-org.ts` around lines 63 - 93, Add structured audit
logging inside resolveApiKey around the early returns so failed API-key
resolutions are recorded: when extractApiKey() yields no key, when
auth.api.verifyApiKey(...) returns invalid or missing key, when
parseApiKeyMetadata(...) yields a non-string organizationId, and when
db.query.members.findFirst(...) finds no membership. Use the project's central
logger (e.g., processLogger or auditLogger) to log a concise event with masked
apiKey, userId, reason
("no-api-key"|"verify-failed"|"no-organization-metadata"|"no-membership") and
any available metadata (organizationId if present) before returning the
corresponding { kind: ... } result; keep logs structured and avoid emitting raw
secrets.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@apps/api/src/lib/api-key-org.ts`:
- Around line 63-93: Add structured audit logging inside resolveApiKey around
the early returns so failed API-key resolutions are recorded: when
extractApiKey() yields no key, when auth.api.verifyApiKey(...) returns invalid
or missing key, when parseApiKeyMetadata(...) yields a non-string
organizationId, and when db.query.members.findFirst(...) finds no membership.
Use the project's central logger (e.g., processLogger or auditLogger) to log a
concise event with masked apiKey, userId, reason
("no-api-key"|"verify-failed"|"no-organization-metadata"|"no-membership") and
any available metadata (organizationId if present) before returning the
corresponding { kind: ... } result; keep logs structured and avoid emitting raw
secrets.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 24715049-e1cd-443e-a98c-a4828a1ac54e

📥 Commits

Reviewing files that changed from the base of the PR and between f303c11 and a507841.

📒 Files selected for processing (2)
  • apps/api/src/lib/api-key-org.ts
  • apps/api/src/trpc/context.ts

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 2 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/api/src/lib/api-key-org.ts">

<violation number="1" location="apps/api/src/lib/api-key-org.ts:32">
P2: Silent `catch {}` on an auth-critical path swallows verification and DB errors without any logging. Add at least a warning log with context so failures are observable in production.

(Based on your team's feedback about handling async errors explicitly and not silently swallowing failures.) [FEEDBACK_USED]</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread apps/api/src/lib/api-key-org.ts
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/auth/src/server.ts (1)

42-59: Consider excluding arrays from valid metadata.

typeof parsed === "object" returns true for arrays. If metadata like "[]" is passed, it would be returned as Record<string, unknown> which may not be the intended behavior.

🔧 Proposed fix
 function parseApiKeyMetadata(
 	metadata: unknown,
 ): Record<string, unknown> | null {
 	if (!metadata) return null;
 	if (typeof metadata === "string") {
 		try {
 			const parsed = JSON.parse(metadata);
-			return parsed && typeof parsed === "object"
+			return parsed && typeof parsed === "object" && !Array.isArray(parsed)
 				? (parsed as Record<string, unknown>)
 				: null;
 		} catch {
 			return null;
 		}
 	}
-	return typeof metadata === "object"
+	return typeof metadata === "object" && !Array.isArray(metadata)
 		? (metadata as Record<string, unknown>)
 		: null;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/auth/src/server.ts` around lines 42 - 59, The parseApiKeyMetadata
function treats arrays as valid objects because it only checks typeof ===
"object"; update it to exclude arrays by ensuring parsed is non-null, not an
array (use Array.isArray(parsed) check) before casting to Record<string,
unknown>, and do the same for the branch that returns metadata when metadata is
already an object (ensure metadata is not null and not an array before casting).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/auth/src/server.ts`:
- Around line 42-59: The parseApiKeyMetadata function treats arrays as valid
objects because it only checks typeof === "object"; update it to exclude arrays
by ensuring parsed is non-null, not an array (use Array.isArray(parsed) check)
before casting to Record<string, unknown>, and do the same for the branch that
returns metadata when metadata is already an object (ensure metadata is not null
and not an array before casting).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a0cf4fd5-0a36-4108-b5bc-6083c7f405f0

📥 Commits

Reviewing files that changed from the base of the PR and between a507841 and d5f04d7.

📒 Files selected for processing (1)
  • packages/auth/src/server.ts

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 3 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/api/src/trpc/context.ts">

<violation number="1">
P0: This change bypasses api-key org resolution/membership enforcement by passing the raw session directly. Invalid api-key contexts will no longer be nulled here, and org scoping from api-key metadata is not applied.</violation>
</file>

<file name="packages/auth/src/server.ts">

<violation number="1" location="packages/auth/src/server.ts:609">
P2: These two independent DB queries (`apikeys.findFirst` and `members.findMany`) run sequentially but have no data dependency. Parallelize them with `Promise.all` to cut per-request latency on every API-key-authenticated call.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread packages/auth/src/server.ts Outdated
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 1 file (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/auth/src/server.ts">

<violation number="1" location="packages/auth/src/server.ts:230">
P2: The API-key org resolution logic (metadata lookup → membership check → org narrowing) is duplicated nearly verbatim between `definePayload` and `customSession`. Extract it into a shared helper like `resolveApiKeyOrgBinding(db, sessionId, userId)` returning `{ boundOrganizationId, boundMembership }` — the membership query can select both `userId` and `role` to satisfy both callers. This avoids drift if the metadata format or membership check changes.

(Based on your team's feedback about preferring shared helpers over repeated multi-step flows.) [FEEDBACK_USED]</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

// `ctx.organizationIds.includes(input.organizationId)` check.
// Matches the narrowing `customSession` does for the
// protectedProcedure path.
if (session.token?.startsWith("sk_live_") && session.id) {
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The API-key org resolution logic (metadata lookup → membership check → org narrowing) is duplicated nearly verbatim between definePayload and customSession. Extract it into a shared helper like resolveApiKeyOrgBinding(db, sessionId, userId) returning { boundOrganizationId, boundMembership } — the membership query can select both userId and role to satisfy both callers. This avoids drift if the metadata format or membership check changes.

(Based on your team's feedback about preferring shared helpers over repeated multi-step flows.)

View Feedback

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/auth/src/server.ts, line 230:

<comment>The API-key org resolution logic (metadata lookup → membership check → org narrowing) is duplicated nearly verbatim between `definePayload` and `customSession`. Extract it into a shared helper like `resolveApiKeyOrgBinding(db, sessionId, userId)` returning `{ boundOrganizationId, boundMembership }` — the membership query can select both `userId` and `role` to satisfy both callers. This avoids drift if the metadata format or membership check changes.

(Based on your team's feedback about preferring shared helpers over repeated multi-step flows.) </comment>

<file context>
@@ -215,10 +215,46 @@ export const auth = betterAuth({
+					// `ctx.organizationIds.includes(input.organizationId)` check.
+					// Matches the narrowing `customSession` does for the
+					// protectedProcedure path.
+					if (session.token?.startsWith("sk_live_") && session.id) {
+						const keyRow = await db.query.apikeys.findFirst({
+							where: eq(authSchema.apikeys.id, session.id),
</file context>
Fix with Cubic

…d org

Two additions on top of the helper-in-context `resolveApiKey` change:

1. Top-level `hooks.before` matcher that rejects any
   `POST /api-key/update` request whose body contains a `metadata`
   field. Closes the pivot where an attacker holding a stolen
   `sk_live_*` key could call `/api-key/update` on that same key and
   rewrite `metadata.organizationId` — which is the authority for the
   org binding `resolveApiKey` enforces — to any value, bypassing the
   strict check. Other update fields (name, enabled, expiresIn) remain
   mutable. No codebase callers of `auth.api.updateApiKey` and no UI
   surface for metadata updates.

2. `jwt.definePayload` detects api-key-synthesized sessions via
   `session.token?.startsWith("sk_live_") && session.id` and, for
   those, emits `organizationIds: [boundOrg]` in the JWT claim instead
   of the full user membership list. This closes the `jwtProcedure`
   Pattern B pivot: routes like `v2-workspace.create`,
   `v2-project.create`, and `device.create` read `ctx.organizationIds`
   from the JWT payload and gate input via
   `ctx.organizationIds.includes(input.organizationId)`. Without the
   narrowing, a JWT minted from an api key by the host service would
   carry every org the owner is a member of.

Verified end-to-end against localhost:4521 with a Superset-bound
`sk_live_*` key whose owner is a member of three orgs:

- `GET /api/auth/token` → JWT payload contains
  `organizationIds: [superset-uuid]` only, not all three.
- `v2Workspace.create` with `input.organizationId = kiets-org-uuid`
  → 403 "Not a member of this organization".
- `v2Workspace.create` with `input.organizationId = superset-uuid`
  → gets past the membership check (fails later at project lookup
  with a fake projectId, the expected "authorized but no data" mode).
- `POST /api-key/update` with `{ metadata: {...} }` → 400 with the
  lockdown message; with `{ name }` or `{ enabled }` → 200.

Not closed by this change (deferred follow-up):

`protectedProcedure` Pattern B routes that call
`verifyOrgMembership(ctx.session.user.id, input.organizationId)`
directly against the db (integration/linear, integration/github,
organization.{removeMember,leave,updateMemberRole,...}). Those read
memberships from the db, not from the session, so narrowing the
session doesn't constrain them. An api-key-authed request can still
reach them for any org the owner belongs to. Closing this requires
either a session marker ("this came from an api key") that
`verifyOrgMembership` can check, or a procedure-base refactor.
After `node-gyp rebuild` produces `build/Release/pty.node`, the
staging tree still contains `obj.target/` (compiled .o files),
`Makefile`, `config.gypi`, `.deps/`, and `node_addon_api_except.stamp`
alongside the binding. Only `pty.node` is needed at runtime; the rest
ships ~a few MB of dead weight in the tarball.

Fix: after verifying the binding exists, read its bytes, wipe
`build/` entirely, then recreate `build/Release/` with just
`pty.node`. Matches how the darwin path leaves `build/` in a
minimal state (there, by deleting it entirely and falling back to
the in-package prebuilds).

Verified against a linux-x64 build in a sandbox:
- before: `build/` contains Release/pty.node + obj.target/ + Makefile
  + config.gypi + .deps/ + node_addon_api_except.stamp
- after: `build/` contains only `Release/pty.node` (68K total)
- tar listing shows exactly 3 entries under node-pty/build/

Also tighten the log line to mention the toolchain dependency
(python3 + gcc) so first-time linux contributors get a clearer hint
if node-gyp rebuild fails for environmental reasons.

Addresses Greptile P2 review comments on PR #3493.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant