Skip to content

Player redesign 8b-1: household members (server + console) - #127

Merged
KodeStar merged 22 commits into
mainfrom
feat/player-8b-1-members
Oct 14, 2026
Merged

KodeStar merged 22 commits into
mainfrom
feat/player-8b-1-members

Conversation

@KodeStar

@KodeStar KodeStar commented Oct 13, 2026 •

Copy link
Copy Markdown
Owner

Player redesign Phase 8b-1 (★): household members, server side and the admin console. Pairs with KodeStar/audiosilo-frontend#100, KodeStar/audiosilo-docs#130 and KodeStar/audiosilo#54 (plan + CROSS-REPO §22). Merge order: this -> frontend -> docs -> workspace. No release.

What it does

Any non-demo, non-member person can add kids and grown-ups as real accounts from the player, with no admin and no invite. A member is a user with managed_by (the owner), member_kind (kid/grownup) and member_access (all/collections).

  • Access (security-critical): a member reaches their own grants plus, for all, the owner's current access (an admin owner: every library as content scope, never the role), or for collections, the books in collections the owner owns and shares with them, each only while the owner can reach it. Read per request from the request's user (lookupToken -> callerOf -> CallerScope/CallerScopes/CallerLibraries); the by-id UserScope/UserScopes/AccessibleLibraries apply the same rule. Long path lists use a single-parameter, byte-safe filter (one bound parameter for 1,000 items).
  • Migration 0044: users.managed_by, member_kind, member_access, can_add_members, display_name; tokens.created_on_token_id (information only).
  • Routes (handlers_members.go): GET/POST /me/household/members, PATCH/DELETE /me/household/members/{id}, POST .../{id}/session, POST .../{id}/pair, GET .../{id}/devices, DELETE .../{id}/devices/{device_id}. API keys, demo and member callers refused; another owner's member is 404; 409 member_limit, 409 member_disabled.
  • Kids: no password, API key, recovery code, own pairing token, invite, house sharing or collection sharing: one notKid route wrapper plus refusals inside every minting write, collection shares and the admin's invite/rotate. Switching to kid clears all of these (sessions stay). Kids are visible in share targets only to their owner and never in Listening in the house.
  • Existing routes (additive): user.managed_by/member_kind (+ display_name for members) on /me and session envelopes; member_sign_ins and ?also_members=1 on My devices; capability household_members; admin user fields, PATCH (can_add_members, Assign to someone, Make independent; one transaction), DELETE ?keep_members=; settings household.members_enabled (default on) / household.max_members (default 6, 1-50); audit member.*.
  • Console: People grouped by household with chips; the person page's Household card, Assign dialog, member Access tab, delete dialog keep list; Settings > Household; no password, invite or rotate on a kid's page.

Tests

Every row of the plan's test table has allowed and denied tests on file-backed stores (TestMembers_* in internal/api/members_test.go, internal/auth/members_test.go, internal/catalog/members_test.go), plus TestPathFilterForms, TestCallerScopes_NonMemberReadsNoRule, race tests for the kid switch, a test that auth and catalog derive the same member rule, and BenchmarkMemberScopes (3,000 books: a 1,000-item kid browse 2.4 ms vs 0.26 ms for a whole-library listener; a non-member's scope load is cheaper than before).

Gates

go build, go vet, go test -race ./..., golangci-lint (0 issues), GOOS=windows go build, scripts/build-admin.sh (730 console tests). Manager: full Go gate passes against this branch through its replace; no manager change.

Quality passes

  • Real-browser pass (400/834/1440, both themes, German) and device pass (Pixel 6a Android 16, iPhone Air iOS 26.6.2) before the reviews; their findings fixed.
  • /simplify (4 reviewers): member rule carried on the request user, one kid wrapper, one UpdateUser transaction, set-based deletes, console cleanups.
  • /code-review max --fix: 15 confirmed bugs + 12 smaller, all fixed with regression tests (worst: a member moved to another owner kept the old owner's phone session with the new owner's access).
  • Later fixes: a racing password change answers 403 kid_account; collection shares refuse a kid inside the transaction; the admin's invite/rotate refuse a kid.

Deliberately not fixed (follow-ups)

  • The owner sees a grown-up member's device addresses and apps (as My devices shows them).
  • The derived username can show an owner that a hidden account exists.
  • No new-device alert for sessions an owner makes for a member.
  • The admin's delete can remove a member added after the page opened (needs a delete_members list).
  • No warning that an owner's role change changes what their members reach.
  • Invisible or right-to-left characters in names (same rule as collection names; change together).
  • The console's existing role label "Member" next to household members (left as is).
  • Member create/session/pair share the per-IP self-service limiter (10 per 15 quiet minutes), as the plan specified; a large household set up with retries can hit it.
  • /code-review ultra not run (optional).

