2026 10 13 town hall wp bridge design - xopherdeep/do-it-for-the-xp GitHub Wiki

The Town Hall: the bridge to the community site — design

Date: 2026-10-13. Status: design approved in chat by the owner, section by section; this spec awaits review.

Why

The Town Hall is the building that faces the outside world. Its News tab already reads posts from doit.forthexp.com, but nothing goes the other way: the clerk's "Report Bug" and "Suggest Feature" only say "coming soon", and the Updates tab shows a made-up "Version 2.1.0" note. The owner wants the Town Hall to be the bridge between the community and the WordPress site, using what the site already does rather than building a new backend.

Measured against S'moresUp's Campfire, which mixes three things in one feed:

Campfire part Ours
Family wall Family Chat (src/app/Admin/XpChat/, room family-chat), already built
Community wall and support The Town Hall, through doit.forthexp.com (this spec)
Family connections The multiplayer backlog (#1159), later

Success: anyone in the family can send a bug or an idea from inside the game and the owner sees it on the site; a Game Master can link the family's site account from the same place; nothing reaches the public without approval.

Decisions (owner, 2026-10-13)

  • No new backend. The site already has posts, comments, network accounts, WordPress's built-in app sign-in (Application Passwords), the Bulletin Board plugin and Bug Net. The work is in the app, plus one small hook in xophz-compass-xp (section 4).
  • Two sites, one network. doit.forthexp.com is the users' site; forthexp.com is the XP plugin's main page and the network's main site. Accounts are created on forthexp.com (WordPress sends every network sign-up there) and work on doit.forthexp.com because network accounts are shared. Network registration is on, for user accounts only (changed by the owner 2026-10-13, verified).
  • One account per family, held by the Game Master. Heroes never get an account or an email (COPPA; this matches #1161). Heroes post through the family's account, signed with their hero name.
  • Two approvals for a hero's public post: the family's Game Master in the game, then the owner on the site. Bug reports are private and skip the Game Master.
  • Sign-in: WordPress Application Passwords (option 1). WP OAuth Server is installed but not used here.
  • Membership: the XP plugin makes a linking parent a Contributor on doit.forthexp.com (option 3), only for the game's connection, never changing an existing member's role.
  • The family wall is Family Chat. The Town Hall gets a door to it.
  • Ship in four steps, each usable alone. The owner makes every site change, each in the step that needs it (comment moderation now, because sign-ups are already open).
  • The Town Hall is free, not XP Pro.

1. The screen

Four tabs, moved from the header to a segment at the bottom of the page so they sit in thumb reach, stacked above the shared bottom nav (which is not touched):

Tab Content Source
News Posts, newest first, with their approved comments under each GET /wp/v2/posts excluding the Patch Notes category; GET /wp/v2/comments?post=
Board The four community boards, their published topics and replies; a hero's own waiting posts on top Bulletin Board: cafeteria_board, cafeteria_topic, comments on topics
Events The hero's quests for the week ahead (unchanged) The quest engine
Updates Patch notes, then the family stats card (unchanged) Posts in the category with slug patch-notes

The boards shown are, in this order: feature-requests, ideas-suggestions, comments-feedback, suggestion-box. The noo* boards are not shown. Topics and News posts render through the existing sanitizeHtml.

The header gains a Family Chat button (like the Hotel's ATM button) that opens the chat on a hero-reachable route (section 6). The clerk's FAB stays at the bottom centre.

The clerk's menu (the existing XpRevealPrompt, Town Clerk voice, townHall accent):

Choice Who sees it Does
Post on the Board Everyone Pick a board, write a title and a message (section 3)
Report a bug Everyone Write what happened; the app adds the details (section 3)
Family registration Game Master only Link, show who is linked, unlink (section 2)
Never mind Everyone Closes

"Submit Announcement" is removed; the Board covers it. A comment on a News post or a reply to a topic is written from that card ("Comment" / "Reply"), not from the clerk. "Game Master" uses the existing rule in src/router/_Guards/gm.guard.ts (canAccessGameMaster: an isAdult profile, or a Game Master masquerading).

Every failure is a clerk line in the game's own dialog, never a bare error card (section 5).

2. Linking the family

The Town Hall always talks to doit.forthexp.com. The site is a build setting, VUE_APP_TOWN_HALL_SITE (default https://doit.forthexp.com). Development builds also read an override from localStorage key xp:townHallSite, so the local rehearsal can point the shared dev server at it without restarting it.

When WordPress itself serves the app and its wpApiSettings.root is the Town Hall site's REST root, the app reads and posts with that visitor's login (cookie and X-WP-Nonce, as api.class.ts does today) whenever the device has no link. Family registration stays available there, with a line saying they are signed in through the website: the website login alone does not make a parent a Contributor, and linking does. A device with a link always uses the link.

Everywhere else (app.forthexp.com, ionic serve, the phone apps), the Game Master links once per device:

  1. Clerk → Family registration → "I have an account" / "Create one" / "Never mind". The clerk reminds them that their site display name is public and should not be their full name.

  2. "Create one" opens https://www.forthexp.com/wp-signup.php in a new tab. WordPress emails an activation link; they activate, then come back and choose "I have an account".

  3. "I have an account" sends the browser to {site}/wp-admin/authorize-application.php with:

    • app_name: Do It For The XP – {browser} on {platform} (for example "Chrome on Android"), so each device shows separately in the parent's WordPress profile and can be revoked alone;
    • app_id: 7606ce60-29c7-43b6-9533-2063ee726df4 (TOWN_HALL_APP_ID, fixed, shared with the plugin);
    • success_url and reject_url: the Town Hall's own address for the hero on screen, shaped for the router's history mode the way proReturnUrl is ({origin}{pathname}#/town-hall/{userId} with hash history, {origin}/town-hall/{userId} otherwise), each carrying the one-shot state (below): success_url = {return}?th_state={state}, reject_url = {return}?th_state={state}&success=false. The app builds reject_url itself because WordPress adds success=false only to a reject address it makes; one it is given is used as is. A separate /town-hall/linked route is not possible: the Town Hall's route is /town-hall/:userId?/, so "linked" would be read as a hero id.

    The one-shot state (ruling 26). Anyone can make valid credentials on the network, so a crafted link (or any app opening com.forthexp.doit://town-hall?...) must not link the family to a stranger's account. Before leaving for the authorize page (a Game Master's errand only) the device remembers { state: uuid v4, heroId, expiresAt: now + 30 min } (townHallLinkState.ts): in sessionStorage on the web (WordPress returns to the same tab), in @capacitor/preferences in the phone apps (the app may be killed while the parent is in the system browser). A return counts only when its th_state matches a fresh remembered state, which is then deleted (one shot). A missing, wrong, expired or replayed state is ignored: nothing is asked of the site, nothing is stored, the address is still cleaned, and the clerk says nothing.

  4. They sign in on doit.forthexp.com and press Approve. The plugin hook makes them a Contributor (section 4).

  5. WordPress sends the parent back to success_url with site_url, user_login and password added (ruling 25). Its approve button (wp-admin/js/auth-app.js) appends them by plain concatenation to the END of success_url (? or &, values through encodeURIComponent, so spaces are %20): with hash history they land inside the route's own query, after the fragment (#/town-hall/gm-1?th_state=…&site_url=…&password=…), and window.location.search is empty. Only the no-JavaScript fallback uses add_query_arg, which puts them before the fragment (spaces as +). So a boot step in src/main.ts, run before the router is made, reads BOTH the page's query and the hash route's query (captureLinkReturn), at once replaces the address with history.replaceState (both queries removed, the hash route kept), and keeps the return in memory (pendingReturn.ts: stashLinkReturn, takeLinkReturn) for the Town Hall page to take. Because vue-router has not read the address yet, it never memorises the password in its route or in history.state (current or back). On reject the address is reject_url, which carries the state and success=false.

  6. The Town Hall takes the return, checks the state, then checks the three values with GET /wp/v2/users/me?context=edit using Basic auth, and reads the connection's id from GET /wp/v2/users/me/application-passwords/introspect. On success it stores the link (section 7) and the clerk greets the parent by display name; a link that replaces an older one asks the site to delete the older connection (with the older sign-in, best effort). If the check fails, nothing is stored and the clerk says so. A rejection with the matching state gets "Maybe another time".

  7. Unlink (Game Master only) deletes the stored link on this device and tries DELETE /wp/v2/users/me/application-passwords/{uuid}. If the site refuses, the clerk tells the parent to remove "Do It For The XP – …" from their WordPress profile.

The phone apps cannot receive a web redirect; they get a return link in step 4 of the build (section 9). Their return address is the app's own (com.forthexp.doit://town-hall?th_state=…), read by the appUrlOpen listener registered in src/main.ts, and checked against the state kept in Preferences.

3. Posting

What Endpoint Status on the site Game Master step
Board topic POST /wp/v2/cafeteria_topic {title, content, status: "pending", cafeteria_board: [id]} Pending until the owner publishes Heroes only
Comment on a News post or a topic POST /wp/v2/comments {post, content} Held by the site's comment moderation Heroes only
Bug report POST /wp/v2/compass_bug {title, content, status: "pending", bug_status: "new", bug_priority: "medium", bug_type: "bug", bug_plugin: "do-it-for-the-xp", bug_url, bug_environment} Pending, never published None
  • Signature. Every topic and comment ends with a line naming the hero: — {hero nickname}, from Do It For The XP. No real names, no family name (ruling 29): the nickname starts out as the first name, so the app signs with the nickname only when it differs (trimmed, any case) from the hero's first name (and from the family and full name); otherwise the line is — a hero, from Do It For The XP (signingName). Nothing sent ever falls back to the first name, a bug report's default title included. The Game Master's approval card shows the exact signature line that will be published, and names the author as the family knows them.
  • Bug details, added by the app: app version, the route the hero was on, the platform and browser, and the time, in bug_environment (plain text) and bug_url (the route). The app version is exposed at build time from package.json (nothing exposes it today).
  • Text only. Contributors cannot upload images, and photos of children stay off a public site.
  • Limits: title up to 100 characters, message up to 2,000 (bugs up to 4,000), checked before the clerk accepts it.
  • A hero's topic or comment is saved as "waiting for your Game Master". It appears at the top of the hero's Board tab and in a new Town Hall segment of the Game Master's Approvals page (XpApprovals), with Approve and Reject. Approve moves it to the outbox; Reject marks it "not approved" and the hero can dismiss it. Like quest approvals today, this works on the device the family shares; approving from a different phone waits for family sync (#1161).
  • A Game Master's own topic or comment goes straight to the outbox.

The outbox. Everything bound for the site waits in an outbox on the device ("I'll keep it on file"). It is sent when the Town Hall opens, right after linking, right after a Game Master approves, and when the device comes back online while the app is open. An item leaves the outbox only when the site confirms it; the Send button is disabled while a send is in flight. If a response is lost after the site saved the post, a retry can make a duplicate; it lands in the owner's pending queue, where it is deleted. Not linked yet: items stay queued and the clerk tells a hero to ask their Game Master, and a Game Master to register. A Game Master who opens the Town Hall with queued posts and no link is offered Family Registration by the clerk, once per visit; the Approvals page's Town Hall segment says in plain words "This device isn't registered at the Town Hall, so approved posts wait here. Register from the Town Hall clerk." while posts are queued and the device is not linked, and says to register again when the send after an approval finds the registration refused (401) (ruling 30).

WordPress's comment refusals are settled by their error code (TownHallError.siteCode), not their status (ruling 27): a non-moderator may comment once every 15 seconds, so comment_flood (400) keeps that comment queued and the flush sends no more comments this time (topics and bugs still go); comment_duplicate (409, a retry of a comment the site already has) counts as sent (remoteId 0); rest_comment_closed and rest_comment_draft_post (403) fail that comment with the site's message, so it can be cleared. None of these is a refused account: they never offer to redo the registration or forget the link.

4. The membership hook (xophz-compass-xp)

A new class in xophz-compass-xp makes a parent who links from the game a Contributor on doit.forthexp.com.

  • It listens to admin_init on the approval screen (authorize-application.php), because WordPress refuses to create an application password for a network user who is not a member of the site (the rehearsal answer below explains why), so the parent has to join before they press Approve. It also listens to wp_create_application_password (the Approve moment) and application_password_did_authenticate (every request made with an application password), so membership also happens on the first API call if the approval screen was reached some other way.
  • It acts only when all of these hold: WordPress is a network (is_multisite()); the application password's app_id is TOWN_HALL_APP_ID; the current site's address (home_url(), no trailing slash) is on an allowlist (filter xp_town_hall_site_urls, default https://doit.forthexp.com; the rehearsal adds its own); the user is not already a member of the current site (is_user_member_of_blog). Then it calls add_user_to_blog with the role contributor.
  • It never changes the role of an existing member, so it cannot demote the owner or anyone else.
  • It does nothing on other sites of the network, even when the plugin is network-active.
  • It holds the game's comments for the owner (ruling 28). WordPress approves a comment at once when its author wrote the post ("the author and the admins get respect"), so once the owner publishes a family's topic the family's replies would go public with no approval. A pre_comment_approved filter (priority 99, after core and spam checks; 2 arguments) returns 0 on an allowlisted network site when the request signed in with an application password whose app_id is TOWN_HALL_APP_ID (rest_get_authenticated_app_password() gives the uuid, WP_Application_Passwords::get_user_application_password() its record). A spam or trash verdict (or an error) is kept. The decision is the pure comment_verdict(), tested in the plugin's node harness.

Rehearsal answer (2026-10-13): on the rehearsal network (WordPress 7.1.3, subdirectory multisite) a signed-in network user who is a member of no site (famparent) can open /doit/wp-admin/authorize-application.php, and the main site's copy of the page, and sees the normal Approve screen. Pressing Approve fails on /doit/ with "Invalid user ID.": the page's script posts to /wp/v2/users/me/application-passwords, and in a network that controller answers 404 rest_user_invalid_id for a user who is not a member of the current site (is_user_member_of_blog). No application password is created, so wp_create_application_password never fires and no membership arrives (no password, no Contributor). The earlier fallback (approve on a site the parent can reach, then let application_password_did_authenticate add them on the first call) does not fix this by itself: Approve on the main site was not exercised, but the same check applies there and famparent belongs to no site, so it is expected to fail the same way. Second run, with the plugin's admin_init listener (xophz-compass-xp d06ad40, which adds the parent as a Contributor when they open authorize-application.php with the game's app id): famparent was a Contributor on /doit/ as soon as the Approve screen was open, before pressing Approve; Approve then succeeded, WordPress sent the browser back to the Town Hall with a clean address bar (no password), and the clerk said "Welcome, famparent! Your family is registered at the Town Hall." The site then held one application password for the game's app id, named "Do It For The XP – Chrome on Linux". Unlink this device made the clerk say "This device no longer posts for your family." (no "remove it from your profile" line) and left famparent with 0 application passwords: the site forgot the connection. The Contributor seat stays after Unlink.

5. When things go wrong

Situation What the clerk does
Site unreachable or offline (network error, timeout, 5xx) "The courier hasn't arrived" with Try again; the tab keeps what it last showed; the outbox waits
401 on a linked device (link removed on the site) Tells the Game Master the family must register again; deletes the stored link; keeps the outbox
403 on a post (the membership step did not happen) Offers to redo Family registration; the item stays queued
400 (the site rejected the content) Marks the item failed with the site's message; the Game Master can edit or delete it
A comment refused by code (comment_flood, comment_duplicate, rest_comment_closed, rest_comment_draft_post) As in section 3: queued (no more comments this flush), sent, or failed with the site's message; never "redo registration"
Parent pressed Reject on the WordPress screen "Maybe another time"; nothing stored
A return without the device's fresh state (crafted, stale, replayed) Nothing; nothing stored; the address is still cleaned
Patch Notes category missing Updates shows the stats card and "No patch notes yet"
A board is missing That board is left out of the Board tab

Requests time out after 8 seconds (as proClient does).

6. Family Chat for heroes

The chat page lives at /game-master/chat (xp-chat), behind the Game Master guard, and nothing in the hero's portal links to it. A new route, /town-hall/:userId/chat (town-hall-chat), renders the same XpChat.vue for heroes; the Game Master route stays as it is. Before reusing it, check XpChat for Game-Master-only actions and hide them for heroes. parentOf gives the new route the Town Hall as its parent, so Back works from a fresh load.

7. Modules and state

New pure modules under src/lib/services/townhall/, each with a jest spec in tests/unit/:

Module Holds
townHallSite.ts The site URL and REST root, TOWN_HALL_APP_ID, the board slugs, the Patch Notes slug, the device's connection name, and whether the host page's nonce applies
townHallLink.ts Building the authorize URL and the return URL; reading the return query into linked, rejected or none; finding the return in both queries and the address without it
townHallLinkState.ts The one-shot state of a registration: remembering it (sessionStorage or Preferences) and claiming it once
pendingReturn.ts The boot step that takes the return out of the address before the router reads it, and keeps it for the Town Hall
townHallClient.ts fetch with the right auth (Basic, nonce or none), the 8-second timeout, and status → error code (offline, unauthorized, forbidden, invalid, server)
townHallOutbox.ts The post state machine: awaiting-gm → queued → sending → sent, plus rejected and failed; who may approve; what each error code does to an item
townHallPosts.ts Turning a draft into the request body: signature, limits, bug details

The clerk's new lines join the Town Hall section of src/lib/services/hometown/hometownPrompts.ts.

State on the device, in a new TownHallDb through StorageService:

interface TownHallLink {
  siteUrl: string;
  userLogin: string;
  appPassword: string;
  displayName: string;
  linkedAt: number;
  appPasswordUuid?: string;
}

interface TownHallPost {
  id: string;                 // uuid, made on the device
  kind: "topic" | "comment" | "bug";
  heroId: string;
  heroName: string;
  createdAt: number;
  status: "awaiting-gm" | "queued" | "sending" | "sent" | "rejected" | "failed";
  board?: string;             // topic: board slug
  postId?: number;            // comment: the post or topic it answers
  title?: string;
  body: string;
  bug?: { route: string; environment: string };
  remoteId?: number;          // the site's id once sent
  error?: string;
}

TownHallDb is not added to BACKUP_STORES (src/lib/services/core/BackupService.ts), so a backup file never carries the family's sign-in or unsent posts.

The page follows the conventions: TownHall.vue becomes a defineComponent page with a sibling TownHall.ts and _TownHall.scss; each tab is a <script setup> component under TownHall/components/. The hardcoded wpBaseUrl goes; reads go through townHallClient. The Approvals segment is its own component (XpTownHallApprovals.vue) so XpApprovals.vue does not grow.

8. Site setup

The owner makes these changes; each can be undone, and each happens in the step that needs it.

Step Change Where
Done Network registration: user accounts only forthexp.com network settings
Now Settings → Discussion: "Comment must be manually approved" (sign-ups are open, so any network account can already comment through the website) doit.forthexp.com
1 Create the Patch Notes category (slug patch-notes) and post the first notes doit.forthexp.com
2 Install the updated xophz-compass-xp with the membership hook, after the local rehearsal passes doit.forthexp.com
3 Activate Bug Net. First check which GitHub repo it copies bugs to: if public, point it at a private repo or leave the GitHub copy off on this site. Keep bugs Pending; track them with Bug Net's own status doit.forthexp.com

9. Slices

  1. Read-only Town Hall. Bottom tabs, News with comments, the Board (read), Updates with Patch Notes, the Family Chat door and route, the new clerk menu (posting and registration still answer "coming soon"), the page split into .ts/.scss/components, townHallSite and townHallClient. No site changes beyond the category.
  2. Family registration. The rehearsal network first (section 10), then the membership hook, townHallLink, TownHallDb, link, check, unlink, the nonce path for the hosted app. Ends with the owner installing the plugin update and linking our family on doit.forthexp.com.
  3. Posting. townHallPosts, townHallOutbox, Board topics, comments, bug reports, the Approvals segment, the hero's waiting list, the error handling. Site change: Bug Net.
  4. The phone apps. A custom URL scheme (com.forthexp.doit://town-hall) registered for iOS and Android, an appUrlOpen listener (@capacitor/app, installed) feeding the same townHallLink parser, and the system browser for the approve screen (@capacitor/browser, a new dependency).

10. Testing

  • Jest for every module in section 7: authorize and return URLs in both history modes, parsing and stripping the return query, auth headers per mode, error-code mapping, every outbox transition and who may make it, signatures, limits and bug details.
  • The local rehearsal on a throwaway WordPress network in Docker (tools/townhall-rehearsal/, port 8090: a subdirectory network whose /doit site stands in for doit.forthexp.com). The owner's "My Compass" Docker site is a single site, so it is not used for this and is not changed; the plugin's PHP unit tests still run through its existing harness. The rehearsal serves plain http, so it sets WP_ENVIRONMENT_TYPE to local for Application Passwords. It mounts Bulletin Board, Bug Net and the XP plugin from Xophz-COMPASS, then runs link → topic → Game Master approve → owner approve, plus a bug report, with the xp:townHallSite override pointing at it.
  • Cypress at 390×844 (tests/e2e/specs/town-hall.spec.js, run with --browser chrome): the bottom tabs, News, Board and Updates from fixtures through cy.intercept (never the live site), the clerk menu for a hero and for a Game Master, the Family Chat door.
  • chemx verify before every commit.
  • The live check at the end of step 2 and of step 3: the owner links our family on doit.forthexp.com, posts one test topic and one bug, approves, then deletes them.

Out of scope

  • Family wall sync across devices and family connections (#1159–#1161).
  • Photos and attachments; editing or deleting a sent post from the app.
  • Notifications when someone replies.
  • WP OAuth Server; moving doit.forthexp.com into a network of its own; a sign-up page on doit.forthexp.com.
  • Accounts for heroes.
⚠️ **GitHub.com Fallback** ⚠️