eleven / messenger Public

Tiers (server core): admin-curated member sets with per-tier keys (#645)

* server: tiers core β€” admin-curated member sets with per-tier keys

tiers/user_tiers/tier_keys tables (names plaintext by design, like
branding); admin-role-gated create/assign/remove/delete; key grants on the
instance-key discipline (client-side wraps, mint-moment admin gate, holder
gate after, never overwrite); targeted WS prods (tier_key_nudge to holders
with the target's pubkey, tier_key_granted + tiers_changed to the member).
Assignment records its source ('admin') so cohorts and billing plug in as
new assigners without touching the machinery below.

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

* Access levels (web): Admin app management + key grant machinery (#647)

* web: access levels β€” Admin app management + tier-key grant machinery

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

* Level-gated open groups: open_key under an access level's key (#651)

* server+web+ios+mac: level-gated open groups β€” open_key under a tier key

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

* review fixes: one-way transactional mint (key_minted), key-lost is loud (410), delete guard for gating levels, removeUser tier cascade, client never installs rejected keys, holder backfill + focus resync, keyless reopen symmetry, gated groups can't be default, joined-first Discover (web+native)

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 2c02680b745026e895f4026ef6388191fe7f81b0 parent 19494e6
11 files changed, +1740 −53
  • changelog/2026-07-11-access-levels.md +5 −0
  • changelog/2026-07-11-level-gated-groups.md +6 −0
  • db.go +343 −16
  • handlers_test.go +414 −0
  • ios/LChatCore/Sources/LChatCore/DiscoverGroupsView.swift +9 −0
  • ios/LChatCore/Sources/LChatCore/LChatAPI.swift +11 −0
  • ios/LChatCore/Sources/LChatCore/Models.swift +26 −1
  • ios/LChatCore/Sources/LChatCore/ThreadStore.swift +76 −9
  • main.go +331 −3
  • web/static/apps/admin.js +165 −0
  • web/static/shell.js +354 −24
diff --git a/changelog/2026-07-11-access-levels.md b/changelog/2026-07-11-access-levels.md
new file mode 100644
index 0000000..5320824
--- /dev/null
+++ b/changelog/2026-07-11-access-levels.md
@@ -0,0 +1,5 @@
+πŸ€– **Access levels.** Admins can now group members into named access levels
+β€” Coaches, Family, Founders β€” from the Admin app. Each level has its own
+key, held only by its members and never by the server, so everything stays
+end-to-end encrypted. Coming next: groups that only an access level can
+find and join.
diff --git a/changelog/2026-07-11-level-gated-groups.md b/changelog/2026-07-11-level-gated-groups.md
new file mode 100644
index 0000000..df137cc
--- /dev/null
+++ b/changelog/2026-07-11-level-gated-groups.md
@@ -0,0 +1,6 @@
+πŸ€– **Groups just for an access level.** An open group can now be limited to
+one access level β€” pick "Only Coaches" when you create or open it. Members
+of that level find and join it under Discover like any open group; everyone
+else sees an honest locked entry ("A group for Coaches") with the name and
+everything inside it staying sealed. New-member auto-join never applies to
+limited groups.
diff --git a/db.go b/db.go
index b07767d..45f8435 100644
--- a/db.go
+++ b/db.go
@@ -299,6 +299,36 @@ CREATE TABLE IF NOT EXISTS instance_keys (
user_id TEXT PRIMARY KEY,
wrapped TEXT NOT NULL
);
+-- Tiers (docs/community-and-tiers.md): admin-curated member sets that CARRY
+-- KEY MATERIAL β€” the opposite of users.role, which by invariant carries none.
+-- A member holds a set of tiers; each tier has its own AES key (tier_keys),
+-- and tier-gated open groups wrap their thread key under it. The name is
+-- plaintext BY DESIGN, like branding: admin-set space policy, never member
+-- content, and it must render where no key exists yet (invite claim screens,
+-- later checkout).
+CREATE TABLE IF NOT EXISTS tiers (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ created_at INTEGER NOT NULL
+);
+-- who's in a tier, and which assigner put them there ('admin' today;
+-- 'invite' when cohorts land, billing sources later β€” the assignment axis of
+-- the design doc; the machinery below this line never cares which).
+CREATE TABLE IF NOT EXISTS user_tiers (
+ tier_id TEXT NOT NULL,
+ user_id TEXT NOT NULL,
+ source TEXT NOT NULL DEFAULT 'admin',
+ created_at INTEGER NOT NULL,
+ PRIMARY KEY (tier_id, user_id)
+);
+-- each tier's key wrapped to each member's pubkey β€” instance_keys, per tier.
+-- Client-side wraps only; the server stores blobs it cannot open.
+CREATE TABLE IF NOT EXISTS tier_keys (
+ tier_id TEXT NOT NULL,
+ user_id TEXT NOT NULL,
+ wrapped TEXT NOT NULL,
+ PRIMARY KEY (tier_id, user_id)
+);
-- per-user "closed" chats: a thread hidden from that user's list (synced across
-- their devices). Just a marker β€” no content. Cleared for everyone on new
-- activity (see insertMessage) so a closed chat returns when it's messaged.
@@ -552,6 +582,17 @@ 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",
+ // 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
+ // minting a SECOND key would seal every group gated under the first.
+ "ALTER TABLE tiers ADD COLUMN key_minted INTEGER NOT NULL DEFAULT 0",
+ // Level-gated open groups: the tier whose members may discover/join
+ // this open group ('' / NULL = every member; the open_key is then
+ // wrapped under that TIER's key instead of the instance key). Fixed
+ // once open_key exists β€” changing the gate would need a rekey the
+ // never-overwrite rule forbids (close the group and make a new one).
+ "ALTER TABLE threads ADD COLUMN open_tier TEXT",
} {
if _, err := db.sql.Exec(c); err != nil && !strings.Contains(err.Error(), "duplicate column") {
return err
@@ -1349,6 +1390,12 @@ func (db *DB) removeUser(id string) error {
{"DELETE FROM device_tokens WHERE user_id = ?", []any{id}},
{"DELETE FROM key_invites WHERE user_id = ?", []any{id}},
{"DELETE FROM instance_keys WHERE user_id = ?", []any{id}},
+ // Access levels: their memberships and key copies go too β€” a removed
+ // member must not linger as a ghost holder (which would block the
+ // "key lost" detection) or a ghost member (wrong counts, a forever
+ // "keys on their way" line in the Admin app).
+ {"DELETE FROM user_tiers WHERE user_id = ?", []any{id}},
+ {"DELETE FROM tier_keys WHERE user_id = ?", []any{id}},
{"DELETE FROM dm_pairs WHERE user_a = ? OR user_b = ?", []any{id, id}},
{"DELETE FROM reactions WHERE token = ?", []any{id}},
{"DELETE FROM messages WHERE author_hash = ?", []any{id}},
@@ -1624,6 +1671,232 @@ func (db *DB) grantInstanceKey(userID, wrapped string) error {
return err
}
+// Tier is one admin-curated member set with key material (see the schema
+// comment). Members/Mine/Wrapped/Have are filled per requesting user by
+// listTiers, so one fetch renders the whole management + key-grant picture.
+type Tier struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ CreatedAt int64 `json:"created_at"`
+ Members []string `json:"members,omitempty"`
+ // Mine: the requesting user belongs to this tier. Wrapped: their copy of
+ // the tier key (unwrap client-side). Have: who already holds a wrapped
+ // copy, so a holder only wraps to those missing (the grantInstanceKeyTo
+ // rule, per tier).
+ Mine bool `json:"mine,omitempty"`
+ Wrapped string `json:"wrapped,omitempty"`
+ Have []string `json:"have,omitempty"`
+ // 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"`
+}
+
+func (db *DB) createTier(name string) (string, error) {
+ id, err := randToken()
+ if err != nil {
+ return "", err
+ }
+ _, err = db.sql.Exec("INSERT INTO tiers(id, name, created_at) VALUES(?, ?, ?)",
+ id, name, time.Now().Unix())
+ return id, err
+}
+
+// deleteTier removes the tier, its memberships and its key rows. Callers must
+// refuse deletion while any thread is gated on this level (tierGatesThreads):
+// a gated group's open_key is wrapped under this key, and for self-serve
+// joiners that wrap is their ONLY key source β€” deleting it would seal the
+// group's name and history on their devices for good.
+func (db *DB) deleteTier(id string) error {
+ tx, err := db.sql.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+ for _, q := range []string{
+ "DELETE FROM tier_keys WHERE tier_id = ?",
+ "DELETE FROM user_tiers WHERE tier_id = ?",
+ "DELETE FROM tiers WHERE id = ?",
+ } {
+ if _, err := tx.Exec(q, id); err != nil {
+ return err
+ }
+ }
+ return tx.Commit()
+}
+
+func (db *DB) tierExists(id string) bool {
+ var x int
+ return db.sql.QueryRow("SELECT 1 FROM tiers WHERE id = ?", id).Scan(&x) == nil
+}
+
+func (db *DB) tierMember(tierID, userID string) bool {
+ var x int
+ return db.sql.QueryRow("SELECT 1 FROM user_tiers WHERE tier_id = ? AND user_id = ?", tierID, userID).Scan(&x) == nil
+}
+
+// tierHolder reports whether a member holds a wrapped copy of the tier key β€”
+// the gate for granting to others once the key exists (see postTierKeys).
+func (db *DB) tierHolder(tierID, userID string) bool {
+ var x int
+ return db.sql.QueryRow("SELECT 1 FROM tier_keys WHERE tier_id = ? AND user_id = ?", tierID, userID).Scan(&x) == nil
+}
+
+func (db *DB) tierKeyCount(tierID string) int {
+ var n int
+ db.sql.QueryRow("SELECT COUNT(*) FROM tier_keys WHERE tier_id = ?", tierID).Scan(&n)
+ return n
+}
+
+func (db *DB) assignTier(tierID, userID, source string) error {
+ _, err := db.sql.Exec(
+ "INSERT OR IGNORE INTO user_tiers(tier_id, user_id, source, created_at) VALUES(?, ?, ?, ?)",
+ tierID, userID, source, time.Now().Unix())
+ return err
+}
+
+// unassignTier drops a member from a tier: membership + their key row. Like
+// removeThreadMember, this is service-level revocation β€” the tier key isn't
+// rotated, so it stops the server serving them tier-gated content, but a
+// device that already holds the key keeps what it has (rekey is deferred;
+// docs/community-and-tiers.md Β§ revocation).
+func (db *DB) unassignTier(tierID, userID string) error {
+ tx, err := db.sql.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+ if _, err := tx.Exec("DELETE FROM user_tiers WHERE tier_id = ? AND user_id = ?", tierID, userID); err != nil {
+ return err
+ }
+ if _, err := tx.Exec("DELETE FROM tier_keys WHERE tier_id = ? AND user_id = ?", tierID, userID); err != nil {
+ return err
+ }
+ return tx.Commit()
+}
+
+// grantTierKey stores one member's wrapped copy of a tier key. DO NOTHING,
+// never overwrite β€” the grantInstanceKey anti-brick rule, per tier.
+func (db *DB) grantTierKey(tierID, userID, wrapped string) error {
+ _, err := db.sql.Exec(
+ "INSERT INTO tier_keys(tier_id, user_id, wrapped) VALUES(?, ?, ?) ON CONFLICT(tier_id, user_id) DO NOTHING",
+ tierID, userID, wrapped)
+ return err
+}
+
+var errAlreadyMinted = errors.New("tier key already minted")
+
+// mintTierKeys is the ONE-TIME key creation for a level: it flips key_minted
+// and stores the initial grants in a single transaction. The guarded UPDATE
+// makes concurrent minters serialize β€” exactly one wins; the loser's key
+// never enters the tier (it reloads and proceeds as a nudger/holder instead).
+func (db *DB) mintTierKeys(tierID string, grants []ThreadMemberKey) error {
+ tx, err := db.sql.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+ res, err := tx.Exec("UPDATE tiers SET key_minted = 1 WHERE id = ? AND key_minted = 0", tierID)
+ if err != nil {
+ return err
+ }
+ if n, _ := res.RowsAffected(); n == 0 {
+ return errAlreadyMinted
+ }
+ for _, g := range grants {
+ if _, err := tx.Exec(
+ "INSERT INTO tier_keys(tier_id, user_id, wrapped) VALUES(?, ?, ?) ON CONFLICT(tier_id, user_id) DO NOTHING",
+ tierID, g.UserID, g.Wrapped); err != nil {
+ return err
+ }
+ }
+ return tx.Commit()
+}
+
+func (db *DB) tierKeyMinted(tierID string) bool {
+ var x int
+ if db.sql.QueryRow("SELECT COALESCE(key_minted, 0) FROM tiers WHERE id = ?", tierID).Scan(&x) != nil {
+ return false
+ }
+ return x == 1
+}
+
+// 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.
+func (db *DB) tierGatesThreads(tierID string) int {
+ var n int
+ db.sql.QueryRow("SELECT COUNT(*) FROM threads WHERE open_tier = ?", tierID).Scan(&n)
+ return n
+}
+
+// tierMemberIDs / tierHolderIDs feed the targeted WS prods: a key nudge goes
+// only to members who can actually help (holders), a grant prod only to the
+// members who just received a copy.
+func (db *DB) tierMemberIDs(tierID string) ([]string, error) {
+ return db.idColumn("SELECT user_id FROM user_tiers WHERE tier_id = ?", tierID)
+}
+
+func (db *DB) tierHolderIDs(tierID string) ([]string, error) {
+ return db.idColumn("SELECT user_id FROM tier_keys WHERE tier_id = ?", tierID)
+}
+
+func (db *DB) idColumn(query, arg string) ([]string, error) {
+ rows, err := db.sql.Query(query, arg)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ out := []string{}
+ for rows.Next() {
+ var id string
+ if err := rows.Scan(&id); err != nil {
+ return nil, err
+ }
+ out = append(out, id)
+ }
+ return out, rows.Err()
+}
+
+// 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) {
+ 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 = ?)
+ FROM tiers t
+ LEFT JOIN tier_keys k ON k.tier_id = t.id AND k.user_id = ?
+ ORDER BY t.created_at, t.id`, userID, userID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ out := []Tier{}
+ minted := []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 {
+ return nil, err
+ }
+ minted = append(minted, m == 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 {
+ return nil, err
+ }
+ out[i].KeyLost = minted[i] && len(out[i].Have) == 0
+ }
+ return out, nil
+}
+
type Thread struct {
ID string `json:"id"`
Kind string `json:"kind"` // self | dm | group
@@ -1653,10 +1926,12 @@ type Thread struct {
// Visibility is "open" for a community group any member can discover and
// join (see openThreads); omitted for ordinary invite-only threads.
Visibility string `json:"visibility,omitempty"`
- // OpenKey is the thread key encrypted under the instance key (open groups
- // only) β€” the key source for members who joined self-serve and so have no
- // per-member wrapped copy in thread_keys.
+ // OpenKey is the thread key encrypted under the instance key β€” or, when
+ // OpenTier is set, under that tier's key (open groups only). It's the key
+ // source for members who joined self-serve with no per-member wrapped copy.
OpenKey string `json:"open_key,omitempty"`
+ // OpenTier gates an open group to one access level ("" = every member).
+ OpenTier string `json:"open_tier,omitempty"`
// DefaultJoin marks an open group new members are auto-joined to.
DefaultJoin bool `json:"default_join,omitempty"`
}
@@ -1670,14 +1945,14 @@ type ThreadMemberKey struct {
// createThread creates a thread with its members and their wrapped key blobs in
// one transaction. nameBlob is the client-encrypted group name ("" for dm/self).
func (db *DB) createThread(kind, nameBlob string, members []ThreadMemberKey) (string, error) {
- return db.createThreadVis(kind, nameBlob, members, "", "")
+ return db.createThreadVis(kind, nameBlob, members, "", "", "")
}
// createThreadVis also stamps visibility + open_key for open community groups
// (see openThreads). "" visibility means the default 'invite'. Members with an
// empty Wrapped get no thread_keys row β€” an open group's key reaches them via
// open_key instead.
-func (db *DB) createThreadVis(kind, nameBlob string, members []ThreadMemberKey, visibility, openKey string) (string, error) {
+func (db *DB) createThreadVis(kind, nameBlob string, members []ThreadMemberKey, visibility, openKey, openTier string) (string, error) {
id, err := randToken()
if err != nil {
return "", err
@@ -1695,11 +1970,14 @@ func (db *DB) createThreadVis(kind, nameBlob string, members []ThreadMemberKey,
if visibility == "" {
visibility = "invite"
}
- var ok any
+ var ok, ot any
if openKey != "" {
ok = openKey
}
- if _, err := tx.Exec("INSERT INTO threads(id, kind, name_blob, created_at, visibility, open_key) VALUES(?, ?, ?, ?, ?, ?)", id, kind, nb, now, visibility, ok); err != nil {
+ if openTier != "" {
+ ot = openTier
+ }
+ if _, err := tx.Exec("INSERT INTO threads(id, kind, name_blob, created_at, visibility, open_key, open_tier) VALUES(?, ?, ?, ?, ?, ?, ?)", id, kind, nb, now, visibility, ok, ot); err != nil {
return "", err
}
for _, m := range members {
@@ -1805,7 +2083,7 @@ func (db *DB) threadsForUser(userID string) ([]Thread, error) {
COALESCE((SELECT blob FROM messages WHERE thread_id = t.id ORDER BY id DESC LIMIT 1), ''),
COALESCE((SELECT created_at FROM messages WHERE thread_id = t.id ORDER BY id DESC LIMIT 1), 0),
COALESCE(t.avatar_blob, ''),
- COALESCE(t.visibility, 'invite'), COALESCE(t.open_key, ''), COALESCE(t.default_join, 0),
+ COALESCE(t.visibility, 'invite'), COALESCE(t.open_key, ''), COALESCE(t.open_tier, ''), COALESCE(t.default_join, 0),
EXISTS(SELECT 1 FROM hidden_threads h WHERE h.thread_id = t.id AND h.user_id = m.user_id),
EXISTS(SELECT 1 FROM pinned_threads p WHERE p.thread_id = t.id AND p.user_id = m.user_id) AS pinned,
COALESCE((SELECT last_read_at FROM read_marks r WHERE r.thread_id = t.id AND r.user_id = m.user_id), 0)
@@ -1826,7 +2104,7 @@ func (db *DB) threadsForUser(userID string) ([]Thread, error) {
out := []Thread{}
for rows.Next() {
var t Thread
- if err := rows.Scan(&t.ID, &t.Kind, &t.NameBlob, &t.CreatedAt, &t.Wrapped, &t.LastBlob, &t.LastAt, &t.Avatar, &t.Visibility, &t.OpenKey, &t.DefaultJoin, &t.Hidden, &t.Pinned, &t.LastReadAt); err != nil {
+ if err := rows.Scan(&t.ID, &t.Kind, &t.NameBlob, &t.CreatedAt, &t.Wrapped, &t.LastBlob, &t.LastAt, &t.Avatar, &t.Visibility, &t.OpenKey, &t.OpenTier, &t.DefaultJoin, &t.Hidden, &t.Pinned, &t.LastReadAt); err != nil {
return nil, err
}
if t.Visibility == "invite" {
@@ -1858,6 +2136,12 @@ type OpenThread struct {
MemberCount int `json:"member_count"`
Joined bool `json:"joined"`
DefaultJoin bool `json:"default_join,omitempty"`
+ // Level gate: the tier that may join ("" = everyone), its plaintext name
+ // (so ineligible members see an honest "For Coaches" lock, not a blank),
+ // and whether the requesting user is eligible.
+ OpenTier string `json:"open_tier,omitempty"`
+ TierName string `json:"tier_name,omitempty"`
+ Eligible bool `json:"eligible"`
}
// openThreads lists every open group (joined or not) for the community
@@ -1867,10 +2151,14 @@ func (db *DB) openThreads(userID string) ([]OpenThread, error) {
SELECT t.id, COALESCE(t.name_blob, ''), t.created_at, COALESCE(t.avatar_blob, ''), COALESCE(t.open_key, ''),
(SELECT COUNT(*) FROM thread_members m WHERE m.thread_id = t.id) AS n,
EXISTS(SELECT 1 FROM thread_members m WHERE m.thread_id = t.id AND m.user_id = ?),
- COALESCE(t.default_join, 0)
+ COALESCE(t.default_join, 0),
+ COALESCE(t.open_tier, ''), COALESCE(tr.name, ''),
+ (COALESCE(t.open_tier, '') = '' OR EXISTS(
+ SELECT 1 FROM user_tiers ut WHERE ut.tier_id = t.open_tier AND ut.user_id = ?))
FROM threads t
+ LEFT JOIN tiers tr ON tr.id = t.open_tier
WHERE t.visibility = 'open'
- ORDER BY n DESC, t.created_at DESC`, userID)
+ ORDER BY n DESC, t.created_at DESC`, userID, userID)
if err != nil {
return nil, err
}
@@ -1878,7 +2166,7 @@ func (db *DB) openThreads(userID string) ([]OpenThread, error) {
out := []OpenThread{}
for rows.Next() {
var t OpenThread
- if err := rows.Scan(&t.ID, &t.NameBlob, &t.CreatedAt, &t.Avatar, &t.OpenKey, &t.MemberCount, &t.Joined, &t.DefaultJoin); err != nil {
+ if err := rows.Scan(&t.ID, &t.NameBlob, &t.CreatedAt, &t.Avatar, &t.OpenKey, &t.MemberCount, &t.Joined, &t.DefaultJoin, &t.OpenTier, &t.TierName, &t.Eligible); err != nil {
return nil, err
}
out = append(out, t)
@@ -1895,6 +2183,26 @@ func (db *DB) threadIsOpen(threadID string) bool {
return v == "open"
}
+// threadOpenInfo reports whether a thread already has an open_key stored and
+// which access level (if any) gates it β€” the immutability check for
+// postThreadVisibility.
+func (db *DB) threadOpenInfo(threadID string) (hasKey bool, tier string) {
+ var k sql.NullString
+ var t sql.NullString
+ if db.sql.QueryRow("SELECT open_key, open_tier FROM threads WHERE id = ?", threadID).Scan(&k, &t) != nil {
+ return false, ""
+ }
+ return k.Valid && k.String != "", t.String
+}
+
+// nullable maps "" to SQL NULL, for optional TEXT columns.
+func nullable(s string) any {
+ if s == "" {
+ return nil
+ }
+ return s
+}
+
// setThreadDefault flags (or unflags) an open group as joined-by-default for
// new members. Policy, not key material β€” flipping it moves no keys.
func (db *DB) setThreadDefault(threadID string, def bool) error {
@@ -1917,6 +2225,7 @@ func (db *DB) joinDefaultThreads(userID string) error {
SELECT t.id, ?, ?
FROM threads t
WHERE t.visibility = 'open' AND COALESCE(t.default_join, 0) = 1
+ AND COALESCE(t.open_tier, '') = ''
AND (SELECT COALESCE(is_agent, 0) FROM users WHERE id = ?) = 0`,
userID, time.Now().Unix(), userID)
return err
@@ -1934,8 +2243,8 @@ func (db *DB) joinOpenThread(threadID, userID string) error {
return err
}
defer tx.Rollback()
- var v string
- if err := tx.QueryRow("SELECT COALESCE(visibility, 'invite') FROM threads WHERE id = ?", threadID).Scan(&v); err != nil {
+ var v, gate string
+ if err := tx.QueryRow("SELECT COALESCE(visibility, 'invite'), COALESCE(open_tier, '') FROM threads WHERE id = ?", threadID).Scan(&v, &gate); err != nil {
if err == sql.ErrNoRows {
return errNotOpen
}
@@ -1944,6 +2253,18 @@ func (db *DB) joinOpenThread(threadID, userID string) error {
if v != "open" {
return errNotOpen
}
+ // A level-gated group is open only to that access level β€” the same
+ // errNotOpen to outsiders, so the refusal leaks nothing they can't
+ // already see in the directory's honest lock.
+ if gate != "" {
+ var x int
+ if err := tx.QueryRow("SELECT 1 FROM user_tiers WHERE tier_id = ? AND user_id = ?", gate, userID).Scan(&x); err != nil {
+ if err == sql.ErrNoRows {
+ return errNotOpen
+ }
+ return err
+ }
+ }
if _, err := tx.Exec("INSERT OR IGNORE INTO thread_members(thread_id, user_id, created_at) VALUES(?, ?, ?)", threadID, userID, time.Now().Unix()); err != nil {
return err
}
@@ -1956,9 +2277,15 @@ func (db *DB) joinOpenThread(threadID, userID string) error {
// good key blob with junk and cut off every self-serve joiner). Closing keeps
// open_key β€” members who joined self-serve still decrypt through it; 'invite'
// only stops discovery and new joins.
-func (db *DB) setThreadVisibility(threadID, visibility, openKey string) error {
+func (db *DB) setThreadVisibility(threadID, visibility, openKey, openTier string) error {
if visibility == "open" {
- _, err := db.sql.Exec("UPDATE threads SET visibility = 'open', open_key = COALESCE(open_key, ?) WHERE id = ?", openKey, threadID)
+ // open_tier is stamped in the same breath as open_key and only then β€”
+ // SET expressions read the pre-update row, so both CASEs test the OLD
+ // open_key. A later gate change would need a rekey the never-overwrite
+ // rule forbids.
+ _, err := db.sql.Exec(`UPDATE threads SET visibility = 'open',
+ open_tier = CASE WHEN open_key IS NULL THEN ? ELSE open_tier END,
+ open_key = COALESCE(open_key, ?) WHERE id = ?`, nullable(openTier), openKey, threadID)
return err
}
_, err := db.sql.Exec("UPDATE threads SET visibility = ? WHERE id = ?", visibility, threadID)
diff --git a/handlers_test.go b/handlers_test.go
index 5662d23..2076728 100644
--- a/handlers_test.go
+++ b/handlers_test.go
@@ -2151,3 +2151,417 @@ func TestDefaultGroups(t *testing.T) {
t.Fatalf("add-device claim re-joined a left default group")
}
}
+
+// TestTiers covers the tier server core: admin-gated management, the
+// mint-vs-holder gate on key grants, the never-overwrite rule, targeted
+// membership checks, and revocation dropping the key row.
+func TestTiers(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 %s: %v", label, err)
+ }
+ if err := srv.db.createKeyInvite("inv-"+label, u, "wrap", testClaimHash, 0); err != nil {
+ t.Fatalf("createKeyInvite %s: %v", label, err)
+ }
+ tk, ok, err := srv.db.claimKeyInvite("inv-"+label, label, "", testClaimSecret)
+ if err != nil || !ok {
+ t.Fatalf("claimKeyInvite %s: ok=%v err=%v", label, ok, err)
+ }
+ if err := srv.db.setUserPublicKey(u, "pub-"+label); err != nil {
+ t.Fatalf("setUserPublicKey %s: %v", label, 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
+ }
+ tiersFor := func(tok string) []Tier {
+ resp, err := http.Get(ts.URL + "/api/tiers?token=" + tok)
+ if err != nil || resp.StatusCode != 200 {
+ t.Fatalf("get tiers: %v code=%d", err, resp.StatusCode)
+ }
+ defer resp.Body.Close()
+ var out struct {
+ Tiers []Tier `json:"tiers"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
+ t.Fatalf("decode tiers: %v", err)
+ }
+ return out.Tiers
+ }
+
+ tokAdmin, uidAdmin := mkUser("Root")
+ if err := srv.db.setUserRole(uidAdmin, "admin"); err != nil {
+ t.Fatalf("setUserRole: %v", err)
+ }
+ tokB, uidB := mkUser("Bea")
+ tokC, uidC := mkUser("Cal")
+ _, uidD := mkUser("Dot")
+
+ // Create: members can't, admins can, names are validated.
+ if code := do("POST", "/api/tiers", `{"token":"`+tokB+`","name":"Coaches"}`); code != 403 {
+ t.Fatalf("member create tier: code=%d, want 403", code)
+ }
+ if code := do("POST", "/api/tiers", `{"token":"`+tokAdmin+`","name":" "}`); code != 400 {
+ t.Fatalf("blank tier name: code=%d, want 400", code)
+ }
+ resp, err := http.Post(ts.URL+"/api/tiers", "application/json",
+ strings.NewReader(`{"token":"`+tokAdmin+`","name":"Coaches"}`))
+ if err != nil || resp.StatusCode != 200 {
+ t.Fatalf("create tier: %v code=%d", err, resp.StatusCode)
+ }
+ var created struct {
+ ID string `json:"id"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&created); err != nil || created.ID == "" {
+ t.Fatalf("create tier: id=%q err=%v", created.ID, err)
+ }
+ resp.Body.Close()
+ tid := created.ID
+
+ // Assign: admin-gated; unknown tier/member rejected.
+ if code := do("POST", "/api/tiers/"+tid+"/members", `{"token":"`+tokB+`","user_id":"`+uidC+`"}`); code != 403 {
+ t.Fatalf("member assign: code=%d, want 403", code)
+ }
+ if code := do("POST", "/api/tiers/nope/members", `{"token":"`+tokAdmin+`","user_id":"`+uidB+`"}`); code != 400 {
+ t.Fatalf("assign to unknown tier: code=%d, want 400", code)
+ }
+ for _, u := range []string{uidB, uidC} {
+ if code := do("POST", "/api/tiers/"+tid+"/members", `{"token":"`+tokAdmin+`","user_id":"`+u+`"}`); code != 200 {
+ t.Fatalf("assign %s: code=%d", u, code)
+ }
+ }
+ got := tiersFor(tokB)
+ if len(got) != 1 || got[0].Name != "Coaches" || !got[0].Mine || len(got[0].Members) != 2 {
+ t.Fatalf("Bea's view: %+v", got)
+ }
+ if admin := tiersFor(tokAdmin); admin[0].Mine {
+ t.Fatalf("assigning admin must not be a member themselves")
+ }
+
+ // Key grants: minting (no copies yet) is admin-only; grants land only on
+ // tier members; a holder can grant; nothing ever overwrites.
+ if code := do("POST", "/api/tiers/"+tid+"/keys", `{"token":"`+tokB+`","grants":[{"user_id":"`+uidB+`","wrapped":"wb"}]}`); code != 403 {
+ t.Fatalf("non-admin mint: code=%d, want 403", code)
+ }
+ if code := do("POST", "/api/tiers/"+tid+"/keys", `{"token":"`+tokAdmin+`","grants":[{"user_id":"`+uidB+`","wrapped":"wb"},{"user_id":"`+uidD+`","wrapped":"junk"}]}`); code != 200 {
+ t.Fatalf("mint: code=%d", code)
+ }
+ got = tiersFor(tokB)
+ if got[0].Wrapped != "wb" || len(got[0].Have) != 1 || got[0].Have[0] != uidB {
+ t.Fatalf("after mint: %+v", got[0])
+ }
+ if d := tiersFor(tokAdmin); len(d[0].Have) != 1 {
+ t.Fatalf("non-member Dot must not receive a key: %+v", d[0])
+ }
+ // Keyless member can't grant; holder can; overwrite is a no-op.
+ if code := do("POST", "/api/tiers/"+tid+"/keys", `{"token":"`+tokC+`","grants":[{"user_id":"`+uidC+`","wrapped":"wc"}]}`); code != 403 {
+ t.Fatalf("keyless grant: code=%d, want 403", code)
+ }
+ if code := do("POST", "/api/tiers/"+tid+"/keys", `{"token":"`+tokB+`","grants":[{"user_id":"`+uidC+`","wrapped":"wc"},{"user_id":"`+uidB+`","wrapped":"evil"}]}`); code != 200 {
+ t.Fatalf("holder grant: code=%d", code)
+ }
+ got = tiersFor(tokB)
+ if got[0].Wrapped != "wb" {
+ t.Fatalf("overwrite must be a no-op: %+v", got[0])
+ }
+ if c := tiersFor(tokC); c[0].Wrapped != "wc" {
+ t.Fatalf("Cal's grant missing: %+v", c[0])
+ }
+
+ // Nudge: members only.
+ if code := do("POST", "/api/tiers/"+tid+"/nudge", `{"token":"`+tokAdmin+`"}`); code != 403 {
+ t.Fatalf("non-member nudge: code=%d, want 403", code)
+ }
+ if code := do("POST", "/api/tiers/"+tid+"/nudge", `{"token":"`+tokC+`"}`); code != 200 {
+ t.Fatalf("member nudge: code=%d", code)
+ }
+
+ // Revocation: unassign drops membership AND the key row.
+ if code := do("POST", "/api/tiers/"+tid+"/remove", `{"token":"`+tokAdmin+`","user_id":"`+uidC+`"}`); code != 200 {
+ 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 {
+ t.Fatalf("after remove: %+v", got[0])
+ }
+
+ // Delete: admin-gated, cascades.
+ if code := do("DELETE", "/api/tiers/"+tid, `{"token":"`+tokB+`"}`); code != 403 {
+ t.Fatalf("member delete: code=%d, want 403", code)
+ }
+ if code := do("DELETE", "/api/tiers/"+tid, `{"token":"`+tokAdmin+`"}`); code != 200 {
+ t.Fatalf("delete: code=%d", code)
+ }
+ if got = tiersFor(tokB); len(got) != 0 {
+ t.Fatalf("after delete: %+v", got)
+ }
+ if srv.db.tierKeyCount(tid) != 0 {
+ t.Fatalf("delete must cascade key rows")
+ }
+}
+
+// TestLevelGatedGroups: an open group gated to an access level β€” create gate,
+// directory honesty (eligible flag + plaintext level name), join gate, the
+// default-join sweep skipping gated groups, and gate immutability.
+func TestLevelGatedGroups(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 %s: %v", label, err)
+ }
+ if err := srv.db.createKeyInvite("inv-"+label, u, "wrap", testClaimHash, 0); err != nil {
+ t.Fatalf("createKeyInvite %s: %v", label, err)
+ }
+ tk, ok, err := srv.db.claimKeyInvite("inv-"+label, label, "", testClaimSecret)
+ if err != nil || !ok {
+ t.Fatalf("claimKeyInvite %s: ok=%v err=%v", label, ok, err)
+ }
+ if err := srv.db.setUserPublicKey(u, "pub-"+label); err != nil {
+ t.Fatalf("setUserPublicKey %s: %v", label, err)
+ }
+ return tk, u
+ }
+ post := func(path, body string) (int, map[string]any) {
+ resp, err := http.Post(ts.URL+path, "application/json", strings.NewReader(body))
+ if err != nil {
+ t.Fatalf("post %s: %v", path, err)
+ }
+ defer resp.Body.Close()
+ var out map[string]any
+ _ = json.NewDecoder(resp.Body).Decode(&out)
+ return resp.StatusCode, out
+ }
+
+ tokB, uidB := mkUser("Bea")
+ tokC, _ := mkUser("Cal")
+
+ tid, err := srv.db.createTier("Coaches")
+ if err != nil {
+ t.Fatalf("createTier: %v", err)
+ }
+ if err := srv.db.assignTier(tid, uidB, "admin"); err != nil {
+ t.Fatalf("assignTier: %v", err)
+ }
+ if err := srv.db.grantTierKey(tid, uidB, "wb"); err != nil {
+ t.Fatalf("grantTierKey: %v", err)
+ }
+
+ // Create: only a member of the level can gate a group on it.
+ if code, _ := post("/api/threads", `{"token":"`+tokC+`","kind":"group","visibility":"open","open_key":"gk","open_tier":"`+tid+`","members":[{"user_id":"`+mustUID(t, srv, tokC)+`","wrapped":""}]}`); code != 400 {
+ t.Fatalf("non-member gates a group: code=%d, want 400", code)
+ }
+ if code, _ := post("/api/threads", `{"token":"`+tokB+`","kind":"group","visibility":"open","open_key":"gk","open_tier":"nope","members":[{"user_id":"`+uidB+`","wrapped":""}]}`); code != 400 {
+ t.Fatalf("unknown level: code=%d, want 400", code)
+ }
+ code, out := post("/api/threads", `{"token":"`+tokB+`","kind":"group","visibility":"open","open_key":"gk","open_tier":"`+tid+`","members":[{"user_id":"`+uidB+`","wrapped":""}]}`)
+ if code != 200 {
+ t.Fatalf("create gated group: code=%d", code)
+ }
+ gid, _ := out["thread_id"].(string)
+
+ // Directory: Cal sees the row with an honest lock (name + eligible=false).
+ resp, err := http.Get(ts.URL + "/api/threads/open?token=" + tokC)
+ if err != nil {
+ t.Fatalf("directory: %v", err)
+ }
+ defer resp.Body.Close()
+ var dir struct {
+ Threads []OpenThread `json:"threads"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&dir); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if len(dir.Threads) != 1 || dir.Threads[0].Eligible || dir.Threads[0].TierName != "Coaches" || dir.Threads[0].OpenTier != tid {
+ t.Fatalf("Cal's directory row: %+v", dir.Threads)
+ }
+
+ // Join: gated to the level.
+ if code, _ := post("/api/threads/"+gid+"/join", `{"token":"`+tokC+`"}`); code != 403 {
+ t.Fatalf("non-member join: code=%d, want 403", code)
+ }
+ if code, _ := post("/api/threads/"+gid+"/join", `{"token":"`+tokB+`"}`); code != 200 {
+ t.Fatalf("member join: code=%d", code)
+ }
+
+ // The default-join sweep never lands newcomers in a gated group.
+ if err := srv.db.setThreadDefault(gid, true); err != nil {
+ t.Fatalf("setThreadDefault: %v", err)
+ }
+ _, uidNew := mkUser("Newbie")
+ if srv.db.threadMember(gid, uidNew) {
+ t.Fatalf("newcomer auto-joined a level-gated group")
+ }
+
+ // The gate is fixed once opened: closing keeps it, reopening with a
+ // different (or no) gate is refused, the same gate is accepted.
+ if code, _ := post("/api/threads/"+gid+"/visibility", `{"token":"`+tokB+`","visibility":"invite"}`); code != 200 {
+ t.Fatalf("close: code=%d", code)
+ }
+ if code, _ := post("/api/threads/"+gid+"/visibility", `{"token":"`+tokB+`","visibility":"open","open_key":"gk2"}`); code != 400 {
+ t.Fatalf("reopen without the gate: code=%d, want 400", code)
+ }
+ if code, _ := post("/api/threads/"+gid+"/visibility", `{"token":"`+tokB+`","visibility":"open","open_key":"gk2","open_tier":"`+tid+`"}`); code != 200 {
+ t.Fatalf("reopen with the same gate: code=%d", code)
+ }
+ // Reopening needs NO key blob at all β€” the stored open_key survives, so
+ // any member can reopen (symmetric with closing).
+ if code, _ := post("/api/threads/"+gid+"/visibility", `{"token":"`+tokB+`","visibility":"invite"}`); code != 200 {
+ t.Fatalf("re-close: code=%d", code)
+ }
+ if code, _ := post("/api/threads/"+gid+"/visibility", `{"token":"`+tokB+`","visibility":"open","open_tier":"`+tid+`"}`); code != 200 {
+ t.Fatalf("keyless reopen: code=%d", code)
+ }
+ resp2, _ := http.Get(ts.URL + "/api/threads/open?token=" + tokB)
+ defer resp2.Body.Close()
+ var dir2 struct {
+ Threads []OpenThread `json:"threads"`
+ }
+ _ = json.NewDecoder(resp2.Body).Decode(&dir2)
+ if len(dir2.Threads) != 1 || dir2.Threads[0].OpenKey != "gk" || !dir2.Threads[0].Eligible {
+ t.Fatalf("after reopen: %+v", dir2.Threads)
+ }
+
+ // default_join is refused on gated groups (the sweep skips them β€” a saved
+ // flag would be a silent lie).
+ if err := srv.db.setUserRole(uidB, "admin"); err != nil {
+ t.Fatalf("setUserRole: %v", err)
+ }
+ if code, _ := post("/api/threads/"+gid+"/default", `{"token":"`+tokB+`","default":true}`); code != 400 {
+ t.Fatalf("default on gated group: code=%d, want 400", code)
+ }
+}
+
+// mustUID resolves a token's user id or fails the test.
+func mustUID(t *testing.T, srv *Server, token string) string {
+ t.Helper()
+ uid, ok := srv.db.deviceTokenUser(token)
+ if !ok {
+ t.Fatalf("no uid for token")
+ }
+ return uid
+}
+
+
+// TestTierKeyLifecycle pins the one-way mint and the honest dead ends: a
+// minted level whose holders are all gone is LOST (410, never re-minted), a
+// level still gating groups can't be deleted, and removing a user cascades
+// their tier rows.
+func TestTierKeyLifecycle(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 %s: %v", label, err)
+ }
+ if err := srv.db.createKeyInvite("inv-"+label, u, "wrap", testClaimHash, 0); err != nil {
+ t.Fatalf("createKeyInvite %s: %v", label, err)
+ }
+ tk, ok, err := srv.db.claimKeyInvite("inv-"+label, label, "", testClaimSecret)
+ if err != nil || !ok {
+ t.Fatalf("claimKeyInvite %s: ok=%v err=%v", label, ok, err)
+ }
+ if err := srv.db.setUserPublicKey(u, "pub-"+label); err != nil {
+ t.Fatalf("setUserPublicKey %s: %v", label, 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
+ }
+
+ tokAdmin, uidAdmin := mkUser("Root")
+ if err := srv.db.setUserRole(uidAdmin, "admin"); err != nil {
+ t.Fatalf("setUserRole: %v", err)
+ }
+ tokB, uidB := mkUser("Bea")
+
+ tid, err := srv.db.createTier("Coaches")
+ if err != nil {
+ t.Fatalf("createTier: %v", err)
+ }
+ if code := do("POST", "/api/tiers/"+tid+"/members", `{"token":"`+tokAdmin+`","user_id":"`+uidB+`"}`); code != 200 {
+ t.Fatalf("assign: code=%d", code)
+ }
+ if code := do("POST", "/api/tiers/"+tid+"/keys", `{"token":"`+tokAdmin+`","grants":[{"user_id":"`+uidB+`","wrapped":"wb"}]}`); code != 200 {
+ t.Fatalf("mint: code=%d", code)
+ }
+
+ // Mint is one-way: a second mint can never happen (db-level serialization).
+ if err := srv.db.mintTierKeys(tid, nil); err != errAlreadyMinted {
+ t.Fatalf("second mint: err=%v, want errAlreadyMinted", err)
+ }
+
+ // Bea gates a group on the level, then loses her level membership: the
+ // level is minted with ZERO holders β†’ the key is lost, loudly.
+ code := 0
+ {
+ resp, err := http.Post(ts.URL+"/api/threads", "application/json",
+ strings.NewReader(`{"token":"`+tokB+`","kind":"group","visibility":"open","open_key":"gk","open_tier":"`+tid+`","members":[{"user_id":"`+uidB+`","wrapped":""}]}`))
+ if err != nil {
+ t.Fatalf("create gated: %v", err)
+ }
+ code = resp.StatusCode
+ resp.Body.Close()
+ }
+ if code != 200 {
+ t.Fatalf("create gated group: code=%d", code)
+ }
+ if code := do("POST", "/api/tiers/"+tid+"/remove", `{"token":"`+tokAdmin+`","user_id":"`+uidB+`"}`); code != 200 {
+ t.Fatalf("unassign holder: code=%d", code)
+ }
+ 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)
+ if err != nil || len(tiers) != 1 || !tiers[0].KeyLost {
+ t.Fatalf("key_lost not reported: %+v err=%v", tiers, err)
+ }
+
+ // The level still gates a group β†’ deletion refused with the reason.
+ if code := do("DELETE", "/api/tiers/"+tid, `{"token":"`+tokAdmin+`"}`); code != 409 {
+ t.Fatalf("delete while gating: code=%d, want 409", code)
+ }
+
+ // removeUser cascades tier rows: re-assign Bea, grant via a fresh level,
+ // then remove her β€” no ghost membership or holdership may remain.
+ tid2, err := srv.db.createTier("Founders")
+ if err != nil {
+ t.Fatalf("createTier 2: %v", err)
+ }
+ if err := srv.db.assignTier(tid2, uidB, "admin"); err != nil {
+ t.Fatalf("assignTier: %v", err)
+ }
+ if err := srv.db.mintTierKeys(tid2, []ThreadMemberKey{{UserID: uidB, Wrapped: "wb2"}}); err != nil {
+ t.Fatalf("mint 2: %v", err)
+ }
+ if err := srv.db.removeUser(uidB); err != nil {
+ t.Fatalf("removeUser: %v", err)
+ }
+ if ms, _ := srv.db.tierMemberIDs(tid2); len(ms) != 0 {
+ t.Fatalf("ghost tier membership after removeUser: %v", ms)
+ }
+ if hs, _ := srv.db.tierHolderIDs(tid2); len(hs) != 0 {
+ t.Fatalf("ghost key holder after removeUser: %v", hs)
+ }
+}
diff --git a/ios/LChatCore/Sources/LChatCore/DiscoverGroupsView.swift b/ios/LChatCore/Sources/LChatCore/DiscoverGroupsView.swift
index 99662eb..1429b6b 100644
--- a/ios/LChatCore/Sources/LChatCore/DiscoverGroupsView.swift
+++ b/ios/LChatCore/Sources/LChatCore/DiscoverGroupsView.swift
@@ -66,6 +66,13 @@ public struct DiscoverGroupsView: View {
.buttonStyle(.bordered)
.buttonBorderShape(.capsule)
.accessibilityLabel("Open \(g.title)")
+ } else if g.locked || g.keyPending {
+ // No dead Join buttons: a lock is information, not an error,
+ // and the state is carried by the row text too (never a
+ // glyph alone).
+ Image(systemName: g.locked ? "lock" : "hourglass")
+ .foregroundStyle(.secondary)
+ .accessibilityLabel(g.locked ? "Locked" : "Waiting for your key")
} else {
Button { join(g) } label: {
if joining == g.id { ProgressView().controlSize(.small) } else { Text("Join") }
@@ -82,6 +89,8 @@ public struct DiscoverGroupsView: View {
private func subtitle(_ g: DiscoverGroup) -> String {
if failedJoin == g.id { return "Couldn't join β€” try again." }
let count = g.memberCount == 1 ? "1 member" : "\(g.memberCount) members"
+ if g.locked { return g.tierName.isEmpty ? "\(count) Β· limited access" : "\(count) Β· for \(g.tierName) only" }
+ if g.keyPending { return "\(count) Β· your key is on its way" }
return g.defaultJoin ? "\(count) Β· everyone starts here" : count
}
diff --git a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
index 0b43a5f..e047c94 100644
--- a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
+++ b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
@@ -40,6 +40,17 @@ public struct LChatAPI {
return (try? await getJSON("/api/threads/open", Resp.self))?.threads ?? []
}
+ /// The access levels, personalised (our wrapped key copies ride along).
+ public func tiers() async -> [TierRow] {
+ struct Resp: Codable { let tiers: [TierRow]? }
+ return (try? await getJSON("/api/tiers", Resp.self))?.tiers ?? []
+ }
+
+ /// Ask current holders to wrap an access level's key to us (keyless prod).
+ public func nudgeTier(_ tier: String) async {
+ _ = await postJSON("/api/tiers/\(tier)/nudge", ["token": token])
+ }
+
/// Self-serve join an open group. True only on a real 200 β€” a 403 (the
/// group closed while the sheet was up) must not read as joined.
public func joinThread(_ thread: String) async -> Bool {
diff --git a/ios/LChatCore/Sources/LChatCore/Models.swift b/ios/LChatCore/Sources/LChatCore/Models.swift
index 2431bdc..81e8218 100644
--- a/ios/LChatCore/Sources/LChatCore/Models.swift
+++ b/ios/LChatCore/Sources/LChatCore/Models.swift
@@ -85,6 +85,7 @@ public struct ServerThread: Codable {
public var last_read_at: Int64? // this user's read watermark (per-user, server-synced)
public var visibility: String? // "open" for a community group anyone can join
public var open_key: String? // the thread key, encrypted under the instance key
+ public var open_tier: String? // access level gating the group ("" / nil = everyone)
}
public struct RosterEntry: Codable, Equatable {
@@ -219,22 +220,46 @@ public struct OpenThreadRow: Codable {
public var member_count: Int?
public var joined: Bool?
public var default_join: Bool?
+ public var open_tier: String? // level gate ("" = everyone)
+ public var tier_name: String? // the gate's plaintext name, for honest locks
+ public var eligible: Bool? // whether WE may join (server-checked too)
+}
+
+// One access level from GET /api/tiers, personalised to this user (mirrors
+// the web's Tier rows; names are plaintext space policy, like branding).
+public struct TierRow: Codable {
+ public let id: String
+ public var name: String?
+ public var wrapped: String? // our copy of the level key (ECIES), if granted
+ public var mine: Bool?
+ public var members: [String]?
+ public var have: [String]?
}
// A decrypted directory row, ready for the Discover list on either platform.
+// A level-gated group we're not in stays SEALED: locked=true, the title is the
+// honest placeholder ("A group for Coaches"), and there is nothing to join.
+// keyPending: it's ours but the level key hasn't reached this device yet.
public struct DiscoverGroup: Identifiable, Equatable {
public let id: String
public let title: String
public let memberCount: Int
public let joined: Bool
public let defaultJoin: Bool
+ public let locked: Bool
+ public let keyPending: Bool
+ public let tierName: String
- public init(id: String, title: String, memberCount: Int, joined: Bool, defaultJoin: Bool) {
+ public init(id: String, title: String, memberCount: Int, joined: Bool, defaultJoin: Bool,
+ locked: Bool = false, keyPending: Bool = false, tierName: String = "") {
self.id = id
self.title = title
self.memberCount = memberCount
self.joined = joined
self.defaultJoin = defaultJoin
+ self.locked = locked
+ self.keyPending = keyPending
+ self.tierName = tierName
}
}
diff --git a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
index 793b119..650ce93 100644
--- a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
+++ b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
@@ -18,6 +18,8 @@ public final class ThreadStore: ObservableObject {
public let instance: SavedInstance
public private(set) var myUserId: String
private var instanceKeyB64: String? // for member avatars (optional)
+ private var tierKeysB64: [String: String] = [:] // level id -> our unwrapped key
+ private var tiers: [TierRow] = [] // /api/tiers rows, personalised
private var rosterLabels: [String: String] = [:]
private var api: LChatAPI? {
@@ -79,6 +81,7 @@ public final class ThreadStore: ObservableObject {
let wrappedInstanceKey = await api.instanceKeyWrapped()
instanceName = await api.groupName()
let serverThreads = await api.threads()
+ let tierRows = await api.tiers()
// ALL crypto in one detached pass β€” the instance-key unwrap, roster
// labels, per-thread ECDH unwraps, previews, titles, app ids. refresh()
@@ -89,9 +92,19 @@ public final class ThreadStore: ObservableObject {
let uid = myUserId
let folded = await Task.detached(priority: .userInitiated) {
Self.fold(instance: inst, myUserId: uid, peopleIn: people,
- wrappedInstanceKey: wrappedInstanceKey, serverThreads: serverThreads)
+ wrappedInstanceKey: wrappedInstanceKey, serverThreads: serverThreads,
+ tierRows: tierRows)
}.value
instanceKeyB64 = folded.instanceKeyB64
+ tierKeysB64 = folded.tierKeysB64
+ tiers = tierRows
+ // A level we belong to whose key hasn't reached this device: prod the
+ // holders (keyless), mirroring the web's loadTiers auto-nudge.
+ for row in tierRows where (row.mine ?? false)
+ && (row.wrapped ?? "").isEmpty && !(row.have ?? []).isEmpty {
+ let id = row.id
+ Task { await api.nudgeTier(id) }
+ }
roster = folded.people
rosterLabels = folded.rosterLabels
// Hand the fresh names to the native feed models (live and parked) β€”
@@ -132,6 +145,7 @@ public final class ThreadStore: ObservableObject {
// Everything refresh() publishes, computed off the main actor.
private struct Folded {
var instanceKeyB64: String?
+ var tierKeysB64: [String: String] // level id -> unwrapped key (ours)
var people: [RosterEntry]
var rosterLabels: [String: String]
var rows: [ChatThread]
@@ -143,7 +157,8 @@ public final class ThreadStore: ObservableObject {
nonisolated private static func fold(instance: SavedInstance, myUserId: String,
peopleIn: [RosterEntry],
wrappedInstanceKey: String?,
- serverThreads: [ServerThread]) -> Folded {
+ serverThreads: [ServerThread],
+ tierRows: [TierRow] = []) -> Folded {
var people = peopleIn
var instanceKeyB64: String?
var instanceGroupKey: GroupKey?
@@ -152,6 +167,17 @@ public final class ThreadStore: ObservableObject {
instanceKeyB64 = raw.base64urlEncodedString()
instanceGroupKey = try? GroupKey(rawKey: raw)
}
+ // Our access-level keys: the same ECIES wrap as the instance key, one
+ // per level we hold β€” they unwrap gated groups' open_keys below.
+ var tierKeysB64: [String: String] = [:]
+ var tierGroupKeys: [String: GroupKey] = [:]
+ for row in tierRows {
+ guard let w = row.wrapped, !w.isEmpty,
+ let raw = try? ECIES.unwrap(wrappedB64: w, privPkcs8B64: instance.userPriv)
+ else { continue }
+ tierKeysB64[row.id] = raw.base64urlEncodedString()
+ tierGroupKeys[row.id] = try? GroupKey(rawKey: raw)
+ }
// Screen names arrive encrypted under the instance key (label_blob); the
// server blanks the plaintext `label` once a name is set that way, so
@@ -196,9 +222,14 @@ public final class ThreadStore: ObservableObject {
}
// A true fallback, not an else: a bad wrapped blob must not hide
// an open group the instance key can decrypt.
- if rawKey == nil, let openKey = t.open_key, !openKey.isEmpty, let ik = instanceGroupKey,
- let payload = try? ik.decryptJSON(OpenKeyPayload.self, blob: openKey) {
- rawKey = Data(base64urlEncoded: payload.k)
+ if rawKey == nil, let openKey = t.open_key, !openKey.isEmpty {
+ // The wrapping key follows the gate: a level-gated group's
+ // open_key rides under that tier's key, an ungated one under
+ // the instance key (mirrors loadThreads in shell.js).
+ let src = (t.open_tier?.isEmpty == false) ? tierGroupKeys[t.open_tier!] : instanceGroupKey
+ if let src, let payload = try? src.decryptJSON(OpenKeyPayload.self, blob: openKey) {
+ rawKey = Data(base64urlEncoded: payload.k)
+ }
}
guard let raw = rawKey else { continue }
let key = try? GroupKey(rawKey: raw)
@@ -345,7 +376,7 @@ public final class ThreadStore: ObservableObject {
// Closed (hidden) chats stay in the array β€” ThreadListView drops them from
// the default view but reveals them in search, so they can be reopened.
rows.sort(by: order)
- return Folded(instanceKeyB64: instanceKeyB64, people: people,
+ return Folded(instanceKeyB64: instanceKeyB64, tierKeysB64: tierKeysB64, people: people,
rosterLabels: rosterLabels, rows: rows, readMarks: readMarks)
}
@@ -459,9 +490,44 @@ public final class ThreadStore: ObservableObject {
guard let ikB64 = instanceKeyB64, let ik = try? GroupKey(base64url: ikB64) else { return nil }
var out: [DiscoverGroup] = []
for row in rows {
- guard let payload = try? ik.decryptJSON(OpenKeyPayload.self, blob: row.open_key),
+ // Three states, mirroring the web's showDiscover: joinable (we can
+ // decrypt), locked to another access level (the name STAYS sealed β€”
+ // that's the feature, an honest lock), or ours with the level key
+ // still on its way to this device.
+ let gate = row.open_tier ?? ""
+ let tierName = row.tier_name ?? ""
+ let joined = row.joined ?? false
+ // Joined always wins: membership IS access, so a member of a
+ // gated group they've since left the level of still gets Open.
+ if !joined && !gate.isEmpty && !(row.eligible ?? false) {
+ out.append(DiscoverGroup(id: row.id,
+ title: tierName.isEmpty ? "A limited group" : "A group for \(tierName)",
+ memberCount: row.member_count ?? 0,
+ joined: false, defaultJoin: false,
+ locked: true, tierName: tierName))
+ continue
+ }
+ let srcB64 = gate.isEmpty ? ikB64 : tierKeysB64[gate]
+ guard let srcB64, let src = try? GroupKey(base64url: srcB64) else {
+ out.append(DiscoverGroup(id: row.id,
+ title: tierName.isEmpty ? "Open group" : "A \(tierName) group",
+ memberCount: row.member_count ?? 0,
+ joined: joined, defaultJoin: false,
+ keyPending: !joined, tierName: tierName))
+ continue
+ }
+ guard let payload = try? src.decryptJSON(OpenKeyPayload.self, blob: row.open_key),
let raw = Data(base64urlEncoded: payload.k),
- let key = try? GroupKey(rawKey: raw) else { continue }
+ let key = try? GroupKey(rawKey: raw) else {
+ if joined {
+ // Undecryptable but ours: keep the row navigable.
+ out.append(DiscoverGroup(id: row.id,
+ title: tierName.isEmpty ? "Open group" : "A group for \(tierName)",
+ memberCount: row.member_count ?? 0,
+ joined: true, defaultJoin: false, tierName: tierName))
+ }
+ continue
+ }
var title = "Open group"
if let nb = row.name_blob, !nb.isEmpty,
let n = (try? key.decryptJSON(GroupNamePayload.self, blob: nb))?.name, !n.isEmpty {
@@ -470,7 +536,8 @@ public final class ThreadStore: ObservableObject {
out.append(DiscoverGroup(id: row.id, title: title,
memberCount: row.member_count ?? 0,
joined: row.joined ?? false,
- defaultJoin: row.default_join ?? false))
+ defaultJoin: row.default_join ?? false,
+ tierName: tierName))
}
return out
}
diff --git a/main.go b/main.go
index 57e88ae..7029639 100644
--- a/main.go
+++ b/main.go
@@ -367,6 +367,14 @@ func (s *Server) handler() http.Handler {
mux.HandleFunc("GET /api/instance-key", s.getInstanceKey)
mux.HandleFunc("POST /api/instance-key", s.postInstanceKey)
mux.HandleFunc("POST /api/instance-key/nudge", s.nudgeInstanceKey)
+ // Tiers: admin-curated member sets with their own keys (docs/community-and-tiers.md).
+ mux.HandleFunc("GET /api/tiers", s.getTiers)
+ mux.HandleFunc("POST /api/tiers", s.postTier)
+ mux.HandleFunc("DELETE /api/tiers/{id}", s.deleteTierHandler)
+ mux.HandleFunc("POST /api/tiers/{id}/members", s.postTierMember)
+ 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/label", s.postLabel)
mux.HandleFunc("POST /api/presence", s.postPresence)
mux.HandleFunc("POST /api/avatar", s.postAvatar)
@@ -1002,6 +1010,7 @@ func (s *Server) postThread(w http.ResponseWriter, r *http.Request) {
Members []ThreadMemberKey `json:"members"`
Visibility string `json:"visibility"`
OpenKey string `json:"open_key"`
+ OpenTier string `json:"open_tier"`
}
if json.NewDecoder(r.Body).Decode(&body) != nil {
httpError(w, http.StatusBadRequest, "bad request")
@@ -1042,6 +1051,13 @@ func (s *Server) postThread(w http.ResponseWriter, r *http.Request) {
httpError(w, http.StatusBadRequest, "bad open key")
return
}
+ // A level gate rides only on open groups, and only a member of that level
+ // can set it β€” they wrapped open_key under the level's key, which only
+ // members hold (the server can verify membership, not the wrap itself).
+ if body.OpenTier != "" && (!open || !s.db.tierExists(body.OpenTier) || !s.db.tierMember(body.OpenTier, uid)) {
+ httpError(w, http.StatusBadRequest, "bad access level")
+ return
+ }
inSet := false
for _, m := range body.Members {
// An open group's members may omit their per-member wrap β€” the key
@@ -1069,7 +1085,7 @@ func (s *Server) postThread(w http.ResponseWriter, r *http.Request) {
return
}
}
- id, err := s.db.createThreadVis(body.Kind, body.NameBlob, body.Members, body.Visibility, body.OpenKey)
+ id, err := s.db.createThreadVis(body.Kind, body.NameBlob, body.Members, body.Visibility, body.OpenKey, body.OpenTier)
if err != nil {
httpError(w, http.StatusInternalServerError, "db error")
return
@@ -1160,6 +1176,7 @@ func (s *Server) postThreadVisibility(w http.ResponseWriter, r *http.Request) {
Token string `json:"token"`
Visibility string `json:"visibility"`
OpenKey string `json:"open_key"`
+ OpenTier string `json:"open_tier"`
}
if json.NewDecoder(r.Body).Decode(&body) != nil {
httpError(w, http.StatusBadRequest, "bad request")
@@ -1188,11 +1205,31 @@ func (s *Server) postThreadVisibility(w http.ResponseWriter, r *http.Request) {
httpError(w, http.StatusBadRequest, "bad visibility")
return
}
- if (open && (body.OpenKey == "" || len(body.OpenKey) > 4096)) || (!open && body.OpenKey != "") {
+ hasKey, curTier := s.db.threadOpenInfo(threadID)
+ // First open requires the key blob; a REOPEN must not (the stored
+ // open_key survives closing and is never overwritten) β€” otherwise only
+ // wrap-key holders could reopen, while any member could close.
+ if (open && !hasKey && (body.OpenKey == "" || len(body.OpenKey) > 4096)) ||
+ len(body.OpenKey) > 4096 || (!open && body.OpenKey != "") {
httpError(w, http.StatusBadRequest, "bad open key")
return
}
- if err := s.db.setThreadVisibility(threadID, body.Visibility, body.OpenKey); err != nil {
+ // A NEW gate needs its setter to be in the level (they wrapped open_key
+ // under the level's key, which only members hold). Re-naming the stored
+ // gate on reopen moves no key material, so it needs no such check.
+ if body.OpenTier != "" && body.OpenTier != curTier &&
+ (!open || !s.db.tierExists(body.OpenTier) || !s.db.tierMember(body.OpenTier, uid)) {
+ httpError(w, http.StatusBadRequest, "bad access level")
+ return
+ }
+ // The gate is fixed once an open_key exists (changing it would need the
+ // rekey the never-overwrite rule forbids) β€” refuse loudly rather than
+ // letting a caller believe the gate moved.
+ if open && hasKey && body.OpenTier != curTier {
+ httpError(w, http.StatusBadRequest, "access level is fixed once a group has opened")
+ return
+ }
+ if err := s.db.setThreadVisibility(threadID, body.Visibility, body.OpenKey, body.OpenTier); err != nil {
httpError(w, http.StatusInternalServerError, "db error")
return
}
@@ -1231,6 +1268,12 @@ func (s *Server) postThreadDefault(w http.ResponseWriter, r *http.Request) {
httpError(w, http.StatusBadRequest, "only open groups can be default")
return
}
+ // The default sweep deliberately skips level-gated groups (a newcomer
+ // holds no level yet) β€” refuse the flag rather than save-and-ignore it.
+ if _, gate := s.db.threadOpenInfo(threadID); body.Default && gate != "" {
+ httpError(w, http.StatusBadRequest, "limited groups can't be default")
+ return
+ }
if err := s.db.setThreadDefault(threadID, body.Default); err != nil {
httpError(w, http.StatusInternalServerError, "db error")
return
@@ -1415,6 +1458,291 @@ func (s *Server) nudgeInstanceKey(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
+// ---- 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
+// key, the server stores blobs it can't open, grants never overwrite, and WS
+// prods are keyless hints. Management (create/assign/remove/delete) is
+// admin-ROLE-gated β€” tiers are space policy, like default groups.
+
+// tierAdmin resolves the caller and requires the admin role. Returns ("",
+// false) after writing the error when the gate fails.
+func (s *Server) tierAdmin(w http.ResponseWriter, token string) (string, bool) {
+ if !s.tokenOK(token) {
+ httpError(w, http.StatusForbidden, "not a member")
+ return "", false
+ }
+ uid, ok := s.db.deviceTokenUser(token)
+ if !ok || s.db.userRole(uid) != "admin" {
+ httpError(w, http.StatusForbidden, "admins only")
+ return "", false
+ }
+ return uid, true
+}
+
+// getTiers lists every tier for any member (names are plaintext space policy,
+// like the roster), personalised with the caller's membership + key state.
+func (s *Server) getTiers(w http.ResponseWriter, r *http.Request) {
+ token := r.URL.Query().Get("token")
+ if !s.tokenOK(token) {
+ httpError(w, http.StatusForbidden, "not a member")
+ return
+ }
+ uid, ok := s.db.deviceTokenUser(token)
+ if !ok {
+ writeJSON(w, http.StatusOK, map[string]any{"tiers": []Tier{}})
+ return
+ }
+ ts, err := s.db.listTiers(uid)
+ if err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"tiers": ts})
+}
+
+func (s *Server) postTier(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Token string `json:"token"`
+ Name string `json:"name"`
+ }
+ if json.NewDecoder(r.Body).Decode(&body) != nil {
+ httpError(w, http.StatusBadRequest, "bad request")
+ return
+ }
+ if _, ok := s.tierAdmin(w, body.Token); !ok {
+ return
+ }
+ name := strings.TrimSpace(body.Name)
+ if name == "" || utf8.RuneCountInString(name) > 60 {
+ httpError(w, http.StatusBadRequest, "bad name")
+ return
+ }
+ id, err := s.db.createTier(name)
+ if err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"id": id})
+}
+
+func (s *Server) deleteTierHandler(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Token string `json:"token"`
+ }
+ if json.NewDecoder(r.Body).Decode(&body) != nil {
+ httpError(w, http.StatusBadRequest, "bad request")
+ return
+ }
+ if _, ok := s.tierAdmin(w, body.Token); !ok {
+ return
+ }
+ id := r.PathValue("id")
+ // A level that still gates groups can't be deleted: those groups' keys
+ // are wrapped under it forever, and for self-serve joiners that wrap is
+ // their only key source (see deleteTier's comment).
+ if n := s.db.tierGatesThreads(id); n > 0 {
+ httpError(w, http.StatusConflict, "this level still limits groups")
+ return
+ }
+ if err := s.db.deleteTier(id); err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
+// postTierMember assigns a member to a tier ('admin' source). The prod fans
+// out two keyless hints: holders learn someone needs the key wrapped to them
+// (so an online holder acts immediately, no reload), and the assignee's own
+// tabs learn their tiers changed.
+func (s *Server) postTierMember(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Token string `json:"token"`
+ UserID string `json:"user_id"`
+ }
+ if json.NewDecoder(r.Body).Decode(&body) != nil {
+ httpError(w, http.StatusBadRequest, "bad request")
+ return
+ }
+ if _, ok := s.tierAdmin(w, body.Token); !ok {
+ return
+ }
+ tierID := r.PathValue("id")
+ if !s.db.tierExists(tierID) || !s.db.userExists(body.UserID) {
+ httpError(w, http.StatusBadRequest, "bad tier or member")
+ return
+ }
+ if err := s.db.assignTier(tierID, body.UserID, "admin"); err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ s.prodTierHolders(tierID, body.UserID)
+ s.prodTiersChanged(body.UserID)
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
+func (s *Server) postTierRemove(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Token string `json:"token"`
+ UserID string `json:"user_id"`
+ }
+ 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 err := s.db.unassignTier(r.PathValue("id"), body.UserID); err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ s.prodTiersChanged(body.UserID)
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
+// postTierKeys stores wrapped tier-key copies. Two callers are legitimate:
+// the MINT moment (no copies exist yet β€” admin-only, their client just
+// generated the key and wrapped it to the initial members), and a HOLDER
+// wrapping to members who joined since. Grants only land on tier members,
+// and never overwrite (grantTierKey).
+func (s *Server) postTierKeys(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Token string `json:"token"`
+ Grants []ThreadMemberKey `json:"grants"`
+ }
+ 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
+ }
+ tierID := r.PathValue("id")
+ if !s.db.tierExists(tierID) {
+ httpError(w, http.StatusBadRequest, "no such tier")
+ return
+ }
+ valid := []ThreadMemberKey{}
+ granted := map[string]bool{}
+ for _, g := range body.Grants {
+ if g.UserID == "" || g.Wrapped == "" || len(g.Wrapped) > 4096 || !s.db.tierMember(tierID, g.UserID) {
+ continue
+ }
+ valid = append(valid, g)
+ granted[g.UserID] = true
+ }
+ if !s.db.tierKeyMinted(tierID) {
+ // The mint moment β€” admin-only, one-way, transactional: concurrent
+ // minters serialize on key_minted, exactly one key ever enters a
+ // level. The loser gets 409 and must reload (never install its key).
+ if s.db.userRole(uid) != "admin" {
+ httpError(w, http.StatusForbidden, "admins only")
+ return
+ }
+ if err := s.db.mintTierKeys(tierID, valid); err != nil {
+ if errors.Is(err, errAlreadyMinted) {
+ httpError(w, http.StatusConflict, "already minted")
+ return
+ }
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ } else if s.db.tierKeyCount(tierID) == 0 {
+ // Minted once, but every holder is gone β€” the key is unrecoverable.
+ // Refuse loudly instead of silently minting a second key that would
+ // seal every group gated under the first: the honest path is to
+ // delete this level and create a fresh one.
+ httpError(w, http.StatusGone, "this level's key is lost β€” delete the level and create a new one")
+ return
+ } else {
+ if !s.db.tierHolder(tierID, uid) {
+ httpError(w, http.StatusForbidden, "not a key holder")
+ return
+ }
+ for _, g := range valid {
+ if err := s.db.grantTierKey(tierID, g.UserID, g.Wrapped); err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ }
+ }
+ if len(granted) > 0 {
+ out, _ := json.Marshal(map[string]any{"type": "tier_key_granted", "tier_id": tierID})
+ s.hub.broadcast <- outbound{members: granted, data: out}
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
+// nudgeTierKey: a tier member with no key copy asks for one. Unlike the
+// instance-key nudge (everyone), this prods exactly the members who can help β€”
+// current holders. Keyless, like every prod.
+func (s *Server) nudgeTierKey(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Token string `json:"token"`
+ }
+ 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
+ }
+ tierID := r.PathValue("id")
+ if !s.db.tierMember(tierID, uid) {
+ httpError(w, http.StatusForbidden, "not in this tier")
+ return
+ }
+ s.prodTierHolders(tierID, uid)
+ 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).
+func (s *Server) prodTierHolders(tierID, userID string) {
+ holders, err := s.db.tierHolderIDs(tierID)
+ if err != nil || len(holders) == 0 {
+ return
+ }
+ pub := s.db.userPublicKey(userID)
+ if pub == "" {
+ return
+ }
+ set := map[string]bool{}
+ for _, h := range holders {
+ if h != userID {
+ set[h] = true
+ }
+ }
+ if len(set) == 0 {
+ return
+ }
+ out, _ := json.Marshal(map[string]any{
+ "type": "tier_key_nudge", "tier_id": tierID, "user_id": userID, "public_key": pub,
+ })
+ s.hub.broadcast <- outbound{members: set, data: out}
+}
+
+// prodTiersChanged tells one member's own tabs their tier set changed.
+func (s *Server) prodTiersChanged(userID string) {
+ out, _ := json.Marshal(map[string]any{"type": "tiers_changed"})
+ s.hub.broadcast <- outbound{members: map[string]bool{userID: true}, data: out}
+}
+
// postLabel sets the caller's own display name, encrypted under the instance key
// (a server-blind blob). This is how a person publishes their name β€” the server
// never sees the plaintext. Broadcasts so every client refreshes the roster name.
diff --git a/web/static/apps/admin.js b/web/static/apps/admin.js
index cb9fe8a..510ec0a 100644
--- a/web/static/apps/admin.js
+++ b/web/static/apps/admin.js
@@ -369,6 +369,171 @@ export const app = defineApp({
}
};
+ // ---- Access levels ---------------------------------------------------------
+ // Tiers server-side: named member sets, each with its own key held only by
+ // 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.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.";
+ levelsSec.appendChild(levelsDesc);
+ const levelsList = document.createElement("div");
+ levelsSec.appendChild(levelsList);
+ const levelAddRow = document.createElement("div");
+ levelAddRow.className = "admin-row";
+ const levelName = document.createElement("input");
+ levelName.type = "text";
+ levelName.maxLength = 60;
+ levelName.placeholder = "New level name (e.g. Coaches)";
+ levelName.setAttribute("aria-label", "New access level name");
+ const levelAddBtn = document.createElement("button");
+ levelAddBtn.textContent = "Add level";
+ 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;
+ }
+ const labelOf = (uid) => {
+ const e = people.find((p) => p.user_id === uid);
+ return e ? e.label : "";
+ };
+ 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);
+ }
+ // 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);
+ }
+
+ 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);
+ };
+ 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();
+ };
+ levelsList.appendChild(box);
+ }
+ };
+ 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) {
+ flash(levelsStatus, "Couldn't create β€” try again.");
+ return;
+ }
+ levelName.value = "";
+ flash(levelsStatus, `"${name}" created β€” now add its members.`);
+ await paintLevels();
+ };
+ paintLevels();
+
// ---- Updates ---------------------------------------------------------------
const updSec = section("Updates");
const updDesc = document.createElement("p");
diff --git a/web/static/shell.js b/web/static/shell.js
index 7dd1ca1..550bf0b 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -410,6 +410,11 @@ let attachSeq = 0; // local id counter for staged attachments (drives per-chip r
const blobURLs = {}; // blob id -> decrypted object URL (cache)
let instanceKey = null; // shared key for all-members content (profile photos)
let instanceKeyB64 = null;
+// Tiers β€” "access levels" in every UI surface: admin-curated member sets,
+// each with its own key on the instance-key pattern (docs/community-and-tiers.md).
+let tiers = []; // /api/tiers rows, personalised to this user
+const tierKeys = {}; // tier id -> CryptoKey
+const tierKeyB64 = {}; // tier id -> raw base64url (kept to wrap for others)
const avatarURLs = {}; // user id -> decrypted profile-photo object URL (cache)
// The fixed reaction set (mirrors allowedReactions in hub.go).
@@ -725,9 +730,10 @@ async function boot() {
if (document.getElementById("thread-list")) refreshNamesUI();
});
await Promise.all([setupInstanceKey(), loadThreads(), Promise.race([nickLoad, new Promise((res) => setTimeout(res, 3000))])]);
- // The two loads race: when the thread list landed first, any open group
- // (key under the instance key) was skipped β€” fold it in now the key's here.
- if (instanceKey && threads.some((t) => t.open_key && !threadKeys[t.id])) await loadThreads();
+ await loadTiers(); // before the re-fold: gated groups unwrap via tier keys
+ // The loads race: when the thread list landed first, any open group (key
+ // under the instance key or a tier key) was skipped β€” fold it in now.
+ if (threads.some((t) => t.open_key && !threadKeys[t.id])) await loadThreads();
// Member-built apps are sealed under the instance key, so they can only
// register now (a late-arriving key retries from onInstanceKeyReady).
// Before renderApp + connectWS on purpose: their threads and live events
@@ -1409,6 +1415,110 @@ async function pollForInstanceKey(tries = 24) {
}
}
+// Fetch the access levels and unwrap our tier keys (each an instance-key-style
+// wrapped blob). A level we belong to but hold no key for yet gets a targeted
+// nudge β€” the server prods current holders, one of whom wraps it to us.
+async function loadTiers() {
+ let rows;
+ try {
+ rows = (await (await fetch(`/api/tiers?token=${encodeURIComponent(session.token)}`)).json()).tiers || [];
+ } catch {
+ return; // offline β€” the next boot or WS prod retries
+ }
+ tiers = rows;
+ for (const t of tiers) {
+ if (!tierKeys[t.id] && t.wrapped) {
+ try {
+ const b64 = await unwrapThreadKey(t.wrapped, session.userPriv);
+ tierKeys[t.id] = await importKey(b64);
+ tierKeyB64[t.id] = b64;
+ } catch (e) {
+ console.error("unwrap tier", t.id, e);
+ }
+ }
+ if (t.mine && !t.wrapped && (t.have || []).length) {
+ fetch(`/api/tiers/${encodeURIComponent(t.id)}/nudge`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: session.token }),
+ }).catch(() => {});
+ }
+ // Holder-side self-healing (grantInstanceKeyTo's rule, per level): wrap
+ // our copy to any member still missing theirs β€” no nudge needed, so key
+ // delivery survives the one-shot prod being missed.
+ if (tierKeyB64[t.id]) {
+ const have = new Set(t.have || []);
+ const grants = [];
+ for (const uid of (t.members || []).filter((u) => !have.has(u))) {
+ const e = roster.find((x) => x.user_id === uid && x.public_key);
+ if (e) grants.push({ user_id: uid, wrapped: await wrapThreadKey(tierKeyB64[t.id], e.public_key) });
+ }
+ if (grants.length) {
+ fetch(`/api/tiers/${encodeURIComponent(t.id)}/keys`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: session.token, grants }),
+ }).catch(() => {});
+ }
+ }
+ }
+}
+
+// Access levels re-sync on focus (throttled): the instance key polls on a
+// timer; levels get the cheaper version β€” returning to the tab retries key
+// delivery both ways (our nudge, our holder backfill).
+let tiersFocusSyncAt = 0;
+window.addEventListener("focus", () => {
+ if (!session || Date.now() - tiersFocusSyncAt < 60000) return;
+ tiersFocusSyncAt = Date.now();
+ loadTiers();
+});
+
+// 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
+// member of the level DISCARDS the raw key after wrapping: curating a level
+// must not silently grant the curator read access to what it will gate.
+async function ensureTierKeys(id) {
+ await loadTiers();
+ const level = tiers.find((t) => t.id === id);
+ if (!level) return;
+ const have = new Set(level.have || []);
+ const need = (level.members || []).filter((uid) => !have.has(uid));
+ if (!need.length) return;
+ let b64 = tierKeyB64[id];
+ let minting = false;
+ if (!b64) {
+ if (have.size) return; // holders exist β€” the server's prod asks them instead
+ minting = true;
+ b64 = (await generateKey()).b64;
+ }
+ const grants = [];
+ for (const uid of need) {
+ const e = roster.find((x) => x.user_id === uid && x.public_key);
+ if (e) grants.push({ user_id: uid, wrapped: await wrapThreadKey(b64, e.public_key) });
+ }
+ if (!grants.length) return; // a key that reached nobody must not be installed
+ const r = await fetch(`/api/tiers/${encodeURIComponent(id)}/keys`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: session.token, grants }),
+ });
+ if (!r.ok) {
+ // Lost a mint race (409), the level's key is gone (410), or auth/server
+ // trouble β€” NEVER install a key the server rejected: a locally-kept
+ // loser key would gate groups nobody else can open. Reload and let the
+ // holder/nudge machinery take over from the server's truth.
+ await loadTiers();
+ return;
+ }
+ if (minting && (level.members || []).includes(myUserId)) {
+ tierKeyB64[id] = b64;
+ tierKeys[id] = await importKey(b64);
+ }
+ // minting && not a member β†’ b64 goes out of scope right here, on purpose.
+}
+
// Flip the open Edit-profile sheet's photo control from "Photo syncing…" to
// "Change photo" the moment the instance key arrives (live, no reload needed).
function refreshProfilePhotoBtn() {
@@ -1509,11 +1619,18 @@ async function loadThreads() {
console.error("unwrap thread", t.id, e);
}
}
- if (!b64 && t.open_key && instanceKey) {
- try {
- b64 = (await decrypt(instanceKey, t.open_key)).k;
- } catch (e) {
- console.error("unwrap open thread", t.id, e);
+ if (!b64 && t.open_key) {
+ // The open_key's wrapping key depends on the gate: a level-gated
+ // group's rides under that tier's key, an ungated one under the
+ // instance key. Either may not have arrived yet β€” boot and the
+ // key-granted prods re-fold when it does.
+ const src = t.open_tier ? tierKeys[t.open_tier] : instanceKey;
+ if (src) {
+ try {
+ b64 = (await decrypt(src, t.open_key)).k;
+ } catch (e) {
+ console.error("unwrap open thread", t.id, e);
+ }
}
}
if (!b64) continue;
@@ -3977,6 +4094,74 @@ setSetupBridge({
return false;
}
},
+ // ---- Access levels (tiers) β€” the Admin app's management surface ---------
+ // All crypto stays here in the shell: the app sees plain rows and verbs.
+ tiers: async () => {
+ await loadTiers();
+ return tiers;
+ },
+ // Joined people for the level editor (agents excluded on purpose: adding a
+ // 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 })),
+ createTier: async (name) => {
+ try {
+ const r = await fetch("/api/tiers", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: session.token, name }),
+ });
+ if (!r.ok) return null;
+ return (await r.json()).id || null;
+ } catch {
+ return null;
+ }
+ },
+ // Returns the HTTP status (200 ok, 409 = still gating groups) so the Admin
+ // app can say WHY a delete was refused instead of a generic failure.
+ deleteTier: async (id) => {
+ try {
+ const r = await fetch(`/api/tiers/${encodeURIComponent(id)}`, {
+ method: "DELETE",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: session.token }),
+ });
+ return r.status;
+ } catch {
+ return 0;
+ }
+ },
+ // 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).
+ setTierMembers: async (id, memberIds) => {
+ try {
+ await loadTiers();
+ const level = tiers.find((t) => t.id === id);
+ if (!level) return false;
+ const want = new Set(memberIds);
+ const post = (path, body) =>
+ fetch(path, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: session.token, ...body }),
+ });
+ for (const uid of level.members || []) {
+ if (!want.has(uid)) await post(`/api/tiers/${encodeURIComponent(id)}/remove`, { user_id: uid });
+ }
+ for (const uid of want) {
+ if (!(level.members || []).includes(uid)) {
+ await post(`/api/tiers/${encodeURIComponent(id)}/members`, { user_id: uid });
+ }
+ }
+ await ensureTierKeys(id);
+ await loadTiers();
+ return true;
+ } catch {
+ return false;
+ }
+ },
// Create an admin-granting invite. The claimer can either join as a new
// member or claim it on their existing account to get promoted to admin.
// Returns {link} or null on failure.
@@ -5569,6 +5754,39 @@ function connectWS() {
}
return;
}
+ if (msg.type === "tier_key_nudge") {
+ // A member of an access level we hold the key for needs their copy. Same
+ // rule as the instance-key nudge: never wrap to a key from a raw frame β€”
+ // refresh, then wrap only to the PUBLISHED key of a current level member.
+ const b64 = tierKeyB64[msg.tier_id];
+ if (b64 && msg.user_id) {
+ try {
+ await Promise.all([loadRoster(), loadTiers()]);
+ const target = roster.find((e) => e.user_id === msg.user_id && e.public_key);
+ const level = tiers.find((t) => t.id === msg.tier_id);
+ if (target && level && (level.members || []).includes(target.user_id)) {
+ const wrapped = await wrapThreadKey(b64, target.public_key);
+ await fetch(`/api/tiers/${encodeURIComponent(msg.tier_id)}/keys`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: session.token, grants: [{ user_id: target.user_id, wrapped }] }),
+ });
+ }
+ } catch {}
+ }
+ return;
+ }
+ if (msg.type === "tier_key_granted" || msg.type === "tiers_changed") {
+ // Our level set or key state changed β€” refetch (loadTiers unwraps the
+ // new copy, or nudges holders if we were just assigned without one).
+ await loadTiers();
+ // A gated group may have been skipped while its level key was missing.
+ if (threads.some((t) => t.open_key && !threadKeys[t.id])) {
+ await loadThreads();
+ renderThreadList();
+ }
+ return;
+ }
if (msg.type === "label") {
const e = roster.find((x) => x.user_id === msg.user_id);
if (e) {
@@ -6091,7 +6309,11 @@ async function editGroup() {
// only offered when this device holds both keys it needs to mint the blob.
let openCb = null;
let defaultCb = null;
- if (instanceKey && threadKeyB64[id]) {
+ let gateSel = null;
+ // First open needs wrap keys on this device; a reopen needs none (the
+ // server kept the blob) β€” so an already-opened group offers the toggle to
+ // any member.
+ if ((instanceKey && threadKeyB64[id]) || t.open_key) {
const optRow = (label, descText) => {
const row = document.createElement("label");
row.className = "opt-row";
@@ -6110,10 +6332,45 @@ async function editGroup() {
};
openCb = optRow("Open group", "Anyone in this space can find it under Discover and join.");
openCb.checked = t.visibility === "open";
+ // The level gate: pickable until the group first opens (the open_key is
+ // wrapped under the chosen key), then fixed β€” server-enforced, so the UI
+ // states it rather than offering a dead control.
+ if (t.open_key) {
+ if (t.open_tier) {
+ const lvl = tiers.find((x) => x.id === t.open_tier);
+ const note = document.createElement("span");
+ note.className = "opt-desc";
+ note.style.margin = "0 0 6px 30px";
+ note.textContent = `Only ${lvl ? lvl.name : "one access level"} can find and join β€” fixed once a group has opened.`;
+ card.appendChild(note);
+ }
+ } else {
+ const myLevels = tiers.filter((x) => x.mine && tierKeyB64[x.id]);
+ if (myLevels.length) {
+ gateSel = document.createElement("select");
+ gateSel.className = "sheet-input";
+ gateSel.setAttribute("aria-label", "Who can find and join");
+ const all = document.createElement("option");
+ all.value = "";
+ all.textContent = "Everyone in this space";
+ gateSel.appendChild(all);
+ for (const x of myLevels) {
+ const o = document.createElement("option");
+ o.value = x.id;
+ o.textContent = `Only ${x.name}`;
+ gateSel.appendChild(o);
+ }
+ gateSel.style.display = openCb.checked ? "" : "none";
+ openCb.addEventListener("change", () => {
+ gateSel.style.display = openCb.checked ? "" : "none";
+ });
+ card.appendChild(gateSel);
+ }
+ }
// Default groups are space policy (who lands where on arrival), so the
// choice is admin-only β€” and only meaningful while the group is open.
const meEntry = roster.find((e) => e.user_id === myUserId);
- if (meEntry && meEntry.role === "admin") {
+ if (meEntry && meEntry.role === "admin" && !t.open_tier) {
defaultCb = optRow(
"New members join automatically",
"Everyone who joins the space starts in this group. Nobody already here is added.",
@@ -6159,7 +6416,20 @@ async function editGroup() {
}
if (flipOpen) {
const body = { token: session.token, visibility: openCb.checked ? "open" : "invite" };
- if (openCb.checked) body.open_key = await encrypt(instanceKey, { k: threadKeyB64[id] });
+ if (openCb.checked) {
+ // First open: wrap under the picked gate's key (or the instance
+ // key for everyone). Reopen: send NO key β€” the server kept the
+ // original open_key (never overwritten), it only wants the
+ // original gate named again. That keeps close/reopen symmetric:
+ // any member can do either, wrap keys not required.
+ const gate = t.open_key ? t.open_tier || "" : (gateSel && gateSel.value) || "";
+ if (!t.open_key) {
+ const src = gate ? tierKeys[gate] : instanceKey;
+ if (!src) throw new Error("no wrapping key");
+ body.open_key = await encrypt(src, { k: threadKeyB64[id] });
+ }
+ if (gate) body.open_tier = gate;
+ }
const r = await fetch(`/api/threads/${encodeURIComponent(id)}/visibility`, {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -6167,6 +6437,8 @@ async function editGroup() {
});
if (!r.ok) throw new Error("server");
t.visibility = openCb.checked ? "open" : "";
+ if (openCb.checked && !t.open_tier && gateSel && gateSel.value) t.open_tier = gateSel.value;
+ if (openCb.checked && !t.open_key) t.open_key = "set"; // gate now fixed; the real blob arrives on the next loadThreads
}
if (flipDefault) {
// After the visibility flip on purpose: making a group open AND
@@ -7030,7 +7302,7 @@ const disc = document.createElement("button");
return;
}
- let nameInput, openCb;
+ let nameInput, openCb, tierSel;
if (mode === "group") {
nameInput = document.createElement("input");
nameInput.placeholder = "Group name (optional)";
@@ -7056,6 +7328,29 @@ const disc = document.createElement("button");
row.appendChild(openCb);
row.appendChild(txt);
card.appendChild(row);
+ // Gate the group to an access level β€” offered only for levels whose
+ // key this device holds (the open_key is wrapped under it).
+ const myLevels = tiers.filter((t) => t.mine && tierKeyB64[t.id]);
+ if (myLevels.length) {
+ tierSel = document.createElement("select");
+ tierSel.className = "sheet-input";
+ tierSel.setAttribute("aria-label", "Who can find and join");
+ const all = document.createElement("option");
+ all.value = "";
+ all.textContent = "Everyone in this space";
+ tierSel.appendChild(all);
+ for (const t of myLevels) {
+ const o = document.createElement("option");
+ o.value = t.id;
+ o.textContent = `Only ${t.name}`;
+ tierSel.appendChild(o);
+ }
+ tierSel.style.display = openCb.checked ? "" : "none";
+ openCb.addEventListener("change", () => {
+ tierSel.style.display = openCb.checked ? "" : "none";
+ });
+ card.appendChild(tierSel);
+ }
}
}
@@ -7159,6 +7454,7 @@ const disc = document.createElement("button");
group: true,
name: nameInput.value.trim(),
open: !!(openCb && openCb.checked),
+ openTier: (openCb && openCb.checked && tierSel && tierSel.value) || "",
}),
create,
);
@@ -7228,14 +7524,27 @@ async function showDiscover() {
const list = document.createElement("div");
list.className = "pick-list";
card.appendChild(list);
+ await loadTiers(); // gate keys + level names for the rows below
for (const row of rows) {
- let name = "Open group";
- try {
- const key = await importKey((await decrypt(instanceKey, row.open_key)).k);
- if (row.name_blob) name = (await decrypt(key, row.name_blob)).name || name;
- } catch (e) {
- console.error("discover row", row.id, e);
- continue; // an undecryptable row is unjoinable β€” don't offer it
+ // Row states: joined always wins (membership IS access β€” Open must work
+ // even if we've since left the gate level); then locked to another level
+ // (an honest lock β€” the name STAYS sealed, that's the feature); then
+ // ours-but-the-key-hasn't-arrived.
+ const joined = !!row.joined;
+ const locked = !joined && row.open_tier && !row.eligible;
+ const keyPending = !joined && !locked && row.open_tier && !tierKeys[row.open_tier];
+ let name = row.tier_name ? `A group for ${row.tier_name}` : "Open group";
+ if (!locked && !keyPending) {
+ const src = row.open_tier ? tierKeys[row.open_tier] : instanceKey;
+ if (src) {
+ try {
+ const key = await importKey((await decrypt(src, row.open_key)).k);
+ if (row.name_blob) name = (await decrypt(key, row.name_blob)).name || name;
+ } catch (e) {
+ console.error("discover row", row.id, e);
+ if (!joined) continue; // undecryptable AND unjoinable β€” don't offer it
+ }
+ }
}
const item = document.createElement("div");
item.className = "list-row static";
@@ -7247,12 +7556,30 @@ async function showDiscover() {
const sub = document.createElement("span");
sub.className = "helper-desc";
const count = row.member_count === 1 ? "1 member" : `${row.member_count} members`;
- sub.textContent = row.default_join ? `${count} Β· everyone starts here` : count;
+ if (joined && row.open_tier && !row.eligible) {
+ sub.textContent = `${count} Β· you're in, but no longer in ${row.tier_name || "its level"}`;
+ } else if (locked) {
+ sub.textContent = row.tier_name ? `${count} Β· for ${row.tier_name} only` : `${count} Β· limited access`;
+ } else if (keyPending) {
+ sub.textContent = `${count} Β· your key is on its way`;
+ } else {
+ sub.textContent = row.default_join ? `${count} Β· everyone starts here` : count;
+ }
info.appendChild(title);
info.appendChild(sub);
item.appendChild(info);
const btn = document.createElement("button");
btn.className = "pill-btn";
+ if (!joined && (locked || keyPending)) {
+ // No dead Join buttons: the state is the row's text (never color
+ // alone); a lock is information, not an error.
+ btn.classList.add("quiet");
+ btn.disabled = true;
+ btn.textContent = locked ? "Locked" : "Waiting";
+ item.appendChild(btn);
+ list.appendChild(item);
+ continue;
+ }
if (row.joined) {
// Already yours β€” the quiet variant keeps Join the sheet's one loud act.
btn.classList.add("quiet");
@@ -7375,12 +7702,15 @@ async function createThread(otherIds, opts = {}) {
// to its member names for display (see displayName), rather than "Group".
if (group && opts.name) body.name_blob = await encrypt(key, { name: opts.name });
// An open community group also ships its key encrypted under the instance
- // key, so any member can discover it and self-join (the server can open
- // neither blob). Picked members still get their normal wrapped copy above.
+ // key β€” or under an access level's key when gated (opts.openTier), so only
+ // that level can discover and join. The server can open neither blob.
+ // Picked members still get their normal wrapped copy above.
if (group && opts.open) {
- if (!instanceKey) throw new Error("no instance key yet");
+ const src = opts.openTier ? tierKeys[opts.openTier] : instanceKey;
+ if (!src) throw new Error("no wrapping key yet");
body.visibility = "open";
- body.open_key = await encrypt(instanceKey, { k: b64 });
+ body.open_key = await encrypt(src, { k: b64 });
+ if (opts.openTier) body.open_tier = opts.openTier;
}
const r = await (
await fetch("/api/threads", {