2026 10 13 town hall wp bridge design - xopherdeep/do-it-for-the-xp GitHub Wiki
Date: 2026-10-13. Status: design approved in chat by the owner, section by section; this spec awaits review.
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.
-
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.
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).
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:
-
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.
-
"Create one" opens
https://www.forthexp.com/wp-signup.phpin a new tab. WordPress emails an activation link; they activate, then come back and choose "I have an account". -
"I have an account" sends the browser to
{site}/wp-admin/authorize-application.phpwith:-
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_urlandreject_url: the Town Hall's own address for the hero on screen, shaped for the router's history mode the wayproReturnUrlis ({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 buildsreject_urlitself because WordPress addssuccess=falseonly to a reject address it makes; one it is given is used as is. A separate/town-hall/linkedroute 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): insessionStorageon the web (WordPress returns to the same tab), in@capacitor/preferencesin the phone apps (the app may be killed while the parent is in the system browser). A return counts only when itsth_statematches 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. -
-
They sign in on doit.forthexp.com and press Approve. The plugin hook makes them a Contributor (section 4).
-
WordPress sends the parent back to
success_urlwithsite_url,user_loginandpasswordadded (ruling 25). Its approve button (wp-admin/js/auth-app.js) appends them by plain concatenation to the END ofsuccess_url(?or&, values throughencodeURIComponent, 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=…), andwindow.location.searchis empty. Only the no-JavaScript fallback usesadd_query_arg, which puts them before the fragment (spaces as+). So a boot step insrc/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 withhistory.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 inhistory.state(currentorback). On reject the address isreject_url, which carries the state andsuccess=false. -
The Town Hall takes the return, checks the state, then checks the three values with
GET /wp/v2/users/me?context=editusing Basic auth, and reads the connection's id fromGET /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". -
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.
| 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) andbug_url(the route). The app version is exposed at build time frompackage.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.
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_initon 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 towp_create_application_password(the Approve moment) andapplication_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'sapp_idisTOWN_HALL_APP_ID; the current site's address (home_url(), no trailing slash) is on an allowlist (filterxp_town_hall_site_urls, defaulthttps://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 callsadd_user_to_blogwith the rolecontributor. - 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_approvedfilter (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 whoseapp_idisTOWN_HALL_APP_ID(rest_get_authenticated_app_password()gives the uuid,WP_Application_Passwords::get_user_application_password()its record). Aspamortrashverdict (or an error) is kept. The decision is the purecomment_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.
| 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).
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.
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.
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 |
-
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,townHallSiteandtownHallClient. No site changes beyond the category. -
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. -
Posting.
townHallPosts,townHallOutbox, Board topics, comments, bug reports, the Approvals segment, the hero's waiting list, the error handling. Site change: Bug Net. -
The phone apps. A custom URL scheme (
com.forthexp.doit://town-hall) registered for iOS and Android, anappUrlOpenlistener (@capacitor/app, installed) feeding the sametownHallLinkparser, and the system browser for the approve screen (@capacitor/browser, a new dependency).
- 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/doitsite 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 setsWP_ENVIRONMENT_TYPEtolocalfor Application Passwords. It mounts Bulletin Board, Bug Net and the XP plugin fromXophz-COMPASS, then runs link → topic → Game Master approve → owner approve, plus a bug report, with thexp:townHallSiteoverride 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 throughcy.intercept(never the live site), the clerk menu for a hero and for a Game Master, the Family Chat door. -
chemx verifybefore 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.
- 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.