Skip to content

feat(api): POST /trust/handshake — idempotent genie-host registration (D5 Group 1.1) - #556

Merged
namastex888 merged 1 commit into
devfrom
feat/genie-hosts-handshake-endpoint
Apr 29, 2026
Merged

namastex888 merged 1 commit into
devfrom
feat/genie-hosts-handshake-endpoint

Conversation

@namastex888

Copy link
Copy Markdown
Contributor

Summary

Group 1.1 of the omni-host-fingerprint-trust wish (D5 follow-up). Builds on the schema landed in #555 to expose the registration endpoint that genie omni handshake will call from the genie side (Group 2).

Endpoints

POST /api/v2/trust/handshake
  body:    { pubkey, hostname, capabilities? }
  201:     first registration → returns { data: newHost }
  200:     idempotent replay  → returns { data: existingHost }
  400:     malformed pubkey or empty hostname

GET  /api/v2/trust/hosts
  200:     { items: [host, ...] }   (active hosts only — revokedAt IS NULL)

Idempotency invariant

Re-registering the same pubkey returns the existing record unchanged. Hostname and capabilities are NOT mutated by handshake replays — operator-driven changes go through omni trust update / revoke (Group 1.2). This keeps the audit story clean: every change to a host's metadata is an explicit operator action, not a side-effect of a re-handshake.

Auth model

The endpoint inherits the existing bearer-token middleware. A valid omni_sk_… is required to register a new host. Once Group 4's verification middleware lands, signed requests from already-registered hosts can ALSO authenticate, but the first handshake always requires bearer auth — there's no other way to bootstrap trust for a brand-new host.

Validation

pubkey must look like a base64url-encoded 32-byte ed25519 key (43 chars unpadded or 44 chars padded; alphabet [A-Za-z0-9_-], optional =). The actual cryptographic validation (is this a valid curve point?) lives in Group 4's per-request verifier — a format gate here just keeps obvious garbage out of the table.

What changed

File Change
packages/api/src/services/genie-hosts.ts (new) GenieHostsService with register / findByPubkey / findById / listActive / touchLastSeen. No crypto — only stores already-validated keys. Signature verification lives in Group 4's middleware so the data path can be reviewed independently.
packages/api/src/services/index.ts Wire GenieHostsService into the Services container.
packages/api/src/routes/v2/trust.ts (new) POST /handshake (idempotent on pubkey) + GET /hosts (active list).
packages/api/src/routes/v2/index.ts Mount /trust under /api/v2.
packages/api/src/routes/v2/__tests__/trust-handshake.test.ts (new) 6 contract tests covering: malformed pubkey rejection (400), empty hostname rejection (400), first registration (201), idempotent replay returns existing record without mutation (200), padded base64url accepted, GET list shape.

Test plan

  • bun test packages/api/src/routes/v2/__tests__/trust-handshake.test.ts → 6/6 pass
  • make typecheck → green (FULL TURBO)
  • bunx biome check on touched files → clean
  • Pre-push gate (typecheck + lint + dead-code + 3815 tests) → all green

What's NOT in (subsequent PRs)

Group Scope Status
1.2 omni trust list / get / update / revoke CLI next PR
2 genie omni handshake (genie-side keygen) genie repo
3 genie request signing genie repo
4 omni signature verification middleware security-review gate
5 per-host scope enforcement this repo
6 --require-genie-signature per-instance opt-in this repo
7 brain entries + ADR genie-configure repo

Context

Tracked under the omni-host-fingerprint-trust wish. Predecessor: #555 (the foundation schema, landed in this repo). Wish doc lives in the genie repo at .genie/wishes/omni-host-fingerprint-trust/WISH.md (filed in genie #1520).

Group 1.1 of the omni-host-fingerprint-trust wish (D5 follow-up).
Builds on the schema landed in #555.

Endpoint
========
  POST /api/v2/trust/handshake
    body: { pubkey, hostname, capabilities? }
    201 + { data: host } on first registration
    200 + { data: existingHost } on idempotent replay

  GET  /api/v2/trust/hosts
    200 + { items: [host, ...] }    (active hosts only — revokedAt IS NULL)

