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>
11 files changed,
+1740
−53
changelog/2026-07-11-access-levels.md+5 −0changelog/2026-07-11-level-gated-groups.md+6 −0db.go+343 −16handlers_test.go+414 −0ios/LChatCore/Sources/LChatCore/DiscoverGroupsView.swift+9 −0ios/LChatCore/Sources/LChatCore/LChatAPI.swift+11 −0ios/LChatCore/Sources/LChatCore/Models.swift+26 −1ios/LChatCore/Sources/LChatCore/ThreadStore.swift+76 −9main.go+331 −3web/static/apps/admin.js+165 −0web/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", { |