eleven / messenger Public

Cohort invites: an invite carries an access level (#657)

* server+web: cohort invites — an invite carries an access level

key_invites.tier_id (admin-minted only — level assignment stays an admin
power); assignment at first-device claim ('invite' source, best-effort like
default groups, deleted-level fails safe); the invite lookup names the level
for the welcome card (link-holders only, branding-class); setPubkey prods
level-key holders the moment the joiner is wrappable-to; console invite form
gains the cohort picker (hidden until levels exist, dead levels excluded).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* admin: access levels manage in place — no repaint flash; placeholder shows example names

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* admin: restore the add-level handler dropped by the in-place rework

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web+server: cohort picker in the in-chat invite flow (admins), Enter creates a level, member rows never render nameless

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* cohort invites: anyone in a level can invite into it (flat-group rule); admins any level; picker fetches fresh so a just-created level shows

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: show a person's access levels as chips on their profile card

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: show your own access levels in Edit profile (self-facing mirror of the profile chips)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* server+web: access-level visibility — admin policy (shown/hidden/user) + per-member choice

Each level's roster is served full to its members and admins (they share the
key — insiders inherently know each other) but filtered for non-members by
the admin policy: shown → all, hidden → none, user → each member's own choice
(setUserTierVisible). Admin app gets the 3-way policy select per level; Edit
profile gets a per-level 'show on my profile' toggle under the 'user' policy,
a members-only note under 'hidden'. Endpoints: POST /api/tiers/{id}/policy
(admin), /api/tiers/{id}/show (member). TestTierVisibility covers all three.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* admin: note that gating a group to a level needs level membership; drop the stale 'Soon'

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: a parked thread's refresh must not paint into the shared pane

The welcome/tour emits steps asynchronously; each appendNotice fires the
thread's refreshFeed closure, which re-renders via renderApp(threadId,'chat',
#app-pane) — the shared pane. mountedCtx/refreshFeed persist for a PARKED
thread, so a step firing after you navigated away rendered the welcome (Eleven)
into whatever chat now occupies #app-pane (the reported 'Eleven in my inviter
DM'). render() already guards hotkeys/native against a parked re-render but not
the feed. Stamp the mount root with its threadId and skip the refresh when the
pane belongs to another thread — the notice is already stored and paints on the
thread's next open. (Groups were fixed earlier via the not-loaded path; this is
the DM/loaded-thread path.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: 'New members join automatically' at group-creation time too (was Edit-group only)

Admin-only, shown when Open is ticked and no access-level gate (a gated group
can't be default — server refuses). createThread marks it default after the
open group is created, mirroring the Edit-group toggle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: open groups open with a start-of-conversation note (name + live member count + Say hi)

Reuses the chatIntro persistent-first-row primitive; intro vrev now tracks its
text so the member count stays current as people join.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell d85c1bc3a1486ed9608d70c8a5d3ac44e5ab745e parent 8de8eaf
11 files changed, +1023 −144
  • changelog/2026-07-11-cohort-invites.md +6 −0
  • db.go +127 −11
  • handlers_test.go +288 −2
  • main.go +130 −1
  • web/pages/admin.html +2 −0
  • web/static/admin.js +33 −2
  • web/static/apps/admin.js +195 −122
  • web/static/apps/chat.js +3 −1
  • web/static/join.js +14 −0
  • web/static/shell.js +217 −5
  • web/static/style.css +8 −0
diff --git a/changelog/2026-07-11-cohort-invites.md b/changelog/2026-07-11-cohort-invites.md
new file mode 100644
index 0000000..cf995be
--- /dev/null
+++ b/changelog/2026-07-11-cohort-invites.md
@@ -0,0 +1,6 @@
+🤖 **Cohort invites.** An invite can now carry an access level: pick "Add
+them to January 2027" when you create the invite, and everyone who joins
+through that link lands in the level automatically — their key follows from
+a member the moment they arrive. The invitation itself says which group
+they're joining. Pair it with a level-limited group and each intake gets
+its own room from day one.
diff --git a/db.go b/db.go
index 45f8435..004225f 100644
--- a/db.go
+++ b/db.go
@@ -582,6 +582,20 @@ CREATE TABLE IF NOT EXISTS member_bots (
// never force-joined). Admin-set, meaningful only while visibility is
// 'open' — joining still needs no key material beyond open_key.
"ALTER TABLE threads ADD COLUMN default_join INTEGER NOT NULL DEFAULT 0",
+ // Cohort invites (docs/community-and-tiers.md): an invite may carry an
+ // access level — its claimer is assigned the level on first-device
+ // claim ('invite' source). Admin-set only.
+ "ALTER TABLE key_invites ADD COLUMN tier_id TEXT",
+ // Level visibility policy (admin-set, per level): 'shown' (on everyone's
+ // profile — the default, = today's behaviour), 'hidden' (only the
+ // level's own members see who's in it — non-members never do), or
+ // 'user' (each member chooses via user_tiers.visible). NB: "hidden"
+ // can only mean hidden-from-NON-members — a level's members share its
+ // key, so they inherently know each other (E2E, not a choice).
+ "ALTER TABLE tiers ADD COLUMN visibility TEXT NOT NULL DEFAULT 'shown'",
+ // Per-member show/hide, consulted only when the level's policy is
+ // 'user'. Default 1 = shown.
+ "ALTER TABLE user_tiers ADD COLUMN visible INTEGER NOT NULL DEFAULT 1",
// One-way mint marker: set (transactionally, with the first grants)
// when a level's key is first created. "Zero key rows" must NOT mean
// "never minted" — holders can all be removed later, and silently
@@ -1014,6 +1028,7 @@ type KeyInvite struct {
ThreadID string // the pending DM this invite drops the joiner into ("" if none)
ClaimHash string // hashToken of the claim secret; "" for pre-migration invites
GrantsAdmin bool // a setup invite: claiming makes the user the (first) admin
+ TierID string // cohort invite: the access level its claimer joins ("" = none)
Used bool
Expired bool
}
@@ -1024,8 +1039,8 @@ func (db *DB) getKeyInvite(id string) (KeyInvite, bool, error) {
var threadID, claimHash sql.NullString
var grantsAdmin int
err := db.sql.QueryRow(
- "SELECT id, user_id, wrapped, thread_id, claim_hash, COALESCE(grants_admin, 0), used_at, expires_at FROM key_invites WHERE id = ?", id,
- ).Scan(&ki.ID, &ki.UserID, &ki.Wrapped, &threadID, &claimHash, &grantsAdmin, &used, &exp)
+ "SELECT id, user_id, wrapped, thread_id, claim_hash, COALESCE(grants_admin, 0), COALESCE(tier_id, ''), used_at, expires_at FROM key_invites WHERE id = ?", id,
+ ).Scan(&ki.ID, &ki.UserID, &ki.Wrapped, &threadID, &claimHash, &grantsAdmin, &ki.TierID, &used, &exp)
if err == sql.ErrNoRows {
return KeyInvite{}, false, nil
}
@@ -1136,6 +1151,12 @@ func (db *DB) claimKeyInvite(id, deviceLabel, wrappedSelf, claimSecret string) (
// would brick the invite — and a missed default join is recoverable
// (the group is one Discover tap away).
_ = db.joinDefaultThreads(ki.UserID)
+ // A cohort invite also carries an access level — assign it now
+ // ('invite' source), same best-effort rule. Their level key follows
+ // once they publish a pubkey (setPubkey prods the holders).
+ if ki.TierID != "" && db.tierExists(ki.TierID) {
+ _ = db.assignTier(ki.TierID, ki.UserID, "invite")
+ }
}
if ki.ThreadID != "" && wrappedSelf != "" {
// They're already a member of the pending DM (added at invite time); this
@@ -1689,6 +1710,11 @@ type Tier struct {
// KeyLost: the key was minted but every holder is gone — unrecoverable
// (grants are refused with 410); the level should be deleted and remade.
KeyLost bool `json:"key_lost,omitempty"`
+ // Visibility policy: 'shown' | 'hidden' | 'user' (see the schema comment).
+ Visibility string `json:"visibility,omitempty"`
+ // Hidden: MY membership of this level is currently hidden from non-members
+ // — meaningful only when I'm a member and Visibility=='user'.
+ Hidden bool `json:"hidden,omitempty"`
}
func (db *DB) createTier(name string) (string, error) {
@@ -1820,6 +1846,45 @@ func (db *DB) tierKeyMinted(tierID string) bool {
return x == 1
}
+// setInviteTier attaches an access level to an invite (a cohort invite): the
+// claimer is assigned the level on first-device claim (claimKeyInvite).
+func (db *DB) setInviteTier(inviteID, tierID string) error {
+ _, err := db.sql.Exec("UPDATE key_invites SET tier_id = ? WHERE id = ?", tierID, inviteID)
+ return err
+}
+
+// tierName returns a level's plaintext name ("" if unknown). Names are
+// branding-class by design — they render on claim screens where no key exists.
+func (db *DB) tierName(id string) string {
+ var n string
+ if db.sql.QueryRow("SELECT name FROM tiers WHERE id = ?", id).Scan(&n) != nil {
+ return ""
+ }
+ return n
+}
+
+// userTiersNeedingKeys lists the levels a member belongs to but holds no key
+// copy for — the setPubkey prod targets (a cohort joiner becomes wrappable-to
+// the moment their pubkey lands).
+func (db *DB) userTiersNeedingKeys(userID string) []string {
+ rows, err := db.sql.Query(`
+ SELECT ut.tier_id FROM user_tiers ut
+ LEFT JOIN tier_keys k ON k.tier_id = ut.tier_id AND k.user_id = ut.user_id
+ WHERE ut.user_id = ? AND k.user_id IS NULL`, userID)
+ if err != nil {
+ return nil
+ }
+ defer rows.Close()
+ out := []string{}
+ for rows.Next() {
+ var id string
+ if rows.Scan(&id) == nil {
+ out = append(out, id)
+ }
+ }
+ return out
+}
+
// tierGatesThreads counts threads (any visibility) whose gate is this level —
// the deletion guard: those groups' open_keys are wrapped under this level's
// key forever, so deleting the level would seal them for self-serve joiners.
@@ -1860,12 +1925,22 @@ func (db *DB) idColumn(query, arg string) ([]string, error) {
// listTiers returns every tier (names are plaintext space policy, visible to
// all members like the roster), personalised with the caller's membership,
// their wrapped key copy, and who already holds one.
-func (db *DB) listTiers(userID string) ([]Tier, error) {
+// listTiers returns every level, personalised to userID. isAdmin callers (and
+// the level's own members) get the FULL member/holder lists — an admin manages
+// them, and a member must be able to wrap the key to every co-member. To an
+// OUTSIDER (not a member, not admin) each level's members are filtered by its
+// visibility policy: 'shown' → all, 'hidden' → none, 'user' → only members who
+// opted visible. So a hidden level never leaks its roster to non-members, while
+// the crypto's own truth — members know each other — is preserved.
+func (db *DB) listTiers(userID string, isAdmin bool) ([]Tier, error) {
rows, err := db.sql.Query(`
SELECT t.id, t.name, t.created_at, COALESCE(k.wrapped, ''), COALESCE(t.key_minted, 0),
- EXISTS(SELECT 1 FROM user_tiers m WHERE m.tier_id = t.id AND m.user_id = ?)
+ COALESCE(t.visibility, 'shown'),
+ CASE WHEN m.user_id IS NULL THEN 0 ELSE 1 END,
+ COALESCE(m.visible, 1)
FROM tiers t
LEFT JOIN tier_keys k ON k.tier_id = t.id AND k.user_id = ?
+ LEFT JOIN user_tiers m ON m.tier_id = t.id AND m.user_id = ?
ORDER BY t.created_at, t.id`, userID, userID)
if err != nil {
return nil, err
@@ -1873,30 +1948,71 @@ func (db *DB) listTiers(userID string) ([]Tier, error) {
defer rows.Close()
out := []Tier{}
minted := []bool{}
+ myVisible := []bool{}
for rows.Next() {
var t Tier
- var m int
- if err := rows.Scan(&t.ID, &t.Name, &t.CreatedAt, &t.Wrapped, &m, &t.Mine); err != nil {
+ var m, mine, vis int
+ if err := rows.Scan(&t.ID, &t.Name, &t.CreatedAt, &t.Wrapped, &m, &t.Visibility, &mine, &vis); err != nil {
return nil, err
}
+ t.Mine = mine == 1
minted = append(minted, m == 1)
+ myVisible = append(myVisible, vis == 1)
out = append(out, t)
}
if err := rows.Err(); err != nil {
return nil, err
}
for i := range out {
- if out[i].Members, err = db.tierMemberIDs(out[i].ID); err != nil {
- return nil, err
- }
- if out[i].Have, err = db.tierHolderIDs(out[i].ID); err != nil {
+ insider := isAdmin || out[i].Mine
+ if insider {
+ if out[i].Members, err = db.tierMemberIDs(out[i].ID); err != nil {
+ return nil, err
+ }
+ if out[i].Have, err = db.tierHolderIDs(out[i].ID); err != nil {
+ return nil, err
+ }
+ out[i].KeyLost = minted[i] && len(out[i].Have) == 0
+ } else if out[i].Members, err = db.publicTierMemberIDs(out[i].ID, out[i].Visibility); err != nil {
return nil, err
}
- out[i].KeyLost = minted[i] && len(out[i].Have) == 0
+ // My own show/hide state — only a control when I'm a member of a
+ // 'user'-policy level; otherwise the policy decides, no personal say.
+ out[i].Hidden = out[i].Mine && out[i].Visibility == "user" && !myVisible[i]
}
return out, nil
}
+// publicTierMemberIDs is the member list an OUTSIDER may see: all for 'shown',
+// none for 'hidden', the opted-in subset for 'user'.
+func (db *DB) publicTierMemberIDs(tierID, visibility string) ([]string, error) {
+ switch visibility {
+ case "hidden":
+ return []string{}, nil
+ case "user":
+ return db.idColumn("SELECT user_id FROM user_tiers WHERE tier_id = ? AND COALESCE(visible, 1) = 1", tierID)
+ default: // shown
+ return db.tierMemberIDs(tierID)
+ }
+}
+
+// setTierVisibility sets a level's admin policy ('shown'|'hidden'|'user').
+func (db *DB) setTierVisibility(tierID, visibility string) error {
+ _, err := db.sql.Exec("UPDATE tiers SET visibility = ? WHERE id = ?", visibility, tierID)
+ return err
+}
+
+// setUserTierVisible sets one member's show/hide for a level (their own choice,
+// honoured only while the level's policy is 'user').
+func (db *DB) setUserTierVisible(tierID, userID string, visible bool) error {
+ v := 0
+ if visible {
+ v = 1
+ }
+ _, err := db.sql.Exec("UPDATE user_tiers SET visible = ? WHERE tier_id = ? AND user_id = ?", v, tierID, userID)
+ return err
+}
+
type Thread struct {
ID string `json:"id"`
Kind string `json:"kind"` // self | dm | group
diff --git a/handlers_test.go b/handlers_test.go
index ba184a2..e156cfb 100644
--- a/handlers_test.go
+++ b/handlers_test.go
@@ -2291,7 +2291,9 @@ func TestTiers(t *testing.T) {
t.Fatalf("remove: code=%d", code)
}
got = tiersFor(tokC)
- if got[0].Mine || got[0].Wrapped != "" || len(got[0].Members) != 1 || len(got[0].Have) != 1 {
+ // Cal is now an outsider: on this default 'shown' level they still see the
+ // member list (Bea), but the holder list is insiders/admins only.
+ if got[0].Mine || got[0].Wrapped != "" || len(got[0].Members) != 1 || len(got[0].Have) != 0 {
t.Fatalf("after remove: %+v", got[0])
}
@@ -2534,7 +2536,7 @@ func TestTierKeyLifecycle(t *testing.T) {
if code := do("POST", "/api/tiers/"+tid+"/keys", `{"token":"`+tokAdmin+`","grants":[{"user_id":"`+uidB+`","wrapped":"w2"}]}`); code != 410 {
t.Fatalf("grant on lost key: code=%d, want 410 (never a silent re-mint)", code)
}
- tiers, err := srv.db.listTiers(uidAdmin)
+ tiers, err := srv.db.listTiers(uidAdmin, true)
if err != nil || len(tiers) != 1 || !tiers[0].KeyLost {
t.Fatalf("key_lost not reported: %+v err=%v", tiers, err)
}
@@ -2566,3 +2568,287 @@ func TestTierKeyLifecycle(t *testing.T) {
t.Fatalf("ghost key holder after removeUser: %v", hs)
}
}
+
+// TestCohortInvites: an admin-minted invite carries an access level; its
+// claimer lands in the level on FIRST-device claim (source 'invite'), the
+// welcome card learns the level's name, and stale/invalid gates fail safely.
+func TestCohortInvites(t *testing.T) {
+ ts, srv := newTestServer(t)
+
+ tid, err := srv.db.createTier("January 2027")
+ if err != nil {
+ t.Fatalf("createTier: %v", err)
+ }
+
+ mint := func(inviteID, tierID string) int {
+ body := `{"id":"` + inviteID + `","wrapped":"","claim_hash":"` + testClaimHash + `","tier_id":"` + tierID + `"}`
+ req, _ := http.NewRequest("POST", ts.URL+"/api/admin/key-invites", strings.NewReader(body))
+ req.Header.Set("Authorization", "Bearer admintok")
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("mint: %v", err)
+ }
+ resp.Body.Close()
+ return resp.StatusCode
+ }
+
+ if code := mint("coh-bad", "nope"); code != 400 {
+ t.Fatalf("mint with unknown level: code=%d, want 400", code)
+ }
+ if code := mint("coh-1", tid); code != 200 {
+ t.Fatalf("mint cohort invite: code=%d", code)
+ }
+
+ // The invite lookup names the level (link-holders only, like description).
+ resp, err := http.Get(ts.URL + "/api/key-invite/coh-1")
+ if err != nil {
+ t.Fatalf("lookup: %v", err)
+ }
+ var info struct {
+ OK bool `json:"ok"`
+ TierName string `json:"tier_name"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ resp.Body.Close()
+ if !info.OK || info.TierName != "January 2027" {
+ t.Fatalf("lookup: %+v", info)
+ }
+
+ // Claim → the joiner is in the level, attributed to the invite.
+ ki, _, _ := srv.db.getKeyInvite("coh-1")
+ if _, ok, err := srv.db.claimKeyInvite("coh-1", "phone", "", testClaimSecret); !ok || err != nil {
+ t.Fatalf("claim: ok=%v err=%v", ok, err)
+ }
+ if !srv.db.tierMember(tid, ki.UserID) {
+ t.Fatalf("claimer not in the cohort level")
+ }
+ var src string
+ srv.db.sql.QueryRow("SELECT source FROM user_tiers WHERE tier_id = ? AND user_id = ?", tid, ki.UserID).Scan(&src)
+ if src != "invite" {
+ t.Fatalf("assignment source = %q, want invite", src)
+ }
+
+ // An add-device claim later never re-assigns: leave the level, add a
+ // device, still out.
+ if err := srv.db.unassignTier(tid, ki.UserID); err != nil {
+ t.Fatalf("unassign: %v", err)
+ }
+ if err := srv.db.createKeyInvite("coh-dev", ki.UserID, "wrap", testClaimHash, 0); err != nil {
+ t.Fatalf("device invite: %v", err)
+ }
+ if err := srv.db.setInviteTier("coh-dev", tid); err != nil {
+ t.Fatalf("setInviteTier: %v", err)
+ }
+ if _, ok, err := srv.db.claimKeyInvite("coh-dev", "laptop", "", testClaimSecret); !ok || err != nil {
+ t.Fatalf("device claim: ok=%v err=%v", ok, err)
+ }
+ if srv.db.tierMember(tid, ki.UserID) {
+ t.Fatalf("add-device claim re-assigned a left level")
+ }
+
+ // A level deleted between mint and claim fails safe: claim succeeds, no
+ // assignment (tierExists guard in claimKeyInvite).
+ if code := mint("coh-2", tid); code != 200 {
+ t.Fatalf("mint 2: code=%d", code)
+ }
+ if err := srv.db.deleteTier(tid); err != nil {
+ t.Fatalf("deleteTier: %v", err)
+ }
+ ki2, _, _ := srv.db.getKeyInvite("coh-2")
+ if _, ok, err := srv.db.claimKeyInvite("coh-2", "", "", testClaimSecret); !ok || err != nil {
+ t.Fatalf("claim after delete: ok=%v err=%v", ok, err)
+ }
+ if srv.db.tierMember(tid, ki2.UserID) {
+ t.Fatalf("assignment to a deleted level")
+ }
+}
+
+// TestCohortInviteFromChat: the in-chat invite path accepts a level from
+// role-admins only — a member invite can't smuggle anyone into a level.
+func TestCohortInviteFromChat(t *testing.T) {
+ ts, srv := newTestServer(t)
+
+ mkUser := func(label string) (tok, uid string) {
+ u, err := srv.db.createUser(label)
+ if err != nil {
+ t.Fatalf("createUser: %v", err)
+ }
+ if err := srv.db.createKeyInvite("inv-"+label, u, "wrap", testClaimHash, 0); err != nil {
+ t.Fatalf("createKeyInvite: %v", err)
+ }
+ tk, ok, err := srv.db.claimKeyInvite("inv-"+label, label, "", testClaimSecret)
+ if err != nil || !ok {
+ t.Fatalf("claim: ok=%v err=%v", ok, err)
+ }
+ if err := srv.db.setUserPublicKey(u, "pub-"+label); err != nil {
+ t.Fatalf("setUserPublicKey: %v", err)
+ }
+ return tk, u
+ }
+ tokAdmin, uidAdmin := mkUser("Root")
+ if err := srv.db.setUserRole(uidAdmin, "admin"); err != nil {
+ t.Fatalf("setUserRole: %v", err)
+ }
+ tokB, _ := mkUser("Bea")
+ if err := srv.db.setUsersCanInvite(true); err != nil {
+ t.Fatalf("setUsersCanInvite: %v", err)
+ }
+ tid, err := srv.db.createTier("VIP")
+ if err != nil {
+ t.Fatalf("createTier: %v", err)
+ }
+
+ mint := func(tok, inviteID, tierID string) int {
+ body := `{"token":"` + tok + `","label_blob":"lb","id":"` + inviteID + `","wrapped":"w","self_wrapped":"sw","claim_hash":"` + testClaimHash + `","tier_id":"` + tierID + `"}`
+ resp, err := http.Post(ts.URL+"/api/invites", "application/json", strings.NewReader(body))
+ if err != nil {
+ t.Fatalf("post: %v", err)
+ }
+ resp.Body.Close()
+ return resp.StatusCode
+ }
+
+ // A member NOT in the level can't mint access to it (they don't hold the
+ // key — they couldn't wrap it anyway).
+ if code := mint(tokB, "chat-coh-1", tid); code != 403 {
+ t.Fatalf("outsider cohort invite: code=%d, want 403", code)
+ }
+ if code := mint(tokAdmin, "chat-coh-2", "nope"); code != 400 {
+ t.Fatalf("unknown level: code=%d, want 400", code)
+ }
+ // Admins mint for any level; a member IN the level can too (the flat-group
+ // rule — membership is the power to extend membership).
+ if code := mint(tokAdmin, "chat-coh-3", tid); code != 200 {
+ t.Fatalf("admin cohort invite: code=%d", code)
+ }
+ if err := srv.db.assignTier(tid, mustUID(t, srv, tokB), "admin"); err != nil {
+ t.Fatalf("assignTier: %v", err)
+ }
+ if code := mint(tokB, "chat-coh-4", tid); code != 200 {
+ t.Fatalf("in-level member cohort invite: code=%d, want 200", code)
+ }
+ ki, found, _ := srv.db.getKeyInvite("chat-coh-3")
+ if !found || ki.TierID != tid {
+ t.Fatalf("tier not stored on chat invite: %+v", ki)
+ }
+ if _, ok, err := srv.db.claimKeyInvite("chat-coh-3", "", "sw2", testClaimSecret); !ok || err != nil {
+ t.Fatalf("claim: ok=%v err=%v", ok, err)
+ }
+ if !srv.db.tierMember(tid, ki.UserID) {
+ t.Fatalf("chat-invite claimer not in the level")
+ }
+}
+
+// TestTierVisibility: admin policy (shown/hidden/user) and the per-member
+// choice govern what an OUTSIDER sees in a level's member list; insiders and
+// admins always see the full roster (they hold the shared key).
+func TestTierVisibility(t *testing.T) {
+ ts, srv := newTestServer(t)
+
+ mkUser := func(label string) (tok, uid string) {
+ u, err := srv.db.createUser(label)
+ if err != nil {
+ t.Fatalf("createUser: %v", err)
+ }
+ if err := srv.db.createKeyInvite("inv-"+label, u, "wrap", testClaimHash, 0); err != nil {
+ t.Fatalf("createKeyInvite: %v", err)
+ }
+ tk, ok, err := srv.db.claimKeyInvite("inv-"+label, label, "", testClaimSecret)
+ if err != nil || !ok {
+ t.Fatalf("claim: ok=%v err=%v", ok, err)
+ }
+ if err := srv.db.setUserPublicKey(u, "pub-"+label); err != nil {
+ t.Fatalf("setUserPublicKey: %v", err)
+ }
+ return tk, u
+ }
+ do := func(method, path, body string) int {
+ req, _ := http.NewRequest(method, ts.URL+path, strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("%s %s: %v", method, path, err)
+ }
+ resp.Body.Close()
+ return resp.StatusCode
+ }
+ tierRow := func(tok, id string) Tier {
+ resp, err := http.Get(ts.URL + "/api/tiers?token=" + tok)
+ if err != nil {
+ t.Fatalf("get tiers: %v", err)
+ }
+ defer resp.Body.Close()
+ var out struct {
+ Tiers []Tier `json:"tiers"`
+ }
+ json.NewDecoder(resp.Body).Decode(&out)
+ for _, tr := range out.Tiers {
+ if tr.ID == id {
+ return tr
+ }
+ }
+ t.Fatalf("tier %s not in %s's view", id, tok)
+ return Tier{}
+ }
+
+ tokAdmin, uidAdmin := mkUser("Root")
+ if err := srv.db.setUserRole(uidAdmin, "admin"); err != nil {
+ t.Fatalf("setUserRole: %v", err)
+ }
+ _, uidB := mkUser("Bea")
+ tokOut, _ := mkUser("Outsider")
+
+ tid, _ := srv.db.createTier("VIP")
+ if err := srv.db.assignTier(tid, uidB, "admin"); err != nil {
+ t.Fatalf("assign: %v", err)
+ }
+
+ // Default 'shown': the outsider sees Bea.
+ if m := tierRow(tokOut, tid).Members; len(m) != 1 || m[0] != uidB {
+ t.Fatalf("shown → outsider sees members: %v", m)
+ }
+
+ // Only admins set policy.
+ if code := do("POST", "/api/tiers/"+tid+"/policy", `{"token":"`+tokOut+`","policy":"hidden"}`); code != 403 {
+ t.Fatalf("non-admin policy: code=%d, want 403", code)
+ }
+ if code := do("POST", "/api/tiers/"+tid+"/policy", `{"token":"`+tokAdmin+`","policy":"nope"}`); code != 400 {
+ t.Fatalf("bad policy value: code=%d, want 400", code)
+ }
+
+ // 'hidden': the outsider sees an empty roster; the admin still sees Bea.
+ if code := do("POST", "/api/tiers/"+tid+"/policy", `{"token":"`+tokAdmin+`","policy":"hidden"}`); code != 200 {
+ t.Fatalf("set hidden: code=%d", code)
+ }
+ if m := tierRow(tokOut, tid).Members; len(m) != 0 {
+ t.Fatalf("hidden → outsider sees nobody: %v", m)
+ }
+ if m := tierRow(tokAdmin, tid).Members; len(m) != 1 {
+ t.Fatalf("hidden → admin still sees members: %v", m)
+ }
+
+ // 'user': Bea shows by default; when she hides, the outsider loses her.
+ if code := do("POST", "/api/tiers/"+tid+"/policy", `{"token":"`+tokAdmin+`","policy":"user"}`); code != 200 {
+ t.Fatalf("set user: code=%d", code)
+ }
+ if m := tierRow(tokOut, tid).Members; len(m) != 1 {
+ t.Fatalf("user default → outsider sees Bea: %v", m)
+ }
+ // Only a member sets their own show/hide — an outsider can't touch it.
+ if code := do("POST", "/api/tiers/"+tid+"/show", `{"token":"`+tokOut+`","show":false}`); code != 403 {
+ t.Fatalf("outsider sets show: code=%d, want 403", code)
+ }
+ // Bea hides herself → the outsider loses her, the admin still sees her.
+ if err := srv.db.setUserTierVisible(tid, uidB, false); err != nil {
+ t.Fatalf("hide: %v", err)
+ }
+ if m := tierRow(tokOut, tid).Members; len(m) != 0 {
+ t.Fatalf("user + Bea hidden → outsider sees nobody: %v", m)
+ }
+ if m := tierRow(tokAdmin, tid).Members; len(m) != 1 {
+ t.Fatalf("user + Bea hidden → admin still sees Bea: %v", m)
+ }
+}
diff --git a/main.go b/main.go
index 98b66ab..d95fc1f 100644
--- a/main.go
+++ b/main.go
@@ -378,6 +378,8 @@ func (s *Server) handler() http.Handler {
mux.HandleFunc("POST /api/tiers/{id}/remove", s.postTierRemove)
mux.HandleFunc("POST /api/tiers/{id}/keys", s.postTierKeys)
mux.HandleFunc("POST /api/tiers/{id}/nudge", s.nudgeTierKey)
+ mux.HandleFunc("POST /api/tiers/{id}/policy", s.postTierPolicy)
+ mux.HandleFunc("POST /api/tiers/{id}/show", s.postTierShow)
mux.HandleFunc("POST /api/label", s.postLabel)
mux.HandleFunc("POST /api/presence", s.postPresence)
mux.HandleFunc("POST /api/avatar", s.postAvatar)
@@ -439,6 +441,7 @@ func (s *Server) handler() http.Handler {
mux.HandleFunc("POST /api/key-invite/{id}/claim", s.claimKeyInvite)
mux.HandleFunc("POST /api/key-invite/{id}/adopt", s.adoptKeyInvite)
mux.HandleFunc("POST /api/admin/key-invites", s.createKeyInvite)
+ mux.HandleFunc("GET /api/admin/tiers", s.adminTiers)
mux.HandleFunc("GET /api/admin/users", s.listUsers)
mux.HandleFunc("POST /api/admin/users/{id}/remove", s.removeUser)
mux.HandleFunc("POST /api/admin/users/{id}/label", s.renameUser)
@@ -878,6 +881,12 @@ func (s *Server) setPubkey(w http.ResponseWriter, r *http.Request) {
if firstKey {
out, _ := json.Marshal(map[string]any{"type": "joined", "user_id": uid})
s.hub.broadcast <- outbound{data: out}
+ // A cohort joiner is only NOW wrappable-to: prod each of their levels'
+ // key holders (targeted, keyless). The holder backfill in loadTiers
+ // self-heals later regardless; this makes the common case immediate.
+ for _, tid := range s.db.userTiersNeedingKeys(uid) {
+ s.prodTierHolders(tid, uid)
+ }
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
@@ -1461,6 +1470,21 @@ func (s *Server) nudgeInstanceKey(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
+// adminTiers lists the access levels for the admin console's cohort picker
+// (bearer-token surface — the console holds no member token). Same rows the
+// member endpoint serves, unpersonalised.
+func (s *Server) adminTiers(w http.ResponseWriter, r *http.Request) {
+ if !s.requireAdmin(w, r) {
+ return
+ }
+ ts, err := s.db.listTiers("", true)
+ if err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"tiers": ts})
+}
+
// ---- tiers ----------------------------------------------------------------
// Admin-curated member sets that carry key material (docs/community-and-tiers.md).
// The key machinery is the instance key's, per tier: clients mint and wrap the
@@ -1496,7 +1520,7 @@ func (s *Server) getTiers(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"tiers": []Tier{}})
return
}
- ts, err := s.db.listTiers(uid)
+ ts, err := s.db.listTiers(uid, s.db.userRole(uid) == "admin")
if err != nil {
httpError(w, http.StatusInternalServerError, "db error")
return
@@ -1713,6 +1737,67 @@ func (s *Server) nudgeTierKey(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
+// postTierPolicy sets a level's visibility policy — admin-gated (it's space
+// policy, like management). 'shown' | 'hidden' | 'user'.
+func (s *Server) postTierPolicy(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Token string `json:"token"`
+ Policy string `json:"policy"`
+ }
+ if json.NewDecoder(r.Body).Decode(&body) != nil {
+ httpError(w, http.StatusBadRequest, "bad request")
+ return
+ }
+ if _, ok := s.tierAdmin(w, body.Token); !ok {
+ return
+ }
+ if body.Policy != "shown" && body.Policy != "hidden" && body.Policy != "user" {
+ httpError(w, http.StatusBadRequest, "bad policy")
+ return
+ }
+ if !s.db.tierExists(r.PathValue("id")) {
+ httpError(w, http.StatusBadRequest, "no such level")
+ return
+ }
+ if err := s.db.setTierVisibility(r.PathValue("id"), body.Policy); err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
+// postTierShow is a member's own show/hide choice for a level they're in —
+// honoured only while the level's policy is 'user' (else the admin policy
+// decides). Members choose whether THEIR membership shows to non-members.
+func (s *Server) postTierShow(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Token string `json:"token"`
+ Show bool `json:"show"`
+ }
+ if json.NewDecoder(r.Body).Decode(&body) != nil {
+ httpError(w, http.StatusBadRequest, "bad request")
+ return
+ }
+ if !s.tokenOK(body.Token) {
+ httpError(w, http.StatusForbidden, "not a member")
+ return
+ }
+ uid, ok := s.db.deviceTokenUser(body.Token)
+ if !ok {
+ httpError(w, http.StatusForbidden, "no user for token")
+ return
+ }
+ if !s.db.tierMember(r.PathValue("id"), uid) {
+ httpError(w, http.StatusForbidden, "not in this level")
+ return
+ }
+ if err := s.db.setUserTierVisible(r.PathValue("id"), uid, body.Show); err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
// prodTierHolders tells current key holders that userID needs the tier key
// wrapped to them — carrying the target's pubkey, since a holder's roster
// view may not include a fresh joiner (the instance-key nudge's reasoning).
@@ -2392,6 +2477,10 @@ func (s *Server) createKeyInvite(w http.ResponseWriter, r *http.Request) {
// add-instance for a fresh instance's first user (and available to admins
// for promoting someone at invite time). See docs/conversational-onboarding.md.
GrantsAdmin bool `json:"grants_admin"`
+ // TierID makes this a COHORT invite: the claimer is assigned that
+ // access level on first-device claim ('invite' source). Admin-minted
+ // only, which is what keeps level assignment an admin power.
+ TierID string `json:"tier_id"`
}
if json.NewDecoder(r.Body).Decode(&body) != nil {
httpError(w, http.StatusBadRequest, "bad request")
@@ -2438,6 +2527,10 @@ func (s *Server) createKeyInvite(w http.ResponseWriter, r *http.Request) {
return
}
}
+ if body.TierID != "" && !s.db.tierExists(body.TierID) {
+ httpError(w, http.StatusBadRequest, "bad access level")
+ return
+ }
if err := s.db.createKeyInvite(body.ID, userID, body.Wrapped, body.ClaimHash, body.Expires); err != nil {
httpError(w, http.StatusInternalServerError, "db error")
return
@@ -2448,6 +2541,12 @@ func (s *Server) createKeyInvite(w http.ResponseWriter, r *http.Request) {
return
}
}
+ if body.TierID != "" {
+ if err := s.db.setInviteTier(body.ID, body.TierID); err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ }
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "user_id": userID})
}
@@ -2476,6 +2575,14 @@ func (s *Server) getKeyInvite(w http.ResponseWriter, r *http.Request) {
if d := s.db.getGroupDescription(); d != "" {
resp["description"] = d
}
+ // A cohort invite says which access level the joiner lands in — the
+ // level NAME is branding-class by design (plaintext space policy), and
+ // like the description it's served to link-holders only.
+ if ki.TierID != "" {
+ if n := s.db.tierName(ki.TierID); n != "" {
+ resp["tier_name"] = n
+ }
+ }
// The suggested pre-fill name is NOT returned here — it rides in the invite
// link's #fragment (never sent to the server), so the server never sees it.
}
@@ -2701,6 +2808,10 @@ func (s *Server) postUserInvite(w http.ResponseWriter, r *http.Request) {
SelfWrapped string `json:"self_wrapped"`
ClaimHash string `json:"claim_hash"`
ThreadID string `json:"thread_id"`
+ // TierID makes this a cohort invite (claimer lands in the level on
+ // first-device claim). Role-admins only — level assignment is an
+ // admin power, so a member invite can't smuggle anyone in.
+ TierID string `json:"tier_id"`
}
if json.NewDecoder(r.Body).Decode(&body) != nil {
httpError(w, http.StatusBadRequest, "bad request")
@@ -2760,11 +2871,29 @@ func (s *Server) postUserInvite(w http.ResponseWriter, r *http.Request) {
httpError(w, http.StatusBadRequest, "bad request")
return
}
+ // The flat-group rule applied to levels: membership is the power to
+ // extend membership. Anyone may cohort-invite into a level THEY belong to
+ // (they hold its key already — the server can't pretend otherwise);
+ // admins may mint for any level. Nobody mints access they don't hold.
+ if body.TierID != "" && !s.db.tierExists(body.TierID) {
+ httpError(w, http.StatusBadRequest, "bad access level")
+ return
+ }
+ if body.TierID != "" && s.db.userRole(uid) != "admin" && !s.db.tierMember(body.TierID, uid) {
+ httpError(w, http.StatusForbidden, "not in that level")
+ return
+ }
threadID, inviteeID, err := s.db.createUserInvite(uid, body.ID, body.LabelBlob, body.Wrapped, body.SelfWrapped, body.ClaimHash, 0)
if err != nil {
httpError(w, http.StatusInternalServerError, "db error")
return
}
+ if body.TierID != "" {
+ if err := s.db.setInviteTier(body.ID, body.TierID); err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ }
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "thread_id": threadID, "user_id": inviteeID})
}
diff --git a/web/pages/admin.html b/web/pages/admin.html
index 3f68fd3..a4821ab 100644
--- a/web/pages/admin.html
+++ b/web/pages/admin.html
@@ -182,6 +182,8 @@
<p class="sub" style="margin:0 0 10px">Invite the first person with a short, single-use link (and QR) — they pick a screen name and their device makes its own keys, so the server never sees any key. After someone joins, people are added from inside the chat.</p>
<form id="createForm" class="inline-form" autocomplete="off">
<input id="label" placeholder="Their name (e.g. Sophie)" maxlength="60" />
+ <!-- Cohort invites: hidden until the space has access levels. -->
+ <select id="inviteTier" class="hidden" aria-label="Access level for this invite"></select>
<button type="submit">Create invite</button>
</form>
</div>
diff --git a/web/static/admin.js b/web/static/admin.js
index fb8fef2..2004121 100644
--- a/web/static/admin.js
+++ b/web/static/admin.js
@@ -472,12 +472,42 @@ function wireCreate() {
document.getElementById("createForm").onsubmit = async (e) => {
e.preventDefault();
const label = document.getElementById("label").value.trim();
- const minted = await mintInvite(label, null);
+ const tierSel = document.getElementById("inviteTier");
+ const minted = await mintInvite(label, null, false, null, false, (tierSel && tierSel.value) || "");
if (!minted) return;
document.getElementById("label").value = "";
showInviteModal(minted.link, label || "New invite");
loadUsers();
};
+ loadInviteTiers();
+}
+
+// Cohort invites: when the space has access levels, the invite form offers
+// "add them to <level>" — the claimer lands in that level on join (their
+// level key follows from a holder once they publish a pubkey).
+async function loadInviteTiers() {
+ const sel = document.getElementById("inviteTier");
+ if (!sel) return;
+ let tiers = [];
+ try {
+ tiers = (await (await api("GET", "/api/admin/tiers")).json()).tiers || [];
+ } catch {
+ return; // older server or offline — the picker just stays hidden
+ }
+ if (!tiers.length) return;
+ sel.textContent = "";
+ const none = document.createElement("option");
+ none.value = "";
+ none.textContent = "No access level";
+ sel.appendChild(none);
+ for (const t of tiers) {
+ if (t.key_lost) continue; // a dead level can't receive anyone
+ const o = document.createElement("option");
+ o.value = t.id;
+ o.textContent = `Add them to ${t.name}`;
+ sel.appendChild(o);
+ }
+ sel.classList.remove("hidden");
}
// Adding an agent is a deliberate, separate action from inviting a person: it
@@ -512,7 +542,7 @@ function wireCreateAgent() {
// Mint a single-use invite: generate the secret T client-side, wrap the group
// key under it, and store only {id, wrapped} server-side. Pass a userId to
// re-issue for an existing person (add/recover a device). Returns the link.
-async function mintInvite(label, userId, agent, config, grantsAdmin) {
+async function mintInvite(label, userId, agent, config, grantsAdmin, tierID) {
// Auth-only invite (threads model): no instance-wide key to wrap — the joiner
// generates their own user keypair on join. See docs/threads-and-dms.md.
const T = randomSecret();
@@ -528,6 +558,7 @@ async function mintInvite(label, userId, agent, config, grantsAdmin) {
claim_hash: claimHash,
agent: !!agent,
grants_admin: !!grantsAdmin,
+ tier_id: tierID || "",
...(config || {}), // model, system_prompt, docs — agent only
});
if (!res.ok) {
diff --git a/web/static/apps/admin.js b/web/static/apps/admin.js
index 510ec0a..acf197b 100644
--- a/web/static/apps/admin.js
+++ b/web/static/apps/admin.js
@@ -374,10 +374,19 @@ export const app = defineApp({
// its members (the shell does all crypto — this section is pure UI).
const levelsSec = section("Access levels");
const levelsDesc = document.createElement("p");
- levelsDesc.style.cssText = "font-size:13px;color:var(--muted);margin:0 0 12px";
+ levelsDesc.style.cssText = "font-size:13px;color:var(--muted);margin:0 0 8px";
levelsDesc.textContent =
- "Named sets of members — Coaches, Family, Founders. Soon a group can be limited to an access level: only its members can find and join it.";
+ "Named sets of members — Coaches, Family, Founders. A group can be limited to an access level: only its members can find and join it.";
levelsSec.appendChild(levelsDesc);
+ // The one constraint worth stating: gating a group means wrapping its key
+ // under the level's key, which only that level's members hold — so you
+ // must be in a level to make a group limited to it (an admin who curates
+ // a level without joining it can't read what it gates, by design).
+ const levelsNote = document.createElement("p");
+ levelsNote.style.cssText = "font-size:13px;color:var(--muted);margin:0 0 12px";
+ levelsNote.textContent =
+ "To make a group limited to a level, you need to be a member of that level yourself.";
+ levelsSec.appendChild(levelsNote);
const levelsList = document.createElement("div");
levelsSec.appendChild(levelsList);
const levelAddRow = document.createElement("div");
@@ -385,143 +394,205 @@ export const app = defineApp({
const levelName = document.createElement("input");
levelName.type = "text";
levelName.maxLength = 60;
- levelName.placeholder = "New level name (e.g. Coaches)";
+ levelName.placeholder = "New level name — e.g. Coach, VIP, MVP";
levelName.setAttribute("aria-label", "New access level name");
const levelAddBtn = document.createElement("button");
levelAddBtn.textContent = "Add level";
+ levelName.addEventListener("keydown", (e) => {
+ // The row isn't a <form> (the app builds plain DOM), so give the input
+ // the form behavior people expect: Return creates the level.
+ if (e.key === "Enter") {
+ e.preventDefault();
+ levelAddBtn.click();
+ }
+ });
levelAddRow.append(levelName, levelAddBtn);
levelsSec.appendChild(levelAddRow);
const levelsStatus = statusLine(levelsSec);
- const paintLevels = async () => {
- const [levels, people] = await Promise.all([bridge.tiers(), Promise.resolve(bridge.levelMembers())]);
- levelsList.textContent = "";
- if (!levels.length) {
- const p = document.createElement("p");
- p.style.cssText = "font-size:13px;color:var(--muted);margin:0 0 8px";
- p.textContent = "No access levels yet.";
- levelsList.appendChild(p);
- return;
+ // In-place list management — add appends, delete removes, saving members
+ // swaps one box. A full repaint (initial load only) rebuilds everything;
+ // doing that after every action flashed the whole section blank.
+ let people = bridge.levelMembers();
+ const emptyNote = document.createElement("p");
+ emptyNote.style.cssText = "font-size:13px;color:var(--muted);margin:0 0 8px";
+ emptyNote.textContent = "No access levels yet.";
+ const syncEmptyNote = () => {
+ if (!levelsList.querySelector("[data-level]")) levelsList.appendChild(emptyNote);
+ else emptyNote.remove();
+ };
+
+ const buildLevelBox = (level) => {
+ const box = document.createElement("div");
+ box.className = "admin-row";
+ box.dataset.level = level.id;
+ box.style.cssText = "display:block;padding:10px 0";
+ const head = document.createElement("div");
+ head.style.cssText = "display:flex;align-items:center;gap:8px";
+ const name = document.createElement("strong");
+ name.textContent = level.name;
+ const count = document.createElement("span");
+ count.style.cssText = "font-size:13px;color:var(--muted)";
+ const n = (level.members || []).length;
+ count.textContent = n === 1 ? "1 member" : `${n} members`;
+ const spacer = document.createElement("span");
+ spacer.style.flex = "1";
+ const editBtn = document.createElement("button");
+ editBtn.className = "linkish";
+ editBtn.textContent = "Members";
+ editBtn.setAttribute("aria-expanded", "false");
+ const delBtn = document.createElement("button");
+ delBtn.className = "linkish danger";
+ delBtn.textContent = "Delete";
+ head.append(name, count, spacer, editBtn, delBtn);
+ box.appendChild(head);
+
+ // Who can see who's in this level. "Members choose" hands each member
+ // the toggle in their own profile. All three hide the roster only from
+ // NON-members — a level's members share its key, so they always know
+ // each other.
+ const visRow = document.createElement("div");
+ visRow.style.cssText = "display:flex;align-items:center;gap:8px;margin-top:6px";
+ const visLabel = document.createElement("span");
+ visLabel.style.cssText = "font-size:12px;color:var(--muted)";
+ visLabel.textContent = "Shown on profiles:";
+ const visSel = document.createElement("select");
+ visSel.setAttribute("aria-label", `Who sees who's in ${level.name}`);
+ for (const [val, text] of [
+ ["shown", "Everyone"],
+ ["hidden", "Only its members"],
+ ["user", "Members choose"],
+ ]) {
+ const o = document.createElement("option");
+ o.value = val;
+ o.textContent = text;
+ visSel.appendChild(o);
+ }
+ visSel.value = level.visibility || "shown";
+ visSel.onchange = async () => {
+ const prev = level.visibility || "shown";
+ const want = visSel.value;
+ visSel.disabled = true;
+ const ok = await bridge.setTierPolicy(level.id, want);
+ visSel.disabled = false;
+ if (ok) {
+ level.visibility = want;
+ flash(levelsStatus, "Saved.");
+ } else {
+ visSel.value = prev;
+ flash(levelsStatus, "Couldn't save — try again.");
+ }
+ };
+ visRow.append(visLabel, visSel);
+ box.appendChild(visRow);
+
+ if (level.key_lost) {
+ // Minted once, every holder gone — unrecoverable by design (keys
+ // never re-mint under groups wrapped to the old one). Say so.
+ const lost = document.createElement("div");
+ lost.style.cssText = "font-size:12px;color:var(--danger);margin-top:4px";
+ lost.textContent =
+ "This level's key was lost when its last member left. Delete it and create a new one.";
+ box.appendChild(lost);
+ }
+ // Members whose key copy hasn't landed yet: honest inline status, the
+ // same "on its way" register as photo syncing (never a toast).
+ const missing = (level.members || []).filter((uid) => !(level.have || []).includes(uid));
+ if (!level.key_lost && missing.length) {
+ const wait = document.createElement("div");
+ wait.style.cssText = "font-size:12px;color:var(--muted);margin-top:4px";
+ wait.textContent =
+ missing.length === 1
+ ? "1 member's key is on its way — it arrives when a key holder is online."
+ : `${missing.length} members' keys are on their way — they arrive when a key holder is online.`;
+ box.appendChild(wait);
}
- const labelOf = (uid) => {
- const e = people.find((p) => p.user_id === uid);
- return e ? e.label : "";
+
+ // Refresh just this box from the server's rows (post-save): the box
+ // swaps in place, the rest of the list never repaints.
+ const refreshBox = async () => {
+ const fresh = (await bridge.tiers()).find((t) => t.id === level.id);
+ if (fresh) box.replaceWith(buildLevelBox(fresh));
};
- for (const level of levels) {
- const box = document.createElement("div");
- box.className = "admin-row";
- box.style.cssText = "display:block;padding:10px 0";
- const head = document.createElement("div");
- head.style.cssText = "display:flex;align-items:center;gap:8px";
- const name = document.createElement("strong");
- name.textContent = level.name;
- const count = document.createElement("span");
- count.style.cssText = "font-size:13px;color:var(--muted)";
- const n = (level.members || []).length;
- count.textContent = n === 1 ? "1 member" : `${n} members`;
- const spacer = document.createElement("span");
- spacer.style.flex = "1";
- const editBtn = document.createElement("button");
- editBtn.className = "linkish";
- editBtn.textContent = "Members";
- editBtn.setAttribute("aria-expanded", "false");
- const delBtn = document.createElement("button");
- delBtn.className = "linkish danger";
- delBtn.textContent = "Delete";
- head.append(name, count, spacer, editBtn, delBtn);
- box.appendChild(head);
-
- if (level.key_lost) {
- // Minted once, every holder gone — unrecoverable by design (keys
- // never re-mint under groups wrapped to the old one). Say so.
- const lost = document.createElement("div");
- lost.style.cssText = "font-size:12px;color:var(--danger);margin-top:4px";
- lost.textContent =
- "This level's key was lost when its last member left. Delete it and create a new one.";
- box.appendChild(lost);
+
+ let editor = null;
+ editBtn.onclick = () => {
+ if (editor) {
+ editor.remove();
+ editor = null;
+ editBtn.setAttribute("aria-expanded", "false");
+ return;
}
- // Members whose key copy hasn't landed yet: honest inline status, the
- // same "on its way" register as photo syncing (never a toast).
- const missing = (level.members || []).filter((uid) => !(level.have || []).includes(uid));
- if (!level.key_lost && missing.length) {
- const wait = document.createElement("div");
- wait.style.cssText = "font-size:12px;color:var(--muted);margin-top:4px";
- wait.textContent =
- missing.length === 1
- ? "1 member's key is on its way — it arrives when a key holder is online."
- : `${missing.length} members' keys are on their way — they arrive when a key holder is online.`;
- box.appendChild(wait);
+ editBtn.setAttribute("aria-expanded", "true");
+ people = bridge.levelMembers(); // fresh roster snapshot for the picker
+ editor = document.createElement("div");
+ editor.style.cssText = "margin:8px 0 4px;padding-left:4px";
+ const selected = new Set(level.members || []);
+ for (const p of people) {
+ const row = document.createElement("label");
+ row.style.cssText = "display:flex;align-items:center;gap:8px;padding:4px 0;cursor:pointer";
+ const cb = document.createElement("input");
+ cb.type = "checkbox";
+ cb.checked = selected.has(p.user_id);
+ cb.onchange = () => (cb.checked ? selected.add(p.user_id) : selected.delete(p.user_id));
+ const l = document.createElement("span");
+ l.textContent = p.label;
+ row.append(cb, l);
+ editor.appendChild(row);
}
-
- let editor = null;
- editBtn.onclick = () => {
- if (editor) {
- editor.remove();
- editor = null;
- editBtn.setAttribute("aria-expanded", "false");
- return;
- }
- editBtn.setAttribute("aria-expanded", "true");
- editor = document.createElement("div");
- editor.style.cssText = "margin:8px 0 4px;padding-left:4px";
- const selected = new Set(level.members || []);
- for (const p of people) {
- const row = document.createElement("label");
- row.style.cssText = "display:flex;align-items:center;gap:8px;padding:4px 0;cursor:pointer";
- const cb = document.createElement("input");
- cb.type = "checkbox";
- cb.checked = selected.has(p.user_id);
- cb.onchange = () => (cb.checked ? selected.add(p.user_id) : selected.delete(p.user_id));
- const l = document.createElement("span");
- l.textContent = p.label;
- row.append(cb, l);
- editor.appendChild(row);
- }
- const foot = document.createElement("div");
- foot.style.cssText = "display:flex;gap:8px;margin-top:6px";
- const save = document.createElement("button");
- save.textContent = "Save members";
- save.onclick = async () => {
- save.disabled = true;
- flash(levelsStatus, "Saving…");
- const ok = await bridge.setTierMembers(level.id, [...selected]);
- flash(levelsStatus, ok ? "Saved." : "Couldn't save — try again.");
- await paintLevels();
- };
- const cancel = document.createElement("button");
- cancel.className = "secondary";
- cancel.textContent = "Cancel";
- cancel.onclick = () => {
- editor.remove();
- editor = null;
- editBtn.setAttribute("aria-expanded", "false");
- };
- foot.append(save, cancel);
- editor.appendChild(foot);
- box.appendChild(editor);
+ const foot = document.createElement("div");
+ foot.style.cssText = "display:flex;gap:8px;margin-top:6px";
+ const save = document.createElement("button");
+ save.textContent = "Save members";
+ save.onclick = async () => {
+ save.disabled = true;
+ flash(levelsStatus, "Saving…");
+ const ok = await bridge.setTierMembers(level.id, [...selected]);
+ flash(levelsStatus, ok ? "Saved." : "Couldn't save — try again.");
+ await refreshBox();
};
- delBtn.onclick = async () => {
- if (!confirm(`Delete the "${level.name}" access level?`)) return;
- delBtn.disabled = true;
- const status = await bridge.deleteTier(level.id);
- flash(
- levelsStatus,
- status === 200
- ? "Deleted."
- : status === 409
- ? "Can't delete — groups are still limited to this level."
- : "Couldn't delete — try again.",
- );
- await paintLevels();
+ const cancel = document.createElement("button");
+ cancel.className = "secondary";
+ cancel.textContent = "Cancel";
+ cancel.onclick = () => {
+ editor.remove();
+ editor = null;
+ editBtn.setAttribute("aria-expanded", "false");
};
- levelsList.appendChild(box);
- }
+ foot.append(save, cancel);
+ editor.appendChild(foot);
+ box.appendChild(editor);
+ };
+ delBtn.onclick = async () => {
+ if (!confirm(`Delete the "${level.name}" access level?`)) return;
+ delBtn.disabled = true;
+ const status = await bridge.deleteTier(level.id);
+ if (status === 200) {
+ box.remove();
+ syncEmptyNote();
+ flash(levelsStatus, "Deleted.");
+ return;
+ }
+ delBtn.disabled = false;
+ flash(
+ levelsStatus,
+ status === 409 ? "Can't delete — groups are still limited to this level." : "Couldn't delete — try again.",
+ );
+ };
+ return box;
+ };
+
+ const paintLevels = async () => {
+ const levels = await bridge.tiers();
+ people = bridge.levelMembers();
+ levelsList.textContent = "";
+ for (const level of levels) levelsList.appendChild(buildLevelBox(level));
+ syncEmptyNote();
};
levelAddBtn.onclick = async () => {
const name = levelName.value.trim();
if (!name) return;
levelAddBtn.disabled = true;
- flash(levelsStatus, "Creating…");
const id = await bridge.createTier(name);
levelAddBtn.disabled = false;
if (!id) {
@@ -530,7 +601,9 @@ export const app = defineApp({
}
levelName.value = "";
flash(levelsStatus, `"${name}" created — now add its members.`);
- await paintLevels();
+ // Appears in place — a full repaint here flashed the whole section.
+ levelsList.appendChild(buildLevelBox({ id, name, members: [], have: [] }));
+ syncEmptyNote();
};
paintLevels();
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js
index aa14410..940e029 100644
--- a/web/static/apps/chat.js
+++ b/web/static/apps/chat.js
@@ -694,7 +694,9 @@ export const app = defineApp({
// its action lines grow, marking the beginning, rather than a vanishing
// empty-state. host.chatIntro is null for a normal chat (no intro row).
const introText = host && host.chatIntro && host.chatIntro(ctx.threadId);
- if (introText) items.unshift({ id: "chatapp-intro", intro: true, text: introText, vrev: "intro" });
+ // vrev tracks the text so a dynamic intro (an open group's live "N people
+ // here") re-renders when it changes; static intros keep a stable rev.
+ if (introText) items.unshift({ id: "chatapp-intro", intro: true, text: introText, vrev: "intro|" + introText });
keyedList(feed, items, {
key: (m) => m.id,
rev: (m) => m.vrev,
diff --git a/web/static/join.js b/web/static/join.js
index 21ad2bb..d5aa5f9 100644
--- a/web/static/join.js
+++ b/web/static/join.js
@@ -92,6 +92,9 @@ async function start() {
// only — it's never on the public read), so the welcome card can say what
// this place is before the joiner holds any key.
const spaceDesc = typeof info.description === "string" ? info.description : "";
+ // A cohort invite names the access level the joiner lands in (plaintext by
+ // design, like the description — served to link-holders only).
+ cohortName = typeof info.tier_name === "string" ? info.tier_name : "";
// Note whether this instance has a branding photo (a public flag), so the
// onboarding cards below can show it instead of the placeholder emoji.
@@ -244,6 +247,8 @@ function markWelcomePending(suggested) {
// The welcome card: the invite is inspected (a non-consuming GET) but nothing is
// claimed until a button is pressed. In the native add-chat flow (__lchatEmbed)
// the person already chose explicitly in the app, so continue straight on.
+let cohortName = ""; // cohort invite: the level the joiner will be part of
+
// `spaceDesc` is the space's admin-set description (from the invite lookup).
function showWelcome(kind, onContinue, spaceDesc = "") {
if (window.__lchatEmbed) return onContinue();
@@ -259,6 +264,15 @@ function showWelcome(kind, onContinue, spaceDesc = "") {
d.textContent = spaceDesc.slice(0, 600);
d.classList.remove("hidden");
}
+ if (cohortName && kind !== "device") {
+ // The cohort line sits under the subtitle: arriving somewhere and being
+ // told which circle you're joining is half the welcome.
+ const c = document.createElement("p");
+ c.className = "sub";
+ c.textContent = `You'll be part of ${cohortName}.`.slice(0, 120);
+ const sub = card.querySelector("#welcome-sub");
+ if (sub) sub.after(c);
+ }
if (kind === "device") {
// document.title carries the substituted group name.
card.querySelector("#welcome-title").textContent = `Sign in to ${document.title} on this device`;
diff --git a/web/static/shell.js b/web/static/shell.js
index ef5bee0..a5f27b2 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -1488,6 +1488,21 @@ window.addEventListener("focus", () => {
loadTiers();
});
+// A member's own show/hide for a level (honoured only under the "user"
+// policy). Used by the self-view in Edit profile; returns true on success.
+async function setTierShown(id, show) {
+ try {
+ const r = await fetch(`/api/tiers/${encodeURIComponent(id)}/show`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: session.token, show }),
+ });
+ return r.ok;
+ } catch {
+ return false;
+ }
+}
+
// Make sure every member of a level holds its key, as far as THIS client can:
// wrap with our copy if we hold one, or mint if nobody holds one yet (the mint
// moment — the server allows it for admins only). A minting admin who isn't a
@@ -3612,6 +3627,17 @@ setChatAppHost({
const name = other && rosterLabel(other);
if (name) return `This is the start of your chat with ${name}.`;
}
+ // An open community group opens with a start marker that invites the first
+ // hello — a stranger arriving from Discover lands in a room that greets
+ // them rather than a blank feed. Persists as the history's first row (it
+ // scrolls up as the conversation grows); the count stays current as people
+ // join (the intro re-renders when its text changes — see chat.js).
+ if (t && t.kind === "group" && t.visibility === "open") {
+ const name = threadName[threadId] || "this group";
+ const n = (t.members || []).length;
+ const who = n === 1 ? "There's 1 person here." : `There are ${n} people here.`;
+ return `This is the start of the conversation in ${name}. ${who} Say hi!`;
+ }
return null;
},
icons: { reply: ICON_REPLY, edit: ICON_EDIT, react: ICON_REACT, trash: ICON_TRASH, x: ICON_X, play: ICON_PLAY, copy: ICON_COPY },
@@ -4137,7 +4163,18 @@ setSetupBridge({
// bot to a level would widen what its sidecar can read — bot access stays a
// deliberate per-thread choice, docs/bots.md).
levelMembers: () =>
- roster.filter((e) => e.public_key && !e.is_agent).map((e) => ({ user_id: e.user_id, label: e.label })),
+ roster
+ .filter((e) => e.public_key && !e.is_agent)
+ .map((e) => ({
+ user_id: e.user_id,
+ // Labels decrypt under the instance key and can be momentarily (or
+ // legitimately) empty — a naked checkbox is not a member row. Name
+ // yourself properly; strangers get an honest placeholder.
+ label:
+ e.user_id === myUserId
+ ? `${e.label || session.name || "You"} (you)`
+ : e.label || "Unnamed member",
+ })),
createTier: async (name) => {
try {
const r = await fetch("/api/tiers", {
@@ -4168,6 +4205,32 @@ setSetupBridge({
// Set a level's membership to exactly memberIds: assign the missing, remove
// the dropped, then get keys to whoever this client can reach (minting on
// first use — see ensureTierKeys for the no-copy-for-curators rule).
+ // Admin sets a level's visibility policy: "shown" | "hidden" | "user".
+ setTierPolicy: async (id, policy) => {
+ try {
+ const r = await fetch(`/api/tiers/${encodeURIComponent(id)}/policy`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: session.token, policy }),
+ });
+ return r.ok;
+ } catch {
+ return false;
+ }
+ },
+ // A member's own show/hide for a level (honoured only under the "user" policy).
+ setTierShown: async (id, show) => {
+ try {
+ const r = await fetch(`/api/tiers/${encodeURIComponent(id)}/show`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: session.token, show }),
+ });
+ return r.ok;
+ } catch {
+ return false;
+ }
+ },
setTierMembers: async (id, memberIds) => {
try {
await loadTiers();
@@ -6704,6 +6767,25 @@ function viewProfile(uid) {
card.appendChild(p);
}
+ // The person's access levels, as chips. Level names are plaintext space
+ // policy already listed to every member by /api/tiers, so this surfaces
+ // what the roster view exposes — no new disclosure. Populated from a fresh
+ // fetch (the cache can lag a just-made level); hidden until there's one.
+ if (!e.is_agent) {
+ const chips = document.createElement("div");
+ chips.className = "level-chips";
+ card.appendChild(chips);
+ loadTiers().then(() => {
+ const mine = tiers.filter((tt) => (tt.members || []).includes(uid));
+ for (const tt of mine) {
+ const chip = document.createElement("span");
+ chip.className = "level-chip";
+ chip.textContent = tt.name;
+ chips.appendChild(chip);
+ }
+ });
+ }
+
// Message them — the card's action when it was reached from somewhere other
// than their DM (the member sheet, a mention). Inside their DM it's noise.
const dm = existingDM(uid);
@@ -7295,6 +7377,36 @@ const disc = document.createElement("button");
who.maxLength = 60;
who.className = "sheet-input";
card.appendChild(who);
+ // Cohort invites, right where people actually invite. The select lives
+ // in the DOM from the start (keeping its place between the name field
+ // and the buttons) but populates from a FRESH fetch — the module `tiers`
+ // cache can lag a level just created in the Admin app, or another
+ // member's change this session. Hidden until it has an option.
+ const inviteTierSel = document.createElement("select");
+ inviteTierSel.className = "sheet-input";
+ inviteTierSel.setAttribute("aria-label", "Access level for this invite");
+ inviteTierSel.style.display = "none";
+ card.appendChild(inviteTierSel);
+ loadTiers().then(() => {
+ const meEntry = roster.find((e) => e.user_id === myUserId);
+ const isAdmin = !!(meEntry && meEntry.role === "admin");
+ // The flat-group rule applied to levels: you can bring someone into a
+ // level you're IN (you hold its key already); admins mint for any
+ // level. The server enforces the same line.
+ const usableLevels = tiers.filter((t) => !t.key_lost && (isAdmin || t.mine));
+ if (!usableLevels.length) return;
+ const none = document.createElement("option");
+ none.value = "";
+ none.textContent = "No access level";
+ inviteTierSel.appendChild(none);
+ for (const t of usableLevels) {
+ const o = document.createElement("option");
+ o.value = t.id;
+ o.textContent = `Add them to ${t.name}`;
+ inviteTierSel.appendChild(o);
+ }
+ inviteTierSel.style.display = "";
+ });
const foot = document.createElement("div");
foot.className = "sheet-foot";
const cancel = document.createElement("button");
@@ -7305,7 +7417,8 @@ const disc = document.createElement("button");
create.textContent = "Create invite";
create.disabled = true;
who.addEventListener("input", () => (create.disabled = !who.value.trim()));
- create.onclick = () => go(inviteSomeone(who.value.trim()), create);
+ create.onclick = () =>
+ go(inviteSomeone(who.value.trim(), (inviteTierSel && inviteTierSel.value) || ""), create);
submitOnEnter(who, () => create.click());
foot.appendChild(cancel);
foot.appendChild(create);
@@ -7339,7 +7452,7 @@ const disc = document.createElement("button");
return;
}
- let nameInput, openCb, tierSel;
+ let nameInput, openCb, tierSel, defaultCb;
if (mode === "group") {
nameInput = document.createElement("input");
nameInput.placeholder = "Group name (optional)";
@@ -7388,6 +7501,35 @@ const disc = document.createElement("button");
});
card.appendChild(tierSel);
}
+ // Admins can make an open group one that new members auto-join
+ // (a #general). Admin-only, and only while open + ungated (a gated
+ // group can't be default — the server refuses; a newcomer holds no
+ // level yet). Mirrors the Edit-group toggle.
+ const meEntry = roster.find((e) => e.user_id === myUserId);
+ if (meEntry && meEntry.role === "admin") {
+ const drow = document.createElement("label");
+ drow.className = "opt-row";
+ defaultCb = document.createElement("input");
+ defaultCb.type = "checkbox";
+ const dtxt = document.createElement("span");
+ dtxt.textContent = "New members join automatically";
+ const ddesc = document.createElement("span");
+ ddesc.className = "opt-desc";
+ ddesc.textContent = "Everyone who joins the space starts in this group.";
+ dtxt.appendChild(ddesc);
+ drow.appendChild(defaultCb);
+ drow.appendChild(dtxt);
+ card.appendChild(drow);
+ const syncDefault = () => {
+ const gated = tierSel && tierSel.value;
+ const on = openCb.checked && !gated;
+ drow.style.display = on ? "" : "none";
+ if (!on) defaultCb.checked = false;
+ };
+ syncDefault();
+ openCb.addEventListener("change", syncDefault);
+ if (tierSel) tierSel.addEventListener("change", syncDefault);
+ }
}
}
@@ -7492,6 +7634,7 @@ const disc = document.createElement("button");
name: nameInput.value.trim(),
open: !!(openCb && openCb.checked),
openTier: (openCb && openCb.checked && tierSel && tierSel.value) || "",
+ default: !!(defaultCb && defaultCb.checked),
}),
create,
);
@@ -7764,6 +7907,16 @@ async function createThread(otherIds, opts = {}) {
threadKeys[r.thread_id] = key;
threadKeyB64[r.thread_id] = b64;
}
+ // An open, ungated group can be marked default (new members auto-join) at
+ // creation — a separate admin-gated call after the thread exists. Ungated
+ // only: the server refuses default on a level-gated group.
+ if (opts.default && opts.open && !opts.openTier && !r.existing) {
+ await fetch(`/api/threads/${encodeURIComponent(r.thread_id)}/default`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: session.token, default: true }),
+ }).catch(() => {});
+ }
return r.thread_id;
}
@@ -7773,7 +7926,7 @@ async function createThread(otherIds, opts = {}) {
// to join. The browser generates the DM key, wraps it to us (so we hold it) and
// encrypts it to the invite secret (so the joiner recovers it) — the server only
// stores blobs. Returns the pending thread id; the link lands in inviteLinks.
-async function inviteSomeone(name) {
+async function inviteSomeone(name, tierID = "") {
const T = randomSecret();
const { id, wrapKey, claimHash } = await deriveInvite(T);
const { key, b64 } = await generateKey();
@@ -7789,7 +7942,7 @@ async function inviteSomeone(name) {
await fetch("/api/invites", {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ token: session.token, label_blob: labelBlob, id, wrapped, self_wrapped: selfWrapped, claim_hash: claimHash }),
+ body: JSON.stringify({ token: session.token, label_blob: labelBlob, id, wrapped, self_wrapped: selfWrapped, claim_hash: claimHash, tier_id: tierID }),
})
).json();
if (!r.thread_id) throw new Error("invite failed");
@@ -8179,6 +8332,65 @@ function editProfile() {
card.appendChild(aboutLabel);
card.appendChild(about);
+ // Your access levels — read-only here (an admin, or another member of the
+ // level, grants them; docs/community-and-tiers.md). The self-facing mirror
+ // of the chips on someone else's profile card; fetched fresh, hidden when
+ // you're in none so members of spaces without levels see nothing.
+ const levelLabel = document.createElement("label");
+ levelLabel.className = "field-label";
+ levelLabel.style.marginTop = "14px";
+ levelLabel.textContent = "Your access levels";
+ levelLabel.style.display = "none";
+ const levelChips = document.createElement("div");
+ levelChips.className = "level-chips";
+ levelChips.style.justifyContent = "flex-start"; // left-aligned under the label
+ card.appendChild(levelLabel);
+ card.appendChild(levelChips);
+ const levelNotes = document.createElement("div");
+ card.appendChild(levelNotes);
+ loadTiers().then(() => {
+ const mine = tiers.filter((tt) => (tt.members || []).includes(myUserId));
+ if (!mine.length) return;
+ levelLabel.style.display = "";
+ for (const tt of mine) {
+ const chip = document.createElement("span");
+ chip.className = "level-chip";
+ chip.textContent = tt.name;
+ levelChips.appendChild(chip);
+ }
+ // Per-level visibility in the member's own words:
+ // - "user" → a live toggle they control.
+ // - "hidden" → a muted note (members-only; no control).
+ // - "shown" → nothing (the default; a note per level would be clutter).
+ for (const tt of mine) {
+ if (tt.visibility === "user") {
+ const row = document.createElement("label");
+ row.style.cssText =
+ "display:flex;align-items:center;gap:8px;font-size:13px;color:var(--muted);margin-top:8px;cursor:pointer";
+ const cb = document.createElement("input");
+ cb.type = "checkbox";
+ cb.checked = !tt.hidden; // hidden=true → currently NOT shown to others
+ cb.onchange = async () => {
+ cb.disabled = true;
+ const ok = await setTierShown(tt.id, cb.checked);
+ cb.disabled = false;
+ if (ok) tt.hidden = !cb.checked;
+ else cb.checked = !cb.checked; // revert on failure
+ };
+ const txt = document.createElement("span");
+ txt.textContent = `Show “${tt.name}” on my profile`;
+ row.append(cb, txt);
+ levelNotes.appendChild(row);
+ } else if (tt.visibility === "hidden") {
+ const note = document.createElement("p");
+ note.className = "sub";
+ note.style.margin = "6px 0 0";
+ note.textContent = `“${tt.name}” shows only to its members.`;
+ levelNotes.appendChild(note);
+ }
+ }
+ });
+
// Away: pins your presence dot to away, so activity doesn't flip it back to
// online. Applies immediately (server-stored, follows you across devices) —
// Save is only for the name.
diff --git a/web/static/style.css b/web/static/style.css
index 6dc1918..b903e2b 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -2010,6 +2010,14 @@ input.filter-check:focus-visible { outline: 2px solid var(--accent); outline-off
.p-dot.offline { background: var(--offline); }
/* Member sheet: the muted status note beside a member's name. */
.member-presence { margin-left: 8px; font-size: 12px; color: var(--muted); flex: none; }
+/* Access-level chips on the profile card — the person's levels ("Coach",
+ "VIP"), a quiet accent-tinted badge (names are plaintext space policy). */
+.level-chips { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; margin: 8px 0 4px; }
+.level-chip {
+ font-size: 12px; font-weight: 600; line-height: 1;
+ color: var(--accent-ink); background: var(--accent);
+ border-radius: 999px; padding: 5px 10px;
+}
.thread-row-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
.thread-row-name { font-size: 16px; font-weight: 600; display: flex; align-items: center; min-width: 0; }
.thread-row-name-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }