Skip to content

Wire admin/member invitation links into the realm join wizard#190

Draft
deucalioncodes wants to merge 7 commits into
mainfrom
cursor/invitation-system-7b00
Draft

Wire admin/member invitation links into the realm join wizard#190
deucalioncodes wants to merge 7 commits into
mainfrom
cursor/invitation-system-7b00

Conversation

@deucalioncodes
Copy link
Copy Markdown
Member

@deucalioncodes deucalioncodes commented Apr 18, 2026

What & why

When a user creates a realm in the registry and signs in for the first time on the deployed realm, the only profile the join wizard should let them claim is Administrator. After that, every additional admin (or even just an additional member) should join by clicking an invitation link generated by an existing admin — and the link itself should dictate whether the joiner becomes a member or an admin.

Today none of that is enforced:

  • join_realm(profile, …) accepts any profile string from any caller.
  • The admin_dashboard extension already has a half-built RegistrationCode model and a RegistrationUrlManager.svelte UI, but the codes carry no role, are never actually consumed, and the join wizard never reads them.
  • The realm has no concept of who its creator is, so the bootstrap-admin path can only fall back on ic.is_controller(caller) — which works for manual dfx deploys but fails for the registry-driven flow where canister-management (not the human) is the controller.

This PR finishes the feature end-to-end and resolves the cross-origin principal handoff for the registry flow. Companion changes live in the extensions submodule on the matching branch (the parent commit deps: bump extensions submodule… pins the new submodule SHA).

Cross-origin identity model

Internet Identity issues a different principal per frontend origin (this is the standard II privacy/segregation property). So:

  • The user signs in to realm-registry.ic0.app → has principal A.
  • After deploy, the user signs in to realm-XYZ.ic0.app → has principal B, completely unlinkable from A from outside.

The realm canister can never see A as a caller; it only sees B. The PR resolves this with a small handshake:

  1. The registry (which knows the user as A) deploys the realm via canister-management.
  2. canister-management (a controller of the new canister) immediately calls realm.set_creator_principal(B) — but B is not yet known. So the practical pattern is: the registry asks the user to sign in to the realm in a second tab to capture B, then forwards B to canister-management, which forwards it to the realm.
  3. canister-management calls realm.mint_bootstrap_admin_invite(24). The realm uses admin_dashboard to mint a single-use admin invitation, returns the plaintext URL exactly once, and stores only the SHA-256 hash.
  4. The registry shows the user a "Claim your realm" button pointing at https://realm-XYZ.ic0.app/join?invite=<code>.
  5. The user clicks. They land on the realm's join wizard (already signed in as B), the wizard calls join_realm_with_invite("admin", "", code), and the realm grants admin to B.

For manual dfx deploys, neither step 2 nor step 3 are needed: the dfx identity is a controller and can self-promote via join_realm("admin", "") directly (the existing is_controller-based bootstrap path is preserved as a back-compat).

Changes

src/realm_backend/ggg/governance/realm.py

  • New field creator_principal: String(max_length=64, default=""). Records the realm-side II principal of the human that triggered this realm's creation.

src/realm_backend/main.py

  • _do_join_realm: bootstrap-admin path now accepts the caller as the bootstrap admin when any of:
    1. caller == Realm.creator_principal (registry-driven flow).
    2. ic.is_controller(caller) (manual dfx deploy).
    3. Init-time controller fallback for older basilisk runtimes.
      Once any admin exists, every admin claim — including by the creator — requires a valid invite code.
  • New @update set_creator_principal(principal): controller-only. Idempotent on the same value, refuses to silently overwrite a different value (so a compromised controller cannot quietly transfer bootstrap-admin rights). Genuine creator changes go through governance after admin is established.
  • New @update mint_bootstrap_admin_invite(expires_in_hours: nat): controller-only, allowed only while the realm has zero admin Users. Calls admin_dashboard.generate_registration_url via extension_sync_call to mint a single-use admin invitation and returns { code, code_hash, expires_at, profile } packed as JSON in the response message. The plaintext is returned exactly once and never persisted in canister state.
  • _do_join_realm shared by join_realm (legacy 2-arg, signature-compatible) and the new join_realm_with_invite(profile, preferred_quarter, invite_code).

src/realm_frontend/src/routes/(no-sidebar)/join/+page.svelte

  • Reads ?invite=<code> (with ?code= as a legacy alias) on mount and calls validate_registration_code to discover the invite's profile.
  • The "Select Profile" step now renders only the profile card matching the invite. Without an invite it renders only the Member card by default (the legacy TEST_MODE_ADMIN_SELF_REGISTRATION flag still surfaces the Admin card for dev/E2E).
  • Auto-selects the only available profile so the user just clicks "Join Realm".
  • Surfaces a yellow banner explaining what's wrong (expired/revoked/used) if the invite is rejected, while still letting the user fall back to a member join.
  • Uses join_realm_with_invite when an invite is present.

src/realm_frontend/src/lib/realm_backend.did.js

  • Snapshot updated to include join_realm_with_invite, set_creator_principal, mint_bootstrap_admin_invite. The actually-bundled declarations come from dfx generate against the basilisk-emitted .did file, so this is informational.

extensions submodule (admin_dashboard 1.0.7)

  • New RegistrationCode fields: profile, max_uses, uses_count, principals_redeemed, revoked (legacy used/used_at kept for backwards compatibility).
  • Hash-at-rest secret model. Canister state is replicated to every node in the subnet, so anything written to stable storage is in principle visible to a node operator with disk access. The plaintext invitation code is therefore never persisted — only code_hash = sha256(plaintext) is stored, in a new code_hash field that becomes the entity alias.
    • The plaintext is generated in transient call-execution memory inside generate_registration_url, returned once in the response so the caller can build the join URL, and immediately discarded by the canister. There is no API for retrieving a code's plaintext after that — if it's lost, revoke and re-mint.
    • validate_registration_code and consume_registration_code accept the plaintext over the wire, hash it on the fly, and look up by code_hash.
    • revoke_registration_code accepts EITHER a plaintext code (back-compat) OR a code_hash (the path the admin UI uses, since the listing exposes only the hash).
    • get_registration_codes rows expose code_hash but never the plaintext or the registration URL.
  • New consume_registration_code and revoke_registration_code endpoints; admin-only enforcement on generate_registration_url (with bootstrap exception when no admin exists yet).
  • registration_url now points at /join?invite=<code> so it lands the invitee directly in the realm join wizard.
  • RegistrationUrlManager.svelte gains a profile selector, a max_uses field, a Revoke-by-code_hash button, and a richer status column. The "Generated URL" panel warns "Copy this link now — it is shown only once" with an explanation of why. The listing column "Code" becomes "Code hash" (12-char prefix with full hash on hover); the per-row "Copy URL" action is removed because the URL cannot be reconstructed from the hash.
  • The broken UserRegistration.svelte is replaced by a thin redirect to /join?invite=<code> for back-compat with pre-1.0.6 links.
  • New SvelteKit routes mount the manager: (sidebar)/admin/invitations and (sidebar)/extensions/admin_dashboard/invitations.
  • AdminDashboard.svelte exposes the manager via an "Invitations" widget.
  • tests/test_registration_codes.py is extended to cover: profile-aware minting, single-use enforcement, multi-use redemption tracking with per-principal guard, revocation by both code_hash and plaintext, unknown-profile rejection, and explicit assertions that the listing never leaks the plaintext code or registration URL and that persisted entities expose only code_hash.

Extensions PR: https://github.com/smart-social-contracts/realms-extensions/pull/new/cursor/invitation-system-7b00

tests/integration/test_invitations_join.py (new)