…er rule

Migration 0044 adds users.managed_by/member_kind/member_access/can_add_members/
display_name and tokens.created_on_token_id. auth/members.go creates, lists,
changes and deletes an owner's members (derived usernames, the owner checks,
a kid's credentials cleared), signs them in on the owner's device, mints their
pairing token, and the admin's assign / make independent; DeleteUser takes a
keep list. catalog's UserScope/UserScopes/AccessibleLibraries add the member
rule (all: the owner's current scopes; collections: the owner's shared
collection items the owner still reaches), and long path lists bind as one
json_each parameter. Kids are hidden from share targets and house listening.
People chips, the person page's Household card and member access, the
delete dialog's member list, Settings > Household, the 409 refusals and
the member.* audit events.
AdminUser carries managed_by, member_kind, member_access, can_add_members,
members_count and display_name; the user detail its members and
member_collections. updateUser takes the member fields (Assign to someone
needs kind and access beside managed_by; managed_by null makes someone
independent), deleteUser sends ?keep_members=, settings gain a household
section, and the new 409 codes read as plain English.
Modelled on Demo mode: one card, locked fields show what sets them, only
changed fields are sent. Findable from the palette.
People keeps each household together (members follow their owner; one
whose owner isn't listed keeps its place) with Managed by and Kid or
Grown-up chips. The Account tab gets a Household card: the per-person
switch and the person's members, or for a member who manages them, Make
independent and Assign to someone (owner, kind, access). The role select
never offers admin to a member. The Access tab says where a member's books
come from, read-only, beside the direct grants. Deleting an owner lists
their members, each ticked to go too; unticked ones are kept.
A Household members area, member.* actions in words, and the member fields
user.update and user.delete now record.
/me/household/members (list, create, session, pair, devices, device sign-out,
change, delete) in handlers_members.go, refused for API keys, demo accounts and
members; adding also for the server's switch (household.members_enabled), the
person's (can_add_members) and the cap (household.max_members). A kid gets 403
kid_account on a password, API keys, pairing, recovery, house sharing and
sharing collections. /me/devices rows name the members signed in on them
(member_sign_ins) and DELETE takes ?also_members=1. The admin's user objects
carry the member fields, PATCH assigns / makes independent / switches, DELETE
takes ?keep_members=. member.* audit events; household_members capability.
The single-parameter filter walked json_each for every row (a 1,000-item
member's browse took 30 ms against 0.27 ms); it now looks a row's own path and
its ancestors up in the list, which SQLite builds once, with the same rows as
the OR form (which can't hold 1,000 arms at all: expression depth).
BenchmarkMemberScopes measures browse, search, the progress list and the scope
load for a 1,000-item collections kid against a whole-library listener.
…one-transaction admin PATCH

- Assigning a demo account as someone's member is refused (409
  member_is_demo); the console maps it to its existing "A demo account
  can't join a household" copy.
- writeMemberError no longer turns every auth.ErrNotFound into "device not
  found": the device revoke route answers that itself, a member gone since
  is "member not found", a failed lookup of the caller is a logged 500.
  MemberSession reads the member inside its own transaction.
- PATCH /admin/users/{id} is one auth.UpdateUser transaction that checks
  every field (password, household, role, disabled, can_add_members) against
  the account as the earlier ones leave it before writing, so a refused
  field changes nothing; a password beside assigning a kid is 400
  kid_no_password (console copy in six locales). SetPassword, SetRole,
  SetDisabled, SetCanAddMembers, AssignMember and MakeIndependent are thin
  wrappers over it.
UpdateUser refuses a non-empty password for an account that is, or in the
same change ends up as, a kid (400 kid_no_password); clearing one stays
allowed, beside assigning a kid too. The console's Sign-in tab offers a
kid no password and says how they sign in instead.
…-owner copy, can-add text, role copy, member cap range check
…p, and leaner account writes

The token lookup reads managed_by, member_kind and the member rule through a
left join in its one statement, so a non-member's scopes cost what they did
before Phase 8b-1 (no rule read) and a kid's refusal needs no account read.
catalog.Caller carries the rule (CallerScope/CallerScopes/CallerLibraries);
UserScope/UserScopes/AccessibleLibraries read it themselves for callers known
only by id. One AccessibleLibraries path for every non-admin; grantScopes
dedupes in SQL; MemberCollections computes the owner's scopes itself and
shares scopedItemCounts with countAndPreview.

A kid's household_sharing row is cleared when they become a kid (so the house
filters go; a kid made a grown-up again opts in again). auth.MayAddMembers is
the one may-add ladder; CreateMember returns the signed-in account,
MemberPairing and RevokeMemberDevice the member, each in one transaction.
UpdateUser writes one UPDATE with one kid-password check; DeleteUser keeps and
deletes members set-based; deriveUsername reads once; the also_members revoke
uses RETURNING. The test-only SetRole/SetDisabled/SetCanAddMembers/
AssignMember/MakeIndependent wrappers are gone (tests call UpdateUser).
auth.User's display_name moves to the player's /me view (api.meUser).
…s, and smaller transport

notKid (api.go) refuses a kid (403 kid_account) on POST /auth/password|tokens|
pair|recovery, PUT /me/household and PUT /me/collections/{id}/shares, reading
the member_kind the token lookup carries (handlePair loads no account any
more). The members list names its reason from auth.MayAddMembers. A member's
admin detail carries owner_libraries and owner_is_admin; the admin view of
managed_by adds the owner's display_name (never /me's); adminMember embeds
auth.Member. The settings envelope carries household.max_members_default,
_min and _max as read-only facts, and config validates max_members once
(Validate). /me/devices reads no member sign-ins for a member. pathID is the
one path id + 400 helper; parseIDList reads each id with parseOptionalID.
…pieces

