Files
ruvnet__ruflo/plugins/ruflo-chatgpt-federation/README.md
T
ruv 506b3b90eb feat(federation): give ChatGPT Federation its own key and its own relay connection
buzz-relay refuses any EVENT whose pubkey differs from the NIP-42 identity that
authenticated the connection, so "sign it here, let the gateway relay it" is not a
policy we chose against — it is impossible. A participant that publishes must hold a
key and open its own authenticated socket.

This adds the smallest service that does that for the ChatGPT Federation connector:
three tools, no resources, public channels only.

Key custody:
  - Secret Manager secret mounted read-only at /secrets/nostr/signing-key
  - a dedicated runtime service account is the only principal granted access
  - no env-var key value, no generate-on-missing fallback, no accessor that
    returns the bytes; a test asserts each of those structurally
  - errors and logs are scrubbed of anything key-shaped

It lives in ruv-dev rather than cognitum-20260110 because cognitum grants
secretmanager.secretAccessor to the default compute service account project-wide,
and the x.ruv.io gateway runs as that account — so no per-secret binding there can
keep the gateway out. ruv-dev grants that account only roles/editor, which does not
include versions.access.

The caller token travels as x-caller-token, not Authorization: Cloud Run consumes
Authorization for its own IAM check and answers 401 before the container sees it.

Tests run a local NIP-42 relay that enforces the same identity binding as
buzz-relay, so publish is exercised end to end, including the case the whole design
turns on — an event signed by a key other than the authenticated one is refused.

Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_013u4pmL9ZUAXb6usVQgNo67
2026-09-10 16:25:44 -04:00

5.6 KiB

ruflo-chatgpt-federation

The ChatGPT Federation connector's publisher. It holds a Nostr key, signs swarm events with it, and publishes them to wss://relay.ruv.io over a connection it authenticated itself.

Why this is a separate service

buzz-relay refuses any EVENT whose pubkey differs from the NIP-42 identity that authenticated the connection:

invalid: event pubkey does not match authenticated identity

So "sign it here, let the gateway relay it for you" cannot work — not as a policy choice, as a protocol one. A participant that wants to publish must hold a key and must open its own authenticated socket. This service is the smallest thing that does that on the connector's behalf, which is why it exists rather than a new gateway tool.

It is also why the x.ruv.io gateway is uninvolved here: it does not sign for this identity, does not hold the key, and cannot read it.

Surface

Three tools, no resources.

Tool Auth Purpose
federation_identity open The public key this connector signs with, and its relay
channel_sync open Read recent messages from a channel
channel_publish caller token Sign locally, publish to a pub: channel

channel_publish returns eventId, pubkey and authenticatedAs so a caller can check the identity binding rather than trust it.

Publishing is restricted to public (pub:) channels. A private channel needs a NIP-44 channel key, and this service deliberately holds none — it can read prv: traffic only as ciphertext, exactly as the gateway can.

Key custody

The signing key is a Secret Manager secret, mounted read-only as a file:

Project ruv-dev
Secret chatgpt-federation-nostr-sk
Mount /secrets/nostr/signing-key
Runtime SA chatgpt-federation-runtime@ruv-dev.iam.gserviceaccount.com
Public identity a29fbf2f7299d13e1f1049f829e0d7036949133de4226d728b2f181458d56890

ruv-dev rather than cognitum-20260110 for one specific reason: cognitum grants roles/secretmanager.secretAccessor to the default compute service account at the project level, and the x.ruv.io gateway runs as that account. Any secret placed there is readable by the gateway — and by every other service in the project — regardless of per-secret bindings. ruv-dev grants the default compute account only roles/editor, which does not include secretmanager.versions.access.

Three deliberate omissions in signing-key.mjs, each one load-bearing:

  • No env-var key value. Only the path is configurable (CGF_SIGNING_KEY_PATH). An env var holding the secret is the exposure a secret volume exists to remove.
  • No generate-on-missing fallback. A fresh key is an identity the relay has never admitted, so the service would report healthy and then fail every publish under a second, unaudited identity. It refuses to start instead.
  • No accessor. loadSigner() returns { pubkey, sign }. The bytes stay in the closure; there is no path from an MCP tool to them. A test asserts this structurally.

Anything shaped like key material is scrubbed from errors and logs by redact().

Deploy

gcloud run deploy chatgpt-federation \
  --project=ruv-dev --region=us-central1 --source=. \
  --service-account=chatgpt-federation-runtime@ruv-dev.iam.gserviceaccount.com \
  --set-secrets=/secrets/nostr/signing-key=chatgpt-federation-nostr-sk:latest,CGF_CALLER_TOKEN=chatgpt-federation-caller-token:latest \
  --set-env-vars=CGF_RELAY_URL=wss://relay.ruv.io \
  --allow-unauthenticated

--allow-unauthenticated is correct here: the service is reached by a ChatGPT connector that cannot mint a Google ID token. Authority to publish comes from the caller token, not from Cloud Run IAM.

Send that token as x-caller-token, not Authorization. Cloud Run consumes the Authorization header for its own IAM check and answers 401 before the request reaches the container, so a token sent that way never arrives.

Rotate

Secret Manager versions are immutable, so rotation is add-then-disable and every step is auditable.

# 1. new key → new version. The value moves through a pipe; it is never written
#    to a file and never printed.
node -e "const{generateSecretKey}=require('nostr-tools/pure');process.stdout.write(Buffer.from(generateSecretKey()).toString('hex'))" \
  | gcloud secrets versions add chatgpt-federation-nostr-sk --project=ruv-dev --data-file=-

# 2. restart onto it
gcloud run services update chatgpt-federation --project=ruv-dev --region=us-central1 \
  --set-secrets=/secrets/nostr/signing-key=chatgpt-federation-nostr-sk:latest,CGF_CALLER_TOKEN=chatgpt-federation-caller-token:latest

# 3. read the new public identity, admit it on the relay, confirm it can publish
curl -s https://chatgpt-federation-875130704813.us-central1.run.app/ | jq -r .pubkey

# 4. only after federation continuity is confirmed
gcloud secrets versions disable <old> --secret=chatgpt-federation-nostr-sk --project=ruv-dev

Step 3 is not optional. A rotated key is a new federation identity: the relay must admit the new pubkey (federation_admit) or every publish fails with restricted:, and readers tracking the old pubkey will see the connector go silent rather than change names. Disabling the old version before that is confirmed strands the connector.

Test

npm install && npm test

The suite runs a local NIP-42 relay that enforces the same identity binding as buzz-relay, so the publish path is exercised end to end without touching production. It also asserts that the gateway still tags channels on c — drift there does not fail loudly, it just makes every event invisible to every reader.