Plain-Python integration test (matches the convention of every other tests/integration/test_*.py script — no pytest dependency) exercising the full end-to-end contract:

  1. Mint a member invite via extension_sync_call → admin_dashboard.generate_registration_url and assert the response carries both code (plaintext, returned once) and code_hash (durable identifier), and that they differ.
  2. Assert get_registration_codes listings never leak the plaintext or a reconstructed URL.
  3. Verify validate_registration_code returns the same profile.
  4. set_creator_principal records the dfx identity's principal, is idempotent on the same value, and refuses to silently overwrite a different value.
  5. mint_bootstrap_admin_invite returns a usable single-use admin invite with plaintext distinct from hash, while no admin exists.
  6. Call realm_backend.join_realm_with_invite("member", "", code) and assert the returned userGet.profiles contains "member".
  7. Re-call with the same code and assert it's rejected (single-use guard).
  8. Bootstrap-then-reject for admin self-join: first call may succeed (controller bootstrap) but a second call must fail with an "admin invitation required" error.
  9. mint_bootstrap_admin_invite is rejected once an admin exists, with an "already has admin" error.
  10. Bogus invite codes are rejected.

The test gracefully skips the body if admin_dashboard isn't installed (so it's safe to keep in CI even when the test mundus chooses not to install the extension).

deployments/local-mundus.yml

The descriptor's verify.integration_tests list pointed at nonexistent tests/backend/* paths (the files actually live under tests/integration/). Today that list isn't read by scripts/ci_install_mundus.py (it's only consumed by _verify-mundus.yml, which ci-pr.yml doesn't invoke), so the wrong paths were silently ineffective. This PR fixes them and adds the new test to the list, so when CI gets wired through _verify-mundus.yml the suite picks up automatically.

Behaviour summary after this PR

Scenario Profile granted
Fresh realm, set_creator_principal(B) recorded by canister-management, user signs in as B, calls join_realm("admin", …) admin (creator bootstrap)
Fresh realm, dfx identity is canister controller, calls join_realm("admin", …) admin (controller bootstrap)
Fresh realm, anyone else calls join_realm("admin", …) rejected
Anyone, join_realm("member", …) member
?invite=<member_code> → wizard auto-selects Member, calls join_realm_with_invite member, code consumed
?invite=<admin_code> → wizard auto-selects Administrator, calls join_realm_with_invite admin, code consumed
Same principal redeems same invite twice rejected (per-principal guard)
Multi-use code consumed by max_uses distinct principals, then a new principal rejected (capacity)
Admin clicks Revoke in the manager UI code immediately rejected by validate_registration_code
Node operator dumps canister state at rest sees only sha256(code), useless without inverting SHA-256
mint_bootstrap_admin_invite after admin exists rejected with "already has admin" error
Compromised controller calls set_creator_principal(<attacker>) after the legitimate creator was set rejected — silent overwrite refused

Threat model & residual risk

Hash-at-rest closes the largest exposure surface: even if a node operator can read the canister's stable storage on disk, all they see are SHA-256 digests of single-use, short-TTL invitation codes — useless without inverting SHA-256.

The remaining exposure is in transit: when the user submits the plaintext code in join_realm_with_invite's args, the IC's consensus nodes processing that update call momentarily see the code in the call payload. That exposure is the same as for every other authenticated update call on the IC (ckBTC transfers, NNS votes, every authenticated extension call), and the IC's whole security story rests on "honest majority of nodes per subnet". It is mitigated by:

  • Single-use. Once any principal redeems the code, it's burned. Even if a malicious node sees the code in transit, the legitimate user's call almost always arrives first; if the attacker did somehow win the race, the legitimate user gets a clear "code already used" error and asks for a new one.
  • Per-principal binding. principals_redeemed blocks the same principal from burning a multi-use code twice.
  • Short TTL. 24h default; admins can set it shorter.

A vetKeys-based design (IBE-encrypt the bootstrap secret to the creator's principal, deliver the decryption key wrapped under the user's ephemeral browser key so it's never plaintext on the wire) closes the in-transit gap too. It's a backward-compatible upgrade that can ship in a follow-up PR once we have a real production realm with actual stakes.

Backwards compatibility

  • join_realm(profile, preferred_quarter) keeps its signature, so every existing CLI, dfx, test, and frontend call site still compiles. Manual dfx deploys (where the dfx identity is the controller) continue to be able to claim bootstrap admin via the ic.is_controller branch.
  • Legacy ?code=<code> URLs minted by older admin_dashboard versions still work: the rewritten UserRegistration.svelte forwards them to /join?invite=<code>.
  • Schema-breaking change in RegistrationCode: any rows minted under the previous in-progress 1.0.6 version (which used a code field) won't load under 1.0.7. Acceptable because 1.0.6 was never released as a stable version and there are no production rows.

CI coverage

  • Admin Dashboard Test workflow (extensions repo): the extended tests/test_registration_codes.py runs automatically — it was already declared in test_config.json's backend_tests.test_files. The new no-leak assertions run inline.
  • PR CI on this repo (ci-pr.yml): continues to run ci_install_mundus.py against the local mundus. The new integration test gracefully skips because local-mundus.yml keeps artifacts.extensions: [] (PR runner doesn't build runtime bundles); when the runner-side bundle build is added, flipping extensions back to all (or adding admin_dashboard explicitly) will make the test run end-to-end with no further code changes.
  • Main CI (ci-main.yml): _verify-mundus.yml already runs verify.integration_tests from the descriptor against staging — once the staging descriptor opts into running them (currently integration_tests: [] with an explicit comment), the new test starts running there.

Open follow-ups (not in this PR)

  • Wire canister-management (registry-side service, lives outside this repo) to call realm.set_creator_principal(B) and realm.mint_bootstrap_admin_invite(24) immediately after deploy, and surface the resulting URL to the registry frontend as the "Claim your realm" button. The realm-side endpoints are now in place; this is purely a registry/canister-management change.
  • Wire tests/integration/test_invitations_join.py (and the existing integration suites) into PR CI by either calling _verify-mundus.yml from ci-pr.yml, or by adding a step that runs bash tests/integration/run_tests.sh against the local replica that ci_install_mundus.py brought up. Bundled with adding the runtime extension build to PR CI.
  • vetKeys-based hardening of the in-transit redemption path (IBE-encrypt the bootstrap secret to the creator's principal and deliver the decryption key wrapped under the user's ephemeral browser key). Backward-compatible upgrade on top of the current hash-at-rest design.
Open in Web Open in Cursor 

cursoragent and others added 7 commits April 18, 2026 20:20
The 2-arg join_realm endpoint keeps its existing signature for
backwards compatibility with every existing caller (tests, dfx
scripts, CLI, frontend) but its semantics are tightened:

- Asking to join as 'admin' is rejected unless the realm has zero
  admin Users yet AND the caller is the canister controller. This
  is the bootstrap path for the realm creator's first sign-in.
- All other calls fall through to a normal 'member' join.

A new endpoint join_realm_with_invite(profile, preferred_quarter,
invite_code) accepts an invite code and:

- Calls admin_dashboard's consume_registration_code via
  extension_sync_call to atomically validate and consume the code
  for the calling principal.
- Grants whatever profile is recorded on the code (member or admin),
  ignoring the requested profile. Single- or multi-use is enforced
  by the extension; the same principal cannot redeem the same code
  twice.

Both endpoints share a private _do_join_realm helper so the quarter
assignment and User registration logic stays in one place.

Co-authored-by: Jose Perez <deucalioncodes@users.noreply.github.com>
…vite

When the realm join page is opened with ?invite=<code> in the URL
(or the legacy ?code=<code> alias), the wizard now:

- Calls admin_dashboard.validate_registration_code through the realm
  backend on mount to discover which profile the invite grants.
- Renders only the matching profile card in the 'Select Profile'
  step, with copy explaining who invited the user. Without an invite,
  the wizard renders only the Member card by default; the legacy
  TEST_MODE_ADMIN_SELF_REGISTRATION test flag still surfaces the
  Admin card for dev/E2E setups.
- Auto-selects the only available profile so the user just clicks
  'Join Realm' to confirm.
- Surfaces a yellow banner explaining what's wrong (expired,
  revoked, already used, …) when the invite is rejected, while
  still letting the user fall back to a member join.
- Calls the new join_realm_with_invite(profile, preferred_quarter,
  invite_code) endpoint to consume the code on the capital. The
  follow-up quarter registration uses the existing 2-arg
  join_realm because the code has already been consumed.

The candid snapshot in src/lib/realm_backend.did.js is updated to
reflect the new endpoint; the actually-used declarations are
generated by dfx at build time from the basilisk-emitted .did file.

Co-authored-by: Jose Perez <deucalioncodes@users.noreply.github.com>
Pins the extensions submodule to the matching commit on
realms-extensions that ships the extended RegistrationCode model and
new consume_registration_code / revoke_registration_code endpoints
required by realm_backend's join_realm_with_invite.

Co-authored-by: Jose Perez <deucalioncodes@users.noreply.github.com>
Adds tests/integration/test_invitations_join.py exercising the full
end-to-end contract introduced in this PR:

  * generate_registration_url(profile=member|admin, max_uses=N)
    via realm_backend.extension_sync_call
  * validate_registration_code returns the same profile
  * join_realm_with_invite(profile, '', invite_code) calls into
    admin_dashboard's consume_registration_code, registers the
    caller as a User, and grants the role stored on the code
  * the same code cannot be consumed twice by the same caller
  * plain join_realm('admin', '') is rejected once an admin exists
    in the realm (we bootstrap on first call, then assert the
    second call is rejected)
  * bogus invite codes are rejected

The test is a plain Python script following the convention of every
other tests/integration/test_*.py file (no pytest dependency). It
guards itself via _has_admin_dashboard_installed and skips cleanly
on realms that do not have admin_dashboard installed (such as
today's PR CI, which deploys with artifacts.extensions: []).

Also fixes deployments/local-mundus.yml: the verify.integration_tests
list pointed at tests/backend/* paths that don't exist on disk
(the actual files live under tests/integration/). The list isn't
consumed by ci_install_mundus.py today (only by ci-main.yml's
_verify-mundus.yml step), so the wrong paths were silently
ineffective; with this fix, the list now correctly reflects the
suites a layered local install should run, and includes the new
invitation test.

Co-authored-by: Jose Perez <deucalioncodes@users.noreply.github.com>
- Adds test_listing_does_not_leak_plaintext_code which asserts that
  get_registration_codes' rows expose only code_hash and never the
  plaintext invitation code or the reconstructed registration URL.
- Tightens test_mint_member_invite_returns_member_profile to also
  require the mint response to include code_hash (the durable
  identifier the admin UI uses for revocation) distinct from the
  plaintext.
- Bumps the extensions submodule pointer to the matching
  realms-extensions commit that switches RegistrationCode to
  hash-at-rest persistence (admin_dashboard 1.0.7).

Co-authored-by: Jose Perez <deucalioncodes@users.noreply.github.com>
Closes the cross-origin principal handoff that ic.is_controller alone
could not handle in the registry-driven deploy flow.

The setup the field solves:
  * User signs in to the registry frontend with II — gets principal A
    (specific to the registry origin).
  * Registry asks canister-management to deploy a fresh realm canister.
    canister-management is the controller of the new canister, NOT
    the human user.
  * User visits the new realm and signs in with II again — now they
    have principal B (specific to the realm origin), which is unrelated
    to A. ic.is_controller(B) is false, so the previous bootstrap path
    'controller can self-promote to admin' did not work for the human.

The new field + endpoints:

  * Realm.creator_principal (String, default ''):
      The principal the human will present when they sign in to the
      deployed realm (i.e. their realm-side II principal B). Set once
      at deploy time by a canister controller.

  * @update set_creator_principal(principal):
      Controller-only. Idempotent on the same value, refuses to
      overwrite a different value (so a compromised controller cannot
      silently transfer bootstrap-admin rights). Genuine creator
      changes go through governance after admin is established.

  * @update mint_bootstrap_admin_invite(expires_in_hours):
      Controller-only, allowed only while the realm has zero admin
      Users. Calls admin_dashboard.generate_registration_url through
      extension_sync_call to mint a single-use admin invitation, and
      returns the plaintext code + hash + expiry packed as JSON in
      the response message field. The plaintext is returned exactly
      once and never persisted in canister state (only its SHA-256
      hash is — see admin_dashboard 1.0.7 hash-at-rest model).
      canister-management is intended to call this immediately after
      install and hand the resulting URL to the registry, which
      shows it to the user as their 'Claim your realm' button.

  * _do_join_realm bootstrap check (admin path with no invite):
      Now allows the caller to claim the first admin profile when
      EITHER (a) caller == Realm.creator_principal, OR (b) caller is
      a current canister controller (manual dfx deploy back-compat),
      OR (c) caller matches the init-time controller fallback for
      older basilisk runtimes that don't expose ic.is_controller.
      Once any admin User exists, every admin claim — including by
      the creator — requires a valid invite code.

Candid snapshot in src/realm_frontend/src/lib/realm_backend.did.js
updated to declare the two new endpoints; the actually-bundled
declarations come from dfx generate against the basilisk-emitted
.did file at build time.

Co-authored-by: Jose Perez <deucalioncodes@users.noreply.github.com>
Adds three integration test cases:

  1. test_set_creator_principal_records_value
       - Asserts the call succeeds for the controller (the dfx
         identity in the test harness).
       - Asserts setting the same value again is idempotent.
       - Asserts setting a different value is rejected (we refuse
         to silently overwrite, to prevent a compromised controller
         from transferring bootstrap-admin rights).

  2. test_mint_bootstrap_admin_invite_when_no_admin_yet
       - Runs while the realm still has zero admins.
       - Asserts the response carries a plaintext code distinct
         from its hash (the plaintext is returned exactly once,
         by design).
       - Validates the resulting code via the extension and asserts
         it carries profile=admin. Does NOT redeem it — that would
         create an admin and pollute later tests.

  3. test_mint_bootstrap_admin_invite_rejected_after_admin_exists
       - Runs after the existing admin-bootstrap test has
         established the first admin.
       - Asserts mint_bootstrap_admin_invite is rejected with an
         'already has admin' error. After the first admin exists,
         further admin invites must be minted by existing admins
         through admin_dashboard, not via this convenience endpoint.

The test list ordering in __main__ is updated so tests 1+2 run
before the admin-bootstrap test, and test 3 runs after it.

Co-authored-by: Jose Perez <deucalioncodes@users.noreply.github.com>
deucalioncodes added a commit that referenced this pull request May 3, 2026
Implement invite-code registration with client-side SHA-256 hashing.
Plaintext codes never reach the backend — only hashes are stored and
verified on-chain.

- Consolidate join_realm + join_realm_with_invite into single
  join_realm(profile, quarter, invite_code_checksum_hex) endpoint
- Add RegistrationCode entity to ggg core library with create/
  validate/consume/revoke/list helpers
- Add open_registration field on Realm for toggleable codeless
  member signup
- Add require_controller decorator in core/access.py
- Frontend join page hashes invite codes via Web Crypto before
  calling backend
- realm_installer passes admin_invite_hash during deployment
- Disable automatic CI triggers (manual workflow_dispatch only)
- Integration tests for invitation flow
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.

2 participants