From 40e4a27565e2b49ab7b33612d5090f2e200542df Mon Sep 17 00:00:00 2001
From: "shakespeare.diy"
Date: Fri, 24 Jul 2026 16:46:20 +0700
Subject: [PATCH] Add crew NIP-05 (any-domain, founder-verified) +
founder/delegated-admin governance
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- SkateCrew: add nip05 + admins fields; parse `admin` role marker on p tags (admins = subset of members)
- CrewForm: NIP-05 input + per-member "Make admin" toggle; write nip05 tag + admin-marked p tags (works on create & edit via shared form)
- useCrewNip05: verify crew handle on ANY domain against the founder pubkey (verified/mismatch/unreachable), CORS-proxy fallback + caching
- CrewNip05Badge: green ✓ / red ⚠ / muted handle states with tooltips
- CollectiveMembers: show Admin chips, sort admins first, admin count in summary
- useCrewRole: centralized founder/admin/member/none roles (canManage, canEditProfile) — founder owns identity, admins get delegated powers with their own keys; egalitarian shared-editing intentionally unsupported
- CrewDetailPage: NIP-05 badge by crew name, viewer role badges (admin/member)
- NIP.md: document crew nip05 verification, admin role marker, and governance/conflict-resolution model
Co-authored-by: shakespeare.diy
---
NIP.md | 51 ++++++++++++++-
src/components/CollectiveMembers.tsx | 29 ++++++++-
src/components/CrewForm.tsx | 93 ++++++++++++++++++++++++++--
src/components/CrewNip05Badge.tsx | 91 +++++++++++++++++++++++++++
src/hooks/useCrewNip05.ts | 88 ++++++++++++++++++++++++++
src/hooks/useCrewRole.ts | 71 +++++++++++++++++++++
src/lib/types.ts | 28 +++++++++
src/pages/CrewDetailPage.tsx | 36 +++++++----
8 files changed, 466 insertions(+), 21 deletions(-)
create mode 100644 src/components/CrewNip05Badge.tsx
create mode 100644 src/hooks/useCrewNip05.ts
create mode 100644 src/hooks/useCrewRole.ts
diff --git a/NIP.md b/NIP.md
index aa6f2d8..9b1e795 100644
--- a/NIP.md
+++ b/NIP.md
@@ -1716,9 +1716,10 @@ bare `p` tag on the crew is only a claim until the member self-labels.
["founded", "2019"],
["t", "street"],
["t", "transition"],
- ["p", ""],
+ ["p", "", "", "admin"],
["p", ""],
["a", "30818::"],
+ ["nip05", "barcelonanightcrew@bcnnightcrew.example"],
["website", "https://bcnnightcrew.example"],
["noffer", "noffer1qqs..."],
["alt", "Skate crew: Barcelona Night Crew"]
@@ -1743,8 +1744,9 @@ bare `p` tag on the crew is only a claim until the member self-labels.
| `location` | Human-readable home location |
| `founded` | Year founded (cosmetic) |
| `t: ` | Style/focus tags. Preset vibes plus any user-defined custom hashtag (lowercased, hyphenated slug). Examples: `street`, `transition`, `park`, `diy`, `oldschool`, `allterrain`, `cruising`, `girls`, `youth`, `adaptive`, `downhill`, `night-sessions`, etc. |
-| `p` | Member pubkeys (hex). Confirmed only when the member self-attests (see below). |
+| `p` | Member pubkeys (hex). Confirmed only when the member self-attests (see below). An optional 4th element `"admin"` marks the member as a delegated admin (see Governance). |
| `a` | Home spots the crew claims/haunts — references to kind `30818` skate spots (up to 8). Cross-links the crew to the map. |
+| `nip05` | Optional crew NIP-05 identifier (`local@domain`). Verified against the **founder** pubkey — see NIP-05 Verification. |
| `website`, `instagram`, `social` | Links |
| `noffer` | Optional shared tip jar (CLINK Offer — see below) |
@@ -1755,6 +1757,51 @@ belonging by publishing a NIP-32 label (kind `1985`) referencing the crew's
`a`-coordinate, `["l", "member", "org.skatespots.membership"]`. Confirmed /
pending chips and removal semantics are identical to Lab collectives.
+### Governance (founder + delegated admins)
+
+A crew's identity is a single **addressable event authored by the founder**.
+On Nostr, an addressable event can only be overwritten by its author's key, so
+the founder's pubkey is the crew's single source of truth — **conflicts are
+impossible at the data layer** (a fork by anyone else is a *different* event
+under a *different* author and is ignored by clients querying the founder's
+coordinate).
+
+Roles:
+
+| Role | Determined by | Powers |
+|------|---------------|--------|
+| **Founder** | Authored the crew event | Sole owner of the crew identity. Only role that can **edit the profile** (name, logo, members, admins, NIP-05, etc.). |
+| **Admin** | A confirmed member whose `p` tag carries the `"admin"` marker | Delegated *powers* exercised with the admin's **own key**: invite/vouch members, moderate the crew feed & chat, represent the crew. Cannot rewrite the profile. |
+| **Member** | Listed `p` tag + self-attested | Appears in the crew, posts to the feed/chat. |
+
+Admins are always a **subset of members** — a pubkey marked `admin` that is not
+also a confirmed member is treated as a plain (pending) member until it attests.
+
+> **Why not egalitarian shared editing?** Letting arbitrary members overwrite
+> the crew profile would require either a **shared private key** (a single leak
+> hijacks the crew) or a **consensus/multisig layer** (heavy and
+> non-interoperable). SkateSpots deliberately uses the owner + delegated-mods
+> model (like GitHub orgs, Discord, and NIP-72 communities): the founder owns
+> the record, admins wield delegated powers with their own keys.
+
+### NIP-05 Verification
+
+The optional `nip05` tag is a crew handle on **any domain the crew controls**
+(fully decentralized — SkateSpots does not gatekeep the handle). Because the
+crew event is authored by the founder, a client verifies the handle by fetching
+`https:///.well-known/nostr.json?name=` and checking that the
+returned pubkey equals the **founder** pubkey:
+
+| Result | Meaning | UI |
+|--------|---------|----|
+| name → founder pubkey | Verified | Green ✓ handle |
+| name → different pubkey | Impersonation / stale | Red ⚠ handle |
+| domain/JSON unreachable | Can't verify right now | Muted handle |
+| no `nip05` | — | (nothing) |
+
+Verification is fetched directly with a CORS-proxy fallback and cached
+client-side.
+
### Map Integration
Crews with a `g` (home turf) render on the SkateSpots map as teal pins. Clicking
diff --git a/src/components/CollectiveMembers.tsx b/src/components/CollectiveMembers.tsx
index 397e3e4..91ce8ed 100644
--- a/src/components/CollectiveMembers.tsx
+++ b/src/components/CollectiveMembers.tsx
@@ -20,6 +20,8 @@ interface CollectiveMembersProps {
founderPubkey: string;
identifier: string;
claimedMembers: string[];
+ /** Delegated admin pubkeys (subset of members). */
+ admins?: string[];
accentText?: string;
accentBorder?: string;
}
@@ -48,7 +50,15 @@ function StatusChip({ status }: { status: CollectiveMember['status'] }) {
);
}
-function MemberRow({ member }: { member: CollectiveMember }) {
+function AdminChip() {
+ return (
+
+ Admin
+
+ );
+}
+
+function MemberRow({ member, isAdmin }: { member: CollectiveMember; isAdmin: boolean }) {
const author = useAuthor(member.pubkey);
const metadata = author.data?.metadata;
const name = metadata?.name ?? genUserName(member.pubkey);
@@ -64,6 +74,7 @@ function MemberRow({ member }: { member: CollectiveMember }) {
{name.slice(0, 2).toUpperCase()}{name}
+ {isAdmin && }
);
@@ -78,6 +89,7 @@ export function CollectiveMembers({
founderPubkey,
identifier,
claimedMembers,
+ admins = [],
accentText = 'text-indigo-400',
accentBorder = 'border-gray-700',
}: CollectiveMembersProps) {
@@ -91,8 +103,18 @@ export function CollectiveMembers({
if (claimedMembers.length === 0) return null;
+ const adminSet = new Set(admins);
const confirmedCount = members?.filter((m) => m.status === 'confirmed').length ?? 0;
+ // Admins first, then the hook's existing confirmed-first ordering.
+ const orderedMembers = members
+ ? [...members].sort((a, b) => {
+ const aa = adminSet.has(a.pubkey) ? 1 : 0;
+ const ba = adminSet.has(b.pubkey) ? 1 : 0;
+ return ba - aa;
+ })
+ : members;
+
return (
@@ -108,6 +130,7 @@ export function CollectiveMembers({
{confirmedCount > 0
? `${confirmedCount} of ${claimedMembers.length} verified — each confirmed member signed with their own Nostr key.`
: 'Membership is verified per member. Each member confirms with their own Nostr key.'}
+ {admins.length > 0 && ` ${admins.length} admin${admins.length !== 1 ? 's' : ''} help run the crew.`}
@@ -123,8 +146,8 @@ export function CollectiveMembers({
) : (
+
@@ -85,10 +116,20 @@ export function CrewForm({ existing }: CrewFormProps) {
const [instagram, setInstagram] = useState(existing?.instagram ?? '');
const [socials, setSocials] = useState(existing?.socials ?? []);
const [socialInput, setSocialInput] = useState('');
+ const [nip05, setNip05] = useState(existing?.nip05 ?? '');
const [noffer, setNoffer] = useState(existing?.noffer ?? '');
const [members, setMembers] = useState(existing?.members ?? []);
+ const [admins, setAdmins] = useState(existing?.admins ?? []);
const [memberInput, setMemberInput] = useState('');
const [memberError, setMemberError] = useState(null);
+
+ const toggleAdmin = (pk: string) =>
+ setAdmins((prev) => (prev.includes(pk) ? prev.filter((x) => x !== pk) : [...prev, pk]));
+
+ const removeMember = (pk: string) => {
+ setMembers((prev) => prev.filter((m) => m !== pk));
+ setAdmins((prev) => prev.filter((a) => a !== pk));
+ };
const [selectedSpots, setSelectedSpots] = useState(
(existing?.spotRefs ?? []).map((ref) => {
const parts = ref.split(':');
@@ -232,9 +273,17 @@ export function CrewForm({ existing }: CrewFormProps) {
for (const s of socials) {
if (s.trim()) tags.push(['social', s.trim()]);
}
+ if (nip05.trim()) tags.push(['nip05', nip05.trim().toLowerCase()]);
if (isValidNoffer(noffer.trim())) tags.push(['noffer', noffer.trim().toLowerCase()]);
- for (const pk of members) tags.push(['p', pk]);
+ // Members: admins get an `admin` role marker on their `p` tag.
+ for (const pk of members) {
+ if (admins.includes(pk)) {
+ tags.push(['p', pk, '', 'admin']);
+ } else {
+ tags.push(['p', pk]);
+ }
+ }
for (const spotId of selectedSpots) {
const [spotPubkey, spotIdentifier] = spotId.split(':');
@@ -346,6 +395,14 @@ export function CrewForm({ existing }: CrewFormProps) {
Add your crew's Nostr accounts. Each member confirms from their own key — until then they show as “pending”.
+
+
+
+ Make trusted members admins — they can invite members
+ and moderate the crew feed & chat with their own keys. You (the founder) always stay the
+ owner of the crew's identity.
+
+
+ A verifiable handle you control on any domain, e.g. crew@yourdomain.com.
+ It's verified against this crew's founder key — a green check shows when the domain confirms it.
+