Skip to content

feat: team-owned API keys - #3435

Open
jordan-simonovski wants to merge 7 commits into
mainfrom
jordansimonovski/org-api-keys
Open

jordan-simonovski wants to merge 7 commits into
mainfrom
jordansimonovski/org-api-keys

Conversation

@jordan-simonovski

@jordan-simonovski jordan-simonovski commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Team members can create API keys owned by the team instead of a person, so Terraform, CI and other automation keep working when the person who set them up leaves. Today the only bearer key is each user's personal access key, which stops working when that user is removed. Teams work around that with bot users.

  • Create a key from Team Settings > API & Agents with a description and an expiry (30 days, 1 year or never).
  • Each key is its own service account with the same permissions as any other user. Anything created with the key (alerts, dashboards, saved searches) is attributed to that service account, never to the person who made the key.
  • Service accounts can't sign in and don't appear under Members. They only show up in the API keys list.
  • The list shows who created each key, when, when it was last used, and when it expires. Keys made by someone who has since been removed show "Deleted user" and keep working.
  • The full key is shown once at creation and stored as a SHA-256 hash. Revoking or expiring a key takes effect on the next request.
  • Org keys work anywhere a personal key does: the external API v2 and the MCP server.
  • Keys can only be managed from Team Settings while logged in, so no key can create or revoke another key.
Implementation detail
  • Creating a key creates a User with isServiceAccount: true, a synthetic @service-accounts.hyperdx.invalid email and the description as its name, linked from the OrgApiKey record that holds the hash, expiry and last-used time. Bearer auth resolves the key to that user, so every handler gets a normal user.
  • Service accounts are left out of personal access key auth, password sign-in, session restore, the member lists, member removal, onboarding tracking and the usage-stats user count. GET /api/v2/ leaves out their unused personal access key.
  • Revoking deletes the OrgApiKey and keeps the service account, so anything it created still shows who made it. Editing a key's description renames the service account too.
  • When a key removes a member, that member's alerts are reassigned to the service account. A key can't remove the last person in the team (service accounts don't count). Mongo here has no transactions, so a removal that leaves the team empty puts the member back.
  • lastUsedAt is written at most once every 5 minutes per key, without waiting on the write.
  • There's no RBAC: OSS has no roles, so any team member can manage org keys, and a key can do whatever a user can. Service accounts can't manage keys.
  • Metrics: hyperdx.org_api_key.auth_failures, hyperdx.org_api_key.operations (by operation), hyperdx.org_api_key.last_used_write_failures and hyperdx.team.member_restore_failures.

Tested with integration tests covering key create, list, edit and revoke, team isolation, expiry, last use, a deleted creator, service-account sign-in and personal-key rejection, member removal, and the external API and MCP with an org key. The Team Settings section has unit tests.

Screenshots or video

CleanShot 2026-10-22 at 8 25 59 AM CleanShot 2026-10-22 at 8 25 38 AM CleanShot 2026-10-23 at 12 49 38 AM@2x CleanShot 2026-10-23 at 12 49 50 AM@2x

How to reproduce

Starting point: yarn dev (this needs the API).

On main:

  1. Open Team Settings > API & Agents. The only bearer credential is the personal access key.

On this branch:

  1. Open Team Settings > API & Agents and click "Create API key".
  2. Enter a description, leave Expires as 30 days, then click "Create". Copy the key.
  3. Run curl -H "Authorization: Bearer <key>" localhost:<api port>/api/v2/dashboards and confirm it returns 200.
  4. Open Team Settings > Members and confirm the key's service account isn't listed.
  5. Click "Revoke" on the key and confirm. Repeat step 3 and confirm it returns 401.

How to test on Vercel preview

N/A: the Vercel preview runs in local mode without the API, where Team Settings keys aren't available.

References

  • Linear Issue: HDX-5784

Admins can create API keys owned by the team rather than a person, so
Terraform, CI and other automation keep working when someone leaves.
Each key has a description, an admin or member scope, and a 30 day,
1 year or no expiry. Keys are shown once, stored hashed, and accepted
by the external API v2 and MCP. Member-scoped keys can't manage team
members or invitations.

Closes HDX-5784
@changeset-bot

changeset-bot Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 12fa9fd

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 4 packages
Name Type
@hyperdx/api Minor
@hyperdx/app Minor
@hyperdx/common-utils Minor
@hyperdx/otel-collector Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
hyperdx-oss Ready Ready Preview Oct 23, 2026 4:58am UTC
hyperdx-storybook Ready Ready Preview Oct 23, 2026 4:58am UTC

Request Review

@github-actions github-actions Bot added the review/tier-4 Critical — deep review + domain expert sign-off label Oct 21, 2026
@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

🔴 Tier 4 — Critical

Touches authentication, tenancy data models, the public API or shipped database config — or substantially changes the query rendering engine, background tasks, the OTel pipeline, image build, or release CI.

Why this tier:

  • Security-critical files (1) — auth or input validation, escalated at any size:
    • packages/api/src/middleware/auth.ts
  • Critical-path files (4) — tenancy, public API, or shipped database config:
    • packages/api/src/models/user.ts
    • packages/api/src/routers/api/team.ts
    • packages/api/src/routers/external-api/v2/index.ts
    • packages/api/src/routers/external-api/v2/team.ts
  • Cross-layer change: touches frontend (packages/app) + backend (packages/api) + shared utils (packages/common-utils)

Additional context: touches background tasks or the delivery pipeline lightly (3 lines, under the 30-line bar for Tier 4)

Review process: Deep review from a domain expert. Synchronous walkthrough may be required.
SLA: Schedule synchronous review within 2 business days.

Stats
  • Production files changed: 17
  • Production lines changed: 976 (+ 827 in test files, excluded from tier calculation)
  • Critical-path lines changed: 94
  • Branch: jordansimonovski/org-api-keys
  • Author: jordan-simonovski

To override this classification, remove the review/tier-4 label and apply a different review/tier-* label. Manual overrides are preserved on subsequent pushes.

@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Duplicate code analysis

No new duplicate code introduced by this PR.


jscpd compared this branch against origin/main
across app, api and common-utils. Only new clones are listed; the codebase
currently holds 116 in total (1.43%).
Run yarn dupes locally to reproduce, or yarn dupes:all for the full picture.

@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

E2E Test Results

✅ All tests passed • 459 passed • 1 skipped • 1740s

Status Count
✅ Passed 459
❌ Failed 0
⚠️ Flaky 0
⏭️ Skipped 1

Tests ran across 4 shards in parallel.

View full report →

Comment thread packages/api/src/routers/external-api/v2/team.ts Outdated
};

