Problem
The current /join?code=… flow depends on Element's knock UX:
- User clicks the landing button → opens
matrix.to/#/#shape-rotator:… in Element.
- User is expected to find the "Request to join" button and paste the invite code into Element's "reason" text box.
- The
knock-approver service sees the knock in /sync, matches the reason against /data/codes.json, and invites them in.
In practice this breaks at step 2. The Shape Rotator room is a Matrix space (type: m.space, room_version: 12, join_rule: knock), and Element's knock UI for spaces is weak-to-absent depending on client version (Web / iOS / Android all differ). Observed failure: at least one invited user received a valid code but never produced a membership event of any kind on the homeserver — the knock never fired. The audit log has zero successful federation knocks since launch; every knock_approved entry is a local test user, not a real federated invitee.
Related: #2 (Path A instructions hardcode socrates1024 as inviter) — both issues stem from pushing too much protocol-literacy onto the user.
Proposed design: ephemeral per-code welcome rooms
Replace the knock flow with a lobby-room pattern where each invite code maps to its own fresh public room. The code gets baked into the room's identity instead of being something the user has to paste.
Flow
- User opens
https://mtrx.shaperotator.xyz/join?code=<CODE>.
- Page calls
POST /join/api {"code":"<CODE>"} against the approver.
- Approver looks up the code. If valid and has uses remaining:
- Mint (or reuse) a public room
#welcome-<short-hash>:mtrx.shaperotator.xyz with preset: public_chat, approver as creator + admin, auto-invite disabled.
- Store a mapping
code → {room_id, created_at} in /data/welcome_rooms.json so a second call with the same code returns the same room (idempotent).
- Post a welcome message in the room (unencrypted, since it's public): brief "welcome, sit tight, you're about to be invited to the main space."
- Return
{"room_alias": "#welcome-<short-hash>:mtrx.shaperotator.xyz"} to the landing page.
- Landing page swaps the old "Open Shape Rotator in Element" button for a deep-link to the welcome room alias. User clicks; Element shows a public room with a plain "Join" button (not "Request to join").
- User joins the welcome room. This is the first and only Element UI step they need.
- Approver's
/sync loop sees the new membership=join event in a #welcome-* room it owns. It:
- Decrements the code's
uses_remaining.
- Invites the user to the main space (
!4FL8uL5OEYLATG1VH4wC2CD3pfIV6BMFId9VT7rmm-g).
- Posts a confirmation message in the welcome room: "invite sent — accept it in Element and you're in."
- User accepts the space invite; restricted-join pulls them into the child rooms.
- Cleanup: after the user joins the space (or after N hours of inactivity, whichever first), the approver tombstones the welcome room and leaves it.
What this gets us
- No code-pasting. The code is consumed when the unique room is minted, not when a user types it into Element's reason field.
- No MXID-pasting. The bot reads sender MXID from the join event.
- No knock-UI archaeology. Public rooms have a big, obvious "Join" button in every Element client.
- Per-user visible trail. Each user gets their own small room where the bot can log what's happening and where they can ask questions if something goes wrong. Much better failure-mode visibility than a silent 403 from the approver's HTTP endpoint.
- Still code-gated. Minting the welcome room requires a valid code; the approver never invites anyone to the main space without one.
Spec details for the implementer
Approver changes (knock-approver/approver.py)
-
New HTTP endpoint POST /join/api {"code": "<CODE>"}:
- Load
/data/codes.json (the knock codes, now repurposed as welcome codes). If code missing or uses_remaining <= 0, return {"error":"code_exhausted"} (distinct from invalid_code, per pending fix).
- If code already has a welcome room mapping in
/data/welcome_rooms.json AND the room is still alive, return the existing alias. Idempotent.
- Otherwise create room:
POST /_matrix/client/v3/createRoom with {preset:"public_chat", room_alias_name:"welcome-<short>", name:"Shape Rotator welcome", topic:"join this room, the bot will invite you to the space"}. Post a pinned welcome message. Add mapping to /data/welcome_rooms.json.
- Do not decrement
uses_remaining yet — that happens on successful join, so a user who clicks the link but never completes doesn't burn the code.
-
Extend the /sync loop to watch for joins in #welcome-* rooms:
- On
membership=join in any room whose room_id appears in /data/welcome_rooms.json:
- Decrement the code's
uses_remaining, persist.
POST /rooms/<space>/invite {"user_id": "<joiner MXID>"}.
- Post confirmation message.
- Record
joined_by in the welcome room's mapping entry + timestamp.
- On subsequent re-joins or duplicate events, no-op (idempotent — the mapping has
joined_by).
-
Cleanup job (runs on startup + every N minutes):
- Any welcome room with
joined_by set and joined_at + 30m < now: kick all members, tombstone, remove from mapping.
- Any welcome room with no
joined_by and created_at + 48h < now: same cleanup (link expired without use).
Landing changes
landing/join.html: replace the current matrix.to button. On page load, fetch('/join/api', {body: {code: <url param>}}). When it returns a room alias, show "Open the Shape Rotator welcome room in Element →" linking to matrix.to/#/<alias>. On error, show friendly "this code is no longer valid, ask whoever sent you the link for a new one."
landing/nginx.conf: add location = /join/api { proxy_pass http://knock-approver:8001/join/api; ... }.
Data files
/data/codes.json stays as is (the source of truth for what codes exist).
- New
/data/welcome_rooms.json: {code: {room_id, room_alias, created_at, joined_by?, joined_at?}}.
Smoke test
tests/smoke.py should gain a path like:
- POST /join/api with a fresh code → get a welcome room alias.
- As a matrix.org (or fresh local) test user, join the welcome room.
- Wait up to 15s for approver to invite to the space.
- Accept the invite + join children.
- Cleanup users at the end.
Open questions
- Short-hash format.
welcome-<first 8 chars of the code> is guessable. Better: welcome-<sha256(code + server_secret)[:8]>. Avoids revealing the code from the room alias.
- What if matrix.org decides welcome rooms are spam? Unlikely at this scale but worth thinking about — one public room per code means we're creating one room per invited person from matrix.org's perspective. If it ever becomes an issue, the mitigation is moving off matrix.org-federated-users as the primary onboarding target.
- Should the old knock flow still work? For experienced Matrix users who know how to knock on spaces, leaving the
join_rule: knock on the main space doesn't hurt anything. Suggest: leave both paths alive. Knock-approver code stays; we just stop pointing at it from /join.
- Code semantics drift. Today
/data/codes.json and /data/signup_codes.json are distinct (one for knocks, one for /signup/api). This design reuses the knock codes for welcome rooms. If we want new semantics (e.g., multi-use codes that each mint a new room), that's a separate schema decision.
Related PRs / issues
Problem
The current
/join?code=…flow depends on Element's knock UX:matrix.to/#/#shape-rotator:…in Element.knock-approverservice sees the knock in/sync, matches the reason against/data/codes.json, and invites them in.In practice this breaks at step 2. The Shape Rotator room is a Matrix space (
type: m.space,room_version: 12,join_rule: knock), and Element's knock UI for spaces is weak-to-absent depending on client version (Web / iOS / Android all differ). Observed failure: at least one invited user received a valid code but never produced a membership event of any kind on the homeserver — the knock never fired. The audit log has zero successful federation knocks since launch; everyknock_approvedentry is a local test user, not a real federated invitee.Related: #2 (Path A instructions hardcode
socrates1024as inviter) — both issues stem from pushing too much protocol-literacy onto the user.Proposed design: ephemeral per-code welcome rooms
Replace the knock flow with a lobby-room pattern where each invite code maps to its own fresh public room. The code gets baked into the room's identity instead of being something the user has to paste.
Flow
https://mtrx.shaperotator.xyz/join?code=<CODE>.POST /join/api {"code":"<CODE>"}against the approver.#welcome-<short-hash>:mtrx.shaperotator.xyzwithpreset: public_chat, approver as creator + admin, auto-invite disabled.code → {room_id, created_at}in/data/welcome_rooms.jsonso a second call with the same code returns the same room (idempotent).{"room_alias": "#welcome-<short-hash>:mtrx.shaperotator.xyz"}to the landing page./syncloop sees the newmembership=joinevent in a#welcome-*room it owns. It:uses_remaining.!4FL8uL5OEYLATG1VH4wC2CD3pfIV6BMFId9VT7rmm-g).What this gets us
Spec details for the implementer
Approver changes (
knock-approver/approver.py)New HTTP endpoint
POST /join/api {"code": "<CODE>"}:/data/codes.json(the knock codes, now repurposed as welcome codes). If code missing oruses_remaining <= 0, return{"error":"code_exhausted"}(distinct frominvalid_code, per pending fix)./data/welcome_rooms.jsonAND the room is still alive, return the existing alias. Idempotent.POST /_matrix/client/v3/createRoomwith{preset:"public_chat", room_alias_name:"welcome-<short>", name:"Shape Rotator welcome", topic:"join this room, the bot will invite you to the space"}. Post a pinned welcome message. Add mapping to/data/welcome_rooms.json.uses_remainingyet — that happens on successful join, so a user who clicks the link but never completes doesn't burn the code.Extend the
/syncloop to watch for joins in#welcome-*rooms:membership=joinin any room whoseroom_idappears in/data/welcome_rooms.json:uses_remaining, persist.POST /rooms/<space>/invite {"user_id": "<joiner MXID>"}.joined_byin the welcome room's mapping entry + timestamp.joined_by).Cleanup job (runs on startup + every N minutes):
joined_byset andjoined_at + 30m < now: kick all members, tombstone, remove from mapping.joined_byandcreated_at + 48h < now: same cleanup (link expired without use).Landing changes
landing/join.html: replace the current matrix.to button. On page load,fetch('/join/api', {body: {code: <url param>}}). When it returns a room alias, show "Open the Shape Rotator welcome room in Element →" linking tomatrix.to/#/<alias>. On error, show friendly "this code is no longer valid, ask whoever sent you the link for a new one."landing/nginx.conf: addlocation = /join/api { proxy_pass http://knock-approver:8001/join/api; ... }.Data files
/data/codes.jsonstays as is (the source of truth for what codes exist)./data/welcome_rooms.json:{code: {room_id, room_alias, created_at, joined_by?, joined_at?}}.Smoke test
tests/smoke.pyshould gain a path like:Open questions
welcome-<first 8 chars of the code>is guessable. Better:welcome-<sha256(code + server_secret)[:8]>. Avoids revealing the code from the room alias.join_rule: knockon the main space doesn't hurt anything. Suggest: leave both paths alive. Knock-approver code stays; we just stop pointing at it from/join./data/codes.jsonand/data/signup_codes.jsonare distinct (one for knocks, one for/signup/api). This design reuses the knock codes for welcome rooms. If we want new semantics (e.g., multi-use codes that each mint a new room), that's a separate schema decision.Related PRs / issues