v0.5 — Audiences runbook

Every /v0/audience/* endpoint, every MCP tool, every CLI verb. Setup, invite flows, member rotation, and troubleshooting.

4A v0.5 — Audiences runbook

Audience: Operators of an api.4a4.ai deployment (or anyone running the gateway against their own Cloudflare account).

Scope: Setup, the seven /v0/audience/* routes, the inbox endpoint, the NIP-05 fa extension, the eight MCP tools, the 4a audience CLI verbs, and troubleshooting.

Status: Ready-to-run for v0.5. The §10 worked-example walkthrough is ready-to-run, needs Evan to execute — this build session does not have outbound websocket access to the relay set, so the live-relay round-trip is deferred to the operator.


1. Setup

1.1 Prerequisites

  • Node ≥ 20, Cloudflare Wrangler ≥ 4.85, an AWS account with a KMS HMAC key for derivation (the same one Phase 2 already uses for nostr_priv derivation).
  • A 4A bearer JWT in $FOUR_A_JWT. Obtain one at https://api.4a4.ai/auth/github/start. The CLI and curl examples below all require Authorization: Bearer $FOUR_A_JWT.
  • For the worked example: one Cloudflare Pages project routed to claim.4a4.ai (DNS pointing at <project>.pages.dev via CNAME).

1.2 Sign in

The custodial OAuth path Phase 2 already ships covers v0.5 unchanged. Visit https://4a4.ai, click Continue with GitHub, and the gateway:

  1. Receives the OAuth callback.
  2. Constructs oauth_id_string = "github:<id>", runs KMS.GenerateMac(MacAlgorithm=HMAC_SHA_256) to derive 32 bytes, clamps to a valid secp256k1 scalar, computes the schnorr pubkey.
  3. Mints a 24-hour HS256 JWT carrying {sub, pub} and sets it as a cookie.
  4. Discards the priv from memory.

Use that JWT verbatim as $FOUR_A_JWT for everything below.

1.3 The two key types

Audience identity (aud_id) and per-epoch (aud_epoch_n) keypairs are audience-scoped, not user-scoped — they're not KMS-derived. The gateway generates them inside /v0/audience/create and /v0/audience/rotate and returns them in the response body. The default behavior is non-custodial: the gateway does NOT persist these privs. Operator stores them; clients pass them back on subsequent calls.

A future revision may add ?delegate=true to opt into gateway-side custody (see PLAN-v0.5 §6 Q1) — not in v0.5.


2. Creating an audience

2.1 curl

curl -sS -X POST https://api.4a4.ai/v0/audience/create \
  -H "Authorization: Bearer $FOUR_A_JWT" \
  -H "Content-Type: application/json" \
  -d '{
    "slug": "team-design",
    "name": "team-design",
    "description": "Design notes shared with Allison."
  }'

Successful response:

{
  "ok": true,
  "audience_address": "30520:<aud_id_pub>:team-design",
  "aud_id_pub": "<32-byte hex>",
  "aud_id_priv": "<32-byte hex>",
  "epoch": 1,
  "aud_epoch_pub": "<32-byte hex>",
  "aud_epoch_priv": "<32-byte hex>",
  "declaration_event_id": "<64-byte hex>",
  "founding_grant_event_id": "<64-byte hex>",
  "founder_pubkey": "<your custodial nostr_pub>",
  "founder_npub": "npub1...",
  "relay_acks": { "declaration": [...], "founding_grant": [...] }
}

Save aud_id_priv and aud_epoch_priv somewhere safe. Lose them and the audience is unrecoverable.

2.2 CLI

4a audience create --slug team-design --name team-design \
  --description "Design notes shared with Allison."

2.3 MCP

// MCP tools/call payload
{
  "name": "audience_create",
  "arguments": {
    "slug": "team-design",
    "name": "team-design",
    "description": "Design notes shared with Allison."
  }
}

3. Inviting (single-paste UX, four input types)

/v0/audience/invite only handles the email + blank cases — when identity isn't already known. The handle and npub cases route through /v0/audience/grant directly (no claim flow).

3.1 Email or blank → claimable URL

curl -sS -X POST https://api.4a4.ai/v0/audience/invite \
  -H "Authorization: Bearer $FOUR_A_JWT" \
  -H "Content-Type: application/json" \
  -d "{
    \"audience_address\": \"$AUDIENCE_ADDRESS\",
    \"aud_id_priv\": \"$AUD_ID_PRIV\",
    \"ttl_seconds\": 604800
  }"

Returns:

{
  "ok": true,
  "four_a_url": "4a://invite/team-design/1?k=4ainv1...",
  "https_url": "https://claim.4a4.ai/invite/team-design/1?k=4ainv1...",
  "invite_pub": "<32-byte hex>",
  "invite_priv_4ainv": "4ainv1...",
  "expires_at": 1893456000,
  "declaration_event_id": "<64-byte hex>"
}

Paste either URL into email/Slack/iMessage. The 4a:// form opens any local 4A client that registered the scheme; the HTTPS form falls back to the static claim page at claim.4a4.ai.

3.2 Handle or npub → direct grant

# Handle: GET https://<host>/.well-known/nostr.json?name=<name> first to
# resolve the pubkey, then:
curl -sS -X POST https://api.4a4.ai/v0/audience/grant \
  -H "Authorization: Bearer $FOUR_A_JWT" \
  -H "Content-Type: application/json" \
  -d "{
    \"audience_address\": \"$AUDIENCE_ADDRESS\",
    \"aud_id_priv\": \"$AUD_ID_PRIV\",
    \"aud_epoch_priv\": \"$AUD_EPOCH_PRIV\",
    \"recipient_pubkey\": \"$RECIPIENT_PUB\"
  }"

/grant issues a kind:30521 directly to the recipient (NIP-44-encrypted from your KMS-derived identity to the recipient pub) and republishes the declaration with the new member added. No epoch rotation — adding members exposes no historical content per PLAN-v0.5 §6 Q2 default.

3.3 CLI

4a audience invite --address $AUDIENCE_ADDRESS --aud-id-priv $AUD_ID_PRIV
4a audience grant  --address $AUDIENCE_ADDRESS --aud-id-priv $AUD_ID_PRIV \
                   --aud-epoch-priv $AUD_EPOCH_PRIV --recipient $RECIPIENT_PUB

4. Publishing

curl -sS -X POST https://api.4a4.ai/v0/audience/publish \
  -H "Authorization: Bearer $FOUR_A_JWT" \
  -H "Content-Type: application/json" \
  -d "{
    \"audience_address\": \"$AUDIENCE_ADDRESS\",
    \"aud_epoch_pub\": \"$AUD_EPOCH_PUB\",
    \"kind\": 30510,
    \"d_tag\": \"team-design-css-reset\",
    \"alt\": \"encrypted Observation in team-design\",
    \"payload\": {
      \"@context\": \"https://4a4.ai/ns/v0\",
      \"@type\": \"Observation\",
      \"observationAbout\": { \"@id\": \"https://example.org/css-resets\" },
      \"measuredProperty\": \"css-trick\",
      \"value\": \"Saw a slick CSS reset that drops margin: 0 on body…\"
    }
  }"

The route:

  1. NIP-44-encrypts payload (JSON-stringified) to aud_epoch_pub.
  2. Builds the kind:30510-30514 rumor with a, fa:epoch, one p per current member, and blake3 of the ciphertext.
  3. For each current member, builds a NIP-17 seal + gift-wrap (fresh ephemeral key per recipient), publishes the wrap.
  4. Returns the rumor event id and per-recipient gift-wrap acks.

Publisher MUST be a current member of the audience (verified server-side).

4.1 Five payload kinds

kind Inner payload type Public counterpart
30510 Observation 30500
30511 Claim 30501
30512 Entity 30502
30513 Relation 30503
30514 Commons 30504

The inner payload shape is the v0 JSON-LD shape unchanged — see SPEC.md.


5. Reading (the inbox)

curl -sS "https://api.4a4.ai/v0/audience/team-design/inbox?limit=50" \
  -H "Authorization: Bearer $FOUR_A_JWT"

Returns:

{
  "ok": true,
  "audience_slug": "team-design",
  "reader_pubkey": "<your nostr_pub>",
  "since": null, "limit": 50,
  "items": [
    {
      "event_id": "<rumor id>", "kind": 30510,
      "audience_slug": "team-design", "epoch": 2,
      "publisher_pubkey": "<publisher pub>",
      "created_at": 1777344420,
      "payload": { ...decrypted JSON-LD... },
      "d_tag": "team-design-css-reset"
    }
  ]
}

The route runs the §2.5 capability-based decryption pipeline entirely inside the per-request KMS-derivation window — your priv lives only on the request stack and is zeroed at the end. No keys persist.

5.1 Local-client alternative

If you'd rather not delegate decryption to the gateway, skip the inbox endpoint. Subscribe to kinds:[1059], #p:[<your-pub>] on your relay set, NIP-17-unwrap, look up the matching kind:30521, decrypt locally. The Sonata plugin and the 4a CLI both ship this code path.

4a audience inbox --slug team-design --limit 50

6. Rotating

curl -sS -X POST https://api.4a4.ai/v0/audience/rotate \
  -H "Authorization: Bearer $FOUR_A_JWT" \
  -H "Content-Type: application/json" \
  -d "{
    \"audience_address\": \"$AUDIENCE_ADDRESS\",
    \"aud_id_priv\": \"$AUD_ID_PRIV\",
    \"add_members\": [],
    \"remove_members\": [\"$EX_MEMBER_PUB\"],
    \"remove_pending\": []
  }"

Response includes the new epoch, the new aud_epoch_pub + aud_epoch_priv, the rebuilt declaration event id, and a per-recipient grant manifest.

When to rotate: on member removal (so the ex-member can't read new content) and periodically as a hygiene refresh (PLAN-v0.5 §6 Q2 default). Rotation on member add is unnecessary — adding a member exposes no historical content because they only get the current epoch key, not past keys.

6.1 Polled claim-watcher

When inviters can't run a live relay subscription, poll instead:

4a audience process-claims --address $AUDIENCE_ADDRESS --aud-id-priv $AUD_ID_PRIV

This scans the audience's pending invites for matching kind:30522 events. On match it triggers a rotation that adds the claim_pubkey as a member and drops the matched pending entries. Idempotent — the pending entry is gone after rotation, so re-polling is a no-op.

A reasonable cadence: every 5 minutes via cron, or every load of the audience UI.


7. The NIP-05 directory + fa extension

https://4a4.ai/.well-known/nostr.json serves the standard NIP-05 shape (names, relays) plus the optional fa extension keyed by pubkey to {audiences, context}.

The directory body is supplied via the NOSTR_DIRECTORY_JSON env var:

wrangler secret put NOSTR_DIRECTORY_JSON --config gateway/wrangler.toml
# paste a JSON object matching the §7.2 schema, then enter

?name=<name> filters to a single name's entries per NIP-05 conventions.

CI test (gateway/src/__tests__/well-known.test.ts) covers every §7.4 invariant:

  • every fa key appears in names
  • every audiences entry is a valid slug
  • every fa.context equals https://4a4.ai/ns/v0

8. The claim page

distribution/claim/index.html is the static Cloudflare Pages site for claim.4a4.ai. Single-page, vanilla JS, no framework.

8.1 Deploy

# From the 4A repo root, with wrangler authenticated against the 4A account:
wrangler pages deploy distribution/claim --project-name claim-4a-ai
# Then in Cloudflare DNS, add a CNAME record:
#   claim → claim-4a-ai.pages.dev

The page parses 4a://invite/<slug>/<epoch>?k=4ainv1… URLs (in HTTPS form), runs OAuth through api.4a4.ai/auth/github/start?return_to=…, then on callback POSTs /v0/audience/claim and shows the result. The copy reinforces SPEC-v0.5 §7.3 — this is one host of convenience, not a privileged authority.


9. Troubleshooting

Symptom Likely cause Fix
/audience/create 401 JWT expired Re-run OAuth at /auth/github/start
/audience/invite 401 with "aud_id_priv does not match audience_address" Wrong priv passed Ensure aud_id_priv is the value /audience/create returned for this slug
/audience/publish 403 with "publisher is not a current member" Caller's KMS-derived pub isn't on the audience's roster Run /audience/grant first to add the caller as a member
/audience/publish 404 with "audience declaration not found in relay cache" The DO doesn't have the declaration cached Republish the declaration via /audience/rotate (or wait for the next sweep)
Inbox returns no items Same-instance cache is the only delivery in v0.5 The publisher must be the same gateway instance that owns the reader's relay-pool DO; cross-instance subscription is a t15 follow-up
Claim page hangs after OAuth ?inviter and ?address query params not present in the return URL The inviter's UI must embed both in the invite link copy that reaches the invitee
Gift-wrap unwrap fails locally Reader doesn't hold the matching kind:30521 for the rumor's (audience, epoch) Ask the inviter to re-issue the grant for your current epoch

10. The §5 worked example (ready-to-run, needs operator to execute)

docs/examples/v0.5/ carries ten deterministically-generated JSON fixtures matching v0.5-design.md §5.5's table. The fixtures were built by scripts/build-v0.5-fixtures.mjs using deterministic seed pubkeys; bytes are identical across runs.

To exercise the round-trip against a live 4a4.ai relay:

# 1. Build & deploy the gateway with the v0.5 changes.
npm install
npm run deploy   # wrangler deploy gateway/wrangler.toml

# 2. Sign in as Evan, capture $JWT_EVAN.
open https://api.4a4.ai/auth/github/start

# 3. Create the audience.
4a audience create --slug team-design --name team-design \
  --description "Design notes shared with Allison."
# stash audience_address, aud_id_priv, aud_epoch_priv

# 4. Invite Allison.
4a audience invite --address $AUDIENCE --aud-id-priv $AUD_ID_PRIV
# email the https_url to allison@enginable.com

# 5. Allison claims via claim.4a4.ai → POST /v0/audience/claim happens in the
#    page; the claim event lands on the relays.

# 6. Process the claim → rotate to epoch 2.
4a audience process-claims --address $AUDIENCE --aud-id-priv $AUD_ID_PRIV
# stash the new aud_epoch_priv from the rotation response.

# 7. Publish an Observation.
4a audience publish --address $AUDIENCE --aud-epoch-pub $AUD_EPOCH_PUB \
  --kind 30510 --d "team-design-css-reset" --alt "encrypted Observation" \
  --payload '{"@context":"https://4a4.ai/ns/v0","@type":"Observation",
              "observationAbout":{"@id":"https://example.org/css-resets"},
              "measuredProperty":"css-trick",
              "value":"Saw a slick CSS reset…"}'

# 8. Read it back as Allison.
4a audience inbox --slug team-design --limit 10

After the run, capture the ten on-the-wire events (declaration v1, founding grant, declaration v1', claim, declaration v2, two epoch-2 grants, encrypted-variant rumor, two gift-wraps) and diff their JSON shapes against docs/examples/v0.5/. Differences in created_at, id, sig, and NIP-44 nonces are expected; the tag/content shape MUST match.


11. Security model

  • Capability-based access. Anyone holding aud_epoch_n_priv can decrypt audience-addressed events from epoch n. There is no relay-level access control; the relay only sees opaque ciphertexts.
  • Lose-OAuth-lose-access. Custodial readers' identity privs are deterministically re-derived from (provider, oauth_id) via KMS; lose the OAuth account, lose the ability to re-derive. v0.5 ships no rotation. NIP-46 (bunker) is the escape hatch — bring your own signer.
  • Forward secrecy. None at the protocol layer in v0.5. Removed members forget the new epoch's priv but still hold the old one; old ciphertexts remain decryptable to anyone who held the old key. NIP-104 / MLS migration (v1) addresses this.
  • Metadata privacy. NIP-17 gift-wrapping conceals the audience-membership graph from relay operators. The §4 MUST is load-bearing; bare kind:30510-30514 events MUST NOT be published.

12. Companion docs