return (
<Modal opened={opened} onClose={close} title="Create API key" centered>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Closing the modal by clicking outside it or pressing Escape after creation discards the only copy of the key

Once createdKey is set, the Modal's onClose={close} still fires on an overlay click or Escape. That clears createdKey, and the plaintext can never be fetched again. Fix: pass closeOnClickOutside={createdKey == null} and closeOnEscape={createdKey == null} (or hide the close button) so only "Done" dismisses the reveal step.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

return (
<Table.Tr>
<Table.Td>
<InlineNameInput

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — A failed description rename silently reverts with no error, and the 100-char limit isn't enforced in the field

The server rejects descriptions over 100 chars (OrgApiKeyDescriptionSchema). InlineNameInput (packages/app/src/components/InlineNameInput/InlineNameInput.tsx:141) swallows the rejection and just reverts the field. The user's edit vanishes with no message. Fix: pass maxLength={100} to InlineNameInput, and attach an onError notification to the update mutation, as the revoke path already does.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

Comment thread packages/app/src/components/TeamSettings/OrgApiKeysSection.tsx Outdated
</Table.Td>
<Table.Td>
{isExpired ? (
<Badge color="red" variant="light">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — The Expired badge uses the raw Mantine palette color="red" variant="light"

This breaks the "Semantic design tokens (prefer over raw Mantine colors)" convention. Fix: style the status with the danger tokens (e.g. var(--color-text-danger) / --color-bg-danger-subtle) or use <Text variant="danger">Expired</Text>.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

expect(await lastUsed()).toBe(t0.getTime());

const fresh = (await OrgApiKey.findById(doc._id))!;
touchOrgApiKeyLastUsed(fresh, new Date(t0.getTime() + 60_000));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — The lastUsedAt throttle test never exercises the DB-side conditional filter that is meant to guard concurrent writes

The second call passes fresh with lastUsedAt = t0, so touchOrgApiKeyLastUsed returns early in JS and never calls updateOne. The test would still pass if the $or: [{lastUsedAt: null}, {lastUsedAt: {$lt: staleBefore}}] filter were removed. Fix: add a case that passes a stale in-memory doc (e.g. the original doc with lastUsedAt: null) after a recent write, and assert that the stored value doesn't change.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

PR Review

If you are a coding agent acting for the author, read this first.
Nothing below blocks merge automatically; a maintainer decides. What they will expect:

  • 🔴 critical or 🟠 major in code this PR adds or changes: fix before asking for review.
  • 🔵 minor in code this PR changes: your call. Fix if small, otherwise reply.
  • Anything about surrounding code, or asking you to widen the change (hoist a helper,
    dedupe with another file, fix other call sites): never fix here, whatever the
    severity. Reply with one sentence; if it is critical, say so plainly so a human sees it.

One commit per review round. After two rounds, stop and ask a maintainer to review
scope rather than addressing more automated findings. Full rule: AGENTS.md.

5 finding(s): 🔴 0 critical · 🟠 1 major · 🔵 4 minor

5 posted as inline comment(s) on the changed lines.


Severity is the reviewer's own estimate and is used for ordering, not filtering. No finding blocks merge automatically; a maintainer decides.

@greptile-apps

greptile-apps Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 5/5

[High impact] The PR appears safe to merge; no new blocking issue was found.

Summary

Adds team-owned API keys for automation, backed by separate service accounts.

  • Team members can create and manage team-owned API keys.
  • API and MCP requests can use a key's service-account identity.
  • API keys can remove team members but keep one person on the team.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[Signed-in team member] --> B[Create API key]
  B --> C[Store key hash]
  B --> D[Create service account]
  E[Bearer request] --> F{Key exists and has not expired?}
  C --> F
  F -->|Yes| G[Use service account as caller]
  D --> G
  G --> H[External API v2 or MCP]
  F -->|No| I[Return 401]
Loading

Reviews (6) · Last reviewed commit: "Merge branch 'main' into jordansimonovsk..." · Reviewed by Greptile

Comment thread packages/api/src/routers/external-api/v2/team.ts Outdated
Comment thread packages/app/src/components/TeamSettings/OrgApiKeysSection.tsx Outdated
Comment thread packages/app/src/components/TeamSettings/OrgApiKeysSection.tsx
Comment thread packages/app/src/components/TeamSettings/CreateOrgApiKeyModal.tsx
Comment thread packages/api/src/controllers/orgApiKey.ts
@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Deep Review

Scope: 24 files (+1777/-33) vs 0b1e0784 — team-owned/service-account API keys: bearer-auth resolution, hashed key storage, expiry/revocation, member removal, Team Settings UI.
Verdict: Ready with minor fixes. No P0/P1 ship-blockers. Prior review feedback (Greptile P1 + five P2s) is genuinely resolved and test-backed. Remaining items are narrow concurrency edges and consistency nits.

✅ No critical issues found. Security (key entropy, hashing, team isolation, expiry/revocation, session exclusion) and the previously-flagged bugs all verified clean.

🟡 P2 — recommended

  • packages/api/src/controllers/user.ts:107 — The human removal path deleteTeamMember has no last-member guard, so the new keep-one protection is bypassable: a key removal and a concurrent human removal (or two humans removing each other) can interleave so the post-delete recount never observes zero, leaving the team with no sign-in user despite the documented "a key can't empty the team" invariant.
    • Fix: Route all member removals through a single guarded entry point (or a per-team advisory lock) so the last-sign-in-user check and delete cannot be raced across the service-account and human paths.
    • adversarial, reliability, correctness
  • packages/api/src/controllers/user.ts:191 — restoreTeamMember's terminal failure branch (retries exhausted, or a non-_id duplicate such as the email being re-taken in the race window) rethrows after the member is already deleted, permanently losing the member and returning 500; this data-loss path has no test and the non-_id duplicate branch is only indirectly exercised.
    • Fix: Add tests mocking insertOne to reject RESTORE_ATTEMPTS times and to throw a code:11000 error whose keyPattern lacks _id, asserting the failure counter increments and the propagation behavior, and consider persisting the deleted doc for manual recovery.
    • testing, adversarial, reliability
🔵 P3 nitpicks (9)
  • packages/api/src/middleware/auth.ts:105 — Every hdx_org_ request runs findUserByAccessKey (a guaranteed miss, since service accounts are excluded) before findActiveOrgApiKey, doubling auth-path DB round-trips.
    • Fix: Short-circuit to authenticateOrgApiKey when the token carries the org-key prefix, skipping the personal-key lookup.
    • reliability, maintainability
  • packages/api/src/controllers/orgApiKey.ts:56 — The createOrgApiKey catch awaits an unguarded User.deleteOne compensating delete; if that delete also throws it masks the original create error and still orphans the service-account user.
    • Fix: Wrap the cleanup in its own try/catch that logs and rethrows the original error.
    • reliability, kieran-typescript
  • packages/api/src/controllers/user.ts:170 — The alert re-attribution Alert.updateMany(...) is duplicated between deleteTeamMember and deleteTeamMemberKeepingOne, inviting drift in how orphaned alerts are re-homed.
    • Fix: Extract a shared reassignAlerts(teamId, fromUserId, toUserId) helper used by both paths.
    • maintainability
  • packages/api/src/controllers/orgApiKey.ts:96 — Revoking keeps the service-account user and no endpoint ever deletes one, so repeated key rotation accumulates orphan User records indefinitely.
    • Fix: Add a reaper (or document the accepted growth) for service accounts whose OrgApiKey is gone and that own no alerts, and confirm no seat/billing query counts users without NOT_SERVICE_ACCOUNT.
    • adversarial
  • packages/api/src/routers/api/orgApiKeys.ts:37 — 403/404 paths use res.sendStatus (empty body) while sibling /api/team routes return { message } JSON, breaking uniform client error parsing.
    • Fix: Return res.status(...).json({ message }) on these error paths.
    • api-contract
  • packages/api/src/routers/api/orgApiKeys.ts:56 — GET returns { data: [...] } but POST/PATCH return bare objects, forcing per-endpoint unwrapping.
    • Fix: Settle on one response envelope for the resource across list and single-item endpoints.
    • api-contract
  • packages/app/src/components/TeamSettings/CreateOrgApiKeyModal.tsx:20 — EXPIRY_OPTIONS is untyped, so a mistyped value compiles and only throws at runtime inside the SegmentedControl onChange via OrgApiKeyExpirySchema.parse.
    • Fix: Annotate the array as { value: OrgApiKeyExpiry; label: string }[] to catch bad values at compile time.
    • kieran-typescript
  • packages/api/src/controllers/orgApiKey.ts:78 — updateOrgApiKeyDescription persists the description then updates the service-account name non-atomically; a failure on the second write returns 500 after the description already changed.
    • Fix: Make the service-account name update best-effort, or comment the intentional non-atomicity.
    • correctness
  • packages/app/src/components/TeamSettings/OrgApiKeysSection.tsx:82 — Rapid successive inline renames fire concurrent PATCHes with no sequencing or cancellation, so the server may persist the earlier value (UI self-heals on refetch).
    • Fix: Serialize per-row edits by chaining on the prior mutateAsync, or abort the in-flight request before starting the next.
    • frontend-races

Reviewers (11): correctness, security, adversarial, reliability, api-contract, kieran-typescript, frontend-races, testing, maintainability, project-standards, previous-comments.

Testing gaps:

  • No concurrency test interleaving a service-account keep-one removal with a human deleteTeamMember that empties the team.
  • restoreTeamMember retry-exhaustion and non-_id duplicate-key branches are untested (permanent-member-loss path).
  • The orgApiKeys router 403 guard (req.user.isServiceAccount / no team) and deserializeUser's service-account short-circuit are defensive-only and uncovered.

No finding blocks merge automatically. A maintainer will expect P0/P1 findings in code this PR changes to be fixed; P2/P3 are your call -- fix or reply. Never fix findings about surrounding code here; reply instead. Do not widen the PR. How to respond

- An org key removing members concurrently can no longer empty the team.
  A removal that leaves it empty puts the member back, credentials intact.
- Bearer auth responds with an error on a database failure instead of
  leaving the request hanging.
- Express.User no longer claims _id and email exist on org-key requests;
  session-only routes get them through getSessionUser.
- Failed last-used writes are counted and recorded on the span.
- The Expired badge updates while the page is open, failed description
  edits show an error, and the create dialog can't close mid-request.
@jordan-simonovski

Copy link
Copy Markdown
Contributor Author

Deep Review P2s fixed in 256d5e9:

  • auth.ts:149: Express.User now types _id and email as optional, so code reached by org-key requests has to narrow them. Session-only routes use a new getSessionUser helper.
  • auth.ts:139: validateUserAccessKey catches lookup errors and passes them to next(err), so a database failure returns 500 instead of hanging. There's a test for it.

The P3s that touch these lines are also fixed: the last-member race, and a 404 rather than 400 for a non-member. I've left the other P3s alone.

Each key now creates a service-account user and requests made with the key
authenticate as that user, so everything downstream treats it like any other
user and work done with a key is attributed to it. Service accounts can't sign
in, don't use a personal access key, and don't appear as team members.
Revoking a key keeps its service account so attribution survives.

This replaces the team-only principal, so the special handling for requests
with no user goes back to how main has it.
An org key's service account now checks that the target is a person in the
team and that someone else would remain before deleting anyone. The delete
then restore fallback only runs when two removals race, retries a failed
restore, and records a lost member on the trace.
@jordan-simonovski

Copy link
Copy Markdown
Contributor Author

Deep Review P2s on c28468e:

  • orgApiKey.ts:96 (service account kept after revoke): deliberate. The spec keeps the service account so anything it created still shows who made it. I've added a comment on deleteOrgApiKey saying so, and the controller tests assert it.
  • user.ts:181 (restore can fail): fixed in fec5151. Removal now checks membership and the person count before deleting, so only two removals racing past that check reach the restore. The restore retries with a delay, treats a duplicate _id as already restored, and stops at once on a duplicate email.
  • user.ts:183 (span): fixed in fec5151. Restore failures are recorded on the active span as well as counted and logged.

Comment thread packages/app/src/api.ts
},
});
},
useOrgApiKeys() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — useOrgApiKeys has no IS_LOCAL_MODE guard, so local mode always shows a load error