A member's owner is named from managed_by's display_name (ownerName(u), no
People list fetched), and the Access tab reads owner_libraries and
owner_is_admin from the member's own detail. Settings > Household takes the
cap's default and range from the server's read-only facts. One AccessRow (with
an action slot) for the direct grants and the owner's access, one KindBadge,
one namedApart rule for showing a household name (case-insensitive), counted()
for item counts, bookQuery for keep_members. The delete dialog tracks the
members it keeps. The audit log reads member_kind and member_access with the
kind and access words (their duplicate keys go), and the dead admin/demo
assign cases and their unused strings are gone.
…d the owner facts; shared fixtures

Tests: the request's user carries the member rule as GetUser reads it (bad
data derives nothing), becoming a kid clears house sharing, MayAddMembers'
ladder, a non-member Caller's scopes read nothing but their grants (users
renamed away), and a member's admin detail carries owner_libraries,
owner_is_admin and the owner's display_name (never on /me). The benchmark
reuses the member fixture and measures the Caller path (plus scopes_by_id);
createCollection lives on testEnv; the console's sam fixture spreads member.
CLAUDE.md: the rule on the request, notKid, the kid's house sharing reset, the
admin and settings facts, and the new benchmark numbers.
…ter, admin guard

- Joining another owner's household signs out sessions made on other people's
  devices and revokes open pairing codes (leaveOwners); Make independent and a
  kept member revoke the codes only.
- Becoming a kid also retires live invites and drops collection shares with
  anyone but the owner, both ways; minting an API key, recovery code, own
  pairing token or house sharing refuses a kid inside its own transaction.
- The single-parameter path filter splits on bytes; a non-UTF-8 path gets its
  own OR arm instead of sending the whole list to the OR form.
- DeleteUser checks the last admin inside its transaction; keep_members reads
  repeated values; a disabled member's session or pairing is 409
  member_disabled; usernames number without a cap; demo and member callers
  are refused before the account limiter counts them; also_members sign-outs
  are audited; display_name changes only on a rename.
- Denied tests for every member route (API key, demo, member caller) and a
  test that auth and catalog derive the same member rule.
…nsole copy

POST /auth/password racing the owner's switch to kid answers 403 kid_account
(SetPassword's ErrKidNoPassword) like the other in-transaction kid refusals;
the admin PATCH keeps 400 kid_no_password. SetCollectionShares refuses a kid
caller inside its transaction (ErrKidAccount -> 403 kid_account), so a share
that passed notKid before the switch can't re-add a non-owner. The race test
covers both; a catalog test pins the kid refusal (allowed: grown-up member,
independent).

Console: moveBody says the move signs the person out on other people's
devices but the new owner's; kidClears adds the recovery code, invites and
house sharing, and sharing with anyone but the managing person. All six
locales, no gendered pronouns.
POST /admin/users/{id}/authcode and POST /admin/authcodes/{id}/rotate answer
409 kid_account for a kid's account (auth.refuseKid inside CreateInvite and
RotateAuthCode, so a used-up invite kept as history can't be revived into a
kid's account); a missing user is now 404 rather than 500. The console offers
no pairing or Rotate on a kid's page and says who sets up their devices, and
maps kid_account. Migration 0044's comment (unreleased) says what DeleteUser
does with an owner's members.
@KodeStar
KodeStar merged commit 14a5bb0 into main Oct 14, 2026
3 checks passed
@KodeStar
KodeStar deleted the feat/player-8b-1-members branch October 14, 2026 08:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant