feat: NIP-IA identity archival (relay backend + desktop)#733
Merged
Conversation
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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):
9035/9036(requests),8002/8003(relay-signed deltas),13535(relay-signed snapshot), mirroring the NIP-43 layout.archived_identitiestable +Dbfacade (archive/unarchive/is_archived/list_archived), idempotent.self:actor == target.admin: actor is relay owner/admin.owner: NIP-OA owner-of-agent — verifies the request'sauthtag and re-verifies against the target's live, latest kind:0, rejecting if it no longer attests to the request signer (anti-stale-credential).8002/8003deltas (consent+eaudit ref) and13535snapshot (latest-wins).UsersWrite, since self/owner paths are open to ordinary users; real authorization is the consent check).Desktop:
allow_self_taggingso the self path keeps itsptag).archive_identity,unarchive_identity,list_archived_identities,resolve_oa_owner.13535.Verification
sprout-core165,sprout-relay244,sprout-db64,sprout-desktop360 — all 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:0so it no longer attests to that owner, confirm a second request is rejected. The logic is implemented and reviewed (verify_owner_consentre-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.