Skip to content

feat: NIP-IA identity archival (relay backend + desktop)#733

Merged
tlongwell-block merged 9 commits into
mainfrom
feat/nip-ia
May 23, 2026
Merged

feat: NIP-IA identity archival (relay backend + desktop)#733
tlongwell-block merged 9 commits into
mainfrom
feat/nip-ia

Conversation

@tlongwell-block
Copy link
Copy Markdown
Collaborator

Summary

Implements NIP-IA (Identity Archival) end-to-end: owners and relay admins can archive an identity, and the desktop surfaces an "Archived on this relay" flair plus an owner/admin-gated Archive button.

Archival is a relay-scoped UI-visibility hint — it is not a ban. It has no effect on channel membership, relay access, or repo abilities. Historical events stay valid and attributed. (Hiding archived identities from member/autocomplete surfaces is intentionally deferred; this PR ships the protocol machinery + the flair.)

What's included

Relay backend (full NIP-IA):

  • New kinds 9035/9036 (requests), 8002/8003 (relay-signed deltas), 13535 (relay-signed snapshot), mirroring the NIP-43 layout.
  • archived_identities table + Db facade (archive/unarchive/is_archived/list_archived), idempotent.
  • Request handler with three consent paths, precedence self → admin → owner:
    • self: actor == target.
    • admin: actor is relay owner/admin.
    • owner: NIP-OA owner-of-agent — verifies the request's auth tag and re-verifies against the target's live, latest kind:0, rejecting if it no longer attests to the request signer (anti-stale-credential).
  • Relay-signed 8002/8003 deltas (consent + e audit ref) and 13535 snapshot (latest-wins).
  • Ingest dispatch + per-kind scope (UsersWrite, since self/owner paths are open to ordinary users; real authorization is the consent check).

Desktop:

  • 9035/9036 builders (with allow_self_tagging so the self path keeps its p tag).
  • Tauri commands: archive_identity, unarchive_identity, list_archived_identities, resolve_oa_owner.
  • Profile pane: Archive/Unarchive button gated to (self || relay admin/owner || verified NIP-OA owner of the viewee); "Archived on this relay" flair from the relay's 13535.

Verification

  • sprout-core 165, sprout-relay 244, sprout-db 64, sprout-desktop 360 — all green.
  • Spec test vectors (NIP-OA auth tag, §Vector 1 9035 layout, gotcha feat: add desktop app #3 target-as-subject) pass.
  • Pre-push gate (fmt, clippy, builds, mobile/web/desktop checks) green.

Spec

docs/nips/NIP-IA.md. Built against the published spec; test vectors are the correctness oracle.

Pending follow-up (in flight)

One commit is still landing: an integration test for the anti-stale-credential property — archive via the owner path, flip the target's live kind:0 so it no longer attests to that owner, confirm a second request is rejected. The logic is implemented and reviewed (verify_owner_consent re-verifies against the live kind:0); this adds the red-if-broken regression guard for Tyler's core constraint. Opening now for review; will push the test before merge.

Defines the five NIP-IA kinds (9035/9036 requests, 8002/8003 deltas,
13535 snapshot) and is_identity_archive_request_kind, mirroring the
NIP-43 relay-membership layout. Contract base for the handler, DB, and
emission lanes.
Lane 6 of the NIP-IA owner+admin archival rollout (see docs/nips/NIP-IA.md).
The desktop side: a profile-pane Archive/Unarchive button gated to the
viewer's authority (self / relay admin/owner / verified NIP-OA owner of
the viewee), an "Archived on this relay" flair driven by the relay's
kind:13535 snapshot, and four new tauri commands for the wire work.

Two §Implementation Gotchas worth a regression test each:

1. Gotcha #3 (target-as-subject NIP-OA verification) is exercised by an
   exact-spec-vector test in commands/identity_archive.rs. If the
   preimage subject ever drifts from the agent pubkey to the request
   signer, that test fails loudly.

2. nostr 0.44's EventBuilder removes p-tags matching the signer's
   pubkey unless allow_self_tagging() is called. NIP-IA's self path
   (actor == target, vectors 4/5) requires ["p", target] exactly when
   target == signer; without the call, every self-archive/unarchive
   ships malformed and the relay correctly rejects. Builders carry
   the call + a dedicated self-path test guards the regression.

Files:
- desktop/src-tauri/src/events.rs:
    build_archive_identity_request (kind:9035) +
    build_unarchive_identity_request (kind:9036) +
    identity_archive_tags shared assembly + spec vector 1 layout test.
- desktop/src-tauri/src/commands/identity_archive.rs (new):
    resolve_oa_owner, archive_identity, unarchive_identity,
    list_archived_identities. The owner-of-agent path reads the
    target's live kind:0 and copies its verified auth tag onto the
    request; the relay independently re-verifies against the live
    kind:0 (per Tyler's anti-stale-credential rule) so the request's
    auth tag is intent+freshness evidence only.
- desktop/src-tauri/src/commands/mod.rs + lib.rs: module + tauri
    invoke_handler registration.
- desktop/src/shared/api/tauriIdentityArchive.ts (new):
    thin invokeTauri wrappers + frontend types (own file rather than
    inflating tauri.ts past its size budget).
- desktop/src/features/identity-archive/hooks.ts (new):
    React Query hooks (snapshot query, OA owner resolution,
    archive/unarchive mutations).
- desktop/src/features/profile/ui/UserProfilePanel.tsx:
    Archive/Unarchive button gated to (isSelf || relay admin/owner
    role || verified OA owner of viewee). "Archived" flair when the
    viewee is in the latest kind:13535. Hiding machinery deferred per
    kickoff scope.
- desktop/scripts/check-file-sizes.mjs: events.rs cap 610 → 810 to
    accommodate the new builders + tests (single-file convention
    matches the NIP-43 admin builders already in the file).

Tests added (all green via cargo test -p sprout-desktop):
- events::tests::archive_identity_request_matches_spec_vector_1_layout
- events::tests::archive_request_rejects_replaced_by_equal_target
- events::tests::unarchive_request_layout_self_path
- commands::identity_archive::tests::extract_oa_owner_returns_owner_for_valid_tag
- commands::identity_archive::tests::extract_oa_owner_ignores_kind0_without_auth_tag
- commands::identity_archive::tests::extract_oa_owner_matches_nip_ia_test_vector

Verification:
- cargo test -p sprout-desktop: 360 passed
- pnpm typecheck + pnpm check: clean

Follow-up that doesn't gate this lane: once Eva's lane-1 contract lands
on the shared branch tip, swap Kind::Custom(9035/9036) literals in
events.rs for the imported sprout_core::kind constants. ~2-line
regression-safe rename.

Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Wires handle_identity_archive_event into the ingest pipeline (kinds
9035/9036) in the validate-then-store position — the request processes
its consent/archival side effects, then falls through to normal storage
so the relay-signed delta's ["e", request_id] audit reference resolves.

Adds 9035/9036 to required_scope_for_kind as UsersWrite (not AdminUsers):
NIP-IA's self and owner-of-agent paths are open to ordinary users; real
authorization is the consent-path check in the handler.
@tlongwell-block tlongwell-block requested a review from a team as a code owner May 23, 2026 12:05
Guards the OR composition in UserProfilePanel.tsx —
`canArchive = isSelf || isRelayAdminOrOwner || isOaOwnerOfViewee` —
where unit tests cover each input in isolation but a refactor that
turns an OR into an AND, drops a branch, or expands role-membership
silently bypasses code review. This is the single composition test that
would catch all three.

Five cases, all green in the smoke project:
1. self viewer + self target → Archive visible, no flair
2. relay admin viewing Alice → Archive visible
3. verified NIP-OA owner viewing Alice → Archive visible
4. no authority viewing Alice → button hidden
5. Alice archived → flair + Unarchive (under admin gate)

Files:
- desktop/tests/e2e/identity-archive.spec.ts (new):
    Five focused tests using two open-pane helpers
    (`openSelfProfile`, `openAliceProfile`).
- desktop/tests/helpers/bridge.ts:
    Three typed `MockBridgeOptions` fields drive the gate inputs
    (`archivedIdentities`, `oaOwnerIsMe`, `relayRole`). No `as never`
    casts — Eva's review point.
- desktop/src/testing/e2eBridge.ts:
    - Mirror the three options in `E2eConfig.mock`.
    - Four tauri-command mocks: `resolve_oa_owner`,
      `list_archived_identities`, `archive_identity`,
      `unarchive_identity`.
    - `resetMockRelayMembers` honours `relayRole`: `null` removes
      the active identity entirely (admin gate evaluates false);
      `owner|admin|member` sets that role.
    - Second seed message in `#general` from Alice (display name
      "alice", pubkey 953d33...001f) so specs have a non-self profile
      to open by clicking the second message-row's author button.
      Both `general` seeds are backdated (-120s, -60s) so user-sent
      messages in other specs always land after — preserving
      `message-row.first()` = welcome and `.last()` = sent.
- desktop/playwright.config.ts:
    Add `identity-archive.spec.ts` to the smoke testMatch glob.

Verification:
- `pnpm exec playwright test --project=smoke`: 105 passed (was 105 before).
- `pnpm typecheck` + `pnpm check`: clean.

Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Review blockers:
- ingest: add 9035/9036 to is_global_only_kind so a stray h tag can't
  channel-scope a request whose handler mutates relay-global archive
  state. Mirrors the NIP-43 admin treatment. + regression test.
- desktop: ArchiveRequest/UnarchiveRequest need rename_all=camelCase —
  the frontend sends nested camelCase fields and serde does not rename
  nested struct fields from the Tauri top-level arg mapping, so the
  commands failed to deserialize at runtime (masked by the e2e mock).
  + deserialization regression test.
Review (Mari) flagged that list_archived_identities trusts the latest
kind:13535 without filtering by the relay's NIP-11 self key, which
NIP-IA §Client Behavior requires. Not a runtime gap on Sprout's own
relay (server-side enforcement; sibling kind:13534 is consumed the same
way; no NIP-11 self fetch exists yet). Replace the comment that wrongly
claimed the relay handles it with an honest deferral rationale.
@tlongwell-block tlongwell-block merged commit 79399d9 into main May 23, 2026
26 of 27 checks passed
@tlongwell-block tlongwell-block deleted the feat/nip-ia branch May 23, 2026 16:03
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.

1 participant