useMe, useTeam, useInstallation and useTags in this file all skip the request when IS_LOCAL_MODE is set. useOrgApiKeys doesn't, and OrgApiKeysSection is mounted with no local-mode check in TeamPage.tsx:171. In local mode (no API, including the Vercel preview the PR description mentions) the section always fetches team/api-keys, fails, shows "Couldn't load organization API keys" and offers a Create button that can't work. Fix: short-circuit the query in local mode (or gate the section on !IS_LOCAL_MODE) and hide the section there.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

</Table.Td>
<Table.Td>
{isExpired ? (
<Badge color="red" variant="light">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Expired badge uses a raw Mantine palette color instead of the themed badge used elsewhere

The conventions say to prefer semantic tokens or click-ui primitives (Badge is listed) over raw Mantine colors. The sibling sections TeamMembersSection.tsx and IacMigrationSection.tsx use click-ui <Badge text=… state=…>. Fix: render the Expired badge as <Badge text="Expired" state="danger" /> from @clickhouse/click-ui instead of <Badge color="red" variant="light">.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

Comment thread packages/api/src/middleware/auth.ts Outdated
_hdx_connection?: Connection;
// Set when the bearer token is an org API key; req.user is its service
// account.
orgApiKey?: { id: string; description: string; scope: OrgApiKeyScope };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — req.orgApiKey.description is set on every request but never read

Grepping packages/api/src finds req.orgApiKey read only for .scope (utils/orgApiKeyPolicy.ts:11) and .id (the business-context attributes). description is assigned in authenticateOrgApiKey but nothing reads it. Fix: drop description from the global Request.orgApiKey type and from the assignment.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

The team opted out of RBAC in this change, so a key's service account has the
same permissions as any other user. Removes the admin/member scope, the
admin-only gates on the external API team routes, and the policy module.
Key management stays session-only and refuses service accounts, and a key
still can't remove the last person in the team.
@jordan-simonovski jordan-simonovski changed the title feat: team-owned API keys managed by admins feat: team-owned API keys Oct 22, 2026
// Express 4 drops rejected promises from middleware, which would leave the
// request hanging on a database error.
try {
const user = await findUserByAccessKey(key);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Every org-key request makes a wasted personal-key lookup before the hash lookup

validateUserAccessKey always runs findUserByAccessKey(key) first, so every hdx_org_… bearer request does an uncached User.findOne({accessKey}) that can never match before it reaches findActiveOrgApiKey. That doubles the Mongo round-trips on the v2 and MCP hot path for org keys. Check the prefix first: if (key.startsWith(ORG_API_KEY_PREFIX)) return authenticateOrgApiKey(...), and only then fall back to findUserByAccessKey.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

const RESTORE_ATTEMPTS = 3;
const RESTORE_RETRY_DELAY_MS = 100;

const isDuplicateKey = (err: unknown): err is { keyPattern?: object } =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — isDuplicateKey re-implements the existing isDuplicateKeyError helper

packages/api/src/utils/errors.ts:62 already exports isDuplicateKeyError (Mongo code 11000). Import it and check keyPattern on the narrowed error, instead of adding a second local 11000 type guard. The repo's DRY rule is marked REQUIRED.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond


import CreateOrgApiKeyModal from './CreateOrgApiKeyModal';

const formatDate = (iso: string) => new Date(iso).toLocaleDateString();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Local formatDate ignores the user's UTC and clock preferences

new Date(iso).toLocaleDateString() always renders in browser-local time. Every other timestamp goes through useFormatTime/FormatTime (packages/app/src/useFormatTime.tsx, backed by formatDate in common-utils), which applies the user's isUTC setting. With UTC turned on, the created, last used and expiry dates here can be off by a day compared with the rest of the app. Use useFormatTime() with a date format, or extend it with a date-only format, instead of adding a new helper.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

});
}