Idempotent replay does NOT mutate hostname or capabilities. Operator-
driven changes go through `omni trust update / revoke` (Group 1.2).

Auth: inherits the existing bearer-token middleware. A valid omni_sk_…
is required to register a new host. Once Group 4's verification
middleware lands, signed requests from already-registered hosts can
ALSO authenticate, but the FIRST handshake always requires bearer auth
— there's no other way to bootstrap trust for a brand-new host.

Validation: pubkey must look like a base64url-encoded 32-byte ed25519
key (43 chars unpadded or 44 chars padded; alphabet `[A-Za-z0-9_-]`,
optional `=`). The actual cryptographic validation (is this a valid
curve point?) lives in Group 4's per-request verifier — a format gate
here just keeps obvious garbage out of the table.

What changed
============
  packages/api/src/services/genie-hosts.ts (new)
    + GenieHostsService — register / findByPubkey / findById /
      listActive / touchLastSeen.
    + No crypto in this service — only stores already-validated keys.
      Signature verification lives in Group 4's middleware so the
      data path can be reviewed independently.

  packages/api/src/services/index.ts
    + Wire GenieHostsService into the Services container.

  packages/api/src/routes/v2/trust.ts (new)
    + POST /handshake (idempotent on pubkey).
    + GET  /hosts     (active list).

  packages/api/src/routes/v2/index.ts
    + Mount /trust under /api/v2.

  packages/api/src/routes/v2/__tests__/trust-handshake.test.ts (new)
    + 6 contract tests covering: malformed pubkey rejection (400),
      empty hostname rejection (400), first registration (201),
      idempotent replay returns existing record without mutation (200),
      padded base64url accepted, GET /trust/hosts list shape.

What's NOT in (subsequent PRs)
==============================
  - omni trust list/get/update/revoke CLI            → Group 1.2
  - genie omni handshake command (genie-side keygen) → Group 2
  - genie request signing                             → Group 3
  - omni signature verification middleware            → Group 4 (security review)
  - per-host scope enforcement                        → Group 5
  - --require-genie-signature per-instance opt-in     → Group 6
  - docs + brain                                      → Group 7

Tests: 6/6 in trust-handshake.test.ts; typecheck FULL TURBO; biome clean.
@coderabbitai

coderabbitai Bot commented Apr 29, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c52aec47-981d-4c42-94e8-cf92eb0f2c20

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/genie-hosts-handshake-endpoint

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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 201fd879e9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

v2Routes.route('/keys', keysRoutes); // API key management
v2Routes.route('/context', contextRoutes); // Conversation context for turn-based agents
v2Routes.route('/turns', turnsRoutes); // Turn lifecycle for turn-based agents
v2Routes.route('/trust', trustRoutes); // Genie host fingerprint trust (omni-host-fingerprint-trust wish)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Add scope-map entries for new trust routes

Mounting /trust here introduces new protected endpoints, but this commit does not add corresponding entries in SCOPE_MAP (packages/api/src/constants/scopes.ts), and that middleware is explicitly deny-by-default for unmapped routes. In environments using scoped keys (anything without *), both POST /api/v2/trust/handshake and GET /api/v2/trust/hosts will return 403, making the new feature unreachable despite valid bearer auth.

Useful? React with 👍 / 👎.

Comment on lines +46 to +53
const existing = await this.findByPubkey(input.pubkey);
if (existing) {
log.info('genie host handshake — idempotent reuse', { hostId: existing.id, hostname: existing.hostname });
return existing;
}

const [created] = await this.db
.insert(genieHosts)

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 Badge Make host registration idempotent under concurrency

This read-then-insert sequence is not atomic: two simultaneous handshakes with the same pubkey can both observe no existing row, then race on insert, causing one request to hit the unique constraint and fail (mapped to 409) instead of returning the existing host. That violates the endpoint’s idempotency contract in concurrent first-registration scenarios; use an atomic upsert/conflict path and then fetch the winner row.

Useful? React with 👍 / 👎.

@gemini-code-assist gemini-code-assist Bot left a comment

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.

