Future profiles

Relay or community admission

Use this when a relay, forum, chat, marketplace, or private community wants a threshold-backed admission signal without exposing which reviewers vouched for the applicant. nostr-veil supports the admission-gate building block today: a normal NIP-85 kind 30382 vouch for the applicant pubkey, plus an additive challenge/presentation handshake that proves the live applicant controls that pubkey. A complete anonymous-access system still needs session continuity, transport privacy, revocation, rate limiting, and abuse handling outside the NIP-85 assertion. Treat nostr-veil as the verifiable admission evidence, not as the whole relay policy engine.

Use this when a relay, forum, chat, marketplace, or private community wants a threshold-backed admission signal without exposing which reviewers vouched for the applicant. nostr-veil supports the admission-gate building block today: a normal NIP-85 kind 30382 vouch for the applicant pubkey, plus an additive challenge/presentation handshake that proves the live applicant controls that pubkey.

A complete anonymous-access system still needs session continuity, transport privacy, revocation, rate limiting, and abuse handling outside the NIP-85 assertion. Treat nostr-veil as the verifiable admission evidence, not as the whole relay policy engine.

Fit

  • Status: supported admission-gate building block today; full anonymous-access profile still future.
  • Current NIP-85 shape: kind 30382 user assertion for the candidate pubkey.
  • Subject: candidate account pubkey in d, mirrored in p.
  • Helper path: createAdmissionChallenge(), createAdmissionPresentation(), and verifyAdmissionRequest().
  • Proof version: v2 recommended because it binds the kind and subject hint to the proof context.
  • Useful metric today: rank as admission confidence, sponsor confidence, or policy completeness.

Subject design

  • The NIP-85 subject is the applicant pubkey. Use kind 30382, with d and p equal to that pubkey.
  • The admission challenge is separate from the NIP-85 event. It binds a single admission attempt to the applicant pubkey, the relay or community audience, an expiry, and a nonce.
  • The applicant presentation is also separate from the NIP-85 event. It is a Schnorr signature by the applicant pubkey over the challenge context.
  • The deployment bundle is signed operator policy. It says which admission circle, threshold, subject, freshness, and metric bounds the verifier accepts.
  • If the bundle is distributed over Nostr, carry it in a separate application event such as NIP-78 kind 30078. That transport event is not a NIP-85 assertion and must not be counted as the vouch.

What to publish

  • Publish the vouch as a signed NIP-85 kind 30382 event produced by aggregateContributions().
  • Keep the vouch tags canonical: d, p, metric tags such as rank, and veil-* proof tags. Do not add admission-* tags to the NIP-85 event.
  • Publish or distribute the signed deployment bundle from a trusted operator key. This can be HTTPS, a release artefact, operator config, or a NIP-78 kind 30078 application data event.
  • Do not publish the challenge as long-lived state. It is an admission attempt, so keep it short-lived and store only the replay cache or audit record needed by the relay.

Operator recipe

  1. Build an admission circle manifest with the reviewer pubkeys and the relay-community-admission profile id.
  2. Build a deployment policy for the applicant pubkey, requiring rank, proof v2, an accepted circle, fresh events, and valid Nostr signatures.
  3. Sign that policy with createSignedDeploymentBundle() and pin the bundle signer in relay config.
  4. When an applicant connects, create an admission challenge for relay:wss://your-relay.example.
  5. Ask the applicant to return createAdmissionPresentation(challenge, applicantPrivateKey), or the equivalent wallet/client signature.
  6. Fetch the applicant's signed kind 30382 vouch and the current signed bundle.
  7. Call verifyAdmissionRequest(vouch, bundle, challenge, presentation, { trustedPublishers, expectedAudience, usedChallengeIds, revokedApplicantPubkeys }).
  8. Act on the decision band: admit, rate-limit, manual-review, deny, or revoke.
const challenge = createAdmissionChallenge({
  applicantPubkey,
  audience: 'relay:wss://relay.example.com',
  maxAgeSeconds: 120,
})

const result = verifyAdmissionRequest(vouchFromRelay, signedBundle, challenge, presentation, {
  admitRank: 90,
  expectedAudience: 'relay:wss://relay.example.com',
  now: Math.floor(Date.now() / 1000),
  rateLimitRank: 75,
  revokedApplicantPubkeys,
  trustedPublishers: [operatorBundlePubkey],
  usedChallengeIds,
})

if (result.decision === 'admit') admit(applicantPubkey)
if (result.decision === 'rate-limit') admitWithLimits(applicantPubkey)
if (result.decision === 'manual-review') queueReview(applicantPubkey, result.errors)
if (result.decision === 'deny') rejectAdmission(applicantPubkey, result.errors)
if (result.decision === 'revoke') revokeSession(applicantPubkey)

Worked example for the vouch

examples/use-cases/relay-community-admission.ts
import {
  NIP85_KINDS,
  aggregateContributions,
  contributeAssertion,
  createTrustCircle,
} from 'nostr-veil'
import {
  defaultMembers,
  memberIndex,
  proofVersion,
  subjectPubkey,
  verifyUseCaseAssertion,
  withCreatedAt,
} from './_shared.js'

const slug = 'relay-community-admission'
const applicantPubkey = subjectPubkey
const circle = createTrustCircle(defaultMembers.map(member => member.pub))

const contributions = defaultMembers.map((member, index) =>
  contributeAssertion(
    circle,
    applicantPubkey,
    { rank: 91 + index },
    member.priv,
    memberIndex(circle, member.pub),
    { proofVersion },
  ),
)

export const assertion = withCreatedAt(aggregateContributions(
  circle,
  applicantPubkey,
  contributions,
  { proofVersion },
))

export const result = verifyUseCaseAssertion(slug, assertion, {
  kind: NIP85_KINDS.USER,
  subject: applicantPubkey,
  subjectTag: 'p',
  circleId: circle.circleId,
  minDistinctSigners: 3,
  freshAfter: assertion.created_at - 300,
})

Live relay proof

The public live smoke test publishes two signed events to wss://relay.trotters.cc:

  • a NIP-85 kind 30382 admission vouch whose tags stay byte-for-byte aligned with the canonical example;
  • a NIP-78 kind 30078 application data event that carries the signed deployment bundle for the verifier.

The test fetches both events back by id, verifies their Nostr signatures, parses the signed bundle, verifies the bundle signature and trusted publisher, then runs verifyAdmissionRequest() against the fetched vouch. Run it locally with:

npm run test:admission-gate:relay -- --dry-run
npm run test:admission-gate:relay -- --write docs/admission-gate-relay-check.json

What to verify

  • The vouch is NIP-85 kind 30382, with d and p equal to the applicant pubkey.
  • The vouch has strict syntax and a valid proof v2.
  • The vouch Nostr event signature is valid after relay fetch.
  • The admission circle is accepted by the signed deployment bundle and has enough distinct signers.
  • The rank metric is present, numeric, bounded, and has a documented decision meaning.
  • The deployment bundle is signed, unexpired, and signed by a trusted operator key.
  • The challenge is fresh, for the expected relay or community audience, and not in the replay cache.
  • The presentation signature verifies against the applicant pubkey.
  • The presentation applicant matches the vouch subject.
  • The applicant is not revoked, banned, or already rate-limited by local policy.

What this proves today

  • Enough distinct admission-circle members vouched for the applicant pubkey.
  • The public vouch does not reveal which circle members contributed.
  • The live applicant controlled the vouched pubkey for this challenge.
  • The relay can make an admission decision using signed policy instead of handwritten glue.

What this does not solve by itself

BoundaryWhat to do
The applicant pubkey is public in the kind 30382 vouch.Use this as pubkey-bound admission today. For anonymous admission, add a credential/session profile that does not reuse the public applicant pubkey as the access token.
IP address, timing, and relay metadata are outside the proof.Add transport privacy, batching, short logs, and metadata-minimising client flows.
A valid vouch is not a permanent right to enter.Keep bans, revocation, expiry, rate limits, and appeal policy in the relay/community system.
A signed bundle says what policy was configured, not that the policy is socially good.Govern circle membership, reviewer independence, conflicts, evidence retention, and incident response.
Replay protection is local state.Store used challenge ids until expiry and reject duplicates before applying the admission decision.

What not to claim

  • Do not claim nostr-veil currently implements complete anonymous relay access.
  • Do not claim the vouch hides the applicant pubkey; today's building block is a public assertion about that pubkey.
  • Do not claim a valid vouch overrides relay policy, bans, rate limits, moderation action, or abuse response.
  • Do not treat the NIP-78 kind 30078 bundle transport event as a NIP-85 assertion. It is just a signed carrier for operator policy in the live test.

Failure handling

  • Reject vouches for the wrong pubkey, wrong kind, stale proof, unknown circle, insufficient threshold, or invalid Nostr signature.
  • Reject bundles that are missing, expired, unsigned, tampered, or signed by an untrusted publisher.
  • Reject presentations for the wrong audience, expired challenges, replayed challenge ids, invalid applicant signatures, or a pubkey mismatch.
  • Move to manual review when the proof is valid but the score falls into the manual band.
  • Rate-limit instead of fully admitting when the score is enough to reduce friction but not enough for normal privileges.
  • Revoke or deny when the applicant is in local revocation, ban, or abuse state, even if the old vouch still verifies.

Operational controls

Risk to handleRequired control
Challenge replayShort challenge TTL, replay cache keyed by challenge id, and one decision per challenge.
Clock skewSmall tolerance in the relay, short bundle/assertion expiry, and monitoring for future-dated events.
Stale reviewersCircle manifests with expiry, supersession, and revoked circle ids.
Untrusted policy updatesPin trusted bundle publisher pubkeys and rotate with an explicit operator process.
Applicant compromiseRevocation list, re-admission rules, session invalidation, and appeal path.
Reviewer abuseCircle governance, independence rules, evidence retention, audit logs, and conflict handling.

Live admission gate test

The opt-in admission gate test publishes the normal NIP-85 kind 30382 vouch and a separate NIP-78 kind 30078 carrier for the signed deployment bundle to wss://relay.trotters.cc, fetches both back by id, and runs verifyAdmissionRequest() against the fetched material.

Passed 2026-05-18T08:55:39.000Z
Decision
admit at rank 92
Vouch
kind 30382 NIP-85 user assertion
Bundle
kind 30078 NIP-78 application data carrier
  • Local admission gate verifies before publishing
  • NIP-85 kind 30382 vouch was fetched from relay
  • Vouch Nostr signature verifies after relay fetch
  • Vouch tags stayed unchanged from the canonical example
  • NIP-78 kind 30078 bundle carrier was fetched from relay
  • Bundle carrier Nostr signature verifies
  • Signed deployment bundle signature verifies
  • Applicant challenge presentation verifies
  • verifyAdmissionRequest() admits the fetched material
vouch 4a288c5fceca...5dfd0d5b bundle 131b014e4982...32bc0634

Run the same check with npm run test:admission-gate:relay -- --write docs/admission-gate-relay-check.json.

NIP-85 kind reference

NIP-85 defines the assertion kind by the subject being scored. The kind number is part of the proof v2 context, so deployments should verify both the number and the subject hint tag.

30382 User assertion

Nostr pubkey subjects. Subject hint: p.

30383 Event assertion

Nostr event id subjects. Subject hint: e.

30384 Addressable event assertion

NIP-33 address subjects. Subject hint: a.

30385 NIP-73/external identifier assertion

packages, relays, domains, vendors, and other identifiers. Subject hint: k.

10040 Trusted service provider declaration

provider metadata, not a score assertion. Subject hint: provider tags.

Spec: NIP-85 trusted assertions.

Live relay test

The opt-in relay test signs this canonical example as real Nostr event data, publishes it to wss://relay.trotters.cc, fetches it back by id, and re-runs the application, syntax, Nostr signature, canonical tag, and proof checks.

Passed 2026-05-17T21:54:12.000Z
Events
1/1 fetched from relay
Proof
3/3 threshold from a 3-member ring
Run
tf7bic-3b3a5ae6d6
  • Canonical example passes locally
  • Relay stored and returned every signed event
  • Fetched Nostr event signatures are valid
  • Fetched tags match the canonical example
  • NIP-85 syntax validation passes
  • nostr-veil proof verification passes
  • Deployment profile verifier passes
d2a88581287f...eb171093

Run the same check with npm run test:use-cases:relay -- --write docs/use-case-relay-checks.json.

Safety checks

Each canonical use-case example is also exercised by an adversarial test harness. These are the failure modes a production verifier should reject before acting on the score.

Tampered metric

Published scores must still match the signed contribution aggregate.

Wrong subject

The d tag and subject hint must stay bound to the signed v2 proof.

Wrong kind

The assertion kind must match the profile and the signed v2 context.

Proof downgrade

New deployment profiles require proof v2.

Duplicate signer

Repeated key images must not increase the signer count.

Insufficient threshold

Removing a signature must fail the profile threshold.

Stale assertion

created_at must remain inside the freshness window.

Unknown circle

The circle ID must be accepted by deployment policy.

Unsigned policy

verifyProductionDeployment() should require a signed deployment bundle from a trusted publisher.

Relay mutation

Fetched event content and tags must match the Nostr signature.

Use validateUseCaseProfileDefinition() for custom profiles, then verifyProductionDeployment() with trusted bundle publishers, signed relay events, accepted circle manifests, expected subject, freshness, and threshold policy so these checks are not left to application glue. For application UI and audit logs, use verifyProductionDeploymentReport() or createProductionDecisionReport() so failures include issue codes, remediation text, a recommended action, pass/fail/not-checked status for the controls, and the profile's proofClaims, proofLimitations, requiredControls, and recommendedActions.

Next examples