diff --git a/.github/workflows/group-change-request-fixtures.yml b/.github/workflows/group-change-request-fixtures.yml new file mode 100644 index 0000000..55e6d38 --- /dev/null +++ b/.github/workflows/group-change-request-fixtures.yml @@ -0,0 +1,16 @@ +name: Group change request fixtures +on: + pull_request: + push: + branches: [master] +permissions: + contents: read +jobs: + fixtures: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - name: Run bounded reference fixtures + run: python3 -m unittest discover -s tests -p 'test_group_change_requests.py' -v diff --git a/.gitignore b/.gitignore index 0ecdac2..5ec02cf 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,7 @@ *.swp *.swo *~ + +# Python reference fixtures +__pycache__/ +*.pyc diff --git a/app-components/README.md b/app-components/README.md index 2e1c3ef..4820c6a 100644 --- a/app-components/README.md +++ b/app-components/README.md @@ -189,6 +189,10 @@ group-level components default to the same active-admin role that may commit it. component change, if a feature defines one, is carried as a Marmot app payload or feature-owned request flow rather than as an MLS AppDataUpdate proposal. +The optional draft [group change requests](../features/group-change-requests.md) feature defines member requests +for ordinary group settings. Approval uses the owning component's existing update bytes and authorization; the +request does not relax either proposal-sender or Commit authorization. + For a Commit, a Marmot client evaluates the single AppDataUpdate operation, if any, for each component. The component validates the proposal sender, the committer, the prior state, and the operation. It returns the new state bytes, removes the component, or returns an invalid result. If any component operation is invalid, the Commit is invalid. diff --git a/features/README.md b/features/README.md index 44c447d..6cf278a 100644 --- a/features/README.md +++ b/features/README.md @@ -15,6 +15,8 @@ they used to be described in a MIP. The old-to-new MIP map lives in [../mip-cove ## Current feature docs - [content-moderation.md](./content-moderation.md) - draft group reports, admin dismissal labels, and admin deletion. +- [group-change-requests.md](./group-change-requests.md) - draft member requests for invitations and group settings, + with authenticated admin decisions. - [encrypted-media.md](./encrypted-media.md) - current v2 message-attached encrypted blobs. - [encrypted-media-v1.md](./encrypted-media-v1.md) - frozen legacy encrypted-media v1 wire behavior. - [agent-text-streams-quic.md](./agent-text-streams-quic.md) - experimental QUIC-backed live previews for agent text diff --git a/features/group-change-requests.md b/features/group-change-requests.md new file mode 100644 index 0000000..b1d84e3 --- /dev/null +++ b/features/group-change-requests.md @@ -0,0 +1,239 @@ +# Group change requests v1 + +Status: draft. Optional; not required for baseline Marmot conformance. + +A current member can request that an admin invite an account or change the group's name, description, image, or +disappearing-message timer. Any active admin can Approve or Reject. A request grants no admin powers and changes no +group state by itself. Requests and decisions are visible to group members, not private submissions to admins. + +## Surfaces and terminology + +This feature owns kind `458` inner app events and their request/decision semantics. It uses the existing +[application payload contract](../foundation/application-messages.md), [admin policy](../app-components/admin-policy-v1.md), +[component updates](../app-components/README.md#groupcontext-update-processing), [KeyPackages](../foundation/key-packages.md), +[Welcome flow](../protocol-core/joining.md), [convergence](../protocol-core/convergence.md), +[durability](../protocol-core/durability.md), and [retention](../app-components/message-retention-v1.md). +It adds no MLS proposal type, persistent component, transport, or change to existing authorization. + +- **Requester:** the current member suggesting the change; the MLS-authenticated app-event author. +- **Requested account:** the person to invite, distinct from the requester. +- **Request:** one immutable suggestion, identified by its inner app-event id within this MLS group. +- **Active admin:** exactly the role defined by admin policy, not possession of an inbox key. +- **Approve:** attempt the existing authorized operation; not proof that it succeeded. +- **Applied:** an authenticated decision correlated with an accepted matching Commit. +- **Invited versus Joined:** an accepted Add establishes the invitation's group-state effect. It does not prove that + the requested account received, accepted, or successfully processed its Welcome. + +The [group invite-links proposal](https://github.com/marmot-protocol/marmot/pull/429) is non-normative context, not a +dependency. Both experiences use Requests, Approve/Reject, active admins, waiting, and the existing Welcome flow. +An invite-link join request comes from outside the group and targets a particular joining device; this feature's +member request suggests an account and deliberately carries no package or device reference. Its group-visible +privacy, account-level subject, and retention rules do not specify the invite-link admin-private flow. + +## Eligibility before requesting an invitation + +Before sending `invite_account`, a requester client MUST verify that the account is not already a member and discover +at least one valid KeyPackage compatible with the group's current requirements, following +[KeyPackage validation and selection](../foundation/key-packages.md#selection-and-lifecycle) and the active transport +binding. The check does not consume or reserve the package. + +No valid candidate found and discovery unavailable are distinct local outcomes. Neither permits normal submission; +the client explains the problem and offers retry. Failure to find a candidate is not proof that none exists anywhere. +No other invitee data is sent: `data` contains only the requested account's public key. KeyPackage bytes, references, +publication references, device identifiers, and discovery hints are not part of the request. + +Clients SHOULD explain that group members can see the requested account and that preflight discovery can expose +interest in that account to the queried transport services. Encryption of the request does not hide those lookups. + +This preflight is not verifiable from a public-key-only request. Receivers MUST NOT treat it as a security proof. +When approving, the admin client MUST independently discover and validate a currently usable package for the exact +requested account, including lifetime, identity proof, compatibility, publication provenance, and reuse/replacement +rules. Selection follows the existing rules, not merely the newest timestamp or the requester's cached choice. +Package rotation between the two checks needs no new request if the account identity remains the same. + +If no usable package can be established, the client MUST NOT create an Add or report Applied. It offers retry without +turning that operational failure into Rejected. Discovery cannot guarantee that a recipient still retains a package's +private material when a delayed Welcome arrives. The existing Commit-before-Welcome and receiver validation remain +unchanged; no new Joined receipt or first-contact authenticity guarantee is defined here. + +## Message format + +All actions use kind `458`, the ordinary six-field unsigned app-event envelope, and `tags: []`. `content` is UTF-8 JSON +with the exact members below; unknown members, duplicate keys, unsupported actions/operations/versions, wrong types, +noncanonical encodings, and content longer than 65,536 UTF-8 bytes have no request/decision effect. This does not reject +otherwise-valid MLS processing or create a group-state authorization check. + +Content objects, including nested objects, use lexicographically sorted ASCII member names, no whitespace between +tokens. Content string values contain Unicode scalar values. Escape quote and backslash as `\"` and `\\`, and +backspace, form feed, newline, carriage return, and tab with their named JSON escapes. Other characters below U+0020 +use lowercase six-character `\u00xx` escapes; all other characters, including slash and non-ASCII characters, are +literal UTF-8. The outer app-event id still uses [canonical encoding](../foundation/canonical-encoding.md#nostr-shaped-values) +on the complete content string, including its literal backslashes. Receivers MUST require byte equality with that +canonical serialization. JSON object ordering therefore has one encoding; strings are never normalized or trimmed. +The complete app-event id is calculated by the existing foundation rule over the exact content string. + +### Request + +Exact members: `v` (integer `1`), `action` (`"request"`), `nonce`, `operation`, and `data`. +`nonce` is 32 cryptographically random bytes represented by 64 lowercase hex characters. A fresh intent gets a fresh +nonce, including an otherwise-identical submission in the same second. Retrying republishes the same complete app +event; editing a request creates a new request and withdraws the old one when possible. + +| Operation | Exact `data` members | Values and existing owner | +| --- | --- | --- | +| `invite_account` | `pubkey` | 64 lowercase hex characters encoding a valid x-only account key; [identity](../foundation/identity.md). | +| `set_name` | `expected`, `value` | UTF-8 strings satisfying the name bounds in [profile](../app-components/group-profile-v1.md). | +| `set_description` | `expected`, `value` | UTF-8 strings satisfying the description bounds in [profile](../app-components/group-profile-v1.md). | +| `set_retention` | `expected`, `value` | Unsigned decimal strings representing the duration range in [retention](../app-components/message-retention-v1.md). | +| `set_avatar_url` | `expected`, `value` | Encoded complete component state; [URL avatar](../app-components/group-avatar-url-v1.md). | +| `set_blossom_image` | `expected`, `value` | Encoded complete component state; [Blossom image](../app-components/group-blossom-image-v1.md). | + +For settings, `expected` may instead be JSON `null`, meaning the owning component is absent. It is distinct from a +present empty string, explicit zero timer, or encoded empty image state. `value` MUST NOT be null. Decimal strings +are `"0"` or start with `1..9` followed by decimal digits; leading zeros, signs, whitespace, and numeric JSON values +are invalid. Zero disables disappearing messages through a present retention component. + +Image strings use RFC 4648 standard padded base64 with no whitespace or alternative alphabet. Decoded bytes are at +most 4096 bytes and MUST decode exactly as a canonical valid state of the named component. Empty images use that +component's encoded empty state, not an empty base64 string. Its opaque render hints retain their owning validation. +Only those two image components are allowed; this is not an arbitrary component update interface. URL/Blossom +coexistence and display precedence remain unchanged. Clients SHOULD explain when a proposed Blossom image would be +hidden by an existing URL avatar. Rendering or fetching a suggested image is local policy, not an acceptance check. + +The requester MUST obtain `expected` from the current authenticated group state. At approval, the relevant candidate +parent value MUST equal it. Otherwise the request is locally Stale and MUST NOT be executed through this request. +A new request or an independent admin action can supersede it. Unrelated epoch changes do not stale the request. +For profile fields, the admin changes only the requested field and preserves the other current field in the full +replacement component. When creating an absent profile, its other field is the empty string. Image requests replace +only the named component; they do not clear a coexisting image component. Component removal is not defined here. +The 4096-byte decoded image bound is this feature's wire budget, not a new component validity rule. Every state of +the two current image schemas fits it (their maximum encoded lengths are 2566 and 242 bytes respectively); the +reference fixtures check these bounds against the owning documents. A future larger image schema needs an explicit +new request operation/version, not an implicit change to these v1 requests. + +### Rejection and withdrawal + +Rejection has exactly `v: 1`, `action: "rejected"`, `request` (the 64-character lowercase hex request app-event id), +and `reason` (a UTF-8 string of at most 1024 bytes; empty is allowed). +Withdrawal has exactly `v: 1`, `action: "withdrawn"`, and `request`. + +Receivers MUST verify that a rejecting sender was an active admin in the event's authenticated source-epoch branch. +A withdrawal MUST be authored by the same account as the request, including another valid leaf of that account. +Today's admin list and author-provided timestamps MUST NOT replace these checks. A rejection dismisses a suggestion; +it does not veto an admin's independent action or remove an already-added account. + +### Applied receipt + +Exact members: `v: 1`, `action: "applied"`, `request`, and `commit`. +`request` has the same encoding as above. `commit` is the 64-character lowercase hex SHA-256 digest of the complete +serialized Commit MLSMessage bytes, as used by [convergence](../protocol-core/convergence.md#same-epoch-races), not an +outer transport event id. The receipt sender's MLS-authenticated account MUST equal the committer's account; another +valid leaf of that account is allowed. The sender MUST be an active admin in the receipt's source-epoch branch. +A receipt never applies or authorizes its referenced Commit. + +All references are resolved within the same MLS group. Receivers MUST establish the retained request, receipt +authorization, and an accepted matching Commit before showing Applied. Missing evidence remains Unresolved, not +success. Commit validity MUST NOT depend on possession of a request or receipt. + +A matching Commit MUST have been authorized under existing rules and have the requester as a current member in its +candidate parent. That parent MUST be the request's authenticated source-epoch state or a descendant on the selected +branch, not an older epoch or unrelated branch. The receipt's authenticated source epoch MUST be the matching +Commit's resulting state or a descendant on that branch. For `invite_account`, the requested account is absent in that parent and exactly one added leaf +belongs to it; no other leaves are added or removed. For settings, the parent satisfies `expected`, the resulting +requested value equals `value`, and any unaffected field in the owning component is preserved as specified above. +For either operation, other component values, required capabilities, and existing membership MUST be unchanged; +ordinary MLS epoch advancement and the committer's routine leaf/key update are allowed. Resulting-state component +validation still applies. An unrelated or broader admin change is not fulfillment of this exact request. + +The admin emits a receipt only after successful Commit publication and local canonical application under the existing +publish lifecycle. Publication failure leaves the request unapplied. An Add receipt means Invited, never Joined; +Welcome delivery failure MUST remain separately visible, not be hidden behind the Applied request status. + +Before publishing a request-driven Commit, the admin MUST retain or be able to reconstruct its request correlation +under [durability](../protocol-core/durability.md#recoverable-protocol-facts). After restart it SHOULD publish a missing +receipt for an accepted matching Commit when the request is still retained/unexpired and its current leaf can author +an authorized receipt. A prepared receipt retains its exact app-event identity across retries. The admin MUST NOT +repeat an Add or settings mutation solely because its receipt is missing. Without retained correlation or current +receipt authority, the client reports the group change separately and leaves request fulfillment Unresolved rather +than fabricating success; old authenticated receipts retain their source-epoch authorization after demotion. + +## Projection, concurrency, and recovery + +For retained requests, supported clients MUST derive the same status from the same authenticated evidence set: + +1. Applied if at least one valid receipt references an accepted matching Commit. +2. Otherwise Withdrawn if at least one valid requester withdrawal exists. +3. Otherwise Rejected if at least one authorized rejection exists. +4. Otherwise Pending; an unresolved receipt is separately indicated and never interpreted as Applied. + +Local Stale, Already a member, Unable to check eligibility, and Waiting for an admin explain why an otherwise Pending +request is not currently actionable. They do not synthesize decisions. A rejected or withdrawn request MUST NOT be +offered for approval. Before staging a Commit, the admin rechecks the request status, current authority, requester +membership, expected values, and invitation eligibility. Admins may act independently outside a request. + +Simultaneous admin actions follow existing convergence. Neither receipt timestamps nor first arrival choose group +state. An in-flight authorized Commit can race rejection or withdrawal; a verified Applied result wins over either, +because they cannot undo the Commit. Multiple matching receipts do not apply the operation again. Reasons remain +attributed to their admins; the protocol does not select a winning explanation. + +When convergence withdraws a request, receipt, rejection, withdrawal, or its matching Commit, clients MUST recompute +the projection and withdraw effects supported only by that input. Applied is not irreversible global finality. +Receipt authorization and matching evidence MUST remain reproducible, or be recorded as a verdict bound to the +request, receipt, Commit digest, and authenticated branch, for as long as their effects are retained. Pruning a parent +state MUST NOT revoke an established verdict; later branch withdrawal still invalidates its effect. Restart uses the +same evidence and MUST NOT invent Applied or reopen a retained decision merely because a process restarted. + +Requests and decisions follow existing app-message expiry and retained-history limits, with no silent exemption. +Expired request content MUST NOT be revived for approval; the member can make a new request. Pruning evidence is an +availability limit, not evidence of rejection or that a completed group change was undone. A timer change preserves +every older message's pinned expiry and never performs a retroactive history purge. + +Clients SHOULD bound active request lists, unresolved-reference buffering, retries, and notifications. Resource limits +are local admission/presentation policy; they MUST NOT make otherwise-valid MLS input invalid. Unsupported clients +use ordinary unknown-app-event handling and are not assumed to offer a Requests screen. No response means waiting, +not rejection. A request is not an invitation or consent from the requested account. + +## Examples + +These are canonical `content` strings inside kind `458` events; the ordinary envelope still applies. + +```json +{"action":"request","data":{"pubkey":"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"},"nonce":"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f","operation":"invite_account","v":1} +``` + +The nonce above is a fixture only; real clients generate it randomly. + +With envelope author `79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798`, `created_at: 1700000000`, +kind `458`, empty tags, and the exact invitation content above, the canonical NIP-01 app-event id is +`e5ab295cece5e1a951e8d0c591f42f4a0666c26f9f7f7d34d9e09fe21ab36d31`. +This example author/subject equality demonstrates encoding only; an already-member subject fails invitation preflight. + +A proposed timer change: + +```json +{"action":"request","data":{"expected":"86400","value":"604800"},"nonce":"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f","operation":"set_retention","v":1} +``` + +An applied receipt for a fixture request and Commit: + +```json +{"action":"applied","commit":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","request":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","v":1} +``` + +The example references are placeholders, not evidence that a Commit exists. Rejection uses the same `request` reference: + +```json +{"action":"rejected","reason":"Please check with the person first.","request":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","v":1} +``` + +## Conformance and migration + +Implementations of this feature need conformance cases for canonical content and app-event ids, no package fields in invitation requests, proposer preflight +and independent approval discovery, expired/rotated/incompatible packages, stale field checks, preservation of other +profile fields, exact image bytes and precedence, absent versus empty state, response-before-request delivery, +non-admin decisions, cross-group references, two-admin races, losing Commit invalidation, restart, expiry, missing +evidence, and unsupported clients. Successful invitation and successful Welcome processing are tested separately. + +This is a new optional application feature, not a migration of MIP-era MLS proposal queues. The new kind and `v: 1` +identify its wire semantics. Unsupported future versions have no v1 effect. No adoption of a separate idea, including +invite links or multi-device enrollment, changes this operation set or authorizes new state transitions implicitly. diff --git a/foundation/application-messages.md b/foundation/application-messages.md index 2cd406b..12cb721 100644 --- a/foundation/application-messages.md +++ b/foundation/application-messages.md @@ -88,6 +88,9 @@ or decline to render unsupported application semantics after delivering the acce The optional [group content moderation](../features/content-moderation.md) feature defines reports (kind `1984`), admin dismissal labels (kind `1985`), and admin deletion (kind `4891`). +The optional draft [group change requests](../features/group-change-requests.md) feature defines kind `458` requests +and authenticated decisions. Its requests are not MLS proposals and do not confer group-state authority. + Polls use the standard [NIP-88](https://github.com/nostr-protocol/nips/blob/master/88.md) app-event shapes: kind `1068` for a poll and kind `1018` for a response. Both are ordinary Marmot app events inside MLS and remain subject to the encoding and receiver-authentication rules above. Their inner `relay` tags, when present, never affect delivery; diff --git a/foundation/registries.md b/foundation/registries.md index 4ed4c5b..f6bad26 100644 --- a/foundation/registries.md +++ b/foundation/registries.md @@ -99,6 +99,7 @@ seal), kind `10002` (NIP-65 relay list), and kind `10050` (NIP-17 DM inbox relay | `449` | Push token removal | Marmot app payload | [push-notifications.md](../features/push-notifications.md) | | `450` | Account identity proof v2 event | Local signing template, not relayed | [account-identity-proof-v2.md](../app-components/account-identity-proof-v2.md) | | `451` | Push owner proof event | Local signing template, not relayed | [push-notifications.md](../features/push-notifications.md) | +| `458` | Group change request control v1 | Marmot app payload | [group-change-requests.md](../features/group-change-requests.md) | | `1009` | Message edit | Marmot app payload | [application-messages.md](application-messages.md) | | `1018` | Poll response (NIP-88) | Marmot app payload | [application-messages.md](application-messages.md) | | `1068` | Poll (NIP-88) | Marmot app payload | [application-messages.md](application-messages.md) | @@ -111,6 +112,9 @@ seal), kind `10002` (NIP-65 relay list), and kind `10050` (NIP-17 DM inbox relay Kind `4891` is allocated by Marmot for admin deletion inside MLS app payloads. +Kind `458` identifies the draft optional group-change request, rejection, withdrawal, and applied-receipt actions. +It is an inner app-event allocation only; it does not add a public Nostr transport event. + The experimental agent text stream QUIC feature claims kind `1200` for durable stream start app events. Live stream chunks are transient QUIC records. diff --git a/layout.md b/layout.md index cf5019b..ad5b855 100644 --- a/layout.md +++ b/layout.md @@ -60,6 +60,7 @@ transports/ features/ README.md content-moderation.md + group-change-requests.md encrypted-media.md encrypted-media-v1.md agent-text-streams-quic.md diff --git a/protocol-core/group-messaging.md b/protocol-core/group-messaging.md index c166401..96ec697 100644 --- a/protocol-core/group-messaging.md +++ b/protocol-core/group-messaging.md @@ -56,6 +56,9 @@ Non-admin members MAY send standalone MLS proposals only where the spec explicit that proposal flow is SelfRemove. A request for an admin-gated group-state change is an application payload or feature-owned request flow, not a standalone MLS proposal. +The optional draft [group change requests](../features/group-change-requests.md) feature defines one such flow. +Its request or decision alone does not authorize or apply a Commit; existing authorization and convergence still apply. + ## Publish before apply A locally generated Commit MUST NOT become the sender's canonical local state until its publish obligation succeeds. diff --git a/tests/test_group_change_requests.py b/tests/test_group_change_requests.py new file mode 100644 index 0000000..8ab3dad --- /dev/null +++ b/tests/test_group_change_requests.py @@ -0,0 +1,280 @@ +"""Bounded encoding/projection fixtures, not an MLS or component implementation. + +Source-authority and Commit-matching facts are supplied inputs. The model checks +their use, not their cryptographic derivation. Component decoding, account-key +validity and package discovery are outside this reference model. +""" +import base64 +import hashlib +import itertools +import json +from pathlib import Path +import re +import unittest + + +HEX = re.compile(r"[0-9a-f]{64}\Z") +DECIMAL = re.compile(r"(?:0|[1-9][0-9]*)\Z") +PUBKEY = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" +NONCE = bytes(range(32)).hex() + + +def canonical(value): + return json.dumps(value, sort_keys=True, ensure_ascii=False, separators=(",", ":")) + + +def unique_object(pairs): + obj = {} + for key, value in pairs: + if key in obj: + raise ValueError("duplicate key") + obj[key] = value + return obj + + +def require(ok): + if not ok: + raise ValueError("invalid fixture") + + +def text(value, limit): + require(isinstance(value, str)) + require(len(value.encode("utf-8")) <= limit) + + +def hex64(value): + require(isinstance(value, str) and HEX.fullmatch(value)) + + +def content_shape(raw): + require(len(raw.encode("utf-8")) <= 65536) + value = json.loads(raw, object_pairs_hook=unique_object) + require(isinstance(value, dict)) + require(type(value.get("v")) is int and value["v"] == 1) + action = value.get("action") + if action == "request": + require(set(value) == {"v", "action", "nonce", "operation", "data"}) + hex64(value["nonce"]) + data = value["data"] + require(isinstance(data, dict)) + operation = value["operation"] + if operation == "invite_account": + require(set(data) == {"pubkey"}) + hex64(data["pubkey"]) + # Mathematical key validity is the foundation identity check. + else: + require(set(data) == {"expected", "value"}) + require(data["value"] is not None) + if operation in {"set_name", "set_description"}: + limit = 256 if operation == "set_name" else 4096 + text(data["value"], limit) + if data["expected"] is not None: + text(data["expected"], limit) + elif operation == "set_retention": + for item in (data["expected"], data["value"]): + if item is not None: + require(isinstance(item, str) and DECIMAL.fullmatch(item)) + require(int(item) <= 2**64 - 1) + elif operation in {"set_avatar_url", "set_blossom_image"}: + for item in (data["expected"], data["value"]): + if item is not None: + require(isinstance(item, str) and item != "") + decoded = base64.b64decode(item, validate=True) + require(len(decoded) <= 4096) + require(base64.b64encode(decoded).decode("ascii") == item) + # Owning component validation is an external input. + else: + raise ValueError("unknown operation") + elif action == "rejected": + require(set(value) == {"v", "action", "request", "reason"}) + hex64(value["request"]) + text(value["reason"], 1024) + elif action == "withdrawn": + require(set(value) == {"v", "action", "request"}) + hex64(value["request"]) + elif action == "applied": + require(set(value) == {"v", "action", "request", "commit"}) + hex64(value["request"]) + hex64(value["commit"]) + else: + raise ValueError("unknown action") + require(raw == canonical(value)) + return value + + +def request(operation="invite_account", data=None, nonce=NONCE): + return {"v": 1, "action": "request", "nonce": nonce, "operation": operation, + "data": {"pubkey": PUBKEY} if data is None else data} + + +def event_id(content, created_at=1700000000): + # NIP-01 fixture with no unsupported numeric or string values. + preimage = json.dumps([0, PUBKEY, created_at, 458, [], content], + ensure_ascii=False, separators=(",", ":")) + return hashlib.sha256(preimage.encode("utf-8")).hexdigest() + + +def evidence(action, **changes): + result = {"action": action, "request": "request-a", "group": "group-a", "sender": "admin-account", + "source_admin": True, "selected_branch": True, "request_seen": True, + "commit_matches": True, "commit_accepted": True, "receipt_causal": True} + result.update(changes) + return result + + +def project(records, target="request-a", group="group-a", requester="member-account", committer="admin-account"): + effects = set() + for item in records: + if (item["request"] != target or item["group"] != group + or not item["selected_branch"] or not item["request_seen"]): + continue + action = item["action"] + if action == "applied": + if (item["source_admin"] and item["sender"] == committer + and item["commit_matches"] and item["commit_accepted"] and item["receipt_causal"]): + effects.add("matching_applied") + elif action == "rejected" and item["source_admin"]: + effects.add("rejected") + elif action == "withdrawn" and item["sender"] == requester: + effects.add("withdrawn") + for effect, status in (("matching_applied", "Applied"), + ("withdrawn", "Withdrawn"), ("rejected", "Rejected")): + if effect in effects: + return status + return "Pending" + + +class Fixtures(unittest.TestCase): + def test_document_examples_are_canonical_shapes(self): + doc = Path(__file__).resolve().parents[1] / "features/group-change-requests.md" + examples = re.findall(r"```json\n(.*?)\n```", doc.read_text(), re.S) + self.assertEqual(len(examples), 4) + for example in examples: + content_shape(example) + + def test_invitation_has_only_account_data(self): + for extra in ("key_package", "key_package_ref", "publication", "relay", "device"): + value = request() + value["data"][extra] = "ignored" + with self.assertRaises(ValueError): + content_shape(canonical(value)) + + def test_version_and_unknown_actions(self): + for field, item in (("v", True), ("v", 2), ("action", "approved"), + ("operation", "promote_admin")): + value = request() + value[field] = item + with self.assertRaises(ValueError): + content_shape(canonical(value)) + + def test_duplicate_keys_and_noncanonical_content(self): + for raw in ('{"v":1,"v":1}', json.dumps(request()), + canonical(request()).replace('"pubkey":', '"pubkey": ')): + with self.assertRaises(ValueError): + content_shape(raw) + + def test_unicode_bounds_and_no_normalization(self): + good = request("set_name", {"expected": "e\u0301", "value": "é" * 128}) + self.assertNotEqual(good["data"]["expected"], "é") + content_shape(canonical(good)) + good["data"]["value"] += "é" + with self.assertRaises(ValueError): + content_shape(canonical(good)) + + def test_non_scalar_strings_are_invalid(self): + # UnicodeEncodeError is a ValueError; escaped and raw surrogates both fail. + value = request("set_name", {"expected": "", "value": "\ud800"}) + for raw in (json.dumps(value, sort_keys=True, separators=(",", ":")), canonical(value)): + with self.assertRaises(ValueError): + content_shape(raw) + + def test_component_limit_alignment(self): + root = Path(__file__).resolve().parents[1] + profile = (root / "app-components/group-profile-v1.md").read_text() + retention = (root / "app-components/message-retention-v1.md").read_text() + self.assertIn("opaque name<0..256>", profile) + self.assertIn("opaque description<0..4096>", profile) + self.assertIn("uint64 disappearing_message_secs", retention) + for filename, expected in (("group-avatar-url-v1.md", 2566), + ("group-blossom-image-v1.md", 242)): + source = (root / "app-components" / filename).read_text() + schema = re.search(r"```text\n(.*?)\n```", source, re.S).group(1) + limits = [int(n) for n in re.findall(r"opaque \w+<0\.\.(\d+)>", schema)] + maximum = sum(n + (1 if n <= 63 else 2 if n <= 16383 else 4) for n in limits) + self.assertEqual(maximum, expected) + self.assertLessEqual(maximum, 4096) + + def test_description_named_escapes_fit_envelope(self): + content_shape(canonical(request("set_description", + {"expected": "\n" * 4096, "value": "\t" * 4096}))) + + def test_existing_control_characters_round_trip(self): + for operation, limit in (("set_name", 256), ("set_description", 4096)): + value = request(operation, {"expected": "\0" * limit, "value": "\x01" * limit}) + self.assertEqual(content_shape(canonical(value)), value) + + def test_decimal_full_range_and_null(self): + for old in (None, "0"): + content_shape(canonical(request("set_retention", + {"expected": old, "value": str(2**64 - 1)}))) + for bad in ("00", "+1", " 1", "1.0", str(2**64), 1, None): + with self.assertRaises(ValueError): + content_shape(canonical(request("set_retention", {"expected": "0", "value": bad}))) + + def test_absent_differs_from_present_empty(self): + absent = request("set_name", {"expected": None, "value": "club"}) + empty = request("set_name", {"expected": "", "value": "club"}) + self.assertNotEqual(event_id(canonical(absent)), event_id(canonical(empty))) + + def test_image_base64_encoding_not_component_validity(self): + # URL-avatar empty state: three zero-length vectors. + content_shape(canonical(request("set_avatar_url", {"expected": None, "value": "AAAA"}))) + for bad in ("", "AA", "AB==", "AA==\n", "_A=="): + with self.assertRaises(ValueError): + content_shape(canonical(request("set_avatar_url", {"expected": None, "value": bad}))) + + def test_same_second_intents_and_retries(self): + first = canonical(request()) + second = canonical(request(nonce="f" * 64)) + self.assertEqual(event_id(first), event_id(first)) + self.assertNotEqual(event_id(first), event_id(second)) + + def test_fixed_app_event_id(self): + self.assertEqual(event_id(canonical(request())), + "e5ab295cece5e1a951e8d0c591f42f4a0666c26f9f7f7d34d9e09fe21ab36d31") + + def test_delivery_order_does_not_select_status(self): + effects = [evidence("applied"), evidence("rejected"), + evidence("withdrawn", sender="member-account")] + for order in itertools.permutations(effects): + self.assertEqual(project(order), "Applied") + + def test_unresolved_or_invalid_authority_is_not_applied(self): + for field, value in (("request", "request-b"), ("group", "group-b"), ("sender", "another-admin"), + ("source_admin", False), ("selected_branch", False), + ("request_seen", False), ("commit_matches", False), + ("commit_accepted", False), ("receipt_causal", False)): + self.assertEqual(project([evidence("applied", **{field: value})]), "Pending", field) + self.assertEqual(project([evidence("rejected", source_admin=False)]), "Pending") + self.assertEqual(project([evidence("withdrawn", sender="another-member")]), "Pending") + self.assertEqual(project([evidence("rejected"), + evidence("withdrawn", sender="member-account")]), "Withdrawn") + + def test_two_requests_have_independent_status(self): + records = [evidence("applied", request="request-a"), + evidence("rejected", request="request-b")] + self.assertEqual(project(records, target="request-a"), "Applied") + self.assertEqual(project(records, target="request-b"), "Rejected") + self.assertEqual(project(records, target="request-c"), "Pending") + + def test_reorg_withdraws_only_supported_effects(self): + effects = [evidence("applied"), evidence("rejected")] + self.assertEqual(project(effects), "Applied") + effects[0]["commit_accepted"] = False + self.assertEqual(project(effects), "Rejected") + # Restart from the same retained facts produces the same view. + self.assertEqual(project(json.loads(json.dumps(effects))), "Rejected") + + +if __name__ == "__main__": + unittest.main()