Code Review

This pull request introduces the GenieHostsService and associated routes to handle host fingerprint registration and trust management. The implementation includes an idempotent handshake endpoint and a service layer for managing host records. Feedback focuses on ensuring true idempotency by normalizing public keys (removing base64 padding) and addressing a potential race condition in the registration logic where concurrent requests could cause unique constraint violations.

const log = createLogger('genie-hosts');

export interface RegisterHostInput {
/** ed25519 public key in base64url. Must be 44 chars (non-padded base64url of 32 bytes). */

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.

medium

The comment contains a contradiction: 32 bytes of data encoded in base64url results in 43 characters when unpadded. A 44-character string would include a padding character (=). Since the service should ideally store a canonical (unpadded) form to ensure idempotency regardless of how the client handles padding, this comment should be updated to reflect the correct length.

Suggested change
/** ed25519 public key in base64url. Must be 44 chars (non-padded base64url of 32 bytes). */
/** ed25519 public key in base64url. Normalized to 43 chars (unpadded base64url of 32 bytes). */

Comment on lines +45 to +67
async register(input: RegisterHostInput): Promise<GenieHost> {
const existing = await this.findByPubkey(input.pubkey);
if (existing) {
log.info('genie host handshake — idempotent reuse', { hostId: existing.id, hostname: existing.hostname });
return existing;
}

const [created] = await this.db
.insert(genieHosts)
.values({
pubkey: input.pubkey,
hostname: input.hostname,
capabilities: input.capabilities ?? {},
})
.returning();

if (!created) {
throw new Error('failed to insert genie host');
}

log.info('genie host registered', { hostId: created.id, hostname: created.hostname });
return created;
}

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.

medium

The register method has two issues:

  1. Race Condition: It uses a "check-then-insert" pattern. If two concurrent requests for the same pubkey arrive, both might pass the initial check and attempt to insert, resulting in a unique constraint violation (500 error) for one of them.
  2. Normalization: It doesn't normalize the pubkey before insertion. Since the API allows both padded and unpadded base64url strings, they would be treated as different keys by the database, breaking the idempotency invariant.

Wrapping the insert in a try-catch to handle the race and normalizing the key ensures true idempotency. The redundant findByPubkey check at the start can also be removed since the route already performs this check for status code branching.

  async register(input: RegisterHostInput): Promise<GenieHost> {
    const pubkey = input.pubkey.replace(/=/g, '');
    try {
      const [created] = await this.db
        .insert(genieHosts)
        .values({
          pubkey,
          hostname: input.hostname,
          capabilities: input.capabilities ?? {},
        })
        .returning();

      if (!created) {
        throw new Error('failed to insert genie host');
      }

      log.info('genie host registered', { hostId: created.id, hostname: created.hostname });
      return created;
    } catch (err) {
      const existing = await this.findByPubkey(pubkey);
      if (existing) {
        log.info('genie host handshake — idempotent reuse (race)', { hostId: existing.id, hostname: existing.hostname });
        return existing;
      }
      throw err;
    }
  }

Comment on lines +70 to +73
async findByPubkey(pubkey: string): Promise<GenieHost | null> {
const [row] = await this.db.select().from(genieHosts).where(eq(genieHosts.pubkey, pubkey)).limit(1);
return row ?? null;
}

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.

medium

The pubkey should be normalized (e.g., by removing base64 padding) before performing the lookup. This ensures that clients providing the same key with different padding styles are correctly identified as the same host.

Suggested change
async findByPubkey(pubkey: string): Promise<GenieHost | null> {
const [row] = await this.db.select().from(genieHosts).where(eq(genieHosts.pubkey, pubkey)).limit(1);
return row ?? null;
}
async findByPubkey(pubkey: string): Promise<GenieHost | null> {
const normalized = pubkey.replace(/=/g, '');
const [row] = await this.db.select().from(genieHosts).where(eq(genieHosts.pubkey, normalized)).limit(1);
return row ?? null;
}

@namastex888
namastex888 merged commit 75d9da3 into dev Apr 29, 2026
10 checks passed
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.

2 participants