// People can't remove themselves, so only a key can empty the team.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 major — Last-member guard only covers keys, but people can also empty the team

The comment says "People can't remove themselves", but the session route DELETE /team/member/:id (packages/api/src/routers/api/team.ts:284-311) has no self check and no last-member check. A last member who sends their own id is deleted. Two members with personal keys can also remove each other at the same time through this route's non-service-account branch. Either way, no person is left in the team while the org keys keep working, and those keys can only be revoked from a logged-in session, so nobody can revoke them. Move the last-member check into deleteTeamMember so every removal path gets it, or at least add it to the session route, and fix the comment.

Nothing blocks merge automatically, but a maintainer will expect this fixed if it is a real defect in code this PR changes. If it is about surrounding code, reply and say so instead of patching. Do not widen the PR. How to respond

// Express 4 drops rejected promises from middleware, which would leave the
// request hanging on a database error.
try {
const user = await findUserByAccessKey(key);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Org API keys pay for a wasted personal-access-key lookup on every request

Every hdx_org_ bearer token first runs findUserByAccessKey, which can never match it, before authenticateOrgApiKey runs. That adds a second uncached Mongo round trip to every org-key request (controllers/user.ts:23 already notes this path is hit uncached on every bearer request). Check key.startsWith(ORG_API_KEY_PREFIX) first and go straight to authenticateOrgApiKey when it matches.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

const RESTORE_ATTEMPTS = 3;
const RESTORE_RETRY_DELAY_MS = 100;

const isDuplicateKey = (err: unknown): err is { keyPattern?: object } =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — isDuplicateKey re-implements the existing isDuplicateKeyError

isDuplicateKeyError in packages/api/src/utils/errors.ts:62 already detects Mongo error code 11000 and is used by the webhook and source routes. Import it, or extend it to narrow the type to { keyPattern?: object }, instead of adding a second copy.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

<Table.Td>
{isExpired ? (
<Badge color="red" variant="light">
Expired

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — Expired badge uses a raw palette color instead of a semantic token

<Badge color="red" variant="light"> uses the raw Mantine palette for status, which the 'Semantic design tokens' convention rules out. Use a semantic danger token, or <Text variant="danger">Expired</Text>, so the badge follows the brand and light/dark theme.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

};

return (
<Modal opened={opened} onClose={close} title="Create API key" centered>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 minor — One stray click outside the modal throws away the key that is only shown once

While the new key is on screen, the Modal still closes on outside click and Escape. close() then clears createdKey, and the key can never be shown again. Pass closeOnClickOutside={createdKey == null} (and the same for closeOnEscape) so only the Done button dismisses the key.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

This branch was successfully deployed

2 active deployments
Preview – hyperdx-oss — 12fa9fd1 Deployed Oct 23, 2026 by vercel[bot]
Preview – hyperdx-storybook — 12fa9fd1 Deployed Oct 23, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review/tier-4 Critical — deep review + domain expert sign-off

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant