From 0585b5d0a5effa3b4c6071ec2737d2a16510d95d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 13:26:14 +0000 Subject: [PATCH 01/19] docs(quartz): draft a link vocabulary for what each kind's references mean The hint providers name the ids an event links but not what each link means, so every consumer re-derives it per kind (Amethyst's feed filters, and the Neo4j graph projection's RoleTable and LinkRules). This plan proposes an open Relation vocabulary with Link and LinkProvider types in nip01Core/links/. It covers conversation, reactions and zaps, moderation, lists, badges, NIP-85 trust and more. A REFERENCES default derived from the hint providers means no link disappears before its kind is classified. Draft for review; nothing is implemented. Also records a kind clash found on the way: TextNoteModificationEvent and GoodWikiRelayListEvent both claim kind 1010. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pu8Fpp4KbXaiax8YTYxhJm --- .../plans/2026-09-29-graph-link-vocabulary.md | 249 ++++++++++++++++++ 1 file changed, 249 insertions(+) create mode 100644 quartz/plans/2026-09-29-graph-link-vocabulary.md diff --git a/quartz/plans/2026-09-29-graph-link-vocabulary.md b/quartz/plans/2026-09-29-graph-link-vocabulary.md new file mode 100644 index 0000000000..cb05b813f6 --- /dev/null +++ b/quartz/plans/2026-09-29-graph-link-vocabulary.md @@ -0,0 +1,249 @@ +# A link vocabulary: what each kind's references MEAN + +Status: **draft for review** (2026-09-29). Nothing is implemented yet; the names below are the +thing to review. + +## Why + +Quartz already knows which values in an event are references: the hint providers +(`EventHintProvider`, `PubKeyHintProvider`, `AddressHintProvider`) name the linked ids, kind by +kind. They do not say what a link MEANS. They return `List`, so a consumer cannot tell a +reply's parent from its thread root, a reaction's target from a `p` it notifies, or a report +about a person from a report about their note. + +Every consumer that needs the meaning re-derives it: +- Amethyst, in its feed filters; +- neo4j-eventstore, the graph projection of the relay's store. Its `RoleTable`, `LinkRules` and + report extractors are per-kind interpretation written one repository downstream of the kinds. + +That graph named its relationships mechanically, `_` (`p_3`, `e_1111`, `z_39999`). The +names are complete without curation, but they push the per-kind knowledge onto every query +author: to find a comment's parent you must know kind 1111 puts it in `e`, and a newer kind may +put an address in `z` or `c`. The knowledge is needed either way. It belongs next to the kind, +written once, where the tags are already parsed — the pattern `SearchFieldExtractor` / +`IndexableFields` already follows for search. + +## The model + +A **link** is one statement an event makes about something else: + +```kotlin +@JvmInline value class Relation(val name: String) // an open vocabulary: constants below, extensible + +sealed interface LinkTarget { + data class Event(val id: HexKey) : LinkTarget + data class User(val pubkey: HexKey) : LinkTarget + data class Address(val value: String) : LinkTarget // kind:pubkey:d + data class Tag(val name: String, val value: String) : LinkTarget // a topic, url, label value… +} + +data class Link( + val relation: Relation, + val target: LinkTarget, + val via: String? = null, // "content" for a nostr: URI in the text, else the tag name + val props: Map? = null, // relation-specific values (a report's type, a rank) +) + +interface LinkProvider { fun links(): List } +``` + +Rules the vocabulary follows: + +1. **Every link starts at the event that makes the statement.** The event is the provenance: its + author, its time, and the version that superseded it all hang off it. The one exception is + `OWNED_BY` (address → user), which no event states. +2. **One relation per action, across kinds.** `REPLIES_TO` is a kind 1 reply, a NIP-22 comment, a + git reply and a chat reply. The source event's `kind` says which; a query that cares filters + on it (`(c:Event {kind: 1111})-[:REPLIES_TO]->(x)`). Kinds are not repeated in the name. +3. **An action points at the content AND at the person it is about.** A reaction + `REACTS_TO` the note and `REACTS_TO` its author; the target's type (event, address, user) + tells them apart. "Reactions to my notes" and "reactions naming me" are both one hop. +4. **Split a relation when queries separate its meanings on the same target type.** Counting a + relation per node is constant-time in Neo4j, but filtering on a property reads every edge. + So a distinction that is filtered all the time becomes two relations: `REPORTS_USER` (a + complaint about the person) is not `REPORTS_AUTHOR` (the author of reported content), and + `FOLLOWS` (the kind 3 social graph) is not `SUBSCRIBES_TO` (every other follow-like list). +5. **Nothing is invisible before it is classified.** A class that implements no `LinkProvider` + gets a default derived from its hint providers: every linked id becomes a `REFERENCES` link + with `via` = the tag it came from. Classifying a kind later is an additive change. +6. **Values that qualify a link ride on it** (`props`): a report's type, an assertion's rank, a + zap request's amount. They are what a query filters on after choosing the relation. + +## The vocabulary + +Targets: **E** event, **A** address, **U** user, **T** tag value. "Kinds" lists the Quartz classes +the relation comes from today; each row is a golden test when implemented. + +### Authorship and identity + +| Relation | Targets | Meaning | Kinds | +|---|---|---|---| +| `AUTHORED_BY` | U | The event's signer | every kind | +| `VERSION_OF` | A | The addressable event's own address | 30000–39999 | +| `OWNED_BY` | U | address → its pubkey (not stated by an event) | every address | + +### Conversation + +| Relation | Targets | Meaning | Kinds | +|---|---|---|---| +| `REPLIES_TO` | E, A, U | The direct parent, and (to U) its author | 1 (NIP-10, `replyingTo()`), 1111 (`e`/`a`/`p`), 1244, 1622, 2004, 30818, 14, 42, 1311, and 9 — whose reply parent is a **`q`** tag (NIP-C7), the case that shows why tag letters cannot be the schema | +| `THREAD_ROOT` | E, A, U | The thread's root, and (to U) its author | 1 (`root()`), 1111 (`E`/`A`/`P`), 1622, 42 | +| `MENTIONS` | E, A, U | Named in passing: a NIP-10 `mention` marker, a `p` that notifies, a `nostr:` URI in the text (`via: content`) | 1, 1111, 9, 24, 42, 1311, 1621, 1622, 9802, 30023, 30817, 30818, … | +| `QUOTES` | E, A | A `q` tag (except kind 9, where `q` is the reply parent) | 1, 42, 1111, 1311, 1621, 30023, … | +| `FORK_OF` | E | NIP-10 `fork` marker | 1 | +| `EDITS` | E | A later edit of that event | 1010 (TextNoteModification), 3302 | +| `POSTED_IN` | E, A | The container a message belongs to: a channel, live activity, community, repository | 42 (channel `root`), 1311 (`a`), 1617–1622 (repo `a`), posts tagging a 34550 | +| `SENT_TO` | U | A direct or gift-wrapped message's recipients | 4, 14, 15, 24, 1059, 21059 | + +### Reactions, reposts, zaps + +| Relation | Targets | Meaning | Kinds | +|---|---|---|---| +| `REACTS_TO` | E, A, U, T | The reacted-to content (the last `e`/`a`, `originalPost()`) and its author; kind 17 reacts to a URL / external id (T) | 7, 17 | +| `REPOSTS` | E, A, U | The reposted content (`boostedEventId()` / `boostedAddress()`) and its author | 6, 16 | +| `ZAPS` | E, A, U | The zapped content and the recipient. Props: `msats` | 9734, 9735, 9733, 9321, 8333, 9736, 9737 | +| `ZAP_SENDER` | U | Who paid (a receipt's embedded request author) | 9735 | +| `HIGHLIGHTS` | E, A, U | The highlighted source and its author | 9802 | +| `RATES` | E, A, U | The rated entity | 34259 | + +### Moderation + +| Relation | Targets | Meaning | Kinds | +|---|---|---|---| +| `DELETES` | E, A | NIP-09 targets | 5 | +| `REPORTS_USER` | U | A report about the PERSON: it names no event, address or blob | 1984 | +| `REPORTS` | E, A, T | Reported content (T: a blob hash) | 1984 | +| `REPORTS_AUTHOR` | U | The author of reported content | 1984 | +| `LABELS` | E, A, U, T | NIP-32 targets. Props: `labels` (the `l` values, with namespace) | 1985 | +| `MUTES` | U, E, T | A user's own mutes: people, threads, words/hashtags | 10000, 30007 | +| `HIDES` | E, U | A channel moderator hides a message (43) or a user (44) in the channel — moderation, not a personal mute | 43, 44 | +| `APPROVES` | E, A | A community moderator approves a post | 4550 | +| `MODERATOR` | U | A community's moderators | 34550 | + +Report props (all three report relations): `report` (the category, Quartz's `ReportType` code), +`report_raw` (the type as written, lowercased). Splitting the relations replaces the `scope` +property of the current graph schema: "user-wide reports of X" is +`COUNT { (x)<-[:REPORTS_USER]-() }`, constant-time. + +### Social graph and lists + +| Relation | Targets | Meaning | Kinds | +|---|---|---|---| +| `FOLLOWS` | U | The kind 3 follow list — the social graph | 3 | +| `SUBSCRIBES_TO` | U, E, A, T | Every other "follow this" list: media follows, communities, public chats, interests (hashtags and interest sets) | 10020, 10004, 10005, 10015 | +| `LISTS` | U, E, A | Membership in a named set or directory: follow sets, starter packs, author lists, trusted lists, calendars, publications, emoji sets | 30000, 39089, 39092, 10017, 10101, 10064, 30392–30395, 31924, 30040, 30045, 10030 | +| `RECOMMENDS` | A | An app-handler recommendation | 31989 | +| `BOOKMARKS` | E, A | Private-ish saves | 10003, 30001, 30003 | +| `CURATES` | E, A | Published curation sets | 30004, 30005, 30006, 30063, 30267, 37517 | +| `PINS` | E | Pinned to a profile or a live stream | 10001, 30311 / 30313 (`pinned`) | + +### Badges + +| Relation | Targets | Meaning | Kinds | +|---|---|---|---| +| `AWARDS` | U | A badge award's recipients | 8 | +| `BADGE` | A | The badge definition an award or a profile refers to | 8, 30008, 10008 | +| `ACCEPTS` | E | A profile accepting an award | 30008, 10008 | + +### Trust (NIP-85) + +| Relation | Targets | Meaning | Kinds | +|---|---|---|---| +| `ASSERTS` | U, E, A | The assertion's subject (`d`). Props: `rank`, `followers`, … | 30382, 30383, 30384 | +| `TRUSTS_PROVIDER` | U | A 10040's service for one assertion. Props: `service` (`30382:rank`) — one link per service entry | 10040 | + +### Events, calendars, live activities, markets + +| Relation | Targets | Meaning | Kinds | +|---|---|---|---| +| `PARTICIPANT` | U | Listed participants / speakers / hosts | 30311, 30312, 30313, 31922, 31923 | +| `RSVPS` | A, E | A calendar RSVP's event | 31925 | +| `PRESENT_IN` | A | Presence in a meeting room | 10312 | +| `RAIDS` | A | A live-activity raid target | 1312 | +| `CLIPS` | A, U | A clip of a stream | 1313 | +| `VOTES_IN` | E | A poll response's poll | 1018 | +| `BIDS_ON` / `CONFIRMS_BID` | E | Marketplace bids | 1021 / 1022 | +| `TIMESTAMPS` | E | An OpenTimestamps proof's target | 1040 | +| `STATUS_OF` | E | A NIP-34 status for a patch / issue | 1630–1633 | +| `UPDATES` | E | A later statement about that event: a PR update's pull request, a channel's new metadata | 1619, 41 | +| `REDIRECTS_TO` | A | A wiki redirect | 30819 | + +### Topics and plain tags + +| Relation | Targets | Meaning | Kinds | +|---|---|---|---| +| `TOPIC` | T | A hashtag (`t`) | any | +| `TAGGED` | T | Any other allowlisted value tag: `i` (external id), `k`, `l`/`L`, `r` (url), `g` (geohash). The target's name says which | any | + +### Fallback + +| Relation | Targets | Meaning | +|---|---|---| +| `REFERENCES` | E, A, U | A link a provider names (or a value shaped like an id) that no relation above claims. Props: `tag` | + +Until classified, these stay `REFERENCES`: +- NIP-90 DVM requests, results and feedback (5000–7000); +- NIP-29 group events; +- experimental kinds (workouts, geocaching, roadstr, attestations, zap polls); +- wiki merge requests (818 / 819); +- user status (30315); +- zap goals (9041); +- classifieds (30402); +- video collaboration (34238). + +Each is a small, additive classification when someone needs it. + +## Open questions for review + +1. **`FOLLOWS` user to user?** Kind 3 is replaceable: a user holds exactly one, so `FOLLOWS` could + run `(user)-[:FOLLOWS]->(user)` instead of `(list)-[:FOLLOWS]->(user)`, and the list event + would keep only `AUTHORED_BY`. Follows-of-follows drops from four hops to two, which is most of + web-of-trust. The cost is that the follow's provenance (which list version, when) moves to the + list node. Same question for `MUTES` from kind 10000. Recommendation: yes for both. The + projection would handle it, not Quartz: the relation is the same, only where it starts + differs. +2. **Action relations to the author** (rule 3): one `REACTS_TO` for the note and the person, or a + separate `REACTS_TO_AUTHOR`? Recommendation: one, except where queries must separate them on + the same target type (reports, rule 4). +3. **`kind` on links.** Rule 2 puts the kind only on the source event, not on every link. At + billions of relationships, a property on each one costs tens of GB in the graph; reading the + source node is one hop. A relation whose counts are per kind should be split instead (as + `FOLLOWS` is). +4. **Naming style.** UPPER_SNAKE, verbs in the present tense, as Neo4j convention has it. + Alternatives welcome on any row: `THREAD_ROOT` vs `IN_THREAD`, `LISTS` vs `LISTS_MEMBER`, + `AUTHORED_BY` vs `BY`. +5. **Where it lives.** `nip01Core/links/` (the interface, the value classes, the relation + constants) plus one `links()` per class, beside its tags. The default (rule 5) sits on `Event` + and reads the hint providers. +6. **Vocabulary stability.** Adding a relation or classifying a kind is additive. Renaming or + re-splitting one breaks graph queries, so this review is the cheap moment. + +## What changes downstream + +- **neo4j-eventstore:** the relationship type is the relation name, and the link's `props` are + its properties. Its `RoleTable`, most of `LinkRules` and the report logic move here. It keeps + the curated node values (names, reaction symbol, title), the nsec rule, and the key bounds. + Graph schema 2.0; nothing is in production yet. +- **Amethyst:** feed filters can read the same links instead of re-deriving roles (optional, + incremental). + +## Upstream fixes found on the way + +These were already catalogued in neo4j-eventstore's `docs/appendix-providers.md`: +- `ListEntityExt.pubKeys()` maps an `nsec` to its hex (a private key) as a "linked pubkey"; +- `QTag.parseAddressId` rejects every address; +- `ChannelCreateEvent.linkedEventIds()` returns its own id; +- `ZapReceiptEvent` omits the zap sender. + +New: two classes claim kind **1010**, `experimental/edits/TextNoteModificationEvent` and +`nip51Lists/goodWikiRelayList/GoodWikiRelayListEvent`. `EventFactory` can only type one of them. + +## Plan + +1. This review: the vocabulary, the model, the open questions. +2. Quartz: `nip01Core/links/` and the default from the hint providers; then `links()` for the + kinds the graph already interprets (NIP-10, 18, 22, 25, 56, 57, 85, 51, 58, 72, 09), each with + a golden test. The upstream fixes above land with them. +3. neo4j-eventstore: derive from `links()`, schema 2.0, rewrite `docs/schema.md` and the reference + queries. +4. The remaining kinds, as someone needs them. From 552f99fcc9480dbad3ec1796c66499034b654730 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 13:32:53 +0000 Subject: [PATCH 02/19] docs(quartz): record the link vocabulary review decisions Links always start at an event (no user-to-user shortcuts). The author of acted-on content gets its own relation (REACTS_TO_AUTHOR, REPOSTS_AUTHOR, ZAP_RECIPIENT, ...). The kind stays on the source event only. Naming is still open. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pu8Fpp4KbXaiax8YTYxhJm --- .../plans/2026-09-29-graph-link-vocabulary.md | 57 ++++++++++--------- 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/quartz/plans/2026-09-29-graph-link-vocabulary.md b/quartz/plans/2026-09-29-graph-link-vocabulary.md index cb05b813f6..3f91684947 100644 --- a/quartz/plans/2026-09-29-graph-link-vocabulary.md +++ b/quartz/plans/2026-09-29-graph-link-vocabulary.md @@ -1,7 +1,9 @@ # A link vocabulary: what each kind's references MEAN -Status: **draft for review** (2026-09-29). Nothing is implemented yet; the names below are the -thing to review. +Status: **draft for review** (2026-09-29). Nothing is implemented yet. Decided in review: links +always start at an event (no user-to-user shortcuts), the author of acted-on content gets its OWN +relation, and the kind stays on the source event only. **Naming is still open** (the style below +is a placeholder). ## Why @@ -55,9 +57,10 @@ Rules the vocabulary follows: 2. **One relation per action, across kinds.** `REPLIES_TO` is a kind 1 reply, a NIP-22 comment, a git reply and a chat reply. The source event's `kind` says which; a query that cares filters on it (`(c:Event {kind: 1111})-[:REPLIES_TO]->(x)`). Kinds are not repeated in the name. -3. **An action points at the content AND at the person it is about.** A reaction - `REACTS_TO` the note and `REACTS_TO` its author; the target's type (event, address, user) - tells them apart. "Reactions to my notes" and "reactions naming me" are both one hop. +3. **The author of acted-on content gets a relation of its own.** A reaction `REACTS_TO` the + note, and names the note's author through `REACTS_TO_AUTHOR`, not through a second + `REACTS_TO`. "Reactions to my notes" and "reactions to anything by me" stay one hop, and each + is its own constant-time count. 4. **Split a relation when queries separate its meanings on the same target type.** Counting a relation per node is constant-time in Neo4j, but filtering on a property reads every edge. So a distinction that is filtered all the time becomes two relations: `REPORTS_USER` (a @@ -86,8 +89,10 @@ the relation comes from today; each row is a golden test when implemented. | Relation | Targets | Meaning | Kinds | |---|---|---|---| -| `REPLIES_TO` | E, A, U | The direct parent, and (to U) its author | 1 (NIP-10, `replyingTo()`), 1111 (`e`/`a`/`p`), 1244, 1622, 2004, 30818, 14, 42, 1311, and 9 — whose reply parent is a **`q`** tag (NIP-C7), the case that shows why tag letters cannot be the schema | -| `THREAD_ROOT` | E, A, U | The thread's root, and (to U) its author | 1 (`root()`), 1111 (`E`/`A`/`P`), 1622, 42 | +| `REPLIES_TO` | E, A | The direct parent | 1 (NIP-10, `replyingTo()`), 1111 (`e`/`a`/`p`), 1244, 1622, 2004, 30818, 14, 42, 1311, and 9 — whose reply parent is a **`q`** tag (NIP-C7), the case that shows why tag letters cannot be the schema | +| `THREAD_ROOT` | E, A | The thread's root | 1 (`root()`), 1111 (`E`/`A`), 1622, 42 | +| `REPLIES_TO_AUTHOR` | U | The direct parent's author | 1111 (`p`), 1244 | +| `THREAD_ROOT_AUTHOR` | U | The root's author | 1111 (`P`), 1244 | | `MENTIONS` | E, A, U | Named in passing: a NIP-10 `mention` marker, a `p` that notifies, a `nostr:` URI in the text (`via: content`) | 1, 1111, 9, 24, 42, 1311, 1621, 1622, 9802, 30023, 30817, 30818, … | | `QUOTES` | E, A | A `q` tag (except kind 9, where `q` is the reply parent) | 1, 42, 1111, 1311, 1621, 30023, … | | `FORK_OF` | E | NIP-10 `fork` marker | 1 | @@ -99,11 +104,15 @@ the relation comes from today; each row is a golden test when implemented. | Relation | Targets | Meaning | Kinds | |---|---|---|---| -| `REACTS_TO` | E, A, U, T | The reacted-to content (the last `e`/`a`, `originalPost()`) and its author; kind 17 reacts to a URL / external id (T) | 7, 17 | -| `REPOSTS` | E, A, U | The reposted content (`boostedEventId()` / `boostedAddress()`) and its author | 6, 16 | -| `ZAPS` | E, A, U | The zapped content and the recipient. Props: `msats` | 9734, 9735, 9733, 9321, 8333, 9736, 9737 | -| `ZAP_SENDER` | U | Who paid (a receipt's embedded request author) | 9735 | -| `HIGHLIGHTS` | E, A, U | The highlighted source and its author | 9802 | +| `REACTS_TO` | E, A, T | The reacted-to content (the last `e`/`a`, `originalPost()`); kind 17 reacts to a URL / external id (T) | 7, 17 | +| `REACTS_TO_AUTHOR` | U | Its author (`originalAuthor()`) | 7 | +| `REPOSTS` | E, A | The reposted content (`boostedEventId()` / `boostedAddress()`) | 6, 16 | +| `REPOSTS_AUTHOR` | U | Its author | 6, 16 | +| `ZAPS` | E, A | The zapped content. Props: `msats` | 9734, 9735, 9733, 9321, 8333, 9736, 9737 | +| `ZAP_RECIPIENT` | U | Who is paid (NIP-57 `p`). Props: `msats` | same | +| `ZAP_SENDER` | U | Who paid (NIP-57 `P`, the embedded request's author) | 9735 | +| `HIGHLIGHTS` | E, A | The highlighted source | 9802 | +| `HIGHLIGHTS_AUTHOR` | U | Its author | 9802 | | `RATES` | E, A, U | The rated entity | 34259 | ### Moderation @@ -195,20 +204,16 @@ Each is a small, additive classification when someone needs it. ## Open questions for review -1. **`FOLLOWS` user to user?** Kind 3 is replaceable: a user holds exactly one, so `FOLLOWS` could - run `(user)-[:FOLLOWS]->(user)` instead of `(list)-[:FOLLOWS]->(user)`, and the list event - would keep only `AUTHORED_BY`. Follows-of-follows drops from four hops to two, which is most of - web-of-trust. The cost is that the follow's provenance (which list version, when) moves to the - list node. Same question for `MUTES` from kind 10000. Recommendation: yes for both. The - projection would handle it, not Quartz: the relation is the same, only where it starts - differs. -2. **Action relations to the author** (rule 3): one `REACTS_TO` for the note and the person, or a - separate `REACTS_TO_AUTHOR`? Recommendation: one, except where queries must separate them on - the same target type (reports, rule 4). -3. **`kind` on links.** Rule 2 puts the kind only on the source event, not on every link. At - billions of relationships, a property on each one costs tens of GB in the graph; reading the - source node is one hop. A relation whose counts are per kind should be split instead (as - `FOLLOWS` is). +Decided: +- **No user-to-user shortcuts.** `FOLLOWS` runs from the kind 3 event, like every other list. There + are more than twenty people lists, and a shortcut for one invites one for each. +- **The author of acted-on content has its own relation** (rule 3). +- **`kind` stays on the source event only.** A property on billions of links would cost tens of GB, + and the source node is one hop away. A relation whose counts are needed per kind is split + instead (as `FOLLOWS` is). + +Open: + 4. **Naming style.** UPPER_SNAKE, verbs in the present tense, as Neo4j convention has it. Alternatives welcome on any row: `THREAD_ROOT` vs `IN_THREAD`, `LISTS` vs `LISTS_MEMBER`, `AUTHORED_BY` vs `BY`. From cc6b2a5d2d2b85b8b74a9faa61a91e086d92b7b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 13:46:01 +0000 Subject: [PATCH 03/19] docs(quartz): name the link vocabulary in Nostr's own words Each relation names what the target is to the event, in the NIP's word for that slot where one exists: - ROOT / PARENT / ROOT_AUTHOR / PARENT_AUTHOR (NIP-10, NIP-22); - ZAP_SENDER / ZAP_RECIPIENT (NIP-57), SUBJECT (NIP-85); - BADGE_DEFINITION / BADGE_AWARD (NIP-58), QUOTE (NIP-18). Otherwise it is the past participle of the NIP's action (REACTED, REPORTED, DELETED), and list entries are named as their list names them (FOLLOW, BOOKMARK, MUTE). NIPs that reuse the root marker (NIP-28 channels, NIP-53 chats, NIP-34 statuses) map to ROOT, as they tag it. PARENT is used over NIP-10's reply marker so that PARENT_AUTHOR cannot be misread. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pu8Fpp4KbXaiax8YTYxhJm --- .../plans/2026-09-29-graph-link-vocabulary.md | 193 ++++++++++-------- 1 file changed, 111 insertions(+), 82 deletions(-) diff --git a/quartz/plans/2026-09-29-graph-link-vocabulary.md b/quartz/plans/2026-09-29-graph-link-vocabulary.md index 3f91684947..4344c88f07 100644 --- a/quartz/plans/2026-09-29-graph-link-vocabulary.md +++ b/quartz/plans/2026-09-29-graph-link-vocabulary.md @@ -1,9 +1,8 @@ # A link vocabulary: what each kind's references MEAN Status: **draft for review** (2026-09-29). Nothing is implemented yet. Decided in review: links -always start at an event (no user-to-user shortcuts), the author of acted-on content gets its OWN -relation, and the kind stays on the source event only. **Naming is still open** (the style below -is a placeholder). +always start at an event (no user-to-user shortcuts); the author of acted-on content gets its OWN +relation; the kind stays on the source event only; names follow Nostr's own words (rule 7). ## Why @@ -53,24 +52,38 @@ Rules the vocabulary follows: 1. **Every link starts at the event that makes the statement.** The event is the provenance: its author, its time, and the version that superseded it all hang off it. The one exception is - `OWNED_BY` (address → user), which no event states. -2. **One relation per action, across kinds.** `REPLIES_TO` is a kind 1 reply, a NIP-22 comment, a - git reply and a chat reply. The source event's `kind` says which; a query that cares filters - on it (`(c:Event {kind: 1111})-[:REPLIES_TO]->(x)`). Kinds are not repeated in the name. -3. **The author of acted-on content gets a relation of its own.** A reaction `REACTS_TO` the - note, and names the note's author through `REACTS_TO_AUTHOR`, not through a second - `REACTS_TO`. "Reactions to my notes" and "reactions to anything by me" stay one hop, and each - is its own constant-time count. + `AUTHOR` from an address to its pubkey, which no event states. +2. **One relation per role, across kinds.** `PARENT` is a kind 1 reply's parent, a NIP-22 + comment's parent item, a git reply's and a chat reply's. The source event's `kind` says which; + a query that cares filters on it (`(c:Event {kind: 1111})-[:PARENT]->(x)`). Kinds are not + repeated in the name. +3. **The author of acted-on content gets a relation of its own.** A reaction points at the note + through `REACTED` and at the note's author through `REACTED_AUTHOR`. "Reactions to my notes" + and "reactions to anything by me" stay one hop, and each is its own constant-time count. 4. **Split a relation when queries separate its meanings on the same target type.** Counting a relation per node is constant-time in Neo4j, but filtering on a property reads every edge. - So a distinction that is filtered all the time becomes two relations: `REPORTS_USER` (a - complaint about the person) is not `REPORTS_AUTHOR` (the author of reported content), and - `FOLLOWS` (the kind 3 social graph) is not `SUBSCRIBES_TO` (every other follow-like list). + So a distinction that is filtered all the time becomes two relations: `REPORTED_USER` (a + complaint about the person) is not `REPORTED_AUTHOR` (the author of reported content), and + `FOLLOW` (the kind 3 social graph) is not `SUBSCRIBED` (every other follow-like list). 5. **Nothing is invisible before it is classified.** A class that implements no `LinkProvider` - gets a default derived from its hint providers: every linked id becomes a `REFERENCES` link + gets a default derived from its hint providers: every linked id becomes a `REFERENCE` link with `via` = the tag it came from. Classifying a kind later is an additive change. 6. **Values that qualify a link ride on it** (`props`): a report's type, an assertion's rank, a zap request's amount. They are what a query filters on after choosing the relation. +7. **Names are Nostr's own words for the slot.** A relation names what the TARGET is to the + event: its `AUTHOR`, its `ROOT`, its `PARENT`, the `ZAP_RECIPIENT`. Where a NIP has a word for + the slot, that word is the name: NIP-10's markers (`root`), NIP-22's "root scope" and + "parent item" (`ROOT`, `PARENT`, `ROOT_AUTHOR`, `PARENT_AUTHOR`), NIP-57's "sender" and + "recipient", NIP-85's "subject", NIP-58's "badge definition" and "badge award", NIP-18's + "quote". Where a NIP uses a marker, the marker wins over a friendlier noun: a NIP-28 channel + message's channel is its `ROOT`, as NIP-28 tags it. Where a NIP has no word, or only a + generic one ("target"), the name is the past participle of the NIP's action: `REACTED`, + `REPOSTED`, `REPORTED`, `DELETED`, `TIMESTAMPED`. Lists name their entries the way the list + names them: a follow list holds `FOLLOW`s, a bookmark list `BOOKMARK`s. Casing is + UPPER_SNAKE, the Cypher convention, which also keeps relations apart from properties + (`r.report`). + - `PARENT`, not NIP-10's `reply` marker: `REPLY_AUTHOR` would read as the author of the + reply, and `(c)-[:REPLY]->(p)` as if `p` were the reply. ## The vocabulary @@ -81,116 +94,116 @@ the relation comes from today; each row is a golden test when implemented. | Relation | Targets | Meaning | Kinds | |---|---|---|---| -| `AUTHORED_BY` | U | The event's signer | every kind | -| `VERSION_OF` | A | The addressable event's own address | 30000–39999 | -| `OWNED_BY` | U | address → its pubkey (not stated by an event) | every address | +| `AUTHOR` | U | The event's signer; from an address, its pubkey (the only link no event states) | every kind; every address | +| `ADDRESS` | A | The addressable event's own address (NIP-01) | 30000–39999 | ### Conversation | Relation | Targets | Meaning | Kinds | |---|---|---|---| -| `REPLIES_TO` | E, A | The direct parent | 1 (NIP-10, `replyingTo()`), 1111 (`e`/`a`/`p`), 1244, 1622, 2004, 30818, 14, 42, 1311, and 9 — whose reply parent is a **`q`** tag (NIP-C7), the case that shows why tag letters cannot be the schema | -| `THREAD_ROOT` | E, A | The thread's root | 1 (`root()`), 1111 (`E`/`A`), 1622, 42 | -| `REPLIES_TO_AUTHOR` | U | The direct parent's author | 1111 (`p`), 1244 | -| `THREAD_ROOT_AUTHOR` | U | The root's author | 1111 (`P`), 1244 | -| `MENTIONS` | E, A, U | Named in passing: a NIP-10 `mention` marker, a `p` that notifies, a `nostr:` URI in the text (`via: content`) | 1, 1111, 9, 24, 42, 1311, 1621, 1622, 9802, 30023, 30817, 30818, … | -| `QUOTES` | E, A | A `q` tag (except kind 9, where `q` is the reply parent) | 1, 42, 1111, 1311, 1621, 30023, … | -| `FORK_OF` | E | NIP-10 `fork` marker | 1 | -| `EDITS` | E | A later edit of that event | 1010 (TextNoteModification), 3302 | -| `POSTED_IN` | E, A | The container a message belongs to: a channel, live activity, community, repository | 42 (channel `root`), 1311 (`a`), 1617–1622 (repo `a`), posts tagging a 34550 | -| `SENT_TO` | U | A direct or gift-wrapped message's recipients | 4, 14, 15, 24, 1059, 21059 | +| `ROOT` | E, A | The root: NIP-10 `root` (`root()`), NIP-22 root scope (`E`/`A`), and every NIP that reuses the `root` marker — a NIP-28 message's channel (41, 42), a NIP-53 chat's activity (1311) and a presence's room (10312), a NIP-34 status's or PR update's patch/issue/PR (1630–1633, 1619 `E`) | 1, 1111, 1244, 1622, 41, 42, 1311, 10312, 1619, 1630–1633 | +| `PARENT` | E, A | The direct parent: NIP-10 `replyingTo()`, NIP-22 parent item (`e`/`a`), NIP-53's parent space (30313 → 30312), a NIP-34 status's accepted revision. Kind 9 (NIP-C7) puts its parent in a **`q`** tag — the case that shows why tag letters cannot be the schema | 1, 1111, 1244, 1622, 2004, 30818, 14, 42, 1311, 9, 30313, 1630–1633 | +| `ROOT_AUTHOR` | U | The root scope's author (NIP-22 `P`) | 1111, 1244 | +| `PARENT_AUTHOR` | U | The parent item's author (NIP-22 `p`) | 1111, 1244 | +| `MENTION` | E, A, U | Named in passing: a `p` that notifies, a NIP-10 `mention` marker, a `nostr:` URI in the text (NIP-27, `via: content`) | 1, 1111, 9, 24, 42, 1311, 1621, 1622, 9802, 30023, 30817, 30818, … | +| `QUOTE` | E, A | A NIP-18 `q` (except kind 9, where `q` is the parent) | 1, 42, 1111, 1311, 1621, 30023, … | +| `FORK` | E | The event a note forks (the `fork` marker) | 1 | +| `EDITED` | E | The event this one edits | 1010 (TextNoteModification), 3302 | +| `RECIPIENT` | U | A direct or gift-wrapped message's recipients | 4, 14, 15, 24, 1059, 21059 | +| `COMMUNITY` | A | A NIP-72 community a post is submitted to (and an approval's community) | posts tagging a 34550, 4550 | +| `REPOSITORY` | A | A NIP-34 patch's, PR's or issue's repository | 1617, 1618, 1621 | ### Reactions, reposts, zaps | Relation | Targets | Meaning | Kinds | |---|---|---|---| -| `REACTS_TO` | E, A, T | The reacted-to content (the last `e`/`a`, `originalPost()`); kind 17 reacts to a URL / external id (T) | 7, 17 | -| `REACTS_TO_AUTHOR` | U | Its author (`originalAuthor()`) | 7 | -| `REPOSTS` | E, A | The reposted content (`boostedEventId()` / `boostedAddress()`) | 6, 16 | -| `REPOSTS_AUTHOR` | U | Its author | 6, 16 | -| `ZAPS` | E, A | The zapped content. Props: `msats` | 9734, 9735, 9733, 9321, 8333, 9736, 9737 | -| `ZAP_RECIPIENT` | U | Who is paid (NIP-57 `p`). Props: `msats` | same | -| `ZAP_SENDER` | U | Who paid (NIP-57 `P`, the embedded request's author) | 9735 | -| `HIGHLIGHTS` | E, A | The highlighted source | 9802 | -| `HIGHLIGHTS_AUTHOR` | U | Its author | 9802 | -| `RATES` | E, A, U | The rated entity | 34259 | +| `REACTED` | E, A, T | The reacted-to content (the last `e`/`a`, `originalPost()`); kind 17 reacts to a URL / external id (T) | 7, 17 | +| `REACTED_AUTHOR` | U | Its author (`originalAuthor()`) | 7 | +| `REPOSTED` | E, A | The reposted content (`boostedEventId()` / `boostedAddress()`) | 6, 16 | +| `REPOSTED_AUTHOR` | U | Its author | 6, 16 | +| `ZAPPED` | E, A | The zapped content. Props: `msats` | 9734, 9735, 9733, 9321, 8333, 9736, 9737 | +| `ZAP_RECIPIENT` | U | Who is paid (NIP-57 `p`, the "recipient"). Props: `msats` | same | +| `ZAP_SENDER` | U | Who paid (NIP-57 `P`, the "sender": the embedded request's author) | 9735 | +| `HIGHLIGHTED` | E, A | The highlighted source | 9802 | +| `HIGHLIGHTED_AUTHOR` | U | Its author | 9802 | +| `RATED` | E, A, U | The rated entity | 34259 | ### Moderation | Relation | Targets | Meaning | Kinds | |---|---|---|---| -| `DELETES` | E, A | NIP-09 targets | 5 | -| `REPORTS_USER` | U | A report about the PERSON: it names no event, address or blob | 1984 | -| `REPORTS` | E, A, T | Reported content (T: a blob hash) | 1984 | -| `REPORTS_AUTHOR` | U | The author of reported content | 1984 | -| `LABELS` | E, A, U, T | NIP-32 targets. Props: `labels` (the `l` values, with namespace) | 1985 | -| `MUTES` | U, E, T | A user's own mutes: people, threads, words/hashtags | 10000, 30007 | -| `HIDES` | E, U | A channel moderator hides a message (43) or a user (44) in the channel — moderation, not a personal mute | 43, 44 | -| `APPROVES` | E, A | A community moderator approves a post | 4550 | +| `DELETED` | E, A | NIP-09 deletion request targets | 5 | +| `REPORTED_USER` | U | A report about the PERSON: it names no event, address or blob | 1984 | +| `REPORTED` | E, A, T | Reported content (T: a blob hash) | 1984 | +| `REPORTED_AUTHOR` | U | The author of reported content | 1984 | +| `LABELED` | E, A, U, T | NIP-32 label targets. Props: `labels` (the `l` values, with namespace) | 1985 | +| `MUTE` | U, E, T | A mute list's entries: people, threads, words/hashtags | 10000, 30007 | +| `HIDDEN` | E | A NIP-28 "hide message" | 43 | +| `CHANNEL_MUTED` | U | A NIP-28 "mute user": channel moderation, not a personal mute | 44 | +| `APPROVED` | E, A | A NIP-72 approval's post | 4550 | | `MODERATOR` | U | A community's moderators | 34550 | Report props (all three report relations): `report` (the category, Quartz's `ReportType` code), `report_raw` (the type as written, lowercased). Splitting the relations replaces the `scope` property of the current graph schema: "user-wide reports of X" is -`COUNT { (x)<-[:REPORTS_USER]-() }`, constant-time. +`COUNT { (x)<-[:REPORTED_USER]-() }`, constant-time. ### Social graph and lists | Relation | Targets | Meaning | Kinds | |---|---|---|---| -| `FOLLOWS` | U | The kind 3 follow list — the social graph | 3 | -| `SUBSCRIBES_TO` | U, E, A, T | Every other "follow this" list: media follows, communities, public chats, interests (hashtags and interest sets) | 10020, 10004, 10005, 10015 | -| `LISTS` | U, E, A | Membership in a named set or directory: follow sets, starter packs, author lists, trusted lists, calendars, publications, emoji sets | 30000, 39089, 39092, 10017, 10101, 10064, 30392–30395, 31924, 30040, 30045, 10030 | -| `RECOMMENDS` | A | An app-handler recommendation | 31989 | -| `BOOKMARKS` | E, A | Private-ish saves | 10003, 30001, 30003 | -| `CURATES` | E, A | Published curation sets | 30004, 30005, 30006, 30063, 30267, 37517 | -| `PINS` | E | Pinned to a profile or a live stream | 10001, 30311 / 30313 (`pinned`) | +| `FOLLOW` | U | A kind 3 follow list's entries — the social graph | 3 | +| `SUBSCRIBED` | U, E, A, T | Every other "follow this" list: media follows, communities, public chats, interests (hashtags and interest sets) | 10020, 10004, 10005, 10015 | +| `MEMBER` | U, E, A | Membership in a named set or directory: follow sets, starter packs, author lists, trusted lists, calendars, publications, emoji sets | 30000, 39089, 39092, 10017, 10101, 10064, 30392–30395, 31924, 30040, 30045, 10030 | +| `RECOMMENDED` | A | A NIP-89 recommendation's app handler | 31989 | +| `BOOKMARK` | E, A | Bookmark lists' and sets' entries | 10003, 30001, 30003 | +| `CURATED` | E, A | Published curation sets' entries | 30004, 30005, 30006, 30063, 30267, 37517 | +| `PIN` | E | Pinned to a profile or a live stream | 10001, 30311 / 30313 (`pinned`) | -### Badges +### Badges (NIP-58) | Relation | Targets | Meaning | Kinds | |---|---|---|---| -| `AWARDS` | U | A badge award's recipients | 8 | -| `BADGE` | A | The badge definition an award or a profile refers to | 8, 30008, 10008 | -| `ACCEPTS` | E | A profile accepting an award | 30008, 10008 | +| `AWARDED` | U | A badge award's recipients ("each pubkey the issuer wishes to award") | 8 | +| `BADGE_DEFINITION` | A | The badge definition an award or a profile refers to | 8, 30008, 10008 | +| `BADGE_AWARD` | E | The badge award a profile displays | 30008, 10008 | ### Trust (NIP-85) | Relation | Targets | Meaning | Kinds | |---|---|---|---| -| `ASSERTS` | U, E, A | The assertion's subject (`d`). Props: `rank`, `followers`, … | 30382, 30383, 30384 | -| `TRUSTS_PROVIDER` | U | A 10040's service for one assertion. Props: `service` (`30382:rank`) — one link per service entry | 10040 | +| `SUBJECT` | U, E, A | The assertion's subject (`d`). Props: `rank`, `followers`, … | 30382, 30383, 30384 | +| `SERVICE_PROVIDER` | U | A 10040's provider for one assertion. Props: `service` (`30382:rank`) — one link per entry | 10040 | ### Events, calendars, live activities, markets | Relation | Targets | Meaning | Kinds | |---|---|---|---| | `PARTICIPANT` | U | Listed participants / speakers / hosts | 30311, 30312, 30313, 31922, 31923 | -| `RSVPS` | A, E | A calendar RSVP's event | 31925 | -| `PRESENT_IN` | A | Presence in a meeting room | 10312 | -| `RAIDS` | A | A live-activity raid target | 1312 | -| `CLIPS` | A, U | A clip of a stream | 1313 | -| `VOTES_IN` | E | A poll response's poll | 1018 | -| `BIDS_ON` / `CONFIRMS_BID` | E | Marketplace bids | 1021 / 1022 | -| `TIMESTAMPS` | E | An OpenTimestamps proof's target | 1040 | -| `STATUS_OF` | E | A NIP-34 status for a patch / issue | 1630–1633 | -| `UPDATES` | E | A later statement about that event: a PR update's pull request, a channel's new metadata | 1619, 41 | -| `REDIRECTS_TO` | A | A wiki redirect | 30819 | +| `CALENDAR_EVENT` | A, E | A calendar RSVP's calendar event | 31925 | +| `RAIDED` | A | A live-activity raid's target | 1312 | +| `CLIPPED` | A | A clip's stream | 1313 | +| `CLIPPED_AUTHOR` | U | The clipped stream's host | 1313 | +| `POLL` | E | A poll response's poll | 1018 | +| `AUCTION` | E | A bid's (and a bid confirmation's) auction | 1021, 1022 | +| `BID` | E | The bid a confirmation confirms | 1022 | +| `TIMESTAMPED` | E | An OpenTimestamps proof's target (NIP-03 says "target", too generic to name a relation) | 1040 | +| `REDIRECT` | A | A wiki redirect's destination | 30819 | ### Topics and plain tags | Relation | Targets | Meaning | Kinds | |---|---|---|---| -| `TOPIC` | T | A hashtag (`t`) | any | -| `TAGGED` | T | Any other allowlisted value tag: `i` (external id), `k`, `l`/`L`, `r` (url), `g` (geohash). The target's name says which | any | +| `HASHTAG` | T | A `t` tag | any | +| `TAG` | T | Any other allowlisted value tag: `i` (external id), `k`, `l`/`L`, `r` (url), `g` (geohash). The target's name says which | any | ### Fallback | Relation | Targets | Meaning | |---|---|---| -| `REFERENCES` | E, A, U | A link a provider names (or a value shaped like an id) that no relation above claims. Props: `tag` | +| `REFERENCE` | E, A, U | A link a provider names (or a value shaped like an id) that no relation above claims. Props: `tag` | -Until classified, these stay `REFERENCES`: +Until classified, these stay `REFERENCE`: - NIP-90 DVM requests, results and feedback (5000–7000); - NIP-29 group events; - experimental kinds (workouts, geocaching, roadstr, attestations, zap polls); @@ -202,25 +215,41 @@ Until classified, these stay `REFERENCES`: Each is a small, additive classification when someone needs it. +## Reading it back + +```cypher +// a whole reply tree +MATCH (:Event {id: $root})<-[:PARENT*]-(r) RETURN r + +// reactions to my posts by people I follow +MATCH (me:User {pubkey: $me})<-[:AUTHOR]-(:Event {kind: 3})-[:FOLLOW]->(f), + (f)<-[:AUTHOR]-(r)-[:REACTED_AUTHOR]->(me) +RETURN r + +// user-wide reports against X, by category +MATCH (:User {pubkey: $x})<-[r:REPORTED_USER]-() RETURN r.report, count(*) + +// who zapped whom, from one sender +MATCH (:User {pubkey: $x})<-[:ZAP_SENDER]-(z)-[:ZAP_RECIPIENT]->(u) RETURN u, sum(z.msats) +``` + ## Open questions for review Decided: -- **No user-to-user shortcuts.** `FOLLOWS` runs from the kind 3 event, like every other list. There +- **No user-to-user shortcuts.** `FOLLOW` runs from the kind 3 event, like every other list. There are more than twenty people lists, and a shortcut for one invites one for each. - **The author of acted-on content has its own relation** (rule 3). - **`kind` stays on the source event only.** A property on billions of links would cost tens of GB, and the source node is one hop away. A relation whose counts are needed per kind is split - instead (as `FOLLOWS` is). + instead (as `FOLLOW` is). +- **Names follow Nostr's words** (rule 7), with `PARENT` for the direct parent. Open: -4. **Naming style.** UPPER_SNAKE, verbs in the present tense, as Neo4j convention has it. - Alternatives welcome on any row: `THREAD_ROOT` vs `IN_THREAD`, `LISTS` vs `LISTS_MEMBER`, - `AUTHORED_BY` vs `BY`. -5. **Where it lives.** `nip01Core/links/` (the interface, the value classes, the relation +1. **Where it lives.** `nip01Core/links/` (the interface, the value classes, the relation constants) plus one `links()` per class, beside its tags. The default (rule 5) sits on `Event` and reads the hint providers. -6. **Vocabulary stability.** Adding a relation or classifying a kind is additive. Renaming or +2. **Vocabulary stability.** Adding a relation or classifying a kind is additive. Renaming or re-splitting one breaks graph queries, so this review is the cheap moment. ## What changes downstream @@ -245,7 +274,7 @@ New: two classes claim kind **1010**, `experimental/edits/TextNoteModificationEv ## Plan -1. This review: the vocabulary, the model, the open questions. +1. This review: the vocabulary, the model, the open questions. Done except the two open points. 2. Quartz: `nip01Core/links/` and the default from the hint providers; then `links()` for the kinds the graph already interprets (NIP-10, 18, 22, 25, 56, 57, 85, 51, 58, 72, 09), each with a golden test. The upstream fixes above land with them. From e18bd42835756e3a855f3a9e93dbfc3905d0eca2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 13:56:51 +0000 Subject: [PATCH 04/19] docs(quartz): measure link vocabulary coverage; the fallback must read tag shapes EventFactory types 410 classes. The draft classifies about 150, about 110 carry no references, and about 150 carry references but are unclassified. Only 12 of those implement a hint provider, so a fallback built from hint providers alone would drop the rest. The REFERENCE default now also reads generic e/E/q, p/P and a/A tags by shape. A coverage test is planned so that every EventFactory kind must be classified, REFERENCE-only or link-free. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pu8Fpp4KbXaiax8YTYxhJm --- .../plans/2026-09-29-graph-link-vocabulary.md | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/quartz/plans/2026-09-29-graph-link-vocabulary.md b/quartz/plans/2026-09-29-graph-link-vocabulary.md index 4344c88f07..fa413bca76 100644 --- a/quartz/plans/2026-09-29-graph-link-vocabulary.md +++ b/quartz/plans/2026-09-29-graph-link-vocabulary.md @@ -66,8 +66,11 @@ Rules the vocabulary follows: complaint about the person) is not `REPORTED_AUTHOR` (the author of reported content), and `FOLLOW` (the kind 3 social graph) is not `SUBSCRIBED` (every other follow-like list). 5. **Nothing is invisible before it is classified.** A class that implements no `LinkProvider` - gets a default derived from its hint providers: every linked id becomes a `REFERENCE` link - with `via` = the tag it came from. Classifying a kind later is an additive change. + gets a default `REFERENCE` link (with `via` = the tag it came from) for every id its hint + providers name AND every generic reference tag whose value has the right shape: `e`/`E`/`q` + with a 64-hex id, `p`/`P` with a 64-hex key, `a`/`A` with a valid address. The shape half is + not optional: most kinds Quartz types implement no hint provider (measured below), and without + it their references would vanish. Classifying a kind later is an additive change. 6. **Values that qualify a link ride on it** (`props`): a report's type, an assertion's rank, a zap request's amount. They are what a query filters on after choosing the relation. 7. **Names are Nostr's own words for the slot.** A relation names what the TARGET is to the @@ -272,9 +275,36 @@ These were already catalogued in neo4j-eventstore's `docs/appendix-providers.md` New: two classes claim kind **1010**, `experimental/edits/TextNoteModificationEvent` and `nip51Lists/goodWikiRelayList/GoodWikiRelayListEvent`. `EventFactory` can only type one of them. +## Coverage + +Measured on 2026-09-29: `EventFactory` types **410** classes. The measurement is a text scan, so +the split below is approximate; an exact per-class table is plan step 2a. +- **~150 are classified above.** That covers the NIPs the graph already interprets. +- **~110 carry no references.** Settings, metadata, relay and server lists, key packages, + ephemeral auth. They need nothing beyond `AUTHOR` (and `ADDRESS`). +- **~150 carry references and are not classified yet.** Only 12 of them implement a hint + provider; the rest reach the graph only through the shape half of rule 5. The largest groups: + - `buzz/` (~65 kinds: streams, workflows, jobs, huddles, forums, DMs, moderation); + - NIP-29 groups (9000–9010, 39000–39005); + - NIP-43 and buzz relay membership; + - NIP-47 wallet connect and NIP-46 remote signer traffic; + - WebRTC calls (25050–25055); + - NIP-71 videos (21, 22, 34235, 34236); + - file metadata (1063, 1065); + - chess (64, Jester); + - clink, cashu, contextvm, marmot; + - app data and handlers (78, 30078, 31990); + - music playlists, interactive stories, attestations, workouts, geocaching, list items + (9999 / 39999), torrents (2003). + +**Enforced, not hoped for:** a Quartz test walks every `EventFactory` kind and fails unless the +class is one of: classified (implements `LinkProvider`), explicitly `REFERENCE`-only, or +explicitly link-free. A new kind then cannot land without a decision about its links. + ## Plan 1. This review: the vocabulary, the model, the open questions. Done except the two open points. + - 2a. The exact per-class coverage table (all 410), generated, as an appendix to this plan. 2. Quartz: `nip01Core/links/` and the default from the hint providers; then `links()` for the kinds the graph already interprets (NIP-10, 18, 22, 25, 56, 57, 85, 51, 58, 72, 09), each with a golden test. The upstream fixes above land with them. From 16b540224c6233a0818c086e1f588304cd9d2b8c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 14:14:42 +0000 Subject: [PATCH 05/19] docs(quartz): classify every Quartz event class's links; drop the fallback Every one of the 410 classes EventFactory types is now read against its tags, its parsers and its NIP. 340 carry references and 70 carry none. The results are in the new appendix, 2026-09-29-graph-link-vocabulary-appendix.md: one row per class with its links, the Quartz accessor to build them from, and notes. It also lists the 115 relations the tables did not have yet. Synonyms coined by different groups are unified: ADDED_USER / REMOVED_USER, REQUEST / REQUEST_AUTHOR, ZAP_SPLIT. The plan changes to match: - Rule 5 is now "no fallback". Every class implements links() or is declared link-free, and a coverage test holds that. Shape-guessing is unsafe: a chess board hash looks like an event id, and `t` / `r` mean other things on some kinds. - The tags every kind may carry (client, zap splits, emoji sets) move to Event. - Corrections to the tables from the review, and the open decisions it raised (groups, URL targets, derived links, content JSON). - The Quartz bugs found, including a privacy bug in GeohashListEvent and the real kind collision at 20001. The earlier claim of a 1010 collision was a prefix-match mistake and is corrected. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pu8Fpp4KbXaiax8YTYxhJm --- ...26-09-29-graph-link-vocabulary-appendix.md | 945 ++++++++++++++++++ .../plans/2026-09-29-graph-link-vocabulary.md | 135 ++- 2 files changed, 1045 insertions(+), 35 deletions(-) create mode 100644 quartz/plans/2026-09-29-graph-link-vocabulary-appendix.md diff --git a/quartz/plans/2026-09-29-graph-link-vocabulary-appendix.md b/quartz/plans/2026-09-29-graph-link-vocabulary-appendix.md new file mode 100644 index 0000000000..18b0e6fa5f --- /dev/null +++ b/quartz/plans/2026-09-29-graph-link-vocabulary-appendix.md @@ -0,0 +1,945 @@ +# Appendix: every Quartz event class, and what its references mean + +Companion to [`2026-09-29-graph-link-vocabulary.md`](2026-09-29-graph-link-vocabulary.md). +Generated 2026-09-29 from `utils/EventFactory.kt`: **410 classes**, each read against its tags, +its tag parsers and its NIP (or, for Quartz-only families, its package docs). **340** carry +references; **70** carry none (they get only `AUTHOR`, and `ADDRESS` when addressable). + +How to read a row: +- **Links**: `tag[marker/slot] -> RELATION (targets)`, targets **E** event, **A** address, + **U** user, **T** tag value. `AUTHOR` and `ADDRESS` apply to every class and are not + repeated. +- **Built from**: the Quartz accessor or tag parser the class's `links()` would call, or + "new parser needed". +- **Notes**: the NIP or spec, `UNCERTAIN:` where the spec is silent or unmerged, `DRAFT FIX:` + where the vocabulary tables need correcting, and Quartz bugs found while reading. + +Relation names are normalized to one per role (rule 2): `ADDED`→`ADDED_USER`, +`REMOVED`→`REMOVED_USER`, `JOB_REQUEST`→`REQUEST`, `CUSTOMER`→`REQUEST_AUTHOR` (NIP-90's word +is "customer"), `BENEFICIARY`→`ZAP_SPLIT`. + +## Relations this review adds + +Not in the vocabulary tables yet; each needs the maintainer's review like the tables did. +**Any kind** marks a tag that can appear on every event, better emitted once by the default on +`Event` than per class. + +| Relation | Kinds | Justification (from the reviewing pass) | +|---|---|---| +| `ABOUT` | 23903, 30392, 30393, 30394, 30395 | the event a wake-up is about (Quartz builder about(); also the trusted lists' discovery slot) | +| `ABOUT_AUTHOR` | 23903 | its author (rule 3; KDoc: 'p-tags identify the AUTHORS of the referenced events', not recipients) | +| `ACCEPTED` | 30065 | the challenge this event accepts (past participle of the action). OPPONENT: see 30 | +| `ACTOR` | 8002, 8003, 40099, 44100, 44101, 48001 | – | +| `ADDED_USER` | 8000, 9000, 9030, 40099, 41011, 44100 | past participle of NIP-43 'Add User' / NIP-29 put-user; the member an add command/notification adds. Props: role. Should be shared with NIP-43 8000 and NIP-29 9000 | +| `ADMIN` | 39001 | a NIP-29 group's admins (kind 39001 'group admins'; props: roles). It is kept apart from NIP-72 MODERATOR because roles are relay-defined | +| `AGENT` | 24200, 30177, 43001, 44200 | Buzz's `agent` tag word; the AI agent a frame/metric/job/managed-agent record is about | +| `ALLOWED` | 30175, 30177, 34551 | entries of NIP-AP's respond_to_allowlist (who the agent answers), named as the list names them | +| `APP` | 5129, 15128, 15129, 35128, 35129 | NIP-5A `app` tag, 'an addressable event reference to an app descriptor' (NIP-89 31990 / 32267) | +| `APPLICATION` | 30063 | NIP-51's own example names the a the 'Reference to parent software application' (kind 32267): the release belongs to it, it is not a curated item | +| `APPLIED` | 1631 | the patch(es) a 1631 status applied or merged (NIP-34 'applied-or-merged-patch-event-id'; past participle of the status' own name). A q here is not a NIP-18 quote. REPOSITORY_OWNER: see 1617 | +| `APPROVED_AUTHOR` | 4550 | rule 3 - NIP-72 requires 'the p tag of the author of the post (for approval notifications)' | +| `APPROVER` | 46010 | Buzz's word; the person whose approval a paused workflow waits for | +| `ARCHIVED` | 8002, 9035, 13535 | past participle of NIP-IA's action (archive identity); also the entries of the 13535 archived list \| ACTOR: who performed/consented to the action a relay-signed record reports (NIP-IA consent tag's 'actor'); props path=self/owner/admin \| REQUEST: the originating request this relay-signed delta answers (Buzz 'originating request'; also fits NIP-90 results) \| REPLACED_BY: NIP-IA's own word for the successor identity (rotation pointer) | +| `ASSERTION` | 31871, 31872 | the event an attestation or attestation request is about (Quartz assertionEventId/assertionAddrId; UNCERTAIN it is the spec's word, fallback ATTESTED) | +| `ATTESTOR` | 31872 | an attestor asked to attest (the spec family's own word; builder attestorPubKeys). ASSERTION: see 31871 | +| `AUCTION_AUTHOR` | 1021 | the auction's merchant (rule 3, the author of acted-on content, as REACTED_AUTHOR). Quartz writes it via notifyAuthor() | +| `AUDITED` | 48001 | past participle of the audit action; the object an entry records an action on. Props: action | +| `AUTHORED` | 10064 | NIP-F4/NIP-51 'podcasts the user authors' - the counter-claim that verifies a 10154 PODCAST_AUTHOR; past participle of the NIP's action | +| `BADGE_SET` | 10008 | NIP-58 '(Profile badges) may also contain a tags referencing "Badge Set" events' (30008) - the NIP's own noun; not a badge definition | +| `BANNED` | 9040 | past participle of Buzz's ban action. Props: expiration, reason. (Unban 9041, not registered, would be UNBANNED) | +| `BASE_VERSION` | 818 | 'version of the article on which this modification is based' (no marker) | +| `BID_AUTHOR` | 1022 | the bidder (rule 3). Quartz writes it via notifyBidder() | +| `CALENDAR` | 31922, 31923 | NIP-52 'a (repeated) reference tag to kind 31924 calendar event requesting to be included in Calendar' - the NIP's noun for the target | +| `CALENDAR_EVENT_AUTHOR` | 31925 | rule 3 author of the acted-on calendar event; NIP-52 'p (optional) pubkey of the author of the calendar event being responded to' | +| `CHILD` | 9002, 39000 | see 9002 | +| `CLIENT` | **any kind** — seen on 31990 | NIP-89 client tag ('identifying the client that published the note' by its 31990 address) - cross-cutting, any event may carry it | +| `COLLABORATED` | 34238 | the NIP-71 video the signer accepts (or declines) a collaborator credit on; props role, status (accepted\|declined; absent = accepted). COLLABORATED_AUTHOR: that video's author (rule 3) | +| `COLLABORATED_AUTHOR` | 34238 | – | +| `CONCEPT_GRAPH` | 39998 | – | +| `CONFIRMED` | 1316 | the kind-1315 report a Roadstr confirmation confirms or denies (spec: 'report being confirmed or denied'; the kind is named Road Event Confirmation). Props: status (still_there \| no_longer_there) | +| `COPIED` | 15128, 15129, 35128, 35129 | NIP-5A 'a copied site MUST include exactly one lowercase a tag referencing the immediate parent nsite from which it was copied' (past participle of the NIP's action) | +| `CREATED` | 7376 | NIP-60 marker 'created' - the token event this spend created | +| `CREDITED` | 21, 22, 34235, 34236 | divine.video credit markers on p/a/e (inspired-by, audio, collaborator...) that are neither a NIP-71 participant nor a mention; one relation + props.credit instead of one relation per free-text label | +| `CURRENT_SCENE` | 30298 | the scene a reader is at (Quartz currentScene()) | +| `DEFER` | 30818 | the NIP-54 `defer` marker (rule 7, marker wins) - 'considers someone else's entry as a better version of itself'; a WoT-weight transfer, neither a fork nor a mention | +| `DELETED_AUTHOR` | 5 | rule 3 author of the acted-on content; Quartz's builders write a `p` per deleted event's author. For a valid request it always equals AUTHOR, so it may be dropped if the maintainer prefers - but the tag exists and points at a pubkey | +| `DENIED` | 34551 | – | +| `DESTINATION` | 818 | NIP-54 addresses the request to 'destination-pubkey' and its a is '30818::' (the NIP's only other word is the generic 'target') | +| `DESTINATION_AUTHOR` | 818 | rule 3 author of the acted-on article (NIP-54 'destination pubkey') | +| `DESTROYED` | 7376 | NIP-60 marker 'destroyed' - the token event it consumed | +| `EDITED_AUTHOR` | 1010 | the edited note's author (rule 3). create(notify=) writes it only when editing someone else's note (EditPostViewModel), so it is the author of acted-on content, not a passing mention | +| `ELEMENT_OF` | 39999 | – | +| `EMOJI_SET` | **any kind** — seen on 0, 1, 7, 17, 1111, 10030, 30023, 30030 … (9 kinds) | NIP-30's own name for the optional 4th emoji-tag slot ('the kind 30030 emoji set the emoji belongs to'); an address pointer, so it needs a relation; cross-cutting on every kind NIP-30 allows emoji tags on (0, 1, 1111, 7, 30315) plus 10030/30030 | +| `EXERCISE` | 1301 | a POWR/NIP-101e set's kind-33401 exercise template (the tag's own name; props weight/reps/rpe/set_type) | +| `FAVORITE` | 10012, 10021, 10054, 10090 | NIP-51 names these lists by 'favorite' (10012 'user favorite browsable relays (and relay sets)', 10021 'Favorite follow sets', 10054 'Favorite podcasts'); alternative SUBSCRIBED | +| `FILE_DATA` | 1065 | the kind-1064 storage event holding the bytes this header describes (NIP-95 draft); no existing relation means 'the payload of this metadata' | +| `FINDER` | 7517 | NIP-CC's word for the person the verification attests ('the finder's pubkey') | +| `FOR_USER` | 5300, 5301 | DVM spec kinds/5300 'pubkey of the user to generate recommendations for' (Quartz writes it as ["param","user",hex]); USER alone would collide with the User node label | +| `FOUND` | 7516 | NIP-CC kind 7516 is the 'Found Log' that 'record[s] successful visits'; past participle of the NIP's action | +| `FUNDED` | 9041 | NIP-75 'The goal MAY include an r or a tag linking to a URL or addressable event' - use case 'adding funding goals to events'; past participle of the goal's action | +| `GOAL` | 30311 | the NIP-75 zap goal (kind 9041) a stream raises toward (the tag's own name, 'goal') | +| `GROUP` | 444, 445, 9000, 9001, 9002, 9005, 9007, 9008 … (59 kinds) | NIP-29 `h` group id (Buzz channel UUID) the event is scoped to, target Tag("h", id); NIP-29's word for the slot; `h` is the one reference every channel-scoped Buzz kind carries and it is not E/A/U, so it needs a T relation (propose adding `h` to the allowlisted value tags) | +| `INHERIT_FROM` | 39998, 39999 | the node a b tag claims to inherit from / correspond to (Tapestry draft 'Inherit-From'); props type (pointer\|inherit\|inherit-items). CONCEPT_GRAPH: the concept's Concept Graph core node (tag name) | +| `INPUT` | 5000, 5001, 5002, 5050, 5100, 5200, 5201, 5202 … (38 kinds) | NIP-90 'i' is 'Input data for the job' (props input_type, marker) | +| `INPUT_JOB` | 5000, 5001, 5002, 5050, 5100, 5200, 5201, 5202 … (38 kinds) | NIP-90 input-type 'job' = 'the output of a previous job with the specified event ID' (job chaining), target is that job request | +| `ITEM` | 9999, 39999 | – | +| `KEY_PACKAGE` | 444 | the kind 30443 KeyPackage event this Welcome consumed (MIP-02 names the slot 'KeyPackage'). GROUP: see 9007 | +| `KICKED` | 4312 | the participant a room host ejects (the nostrnests / EGG-07 verb 'kick', as past participle); CHANNEL_MUTED reused for the force-mute verb (room-scoped moderation, not a personal mute) | +| `LINKED` | 30315 | NIP-38 'The status MAY include an r, p, e or a tag linking to a URL, profile, note, or addressable event' - past participle of the NIP's verb; deliberate (the status is about it), so not MENTION | +| `MAINTAINER` | 30617 | the repository's other recognized maintainers (NIP-34 'maintainers' tag; a list named as the list names it) | +| `MERCHANT` | 30019 | the merchants a NIP-15 marketplace groups ('merchants': array of pubkeys). Lists name their entries as the list does (rule 7) | +| `NOTIFICATION_SERVER` | 447, 448, 449 | the push notification server a token record targets (features/push-notifications.md 'notification server'; record key member_id, leaf, platform, server_pubkey) | +| `OBSERVER` | 30392, 30393, 30394, 30395 | the point of view the list was computed under (tag name, NIP-85 vocabulary) | +| `OPEN_TIMESTAMP` | 31 | the kind-1040 NIP-03 proof attesting when the cited page was seen; named after the tag (the spec's word for the slot), not TIMESTAMPED, which is the 1040's own link to its target | +| `OPPONENT` | 30, 30064, 30065, 30066, 30067, 30068 | the other player (Quartz OpponentTag/opponentPubkey(); Jester FLOW 'opponent'), shared with the live chess kinds 30064-30068 | +| `OPTION` | 30296, 30297 | a scene an interactive story branches to (the tag's own name; props: the option text) | +| `ORIGIN` | 5129, 15128, 15129, 35128, 35129 | the uppercase A, 'the origin nsite of the copy lineage' (the NIP's word) | +| `OWNER` | 9035, 9036, 30174, 44200 | NIP-OA's own word; the owner key attesting the event's (agent) author via the `auth` tag, or the owner an agent reports to (NIP-AM/NIP-AE `p`). Props: conditions (attestation only) | +| `PALETTE` | 3330, 11333, 33331 | DECK-0003 §1.3b names the payload field `palette`: an nevent/naddr to a palette published as its own event; a content-borne event/address reference that is not a passing mention | +| `PARENT_LIST` | 9999, 39999 | the list header an item is filed under (spec: 'a pointer to the parent list (the list header)'); T when the z is the bare name of an undeclared list. ITEM: the thing declared as an item on that list (spec: 'declaring a pubkey, event id, string, or naddr as an item on a list') | +| `PERSONA` | 30177 | NIP-AP's word; the 30175 persona a managed agent is defined by (Address 30175:author:persona_id) | +| `PODCAST_AUTHOR` | 10154 | NIP-F4 '["p", , ]'; AUTHOR is taken by the signer (here the podcast key itself), so the NIP's own 'podcast author' is the name | +| `POLL_AUTHOR` | 1018 | rule 3 - the author of the acted-on poll; Quartz writes a p for the poll author (notifyAuthor) that NIP-88 does not define | +| `PUBLICATION` | 30041 | the kind-30040 index a section belongs to (Quartz publicationAddress(); the inverse of the index's MEMBER, stated by the section) | +| `RATED_AUTHOR` | 34259 | the rated entity's author (rule 3; the p is 'the rated author', not the rated user, which comes from d when mark=profile) | +| `REDEEMED` | 7376 | NIP-60/61 marker 'redeemed' - the nutzap (9321) this history entry claimed | +| `REDEEMED_AUTHOR` | 7376 | rule 3 - NIP-61 'pubkey of the author of the 9321 event (nutzap sender)' | +| `RELEASE` | 32267 | an application's release (kind 30063 NIP-82 / NIP-51 release artifact set) that the app event points to | +| `REMINDED` | 40007 | past participle of the reminder action; the message a reminder is about (would also serve NIP-ER 30300 targets) | +| `REMOVED_USER` | 8001, 9001, 9031, 40099, 44101 | past participle of NIP-43 'Remove User' / NIP-29 remove-user; should be shared with NIP-43 8001 and NIP-29 9001 | +| `REPLACED_BY` | 8002, 9035 | – | +| `REPOSITORY_OWNER` | 1617, 1618, 1619, 1621, 1630, 1631, 1632, 1633 | NIP-34's 'repository-owner' p on patches, PRs, issues and statuses (rule 3 author-of-acted-on-content, named by the NIP's word). It is the one-hop 'patches to my repos'. | +| `REQUEST` | 819, 6000, 6001, 6002, 6050, 6100, 6200, 6201 … (33 kinds) | the request event a response answers (CLINK: 'a response is distinguished by an e tag referencing the request'); same role as NIP-47/NIP-90 responses and an attestation's request, none classified yet | +| `REQUEST_AUTHOR` | 819, 6000, 6001, 6002, 6050, 6100, 6200, 6201 … (25 kinds) | the requester, i.e. the author of the REQUEST target (rule 3; also fits NIP-90 result/feedback `p`) | +| `RESOLVED` | 9044 | past participle of Buzz's resolve-report action; the kind 1984 report being closed. Props: status (resolved/dismissed), action (delete/kick/ban/timeout/dismiss/escalate), reason | +| `RESULT` | 819, 6300, 6301, 6302, 6303, 6900, 6905, 6970 | NIP-90 'Job result ... providing the output'; the entities a job returns in content (via content) | +| `REVISED` | 1618 | the root patch this PR is a revision of (NIP-34: 'indicate PR is a revision of an existing patch, which should be closed'; past participle of the action) | +| `ROLE_CHANGED` | 9032 | past participle of Buzz's change-role action; the member whose role changes. Props: role. Distinct from ADDED_USER because the target is already a member | +| `SCHEDULED` | 5905 | DVM spec kinds/5905 'Schedule events to be published in the future'; past participle of the action for the signed event the DVM will publish | +| `SEARCH_AUTHOR` | 5302 | DVM spec kinds/5302 param 'users' = 'pubkeys of users to filter notes from' (the authors the search is restricted to) | +| `SITE_MANIFEST` | 31990 | NIP-89 'App descriptor events SHOULD tag or otherwise reference related site manifest events' (latest/next nsite manifests) | +| `SNAPSHOTTED` | 5129 | NIP-5A snapshot (5128) 'MUST include exactly one a tag referencing the source root site or named site' - past participle of the NIP's action; ORIGIN / APP: see 15128 | +| `SOURCE` | 818, 1163, 30040 | the event a reproduced piece of content was taken from (here the post the gallery picture came from; also used for NKBIP-01 derivative works on 30040). Quartz calls the slot fromEvent | +| `SOURCE_TAG` | 30392, 30393, 30394, 30395 | the tag definition the membership was computed from (tag name) | +| `STALL` | 30018, 30020 | the NIP-15 stall a product or auction belongs to ('stall_id: id of the stall to which this product belong to'). The address is derived from the author plus the content stall_id | +| `SUBSET_OF` | 39999 | the superset a set claims to be a subset of ('s'). Named with the draft's words; the draft derives the reversed edges HAS_ELEMENT / IS_A_SUPERSET_OF | +| `TAGGED` | 20 | NIP-68 names its p tags 'Tagged users' and annotate-user 'places a user link in the image' - people shown in the picture, not a passing mention | +| `TEMPLATE` | 1301 | the kind-33402 workout template the session was built from (the tag's own name) | +| `TEXT_TRACK` | 21, 22, 34235, 34236 | NIP-71 `text-track` names the captions/subtitles track (an encoded event or a 39307 coordinate) - the NIP's own tag word | +| `TIMED_OUT` | 9042 | past participle of Buzz's timeout action. Props: expiration, reason | +| `TIMEOUT_CLEARED` | 9043 | Buzz's 'untimeout' = 'clears a timeout'; UNTIMED_OUT is the literal participle but unreadable | +| `TRIGGERED` | 46020 | past participle of Buzz's trigger action; the 30620 workflow definition run | +| `UNARCHIVED` | 8003, 9036 | past participle of NIP-IA unarchive; split from ARCHIVED so 'is X archived' needs no property filter (rule 4) | +| `VERIFIED` | 7517 | past participle - 'the geocache naddr being verified' | +| `VERIFIER` | 37516 | NIP-CC verification tag 'public key for verifying finds' - the key that signs 7517s | +| `VIDEO` | 39307 | the NIP-71 video this caption/subtitle track belongs to (Quartz video()); the target named by its slot, like POLL or COMMUNITY | +| `VIEWER` | 30622 | NIP-DV's own word; the user a relay-signed per-viewer snapshot belongs to | +| `VOTED` | 45002 | past participle of Buzz's vote action; the voted post. Props: direction (+/-) | +| `WIKILINK` | 30041 | a resolved [[double bracket]] reference from the body (NKBIP-01 tag name; T = the target slug when no id is given) | +| `WIKILINK_AUTHOR` | 30041 | the author named in the wikilink's pubkey slot (rule 3) | +| `WINNER` | 30067, 37516 | the winning player (the tag's own name, 'winner'). OPPONENT: see 30 | +| `WOT_ROOT` | 34551 | the wot tag's 'root-pubkey' from which posters must be reachable | +| `ZAP_REQUEST` | 9735 | NIP-57's own name ('zap request') for the event embedded in the `description` tag; a content-borne event reference, so it needs a relation (props could carry the request's comment) | +| `ZAP_SPLIT` | **any kind** — seen on 1, 14, 1111, 9041, 30023 | NIP-57 Appendix G `zap` tag names a pubkey that receives zaps sent to this event; a configuration, not a payment, so it must not be ZAP_RECIPIENT (rule 4); props weight; cross-cutting (Amethyst writes it on 1, 14, 1111, 30023, 30402). EMOJI_SET: see kind 0 | + +## The classes, by package + +### `buzz` (74) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 8002 | `ArchivedIdentityEvent` | p -> ARCHIVED (U); consent[actor] -> ACTOR (U); e -> REQUEST (E); replaced-by -> REPLACED_BY (U) | ArchivedIdentityEvent.target()/consent()/requestId()/replacedBy() (PTag.parseKey, ConsentTag.parse, ETag.parseId, ReplacedByTag.parse) | Buzz NIP-IA, relay-signed delta. Props on ARCHIVED: reason. BUG: ReplacedByTag.parse and ConsentTag.parse do not check hex64 (a malformed key would become a User link). ACTOR is the 9035 author, so ACTOR duplicates (e)-[:REQUEST]->(r)-[:AUTHOR]; kept because the request is never stored (relay audits, not stores, 9035). | +| 8003 | `UnarchivedIdentityEvent` | p -> UNARCHIVED (U); consent[actor] -> ACTOR (U); e -> REQUEST (E) | UnarchivedIdentityEvent.target()/consent()/requestId() (PTag.parseKey, ConsentTag.parse, ETag.parseId) | Buzz NIP-IA relay-signed delta. Props: reason. ConsentTag actor not hex-validated. REQUEST target (9036) is never stored by the relay. | +| 9030 | `RelayAdminAddMemberEvent` | p -> ADDED_USER (U) | RelayAdminAddMemberEvent.target()/role() (PTag.parseKey, RoleTag.parse) | Buzz 'NIP-43' admin command (not the NIP-43 kind 8000); relay executes and never stores it. No `h` (tenant = connection host). Cross-group consistency: NIP-29/NIP-43 classifier should use the same ADDED/REMOVED. | +| 9031 | `RelayAdminRemoveMemberEvent` | p -> REMOVED_USER (U) | RelayAdminRemoveMemberEvent.target() (PTag.parseKey) | Buzz admin command, never stored. No `h`. | +| 9032 | `RelayAdminChangeRoleEvent` | p -> ROLE_CHANGED (U) | RelayAdminChangeRoleEvent.target()/role() (PTag.parseKey, RoleTag.parse) | UNCERTAIN: NIP-29 folds role assignment into put-user (9000), so ADDED+props role is the alternative. Owner-signed, never stored. | +| 9033 | `SetWorkspaceProfileEvent` | *none* | – | NIP-WP: only an `icon` URL tag (http(s)/data: URL) - not modelled. Only AUTHOR applies. | +| 9035 | `ArchiveRequestEvent` | p -> ARCHIVED (U); replaced-by -> REPLACED_BY (U); auth[owner] -> OWNER (U) | ArchiveRequestEvent.target()/replacedBy()/auth() (PTag.parseKey, ReplacedByTag.parse, AuthTag.parse -> OwnerAttestation.ownerPubKey) | Buzz NIP-IA request, NIP-70 protected; audited but not stored by the relay. Props on ARCHIVED: reason. ReplacedByTag.parse lacks hex64 check. OWNER link is unverified unless OwnerAttestation.verify(author) passes - suggest emitting only verified attestations. | +| 9036 | `UnarchiveRequestEvent` | p -> UNARCHIVED (U); auth[owner] -> OWNER (U) | UnarchiveRequestEvent.target()/auth() (PTag.parseKey, AuthTag.parse) | Buzz NIP-IA request, NIP-70 protected, not stored. Props: reason. | +| 9040 | `ModerationBanEvent` | p -> BANNED (U) | ModerationBanEvent.target()/expiresAt()/reason() (PTag.parseKey, ReasonTag.parse) | Mod-signed command, executed not stored; no `h`. Kind 9041 ModerationUnbanEvent collides with NIP-75 ZapGoalEvent (documented, not in EventFactory). | +| 9042 | `ModerationTimeoutEvent` | p -> TIMED_OUT (U) | ModerationTimeoutEvent.target()/expiresAt()/reason() (PTag.parseKey) | Mod-signed command, executed not stored; no `h`. | +| 9043 | `ModerationUntimeoutEvent` | p -> TIMEOUT_CLEARED (U) | ModerationUntimeoutEvent.target() (PTag.parseKey) | UNCERTAIN naming (alternative UNTIMED_OUT for symmetry with TIMED_OUT). Command, not stored. | +| 9044 | `ModerationResolveReportEvent` | report -> RESOLVED (E) | ModerationResolveReportEvent.report()/status()/action()/reason() (ReportTag.parse) | Custom `report` tag (not `e`) by design. BUG: ReportTag.parse does not check hex64. Command, not stored. No REPORTED_* links derivable without the 1984. | +| 10100 | `AgentProfileEvent` | *none* | – | Replaceable, content-only (loose JSON). Content `channel_ids` are group UUIDs (would be GROUP T via content if content JSON refs are ever modelled) - not modelled; schema flagged conservative. Only AUTHOR/ADDRESS. | +| 13535 | `ArchivedIdentitiesListEvent` | p -> ARCHIVED (U) | ArchivedIdentitiesListEvent.archivedIdentities() (PTag.parseKey) | Relay-signed replaceable snapshot, one bare `p` per archived identity; list entries named as the list names them (archived identities). | +| 20002 | `TypingIndicatorEvent` | h -> GROUP (T); e[root] -> ROOT (E); e[reply] -> PARENT (E) | TypingIndicatorEvent.channelId()/threadRootId()/threadReplyId() (GroupIdTag.parse, MarkedETag.parseAllThreadTags) | Ephemeral, never stored (graph may skip). Builder emits root only when root != reply, so a lone `reply` e is both ROOT and PARENT. parseAllThreadTags does not check hex64 on the id. | +| 24134 | `PairingEvent` | p -> RECIPIENT (U) | PairingEvent.recipientPubKey() (PTag.parseKey) | Buzz NIP-AB, ephemeral; the `p` is an EPHEMERAL session key, not a user identity (graph may want to skip ephemeral kinds). | +| 24200 | `ObserverFrameEvent` | p -> RECIPIENT (U); agent -> AGENT (U) | ObserverFrameEvent.recipientPubKey()/agentPubKey() (PTag.parseKey, AgentTag.parse) | Buzz NIP-AO, ephemeral. Telemetry: AGENT == author (self-link); control: AGENT == RECIPIENT (duplicate). Props: frame (telemetry/control). BUG: AgentTag.parse lacks hex64 check. | +| 24810 | `HuddleReactionEvent` | h -> GROUP (T) | HuddleReactionEvent.channelId() (GroupIdTag.parse) | Ephemeral. `h` is the EPHEMERAL huddle channel UUID, not the parent timeline channel. `reaction`/`sender_name` are values; NIP-30 `emoji` URL not modelled. No reacted target. | +| 30174 | `EngramEvent` | p -> OWNER (U) | EngramEvent.ownerPubKey() (PTag.parseKey) | Buzz NIP-AE. TRAP: `d` is a blinded 64-hex HMAC that looks like an id/pubkey by shape - must never be shape-matched into a link. | +| 30175 | `PersonaEvent` | content{respond_to_allowlist} -> ALLOWED (U) | new parser needed (PersonaEvent.personaOrNull()?.respondToAllowlist) | UNCERTAIN: reference lives in plaintext content JSON, not tags; entries appear to be pubkey hex (test uses a truncated key) - validate hex64. `d` = persona slug (ADDRESS). | +| 30176 | `TeamEvent` | content{persona_ids} -> MEMBER (A) | new parser needed (TeamEvent.teamOrNull()?.personaIds -> Address(30175, author, id)) | UNCERTAIN: content JSON; persona ids assumed to be 30175 `d` slugs under the SAME author (a team groups the owner's personas) - confirm against Buzz team_events.rs. | +| 30177 | `ManagedAgentEvent` | d -> AGENT (U); content{persona_id} -> PERSONA (A); content{respond_to_allowlist} -> ALLOWED (U) | ManagedAgentEvent.agentPubKey() (dTag); new parser needed for agentOrNull()?.personaId / respondToAllowlist | The `d` tag IS the agent pubkey (like NIP-85 d -> SUBJECT); dTag() not hex-validated. UNCERTAIN: persona_id assumed to be the owner's own 30175 slug. | +| 30300 | `EventReminderEvent` | *none* | – | Buzz NIP-ER: public tags are d/not_before/expiration/alt only. The reminder target (id / a) is inside self-encrypted content - private, not modelled (would be REMINDED if ever decrypted). | +| 30350 | `PushLeaseEvent` | *none* | – | Buzz NIP-PL: d = installation id, `exec` = gateway key id (opaque, not a Nostr pubkey), expiration; descriptor encrypted. Only AUTHOR/ADDRESS. | +| 30620 | `WorkflowDefEvent` | h -> GROUP (T) | WorkflowDefEvent.channel() (GroupIdTag via firstTagValue) | d = workflow UUID (ADDRESS). `name` is a value. workflowChannel() uses firstTagValue with no emptiness check. | +| 30622 | `DmVisibilityEvent` | p -> VIEWER (U); h -> HIDDEN (T) | DmVisibilityEvent.viewerFromPTag()/hiddenChannels() (PTag.parseKey, GroupIdTag.parse) | Relay-signed addressable; d == p == viewer pubkey (duplicate, d not validated). HIDDEN extended to a T target (group id): these `h` are hidden DMs, not scope, so they are HIDDEN not GROUP. | +| 39006 | `WindowBoundsEvent` | h -> GROUP (T) | WindowBoundsEvent.channelId() (GroupIdTag.parse) | Relay-synthesized, never stored. d = :. content next_cursor.id is a pagination boundary event id - not modelled (not a statement). | +| 40002 | `StreamMessageV2Event` | h -> GROUP (T); p -> MENTION (U); e[root] -> ROOT (E); e[reply] -> PARENT (E) | StreamMessageV2Event.channel()/mentions() (GroupIdTag.parse, PTag.parseKey); buzzThreadRoot()/buzzThreadReply() in buzz/threading (not exposed on the class) | Thread e-tags per Buzz thread_tags (shared with 45003, per buzz/threading KDoc) but the Quartz class/builder neither reads nor writes them - GAP. Lone `reply` marker = direct reply, so it is both ROOT and PARENT. `broadcast` is a flag. Content nostr: URIs not parsed by the class. | +| 40003 | `StreamMessageEditEvent` | h -> GROUP (T); e -> EDITED (E) | StreamMessageEditEvent.channel()/editedMessage() (ETag.parseId) | Buzz build_edit. | +| 40004 | `StreamMessagePinnedEvent` | h -> GROUP (T); e -> PIN (E) | StreamMessagePinnedEvent.channel()/pinnedMessage() (ETag.parseId) | PIN extended to a channel pin. Tag shape inferred (no Buzz builder). | +| 40005 | `StreamMessageBookmarkedEvent` | h -> GROUP (T); e -> BOOKMARK (E) | StreamMessageBookmarkedEvent.channel()/bookmarkedMessage() (ETag.parseId) | Tag shape inferred (no Buzz builder). | +| 40006 | `StreamMessageScheduledEvent` | h -> GROUP (T) | StreamMessageScheduledEvent.channel() (GroupIdTag.parse) | Schema inferred; only `h` modelled. | +| 40007 | `StreamReminderEvent` | h -> GROUP (T); p -> RECIPIENT (U); e -> REMINDED (E) | StreamReminderEvent.channel()/recipients()/targetMessage() (PTag.parseKey, ETag.parseId) | UNCERTAIN: no Buzz builder; `e` target is read but never written by Quartz's builder. RECIPIENT = 'the user the reminder is for'. | +| 40008 | `StreamMessageDiffEvent` | h -> GROUP (T); l -> TAG (T) | StreamMessageDiffEvent.channel()/diffMeta() (GroupIdTag.parse, LanguageTag.parse) | `l` here is the diff's programming language, NOT NIP-32 (target Tag(l, lang)). repo (URL), commit/parent-commit (git SHAs), file, branch, pr are not Nostr entities - not modelled. | +| 40099 | `SystemMessageEvent` | h -> GROUP (T); content{actor} -> ACTOR (U); content{target}[member_joined] -> ADDED_USER (U); content{target}[member_removed\|member_left] -> REMOVED_USER (U); content{target_event_id}[message_deleted] -> DELETED (E); content{participants}[dm_created] -> PARTICIPANT (U) | SystemMessageEvent.channel()/payload() (SystemMessagePayload.actor/target/targetEventId/participants); new parser needed to map by type | Relay-signed. UNCERTAIN: all refs are in content JSON, keyed by payload.type; no hex validation on actor/target/target_event_id/participants. Props: type, reason_code. | +| 40100 | `CanvasEvent` | h -> GROUP (T) | CanvasEvent.channel() (GroupIdTag.parse) | Buzz build_set_canvas; markdown content, no nostr: parsing. | +| 40901 | `ChannelSummaryEvent` | h -> GROUP (T) | ChannelSummaryEvent.channel() (GroupIdTag.parse) | Relay-only sidecar; schema unconfirmed (no Buzz emitter). content channel_id duplicates `h`. | +| 40902 | `PresenceSnapshotEvent` | content{entries[].pubkey} -> SUBJECT (U) | new parser needed (PresenceSnapshotEvent.snapshot()?.entries) | UNCERTAIN: relay-only sidecar, schema unconfirmed (no Buzz emitter; relay answers with 20001s whose `p` is the 'subject'). SUBJECT extended from NIP-85 to a relay's presence statement; props status, last_seen_at. No `h`. | +| 41001 | `DmCreatedEvent` | d -> GROUP (T); p -> PARTICIPANT (U) | DmCreatedEvent.dmId()/participants() (DTag via firstTagValue, PTag.parseKey) | Relay-signed. The DM id rides in `d` on a REGULAR kind (not addressable) - emit as Tag("h", id) so it joins the DM's GROUP links. dmId() returns "" when absent (must not emit). | +| 41010 | `DmOpenEvent` | p -> PARTICIPANT (U) | DmOpenEvent.participants() (PTag.parseKey) | Command (1-8 participants); relay replies with 41001. | +| 41011 | `DmAddMemberEvent` | h -> GROUP (T); p -> ADDED_USER (U) | DmAddMemberEvent.channelId()/member() (GroupIdTag.parse, PTag.parseKey) | – | +| 41012 | `DmHideEvent` | h -> HIDDEN (T) | DmHideEvent.channelId() (GroupIdTag.parse) | HIDDEN (NIP-28 'hide message') extended to a T target: the `h` is the DM being hidden, the object of the action, not just scope. | +| 42000 | `ProductFeedbackEvent` | *none* | – | Only `category` value and optional `imeta` (media URLs, not modelled). Never stored by the relay. | +| 43001 | `JobRequestEvent` | h -> GROUP (T); p -> AGENT (U) | JobRequestEvent.channel()/target() (GroupIdTag.parse, PTag.parseKey) | UNCERTAIN: 43001-43006 reserved in Buzz with no builder; Quartz tag layout is best-effort. | +| 43002 | `JobAcceptedEvent` | e -> REQUEST (E); h -> GROUP (T); p -> REQUEST_AUTHOR (U) | JobAcceptedEvent.jobRequest()/channel()/requester() (ETag.parseId, GroupIdTag.parse, PTag.parseKey) | UNCERTAIN schema (reserved kind). | +| 43003 | `JobProgressEvent` | e -> REQUEST (E); h -> GROUP (T) | JobProgressEvent.jobRequest()/channel() (ETag.parseId, GroupIdTag.parse) | UNCERTAIN schema. Props: status. | +| 43004 | `JobResultEvent` | e -> REQUEST (E); h -> GROUP (T); p -> REQUEST_AUTHOR (U) | JobResultEvent.jobRequest()/channel()/requester() | UNCERTAIN schema. Props: status. | +| 43005 | `JobCancelEvent` | e -> REQUEST (E); h -> GROUP (T) | JobCancelEvent.jobRequest()/channel() | UNCERTAIN schema. | +| 43006 | `JobErrorEvent` | e -> REQUEST (E); h -> GROUP (T); p -> REQUEST_AUTHOR (U) | JobErrorEvent.jobRequest()/channel()/requester() | UNCERTAIN schema. Props: status. | +| 44100 | `MemberAddedNotificationEvent` | p -> ADDED_USER (U); h -> GROUP (T); content{actor} -> ACTOR (U) | MemberAddedNotificationEvent.target()/channel()/actor() (PTag.parseKey, firstTagValue(h), MembershipNotificationContent.parse) | Relay-signed. Self-join reports actor == target (ACTOR == ADDED). | +| 44101 | `MemberRemovedNotificationEvent` | p -> REMOVED_USER (U); h -> GROUP (T); content{actor} -> ACTOR (U) | MemberRemovedNotificationEvent.target()/channel()/actor() | Relay-signed. | +| 44200 | `AgentTurnMetricEvent` | p -> OWNER (U); agent -> AGENT (U) | AgentTurnMetricEvent.ownerPubKey()/agentPubKey() (PTag.parseKey, AgentTag.parse) | Buzz NIP-AM. AGENT normally == author (self-link). AgentTag.parse lacks hex64 check. | +| 45001 | `ForumPostEvent` | h -> GROUP (T); p -> MENTION (U) | ForumPostEvent.channel()/mentions() (firstTagValue(h), PTag.parseKey) | Thread root of a forum thread. | +| 45002 | `ForumVoteEvent` | h -> GROUP (T); e -> VOTED (E) | ForumVoteEvent.channel()/target()/direction() (ETag.parseId) | Alternative: REACTED (a +/- vote is reaction-like); no author `p`, so no VOTED_AUTHOR on the wire. | +| 45003 | `ForumCommentEvent` | h -> GROUP (T); e[root] -> ROOT (E); e[reply] -> PARENT (E); p -> MENTION (U) | ForumCommentEvent.channel()/threadRoot()/replyTo()/mentions() (buzzThreadRoot/buzzThreadReply, PTag.parseKey) | Buzz thread_tags: a direct reply has ONLY a `reply` marker (root == parent), so the method must emit ROOT too when no root marker is present (threadRoot() returns null there). | +| 46001 | `WorkflowTriggeredEvent` | h -> GROUP (T) | WorkflowTriggeredEvent.channel() | Relay-emitted lifecycle; run/step ids not modelled (schema not fixed). | +| 46002 | `WorkflowStepStartedEvent` | h -> GROUP (T) | WorkflowStepStartedEvent.channel() | Same as 46001. | +| 46003 | `WorkflowStepCompletedEvent` | h -> GROUP (T) | WorkflowStepCompletedEvent.channel() | Same as 46001. | +| 46004 | `WorkflowStepFailedEvent` | h -> GROUP (T) | WorkflowStepFailedEvent.channel() | Same as 46001. | +| 46005 | `WorkflowCompletedEvent` | h -> GROUP (T) | WorkflowCompletedEvent.channel() | Same as 46001. | +| 46006 | `WorkflowFailedEvent` | h -> GROUP (T) | WorkflowFailedEvent.channel() | Same as 46001. | +| 46007 | `WorkflowCancelledEvent` | h -> GROUP (T) | WorkflowCancelledEvent.channel() | Same as 46001. | +| 46010 | `WorkflowApprovalRequestedEvent` | h -> GROUP (T); p -> APPROVER (U) | WorkflowApprovalRequestedEvent.channel()/approver() (PTag.parseKey) | Relay-signed needs-action item. | +| 46011 | `WorkflowApprovalGrantedEvent` | h -> GROUP (T) | WorkflowApprovalGrantedEvent.channel() | Relay lifecycle event; no link to the 46010/46030 modelled. | +| 46012 | `WorkflowApprovalDeniedEvent` | h -> GROUP (T) | WorkflowApprovalDeniedEvent.channel() | Same as 46011. | +| 46020 | `WorkflowTriggerEvent` | d -> TRIGGERED (A) | WorkflowTriggerEvent.workflowId() (DTag via firstTagValue) -> Address(30620, author, d) | UNCERTAIN: the `d` (on a REGULAR kind) holds only the workflow UUID; address assumes owner == author, which the relay enforces ('only the workflow owner may trigger'). | +| 46030 | `ApprovalGrantEvent` | *none* | – | `d` (on a regular kind) = approval token hash - opaque, not an event id, not modelled. Only AUTHOR. | +| 46031 | `ApprovalDenyEvent` | *none* | – | Same as 46030. | +| 48001 | `AuditEntryEvent` | p -> ACTOR (U); object -> AUDITED (E\|T) | AuditEntryEvent.actor()/objectId() (PTag.parseKey, ObjectTag.parse) | UNCERTAIN: schema is a Quartz-side projection - Buzz never emits 48001 on the wire. `object` is polymorphic (event id, channel UUID, media sha256): E only when the action targets an event, else T; a 64-hex sha256 must not be shape-matched as an event. | +| 48100 | `HuddleStartedEvent` | h -> GROUP (T) | HuddleStartedEvent.channelId() | Content ephemeral_channel_id (UUID) not modelled. | +| 48101 | `HuddleParticipantJoinedEvent` | h -> GROUP (T); p -> PARTICIPANT (U) | HuddleParticipantJoinedEvent.channelId()/participant() (PTag.parseKey) | Relay-signed; participant is the `p`, not the author. | +| 48102 | `HuddleParticipantLeftEvent` | h -> GROUP (T); p -> PARTICIPANT (U) | HuddleParticipantLeftEvent.channelId()/participant() | Relay-signed. PARTICIPANT for both join and leave: the kind says which. | +| 48103 | `HuddleEndedEvent` | h -> GROUP (T); p -> PARTICIPANT (U) | HuddleEndedEvent.channelId()/participant() | `p` = last participant (optional). | +| 48106 | `HuddleGuidelinesEvent` | h -> GROUP (T) | HuddleGuidelinesEvent.channelId() | Schema uncertain (no Buzz constructor). | + +### `experimental` (57) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 31 | `ExternalCitationEvent` | open_timestamp -> OPEN_TIMESTAMP (E); g -> TAG (T) | ExternalCitationEvent.openTimestamp() (CitationTags.OPEN_TIMESTAMP; no 64-hex validation); CitationEvent.geohash() (CitationTags.GEOHASH) | Spec: silberengel/jumble citation vocabulary (no NIP). u/url (the cited URL) is not modelled in v1 (not an allowlisted value tag; would be TAG if u joins the list). Bug: openTimestamp() returns any non-empty string, no id-shape check. Kind 30 (internal citation) deliberately unmodelled: collides with Jester chess. | +| 32 | `HardcopyCitationEvent` | g -> TAG (T) | CitationEvent.geohash() (CitationTags.GEOHASH) | Spec: jumble citations (no NIP). doi, published_in, author are free-text values, not link targets. Only g carries a link, via the shared base. | +| 33 | `PromptCitationEvent` | g -> TAG (T) | CitationEvent.geohash() (CitationTags.GEOHASH) | Spec: jumble citations (no NIP). u (conversation URL) and llm are not modelled. | +| 82 | `FhirResourceEvent` | *none* | – | No spec reference in KDoc; FHIR JSON payload in content, class reads no tags. Anything an initializer adds is unparsed. | +| 1010 | `TextNoteModificationEvent` | e -> EDITED (E); p -> EDITED_AUTHOR (U) | editedNote() = firstTaggedEvent(); p read only via generic taggedUsers (new parser needed for the p slot) | Draft edits NIP (kind 1010). Only the FIRST e is read (firstTaggedEvent). summary is a value tag. Draft-plan correction: the '1010 claimed by two classes' note is wrong: GoodWikiRelayListEvent.KIND is 10102, not 1010, and EventFactory types 1010 only as TextNoteModificationEvent (the text scan matched the 10102 prefix). | +| 1064 | `FileStorageEvent` | *none* | – | NIP-95 draft (base64 file blob in content). Only m (mime) tag. | +| 1065 | `FileStorageHeaderEvent` | e -> FILE_DATA (E) | dataEventIds() / dataEvent() (ETag::parseId / ETag::parse) | NIP-95 draft (never merged). x/url/image/thumb/fallback/service/magnet are hashes and URLs, not modelled. The e tag carries an author slot (EventHintBundle.toETag) that is a hint, not a separate statement. | +| 1163 | `ProfileGalleryEntryEvent` | e -> SOURCE (E) | fromEvent() (ETag::parseId) | Amethyst profile-gallery kind (no NIP). url/x/ox/imeta-style fields are not modelled. No p for the source author; if one is added later it would be SOURCE_AUTHOR (rule 3). | +| 1301 | `WorkoutRecordEvent` | exercise[coordinate] -> EXERCISE (A); template -> TEMPLATE (A); t -> HASHTAG (T) | exerciseSetAddressIds() (ExerciseSetTag::parseAddressId), templateAddressId() (TemplateTag::parseAddressId), addressHints()/linkedAddressIds(); hashtags via generic HashtagTag | NIP-101e (draft) + RUNSTR dialect. RUNSTR exercise tag is a plain verb (not a link); ExerciseSetTag.isCoordinate distinguishes. Oddity: builder writes a d tag on a regular (non-addressable) kind 1301, so d has no replaceability meaning. Implements RootScope (can be a NIP-22 root). | +| 1315 | `RoadEventReportEvent` | t -> HASHTAG (T); g -> TAG (T) | roadEventTypeCode() (RoadEventTypeTag::parseCode), geohashes() | Roadstr draft NIP (jooray/roadstr nips/roadstr.md). t carries the road-event type code (police, accident...), a category rather than a free hashtag; HASHTAG per the t rule. lat/lon/expiration are values. | +| 1316 | `RoadEventConfirmationEvent` | e -> CONFIRMED (E); g -> TAG (T) | reportId() / linkedEventIds() (RoadReportTag::parseId), geohashes() | Roadstr draft NIP. RoadReportTag reads an author at e[3] (Quartz writes the report author there); the spec does not define it, so it stays a hint, not a CONFIRMED_AUTHOR link. UNCERTAIN: if denials must be counted separately, split per rule 4 (CONFIRMED / DENIED). | +| 1808 | `AudioHeaderEvent` | *none* | – | No spec reference. download_url / stream_url / waveform only: URLs and data, not modelled. | +| 2473 | `BirdDetectionEvent` | i -> TAG (T); g -> TAG (T) | speciesReference() (firstTagValue("i"), http(s)-filtered); Event.geohashes() | Birdstar app kind (no NIP). i is a Wikidata species URL (NIP-73 style). n (scientific name) is a value, not modelled. | +| 3063 | `SoftwareAssetEvent` | i -> TAG (T) | appId() (AppIdTag::parse) | NIP-82 (draft; 32267 is listed in the NIPs README but 82.md is not on master). i is the application's d-tag identifier, not a NIP-73 id. UNCERTAIN: it effectively names the address 32267::; a derived APPLICATION (A) link would be more useful than TAG. url/x/f/apk_certificate_hash are not modelled. | +| 4312 | `AdminCommandEvent` | a -> ROOT (A); p[action=kick] -> KICKED (U); p[action=mute] -> CHANNEL_MUTED (U) | room() and targetPubkey() (raw first a / first p, no validation); action() | nostrnests EGG-07 (ephemeral kind). a names the kind-30312 room; ROOT chosen for consistency with 10312 presence and 1311 chat, but this a carries no root marker: UNCERTAIN (alternative: a new ROOM). Rule 4 split kick vs mute on the same U target. Weak parsing: room()/targetPubkey() take the first a/p without shape checks. | +| 6969 | `ZapPollEvent` | e[root] -> ROOT (E); e[reply] -> PARENT (E); e[mention] -> MENTION (E); q -> QUOTE (E,A); a -> MENTION (A); p -> MENTION (U); content nostr: -> MENTION (E,A,U) | BaseThreadedEvent.root()/reply()/threadTags() (MarkedETag), QTag::parseEventId/parseAddressId, ATag::parseAddressId, PTag::parseKey, citedNIP19() | Zap polls (old NIP-69 draft); a kind-1-shaped threaded note plus poll_option tags, so it should take kind 1's classification verbatim. UNCERTAIN: unmarked a tags treated as MENTION pending kind 1's decision. Known Quartz bug applies: QTag.parseAddressId rejects every address. Votes are zaps (9734/9735) carrying poll_option; not this class. | +| 9998 | `ListHeaderEvent` | *none* | – | Decentralized Lists (Tapestry pre-NIP, nous-clawds4/tapestry protocols/nips/decentralized-lists.md). names/titles/slugs/required/allowed/item-kind/description are values; item-kind names a kind, not an entity. Items point at the header, not vice versa. | +| 9999 | `ListItemEvent` | z -> PARENT_LIST (E,A,T); p -> ITEM (U); e -> ITEM (E); a -> ITEM (A); t -> ITEM (T) | parentLists() (ParentListTag::parse/classify: EventId \| Coordinate \| Name), itemPubKeys() (PTag), itemEvents() (ETag), itemAddresses() (ATag::parse), itemStrings() (HashtagTag); hint providers | Decentralized Lists spec. t here is a list VALUE, case-preserved, not a hashtag (Quartz README says so), hence ITEM (T) instead of HASHTAG. e items may carry the author at e[3] (itemEvent pads the relay slot): a hint. A 9999 may also declare a list (nonstandard method) and then carries header tags (values only). Cross-cutting: Event.dListParents() reads z on ANY kind (Cross-NIP Compatibility), so PARENT_LIST can come from foreign kinds too. | +| 10023 | `EphemeralChatListEvent` | *none* | – | Amethyst ephemeral-chat room list. group tags are [room name, relay URL] pairs: relay-scoped identifiers, not Nostr entities, so not modelled in v1 (would be SUBSCRIBED (T) if room ids become targets). Private rooms live NIP-44 encrypted in content. | +| 11871 | `AttestorProficiencyEvent` | k -> TAG (T) | kinds() (recommendation.tags.KindTag::parse) | Attestations draft NIP (kinds 11871/31871/31872/31873; spec not fetched). The attestor declares the kinds it can attest. | +| 12473 | `BirdexEvent` | i -> TAG (T) | species() (adjacent n/i pairing, asWebReference()) | Birdstar app kind (no NIP). Each i is the Wikidata URL of a species on the life list; n is a value. Arguably list entries (MEMBER (T)), but i -> TAG per the plain-tag rule. | +| 20000 | `GeohashChatEvent` | g -> TAG (T); t[teleport] -> HASHTAG (T) | geohash() (GeoHashTag::parse), isTeleported() (TeleportTag::match) | Bitchat location channels (ephemeral). g is the exact channel cell (single tag, no mip-map). t=teleport is a flag, not a topic; HASHTAG per the t rule (could equally be dropped). n (nickname) is a value. Authors are per-geohash derived keys, unlinkable to the main npub by design. | +| 20001 | `GeohashPresenceEvent` | g -> TAG (T) | geohash() (GeoHashTag::parse) | Bitchat presence (ephemeral). Kind 20001 is also buzz PresenceUpdateEvent; EventFactory disambiguates by the presence of a g tag, so links() must only apply when the class is actually GeohashPresenceEvent. | +| 21001 | `OfferEvent` | p -> RECIPIENT (U); e -> REQUEST (E) | recipientPubKey() (PTag::parseKey), requestId() (ETag::parseId) | CLINK Offers (shocknet/clink specs/clink-offers.md). Ephemeral; content NIP-44 to the p. Same kind for request, response and receipt. | +| 21002 | `DebitEvent` | p -> RECIPIENT (U); e -> REQUEST (E) | recipientPubKey() (PTag::parseKey), requestId() (ETag::parseId) | CLINK Debits (shocknet/clink specs/clink-debits.md). Ephemeral, NIP-44 content. | +| 21003 | `ManageEvent` | p -> RECIPIENT (U); e -> REQUEST (E) | recipientPubKey() (PTag::parseKey), requestId() (ETag::parseId) | CLINK Manage (shocknet/clink specs/clink-manage.md). Ephemeral, NIP-44 content. | +| 23333 | `EphemeralChatEvent` | *none* | – | Ephemeral chat: d = room name and relay = relay URL (RoomTag, RelayTag); neither is a Nostr entity, so not modelled in v1. Note d is used on an ephemeral kind as a room label, not an address. | +| 23903 | `WakeUpEvent` | e -> ABOUT (E); p -> ABOUT_AUTHOR (U); k -> TAG (T) | eventIds() (ETag::parseId), authorKeys() (PTag::parseKey), kinds() (KindTag) | Amethyst experimental push/wake kind (ephemeral, no spec). notifies() returns true for everyone, so p must not be read as RECIPIENT. | +| 30040 | `PublicationIndexEvent` | a -> MEMBER (A); e -> MEMBER (E); p -> MENTION (U); t -> HASHTAG (T); A/E -> SOURCE (A,E) | sections() / PublicationSectionRef.fromTags (a and e, in order), linkedAddressIds() (ATag), linkedPubKeys() (PTag), topics() (hashtags()); A/E: new parser needed | NKBIP-01 (GitCitadel; spec not fetched). MEMBER as in the draft (30040 already listed). Props for MEMBER: order, inline title, level. UNCERTAIN: p semantics unverified (author/contributor pubkey vs mention); author tag is a human name, not a pubkey. EventHintProvider missing: e sections are not in any hint provider. | +| 30041 | `PublicationContentEvent` | T/c -> PUBLICATION (A); wikilink -> WIKILINK (E,T); wikilink[pubkey] -> WIKILINK_AUTHOR (U) | publicationAddress() (T, else c, + own pubkey -> 30040 address), wikilinks() (WikilinkTag::parse) | NKBIP-01. PUBLICATION target is DERIVED (T/c carry only the index d; pubkey assumed = section author), so the link is an inference. AsciiDoc content: no citedNIP19 parsing, so no content MENTIONs today. | +| 30045 | `BookshelfDirectoryEvent` | a -> MEMBER (A); e -> MEMBER (E) | items() (PublicationSectionRef.fromTags), linkedAddressIds() (ATag) | Bookshelf directory (no NIP; already MEMBER in the draft). Hint gap: e entries are not in any EventHintProvider. | +| 30053 | `NNSEvent` | *none* | – | NNS (Nostr name system) record: ip4/ip6/version values only. Bug: neither build() writes a d tag on this addressable kind (every record collapses onto d=""). | +| 30142 | `LearningResourceEvent` | t -> HASHTAG (T) | topics() (hashtags()) | No NIP (edu publishers, schema.org-style flat tags). about:id / learningResourceType:id are vocabulary URIs, encoding:contentUrl a URL: not modelled. | +| 30296 | `InteractiveStoryPrologueEvent` | option -> OPTION (A) | options() (StoryOptionTag::parse) | Interactive stories (no NIP in KDoc). RootScope. StoryOptionTag.parse keeps the relay as a raw string (not normalized). | +| 30297 | `InteractiveStorySceneEvent` | option -> OPTION (A) | options() (StoryOptionTag::parse) | Interactive stories. RootScope. | +| 30298 | `InteractiveStoryReadingStateEvent` | A -> ROOT (A); a -> CURRENT_SCENE (A) | root() (RootSceneTag::parse, uppercase A), currentScene() (ATag::parseAddress) | d = the root story address (a reference in d). BUGS: build() calls rootScene(rootTag), which writes a lowercase a (ATag.toATagArray), and currentScene() then addUnique-replaces it, so Quartz-built events carry no A and root() returns null; build() also swaps storyImage(summary)/storySummary(image). | +| 30392 | `UserTrustedListEvent` | p -> MEMBER (U); a -> ABOUT (A); observer -> OBSERVER (U); source-tag -> SOURCE_TAG (E) | members() (PubKeyMemberTag), aboutAddresses() (ATag), observer() (ObserverTag), sourceTag() (SourceTag) | Tapestry Trusted Lists (+10 of NIP-85 kinds). MEMBER already in the draft; props score (0..100). source-tag also carries the tag's author and slug (hint/provenance; no link proposed). Hint providers ignore observer and source-tag. | +| 30393 | `EventTrustedListEvent` | e -> MEMBER (E); a -> ABOUT (A); p -> ABOUT (U); observer -> OBSERVER (U); source-tag -> SOURCE_TAG (E) | members() (EventMemberTag), aboutAddresses(), aboutPubKeys(), observer(), sourceTag() | Tapestry Trusted Lists. By convention the p is the observer (for #p discovery), duplicating the observer tag; kept as ABOUT (the slot's role). e[3] is a score, not a NIP-10 marker. | +| 30394 | `AddressableTrustedListEvent` | a -> MEMBER (A); p -> ABOUT (U); observer -> OBSERVER (U); source-tag -> SOURCE_TAG (E) | members() (AddressMemberTag), aboutPubKeys(), observer(), sourceTag() | Tapestry Trusted Lists. AddressMemberTag.parseAddressId returns any non-empty a value (no coordinate-shape check). | +| 30395 | `ExternalIdTrustedListEvent` | i -> MEMBER (T); a -> ABOUT (A); p -> ABOUT (U); observer -> OBSERVER (U); source-tag -> SOURCE_TAG (E) | members() (ExternalIdMemberTag), aboutAddresses(), aboutPubKeys(), observer(), sourceTag() | Tapestry Trusted Lists. i members are NIP-73 external ids: MEMBER (T) rather than TAG, since they are list entries (MEMBER needs T added to its targets). | +| 30817 | `NipTextEvent` | a[fork] -> FORK (A); e[fork] -> FORK (E); q -> QUOTE (E,A); a -> MENTION (A); p -> MENTION (U); k -> TAG (T); content nostr: -> MENTION (E,A,U) | forkFromAddress() (ForkTag::parseAddress), forkFromVersion() (MarkedETag.parseForkedEventId), QTag::parseEventId/parseAddressId, ATag::parseAddressId, citedNIP19(), kinds (KindTag) | NIPs-on-Nostr (draft). FORK extends from kind 1 to A targets. BUG: ForkTag.parse / parseValidAddress require kind 34550 (CommunityDefinitionEvent, copy-paste from NIP-72) instead of 30817; forkFromAddress() uses parseAddress, which checks 30817 but not the fork marker, so any a to a 30817 is read as the fork source. Known QTag.parseAddressId bug applies. | +| 31337 | `AudioTrackEvent` | p -> PARTICIPANT (U) | participants() (ParticipantTag::parse) | Zapstr-style audio track (no NIP in KDoc). Zapstr p tags carry a role (Host/Artist) at p[3], which ParticipantTag ignores: should become a prop. c is a type/genre value (not allowlisted); media/cover URLs not modelled. | +| 31871 | `AttestationEvent` | e -> ASSERTION (E); a -> ASSERTION (A); request -> REQUEST (A) | assertionEventId() (ETag), assertionAddrId() (ATag), requestId() / requestAddress() (RequestTag) | Attestations draft (spec not fetched). BUG: RequestTag is copy-pasted from NIP-72 ApprovedAddressTag: parse() returns ApprovedAddressTag and rejects 34550 addresses (meaningless here); linkedAddressIds()/addressHints() ignore the request tag. e carries the attested author at e[3] (hint). | +| 31872 | `AttestationRequestEvent` | e -> ASSERTION (E); a -> ASSERTION (A); p -> ATTESTOR (U) | assertionEventId() (ETag), assertionAddrId() (ATag), linkedPubKeys() (PTag); attestorPubKeys builder | Attestations draft. Naming bug: assertionPubkey()/assertionPTag() read the p, which the builder fills with ATTESTORS, not the assertion's author. cashu_token is a value (payment), not modelled. | +| 31873 | `AttestorRecommendationEvent` | d -> RECOMMENDED (U); k -> TAG (T) | new parser needed (the attestor pubkey is only in dTag(); builder dTag(attestorPubKey)); kinds() (KindTag) | Attestations draft. Reuses RECOMMENDED (NIP-89) with a new U target: the recommended attestor, props kinds. A pubkey in a d tag is invisible to every hint provider and to #p filters; no accessor validates it is 64-hex. | +| 31987 | `RelayReviewEvent` | *none* | – | Relay review (no merged NIP). The reviewed thing is a relay URL (d, or relay tag): relays are not link targets in v1. If relays become nodes, it would be RATED. | +| 32176 | `BlossomPieceIndexEvent` | r -> TAG (T) | url() (firstValue("r")) | No NIP. x (whole-file hash), b (piece hashes) and blossom (servers) are not modelled; note b here means piece hash, unrelated to Tapestry's b (inherit-from). | +| 32267 | `SoftwareApplicationEvent` | a -> RELEASE (A); t -> HASHTAG (T) | appLinks() (ATag::parse), topics() (HashtagTag) | NIP-82 draft. UNCERTAIN: a semantics not documented in Quartz (appLink builder has no KDoc); zapstore apps a-tag their latest 30063 release, hence RELEASE. repository/url/icon/image are URLs, f platform a value. | +| 33401 | `ExerciseTemplateEvent` | *none* | – | NIP-101e draft exercise template (POWR). Referenced by 1301 EXERCISE links; carries only values (title, format, format_units, equipment, difficulty). | +| 33863 | `FundraiserEvent` | t -> HASHTAG (T) | topics() (hashtags()) | Agora app kind (Ditto, no NIP). w = on-chain donation addresses, goal/deadline values: not modelled. Zaps to this event arrive as ZAPPED from 9735s. | +| 34139 | `MusicPlaylistEvent` | a -> CURATED (A); t -> HASHTAG (T) | trackAddresses() (ATag::parseAddress filtered to kind 36787); hashtags | No NIP in KDoc. A playlist is a published curation set of tracks, like 30004-30006, so CURATED (props: order). UNCERTAIN alternative per rule 7: TRACK. Non-track a tags are preserved by edit() but not interpreted. | +| 34238 | `VideoCollaborationEvent` | a -> COLLABORATED (A); p -> COLLABORATED_AUTHOR (U) | video() (ATag::parseAddress), videoAuthor() (PTag::parseKey) | divine-web / divine-mobile convention (no NIP). d may also be the video coordinate (divine-mobile). Listed as REFERENCE-only in the draft; now classified. UNCERTAIN: if declined answers matter to queries, split per rule 4. | +| 34259 | `EntityRatingEvent` | d -> RATED (E,A,U,T); a -> RATED (A); A -> RATED (A); e -> RATED (E); p -> RATED_AUTHOR (U); k -> TAG (T) | targetIdentifier()/mark() (d with mark prefix), targetAddress() (ATag, RootAddressTag), targetEventId() (ETag), targetAuthor() (PTag), targetKind() (ReplyKindTag) | abh3po/nostr-polls XYZ.md. d target type depends on m (event id -> E, profile -> U, coordinate -> A, hashtag/books/movies/relay -> T; relay ones fall under the not-modelled rule). Props: stars/rating, mark. a and A duplicate the same coordinate: emit one link. | +| 36787 | `MusicTrackEvent` | t -> HASHTAG (T) | hashtags (HashtagTag); builder hashtag("music") | No NIP in KDoc. artist/album are free-text names, url/video/image URLs. edit() mentions zap split tags being preserved but no accessor reads them (NIP-57 zap splits would need a relation decided for all kinds). | +| 38192 | `Ps1SaveEvent` | *none* | – | PS1 memory-card blocks (no NIP). m (memory card id), x (hash), block/state/filename/region/title are values. | +| 39998 | `AddressableListHeaderEvent` | b -> INHERIT_FROM (A); concept-graph -> CONCEPT_GRAPH (A) | inheritFrom() (InheritFromTag::parse), conceptGraph() (ConceptGraphTag::parse, else computed) | Tapestry drafts on Decentralized Lists. CONCEPT_GRAPH is computable when the tag is absent (39999::-concept-graph): only emit it when present. json tag may embed node uuids (addresses) in JSON: not modelled. b-tag-deferred marker is not a link. | +| 39999 | `AddressableListItemEvent` | z -> PARENT_LIST (E,A,T); p -> ITEM (U); e -> ITEM (E); a -> ITEM (A); t -> ITEM (T); b -> INHERIT_FROM (A); n -> ELEMENT_OF (A); s -> SUBSET_OF (A); q -> QUOTE (E,A) | parentLists() (ParentListTag), itemPubKeys()/itemEvents()/itemAddresses()/itemStrings(), inheritFrom() (InheritFromTag), elementOf() (ElementOfTag), subsetOf() (SubsetOfTag); q: new parser needed (CurationCopy only writes it) | Tapestry. q appears on assistant curation copies, pointing back to the original (address and exact version, author at q[3]); QUOTE reused, UNCERTAIN (a COPIED relation would be more precise). Taggings (TagElement, PubKeyTagging, EventTagging, TagPin) overload the item slots (e.g. PubKeyTagging: p = target, a/e = the tag applied) and are only told apart by deployment-configured z namespaces, so links() can only emit the generic ITEM; polarity/curation-method are props. Known QTag.parseAddressId bug breaks the address q. | + +### `nip90Dvms` (40) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 5000 | `DvmTextExtractionRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people) | DVM spec kinds/5000: input is a url (audio/video) or event. Quartz build() writes i[url]; reads inputs(), outputMimeType(), range/alignment params. NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 5001 | `DvmSummarizationRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people) | DVM spec kinds/5001: example mixes i[event] and i[job]. Quartz build() writes i[event] per eventId (inputEvent). NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 5002 | `DvmTranslationRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people) | DVM spec kinds/5002: i[event] to translate. Quartz build() writes i[event] (inputEvent); param language. NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 5050 | `DvmTextGenerationRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people) | DVM spec kinds/5050 uses input-type 'prompt' (not in NIP-90's list; free text, not modelled). Quartz build() writes i[prompt]; indexes prompt/text inputs for search. NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 5100 | `DvmImageGenerationRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people) | DVM spec kinds/5100: i[text] prompt + optional i[url] source image. Quartz build() writes both (sourceImageUrl -> i[url]). NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 5200 | `DvmVideoConversionRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people) | DVM spec kinds/5200: i[url] social media/video link. Quartz build() writes i[url]. NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 5201 | `DvmVideoTranslationRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people) | DVM spec kinds/5201 example has i[url], i[event] and i[job]. Quartz build() writes i[url] only. NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 5202 | `DvmImageToVideoRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people) | DVM spec kinds/5202: i[url] image. Quartz build() writes i[url] (imageUrl()). NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 5250 | `DvmTextToSpeechRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people) | Quartz build() writes i[text] (not modelled); i[event] possible per NIP-90. NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 5300 | `DvmContentDiscoveryRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); param[user] -> FOR_USER (U); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people); dvmPubKey() (first p only) and user() = tags.dvmParam("user"), both on the class | DVM spec kinds/5300 lists the user as a `p` param, which collides with NIP-90's `p` = service provider; Quartz (and Amethyst) put the DVM in `p` and the user in param 'user'. UNCERTAIN: other clients may put the user in `p`. relays tag (RelaysTag) not modelled. NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 5301 | `DvmUserDiscoveryRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); param[user] -> FOR_USER (U); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people); dvmPubKey() (first p only) and user() = tags.dvmParam("user") | DVM spec kinds/5301 is a copy of 5300 (same p-param ambiguity). build() takes only an initializer: Quartz writes nothing itself. NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 5302 | `DvmContentSearchRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); param[users] -> SEARCH_AUTHOR (U); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people); users() returns the raw param string: new parser needed to decode its JSON-stringified p tags | Quartz build() writes i[text] query (not modelled) and param users as an opaque string. UNCERTAIN: SEARCH_AUTHOR name; the value is a JSON array of p tags in the spec example. NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 5303 | `DvmPeopleSearchRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people) | Quartz build() writes i[text] query (not modelled) and max_results. NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 5400 | `DvmEventCountRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people) | DVM spec kinds/5400: inputs are tag values (i[text]); content is a NIP-01 filter JSON whose ids/authors/#e/#p are a query, not a statement: not modelled. params relay/group not modelled. NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 5500 | `DvmMalwareScanRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people) | DVM spec kinds/5500: i[url] file to scan. Quartz build() writes i[url] (fileUrl()). NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 5900 | `DvmEventTimestampingRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people) | DVM spec kinds/5900: i[event] = event to stamp (eventIdToStamp()). INPUT, not TIMESTAMPED: the request asks for a stamp; the 1040 proof (TIMESTAMPED) comes back as the 6900's RESULT. NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 5901 | `DvmOpReturnRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people) | DVM spec kinds/5901: i[text] OP_RETURN payload (not modelled). NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 5905 | `DvmEventPublishScheduleRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); i[text] embedded event JSON -> SCHEDULED (E); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people); eventJsons() returns the raw JSON strings: new parser needed to read each embedded event's id | DVM spec kinds/5905: request is normally encrypted (i in NIP-04 content, only p visible), so SCHEDULED is rare in public data. UNCERTAIN: SCHEDULED could be dropped since the 6905 RESULT names the same published id. params relays not modelled. NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 5970 | `DvmEventPowDelegationRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people) | DVM spec kinds/5970: i[text] is an UNSIGNED event template (no id until mined): not modelled. param pow not modelled. NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 6000 | `DvmTextExtractionResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags) | Content = extracted text (free text; Quartz parses no nostr: URIs). NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 6001 | `DvmSummarizationResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags) | Content = summary text (free text; Quartz parses no nostr: URIs). UNCERTAIN: a summary may cite nostr: URIs; content nostr: -> MENTION would need a new parser. NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 6002 | `DvmTranslationResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags) | Content = translated text (free text; Quartz parses no nostr: URIs). NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 6050 | `DvmTextGenerationResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags) | Content = generated text (free text; Quartz parses no nostr: URIs). NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 6100 | `DvmImageGenerationResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags) | Content = image URL: not modelled. NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 6200 | `DvmVideoConversionResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags) | Content = video URL: not modelled. NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 6201 | `DvmVideoTranslationResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags) | Content = video URL: not modelled. NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 6202 | `DvmImageToVideoResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags) | Content = video URL: not modelled. NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 6250 | `DvmTextToSpeechResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags) | Content = audio URL: not modelled. NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 6300 | `DvmContentDiscoveryResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); content e/a tags -> RESULT (E,A) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags); innerTags() (parses content as a tag array, keeps e/a values) | DVM spec kinds/5300 output: content = JSON-stringified list of e/a tags. Quartz wart: innerTags() returns List mixing event ids and address strings (Amethyst re-splits with splitInnerTags) and drops relay hints; a typed parser is needed. NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 6301 | `DvmUserDiscoveryResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); content p tags -> RESULT (U) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags); innerTags() (content tag array, p values only) | DVM spec kinds/5301 prose says output tags 'SHOULD be a or e' (copy of 5300) but its example returns p; Quartz reads p only. NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 6302 | `DvmContentSearchResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); content e/a tags -> RESULT (E,A) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags); innerTags() (content tag array, e/a values) | DVM spec kinds/5302 output: content = JSON-stringified e/a tags. Same innerTags() mixed-type wart as 6300. NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 6303 | `DvmPeopleSearchResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); content p tags -> RESULT (U) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags); innerTags() (content tag array, p values) | DVM spec kinds/5303 output: content = JSON-stringified p tags. NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 6400 | `DvmEventCountResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags) | Content = a number or a grouped-count JSON (count()): not a reference. NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 6500 | `DvmMalwareScanResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags) | Content = 'CLEAN' or scan report text: not a reference. NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 6900 | `DvmEventTimestampingResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); content event id -> RESULT (E) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags); otsEventId() (content) | DVM spec kinds/5900 output: content MUST be the id of the kind 1040 OTS event (which itself links TIMESTAMPED to the stamped event). Quartz does not check it is 64-hex. NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 6901 | `DvmOpReturnResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags) | Content = bitcoin txid (transactionId()): not a Nostr entity, not modelled. NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 6905 | `DvmEventPublishScheduleResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); content event id -> RESULT (E) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags); publishedEventId() (content) | DVM spec kinds/5905 output: 'Event ID that was published'. Spec example is copy-pasted from 5900 (says 1040 / kind 6900). Quartz does not check 64-hex. NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 6970 | `DvmEventPowDelegationResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); content mined event JSON -> RESULT (E) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags); new parser needed: the class exposes no content accessor; parse the JSON and take its id | DVM spec kinds/5970 output: 'Mined event json with nonce and calculated id'. UNCERTAIN: RESULT to an embedded event that may never be published. NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 7000 | `DvmStatusEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U) | new parser needed: the class exposes only status() and firstAmount(); build from generic tags.taggedEventIds() / tags.taggedUserIds() | NIP-90 job feedback. status (code, extra-info) and amount (msats, bolt11) are props, not links: status could ride on JOB_REQUEST (prop status). Content may hold a partial result (free text; for 5300 feeds an e/a tag list could appear: UNCERTAIN). With 'encrypted' the content is NIP-04. Amethyst follows #e = requestId. | +| 11998 | `DvmHeartbeatEvent` | *none* | status(), expiration(); dTag() | Experimental DVM heartbeat (no NIP). Tags d (the DVM's NIP-89 d), status (free text), expiration: none points at another entity. Its d mirrors Address(31990, author, d), a derived link no tag states (not proposed). Replaceable-range kind on BaseAddressableEvent by design (d splits the client-side address). | + +### `nip51Lists` (32) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 10000 | `MuteListEvent` | p -> MUTE (U); e -> MUTE (E); t -> MUTE (T); word -> MUTE (T) | publicMutes() / MuteTag.parse (UserTag, EventTag, HashtagTag, WordTag in nip51Lists/muteList/tags); tags.mutedUserIds(), mutedThreadIds(), mutedHashtags(), mutedWords() | NIP-51 mute list (p pubkeys, t hashtags, word lowercase strings, e threads). PRIVATE entries: yes (NIP-44 content, privateMutes()); the graph sees public tags only. t targets the same Tag(t) node HASHTAG uses; 'word' is not in the T allowlist (i/r/g/k/l/L/t): needs allowlisting as Tag(word). Quartz: implements PubKeyHintProvider but no EventHintProvider although it holds e threads. | +| 10001 | `PinListEvent` | e -> PIN (E) | pinnedEvents() / EventBookmark.parse; linkedEventIds() (EventHintProvider) | NIP-51 pinned notes (e kind 1). PRIVATE entries: no (BaseReplaceableEvent, never decrypts). Quartz reads e only; an a tag would be ignored (NIP-51 lists e only). | +| 10003 | `BookmarkListEvent` | e -> BOOKMARK (E); a -> BOOKMARK (A) | publicBookmarks() / BookmarkIdTag.parse (EventBookmark, AddressBookmark); linkedEventIds(), linkedAddressIds() | NIP-51 bookmarks (e kind 1, a kind 30023). PRIVATE entries: yes (PrivateReplaceableTagArrayEvent, privateBookmarks()). EventBookmark also reads an author pubkey hint from positions 2-4: a hint, not a link (no BOOKMARK_AUTHOR proposed). Quartz: linkedAddressIds() uses parseAddressId, which returns the raw a value unvalidated (parseValidAddress exists). | +| 10006 | `BlockedRelayListEvent` | *none* | publicRelays() / RelayTag.parse (relay URLs only) | NIP-51 blocked relays (relay tags). Relay URLs are not link targets in v1: not modelled. PRIVATE entries: yes (PrivateTagArrayEvent). Quartz: a replaceable kind on PrivateTagArrayEvent (BaseAddressableEvent) with no dTag() override, so a stray d tag splits its ADDRESS (MuteListEvent/FavoriteFollowSetsListEvent pin it to ""). | +| 10009 | `SimpleGroupListEvent` | group -> SUBSCRIBED (T) | publicGroups() / GroupTag.parse (nip51Lists/simpleGroupList) | NIP-51 simple groups: 'NIP-29 groups the user is in' (group = id + relay URL + name), filed with 10004/10005 as SUBSCRIBED. The group is not an event/address/user (its 39000 metadata is signed by an unknown relay key), so the target is Tag(group, "'" per NIP-29's identifier form): needs 'group' added to the T allowlist. UNCERTAIN: target encoding. The r tags NIP-51 also lists are relay URLs (not modelled; Quartz does not read them). PRIVATE entries: yes. Quartz: no dTag() override (see 10006). | +| 10012 | `FavoriteRelayListEvent` | a -> FAVORITE (A) | publicRelaySets() = tags.relaySetPointers() (AddressBookmark filtered to kind 30002); relay tags via publicRelays() | NIP-51 relay feeds: relay tags (not modelled) and a to kind 30002 relay sets. PRIVATE entries: yes (privateTags; no private relay-set accessor). Quartz: no dTag() override (see 10006). | +| 10015 | `InterestListEvent` | t -> SUBSCRIBED (T); a -> SUBSCRIBED (A) | publicHashtags() (HashtagTag.parse); publicInterestSets() = tags.interestSetPointers() (AddressBookmark filtered to kind 30015) | NIP-51 interests: t hashtags and a to kind 30015 interest sets (draft: SUBSCRIBED). t target is the same Tag(t) node HASHTAG uses. PRIVATE entries: yes (privateTags, privateInterestSets()). Quartz: no dTag() override (see 10006). | +| 10017 | `GitAuthorListEvent` | p -> SUBSCRIBED (U) | publicAuthors() / GitAuthorTag.parse; linkedPubKeys() (PubKeyHintProvider) | NIP-51 git authors: 'code (people who produce NIP-34 events) follow list', p with relay hint + petname like NIP-02. UNCERTAIN: the draft files 10017 under MEMBER; rule 4 (FOLLOW is kind 3, every other follow-like list is SUBSCRIBED) and 10020's identical shape argue for SUBSCRIBED. Petname could ride as a prop. PRIVATE entries: yes. Quartz: no dTag() override (see 10006). | +| 10018 | `GitRepositoryListEvent` | a -> SUBSCRIBED (A) | publicRepositories() / AddressBookmark.parse; linkedAddressIds() (AddressHintProvider) | NIP-51 git repositories: 'NIP-34 followed repositories' (a kind 30617), a follow-like list, so SUBSCRIBED (not in the draft yet; REPOSITORY is a patch's repo). PRIVATE entries: yes. Quartz: linkedAddressIds() returns unvalidated a values; no dTag() override (see 10006). | +| 10020 | `MediaFollowListEvent` | p -> SUBSCRIBED (U) | publicFollows() / UserTag.parse; linkedPubKeys() (PubKeyHintProvider) | NIP-51 media follows (draft: SUBSCRIBED). PRIVATE entries: yes (privateFollows()). Quartz: UserTag drops the NIP-02 petname; no dTag() override (see 10006). | +| 10021 | `FavoriteFollowSetsListEvent` | a -> FAVORITE (A) | publicFavoriteFollowSets() = tags.favoriteFollowSetBookmarks() (AddressBookmark filtered to kind 30000) | NIP-51 kind 10021. Not in the draft. Quartz skips a tags of other kinds. PRIVATE entries: yes (privateFavoriteFollowSets()). dTag() correctly pinned to "". No hint provider implemented. | +| 10081 | `GeohashListEvent` | g -> SUBSCRIBED (T) | publicGeohashes() = tags.geohashList() (GeoHashTag.parse, nip01Core/tags/geohash) | Followed locations (geohashes). Not in the NIP-51 table (Amethyst kind). Target is Tag(g) like TAG. PRIVATE entries: yes (decryptPrivateGeohashes()). QUARTZ BUG (privacy): the non-suspend create(publicGeohashes, privateGeohashes, NostrSignerSync) swaps them (privateTagArray = publicGeohashes, publicTagArray = privateGeohashes), so private geohashes are published in clear tags. No dTag() override (see 10006). | +| 10086 | `IndexerRelayListEvent` | *none* | publicRelays() / RelayTag.parse (relay URLs only) | Indexer relays (relay tags); not in the NIP-51 table. Relay URLs are not link targets in v1: not modelled. PRIVATE entries: yes (PrivateTagArrayEvent). Quartz: a replaceable kind on PrivateTagArrayEvent (BaseAddressableEvent) with no dTag() override, so a stray d tag splits its ADDRESS (MuteListEvent/FavoriteFollowSetsListEvent pin it to ""). | +| 10087 | `ProxyRelayListEvent` | *none* | publicRelays() / RelayTag.parse (relay URLs only) | Proxy relays (relay tags); not in the NIP-51 table. Relay URLs are not link targets in v1: not modelled. PRIVATE entries: yes (PrivateTagArrayEvent). Quartz: a replaceable kind on PrivateTagArrayEvent (BaseAddressableEvent) with no dTag() override, so a stray d tag splits its ADDRESS (MuteListEvent/FavoriteFollowSetsListEvent pin it to ""). | +| 10088 | `BroadcastRelayListEvent` | *none* | publicRelays() / RelayTag.parse (relay URLs only) | Broadcast relays (relay tags); not in the NIP-51 table. Relay URLs are not link targets in v1: not modelled. PRIVATE entries: yes (PrivateTagArrayEvent). Quartz: a replaceable kind on PrivateTagArrayEvent (BaseAddressableEvent) with no dTag() override, so a stray d tag splits its ADDRESS (MuteListEvent/FavoriteFollowSetsListEvent pin it to ""). | +| 10089 | `TrustedRelayListEvent` | *none* | publicRelays() / RelayTag.parse (relay URLs only) | Trusted relays (relay tags); not in the NIP-51 table. Relay URLs are not link targets in v1: not modelled. PRIVATE entries: yes (PrivateTagArrayEvent). Quartz: a replaceable kind on PrivateTagArrayEvent (BaseAddressableEvent) with no dTag() override, so a stray d tag splits its ADDRESS (MuteListEvent/FavoriteFollowSetsListEvent pin it to ""). | +| 10090 | `FavoriteAlgoFeedsListEvent` | a -> FAVORITE (A) | publicFavoriteAlgoFeeds() / AddressBookmark.parse; tags.favoriteAlgoFeedsList() | Not in the NIP-51 table (Amethyst kind). a points at feed DVM announcements (kind 31990); Quartz does not filter by kind. PRIVATE entries: yes (privateFavoriteAlgoFeeds()). No dTag() override (see 10006). | +| 10101 | `GoodWikiAuthorListEvent` | p -> RECOMMENDED (U) | publicAuthors() / UserTag.parse; linkedPubKeys() (PubKeyHintProvider) | NIP-51 good wiki authors: 'NIP-54 user recommended wiki authors'. UNCERTAIN: the draft files 10101 under MEMBER; NIP-51's own word is 'recommended', which reuses RECOMMENDED (extends its targets from A to U). PRIVATE entries: yes. No dTag() override (see 10006). | +| 10102 | `GoodWikiRelayListEvent` | *none* | publicRelays() / RelayTag.parse (relay URLs only) | NIP-51 good wiki relays (relay tags). Kind 10102 in Quartz; the draft's note that it claims kind 1010 does not match the current class (KIND = 10102). Relay URLs are not link targets in v1: not modelled. PRIVATE entries: yes (PrivateTagArrayEvent). Quartz: a replaceable kind on PrivateTagArrayEvent (BaseAddressableEvent) with no dTag() override, so a stray d tag splits its ADDRESS (MuteListEvent/FavoriteFollowSetsListEvent pin it to ""). | +| 30000 | `FollowSetEvent` | p -> MEMBER (U); [d=mute] p/e/t/word -> MUTE (U,E,T) | users() = tags.users() (UserTag.parse); linkedPubKeys(); publicMembers() parses MuteTag (p/e/t/word) | NIP-51 follow sets (draft: MEMBER). The deprecated d='mute' form is a mute list (NIP-51 'use instead kind 10000'), which is why Quartz parses MuteTag here: those entries should be MUTE. PRIVATE entries: yes (privateMembers()). | +| 30001 | `OldBookmarkListEvent` | e -> BOOKMARK (E); a -> BOOKMARK (A); [d=pin] e -> PIN (E); [d=communities] a -> SUBSCRIBED (A) | publicBookmarks() / BookmarkIdTag.parse; linkedEventIds(), linkedAddressIds() | Deprecated NIP-51 kind 30001 (d='bookmark' -> 10003, d='pin' -> 10001, d='communities' -> 10004). Quartz treats every 30001 as bookmarks regardless of d; the semantic method should branch on d. PRIVATE entries: yes. linkedAddressIds() unvalidated (see 10003). | +| 30002 | `RelaySetEvent` | *none* | relays() / RelayTag.parse | NIP-51 relay sets (relay tags only): not modelled. PRIVATE entries: yes. | +| 30003 | `BookmarkSetEvent` | e -> BOOKMARK (E); a -> BOOKMARK (A) | publicBookmarks() / BookmarkIdTag.parse; linkedEventIds(), linkedAddressIds() | NIP-51 bookmark sets (draft: BOOKMARK). PRIVATE entries: yes (PrivateTagArrayEvent; no privateBookmarks() accessor on this class, only privateTags()). linkedAddressIds() unvalidated (see 10003). | +| 30004 | `ArticleCurationSetEvent` | a -> CURATED (A); e -> CURATED (E) | publicItems() / BookmarkIdTag.parse; linkedEventIds(), linkedAddressIds() | NIP-51 curation set (a kind 30023, e kind 1) (draft: CURATED). PRIVATE entries: yes (PrivateTagArrayEvent). linkedAddressIds() unvalidated. | +| 30005 | `VideoCurationSetEvent` | e -> CURATED (E); a -> CURATED (A) | publicItems() / BookmarkIdTag.parse; linkedEventIds(), linkedAddressIds() | NIP-51 lists e (kind 21 videos) only; Quartz also accepts a (addressable videos). PRIVATE entries: yes. | +| 30006 | `PictureCurationSetEvent` | e -> CURATED (E); a -> CURATED (A) | publicItems() / BookmarkIdTag.parse; linkedEventIds() (EventHintProvider only) | NIP-51 lists e (kind 20 pictures) only. Quartz inconsistency: publicItems() accepts a too, but the class implements no AddressHintProvider. PRIVATE entries: yes. | +| 30007 | `KindMuteSetEvent` | p -> MUTE (U) | publicMutedUsers() / UserTag.parse; linkedPubKeys() (PubKeyHintProvider) | NIP-51 kind mute sets: 'mute pubkeys by kinds', d MUST be the kind string: the muted kind should ride as a prop (e.g. muted_kind = d) since kind stays off relation names. PRIVATE entries: yes. | +| 30015 | `InterestSetEvent` | t -> MEMBER (T) | publicHashtags() (HashtagTag.parse) | NIP-51 interest sets: 'interest topics represented by a bunch of hashtags'. A named set, so MEMBER as for follow sets (the draft names 30015 nowhere; 10015's pointer to it is SUBSCRIBED). t target is the Tag(t) node HASHTAG uses. PRIVATE entries: yes (privateHashtags()). | +| 30063 | `ReleaseArtifactSetEvent` | e -> CURATED (E); a -> APPLICATION (A); i -> TAG (T) | items() / BookmarkIdTag.parse; assets() (NIP-82 AssetTag e); appId() (NIP-82 i); linkedEventIds(), linkedAddressIds() | Kind shared by NIP-51 release artifact set and NIP-82 software release (isNip82SoftwareRelease()). e = artifacts (NIP-51 kind 1063; NIP-82 kind 3063 assets). UNCERTAIN: draft says CURATED; a release's files are not a curation, a dedicated ARTIFACT (NIP-51 'release artifact') may read better. NIP-82 i = app id (TAG); c channel / version not modelled. PRIVATE entries: no (BaseAddressableEvent, though NIP-51 content may hold them). | +| 30267 | `AppCurationSetEvent` | a -> CURATED (A) | apps() / AddressBookmark.parse; linkedAddressIds() (AddressHintProvider) | NIP-51 app curation sets (a kind 32267 software applications) (draft: CURATED). PRIVATE entries: no in Quartz (BaseAddressableEvent). | +| 39089 | `StarterPackEvent` | p -> MEMBER (U); t -> HASHTAG (T) | follows() / followIds() (UserTag.parse, nip51Lists/starterPack/TagArrayExt.kt); hashtags() (HashtagTag.parse); linkedPubKeys() | NIP-51 starter packs (draft: MEMBER). Quartz also reads t as topics. PRIVATE entries: no (BaseAddressableEvent). | +| 39092 | `MediaStarterPackEvent` | p -> MEMBER (U) | follows() / followIds() (UserTag.parse); linkedPubKeys() (PubKeyHintProvider) | NIP-51 media starter packs (draft: MEMBER). PRIVATE entries: no (BaseAddressableEvent). | + +### `nip29RelayGroups` (16) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 9000 | `GroupPutUserEvent` | h -> GROUP (T\|A); p -> ADDED_USER (U) | groupId() (GroupIdTag), userPubKeys() (PTag::parseKey; roles are p[2..], read via GroupAdminTag::parse) | NIP-29 put-user, with roles riding in the p tag. The Buzz role tag is also written, as a props source. previous holds 8-char event-id prefixes, which cannot resolve to event ids and are not modelled. UNCERTAIN: PUT_USER (literal NIP word) vs ADDED_USER shared with NIP-43. | +| 9001 | `GroupRemoveUserEvent` | h -> GROUP (T\|A); p -> REMOVED_USER (U) | groupId(), userPubKeys() (PTag::parseKey) | NIP-29 remove-user. previous is not modelled (see 9000). | +| 9002 | `GroupEditMetadataEvent` | h -> GROUP (T\|A); parent -> PARENT (T\|A group); child -> CHILD (T\|A group); t -> HASHTAG (T); g -> TAG (T) | groupId(), parent() (ParentTag::parse), children() (ChildTag::parse), hashtags(), geohashes() | NIP-29 edit-metadata. The parent and child values are group ids on the same relay, so they share GROUP's target representation. previous is not modelled. | +| 9005 | `GroupDeleteEventEvent` | h -> GROUP (T\|A); e -> DELETED (E) | groupId(), deletedEventIds() (mapValueTagged('e')) | NIP-29 delete-event: a moderator deletion, which NIP-09's owner-only rule does not govern. DELETED is the NIP's action word. UNCERTAIN: a NIP-09 enforcer querying DELETED must filter on source kind 5, so rule 4 may argue for a separate MODERATOR_DELETED. deletedEventIds() does not validate 64-hex. previous is not modelled. | +| 9007 | `CreateGroupEvent` | h -> GROUP (T\|A) | groupId() (GroupIdTag::parse) | NIP-29 create-group. GROUP target proposal: NIP-29 says a group is referenced by the naddr of its kind 39000 (pubkey = relay NIP-11 self, d = id), so target Address 39000:: when the self key is known (always for a relay-side store; the 39xxx events carry it as author), else Tag('h', id) with the relay in props, or a new LinkTarget.Group(relay, id). A bare Tag('h', id) merges forks and migrations across relays, which NIP-29 says share the same id. The name, about, visibility and channel_type tags (Buzz) are not links. | +| 9008 | `DeleteGroupEvent` | h -> GROUP (T\|A) | groupId() | NIP-29 delete-group. The kind carries the action, so the h stays GROUP rather than DELETED (rule 2). | +| 9009 | `GroupCreateInviteEvent` | h -> GROUP (T\|A) | groupId() | NIP-29 create-invite. The code tag is a secret-ish value and is not modelled. | +| 9010 | `GroupUpdatePinListEvent` | h -> GROUP (T\|A); e -> PIN (E); a -> PIN (A) | groupId(), pins()/pinnedEventIds()/pinnedAddresses() (GroupPin, EventPin, AddressPin) | NIP-29 update-pin-list. It carries the full ordered list, so the order is a prop. The draft's PIN is E-only and must be widened to E, A (both 9010 and 39005 pin addresses). | +| 9021 | `GroupJoinRequestEvent` | h -> GROUP (T\|A) | groupId() | NIP-29 join request. The code (invite) is not modelled. | +| 9022 | `GroupLeaveRequestEvent` | h -> GROUP (T\|A) | groupId() | NIP-29 leave request. | +| 39000 | `GroupMetadataEvent` | parent -> PARENT (A); child -> CHILD (A); t -> HASHTAG (T); g -> TAG (T) | parent() (ParentTag::parse), children() (ChildTag::parse) -> Address 39000::, hashtags(), geohashes() | NIP-29 group metadata, signed by the relay. The group node IS this event's ADDRESS (39000::), which is the NIP's own group reference. Subgroup parent and child are on the same relay, so their addresses are exact: 39000::. t also carries the Buzz channel types (stream/forum/dm/workflow) as values, so those HASHTAGs are not topics (a quirk). | +| 39001 | `GroupAdminsEvent` | d (derived) -> GROUP (A); p -> ADMIN (U) | Address 39000::dTag() (groupId() = dTag()), admins() (GroupAdminTag::parse) | NIP-29 group admins. Relay-signed with d = group id, so the group is exactly Address 39000::. It is derived, not tagged (UNCERTAIN whether derived links belong in links()). | +| 39002 | `GroupMembersEvent` | d (derived) -> GROUP (A); p -> MEMBER (U) | Address 39000::dTag() (groupId() = dTag()), members() (PTag::parseKey) | NIP-29 group members. It is not exhaustive (per the NIP). Relay-signed with d = group id, so the group is exactly Address 39000::. It is derived, not tagged (UNCERTAIN whether derived links belong in links()). | +| 39003 | `GroupRolesEvent` | d (derived) -> GROUP (A) | Address 39000::dTag() (groupId() = dTag()), roles() (RoleTag::parse) has no references | NIP-29 group roles. The role names are values. Relay-signed with d = group id, so the group is exactly Address 39000::. It is derived, not tagged (UNCERTAIN whether derived links belong in links()). | +| 39004 | `GroupParticipantsEvent` | d (derived) -> GROUP (A); participant -> PARTICIPANT (U) | Address 39000::dTag() (groupId() = dTag()), participants() (mapValueTagged('participant')) | NIP-29 LiveKit participants. participants() does not validate 64-hex. Relay-signed with d = group id, so the group is exactly Address 39000::. It is derived, not tagged (UNCERTAIN whether derived links belong in links()). | +| 39005 | `GroupPinnedEvent` | d (derived) -> GROUP (A); e -> PIN (E); a -> PIN (A) | Address 39000::dTag() (groupId() = dTag()), pins()/pinnedEventIds()/pinnedAddresses() | NIP-29 group pinned events, ordered (order as a prop). PIN needs A added (see 9010). Relay-signed with d = group id, so the group is exactly Address 39000::. It is derived, not tagged (UNCERTAIN whether derived links belong in links()). | + +### `nip34Git` (12) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 1617 | `GitPatchEvent` | a -> REPOSITORY (A); p[= repo owner] -> REPOSITORY_OWNER (U); p[other] -> MENTION (U); e[reply] -> PARENT (E); e[root] -> ROOT (E); t -> HASHTAG (T); r -> TAG (T) | repositoryAddress()/repository(), PTag::parseKey (+ compare with the a pubkey), MarkedETag parse (linkedEventIds), isRoot()/isRootRevision() (HashtagTag), earliestUniqueCommit() | NIP-34. e[reply] points at the previous patch in the series, or at the original root patch for a revision. UNCERTAIN: NIP-34 text shows only reply, but ngit also writes root markers on series. The p roles are told apart only by comparison with the a pubkey, so this needs a new parser. t values are the markers 'root' and 'root-revision'. r holds the euc and commit ids. commit, parent-commit and committer are git data, not modelled. The content is a patch, so there is no nostr: parsing. | +| 1618 | `GitPullRequestEvent` | a -> REPOSITORY (A); p[= repo owner] -> REPOSITORY_OWNER (U); p[other] -> MENTION (U); e -> REVISED (E); t -> HASHTAG (T); r -> TAG (T) | repositoryAddress(), PTag::parseKey, rootPatchId() (ETag::parseId), labels() (hashtags), earliestUniqueCommit() | NIP-34. UNCERTAIN: the placeholder is , so ROOT is an alternative, but the PR is not in that patch's thread. It supersedes it. t holds labels. c (tip commit), merge-base, branch-name and clone are not modelled. subject is not a link. | +| 1619 | `GitPullRequestUpdateEvent` | E -> ROOT (E); P -> ROOT_AUTHOR (U); a -> REPOSITORY (A); p[= repo owner] -> REPOSITORY_OWNER (U); p[other] -> MENTION (U); r -> TAG (T) | parentPullRequestId() (RootEventTag::parseKey), parentPullRequestAuthor() (RootAuthorTag::parseKey), repositoryAddress(), PTag::parseKey | NIP-34 PR update uses NIP-22 E/P for the PR, so ROOT and ROOT_AUTHOR follow the draft. c, clone and merge-base are not modelled. Quartz names the getter parentPullRequestId although the tag is the root E. The naming is fine, but note it for the golden test. | +| 1621 | `GitIssueEvent` | a -> REPOSITORY (A); p[= repo owner] -> REPOSITORY_OWNER (U); p[other] -> MENTION (U); q -> QUOTE (E,A); t -> HASHTAG (T); content nostr: -> MENTION (E,A,U) | repositoryAddress()/repository(), PTag::parseKey, QTag::parseEventId/parseAddressId, topics() (hashtags), citedNIP19() | NIP-34 issue. REPOSITORY, MENTION and QUOTE are already listed for 1621 in the draft. subject is not a link. Known QTag.parseAddressId bug. | +| 1622 | `GitReplyEvent` | a -> REPOSITORY (A); e[root] -> ROOT (E); e[reply] -> PARENT (E); p -> MENTION (U); q -> QUOTE (E,A); content nostr: -> MENTION (E,A,U) | repository(), rootIssueOrPatch() (MarkedETag::parseRootId), BaseThreadedEvent.reply(), PTag::parseKey, QTag, citedNIP19() | Legacy NIP-34 reply (deprecated in Quartz; NIP-34 now says to use NIP-22 kind 1111). The root is the issue or patch. It is in the draft's ROOT, PARENT and MENTION. | +| 1630 | `GitStatusOpenEvent` | e[root] -> ROOT (E); e[reply] -> PARENT (E); a -> REPOSITORY (A); p[= e[root] author] -> ROOT_AUTHOR (U); p[= repo owner] -> REPOSITORY_OWNER (U); p[= e[reply] author] -> PARENT_AUTHOR (U); p[other] -> MENTION (U); r -> TAG (T) | GitStatusEvent.rootEventId()/replyEventId(), repositoryAddress(), PTag::parseKey (+ comparison with MarkedETag author and the a pubkey), referenceCommits() | NIP-34 status: e root = the issue, PR or root patch, and e reply = the accepted revision root, as in the draft. The NIP-34 p list is repository-owner, root-event-author and revision-author, with no markers. Telling them apart requires comparing each p with the e author field (Quartz writes it at position 4) and with the a pubkey. That is a new parser, and whatever cannot be resolved stays MENTION. | +| 1631 | `GitStatusAppliedEvent` | e[root] -> ROOT (E); e[reply] -> PARENT (E); a -> REPOSITORY (A); p[= e[root] author] -> ROOT_AUTHOR (U); p[= repo owner] -> REPOSITORY_OWNER (U); p[= e[reply] author] -> PARENT_AUTHOR (U); p[other] -> MENTION (U); r -> TAG (T); q -> APPLIED (E) | GitStatusEvent.rootEventId()/replyEventId(), repositoryAddress(), PTag::parseKey (+ comparison with MarkedETag author and the a pubkey), referenceCommits(), appliedPatchIds() (QTag::parseEventId) | q holds the applied or merged patch ids. merge-commit and applied-as-commits are git data, not modelled (their commits also appear as r -> TAG). Quartz gap: GitStatusEvent.linkedEventIds() reads only e, so the q ids are missing from the hint provider. NIP-34 status: e root = the issue, PR or root patch, and e reply = the accepted revision root, as in the draft. The NIP-34 p list is repository-owner, root-event-author and revision-author, with no markers. Telling them apart requires comparing each p with the e author field (Quartz writes it at position 4) and with the a pubkey. That is a new parser, and whatever cannot be resolved stays MENTION. | +| 1632 | `GitStatusClosedEvent` | e[root] -> ROOT (E); e[reply] -> PARENT (E); a -> REPOSITORY (A); p[= e[root] author] -> ROOT_AUTHOR (U); p[= repo owner] -> REPOSITORY_OWNER (U); p[= e[reply] author] -> PARENT_AUTHOR (U); p[other] -> MENTION (U); r -> TAG (T) | GitStatusEvent.rootEventId()/replyEventId(), repositoryAddress(), PTag::parseKey (+ comparison with MarkedETag author and the a pubkey), referenceCommits() | NIP-34 status: e root = the issue, PR or root patch, and e reply = the accepted revision root, as in the draft. The NIP-34 p list is repository-owner, root-event-author and revision-author, with no markers. Telling them apart requires comparing each p with the e author field (Quartz writes it at position 4) and with the a pubkey. That is a new parser, and whatever cannot be resolved stays MENTION. | +| 1633 | `GitStatusDraftEvent` | e[root] -> ROOT (E); e[reply] -> PARENT (E); a -> REPOSITORY (A); p[= e[root] author] -> ROOT_AUTHOR (U); p[= repo owner] -> REPOSITORY_OWNER (U); p[= e[reply] author] -> PARENT_AUTHOR (U); p[other] -> MENTION (U); r -> TAG (T) | GitStatusEvent.rootEventId()/replyEventId(), repositoryAddress(), PTag::parseKey (+ comparison with MarkedETag author and the a pubkey), referenceCommits() | NIP-34 status: e root = the issue, PR or root patch, and e reply = the accepted revision root, as in the draft. The NIP-34 p list is repository-owner, root-event-author and revision-author, with no markers. Telling them apart requires comparing each p with the e author field (Quartz writes it at position 4) and with the a pubkey. That is a new parser, and whatever cannot be resolved stays MENTION. | +| 10317 | `UserGraspListEvent` | *none* | – | NIP-34 grasp list. The g tags here are grasp SERVER URLs, not geohashes, and servers are not modelled. Cross-cutting trap: a generic g -> TAG (geohash) rule would mislabel these, so g must be read per kind. | +| 30617 | `GitRepositoryEvent` | maintainers -> MAINTAINER (U); t -> HASHTAG (T); r[euc] -> TAG (T); u -> FORK (A) | maintainers() (MaintainersTag::parse), hashtags(), earliestUniqueCommit() (EucTag::parse); new parser needed for u | NIP-34. u ('30617::\|', 'indicate repository is a subordinate fork') is not parsed by Quartz. It is FORK (A) when the value is an address, and not modelled when it is a git URL. This extends the draft's FORK from E to E, A. t includes the 'personal-fork' marker. web, clone and relays are not modelled. | +| 30618 | `GitRepositoryStateEvent` | d (derived) -> REPOSITORY (A) | new parser needed (Address 30617::) | NIP-34: 'd matches the identifier in the corresponding repository announcement', so the repository address is derived from the author plus d, much as the 39xxx GROUP link is. The refs and HEAD are git data, not modelled. UNCERTAIN: whether derived links belong in links() or in the graph layer. | + +### `marmot` (8) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 444 | `WelcomeEvent` | e -> KEY_PACKAGE (E); h -> GROUP (T) | keyPackageEventId() (KeyPackageEventTag::parse), nostrGroupId() | Marmot MIP-02. This is an unsigned rumor inside a NIP-59 gift wrap (1059 -> 13 -> 444), so a relay-side graph never sees it. Links apply only after unwrapping. h is the Marmot nostr_group_id (random 32-byte hex, not relay-scoped, unlike NIP-29), so Tag('h', id) is a sound target here. UNCERTAIN: whether Marmot and NIP-29 should share GROUP or get separate relations. relays is not modelled. Quartz reads h inline in nostrGroupId() instead of mip03 GroupIdTag (minor). | +| 445 | `GroupEvent` | h -> GROUP (T) | groupId() (mip03 GroupIdTag::parse) | Marmot MIP-03. The pubkey is ephemeral per event, so AUTHOR is meaningless here: flag it so the graph does not grow one throwaway User per message. The content is encrypted MLS, and the inner kind 9/7 rumors have their own links. The target is Tag('h', nostr_group_id), a global random id. | +| 446 | `NotificationRequestEvent` | *none* | – | Marmot MIP-05 trigger. It has only a v (version) tag and an ephemeral pubkey. The content is encrypted token chunks addressed to a notification server via gift wrap, so there is nothing to link. | +| 447 | `TokenRequestEvent` | content entries[member_id] -> MEMBER (U); content entries[server_pubkey] -> NOTIFICATION_SERVER (U) | new parser needed (entries() = PushGossip.decodeTokens -> PushTokenEntry.memberIdHex/serverPubKeyHex) | Marmot MIP-05. This is an unsigned inner app payload inside kind 445. It is never relay-visible and is readable only by group members. UNCERTAIN whether to model it at all. Otherwise it would be status none in practice. For a self-update, member_id is the sender. Empty content is a request and has no links. | +| 448 | `TokenListEvent` | content entries[member_id] -> MEMBER (U); content entries[server_pubkey] -> NOTIFICATION_SERVER (U) | new parser needed (entries() = PushGossip.decodeTokens) | Marmot MIP-05. This is an unsigned inner payload, never relay-visible (UNCERTAIN, as for 447). The entries include OTHER members' records relayed with their owner_sig, so member_id is not the sender. | +| 449 | `TokenRemovalEvent` | content entries[member_id] -> MEMBER (U); content entries[server_pubkey] -> NOTIFICATION_SERVER (U) | new parser needed (entries() = PushGossip.decodeRemovals) | Marmot MIP-05 removal (tombstones). This is an unsigned inner payload, never relay-visible (UNCERTAIN, as for 447). A dedicated REMOVED relation would be overkill for a payload no graph sees. | +| 10051 | `KeyPackageRelayListEvent` | *none* | – | Marmot MIP-00 KeyPackage relay list. It holds only relay tags, which are not modelled. | +| 30443 | `KeyPackageEvent` | i -> TAG (T) | keyPackageRef() (KeyPackageRefTag, tag 'i') | Marmot MIP-00. i holds the KeyPackageRef hex (a lookup key). The MLS parameter tags, client and the relays list are not modelled. | + +### `nip53LiveActivities` (8) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 1311 | `LiveActivitiesChatMessageEvent` | a[1st, root marker optional] -> ROOT (A); e -> PARENT (E); p -> MENTION (U); q -> QUOTE (E,A); t -> HASHTAG (T); content nostr: -> MENTION (E,A,U) | activity()/activityAddress() (ATag::parse), BaseThreadedEvent.reply(), PTag::parseKey, QTag::parseEventId/parseAddressId, tags.hashtags(), citedNIP19() | NIP-53: the activity a is the ROOT (the spec example uses the 'root' marker; the text says just 'a'), and e is the direct parent. The activity can be a 30311 or, via roomMessage(), a 30312 space. Bug: unmarkedReplyTos() calls super.markedReplyTos() (copy-paste). Known QTag.parseAddressId bug. | +| 1312 | `LiveActivitiesRaidEvent` | a[root] -> ROOT (A); a[mention] -> RAIDED (A) | fromActivity()/fromAddress(), toActivity()/toAddress() | zap.stream convention, not in NIP-53 master. The root marker is the source stream (the one raiding), so the marker gives ROOT. The mention marker is the target. Rule 7 ('marker wins') would say MENTION, but the draft already chose RAIDED because the target IS the statement. Flag this tension in rule 7. Both are filtered to kind 30311. | +| 1313 | `LiveActivitiesClipEvent` | a -> CLIPPED (A); p -> CLIPPED_AUTHOR (U); r -> TAG (T) | activity()/activityAddress(), host() (PTag::parseKey), videoUrl() (ReferenceTag::parse) | zap.stream convention, not in NIP-53 master. The p is the stream host, which is not necessarily the 30311 signer (a provider may sign), hence CLIPPED_AUTHOR rather than the address AUTHOR. r is the playable video URL. | +| 10112 | `NestsServersEvent` | *none* | – | Nests audio-room server list (server/relay URLs plus auth URLs). The servers are not modelled. | +| 10312 | `MeetingRoomPresenceEvent` | a[root] -> ROOT (A) | interactiveRoom()/linkedAddressIds() (MeetingSpaceTag::parse / parseAddressId) | NIP-53 room presence: ['a', , relay, 'root'], with the ROOT as in the draft. hand, muted, publishing and onstage are flags, not links. Bug: MeetingSpaceTag.assemble writes ['a', addr, relay] WITHOUT the 'root' marker the NIP requires, so the parser must accept an unmarked a. build(root: MeetingRoomEvent) points the presence at a 30313 meeting, while the spec says the room (30312). Both occur. | +| 30311 | `LiveActivitiesEvent` | p -> PARTICIPANT (U); pinned -> PIN (E); goal -> GOAL (E) | participants() (ParticipantTag::parse), pinned() (PinnedEventTag::parse), goalEventId() | NIP-53. Props on PARTICIPANT: role (Host/Speaker/Participant) and proof. UNCERTAIN: rule 4 may justify HOST as its own relation, since the signer is often a provider and the Host p is the actual streamer ('streams by X' queries). t is in the spec but not read by Quartz, so it is not listed. streaming, recording and relays URLs are not modelled. | +| 30312 | `MeetingSpaceEvent` | p -> PARTICIPANT (U) | participants() (ParticipantTag::parse) | NIP-53 space. The p entries are providers with roles (Host/Moderator/Speaker), carried as props. t is in the spec but not read by Quartz. The service, endpoint and relays URLs are not modelled. Style: inline fully-qualified tag names in the class body. | +| 30313 | `MeetingRoomEvent` | a -> PARENT (A); p -> PARTICIPANT (U); pinned -> PIN (E) | interactiveRoom() (MeetingSpaceTag::parse), participants(), pinned() | NIP-53 meeting: the a is the parent space (30312), with PARENT as in the draft. pinned is not in NIP-53 for 30313, but Quartz reads it (the draft lists it). | + +### `nip43RelayMembers` (7) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 8000 | `RelayAddMemberEvent` | p -> ADDED_USER (U) | memberPubKeys() (PTag::parseKey) | NIP-43. Signed by the relay NIP-11 self key. UNCERTAIN: whether to unify with NIP-29 9000 or give that its own PUT_USER, since put-user also re-puts roles of existing members. | +| 8001 | `RelayRemoveMemberEvent` | p -> REMOVED_USER (U) | memberPubKeys() (PTag::parseKey) | NIP-43. Signed by the relay self key. | +| 13534 | `RelayMembershipListEvent` | member -> MEMBER (U) | membersWithRoles() (MemberTag::parseMember) | NIP-43 membership list, signed by the relay self key. Props: roles, which are the d-tags of 33534 role events and resolvable to Address 33534:: if roles ever become links. | +| 28934 | `RelayJoinRequestEvent` | *none* | – | NIP-43 join request. Its only data is the claim (invite code), which is not modelled. Ephemeral. | +| 28935 | `RelayInviteRequestEvent` | *none* | – | NIP-43 invite request. It has no tags beyond the initializer. Ephemeral. | +| 28936 | `RelayLeaveRequestEvent` | *none* | – | NIP-43 leave request. It carries only the NIP-70 '-' tag. Ephemeral. | +| 33534 | `RelayRoleEvent` | *none* | – | NIP-43 role definition: d = role id, with label, description, color and order. There are no references. | + +### `nip64Chess` (7) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 30 | `JesterEvent` | e[1st, move events] -> ROOT (E); e[2nd, move events] -> PARENT (E); p -> OPPONENT (U) | startEventId(), headEventId(), opponentPubkey() | Jester protocol (jesterui FLOW.md), not NIP-64. ROOT/PARENT: Jester links moves as [startId, headId], which is the game thread's root and the previous move. On START events (content.kind=0) the single e is JesterProtocol.START_POSITION_HASH, a sha256 of the start FEN rather than an event id. It is not modelled, and a shape-based rule would make it a phantom Event node that every Jester game links to. Quartz quirk: startEventId() returns that hash for start events. The link needs the content kind, so it needs a content parse. p is set only for private challenges and moves. | +| 64 | `ChessGameEvent` | *none* | – | NIP-64: PGN in content, and only alt as a tag. The White/Black PGN headers are free-text names, not pubkeys. | +| 30064 | `LiveChessGameChallengeEvent` | p -> OPPONENT (U) | opponentPubkey() (OpponentTag::parseKey) | Amethyst-only live chess kind (docs/live-chess-implementation-status.md), not NIP-64. With no p, it is an open challenge. d = gameId, a value. | +| 30065 | `LiveChessGameAcceptEvent` | e -> ACCEPTED (E); p -> OPPONENT (U) | challengeEventId() (ChallengeEventTag::parse), opponentPubkey() | Amethyst-only kind. The p is the challenger. The game itself (the challenge address 30064::) is derivable only from the e, whose tag[3] author Quartz writes. | +| 30066 | `LiveChessMoveEvent` | p -> OPPONENT (U) | opponentPubkey() | Amethyst-only kind. game_id and d (gameId-moveN) are values. The game is 30064::, but the challenger may be the author or the opponent, so it is not derivable from the event alone. A GAME (A) relation would need the challenge in hand (UNCERTAIN, not proposed). | +| 30067 | `LiveChessGameEndEvent` | p -> OPPONENT (U); winner -> WINNER (U) | opponentPubkey(), winnerPubkey() (WinnerTag::parse) | Amethyst-only kind. Props: result and termination. Bug-ish: WinnerTag.parse accepts any non-empty string (no 64-hex check), so it needs validation before becoming a User link. | +| 30068 | `LiveChessDrawOfferEvent` | p -> OPPONENT (U) | opponentPubkey() | Amethyst-only kind. d = gameId. | + +### `nip15Marketplace` (6) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 1021 | `BidEvent` | e -> AUCTION (E); p -> AUCTION_AUTHOR (U) | auctionId() (ETag::parseId), PTag::parseKey | NIP-15: ['e', ], with content = amount (props: amount). The p is Quartz's addition, not in NIP-15. The auction is a 30020 addressable event referenced by id, so the target is E. | +| 1022 | `BidConfirmationEvent` | e[1st] -> BID (E); e[2nd] -> AUCTION (E); p -> BID_AUTHOR (U) | new parser needed (positional e; Quartz only has linkedEventIds() = all ETag ids), PTag::parseKey | NIP-15: [['e', ], ['e', ]], order-defined. Quartz writes bid then auction but exposes no bidId()/auctionId(). Props: status (accepted/rejected/pending/winner) and duration_extension from content. The p is Quartz's addition. | +| 30017 | `StallEvent` | *none* | – | NIP-15 stall: d plus content JSON (name, currency, shipping). There are no references. | +| 30018 | `ProductEvent` | content stall_id -> STALL (A); t -> HASHTAG (T) | new parser needed for STALL (productData().stallId -> Address 30017::), categories() (hashtags) | NIP-15. This is a content reference, not a tag. t holds categories. | +| 30019 | `MarketplaceEvent` | content merchants[] -> MERCHANT (U) | new parser needed (marketplaceData().merchants) | NIP-15 marketplace UI/UX. This is a content reference. MERCHANT follows rule 7's list naming; MEMBER is the alternative (UNCERTAIN). | +| 30020 | `AuctionEvent` | content stall_id -> STALL (A); t -> HASHTAG (T) | new parser needed (auctionData().stallId), tags.hashtags() | NIP-15 auction. Bids reference it by EVENT id (1021/1022), not by address. | + +### `nip28PublicChat` (6) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 40 | `ChannelCreateEvent` | a -> MENTION (A) | ATag::parseAddressId (via linkedAddressIds) | NIP-28 defines no tags on kind 40 (metadata and relays live in content JSON; relays are not modelled). UNCERTAIN: Quartz reads arbitrary a tags as address hints, and nothing gives them a meaning. They could also be dropped. Known bug: linkedEventIds() returns the event's own id. The status is effectively none, except for the stray a tags. | +| 41 | `ChannelMetadataEvent` | e[root] -> ROOT (E) | BasePublicChatEvent.channel()/channelId() (MarkedETag.parseRoot ?: parseUnmarkedRoot) | NIP-28: ['e', , relay, 'root']. The channel is the ROOT, as the draft already decides. NIP-28 also allows t (categories) on 41, which Quartz never reads or writes, so it is not listed. If t is later read, t -> HASHTAG (T). | +| 42 | `ChannelMessageEvent` | e[root] -> ROOT (E); e[reply] -> PARENT (E); p -> MENTION (U); q -> QUOTE (E,A); a -> MENTION (A); content nostr: -> MENTION (E,A,U) | channel()/channelId(), BaseThreadedEvent.reply()/markedReply(), PTag::parseKey, QTag::parseEventId/parseAddressId, ATag::parseAddressId, citedNIP19() | NIP-28 root is the channel and reply is the parent message. The NIP-28 reply example has a p for the replied-to author. Quartz writes it via notify() as a plain p, so it is MENTION per the draft. It could be PARENT_AUTHOR, but no marker distinguishes it (UNCERTAIN). markedReplyTos/unmarkedReplyTos already strip the channel id. Known bug: QTag.parseAddressId rejects every address, so q addresses are lost until it is fixed. | +| 43 | `ChannelHideMessageEvent` | e[root] -> ROOT (E); e[unmarked] -> HIDDEN (E) | channel() (MarkedETag.parseRoot), ETag::parseId for the hidden ids (must exclude the root) | NIP-28 kind 43 carries only ['e', ]. Quartz ALSO writes the channel as a root-marked e. Bugs: (1) eventsToHide() = taggedEventIds() includes the channel root id, so the channel is 'hidden' too. (2) On a spec-conformant 43 (no root), channel() falls back to parseUnmarkedRoot and returns the HIDDEN MESSAGE as the channel. The semantic method must split root from unmarked. | +| 44 | `ChannelMuteUserEvent` | e[root] -> ROOT (E); p -> CHANNEL_MUTED (U) | channel() (MarkedETag.parseRoot), usersToMute() (PTag::parseKey) | NIP-28 kind 44 carries only ['p', pubkey]. The channel root e is Quartz's addition (see 43). CHANNEL_MUTED is already in the draft. | +| 10005 | `PublicChatListEvent` | e -> SUBSCRIBED (E) | channels() (ChannelTag::parse), linkedEventIds() (ChannelTag::parseId) | NIP-51 public chats list, pointing at NIP-28 kind 40 channels. This matches the draft (SUBSCRIBED lists 10005). Private entries are NIP-44 encrypted in content, visible only to the owner, and not linked. ChannelTag reads an optional author at position 2-4, a candidate for props. | + +### `nipACWebRtcCalls` (6) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 25050 | `CallOfferEvent` | p -> RECIPIENT (U) | recipientPubKeys() (PTag::parseKey) | NIP-AC (in-repo NIP-AC.md): 'p: Hex pubkey of the recipient (group calls: one per member)'. Ephemeral kind, delivered inside an ephemeral gift wrap 21059, so a relay graph never sees it plainly. call-id and call-type are session values, not modelled. | +| 25051 | `CallAnswerEvent` | p -> RECIPIENT (U) | recipientPubKeys() (PTag::parseKey) | NIP-AC (in-repo NIP-AC.md): 'p: Hex pubkey of the recipient (group calls: one per member)'. Ephemeral kind, delivered inside an ephemeral gift wrap 21059, so a relay graph never sees it plainly. call-id and call-type are session values, not modelled. | +| 25052 | `CallIceCandidateEvent` | p -> RECIPIENT (U) | PTag::parseKey (no recipientPubKeys() accessor on this class) | NIP-AC (in-repo NIP-AC.md): 'p: Hex pubkey of the recipient (group calls: one per member)'. Ephemeral kind, delivered inside an ephemeral gift wrap 21059, so a relay graph never sees it plainly. call-id and call-type are session values, not modelled. In group calls ICE candidates carry only the peer. | +| 25053 | `CallHangupEvent` | p -> RECIPIENT (U) | recipientPubKeys() (PTag::parseKey) | NIP-AC (in-repo NIP-AC.md): 'p: Hex pubkey of the recipient (group calls: one per member)'. Ephemeral kind, delivered inside an ephemeral gift wrap 21059, so a relay graph never sees it plainly. call-id and call-type are session values, not modelled. | +| 25054 | `CallRejectEvent` | p -> RECIPIENT (U) | recipientPubKeys() (PTag::parseKey) | NIP-AC (in-repo NIP-AC.md): 'p: Hex pubkey of the recipient (group calls: one per member)'. Ephemeral kind, delivered inside an ephemeral gift wrap 21059, so a relay graph never sees it plainly. call-id and call-type are session values, not modelled. | +| 25055 | `CallRenegotiateEvent` | p -> RECIPIENT (U) | recipientPubKeys() (PTag::parseKey) | NIP-AC (in-repo NIP-AC.md): 'p: Hex pubkey of the recipient (group calls: one per member)'. Ephemeral kind, delivered inside an ephemeral gift wrap 21059, so a relay graph never sees it plainly. call-id and call-type are session values, not modelled. | + +### `concord` (5) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 3302 | `ConcordChatEditEvent` | e -> EDITED (E) | editedMessageId() (firstTaggedEvent) | Concord CORD-02 Appendix B edit rumor. channel / epoch / ms binding tags carry Concord-internal channel ids, not Nostr entities: not modelled. Draft row verified. | +| 3308 | `ControlEditionEvent` | *none* | vsk()/eid()/ev()/ep()/vac() (concord/cord04Roles/control/tags) | Concord CORD-02/04 control-plane edition. eid (entity id), ep (prev edition hash), vac (grant id/version/hash) are Concord entity ids/hashes, not Nostr event ids/addresses/pubkeys; content is entity JSON. No Nostr links. | +| 13302 | `ConcordCommunityListEvent` | *none* | decrypt()/decryptDocument() (NIP-44 self-encrypted content, no tags) | Concord CORD-05 joined-communities list; everything (community roots, keys) is in encrypted content; built with emptyArray() tags. | +| 13303 | `ConcordInviteListEvent` | *none* | decrypt() (NIP-44 self-encrypted content, no tags) | Concord CORD-05 invite list; tokens and link-signer keys encrypted; no tags. | +| 33301 | `ConcordInviteBundleEvent` | *none* | versionedSubKind() (VskTag); content NIP-44 encrypted under the link token | Concord CORD-05 invite bundle: d='' and vsk only; no Nostr references visible. | + +### `nip71Video` (5) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 21 | `VideoNormalEvent` | p -> PARTICIPANT (U) [props role=label when present]; p[mention]/a[mention]/e[mention] -> MENTION (U,A,E); p[inspired-by]/a[label]/e[label e.g. audio] -> CREDITED (U,A,E) [props credit=label]; text-track[nevent/address ref] -> TEXT_TRACK (E,A); t -> HASHTAG (T) | participants() (PTag::parse), credits() (VideoCredits.parse: p/a/e with marker labels), textTrack() (TextTrackTag::parse; ref untyped, needs Address/NIP-19 detection), hashtags() | NIP-71: p = 'participant in the video'; text-track = 'link to WebVTT file' but example uses an encoded event and divine.video writes a 39307 address; r (web refs) is in the spec but Quartz does not read it. Credits labels are divine.video convention, not NIP-71. UNCERTAIN: whether role labels like 'Collaborator' stay PARTICIPANT(props role) or become CREDITED. Quartz gap: video classes implement no Event/PubKey/Address hint provider although they carry p/a/e. DRAFT FIX: draft lists 34238 (video collaboration) as REFERENCE-only; the collaborator p/credit convention here overlaps it. DRAFT FIX: PARTICIPANT kinds should add 21, 22, 34235, 34236. | +| 22 | `VideoShortEvent` | p -> PARTICIPANT (U) [props role=label when present]; p[mention]/a[mention]/e[mention] -> MENTION (U,A,E); p[inspired-by]/a[label]/e[label e.g. audio] -> CREDITED (U,A,E) [props credit=label]; text-track[nevent/address ref] -> TEXT_TRACK (E,A); t -> HASHTAG (T) | participants() (PTag::parse), credits() (VideoCredits.parse: p/a/e with marker labels), textTrack() (TextTrackTag::parse; ref untyped, needs Address/NIP-19 detection), hashtags() | Same tags as kind 21 (RegularVideoEvent). NIP-71. | +| 34235 | `AddressableNormalVideoEvent` | p -> PARTICIPANT (U) [props role=label when present]; p[mention]/a[mention]/e[mention] -> MENTION (U,A,E); p[inspired-by]/a[label]/e[label e.g. audio] -> CREDITED (U,A,E) [props credit=label]; text-track[nevent/address ref] -> TEXT_TRACK (E,A); t -> HASHTAG (T) | participants() (PTag::parse), credits() (VideoCredits.parse: p/a/e with marker labels), textTrack() (TextTrackTag::parse; ref untyped, needs Address/NIP-19 detection), hashtags() | Same tags as kind 21 (AddressableVideoEvent). NIP-71 addressable video. | +| 34236 | `AddressableShortVideoEvent` | p -> PARTICIPANT (U) [props role=label when present]; p[mention]/a[mention]/e[mention] -> MENTION (U,A,E); p[inspired-by]/a[label]/e[label e.g. audio] -> CREDITED (U,A,E) [props credit=label]; text-track[nevent/address ref] -> TEXT_TRACK (E,A); t -> HASHTAG (T) | participants() (PTag::parse), credits() (VideoCredits.parse: p/a/e with marker labels), textTrack() (TextTrackTag::parse; ref untyped, needs Address/NIP-19 detection), hashtags() | Same tags as kind 21 (AddressableVideoEvent). NIP-71 addressable short video. | +| 39307 | `TextTrackEvent` | a -> VIDEO (A); l -> TAG (T) | video() (ATag::parseAddress), language() (LanguageTag, l) | divine.video convention (not in NIP-71): addressable timed-text track referenced from a video's text-track tag. url is the hosted WebVTT (not modelled). | + +### `nip85TrustedAssertions` (5) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 10040 | `TrustProviderListEvent` | slot 1 pubkey -> SERVICE_PROVIDER (U) [props service] | serviceProviders() (ServiceProviderTag::parse) | NIP-85 'Declaring Trusted Service Providers'. Draft SERVICE_PROVIDER row confirmed (one link per entry). Encrypted entries in content are invisible to the graph. | +| 30382 | `UserAssertionEvent` | d -> SUBJECT (U) [props rank, followers, hops, ...]; t -> HASHTAG (T) | aboutUser() (dTag), rank()/followerCount()/hops()/... for props, topics() (TopicTag, t) | NIP-85 kind 30382. Draft SUBJECT row confirmed. p with the same value as d is only a relay hint (no extra link). Encrypted contact-card fields (petname/summary) not modelled. | +| 30383 | `EventAssertionEvent` | d -> SUBJECT (E) [props rank, comment_cnt, ...] | aboutEvent() (dTag), rank()/commentCount()/... for props | NIP-85 kind 30383. Draft SUBJECT row confirmed; e equal to d is a relay hint only. | +| 30384 | `AddressableAssertionEvent` | d -> SUBJECT (A) [props rank, comment_cnt, ...] | aboutAddress() (dTag), rank()/... for props | NIP-85 kind 30384. Draft SUBJECT row confirmed; a equal to d is a relay hint only. | +| 30385 | `ExternalIdAssertionEvent` | d -> SUBJECT (T) [NIP-73 id; props rank, comment_cnt, reaction_cnt]; k -> TAG (T) | aboutExternalId() (dTag), rank()/commentCount()/reactionCount(); k: KindTag (not read by the class) | NIP-85 kind 30385 'NIP-73 identifier' subject; 'NIP-73 k tags should be added'. DRAFT FIX: SUBJECT row lists only 30382-30384 and targets U,E,A; add 30385 and target T (Tag name i). | + +### `cyberspace` (4) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 3330 | `SnoShardEvent` | content palette (nevent/naddr) -> PALETTE (E,A) | SnoParser.parse -> SnoPayload.paletteRef (SnoPaletteRef.Event bech32) via readPaletteRef | Cyberspace DECK-0003 §3.2 bag item. Usually sealed (blank content). C coordinate tag is a cyberspace coordinate: not modelled. Pinned to the named event (reader MUST NOT follow forward) - the E target matters for nevent. | +| 11333 | `SnoAvatarEvent` | content palette (nevent/naddr) -> PALETTE (E,A) | SnoParser.parse -> SnoPaletteRef.Event | Cyberspace v2 §8.10 avatar (replaceable). name tag and nonce/PoW: values, not modelled. Blank content = default avatar (no link). | +| 33330 | `CyberspaceBagEvent` | *none* | lookupId() (d), height() (h), hint(), payload() (encrypted tag) | Cyberspace v2 §7.6 bag: items are AES-GCM encrypted inside the `encrypted` tag; d = region lookup id, h = height, version: values. Items once opened are their own events (not links). No Nostr references in cleartext. | +| 33331 | `SnoObjectEvent` | content palette (nevent/naddr) -> PALETTE (E,A) | SnoParser.parse -> SnoPaletteRef.Event | Cyberspace DECK-0003 §3.1 standalone object; name tag is a value. | + +### `nip47WalletConnect` (4) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 13194 | `NwcInfoEvent` | *none* | capabilities() (content), EncryptionTag/NotificationsTag/ExtensionsTag | NIP-47 info event: capabilities, encryption schemes, notification types; no references. | +| 23194 | `NwcRequestEvent` | p -> RECIPIENT (U) | walletServicePubKey() (first p) | NIP-47: p = 'the public key of the wallet service'. Chose RECIPIENT (the addressee an encrypted message is p-tagged and encrypted to, rule 2) over the NIP's role word. UNCERTAIN: alt WALLET_SERVICE (U) if wallet-service graphs are wanted. Ephemeral kind - rarely stored. | +| 23195 | `NwcResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U) | requestId() (first e), requestAuthor() (first p) | NIP-47. UNCERTAIN: p could equally be RECIPIENT (it is the encryption addressee); REQUEST_AUTHOR chosen because it is always the request's author and matches rule 3. Ephemeral kind. | +| 23197 | `NwcNotificationEvent` | p -> RECIPIENT (U) | clientPubKey() (first p) | NIP-47 notification (legacy 23196 same shape): p = client pubkey, encrypted to it. Ephemeral. | + +### `nip52Calendar` (4) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 31922 | `CalendarDateSlotEvent` | p -> PARTICIPANT (U); a -> CALENDAR (A); t -> HASHTAG (T); g -> TAG (T); r -> TAG (T) | participants() (PTag), hashtags(), geohash(), references(); a -> new parser needed | NIP-52. Props on PARTICIPANT: role (p slot 3). location/start/end values. Draft PARTICIPANT row verified; DRAFT FIX: add CALENDAR for the inclusion-request a. | +| 31923 | `CalendarTimeSlotEvent` | p -> PARTICIPANT (U); a -> CALENDAR (A); t -> HASHTAG (T); g -> TAG (T); r -> TAG (T) | participants() (PTag), hashtags(), geohash(), references(); a -> new parser needed | NIP-52. D day-index, start/end/tzid values. Props role on PARTICIPANT. | +| 31924 | `CalendarCollectionEvent` | a -> MEMBER (A) | calendarEventAddresses() (taggedAddresses) / ATag::parseAddressId | NIP-52 calendar: a = 31922/31923 events it includes. Draft MEMBER row verified. | +| 31925 | `CalendarRSVPEvent` | a -> CALENDAR_EVENT (A); e -> CALENDAR_EVENT (E); p -> CALENDAR_EVENT_AUTHOR (U) | calendarEventAddress() (firstTaggedAddress), calendarEventId() (firstTaggedEvent), calendarEventAuthor() (PTag) | NIP-52 RSVP. Props status (accepted/declined/tentative) and fb. Draft CALENDAR_EVENT row verified; DRAFT FIX: add the author relation. | + +### `nip54Wiki` (4) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 818 | `WikiMergeRequestEvent` | a -> DESTINATION (A); p -> DESTINATION_AUTHOR (U); e[source] -> SOURCE (E); unmarked e -> BASE_VERSION (E) | targetArticle() (ATag::parseAddress), destinationAuthor() (PTag::parseKey), mergeSource() (e with source\|fork marker), baseVersion() (unmarked e) | NIP-54 Merge Requests. DRAFT FIX: remove 818 from the REFERENCE-until-classified list. Quartz also accepts `fork` as the source marker. UNCERTAIN: SOURCE is a very generic name in a cross-kind vocabulary (alt: MERGE_SOURCE); BASE_VERSION alt: BASED_ON. | +| 819 | `WikiMergeAcceptanceEvent` | e[result] -> RESULT (E); e[request] -> REQUEST (E); p -> REQUEST_AUTHOR (U) | result()/request() (markedEvent by marker), requester() (PTag::parseKey) | Kind 819 is NOT in NIP-54 on nostr-protocol/nips master (NIP-54 says the destination accepts/rejects via NIP-25 reactions to the 818); Quartz-only / proposal. UNCERTAIN until specified. DRAFT FIX: remove 819 from the REFERENCE list. | +| 30818 | `WikiArticleEvent` | a[fork] -> FORK (A); e[fork] -> FORK (E); a[defer] -> DEFER (A); e[defer] -> DEFER (E); other a/e -> MENTION (E,A); p -> MENTION (U); q -> QUOTE (E,A); content nostr: -> MENTION (E,A,U); t -> HASHTAG (T) | forkFromAddress() (ForkTag::parseAddress), forkFromVersion() (MarkedETag::parseForkedEventId), ATag/MarkedETag/PTag/QTag, citedNIP19(); defer -> new parser needed | NIP-54. DRAFT FIX: draft lists 30818 under PARENT, but NIP-54 defines no parent/reply for articles - its a/e are fork (and defer) references; move 30818 to FORK (and FORK needs A and E targets). Content is Asciidoc/Markdown with wikilinks to d-tags ([[...]]), which are slugs, not addresses: not modelled. Known: QTag.parseAddressId rejects every address. | +| 30819 | `WikiRedirectEvent` | a -> REDIRECT (A) | target() (ATag::parseAddress) | NIP-54 redirects; d = normalized from-slug. Draft row verified. | + +### `nip58Badges` (4) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 8 | `BadgeAwardEvent` | a -> BADGE_DEFINITION (A); p -> AWARDED (U) | awardDefinition() (taggedAddresses), awardeeIds() (taggedUserIds) | NIP-58: single a (30009) + one p per awardee. Hint providers also read e tags, which NIP-58 does not define for kind 8 (ignore). Draft rows verified. | +| 10008 | `ProfileBadgesEvent` | a (paired) -> BADGE_DEFINITION (A); e (paired) -> BADGE_AWARD (E); a to kind 30008 -> BADGE_SET (A) | acceptedBadges() (AcceptedBadge.parseAll pairs); badgeAwardDefinitions() (taggedAddresses); BADGE_SET split -> new parser needed | NIP-58 (10008 is a NIP-51 standard list). Quartz: badgeAwardDefinitions() returns every a tag, so a 30008 badge-set pointer reads as a badge definition (bug for the relation). Hint providers read p tags NIP-58 does not define here. | +| 30008 | `AcceptedBadgeSetEvent` | a (paired) -> BADGE_DEFINITION (A); e (paired) -> BADGE_AWARD (E) | acceptedBadges() (AcceptedBadge.parseAll), badgeAwardEvents(), badgeAwardDefinitions() | NIP-58 Badge Set (NIP-51 set); d=profile_badges is the legacy profile-badges form (treat as 10008). title/image/description values. Draft rows verified. | +| 30009 | `BadgeDefinitionEvent` | *none* | badgeName/badgeImage/badgeThumbs/badgeDescription | NIP-58 badge definition: name, image and thumb URLs only. (ADDRESS/AUTHOR only.) | + +### `nip60Cashu` (4) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 7374 | `CashuMintQuoteEvent` | *none* | – | NIP-60 quote: content is the encrypted quote id; tags expiration and mint URL (not modelled). | +| 7375 | `CashuTokenEvent` | *none* | – | NIP-60 token: everything (mint, proofs, del token ids) is NIP-44 encrypted in content; no public tags. The encrypted del list would be DESTROYED links but is invisible to the graph. | +| 7376 | `CashuSpendingHistoryEvent` | e[redeemed] -> REDEEMED (E); p -> REDEEMED_AUTHOR (U); e[created] -> CREATED (E); e[destroyed] -> DESTROYED (E) | redeemedNutzaps() / redeemedReferences() (TokenReference::parseFromTag), PTag::parseKey; created/destroyed: TokenReference on public tags (usually encrypted) | NIP-60 says created/destroyed e tags SHOULD be encrypted and only redeemed stays public, so CREATED/DESTROYED are rare in the graph (encrypted tags never reach it). UNCERTAIN: p could instead reuse ZAP_SENDER (NIP-61 calls it the 'nutzap sender'), but rule 3 favours REDEEMED_AUTHOR. | +| 17375 | `CashuWalletEvent` | *none* | – | NIP-60 wallet: privkey and mint tags are NIP-44 encrypted in content; no public references. | + +### `nip72ModCommunities` (4) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 4550 | `CommunityPostApprovalEvent` | a[34550] -> COMMUNITY (A); e -> APPROVED (E); a[non-34550] -> APPROVED (A); p -> APPROVED_AUTHOR (U); k -> TAG (T) | communityAddresses() (CommunityTag), approvedEvents() (ApprovedEventTag::parseId), approvedAddresses() (ApprovedAddressTag), PTag::parseKey; k: KindTag | NIP-72 approval. Draft COMMUNITY/APPROVED rows confirmed. Content embeds the approved post JSON (containedPost()) - same id as e, not a separate link. | +| 10004 | `CommunityListEvent` | a[34550] -> SUBSCRIBED (A) | publicCommunities() / communityIds() (CommunityTag) | NIP-51 Communities list: 'NIP-72 communities the user belongs to'. Draft SUBSCRIBED row confirmed (MEMBER would match 'belongs to', but the draft chose SUBSCRIBED for follow-like lists; keep). Private (encrypted) entries never reach the graph. | +| 34550 | `CommunityDefinitionEvent` | p[moderator] -> MODERATOR (U); e/q/a -> MENTION (E,A) | moderators()/moderatorKeys() (ModeratorTag), ETag/QTag/ATag hint providers | NIP-72: p with role 'moderator'; relay tags (URLs) not modelled. Draft MODERATOR row confirmed. Quartz: ModeratorTag.parse accepts any p regardless of the role marker. UNCERTAIN: NIP-72 defines no e/q/a on 34550 yet the hint providers read them; MENTION assumed - confirm or drop. | +| 34551 | `CommunityRulesEvent` | a[34550] -> COMMUNITY (A); p[allow] -> ALLOWED (U) [props role]; p[deny] -> DENIED (U) [props role]; wot -> WOT_ROOT (U) [props depth]; k -> TAG (T) | communityAddress() (ATag), pubkeyRules() (PubkeyRuleTag::parse), wotGates() (WotTag::parse), kindRules() (KindRuleTag) | NIP-9B/9A 'Verifiable Community Rules' (unmerged upstream, 404; read from Quartz KDoc). UNCERTAIN: ALLOWED/DENIED could be one relation with props.policy, but deny vs allow is the filter every query applies (rule 4). | + +### `nipCCGeocaching` (4) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 7516 | `GeocacheFoundLogEvent` | a[37516] -> FOUND (A) | geocache() / geocacheId() (GeocacheTag = ATag, filtered to kind 37516 or legacy kind) | NIP-CC. Props: verified (hasVerificationAttached()). The verification tag embeds a full 7517 JSON (embeddedVerification()) - not a link by id. image URLs not modelled. Non-found logs are NIP-22 1111s (ROOT/PARENT to the 37516). DRAFT FIX: draft lists geocaching among REFERENCE-only experimental kinds; now classified. | +| 7517 | `GeocacheVerificationEvent` | a[:] finder -> FINDER (U); a[:] cache -> VERIFIED (A) | finder() / verifiedCache() (FinderCacheTag::parseFinder / parseCache) | NIP-CC. The a tag is a non-standard composite ':', so a shape-based ATag parser would misread it. Signed by the cache's verification key, not the finder (so AUTHOR = the key named by 37516's verification tag). | +| 37516 | `GeocacheListingEvent` | F -> WINNER (U); verification -> VERIFIER (U); t -> HASHTAG (T); g -> TAG (T) | firstToFindWinner() (FirstToFindWinnerTag, F), verificationKey() (VerificationKeyTag), cacheType()/isArchived() (t), geohashes() | NIP-CC. t here is the cache type / 'archived', not a free hashtag (HASHTAG per the rule, flagged). r are relay URLs for logs (NOT web refs) - not modelled; must not become TAG r. n, D, T, S, hint, mission, image not modelled. UNCERTAIN: VERIFIER is a dedicated key, not a person's identity. | +| 37517 | `GeocacheCurationListEvent` | a[37516] -> CURATED (A); g -> TAG (T) | curatedGeocaches() / curatedAddresses() (ATag), geohashes() | NIP-CC curation list. Draft CURATED row (37517) confirmed. | + +### `nipF4Podcasts` (4) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 54 | `PodcastEpisodeEvent` | *none* | – | NIP-F4 kind 54: title, image, description, audio (URLs). Authored by the podcast's own key, so AUTHOR is the show. | +| 10054 | `FavoritePodcastsListEvent` | p -> FAVORITE (U) | publicFavorites() (UserTag::parse) | NIP-51 / NIP-F4. NIP-51 also allows url (RSS feed URLs) - class ignores them, not modelled. Quartz gap: no PubKeyHintProvider. UNCERTAIN: alternative is BOOKMARK with a U target (NIP-51 literally says 'bookmark') or SUBSCRIBED (NIP-F4: 'publicly advertise to listening to'). | +| 10064 | `AuthoredPodcastsEvent` | p -> AUTHORED (U) | authoredKeys() / linkedPubKeys() (UserTag::parseKey) | NIP-F4 'Authored Podcasts' (spec text says kind 10164 once but the example and NIP-51 say 10064). DRAFT FIX: draft MEMBER row lists 10064; this is not a membership set but an authorship claim, which a query must join with 10154's PODCAST_AUTHOR (both directions must agree). | +| 10154 | `PodcastMetadataEvent` | p -> PODCAST_AUTHOR (U) [props role host/cohost/editor] | claimedAuthors() (AuthorTag::parse) | NIP-F4: the claim 'shouldn't be blindly trusted' until matched by the author's 10064 (AUTHORED). website/image URLs not modelled. Quartz: AuthorTag drops unknown roles (role null) and no PubKeyHintProvider. UNCERTAIN: could reuse PARTICIPANT(props role) as NIP-53 does for Host. | + +### `nip17Dm` (3) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 14 | `ChatMessageEvent` | p -> RECIPIENT (U); e -> PARENT (E); q -> QUOTE (E,A); content nostr: -> MENTION (E,A,U); zap -> ZAP_SPLIT (U) | recipientsPubKey() (BaseDMGroupEvent, PTag); replyTo() (ETag::parseId); q and content citations -> new parser needed (citedNIP19 lives on BaseNoteEvent, not BaseDMGroupEvent); zapSplitSetup() | NIP-17: p = receivers, e = 'the direct parent message this post is replying to', q MAY cite NIP-21 in content. Draft rows verified; DRAFT FIX: QUOTE and MENTION kinds should include 14. subject tag is a value, not modelled. Rumor kind: normally only reaches a store unwrapped. | +| 15 | `ChatMessageEncryptedFileHeaderEvent` | p -> RECIPIENT (U); e[reply] -> PARENT (E) | recipientsPubKey() (BaseDMGroupEvent); replyTo() (ETag::parseId) | NIP-17 file message. DRAFT FIX: PARENT kinds should list 15. x/ox are blob hashes of an encrypted file, content is the file URL: not modelled. No hint provider for the e tag (Quartz gap, like kind 4). | +| 10050 | `DmRelayListEvent` | *none* | RelayTag::parse (relays()) | NIP-17 DM inbox relays: relay URLs only: not modelled. | + +### `nip57Zaps` (3) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 9733 | `PrivateZapEvent` | e -> ZAPPED (E); a -> ZAPPED (A); p -> ZAP_RECIPIENT (U); k -> TAG (T) | ETag::parseId, ATag::parseAddressId, PTag::parseKey (hint providers); KindTag | NIP-57 private zap: the decrypted inner event of an anon tag, built by PrivateZapRequestBuilder from the zap request's tags minus `anon`, so it carries the same e/a/p/k. Draft rows verified. | +| 9734 | `ZapRequestEvent` | e -> ZAPPED (E); a -> ZAPPED (A); p -> ZAP_RECIPIENT (U); k -> TAG (T) | zappedPost() (ETag), ATag::parseAddress, zappedAuthor() (PTag), KindTag; amount tag for props msats | NIP-57 Appendix A/D: exactly one p, 0 or 1 e, optional a, k. Props msats from `amount`. relays/lnurl/anon/poll_option and NIP-29 h: not modelled. Draft rows verified. | +| 9735 | `ZapReceiptEvent` | e -> ZAPPED (E); a -> ZAPPED (A); p -> ZAP_RECIPIENT (U); P -> ZAP_SENDER (U); description (embedded 9734) -> ZAP_REQUEST (E); k -> TAG (T) | zappedPost(), ATag::parseAddress, zappedAuthor(); P -> new parser needed (Quartz reads only zappedRequestAuthor() = zapRequest?.pubKey); zapRequest (containedPost()) | NIP-57 Appendix E. Props msats from bolt11 (amount). ZAP_SENDER should come from P (NIP-57: 'P tag from the pubkey of the zap request (zap sender)'), falling back to the embedded request's pubkey; for anonymous zaps it is a throwaway key. Known upstream: ZapReceiptEvent omits the zap sender from its hint providers. UNCERTAIN: ZAP_REQUEST targets an event that is normally never published to relays. | + +### `nip59Giftwrap` (3) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 13 | `SealEvent` | *none* | n/a (content is NIP-44 encrypted rumor; tags normally empty, optional expiration) | NIP-59. The rumor inside is its own event once unsealed; no link from the seal (innerEventId is local runtime state). | +| 1059 | `GiftWrapEvent` | p -> RECIPIENT (U) | recipientPubKey() (firstTagValue p) / PTag::parseKey | NIP-59/NIP-17. Signed by a throwaway key; content encrypted (inner event not linked). Draft row verified. | +| 21059 | `EphemeralGiftWrapEvent` | p -> RECIPIENT (U) | recipientPubKey() (inherited from GiftWrapEvent) | NIP-59 ephemeral gift wrap (CEP-19 uses it too). Draft row verified. | + +### `nip5dNapplets` (3) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 5129 | `NappletSnapshotEvent` | a -> SNAPSHOTTED (A); A -> ORIGIN (A); app -> APP (A) | new parser needed (NappletManifest reads only path/server/requires/x/title/description/source/icon) | NIP-5D napplets are not on nostr-protocol/nips master (spec fetch 404); per NappletManifest they 'carry the same NIP-5A tag set', so the 5128 snapshot rules are applied by analogy. UNCERTAIN: whole row; Quartz build() writes none of a/A/app. path hashes, server (blossom) and source URLs: not modelled (source may be a NIP-34 nostr:// git URL - a future REPOSITORY candidate). | +| 15129 | `RootNappletEvent` | a -> COPIED (A); A -> ORIGIN (A); app -> APP (A) | new parser needed (NappletManifest) | NIP-5D root napplet; 'carries the NIP-5A tag set' (NappletManifest). requires = NAP capability domains (values, not modelled). UNCERTAIN: NIP-5D not on nips master; applies NIP-5A rules by analogy; Quartz writes none of these tags. | +| 35129 | `NamedNappletEvent` | a -> COPIED (A); A -> ORIGIN (A); app -> APP (A) | new parser needed (NappletManifest) | NIP-5D named napplet; NIP-5A tag set by analogy. UNCERTAIN: NIP-5D not on nips master. | + +### `nip87Ecash` (3) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 38000 | `MintRecommendationEvent` | a[38172/38173] -> RECOMMENDED (A) [props platform cashu/fedimint]; k -> TAG (T) | mintEventAddresses() (raw a values - no Address validation), mintEventKind() (k) | NIP-87 recommendation. DRAFT FIX: RECOMMENDED kinds should add 38000. u values are mint URLs / fedimint invite codes - not modelled. Quartz bug: 38000, 38172, 38173 extend Event, not BaseAddressableEvent, though they are addressable (d-tagged, 3xxxx); mintEventAddresses() returns unvalidated strings. | +| 38172 | `CashuMintEvent` | *none* | – | NIP-87 cashu mint: d is the MINT's pubkey (not a Nostr user), u mint URL, nuts, n. Not modelled. Quartz: extends Event, not BaseAddressableEvent. | +| 38173 | `FedimintEvent` | *none* | – | NIP-87 fedimint: d federation id, u invite codes, modules, n. Quartz: extends Event, not BaseAddressableEvent. | + +### `nipB1Bolt12Zaps` (3) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 9736 | `Bolt12ZapEvent` | p -> ZAP_RECIPIENT (U); P -> ZAP_SENDER (U); e -> ZAPPED (E); a -> ZAPPED (A); k -> TAG (T) | recipient() (PTag), payer() (PayerTag, P), zappedEvent() (ETag), zappedAddress() (ATag), zappedKind(), amount() (msats), zapIntent (embedded 9737) | NIP-B1 not merged upstream (404); read from Quartz KDoc. DRAFT FIX: ZAP_SENDER kinds should add 9736 (the P payer tag, NIP-57's word). Props msats = amount(). description embeds the 9737 intent (its id is not a tag; not modelled). Anonymous zaps have no P. | +| 9737 | `Bolt12ZapIntentEvent` | p -> ZAP_RECIPIENT (U); e -> ZAPPED (E); a -> ZAPPED (A); k -> TAG (T) | recipient() (PTag), zappedEvent() (ETag), zappedAddress() (ATag), zappedKind(), amount() | NIP-B1 (unmerged). Draft lists 9737 under ZAPPED. UNCERTAIN: an intent is not a payment ('never counted on its own'), so ZAPPED/ZAP_RECIPIENT counts must filter on source kind 9736 - same situation as 9734 requests; the signer is the would-be sender (AUTHOR). | +| 10058 | `Bolt12OfferListEvent` | *none* | – | NIP-B1 (unmerged): offer tags (BOLT12 offers) only. | + +### `contextvm` (2) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 11316 | `CvmServerAnnouncementEvent` | *none* | DiscoverySurface.parse(tags) (name/about/picture/website/support_* flags only) | ContextVM CEP-6 (+CEP-23/35). Discovery tags are self-description values; `p`/`e` are routing tags and CvmTags.ROUTING excludes them from the surface; not expected on an announcement. UNCERTAIN: CEP-17 `r` relay tags if present are relay URLs (not modelled). Kind number from CvmKinds.SERVER_ANNOUNCEMENT = 11316. | +| 11317 | `CvmToolsListEvent` | i -> TAG (T); k -> TAG (T) | CommonToolSchema.parseExternalIds(tags) (i); k written by CommonToolSchema.externalKindTag() | ContextVM CEP-6 + CEP-15 common tool schemas: NIP-73-style `[i, , ]` + `[k, io.contextvm/common-schema]` on the announcement. Content is the tools JSON (no nostr refs). UNCERTAIN: CEP-15 says 'one per announcement event' without naming 11316 vs 11317; Quartz's builder is not bound to a class, so the same i/k may also appear on 11316. | + +### `nip18Reposts` (2) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 6 | `RepostEvent` | e -> REPOSTED (E); a -> REPOSTED (A); p -> REPOSTED_AUTHOR (U); k -> TAG (T) | boostedEventId()/boostedAddress() (last e/a), originalAuthorKeys() (PTag), boostedKind() (KindTag) | NIP-18. Content embeds the reposted event JSON (containedPost()) = the same id as e; not a separate link. Kind 6 per NIP-18 is only for kind 1 but Quartz writes `a` for addressables. Draft rows verified. Note boostedEventId takes the LAST e while linkedEventIds lists all; extra e tags (non-spec) would get no meaning - treat non-last e/p as MENTION. | +| 16 | `GenericRepostEvent` | e -> REPOSTED (E); a -> REPOSTED (A); p -> REPOSTED_AUTHOR (U); k -> TAG (T) | boostedEventId()/boostedAddress(), originalAuthorKeys(), boostedKind() | NIP-18 generic repost; a for replaceables, content JSON when a is absent (same id as e, not a separate link). Draft rows verified. | + +### `nip25Reactions` (2) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 7 | `ReactionEvent` | last e -> REACTED (E); a -> REACTED (A); last p -> REACTED_AUTHOR (U); earlier e -> MENTION (E); earlier p -> MENTION (U); k -> TAG (T); emoji[emoji-set-address] -> EMOJI_SET (A) | ETag::parseId, ATag::parseAddress, PTag::parseKey (need last-of, see note), KindTag; EmojiUrlTag (slot 3 new parser) | NIP-25: 'the target event id should be last of the e tags' and 'the target event pubkey should be last of the p tags' (extra e/p are legacy thread copies - MENTION). Quartz bug: originalPost() / originalAuthor() return ALL e ids / p keys, not the last, while the draft cites them for REACTED/REACTED_AUTHOR. DRAFT FIX: cite lastNotNullOfOrNull(ETag::parseId)/(PTag::parseKey), not originalPost()/originalAuthor(). | +| 17 | `ExternalReactionEvent` | i -> REACTED (T); k -> TAG (T); emoji[emoji-set-address] -> EMOJI_SET (A) | externalIds() (ExternalTargetTag::parse), externalKinds() (ReplyKindTag::parse); EmojiUrlTag slot 3 new parser | NIP-25 external content reactions with NIP-73 k+i; several i pairs possible (show + episode), each a REACTED. The i hint (URL) is not a target. Draft row verified. | + +### `nip30CustomEmoji` (2) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 10030 | `EmojiListEvent` | a -> MEMBER (A); emoji[emoji-set-address] -> EMOJI_SET (A) | emojiPackIds() (ATag::parseAddressId); emoji tags: EmojiUrlTag (slot 3 new parser) | NIP-51 'Emojis: user preferred emojis and pointers to emoji sets' (a = kind 30030). Draft lists 10030 under MEMBER. UNCERTAIN / possible DRAFT FIX: the a entries are sets the user USES (a selection), which reads closer to SUBSCRIBED than to membership; rule 7 would name it after the list ('emoji sets'). Loose emoji tags (URLs) not modelled. | +| 30030 | `EmojiPackEvent` | emoji[emoji-set-address] -> EMOJI_SET (A) | tags.emojis() (EmojiUrlTag; slot 3 new parser); private emojis in NIP-44 content | NIP-51 emoji set / NIP-30. The emoji tags themselves are shortcode+URL (not modelled); only the optional 4th slot (the set an emoji came from, NIP-30) is a link. UNCERTAIN: may point at the pack itself - skip self-links. | + +### `nip35Torrents` (2) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 2003 | `TorrentEvent` | i -> TAG (T); t -> HASHTAG (T); q -> QUOTE (E,A); p -> MENTION (U); r -> TAG (T) | HashtagTag/hashtags(); QTag; PTag; i -> new parser needed (TorrentEvent has no i accessor) | NIP-35: i = tcat/newznab/imdb/tmdb/... ids, t = categories. Quartz build() turns nostr: URIs in the description into q (note/nevent/naddr) and p (npub/nprofile via NPub.toQuoteTagArray = PTag) and URLs into r. x/btih info hash, file, tracker: not modelled. Content nostr: is not parsed on read (no citedNIP19 on this class). DRAFT FIX: remove 2003 from the unclassified list. | +| 2004 | `TorrentCommentEvent` | e[root] (or first) -> ROOT (E); e[reply] (or last) -> PARENT (E); middle unmarked e -> MENTION (E); p equal to parent author -> PARENT_AUTHOR (U); other p -> MENTION (U); q -> QUOTE (E,A); content nostr: -> MENTION (E,A,U) | torrent() / torrentIds() (MarkedETag::parseRoot, fallback first ETag), BaseThreadedEvent.reply()/markedReply(), PTag, QTag, citedNIP19() | NIP-35: 'works exactly like a kind 1 and should follow NIP-10'; the root is the 2003 torrent. Deprecated in Quartz (replaced by NIP-22). DRAFT FIX: ROOT, QUOTE and MENTION kinds should list 2004 (draft has it only under PARENT). | + +### `nip37Drafts` (2) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 10013 | `PrivateOutboxRelayListEvent` | *none* | RelayTag::parse (publicRelays()); private relays in NIP-44 content | NIP-37 private-outbox relay list: relay URLs only: not modelled. | +| 31234 | `DraftWrapEvent` | k -> TAG (T); e[root] -> ROOT (E); e[reply] -> PARENT (E); a -> ROOT (A) | KindTag (kind(draft.kind)); exposed tags from ExposeInDraft.exposeInDraft() (ChannelMessageEvent: e root/reply; LiveActivitiesChatMessageEvent: a activity + e reply) - MarkedETag/ATag | NIP-37. The draft itself is NIP-44 encrypted (no content links). Quartz copies the draft's thread anchors (channel, live activity, reply) into public tags so a draft shows in context; they carry the inner kind's meaning (the k tag says which). UNCERTAIN: whether exposed anchors of an unpublished draft should be graph links at all, or get DRAFT_-prefixed relations; kept as ROOT/PARENT per rule 2. | + +### `nip5aStaticWebsites` (2) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 15128 | `RootSiteEvent` | a -> COPIED (A); A -> ORIGIN (A); app -> APP (A) | new parser needed (class reads only path/server/title/description/source/icon) | NIP-5A. path (blob sha256), x aggregate hash, server (blossom), source (URL or NIP-34 nostr:// git URL): not modelled. UNCERTAIN: COPIED/ORIGIN could reuse FORK/ROOT (rule 2: a copy is a fork; NIP-22 uses uppercase for the root) - chose the NIP's words per rule 7. | +| 35128 | `NamedSiteEvent` | a -> COPIED (A); A -> ORIGIN (A); app -> APP (A) | new parser needed (class reads only path/server/title/description/source/icon) | NIP-5A named site (d = identifier). Same notes as 15128. | + +### `nip61Nutzaps` (2) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 9321 | `NutzapEvent` | e -> ZAPPED (E); p -> ZAP_RECIPIENT (U); k -> TAG (T) | ETag::parseId, PTag::parseKey (no zappedEvent()/recipient() accessors), claimedSatsTotal() for props | NIP-61: 'p is the Nostr identity public key of nutzap recipient', 'e is the event that is being nutzapped'. Draft ZAPPED/ZAP_RECIPIENT rows confirmed; props msats = claimedSatsTotal*1000 (sender-claimed). u = mint URL, not modelled. The sender is the AUTHOR. NIP-61 has no a (addressable) target. | +| 10019 | `NutzapInfoEvent` | *none* | – | NIP-61: relay (URLs), mint (URLs), pubkey = the P2PK key, which 'MUST NOT' be the user's Nostr key - not a User node; not modelled. | + +### `nip66RelayMonitor` (2) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 10166 | `RelayMonitorEvent` | g -> TAG (T) | geohashes() | NIP-66 monitor announcement: frequency, timeout, c (checks), g. No E/A/U references. | +| 30166 | `RelayDiscoveryEvent` | t -> HASHTAG (T); g -> TAG (T); k -> TAG (T) | topics() (hashtags), geohashes(), acceptedKinds() (AcceptedKindTag, k) | NIP-66: d is the relay URL (not modelled); n, N, R, T, rtt-* are relay attributes. l (language) is in the spec example but not read by the class. | + +### `nip78AppData` (2) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 78 | `AppDataEvent` | *none* | – | NIP-78: tags are arbitrary, app-private and non-interoperable; class reads only d. DRAFT FIX: draft Coverage lists 'app data and handlers (78, 30078, 31990)' among kinds carrying references; 78/30078 carry none by spec. | +| 30078 | `AppSpecificDataEvent` | *none* | – | NIP-78: arbitrary app-private tags; class reads only d. DRAFT FIX: see 78 - listed in Coverage as carrying references, but carries none by spec. | + +### `nip88Polls` (2) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 1018 | `PollResponseEvent` | e -> POLL (E); p -> POLL_AUTHOR (U) | poll() / PollTag::parseId, PTag::parseKey (written by notifyAuthor()) | NIP-88: 'an e tag with the poll event it is referencing, followed by one or more response tags'. Draft POLL row confirmed. Props on POLL: responses (response tag option ids). Quartz: p is an Amethyst convention, not NIP-88. | +| 1068 | `PollEvent` | *none* | – | NIP-88 poll: option, relay (URLs, not modelled), polltype, endsAt. No references. | + +### `nip89AppHandlers` (2) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 31989 | `AppRecommendationEvent` | a[31990] -> RECOMMENDED (A) [props platform] | recommendationAddresses() (RecommendationTag::parseAddressId) | NIP-89. Draft RECOMMENDED row confirmed. d is the recommended kind number (value, not in the allowlist). | +| 31990 | `AppDefinitionEvent` | a -> SITE_MANIFEST (A); latest -> SITE_MANIFEST (A) [props release=latest]; next -> SITE_MANIFEST (A) [props release=next]; client -> CLIENT (A); k -> TAG (T); t -> HASHTAG (T) | relatedAddresses() (ATag), client() (ClientTag), supportedKinds() (KindTag), categories() (hashtags); latest/next: new parser needed | NIP-89 handler information. Platform links (web/ios/android URLs) not modelled. UNCERTAIN: whether latest vs next deserve two relations (rule 4) - props chosen. CLIENT applies to every kind (Quartz nip89AppHandlers/clientTag); it belongs in the shared default, not per class. Draft Coverage lists 31990 as unclassified. | + +### `nipA0VoiceMessages` (2) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 1222 | `VoiceEvent` | *none* | – | NIP-A0: content is an audio URL; t/g MAY be included per other NIPs but the class does not read them (would be HASHTAG/TAG if added). | +| 1244 | `VoiceReplyEvent` | E/A[root scope] -> ROOT (E,A); P -> ROOT_AUTHOR (U); e -> PARENT (E); p -> PARENT_AUTHOR (U); K/k -> TAG (T) | replyingTo()/markedReplyTos() (ReplyEventTag::parseKey, e), replyAuthorKeys() (ReplyAuthorTag::parseKey, p), directKinds() (k); root scope E/A/P: new parser needed (nip22Comments tag parsers exist) | NIP-A0: kind 1244 'MUST follow the structure of NIP-22'. Draft ROOT/PARENT/ROOT_AUTHOR/PARENT_AUTHOR rows for 1244 match the spec. Quartz bug: VoiceReplyEvent.build writes only e/k/p (parent item) and no E/K/P root scope, and the class reads no root; ReplyEventTag reads only e, so a parent given as an a tag is missed; class implements no hint providers. | + +### `nipB7Blossom` (2) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 10063 | `BlossomServersEvent` | *none* | – | NIP-B7: server URLs - not modelled. | +| 24242 | `BlossomAuthorizationEvent` | *none* | – | NIP-B7/BUD auth: t is the VERB (upload/get/delete/list), not a hashtag, and x a blob hash; server/expiration. Emitting HASHTAG for this t would pollute topics - exclude. Short-lived auth token, normally not stored. | + +### `nipXXPodcasting20` (2) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 30054 | `Podcasting20EpisodeEvent` | edit -> EDITED (E); t -> HASHTAG (T) | editsEventId() (EditTag::parse), topics() (hashtags) | Podcasting-2.0 draft (not a NIP; podstr). edit = 'the event id of the original publication when an addressable episode/trailer is updated' - fits draft EDITED. person tags carry names/URLs, not pubkeys (not modelled). Quartz: EditTag.parse does not check 64-hex. | +| 30055 | `Podcasting20TrailerEvent` | *none* | – | Podcasting-2.0 draft trailer: title, url, pubdate, length, type, season - no references. | + +### `nip01Core` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 0 | `MetadataEvent` | i -> TAG (T); emoji[emoji-set-address] -> EMOJI_SET (A) | IdentityClaimTag::parse (i); EmojiUrlTag does not read slot 3 -> new parser needed for EMOJI_SET | NIP-01 / NIP-39 (identity claims mirrored as `i` tags, nips PR 1770 tag-names) / NIP-30. Content is JSON; Quartz does not parse nostr: URIs in `about` (no citedNIP19 on this class) so no content MENTION. Other name/picture/... tags are plain values, not modelled. | + +### `nip02FollowList` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 3 | `ContactListEvent` | p -> FOLLOW (U) | ContactTag::parseKey / parseValid (petname, relay hint) | NIP-02. Content relay map (legacy) is relay URLs: not modelled. Draft row verified. | + +### `nip03Timestamp` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 1040 | `OtsEvent` | e -> TIMESTAMPED (E); k -> TAG (T) | TargetEventTag::parseId (digestEventId()); targetKind (KindTag) | NIP-03: e = target event, k = target kind. Draft row verified. | + +### `nip04Dm` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 4 | `EncryptedDmEvent` | p -> RECIPIENT (U); e -> PARENT (E) | PTag::parseKey (recipientPubKey()); MarkedETag::parseId (replyTo()) | NIP-04: p = receiver; e = 'the previous message in a conversation or a message we are explicitly replying to'. DRAFT FIX: PARENT kinds should list 4. Content encrypted; NIP-04 says clients should not rewrite nostr refs into tags. EncryptedDmEvent does not implement EventHintProvider for its e tag (only pubkeys) - minor Quartz gap. | + +### `nip09Deletions` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 5 | `DeletionRequestEvent` | e -> DELETED (E); a -> DELETED (A); p -> DELETED_AUTHOR (U); k -> TAG (T) | ETag::parseId (deleteEventIds()), ATag::parseAddressId (deleteAddressIds()), PTag::parseKey (new accessor), KindTag (kinds()) | NIP-09 defines only e/a/k (the p is Quartz practice). Draft DELETED row verified. | + +### `nip10Notes` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 1 | `TextNoteEvent` | e[root] (or first unmarked) -> ROOT (E); e[reply] (or last unmarked) -> PARENT (E); e[mention] or middle unmarked e -> MENTION (E); e[fork] -> FORK (E); a[root] -> ROOT (A); a[reply] -> PARENT (A); a[fork] -> FORK (A); a to kind 34550 -> COMMUNITY (A); other a -> MENTION (A); p equal to the parent's author (e[reply] pubkey slot) -> PARENT_AUTHOR (U); other p -> MENTION (U); q -> QUOTE (E,A); content nostr: -> MENTION (E,A,U); t -> HASHTAG (T); r -> TAG (T); g -> TAG (T); zap -> ZAP_SPLIT (U); emoji[emoji-set-address] -> EMOJI_SET (A) | BaseThreadedEvent.markedRoot/unmarkedRoot/markedReply/unmarkedReply, MarkedETag.parseAllThreadTags/parseForkedEventId (MARKER.ROOT/REPLY/MENTION/FORK); ATag::parseAddress; PTag::parseKey; QTag::parseEventId/parseAddressId; citedNIP19(); Event.hashtags(); zapSplitSetup(); a-marker and PARENT_AUTHOR matching -> new parser needed | NIP-10 (+NIP-18 q, NIP-27 content, NIP-72 legacy community a, NIP-57 zap). DRAFT FIX: PARENT_AUTHOR (and ROOT_AUTHOR from e[root] pubkey slot) should include kind 1 - NIP-10 says the replied-to author is added to p; without it 'replies to my notes' needs a 2-hop join. DRAFT FIX: FORK targets E and A (isAFork accepts a or e with fork marker). NIP-10 no longer defines a `mention` marker; Quartz still parses MARKER.MENTION (legacy). Quartz bug: forkFromAddress() = first ATag::parseAddress regardless of fork marker (a community a-tag reads as the fork source); WikiArticleEvent uses ForkTag correctly. Known: QTag.parseAddressId rejects every address. UNCERTAIN: whether a p that is both parent author and notified thread member emits only PARENT_AUTHOR (proposed) or both. | + +### `nip22Comments` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 1111 | `CommentEvent` | E -> ROOT (E); A -> ROOT (A); I -> ROOT (T); K -> TAG (T); P -> ROOT_AUTHOR (U); e -> PARENT (E); a -> PARENT (A); i -> PARENT (T); k -> TAG (T); p equal to the parent's author -> PARENT_AUTHOR (U); other p -> MENTION (U); q -> QUOTE (E,A); content nostr: -> MENTION (E,A,U); t -> HASHTAG (T); zap -> ZAP_SPLIT (U); emoji[emoji-set-address] -> EMOJI_SET (A) | RootEventTag/RootAddressTag/RootIdentifierTag/RootKindTag/RootAuthorTag, ReplyEventTag/ReplyAddressTag/ReplyIdentifierTag/ReplyKindTag/ReplyAuthorTag (nip22Comments/tags), QTag, citedNIP19(), hashtags(), zapSplitSetup() | NIP-22. DRAFT FIX: ROOT and PARENT need target T for the I/i external-identifier scopes (hashtag, geohash, URL comments). NIP-22 also says 'p tags SHOULD be used when mentioning pubkeys in content' so a lowercase p is PARENT_AUTHOR only when it matches the parent (e tag's pubkey slot / replyAuthor()), else MENTION; ReplyAuthorTag currently treats every p as the parent author (Quartz ambiguity). UNCERTAIN: an A root of kind 34550 is a NIP-72 community post - emit COMMUNITY (A) in addition to ROOT? Known: QTag.parseAddressId rejects every address. | + +### `nip23LongContent` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 30023 | `LongFormContentEvent` | q -> QUOTE (E,A); e -> MENTION (E); a -> MENTION (A); p -> MENTION (U); content nostr: -> MENTION (E,A,U); t -> HASHTAG (T); zap -> ZAP_SPLIT (U); emoji[emoji-set-address] -> EMOJI_SET (A) | QTag::parseEventId/parseAddressId, PTag::parseKey, citedNIP19(), topics()/hashtags(), zapSplitSetup(); e/a not read by hint providers -> new parser needed | NIP-23: 'references to other notes, articles or profiles must be made according to NIP-27 ... optionally adding tags for these' - so e/a/p are mention tags. Although it extends BaseThreadedEvent it has no reply semantics (root()/reply() must not be used for it). Known: QTag.parseAddressId rejects every address. Draft rows verified. | + +### `nip32Labeling` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 1985 | `LabelEvent` | e -> LABELED (E); a -> LABELED (A); p -> LABELED (U); t -> LABELED (T); r -> LABELED (T); l -> TAG (T); L -> TAG (T) | labeledEvents() (ETag), labeledAddresses() (ATag), labeledPubKeys() (PTag), labeledHashtags() (HashtagTag), labeledRelayUrls() (r), labels()/namespaces() | NIP-32. Props labels (l values with namespace) on each LABELED. NOTE: on 1985 `t` and `r` are label TARGETS, not the event's own topics - they must NOT fall back to HASHTAG/TAG. r may be a relay URL; kept as a T target because it is what is labeled (UNCERTAIN given 'relay URLs not modelled in v1'). With no target tag, the labels apply to the label event itself (no link). Draft row verified. | + +### `nip38UserStatus` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 30315 | `UserStatusEvent` | p -> LINKED (U); e -> LINKED (E); a -> LINKED (A); r -> TAG (T); emoji[emoji-set-address] -> EMOJI_SET (A) | create() writes PTag/ETag/ATag but there are no readers (only firstTaggedUrl() for r) -> new parser needed | NIP-38. d = status type (general/music), expiration: values. DRAFT FIX: remove 30315 from the REFERENCE list. UNCERTAIN: LINKED vs reusing MENTION. | + +### `nip39ExtIdentities` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 10011 | `ExternalIdentitiesEvent` | i -> TAG (T) | IdentityClaimTag::parse (claims via replaceClaims) | NIP-39: i = platform:identity with proof. Plain value tag. | + +### `nip42RelayAuth` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 22242 | `RelayAuthEvent` | *none* | relay() (RelayTag), challenge() (ChallengeTag) | NIP-42: relay URL + challenge string only: not modelled. | + +### `nip46RemoteSigner` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 24133 | `NostrConnectEvent` | p -> RECIPIENT (U) | recipientPubKey()/verifiedRecipientPubKey() (first p) | NIP-46: client p-tags remote-signer and vice versa, encrypting to it. DRAFT FIX: RECIPIENT kinds could list 24133, 23194, 23197. Ephemeral. | + +### `nip50Search` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 10007 | `SearchRelayListEvent` | *none* | tags.relays() (RelayTag) | NIP-50/NIP-51 search relay list: relay URLs (public + NIP-44 private): not modelled. | + +### `nip56Reports` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 1984 | `ReportEvent` | p (report names no e/a/x) -> REPORTED_USER (U); p (report also names e/a/x) -> REPORTED_AUTHOR (U); e -> REPORTED (E); a -> REPORTED (A); x -> REPORTED (T); l -> TAG (T); L -> TAG (T) | ReportedAuthorTag / ReportedEventTag / ReportedAddressTag (typed, DefaultReportTag fallback), HashSha256Tag (x); reportedAuthorsWithOwnType() | NIP-56. Props report/report_raw on all three. server tag = media server URL: not modelled. Split must be by presence of e/a/x, NOT by whether p carries its own type: Quartz's own build() writes the type on both e and p. Draft rows verified. | + +### `nip62RequestToVanish` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 62 | `RequestToVanishEvent` | *none* | – | NIP-62: only relay tags (relay URL or ALL_RELAYS) - not modelled. The vanish effect is about the AUTHOR, already the AUTHOR link. | + +### `nip65RelayList` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 10002 | `AdvertisedRelayListEvent` | *none* | – | NIP-65: r tags are relay URLs (read/write markers) - not modelled; NOT the TAG r (web url) meaning. | + +### `nip68Picture` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 20 | `PictureEvent` | p -> TAGGED (U); imeta annotate-user -> TAGGED (U) [props x,y]; t -> HASHTAG (T); g -> TAG (T) | hashtags(), geohashes(); imetaTags() -> PictureMeta.annotations (UserAnnotationTag); p: new parser needed (class has no p accessor; PTag::parseKey) | NIP-68 also defines m, x (hashes), location, L/l (not read by the class; x/location not modelled). Quartz gap: no PubKeyHintProvider though p tags are spec'd. UNCERTAIN: could merge with PARTICIPANT (NIP-71 video 'participant') if the maintainer prefers one people-in-media relation. | + +### `nip69P2pOrderEvents` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 38383 | `P2POrderEvent` | *none* | – | NIP-69: k is the order type (sell/buy), not a kind - do not emit TAG k; f, s, amt, fa, pm, premium, source (URL), network, layer, name, g (spec; not read by the class), bond, y, z. No E/A/U references. | + +### `nip75ZapGoals` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 9041 | `ZapGoalEvent` | e -> FUNDED (E); a -> FUNDED (A); zap -> ZAP_SPLIT (U) [props weight]; p -> MENTION (U); r -> TAG (T); t -> HASHTAG (T) | ETag/ATag::parseId (linked()), PTag::parseKey, topics() (hashtags); zap: Event.zapSplitSetup() (ZapSplitSetupParser, pubkey form only); r: new parser needed (builder writes it via reference()) | NIP-75. Draft lists zap goals (9041) as REFERENCE-only; classified here. NIP-75 defines no p or e tag (Quartz writes e as well as a for addressable targets, and reads p): UNCERTAIN p meaning, MENTION chosen. BENEFICIARY is cross-cutting: any event with NIP-57 zap tags. | + +### `nip7DThreads` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 11 | `ThreadEvent` | *none* | – | NIP-7D: only title (and nostrord's subject). Replies are NIP-22 1111s pointing at it. NIP-29 h group tag is not an E/A/U target. Class extends Event (not BaseNoteEvent) so no nostr: content parsing; if content citations are wanted later it would be MENTION via content (new parser). | + +### `nip84Highlights` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 9802 | `HighlightEvent` | e -> HIGHLIGHTED (E); a -> HIGHLIGHTED (A); i -> HIGHLIGHTED (T); r[source or unmarked] -> HIGHLIGHTED (T); p[author or no role, editor] -> HIGHLIGHTED_AUTHOR (U) [props role]; p[mention] -> MENTION (U); r[mention] -> TAG (T); q -> QUOTE (E,A); content nostr: -> MENTION (E,A,U) | inPostVersion()/inPostAddress() (first e/a), inExternalIds() (ReplyIdentifierTag, i), inReference() (r, source/mention markers), author() (p author role), PTag::parseKey, QTag, citedNIP19() | NIP-84: source via a/e, i (NIP-73), r ('may contain a URL or text'); p tags 'the original authors' with optional role (author, editor); in quote highlights p/r 'mention' marker. DRAFT FIX: HIGHLIGHTED targets should include T (i/r sources), as REACTED does for kind 17. Quartz: author() only reads the author role; editor p tags fall through to linkedPubKeys (role lost); q parsing is Amethyst-side (NIP-84 does not define q). | + +### `nip94FileMetadata` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 1063 | `FileMetadataEvent` | i -> TAG (T) [torrent infohash] | torrentInfoHash() (TorrentInfoHash::parse, tag i) | NIP-94: url, m, x, ox, size, dim, magnet, i, blurhash, thumb, image, summary, alt - no E/A/U references. x/ox are blob hashes (not in the TAG allowlist). DRAFT FIX: draft Coverage lists file metadata (1063) as carrying references; only the i value tag. | + +### `nip96FileStorage` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 10096 | `FileServersEvent` | *none* | – | NIP-96: server URLs - not modelled. | + +### `nip98HttpAuth` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 27235 | `HTTPAuthorizationEvent` | *none* | – | NIP-98: u (URL), method, payload hash - not modelled. | + +### `nip99Classifieds` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 30402 | `ClassifiedsEvent` | e -> MENTION (E); a -> MENTION (A); p -> MENTION (U); t -> HASHTAG (T); content nostr: -> MENTION (E,A,U) | ETag/ATag/PTag::parseKey\|parseId (hint providers), categories() (hashtags); content nostr:: new parser needed (class is BaseAddressableEvent, no citedNIP19) | NIP-99: the example's e/a tags are the events the markdown content cites (NIP-27 style), so MENTION. g is in the spec but not read by the class. Draft lists classifieds (30402) as REFERENCE-only; classified here. UNCERTAIN: NIP-99 gives e/a/p no explicit role. | + +### `nipA3PaymentTargets` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 10133 | `PaymentTargetsEvent` | *none* | – | NIP-A3: payto payment targets (lightning/bitcoin/etc.) - not Nostr entities. | + +### `nipA4PublicMessages` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 24 | `PublicMessageEvent` | p -> RECIPIENT (U); q -> QUOTE (E,A); content nostr: -> MENTION (E,A,U) | ReceiverTag::parseKey (p), citedNIP19() (eventIds/addressIds/pubKeys); q: new parser needed in this class (QTag::parseEventId/parseAddressId exist) | NIP-A4: 'p tags identify one or more receivers'; 'e tags must not be used' (Quartz strips them); q MAY cite events used in content. Draft lists 24 under both RECIPIENT and MENTION: correct only if MENTION means the content nostr: URIs - the p tags are RECIPIENT, never MENTION. Quartz gap: q tags not read by hint providers. | + +### `nipB0WebBookmarks` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 39701 | `WebBookmarkEvent` | d[url] -> BOOKMARK (T) [Tag name r]; t -> HASHTAG (T) | url() (dTagToUrl(dTag())), hashtags() | NIP-B0: 'The d tag is just their URI'. DRAFT FIX: BOOKMARK targets should include T (a web URL) for 39701. UNCERTAIN: URL values are TAG-shaped; if URLs stay out of the graph in v1, this becomes none apart from HASHTAG. Replies are NIP-22 1111s. | + +### `nipBCOnchainZaps` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 8333 | `OnchainZapEvent` | p -> ZAP_RECIPIENT (U); e -> ZAPPED (E); a -> ZAPPED (A); i -> TAG (T) [bitcoin txid]; k -> TAG (T) | recipient() (PTag), zappedEvent() (ETag), zappedAddress() (ATag), txid() (BitcoinTxIdTag, i), claimedAmountInSats() | NIP-BC is not merged upstream (404); read from Quartz KDoc. Draft ZAPPED/ZAP_RECIPIENT rows confirmed. Props msats = claimedAmountInSats*1000, sender-claimed until verified on chain. The sender is the AUTHOR (no P tag). Builder writes both a and e for addressable targets (two ZAPPED links to the same content). | + +### `nipC0CodeSnippets` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 1337 | `CodeSnippetEvent` | repo[30617 address] -> REPOSITORY (A); l -> TAG (T) | TagArray.repo() (RepoTag::parse returns the raw string; needs Address.parse to tell a 30617 coordinate from a URL - not exposed on the class), language() (l) | NIP-C0: repo 'MUST be either a standard URL or ... the address of a NIP-34 Git repository announcement'. A repo URL is not modelled. REPOSITORY (draft: 1617, 1618, 1621) extends to 1337. Also name, extension, description, runtime, license, dep (no references). | + +### `nipC7Chats` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 9 | `ChatEvent` | q[last, the reply] -> PARENT (E); q[slot 3 pubkey of the parent] -> PARENT_AUTHOR (U); q[other] -> QUOTE (E,A); p -> MENTION (U); content nostr: -> MENTION (E,A,U) | replyingTo() (last q), quotedEvents() (QEventTag::parse incl. author slot), QTag::parseEventId/parseAddressId, PTag::parseKey, citedNIP19() (BaseNoteEvent) | NIP-C7: 'A reply to a kind 9 ... quotes the parent using a q tag'; other kinds MAY be quoted per NIP-18. Draft PARENT row (kind 9 via q) confirmed. UNCERTAIN: by tags alone a kind 9 that only quotes (not replies) is indistinguishable from a reply; Quartz takes the LAST q as parent and returns tag[1] even when it is an address (replyingTo() does not check shape). PARENT_AUTHOR from q[3] follows rule 3; NIP-C7 puts the parent's pubkey in the q tag, so no p is needed. | diff --git a/quartz/plans/2026-09-29-graph-link-vocabulary.md b/quartz/plans/2026-09-29-graph-link-vocabulary.md index fa413bca76..7984987755 100644 --- a/quartz/plans/2026-09-29-graph-link-vocabulary.md +++ b/quartz/plans/2026-09-29-graph-link-vocabulary.md @@ -2,7 +2,10 @@ Status: **draft for review** (2026-09-29). Nothing is implemented yet. Decided in review: links always start at an event (no user-to-user shortcuts); the author of acted-on content gets its OWN -relation; the kind stays on the source event only; names follow Nostr's own words (rule 7). +relation; the kind stays on the source event only; names follow Nostr's own words (rule 7); +**no fallback** — every class states the meaning of every reference it carries (rule 5). The +per-class table for all 410 Quartz classes is the +[appendix](2026-09-29-graph-link-vocabulary-appendix.md). ## Why @@ -65,12 +68,13 @@ Rules the vocabulary follows: So a distinction that is filtered all the time becomes two relations: `REPORTED_USER` (a complaint about the person) is not `REPORTED_AUTHOR` (the author of reported content), and `FOLLOW` (the kind 3 social graph) is not `SUBSCRIBED` (every other follow-like list). -5. **Nothing is invisible before it is classified.** A class that implements no `LinkProvider` - gets a default `REFERENCE` link (with `via` = the tag it came from) for every id its hint - providers name AND every generic reference tag whose value has the right shape: `e`/`E`/`q` - with a 64-hex id, `p`/`P` with a 64-hex key, `a`/`A` with a valid address. The shape half is - not optional: most kinds Quartz types implement no hint provider (measured below), and without - it their references would vanish. Classifying a kind later is an additive change. +5. **No fallback: every class says what its references mean.** Each of the 410 classes + `EventFactory` types implements `links()` (or is declared link-free), and a test holds it: a + new kind cannot land without that decision. There is no generic "reference" relation and no + rule that guesses from a value's shape. The per-class review showed why guessing is unsafe: + a 64-hex `e` in a chess start event is a board hash, the 30174 `d` is a blinded HMAC, `t` is + an auth verb in 24242 and `r` holds relay URLs in 10002. A tag that appears on every kind + (`client`, `zap`, the emoji tag's set address) is emitted once by `Event`, not per class. 6. **Values that qualify a link ride on it** (`props`): a report's type, an assertion's rank, a zap request's amount. They are what a query filters on after choosing the relation. 7. **Names are Nostr's own words for the slot.** A relation names what the TARGET is to the @@ -200,23 +204,21 @@ property of the current graph schema: "user-wide reports of X" is | `HASHTAG` | T | A `t` tag | any | | `TAG` | T | Any other allowlisted value tag: `i` (external id), `k`, `l`/`L`, `r` (url), `g` (geohash). The target's name says which | any | -### Fallback +### Every kind: tags any event may carry -| Relation | Targets | Meaning | -|---|---|---| -| `REFERENCE` | E, A, U | A link a provider names (or a value shaped like an id) that no relation above claims. Props: `tag` | +| Relation | Targets | Meaning | Kinds | +|---|---|---|---| +| `CLIENT` | A | The NIP-89 `client` tag's handler address (3rd slot) | any | +| `ZAP_SPLIT` | U | A NIP-57 Appendix G `zap` tag: a split setting, not a payment (rule 4 keeps it apart from `ZAP_RECIPIENT`). Props: `weight` | any | +| `EMOJI_SET` | A | The optional 4th slot of a NIP-30 `emoji` tag: the 30030 set it comes from | any | -Until classified, these stay `REFERENCE`: -- NIP-90 DVM requests, results and feedback (5000–7000); -- NIP-29 group events; -- experimental kinds (workouts, geocaching, roadstr, attestations, zap polls); -- wiki merge requests (818 / 819); -- user status (30315); -- zap goals (9041); -- classifieds (30402); -- video collaboration (34238). +### Relations the per-class review adds -Each is a small, additive classification when someone needs it. +The review of all 410 classes needed **115 relations** beyond the tables above, for kinds the +tables did not reach (NIP-29 groups, NIP-90 DVMs, NIP-34 git roles, NIP-54 wiki merges, NIP-60 +cashu, NIP-71 video credits, buzz, marmot, experimental kinds…). They are listed with their +kinds and justification at the top of the [appendix](2026-09-29-graph-link-vocabulary-appendix.md) +and need the same review these tables had before they are final. ## Reading it back @@ -250,8 +252,9 @@ Decided: Open: 1. **Where it lives.** `nip01Core/links/` (the interface, the value classes, the relation - constants) plus one `links()` per class, beside its tags. The default (rule 5) sits on `Event` - and reads the hint providers. + constants) plus one `links()` per class, beside its tags. `Event` contributes only the + every-kind tags (`client`, `zap`, emoji sets). The hint providers could later be derived from + `links()`, which carry the same ids plus their meaning. 2. **Vocabulary stability.** Adding a relation or classifying a kind is additive. Renaming or re-splitting one breaks graph queries, so this review is the cheap moment. @@ -272,13 +275,34 @@ These were already catalogued in neo4j-eventstore's `docs/appendix-providers.md` - `ChannelCreateEvent.linkedEventIds()` returns its own id; - `ZapReceiptEvent` omits the zap sender. -New: two classes claim kind **1010**, `experimental/edits/TextNoteModificationEvent` and -`nip51Lists/goodWikiRelayList/GoodWikiRelayListEvent`. `EventFactory` can only type one of them. +Found by the per-class review (details in the appendix rows): +- **Kind collision at 20001:** `GeohashPresenceEvent` and buzz `PresenceUpdateEvent`; + `EventFactory` tells them apart by the `g` tag. (An earlier draft claimed a collision at 1010; + that was a prefix-matching mistake: `GoodWikiRelayListEvent` is 10102.) +- **Privacy:** `GeohashListEvent.create(…)` (the `NostrSignerSync` variant) swaps public and + private geohashes, publishing the private ones in clear tags. +- **Addresses:** 15 NIP-51 lists in 10000–19999 extend `PrivateTagArrayEvent`, which builds the + address from `d`, without overriding `dTag()`; a stray `d` tag splits their address. +- **Wrong target:** `ChannelHideMessageEvent.eventsToHide()` includes the channel root; on a + spec-conforming 43, `channel()` returns the hidden message. `ForkTag.parse` (30817) requires + kind 34550; the attestation `RequestTag` returns `ApprovedAddressTag`. +- **Copy-paste:** `LiveActivitiesChatMessageEvent.unmarkedReplyTos()` calls + `markedReplyTos()`; the 30298 reading state's `build()` overwrites its root and swaps summary + and image. +- **Missing NIP-22 scopes:** `VoiceReplyEvent` (1244) writes only `e`/`k`/`p`, though NIP-A0 says + it MUST follow NIP-22. +- **Missing validation:** `WinnerTag`, `AgentTag`, `ReplacedByTag`, `ConsentTag`, + `AddressMemberTag`, `EditTag`, `MarkedETag.parseAllThreadTags` and several list accessors + accept values that are not 64-hex ids or valid addresses. +- **Hint-provider gaps:** the video classes, `PictureEvent`, `VoiceReplyEvent`, the podcast lists + and every buzz class implement none, although their tags are references. ## Coverage -Measured on 2026-09-29: `EventFactory` types **410** classes. The measurement is a text scan, so -the split below is approximate; an exact per-class table is plan step 2a. +`EventFactory` types **410** classes. The [appendix](2026-09-29-graph-link-vocabulary-appendix.md) +classifies every one of them from its code and its NIP: **340** carry references, **70** carry +none. The first estimate below came from a text scan and is kept for the record; the appendix +supersedes it. - **~150 are classified above.** That covers the NIPs the graph already interprets. - **~110 carry no references.** Settings, metadata, relay and server lists, key packages, ephemeral auth. They need nothing beyond `AUTHOR` (and `ADDRESS`). @@ -298,16 +322,57 @@ the split below is approximate; an exact per-class table is plan step 2a. (9999 / 39999), torrents (2003). **Enforced, not hoped for:** a Quartz test walks every `EventFactory` kind and fails unless the -class is one of: classified (implements `LinkProvider`), explicitly `REFERENCE`-only, or -explicitly link-free. A new kind then cannot land without a decision about its links. +class implements `links()` or is explicitly link-free. A new kind then cannot land without a +decision about its links. + +## Corrections from the per-class review + +The review checked the tables above against the code and the NIP texts. To apply before +implementing (each is detailed in its appendix row): +- `REACTED` / `REACTED_AUTHOR` take the **last** `e` / `p` (NIP-25); earlier ones are `MENTION`. + Quartz's `originalPost()` / `originalAuthor()` return all of them, so they cannot be the source. +- A lowercase `p` on kinds 1 and 1111 is `PARENT_AUTHOR` only when it matches the parent's + author; otherwise it is `MENTION`. `ROOT` / `PARENT` also take **T** (NIP-22 `I`/`i` scopes). +- `PARENT` does not apply to 30818 (NIP-54 articles have none); `FORK` takes E and A. +- Kind 24's `p` tags are `RECIPIENT` only; `MENTION` there comes from content alone. +- Kind 1985's `t` / `r` are label targets (`LABELED`), not `HASHTAG` / `TAG`. +- A Buzz-style lone `reply` marker is a direct reply: both `ROOT` and `PARENT`. +- The unclassified list shrinks to nothing: every kind is now in the appendix. + +## Open decisions the review surfaced + +1. **The 115 new relations** (appendix, top table): same review as the tables had. Unified + already where groups coined synonyms: `ADDED_USER` / `REMOVED_USER`, `REQUEST` / + `REQUEST_AUTHOR` (NIP-90's "customer"), `ZAP_SPLIT` (NIP-75's "beneficiary"). Still to + decide: `APP` vs `APPLICATION`, `AUTHORED` (10064) beside `AUTHOR`, whether + `SERVICE_PROVIDER` spans NIP-85 and NIP-90 or splits (rule 4). +2. **What a group is.** NIP-29 names a group by the address of its kind 39000, signed by the + relay's key; `h` alone would merge forks that reuse an id. Options: that address when the + relay key is known, a tag value `h` with the relay as a prop, or a new `LinkTarget.Group`. + Marmot's `h` is a random global id, sound as a tag value. +3. **URLs and external ids as targets.** Kind 17 reactions, highlight sources (`r`), web + bookmarks (39701) and NIP-22 `I` scopes point at URLs or NIP-73 ids. Are those **T** + targets in v1? +4. **Value tags need a per-class opt-in.** The same letter means different things by kind, so + `HASHTAG` / `TAG` come from each class's `links()`, never from a global allowlist. +5. **Links derived from the event's own `d`** (30618 → its repository, 39001–39005 → the group, + 30177 → its agent): in `links()`, or left to the graph? +6. **References inside content JSON** (buzz 40099 / 40902 / 44100, DVM results, 30175–30177, + marketplace stalls): each needs a new parser. +7. **Private list entries** (NIP-44 encrypted NIP-51 items, encrypted DVM requests) are invisible + to any public index. Stated once, not per row. + ## Plan -1. This review: the vocabulary, the model, the open questions. Done except the two open points. - - 2a. The exact per-class coverage table (all 410), generated, as an appendix to this plan. -2. Quartz: `nip01Core/links/` and the default from the hint providers; then `links()` for the - kinds the graph already interprets (NIP-10, 18, 22, 25, 56, 57, 85, 51, 58, 72, 09), each with - a golden test. The upstream fixes above land with them. +1. This review: the vocabulary, the model, the open questions. The per-class + [appendix](2026-09-29-graph-link-vocabulary-appendix.md) is done; the open decisions above + and the 115 new relation names remain. +2. Quartz: `nip01Core/links/`, the every-kind tags on `Event`, and the coverage test; then + `links()` class by class from the appendix, starting with the NIPs the graph already + interprets (10, 18, 22, 25, 56, 57, 85, 51, 58, 72, 09), each with a golden test. The Quartz + bugs above land with the classes they affect. 3. neo4j-eventstore: derive from `links()`, schema 2.0, rewrite `docs/schema.md` and the reference queries. -4. The remaining kinds, as someone needs them. +4. The rest of the appendix, until the coverage test passes for all 410 classes. Kinds with an + unmerged or missing spec (`UNCERTAIN` rows) are decided with their maintainers. From c72c8dd2ed99d96286a8596d05ed0da93dad9b31 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 14:40:31 +0000 Subject: [PATCH 06/19] docs(quartz): record the link vocabulary decisions on groups, URLs, d-derived links and content JSON Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pu8Fpp4KbXaiax8YTYxhJm --- .../plans/2026-09-29-graph-link-vocabulary.md | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/quartz/plans/2026-09-29-graph-link-vocabulary.md b/quartz/plans/2026-09-29-graph-link-vocabulary.md index 7984987755..b902ff34e1 100644 --- a/quartz/plans/2026-09-29-graph-link-vocabulary.md +++ b/quartz/plans/2026-09-29-graph-link-vocabulary.md @@ -346,19 +346,20 @@ implementing (each is detailed in its appendix row): `REQUEST_AUTHOR` (NIP-90's "customer"), `ZAP_SPLIT` (NIP-75's "beneficiary"). Still to decide: `APP` vs `APPLICATION`, `AUTHORED` (10064) beside `AUTHOR`, whether `SERVICE_PROVIDER` spans NIP-85 and NIP-90 or splits (rule 4). -2. **What a group is.** NIP-29 names a group by the address of its kind 39000, signed by the - relay's key; `h` alone would merge forks that reuse an id. Options: that address when the - relay key is known, a tag value `h` with the relay as a prop, or a new `LinkTarget.Group`. - Marmot's `h` is a random global id, sound as a tag value. -3. **URLs and external ids as targets.** Kind 17 reactions, highlight sources (`r`), web - bookmarks (39701) and NIP-22 `I` scopes point at URLs or NIP-73 ids. Are those **T** - targets in v1? +2. **Decided: a group is its `h` value** (a **T** target). Known limit, unsolved: NIP-29 ids are + only unique per relay, so two relays' groups with one id merge into one node. A group's own + metadata (39000–39005) is signed by its relay's key, which could scope it; a message carries + only `h`, so there is nothing to scope it by. Marmot's `h` is a random global id and has no + such limit. +3. **Decided: URLs and external ids are valid T targets** (kind 17 reactions, highlight + sources, web bookmarks 39701, NIP-22 `I` scopes, NIP-73 ids). 4. **Value tags need a per-class opt-in.** The same letter means different things by kind, so `HASHTAG` / `TAG` come from each class's `links()`, never from a global allowlist. -5. **Links derived from the event's own `d`** (30618 → its repository, 39001–39005 → the group, - 30177 → its agent): in `links()`, or left to the graph? -6. **References inside content JSON** (buzz 40099 / 40902 / 44100, DVM results, 30175–30177, - marketplace stalls): each needs a new parser. +5. **Decided: no links derived from an event's own `d`** (30618 → its repository, 39001–39005 → + the group, 30177 → its agent). They restate the event's `ADDRESS`; the graph can join on it. +6. **Decided: references inside content JSON are left out for now** (buzz 40099 / 40902 / + 44100, DVM results, 30175–30177, marketplace stalls). The appendix rows keep them, marked, + for later. 7. **Private list entries** (NIP-44 encrypted NIP-51 items, encrypted DVM requests) are invisible to any public index. Stated once, not per row. From 6c6aec83151fae66316049d2f5b010fd29ecf710 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 15:19:14 +0000 Subject: [PATCH 07/19] fix(quartz): correct tag readers found by the graph link review Reviewing every kind's links for the graph vocabulary turned up readers that return the wrong target, or accept values of the wrong shape: - nip19: pubKeys() no longer returns the key of a pasted nsec - nip18: QTag address parsers accepted event ids and rejected addresses - nip28: a hide-message no longer counts its channel as a hidden message - nip53: live chat unmarkedReplyTos drops the activity; presence events mark their room `root`, as the spec requires - nip51: GeohashListEvent.create no longer swaps public and private entries; a replaceable list ignores a stray d tag in its address - forks: forkFromAddress reads the fork-marked `a`, not the first `a`; ForkTag checks the NIP-text kind, not the community kind - attestations: RequestTag parses as RequestTag and requires kind 31872; attestorPubKeys() names the p tags, assertionPubkey() is deprecated - nip58: profile badges read only kind 30009 definitions - interactive stories: rootScene writes the root-scene tag; reading-state build no longer swaps summary and image - nip87: mint and recommendation events are addressable (EventCache now routes them through consumeBaseReplaceable) - nipA0: voice replies carry the thread's root event, kind and author - id-shaped tags (replaced-by, agent, report, winner, edit, consent, group participants and deletions, citation timestamp, address members) reject values that are not 64-char hex or valid addresses Each fix has a regression test that fails without it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pu8Fpp4KbXaiax8YTYxhJm --- .../commons/model/cache/EventCache.kt | 14 ++--- .../commons/ui/note/types/Attestation.kt | 2 +- .../buzz/amTurnMetrics/tags/AgentTag.kt | 3 +- .../iaIdentityArchival/tags/ConsentTag.kt | 4 +- .../iaIdentityArchival/tags/ReplacedByTag.kt | 3 +- .../quartz/buzz/moderation/tags/ReportTag.kt | 3 +- .../attestation/tags/RequestTag.kt | 23 ++++---- .../request/AttestationRequestEvent.kt | 5 ++ .../citations/ExternalCitationEvent.kt | 3 +- .../experimental/forks/ForkedAddress.kt | 36 ++++++++++++ .../InteractiveStoryReadingStateEvent.kt | 4 +- .../interactiveStories/TagArrayBuilderExt.kt | 5 +- .../experimental/nipsOnNostr/NipTextEvent.kt | 4 +- .../experimental/nipsOnNostr/tags/ForkTag.kt | 5 +- .../addressables/tags/AddressMemberTag.kt | 2 + .../quartz/nip10Notes/TextNoteEvent.kt | 3 +- .../quartz/nip18Reposts/quotes/QTag.kt | 4 +- .../quartz/nip19Bech32/ListEntityExt.kt | 7 ++- .../admin/ChannelHideMessageEvent.kt | 15 ++++- .../metadata/GroupParticipantsEvent.kt | 3 +- .../moderation/TagArrayExt.kt | 3 +- .../quartz/nip51Lists/PrivateTagArrayEvent.kt | 8 +++ .../geohashList/GeohashListEvent.kt | 4 +- .../chat/LiveActivitiesChatMessageEvent.kt | 2 +- .../meetingSpaces/tags/MeetingSpaceTag.kt | 8 +++ .../presence/TagArrayBuilderExt.kt | 6 +- .../quartz/nip54Wiki/WikiArticleEvent.kt | 4 +- .../quartz/nip58Badges/profile/TagArrayExt.kt | 7 ++- .../quartz/nip64Chess/end/tags/WinnerTag.kt | 4 +- .../quartz/nip87Ecash/cashu/CashuMintEvent.kt | 6 +- .../nip87Ecash/fedimint/FedimintEvent.kt | 6 +- .../recommendation/MintRecommendationEvent.kt | 6 +- .../nipA0VoiceMessages/TagArrayBuilderExt.kt | 19 ++++++ .../nipA0VoiceMessages/VoiceReplyEvent.kt | 26 +++++++++ .../nipXXPodcasting20/episode/tags/EditTag.kt | 3 +- .../attestations/RequestTagTest.kt | 52 +++++++++++++++++ .../experimental/forks/ForkedAddressTest.kt | 55 ++++++++++++++++++ .../ReadingStateBuildTest.kt | 49 ++++++++++++++++ .../nip18Reposts/quotes/QTagAddressTest.kt | 40 +++++++++++++ .../nip19Bech32/PubKeysNeverLeakNsecTest.kt | 38 ++++++++++++ .../ChannelHideMessageEventTest.kt | 48 +++++++++++++++ .../nip51Lists/ListAddressAndGeohashTest.kt | 55 ++++++++++++++++++ .../LiveActivitiesLinkFixesTest.kt | 58 +++++++++++++++++++ .../ProfileBadgeDefinitionsTest.kt | 43 ++++++++++++++ .../MintEventsAreAddressableTest.kt | 46 +++++++++++++++ .../VoiceReplyRootScopeTest.kt | 46 +++++++++++++++ .../quartz/utils/IdShapedTagParsersTest.kt | 53 +++++++++++++++++ 47 files changed, 777 insertions(+), 66 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/forks/ForkedAddress.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/attestations/RequestTagTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/forks/ForkedAddressTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/interactiveStories/ReadingStateBuildTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip18Reposts/quotes/QTagAddressTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/PubKeysNeverLeakNsecTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip28PublicChat/ChannelHideMessageEventTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/ListAddressAndGeohashTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/LiveActivitiesLinkFixesTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip58Badges/ProfileBadgeDefinitionsTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip87Ecash/MintEventsAreAddressableTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipA0VoiceMessages/VoiceReplyRootScopeTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/IdShapedTagParsersTest.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt index 992eb25bcf..c2c1bf7dde 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt @@ -3921,6 +3921,10 @@ open class EventCache : is PublicationContentEvent, is RelayReviewEvent, is EntityRatingEvent, + // NIP-87 mint announcements and recommendations (kind 38172 / 38173 / 38000). + is CashuMintEvent, + is FedimintEvent, + is MintRecommendationEvent, -> consumeBaseReplaceable(event, relay, wasVerified) // ============================================================ @@ -3937,16 +3941,6 @@ open class EventCache : is CashuTokenEvent, is CashuSpendingHistoryEvent, is CashuMintQuoteEvent, - // NIP-87 Cashu mint discovery + recommendations: all three are kind 3xxxx - // (parameterized-replaceable per the spec) but neither CashuMintEvent / - // FedimintEvent / MintRecommendationEvent extends AddressableEvent in Quartz - // today, so consumeBaseReplaceable's `check(event is AddressableEvent)` would - // crash. Route them as regular events — downstream consumers - // (CashuMintDirectoryState, CashuWalletState) already dedupe by (pubKey, dTag) - // and keep the newest. - is CashuMintEvent, - is FedimintEvent, - is MintRecommendationEvent, is ChatMessageEncryptedFileHeaderEvent, is ChatMessageEvent, is BirdDetectionEvent, diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Attestation.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Attestation.kt index 7088cfd7c8..b2cd8c1c3e 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Attestation.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Attestation.kt @@ -292,7 +292,7 @@ fun RenderAttestationRequest( val aboutAddress = remember(noteEvent) { noteEvent.assertionAddress() } val aboutEvent = remember(noteEvent) { noteEvent.assertionEventId() } - val aboutPubkey = remember(noteEvent) { noteEvent.assertionPubkey() } + val aboutPubkey = remember(noteEvent) { noteEvent.attestorPubKeys().firstOrNull() } Column( modifier = diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/amTurnMetrics/tags/AgentTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/amTurnMetrics/tags/AgentTag.kt index 6f98db051a..6d3fed1136 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/amTurnMetrics/tags/AgentTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/amTurnMetrics/tags/AgentTag.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.buzz.amTurnMetrics.tags import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.ensure /** @@ -38,7 +39,7 @@ object AgentTag { fun parse(tag: Tag): HexKey? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } - ensure(tag[1].isNotEmpty()) { return null } + ensure(tag[1].length == 64 && Hex.isHex(tag[1])) { return null } return tag[1] } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/iaIdentityArchival/tags/ConsentTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/iaIdentityArchival/tags/ConsentTag.kt index 5ce2886126..86acb88136 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/iaIdentityArchival/tags/ConsentTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/iaIdentityArchival/tags/ConsentTag.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.buzz.iaIdentityArchival.tags import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.ensure /** @@ -48,7 +49,8 @@ object ConsentTag { ensure(tag.has(2)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].isNotEmpty()) { return null } - ensure(tag[2].isNotEmpty()) { return null } + // The actor is a pubkey. + ensure(tag[2].length == 64 && Hex.isHex(tag[2])) { return null } return Consent(tag[1], tag[2]) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/iaIdentityArchival/tags/ReplacedByTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/iaIdentityArchival/tags/ReplacedByTag.kt index 3ef3541d1e..c596fb625a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/iaIdentityArchival/tags/ReplacedByTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/iaIdentityArchival/tags/ReplacedByTag.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.buzz.iaIdentityArchival.tags import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.ensure /** @@ -40,7 +41,7 @@ object ReplacedByTag { fun parse(tag: Array): HexKey? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } - ensure(tag[1].isNotEmpty()) { return null } + ensure(tag[1].length == 64 && Hex.isHex(tag[1])) { return null } return tag[1] } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/moderation/tags/ReportTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/moderation/tags/ReportTag.kt index 72adcb644e..1ae3ce0de5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/moderation/tags/ReportTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/moderation/tags/ReportTag.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.buzz.moderation.tags import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.ensure /** @@ -41,7 +42,7 @@ object ReportTag { fun parse(tag: Array): HexKey? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } - ensure(tag[1].isNotEmpty()) { return null } + ensure(tag[1].length == 64 && Hex.isHex(tag[1])) { return null } return tag[1] } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/RequestTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/RequestTag.kt index d22000d12a..2b67864056 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/RequestTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/RequestTag.kt @@ -20,14 +20,13 @@ */ package com.vitorpamplona.quartz.experimental.attestations.attestation.tags +import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer -import com.vitorpamplona.quartz.nip72ModCommunities.approval.tags.ApprovedAddressTag -import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.ensure @@ -43,8 +42,10 @@ class RequestTag( companion object { const val TAG_NAME = "request" + private val REQUEST_KIND_STR = AttestationRequestEvent.KIND.toString() - fun isTagged(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && !Address.isOfKind(tag[1], CommunityDefinitionEvent.KIND_STR) + // The request an attestation answers is always a kind 31872 attestation request. + fun isTagged(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && Address.isOfKind(tag[1], REQUEST_KIND_STR) fun isTagged( tag: Array, @@ -53,7 +54,7 @@ class RequestTag( fun isTagged( tag: Array, - address: ApprovedAddressTag, + address: RequestTag, ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == address.toTag() fun isIn( @@ -61,20 +62,20 @@ class RequestTag( addressIds: Set, ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in addressIds - fun parse(tag: Array): ApprovedAddressTag? { + fun parse(tag: Array): RequestTag? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } - ensure(!Address.isOfKind(tag[1], CommunityDefinitionEvent.KIND_STR)) { return null } + ensure(Address.isOfKind(tag[1], REQUEST_KIND_STR)) { return null } val address = Address.parse(tag[1]) ?: return null val relayHint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } - return ApprovedAddressTag(address, relayHint) + return RequestTag(address, relayHint) } fun parseValidAddress(tag: Array): String? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } - ensure(!Address.isOfKind(tag[1], CommunityDefinitionEvent.KIND_STR)) { return null } + ensure(Address.isOfKind(tag[1], REQUEST_KIND_STR)) { return null } return Address.parse(tag[1])?.toValue() } @@ -83,21 +84,21 @@ class RequestTag( ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].isNotEmpty()) { return null } val address = Address.parse(tag[1]) ?: return null - ensure(address.kind != CommunityDefinitionEvent.KIND) { return null } + ensure(address.kind == AttestationRequestEvent.KIND) { return null } return address } fun parseAddressId(tag: Array): String? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } - ensure(!Address.isOfKind(tag[1], CommunityDefinitionEvent.KIND_STR)) { return null } + ensure(Address.isOfKind(tag[1], REQUEST_KIND_STR)) { return null } return tag[1] } fun parseAsHint(tag: Array): AddressHint? { ensure(tag.has(2)) { return null } ensure(tag[0] == TAG_NAME) { return null } - ensure(!Address.isOfKind(tag[1], CommunityDefinitionEvent.KIND_STR)) { return null } + ensure(Address.isOfKind(tag[1], REQUEST_KIND_STR)) { return null } ensure(tag[1].contains(':')) { return null } ensure(tag[2].isNotEmpty()) { return null } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/request/AttestationRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/request/AttestationRequestEvent.kt index 70eae12f2b..b374e16143 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/request/AttestationRequestEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/request/AttestationRequestEvent.kt @@ -86,8 +86,13 @@ class AttestationRequestEvent( fun assertionETag() = tags.firstNotNullOfOrNull(ETag::parse) + /** The attestors this request asks (its `p` tags, written by [buildEvent]'s `attestorPubKeys`). */ + fun attestorPubKeys() = tags.mapNotNull(PTag::parseKey) + + @Deprecated("Returns the first ATTESTOR, not the assertion's author", ReplaceWith("attestorPubKeys().firstOrNull()")) fun assertionPubkey() = tags.firstNotNullOfOrNull(PTag::parseKey) + @Deprecated("Returns the first ATTESTOR's tag, not the assertion's author") fun assertionPTag() = tags.firstNotNullOfOrNull(PTag::parse) companion object { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/citations/ExternalCitationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/citations/ExternalCitationEvent.kt index 890bb9afd6..0703a0ecdc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/citations/ExternalCitationEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/citations/ExternalCitationEvent.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag +import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.TimeUtils /** A citation of something on the web (kind 31): a URL, optionally timestamped. */ @@ -50,7 +51,7 @@ class ExternalCitationEvent( fun url() = value(CitationTags.URL) ?: value("url") /** The id of a NIP-03 kind-1040 timestamp attesting when the page was seen. */ - fun openTimestamp() = value(CitationTags.OPEN_TIMESTAMP) + fun openTimestamp() = value(CitationTags.OPEN_TIMESTAMP)?.takeIf { it.length == 64 && Hex.isHex(it) } override fun displayTitle(): String? = title() ?: url() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/forks/ForkedAddress.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/forks/ForkedAddress.kt new file mode 100644 index 0000000000..435d7a11af --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/forks/ForkedAddress.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.forks + +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag + +/** + * The address an `a` tag marked `fork` points at: `["a",
, , "fork"]`, the + * version a note, a NIP text or a wiki article (NIP-54 "Forks") was forked from — of any kind. + * Only the marked tag counts: an event also carries unmarked `a` tags (a community, a mention), + * and those are not its origin. + */ +fun parseForkedAddress(tag: Array): Address? { + if (tag.size < 4 || tag[0] != "a") return null + if (tag[3] != MarkedETag.MARKER.FORK.code) return null + return Address.parse(tag[1]) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStoryReadingStateEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStoryReadingStateEvent.kt index 32a71a23c3..c52c137022 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStoryReadingStateEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStoryReadingStateEvent.kt @@ -128,8 +128,8 @@ class InteractiveStoryReadingStateEvent( status(status) root.event.title()?.let { storyTitle(it) } - root.event.summary()?.let { storyImage(it) } - root.event.image()?.let { storySummary(it) } + root.event.summary()?.let { storySummary(it) } + root.event.image()?.let { storyImage(it) } initializer() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/interactiveStories/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/interactiveStories/TagArrayBuilderExt.kt index b916f3283a..3da412a1fc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/interactiveStories/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/interactiveStories/TagArrayBuilderExt.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.experimental.interactiveStories import com.vitorpamplona.quartz.experimental.interactiveStories.tags.ReadStatusTag +import com.vitorpamplona.quartz.experimental.interactiveStories.tags.RootSceneTag import com.vitorpamplona.quartz.experimental.interactiveStories.tags.StoryOptionTag import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag @@ -51,7 +52,9 @@ fun TagArrayBuilder.storyImage(imageUrl: Stri fun TagArrayBuilder.storyImages(imageUrls: List) = addAll(imageUrls.map { ImageTag.assemble(it) }) -fun TagArrayBuilder.rootScene(scene: ATag) = addUnique(scene.toATagArray()) +// The root is the uppercase `A` (RootSceneTag): written as a lowercase `a`, the current scene +// (also an `a`) replaced it and root() found nothing. +fun TagArrayBuilder.rootScene(scene: ATag) = addUnique(RootSceneTag.assemble(scene.toTag(), scene.relay)) fun TagArrayBuilder.currentScene(scene: ATag) = addUnique(scene.toATagArray()) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipsOnNostr/NipTextEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipsOnNostr/NipTextEvent.kt index b8c53abcf1..0fb12f35a2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipsOnNostr/NipTextEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipsOnNostr/NipTextEvent.kt @@ -22,8 +22,8 @@ package com.vitorpamplona.quartz.experimental.nipsOnNostr import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.experimental.forks.IForkableEvent +import com.vitorpamplona.quartz.experimental.forks.parseForkedAddress import com.vitorpamplona.quartz.experimental.forks.parseForkedEventId -import com.vitorpamplona.quartz.experimental.nipsOnNostr.tags.ForkTag import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -113,7 +113,7 @@ class NipTextEvent( override fun isAFork() = tags.any { it.size > 3 && (it[0] == "a" || it[0] == "e") && it[3] == "fork" } - override fun forkFromAddress() = tags.firstNotNullOfOrNull(ForkTag::parseAddress) + override fun forkFromAddress() = tags.firstNotNullOfOrNull(::parseForkedAddress) override fun forkFromVersion() = tags.firstNotNullOfOrNull(MarkedETag::parseForkedEventId) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipsOnNostr/tags/ForkTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipsOnNostr/tags/ForkTag.kt index 699078cdea..7b5eaffd3c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipsOnNostr/tags/ForkTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipsOnNostr/tags/ForkTag.kt @@ -26,7 +26,6 @@ import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer -import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.ensure @@ -66,7 +65,7 @@ class ForkTag( ensure( Address.Companion.isOfKind( tag[1], - CommunityDefinitionEvent.Companion.KIND_STR, + NipTextEvent.KIND_STR, ), ) { return null } @@ -81,7 +80,7 @@ class ForkTag( ensure( Address.Companion.isOfKind( tag[1], - CommunityDefinitionEvent.Companion.KIND_STR, + NipTextEvent.KIND_STR, ), ) { return null } return Address.Companion.parse(tag[1])?.toValue() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/addressables/tags/AddressMemberTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/addressables/tags/AddressMemberTag.kt index 8257f672cb..1221b7309a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/addressables/tags/AddressMemberTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/addressables/tags/AddressMemberTag.kt @@ -69,6 +69,7 @@ data class AddressMemberTag( ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].isNotEmpty()) { return null } + ensure(AddressSerializer.parse(tag[1]) != null) { return null } return AddressMemberTag(tag[1], MemberTagFields.relayHint(tag), MemberTagFields.score(tag)) } @@ -77,6 +78,7 @@ data class AddressMemberTag( ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].isNotEmpty()) { return null } + ensure(AddressSerializer.parse(tag[1]) != null) { return null } return tag[1] } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt index e9049476af..ba525a7239 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip10Notes import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.experimental.forks.IForkableEvent +import com.vitorpamplona.quartz.experimental.forks.parseForkedAddress import com.vitorpamplona.quartz.experimental.forks.parseForkedEventId import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder @@ -124,7 +125,7 @@ class TextNoteEvent( override fun isAFork() = tags.any { it.size > 3 && (it[0] == "a" || it[0] == "e") && it[3] == "fork" } - override fun forkFromAddress() = tags.firstNotNullOfOrNull(ATag::parseAddress) + override fun forkFromAddress() = tags.firstNotNullOfOrNull(::parseForkedAddress) override fun forkFromVersion() = tags.firstNotNullOfOrNull(MarkedETag::parseForkedEventId) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip18Reposts/quotes/QTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip18Reposts/quotes/QTag.kt index b0f69fe20e..590067ec1a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip18Reposts/quotes/QTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip18Reposts/quotes/QTag.kt @@ -90,7 +90,7 @@ interface QTag { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length != 64) { return null } - ensure(!tag[1].contains(':')) { return null } + ensure(tag[1].contains(':')) { return null } return tag[1] } @@ -99,7 +99,7 @@ interface QTag { ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].length != 64) { return null } ensure(tag[2].isNotEmpty()) { return null } - ensure(!tag[1].contains(':')) { return null } + ensure(tag[1].contains(':')) { return null } val relayHint = pickRelayHint(tag) ensure(relayHint != null) { return null } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/ListEntityExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/ListEntityExt.kt index 77229402b1..af697d5130 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/ListEntityExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/ListEntityExt.kt @@ -32,7 +32,6 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent import com.vitorpamplona.quartz.nip19Bech32.entities.NNote import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile import com.vitorpamplona.quartz.nip19Bech32.entities.NPub -import com.vitorpamplona.quartz.nip19Bech32.entities.NSec fun NEvent.toEventHint() = relay.map { EventIdHint(hex, it) } @@ -86,12 +85,16 @@ fun List.pubKeyHints(): List = } }.flatten() +/** + * The pubkeys cited as `npub` / `nprofile`. An `nsec` is deliberately NOT here: its hex is a + * PRIVATE key, so reporting a pasted one as a "linked pubkey" would publish the secret to every + * index, hint store and relay filter that consumes this list. + */ fun List.pubKeys(): List = mapNotNull { entity -> when (entity) { is NProfile -> entity.hex is NPub -> entity.hex - is NSec -> entity.hex else -> null } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelHideMessageEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelHideMessageEvent.kt index 3c261b869f..6e91f565a1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelHideMessageEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelHideMessageEvent.kt @@ -47,7 +47,20 @@ class ChannelHideMessageEvent( override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) - fun eventsToHide() = tags.taggedEventIds() + /** + * NIP-28 names the channel only through a MARKED root (Quartz writes one; the spec's own 43 + * has none): the unmarked-root fallback of [BasePublicChatEvent] would read the first hidden + * message as the channel. + */ + override fun channel() = markedRoot() + + override fun channelId() = channel()?.eventId + + /** The hidden messages: every `e` except the channel it is posted in. */ + fun eventsToHide(): List { + val channel = channelId() + return tags.taggedEventIds().filter { it != channel } + } companion object { const val KIND = 43 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupParticipantsEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupParticipantsEvent.kt index dbb6d36337..b4b464f495 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupParticipantsEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupParticipantsEvent.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.core.mapValueTagged import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.TimeUtils /** @@ -48,7 +49,7 @@ class GroupParticipantsEvent( ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { fun groupId() = dTag() - fun participants(): List = tags.mapValueTagged(TAG_NAME) { it } + fun participants(): List = tags.mapValueTagged(TAG_NAME) { it }.filter { it.length == 64 && Hex.isHex(it) } companion object { const val KIND = 39004 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayExt.kt index 4a1e9f26d0..ef3e7d9968 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayExt.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupPin import com.vitorpamplona.quartz.nip29RelayGroups.tags.ParentTag import com.vitorpamplona.quartz.nip29RelayGroups.tags.PreviousTag +import com.vitorpamplona.quartz.utils.Hex fun TagArray.groupId() = firstTagValue(GroupIdTag.TAG_NAME) @@ -56,7 +57,7 @@ fun TagArray.childGroupIds(): List = mapNotNull(ChildTag::parse) fun TagArray.userPubKeys(): List = mapNotNull(PTag::parseKey) -fun TagArray.deletedEventIds(): List = mapValueTagged("e") { it } +fun TagArray.deletedEventIds(): List = mapValueTagged("e") { it }.filter { it.length == 64 && Hex.isHex(it) } /** The ordered pin list: `e` (event id) and `a` (address) references, interleaved as sent. */ fun TagArray.groupPins(): List = mapNotNull(GroupPin::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/PrivateTagArrayEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/PrivateTagArrayEvent.kt index a47a2d4a74..18178e1d2b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/PrivateTagArrayEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/PrivateTagArrayEvent.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.isReplaceable import com.vitorpamplona.quartz.nip01Core.diff.ContentChange import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions @@ -42,6 +43,13 @@ abstract class PrivateTagArrayEvent( ) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) { override fun isContentEncoded() = true + /** + * A NIP-51 LIST (10000–19999) is replaceable: NIP-01 fixes its address to `kind:pubkey:` + * whatever tags it carries. Read from the tags, a stray `d` would split one user's list + * into several addresses. SETS (30000–39999) are addressed by their `d`. + */ + override fun dTag(): String = if (kind.isReplaceable()) "" else super.dTag() + /** * How the NIP-44 encrypted private items changed since [older]. They can't be compared * item by item without decrypting, so only as a whole. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/geohashList/GeohashListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/geohashList/GeohashListEvent.kt index 936a691d84..a9d0b6f037 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/geohashList/GeohashListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/geohashList/GeohashListEvent.kt @@ -198,8 +198,8 @@ class GeohashListEvent( signer: NostrSignerSync, createdAt: Long = TimeUtils.now(), ): GeohashListEvent { - val privateTagArray = publicGeohashes.map { GeoHashTag.assembleSingle(it) }.toTypedArray() - val publicTagArray = privateGeohashes.map { GeoHashTag.assembleSingle(it) }.toTypedArray() + val publicTagArray = publicGeohashes.map { GeoHashTag.assembleSingle(it) }.toTypedArray() + val privateTagArray = privateGeohashes.map { GeoHashTag.assembleSingle(it) }.toTypedArray() return signer.signNip51List(createdAt, KIND, publicTagArray, privateTagArray) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/chat/LiveActivitiesChatMessageEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/chat/LiveActivitiesChatMessageEvent.kt index bdad09bcf8..e850a18671 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/chat/LiveActivitiesChatMessageEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/chat/LiveActivitiesChatMessageEvent.kt @@ -129,7 +129,7 @@ class LiveActivitiesChatMessageEvent( override fun markedReplyTos() = super.markedReplyTos().minus(activityHex() ?: "") - override fun unmarkedReplyTos() = super.markedReplyTos().minus(activityHex() ?: "") + override fun unmarkedReplyTos() = super.unmarkedReplyTos().minus(activityHex() ?: "") override fun exposeInDraft() = tagArray { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/MeetingSpaceTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/MeetingSpaceTag.kt index 138de76efe..a6f105fa20 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/MeetingSpaceTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/MeetingSpaceTag.kt @@ -37,10 +37,18 @@ class MeetingSpaceTag( fun toTagArray() = assemble(address, relayHint) + /** + * The form a kind 10312 presence uses: NIP-53 marks the room it points at as its `root` + * (`["a", , , "root"]`). A meeting room (30313) references its space WITHOUT a + * marker, so this is not [toTagArray]. + */ + fun toRootTagArray() = arrayOf(TAG_NAME, address.toValue(), relayHint?.url ?: "", ROOT_MARKER) + fun toTagIdOnly() = assemble(address, null) companion object Companion { const val TAG_NAME = "a" + const val ROOT_MARKER = "root" fun isTagged(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/TagArrayBuilderExt.kt index 37ae7110ea..2d861e2334 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/presence/TagArrayBuilderExt.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.quartz.nip53LiveActivities.presence import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle -import com.vitorpamplona.quartz.nip01Core.tags.aTag.toATag import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.MeetingSpaceTag import com.vitorpamplona.quartz.nip53LiveActivities.presence.tags.HandRaisedTag @@ -30,9 +29,10 @@ import com.vitorpamplona.quartz.nip53LiveActivities.presence.tags.MutedTag import com.vitorpamplona.quartz.nip53LiveActivities.presence.tags.OnstageTag import com.vitorpamplona.quartz.nip53LiveActivities.presence.tags.PublishingTag -fun TagArrayBuilder.roomMeeting(rep: MeetingSpaceTag) = addUnique(rep.toTagArray()) +// NIP-53: a presence points at its room with the `root` marker. +fun TagArrayBuilder.roomMeeting(rep: MeetingSpaceTag) = addUnique(rep.toRootTagArray()) -fun TagArrayBuilder.roomMeeting(rep: EventHintBundle) = addUnique(rep.toATag().toATagArray()) +fun TagArrayBuilder.roomMeeting(rep: EventHintBundle) = addUnique(MeetingSpaceTag(rep.event.address(), rep.relay).toRootTagArray()) fun TagArrayBuilder.handRaised(raised: Boolean) = addUnique(HandRaisedTag.assemble(raised)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip54Wiki/WikiArticleEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip54Wiki/WikiArticleEvent.kt index ce75d16efb..61f65f61c5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip54Wiki/WikiArticleEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip54Wiki/WikiArticleEvent.kt @@ -22,8 +22,8 @@ package com.vitorpamplona.quartz.nip54Wiki import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.experimental.forks.IForkableEvent +import com.vitorpamplona.quartz.experimental.forks.parseForkedAddress import com.vitorpamplona.quartz.experimental.forks.parseForkedEventId -import com.vitorpamplona.quartz.experimental.nipsOnNostr.tags.ForkTag import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -150,7 +150,7 @@ class WikiArticleEvent( override fun isAFork() = tags.any { it.size > 3 && (it[0] == "a" || it[0] == "e") && it[3] == "fork" } - override fun forkFromAddress() = tags.firstNotNullOfOrNull(ForkTag::parseAddress) + override fun forkFromAddress() = tags.firstNotNullOfOrNull(::parseForkedAddress) override fun forkFromVersion() = tags.firstNotNullOfOrNull(MarkedETag::parseForkedEventId) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip58Badges/profile/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip58Badges/profile/TagArrayExt.kt index 478737e306..d5b4bb5cd4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip58Badges/profile/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip58Badges/profile/TagArrayExt.kt @@ -24,9 +24,14 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedAddresses import com.vitorpamplona.quartz.nip01Core.tags.events.taggedEvents import com.vitorpamplona.quartz.nip58Badges.accepted.tags.AcceptedBadge +import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent fun TagArray.acceptedBadges() = AcceptedBadge.parseAll(this) fun TagArray.badgeAwardEvents() = taggedEvents() -fun TagArray.badgeAwardDefinitions() = taggedAddresses() +/** + * The badge DEFINITIONS (kind 30009) a profile displays. NIP-58 profiles also carry `a` tags + * pointing at badge SETS (kind 30008), which are not definitions. + */ +fun TagArray.badgeAwardDefinitions() = taggedAddresses().filter { it.kind == BadgeDefinitionEvent.KIND } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/end/tags/WinnerTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/end/tags/WinnerTag.kt index c260a465b0..da85956a39 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/end/tags/WinnerTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/end/tags/WinnerTag.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip64Chess.end.tags import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.ensure class WinnerTag { @@ -32,7 +33,8 @@ class WinnerTag { fun parse(tag: Array): HexKey? { ensure(tag.has(1) && tag[0] == TAG_NAME) { return null } - ensure(tag[1].isNotEmpty()) { return null } + // The winner is a pubkey. + ensure(tag[1].length == 64 && Hex.isHex(tag[1])) { return null } return tag[1] } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/cashu/CashuMintEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/cashu/CashuMintEvent.kt index d3db502775..eb67b6f962 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/cashu/CashuMintEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/cashu/CashuMintEvent.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.quartz.nip87Ecash.cashu import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate @@ -36,15 +36,13 @@ class CashuMintEvent( tags: Array>, content: String, sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { fun mintUrl() = tags.mintUrl() fun nuts() = tags.nuts() fun network() = tags.network() - fun dTag() = tags.dTag() - companion object { const val KIND = 38172 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/fedimint/FedimintEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/fedimint/FedimintEvent.kt index ceea8374c1..b46a30f248 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/fedimint/FedimintEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/fedimint/FedimintEvent.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.quartz.nip87Ecash.fedimint import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate @@ -36,15 +36,13 @@ class FedimintEvent( tags: Array>, content: String, sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { fun inviteCodes() = tags.inviteCodes() fun modules() = tags.modules() fun network() = tags.network() - fun dTag() = tags.dTag() - companion object { const val KIND = 38173 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/recommendation/MintRecommendationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/recommendation/MintRecommendationEvent.kt index 88bb9697d4..467e9a2fd6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/recommendation/MintRecommendationEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/recommendation/MintRecommendationEvent.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.quartz.nip87Ecash.recommendation import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate @@ -39,7 +39,7 @@ class MintRecommendationEvent( tags: Array>, content: String, sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig), +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), SearchableEvent { override fun indexableContent() = content @@ -53,8 +53,6 @@ class MintRecommendationEvent( fun mintEventKind() = tags.mintEventKind() - fun dTag() = tags.dTag() - fun mintEventAddresses() = tags.mintEventAddresses() fun isCashuRecommendation() = mintEventKind() == CashuMintEvent.KIND diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA0VoiceMessages/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA0VoiceMessages/TagArrayBuilderExt.kt index f7860df5c9..095b12d9ef 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA0VoiceMessages/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA0VoiceMessages/TagArrayBuilderExt.kt @@ -23,6 +23,9 @@ package com.vitorpamplona.quartz.nipA0VoiceMessages import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip22Comments.tags.RootAuthorTag +import com.vitorpamplona.quartz.nip22Comments.tags.RootEventTag +import com.vitorpamplona.quartz.nip22Comments.tags.RootKindTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.HashSha256Tag import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.ReplyAuthorTag import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.ReplyEventTag @@ -47,6 +50,22 @@ fun TagArrayBuilder.audioIMeta(audioUrls: List.rootEvent( + eventId: String, + relayHint: NormalizedRelayUrl?, + pubkey: String?, +) = addUnique(RootEventTag.assemble(eventId, relayHint, pubkey)) + +fun TagArrayBuilder.rootKind(kind: String) = addUnique(RootKindTag.assemble(kind)) + +fun TagArrayBuilder.rootKind(kind: Int) = addUnique(RootKindTag.assemble(kind)) + +fun TagArrayBuilder.rootAuthor( + pubKey: HexKey, + relay: NormalizedRelayUrl?, +) = addUnique(RootAuthorTag.assemble(pubKey, relay)) + fun TagArrayBuilder.replyEvent( eventId: String, relayHint: NormalizedRelayUrl?, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA0VoiceMessages/VoiceReplyEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA0VoiceMessages/VoiceReplyEvent.kt index 102522ae66..ee2bf6523f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA0VoiceMessages/VoiceReplyEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA0VoiceMessages/VoiceReplyEvent.kt @@ -24,6 +24,9 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip22Comments.tags.RootAuthorTag +import com.vitorpamplona.quartz.nip22Comments.tags.RootEventTag +import com.vitorpamplona.quartz.nip22Comments.tags.RootKindTag import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.ReplyAuthorTag import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.ReplyEventTag import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.ReplyKindTag @@ -55,6 +58,12 @@ class VoiceReplyEvent( fun replyingTo(): HexKey? = tags.lastNotNullOfOrNull(ReplyEventTag::parseKey) + /** The thread's root scope (NIP-22 `E`): the voice message the conversation started from. */ + fun rootEventId(): HexKey? = tags.firstNotNullOfOrNull(RootEventTag::parseKey) + + /** The root scope's author (NIP-22 `P`). */ + fun rootAuthorKey(): HexKey? = tags.firstNotNullOfOrNull(RootAuthorTag::parseKey) + companion object { const val KIND = 1244 @@ -73,6 +82,23 @@ class VoiceReplyEvent( createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = build(voiceMessage, KIND, createdAt) { + // NIP-A0: a voice reply MUST follow NIP-22, so it names the thread's root scope + // (E / K / P) as well as its parent. Replying to a reply inherits that reply's root; + // replying to the voice message itself makes it the root. + val parent = replyingTo.event + val inherited = + if (parent is VoiceReplyEvent) { + parent.tags.filter { RootEventTag.match(it) || RootKindTag.match(it) || RootAuthorTag.match(it) } + } else { + emptyList() + } + if (inherited.any { RootEventTag.match(it) }) { + inherited.forEach { addUnique(it) } + } else { + rootEvent(parent.id, replyingTo.relay, parent.pubKey) + rootKind(parent.kind) + rootAuthor(parent.pubKey, replyingTo.authorHomeRelay) + } replyEvent(replyingTo.event.id, replyingTo.relay, replyingTo.event.pubKey) replyKind(replyingTo.event.kind) replyAuthor(replyingTo.event.pubKey, replyingTo.authorHomeRelay) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/EditTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/EditTag.kt index 2deea22cc9..0211f098f7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/EditTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/EditTag.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.ensure /** @@ -36,7 +37,7 @@ class EditTag { fun parse(tag: Array): HexKey? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } - ensure(tag[1].isNotEmpty()) { return null } + ensure(tag[1].length == 64 && Hex.isHex(tag[1])) { return null } return tag[1] } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/attestations/RequestTagTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/attestations/RequestTagTest.kt new file mode 100644 index 0000000000..fe9de285f4 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/attestations/RequestTagTest.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.attestations + +import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.RequestTag +import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class RequestTagTest { + private val pk = "1".repeat(64) + + @Test + fun aRequestTagPointsAtAnAttestationRequest() { + val request = "${AttestationRequestEvent.KIND}:$pk:claim" + val parsed: RequestTag? = RequestTag.parse(arrayOf("request", request)) + assertEquals(request, parsed?.toTag()) + assertEquals(request, RequestTag.parseAddressId(arrayOf("request", request))) + } + + @Test + fun anythingElseIsNotARequest() { + assertNull(RequestTag.parse(arrayOf("request", "30023:$pk:post"))) + } + + @Test + fun theRequestsPTagsAreItsAttestors() { + val a = "a".repeat(64) + val b = "b".repeat(64) + val event = AttestationRequestEvent("0".repeat(64), pk, 1, arrayOf(arrayOf("d", "x"), arrayOf("p", a), arrayOf("p", b)), "", "0".repeat(128)) + assertEquals(listOf(a, b), event.attestorPubKeys()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/forks/ForkedAddressTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/forks/ForkedAddressTest.kt new file mode 100644 index 0000000000..6745b8a850 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/forks/ForkedAddressTest.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.forks + +import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent +import com.vitorpamplona.quartz.experimental.nipsOnNostr.tags.ForkTag +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiArticleEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +class ForkedAddressTest { + private val pk = "1".repeat(64) + private val community = "34550:$pk:group" + + @Test + fun aNoteForksFromItsMarkedAddressNotItsCommunity() { + val origin = "30023:$pk:post" + val note = TextNoteEvent("0".repeat(64), pk, 1, arrayOf(arrayOf("a", community), arrayOf("a", origin, "", "fork")), "", "0".repeat(128)) + assertEquals(origin, note.forkFromAddress()?.toValue()) + } + + @Test + fun aWikiArticleFindsTheArticleItWasForkedFrom() { + val origin = "30818:$pk:bitcoin" + val article = WikiArticleEvent("0".repeat(64), pk, 1, arrayOf(arrayOf("d", "bitcoin"), arrayOf("a", origin, "", "fork")), "", "0".repeat(128)) + assertEquals(origin, article.forkFromAddress()?.toValue()) + } + + @Test + fun aNipTextForkTagIsItsOwnKind() { + val origin = "${NipTextEvent.KIND}:$pk:nip-01" + assertNotNull(ForkTag.parse(arrayOf("a", origin, "", "fork"))) + assertEquals(origin, ForkTag.parseValidAddress(arrayOf("a", origin, "", "fork"))) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/interactiveStories/ReadingStateBuildTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/interactiveStories/ReadingStateBuildTest.kt new file mode 100644 index 0000000000..7b0475ceaa --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/interactiveStories/ReadingStateBuildTest.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.interactiveStories + +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import kotlin.test.Test +import kotlin.test.assertEquals + +class ReadingStateBuildTest { + @Test + fun theReadingStateKeepsItsRootAndItsSummaryAndImage() { + val pk = "1".repeat(64) + val prologue = + InteractiveStoryPrologueEvent( + "0".repeat(64), + pk, + 1, + arrayOf(arrayOf("d", "story"), arrayOf("title", "A Story"), arrayOf("summary", "the summary"), arrayOf("image", "https://img.example/cover.png")), + "", + "0".repeat(128), + ) + val scene = InteractiveStorySceneEvent("2".repeat(64), pk, 2, arrayOf(arrayOf("d", "scene-2")), "", "0".repeat(128)) + val state = NostrSignerSync().sign(InteractiveStoryReadingStateEvent.build(EventHintBundle(prologue), EventHintBundle(scene))) + + assertEquals(prologue.address().toValue(), state.root()?.toTag()) + assertEquals(scene.address().toValue(), state.currentScene()?.toValue()) + assertEquals("the summary", state.summary()) + assertEquals("https://img.example/cover.png", state.image()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip18Reposts/quotes/QTagAddressTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip18Reposts/quotes/QTagAddressTest.kt new file mode 100644 index 0000000000..0e2dcc4ba5 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip18Reposts/quotes/QTagAddressTest.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip18Reposts.quotes + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class QTagAddressTest { + private val address = "30023:460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c:my-article" + + @Test + fun anAddressQuoteParsesAsAnAddress() { + assertEquals(address, QTag.parseAddressId(arrayOf("q", address))) + assertEquals(address, QTag.parseAddressAsHint(arrayOf("q", address, "wss://relay.example.com"))?.addressId) + } + + @Test + fun anEventQuoteIsNotAnAddress() { + assertNull(QTag.parseAddressId(arrayOf("q", "a".repeat(64)))) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/PubKeysNeverLeakNsecTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/PubKeysNeverLeakNsecTest.kt new file mode 100644 index 0000000000..247b106bbd --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/PubKeysNeverLeakNsecTest.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip19Bech32 + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import kotlin.test.Test +import kotlin.test.assertEquals + +class PubKeysNeverLeakNsecTest { + private val secret = "7f7ff03d123792d6ac594bfa67bf6d0c0ab55b6b1fdb6249303fe861f1ccba9a" + private val npubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + + @Test + fun aPastedNsecIsNotALinkedPubkey() { + val nsec = secret.hexToByteArray().toNsec() + val npub = npubKey.hexToByteArray().toNpub() + val cited = Nip19Parser.parseAll("leaked nostr:$nsec and hi nostr:$npub") + assertEquals(listOf(npubKey), cited.pubKeys()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip28PublicChat/ChannelHideMessageEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip28PublicChat/ChannelHideMessageEventTest.kt new file mode 100644 index 0000000000..32d5a848e5 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip28PublicChat/ChannelHideMessageEventTest.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip28PublicChat + +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelHideMessageEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ChannelHideMessageEventTest { + private val channel = "c".repeat(64) + private val message = "d".repeat(64) + + private fun hide(vararg tags: Array) = ChannelHideMessageEvent("0".repeat(64), "1".repeat(64), 1, arrayOf(*tags), "spam", "0".repeat(128)) + + @Test + fun theChannelIsNotAmongTheHiddenMessages() { + val event = hide(arrayOf("e", channel, "", "root"), arrayOf("e", message)) + assertEquals(channel, event.channelId()) + assertEquals(listOf(message), event.eventsToHide()) + } + + @Test + fun aSpecShapedHideDoesNotReadTheHiddenMessageAsTheChannel() { + // NIP-28's own kind 43 carries only the message to hide. + val event = hide(arrayOf("e", message)) + assertNull(event.channelId()) + assertEquals(listOf(message), event.eventsToHide()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/ListAddressAndGeohashTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/ListAddressAndGeohashTest.kt new file mode 100644 index 0000000000..bc105bd37d --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/ListAddressAndGeohashTest.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists + +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip51Lists.followSet.FollowSetEvent +import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent +import com.vitorpamplona.quartz.nip51Lists.mediaFollowList.MediaFollowListEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ListAddressAndGeohashTest { + private val pk = "1".repeat(64) + + @Test + fun aStrayDTagDoesNotSplitAReplaceableListsAddress() { + val list = MediaFollowListEvent("0".repeat(64), pk, 1, arrayOf(arrayOf("d", "stray")), "", "0".repeat(128)) + assertEquals("", list.dTag()) + assertEquals("${MediaFollowListEvent.KIND}:$pk:", list.addressTag()) + } + + @Test + fun aSetIsStillAddressedByItsD() { + val set = FollowSetEvent("0".repeat(64), pk, 1, arrayOf(arrayOf("d", "friends")), "", "0".repeat(128)) + assertEquals("friends", set.dTag()) + } + + @Test + fun privateGeohashesAreNeverPublishedInClear() { + val event = GeohashListEvent.create(listOf("u4pr"), listOf("9q8y"), NostrSignerSync()) + val clear = event.tags.filter { it.size > 1 && it[0] == "g" }.map { it[1] } + assertTrue("u4pr" in clear, "$clear") + assertFalse("9q8y" in clear, "a private geohash leaked into the public tags: $clear") + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/LiveActivitiesLinkFixesTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/LiveActivitiesLinkFixesTest.kt new file mode 100644 index 0000000000..9fa2ca2997 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/LiveActivitiesLinkFixesTest.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip53LiveActivities + +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.tagArray +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.MeetingSpaceTag +import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent +import com.vitorpamplona.quartz.nip53LiveActivities.presence.roomMeeting +import kotlin.test.Test +import kotlin.test.assertEquals + +class LiveActivitiesLinkFixesTest { + private val pk = "1".repeat(64) + + @Test + fun unmarkedReplyTosAreTheUnmarkedOnes() { + val marked = "a".repeat(64) + val unmarked = "b".repeat(64) + val chat = + LiveActivitiesChatMessageEvent( + "0".repeat(64), + pk, + 1, + arrayOf(arrayOf("a", "30311:$pk:stream", "", "root"), arrayOf("e", marked, "", "reply"), arrayOf("e", unmarked)), + "hi", + "0".repeat(128), + ) + assertEquals(listOf(marked), chat.markedReplyTos()) + assertEquals(listOf(unmarked), chat.unmarkedReplyTos()) + } + + @Test + fun aPresencePointsAtItsRoomWithTheRootMarker() { + val room = Address(30313, pk, "room") + val tags = tagArray { roomMeeting(MeetingSpaceTag(room, null)) } + assertEquals(listOf("a", room.toValue(), "", "root"), tags.single().toList()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip58Badges/ProfileBadgeDefinitionsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip58Badges/ProfileBadgeDefinitionsTest.kt new file mode 100644 index 0000000000..5064b4d0a9 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip58Badges/ProfileBadgeDefinitionsTest.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip58Badges + +import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent +import kotlin.test.Test +import kotlin.test.assertEquals + +class ProfileBadgeDefinitionsTest { + @Test + fun badgeSetPointersAreNotBadgeDefinitions() { + val pk = "1".repeat(64) + val definition = "30009:$pk:early-adopter" + val profile = + ProfileBadgesEvent( + "0".repeat(64), + pk, + 1, + arrayOf(arrayOf("d", "profile_badges"), arrayOf("a", definition), arrayOf("e", "e".repeat(64)), arrayOf("a", "30008:$pk:favorites")), + "", + "0".repeat(128), + ) + assertEquals(listOf(definition), profile.badgeAwardDefinitions().map { it.toValue() }) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip87Ecash/MintEventsAreAddressableTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip87Ecash/MintEventsAreAddressableTest.kt new file mode 100644 index 0000000000..8339a50007 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip87Ecash/MintEventsAreAddressableTest.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip87Ecash + +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent +import com.vitorpamplona.quartz.nip87Ecash.cashu.CashuMintEvent +import com.vitorpamplona.quartz.nip87Ecash.fedimint.FedimintEvent +import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class MintEventsAreAddressableTest { + private val pk = "1".repeat(64) + private val tags = arrayOf(arrayOf("d", "mint-id")) + + @Test + fun theNip87KindsAreAddressable() { + for (event in listOf( + CashuMintEvent("0".repeat(64), pk, 1, tags, "", "0".repeat(128)), + FedimintEvent("0".repeat(64), pk, 1, tags, "", "0".repeat(128)), + MintRecommendationEvent("0".repeat(64), pk, 1, tags, "", "0".repeat(128)), + )) { + assertIs(event) + assertEquals("${event.kind}:$pk:mint-id", event.addressTag()) + } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipA0VoiceMessages/VoiceReplyRootScopeTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipA0VoiceMessages/VoiceReplyRootScopeTest.kt new file mode 100644 index 0000000000..bcdd161e8a --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipA0VoiceMessages/VoiceReplyRootScopeTest.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipA0VoiceMessages + +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import kotlin.test.Test +import kotlin.test.assertEquals + +class VoiceReplyRootScopeTest { + private val audio = AudioMeta("https://blossom.example/a.m4a", "audio/mp4", "f".repeat(64), 3, listOf(0.1f)) + + @Test + fun aVoiceReplyNamesItsThreadsRootScope() { + val signer = NostrSignerSync() + val voice = VoiceEvent("9".repeat(64), "1".repeat(64), 1, arrayOf(arrayOf("imeta", "url https://blossom.example/v.m4a")), "", "0".repeat(128)) + + val reply = signer.sign(VoiceReplyEvent.build(audio, EventHintBundle(voice))) + assertEquals(voice.id, reply.rootEventId()) + assertEquals(voice.pubKey, reply.rootAuthorKey()) + assertEquals(voice.id, reply.replyingTo()) + + // A reply to that reply keeps the thread's root and names its new parent. + val nested = signer.sign(VoiceReplyEvent.build(audio, EventHintBundle(reply))) + assertEquals(voice.id, nested.rootEventId()) + assertEquals(reply.id, nested.replyingTo()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/IdShapedTagParsersTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/IdShapedTagParsersTest.kt new file mode 100644 index 0000000000..7065b63899 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/IdShapedTagParsersTest.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils + +import com.vitorpamplona.quartz.buzz.amTurnMetrics.tags.AgentTag +import com.vitorpamplona.quartz.buzz.iaIdentityArchival.tags.ConsentTag +import com.vitorpamplona.quartz.buzz.iaIdentityArchival.tags.ReplacedByTag +import com.vitorpamplona.quartz.buzz.moderation.tags.ReportTag +import com.vitorpamplona.quartz.nip64Chess.end.tags.WinnerTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.EditTag +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** Tags whose value IS a pubkey or event id must not hand back anything else as one. */ +class IdShapedTagParsersTest { + private val key = "a".repeat(64) + + @Test + fun onlyHexIdsParse() { + for ((name, parse) in listOf) -> String?>>( + ReplacedByTag.TAG_NAME to ReplacedByTag::parse, + AgentTag.TAG_NAME to AgentTag::parse, + ReportTag.TAG_NAME to ReportTag::parse, + WinnerTag.TAG_NAME to WinnerTag::parse, + EditTag.TAG_NAME to EditTag::parse, + )) { + assertEquals(key, parse(arrayOf(name, key)), name) + assertNull(parse(arrayOf(name, "not-a-key")), name) + assertNull(parse(arrayOf(name, "g".repeat(64))), name) + } + assertNull(ConsentTag.parse(arrayOf(ConsentTag.TAG_NAME, "path", "not-a-key"))) + assertEquals(key, ConsentTag.parse(arrayOf(ConsentTag.TAG_NAME, "path", key))?.actorPubKey) + } +} From 01c53e8c72da5eb42169323c1fb6688a0ae65d73 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 15:47:26 +0000 Subject: [PATCH 08/19] feat: name divine.video's infrastructure kinds in kind registries Relay stats showed divine.video's private kinds as bare numbers. Register them by name in KindNames (used by amy and as the app's fallback) and add translated labels in KindDisplayName: - 3079 / 3080 / 3083: push token registration, deregistration and notification preferences for divine-push-service (NIP-44 encrypted) - 22236: ephemeral video-view analytics The constants live in quartz experimental/divine/DivineKinds, since none of these carry content a client renders. Also registers the already supported 34238 (video collaboration) and 39307 (subtitles) in KindNames, which only the Compose layer knew about. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014VMBKXQwG1xa3mMEW3MsMQ --- .../composeResources/values/strings.xml | 4 ++ .../screen/loggedIn/relays/KindDisplayName.kt | 9 ++++ .../quartz/experimental/divine/DivineKinds.kt | 47 +++++++++++++++++++ .../vitorpamplona/quartz/kinds/KindNames.kt | 9 ++++ 4 files changed, 69 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/divine/DivineKinds.kt diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index e50c6fd0f3..b45a31288a 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -4105,6 +4105,9 @@ Profile Gallery Proxy Relays Public Message + Push Registration + Push Deregistration + Push Preferences Reactions Relay Auth Relay Discovery @@ -4129,6 +4132,7 @@ Video Video Collaboration Video List + Video Views Video (Repl) Video Subtitles Voice Msg diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/KindDisplayName.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/KindDisplayName.kt index 22692e2c39..eda8d0a1b7 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/KindDisplayName.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/KindDisplayName.kt @@ -140,6 +140,9 @@ import com.vitorpamplona.amethyst.commons.resources.kind_profile_badges import com.vitorpamplona.amethyst.commons.resources.kind_profile_gallery import com.vitorpamplona.amethyst.commons.resources.kind_proxy_relays import com.vitorpamplona.amethyst.commons.resources.kind_public_message +import com.vitorpamplona.amethyst.commons.resources.kind_push_deregistration +import com.vitorpamplona.amethyst.commons.resources.kind_push_preferences +import com.vitorpamplona.amethyst.commons.resources.kind_push_registration import com.vitorpamplona.amethyst.commons.resources.kind_reactions import com.vitorpamplona.amethyst.commons.resources.kind_relay_auth import com.vitorpamplona.amethyst.commons.resources.kind_relay_discovery @@ -165,6 +168,7 @@ import com.vitorpamplona.amethyst.commons.resources.kind_video_collaboration import com.vitorpamplona.amethyst.commons.resources.kind_video_list import com.vitorpamplona.amethyst.commons.resources.kind_video_repl import com.vitorpamplona.amethyst.commons.resources.kind_video_subtitles +import com.vitorpamplona.amethyst.commons.resources.kind_video_views import com.vitorpamplona.amethyst.commons.resources.kind_voice_msg import com.vitorpamplona.amethyst.commons.resources.kind_voice_reply import com.vitorpamplona.amethyst.commons.resources.kind_wake @@ -183,6 +187,7 @@ import com.vitorpamplona.quartz.experimental.attestations.recommendation.Attesto import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent +import com.vitorpamplona.quartz.experimental.divine.DivineKinds import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent @@ -485,6 +490,10 @@ fun kindDisplayName(kind: Int): StringResource? = VideoCurationSetEvent.KIND -> Res.string.kind_video_list VideoCollaborationEvent.KIND -> Res.string.kind_video_collaboration TextTrackEvent.KIND -> Res.string.kind_video_subtitles + DivineKinds.VIDEO_VIEW -> Res.string.kind_video_views + DivineKinds.PUSH_REGISTRATION -> Res.string.kind_push_registration + DivineKinds.PUSH_DEREGISTRATION -> Res.string.kind_push_deregistration + DivineKinds.PUSH_PREFERENCES -> Res.string.kind_push_preferences AddressableNormalVideoEvent.KIND -> Res.string.kind_video_repl AddressableShortVideoEvent.KIND -> Res.string.kind_shorts_repl VideoNormalEvent.KIND -> Res.string.kind_video diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/divine/DivineKinds.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/divine/DivineKinds.kt new file mode 100644 index 0000000000..8a3d995877 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/divine/DivineKinds.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.divine + +/** + * Kinds divine.video () publishes for its own infrastructure. + * + * None of them carry content a client renders, so Quartz has no event classes for them: they are + * registered only so relay statistics and `amy kind` can put a name on them instead of a number. + */ +object DivineKinds { + /** + * Registers a device's push token with divine-push-service. The content is NIP-44 encrypted to + * the service's `p`-tagged key. Spec: divine-push-service `docs/nip-xx-push-notifications.md`. + */ + const val PUSH_REGISTRATION = 3079 + + /** Removes a token registered with [PUSH_REGISTRATION]. Same draft, same encrypted shape. */ + const val PUSH_DEREGISTRATION = 3080 + + /** Which notification categories the push service should send, NIP-44 encrypted to it. */ + const val PUSH_PREFERENCES = 3083 + + /** + * Ephemeral "watched this video" analytics, `a`/`e`-tagging a kind-34236 video with `phase`, + * `viewed`, `loops` and `source` tags. Divine's relay turns these into view and loop counts. + */ + const val VIDEO_VIEW = 22236 +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt index 588e9782b7..37516c0615 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt @@ -40,6 +40,7 @@ import com.vitorpamplona.quartz.experimental.decentralizedLists.header.ListHeade import com.vitorpamplona.quartz.experimental.decentralizedLists.item.AddressableListItemEvent import com.vitorpamplona.quartz.experimental.decentralizedLists.item.ListItemEvent import com.vitorpamplona.quartz.experimental.decoupling.setup.EncryptionKeyListEvent +import com.vitorpamplona.quartz.experimental.divine.DivineKinds import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent @@ -74,6 +75,7 @@ import com.vitorpamplona.quartz.experimental.trustedLists.addressables.Addressab import com.vitorpamplona.quartz.experimental.trustedLists.events.EventTrustedListEvent import com.vitorpamplona.quartz.experimental.trustedLists.externalIds.ExternalIdTrustedListEvent import com.vitorpamplona.quartz.experimental.trustedLists.users.UserTrustedListEvent +import com.vitorpamplona.quartz.experimental.videoCollaboration.VideoCollaborationEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.feedDefinition.FeedDefinitionEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent @@ -243,6 +245,7 @@ import com.vitorpamplona.quartz.nip71Video.AddressableNormalVideoEvent import com.vitorpamplona.quartz.nip71Video.AddressableShortVideoEvent import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent import com.vitorpamplona.quartz.nip71Video.VideoShortEvent +import com.vitorpamplona.quartz.nip71Video.textTrack.TextTrackEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent @@ -506,6 +509,12 @@ object KindNames { AddressableShortVideoEvent.KIND to KindName("Shorts (Repl)", "71"), VideoNormalEvent.KIND to KindName("Video", "71"), VideoShortEvent.KIND to KindName("Shorts", "71"), + VideoCollaborationEvent.KIND to KindName("Video Collaboration", null), + TextTrackEvent.KIND to KindName("Video Subtitles", null), + DivineKinds.VIDEO_VIEW to KindName("Video Views", null), + DivineKinds.PUSH_REGISTRATION to KindName("Push Registration", null), + DivineKinds.PUSH_DEREGISTRATION to KindName("Push Deregistration", null), + DivineKinds.PUSH_PREFERENCES to KindName("Push Preferences", null), VoiceEvent.KIND to KindName("Voice Msg", "A0"), VoiceReplyEvent.KIND to KindName("Voice Reply", "A0"), WakeUpEvent.KIND to KindName("WakeUp", null), From ab1c71a5ef1df21ee8b6c842f136768a4e2604c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 15:58:40 +0000 Subject: [PATCH 09/19] fix(quartz): address audit findings on the link-review fixes Bugs: - Address.isOfKind / AddressSerializer.isOfKind (all actuals) indexed past the end of a value that is exactly the kind string, so a hostile ["request","31872"] threw from every RequestTag reader. Bounds-checked. - AddressMemberTag accepted an naddr (AddressSerializer decodes bech32) and returned the raw bech32 as the member key, and isTag / parseAsHint were looser than parse. All readers now gate on the allocation-free CoordinateShape, which also stops a WARN log per rejected member. - Voice replies dropped an address- or identifier-rooted thread (only E was inherited) and rooted replies to pre-NIP-22 voice replies at the reply. rootScopeTags() now inherits E/A/I + K/P, and derives the root from a legacy reply's lowercase e/p when its parent is a voice message. - Interactive-story reading states published by the old builder carry no root tag: root() falls back to the d-tag (always the root's address) and update() writes the missing A tag. - Event.dTag() now defers to AddressableEvent, so a replaceable list seen as a plain Event gets the same address as the cache (zap a-tags, backup slots). EncryptionKeyListEvent (10044) ignores a stray d like the lists. - deleteRecommendation wrote the 38000 address twice: build() adds it now that MintRecommendationEvent is addressable. - The NIP-87 wallet and mint-directory backfills scanned only cache.notes; addressable events live in cache.addressables. Cleanups: the new hex checks reuse HexKey.isValid(), the nip29 readers are single-pass, and WinnerTag.isTag agrees with parse. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pu8Fpp4KbXaiax8YTYxhJm --- .../commons/cashu/ops/CashuWalletOps.kt | 16 ++---- .../nip60Cashu/CashuMintDirectoryState.kt | 7 ++- .../model/nip60Cashu/CashuWalletState.kt | 16 ++++-- .../quartz/nip01Core/core/Address.kt | 2 +- .../buzz/amTurnMetrics/tags/AgentTag.kt | 4 +- .../iaIdentityArchival/tags/ConsentTag.kt | 4 +- .../iaIdentityArchival/tags/ReplacedByTag.kt | 4 +- .../quartz/buzz/moderation/tags/ReportTag.kt | 4 +- .../citations/ExternalCitationEvent.kt | 4 +- .../setup/EncryptionKeyListEvent.kt | 4 ++ .../InteractiveStoryReadingStateEvent.kt | 14 +++++- .../addressables/tags/AddressMemberTag.kt | 19 +++---- .../nip01Core/core/AddressSerializer.kt | 2 +- .../quartz/nip01Core/tags/dTag/EventExt.kt | 8 ++- .../metadata/GroupParticipantsEvent.kt | 4 +- .../moderation/TagArrayExt.kt | 4 +- .../quartz/nip64Chess/end/tags/WinnerTag.kt | 6 +-- .../nipA0VoiceMessages/VoiceReplyEvent.kt | 39 +++++++++++---- .../nipXXPodcasting20/episode/tags/EditTag.kt | 4 +- .../ReadingStateBuildTest.kt | 16 ++++++ .../trustedLists/AddressMemberShapeTest.kt | 49 +++++++++++++++++++ .../core/AddressIsOfKindBoundsTest.kt | 43 ++++++++++++++++ .../EventDTagAgreesWithAddressTest.kt | 41 ++++++++++++++++ .../MintEventsAreAddressableTest.kt | 9 ++++ .../VoiceReplyRootScopeTest.kt | 41 ++++++++++++++++ .../quartz/nip01Core/core/Address.jvm.kt | 2 +- .../quartz/nip01Core/core/Address.native.kt | 2 +- 27 files changed, 305 insertions(+), 63 deletions(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/AddressMemberShapeTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/core/AddressIsOfKindBoundsTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/EventDTagAgreesWithAddressTest.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt index 3bfa9f2b5a..dccbabcfc2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cashu/ops/CashuWalletOps.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.commons.cashu.ops -import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray @@ -28,7 +27,6 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip01Core.tags.aTag.aTag import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.nip60Cashu.bdhke.Bdhke import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent @@ -1094,19 +1092,11 @@ class CashuWalletOps( * pointing at the address coordinate (kind:pubkey:dTag) drops all * versions on compliant relays. We also include the original event id * via DeletionRequestEvent.build so relays that only track by event id still - * remove it. MintRecommendationEvent doesn't extend AddressableEvent - * today, so we compute and add the `a` tag ourselves. + * remove it. MintRecommendationEvent is an AddressableEvent, so + * DeletionRequestEvent.build writes that `a` tag itself. */ suspend fun deleteRecommendation(event: MintRecommendationEvent) { - // Add the `a` tag when we have a d-tag — kind:38000 is parameterized- - // replaceable, so the address coordinate lets compliant relays drop - // all versions, not just the specific id. Recommendations without a - // d-tag still get a NIP-09 `e`-only delete (the default build path). - val dTag = event.dTag() - val template = - DeletionRequestEvent.build(listOf(event)) { - if (dTag != null) aTag(Address(event.kind, event.pubKey, dTag)) - } + val template = DeletionRequestEvent.build(listOf(event)) val delEvent = signer.sign(template) publish(delEvent) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip60Cashu/CashuMintDirectoryState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip60Cashu/CashuMintDirectoryState.kt index 9ca368adad..b398ea5305 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip60Cashu/CashuMintDirectoryState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip60Cashu/CashuMintDirectoryState.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.commons.model.nip60Cashu import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryQueryState @@ -201,13 +202,17 @@ class CashuMintDirectoryState( private fun backfillFromCacheAsync() { scope.launch(Dispatchers.Default) { - cache.notes.forEach { _, note -> + // NIP-87 kinds are addressable: the current version lives in `addressables` (its + // per-id note is weakly held and pruned once superseded). Both maps are keyed by id. + val visit = { note: Note -> when (val e = note.event) { is CashuMintEvent -> announcements[e.id] = e is MintRecommendationEvent -> if (e.isCashuRecommendation()) recommendations[e.id] = e else -> Unit } } + cache.notes.forEach { _, note -> visit(note) } + cache.addressables.forEach { _, note -> visit(note) } rebuildEntries() } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip60Cashu/CashuWalletState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip60Cashu/CashuWalletState.kt index 8726daaecd..2751ae7631 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip60Cashu/CashuWalletState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip60Cashu/CashuWalletState.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.cashu.ops.SendTokenCompleted import com.vitorpamplona.amethyst.commons.cashu.ops.TokenEntry import com.vitorpamplona.amethyst.commons.cashu.ops.describeMintError import com.vitorpamplona.amethyst.commons.model.AccountSettings +import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.relayClient.assemblers.cashuProofBackfillFilters import com.vitorpamplona.quartz.nip01Core.core.Event @@ -906,12 +907,17 @@ class CashuWalletState( } private fun scanCacheForOwnEvents(): List { - val collected = mutableListOf() - cache.notes.forEach { _, note -> - val e = note.event ?: return@forEach - if (isRelevantEvent(e)) collected += e + // Replaceable and addressable kinds (the wallet, NIP-87 recommendations) live in + // `addressables`: their per-id note is only weakly held and pruned once superseded. + // The current version can sit in both maps, so collect by id. + val collected = LinkedHashMap() + val visit = { note: Note -> + val e = note.event + if (e != null && isRelevantEvent(e)) collected[e.id] = e } - return collected + cache.notes.forEach { _, note -> visit(note) } + cache.addressables.forEach { _, note -> visit(note) } + return collected.values.toList() } // ============================================================ diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Address.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Address.kt index e553dbe8dd..2e0f6b455a 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Address.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Address.kt @@ -74,7 +74,7 @@ actual data class Address actual constructor( actual fun isOfKind( addressId: String, kind: String, - ) = addressId.startsWith(kind) && addressId[kind.length] == ':' + ) = addressId.length > kind.length && addressId.startsWith(kind) && addressId[kind.length] == ':' } // ----------- diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/amTurnMetrics/tags/AgentTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/amTurnMetrics/tags/AgentTag.kt index 6d3fed1136..da8d777962 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/amTurnMetrics/tags/AgentTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/amTurnMetrics/tags/AgentTag.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.quartz.buzz.amTurnMetrics.tags import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.has -import com.vitorpamplona.quartz.utils.Hex +import com.vitorpamplona.quartz.nip01Core.core.isValid import com.vitorpamplona.quartz.utils.ensure /** @@ -39,7 +39,7 @@ object AgentTag { fun parse(tag: Tag): HexKey? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } - ensure(tag[1].length == 64 && Hex.isHex(tag[1])) { return null } + ensure(tag[1].isValid()) { return null } return tag[1] } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/iaIdentityArchival/tags/ConsentTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/iaIdentityArchival/tags/ConsentTag.kt index 86acb88136..d70bc74f78 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/iaIdentityArchival/tags/ConsentTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/iaIdentityArchival/tags/ConsentTag.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.quartz.buzz.iaIdentityArchival.tags import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.has -import com.vitorpamplona.quartz.utils.Hex +import com.vitorpamplona.quartz.nip01Core.core.isValid import com.vitorpamplona.quartz.utils.ensure /** @@ -50,7 +50,7 @@ object ConsentTag { ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].isNotEmpty()) { return null } // The actor is a pubkey. - ensure(tag[2].length == 64 && Hex.isHex(tag[2])) { return null } + ensure(tag[2].isValid()) { return null } return Consent(tag[1], tag[2]) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/iaIdentityArchival/tags/ReplacedByTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/iaIdentityArchival/tags/ReplacedByTag.kt index c596fb625a..a2fb2608f0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/iaIdentityArchival/tags/ReplacedByTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/iaIdentityArchival/tags/ReplacedByTag.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.quartz.buzz.iaIdentityArchival.tags import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.has -import com.vitorpamplona.quartz.utils.Hex +import com.vitorpamplona.quartz.nip01Core.core.isValid import com.vitorpamplona.quartz.utils.ensure /** @@ -41,7 +41,7 @@ object ReplacedByTag { fun parse(tag: Array): HexKey? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } - ensure(tag[1].length == 64 && Hex.isHex(tag[1])) { return null } + ensure(tag[1].isValid()) { return null } return tag[1] } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/moderation/tags/ReportTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/moderation/tags/ReportTag.kt index 1ae3ce0de5..bab101f517 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/moderation/tags/ReportTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/moderation/tags/ReportTag.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.quartz.buzz.moderation.tags import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.core.has -import com.vitorpamplona.quartz.utils.Hex +import com.vitorpamplona.quartz.nip01Core.core.isValid import com.vitorpamplona.quartz.utils.ensure /** @@ -42,7 +42,7 @@ object ReportTag { fun parse(tag: Array): HexKey? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } - ensure(tag[1].length == 64 && Hex.isHex(tag[1])) { return null } + ensure(tag[1].isValid()) { return null } return tag[1] } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/citations/ExternalCitationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/citations/ExternalCitationEvent.kt index 0703a0ecdc..159a6ff00a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/citations/ExternalCitationEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/citations/ExternalCitationEvent.kt @@ -25,10 +25,10 @@ import com.vitorpamplona.quartz.experimental.citations.tags.CitationTags import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.isValid import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag -import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.TimeUtils /** A citation of something on the web (kind 31): a URL, optionally timestamped. */ @@ -51,7 +51,7 @@ class ExternalCitationEvent( fun url() = value(CitationTags.URL) ?: value("url") /** The id of a NIP-03 kind-1040 timestamp attesting when the page was seen. */ - fun openTimestamp() = value(CitationTags.OPEN_TIMESTAMP)?.takeIf { it.length == 64 && Hex.isHex(it) } + fun openTimestamp() = value(CitationTags.OPEN_TIMESTAMP)?.takeIf { it.isValid() } override fun displayTitle(): String? = title() ?: url() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decoupling/setup/EncryptionKeyListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decoupling/setup/EncryptionKeyListEvent.kt index 2a5d6f6a73..dec4aec02e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decoupling/setup/EncryptionKeyListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decoupling/setup/EncryptionKeyListEvent.kt @@ -40,6 +40,10 @@ class EncryptionKeyListEvent( ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { fun keys() = tags.mapNotNull(KeyTag::parse) + // Kind 10044 is replaceable: NIP-01 fixes its address to `kind:pubkey:`, so a stray `d` + // tag must not split one user's key list into several addresses. + override fun dTag(): String = "" + companion object { const val KIND = 10044 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStoryReadingStateEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStoryReadingStateEvent.kt index c52c137022..826a37bfce 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStoryReadingStateEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStoryReadingStateEvent.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.core.builder +import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate @@ -56,7 +57,14 @@ class InteractiveStoryReadingStateEvent( fun status() = tags.firstNotNullOfOrNull(StatusTag::parse) - fun root() = tags.firstNotNullOfOrNull(RootSceneTag::parse) + /** + * The story this state tracks. Reading states written before `rootScene` emitted the `A` + * tag carry no root tag at all (the lowercase `a` it wrote was replaced by the current + * scene's), but `build` always set the d-tag to the root's address, so that is the fallback. + */ + fun root() = + tags.firstNotNullOfOrNull(RootSceneTag::parse) + ?: Address.parse(dTag())?.let { RootSceneTag(it.kind, it.pubKeyHex, it.dTag, null) } fun currentScene() = tags.firstNotNullOfOrNull(ATag::parseAddress) @@ -97,6 +105,10 @@ class InteractiveStoryReadingStateEvent( val updatedTags = base.tags.builder { + // Heal a state written without its root tag (see [root]). + if (base.tags.none { it.has(1) && it[0] == RootSceneTag.TAG_NAME } && Address.parse(rootTag) != null) { + add(RootSceneTag.assemble(rootTag, null)) + } currentScene(sceneTag) status(status) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/addressables/tags/AddressMemberTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/addressables/tags/AddressMemberTag.kt index 1221b7309a..f138a54b66 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/addressables/tags/AddressMemberTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/addressables/tags/AddressMemberTag.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.experimental.trustedLists.addressables.tags import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.decentralizedLists.CoordinateShape import com.vitorpamplona.quartz.experimental.trustedLists.tags.MemberTagFields import com.vitorpamplona.quartz.experimental.trustedLists.tags.TrustedListMemberTag import com.vitorpamplona.quartz.nip01Core.core.Address @@ -58,7 +59,11 @@ data class AddressMemberTag( companion object { const val TAG_NAME = "a" - fun isTag(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + // A member is a `kind:pubkey:d` coordinate. CoordinateShape checks that without allocating + // (a 30394 can list thousands of members) and, unlike AddressSerializer.parse, neither + // decodes an naddr (whose raw bech32 would then be used as the member key) nor logs a + // warning per rejected value. + fun isTag(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME && CoordinateShape.matches(tag[1]) fun isTagged( tag: Tag, @@ -68,8 +73,7 @@ data class AddressMemberTag( fun parse(tag: Tag): AddressMemberTag? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } - ensure(tag[1].isNotEmpty()) { return null } - ensure(AddressSerializer.parse(tag[1]) != null) { return null } + ensure(CoordinateShape.matches(tag[1])) { return null } return AddressMemberTag(tag[1], MemberTagFields.relayHint(tag), MemberTagFields.score(tag)) } @@ -77,24 +81,21 @@ data class AddressMemberTag( fun parseAddressId(tag: Tag): String? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } - ensure(tag[1].isNotEmpty()) { return null } - ensure(AddressSerializer.parse(tag[1]) != null) { return null } + ensure(CoordinateShape.matches(tag[1])) { return null } return tag[1] } fun parseAddress(tag: Tag): Address? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } - ensure(tag[1].isNotEmpty()) { return null } + ensure(CoordinateShape.matches(tag[1])) { return null } return AddressSerializer.parse(tag[1]) } fun parseAsHint(tag: Tag): AddressHint? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } - ensure(tag[1].isNotEmpty()) { return null } - // only index a value that is actually a coordinate, as ATag does - ensure(tag[1].contains(':')) { return null } + ensure(CoordinateShape.matches(tag[1])) { return null } val hint = MemberTagFields.relayHint(tag) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/AddressSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/AddressSerializer.kt index 80ca91929b..58fa7265aa 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/AddressSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/AddressSerializer.kt @@ -75,6 +75,6 @@ class AddressSerializer { fun isOfKind( addressId: String, kind: String, - ) = addressId.startsWith(kind) && addressId[kind.length] == ':' + ) = addressId.length > kind.length && addressId.startsWith(kind) && addressId[kind.length] == ':' } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/dTag/EventExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/dTag/EventExt.kt index a2758ff2f0..836c9265a0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/dTag/EventExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/dTag/EventExt.kt @@ -20,6 +20,12 @@ */ package com.vitorpamplona.quartz.nip01Core.tags.dTag +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event -fun Event.dTag() = tags.dTag() +/** + * The d-tag that places this event in its address. An [AddressableEvent] decides it (a + * replaceable kind's is always "", whatever `d` tags it carries), so a caller holding a plain + * [Event] gets the same answer as one holding the concrete class; anything else reads the tag. + */ +fun Event.dTag(): String = (this as? AddressableEvent)?.dTag() ?: tags.dTag() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupParticipantsEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupParticipantsEvent.kt index b4b464f495..8dc6adb964 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupParticipantsEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/metadata/GroupParticipantsEvent.kt @@ -24,10 +24,10 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.isValid import com.vitorpamplona.quartz.nip01Core.core.mapValueTagged import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag -import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.TimeUtils /** @@ -49,7 +49,7 @@ class GroupParticipantsEvent( ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { fun groupId() = dTag() - fun participants(): List = tags.mapValueTagged(TAG_NAME) { it }.filter { it.length == 64 && Hex.isHex(it) } + fun participants(): List = tags.mapValueTagged(TAG_NAME) { it.takeIf { value -> value.isValid() } } companion object { const val KIND = 39004 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayExt.kt index ef3e7d9968..80fa2c9c24 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/moderation/TagArrayExt.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip01Core.core.fastForEach import com.vitorpamplona.quartz.nip01Core.core.firstTagValue +import com.vitorpamplona.quartz.nip01Core.core.isValid import com.vitorpamplona.quartz.nip01Core.core.mapValueTagged import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip29RelayGroups.tags.AddressPin @@ -35,7 +36,6 @@ import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupPin import com.vitorpamplona.quartz.nip29RelayGroups.tags.ParentTag import com.vitorpamplona.quartz.nip29RelayGroups.tags.PreviousTag -import com.vitorpamplona.quartz.utils.Hex fun TagArray.groupId() = firstTagValue(GroupIdTag.TAG_NAME) @@ -57,7 +57,7 @@ fun TagArray.childGroupIds(): List = mapNotNull(ChildTag::parse) fun TagArray.userPubKeys(): List = mapNotNull(PTag::parseKey) -fun TagArray.deletedEventIds(): List = mapValueTagged("e") { it }.filter { it.length == 64 && Hex.isHex(it) } +fun TagArray.deletedEventIds(): List = mapValueTagged("e") { it.takeIf { value -> value.isValid() } } /** The ordered pin list: `e` (event id) and `a` (address) references, interleaved as sent. */ fun TagArray.groupPins(): List = mapNotNull(GroupPin::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/end/tags/WinnerTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/end/tags/WinnerTag.kt index da85956a39..cb3d672ab7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/end/tags/WinnerTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/end/tags/WinnerTag.kt @@ -22,19 +22,19 @@ package com.vitorpamplona.quartz.nip64Chess.end.tags import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.has -import com.vitorpamplona.quartz.utils.Hex +import com.vitorpamplona.quartz.nip01Core.core.isValid import com.vitorpamplona.quartz.utils.ensure class WinnerTag { companion object { const val TAG_NAME = "winner" - fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isValid() fun parse(tag: Array): HexKey? { ensure(tag.has(1) && tag[0] == TAG_NAME) { return null } // The winner is a pubkey. - ensure(tag[1].length == 64 && Hex.isHex(tag[1])) { return null } + ensure(tag[1].isValid()) { return null } return tag[1] } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA0VoiceMessages/VoiceReplyEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA0VoiceMessages/VoiceReplyEvent.kt index ee2bf6523f..e1026e45ce 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA0VoiceMessages/VoiceReplyEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA0VoiceMessages/VoiceReplyEvent.kt @@ -24,8 +24,10 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip22Comments.tags.RootAddressTag import com.vitorpamplona.quartz.nip22Comments.tags.RootAuthorTag import com.vitorpamplona.quartz.nip22Comments.tags.RootEventTag +import com.vitorpamplona.quartz.nip22Comments.tags.RootIdentifierTag import com.vitorpamplona.quartz.nip22Comments.tags.RootKindTag import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.ReplyAuthorTag import com.vitorpamplona.quartz.nipA0VoiceMessages.tags.ReplyEventTag @@ -64,6 +66,29 @@ class VoiceReplyEvent( /** The root scope's author (NIP-22 `P`). */ fun rootAuthorKey(): HexKey? = tags.firstNotNullOfOrNull(RootAuthorTag::parseKey) + /** + * The root-scope tags a reply to this event inherits, or null when this event names no root. + * + * A NIP-22 reply carries them verbatim: `E`, `A` or `I` (a thread may be rooted at an + * address or an external id, not only at an event) plus `K` and `P`. A reply published + * before voice replies followed NIP-22 has only the lowercase parent tags; when that parent + * is a voice message (`k` 1222) the parent IS the root, so its `e` / `p` are rewritten as + * `E` / `P` (identical layouts). An older reply to a reply has lost its root: null. + */ + fun rootScopeTags(): List>? { + val scope = tags.filter { RootEventTag.match(it) || RootAddressTag.match(it) || RootIdentifierTag.match(it) || RootKindTag.match(it) || RootAuthorTag.match(it) } + if (scope.any { RootEventTag.match(it) || RootAddressTag.match(it) || RootIdentifierTag.match(it) }) return scope + + if (tags.none { ReplyKindTag.match(it) && it[1] == VoiceEvent.KIND.toString() }) return null + val parentTag = tags.lastOrNull { ReplyEventTag.parseKey(it) != null } ?: return null + val authorTag = tags.lastOrNull { ReplyAuthorTag.parseKey(it) != null } + return listOfNotNull( + parentTag.copyOf().also { it[0] = RootEventTag.TAG_NAME }, + RootKindTag.assemble(VoiceEvent.KIND), + authorTag?.copyOf()?.also { it[0] = RootAuthorTag.TAG_NAME }, + ) + } + companion object { const val KIND = 1244 @@ -82,17 +107,11 @@ class VoiceReplyEvent( createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = build(voiceMessage, KIND, createdAt) { - // NIP-A0: a voice reply MUST follow NIP-22, so it names the thread's root scope - // (E / K / P) as well as its parent. Replying to a reply inherits that reply's root; - // replying to the voice message itself makes it the root. + // NIP-A0: a voice reply MUST follow NIP-22, so it names the thread's root scope as + // well as its parent. Replying to the voice message itself makes it the root. val parent = replyingTo.event - val inherited = - if (parent is VoiceReplyEvent) { - parent.tags.filter { RootEventTag.match(it) || RootKindTag.match(it) || RootAuthorTag.match(it) } - } else { - emptyList() - } - if (inherited.any { RootEventTag.match(it) }) { + val inherited = if (parent is VoiceReplyEvent) parent.rootScopeTags() else null + if (inherited != null) { inherited.forEach { addUnique(it) } } else { rootEvent(parent.id, replyingTo.relay, parent.pubKey) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/EditTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/EditTag.kt index 0211f098f7..4369d95390 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/EditTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/EditTag.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.has -import com.vitorpamplona.quartz.utils.Hex +import com.vitorpamplona.quartz.nip01Core.core.isValid import com.vitorpamplona.quartz.utils.ensure /** @@ -37,7 +37,7 @@ class EditTag { fun parse(tag: Array): HexKey? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } - ensure(tag[1].length == 64 && Hex.isHex(tag[1])) { return null } + ensure(tag[1].isValid()) { return null } return tag[1] } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/interactiveStories/ReadingStateBuildTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/interactiveStories/ReadingStateBuildTest.kt index 7b0475ceaa..7921cb194b 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/interactiveStories/ReadingStateBuildTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/interactiveStories/ReadingStateBuildTest.kt @@ -46,4 +46,20 @@ class ReadingStateBuildTest { assertEquals("the summary", state.summary()) assertEquals("https://img.example/cover.png", state.image()) } + + @Test + fun aStateWrittenWithoutItsRootTagFallsBackToItsDTagAndHealsOnUpdate() { + val pk = "1".repeat(64) + val story = "30296:$pk:story" + val scene = "30297:$pk:scene-2" + // What the old builder published: the root's lowercase `a` was replaced by the scene's. + val old = + InteractiveStoryReadingStateEvent("0".repeat(64), pk, 1, arrayOf(arrayOf("d", story), arrayOf("a", scene), arrayOf("status", "reading")), "", "0".repeat(128)) + assertEquals(story, old.root()?.toTag()) + + val next = InteractiveStorySceneEvent("3".repeat(64), pk, 2, arrayOf(arrayOf("d", "scene-3")), "", "0".repeat(128)) + val updated = NostrSignerSync().sign(InteractiveStoryReadingStateEvent.update(old, EventHintBundle(next))) + assertEquals(listOf(story), updated.tags.filter { it[0] == "A" }.map { it[1] }) + assertEquals(next.address().toValue(), updated.currentScene()?.toValue()) + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/AddressMemberShapeTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/AddressMemberShapeTest.kt new file mode 100644 index 0000000000..25540b00b6 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/trustedLists/AddressMemberShapeTest.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.trustedLists + +import com.vitorpamplona.quartz.experimental.trustedLists.addressables.tags.AddressMemberTag +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull + +class AddressMemberShapeTest { + private val pk = "1".repeat(64) + + @Test + fun onlyCoordinatesAreMembers() { + val coordinate = "30023:$pk:post" + assertEquals(coordinate, AddressMemberTag.parseAddressId(arrayOf("a", coordinate))) + + // An naddr would decode, but its raw bech32 must not become the member key. + val naddr = NAddress.create(30023, pk, "post", null) + for (value in listOf(naddr, "abc:$pk:d", "not-an-address", "30023:short:d")) { + assertNull(AddressMemberTag.parse(arrayOf("a", value)), value) + assertNull(AddressMemberTag.parseAddressId(arrayOf("a", value)), value) + assertNull(AddressMemberTag.parseAddress(arrayOf("a", value)), value) + assertFalse(AddressMemberTag.isTag(arrayOf("a", value)), value) + } + assertEquals(Address(30023, pk, "post"), AddressMemberTag.parseAddress(arrayOf("a", coordinate))) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/core/AddressIsOfKindBoundsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/core/AddressIsOfKindBoundsTest.kt new file mode 100644 index 0000000000..560db53164 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/core/AddressIsOfKindBoundsTest.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.core + +import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.RequestTag +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class AddressIsOfKindBoundsTest { + @Test + fun aValueThatIsOnlyTheKindIsNotOfThatKind() { + assertFalse(Address.isOfKind("31872", "31872")) + assertFalse(AddressSerializer.isOfKind("31872", "31872")) + assertFalse(Address.isOfKind("318720:x:y", "31872")) + assertTrue(Address.isOfKind("31872:x:y", "31872")) + } + + @Test + fun aHostileRequestTagIsRejectedNotThrown() { + assertNull(RequestTag.parse(arrayOf("request", "31872"))) + assertFalse(RequestTag.isTagged(arrayOf("request", "31872"))) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/EventDTagAgreesWithAddressTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/EventDTagAgreesWithAddressTest.kt new file mode 100644 index 0000000000..30d17ee43a --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/EventDTagAgreesWithAddressTest.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip51Lists.followSet.FollowSetEvent +import com.vitorpamplona.quartz.nip51Lists.mediaFollowList.MediaFollowListEvent +import kotlin.test.Test +import kotlin.test.assertEquals + +class EventDTagAgreesWithAddressTest { + private val pk = "1".repeat(64) + + @Test + fun aListSeenAsAPlainEventHasItsAddressesDTag() { + val list: Event = MediaFollowListEvent("0".repeat(64), pk, 1, arrayOf(arrayOf("d", "stray")), "", "0".repeat(128)) + assertEquals("", list.dTag()) + + val set: Event = FollowSetEvent("0".repeat(64), pk, 1, arrayOf(arrayOf("d", "friends")), "", "0".repeat(128)) + assertEquals("friends", set.dTag()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip87Ecash/MintEventsAreAddressableTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip87Ecash/MintEventsAreAddressableTest.kt index 8339a50007..25f8c9ad13 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip87Ecash/MintEventsAreAddressableTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip87Ecash/MintEventsAreAddressableTest.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.quartz.nip87Ecash import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.nip87Ecash.cashu.CashuMintEvent import com.vitorpamplona.quartz.nip87Ecash.fedimint.FedimintEvent import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent @@ -43,4 +45,11 @@ class MintEventsAreAddressableTest { assertEquals("${event.kind}:$pk:mint-id", event.addressTag()) } } + + @Test + fun deletingARecommendationNamesItsAddressOnce() { + val rec = MintRecommendationEvent("0".repeat(64), pk, 1, tags, "", "0".repeat(128)) + val deletion = NostrSignerSync().sign(DeletionRequestEvent.build(listOf(rec))) + assertEquals(listOf("38000:$pk:mint-id"), deletion.tags.filter { it[0] == "a" }.map { it[1] }) + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipA0VoiceMessages/VoiceReplyRootScopeTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipA0VoiceMessages/VoiceReplyRootScopeTest.kt index bcdd161e8a..2583e344cd 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipA0VoiceMessages/VoiceReplyRootScopeTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipA0VoiceMessages/VoiceReplyRootScopeTest.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertTrue class VoiceReplyRootScopeTest { private val audio = AudioMeta("https://blossom.example/a.m4a", "audio/mp4", "f".repeat(64), 3, listOf(0.1f)) @@ -43,4 +44,44 @@ class VoiceReplyRootScopeTest { assertEquals(voice.id, nested.rootEventId()) assertEquals(reply.id, nested.replyingTo()) } + + @Test + fun aReplyToAnAddressRootedReplyKeepsTheAddressRoot() { + val article = "30023:${"2".repeat(64)}:post" + val parent = + VoiceReplyEvent( + "8".repeat(64), + "3".repeat(64), + 1, + arrayOf(arrayOf("A", article), arrayOf("K", "30023"), arrayOf("P", "2".repeat(64)), arrayOf("e", "7".repeat(64)), arrayOf("k", "1111")), + "", + "0".repeat(128), + ) + val reply = NostrSignerSync().sign(VoiceReplyEvent.build(audio, EventHintBundle(parent))) + assertEquals(listOf(article), reply.tags.filter { it[0] == "A" }.map { it[1] }) + assertEquals(listOf("30023"), reply.tags.filter { it[0] == "K" }.map { it[1] }) + assertTrue(reply.tags.none { it[0] == "E" }) + assertEquals(parent.id, reply.replyingTo()) + } + + @Test + fun aReplyToALegacyReplyFindsTheVoiceMessageRoot() { + // Published before voice replies wrote NIP-22 root tags: only the lowercase parent. + val voiceId = "9".repeat(64) + val voiceAuthor = "1".repeat(64) + val legacy = + VoiceReplyEvent( + "8".repeat(64), + "3".repeat(64), + 1, + arrayOf(arrayOf("e", voiceId, "", voiceAuthor), arrayOf("k", "1222"), arrayOf("p", voiceAuthor)), + "", + "0".repeat(128), + ) + val reply = NostrSignerSync().sign(VoiceReplyEvent.build(audio, EventHintBundle(legacy))) + assertEquals(voiceId, reply.rootEventId()) + assertEquals(voiceAuthor, reply.rootAuthorKey()) + assertEquals(listOf("1222"), reply.tags.filter { it[0] == "K" }.map { it[1] }) + assertEquals(legacy.id, reply.replyingTo()) + } } diff --git a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Address.jvm.kt b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Address.jvm.kt index e620fb99fc..75025e7d9e 100644 --- a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Address.jvm.kt +++ b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Address.jvm.kt @@ -56,6 +56,6 @@ actual data class Address actual constructor( actual fun isOfKind( addressId: String, kind: String, - ) = addressId.startsWith(kind) && addressId[kind.length] == ':' + ) = addressId.length > kind.length && addressId.startsWith(kind) && addressId[kind.length] == ':' } } diff --git a/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Address.native.kt b/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Address.native.kt index d8e20aec43..a9a8f77983 100644 --- a/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Address.native.kt +++ b/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Address.native.kt @@ -61,6 +61,6 @@ actual data class Address actual constructor( actual fun isOfKind( addressId: String, kind: String, - ) = addressId.startsWith(kind) && addressId[kind.length] == ':' + ) = addressId.length > kind.length && addressId.startsWith(kind) && addressId[kind.length] == ':' } } From 5959d9c46e414d400a5fea9eee23f1a255a9fcde Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 17:53:40 +0000 Subject: [PATCH 10/19] feat(quartz): model divine.video's push and video-view kinds as events Replaces the DivineKinds constants holder with real event classes, laid out like nip88Polls (event, TagArrayExt, TagArrayBuilderExt, tags/): - nipXXPushNotifications: PushRegistrationEvent (3079), PushDeregistrationEvent (3080) and PushPreferencesEvent (3083) from divine-push-service's draft. They share a PushServiceEvent base for the p + app envelope and NIP-44 content that either the author or the service can decrypt into PushToken / PushPreferences. - nip71Video/views: VideoViewEvent (22236), the ephemeral two-phase view report, with phase, viewed, loops and source tags and buildStart/buildEnd builders. Registered in EventFactory, KindNames and KindDisplayName. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014VMBKXQwG1xa3mMEW3MsMQ --- .../screen/loggedIn/relays/KindDisplayName.kt | 13 +- .../quartz/experimental/divine/DivineKinds.kt | 47 ------- .../vitorpamplona/quartz/kinds/KindNames.kt | 13 +- .../nip71Video/views/TagArrayBuilderExt.kt | 45 +++++++ .../quartz/nip71Video/views/TagArrayExt.kt | 41 ++++++ .../quartz/nip71Video/views/VideoViewEvent.kt | 125 ++++++++++++++++++ .../quartz/nip71Video/views/tags/LoopsTag.kt | 46 +++++++ .../quartz/nip71Video/views/tags/PhaseTag.kt | 56 ++++++++ .../quartz/nip71Video/views/tags/SourceTag.kt | 62 +++++++++ .../quartz/nip71Video/views/tags/ViewedTag.kt | 57 ++++++++ .../PushServiceEvent.kt | 80 +++++++++++ .../TagArrayBuilderExt.kt | 30 +++++ .../nipXXPushNotifications/TagArrayExt.kt | 29 ++++ .../deregistration/PushDeregistrationEvent.kt | 66 +++++++++ .../preferences/PushPreferences.kt | 39 ++++++ .../preferences/PushPreferencesEvent.kt | 64 +++++++++ .../registration/PushRegistrationEvent.kt | 70 ++++++++++ .../registration/PushToken.kt | 36 +++++ .../nipXXPushNotifications/tags/AppTag.kt | 46 +++++++ .../quartz/utils/EventFactory.kt | 8 ++ .../nip71Video/views/VideoViewEventTest.kt | 123 +++++++++++++++++ .../PushNotificationEventsTest.kt | 109 +++++++++++++++ 22 files changed, 1148 insertions(+), 57 deletions(-) delete mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/divine/DivineKinds.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/TagArrayBuilderExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/TagArrayExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/VideoViewEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/tags/LoopsTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/tags/PhaseTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/tags/SourceTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/tags/ViewedTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/PushServiceEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/TagArrayBuilderExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/TagArrayExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/deregistration/PushDeregistrationEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/preferences/PushPreferences.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/preferences/PushPreferencesEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/registration/PushRegistrationEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/registration/PushToken.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/tags/AppTag.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/views/VideoViewEventTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/PushNotificationEventsTest.kt diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/KindDisplayName.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/KindDisplayName.kt index eda8d0a1b7..4990ed244e 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/KindDisplayName.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/relays/KindDisplayName.kt @@ -187,7 +187,6 @@ import com.vitorpamplona.quartz.experimental.attestations.recommendation.Attesto import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent -import com.vitorpamplona.quartz.experimental.divine.DivineKinds import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent @@ -305,6 +304,7 @@ import com.vitorpamplona.quartz.nip71Video.AddressableShortVideoEvent import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent import com.vitorpamplona.quartz.nip71Video.VideoShortEvent import com.vitorpamplona.quartz.nip71Video.textTrack.TextTrackEvent +import com.vitorpamplona.quartz.nip71Video.views.VideoViewEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent @@ -338,6 +338,9 @@ import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent +import com.vitorpamplona.quartz.nipXXPushNotifications.deregistration.PushDeregistrationEvent +import com.vitorpamplona.quartz.nipXXPushNotifications.preferences.PushPreferencesEvent +import com.vitorpamplona.quartz.nipXXPushNotifications.registration.PushRegistrationEvent import org.jetbrains.compose.resources.StringResource /** Returns the catalog entry for the translated kind name, or null if unknown. */ @@ -490,10 +493,10 @@ fun kindDisplayName(kind: Int): StringResource? = VideoCurationSetEvent.KIND -> Res.string.kind_video_list VideoCollaborationEvent.KIND -> Res.string.kind_video_collaboration TextTrackEvent.KIND -> Res.string.kind_video_subtitles - DivineKinds.VIDEO_VIEW -> Res.string.kind_video_views - DivineKinds.PUSH_REGISTRATION -> Res.string.kind_push_registration - DivineKinds.PUSH_DEREGISTRATION -> Res.string.kind_push_deregistration - DivineKinds.PUSH_PREFERENCES -> Res.string.kind_push_preferences + VideoViewEvent.KIND -> Res.string.kind_video_views + PushRegistrationEvent.KIND -> Res.string.kind_push_registration + PushDeregistrationEvent.KIND -> Res.string.kind_push_deregistration + PushPreferencesEvent.KIND -> Res.string.kind_push_preferences AddressableNormalVideoEvent.KIND -> Res.string.kind_video_repl AddressableShortVideoEvent.KIND -> Res.string.kind_shorts_repl VideoNormalEvent.KIND -> Res.string.kind_video diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/divine/DivineKinds.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/divine/DivineKinds.kt deleted file mode 100644 index 8a3d995877..0000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/divine/DivineKinds.kt +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.experimental.divine - -/** - * Kinds divine.video () publishes for its own infrastructure. - * - * None of them carry content a client renders, so Quartz has no event classes for them: they are - * registered only so relay statistics and `amy kind` can put a name on them instead of a number. - */ -object DivineKinds { - /** - * Registers a device's push token with divine-push-service. The content is NIP-44 encrypted to - * the service's `p`-tagged key. Spec: divine-push-service `docs/nip-xx-push-notifications.md`. - */ - const val PUSH_REGISTRATION = 3079 - - /** Removes a token registered with [PUSH_REGISTRATION]. Same draft, same encrypted shape. */ - const val PUSH_DEREGISTRATION = 3080 - - /** Which notification categories the push service should send, NIP-44 encrypted to it. */ - const val PUSH_PREFERENCES = 3083 - - /** - * Ephemeral "watched this video" analytics, `a`/`e`-tagging a kind-34236 video with `phase`, - * `viewed`, `loops` and `source` tags. Divine's relay turns these into view and loop counts. - */ - const val VIDEO_VIEW = 22236 -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt index 37516c0615..b92be92f12 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt @@ -40,7 +40,6 @@ import com.vitorpamplona.quartz.experimental.decentralizedLists.header.ListHeade import com.vitorpamplona.quartz.experimental.decentralizedLists.item.AddressableListItemEvent import com.vitorpamplona.quartz.experimental.decentralizedLists.item.ListItemEvent import com.vitorpamplona.quartz.experimental.decoupling.setup.EncryptionKeyListEvent -import com.vitorpamplona.quartz.experimental.divine.DivineKinds import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent @@ -246,6 +245,7 @@ import com.vitorpamplona.quartz.nip71Video.AddressableShortVideoEvent import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent import com.vitorpamplona.quartz.nip71Video.VideoShortEvent import com.vitorpamplona.quartz.nip71Video.textTrack.TextTrackEvent +import com.vitorpamplona.quartz.nip71Video.views.VideoViewEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent @@ -338,6 +338,9 @@ import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEven import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent +import com.vitorpamplona.quartz.nipXXPushNotifications.deregistration.PushDeregistrationEvent +import com.vitorpamplona.quartz.nipXXPushNotifications.preferences.PushPreferencesEvent +import com.vitorpamplona.quartz.nipXXPushNotifications.registration.PushRegistrationEvent /** * Human-readable label and defining NIP for a Nostr event kind. @@ -511,10 +514,10 @@ object KindNames { VideoShortEvent.KIND to KindName("Shorts", "71"), VideoCollaborationEvent.KIND to KindName("Video Collaboration", null), TextTrackEvent.KIND to KindName("Video Subtitles", null), - DivineKinds.VIDEO_VIEW to KindName("Video Views", null), - DivineKinds.PUSH_REGISTRATION to KindName("Push Registration", null), - DivineKinds.PUSH_DEREGISTRATION to KindName("Push Deregistration", null), - DivineKinds.PUSH_PREFERENCES to KindName("Push Preferences", null), + VideoViewEvent.KIND to KindName("Video Views", null), + PushRegistrationEvent.KIND to KindName("Push Registration", null), + PushDeregistrationEvent.KIND to KindName("Push Deregistration", null), + PushPreferencesEvent.KIND to KindName("Push Preferences", null), VoiceEvent.KIND to KindName("Voice Msg", "A0"), VoiceReplyEvent.KIND to KindName("Voice Reply", "A0"), WakeUpEvent.KIND to KindName("WakeUp", null), diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..41e5fc7e77 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/TagArrayBuilderExt.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip71Video.views + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.tags.aTag.toATag +import com.vitorpamplona.quartz.nip01Core.tags.events.toETagArray +import com.vitorpamplona.quartz.nip71Video.AddressableVideoEvent +import com.vitorpamplona.quartz.nip71Video.views.tags.LoopsTag +import com.vitorpamplona.quartz.nip71Video.views.tags.PhaseTag +import com.vitorpamplona.quartz.nip71Video.views.tags.SourceTag +import com.vitorpamplona.quartz.nip71Video.views.tags.ViewPhase +import com.vitorpamplona.quartz.nip71Video.views.tags.ViewSource +import com.vitorpamplona.quartz.nip71Video.views.tags.ViewedRange +import com.vitorpamplona.quartz.nip71Video.views.tags.ViewedTag + +/** Both pointers: the address for the video, the id for the exact version that was watched. */ +fun TagArrayBuilder.video(video: EventHintBundle) = addUnique(video.toATag().toATagArray()).addUnique(video.toETagArray()) + +fun TagArrayBuilder.phase(phase: ViewPhase) = addUnique(PhaseTag.assemble(phase)) + +fun TagArrayBuilder.viewed(range: ViewedRange) = addUnique(ViewedTag.assemble(range)) + +fun TagArrayBuilder.loops(loops: Double) = addUnique(LoopsTag.assemble(loops)) + +fun TagArrayBuilder.source(source: ViewSource) = addUnique(SourceTag.assemble(source)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/TagArrayExt.kt new file mode 100644 index 0000000000..7186f9bcdd --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/TagArrayExt.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip71Video.views + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip71Video.views.tags.LoopsTag +import com.vitorpamplona.quartz.nip71Video.views.tags.PhaseTag +import com.vitorpamplona.quartz.nip71Video.views.tags.SourceTag +import com.vitorpamplona.quartz.nip71Video.views.tags.ViewedTag + +fun TagArray.video() = firstNotNullOfOrNull(ATag::parseAddress) + +fun TagArray.videoVersion() = firstNotNullOfOrNull(ETag::parseId) + +fun TagArray.phase() = firstNotNullOfOrNull(PhaseTag::parse) + +fun TagArray.viewed() = firstNotNullOfOrNull(ViewedTag::parse) + +fun TagArray.loops() = firstNotNullOfOrNull(LoopsTag::parse) + +fun TagArray.source() = firstNotNullOfOrNull(SourceTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/VideoViewEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/VideoViewEvent.kt new file mode 100644 index 0000000000..6c649346ba --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/VideoViewEvent.kt @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip71Video.views + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip71Video.AddressableVideoEvent +import com.vitorpamplona.quartz.nip71Video.views.tags.ViewPhase +import com.vitorpamplona.quartz.nip71Video.views.tags.ViewSource +import com.vitorpamplona.quartz.nip71Video.views.tags.ViewedRange +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * "Someone watched this video": an ephemeral analytics event divine.video publishes for its + * NIP-71 videos (kind 22236, not part of NIP-71). Relays pass it on without storing it, so only a + * service listening live, such as Divine's relay, turns it into view and loop counts. + * + * A viewing session is reported in two phases: one [ViewPhase.START] when playback begins, which + * counts the view, then one [ViewPhase.END] per interruption carrying the watch time since the + * previous `end`. An event with no `phase` is the older single-shot report. The content is empty. + * + * Schema: divine-mobile `mobile/docs/NOSTR_VIDEO_EVENTS.md`. + */ +@Immutable +class VideoViewEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + AddressHintProvider, + EventHintProvider { + override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) + + override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId) + + override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + + override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + + /** The video that was watched. */ + fun video() = tags.video() + + /** The id of the exact version that was watched. */ + fun videoVersion() = tags.videoVersion() + + fun phase() = tags.phase() + + fun viewed() = tags.viewed() + + fun loops() = tags.loops() + + fun source() = tags.source() + + companion object { + const val KIND = 22236 + + /** Playback started. Carries no watch time: nothing has been watched yet. */ + fun buildStart( + video: EventHintBundle, + source: ViewSource? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = build(video, ViewPhase.START, null, null, source, createdAt, initializer) + + /** A segment ended after [watchedSeconds] of playback, [loops] of them complete or partial. */ + fun buildEnd( + video: EventHintBundle, + watchedSeconds: Long, + loops: Double? = null, + source: ViewSource? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = build(video, ViewPhase.END, ViewedRange(0, watchedSeconds), loops, source, createdAt, initializer) + + /** + * Prefer [buildStart] / [buildEnd]: they keep watch time off `start` events, where it + * would count engagement the viewer never gave. + */ + fun build( + video: EventHintBundle, + phase: ViewPhase?, + viewed: ViewedRange?, + loops: Double?, + source: ViewSource?, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + video(video) + phase?.let { phase(it) } + viewed?.let { viewed(it) } + loops?.let { loops(it) } + source?.let { source(it) } + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/tags/LoopsTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/tags/LoopsTag.kt new file mode 100644 index 0000000000..0f796b9cf7 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/tags/LoopsTag.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip71Video.views.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * How many times the video played through, partial passes included (`0.75`). Informational: + * Divine's relay derives loops from the `viewed` seconds and does not read this tag. + */ +class LoopsTag { + companion object { + const val TAG_NAME = "loops" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): Double? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + val loops = tag[1].toDoubleOrNull() ?: return null + ensure(loops.isFinite() && loops >= 0.0) { return null } + return loops + } + + fun assemble(loops: Double) = arrayOf(TAG_NAME, loops.toString()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/tags/PhaseTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/tags/PhaseTag.kt new file mode 100644 index 0000000000..2d78efbaaf --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/tags/PhaseTag.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip71Video.views.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +enum class ViewPhase( + val code: String, +) { + /** Playback started. This is what counts the view, so an app killed mid-session still counts it. */ + START("start"), + + /** A segment of the session ended. Carries the watch time and loops since the previous `end`. */ + END("end"), +} + +class PhaseTag { + companion object { + const val TAG_NAME = "phase" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): ViewPhase? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + + return when (tag[1]) { + ViewPhase.START.code -> ViewPhase.START + ViewPhase.END.code -> ViewPhase.END + else -> null + } + } + + fun assemble(phase: ViewPhase) = arrayOf(TAG_NAME, phase.code) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/tags/SourceTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/tags/SourceTag.kt new file mode 100644 index 0000000000..c6d812da37 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/tags/SourceTag.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip71Video.views.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.ensure + +/** + * Where the viewer found the video. [type] is one of the documented values below, though + * divine-mobile also writes feed-specific ones such as `discovery:foryou`. [detail] narrows it + * further (a list, a feed mode) when the publisher knows more. + */ +@Immutable +data class ViewSource( + val type: String, + val detail: String? = null, +) { + companion object { + const val HOME = "home" + const val DISCOVERY = "discovery" + const val PROFILE = "profile" + const val SHARE = "share" + const val SEARCH = "search" + } +} + +class SourceTag { + companion object { + const val TAG_NAME = "source" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): ViewSource? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return ViewSource(tag[1], tag.getOrNull(2)?.ifEmpty { null }) + } + + fun assemble(source: ViewSource) = arrayOfNotNull(TAG_NAME, source.type, source.detail) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/tags/ViewedTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/tags/ViewedTag.kt new file mode 100644 index 0000000000..6a6b9d21e6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/tags/ViewedTag.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip71Video.views.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * Elapsed playback, in whole seconds, not positions in the video: a 6-second video looped twice + * is `0..12`. divine-mobile always writes a start of 0. + */ +@Immutable +data class ViewedRange( + val start: Long, + val end: Long, +) { + val seconds get() = end - start +} + +class ViewedTag { + companion object { + const val TAG_NAME = "viewed" + + fun isTag(tag: Array) = tag.has(2) && tag[0] == TAG_NAME && tag[1].isNotEmpty() && tag[2].isNotEmpty() + + fun parse(tag: Array): ViewedRange? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + val start = tag[1].toLongOrNull() ?: return null + val end = tag[2].toLongOrNull() ?: return null + // An inverted range would read as negative watch time; the publisher drops those too. + ensure(start in 0..end) { return null } + return ViewedRange(start, end) + } + + fun assemble(range: ViewedRange) = arrayOf(TAG_NAME, range.start.toString(), range.end.toString()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/PushServiceEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/PushServiceEvent.kt new file mode 100644 index 0000000000..f4d2664d79 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/PushServiceEvent.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipXXPushNotifications + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import kotlinx.serialization.json.Json + +/** + * A control message from a user to a push-notification service, from the draft "NIP-XX Push + * Notifications" that divine.video runs (divine-push-service, `docs/nip-xx-push-notifications.md`). + * + * Every kind in the draft has the same envelope: a `p` tag naming the service, an `app` tag naming + * the application, and content that is NIP-44 ciphertext to the service's key. The service rejects + * plaintext, and ignores events addressed to another service or older than its seven-day replay + * window. Only the author and the service can read the payload; anyone else sees who talks to + * which service, not what they said. + */ +@Immutable +abstract class PushServiceEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, kind, tags, content, sig) { + override fun isContentEncoded() = true + + fun pushService() = tags.pushService() + + fun app() = tags.app() + + fun canDecrypt(signer: NostrSigner): Boolean { + val service = pushService() ?: return false + return signer.pubKey == pubKey || signer.pubKey == service + } + + /** The author reads its own event back through the service's key, the service through the author's. */ + protected suspend fun decryptContent(signer: NostrSigner): String { + if (!canDecrypt(signer)) throw SignerExceptions.UnauthorizedDecryptionException() + val counterparty = if (signer.pubKey == pubKey) pushService()!! else pubKey + return signer.nip44Decrypt(content, counterparty) + } + + companion object { + /** + * The payloads are small JSON objects whose fields the service defines, so unknown keys + * are expected. Absent optionals are left out rather than written as `null`. + */ + internal val json = + Json { + ignoreUnknownKeys = true + explicitNulls = false + encodeDefaults = true + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..c4e8a1e775 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/TagArrayBuilderExt.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipXXPushNotifications + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nipXXPushNotifications.tags.AppTag + +fun TagArrayBuilder.pushService(pubKey: HexKey) = addUnique(PTag.assemble(pubKey, null)) + +fun TagArrayBuilder.app(app: String) = addUnique(AppTag.assemble(app)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/TagArrayExt.kt new file mode 100644 index 0000000000..a3a997fcb0 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/TagArrayExt.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipXXPushNotifications + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nipXXPushNotifications.tags.AppTag + +fun TagArray.pushService() = firstNotNullOfOrNull(PTag::parseKey) + +fun TagArray.app() = firstNotNullOfOrNull(AppTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/deregistration/PushDeregistrationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/deregistration/PushDeregistrationEvent.kt new file mode 100644 index 0000000000..2c65a5dcf3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/deregistration/PushDeregistrationEvent.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipXXPushNotifications.deregistration + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nipXXPushNotifications.PushServiceEvent +import com.vitorpamplona.quartz.nipXXPushNotifications.app +import com.vitorpamplona.quartz.nipXXPushNotifications.pushService +import com.vitorpamplona.quartz.nipXXPushNotifications.registration.PushToken +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Removes a token registered with a [com.vitorpamplona.quartz.nipXXPushNotifications.registration.PushRegistrationEvent] + * (kind 3080). Clients publish it on logout. Preferences are keyed by the user, not the device, so + * they survive it. + */ +@Immutable +class PushDeregistrationEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : PushServiceEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + suspend fun decrypt(signer: NostrSigner): PushToken = json.decodeFromString(PushToken.serializer(), decryptContent(signer)) + + companion object { + const val KIND = 3080 + + suspend fun build( + token: String, + pushService: HexKey, + app: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, signer.nip44Encrypt(json.encodeToString(PushToken.serializer(), PushToken(token)), pushService), createdAt) { + pushService(pushService) + app(app) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/preferences/PushPreferences.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/preferences/PushPreferences.kt new file mode 100644 index 0000000000..a366a88e18 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/preferences/PushPreferences.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipXXPushNotifications.preferences + +import kotlinx.serialization.Serializable + +/** + * The decrypted payload of a [PushPreferencesEvent]. + * + * [kinds] lists notification *categories*, which the service defines. They are numbered after event + * kinds but need not match what triggers them: divine-push-service reads `1` as "comments and + * mentions" (sent for kinds 1111, 30023 and 34236), `3` follows, `7` likes, `16` reposts, and + * `34236` new posts from authors the user subscribed to. An empty list turns everything off. + * + * [campaignsEnabled] is a separate opt-in for engagement campaigns; no category implies it. + */ +@Serializable +data class PushPreferences( + val kinds: List, + val campaignsEnabled: Boolean = false, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/preferences/PushPreferencesEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/preferences/PushPreferencesEvent.kt new file mode 100644 index 0000000000..894abcf90b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/preferences/PushPreferencesEvent.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipXXPushNotifications.preferences + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nipXXPushNotifications.PushServiceEvent +import com.vitorpamplona.quartz.nipXXPushNotifications.app +import com.vitorpamplona.quartz.nipXXPushNotifications.pushService +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Which notifications a user wants from a push service (kind 3083). Optional: a service that never + * receives one sends everything it supports. + */ +@Immutable +class PushPreferencesEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : PushServiceEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + suspend fun decrypt(signer: NostrSigner): PushPreferences = json.decodeFromString(PushPreferences.serializer(), decryptContent(signer)) + + companion object { + const val KIND = 3083 + + suspend fun build( + preferences: PushPreferences, + pushService: HexKey, + app: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, signer.nip44Encrypt(json.encodeToString(PushPreferences.serializer(), preferences), pushService), createdAt) { + pushService(pushService) + app(app) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/registration/PushRegistrationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/registration/PushRegistrationEvent.kt new file mode 100644 index 0000000000..71ca02d366 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/registration/PushRegistrationEvent.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipXXPushNotifications.registration + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.nipXXPushNotifications.PushServiceEvent +import com.vitorpamplona.quartz.nipXXPushNotifications.app +import com.vitorpamplona.quartz.nipXXPushNotifications.pushService +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Registers a device's push token with a push service (kind 3079). + * + * Clients publish it when the push session becomes ready and again whenever the platform rotates + * the token. An `expiration` tag is allowed for relay housekeeping, but the reference service does + * not use it to decide how long the token stays valid. + */ +@Immutable +class PushRegistrationEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : PushServiceEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + suspend fun decrypt(signer: NostrSigner): PushToken = json.decodeFromString(PushToken.serializer(), decryptContent(signer)) + + companion object { + const val KIND = 3079 + + suspend fun build( + token: PushToken, + pushService: HexKey, + app: String, + signer: NostrSigner, + expiresAt: Long? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, signer.nip44Encrypt(json.encodeToString(PushToken.serializer(), token), pushService), createdAt) { + pushService(pushService) + app(app) + expiresAt?.let { expiration(it) } + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/registration/PushToken.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/registration/PushToken.kt new file mode 100644 index 0000000000..216e3f15f3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/registration/PushToken.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipXXPushNotifications.registration + +import kotlinx.serialization.Serializable + +/** + * The decrypted payload of a push registration or deregistration. + * + * [token] is the platform's device token (FCM, APNs, …). [timezoneOffsetMinutes] is the device's + * offset from UTC; the reference service needs it only to schedule campaign pushes, and a + * deregistration leaves it out. + */ +@Serializable +data class PushToken( + val token: String, + val timezoneOffsetMinutes: Int? = null, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/tags/AppTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/tags/AppTag.kt new file mode 100644 index 0000000000..fcf0fda2af --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/tags/AppTag.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipXXPushNotifications.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * `["app", ""]`: which application a push control event is for. The service partitions + * tokens and preferences by it, so two apps signed in with the same key never see each other's + * devices. + */ +class AppTag { + companion object { + const val TAG_NAME = "app" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(app: String) = arrayOf(TAG_NAME, app) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index e122de8707..e0c165d220 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -344,6 +344,7 @@ import com.vitorpamplona.quartz.nip71Video.AddressableShortVideoEvent import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent import com.vitorpamplona.quartz.nip71Video.VideoShortEvent import com.vitorpamplona.quartz.nip71Video.textTrack.TextTrackEvent +import com.vitorpamplona.quartz.nip71Video.views.VideoViewEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent @@ -438,6 +439,9 @@ import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEven import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent +import com.vitorpamplona.quartz.nipXXPushNotifications.deregistration.PushDeregistrationEvent +import com.vitorpamplona.quartz.nipXXPushNotifications.preferences.PushPreferencesEvent +import com.vitorpamplona.quartz.nipXXPushNotifications.registration.PushRegistrationEvent interface EventBuilder { fun build( @@ -894,6 +898,10 @@ class EventFactory { AddressableNormalVideoEvent.KIND -> AddressableNormalVideoEvent(id, pubKey, createdAt, tags, content, sig) AddressableShortVideoEvent.KIND -> AddressableShortVideoEvent(id, pubKey, createdAt, tags, content, sig) TextTrackEvent.KIND -> TextTrackEvent(id, pubKey, createdAt, tags, content, sig) + VideoViewEvent.KIND -> VideoViewEvent(id, pubKey, createdAt, tags, content, sig) + PushRegistrationEvent.KIND -> PushRegistrationEvent(id, pubKey, createdAt, tags, content, sig) + PushDeregistrationEvent.KIND -> PushDeregistrationEvent(id, pubKey, createdAt, tags, content, sig) + PushPreferencesEvent.KIND -> PushPreferencesEvent(id, pubKey, createdAt, tags, content, sig) VideoCollaborationEvent.KIND -> VideoCollaborationEvent(id, pubKey, createdAt, tags, content, sig) VideoNormalEvent.KIND -> VideoNormalEvent(id, pubKey, createdAt, tags, content, sig) VideoShortEvent.KIND -> VideoShortEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/views/VideoViewEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/views/VideoViewEventTest.kt new file mode 100644 index 0000000000..e262099dbc --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/views/VideoViewEventTest.kt @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip71Video.views + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip71Video.AddressableShortVideoEvent +import com.vitorpamplona.quartz.nip71Video.views.tags.LoopsTag +import com.vitorpamplona.quartz.nip71Video.views.tags.PhaseTag +import com.vitorpamplona.quartz.nip71Video.views.tags.ViewPhase +import com.vitorpamplona.quartz.nip71Video.views.tags.ViewSource +import com.vitorpamplona.quartz.nip71Video.views.tags.ViewedRange +import com.vitorpamplona.quartz.nip71Video.views.tags.ViewedTag +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull + +/** + * Kind 22236 against the shape divine-mobile publishes (`mobile/lib/services/view_event_publisher.dart` + * and the example in `mobile/docs/NOSTR_VIDEO_EVENTS.md`). + */ +class VideoViewEventTest { + private val videoAuthor = "4d7dccc0a5116daa057348ef79c573873cddd9eff066fc6a5f3d37e8264afbeb" + private val videoD = "c855df3d07ba963e9097d5a151b0c14a0a3d494e4ff9b04a389bc0b5f5c16862" + private val videoId = "fa5a793b24edfe109f8d02ad6aa6cde77b0a923566f891df403049721b46e8d9" + + private val video = + """{"id":"$videoId","pubkey":"$videoAuthor","created_at":1789661586,"kind":34236,"tags":[["d","$videoD"],["imeta","url https://media.divine.video/$videoD","m video/mp4"],["title","Xmas Already??"]],"content":"","sig":""}""" + + // An `end` segment exactly as divine-mobile writes it, client tag included. + private val endView = + """{"id":"2b1b0b53d6c0c2f1f5a8a2d5f0e7e1f8c9a4b3d2e1f0a9b8c7d6e5f4a3b2c1d0","pubkey":"34257350449d357c37e93eb8aef387ff1fee8879d794da664462346a4b540aa8","created_at":1789666900,"kind":22236,"tags":[["a","34236:$videoAuthor:$videoD","wss://relay.divine.video"],["e","$videoId","wss://relay.divine.video"],["phase","end"],["viewed","0","12"],["source","discovery","foryou"],["loops","2.0"],["version","1.0.23"],["client","Divine","31990:d95aa8fc0eff8e488952495b8064991d27fb96ed8652f12cdedc5a4e8b5ae540:divine-mobile","wss://relay.divine.video"]],"content":"","sig":""}""" + + // The pre-phase single-shot shape: no `phase`, and `viewed` carries the whole session. + private val legacyView = + """{"id":"3c2c1c64e7d1d3f2f6b9b3e6f1f8f2f9dab5c4e3f2f1bac9d8e7f6f5b4c3d2e1","pubkey":"34257350449d357c37e93eb8aef387ff1fee8879d794da664462346a4b540aa8","created_at":1789666900,"kind":22236,"tags":[["a","34236:$videoAuthor:$videoD","wss://relay.divine.video"],["e","$videoId","wss://relay.divine.video"],["viewed","0","5"],["loops","0.75"],["source","discovery"]],"content":"","sig":""}""" + + private val relay = RelayUrlNormalizer.normalizeOrNull("wss://relay.divine.video")!! + + @Test + fun parsesAnEndSegment() { + val event = assertIs(Event.fromJson(endView)) + + assertEquals("34236:$videoAuthor:$videoD", event.video()?.toValue()) + assertEquals(videoId, event.videoVersion()) + assertEquals(ViewPhase.END, event.phase()) + assertEquals(ViewedRange(0, 12), event.viewed()) + assertEquals(12, event.viewed()?.seconds) + assertEquals(2.0, event.loops()) + assertEquals(ViewSource(ViewSource.DISCOVERY, "foryou"), event.source()) + assertEquals(listOf("34236:$videoAuthor:$videoD"), event.linkedAddressIds()) + assertEquals(listOf(videoId), event.linkedEventIds()) + } + + @Test + fun parsesALegacySingleShot() { + val event = assertIs(Event.fromJson(legacyView)) + + assertNull(event.phase()) + assertEquals(ViewedRange(0, 5), event.viewed()) + assertEquals(0.75, event.loops()) + assertEquals(ViewSource(ViewSource.DISCOVERY), event.source()) + } + + @Test + fun rejectsMalformedValues() { + assertNull(PhaseTag.parse(arrayOf("phase", "middle"))) + assertNull(ViewedTag.parse(arrayOf("viewed", "10", "5"))) + assertNull(ViewedTag.parse(arrayOf("viewed", "0"))) + assertNull(LoopsTag.parse(arrayOf("loops", "-1"))) + assertNull(LoopsTag.parse(arrayOf("loops", "NaN"))) + } + + @Test + fun startCarriesNoWatchTime() { + val bundle = EventHintBundle(Event.fromJson(video) as AddressableShortVideoEvent, relay) + val template = VideoViewEvent.buildStart(bundle, ViewSource(ViewSource.HOME)) + + assertEquals(VideoViewEvent.KIND, template.kind) + assertEquals("", template.content) + assertContentEquals( + arrayOf( + arrayOf("a", "34236:$videoAuthor:$videoD", "wss://relay.divine.video/"), + arrayOf("e", videoId, "wss://relay.divine.video/", videoAuthor), + arrayOf("phase", "start"), + arrayOf("source", "home"), + ), + template.tags, + ) + } + + @Test + fun endCarriesTheSegment() { + val bundle = EventHintBundle(Event.fromJson(video) as AddressableShortVideoEvent, relay) + val template = VideoViewEvent.buildEnd(bundle, watchedSeconds = 12, loops = 2.0, source = ViewSource(ViewSource.PROFILE)) + + assertContentEquals(arrayOf("phase", "end"), template.tags[2]) + assertContentEquals(arrayOf("viewed", "0", "12"), template.tags[3]) + assertContentEquals(arrayOf("loops", "2.0"), template.tags[4]) + assertContentEquals(arrayOf("source", "profile"), template.tags[5]) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/PushNotificationEventsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/PushNotificationEventsTest.kt new file mode 100644 index 0000000000..9ca5c398b5 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/PushNotificationEventsTest.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipXXPushNotifications + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.nipXXPushNotifications.deregistration.PushDeregistrationEvent +import com.vitorpamplona.quartz.nipXXPushNotifications.preferences.PushPreferences +import com.vitorpamplona.quartz.nipXXPushNotifications.preferences.PushPreferencesEvent +import com.vitorpamplona.quartz.nipXXPushNotifications.registration.PushRegistrationEvent +import com.vitorpamplona.quartz.nipXXPushNotifications.registration.PushToken +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * The three control kinds of divine-push-service's draft (`docs/nip-xx-push-notifications.md`). + * + * The payloads are checked as the service would decrypt them, not only through our own decoder, + * so a field renamed on this side cannot pass by agreeing with itself. + */ +class PushNotificationEventsTest { + private val user = NostrSignerInternal(KeyPair()) + private val service = NostrSignerInternal(KeyPair()) + private val stranger = NostrSignerInternal(KeyPair()) + private val app = "co.openvine.app" + + private suspend fun plaintextAtService(event: Event) = service.nip44Decrypt(event.content, event.pubKey) + + @Test + fun registration() = + runTest { + val template = PushRegistrationEvent.build(PushToken("fcm-token", -300), service.pubKey, app, user, expiresAt = 1_800_000_000) + val event = assertIs(Event.fromJson(user.sign(template).toJson())) + + assertEquals(service.pubKey, event.pushService()) + assertEquals(app, event.app()) + assertEquals(1_800_000_000, event.expiration()) + assertTrue(event.isContentEncoded()) + assertEquals("""{"token":"fcm-token","timezoneOffsetMinutes":-300}""", plaintextAtService(event)) + + assertEquals(PushToken("fcm-token", -300), event.decrypt(service)) + assertEquals(PushToken("fcm-token", -300), event.decrypt(user)) + } + + @Test + fun deregistrationLeavesTheOffsetOut() = + runTest { + val event = user.sign(PushDeregistrationEvent.build("fcm-token", service.pubKey, app, user)) + + assertEquals(PushDeregistrationEvent.KIND, event.kind) + assertEquals("""{"token":"fcm-token"}""", plaintextAtService(event)) + assertEquals(PushToken("fcm-token"), event.decrypt(service)) + } + + @Test + fun preferences() = + runTest { + val prefs = PushPreferences(listOf(1, 3, 7, 16, 34236), campaignsEnabled = false) + val event = user.sign(PushPreferencesEvent.build(prefs, service.pubKey, app, user)) + + assertEquals("""{"kinds":[1,3,7,16,34236],"campaignsEnabled":false}""", plaintextAtService(event)) + assertEquals(prefs, event.decrypt(service)) + } + + @Test + fun readsWhatTheServiceWrites() = + runTest { + // A payload from a newer client: an unknown field, and no campaign flag. + val content = user.nip44Encrypt("""{"kinds":[],"quietHours":"22-07"}""", service.pubKey) + val event = user.sign(1_789_000_000, PushPreferencesEvent.KIND, arrayOf(arrayOf("p", service.pubKey), arrayOf("app", app)), content) + + assertEquals(PushPreferences(emptyList(), campaignsEnabled = false), event.decrypt(service)) + } + + @Test + fun onlyTheTwoEndsCanDecrypt() = + runTest { + val event = user.sign(PushRegistrationEvent.build(PushToken("fcm-token"), service.pubKey, app, user)) + + assertFalse(event.canDecrypt(stranger)) + assertFailsWith { event.decrypt(stranger) } + } +} From 4d5cf88f053dd31dd3a7c2ffab2df1cf6e35b2f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 18:03:46 +0000 Subject: [PATCH 11/19] test(quartz): compare view-event tag arrays by content Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014VMBKXQwG1xa3mMEW3MsMQ --- .../quartz/nip71Video/views/VideoViewEventTest.kt | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/views/VideoViewEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/views/VideoViewEventTest.kt index e262099dbc..b35c2a0fe7 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/views/VideoViewEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/views/VideoViewEventTest.kt @@ -99,14 +99,14 @@ class VideoViewEventTest { assertEquals(VideoViewEvent.KIND, template.kind) assertEquals("", template.content) - assertContentEquals( - arrayOf( - arrayOf("a", "34236:$videoAuthor:$videoD", "wss://relay.divine.video/"), - arrayOf("e", videoId, "wss://relay.divine.video/", videoAuthor), - arrayOf("phase", "start"), - arrayOf("source", "home"), + assertEquals( + listOf( + listOf("a", "34236:$videoAuthor:$videoD", "wss://relay.divine.video/"), + listOf("e", videoId, "wss://relay.divine.video/", videoAuthor), + listOf("phase", "start"), + listOf("source", "home"), ), - template.tags, + template.tags.map { it.toList() }, ) } From 2d5f8cc1c170fc4f75c16b468aa65c56ed3572fe Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 18:07:56 +0000 Subject: [PATCH 12/19] fix(quartz): tighten request coordinates, hint attestation requests, drop a no-op - RequestTag.parseAddressId / parseAsHint checked only the `31872:` prefix, so `31872:junk` went on as an address id into hints and gatherers. They now require the whole `kind:pubkey:d` shape (CoordinateShape, no allocation). - AttestationEvent's linkedAddressIds / addressHints read only `a` tags; the kind 31872 request it answers (a `request` tag) now reaches them too. - LiveActivitiesChatMessageEvent subtracted the activity's ADDRESS from lists of `e`-tag event ids, which never matched: both overrides and activityHex() are removed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pu8Fpp4KbXaiax8YTYxhJm --- .../attestation/AttestationEvent.kt | 6 +- .../attestation/tags/RequestTag.kt | 11 +- .../experimental/nipsOnNostr/tags/ForkTag.kt | 144 ------------------ .../chat/LiveActivitiesChatMessageEvent.kt | 6 - .../attestations/RequestTagTest.kt | 34 +++++ 5 files changed, 46 insertions(+), 155 deletions(-) delete mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipsOnNostr/tags/ForkTag.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/AttestationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/AttestationEvent.kt index 50aaf0ef06..fe9ad1148e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/AttestationEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/AttestationEvent.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.experimental.attestations.attestation import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.AttestationStatus +import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.RequestTag import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent @@ -65,9 +66,10 @@ class AttestationEvent( override fun linkedEventIds(): List = tags.mapNotNull(ETag::parseId) - override fun addressHints(): List = tags.mapNotNull(ATag::parseAsHint) + // The attested assertion is an `a`; the request it answers (kind 31872) is a `request` tag. + override fun addressHints(): List = tags.mapNotNull(ATag::parseAsHint) + tags.mapNotNull(RequestTag::parseAsHint) - override fun linkedAddressIds(): List = tags.mapNotNull(ATag::parseAddressId) + override fun linkedAddressIds(): List = tags.mapNotNull(ATag::parseAddressId) + tags.mapNotNull(RequestTag::parseAddressId) fun status() = tags.status() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/RequestTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/RequestTag.kt index 2b67864056..839bb4e404 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/RequestTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/attestations/attestation/tags/RequestTag.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.experimental.attestations.attestation.tags import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent +import com.vitorpamplona.quartz.experimental.decentralizedLists.CoordinateShape import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.has @@ -44,6 +45,11 @@ class RequestTag( const val TAG_NAME = "request" private val REQUEST_KIND_STR = AttestationRequestEvent.KIND.toString() + // The raw-string readers hand the value on as an address id (hints, gatherers), so they + // need the whole `31872:<64-hex pubkey>:` shape, not just the kind prefix: checked + // without allocating, as the parsers that build an Address get it from Address.parse. + private fun isRequestCoordinate(value: String) = Address.isOfKind(value, REQUEST_KIND_STR) && CoordinateShape.matches(value) + // The request an attestation answers is always a kind 31872 attestation request. fun isTagged(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && Address.isOfKind(tag[1], REQUEST_KIND_STR) @@ -91,15 +97,14 @@ class RequestTag( fun parseAddressId(tag: Array): String? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } - ensure(Address.isOfKind(tag[1], REQUEST_KIND_STR)) { return null } + ensure(isRequestCoordinate(tag[1])) { return null } return tag[1] } fun parseAsHint(tag: Array): AddressHint? { ensure(tag.has(2)) { return null } ensure(tag[0] == TAG_NAME) { return null } - ensure(Address.isOfKind(tag[1], REQUEST_KIND_STR)) { return null } - ensure(tag[1].contains(':')) { return null } + ensure(isRequestCoordinate(tag[1])) { return null } ensure(tag[2].isNotEmpty()) { return null } val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2]) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipsOnNostr/tags/ForkTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipsOnNostr/tags/ForkTag.kt deleted file mode 100644 index 7b5eaffd3c..0000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipsOnNostr/tags/ForkTag.kt +++ /dev/null @@ -1,144 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.experimental.nipsOnNostr.tags - -import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent -import com.vitorpamplona.quartz.nip01Core.core.Address -import com.vitorpamplona.quartz.nip01Core.core.has -import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer -import com.vitorpamplona.quartz.utils.arrayOfNotNull -import com.vitorpamplona.quartz.utils.ensure - -class ForkTag( - val address: Address, - val relayHint: NormalizedRelayUrl? = null, -) { - fun toTag() = Address.assemble(address.kind, address.pubKeyHex, address.dTag) - - fun toTagArray() = assemble(address, relayHint) - - fun toTagIdOnly() = assemble(address, null) - - companion object { - const val TAG_NAME = "a" - - fun isTagged(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && Address.isOfKind(tag[1], NipTextEvent.KIND_STR) - - fun isTagged( - tag: Array, - addressId: String, - ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == addressId - - fun isTagged( - tag: Array, - address: ForkTag, - ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == address.toTag() - - fun isIn( - tag: Array, - addressIds: Set, - ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in addressIds - - fun parse(tag: Array): ForkTag? { - ensure(tag.has(1)) { return null } - ensure(tag[0] == TAG_NAME) { return null } - ensure( - Address.Companion.isOfKind( - tag[1], - NipTextEvent.KIND_STR, - ), - ) { return null } - - val address = Address.Companion.parse(tag[1]) ?: return null - val relayHint = tag.getOrNull(2)?.let { RelayUrlNormalizer.Companion.normalizeOrNull(it) } - return ForkTag(address, relayHint) - } - - fun parseValidAddress(tag: Array): String? { - ensure(tag.has(1)) { return null } - ensure(tag[0] == TAG_NAME) { return null } - ensure( - Address.Companion.isOfKind( - tag[1], - NipTextEvent.KIND_STR, - ), - ) { return null } - return Address.Companion.parse(tag[1])?.toValue() - } - - fun parseAddress(tag: Array): Address? { - ensure(tag.has(1)) { return null } - ensure(tag[0] == TAG_NAME) { return null } - ensure(tag[1].isNotEmpty()) { return null } - val address = Address.parse(tag[1]) ?: return null - ensure(address.kind == NipTextEvent.KIND) { return null } - return address - } - - fun parseAddressId(tag: Array): String? { - ensure(tag.has(1)) { return null } - ensure(tag[0] == TAG_NAME) { return null } - ensure( - Address.isOfKind( - tag[1], - NipTextEvent.KIND_STR, - ), - ) { return null } - return tag[1] - } - - fun parseAsHint(tag: Array): AddressHint? { - ensure(tag.has(2)) { return null } - ensure(tag[0] == TAG_NAME) { return null } - ensure( - Address.isOfKind( - tag[1], - NipTextEvent.KIND_STR, - ), - ) { return null } - ensure(tag[2].isNotEmpty()) { return null } - - val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2]) - ensure(relayHint != null) { return null } - - return AddressHint(tag[1], relayHint) - } - - fun assemble( - aTagId: String, - relay: NormalizedRelayUrl?, - ) = arrayOfNotNull(TAG_NAME, aTagId, relay?.url, "fork") - - fun assemble( - address: Address, - relay: NormalizedRelayUrl?, - ) = assemble(address.toValue(), relay) - - fun assemble( - kind: Int, - pubKey: String, - dTag: String, - relay: NormalizedRelayUrl?, - ) = assemble(Address.assemble(kind, pubKey, dTag), relay) - } -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/chat/LiveActivitiesChatMessageEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/chat/LiveActivitiesChatMessageEvent.kt index e850a18671..9d342e714f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/chat/LiveActivitiesChatMessageEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/chat/LiveActivitiesChatMessageEvent.kt @@ -121,16 +121,10 @@ class LiveActivitiesChatMessageEvent( return pHints + nip19Hints } - private fun activityHex() = tags.firstNotNullOfOrNull(ATag::parseAddressId) - fun activity() = tags.firstNotNullOfOrNull(ATag::parse) fun activityAddress() = tags.firstNotNullOfOrNull(ATag::parseAddress) - override fun markedReplyTos() = super.markedReplyTos().minus(activityHex() ?: "") - - override fun unmarkedReplyTos() = super.unmarkedReplyTos().minus(activityHex() ?: "") - override fun exposeInDraft() = tagArray { activity()?.let { aTag(it) } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/attestations/RequestTagTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/attestations/RequestTagTest.kt index fe9de285f4..397aea98c5 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/attestations/RequestTagTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/attestations/RequestTagTest.kt @@ -20,11 +20,19 @@ */ package com.vitorpamplona.quartz.experimental.attestations +import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent import com.vitorpamplona.quartz.experimental.attestations.attestation.tags.RequestTag import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull +import kotlin.test.assertTrue class RequestTagTest { private val pk = "1".repeat(64) @@ -49,4 +57,30 @@ class RequestTagTest { val event = AttestationRequestEvent("0".repeat(64), pk, 1, arrayOf(arrayOf("d", "x"), arrayOf("p", a), arrayOf("p", b)), "", "0".repeat(128)) assertEquals(listOf(a, b), event.attestorPubKeys()) } + + @Test + fun aRequestIdMustBeAWholeCoordinate() { + for (value in listOf("${AttestationRequestEvent.KIND}:junk", "${AttestationRequestEvent.KIND}:$pk", "${AttestationRequestEvent.KIND}")) { + assertNull(RequestTag.parseAddressId(arrayOf("request", value)), value) + assertNull(RequestTag.parseAsHint(arrayOf("request", value, "wss://relay.example/")), value) + } + } + + @Test + fun anAttestationLinksAndHintsTheRequestItAnswers() { + val relay = RelayUrlNormalizer.normalizeOrNull("wss://relay.example/")!! + val request = AttestationRequestEvent("0".repeat(64), pk, 1, arrayOf(arrayOf("d", "claim")), "", "0".repeat(128)) + val note = TextNoteEvent("2".repeat(64), "3".repeat(64), 1, emptyArray(), "hi", "0".repeat(128)) + val attestation = + NostrSignerSync().sign( + AttestationEvent.buildEvent( + "att", + EventHintBundle(note), + requestAddress = EventHintBundle(request).also { it.relay = relay }, + ), + ) + val requestId = request.address().toValue() + assertTrue(requestId in attestation.linkedAddressIds()) + assertTrue(AddressHint(requestId, relay) in attestation.addressHints()) + } } From a36792d72f8f76e74ee1c00e6f16a34e3478a48d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 18:07:56 +0000 Subject: [PATCH 13/19] refactor(quartz)!: remove ForkTag Nothing read it after forkFromAddress moved to parseForkedAddress, and it could not have served that role: it ignored the `fork` marker (so any `a` of kind 30817 parsed as a fork) and accepted only kind 30817, so wiki (30818) and note forks never matched. parseForkedAddress in experimental/forks is the reader for every forkable kind. BREAKING CHANGE: com.vitorpamplona.quartz.experimental.nipsOnNostr.tags.ForkTag is gone; use experimental.forks.parseForkedAddress. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pu8Fpp4KbXaiax8YTYxhJm --- .../quartz/experimental/forks/ForkedAddressTest.kt | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/forks/ForkedAddressTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/forks/ForkedAddressTest.kt index 6745b8a850..972241ca52 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/forks/ForkedAddressTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/forks/ForkedAddressTest.kt @@ -21,12 +21,10 @@ package com.vitorpamplona.quartz.experimental.forks import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent -import com.vitorpamplona.quartz.experimental.nipsOnNostr.tags.ForkTag import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip54Wiki.WikiArticleEvent import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertNotNull class ForkedAddressTest { private val pk = "1".repeat(64) @@ -47,9 +45,9 @@ class ForkedAddressTest { } @Test - fun aNipTextForkTagIsItsOwnKind() { + fun aNipTextFindsTheTextItWasForkedFrom() { val origin = "${NipTextEvent.KIND}:$pk:nip-01" - assertNotNull(ForkTag.parse(arrayOf("a", origin, "", "fork"))) - assertEquals(origin, ForkTag.parseValidAddress(arrayOf("a", origin, "", "fork"))) + val text = NipTextEvent("0".repeat(64), pk, 1, arrayOf(arrayOf("d", "nip-01"), arrayOf("a", community), arrayOf("a", origin, "", "fork")), "", "0".repeat(128)) + assertEquals(origin, text.forkFromAddress()?.toValue()) } } From 05dce68a51c350988a6e1b05f1da743182ecd9be Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 18:11:56 +0000 Subject: [PATCH 14/19] fix(quartz): align view and push events with what Divine emits Fixes from auditing the new kind 22236 / 3079-3083 classes: - SourceTag: divine-mobile writes the discovery tab inside the type ("discovery:foryou") and uses the third element for a hashtag, query or featured id. The test fixture had it split; ViewSource now exposes `category` (type without the tab) and an UNKNOWN constant. - VideoViewEvent.buildEnd leaves out loops that are not positive and finite, matching divine-mobile; ViewedRange and LoopsTag.assemble refuse values their parsers would reject instead of signing them. - PushDeregistrationEvent.build takes expiresAt, which the draft allows. - PushServiceEvent resolves the counterparty once instead of scanning the p tags twice behind a non-null assertion. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014VMBKXQwG1xa3mMEW3MsMQ --- .../quartz/nip71Video/views/VideoViewEvent.kt | 12 +++++++-- .../quartz/nip71Video/views/tags/LoopsTag.kt | 5 +++- .../quartz/nip71Video/views/tags/SourceTag.kt | 13 +++++++--- .../quartz/nip71Video/views/tags/ViewedTag.kt | 6 +++++ .../PushServiceEvent.kt | 21 +++++++++++----- .../deregistration/PushDeregistrationEvent.kt | 3 +++ .../nip71Video/views/VideoViewEventTest.kt | 25 ++++++++++++++++--- .../PushNotificationEventsTest.kt | 5 +++- 8 files changed, 73 insertions(+), 17 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/VideoViewEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/VideoViewEvent.kt index 6c649346ba..a275f8a34c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/VideoViewEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/VideoViewEvent.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.events.ETag @@ -91,7 +92,11 @@ class VideoViewEvent( initializer: TagArrayBuilder.() -> Unit = {}, ) = build(video, ViewPhase.START, null, null, source, createdAt, initializer) - /** A segment ended after [watchedSeconds] of playback, [loops] of them complete or partial. */ + /** + * A segment ended after [watchedSeconds] of playback, [loops] of them complete or partial. + * A [loops] that is not a positive finite number is left out, as divine-mobile does; a negative + * [watchedSeconds] throws, since it could only be a bug in the caller's clock. + */ fun buildEnd( video: EventHintBundle, watchedSeconds: Long, @@ -99,7 +104,10 @@ class VideoViewEvent( source: ViewSource? = null, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, - ) = build(video, ViewPhase.END, ViewedRange(0, watchedSeconds), loops, source, createdAt, initializer) + ): EventTemplate { + val playthroughs = loops?.takeIf { it.isFinite() && it > 0.0 } + return build(video, ViewPhase.END, ViewedRange(0, watchedSeconds), playthroughs, source, createdAt, initializer) + } /** * Prefer [buildStart] / [buildEnd]: they keep watch time off `start` events, where it diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/tags/LoopsTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/tags/LoopsTag.kt index 0f796b9cf7..802e8f1eb1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/tags/LoopsTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/tags/LoopsTag.kt @@ -41,6 +41,9 @@ class LoopsTag { return loops } - fun assemble(loops: Double) = arrayOf(TAG_NAME, loops.toString()) + fun assemble(loops: Double): Array { + require(loops.isFinite() && loops >= 0.0) { "Invalid loop count: $loops" } + return arrayOf(TAG_NAME, loops.toString()) + } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/tags/SourceTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/tags/SourceTag.kt index c6d812da37..03f5dc9a66 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/tags/SourceTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/tags/SourceTag.kt @@ -26,21 +26,28 @@ import com.vitorpamplona.quartz.utils.arrayOfNotNull import com.vitorpamplona.quartz.utils.ensure /** - * Where the viewer found the video. [type] is one of the documented values below, though - * divine-mobile also writes feed-specific ones such as `discovery:foryou`. [detail] narrows it - * further (a list, a feed mode) when the publisher knows more. + * Where the viewer found the video. + * + * [type] is written as-is: divine-mobile names the discovery tab inside it (`discovery:foryou`, + * `discovery:new`, `discovery:featured`, …), so [category] strips the tab when only the surface + * matters. [detail] is what the surface was showing: the hashtag, the search query, the featured + * tab's id. */ @Immutable data class ViewSource( val type: String, val detail: String? = null, ) { + /** [type] without the tab: `discovery` for every `discovery:`. */ + val category get() = type.substringBefore(':') + companion object { const val HOME = "home" const val DISCOVERY = "discovery" const val PROFILE = "profile" const val SHARE = "share" const val SEARCH = "search" + const val UNKNOWN = "unknown" } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/tags/ViewedTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/tags/ViewedTag.kt index 6a6b9d21e6..3a4a0e18b3 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/tags/ViewedTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip71Video/views/tags/ViewedTag.kt @@ -33,6 +33,12 @@ data class ViewedRange( val start: Long, val end: Long, ) { + init { + // The same rule ViewedTag.parse applies: an inverted range would read as negative watch + // time, so it is refused on the way out rather than signed and then ignored on the way in. + require(start in 0..end) { "Invalid viewed range: $start..$end" } + } + val seconds get() = end - start } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/PushServiceEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/PushServiceEvent.kt index f4d2664d79..a3f04b0201 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/PushServiceEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/PushServiceEvent.kt @@ -53,15 +53,24 @@ abstract class PushServiceEvent( fun app() = tags.app() - fun canDecrypt(signer: NostrSigner): Boolean { - val service = pushService() ?: return false - return signer.pubKey == pubKey || signer.pubKey == service + fun canDecrypt(signer: NostrSigner) = counterpartyOf(signer.pubKey) != null + + /** + * The other end of the NIP-44 conversation for [reader]: the author reads its own event back + * through the service's key, the service reads it through the author's. Null for anyone else. + */ + private fun counterpartyOf(reader: HexKey): HexKey? { + val service = pushService() ?: return null + return when (reader) { + pubKey -> service + service -> pubKey + else -> null + } } - /** The author reads its own event back through the service's key, the service through the author's. */ + /** Throws [SignerExceptions.UnauthorizedDecryptionException] when [signer] is neither end. */ protected suspend fun decryptContent(signer: NostrSigner): String { - if (!canDecrypt(signer)) throw SignerExceptions.UnauthorizedDecryptionException() - val counterparty = if (signer.pubKey == pubKey) pushService()!! else pubKey + val counterparty = counterpartyOf(signer.pubKey) ?: throw SignerExceptions.UnauthorizedDecryptionException() return signer.nip44Decrypt(content, counterparty) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/deregistration/PushDeregistrationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/deregistration/PushDeregistrationEvent.kt index 2c65a5dcf3..9f80020e2b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/deregistration/PushDeregistrationEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/deregistration/PushDeregistrationEvent.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip40Expiration.expiration import com.vitorpamplona.quartz.nipXXPushNotifications.PushServiceEvent import com.vitorpamplona.quartz.nipXXPushNotifications.app import com.vitorpamplona.quartz.nipXXPushNotifications.pushService @@ -55,11 +56,13 @@ class PushDeregistrationEvent( pushService: HexKey, app: String, signer: NostrSigner, + expiresAt: Long? = null, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, signer.nip44Encrypt(json.encodeToString(PushToken.serializer(), PushToken(token)), pushService), createdAt) { pushService(pushService) app(app) + expiresAt?.let { expiration(it) } initializer() } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/views/VideoViewEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/views/VideoViewEventTest.kt index b35c2a0fe7..84e76de554 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/views/VideoViewEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip71Video/views/VideoViewEventTest.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nip71Video.views.tags.ViewedTag import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertIs import kotlin.test.assertNull @@ -50,11 +51,11 @@ class VideoViewEventTest { // An `end` segment exactly as divine-mobile writes it, client tag included. private val endView = - """{"id":"2b1b0b53d6c0c2f1f5a8a2d5f0e7e1f8c9a4b3d2e1f0a9b8c7d6e5f4a3b2c1d0","pubkey":"34257350449d357c37e93eb8aef387ff1fee8879d794da664462346a4b540aa8","created_at":1789666900,"kind":22236,"tags":[["a","34236:$videoAuthor:$videoD","wss://relay.divine.video"],["e","$videoId","wss://relay.divine.video"],["phase","end"],["viewed","0","12"],["source","discovery","foryou"],["loops","2.0"],["version","1.0.23"],["client","Divine","31990:d95aa8fc0eff8e488952495b8064991d27fb96ed8652f12cdedc5a4e8b5ae540:divine-mobile","wss://relay.divine.video"]],"content":"","sig":""}""" + """{"id":"2b1b0b53d6c0c2f1f5a8a2d5f0e7e1f8c9a4b3d2e1f0a9b8c7d6e5f4a3b2c1d0","pubkey":"34257350449d357c37e93eb8aef387ff1fee8879d794da664462346a4b540aa8","created_at":1789666900,"kind":22236,"tags":[["a","34236:$videoAuthor:$videoD","wss://relay.divine.video"],["e","$videoId","wss://relay.divine.video"],["phase","end"],["viewed","0","12"],["source","discovery:foryou"],["loops","2.0"],["version","1.0.23"],["client","Divine","31990:d95aa8fc0eff8e488952495b8064991d27fb96ed8652f12cdedc5a4e8b5ae540:divine-mobile","wss://relay.divine.video"]],"content":"","sig":""}""" // The pre-phase single-shot shape: no `phase`, and `viewed` carries the whole session. private val legacyView = - """{"id":"3c2c1c64e7d1d3f2f6b9b3e6f1f8f2f9dab5c4e3f2f1bac9d8e7f6f5b4c3d2e1","pubkey":"34257350449d357c37e93eb8aef387ff1fee8879d794da664462346a4b540aa8","created_at":1789666900,"kind":22236,"tags":[["a","34236:$videoAuthor:$videoD","wss://relay.divine.video"],["e","$videoId","wss://relay.divine.video"],["viewed","0","5"],["loops","0.75"],["source","discovery"]],"content":"","sig":""}""" + """{"id":"3c2c1c64e7d1d3f2f6b9b3e6f1f8f2f9dab5c4e3f2f1bac9d8e7f6f5b4c3d2e1","pubkey":"34257350449d357c37e93eb8aef387ff1fee8879d794da664462346a4b540aa8","created_at":1789666900,"kind":22236,"tags":[["a","34236:$videoAuthor:$videoD","wss://relay.divine.video"],["e","$videoId","wss://relay.divine.video"],["viewed","0","5"],["loops","0.75"],["source","search","cats"]],"content":"","sig":""}""" private val relay = RelayUrlNormalizer.normalizeOrNull("wss://relay.divine.video")!! @@ -68,7 +69,9 @@ class VideoViewEventTest { assertEquals(ViewedRange(0, 12), event.viewed()) assertEquals(12, event.viewed()?.seconds) assertEquals(2.0, event.loops()) - assertEquals(ViewSource(ViewSource.DISCOVERY, "foryou"), event.source()) + // The tab rides inside the type; category is how a reader groups every discovery tab. + assertEquals(ViewSource("discovery:foryou"), event.source()) + assertEquals(ViewSource.DISCOVERY, event.source()?.category) assertEquals(listOf("34236:$videoAuthor:$videoD"), event.linkedAddressIds()) assertEquals(listOf(videoId), event.linkedEventIds()) } @@ -80,7 +83,8 @@ class VideoViewEventTest { assertNull(event.phase()) assertEquals(ViewedRange(0, 5), event.viewed()) assertEquals(0.75, event.loops()) - assertEquals(ViewSource(ViewSource.DISCOVERY), event.source()) + assertEquals(ViewSource(ViewSource.SEARCH, "cats"), event.source()) + assertEquals(ViewSource.SEARCH, event.source()?.category) } @Test @@ -92,6 +96,19 @@ class VideoViewEventTest { assertNull(LoopsTag.parse(arrayOf("loops", "NaN"))) } + @Test + fun endRefusesWhatTheParserWouldDrop() { + val bundle = EventHintBundle(Event.fromJson(video) as AddressableShortVideoEvent, relay) + + assertFailsWith { VideoViewEvent.buildEnd(bundle, watchedSeconds = -1) } + + // Not a playthrough count: left out, as divine-mobile does, instead of signed and ignored. + for (loops in listOf(0.0, -1.0, Double.NaN, Double.POSITIVE_INFINITY)) { + val template = VideoViewEvent.buildEnd(bundle, watchedSeconds = 3, loops = loops) + assertNull(template.tags.firstOrNull { it[0] == LoopsTag.TAG_NAME }, "loops=$loops") + } + } + @Test fun startCarriesNoWatchTime() { val bundle = EventHintBundle(Event.fromJson(video) as AddressableShortVideoEvent, relay) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/PushNotificationEventsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/PushNotificationEventsTest.kt index 9ca5c398b5..58c78bab7c 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/PushNotificationEventsTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPushNotifications/PushNotificationEventsTest.kt @@ -71,9 +71,10 @@ class PushNotificationEventsTest { @Test fun deregistrationLeavesTheOffsetOut() = runTest { - val event = user.sign(PushDeregistrationEvent.build("fcm-token", service.pubKey, app, user)) + val event = user.sign(PushDeregistrationEvent.build("fcm-token", service.pubKey, app, user, expiresAt = 1_800_000_000)) assertEquals(PushDeregistrationEvent.KIND, event.kind) + assertEquals(1_800_000_000, event.expiration()) assertEquals("""{"token":"fcm-token"}""", plaintextAtService(event)) assertEquals(PushToken("fcm-token"), event.decrypt(service)) } @@ -103,6 +104,8 @@ class PushNotificationEventsTest { runTest { val event = user.sign(PushRegistrationEvent.build(PushToken("fcm-token"), service.pubKey, app, user)) + assertTrue(event.canDecrypt(user)) + assertTrue(event.canDecrypt(service)) assertFalse(event.canDecrypt(stranger)) assertFailsWith { event.decrypt(stranger) } } From f11103dabe9afead76650c4f74c0d8be1fb3d482 Mon Sep 17 00:00:00 2001 From: vitorpamplona <532031+vitorpamplona@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:20:39 +0000 Subject: [PATCH 15/19] chore: sync Crowdin translations and seed translator npub placeholders --- .../values-pl-rPL/strings.xml | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml b/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml index a56f975937..480ed9bac4 100644 --- a/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml @@ -602,6 +602,7 @@ Dodaj baner Miejsce, w którym publikowane i czytane są zaszyfrowane plany tej społeczności. Ta społeczność została rozwiązana i jest tylko do odczytu. Nadal możesz przeczytać jej historię, ale nie można opublikować żadnych nowych wiadomości. + To jest kanał prywatny, a ty nie posiadasz klucza dostępu do niego, więc nie możesz czytać ani publikować w nim postów. Nazwa Informacje (opcjonalne) Banuj @@ -6384,6 +6385,12 @@ Można ich dodać tylko wtedy, gdy opublikowali pakiet kluczy dla tego koordynatora. Nazwa, npub lub nazwa@domena Administrator + + Administrator grupy %1$d pozostaje bez zmian. + Administratorów grupy %1$d pozostaje bez zmian. + Administratorów grupy %1$d pozostaje bez zmian. + Administratorzy grupy %1$d pozostają bez zmian. + Koordynator Klucz koordynatora Link koordynatora @@ -6475,4 +6482,20 @@ Przypięte wiadomości Reakcje Dodaj + Tylko administrator może wyrazić zgodę. W grupie, w której nie ma administratorów, są to wszyscy członkowie. + Sprawdzanie żądań + Zignoruj + Nie można odczytać żądań. + Nikt nie czeka na dołączenie. + Prośby o dołączenie + Wyślij + Nie udało się wysłać. + Koordynator tej grupy nie jest dostępny, więc na razie nie można niczego wysłać. + Skopiowano + Kopiuj link + Każdy, kto posiada ten link, może poprosić o dołączenie do grupy. Administrator musi jednak zatwierdzić tę prośbę. + Udostępnij tę grupę + Odtwórz wiadomość głosową + Nagraj wiadomość głosową + Zatrzymaj i wyślij From 20163bbc9c436eaa622ddaa7dbf33549552e1ac8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 19:08:41 +0000 Subject: [PATCH 16/19] =?UTF-8?q?feat(concord):=20channel-scope=20rekeys,?= =?UTF-8?q?=20channel=20keyring=20and=20entitlement=20(CORD-06=20=C2=A71-2?= =?UTF-8?q?,=20CORD-03,=20CORD-04=20=C2=A72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ConcordChannelRekey: the single-channel Rekey — the root-keyed `concord/rekey-pseudonym` address, 72-byte scope-bound blobs, chunked kind-3303 rumors with prevcommit continuity and `vac` on every chunk, and the receiver's walk (complete + honored rotations only, epoch-by-epoch adoption off the held key, racing rotators converge on the lowest key, a cut only from an outranking rotator after the join). Pinned to Armada's rekey.ts / useChannelRekeyWatch. - ConcordChannelKeyring: rotate a held key in place (unknown fields kept), read older keys from `seed` / a peer's `priors` without writing any (CORD-02 §8), Armada's `channel_cuts` floor (round-tripped, max wins), and the monotonic next channel epoch for a privatisation. - ConcordInviteVend: entitledMembers / accessChanges (who a grant opens a channel to, who a revoke/ban must cut) and judgeCatchUp (Armada catchUpAdoption). A catch-up now only ADDS a missing key, never replaces a held one, never restores one below a cut, and a manual accept needs a staff sender and a live Private Channel (admissibleCatchUpIds). - ConcordCommunityState.privateChannelIds, AuthorityResolver.owner(). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PmuzkD5qdqgv6dMeRMoC5N --- .../cord02Community/ConcordCommunityState.kt | 3 + .../cord03Channels/ConcordChannelKeyring.kt | 239 +++++++++++++ .../concord/cord04Roles/AuthorityResolver.kt | 3 + .../cord05Invites/ConcordInviteVend.kt | 150 +++++++- .../cord06Rekey/ConcordChannelRekey.kt | 327 ++++++++++++++++++ .../ConcordChannelKeyringTest.kt | 144 ++++++++ .../cord05Invites/ConcordInviteVendTest.kt | 126 ++++++- .../cord06Rekey/ConcordChannelRekeyTest.kt | 230 ++++++++++++ 8 files changed, 1201 insertions(+), 21 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChannelKeyring.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordChannelRekey.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChannelKeyringTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordChannelRekeyTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityState.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityState.kt index 5222e39d52..da3654d054 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityState.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityState.kt @@ -67,6 +67,9 @@ data class ConcordCommunityState( */ val inviteRegistries: Map> = emptyMap(), ) { + /** The ids of the live (non-deleted) Private Channels (CORD-03), lowercase hex. */ + val privateChannelIds: Set by lazy { channels.filterValues { it.definition.private }.keys.mapTo(HashSet()) { it.lowercase() } } + /** The aggregate active-set of live public links: every honored registry's link signers (CORD-05 §5). */ val liveInviteLinks: Set by lazy { inviteRegistries.values.flatMapTo(HashSet()) { it } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChannelKeyring.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChannelKeyring.kt new file mode 100644 index 0000000000..0df4cd0898 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChannelKeyring.kt @@ -0,0 +1,239 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord03Channels + +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.ConcordEntryResidue +import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.longOrNull + +/** A Private Channel key held for an older channel epoch — it still reads its own era's history. */ +class HistoricalChannelKey( + val key: HexKey, + val epoch: Long, +) + +/** + * The Private Channel keys a Community List entry holds, and how a rotation rewrites them + * (CORD-03 §1-2, CORD-06 §2, CORD-02 §8). + * + * - **Current keys** are the entry's `channels` (`privateChannels`): exactly one per channel, the + * newest epoch held. A rotation replaces it in place, keeping any unknown keys another client + * wrote inside the channel object (the round-trip rule, CORD-02 §6/§8). + * - **Older keys** are never written by this client: CORD-02 §8 keeps intermediate keys out of the + * List ("a client's own optimisation … it does not belong in the List"). They are still *read* + * wherever they already are — the entry's `seed` snapshot (the earliest epoch held, the backfill + * anchor) and the reference client's `priors` extension inside a channel object — so history + * written before a rotation stays readable. + * - **Cuts** are the reference client's `channel_cuts` extension on the entry: per channel, the + * channel epoch whose rotation cut this member out. A floor, never rolled back: a key below it is + * refused, so a stale bundle or catch-up cannot quietly restore revoked access. Kept as the raw + * extension (`[{ "id", "epoch" }]`) with every other field in each object preserved. + */ +object ConcordChannelKeyring { + const val CHANNEL_CUTS = "channel_cuts" + const val PRIORS = "priors" + + private val HEX64 = Regex("^[0-9a-fA-F]{64}$") + + /** The current key held for [channelIdHex], or null (a keyless listing is not a key). */ + fun heldKey( + entry: ConcordCommunityListEntry, + channelIdHex: HexKey, + ): PrivateChannelKey? = entry.privateChannels.firstOrNull { it.channelId.equals(channelIdHex, ignoreCase = true) && HEX64.matches(it.key) } + + // ---- cuts ------------------------------------------------------------------ + + /** The `channel_cuts` floors on [entry], channel id (lowercase) → cut epoch (max wins). */ + fun cutsOf(entry: ConcordCommunityListEntry): Map { + val raw = entry.residue.entryExtras[CHANNEL_CUTS] as? JsonArray ?: return emptyMap() + val out = HashMap() + for (element in raw) { + val obj = element as? JsonObject ?: continue + val id = (obj["id"] as? JsonPrimitive)?.contentOrNull?.lowercase() ?: continue + val epoch = (obj["epoch"] as? JsonPrimitive)?.longOrNull ?: continue + if (epoch > (out[id] ?: Long.MIN_VALUE)) out[id] = epoch + } + return out + } + + /** True when a key for [channelIdHex] at [epoch] sits below a recorded cut and must be refused. */ + fun isCutOff( + entry: ConcordCommunityListEntry, + channelIdHex: HexKey, + epoch: Long, + ): Boolean = cutsOf(entry)[channelIdHex.lowercase()]?.let { epoch < it } ?: false + + /** + * [entry] with a cut for [channelIdHex] at [epoch] merged into `channel_cuts` (max wins — a + * removal never rolls back). Other channels' cut objects, and unknown keys inside the replaced + * one, ride through untouched. + */ + fun withCut( + entry: ConcordCommunityListEntry, + channelIdHex: HexKey, + epoch: Long, + ): ConcordCommunityListEntry { + val id = channelIdHex.lowercase() + val existing = (entry.residue.entryExtras[CHANNEL_CUTS] as? JsonArray)?.toList() ?: emptyList() + if ((cutsOf(entry)[id] ?: Long.MIN_VALUE) >= epoch) return entry + val mine = existing.filter { (it as? JsonObject)?.let { o -> (o["id"] as? JsonPrimitive)?.contentOrNull?.lowercase() == id } == true } + val base = (mine.firstOrNull() as? JsonObject) ?: JsonObject(emptyMap()) + val next = JsonObject(base + mapOf("id" to JsonPrimitive(id), "epoch" to JsonPrimitive(epoch))) + val cuts = JsonArray(existing.filterNot { it in mine } + next) + val extras = JsonObject(entry.residue.entryExtras + (CHANNEL_CUTS to cuts)) + return entry.copyChannels(entry.privateChannels, ConcordEntryResidue(extras, entry.residue.seed, entry.residue.currentExtras)) + } + + // ---- current keys ---------------------------------------------------------- + + /** + * [entry] holding [key] as the current key for its channel — replacing a lower epoch, keeping + * the replaced object's unknown fields — or null when it would not move anything forward: a key + * at or below the held epoch, or one below a recorded cut. + */ + fun withChannelKey( + entry: ConcordCommunityListEntry, + key: PrivateChannelKey, + ): ConcordCommunityListEntry? { + if (!HEX64.matches(key.key) || !HEX64.matches(key.channelId)) return null + if (isCutOff(entry, key.channelId, key.epoch)) return null + val held = entry.privateChannels.firstOrNull { it.channelId.equals(key.channelId, ignoreCase = true) } + if (held != null && HEX64.matches(held.key) && held.epoch >= key.epoch) return null + val next = + PrivateChannelKey( + channelId = key.channelId.lowercase(), + key = key.key.lowercase(), + epoch = key.epoch, + name = key.name.ifBlank { held?.name ?: "" }, + extras = held?.extras ?: key.extras, + ) + return entry.copyChannels(entry.privateChannels.filterNot { it.channelId.equals(key.channelId, ignoreCase = true) } + next, entry.residue) + } + + /** [entry] with [channelIdHex]'s key moved to [newKeyHex] at [newEpoch] (a rotation it launched or adopted). */ + fun withRotatedKey( + entry: ConcordCommunityListEntry, + channelIdHex: HexKey, + newKeyHex: HexKey, + newEpoch: Long, + ): ConcordCommunityListEntry? { + val held = heldKey(entry, channelIdHex) ?: return null + return withChannelKey(entry, PrivateChannelKey(held.channelId, newKeyHex, newEpoch, held.name, held.extras)) + } + + /** + * [entry] after a rotation to [cutEpoch] cut this member from [channelIdHex] (CORD-06 §2): the + * key leaves `channels` and the cut is recorded, so no older key can come back. + */ + fun withoutChannel( + entry: ConcordCommunityListEntry, + channelIdHex: HexKey, + cutEpoch: Long, + ): ConcordCommunityListEntry { + val dropped = entry.copyChannels(entry.privateChannels.filterNot { it.channelId.equals(channelIdHex, ignoreCase = true) }, entry.residue) + return withCut(dropped, channelIdHex, cutEpoch) + } + + // ---- older keys -------------------------------------------------------------- + + /** + * Every older key this entry still carries for [channelIdHex] — the `seed` snapshot's and any + * `priors` a peer wrote — excluding the current one, newest first. Each reads its own epoch's + * history. + */ + fun historicalKeys( + entry: ConcordCommunityListEntry, + channelIdHex: HexKey, + ): List { + val id = channelIdHex.lowercase() + val current = heldKey(entry, id) + val out = LinkedHashMap, HistoricalChannelKey>() + + fun add( + key: String?, + epoch: Long?, + ) { + if (key == null || epoch == null || !HEX64.matches(key)) return + val k = key.lowercase() + if (current != null && current.key.equals(k, ignoreCase = true) && current.epoch == epoch) return + out.getOrPut(epoch to k) { HistoricalChannelKey(k, epoch) } + } + current?.extras?.get(PRIORS)?.let { priors -> + for (p in (priors as? JsonArray).orEmpty()) { + val obj = p as? JsonObject ?: continue + add((obj["key"] as? JsonPrimitive)?.contentOrNull, (obj["epoch"] as? JsonPrimitive)?.longOrNull) + } + } + val seedChannels = entry.residue.seed?.get("channels") as? JsonArray + for (c in seedChannels.orEmpty()) { + val obj = c as? JsonObject ?: continue + if ((obj["id"] as? JsonPrimitive)?.contentOrNull?.lowercase() != id) continue + add((obj["key"] as? JsonPrimitive)?.contentOrNull, (obj["epoch"] as? JsonPrimitive)?.longOrNull) + } + return out.values.sortedByDescending { it.epoch } + } + + /** + * The channel epoch a privatisation must mint at (CORD-03 §2): one past the highest generation + * this entry knows of — the held key, any older key, a recorded cut — and [observedFloor] (the + * highest rotation epoch seen on the wire, for a privatiser who never held earlier + * generations). Monotonic, so a stale key is always a lower epoch; 1 for a never-private channel. + */ + fun nextChannelEpoch( + entry: ConcordCommunityListEntry, + channelIdHex: HexKey, + observedFloor: Long = 0, + ): Long { + val id = channelIdHex.lowercase() + var highest = observedFloor + entry.privateChannels.filter { it.channelId.equals(id, ignoreCase = true) }.forEach { highest = maxOf(highest, it.epoch) } + historicalKeys(entry, id).forEach { highest = maxOf(highest, it.epoch) } + cutsOf(entry)[id]?.let { highest = maxOf(highest, it) } + return highest + 1 + } + + private fun ConcordCommunityListEntry.copyChannels( + privateChannels: List, + residue: ConcordEntryResidue, + ) = ConcordCommunityListEntry( + id = id, + owner = owner, + ownerSalt = ownerSalt, + root = root, + rootEpoch = rootEpoch, + controlPk = controlPk, + controlRoot = controlRoot, + heldRoots = heldRoots, + privateChannels = privateChannels, + relays = relays, + name = name, + addedAt = addedAt, + inviteRef = inviteRef, + excludedAtEpoch = excludedAtEpoch, + residue = residue, + ) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt index b2f43b77e6..24aa85b7d1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt @@ -84,6 +84,9 @@ data class AuthorityResolver private constructor( fun isOwner(pubKey: String): Boolean = pubKey.lowercase() == ownerLower + /** The owner's pubkey (lowercase hex), proven by the `community_id` rather than any fold. */ + fun owner(): String = ownerLower + fun isBanned(pubKey: String): Boolean = pubKey.lowercase() in banned /** The role ids a member currently holds (empty for the owner and for plain members). */ diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteVend.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteVend.kt index c0cb4df4d9..148ff1eb0d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteVend.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteVend.kt @@ -20,9 +20,9 @@ */ package com.vitorpamplona.quartz.concord.cord05Invites -import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityList.withPrivateChannels import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeyring import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -80,6 +80,51 @@ object ConcordInviteVend { return held.filter { it.key.isNotBlank() && isEntitled(authority, memberHex, it.channelId) } } + /** + * Everyone entitled to [channelIdHex]'s key under [authority]: the owner plus every non-banned + * holder of a Role scoped to the channel. Entitlement needs a Grant, so the roster enumerates it + * exactly — no member census required. This is the keep-set of a channel rotation (CORD-06). + */ + fun entitledMembers( + authority: AuthorityResolver, + channelIdHex: HexKey, + ): Set { + val scoped = channelRoleIds(authority, channelIdHex) + val out = HashSet() + out.add(authority.owner()) + for (member in authority.roleHolders()) { + if (authority.isBanned(member)) continue + if (authority.rolesOf(member).any { it in scoped }) out.add(member.lowercase()) + } + return out + } + + /** Who gained and who lost a Private Channel's entitlement between two folds. */ + class AccessChange( + val channelIdHex: HexKey, + val gained: Set, + val lost: Set, + ) + + /** + * How a roster change — a Grant, a revoke, a Role's scope edit or deletion, a ban — moved + * entitlement to each of [channelIds] (Armada `channelsHingingOn`, generalised to any edit): + * the members to vend each channel's key to, and the ones a rotation must now cut. Channels + * nobody gained or lost are omitted. + */ + fun accessChanges( + before: AuthorityResolver, + after: AuthorityResolver, + channelIds: Collection, + ): List = + channelIds.mapNotNull { id -> + val was = entitledMembers(before, id) + val now = entitledMembers(after, id) + val gained = now - was + val lost = was - now + if (gained.isEmpty() && lost.isEmpty()) null else AccessChange(id.lowercase(), gained, lost) + } + /** The [held] keys as bundle channel grants (lowercase hex, as Armada writes them). */ fun toInviteChannels(held: List): List = held.map { InviteChannel(it.channelId.lowercase(), it.key.lowercase(), it.epoch, it.name) } @@ -92,6 +137,12 @@ object ConcordInviteVend { * the member onto attacker-read streams. So it counts only on the SAME `community_root`, * `root_epoch` and `control_pk` (swapping `control_pk` alone would eclipse the member onto an * attacker's Control Plane); the base advances only by a CORD-06 rekey. + * + * And it only ever ADDS a channel this member holds no key for. A held key moves forward only + * through a channel rekey (CORD-06 §2), whose `prevcommit` proves it extends the very key held; + * a bare bundle proves nothing, so letting one replace a held key would let any keyholder + * park a member on a dead key at an absurd epoch that every later honest delivery then loses + * to. A key below a recorded cut (the rotation that removed us) never comes back either. */ fun catchUpChannelIds( held: ConcordCommunityListEntry?, @@ -102,34 +153,109 @@ object ConcordInviteVend { if (!bundle.communityRoot.equals(held.root, ignoreCase = true)) return emptyList() if (bundle.rootEpoch != held.rootEpoch) return emptyList() if (!sameOptionalHex(bundle.controlPk, held.controlPk)) return emptyList() - val heldEpochs = held.privateChannels.filter { it.key.isNotBlank() }.associate { it.channelId.lowercase() to it.epoch } + val heldIds = held.privateChannels.filter { HEX64.matches(it.key) }.mapTo(HashSet()) { it.channelId.lowercase() } return bundle.channels .filter { HEX64.matches(it.id) && HEX64.matches(it.key) } - .filter { c -> - val heldEpoch = heldEpochs[c.id.lowercase()] - heldEpoch == null || c.epoch > heldEpoch - }.map { it.id.lowercase() } + .filter { it.id.lowercase() !in heldIds } + .filterNot { ConcordChannelKeyring.isCutOff(held, it.id, it.epoch) } + .map { it.id.lowercase() } .distinct() } /** - * [held] with the Private Channel keys [bundle] newly contributes ([catchUpChannelIds]) merged - * in — a newer epoch replaces the held one — or null when the bundle contributes nothing. The - * base, epoch, control keys and every other field stay exactly as held. + * The subset of [catchUpChannelIds] a catch-up may actually deliver against the held fold: only + * from a [sender] who is staff there (the owner or a Control-writing permission holder, + * CORD-04 §3 — a plain keyholder could otherwise plant a wrong key that blocks the right one), + * and only for channels the fold knows as live Private Channels ([privateChannelIds]). Empty + * when either fails. + */ + fun admissibleCatchUpIds( + held: ConcordCommunityListEntry?, + bundle: CommunityInvite, + authority: AuthorityResolver, + privateChannelIds: Set, + sender: HexKey, + ): List { + if (!authority.isStaff(sender)) return emptyList() + val live = privateChannelIds.mapTo(HashSet()) { it.lowercase() } + return catchUpChannelIds(held, bundle).filter { it in live } + } + + /** + * [held] with the Private Channel keys [bundle] newly contributes ([catchUpChannelIds], narrowed + * to [only] when given) added, or null when the bundle contributes nothing. A held key is never + * replaced; the base, epoch, control keys and every other field stay exactly as held. */ fun adoptCatchUp( held: ConcordCommunityListEntry, bundle: CommunityInvite, + only: Collection? = null, ): ConcordCommunityListEntry? { - val newIds = catchUpChannelIds(held, bundle).toSet() + val newIds = catchUpChannelIds(held, bundle).filter { only == null || it in only }.toSet() if (newIds.isEmpty()) return null val delivered = bundle.channels .filter { it.id.lowercase() in newIds && HEX64.matches(it.key) } .groupBy { it.id.lowercase() } .map { (id, grants) -> grants.maxBy { it.epoch }.let { PrivateChannelKey(id, it.key.lowercase(), it.epoch, it.name) } } - val kept = held.privateChannels.filterNot { it.channelId.lowercase() in newIds } - return held.withPrivateChannels(kept + delivered) + // Through the keyring: a replaced key keeps the unknown fields another client wrote in it. + var next = held + for (key in delivered) next = ConcordChannelKeyring.withChannelKey(next, key) ?: next + return if (next === held) null else next + } + + /** Why a catch-up Direct Invite may or may not be adopted without a click ([judgeCatchUp]). */ + enum class CatchUpVerdict { + /** The Grant was the consent: adopt now. */ + ADOPT, + + /** No folded roster yet (the Grant's fold lags): wait. */ + NO_FOLD, + + /** Not a catch-up at all ([catchUpChannelIds] is empty). */ + NOTHING_NEW, + + /** The recipient is banned (CORD-04 §4). */ + BANNED, + + /** A plain keyholder sent it; only staff may plant a key automatically. */ + SENDER_NOT_STAFF, + + /** It carries a channel the recipient's Roles don't entitle them to. */ + NOT_ENTITLED, + } + + /** + * Whether a parked catch-up invite — a Direct Invite carrying a Private Channel key an existing + * member lacks, which is how a role grant's key arrives (CORD-05 §6) — may be adopted WITHOUT a + * click (Armada `judgeCatchUp`). Consent came from the Grant; this checks the bundle is the + * delivery it prescribes, against the folded [authority]: + * - the [sender] is the owner or staff (CORD-04 §3), so a plain keyholder can't plant a wrong + * key that would block the right one; + * - the [recipient] isn't banned; + * - EVERY newly contributed channel is one the recipient's Roles entitle them to ([isEntitled]). + * + * - every such channel is a live Private Channel in the fold ([privateChannelIds]). + * + * A manual Accept still needs a staff sender and a live Private Channel + * ([admissibleCatchUpIds]); only the entitlement check is waived by the click. + */ + fun judgeCatchUp( + authority: AuthorityResolver?, + privateChannelIds: Set, + recipient: HexKey, + sender: HexKey, + bundle: CommunityInvite, + held: ConcordCommunityListEntry?, + ): CatchUpVerdict { + val vended = catchUpChannelIds(held, bundle) + if (vended.isEmpty()) return CatchUpVerdict.NOTHING_NEW + if (authority == null) return CatchUpVerdict.NO_FOLD + if (authority.isBanned(recipient)) return CatchUpVerdict.BANNED + if (!authority.isStaff(sender)) return CatchUpVerdict.SENDER_NOT_STAFF + val live = privateChannelIds.mapTo(HashSet()) { it.lowercase() } + if (vended.any { it !in live || !isEntitled(authority, recipient, it) }) return CatchUpVerdict.NOT_ENTITLED + return CatchUpVerdict.ADOPT } private val HEX64 = Regex("^[0-9a-fA-F]{64}$") diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordChannelRekey.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordChannelRekey.kt new file mode 100644 index 0000000000..b39c8f5348 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordChannelRekey.kt @@ -0,0 +1,327 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord06Rekey + +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation +import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.VacTag +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.concord.crypto.GroupKey +import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.firstTagValue +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler +import com.vitorpamplona.quartz.utils.RandomInstance + +/** + * One Private Channel's rotation as a receiver sees it (CORD-06 §2): the kind-3303 chunks one + * [rotator] published at one ([newEpoch], [prevCommit]) — two Rotators concurrently rekeying the + * same epoch never merge into one set. [complete] once every chunk `1..total` is held; a missing + * chunk is never a removal. [createdAt] is the newest chunk's `created_at` (seconds), which a + * receiver compares against its join time: a rotation predating the join is not an exclusion. + */ +class ChannelRotation( + val rotator: HexKey, + val channelIdHex: HexKey, + val newEpoch: Long, + val prevEpoch: Long, + val prevCommit: HexKey, + val total: Int, + val chunks: Map>, + val authority: AuthorityCitation?, + val createdAt: Long, +) { + val complete: Boolean get() = (1..total).all { it in chunks } + + /** Every blob across the held chunks. */ + fun blobs(): List = chunks.values.flatten() +} + +/** A key the receiver stepped off while walking a channel's rotations: it still reads its own era. */ +class SteppedChannelKey( + val key: ByteArray, + val epoch: Long, + /** When the rotation that superseded it was published (seconds). */ + val retiredAt: Long, +) + +/** What a Private Channel's pending rotations mean for the key this account holds (CORD-06 §2). */ +sealed class ChannelRekeyOutcome { + /** Nothing to act on (no honored complete rotation past the held epoch, or one we cannot yet verify). */ + object None : ChannelRekeyOutcome() + + /** Adopt [key] at [epoch]; [steppedOver] are the keys the walk left behind, newest first. */ + class Adopted( + val key: ByteArray, + val epoch: Long, + val steppedOver: List, + ) : ChannelRekeyOutcome() + + /** + * A complete, honored rotation to [epoch] that could have carried our blob did not: we were cut + * from the channel. Drop the key and record the cut, so a stale bundle cannot restore it. + */ + class Removed( + val epoch: Long, + ) : ChannelRekeyOutcome() +} + +/** + * Single-channel Rekeys (CORD-06 §1-2): rotate one Private Channel's independent key to exactly the + * members who should keep reading it, and follow such a rotation as a member. + * + * - **Address.** A channel rotation to `new_epoch` rides `group_key("concord/rekey-pseudonym", + * community_root, channel_id, new_epoch)` — keyed by the *community* root, not the channel key + * (CORD-02 derivation table), so every member can precompute it. A Refounding seals its channel + * rekeys under the **prior** root (CORD-06 §3), so a receiver watches under the root it holds and + * the one before it. + * - **Blob.** 72 bytes, `scope_id[32] ‖ epoch_be[8] ‖ new_key[32]`, with the channel id as the + * scope ([RekeyPayload]); scope and epoch are verified against the tags before adoption. + * - **Continuity.** `prevcommit` is the epoch-key commitment over the channel key being replaced at + * its epoch; a receiver adopts only a rotation off the exact key it holds, walking several + * rotations in one pass when it missed some. + * - **Authority.** A single-channel Rekey needs `MANAGE_CHANNELS`, a Refounding's needs `BAN`, and + * the Rotator must strictly outrank every removed target — so a receiver treats "no blob for me" + * as a removal only from a Rotator that outranks it (Armada `useChannelRekeyWatch`). + * + * Pinned byte-for-byte to the reference client's `lib/rekey.ts` (`encodeWrappedKey`, + * `buildRekeyRumors`, `channelRekeyGroupKey`) and walk (`useChannelRekeyWatch`). + */ +object ConcordChannelRekey { + /** + * How many channel epochs past the held one a member watches. Watching only `held + 1` strands + * anyone who missed a rotation — they'd never learn they were removed. The reference client's + * `CHANNEL_REKEY_LOOKAHEAD`. + */ + const val LOOKAHEAD = 8 + + /** The rekey address a rotation of [channelId] to [newEpoch] rides, sealed under [sealingRoot]. */ + fun address( + sealingRoot: ByteArray, + channelId: ByteArray, + newEpoch: Long, + ): GroupKey = ConcordKeyDerivation.channelRekeyAddress(sealingRoot, channelId, newEpoch) + + /** The `prevcommit` a rotation off [heldKey] at [heldEpoch] carries (CORD-02 A.5). */ + fun prevCommit( + heldEpoch: Long, + heldKey: ByteArray, + ): HexKey = ConcordKeyDerivation.epochKeyCommitment(heldEpoch, heldKey).toHexKey() + + /** A fresh 32-byte channel key. */ + fun mintKey(): ByteArray = RandomInstance.bytes(32) + + /** + * The kind-1059 wraps of one channel rotation: the chunked kind-3303 rumors delivering + * [newKey] at `heldEpoch + 1` to [recipients] (the rotator should include itself, or nobody + * could rotate the channel next time), each chunk carrying [authority] (`vac`), sealed + * (encrypted, rotator-signed) at [address] under [sealingRoot]. + */ + suspend fun build( + rotatorSigner: NostrSigner, + sealingRoot: ByteArray, + channelId: ByteArray, + heldKey: ByteArray, + heldEpoch: Long, + newKey: ByteArray, + recipients: Collection, + createdAt: Long, + authority: AuthorityCitation? = null, + ): List { + val newEpoch = heldEpoch + 1 + val prevCommit = prevCommit(heldEpoch, heldKey) + val blobs = + recipients.map { it.lowercase() }.distinct().map { recipient -> + ConcordRekey.blobForSigner(rotatorSigner, recipient.hexToByteArray(), channelId, newEpoch, newKey) + } + val widest = blobs.size.coerceAtLeast(1) + val envelopeTags = ConcordRekey.tags(channelId, newEpoch, heldEpoch, prevCommit, widest, widest, authority) + val envelope = + RumorAssembler + .assembleRumor(rotatorSigner.pubKey, createdAt, ConcordRekey.KIND, envelopeTags, "") + .toJson() + .encodeToByteArray() + .size + val chunks = ConcordRekey.chunkBlobs(blobs, envelope) + val stream = address(sealingRoot, channelId, newEpoch) + return chunks.mapIndexed { index, chunk -> + val tags = ConcordRekey.tags(channelId, newEpoch, heldEpoch, prevCommit, index + 1, chunks.size, authority) + val rumor = RumorAssembler.assembleRumor(rotatorSigner.pubKey, createdAt, ConcordRekey.KIND, tags, ConcordRekey.encodeContent(chunk)) + ConcordStreamEnvelope.wrap(rumor, stream, rotatorSigner, encrypted = true, createdAt = createdAt) + } + } + + /** + * Opens the kind-1059 [wraps] seen at channel-rekey addresses ([keys], address hex → key) and + * groups the well-formed chunks for [channelIdHex] into [ChannelRotation]s, keyed by + * (rotator, newepoch, prevcommit). Drops: a wrap at no known address, a plaintext seal (a rekey + * seal is encrypted, CORD-02 §5), a non-3303 rumor, a scope other than the channel, a + * non-decimal or 0-based chunk, a malformed `vac`. A rotation whose chunks disagree on + * `prevepoch`, `total` or citation is distrusted whole. + */ + fun rotations( + wraps: Collection, + keys: Map, + channelIdHex: HexKey, + ): List { + val scope = channelIdHex.lowercase() + val groups = LinkedHashMap>() + for (wrap in wraps.distinctBy { it.id }) { + val key = keys[wrap.pubKey] ?: continue + val opened = ConcordStreamEnvelope.openOrNull(wrap, key) ?: continue + if (opened.sealKind != ConcordStreamEnvelope.KIND_SEAL_ENCRYPTED) continue + val rumor = opened.rumor + if (rumor.kind != ConcordRekey.KIND) continue + if (rumor.tags.firstTagValue(ConcordRekey.TAG_SCOPE)?.lowercase() != scope) continue + val newEpoch = strictLong(rumor.tags.firstTagValue(ConcordRekey.TAG_NEWEPOCH)) ?: continue + val prevEpoch = strictLong(rumor.tags.firstTagValue(ConcordRekey.TAG_PREVEPOCH)) ?: continue + val prevCommit = rumor.tags.firstTagValue(ConcordRekey.TAG_PREVCOMMIT)?.lowercase() ?: continue + if (!HEX64.matches(prevCommit)) continue + val (index, total) = ConcordRekey.chunkOf(rumor.tags) ?: continue + val vacTag = rumor.tags.firstOrNull { it.isNotEmpty() && it[0] == VacTag.TAG_NAME } + val citation = if (vacTag == null) null else VacTag.parse(vacTag) ?: continue + val rotator = opened.author.lowercase() + groups + .getOrPut("$rotator:$newEpoch:$prevCommit") { ArrayList() } + .add(Parsed(rotator, newEpoch, prevEpoch, prevCommit, index, total, ConcordRekey.decodeContent(rumor.content), citation, rumor.createdAt)) + } + return groups.values.mapNotNull { parsed -> + val first = parsed.first() + if (parsed.any { it.prevEpoch != first.prevEpoch || it.total != first.total }) return@mapNotNull null + val citations = parsed.map { p -> p.citation?.let { VacTag.assemble(it).joinToString(",") } }.distinct() + if (citations.size > 1) return@mapNotNull null + ChannelRotation( + rotator = first.rotator, + channelIdHex = scope, + newEpoch = first.newEpoch, + prevEpoch = first.prevEpoch, + prevCommit = first.prevCommit, + total = first.total, + chunks = parsed.groupBy { it.index }.mapValues { (_, same) -> same.first().blobs }, + authority = first.citation, + createdAt = parsed.maxOf { it.createdAt }, + ) + } + } + + /** + * Walks [rotations] of the channel whose key this account holds ([heldKey] at [heldEpoch]) and + * decides what to do (Armada `useChannelRekeyWatch`): + * + * - only **complete** rotations past [heldEpoch] from an [honored] Rotator count; + * - epoch by epoch, ascending: a rotation carrying our blob **and** continuing the key we hold + * at that point (`prevepoch`/`prevcommit`) hands us the next key — racing Rotators at one + * epoch converge on the lexicographically lowest key — and the walk moves on from it; + * - a rotation that carries our blob off a key we can't verify is neither adoption nor removal + * (a gap to fetch); + * - a rotation with no blob for us, published at or after [joinedAtSecs] by a Rotator who + * [outranksMe], is the read-cut (removal needs no chain: hiding is local and safe); + * - a key adopted above the newest exclusion is a re-admission; otherwise the exclusion wins. + * + * The blob opens through [recipientSigner] (one NIP-44 decrypt: bunker-friendly). + */ + suspend fun walk( + rotations: List, + channelIdHex: HexKey, + heldKey: ByteArray, + heldEpoch: Long, + recipientSigner: NostrSigner, + joinedAtSecs: Long, + honored: (ChannelRotation) -> Boolean, + outranksMe: (HexKey) -> Boolean, + ): ChannelRekeyOutcome { + val channelId = channelIdHex.hexToByteArray() + val byEpoch = + rotations + .filter { it.channelIdHex.equals(channelIdHex, ignoreCase = true) && it.newEpoch > heldEpoch && it.complete && honored(it) } + .groupBy { it.newEpoch } + .toSortedMap() + if (byEpoch.isEmpty()) return ChannelRekeyOutcome.None + + var chainEpoch = heldEpoch + var chainKey = heldKey + var adoptedEpoch: Long? = null + var excludedAt: Long? = null + val stepped = ArrayList() + + for ((epoch, candidates) in byEpoch) { + var keyHere: ByteArray? = null + var publishedHere: Long? = null + var addressedHere = false + for (set in candidates) { + val locator = + ConcordKeyDerivation + .recipientLocator(set.rotator.hexToByteArray(), recipientSigner.pubKey.hexToByteArray(), channelId, epoch) + .toHexKey() + if (set.blobs().none { it.locator == locator }) continue + addressedHere = true + // Only a rotation off the key we hold at this point can hand us the next one. + if (set.prevEpoch != chainEpoch || set.prevCommit != prevCommit(chainEpoch, chainKey)) continue + val payload = + ConcordRekey.findPayloadWithSigner(set.blobs(), recipientSigner, set.rotator.hexToByteArray(), channelId, epoch) + // A channel blob is exactly 72 bytes; a wider (base-form) payload is malformed here. + if (payload == null || payload.newControlPk != null) continue + keyHere = keyHere?.let { if (ConcordRefounding.compareKeys(payload.newKey, it) < 0) payload.newKey else it } ?: payload.newKey + publishedHere = publishedHere?.let { minOf(it, set.createdAt) } ?: set.createdAt + } + val next = keyHere + if (next != null) { + stepped.add(0, SteppedChannelKey(chainKey, chainEpoch, publishedHere ?: 0)) + chainEpoch = epoch + chainKey = next + adoptedEpoch = epoch + continue + } + if (addressedHere) continue + if (candidates.any { it.createdAt >= joinedAtSecs && outranksMe(it.rotator) }) excludedAt = epoch + } + + val adopted = adoptedEpoch + if (adopted != null && (excludedAt == null || adopted > excludedAt)) { + return ChannelRekeyOutcome.Adopted(chainKey, adopted, stepped) + } + return excludedAt?.let { ChannelRekeyOutcome.Removed(it) } ?: ChannelRekeyOutcome.None + } + + private class Parsed( + val rotator: HexKey, + val newEpoch: Long, + val prevEpoch: Long, + val prevCommit: HexKey, + val index: Int, + val total: Int, + val blobs: List, + val citation: AuthorityCitation?, + val createdAt: Long, + ) + + private val HEX64 = Regex("^[0-9a-f]{64}$") + + /** Strict decimal (`0|[1-9][0-9]*`), as the reference client's `isTagDecimal`. */ + private fun strictLong(value: String?): Long? { + if (value.isNullOrEmpty() || value.length > 18 || !value.all { it in '0'..'9' }) return null + if (value.length > 1 && value[0] == '0') return null + return value.toLong() + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChannelKeyringTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChannelKeyringTest.kt new file mode 100644 index 0000000000..e567776c13 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChannelKeyringTest.kt @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord03Channels + +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityList +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The List side of Private Channel keys (CORD-02 §8, CORD-03 §2, CORD-06 §2): rotations replace + * the one current key in place, older keys are read from `seed` / a peer's `priors` but never + * written, and the reference client's `channel_cuts` floor survives a round trip and refuses a + * key below it. + */ +class ConcordChannelKeyringTest { + private val chan = "a1".repeat(32) + private val other = "b2".repeat(32) + private val k0 = "10".repeat(32) + private val k1 = "11".repeat(32) + private val k2 = "12".repeat(32) + + private fun entry(channels: List) = + ConcordCommunityListEntry( + id = "c0".repeat(32), + owner = "0f".repeat(32), + ownerSalt = "5a".repeat(32), + root = "22".repeat(32), + rootEpoch = 2, + privateChannels = channels, + name = "Test", + addedAt = 1, + ) + + private fun roundTrip(e: ConcordCommunityListEntry): ConcordCommunityListEntry = ConcordCommunityList.decode(ConcordCommunityList.encode(listOf(e))).single() + + @Test + fun aRotationReplacesTheKeyInPlaceKeepingUnknownFields() { + val wire = + """{"entries":[{"community_id":"${"c0".repeat(32)}","added_at":1,"current":{"community_id":"${"c0".repeat(32)}", + "owner":"${"0f".repeat(32)}","owner_salt":"${"5a".repeat(32)}","community_root":"${"22".repeat(32)}","root_epoch":2, + "channels":[{"id":"$chan","key":"$k0","epoch":0,"name":"mods","tint":"red"}],"relays":[],"name":"Test"}}]}""" + val held = ConcordCommunityList.decode(wire).single() + val rotated = assertNotNull(ConcordChannelKeyring.withRotatedKey(held, chan, k1, 1)) + val ch = rotated.privateChannels.single() + assertEquals(k1, ch.key) + assertEquals(1, ch.epoch) + assertEquals("mods", ch.name) + // Another client's field inside the channel object survives. + val back = roundTrip(rotated).privateChannels.single() + assertEquals(JsonPrimitive("red"), back.extras["tint"]) + // Never backward, never sideways. + assertNull(ConcordChannelKeyring.withRotatedKey(rotated, chan, k2, 1)) + assertNull(ConcordChannelKeyring.withRotatedKey(rotated, chan, k2, 0)) + } + + @Test + fun aCutRoundTripsAndRefusesOlderKeys() { + val held = entry(listOf(PrivateChannelKey(chan, k0, 0, "mods"), PrivateChannelKey(other, k1, 3, "vip"))) + val cut = ConcordChannelKeyring.withoutChannel(held, chan, 1) + assertEquals(listOf(other), cut.privateChannels.map { it.channelId }) + + // Written as the reference client's entry-level extension and read back. + val back = roundTrip(cut) + val encoded = ConcordJson.instance.parseToJsonElement(ConcordCommunityList.encode(listOf(cut))).jsonObject + val cuts = encoded["entries"]!!.jsonArray[0].jsonObject["channel_cuts"]!!.jsonArray + assertEquals(listOf(JsonObject(mapOf("id" to JsonPrimitive(chan), "epoch" to JsonPrimitive(1L)))), cuts.toList()) + assertEquals(mapOf(chan to 1L), ConcordChannelKeyring.cutsOf(back)) + + // A stale key below the cut never comes back; one at/above it (a re-grant) does. + assertTrue(ConcordChannelKeyring.isCutOff(back, chan, 0)) + assertNull(ConcordChannelKeyring.withChannelKey(back, PrivateChannelKey(chan, k0, 0))) + assertNotNull(ConcordChannelKeyring.withChannelKey(back, PrivateChannelKey(chan, k2, 1))) + + // Max wins; a lower cut never rolls it back. + assertEquals(1L, ConcordChannelKeyring.cutsOf(ConcordChannelKeyring.withCut(back, chan, 0))[chan]) + assertEquals(4L, ConcordChannelKeyring.cutsOf(ConcordChannelKeyring.withCut(back, chan, 4))[chan]) + } + + @Test + fun anUnknownFieldInsideACutSurvivesARaise() { + val wire = + """{"entries":[{"community_id":"${"c0".repeat(32)}","added_at":1,"channel_cuts":[{"id":"$chan","epoch":1,"why":"x"},{"id":"$other","epoch":2}], + "current":{"community_id":"${"c0".repeat(32)}","owner":"${"0f".repeat(32)}","owner_salt":"${"5a".repeat(32)}", + "community_root":"${"22".repeat(32)}","root_epoch":2,"channels":[],"relays":[],"name":"Test"}}]}""" + val held = ConcordCommunityList.decode(wire).single() + val raised = roundTrip(ConcordChannelKeyring.withCut(held, chan, 3)) + val cuts = raised.residue.entryExtras["channel_cuts"] as JsonArray + val mine = cuts.map { it.jsonObject }.single { (it["id"] as JsonPrimitive).content == chan } + assertEquals(JsonPrimitive("x"), mine["why"]) + assertEquals(mapOf(chan to 3L, other to 2L), ConcordChannelKeyring.cutsOf(raised)) + } + + @Test + fun olderKeysAreReadFromSeedAndPriorsButNeverWritten() { + val wire = + """{"entries":[{"community_id":"${"c0".repeat(32)}","added_at":1, + "seed":{"community_id":"${"c0".repeat(32)}","owner":"${"0f".repeat(32)}","owner_salt":"${"5a".repeat(32)}","community_root":"${"22".repeat(32)}", + "root_epoch":0,"channels":[{"id":"$chan","key":"$k0","epoch":0,"name":"mods"}],"relays":[],"name":"Test"}, + "current":{"community_id":"${"c0".repeat(32)}","owner":"${"0f".repeat(32)}","owner_salt":"${"5a".repeat(32)}","community_root":"${"22".repeat(32)}", + "root_epoch":2,"channels":[{"id":"$chan","key":"$k2","epoch":2,"name":"mods","priors":[{"key":"$k1","epoch":1,"retired_at":5}]}],"relays":[],"name":"Test"}}]}""" + val held = ConcordCommunityList.decode(wire).single() + assertEquals(listOf(1L to k1, 0L to k0), ConcordChannelKeyring.historicalKeys(held, chan).map { it.epoch to it.key }) + // The next privatisation climbs past every generation this entry knows of. + assertEquals(3, ConcordChannelKeyring.nextChannelEpoch(held, chan)) + assertEquals(10, ConcordChannelKeyring.nextChannelEpoch(held, chan, observedFloor = 9)) + assertEquals(1, ConcordChannelKeyring.nextChannelEpoch(held, other)) + + // A rotation we launch adds no prior of its own (CORD-02 §8 keeps intermediate keys out of the List). + val rotated = assertNotNull(ConcordChannelKeyring.withRotatedKey(held, chan, "13".repeat(32), 3)) + val priors = rotated.privateChannels.single().extras[ConcordChannelKeyring.PRIORS] as JsonArray + assertEquals(1, priors.size) + assertFalse(ConcordChannelKeyring.historicalKeys(rotated, chan).any { it.key == "13".repeat(32) }) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteVendTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteVendTest.kt index fc7577a4c6..dd4a66317c 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteVendTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteVendTest.kt @@ -22,6 +22,12 @@ package com.vitorpamplona.quartz.concord.cord05Invites import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeyring +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.concord.cord04Roles.ControlFixtures +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull @@ -87,15 +93,15 @@ class ConcordInviteVendTest { } @Test - fun aNewerEpochOfAHeldChannelReplacesIt() { - val newer = "ee".repeat(32) - val b = bundle(channels = listOf(InviteChannel(chanA, newer, 2, "mods"))) - assertEquals(listOf(chanA), ConcordInviteVend.catchUpChannelIds(held, b)) - val adopted = assertNotNull(ConcordInviteVend.adoptCatchUp(held, b)) - assertEquals(listOf(Triple(chanA, newer, 2L)), adopted.privateChannels.map { Triple(it.channelId, it.key, it.epoch) }) - - // Same or older epoch contributes nothing. - assertTrue(ConcordInviteVend.catchUpChannelIds(held, bundle(channels = listOf(InviteChannel(chanA, newer, 1)))).isEmpty()) + fun aBundleNeverReplacesAHeldKey() { + // A held key moves only through a channel rekey (prevcommit continuity). A bare bundle at a + // higher — even absurd — epoch contributes nothing, so a keyholder can't park us on a dead key. + val bogus = "ee".repeat(32) + for (epoch in listOf(2L, 1_000_000_000L)) { + val b = bundle(channels = listOf(InviteChannel(chanA, bogus, epoch, "mods"))) + assertTrue(ConcordInviteVend.catchUpChannelIds(held, b).isEmpty()) + assertNull(ConcordInviteVend.adoptCatchUp(held, b)) + } } @Test @@ -114,4 +120,106 @@ class ConcordInviteVendTest { assertTrue(ConcordInviteVend.catchUpChannelIds(held, bundle(channels = listOf(InviteChannel(chanB, "", 0)))).isEmpty()) assertNull(ConcordInviteVend.adoptCatchUp(held, bundle(channels = emptyList()))) } + + // ---- entitlement (Armada channelAccess.ts) and catch-up adoption (catchUpAdoption.ts) -------- + + private val owner = "0f".repeat(32) + private val alice = "a1".repeat(32) + private val bob = "b2".repeat(32) + private val modRole = "71".repeat(32) + private val accessRole = "72".repeat(32) + + private fun role( + roleId: String, + json: String, + ) = ControlEdition(ControlEntityKind.ROLE, roleId.hexToByteArray(), 0, null, null, json, owner, "role-$roleId", 0) + + private fun grant( + member: String, + roleIds: List, + version: Long = 0, + prev: ControlEdition? = null, + ) = ControlEdition( + ControlEntityKind.GRANT, + ControlFixtures.grantEid(member).hexToByteArray(), + version, + prev?.hash, + null, + """{"member":"$member","role_ids":[${roleIds.joinToString(",") { "\"$it\"" }}]}""", + owner, + "grant-$member-$version", + version, + ) + + private val roles = + listOf( + // A staff role (MANAGE_CHANNELS) with server scope, and a bit-less access role scoped to chanA. + role(modRole, """{"role_id":"$modRole","name":"Mod","position":2,"permissions":"2"}"""), + role(accessRole, """{"role_id":"$accessRole","name":"mods-room","position":10,"permissions":"0","scope":{"kind":"channel","channel_id":"$chanA"}}"""), + ) + + private fun authority(vararg extra: ControlEdition): AuthorityResolver = ControlFixtures.resolve(roles + extra, owner) + + @Test + fun theOwnerAndScopedRoleHoldersAreEntitled() { + val aliceIn = grant(alice, listOf(accessRole)) + val bobMod = grant(bob, listOf(modRole)) + val a = authority(aliceIn, bobMod) + assertEquals(setOf(owner, alice), ConcordInviteVend.entitledMembers(a, chanA)) + assertTrue(ConcordInviteVend.isEntitled(a, owner, chanB)) + // A server-scoped staff role grants authority, never read access. + assertTrue(!ConcordInviteVend.isEntitled(a, bob, chanA)) + // A link has no recipient and vends nothing; a member gets exactly their channels. + val held = listOf(PrivateChannelKey(chanA, keyA, 1, "mods"), PrivateChannelKey(chanB, keyB, 0, "vip")) + assertTrue(ConcordInviteVend.vendableChannels(held, a, null).isEmpty()) + assertEquals(listOf(chanA), ConcordInviteVend.vendableChannels(held, a, alice).map { it.channelId }) + assertEquals(listOf(chanA, chanB), ConcordInviteVend.vendableChannels(held, a, owner).map { it.channelId }) + } + + @Test + fun accessChangesNameWhoToVendAndWhoARotationMustCut() { + val aliceIn = grant(alice, listOf(accessRole)) + val before = authority() + val afterGrant = authority(aliceIn) + val granted = ConcordInviteVend.accessChanges(before, afterGrant, listOf(chanA, chanB)).single() + assertEquals(chanA, granted.channelIdHex) + assertEquals(setOf(alice), granted.gained) + assertTrue(granted.lost.isEmpty()) + + val afterRevoke = authority(aliceIn, grant(alice, emptyList(), version = 1, prev = aliceIn)) + val revoked = ConcordInviteVend.accessChanges(afterGrant, afterRevoke, listOf(chanA)).single() + assertEquals(setOf(alice), revoked.lost) + assertTrue(ConcordInviteVend.accessChanges(afterGrant, afterGrant, listOf(chanA)).isEmpty()) + } + + @Test + fun aStaffSentCatchUpForAnEntitledChannelIsAdoptedWithoutAClick() { + val member = held + val grantedChan = bundle(channels = listOf(InviteChannel(chanB, keyB, 0, "vip"))) + val scopedB = role("73".repeat(32), """{"role_id":"${"73".repeat(32)}","name":"vip","position":11,"permissions":"0","scope":{"kind":"channel","channel_id":"$chanB"}}""") + val a = authority(scopedB, grant(alice, listOf("73".repeat(32))), grant(bob, listOf(modRole))) + val live = setOf(chanA, chanB) + assertEquals(ConcordInviteVend.CatchUpVerdict.ADOPT, ConcordInviteVend.judgeCatchUp(a, live, alice, owner, grantedChan, member)) + assertEquals(ConcordInviteVend.CatchUpVerdict.ADOPT, ConcordInviteVend.judgeCatchUp(a, live, alice, bob, grantedChan, member)) + assertEquals(ConcordInviteVend.CatchUpVerdict.NO_FOLD, ConcordInviteVend.judgeCatchUp(null, live, alice, owner, grantedChan, member)) + // A plain keyholder (alice) can't plant a key in bob's list automatically. + assertEquals(ConcordInviteVend.CatchUpVerdict.SENDER_NOT_STAFF, ConcordInviteVend.judgeCatchUp(a, live, bob, alice, grantedChan, member)) + // Bob holds no role scoped to chanB. + assertEquals(ConcordInviteVend.CatchUpVerdict.NOT_ENTITLED, ConcordInviteVend.judgeCatchUp(a, live, bob, owner, grantedChan, member)) + assertEquals(ConcordInviteVend.CatchUpVerdict.NOTHING_NEW, ConcordInviteVend.judgeCatchUp(a, live, alice, owner, bundle(channels = listOf(InviteChannel(chanA, keyA, 1))), member)) + // A channel the fold doesn't know as a live Private Channel is never auto-adopted. + assertEquals(ConcordInviteVend.CatchUpVerdict.NOT_ENTITLED, ConcordInviteVend.judgeCatchUp(a, setOf(chanA), alice, owner, grantedChan, member)) + + // Manual accept: staff sender and a live Private Channel, entitlement waived by the click. + assertEquals(listOf(chanB), ConcordInviteVend.admissibleCatchUpIds(member, grantedChan, a, live, owner)) + assertTrue(ConcordInviteVend.admissibleCatchUpIds(member, grantedChan, a, live, alice).isEmpty()) + assertTrue(ConcordInviteVend.admissibleCatchUpIds(member, grantedChan, a, setOf(chanA), owner).isEmpty()) + } + + @Test + fun aCatchUpNeverRestoresAKeyBelowACut() { + val cut = ConcordChannelKeyring.withoutChannel(held, chanA, 2) + assertTrue(ConcordInviteVend.catchUpChannelIds(cut, bundle(channels = listOf(InviteChannel(chanA, keyA, 1)))).isEmpty()) + assertEquals(listOf(chanA), ConcordInviteVend.catchUpChannelIds(cut, bundle(channels = listOf(InviteChannel(chanA, keyB, 2))))) + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordChannelRekeyTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordChannelRekeyTest.kt new file mode 100644 index 0000000000..fd8489a45c --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordChannelRekeyTest.kt @@ -0,0 +1,230 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord06Rekey + +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.concord.crypto.ConcordLabels +import com.vitorpamplona.quartz.concord.crypto.GroupKey +import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip44Encryption.Nip44 +import kotlinx.coroutines.test.runTest +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** + * CORD-06 §1-2 single-channel Rekeys: the channel rekey address, the 72-byte scope-bound blob, + * the `prevcommit` continuity walk, chunk completeness, racing rotators and the removal rule — + * pinned to the reference client's `lib/rekey.ts` / `useChannelRekeyWatch`. + */ +class ConcordChannelRekeyTest { + private val admin = NostrSignerInternal(KeyPair()) + private val alice = NostrSignerInternal(KeyPair()) + private val bob = NostrSignerInternal(KeyPair()) + private val root = ByteArray(32) { 0x21 } + private val channelId = ByteArray(32) { 0x5C } + private val channelHex = channelId.toHexKey() + private val key0 = ByteArray(32) { 0x10 } + private val now = 1_700_000_000L + + private fun keysFor(vararg epochs: Long): Map = epochs.associate { e -> ConcordChannelRekey.address(root, channelId, e).let { it.publicKeyHex to it } } + + private suspend fun rotate( + heldKey: ByteArray, + heldEpoch: Long, + newKey: ByteArray, + recipients: List, + rotator: NostrSignerInternal = admin, + createdAt: Long = now, + ): List = ConcordChannelRekey.build(rotator, root, channelId, heldKey, heldEpoch, newKey, recipients + rotator.pubKey, createdAt) + + private suspend fun walk( + wraps: List, + me: NostrSignerInternal, + heldKey: ByteArray = key0, + heldEpoch: Long = 0, + joinedAt: Long = 0, + honored: (ChannelRotation) -> Boolean = { true }, + outranksMe: (HexKey) -> Boolean = { true }, + ): ChannelRekeyOutcome { + val keys = keysFor(*(heldEpoch + 1..heldEpoch + ConcordChannelRekey.LOOKAHEAD).toList().toLongArray()) + return ConcordChannelRekey.walk(ConcordChannelRekey.rotations(wraps, keys, channelHex), channelHex, heldKey, heldEpoch, me, joinedAt, honored, outranksMe) + } + + @Test + fun theChannelRekeyAddressIsTheRootKeyedRekeyPseudonym() { + // CORD-02 derivation table: concord/rekey-pseudonym, prior community_root, channel_id, new_epoch. + val address = ConcordChannelRekey.address(root, channelId, 3) + assertEquals(ConcordKeyDerivation.groupKey(ConcordLabels.REKEY_PSEUDONYM, root, channelId, 3).publicKeyHex, address.publicKeyHex) + // Keyed by the ROOT, never the channel key: every member can precompute it. + assertNotEquals(ConcordKeyDerivation.groupKey(ConcordLabels.REKEY_PSEUDONYM, key0, channelId, 3).publicKeyHex, address.publicKeyHex) + // Distinct per epoch and from the base-rotation address. + assertNotEquals(address.publicKeyHex, ConcordChannelRekey.address(root, channelId, 4).publicKeyHex) + assertNotEquals(address.publicKeyHex, ConcordKeyDerivation.baseRekeyAddress(root, channelId, 3).publicKeyHex) + } + + @OptIn(ExperimentalEncodingApi::class) + @Test + fun aChannelRotationCarriesTheSpecTagsAnd72ByteScopeBoundBlobs() = + runTest { + val newKey = ByteArray(32) { 0x77 } + val wraps = rotate(key0, 0, newKey, listOf(alice.pubKey)) + assertEquals(1, wraps.size) + val opened = assertNotNull(ConcordStreamEnvelope.openOrNull(wraps.single(), ConcordChannelRekey.address(root, channelId, 1))) + // A rekey seal is encrypted; the seal names the rotator. + assertEquals(ConcordStreamEnvelope.KIND_SEAL_ENCRYPTED, opened.sealKind) + assertEquals(admin.pubKey, opened.author) + val rumor = opened.rumor + assertEquals(ConcordRekey.KIND, rumor.kind) + // ["scope", channel_id] ["newepoch", held+1] ["prevepoch", held] ["prevcommit", A.5 over the held key] ["chunk","1","1"] + assertEquals( + listOf( + listOf("scope", channelHex), + listOf("newepoch", "1"), + listOf("prevepoch", "0"), + listOf("prevcommit", ConcordKeyDerivation.epochKeyCommitment(0, key0).toHexKey()), + listOf("chunk", "1", "1"), + ), + rumor.tags.map { it.toList() }, + ) + // Alice's blob opens under the admin<->alice pairwise key to exactly scope ‖ epoch_be ‖ key. + val blobs = ConcordRekey.decodeContent(rumor.content) + assertEquals(2, blobs.size) + val locator = ConcordKeyDerivation.recipientLocator(admin.pubKey.hexToByteArray(), alice.pubKey.hexToByteArray(), channelId, 1).toHexKey() + val mine = blobs.single { it.locator == locator } + val plain = Base64.Default.decode(Nip44.v2.decrypt(mine.wrapped, Nip44.v2.getConversationKey(alice.keyPair.privKey!!, admin.pubKey.hexToByteArray()))) + assertEquals(72, plain.size) + assertContentEquals(channelId, plain.copyOfRange(0, 32)) + assertContentEquals(byteArrayOf(0, 0, 0, 0, 0, 0, 0, 1), plain.copyOfRange(32, 40)) + assertContentEquals(newKey, plain.copyOfRange(40, 72)) + } + + @Test + fun aKeptMemberAdoptsTheNewKeyAndARemovedOneIsCut() = + runTest { + val newKey = ByteArray(32) { 0x42 } + val wraps = rotate(key0, 0, newKey, listOf(alice.pubKey)) + + val kept = assertIs(walk(wraps, alice)) + assertContentEquals(newKey, kept.key) + assertEquals(1, kept.epoch) + assertEquals(1, kept.steppedOver.size) + assertContentEquals(key0, kept.steppedOver.single().key) + + val cut = assertIs(walk(wraps, bob)) + assertEquals(1, cut.epoch) + } + + @Test + fun noBlobIsARemovalOnlyFromAnOutrankingRotatorAfterTheJoin() = + runTest { + val wraps = rotate(key0, 0, ByteArray(32) { 0x42 }, listOf(alice.pubKey)) + // A rotator that does not strictly outrank us cannot cut us (CORD-06 Authority). + assertSame(ChannelRekeyOutcome.None, walk(wraps, bob, outranksMe = { false })) + // A rotation that predates our join is history, not an exclusion. + assertSame(ChannelRekeyOutcome.None, walk(wraps, bob, joinedAt = now + 1)) + // An unauthorized rotator is ignored entirely. + assertSame(ChannelRekeyOutcome.None, walk(wraps, alice, honored = { false })) + } + + @Test + fun aRotationOffAKeyWeDoNotHoldIsNeitherAdoptedNorARemoval() = + runTest { + // The rotator claims to extend a different key at our epoch: a fork, never adopted. + val forged = rotate(ByteArray(32) { 0x66 }, 0, ByteArray(32) { 0x42 }, listOf(alice.pubKey)) + assertSame(ChannelRekeyOutcome.None, walk(forged, alice)) + } + + @Test + fun aMissedRotationIsWalkedInOnePass() = + runTest { + val key1 = ByteArray(32) { 0x31 } + val key2 = ByteArray(32) { 0x32 } + val wraps = rotate(key0, 0, key1, listOf(alice.pubKey)) + rotate(key1, 1, key2, listOf(alice.pubKey), createdAt = now + 10) + val adopted = assertIs(walk(wraps, alice)) + assertContentEquals(key2, adopted.key) + assertEquals(2, adopted.epoch) + assertEquals(listOf(1L, 0L), adopted.steppedOver.map { it.epoch }) + } + + @Test + fun aReadmissionAboveTheCutWinsAndACutAboveTheKeyWins() = + runTest { + val key1 = ByteArray(32) { 0x31 } + val key2 = ByteArray(32) { 0x32 } + // Cut at 1, re-admitted at 2 — but 2 extends key1, which bob never got: no adoption, cut stands. + val wraps = rotate(key0, 0, key1, listOf(alice.pubKey)) + rotate(key1, 1, key2, listOf(alice.pubKey, bob.pubKey)) + assertIs(walk(wraps, bob)) + // Alice kept through 1 and then cut at 2: the cut above her key wins. + val cutLater = rotate(key0, 0, key1, listOf(alice.pubKey)) + rotate(key1, 1, key2, emptyList()) + assertEquals(2, assertIs(walk(cutLater, alice)).epoch) + } + + @Test + fun racingRotatorsConvergeOnTheLowestKey() = + runTest { + val low = ByteArray(32) { 0x01 } + val high = ByteArray(32) { 0x7F } + val other = NostrSignerInternal(KeyPair()) + val wraps = rotate(key0, 0, high, listOf(alice.pubKey)) + rotate(key0, 0, low, listOf(alice.pubKey), rotator = other) + val rotations = ConcordChannelRekey.rotations(wraps, keysFor(1), channelHex) + // Two Rotators at one epoch never merge into one set. + assertEquals(2, rotations.size) + assertContentEquals(low, assertIs(walk(wraps, alice)).key) + } + + @Test + fun anIncompleteRotationIsNeverARemoval() = + runTest { + // 130 recipients span two chunks; drop the second. + val crowd = List(130) { KeyPair().pubKey.toHexKey() } + val wraps = rotate(key0, 0, ByteArray(32) { 0x42 }, crowd) + assertEquals(2, wraps.size) + val rotations = ConcordChannelRekey.rotations(wraps.take(1), keysFor(1), channelHex) + assertTrue(rotations.none { it.complete }) + assertSame(ChannelRekeyOutcome.None, walk(wraps.take(1), bob)) + assertIs(walk(wraps, bob)) + } + + @Test + fun aRotationForAnotherChannelOrAtAnUnwatchedAddressIsIgnored() = + runTest { + val otherChannel = ByteArray(32) { 0x5D } + val elsewhere = ConcordChannelRekey.build(admin, root, otherChannel, key0, 0, ByteArray(32) { 0x42 }, listOf(alice.pubKey), now) + // Not at our channel's addresses, and its scope names another channel. + assertTrue(ConcordChannelRekey.rotations(elsewhere, keysFor(1), channelHex).isEmpty()) + val atOurAddress = mapOf(ConcordChannelRekey.address(root, otherChannel, 1).let { it.publicKeyHex to it }) + assertTrue(ConcordChannelRekey.rotations(elsewhere, atOurAddress, channelHex).isEmpty()) + } +} From fb6525606502fcda885900c1282aba4ba7c6d7c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 19:12:36 +0000 Subject: [PATCH 17/19] =?UTF-8?q?feat(concord):=20Private=20Channels=20ver?= =?UTF-8?q?bs=20=E2=80=94=20create,=20privatise/publicise,=20vend=20on=20g?= =?UTF-8?q?rant,=20rotate=20on=20revoke,=20receive=20(CORD-03/05/06)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ConcordPrivateChannels (headless, shared with amy): create a Private Channel (key at channel epoch 0 + a bit-less access Role at the bottom of the roster, Armada createChannel), privatise a Public one (next channel epoch, floored at the highest rotation seen on the wire), publicise (flag only), the keep-set / authority for a rotation, the channel-rekey watch window (LOOKAHEAD epochs under the current and the prior root), receive + apply (race-safe: only from the epoch computed). - AccountConcordActions: createConcordChannel(private, accessRoleName), privatize/publicizeConcordChannel, rekeyConcordChannel (reserved key per rotation, every chunk confirmed before adopting), grant/revoke/ban reconcile channel access (Direct Invite limited to the gained channels; a rotation for the lost ones), the Refounding rotates every held Private Channel to its entitled kept set under the PRIOR root, and the revision tick drains channel rekeys and auto-adopts staff catch-ups (judgeCatchUp). - Catch-up hardening (audit): a Direct Invite catch-up only adds missing keys from a staff sender for live Private Channels, and is re-applied to the entry the List holds at write time (ConcordChannelListState.update) instead of a pre-suspend snapshot; every new verb writes the same way. - Session buffers channel-rekey wraps and AUTHs/subscribes their addresses; private history across a rotation reads the older keys the entry carries. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PmuzkD5qdqgv6dMeRMoC5N --- .../commons/actions/ConcordActions.kt | 33 +- .../commons/actions/ConcordPrivateChannels.kt | 387 ++++++++++++++++++ .../actions/ConcordSubscriptionPlanner.kt | 8 +- .../amethyst/commons/model/Account.kt | 5 + .../commons/model/AccountConcordActions.kt | 362 +++++++++++++++- .../model/concord/ConcordChannelListState.kt | 22 + .../model/concord/ConcordCommunitySession.kt | 27 +- .../model/concord/ConcordDirectInviteInbox.kt | 22 +- .../actions/ConcordPrivateChannelsTest.kt | 237 +++++++++++ .../concord/ConcordDirectInviteInboxTest.kt | 26 +- 10 files changed, 1103 insertions(+), 26 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPrivateChannels.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPrivateChannelsTest.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index 265fdf5e87..9eda3f4bfc 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeyring import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeys import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent import com.vitorpamplona.quartz.concord.cord03Channels.ConcordDisappearing @@ -212,24 +213,30 @@ object ConcordActions { /** * The older Chat Planes of a channel this account can still read, beside [currentChannelPlane]: - * - Public: its plane under every held prior root ([historicalChannelPlanes]), plus the - * private-era plane when a channel key is held (a channel that was Private before); - * - Private: none. Only the channel-key planes are its own; the root-derived plane is readable - * by every member, so showing it would present public content as private (Armada - * `channelsView`). With no priors kept per channel key, that leaves nothing. + * - Public: its plane under every held prior root ([historicalChannelPlanes]), plus every + * private-era plane a channel key is held for (a channel that was Private before); + * - Private: the planes of the older channel keys the entry still carries (its `seed` and a + * peer's `priors`, [ConcordChannelKeyring.historicalKeys]) — history across a channel rekey. + * Never the root-derived plane: every member reads that one, so showing it would present + * public content as private (Armada `channelsView`). */ fun historicalChannelPlanes( entry: ConcordCommunityListEntry, channelIdHex: HexKey, isPrivate: Boolean, ): List { - if (isPrivate) return emptyList() + val channelId = channelIdHex.hexToByteArray() + val olderKeys = + ConcordChannelKeyring.historicalKeys(entry, channelIdHex).map { old -> + ChannelPlane(channelIdHex, old.epoch, ConcordChannelKeys.privateChannel(old.key.hexToByteArray(), channelId, old.epoch)) + } + if (isPrivate) return olderKeys val rootEras = historicalChannelPlanes(entry.heldRoots, listOf(channelIdHex)) val privateEra = heldPrivateChannelKey(entry, channelIdHex)?.let { held -> - ChannelPlane(channelIdHex, held.epoch, ConcordChannelKeys.privateChannel(held.key.hexToByteArray(), channelIdHex.hexToByteArray(), held.epoch)) + ChannelPlane(channelIdHex, held.epoch, ConcordChannelKeys.privateChannel(held.key.hexToByteArray(), channelId, held.epoch)) } - return rootEras + listOfNotNull(privateEra) + return rootEras + listOfNotNull(privateEra) + olderKeys } /** @@ -676,6 +683,7 @@ object ConcordActions { expiresAtMs: Long? = null, name: String = entry.name, icon: ImagePointer? = null, + onlyChannelIds: Set? = null, ): CommunityInvite = CommunityInvite( communityId = entry.id, @@ -684,7 +692,12 @@ object ConcordActions { communityRoot = entry.root, rootEpoch = entry.rootEpoch, controlPk = entry.controlPk, - channels = ConcordInviteVend.toInviteChannels(ConcordInviteVend.vendableChannels(entry.privateChannels, authority, recipient)), + channels = + ConcordInviteVend.toInviteChannels( + ConcordInviteVend + .vendableChannels(entry.privateChannels, authority, recipient) + .filter { onlyChannelIds == null || it.channelId.lowercase() in onlyChannelIds }, + ), relays = entry.relays.take(ConcordInviteBundle.MAX_COMMUNITY_RELAYS), name = name.ifBlank { entry.name }, icon = icon, @@ -705,6 +718,7 @@ object ConcordActions { sender: HexKey, recipient: HexKey, expiresAtMs: Long? = null, + onlyChannelIds: Set? = null, ): ConcordDirectInviteDraft { val to = recipient.lowercase() if (!HEX64.matches(to)) return ConcordDirectInviteDraft.Refused(ConcordDirectInviteSendResult.INVALID_RECIPIENT) @@ -719,6 +733,7 @@ object ConcordActions { expiresAtMs = expiresAtMs, name = state.metadata?.name ?: entry.name, icon = state.metadata?.icon, + onlyChannelIds = onlyChannelIds?.mapTo(HashSet()) { it.lowercase() }, ), ) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPrivateChannels.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPrivateChannels.kt new file mode 100644 index 0000000000..7cdd4bd189 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPrivateChannels.kt @@ -0,0 +1,387 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.actions + +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeyring +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver +import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordLimits +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity +import com.vitorpamplona.quartz.concord.cord04Roles.RoleScope +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteVend +import com.vitorpamplona.quartz.concord.cord06Rekey.ChannelRekeyOutcome +import com.vitorpamplona.quartz.concord.cord06Rekey.ChannelRotation +import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordChannelRekey +import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding +import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRotationAuthority +import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys +import com.vitorpamplona.quartz.concord.crypto.GroupKey +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.utils.RandomInstance + +/** + * A Private Channel just minted or privatised (CORD-03 §2): its [channelIdHex], the independent + * [key] to store in the Community List **before** [wraps] publish (a lost List write would orphan + * the only copy), the access Role minted beside it ([roleIdHex]), and the Control editions to + * publish in order — the Role first (an orphan Role is inert), then the channel edition. + */ +class PrivateChannelBuild( + val channelIdHex: HexKey, + val key: PrivateChannelKey, + val roleIdHex: HexKey, + val wraps: List, +) + +/** + * Private Channels end to end (CORD-03 §1-2, CORD-04 §2, CORD-05 §6, CORD-06 §1-3) as pure + * builders and decisions, shared by the app and `amy`. The network and the Community List write + * stay with the caller. + * + * Who may read a Private Channel is its Roles: a Role scoped `{kind:"channel", channel_id}` IS its + * access list ([ConcordInviteVend.isEntitled]). Read access is enforced by key possession alone, so + * this decides who a key is delivered TO (a Direct Invite on grant) and who a rotation keeps (a + * channel rekey on revoke). Pinned to Armada's `channelAccess.ts`, `useCommunityActions` + * (`createChannel`, `privatiseChannel`, `publiciseChannel`) and `useRekey` (`useChannelRekey`, + * `useChannelRekeyWatch`). + */ +object ConcordPrivateChannels { + /** + * The position a new access Role takes (Armada `accessRolePosition`): the bottom of the roster, + * never above what [actor] may mint (the owner mints from 1, anyone else strictly below their + * own rank), so a grantee is never promoted by being let into a channel. Null when [actor] holds + * no rank to mint from. + */ + fun accessRolePosition( + authority: AuthorityResolver?, + actor: HexKey, + owner: HexKey, + ): Long? { + val ceiling = + if (actor.equals(owner, ignoreCase = true)) { + 1L + } else { + (authority?.rank(actor) ?: return null) + 1 + } + val lowest = + authority + ?.roles() + ?.values + ?.filterNot { it.deleted } + ?.maxOfOrNull { it.position } ?: 0L + return maxOf(ceiling, lowest + 1) + } + + /** [name] cut to the protocol's 64-byte cap on a character boundary (Armada slices the same way). */ + private fun fitName(name: String): String { + var out = name + while (!ConcordLimits.nameFits(out)) out = out.dropLast(1) + return out + } + + /** The bit-less access Role scoped to [channelIdHex] (CORD-04 §2): read access is the key, never a bit. */ + fun accessRole( + name: String, + channelIdHex: HexKey, + position: Long, + ): RoleEntity = + RoleEntity( + name = fitName(name), + position = position, + permissions = "0", + scope = RoleScope(kind = "channel", channelId = channelIdHex.lowercase()), + ) + + /** + * A new channel (CORD-03 §2): a random `channel_id`, and for a Private one an independent key + * at channel epoch 0 plus its access Role (Armada `createChannel`: a born-private channel is + * epoch 0; the first *privatisation* of a public channel is epoch 1). Returns null when the + * name is invalid or the actor has no rank to mint the Role from. + */ + suspend fun create( + actor: NostrSigner, + cp: ControlPlaneKeys, + communityId: ByteArray, + name: String, + accessRoleName: String?, + current: List, + authority: AuthorityResolver?, + owner: HexKey, + createdAt: Long, + ): PrivateChannelBuild? { + val channel = ChannelEntity(name = name.trim(), private = true) + if (!channel.hasValidName()) return null + val position = accessRolePosition(authority, actor.pubKey, owner) ?: return null + val channelId = RandomInstance.bytes(32) + val channelIdHex = channelId.toHexKey() + val roleId = RandomInstance.bytes(32) + val role = accessRole(accessRoleName?.trim()?.ifBlank { null } ?: channel.name, channelIdHex, position) + val roleWrap = ConcordModeration.defineRole(actor, cp, communityId, roleId, role, current, createdAt, owner = owner) + val channelWrap = ConcordModeration.defineChannel(actor, cp, communityId, channelId, channel, current, createdAt, owner = owner) + return PrivateChannelBuild( + channelIdHex = channelIdHex, + key = PrivateChannelKey(channelIdHex, ConcordChannelRekey.mintKey().toHexKey(), 0, channel.name), + roleIdHex = roleId.toHexKey(), + wraps = listOf(roleWrap, channelWrap), + ) + } + + /** + * Converts the Public channel [channelIdHex] to Private (CORD-03 §2): a fresh independent key at + * the NEXT channel epoch ([ConcordChannelKeyring.nextChannelEpoch], floored at [observedFloor], + * the highest channel rotation seen on the wire), a new access Role, and the channel edition + * flipping `private` on — every other field of [standing] carried through. Protects the future + * only: the public era stays readable to every member. Null when the actor can't mint the Role. + */ + suspend fun privatize( + actor: NostrSigner, + cp: ControlPlaneKeys, + entry: ConcordCommunityListEntry, + channelIdHex: HexKey, + standing: ChannelEntity, + accessRoleName: String?, + current: List, + authority: AuthorityResolver?, + createdAt: Long, + observedFloor: Long = 0, + ): PrivateChannelBuild? { + if (standing.private || standing.deleted) return null + val position = accessRolePosition(authority, actor.pubKey, entry.owner) ?: return null + val communityId = entry.id.hexToByteArray() + val roleId = RandomInstance.bytes(32) + val role = accessRole(accessRoleName?.trim()?.ifBlank { null } ?: standing.name, channelIdHex, position) + val roleWrap = ConcordModeration.defineRole(actor, cp, communityId, roleId, role, current, createdAt, owner = entry.owner) + val channelWrap = ConcordModeration.defineChannel(actor, cp, communityId, channelIdHex.hexToByteArray(), standing.copy(private = true), current, createdAt, owner = entry.owner) + val epoch = ConcordChannelKeyring.nextChannelEpoch(entry, channelIdHex, observedFloor) + return PrivateChannelBuild( + channelIdHex = channelIdHex.lowercase(), + key = PrivateChannelKey(channelIdHex.lowercase(), ConcordChannelRekey.mintKey().toHexKey(), epoch, standing.name), + roleIdHex = roleId.toHexKey(), + wraps = listOf(roleWrap, channelWrap), + ) + } + + /** + * Converts the Private channel [channelIdHex] back to Public (CORD-03 §2): the flag only. The + * channel derives from the `community_root` from here on; the held key stays in the List so its + * holders keep reading the private era, which a later joiner never can. Null when it is not + * private. + */ + suspend fun publicize( + actor: NostrSigner, + cp: ControlPlaneKeys, + communityId: ByteArray, + channelIdHex: HexKey, + standing: ChannelEntity, + current: List, + owner: HexKey, + createdAt: Long, + ): Event? { + if (!standing.private || standing.deleted) return null + return ConcordModeration.defineChannel(actor, cp, communityId, channelIdHex.hexToByteArray(), standing.copy(private = false), current, createdAt, owner = owner) + } + + // ---- channel rotations (CORD-06 §1-2) ------------------------------------- + + /** + * Whether [actor] may launch a single-channel Rekey cutting [removed] (CORD-06 §3 Authority): + * `MANAGE_CHANNELS` (or `BAN`, a Refounding's) and strictly outranking every removed target. + * The owner may always; the owner is never a valid target. + */ + fun canRotate( + authority: AuthorityResolver, + actor: HexKey, + removed: Collection, + bit: Int = ConcordPermissions.MANAGE_CHANNELS, + ): Boolean { + if (authority.isOwner(actor)) return true + if (!authority.hasPermission(actor, bit)) return false + return removed.all { it.equals(actor, ignoreCase = true) || authority.canActOn(actor, it, bit) } + } + + /** + * The members a rotation of [channelIdHex] keeps (Armada `handleRotateChannelKey`): exactly + * those entitled today ([ConcordInviteVend.entitledMembers]) plus the [rotator], who must keep + * every key or nobody could rotate the channel next time. + */ + fun keepSet( + authority: AuthorityResolver, + channelIdHex: HexKey, + rotator: HexKey, + ): Set = ConcordInviteVend.entitledMembers(authority, channelIdHex) + rotator.lowercase() + + /** + * The wraps of one rotation of [held] to [newKey] for [keep], sealed under [sealingRoot] (the + * current root for a single-channel Rekey, the PRIOR root inside a Refounding — CORD-06 §3), and + * citing [authority] on every chunk. + */ + suspend fun buildRotation( + rotator: NostrSigner, + sealingRoot: ByteArray, + held: PrivateChannelKey, + newKey: ByteArray, + keep: Collection, + createdAt: Long, + authority: AuthorityCitation?, + ): List = + ConcordChannelRekey.build( + rotatorSigner = rotator, + sealingRoot = sealingRoot, + channelId = held.channelId.hexToByteArray(), + heldKey = held.key.hexToByteArray(), + heldEpoch = held.epoch, + newKey = newKey, + recipients = keep + rotator.pubKey, + createdAt = createdAt, + authority = authority, + ) + + /** The roots a member watches channel rekeys under: the current one and the canonical prior one (CORD-06 §3). */ + fun watchRoots(entry: ConcordCommunityListEntry): List { + val prior = ConcordRefounding.canonicalHeldRoots(entry.heldRoots).filter { it.epoch == entry.rootEpoch - 1 } + return (listOf(entry.root) + prior.map { it.key }).distinct().map { it.hexToByteArray() } + } + + /** + * Every channel-rekey address this entry should watch (CORD-06 §2): per held Private Channel, + * the next [ConcordChannelRekey.LOOKAHEAD] channel epochs past the held one, under each of + * [watchRoots]. Address hex → key. + */ + fun watchKeys(entry: ConcordCommunityListEntry): Map { + val out = LinkedHashMap() + val roots = watchRoots(entry) + for (held in entry.privateChannels) { + if (ConcordChannelKeyring.heldKey(entry, held.channelId) == null) continue + val channelId = held.channelId.hexToByteArray() + for (root in roots) { + for (ahead in 1..ConcordChannelRekey.LOOKAHEAD) { + val key = ConcordChannelRekey.address(root, channelId, held.epoch + ahead) + out[key.publicKeyHex] = key + } + } + } + return out + } + + /** + * Whether a received channel rotation's Rotator may be honored (CORD-06 §3 Authority): the owner, + * or a non-banned holder of `MANAGE_CHANNELS` (a single-channel Rekey) or `BAN` (a Refounding's + * channel rekeys), whose `vac` cites a Grant our fold has synced. Key possession is never + * authority. + */ + fun isHonoredRotation( + entry: ConcordCommunityListEntry, + editions: Collection, + authority: AuthorityResolver, + rotation: ChannelRotation, + ): Boolean { + val rotator = rotation.rotator + if (!authority.isOwner(rotator)) { + if (authority.isBanned(rotator)) return false + if (!authority.hasPermission(rotator, ConcordPermissions.MANAGE_CHANNELS) && !authority.hasPermission(rotator, ConcordPermissions.BAN)) return false + } + val heads = ConcordRotationAuthority.headsOf(editions, entry.id, entry.owner) + return ConcordRotationAuthority.citationSatisfied(entry.id, rotator, entry.owner, rotation.authority, heads) + } + + /** Whether [rotator] strictly outranks [me] — only such a Rotator's omission is a cut (CORD-06 §3). */ + fun outranks( + authority: AuthorityResolver, + rotator: HexKey, + me: HexKey, + ): Boolean { + if (authority.isOwner(me)) return false + val theirs = authority.rank(rotator) ?: return false + val mine = authority.rank(me) ?: Long.MAX_VALUE + return theirs < mine + } + + /** + * What the buffered channel-rekey [wraps] mean for each Private Channel [entry] holds a key for + * (CORD-06 §2), per channel id: an adoption moves that channel's key forward, a cut drops it and + * records `channel_cuts`. Channels with nothing to do are omitted. The caller applies the result + * inside its List write ([applyOutcome]). + */ + suspend fun receive( + entry: ConcordCommunityListEntry, + wraps: Collection, + editions: Collection, + authority: AuthorityResolver, + recipient: NostrSigner, + ): Map { + if (wraps.isEmpty()) return emptyMap() + val keys = watchKeys(entry) + val me = recipient.pubKey + val joinedAtSecs = entry.addedAt / 1000 + val out = LinkedHashMap() + for (held in entry.privateChannels) { + if (ConcordChannelKeyring.heldKey(entry, held.channelId) == null) continue + val id = held.channelId.lowercase() + val rotations = ConcordChannelRekey.rotations(wraps, keys, id) + if (rotations.isEmpty()) continue + val outcome = + ConcordChannelRekey.walk( + rotations = rotations, + channelIdHex = id, + heldKey = held.key.hexToByteArray(), + heldEpoch = held.epoch, + recipientSigner = recipient, + joinedAtSecs = joinedAtSecs, + honored = { isHonoredRotation(entry, editions, authority, it) }, + outranksMe = { outranks(authority, it, me) }, + ) + if (outcome !is ChannelRekeyOutcome.None) out[id] = outcome + } + return out + } + + /** + * [current] with [outcomes] applied — only where the channel is still at the epoch the outcome + * was computed from ([fromEpochs]), so a write racing another adoption never rolls a key back — + * or null when nothing changed. + */ + fun applyOutcome( + current: ConcordCommunityListEntry, + outcomes: Map, + fromEpochs: Map, + ): ConcordCommunityListEntry? { + var next = current + for ((id, outcome) in outcomes) { + val held = ConcordChannelKeyring.heldKey(next, id) ?: continue + if (held.epoch != fromEpochs[id]) continue + next = + when (outcome) { + is ChannelRekeyOutcome.Adopted -> ConcordChannelKeyring.withRotatedKey(next, id, outcome.key.toHexKey(), outcome.epoch) ?: next + is ChannelRekeyOutcome.Removed -> ConcordChannelKeyring.withoutChannel(next, id, outcome.epoch) + ChannelRekeyOutcome.None -> next + } + } + return if (next === current) null else next + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt index 18f47edd00..1cffadf0cb 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt @@ -96,7 +96,8 @@ object ConcordSubscriptionPlanner { * The off-channel planes every joined community subscribes to upfront (known * from the entry alone): the Guestbook Plane (membership motions) and the * next-epoch base-rekey address (so an inbound Refounding is received live, - * CORD-06), and the dissolution tombstone address (CORD-02 §9). All are kind-1059 + * CORD-06), the dissolution tombstone address (CORD-02 §9), and every held Private Channel's + * next channel-rekey addresses (CORD-06 §2). All are kind-1059 * wraps authored by their derived stream address. */ fun auxiliaryPlaneSubs(entries: List): List = @@ -114,7 +115,10 @@ object ConcordSubscriptionPlanner { ConcordPlaneSub(channelId = null, pubKeyHex = dissolved.publicKeyHex, relays = relays), // The current epoch's own rekey address, so a racing sibling can heal us (CORD-06 §3). sibling?.let { ConcordPlaneSub(channelId = null, pubKeyHex = it.publicKeyHex, relays = relays) }, - ) + ) + + // Each held Private Channel's next channel-rekey addresses (CORD-06 §2), so a rotation + // that moves the key forward — or cuts us — is received live. + ConcordPrivateChannels.watchKeys(e).keys.map { ConcordPlaneSub(channelId = null, pubKeyHex = it, relays = relays) } } /** diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Account.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Account.kt index 7c17764ec3..ff9cb04c6d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Account.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Account.kt @@ -4185,6 +4185,11 @@ class Account( // A promotion to staff delivers the Control Plane write key inside the Grant // itself (CORD-04 §3), so the fold that seats the role is also when it arrives. runCatching { concord.drainConcordStaffGrants() }.onFailure { Log.w("Concord", "staff grant drain failed", it) } + // A Private Channel rotation lands on its channel-rekey address (CORD-06 §2): adopt the new + // key, or drop the channel when it cut us. + runCatching { concord.drainConcordChannelRekeys() }.onFailure { Log.w("Concord", "channel rekey drain failed", it) } + // A Grant folding late turns a parked catch-up invite into one we adopt without a click. + runCatching { concord.drainConcordCatchUps() }.onFailure { Log.w("Concord", "catch-up drain failed", it) } // A rotation we were *excluded* from produces no rekey to drain, so it can only be // found by re-resolving the invite link we joined through. Rate-limited internally. runCatching { concord.recoverStrandedConcordCommunities() }.onFailure { Log.w("Concord", "stranded recovery failed", it) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt index 325314bee5..f56dbe67d7 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.commons.actions.ConcordPinContext import com.vitorpamplona.amethyst.commons.actions.ConcordPinOutcome import com.vitorpamplona.amethyst.commons.actions.ConcordPinWrite import com.vitorpamplona.amethyst.commons.actions.ConcordPinning +import com.vitorpamplona.amethyst.commons.actions.ConcordPrivateChannels import com.vitorpamplona.amethyst.commons.actions.ConcordReceive import com.vitorpamplona.amethyst.commons.actions.ConcordSubscriptionPlanner import com.vitorpamplona.amethyst.commons.defaults.DefaultDmIndexerRelays @@ -53,8 +54,10 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordListTooLargeExcep import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeyring import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent import com.vitorpamplona.quartz.concord.cord03Channels.concordEpoch +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity import com.vitorpamplona.quartz.concord.cord04Roles.ConcordLimits @@ -68,8 +71,11 @@ import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEntry import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEvent import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListTombstone import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteRegistry +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteVend import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus import com.vitorpamplona.quartz.concord.cord05Invites.InviteRelayDictionary +import com.vitorpamplona.quartz.concord.cord06Rekey.ChannelRekeyOutcome +import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordChannelRekey import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding import com.vitorpamplona.quartz.concord.cord06Rekey.IncompleteControlPlaneException import com.vitorpamplona.quartz.concord.cord06Rekey.PendingRefounding @@ -131,6 +137,9 @@ private const val RECOVERY_CHECK_INTERVAL_MS = 15 * 60 * 1000L */ private const val MAX_REFOUNDING_RECIPIENTS = 5_000 +// How many channel epochs a privatisation probes for earlier rotations (Armada MAX_PROBED_CHANNEL_EPOCH). +private const val MAX_PROBED_CHANNEL_EPOCH = 32L + /** A lowercase 32-byte hex key (the Guestbook `invite` tag's creator). */ private val HEX64 = Regex("^[0-9a-f]{64}$") @@ -191,6 +200,21 @@ class AccountConcordActions( /** Adds or replaces [entry] in the Community List; false when it could not be written. */ private suspend fun persistConcordEntry(entry: ConcordCommunityListEntry): Boolean = writeConcordList { it.follow(entry) } + /** + * Rewrites the held entry for [communityId] through [transform], applied to the entry **as the + * List holds it inside the write** ([ConcordChannelListState.update]) — never to a snapshot read + * before a suspension, which a concurrent rekey or import could have moved on. True only when + * [transform] produced a change and it was written. + */ + private suspend fun updateConcordEntry( + communityId: String, + transform: (ConcordCommunityListEntry) -> ConcordCommunityListEntry?, + ): Boolean { + var changed = false + val ok = writeConcordList { list -> list.update(communityId) { cur -> transform(cur)?.also { changed = true } } } + return ok && changed + } + /** Publishes a Guestbook JOIN (kind 3306) for [entry] to its community relays. */ private suspend fun announceConcordGuestbookJoin( entry: ConcordCommunityListEntry, @@ -814,9 +838,42 @@ class AccountConcordActions( val filter = ConcordActions.directInvitesFilter(account.signer.pubKey, directInviteInbox.since()) val wraps = account.client.fetchAll(filters = relays.associateWith { listOf(filter) }) wraps.distinctBy { it.id }.forEach { directInviteInbox.offer(it) } + drainConcordCatchUps() return (directInviteInbox.pending.value.keys - before).size } + /** + * Adopts, without a click, every parked catch-up the held fold says is exactly the delivery a + * Grant prescribes (Armada `judgeCatchUp`, [ConcordInviteVend.judgeCatchUp]): a staff sender, a + * recipient who isn't banned, and only live Private Channels our Roles entitle us to. Consent + * came from the Grant. Anything else waits for a manual Accept. Runs after an inbox sweep and on + * the revision tick (a Grant folding late turns a waiting catch-up adoptable). + */ + internal suspend fun drainConcordCatchUps() { + if (!account.isWriteable()) return + val me = account.signer.pubKey + val now = TimeUtils.nowMillis() + for (opened in directInviteInbox.pending.value.values) { + if (opened.isExpired(now)) continue + val held = + account.concordChannelList.liveCommunities.value + .firstOrNull { it.id.equals(opened.invite.communityId, ignoreCase = true) } ?: continue + val state = + account.concordSessions + .sessionFor(held.id) + ?.state + ?.value ?: continue + if (state.dissolved) continue + val verdict = ConcordInviteVend.judgeCatchUp(state.authority, state.privateChannelIds, me, opened.sender, opened.invite, held) + if (verdict != ConcordInviteVend.CatchUpVerdict.ADOPT) continue + val ids = ConcordInviteVend.catchUpChannelIds(held, opened.invite) + if (updateConcordEntry(held.id) { cur -> ConcordInviteVend.adoptCatchUp(cur, opened.invite, ids) }) { + Log.i("Concord") { "Adopted a granted Private Channel key for ${held.id} from ${opened.sender}" } + directInviteInbox.resolve(opened.wrapId) + } + } + } + /** * The recipient's giftwrap inbox (CORD-05 §6): their kind-10050 DM relays, else NIP-65 read * relays — from the cache when we have their lists, fetched otherwise — else the stock set. @@ -852,6 +909,7 @@ class AccountConcordActions( communityId: String, recipientPubKey: HexKey, expiresAtMs: Long? = null, + onlyChannelIds: Set? = null, ): ConcordDirectInviteSendResult { if (!account.isWriteable()) return ConcordDirectInviteSendResult.NOT_WRITEABLE val recipient = recipientPubKey.lowercase() @@ -865,7 +923,7 @@ class AccountConcordActions( ?.state ?.value ?: return ConcordDirectInviteSendResult.ROSTER_NOT_LOADED val invite = - when (val draft = ConcordActions.draftDirectInvite(entry, state, account.signer.pubKey, recipient, expiresAtMs)) { + when (val draft = ConcordActions.draftDirectInvite(entry, state, account.signer.pubKey, recipient, expiresAtMs, onlyChannelIds)) { is ConcordDirectInviteDraft.Refused -> return draft.reason is ConcordDirectInviteDraft.Ready -> draft.invite } @@ -907,9 +965,12 @@ class AccountConcordActions( // No folded roster yet: whether it bans us is unknown, so the invite waits. DirectInviteAcceptPlan.RosterNotLoaded -> ConcordInviteResult.NotReachable DirectInviteAcceptPlan.NothingNew -> ConcordInviteResult.Joined(bundle.communityId) - // Keys only, on the held base: no second Guestbook Join. - is DirectInviteAcceptPlan.CatchUp -> - if (persistConcordEntry(plan.entry)) ConcordInviteResult.Joined(bundle.communityId) else ConcordInviteResult.NotReachable + // Keys only, on the held base: no second Guestbook Join. Re-applied to the entry the + // List holds at write time, so a root imported meanwhile is never written back over. + is DirectInviteAcceptPlan.CatchUp -> { + val ok = writeConcordList { list -> list.update(plan.entry.id) { cur -> ConcordInviteVend.adoptCatchUp(cur, bundle, plan.channelIds) } } + if (ok) ConcordInviteResult.Joined(bundle.communityId) else ConcordInviteResult.NotReachable + } DirectInviteAcceptPlan.Join -> joinValidatedConcordInvite( bundle = bundle, @@ -1307,6 +1368,7 @@ class AccountConcordActions( val session = account.concordSessions.sessionFor(communityId) ?: return false if (!account.isWriteable()) return false val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_ROLES, member) ?: return false + val before = session.state.value?.authority // A Grant that first makes its member staff must deliver the control_root in the same // edition (CORD-04 §3) — grantWithStaffDelivery attaches the pairwise wrap when the // roles carry a Control-writing bit and we hold the secret to hand over. @@ -1324,6 +1386,8 @@ class AccountConcordActions( epoch = session.entry.rootEpoch, ) publishConcordWrap(session.entry, wrap) + // Role-gated channel keys follow the Grant: vend what it opened, rotate what it closed. + reconcileConcordChannelAccess(communityId, before) return true } @@ -1429,8 +1493,10 @@ class AccountConcordActions( val session = account.concordSessions.sessionFor(communityId) ?: return false if (!account.isWriteable()) return false val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_ROLES, member) ?: return false + val before = session.state.value?.authority val grantWrap = ConcordModeration.grant(account.signer, cp, communityId.hexToByteArray(), member, emptyList(), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) publishConcordWrap(session.entry, grantWrap) + reconcileConcordChannelAccess(communityId, before) return true } @@ -1487,14 +1553,20 @@ class AccountConcordActions( val session = account.concordSessions.sessionFor(communityId) ?: return false if (!account.isWriteable()) return false val cp = controlKeysForAction(session, ConcordPermissions.BAN, member) ?: return false + val before = session.state.value?.authority val wrap = ConcordModeration.ban(account.signer, cp, communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) publishConcordWrap(session.entry, wrap) // Judged on the fold that now carries the ban (publishConcordWrap ingests it first). val state = session.state.value if (state != null && state.banRequiresRefounding(listOf(member))) { + // The Refounding rotates every held Private Channel to its entitled set (CORD-06 §3). if (!refoundConcordCommunity(communityId, setOf(member))) { Log.w("Concord") { "Banned $member from the Private community $communityId, but its Refounding did not complete" } } + } else { + // A Public ban keeps the base, so the Private Channels the target could read are cut by + // their own rekeys (CORD-04 §6: "a Private-Channel rekey for a channel-scoped cut"). + reconcileConcordChannelAccess(communityId, before) } return true } @@ -1850,9 +1922,14 @@ class AccountConcordActions( } if (!compactionLanded) Log.w("Concord") { "Refounding ${entry.id}: some compacted Control Plane heads were not accepted at epoch ${build.newEpoch}" } + // 5b. Rotate every held Private Channel (CORD-06 §3), each to its OWN entitled set among the + // kept members, sealed under the PRIOR root so a base-fork loser can still open it. A + // channel that fails to land keeps its key (and is logged): resumable, not atomic. + val rotatedEntry = rotatePrivateChannelsForRefounding(entry, recipients.toSet(), priorRoot, citation) + // 6. Adopt the new epoch ourselves. This rebuilds our session under the new root and // re-folds the compacted Control Plane (with the ban), dropping the removed members. - val adopted = adoptConcordRoot(entry, keys.newRoot, build.newEpoch, build.newControlKeys.address.hexToByteArray(), keys.newControlRoot) + val adopted = adoptConcordRoot(rotatedEntry, keys.newRoot, build.newEpoch, build.newControlKeys.address.hexToByteArray(), keys.newControlRoot) pendingConcordRefoundings.remove(entry.id) // 7. Move every link we minted to the new epoch. Without this the Refounding orphans them, @@ -1864,6 +1941,40 @@ class AccountConcordActions( return compactionLanded } + /** + * The Refounding's channel duty (CORD-06 §3): every held key of a live Private Channel is + * rotated to the members still entitled to it ∩ [kept], plus ourselves, sealed under + * [priorRoot]. Returns [entry] with each rotated channel moved to its new key (the caller adopts + * the new root from it); a channel whose rotation didn't land keeps its old key. + */ + private suspend fun rotatePrivateChannelsForRefounding( + entry: ConcordCommunityListEntry, + kept: Set, + priorRoot: ByteArray, + citation: AuthorityCitation?, + ): ConcordCommunityListEntry { + val session = account.concordSessions.sessionFor(entry.id) ?: return entry + val state = session.state.value ?: return entry + val me = account.signer.pubKey.lowercase() + val publishTo = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } + val keptLower = kept.mapTo(HashSet()) { it.lowercase() } + var next = entry + for (held in entry.privateChannels) { + val id = held.channelId.lowercase() + if (id !in state.privateChannelIds || ConcordChannelKeyring.heldKey(entry, id) == null) continue + val keep = ConcordPrivateChannels.keepSet(state.authority, id, me).filterTo(HashSet()) { it in keptLower || it == me } + val newKey = ConcordChannelRekey.mintKey() + val wraps = ConcordPrivateChannels.buildRotation(account.signer, priorRoot, held, newKey, keep, TimeUtils.now(), citation) + val landed = wraps.all { runCatching { account.client.publishAndConfirm(it, publishTo) }.getOrDefault(false) } + if (!landed) { + Log.w("Concord") { "Refounding ${entry.id}: the rekey of private channel $id did not land; it keeps its key" } + continue + } + next = ConcordChannelKeyring.withRotatedKey(next, id, newKey.toHexKey(), held.epoch + 1) ?: next + } + return next + } + // Keys reserved for a Refounding in flight, per community (CORD-06 §3): a retry of the same // rotation reuses them. Process-local — a restart mid-rotation mints afresh, which is why the // rekey chunks are all confirmed before anything is adopted. @@ -2266,10 +2377,13 @@ class AccountConcordActions( suspend fun createConcordChannel( communityId: String, name: String, + private: Boolean = false, + accessRoleName: String? = null, ): Boolean { val session = account.concordSessions.sessionFor(communityId) ?: return false if (!account.isWriteable()) return false val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_CHANNELS) ?: return false + if (private) return createPrivateConcordChannel(session, cp, communityId, name, accessRoleName) val channelId = RandomInstance.bytes(32) val channel = ChannelEntity(name = name.trim()) // Readers drop an empty or over-64-byte name (CORD-03 §2); never mint one. @@ -2326,6 +2440,244 @@ class AccountConcordActions( return true } + // ── Private Channels (CORD-03 §1-2, CORD-04 §2, CORD-05 §6, CORD-06 §1-3) ────────────────── + // A Private Channel reads on its own independent key. Its access list is the Roles scoped to it: + // a Grant that opens one vends the key by Direct Invite, a Grant (or ban) that closes one rotates + // it to the members still entitled. The protocol decisions live in ConcordPrivateChannels / + // ConcordChannelRekey (shared with `amy`); only the network and the List write are here. + + /** + * A new Private Channel (CORD-03 §2): an independent key at channel epoch 0 stored in the List + * FIRST (a lost List write would orphan the only copy), then its access Role and the channel + * edition. Nobody holds the Role yet; granting it vends the key ([grantConcordRole]). + */ + private suspend fun createPrivateConcordChannel( + session: ConcordCommunitySession, + cp: ControlPlaneKeys, + communityId: String, + name: String, + accessRoleName: String?, + ): Boolean { + val build = + ConcordPrivateChannels.create( + actor = account.signer, + cp = cp, + communityId = communityId.hexToByteArray(), + name = name, + accessRoleName = accessRoleName, + current = session.controlEditions(), + authority = session.state.value?.authority, + owner = session.entry.owner, + createdAt = TimeUtils.now(), + ) ?: return false + if (!updateConcordEntry(communityId) { cur -> ConcordChannelKeyring.withChannelKey(cur, build.key) }) return false + build.wraps.forEach { publishConcordWrap(session.entry, it) } + return true + } + + /** + * Converts the Public channel [channelIdHex] to Private (CORD-03 §2): a fresh key at the NEXT + * channel epoch — floored at the highest channel rotation seen on the wire, since a privatiser + * may never have held an earlier generation and a reused epoch is silent and unrecoverable — plus + * a new access Role, then the flag. Protects the future only. MANAGE_CHANNELS. + */ + suspend fun privatizeConcordChannel( + communityId: String, + channelIdHex: HexKey, + accessRoleName: String? = null, + ): Boolean { + val session = account.concordSessions.sessionFor(communityId) ?: return false + if (!account.isWriteable()) return false + val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_CHANNELS) ?: return false + val state = session.state.value ?: return false + val standing = state.channels[channelIdHex]?.definition ?: return false + if (standing.private) return false + val floor = observedChannelEpochFloor(session.entry, channelIdHex) ?: return false + val build = + ConcordPrivateChannels.privatize( + actor = account.signer, + cp = cp, + entry = session.entry, + channelIdHex = channelIdHex, + standing = standing, + accessRoleName = accessRoleName, + current = session.controlEditions(), + authority = state.authority, + createdAt = TimeUtils.now(), + observedFloor = floor, + ) ?: return false + if (!updateConcordEntry(communityId) { cur -> ConcordChannelKeyring.withChannelKey(cur, build.key) }) return false + build.wraps.forEach { publishConcordWrap(session.entry, it) } + return true + } + + /** + * The highest channel epoch a rotation of [channelIdHex] was ever published at, read off the + * rekey addresses every held root derives (CORD-06 §2 addresses need no channel key), or null + * when the read is inconclusive — a rotation at the window's top may have more above it, and + * minting on a guess could reuse an epoch (Armada `channelEpochFloor`). 0 when none is seen or no + * relay answers. + */ + private suspend fun observedChannelEpochFloor( + entry: ConcordCommunityListEntry, + channelIdHex: HexKey, + ): Long? { + val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } + if (relays.isEmpty()) return 0 + val channelId = channelIdHex.hexToByteArray() + val window = HashMap() + for (root in (listOf(entry.root) + entry.heldRoots.map { it.key }).distinct()) { + for (epoch in 1L..MAX_PROBED_CHANNEL_EPOCH) window[ConcordChannelRekey.address(root.hexToByteArray(), channelId, epoch).publicKeyHex] = epoch + } + val seen = runCatching { account.client.fetchAll(filters = relays.associateWith { listOf(ConcordActions.planeFilterFor(window.keys.toList())) }) }.getOrDefault(emptyList()) + val highest = seen.mapNotNull { window[it.pubKey] }.maxOrNull() ?: 0 + return if (highest >= MAX_PROBED_CHANNEL_EPOCH) null else highest + } + + /** + * Converts the Private channel [channelIdHex] back to Public (CORD-03 §2): the flag only. The + * held key stays, so its holders keep reading the private era. MANAGE_CHANNELS. + */ + suspend fun publicizeConcordChannel( + communityId: String, + channelIdHex: HexKey, + ): Boolean { + val session = account.concordSessions.sessionFor(communityId) ?: return false + if (!account.isWriteable()) return false + val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_CHANNELS) ?: return false + val standing = + session.state.value + ?.channels + ?.get(channelIdHex) + ?.definition ?: return false + val wrap = ConcordPrivateChannels.publicize(account.signer, cp, communityId.hexToByteArray(), channelIdHex, standing, session.controlEditions(), session.entry.owner, TimeUtils.now()) ?: return false + publishConcordWrap(session.entry, wrap) + return true + } + + // Channel keys reserved for a rotation in flight, keyed by (community, channel, new epoch, + // prevcommit): a retry re-delivers the SAME key rather than minting a sibling that would split + // the members across two keys at one epoch (Armada `mintOrReuseRotationKey`). Process-local. + private val pendingConcordChannelRotations = ConcurrentMap() + + /** + * Rotates Private Channel [channelIdHex] (a single-channel Rekey, CORD-06 §1-2) to exactly the + * members entitled to it today plus ourselves, cutting everyone else. [removed] names who the + * rotation cuts, for the authority check — the Rotator must hold MANAGE_CHANNELS and strictly + * outrank each of them; null takes every known member who is not kept. Every chunk must land on + * a relay before we adopt the new key. Returns whether the rotation was published and adopted. + */ + suspend fun rekeyConcordChannel( + communityId: String, + channelIdHex: HexKey, + removed: Set? = null, + ): Boolean { + val session = account.concordSessions.sessionFor(communityId) ?: return false + if (!account.isWriteable()) return false + val state = session.state.value ?: return false + if (state.dissolved) return false + val entry = session.entry + val me = account.signer.pubKey + val held = ConcordChannelKeyring.heldKey(entry, channelIdHex) ?: return false + val authority = state.authority + val keep = ConcordPrivateChannels.keepSet(authority, channelIdHex, me) + val cut = removed ?: (session.allMembers() - keep) + if (!ConcordPrivateChannels.canRotate(authority, me, cut)) { + Log.w("Concord") { "Refusing to rotate $channelIdHex in $communityId: not MANAGE_CHANNELS, or does not outrank a cut member (CORD-06 §3)" } + return false + } + val editions = session.controlEditions() + val citation = ConcordReceive.rotationCitation(entry, editions, me) + if (citation == null && !authority.isOwner(me)) return false + val publishTo = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } + if (publishTo.isEmpty()) return false + + val newEpoch = held.epoch + 1 + val reservation = "${entry.id}:${held.channelId.lowercase()}:$newEpoch:${ConcordChannelRekey.prevCommit(held.epoch, held.key.hexToByteArray())}" + val newKey = pendingConcordChannelRotations.getOrPut(reservation) { ConcordChannelRekey.mintKey() } + val wraps = ConcordPrivateChannels.buildRotation(account.signer, entry.root.hexToByteArray(), held, newKey, keep, TimeUtils.now(), citation) + for (wrap in wraps) { + if (!runCatching { account.client.publishAndConfirm(wrap, publishTo) }.getOrDefault(false)) { + Log.w("Concord") { "Channel rekey of $channelIdHex aborted: a chunk was not accepted by any relay; retrying reuses the same key" } + return false + } + } + // Adopt at once: the rotator must never keep writing under the severed key. + val adopted = + updateConcordEntry(entry.id) { cur -> + if (ConcordChannelKeyring.heldKey(cur, channelIdHex)?.epoch != held.epoch) null else ConcordChannelKeyring.withRotatedKey(cur, channelIdHex, newKey.toHexKey(), newEpoch) + } + if (adopted) pendingConcordChannelRotations.remove(reservation) + return adopted + } + + /** + * Follows a roster change with the keys it implies (Armada `handleToggleRole`): every Private + * Channel whose entitled set moved between [before] and the current fold ([ConcordInviteVend.accessChanges]) + * — a member who gained one is handed its key by Direct Invite (only the channels gained), and + * one who lost one is cut by rotating it. Only keys we hold can move; a channel we can't vend or + * rotate is logged, since a revoke that cuts nobody is the failure to hear about. + */ + private suspend fun reconcileConcordChannelAccess( + communityId: String, + before: AuthorityResolver?, + ) { + val session = account.concordSessions.sessionFor(communityId) ?: return + val state = session.state.value ?: return + if (before == null || state.dissolved) return + val me = account.signer.pubKey.lowercase() + val changes = ConcordInviteVend.accessChanges(before, state.authority, state.privateChannelIds) + if (changes.isEmpty()) return + + val vend = HashMap>() + for (change in changes) { + val held = ConcordChannelKeyring.heldKey(session.entry, change.channelIdHex) + if (held == null) { + Log.w("Concord") { "Access to ${change.channelIdHex} changed, but we hold no key to vend or rotate it" } + continue + } + for (member in change.gained - me) vend.getOrPut(member) { HashSet() }.add(change.channelIdHex) + val cut = change.lost - me + if (cut.isNotEmpty() && !rekeyConcordChannel(communityId, change.channelIdHex, cut)) { + Log.w("Concord") { "Could not rotate ${change.channelIdHex}: ${cut.size} member(s) may keep reading it until someone who can rotates it" } + } + } + for ((member, channels) in vend) { + val sent = sendConcordDirectInvite(communityId, member, onlyChannelIds = channels) + if (sent != ConcordDirectInviteSendResult.SENT) Log.w("Concord") { "Could not deliver ${channels.size} channel key(s) to $member: $sent" } + } + } + + /** + * Drains the buffered channel rekeys of every joined community (CORD-06 §2 receive path): for + * each held Private Channel, a complete, honored rotation carrying our blob off the key we hold + * moves the key forward; a complete one from a Rotator who outranks us that omits us drops it and + * records the cut. Runs on the revision tick; idempotent once applied (the held epoch moves on). + */ + internal suspend fun drainConcordChannelRekeys() { + if (!account.isWriteable()) return + for (session in account.concordSessions.sessions()) { + val state = session.state.value ?: continue + // Death wins every race (CORD-02 §9). + if (state.dissolved) continue + val wraps = session.pendingChannelRekeyWraps() + if (wraps.isEmpty()) continue + val entry = session.entry + val outcomes = ConcordPrivateChannels.receive(entry, wraps, session.controlEditions(), state.authority, account.signer) + if (outcomes.isEmpty()) continue + val fromEpochs = entry.privateChannels.associate { it.channelId.lowercase() to it.epoch } + if (updateConcordEntry(entry.id) { cur -> ConcordPrivateChannels.applyOutcome(cur, outcomes, fromEpochs) }) { + for ((id, outcome) in outcomes) { + when (outcome) { + is ChannelRekeyOutcome.Adopted -> Log.i("Concord") { "Channel rekey ${entry.id}/$id: adopted epoch ${outcome.epoch}" } + is ChannelRekeyOutcome.Removed -> Log.i("Concord") { "Channel rekey ${entry.id}/$id: cut at epoch ${outcome.epoch}" } + ChannelRekeyOutcome.None -> Unit + } + } + } + } + } + /** * Read-only preview of an invite link: parse it, fetch the kind-33301 bundle from * the link's relays (+ our outbox), and unlock it with the fragment token — WITHOUT diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListState.kt index 1b6fa04690..18f087e773 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListState.kt @@ -243,6 +243,28 @@ class ConcordChannelListState( write(set, doc.entries.filterNot { it.id == entry.id } + live, doc.residue) } + /** + * Read-modify-write one membership: [transform] receives the entry for [communityId] **as the + * List holds it inside the write lock** and returns its replacement, or null to write nothing. + * Returns the fragment events to publish (empty when the community isn't held or nothing + * changed). + * + * Use this, never [follow] with a snapshot, for any change that touches one field of a live + * entry (a channel key, a cut): the List can move between reading a snapshot and writing it — + * a rekey adopted, a new root imported — and following the stale copy would write the old + * root back over it. + */ + suspend fun update( + communityId: String, + transform: (ConcordCommunityListEntry) -> ConcordCommunityListEntry?, + ): List = + writeLock.withLock { + val (set, doc) = snapshot() + val current = doc.entries.firstOrNull { it.id == communityId } ?: return@withLock emptyList() + val next = transform(current) ?: return@withLock emptyList() + write(set, doc.entries.map { if (it.id == communityId) next else it }, doc.residue) + } + /** * Leave [communityId]: drop its membership and tombstone it (CORD-02 §8 — only a tombstone * subtracts a membership; a missing entry is just unseen news another fragment may still diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt index b736c47363..404d998436 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.commons.actions.ConcordLocalEdit import com.vitorpamplona.amethyst.commons.actions.ConcordPinSource import com.vitorpamplona.amethyst.commons.actions.ConcordPinVerifier import com.vitorpamplona.amethyst.commons.actions.ConcordPinning +import com.vitorpamplona.amethyst.commons.actions.ConcordPrivateChannels import com.vitorpamplona.amethyst.commons.util.KmpLock import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry @@ -283,6 +284,14 @@ class ConcordCommunitySession( private val baseRekeyWraps = LinkedHashMap() private val siblingRekeyWraps = LinkedHashMap() + // Channel-rekey addresses (CORD-06 §2) for each held Private Channel's next epochs, under the + // current root and the prior one (a Refounding seals its channel rekeys under the prior root, + // CORD-06 §3) -> key. Re-derived whenever the held channel keys change, since each adoption + // moves the window forward. + @Volatile + private var channelRekeyKeys: Map = ConcordPrivateChannels.watchKeys(entry) + private val channelRekeyWraps = LinkedHashMap() + // Current channel plane pubkey -> plane (channel id, key, bound epoch), refreshed on each control // re-fold. A Public Channel's plane derives from the root at the root epoch; a Private one's from // its held channel key at the channel epoch (CORD-03 §1). A Private Channel we hold no key for has @@ -389,6 +398,7 @@ class ConcordCommunitySession( address == nextBaseRekeyAddress || address == siblingBaseRekeyAddress || address == dissolvedAddress || + address in channelRekeyKeys || address in historicalControlKeys || lock.withLock { address in channelKeysByAddress || address in historicalChannelKeysByAddress } @@ -474,7 +484,13 @@ class ConcordCommunitySession( * The auxiliary plane keys (Guestbook, next base-rekey, and the CORD-02 §9 dissolution address) * for their own isolated AUTH. */ - fun auxStreamKeys(): List = listOfNotNull(guestbookKey, nextBaseRekeyKey, dissolvedKey, siblingBaseRekeyKey) + fun auxStreamKeys(): List = listOfNotNull(guestbookKey, nextBaseRekeyKey, dissolvedKey, siblingBaseRekeyKey) + channelRekeyKeys.values + + /** The channel-rekey addresses this session watches (CORD-06 §2), for the auxiliary subscription. */ + fun channelRekeyAddresses(): Set = channelRekeyKeys.keys + + /** The buffered kind-3303 wraps seen at [channelRekeyAddresses], for the account's channel-rekey drain. */ + fun pendingChannelRekeyWraps(): List = lock.withLock { channelRekeyWraps.values.toList() } /** The community's current Control Plane editions — the input a moderation edition chains onto. */ fun controlEditions(): List = lock.withLock { editionsLocked(controlWraps.values.toList(), controlKeys) } @@ -540,6 +556,7 @@ class ConcordCommunitySession( // Control material may already have swapped in an entry carrying the new keys. if (privateKeySet(newEntry) == derivedPrivateKeys) return false entry = newEntry + channelRekeyKeys = ConcordPrivateChannels.watchKeys(newEntry) true } // Nothing folded yet: the first control wrap derives the planes from the swapped-in entry. @@ -606,6 +623,14 @@ class ConcordCommunitySession( lock.withLock { siblingRekeyWraps[wrap.id] = wrap } return ConcordIngestOutcome.STRUCTURAL } + in channelRekeyKeys -> { + // Buffer only, like the base rekeys: opening a blob takes the account signer, and the + // rotator's authority is judged against the fold at drain time. + lock.withLock { + if (channelRekeyWraps.put(wrap.id, wrap) != null) return ConcordIngestOutcome.NON_STRUCTURAL // dup + } + return ConcordIngestOutcome.STRUCTURAL + } else -> { // A prior-epoch Control Plane wrap: buffer it and re-fold, so the anti-rollback // floor rises as the old epochs drain in. Structural — the floor can change the diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInbox.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInbox.kt index 10e74ca258..2474d8ff70 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInbox.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInbox.kt @@ -75,9 +75,14 @@ sealed interface DirectInviteAcceptPlan { /** A community we don't hold: run the shared join path. */ data object Join : DirectInviteAcceptPlan - /** A held community: store [entry] — the held one plus the newly granted Private Channel keys. */ + /** + * A held community: store [entry] — the held one plus the newly granted Private Channel keys + * ([channelIds]). A writer re-applies [channelIds] to the entry it reads inside the List write + * ([ConcordInviteVend.adoptCatchUp] with `only`), never [entry] itself, which is a snapshot. + */ class CatchUp( val entry: ConcordCommunityListEntry, + val channelIds: List, ) : DirectInviteAcceptPlan /** A held community the bundle adds nothing to (or can't: a different base, or dissolved). */ @@ -216,9 +221,11 @@ class ConcordDirectInviteInbox( * - not held → [DirectInviteAcceptPlan.Join] (the shared join path, which still ban-gates * against the community's own Control Plane); * - held on the SAME base with new Private Channel keys → [DirectInviteAcceptPlan.CatchUp], - * the held entry with only those keys merged in — never moving the base (Armada - * `catchUpChannelIds`) — unless the held roster bans [me]; refused while the roster isn't - * folded ([DirectInviteAcceptPlan.RosterNotLoaded]); + * the held entry with only those keys ADDED — never moving the base, never replacing a + * held key (Armada `catchUpChannelIds`) — and only from a sender who is staff in the held + * fold, for channels it knows as live Private Channels + * ([ConcordInviteVend.admissibleCatchUpIds]); refused when the held roster bans [me], and + * while it isn't folded ([DirectInviteAcceptPlan.RosterNotLoaded]); * - held otherwise (nothing new, a different base, dissolved) → [DirectInviteAcceptPlan.NothingNew]. */ fun acceptPlan( @@ -230,12 +237,15 @@ class ConcordDirectInviteInbox( ): DirectInviteAcceptPlan { if (opened.isExpired(nowMs)) return DirectInviteAcceptPlan.Expired if (held == null) return DirectInviteAcceptPlan.Join - val adopted = ConcordInviteVend.adoptCatchUp(held, opened.invite) ?: return DirectInviteAcceptPlan.NothingNew + if (ConcordInviteVend.catchUpChannelIds(held, opened.invite).isEmpty()) return DirectInviteAcceptPlan.NothingNew if (heldState == null) return DirectInviteAcceptPlan.RosterNotLoaded // Death wins every race (CORD-02 §9): a dissolved community takes no new keys. if (heldState.dissolved) return DirectInviteAcceptPlan.NothingNew if (heldState.authority.isBanned(me)) return DirectInviteAcceptPlan.Banned - return DirectInviteAcceptPlan.CatchUp(adopted) + val ids = ConcordInviteVend.admissibleCatchUpIds(held, opened.invite, heldState.authority, heldState.privateChannelIds, opened.sender) + if (ids.isEmpty()) return DirectInviteAcceptPlan.NothingNew + val adopted = ConcordInviteVend.adoptCatchUp(held, opened.invite, ids) ?: return DirectInviteAcceptPlan.NothingNew + return DirectInviteAcceptPlan.CatchUp(adopted, ids) } /** diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPrivateChannelsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPrivateChannelsTest.kt new file mode 100644 index 0000000000..c6355c62c4 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPrivateChannelsTest.kt @@ -0,0 +1,237 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.actions + +import com.vitorpamplona.amethyst.commons.model.ConcordDirectInviteDraft +import com.vitorpamplona.amethyst.commons.model.concord.ConcordCommunitySession +import com.vitorpamplona.amethyst.commons.model.concord.ConcordIngestOutcome +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity +import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeyring +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteVend +import com.vitorpamplona.quartz.concord.cord06Rekey.ChannelRekeyOutcome +import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordChannelRekey +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Private Channels end to end, headless (CORD-03 §1-2, CORD-04 §2, CORD-05 §6, CORD-06 §1-2): create + * with an access Role, vend on grant through a Direct Invite limited to the gained channel, the + * recipient's click-free adoption, rotate on revoke to the remaining entitled set, and each member's + * receive (the kept adopts, the cut drops the key and records the cut). Plus privatise/publicise. + */ +class ConcordPrivateChannelsTest { + private val owner = NostrSignerInternal(KeyPair()) + private val alice = NostrSignerInternal(KeyPair()) + private val bob = NostrSignerInternal(KeyPair()) + + private class World( + val community: NewConcordCommunity, + val editions: MutableList, + ) { + fun add(wrap: Event) { + editions += ConcordActions.controlEditions(listOf(wrap), community.controlPlane) + } + + fun state(): ConcordCommunityState = ConcordCommunityState.fold(editions, community.communityId, community.ownerPubKey) + } + + private suspend fun world(): World { + val c = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example")) + return World(c, ConcordActions.controlEditions(c.genesisWraps, c.controlPlane).toMutableList()) + } + + private fun entryOf( + c: NewConcordCommunity, + channels: List = emptyList(), + ) = ConcordCommunityListEntry( + id = c.communityIdHex, + owner = c.ownerPubKey, + ownerSalt = c.ownerSalt.toHexKey(), + root = c.communityRoot.toHexKey(), + rootEpoch = c.rootEpoch, + controlPk = c.controlPkHex, + privateChannels = channels, + relays = listOf("wss://relay.example"), + name = "Nostrichs", + addedAt = 1_000L, + ) + + @Test + fun aPrivateChannelIsVendedOnGrantAndRotatedOnRevoke() = + runTest { + val w = world() + val c = w.community + val cid = c.communityId + + // 1. Create: an access Role at the bottom of the roster, the channel flagged private, a key at epoch 0. + val build = assertNotNull(ConcordPrivateChannels.create(owner, c.controlPlane, cid, "mods", null, w.editions, w.state().authority, c.ownerPubKey, 2L)) + build.wraps.forEach { w.add(it) } + val ch = build.channelIdHex + assertEquals(0L, build.key.epoch) + assertTrue(ch in w.state().privateChannelIds) + val role = assertNotNull(w.state().roles[build.roleIdHex]) + assertEquals("channel", role.scope?.kind) + assertEquals(ch, role.scope?.channelId) + assertEquals("0", role.permissions) + val ownerEntry = assertNotNull(ConcordChannelKeyring.withChannelKey(entryOf(c), build.key)) + + // 2. Grant alice and bob the access Role: both gained the channel. + val beforeGrant = w.state().authority + w.add(ConcordModeration.grant(owner, c.controlPlane, cid, alice.pubKey, listOf(build.roleIdHex), w.editions, 3L, owner = c.ownerPubKey)) + w.add(ConcordModeration.grant(owner, c.controlPlane, cid, bob.pubKey, listOf(build.roleIdHex), w.editions, 3L, owner = c.ownerPubKey)) + val granted = ConcordInviteVend.accessChanges(beforeGrant, w.state().authority, w.state().privateChannelIds).single() + assertEquals(setOf(alice.pubKey, bob.pubKey), granted.gained) + + // 3. Vend: a Direct Invite limited to the gained channel, from staff, adopted by alice without a click. + val draft = assertIs(ConcordActions.draftDirectInvite(ownerEntry, w.state(), owner.pubKey, alice.pubKey, onlyChannelIds = setOf(ch))) + assertEquals(listOf(ch), draft.invite.channels.map { it.id }) + val aliceHeld = entryOf(c) + assertEquals( + ConcordInviteVend.CatchUpVerdict.ADOPT, + ConcordInviteVend.judgeCatchUp(w.state().authority, w.state().privateChannelIds, alice.pubKey, owner.pubKey, draft.invite, aliceHeld), + ) + val aliceEntry = assertNotNull(ConcordInviteVend.adoptCatchUp(aliceHeld, draft.invite)) + val bobEntry = assertNotNull(ConcordChannelKeyring.withChannelKey(entryOf(c), build.key)) + assertEquals(build.key.key, ConcordChannelKeyring.heldKey(aliceEntry, ch)?.key) + + // 4. Revoke bob: he lost the channel, so the owner rotates it to the remaining entitled set. + val beforeRevoke = w.state().authority + w.add(ConcordModeration.grant(owner, c.controlPlane, cid, bob.pubKey, emptyList(), w.editions, 4L, owner = c.ownerPubKey)) + val revoked = ConcordInviteVend.accessChanges(beforeRevoke, w.state().authority, w.state().privateChannelIds).single() + assertEquals(setOf(bob.pubKey), revoked.lost) + val keep = ConcordPrivateChannels.keepSet(w.state().authority, ch, owner.pubKey) + assertEquals(setOf(owner.pubKey, alice.pubKey), keep) + assertTrue(ConcordPrivateChannels.canRotate(w.state().authority, owner.pubKey, revoked.lost)) + // A plain member can't rotate anyone out. + assertFalse(ConcordPrivateChannels.canRotate(w.state().authority, alice.pubKey, setOf(bob.pubKey))) + + val newKey = ConcordChannelRekey.mintKey() + val held = assertNotNull(ConcordChannelKeyring.heldKey(ownerEntry, ch)) + val wraps = ConcordPrivateChannels.buildRotation(owner, c.communityRoot, held, newKey, keep, 5L, authority = null) + + // 5. Receive: alice adopts epoch 1, bob is cut and the cut is recorded. + val aliceOut = ConcordPrivateChannels.receive(aliceEntry, wraps, w.editions, w.state().authority, alice) + val adopted = assertIs(aliceOut[ch]) + assertEquals(1L, adopted.epoch) + val aliceNext = assertNotNull(ConcordPrivateChannels.applyOutcome(aliceEntry, aliceOut, mapOf(ch to 0L))) + assertEquals(newKey.toHexKey(), ConcordChannelKeyring.heldKey(aliceNext, ch)?.key) + assertEquals(1L, ConcordChannelKeyring.heldKey(aliceNext, ch)?.epoch) + + val bobOut = ConcordPrivateChannels.receive(bobEntry, wraps, w.editions, w.state().authority, bob) + assertEquals(1L, assertIs(bobOut[ch]).epoch) + val bobNext = assertNotNull(ConcordPrivateChannels.applyOutcome(bobEntry, bobOut, mapOf(ch to 0L))) + assertNull(ConcordChannelKeyring.heldKey(bobNext, ch)) + assertEquals(mapOf(ch to 1L), ConcordChannelKeyring.cutsOf(bobNext)) + // The stale epoch-0 key can't come back through a bundle. + assertTrue(ConcordInviteVend.catchUpChannelIds(bobNext, draft.invite).isEmpty()) + + // A result computed from a stale epoch never rolls the List back. + assertNull(ConcordPrivateChannels.applyOutcome(aliceNext, aliceOut, mapOf(ch to 0L))) + } + + @Test + fun aRotationFromAMemberWithoutAuthorityIsIgnored() = + runTest { + val w = world() + val c = w.community + val build = assertNotNull(ConcordPrivateChannels.create(owner, c.controlPlane, c.communityId, "mods", null, w.editions, w.state().authority, c.ownerPubKey, 2L)) + build.wraps.forEach { w.add(it) } + w.add(ConcordModeration.grant(owner, c.controlPlane, c.communityId, alice.pubKey, listOf(build.roleIdHex), w.editions, 3L, owner = c.ownerPubKey)) + w.add(ConcordModeration.grant(owner, c.controlPlane, c.communityId, bob.pubKey, listOf(build.roleIdHex), w.editions, 3L, owner = c.ownerPubKey)) + val aliceEntry = assertNotNull(ConcordChannelKeyring.withChannelKey(entryOf(c), build.key)) + + // Bob holds the key but no MANAGE_CHANNELS: holding a key is never authority (CORD-06 §3). + val forged = ConcordPrivateChannels.buildRotation(bob, c.communityRoot, build.key, ConcordChannelRekey.mintKey(), setOf(bob.pubKey), 5L, authority = null) + assertTrue(ConcordPrivateChannels.receive(aliceEntry, forged, w.editions, w.state().authority, alice).isEmpty()) + } + + @Test + fun aSessionWatchesAndBuffersItsChannelRekeys() = + runTest { + val c = world().community + val chId = ByteArray(32) { 0x5C } + val key = PrivateChannelKey(chId.toHexKey(), "10".repeat(32), 3, "mods") + val entry = entryOf(c, listOf(key)) + val session = ConcordCommunitySession(entry, alice.pubKey) + + // The next LOOKAHEAD channel epochs past the held one, under the current root. + val next = ConcordChannelRekey.address(c.communityRoot, chId, 4).publicKeyHex + assertTrue(next in session.channelRekeyAddresses()) + assertTrue(ConcordChannelRekey.address(c.communityRoot, chId, 3 + ConcordChannelRekey.LOOKAHEAD.toLong()).publicKeyHex in session.channelRekeyAddresses()) + assertFalse(ConcordChannelRekey.address(c.communityRoot, chId, 3).publicKeyHex in session.channelRekeyAddresses()) + assertTrue(session.ownsPlane(next)) + // Also subscribed with the auxiliary planes. + assertTrue(ConcordSubscriptionPlanner.auxiliaryPlaneSubs(listOf(entry)).any { it.pubKeyHex == next }) + + val wraps = ConcordPrivateChannels.buildRotation(owner, c.communityRoot, key, ConcordChannelRekey.mintKey(), setOf(alice.pubKey), 5L, null) + assertEquals(ConcordIngestOutcome.STRUCTURAL, session.ingest(wraps.single())) + assertEquals(ConcordIngestOutcome.NON_STRUCTURAL, session.ingest(wraps.single())) + assertEquals(listOf(wraps.single().id), session.pendingChannelRekeyWraps().map { it.id }) + } + + @Test + fun privatizeClimbsTheChannelEpochAndPublicizeFlipsTheFlagBack() = + runTest { + val w = world() + val c = w.community + val general = c.generalChannelIdHex + val standing = assertNotNull(w.state().channels[general]).definition + assertFalse(standing.private) + + val build = assertNotNull(ConcordPrivateChannels.privatize(owner, c.controlPlane, entryOf(c), general, standing, "insiders", w.editions, w.state().authority, 2L)) + build.wraps.forEach { w.add(it) } + // The first privatisation is epoch 1 (CORD-03 §2); a floor seen on the wire lifts it. + assertEquals(1L, build.key.epoch) + assertTrue(general in w.state().privateChannelIds) + assertEquals("insiders", w.state().roles[build.roleIdHex]?.name) + val later = assertNotNull(ConcordPrivateChannels.privatize(owner, c.controlPlane, entryOf(c), general, standing, null, w.editions, w.state().authority, 2L, observedFloor = 4)) + assertEquals(5L, later.key.epoch) + // Already private: nothing to do. + assertNull(ConcordPrivateChannels.privatize(owner, c.controlPlane, entryOf(c), general, w.state().channels[general]!!.definition, null, w.editions, w.state().authority, 2L)) + + val flip = assertNotNull(ConcordPrivateChannels.publicize(owner, c.controlPlane, c.communityId, general, w.state().channels[general]!!.definition, w.editions, c.ownerPubKey, 3L)) + w.add(flip) + assertFalse(general in w.state().privateChannelIds) + // The held private-era key keeps reading its history once the channel is public again. + val heldEntry = assertNotNull(ConcordChannelKeyring.withChannelKey(entryOf(c), build.key)) + val planes = ConcordActions.historicalChannelPlanes(heldEntry, general, isPrivate = false) + assertTrue(planes.any { it.epoch == 1L }) + // And the next privatisation climbs past it. + assertEquals(2L, ConcordChannelKeyring.nextChannelEpoch(heldEntry, general)) + assertTrue(general.hexToByteArray().size == 32) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInboxTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInboxTest.kt index d50201fabb..f803207754 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInboxTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInboxTest.kt @@ -27,8 +27,10 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntr import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey +import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite import com.vitorpamplona.quartz.concord.cord05Invites.InviteChannel +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal @@ -91,6 +93,13 @@ class ConcordDirectInviteInboxTest { private fun stateOf(c: NewConcordCommunity): ConcordCommunityState = ConcordCommunityState.fold(ConcordActions.controlEditions(c.genesisWraps, c.controlPlane), c.communityId, c.ownerPubKey) + /** [c]'s fold with [vip] defined as a live Private Channel. */ + private suspend fun stateWithVip(c: NewConcordCommunity): ConcordCommunityState { + val editions = ConcordActions.controlEditions(c.genesisWraps, c.controlPlane).toMutableList() + editions += ConcordActions.controlEditions(listOf(ConcordModeration.defineChannel(owner, c.controlPlane, c.communityId, vip.hexToByteArray(), ChannelEntity(name = "vip", private = true), editions, createdAt = 2L, owner = c.ownerPubKey)), c.controlPlane) + return ConcordCommunityState.fold(editions, c.communityId, c.ownerPubKey) + } + @Test fun aValidWrapIsParkedWithItsVerifiedSenderAndDedupedByWrapId() = runTest { @@ -221,17 +230,23 @@ class ConcordDirectInviteInboxTest { runTest { val c = community() val held = heldEntryOf(c) - val state = stateOf(c) + val state = stateWithVip(c) val grant = listOf(InviteChannel(vip, "db".repeat(32), 0, "vip")) - // Same base, new key: a catch-up that keeps the held base and anchor. - val catchUp = assertNotNull(ConcordActions.openDirectInvite(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c, channels = grant)), me)) + // Same base, new key, from staff (the owner): a catch-up that keeps the held base and anchor. + val catchUp = assertNotNull(ConcordActions.openDirectInvite(ConcordActions.buildDirectInvite(owner, me.pubKey, inviteFor(c, channels = grant)), me)) val plan = assertIs(ConcordDirectInviteInbox.acceptPlan(catchUp, held, state, me.pubKey)) assertEquals(held.root, plan.entry.root) assertEquals(held.rootEpoch, plan.entry.rootEpoch) assertEquals(held.controlPk, plan.entry.controlPk) assertEquals("anchor", plan.entry.inviteRef) assertEquals(listOf(vip), plan.entry.privateChannels.map { it.channelId }) + assertEquals(listOf(vip), plan.channelIds) + + // A plain keyholder can't plant a key, and a channel the fold doesn't know as Private isn't one. + val fromMember = assertNotNull(ConcordActions.openDirectInvite(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c, channels = grant)), me)) + assertEquals(DirectInviteAcceptPlan.NothingNew, ConcordDirectInviteInbox.acceptPlan(fromMember, held, state, me.pubKey)) + assertEquals(DirectInviteAcceptPlan.NothingNew, ConcordDirectInviteInbox.acceptPlan(catchUp, held, stateOf(c), me.pubKey)) // No fold yet: the ban verdict is unknown, so it waits. assertEquals(DirectInviteAcceptPlan.RosterNotLoaded, ConcordDirectInviteInbox.acceptPlan(catchUp, held, null, me.pubKey)) @@ -240,6 +255,11 @@ class ConcordDirectInviteInboxTest { val holding = held.let { ConcordCommunityListEntry(it.id, it.owner, it.ownerSalt, it.root, it.rootEpoch, it.controlPk, privateChannels = listOf(PrivateChannelKey(vip, "db".repeat(32), 0, "vip")), relays = it.relays, name = it.name) } assertEquals(DirectInviteAcceptPlan.NothingNew, ConcordDirectInviteInbox.acceptPlan(catchUp, holding, state, me.pubKey)) + // Even from staff, a bundle never REPLACES a held key — not at a higher, nor an absurd, epoch. + // A held key moves only through a channel rekey, whose prevcommit proves continuity. + val hijack = assertNotNull(ConcordActions.openDirectInvite(ConcordActions.buildDirectInvite(owner, me.pubKey, inviteFor(c, channels = listOf(InviteChannel(vip, "ee".repeat(32), 1_000_000_000L, "vip")))), me)) + assertEquals(DirectInviteAcceptPlan.NothingNew, ConcordDirectInviteInbox.acceptPlan(hijack, holding, state, me.pubKey)) + // A different base for a held community is never adopted, keys or not. val baseMove = assertNotNull(ConcordActions.openDirectInvite(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c, root = "99".repeat(32), channels = grant)), me)) assertEquals(DirectInviteAcceptPlan.NothingNew, ConcordDirectInviteInbox.acceptPlan(baseMove, held, state, me.pubKey)) From a6db681462eac9afda0277591f5c291f059a06f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 19:17:33 +0000 Subject: [PATCH 18/19] feat(cli): amy concord channel create|privatize|publicize|rekey, channel rekeys in rekey/grant/refound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `concord channel create COMMUNITY NAME [--private [--role NAME]]`, `channel privatize` (next channel epoch, probed off the wire), `channel publicize`, `channel rekey` (reserved key in concord.json so a re-run re-delivers the same one) — thin assembly over ConcordPrivateChannels. - `concord rekey` also follows every held private channel's rotations (`channel_rekeys`: adopt, or drop + record the cut). - `concord grant` vends the private channels a Grant opens (Direct Invite limited to them) and rotates the ones it closes. - `concord refound` rotates every held private channel to its entitled kept set under the prior root before adopting the new epoch. - `concord accept` re-applies a catch-up to the record as stored at write time; the store keeps `channelCuts`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PmuzkD5qdqgv6dMeRMoC5N --- cli/README.md | 10 +- .../amethyst/cli/commands/ConcordCommands.kt | 46 +- .../cli/commands/ConcordModCommands.kt | 36 +- .../commands/ConcordPrivateChannelCommands.kt | 409 ++++++++++++++++++ .../amethyst/cli/stores/ConcordStore.kt | 7 + 5 files changed, 495 insertions(+), 13 deletions(-) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordPrivateChannelCommands.kt diff --git a/cli/README.md b/cli/README.md index 8bc626321f..c22f4c7fe2 100644 --- a/cli/README.md +++ b/cli/README.md @@ -677,6 +677,10 @@ also carried on-relay as the encrypted, fragmented kind:33302 Community List | `amy concord list` | List joined Concord communities. | | `amy concord import` | Fetch + decrypt this account's Community List — the kind:33302 fragments plus the retired kind:13302 (carries heldRoots, CORD-06). | | `amy concord channels COMMUNITY` | List a community's channels; `readable` is false for a private channel whose key this account does not hold (CORD-03 §1). | +| `amy concord channel create COMMUNITY NAME [--private [--role NAME]]` | Create a channel (MANAGE_CHANNELS). `--private` gives it its own independent key at channel epoch 0 (stored before anything publishes) plus a bit-less access Role scoped to it (CORD-04 §2, default name = the channel's); nobody holds that Role yet — `concord grant` it to let members read. | +| `amy concord channel privatize COMMUNITY CHANNEL [--role NAME]` | Convert a Public channel to Private (CORD-03 §2): a fresh key at the next channel epoch — floored at the highest channel rotation found on the wire, refused (`inconclusive`) past 32 — plus an access Role, then the flag. Protects the future only. | +| `amy concord channel publicize COMMUNITY CHANNEL` | Convert a Private channel back to Public (flag only); the held key stays so the private era keeps reading. | +| `amy concord channel rekey COMMUNITY CHANNEL` | Rotate a Private channel's key (CORD-06 §1-2) to exactly the members its Roles entitle today plus us: 72-byte scope-bound blobs at the channel-rekey address, `vac` on every chunk. Needs MANAGE_CHANNELS and outranking every cut role holder; the key is reserved in `concord.json` so a re-run re-delivers the same one. | | `amy concord send COMMUNITY CHANNEL TEXT` | Post a message (CHANNEL = `general`\|name\|id). A private channel posts on its own key's plane; without a held key it fails with `no_channel_key` instead of falling back to the community-wide plane. | | `amy concord read COMMUNITY CHANNEL [--limit N] [--epoch N] [--root HEX]` | Read a channel's messages (default 50); `--epoch`/`--root` read a prior epoch's plane (public channels; a private channel reads its held key's plane). | | `amy concord invite COMMUNITY [--base URL]` | Mint + publish a shareable invite link (at most 3 bootstrap relays ride in the fragment, CORD-05 §3; the bundle names this account as creator), then publish this account's Invite Registry (`vsk 8`, CORD-05 §5) listing its live link signers — expired links pruned. Output adds `registry_published`, `public` and `live_invite_links`. | @@ -686,12 +690,12 @@ also carried on-relay as the encrypted, fragmented kind:33302 Community List | `amy concord decline WRAP-ID` | Discard a Direct Invite; its wrap id is remembered in `concord-invites.json` so it never resurfaces. | | `amy concord revoke COMMUNITY TOKEN\|URL` | Retire a link you minted: publishes a `vsk=9` tombstone at its coordinate, records it in your Invite List, then republishes your Invite Registry without it. When it was the community's last live link the output carries `privatized: true` / `refound_required: true`: the community is Private now, and `concord refound COMMUNITY --privatize` rotates its keys (CORD-05 §2). | | `amy concord join URL` | Redeem an invite link, save the community, and publish a Guestbook Join echoing the link's attribution (CORD-05 §1/§6). | -| `amy concord rekey [COMMUNITY]` | Follow a Refounding we were re-keyed for. Honors only a BAN-holding rotator whose `vac` cites a Grant our fold has synced (the owner cites none); racing rotations converge on the lowest root (CORD-06 §3). | +| `amy concord rekey [COMMUNITY]` | Follow a Refounding we were re-keyed for. Honors only a BAN-holding rotator whose `vac` cites a Grant our fold has synced (the owner cites none); racing rotations converge on the lowest root (CORD-06 §3). Then follows every held private channel's own rotations (`channel_rekeys`): a complete, honored rotation off the key we hold is adopted; one from a rotator who outranks us that leaves us out drops the key and records the cut, so no older key comes back. | | `amy concord recover [COMMUNITY] [--rejoin]` | Report whether a Refounding left us behind (our joined-through link resolves to a higher epoch). A bundle never moves the base on its own (CORD-06 §2); `--rejoin` explicitly re-accepts the link. Ban-gated, fails closed. | -| `amy concord refound COMMUNITY --remove U[,U…]` / `--privatize` | CORD-06 Refounding. Aborts unless the whole Control Plane folds; publishes the rekey chunks first (each confirmed), the compacted plane after; reserves its keys so a re-run resumes with the same root; refused for a dissolved community. `--privatize` removes nobody: it converts a Public community to Private (owed once its last live invite link is revoked). | +| `amy concord refound COMMUNITY --remove U[,U…]` / `--privatize` | CORD-06 Refounding. Aborts unless the whole Control Plane folds; publishes the rekey chunks first (each confirmed), the compacted plane after, then rotates every held private channel to its entitled kept set, sealed under the prior root (`channels_rotated`); reserves its keys so a re-run resumes with the same root; refused for a dissolved community. `--privatize` removes nobody: it converts a Public community to Private (owed once its last live invite link is revoked). | | `amy concord roles COMMUNITY` | List live roles + the current banlist (CORD-04), plus the community's mode from the folded Invite Registries (CORD-05 §5): `public` (true while any live invite link exists), `live_invite_links`, and `invite_registries` (links per creator). | | `amy concord role COMMUNITY NAME POSITION PERM…` | Define a role (perms by name, e.g. `BAN KICK`; also `MANAGE_ROLES`, `MANAGE_CHANNELS`, `MANAGE_METADATA`, `MANAGE_MESSAGES`, `CREATE_INVITE`, `VIEW_AUDIT_LOG`, `MENTION_EVERYONE`, `PIN_MESSAGES`). | -| `amy concord grant COMMUNITY USER ROLE-ID` | Grant a role to a member. | +| `amy concord grant COMMUNITY USER ROLE-ID` | Grant a role to a member. Private-channel keys follow the Grant (CORD-03/06): every channel it opens to a member is vended to them by Direct Invite carrying only those channels (`channel_keys_vended`); every channel it closes is rotated (`channels_rotated`, or `channels_not_rotated` with the reason). Only keys this account holds can move (`channels_not_held`). | | `amy concord ban COMMUNITY USER` / `unban COMMUNITY USER` | Ban / unban a member. A ban reports `public` and `refound_required`: a Public ban is the Banlist alone, while a ban from a Private community owes a Refounding (`concord refound COMMUNITY --remove USER`, CORD-06 §3). | | `amy concord pins COMMUNITY CHANNEL` | The channel's Pin List (CORD-04 §7), every entry verified from its proof bundle; entries the author deleted are listed under `deleted`, `edited`/`stale_edit` flag revisions, and `sealed_unavailable` means the list is sealed under a key this account never held (unreadable, not empty). | | `amy concord pin COMMUNITY CHANNEL RUMOR_ID` / `unpin COMMUNITY CHANNEL RUMOR_ID` | Pin / unpin a message (PIN_MESSAGES or owner, plus the control write key). Pinning reopens the message's wrap to prove it with its original seal; a private channel's list is sealed under its current key. Refused (`list_unavailable`, `too_many_pins`, `too_large`, …) rather than published when the list is unreadable or a cap would break. | diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt index 8d7dcf5b34..31b9eaf2c0 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt @@ -44,6 +44,7 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordDissolution import com.vitorpamplona.quartz.concord.cord02Community.ConcordListFragmentSet import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeyring import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver import com.vitorpamplona.quartz.concord.cord04Roles.ConcordLimits import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition @@ -53,6 +54,7 @@ import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListDocument import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEntry import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEvent import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListTombstone +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteVend import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus import com.vitorpamplona.quartz.concord.cord06Rekey.ReceivedRefounding import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys @@ -79,6 +81,15 @@ object ConcordCommands { | concord import fetch + decrypt this account's kind:33302 | community list (carries heldRoots, CORD-06) | concord channels COMMUNITY list a community's channels + | concord channel create COMMUNITY NAME create a channel (MANAGE_CHANNELS); --private + | [--private [--role NAME]] gives it its own key at channel epoch 0 plus a + | bit-less access Role (default: the channel name); + | grant that Role to let members read it + | concord channel privatize COMMUNITY CHANNEL convert a Public channel to Private: a fresh key + | [--role NAME] at the next channel epoch + an access Role + | concord channel publicize COMMUNITY CHANNEL convert a Private channel back to Public (flag only) + | concord channel rekey COMMUNITY CHANNEL rotate a Private channel's key to exactly the + | members its Roles entitle today (CORD-06) | concord send COMMUNITY CHANNEL TEXT post a message (CHANNEL = general|name|id) | concord read COMMUNITY CHANNEL [--limit N] read a channel's messages (default 50); | [--epoch N] [--root HEX] --epoch/--root read a prior epoch's plane @@ -96,7 +107,9 @@ object ConcordCommands { | it in your invite list so it stays retired | concord join URL redeem an invite link and save the community | concord rekey [COMMUNITY] follow a Refounding we were re-keyed for: - | open our blob and adopt the new epoch + | open our blob and adopt the new epoch; then + | follow each held private channel's rotations + | (adopt the new key, or drop it when cut) | concord recover [COMMUNITY] [--rejoin] re-resolve the joined-through invite link and | report whether a Refounding left us behind; | --rejoin re-accepts that link (a bundle never @@ -106,7 +119,9 @@ object ConcordCommands { | and public: true/false + live invite links | from the folded registries (CORD-05 §5) | concord role COMMUNITY NAME POSITION PERM… define a role (perms by name, e.g. BAN KICK) - | concord grant COMMUNITY USER ROLE-ID grant a role to a member + | concord grant COMMUNITY USER ROLE-ID grant a role to a member; the private channels it + | opens are vended by Direct Invite, the ones it + | closes are rotated (CORD-03/06) | concord ban COMMUNITY USER ban a member | concord pins COMMUNITY CHANNEL the channel's verified Pin List (CORD-04 §7) | concord pin COMMUNITY CHANNEL RUMOR_ID pin a message (PIN_MESSAGES); proves it with @@ -133,7 +148,7 @@ object ConcordCommands { route( "concord", tail, - "concord ", + "concord ", help = USAGE, routes = mapOf( @@ -141,6 +156,7 @@ object ConcordCommands { "list" to { rest -> list(dataDir, rest) }, "import" to { rest -> import(dataDir, rest) }, "channels" to { rest -> ConcordChannelCommands.channels(dataDir, rest) }, + "channel" to { rest -> ConcordPrivateChannelCommands.channel(dataDir, rest) }, "send" to { rest -> ConcordChannelCommands.send(dataDir, rest) }, "read" to { rest -> ConcordChannelCommands.read(dataDir, rest) }, "invite" to { rest -> invite(dataDir, rest) }, @@ -729,9 +745,12 @@ object ConcordCommands { 0 } is DirectInviteAcceptPlan.CatchUp -> { - val held = heldSc!! - store.upsert(storedFrom(held, plan.entry)) - val added = plan.entry.privateChannels.filter { pc -> held.privateChannels.none { it.channelId.equals(pc.channelId, ignoreCase = true) && it.epoch == pc.epoch } } + // Re-applied to the record as stored NOW (the fold above took a while), never the + // snapshot the plan was computed from. + val held = store.load().firstOrNull { it.communityId == heldSc!!.communityId } ?: heldSc!! + val adopted = ConcordInviteVend.adoptCatchUp(entryFor(held), opened.invite, plan.channelIds) ?: plan.entry + store.upsert(storedFrom(held, adopted)) + val added = adopted.privateChannels.filter { pc -> held.privateChannels.none { it.channelId.equals(pc.channelId, ignoreCase = true) && it.epoch == pc.epoch } } Output.emit(done(mapOf("joined" to true, "catch_up" to true, "channels" to added.map { mapOf("id" to it.channelId, "name" to it.name, "epoch" to it.epoch) }))) 0 } @@ -840,7 +859,9 @@ object ConcordCommands { } /** The quartz list entry a [StoredCommunity] describes — the shape every commons helper takes. */ - fun entryFor(sc: StoredCommunity) = + fun entryFor(sc: StoredCommunity) = sc.channelCuts.entries.fold(entryShape(sc)) { entry, (id, epoch) -> ConcordChannelKeyring.withCut(entry, id, epoch) } + + private fun entryShape(sc: StoredCommunity) = ConcordCommunityListEntry( id = sc.communityId, owner = sc.owner, @@ -870,6 +891,7 @@ object ConcordCommands { name = entry.name.ifBlank { sc.name }, inviteRef = entry.inviteRef ?: sc.inviteRef, privateChannels = entry.privateChannels.filter { it.key.isNotBlank() }.map { StoredPrivateChannel(it.channelId, it.key, it.epoch, it.name) }, + channelCuts = ConcordChannelKeyring.cutsOf(entry), ) /** @@ -1048,7 +1070,15 @@ object ConcordCommands { store.upsert(storedFrom(sc, adopted).copy(pendingRefounding = null)) results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to true, "from_epoch" to sc.rootEpoch, "root_epoch" to received.newEpoch, "rotator" to received.rotator) } - Output.emit(mapOf("communities" to results)) + // Then every held Private Channel's own rotations (CORD-06 §2), off the records as stored + // now — a base adoption above may have moved the root they are sealed under. + val channelResults = mutableListOf>() + for (id in targets.map { it.communityId }) { + val fresh = store.load().firstOrNull { it.communityId == id } ?: continue + if (fresh.privateChannels.isEmpty() || isDissolved(ctx, fresh)) continue + channelResults += ConcordPrivateChannelCommands.drainChannelRekeys(ctx, store, fresh) + } + Output.emit(mapOf("communities" to results, "channel_rekeys" to channelResults)) return 0 } } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt index c8d9f65c3b..66a90e5811 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt @@ -29,10 +29,13 @@ import com.vitorpamplona.amethyst.cli.stores.StoredCommunity import com.vitorpamplona.amethyst.cli.stores.StoredPendingRefounding import com.vitorpamplona.amethyst.commons.actions.ConcordActions import com.vitorpamplona.amethyst.commons.actions.ConcordModeration +import com.vitorpamplona.amethyst.commons.actions.ConcordPrivateChannels import com.vitorpamplona.amethyst.commons.actions.ConcordReceive import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState import com.vitorpamplona.quartz.concord.cord02Community.ConcordDissolution +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeyring import com.vitorpamplona.quartz.concord.cord03Channels.ConcordDisappearing +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver import com.vitorpamplona.quartz.concord.cord04Roles.ConcordLimits import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition @@ -41,6 +44,7 @@ import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListDocument import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteRegistry import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus +import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordChannelRekey import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding import com.vitorpamplona.quartz.concord.cord06Rekey.IncompleteControlPlaneException import com.vitorpamplona.quartz.concord.cord06Rekey.PendingRefounding @@ -204,7 +208,11 @@ object ConcordModCommands { ) val ack = ctx.publish(wrap, ConcordCommands.relaysFor(ctx, sc)) RawEventSupport.publishGuard(ack, wrap.id)?.let { return it } - Output.emit(mapOf("member" to member, "roles" to listOf(roleId)) + RawEventSupport.ackFields(ack)) + // Role-gated channel keys follow the Grant (CORD-03/06): vend what it opened, rotate what it closed. + val before = AuthorityResolver.resolve(editions, sc.communityId.hexToByteArray(), sc.owner) + val after = editions + ConcordActions.controlEditions(listOf(wrap), cp) + val access = ConcordPrivateChannelCommands.reconcileAccess(ctx, ConcordStore(dataDir.concordFile), loaded.community, before, after) + Output.emit(mapOf("member" to member, "roles" to listOf(roleId)) + access + RawEventSupport.ackFields(ack)) return 0 } } @@ -544,11 +552,33 @@ object ConcordModCommands { } val compactionFailures = build.controlWraps.count { wrap -> ctx.publish(wrap, relays).values.none { it.accepted } } + // 4b. Rotate every held Private Channel (CORD-06 §3), each to its OWN entitled set among + // the kept members, sealed under the PRIOR root so a base-fork loser can still open + // it. One that no relay takes keeps its key and is reported: resumable, not atomic. + val afterBans = ConcordCommunityState.fold(chain, sc.communityId.hexToByteArray(), sc.owner) + val kept = recipients.mapTo(HashSet()) { it.lowercase() } + var withChannels = ConcordCommands.entryFor(loaded.community) + val channelsRotated = mutableListOf() + val channelsNotRotated = mutableListOf() + for (held in withChannels.privateChannels) { + val id = held.channelId.lowercase() + if (id !in afterBans.privateChannelIds || ConcordChannelKeyring.heldKey(withChannels, id) == null) continue + val keep = ConcordPrivateChannels.keepSet(afterBans.authority, id, me).filterTo(HashSet()) { it in kept || it == me.lowercase() } + val newKey = ConcordChannelRekey.mintKey() + val wraps = ConcordPrivateChannels.buildRotation(ctx.signer, priorRoot, held, newKey, keep, TimeUtils.now(), citation) + if (wraps.all { wrap -> ctx.publish(wrap, relays).values.any { it.accepted } }) { + withChannels = ConcordChannelKeyring.withRotatedKey(withChannels, id, newKey.toHexKey(), held.epoch + 1) ?: withChannels + channelsRotated += id + } else { + channelsNotRotated += id + } + } + // 5. Adopt the new epoch ourselves — the same pure rewrite Amethyst uses, banking the // epoch we are leaving for the anti-rollback floor — and drop the reservation. val adopted = ConcordReceive.withAdoptedRoot( - ConcordCommands.entryFor(loaded.community), + withChannels, keys.newRoot, build.newEpoch, build.newControlKeys.address.hexToByteArray(), @@ -606,6 +636,8 @@ object ConcordModCommands { "rekey_wraps" to build.rekeyWraps.size, "compaction_failures" to compactionFailures, "invites_refreshed" to refreshed, + "channels_rotated" to channelsRotated, + "channels_not_rotated" to channelsNotRotated, ), ) return 0 diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordPrivateChannelCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordPrivateChannelCommands.kt new file mode 100644 index 0000000000..06c1e7152d --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordPrivateChannelCommands.kt @@ -0,0 +1,409 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.cli.stores.ConcordStore +import com.vitorpamplona.amethyst.cli.stores.StoredCommunity +import com.vitorpamplona.amethyst.commons.actions.ConcordActions +import com.vitorpamplona.amethyst.commons.actions.ConcordModeration +import com.vitorpamplona.amethyst.commons.actions.ConcordPrivateChannels +import com.vitorpamplona.amethyst.commons.actions.ConcordReceive +import com.vitorpamplona.amethyst.commons.model.ConcordDirectInviteDraft +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeyring +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver +import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteVend +import com.vitorpamplona.quartz.concord.cord06Rekey.ChannelRekeyOutcome +import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordChannelRekey +import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * `amy concord channel create|privatize|publicize|rekey` — Private Channels (CORD-03 §1-2, + * CORD-06 §1-2). Thin assembly over [ConcordPrivateChannels] (commons); the decisions — the key + * epoch, the access Role, who a rotation keeps, whether a received rotation is honored — are all + * shared with Amethyst. Like every amy verb that holds secrets, keys live in the local store only + * (amy does not republish the Community List). + */ +object ConcordPrivateChannelCommands { + /** How many channel epochs `privatize` probes for earlier rotations (Armada MAX_PROBED_CHANNEL_EPOCH). */ + private const val MAX_PROBED_CHANNEL_EPOCH = 32L + + suspend fun channel( + dataDir: DataDir, + tail: Array, + ): Int = + route( + "concord channel", + tail, + "concord channel ", + routes = + mapOf( + "create" to { rest -> create(dataDir, rest) }, + "privatize" to { rest -> privatize(dataDir, rest) }, + "publicize" to { rest -> publicize(dataDir, rest) }, + "rekey" to { rest -> rekey(dataDir, rest) }, + ), + ) + + /** Publishes [wraps] in order to [sc]'s relays; the first one no relay takes stops the run. */ + private suspend fun publishAll( + ctx: Context, + sc: StoredCommunity, + wraps: List, + ): Int? { + val relays = ConcordCommands.relaysFor(ctx, sc) + for (wrap in wraps) { + val ack = ctx.publish(wrap, relays) + RawEventSupport.publishGuard(ack, wrap.id)?.let { return it } + } + return null + } + + private fun canManageChannels( + authority: AuthorityResolver, + me: HexKey, + ): Boolean = authority.isOwner(me) || authority.hasPermission(me, ConcordPermissions.MANAGE_CHANNELS) + + private fun forbidden(): Int = Output.error("forbidden", "this needs the Manage-channels permission (CORD-03 §2); readers would drop the edition") + + /** Stores [sc] with the Private Channel [key] (refused when it would not move the channel forward). */ + private fun storeKey( + store: ConcordStore, + sc: StoredCommunity, + key: PrivateChannelKey, + ): Boolean { + val fresh = store.load().firstOrNull { it.communityId == sc.communityId } ?: sc + val next = ConcordChannelKeyring.withChannelKey(ConcordCommands.entryFor(fresh), key) ?: return false + store.upsert(ConcordCommands.storedFrom(fresh, next)) + return true + } + + /** `concord channel create COMMUNITY NAME [--private [--role NAME]]`. */ + private suspend fun create( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val handle = args.positional(0, "community") + val name = args.positional(1, "name") + val private = args.bool("private") + val roleName = args.flag("role") + args.rejectUnknown() + if (!ChannelEntity(name = name.trim()).hasValidName()) return Output.error("bad_args", "a channel name must be 1..${ChannelEntity.NAME_MAX_BYTES} UTF-8 bytes").let { 2 } + if (roleName != null && !private) return Output.error("bad_args", "--role names a Private channel's access Role; add --private").let { 2 } + val store = ConcordStore(dataDir.concordFile) + val sc = store.find(handle) ?: return ConcordCommands.notFound(handle) + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val loaded = ConcordModCommands.load(ctx, sc, dataDir) + val (cp, editions) = loaded + ConcordModCommands.writeGuard(cp)?.let { return it } + val authority = AuthorityResolver.resolve(editions, sc.communityId.hexToByteArray(), sc.owner) + if (!canManageChannels(authority, ctx.signer.pubKey)) return forbidden() + + if (!private) { + val channelId = RandomInstance.bytes(32) + val wrap = ConcordModeration.defineChannel(ctx.signer, cp, sc.communityId.hexToByteArray(), channelId, ChannelEntity(name = name.trim()), editions, TimeUtils.now(), owner = sc.owner) + publishAll(ctx, sc, listOf(wrap))?.let { return it } + Output.emit(mapOf("channel_id" to channelId.toHexKey(), "name" to name.trim(), "private" to false)) + return 0 + } + + val build = + ConcordPrivateChannels.create(ctx.signer, cp, sc.communityId.hexToByteArray(), name, roleName, editions, authority, sc.owner, TimeUtils.now()) + ?: return Output.error("forbidden", "no rank to mint this channel's access Role from") + // The key goes into the store BEFORE the editions publish: otherwise a crash orphans the only copy. + if (!storeKey(store, loaded.community, build.key)) return Output.error("conflict", "could not store the new channel key") + publishAll(ctx, sc, build.wraps)?.let { return it } + Output.emit( + mapOf( + "channel_id" to build.channelIdHex, + "name" to name.trim(), + "private" to true, + "channel_epoch" to build.key.epoch, + "access_role_id" to build.roleIdHex, + ), + ) + return 0 + } + } + + /** `concord channel privatize COMMUNITY CHANNEL [--role NAME]`. */ + private suspend fun privatize( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val handle = args.positional(0, "community") + val channelRef = args.positional(1, "channel") + val roleName = args.flag("role") + args.rejectUnknown() + val store = ConcordStore(dataDir.concordFile) + val sc = store.find(handle) ?: return ConcordCommands.notFound(handle) + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val channelId = ConcordChannelCommands.resolve(ctx, sc, channelRef) ?: return Output.error("not_found", "no channel '$channelRef'") + val loaded = ConcordModCommands.load(ctx, sc, dataDir) + val (cp, editions) = loaded + ConcordModCommands.writeGuard(cp)?.let { return it } + val state = ConcordCommunityState.fold(editions, sc.communityId.hexToByteArray(), sc.owner) + if (!canManageChannels(state.authority, ctx.signer.pubKey)) return forbidden() + val standing = state.channels[channelId]?.definition ?: return Output.error("not_found", "channel '$channelRef' is not in the folded Control Plane") + if (standing.private) return Output.error("already_private", "channel '$channelRef' is already private") + + // The next channel epoch must climb past every generation ever used — including ones this + // account never held — so probe the rekey addresses the roots derive (CORD-03 §2). + val entry = ConcordCommands.entryFor(loaded.community) + val window = HashMap() + for (root in (listOf(entry.root) + entry.heldRoots.map { it.key }).distinct()) { + for (epoch in 1L..MAX_PROBED_CHANNEL_EPOCH) window[ConcordChannelRekey.address(root.hexToByteArray(), channelId.hexToByteArray(), epoch).publicKeyHex] = epoch + } + val relays = ConcordCommands.relaysFor(ctx, sc) + val seen = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilterFor(window.keys.toList())) }).map { it.second } + val floor = seen.mapNotNull { window[it.pubKey] }.maxOrNull() ?: 0 + if (floor >= MAX_PROBED_CHANNEL_EPOCH) return Output.error("inconclusive", "this channel has rotated at least $MAX_PROBED_CHANNEL_EPOCH times; its next epoch can't be established safely") + + val build = + ConcordPrivateChannels.privatize(ctx.signer, cp, entry, channelId, standing, roleName, editions, state.authority, TimeUtils.now(), floor) + ?: return Output.error("forbidden", "no rank to mint this channel's access Role from") + if (!storeKey(store, loaded.community, build.key)) return Output.error("conflict", "could not store the new channel key") + publishAll(ctx, sc, build.wraps)?.let { return it } + Output.emit(mapOf("channel_id" to channelId, "private" to true, "channel_epoch" to build.key.epoch, "access_role_id" to build.roleIdHex)) + return 0 + } + } + + /** `concord channel publicize COMMUNITY CHANNEL`. */ + private suspend fun publicize( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val handle = args.positional(0, "community") + val channelRef = args.positional(1, "channel") + args.rejectUnknown() + val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return ConcordCommands.notFound(handle) + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val channelId = ConcordChannelCommands.resolve(ctx, sc, channelRef) ?: return Output.error("not_found", "no channel '$channelRef'") + val (cp, editions) = ConcordModCommands.load(ctx, sc, dataDir) + ConcordModCommands.writeGuard(cp)?.let { return it } + val state = ConcordCommunityState.fold(editions, sc.communityId.hexToByteArray(), sc.owner) + if (!canManageChannels(state.authority, ctx.signer.pubKey)) return forbidden() + val standing = state.channels[channelId]?.definition ?: return Output.error("not_found", "channel '$channelRef' is not in the folded Control Plane") + val wrap = + ConcordPrivateChannels.publicize(ctx.signer, cp, sc.communityId.hexToByteArray(), channelId, standing, editions, sc.owner, TimeUtils.now()) + ?: return Output.error("already_public", "channel '$channelRef' is not private") + publishAll(ctx, sc, listOf(wrap))?.let { return it } + Output.emit(mapOf("channel_id" to channelId, "private" to false)) + return 0 + } + } + + /** `concord channel rekey COMMUNITY CHANNEL` — rotate to exactly the members entitled today. */ + private suspend fun rekey( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val handle = args.positional(0, "community") + val channelRef = args.positional(1, "channel") + args.rejectUnknown() + val store = ConcordStore(dataDir.concordFile) + val sc = store.find(handle) ?: return ConcordCommands.notFound(handle) + + Context.open(dataDir).use { ctx -> + ctx.prepare() + if (ConcordCommands.isDissolved(ctx, sc)) return Output.error("dissolved", "community '$handle' has been dissolved (CORD-02 §9)") + val channelId = ConcordChannelCommands.resolve(ctx, sc, channelRef) ?: return Output.error("not_found", "no channel '$channelRef'") + val loaded = ConcordModCommands.load(ctx, sc, dataDir) + val authority = AuthorityResolver.resolve(loaded.editions, sc.communityId.hexToByteArray(), sc.owner) + // The known role holders a rotation to the entitled set leaves out; a roleless member ranks + // last, so any MANAGE_CHANNELS holder outranks them and they need no check. + val keep = ConcordPrivateChannels.keepSet(authority, channelId, ctx.signer.pubKey) + val cut = (authority.roleHolders() + authority.owner()).filterTo(HashSet()) { it !in keep } + return rotate(ctx, store, loaded.community, channelId, authority, loaded.editions, cut) + } + } + + /** A rotation's result: [error] (code to message) when refused or unpublished, else what landed. */ + internal class Rotation( + val error: Pair? = null, + val newEpoch: Long = 0, + val kept: Int = 0, + val chunks: Int = 0, + ) + + /** [rotateSilently] with its outcome emitted as the command's JSON line. */ + internal suspend fun rotate( + ctx: Context, + store: ConcordStore, + sc: StoredCommunity, + channelId: HexKey, + authority: AuthorityResolver, + editions: List, + cut: Set, + ): Int { + val r = rotateSilently(ctx, store, sc, channelId, authority, editions, cut) + r.error?.let { (code, message) -> return Output.error(code, message) } + Output.emit(mapOf("channel_id" to channelId, "rekeyed" to true, "channel_epoch" to r.newEpoch, "kept" to r.kept, "chunks" to r.chunks)) + return 0 + } + + /** + * Rotates [channelId] to its entitled set (CORD-06 §1-2), cutting [cut]: authority checked, the + * key reserved in the store before anything publishes (a retry re-delivers the same key), every + * chunk accepted by a relay before the new key is adopted locally. Prints nothing. + */ + internal suspend fun rotateSilently( + ctx: Context, + store: ConcordStore, + sc: StoredCommunity, + channelId: HexKey, + authority: AuthorityResolver, + editions: List, + cut: Set, + ): Rotation { + val me = ctx.signer.pubKey + val entry = ConcordCommands.entryFor(sc) + val held = ConcordChannelKeyring.heldKey(entry, channelId) ?: return Rotation("no_channel_key" to "this account holds no key for channel $channelId, so it cannot rotate it") + if (!ConcordPrivateChannels.canRotate(authority, me, cut)) { + return Rotation("forbidden" to "rotating needs the Manage-channels permission and outranking every member it cuts (CORD-06 §3)") + } + val citation = ConcordReceive.rotationCitation(entry, editions, me) + if (citation == null && !authority.isOwner(me)) return Rotation("forbidden" to "no Grant of ours to cite; nobody would honor this rotation (CORD-06 §3)") + + val newEpoch = held.epoch + 1 + val reservation = "${held.channelId.lowercase()}:$newEpoch:${ConcordChannelRekey.prevCommit(held.epoch, held.key.hexToByteArray())}" + val newKeyHex = sc.pendingChannelRotations[reservation] ?: ConcordChannelRekey.mintKey().toHexKey() + store.upsert(sc.copy(pendingChannelRotations = sc.pendingChannelRotations + (reservation to newKeyHex))) + + val keep = ConcordPrivateChannels.keepSet(authority, channelId, me) + val wraps = ConcordPrivateChannels.buildRotation(ctx.signer, sc.root.hexToByteArray(), held, newKeyHex.hexToByteArray(), keep, TimeUtils.now(), citation) + val relays = ConcordCommands.relaysFor(ctx, sc) + for (wrap in wraps) { + if (ctx.publish(wrap, relays).values.none { it.accepted }) { + return Rotation("rejected" to "no relay accepted chunk ${wrap.id} of the rotation; re-running re-delivers the same key") + } + } + + // Adopt at once: the rotator must never keep writing under the severed key. + val fresh = store.load().firstOrNull { it.communityId == sc.communityId } ?: sc + val next = ConcordChannelKeyring.withRotatedKey(ConcordCommands.entryFor(fresh), channelId, newKeyHex, newEpoch) + if (next != null) store.upsert(ConcordCommands.storedFrom(fresh, next).copy(pendingChannelRotations = fresh.pendingChannelRotations - reservation)) + return Rotation(newEpoch = newEpoch, kept = keep.size, chunks = wraps.size) + } + + /** + * Follows every held Private Channel's rotations for [sc] (CORD-06 §2): drains the watched + * channel-rekey addresses, adopts a key carried off the one we hold, or drops the channel (and + * records the cut) when a rotation from someone who outranks us left us out. Returns one result + * per channel acted on. + */ + internal suspend fun drainChannelRekeys( + ctx: Context, + store: ConcordStore, + sc: StoredCommunity, + ): List> { + val entry = ConcordCommands.entryFor(sc) + val keys = ConcordPrivateChannels.watchKeys(entry) + if (keys.isEmpty()) return emptyList() + val relays = ConcordCommands.relaysFor(ctx, sc) + ctx.registerConcordStreamKeys(relays, keys.values.map { it.secretKey }) + val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilterFor(keys.keys.toList())) }, pendingOnAuthRequired = true).map { it.second } + if (wraps.isEmpty()) return emptyList() + val loaded = ConcordModCommands.load(ctx, sc) + val authority = AuthorityResolver.resolve(loaded.editions, sc.communityId.hexToByteArray(), sc.owner) + val outcomes = ConcordPrivateChannels.receive(entry, wraps, loaded.editions, authority, ctx.signer) + if (outcomes.isEmpty()) return emptyList() + val fresh = store.load().firstOrNull { it.communityId == sc.communityId } ?: sc + val next = ConcordPrivateChannels.applyOutcome(ConcordCommands.entryFor(fresh), outcomes, entry.privateChannels.associate { it.channelId.lowercase() to it.epoch }) + if (next != null) store.upsert(ConcordCommands.storedFrom(fresh, next)) + return outcomes.map { (id, outcome) -> + when (outcome) { + is ChannelRekeyOutcome.Adopted -> mapOf("community_id" to sc.communityId, "channel_id" to id, "adopted" to true, "channel_epoch" to outcome.epoch) + is ChannelRekeyOutcome.Removed -> mapOf("community_id" to sc.communityId, "channel_id" to id, "removed" to true, "channel_epoch" to outcome.epoch) + ChannelRekeyOutcome.None -> mapOf("community_id" to sc.communityId, "channel_id" to id) + } + } + } + + /** + * After a Grant: vends every Private Channel it opened to its member by Direct Invite (only those + * channels), and rotates every one it closed (CORD-03/06; Armada `handleToggleRole`). Returns what + * it did, for the grant command's output. + */ + internal suspend fun reconcileAccess( + ctx: Context, + store: ConcordStore, + sc: StoredCommunity, + before: AuthorityResolver, + afterEditions: List, + ): Map { + val state = ConcordCommunityState.fold(afterEditions, sc.communityId.hexToByteArray(), sc.owner) + val me = ctx.signer.pubKey.lowercase() + val entry = ConcordCommands.entryFor(sc) + val changes = ConcordInviteVend.accessChanges(before, state.authority, state.privateChannelIds) + val vended = mutableListOf>() + val rotated = mutableListOf() + val unrotated = mutableListOf>() + val unheld = mutableListOf() + val byMember = HashMap>() + for (change in changes) { + if (ConcordChannelKeyring.heldKey(entry, change.channelIdHex) == null) { + unheld += change.channelIdHex + continue + } + for (m in change.gained - me) byMember.getOrPut(m) { HashSet() }.add(change.channelIdHex) + val cut = change.lost - me + if (cut.isNotEmpty()) { + val fresh = store.load().firstOrNull { it.communityId == sc.communityId } ?: sc + val r = rotateSilently(ctx, store, fresh, change.channelIdHex, state.authority, afterEditions, cut) + if (r.error == null) rotated += change.channelIdHex else unrotated += mapOf("channel_id" to change.channelIdHex, "reason" to r.error.second) + } + } + for ((member, channels) in byMember) { + val draft = ConcordActions.draftDirectInvite(entry, state, me, member, onlyChannelIds = channels) as? ConcordDirectInviteDraft.Ready ?: continue + val wrap = ConcordActions.buildDirectInvite(ctx.signer, member, draft.invite) + val lists = ctx.cachedRelayListsOf(member) ?: RecipientRelayFetcher.fetchRelayLists(ctx.client, member, ctx.bootstrapRelays()) + val ack = ctx.publish(wrap, ConcordActions.directInviteDeliveryRelays(lists)) + vended += mapOf("member" to member, "channels" to draft.invite.channels.map { it.id }, "delivered" to ack.values.any { it.accepted }) + } + return mapOf("channel_keys_vended" to vended, "channels_rotated" to rotated, "channels_not_rotated" to unrotated, "channels_not_held" to unheld) + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt index cbfc24fd48..225bf928e8 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt @@ -57,6 +57,13 @@ data class StoredCommunity( // A private channel is read and written ONLY on the plane its own key derives; without one it // is unreadable and `send` refuses rather than fall back to the root-derived plane. val privateChannels: List = emptyList(), + // Per Private Channel, the channel epoch whose rotation cut this account out (CORD-06 §2; the + // reference client's `channel_cuts`): a key below it is never adopted again from a bundle. + val channelCuts: Map = emptyMap(), + // Channel keys reserved for a Private Channel rotation this account started but has not yet + // adopted, keyed "channelId:newEpoch:prevcommit" (CORD-06): a retried `channel rekey` must + // re-deliver the SAME key, never a sibling that splits the members at one epoch. + val pendingChannelRotations: Map = emptyMap(), // Keys reserved for a Refounding this account started but has not yet adopted (CORD-06 §3): a // retried `refound` must re-deliver the SAME root, never mint a sibling that splits the members. val pendingRefounding: StoredPendingRefounding? = null, From 06a58d75ef5434dc61f76e6a761ac401d1eeb45d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 19:26:41 +0000 Subject: [PATCH 19/19] =?UTF-8?q?feat(concord):=20Private=20channel=20UI?= =?UTF-8?q?=20=E2=80=94=20create=20toggle,=20make=20private/public,=20rota?= =?UTF-8?q?te=20key;=20F7=20in=20the=20conformance=20review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Channel create dialog: a "Private channel" switch plus the access-role name (the Roles scoped to a channel are its access list, CORD-04 §2, so a new private channel mints its own; granting it lets members read). - Channel row menu (MANAGE_CHANNELS): Make private (names the access Role, explains it protects the future only) / Make public, and Rotate key for a private channel whose key we hold. - New strings in commonsUI only. - quartz/plans/2026-09-29-concord-spec-conformance.md: F7 fixed, I12 and F6 notes updated. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PmuzkD5qdqgv6dMeRMoC5N --- .../concord/ConcordChannelListScreen.kt | 159 ++++++++++++++++-- .../composeResources/values/strings.xml | 8 + .../2026-09-29-concord-spec-conformance.md | 6 +- 3 files changed, 158 insertions(+), 15 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt index 5a39b183fd..c9aa80fa13 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt @@ -44,6 +44,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -77,15 +78,23 @@ import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.app_name import com.vitorpamplona.amethyst.commons.resources.back import com.vitorpamplona.amethyst.commons.resources.cancel +import com.vitorpamplona.amethyst.commons.resources.concord_channel_access_role_label import com.vitorpamplona.amethyst.commons.resources.concord_channel_create import com.vitorpamplona.amethyst.commons.resources.concord_channel_delete import com.vitorpamplona.amethyst.commons.resources.concord_channel_delete_confirm import com.vitorpamplona.amethyst.commons.resources.concord_channel_delete_message import com.vitorpamplona.amethyst.commons.resources.concord_channel_delete_title +import com.vitorpamplona.amethyst.commons.resources.concord_channel_make_private +import com.vitorpamplona.amethyst.commons.resources.concord_channel_make_private_message +import com.vitorpamplona.amethyst.commons.resources.concord_channel_make_public +import com.vitorpamplona.amethyst.commons.resources.concord_channel_make_public_message import com.vitorpamplona.amethyst.commons.resources.concord_channel_name_label import com.vitorpamplona.amethyst.commons.resources.concord_channel_no_messages +import com.vitorpamplona.amethyst.commons.resources.concord_channel_private_hint +import com.vitorpamplona.amethyst.commons.resources.concord_channel_private_toggle import com.vitorpamplona.amethyst.commons.resources.concord_channel_rename import com.vitorpamplona.amethyst.commons.resources.concord_channel_rename_save +import com.vitorpamplona.amethyst.commons.resources.concord_channel_rotate_key import com.vitorpamplona.amethyst.commons.resources.concord_channels_empty import com.vitorpamplona.amethyst.commons.resources.concord_direct_invite_action import com.vitorpamplona.amethyst.commons.resources.concord_edit_title @@ -119,6 +128,7 @@ import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeyring import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.utils.TimeUtils @@ -237,11 +247,11 @@ fun ConcordChannelListScreen( initialName = editor.initialName, isCreate = editor.channelIdHex == null, onDismiss = { channelEditor = null }, - onConfirm = { newName -> + onConfirm = { newName, makePrivate, accessRoleName -> channelEditor = null scope.launch { if (editor.channelIdHex == null) { - account.concord.createConcordChannel(communityId, newName) + account.concord.createConcordChannel(communityId, newName, makePrivate, accessRoleName) } else { account.concord.renameConcordChannel(communityId, editor.channelIdHex, newName) } @@ -250,6 +260,26 @@ fun ConcordChannelListScreen( ) } + // Privatise / publicise a channel (CORD-03 §2): both are MANAGE_CHANNELS edits, and both say + // plainly what they can't do — a conversion protects the future only. + var channelToConvert by remember { mutableStateOf(null) } + channelToConvert?.let { target -> + ConcordChannelConvertDialog( + target = target, + onDismiss = { channelToConvert = null }, + onConfirm = { accessRoleName -> + channelToConvert = null + scope.launch { + if (target.toPrivate) { + account.concord.privatizeConcordChannel(communityId, target.channelIdHex, accessRoleName) + } else { + account.concord.publicizeConcordChannel(communityId, target.channelIdHex) + } + } + }, + ) + } + channelToDelete?.let { target -> val id = target.channelIdHex ?: return@let AlertDialog( @@ -445,6 +475,8 @@ fun ConcordChannelListScreen( .keys .sorted() } + // A rotation needs the current key (CORD-06): only a holder can rotate. + val holdsKey = def.private && session?.entry?.let { ConcordChannelKeyring.heldKey(it, entry.key) } != null ConcordChannelListRow( communityId = communityId, channelKey = entry.key, @@ -456,6 +488,9 @@ fun ConcordChannelListScreen( onClick = { nav.nav(Route.Concord(communityId, entry.key)) }, onRename = { channelEditor = ConcordChannelEditor(channelIdHex = entry.key, initialName = name) }, onDelete = { channelToDelete = ConcordChannelEditor(channelIdHex = entry.key, initialName = name) }, + onTogglePrivate = { channelToConvert = ConcordChannelConversion(entry.key, name, toPrivate = !def.private) }, + isPrivate = def.private, + onRotateKey = if (holdsKey) ({ scope.launch { account.concord.rekeyConcordChannel(communityId, entry.key) } }) else null, ) HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant) } @@ -483,6 +518,9 @@ private fun ConcordChannelListRow( onClick: () -> Unit, onRename: () -> Unit, onDelete: () -> Unit, + onTogglePrivate: () -> Unit, + isPrivate: Boolean, + onRotateKey: (() -> Unit)?, ) { val account = accountViewModel.account // getOrCreate (not getIfExists): a channel folded on the Control Plane may have no message note @@ -546,6 +584,9 @@ private fun ConcordChannelListRow( ConcordChannelRowMenu( onRename = onRename, onDelete = onDelete, + onTogglePrivate = onTogglePrivate, + isPrivate = isPrivate, + onRotateKey = onRotateKey, ) } } @@ -674,11 +715,62 @@ private data class ConcordChannelEditor( val initialName: String, ) -/** The per-channel-row overflow menu (rename / delete), shown only to channel managers. */ +/** A pending privatise ([toPrivate]) or publicise of [channelIdHex]. */ +private data class ConcordChannelConversion( + val channelIdHex: String, + val name: String, + val toPrivate: Boolean, +) + +/** Confirms a Private/Public conversion; privatising also names the new access Role. */ +@Composable +private fun ConcordChannelConvertDialog( + target: ConcordChannelConversion, + onDismiss: () -> Unit, + onConfirm: (String?) -> Unit, +) { + var roleName by remember { mutableStateOf(target.name) } + val action = if (target.toPrivate) Res.string.concord_channel_make_private else Res.string.concord_channel_make_public + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringRes(action)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (target.toPrivate) { + Text(stringRes(Res.string.concord_channel_make_private_message, target.name, roleName.ifBlank { target.name })) + OutlinedTextField( + value = roleName, + onValueChange = { roleName = it }, + singleLine = true, + label = { Text(stringRes(Res.string.concord_channel_access_role_label)) }, + modifier = Modifier.fillMaxWidth(), + ) + } else { + Text(stringRes(Res.string.concord_channel_make_public_message, target.name)) + } + } + }, + confirmButton = { + TextButton(onClick = { onConfirm(roleName.trim().ifBlank { null }) }) { + Text(stringRes(action)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringRes(Res.string.cancel)) + } + }, + ) +} + +/** The per-channel-row overflow menu (rename / privacy / rotate / delete), shown only to channel managers. */ @Composable private fun ConcordChannelRowMenu( onRename: () -> Unit, onDelete: () -> Unit, + onTogglePrivate: () -> Unit, + isPrivate: Boolean, + onRotateKey: (() -> Unit)?, ) { var expanded by remember { mutableStateOf(false) } Box { @@ -697,6 +789,22 @@ private fun ConcordChannelRowMenu( onRename() }, ) + DropdownMenuItem( + text = { Text(stringRes(if (isPrivate) Res.string.concord_channel_make_public else Res.string.concord_channel_make_private)) }, + onClick = { + expanded = false + onTogglePrivate() + }, + ) + onRotateKey?.let { rotate -> + DropdownMenuItem( + text = { Text(stringRes(Res.string.concord_channel_rotate_key)) }, + onClick = { + expanded = false + rotate() + }, + ) + } DropdownMenuItem( text = { Text( @@ -719,9 +827,11 @@ private fun ConcordChannelEditDialog( initialName: String, isCreate: Boolean, onDismiss: () -> Unit, - onConfirm: (String) -> Unit, + onConfirm: (name: String, makePrivate: Boolean, accessRoleName: String?) -> Unit, ) { var name by remember { mutableStateOf(initialName) } + var makePrivate by remember { mutableStateOf(false) } + var roleName by remember { mutableStateOf("") } AlertDialog( onDismissRequest = onDismiss, title = { @@ -736,18 +846,43 @@ private fun ConcordChannelEditDialog( ) }, text = { - OutlinedTextField( - value = name, - onValueChange = { name = it }, - singleLine = true, - label = { Text(stringRes(Res.string.concord_channel_name_label)) }, - modifier = Modifier.fillMaxWidth(), - ) + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + singleLine = true, + label = { Text(stringRes(Res.string.concord_channel_name_label)) }, + modifier = Modifier.fillMaxWidth(), + ) + // A new channel may be Private (CORD-03): its own key, and an access Role — the + // Roles scoped to a channel ARE its access list (CORD-04 §2). + if (isCreate) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(stringRes(Res.string.concord_channel_private_toggle), modifier = Modifier.weight(1f)) + Switch(checked = makePrivate, onCheckedChange = { makePrivate = it }) + } + if (makePrivate) { + Text( + stringRes(Res.string.concord_channel_private_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField( + value = roleName, + onValueChange = { roleName = it }, + singleLine = true, + placeholder = { Text(name.trim()) }, + label = { Text(stringRes(Res.string.concord_channel_access_role_label)) }, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } }, confirmButton = { TextButton( enabled = name.isNotBlank(), - onClick = { if (name.isNotBlank()) onConfirm(name.trim()) }, + onClick = { if (name.isNotBlank()) onConfirm(name.trim(), makePrivate, roleName.trim().ifBlank { null }) }, ) { Text( stringRes( diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 2390db807d..9f7d400c85 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -3628,15 +3628,23 @@ %1$d channel %1$d channels + Access role name New channel Delete channel Delete Delete #%1$s? This can't be undone and the channel can't be recreated with the same id. Delete channel? + Make private + #%1$s gets its own key from now on, and the "%2$s" role decides who can read it. Nobody holds that role yet: grant it to the members who should have access. Messages already posted stay readable to everyone in the community. + Make public + Every member of the community will be able to read #%1$s from now on. Messages posted while it was private stay readable only to the members who held its key. Channel name No messages yet + Only members holding its access role can read it. Grant the role to let them in; revoking it rotates the channel key. + Private channel Rename channel Rename + Rotate key No channels yet. Create Relays diff --git a/quartz/plans/2026-09-29-concord-spec-conformance.md b/quartz/plans/2026-09-29-concord-spec-conformance.md index 51f1ff2cc6..cba08aba06 100644 --- a/quartz/plans/2026-09-29-concord-spec-conformance.md +++ b/quartz/plans/2026-09-29-concord-spec-conformance.md @@ -70,7 +70,7 @@ Ranked security > interop > feature inside each group. | I9 | 06 §3 | Rotations carry no `vac` | **fixed** — rotations carry `vac` on every chunk (`ConcordRotationAuthority.citationFor`, owner none); receivers require `ConcordReceive.isHonoredRotation` (BAN + `citationSatisfied`, Armada semantics) in the app drain and CLI `rekey`; a rotator whose chunks cite different Grants is dropped | | I10 | 06 | 120 base blobs per chunk can overflow NIP-44; Armada budgets 99 @104 B / 90 @136 B | **fixed** — `ConcordRekey.chunkBlobs` budgets the rumor JSON at 40,960 bytes (Armada `REKEY_RUMOR_MAX_BYTES`) plus the 120 count cap; test pins the seal (wrap plaintext) ≤ 65,535 with 136-byte blobs | | I11 | 05 §3 | Invite links carry more than 3 bootstrap relays; Armada's decoder throws | **fixed** — `encodeFragment` truncates non-stock lists to 3 (stock set stays a flag); `decodeFragment` refuses count > 3 | -| I12 | 06 §3 | No race convergence (lowest new root), not idempotent on retry | **fixed** — `findNewRoot` converges on the lowest authorized root (`accept` filter before `converge`); sessions watch the current epoch's own rekey address and `drainConcordRekeys` heals down-only (`ConcordReceive.withHealedRoot`), keeping the losing root as a same-epoch held root (only the lowest per epoch is folded: `canonicalHeldRoots`); retries reuse reserved keys (`ConcordRefounding.reserveKeys`; in-memory in the app, persisted in amy's store). Not done: the CLI has no heal step; re-issuing a losing branch's channel keys (no private channels yet, F7) | +| I12 | 06 §3 | No race convergence (lowest new root), not idempotent on retry | **fixed** — `findNewRoot` converges on the lowest authorized root (`accept` filter before `converge`); sessions watch the current epoch's own rekey address and `drainConcordRekeys` heals down-only (`ConcordReceive.withHealedRoot`), keeping the losing root as a same-epoch held root (only the lowest per epoch is folded: `canonicalHeldRoots`); retries reuse reserved keys (`ConcordRefounding.reserveKeys`; in-memory in the app, persisted in amy's store). Not done: the CLI has no heal step; re-issuing a losing branch's channel keys on the winning chain (F7 rotates private channels inside a Refounding under the prior root, which both branches can open, but a losing refounder does not yet re-issue its channel keys after the heal) | | I13 | 03 §3 | Binding check not strict (duplicates accepted, `"04"`/`"+4"` parse) | **fixed** — exactly one `channel` and one `epoch` tag, epoch compared as its canonical decimal string (Armada `uniqueTag`/`checkChannelBinding`); builders drop binding tags smuggled in `extraTags` | | I14 | 03 §2 | Channel deletion not terminal across the chain; no 64-byte name cap | **fixed** — any gated channel edition with `deleted:true` retires the channel for good (Armada `everDeleted`); the channel gate refuses an empty or >64-byte name so the fold falls back to the previous candidate, and `defineChannel`/create/rename refuse to mint one | | I15 | 02 §4 | No `ms` tag on chat rumors | **fixed** — every `ChannelChat` rumor carries `["ms", 0..999]` after the binding; malformed/duplicated `ms` drops the rumor; `channelMessages` and edit recency order by `created_at*1000+ms` (the shared feed still sorts by `created_at`; open PR #7 may drop `ms`) | @@ -87,8 +87,8 @@ Ranked security > interop > feature inside each group. | F3 | 07 | A/V calls: only key derivation, the 27235 grant and 23313 presence builders exist; no broker/SFU client, no media E2EE. Needs a LiveKit client whose license must be checked first | open — out of scope for this pass | | F4 | 07 | Broker token has no nonce (same-second requests collide in the broker's replay set); presence fold doesn't take latest-per-author | open → chat-plane batch (quartz only) | | F5 | 05 §5 | Invite Registry (vsk 8) not published or folded | **fixed** — `ConcordInviteRegistry` (builder, strict-array decode, `nextLinks` pruning expired/tombstoned links) + `ConcordCommunityState.inviteRegistries`/`liveInviteLinks`/`isPublic`/`hasForeignLiveLinks`/`banRequiresRefounding`/`retiringWouldPrivatize` (gated on CREATE_INVITE, coordinate bound to author); mint/revoke publish the registry (app + amy); a Private ban Refounds, a Public one is the Banlist alone; retiring the last live link runs a privatizing Refounding (`privatizeConcordCommunity`; amy reports it and adds `refound --privatize`); Public/Private shown in the server view and warned in the revoke dialog. Deviation from Armada, following the spec: a ban Refounds iff the community is Private without the targets' registries (Armada rotates whenever no *foreign* link exists, and only warns on privatizing revokes) | -| F6 | 05 §6 | Direct invites: wire format only, no send/receive | **fixed** — wrap backdates seal/wrap ≤2 days, carries NIP-40 `expiration` = `expires_at`, `ConcordDirectInvite.open` returns the seal-verified sender and refuses rumor/seal pubkey mismatch, bad seal sig, non-3313 rumors, §1 bounds and bad owner proof; send (`sendConcordDirectInvite` / `amy concord invite --to`) vends only the private channels the recipient's channel-scoped roles grant (`ConcordInviteVend`, Armada `vendableChannels`) to their 10050 → NIP-65 read → stock relays; headless `ConcordDirectInviteInbox` (sweep via `directInvitesFilter` + the NIP-17 seal handler) dedupes by wrap id, skips expired wraps, parks invites, remembers declines; accept shares the link join path, refuses past `expires_at`, and for a held community only adopts new private-channel keys on the same root/epoch/control_pk (`catchUpChannelIds`); UI card + "Invite by npub"; `amy concord invites/accept/decline`. Not done: Armada's auto-adopt of staff-sent catch-ups (`judgeCatchUp`) and `channel_cuts` (not modeled here) | -| F7 | 06 §1-2 | Channel-scope rekeys; private-channel keys in invites | open (depends on S2) | +| F6 | 05 §6 | Direct invites: wire format only, no send/receive | **fixed** — wrap backdates seal/wrap ≤2 days, carries NIP-40 `expiration` = `expires_at`, `ConcordDirectInvite.open` returns the seal-verified sender and refuses rumor/seal pubkey mismatch, bad seal sig, non-3313 rumors, §1 bounds and bad owner proof; send (`sendConcordDirectInvite` / `amy concord invite --to`) vends only the private channels the recipient's channel-scoped roles grant (`ConcordInviteVend`, Armada `vendableChannels`) to their 10050 → NIP-65 read → stock relays; headless `ConcordDirectInviteInbox` (sweep via `directInvitesFilter` + the NIP-17 seal handler) dedupes by wrap id, skips expired wraps, parks invites, remembers declines; accept shares the link join path, refuses past `expires_at`, and for a held community only adopts new private-channel keys on the same root/epoch/control_pk (`catchUpChannelIds`); UI card + "Invite by npub"; `amy concord invites/accept/decline`. F7 follow-ups done: staff-sent catch-ups auto-adopt (`judgeCatchUp`), `channel_cuts` is modeled, and a catch-up now only ADDS a missing key from a staff sender for a live Private Channel (never replaces a held key), re-applied inside the List write | +| F7 | 06 §1-2 | Channel-scope rekeys; private-channel keys in invites | **fixed** — quartz `ConcordChannelRekey`: root-keyed `concord/rekey-pseudonym` address, 72-byte scope-bound blobs, chunked 3303 with `prevcommit` over the held channel key and `vac` on every chunk; the receive walk adopts only complete, honored (owner / MANAGE_CHANNELS / BAN + synced `vac`) rotations off the held key (multi-epoch, racing rotators → lowest key) and treats "no blob" as a cut only from a rotator who outranks us, published after our join. `ConcordChannelKeyring` rotates keys in place, reads older keys from `seed`/peer `priors` (never writes intermediate keys, CORD-02 §8) and models Armada's `channel_cuts` floor (extension, round-tripped). `ConcordInviteVend.entitledMembers`/`accessChanges`/`judgeCatchUp`. Commons `ConcordPrivateChannels` + app verbs: create Private channel (key at channel epoch 0 + bit-less access Role, as Armada), privatise (next channel epoch, floored by probing the rekey addresses) / publicise, `rekeyConcordChannel`, vend on grant (Direct Invite limited to the gained channels) and rotate on revoke/ban, the Refounding rotates every held private channel under the prior root, sessions subscribe/AUTH/buffer the channel-rekey window and the revision tick drains it; staff catch-ups auto-adopt. Links carry no channel keys (F6's `vendableChannels`, audience link). UI: Private toggle + access-role name on create, Make private/public + Rotate key per channel. `amy concord channel create/privatize/publicize/rekey`, `rekey`/`grant`/`refound` follow channel keys. Not done: republishing a rotated channel's sealed Pin List under the new key (a SHOULD); history across our own rotations after restart (intermediate keys stay out of the List by spec, and the key walk from `seed` is not implemented); a role *scope edit* in the UI does not trigger reconcile (only grants/revokes/bans do) | | F8 | 06 §2, 02 §8 | Walk forward from `seed`; we still keep intermediate roots in a `held_roots` List extension the spec says doesn't belong there | open | | F9 | 04 §6 | Kick (kind 3309) | open | | F10 | 03 | WebXDC (kind 3310) | open |