Wire admin/member invitation links into the realm join wizard#190
Draft
deucalioncodes wants to merge 7 commits into
Draft
Wire admin/member invitation links into the realm join wizard#190deucalioncodes wants to merge 7 commits into
deucalioncodes wants to merge 7 commits into
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.admin_dashboardextension already has a half-builtRegistrationCodemodel and aRegistrationUrlManager.svelteUI, but the codes carry no role, are never actually consumed, and the join wizard never reads them.ic.is_controller(caller)— which works for manualdfxdeploys but fails for the registry-driven flow wherecanister-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:
realm-registry.ic0.app→ has principal A.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:
canister-management.canister-management(a controller of the new canister) immediately callsrealm.set_creator_principal(B)— butBis 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 tocanister-management, which forwards it to the realm.canister-managementcallsrealm.mint_bootstrap_admin_invite(24). The realm usesadmin_dashboardto mint a single-use admin invitation, returns the plaintext URL exactly once, and stores only the SHA-256 hash.https://realm-XYZ.ic0.app/join?invite=<code>.join_realm_with_invite("admin", "", code), and the realm grants admin to B.For manual
dfxdeploys, neither step 2 nor step 3 are needed: the dfx identity is a controller and can self-promote viajoin_realm("admin", "")directly (the existingis_controller-based bootstrap path is preserved as a back-compat).Changes
src/realm_backend/ggg/governance/realm.pycreator_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:caller == Realm.creator_principal(registry-driven flow).ic.is_controller(caller)(manual dfx deploy).Once any admin exists, every admin claim — including by the creator — requires a valid invite code.
@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.@update mint_bootstrap_admin_invite(expires_in_hours: nat): controller-only, allowed only while the realm has zero admin Users. Callsadmin_dashboard.generate_registration_urlviaextension_sync_callto 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_realmshared byjoin_realm(legacy 2-arg, signature-compatible) and the newjoin_realm_with_invite(profile, preferred_quarter, invite_code).src/realm_frontend/src/routes/(no-sidebar)/join/+page.svelte?invite=<code>(with?code=as a legacy alias) on mount and callsvalidate_registration_codeto discover the invite's profile.TEST_MODE_ADMIN_SELF_REGISTRATIONflag still surfaces the Admin card for dev/E2E).join_realm_with_invitewhen an invite is present.src/realm_frontend/src/lib/realm_backend.did.jsjoin_realm_with_invite,set_creator_principal,mint_bootstrap_admin_invite. The actually-bundled declarations come fromdfx generateagainst the basilisk-emitted.didfile, so this is informational.extensionssubmodule (admin_dashboard 1.0.7)RegistrationCodefields:profile,max_uses,uses_count,principals_redeemed,revoked(legacyused/used_atkept for backwards compatibility).code_hash = sha256(plaintext)is stored, in a newcode_hashfield that becomes the entity alias.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_codeandconsume_registration_codeaccept the plaintext over the wire, hash it on the fly, and look up bycode_hash.revoke_registration_codeaccepts EITHER a plaintext code (back-compat) OR acode_hash(the path the admin UI uses, since the listing exposes only the hash).get_registration_codesrows exposecode_hashbut never the plaintext or the registration URL.consume_registration_codeandrevoke_registration_codeendpoints; admin-only enforcement ongenerate_registration_url(with bootstrap exception when no admin exists yet).registration_urlnow points at/join?invite=<code>so it lands the invitee directly in the realm join wizard.RegistrationUrlManager.sveltegains a profile selector, amax_usesfield, a Revoke-by-code_hashbutton, 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.UserRegistration.svelteis replaced by a thin redirect to/join?invite=<code>for back-compat with pre-1.0.6 links.(sidebar)/admin/invitationsand(sidebar)/extensions/admin_dashboard/invitations.AdminDashboard.svelteexposes the manager via an "Invitations" widget.tests/test_registration_codes.pyis extended to cover: profile-aware minting, single-use enforcement, multi-use redemption tracking with per-principal guard, revocation by bothcode_hashand plaintext, unknown-profile rejection, and explicit assertions that the listing never leaks the plaintext code or registration URL and that persisted entities expose onlycode_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_*.pyscript — no pytest dependency) exercising the full end-to-end contract:memberinvite viaextension_sync_call → admin_dashboard.generate_registration_urland assert the response carries bothcode(plaintext, returned once) andcode_hash(durable identifier), and that they differ.get_registration_codeslistings never leak the plaintext or a reconstructed URL.validate_registration_codereturns the same profile.set_creator_principalrecords the dfx identity's principal, is idempotent on the same value, and refuses to silently overwrite a different value.mint_bootstrap_admin_invitereturns a usable single-use admin invite with plaintext distinct from hash, while no admin exists.realm_backend.join_realm_with_invite("member", "", code)and assert the returneduserGet.profilescontains"member".adminself-join: first call may succeed (controller bootstrap) but a second call must fail with an "admin invitation required" error.mint_bootstrap_admin_inviteis rejected once an admin exists, with an "already has admin" error.The test gracefully skips the body if
admin_dashboardisn't installed (so it's safe to keep in CI even when the test mundus chooses not to install the extension).deployments/local-mundus.ymlThe descriptor's
verify.integration_testslist pointed at nonexistenttests/backend/*paths (the files actually live undertests/integration/). Today that list isn't read byscripts/ci_install_mundus.py(it's only consumed by_verify-mundus.yml, whichci-pr.ymldoesn'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.ymlthe suite picks up automatically.Behaviour summary after this PR
set_creator_principal(B)recorded by canister-management, user signs in as B, callsjoin_realm("admin", …)join_realm("admin", …)join_realm("admin", …)join_realm("member", …)?invite=<member_code>→ wizard auto-selects Member, callsjoin_realm_with_invite?invite=<admin_code>→ wizard auto-selects Administrator, callsjoin_realm_with_invitemax_usesdistinct principals, then a new principalvalidate_registration_codesha256(code), useless without inverting SHA-256mint_bootstrap_admin_inviteafter admin existsset_creator_principal(<attacker>)after the legitimate creator was setThreat 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:principals_redeemedblocks the same principal from burning a multi-use code twice.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 theic.is_controllerbranch.?code=<code>URLs minted by olderadmin_dashboardversions still work: the rewrittenUserRegistration.svelteforwards them to/join?invite=<code>.RegistrationCode: any rows minted under the previous in-progress 1.0.6 version (which used acodefield) 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 Testworkflow (extensions repo): the extendedtests/test_registration_codes.pyruns automatically — it was already declared intest_config.json'sbackend_tests.test_files. The new no-leak assertions run inline.ci-pr.yml): continues to runci_install_mundus.pyagainst the local mundus. The new integration test gracefully skips becauselocal-mundus.ymlkeepsartifacts.extensions: [](PR runner doesn't build runtime bundles); when the runner-side bundle build is added, flippingextensionsback toall(or addingadmin_dashboardexplicitly) will make the test run end-to-end with no further code changes.ci-main.yml):_verify-mundus.ymlalready runsverify.integration_testsfrom the descriptor against staging — once the staging descriptor opts into running them (currentlyintegration_tests: []with an explicit comment), the new test starts running there.Open follow-ups (not in this PR)
canister-management(registry-side service, lives outside this repo) to callrealm.set_creator_principal(B)andrealm.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.tests/integration/test_invitations_join.py(and the existing integration suites) into PR CI by either calling_verify-mundus.ymlfromci-pr.yml, or by adding a step that runsbash tests/integration/run_tests.shagainst the local replica thatci_install_mundus.pybrought up. Bundled with adding the runtime extension build to PR CI.