Skip to content

Replace knock-rule flow with ephemeral per-code welcome rooms #3

@amiller

Description

@amiller

Problem

The current /join?code=… flow depends on Element's knock UX:

  1. User clicks the landing button → opens matrix.to/#/#shape-rotator:… in Element.
  2. User is expected to find the "Request to join" button and paste the invite code into Element's "reason" text box.
  3. 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

  1. User opens https://mtrx.shaperotator.xyz/join?code=<CODE>.
  2. Page calls POST /join/api {"code":"<CODE>"} against the approver.
  3. 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.
  4. 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").
  5. User joins the welcome room. This is the first and only Element UI step they need.
  6. 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."
  7. User accepts the space invite; restricted-join pulls them into the child rooms.
  8. 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)

  1. 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.
  2. 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).
  3. 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:

  1. POST /join/api with a fresh code → get a welcome room alias.
  2. As a matrix.org (or fresh local) test user, join the welcome room.
  3. Wait up to 15s for approver to invite to the space.
  4. Accept the invite + join children.
  5. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions