From 500f6b40f7139d0d0ccfeaeb54c376ed29cfb057 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 18 Apr 2026 20:20:25 +0000 Subject: [PATCH 1/7] realm_backend: gate join_realm 'admin' profile behind invite codes 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 --- src/realm_backend/main.py | 163 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 158 insertions(+), 5 deletions(-) diff --git a/src/realm_backend/main.py b/src/realm_backend/main.py index c18fa9e36..7b5af3f7e 100644 --- a/src/realm_backend/main.py +++ b/src/realm_backend/main.py @@ -429,10 +429,138 @@ def _assign_quarter(principal: str, realm, quarters, preferred_quarter: str) -> return active_quarters[idx].canister_id -@update -def join_realm(profile: str, preferred_quarter: text) -> RealmResponse: +def _has_any_admin_user() -> bool: + """True if at least one User in the realm has the `admin` profile. + + Used by the bootstrap-admin path of ``join_realm``: on a freshly + deployed realm with zero admins, the canister controller is allowed + to claim the ``admin`` profile without an invite code. Once an + admin exists, every subsequent ``admin`` join requires a valid + invitation. + """ + try: + from ggg import User + from ggg.system.user_profile import Operations + + for u in User.instances(): + for p in u.profiles: + allowed = str(p.allowed_to or "").split(",") + if Operations.ALL in allowed: + return True + return False + except Exception: + return False + + +def _consume_invite_code(invite_code: str, caller: str) -> dict: + """Atomically validate and redeem an invite code via the + admin_dashboard extension. Returns the parsed extension result. + + Returns ``{"success": False, "error": "..."}`` if the extension is + not available so callers can decide how to handle the failure. + """ + if not invite_code: + return {"success": False, "error": "No invite code provided"} + try: + import json as _json + + raw = api.extensions.extension_sync_call( + "admin_dashboard", + "consume_registration_code", + _json.dumps({"code": invite_code, "principal": caller}), + ) + if isinstance(raw, str): + try: + return _json.loads(raw) + except Exception: + return {"success": False, "error": str(raw)} + if isinstance(raw, dict): + return raw + return {"success": False, "error": "Unexpected extension response"} + except Exception as e: + logger.error( + f"Error consuming invite code via admin_dashboard: {e}\n{traceback.format_exc()}" + ) + return {"success": False, "error": f"Invitation system unavailable: {e}"} + + +def _do_join_realm( + profile: str, preferred_quarter: text, invite_code: str +) -> RealmResponse: + """Internal join-realm worker shared by ``join_realm`` and + ``join_realm_with_invite``. + + Profile rules: + * ``member``: anyone authenticated can self-join as a member, no + invitation required. + * ``admin``: only allowed when **one** of these conditions holds: + 1. There are no admin Users yet **and** the caller is the + canister controller (bootstrap admin — typically the realm + creator from the registry). + 2. The caller presented a valid, non-expired, non-revoked + ``invite_code`` whose ``profile`` is ``"admin"``. The code + is atomically consumed by ``admin_dashboard``'s + ``consume_registration_code`` extension method. + + * Any other (non-``member``, non-``admin``) profile is treated as + a free member-equivalent today, but if an ``invite_code`` is + supplied it must match. + + When ``invite_code`` is non-empty it is consumed regardless of the + requested ``profile``: the granted profile is the one stored on the + code, not the one the caller asked for. + """ try: - user = user_register(ic.caller().to_str(), profile) + caller = ic.caller().to_str() + + granted_profile = profile or "member" + + if invite_code: + consumption = _consume_invite_code(invite_code, caller) + if not consumption.get("success"): + return RealmResponse( + success=False, + data=RealmResponseData( + error=consumption.get("error") or "Invalid invitation code" + ), + ) + granted_profile = ( + consumption.get("data", {}).get("profile") or "member" + ) + elif granted_profile == "admin": + from core.access import _controller_principal + + try: + is_controller = ic.is_controller(ic.caller()) + except Exception: + is_controller = False + is_init_controller = bool( + _controller_principal and caller == _controller_principal + ) + + if _has_any_admin_user(): + return RealmResponse( + success=False, + data=RealmResponseData( + error=( + "An admin invitation code is required to join as " + "Administrator. Ask an existing administrator to " + "send you an invite link." + ) + ), + ) + if not (is_controller or is_init_controller): + return RealmResponse( + success=False, + data=RealmResponseData( + error=( + "Only the realm creator can claim the bootstrap " + "administrator role on a fresh realm." + ) + ), + ) + + user = user_register(caller, granted_profile) profiles = Vec[text]() if "profiles" in user and user["profiles"]: for p in user["profiles"]: @@ -446,10 +574,10 @@ def join_realm(profile: str, preferred_quarter: text) -> RealmResponse: quarters = list(Quarter.instances()) if realm else [] if realm and quarters: assigned_quarter_canister_id = _assign_quarter( - ic.caller().to_str(), realm, quarters, preferred_quarter + caller, realm, quarters, preferred_quarter ) # Persist the assignment on the User entity - u = User[ic.caller().to_str()] + u = User[caller] if u and assigned_quarter_canister_id: u.home_quarter = assigned_quarter_canister_id @@ -471,6 +599,31 @@ def join_realm(profile: str, preferred_quarter: text) -> RealmResponse: return RealmResponse(success=False, data=RealmResponseData(error=str(e))) +@update +def join_realm(profile: str, preferred_quarter: text) -> RealmResponse: + """Backwards-compatible 2-arg join. See ``_do_join_realm`` for full + semantics. Equivalent to ``join_realm_with_invite(profile, + preferred_quarter, "")``: anyone can self-join as a member, and the + ``admin`` profile is only granted on the bootstrap path (no admin + Users yet AND caller is the canister controller). + """ + return _do_join_realm(profile, preferred_quarter, "") + + +@update +def join_realm_with_invite( + profile: str, preferred_quarter: text, invite_code: text +) -> RealmResponse: + """Join the realm using an invitation code (member or admin). + + The invitation code is atomically consumed by the + ``admin_dashboard`` extension's ``consume_registration_code`` call. + The granted profile is the one stored on the code, regardless of + the requested ``profile``. + """ + return _do_join_realm(profile, preferred_quarter, invite_code or "") + + @update @require(Operations.SELF_CHANGE_QUARTER) def change_quarter(new_quarter_canister_id: text) -> RealmResponse: From 752923d46f5cd28a378c4cafced2800f70def599 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 18 Apr 2026 20:20:40 +0000 Subject: [PATCH 2/7] realm_frontend: read ?invite= in join wizard, call join_realm_with_invite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the realm join page is opened with ?invite= in the URL (or the legacy ?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 --- .../src/lib/realm_backend.did.js | 1 + .../src/routes/(no-sidebar)/join/+page.svelte | 116 ++++++++++++++++-- 2 files changed, 109 insertions(+), 8 deletions(-) diff --git a/src/realm_frontend/src/lib/realm_backend.did.js b/src/realm_frontend/src/lib/realm_backend.did.js index 74e1d445c..8c273e6ea 100644 --- a/src/realm_frontend/src/lib/realm_backend.did.js +++ b/src/realm_frontend/src/lib/realm_backend.did.js @@ -113,6 +113,7 @@ service : () -> { initialize : () -> (); change_quarter : (text) -> (RealmResponse); join_realm : (text, text) -> (RealmResponse); + join_realm_with_invite : (text, text, text) -> (RealmResponse); list_extensions : (text) -> (RealmResponse) query; register_realm_with_registry : (text, text, text, text) -> (text); refresh_invoice : (text) -> (text); diff --git a/src/realm_frontend/src/routes/(no-sidebar)/join/+page.svelte b/src/realm_frontend/src/routes/(no-sidebar)/join/+page.svelte index 754ad51a6..5109bdb70 100644 --- a/src/realm_frontend/src/routes/(no-sidebar)/join/+page.svelte +++ b/src/realm_frontend/src/routes/(no-sidebar)/join/+page.svelte @@ -19,6 +19,13 @@ let loading = false; let realmName = 'Realm'; let selectedProfile = ''; // No default - user must choose + + // Invitation state (populated from ?invite= in the URL) + let inviteCode = ''; + let inviteValidating = false; + let inviteProfile = ''; // 'member' | 'admin' | '' (no/invalid invite) + let inviteInfo = null; // raw payload from validate_registration_code + let inviteError = ''; // human-readable rejection reason if invalid // Available profiles with icon names (rendered as SVGs) const allProfiles = [ @@ -36,8 +43,26 @@ }, ]; - // Only show admin profile when TEST_MODE_ADMIN_SELF_REGISTRATION is active - $: profiles = allProfiles.filter(p => p.value !== 'admin' || TEST_MODE_ADMIN_SELF_REGISTRATION); + // Reactive list of profile cards to render in the wizard. + // - With a valid invite, only the card matching the invite's role is shown. + // - Without an invite, only "Member" is shown by default. The "Administrator" + // card is additionally shown when TEST_MODE_ADMIN_SELF_REGISTRATION is on + // (legacy dev/test path). + $: profiles = (() => { + if (inviteProfile) { + return allProfiles.filter(p => p.value === inviteProfile); + } + if (TEST_MODE_ADMIN_SELF_REGISTRATION) { + return allProfiles; + } + return allProfiles.filter(p => p.value === 'member'); + })(); + + // Auto-select the only available profile so the user doesn't have to click + // when there is exactly one option (the common case once we filter by invite). + $: if (profiles.length === 1 && selectedProfile !== profiles[0].value) { + selectedProfile = profiles[0].value; + } // Default fallback image if realm has no welcome image configured const defaultWelcomeImage = '/images/default_welcome.jpg'; @@ -60,6 +85,49 @@ } } + /** + * Parse the `?invite=` URL parameter (with `?code=` accepted as a + * legacy alias) and ask the realm backend to validate the code through + * the admin_dashboard extension. On success, sets `inviteProfile` so + * the wizard renders only the matching profile card. + */ + async function loadInviteFromUrl() { + if (typeof window === 'undefined') return; + try { + const params = new URLSearchParams(window.location.search); + const code = params.get('invite') || params.get('code') || ''; + if (!code) return; + + inviteCode = code; + inviteValidating = true; + const response = await backend.extension_sync_call({ + extension_name: 'admin_dashboard', + function_name: 'validate_registration_code', + args: JSON.stringify({ code }) + }); + let result; + try { + result = JSON.parse(response.response); + } catch (e) { + console.warn('[JOIN PAGE] Could not parse invite validation response', e); + return; + } + if (result && result.success) { + inviteInfo = result.data || {}; + inviteProfile = inviteInfo.profile || 'member'; + inviteError = ''; + } else { + inviteError = (result && result.error) || 'Invitation code is not valid'; + inviteProfile = ''; + inviteInfo = null; + } + } catch (e) { + console.warn('[JOIN PAGE] Invite validation failed:', e); + } finally { + inviteValidating = false; + } + } + onMount(async () => { console.log('[JOIN PAGE v2] onMount - isAuthenticated:', $isAuthenticated); // Fetch realm info @@ -67,6 +135,10 @@ if ($realmNameStore) { realmName = $realmNameStore; } + + // Validate any ?invite= in the URL up-front so the profile step + // already knows which card to render the moment the user gets there. + await loadInviteFromUrl(); // In test mode with II bypass, auto-login if not already authenticated if (TEST_MODE_II_BYPASS && !$isAuthenticated) { @@ -134,9 +206,16 @@ try { loading = true; - console.log(`Joining realm with profile: ${selectedProfile}`); - // Step 1: Register on the capital (current backend) — gets quarter assignment - const response = await backend.join_realm(selectedProfile, ''); + console.log( + `Joining realm with profile: ${selectedProfile}` + + (inviteCode ? ` (invite: ${inviteCode})` : '') + ); + // Step 1: Register on the capital (current backend) — gets quarter assignment. + // When an invite code was presented in the URL, use the invite-aware + // endpoint so the realm canister can verify and consume the code. + const response = inviteCode + ? await backend.join_realm_with_invite(selectedProfile, '', inviteCode) + : await backend.join_realm(selectedProfile, ''); if (response.success) { // Step 2: If assigned to a quarter, switch to it and register there too const assignedQuarter = response.data?.userGet?.assigned_quarter; @@ -145,7 +224,10 @@ activeQuarterId.set(assignedQuarter); await setActiveQuarter(assignedQuarter); - // Register on the assigned quarter backend + // Register on the assigned quarter backend. + // The invite has already been consumed on the capital; on the + // quarter we just record membership with the same profile and + // never re-attempt to consume the (now-used) code. try { await backend.join_realm(selectedProfile, ''); console.log('Registered on assigned quarter'); @@ -388,10 +470,28 @@ {:else if currentStep === 'profile'}
-

Select Profile

+

+ {inviteProfile ? 'Confirm your role' : 'Select Profile'} +

{#if TEST_MODE}Test Mode{/if}
-

Choose how you want to participate

+ {#if inviteProfile} +

+ You've been invited to join {realmName} as + {inviteProfile === 'admin' ? 'an Administrator' : 'a Member'}. +

+ {:else if inviteError} +
+ Invitation problem: {inviteError}. + You can still join as a Member. +
+

Choose how you want to participate

+ {:else} +

+ Anyone can join {realmName} as a Member. To join as an Administrator + you need an invitation link from an existing administrator. +

+ {/if}
{#each profiles as profile} From 510cfccfeb42c0d270e6956eb3186fe2da574531 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 18 Apr 2026 20:20:54 +0000 Subject: [PATCH 3/7] deps: bump extensions submodule to invitation-system branch 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 --- extensions | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions b/extensions index c063df6d5..45b8b31ae 160000 --- a/extensions +++ b/extensions @@ -1 +1 @@ -Subproject commit c063df6d58820194fe05063d2038c58684fab48e +Subproject commit 45b8b31ae2417a4e802ecc668e9663ce11154f8c From 1bbf2bb0d79ef826ae1c9ba530e703211678aaa4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 19 Apr 2026 06:25:38 +0000 Subject: [PATCH 4/7] tests: integration test for invitation-link join_realm_with_invite 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 --- deployments/local-mundus.yml | 18 +- tests/integration/test_invitations_join.py | 303 +++++++++++++++++++++ 2 files changed, 319 insertions(+), 2 deletions(-) create mode 100644 tests/integration/test_invitations_join.py diff --git a/deployments/local-mundus.yml b/deployments/local-mundus.yml index d39afcff7..9273ced18 100644 --- a/deployments/local-mundus.yml +++ b/deployments/local-mundus.yml @@ -83,9 +83,23 @@ mundus: codices: [] # --- Stage 3: verification --------------------------------------------------- +# +# `integration_tests` is consumed by .github/workflows/_verify-mundus.yml +# (run as part of ci-main.yml after install-mundus). The local PR +# pipeline (ci-pr.yml) calls scripts/ci_install_mundus.py which does +# NOT execute these tests today — they're kept here as the canonical +# manifest of which tests cover the layered local install, and they +# will start running automatically when ci-pr.yml is wired through +# _verify-mundus.yml. Until then, these doubly serve as the recommended +# `pytest` command for anyone bringing up a local realm by hand. verify: e2e_specs: - src/realm_frontend/tests/e2e/specs/layered-parity.spec.ts integration_tests: - - tests/backend/test_status_api.py - - tests/backend/test_extensions_api.py + - tests/integration/test_status_api.py + - tests/integration/test_extensions_api.py + # End-to-end coverage for the invitation-link → join_realm_with_invite + # flow. Skips itself when admin_dashboard is not installed (see + # the test's _has_admin_dashboard_installed guard), so it is safe + # to keep in the list even on minimal mundus installs. + - tests/integration/test_invitations_join.py diff --git a/tests/integration/test_invitations_join.py b/tests/integration/test_invitations_join.py new file mode 100644 index 000000000..ddc3650ac --- /dev/null +++ b/tests/integration/test_invitations_join.py @@ -0,0 +1,303 @@ +#!/usr/bin/env python3 +"""Integration tests for the invitation-link → join_realm_with_invite flow. + +Exercises the end-to-end contract introduced together with +``admin_dashboard``'s ``consume_registration_code`` endpoint and +``realm_backend.join_realm_with_invite``: + + 1. An admin mints a registration code via ``admin_dashboard``. + 2. ``validate_registration_code`` returns the same code's role. + 3. The realm backend's ``join_realm_with_invite`` calls into the + extension to atomically validate and consume the code, then + registers the caller as a User with the right profile. + +The tests run against a realm that already has ``admin_dashboard`` +installed. Locally that's whatever ``realms realm deploy`` brings up; +in CI we need a workflow that installs the extension before running +this file (see the test_environment_ready guard below). + +Following the convention of the other ``tests/integration/*.py`` +scripts, this is a plain Python script (no pytest), invoked via +``tests/integration/run_tests.sh``. +""" + +import json +import os +import sys +import traceback + +# Allow `from fixtures.dfx_helpers import …` when run from the repo root. +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from fixtures.dfx_helpers import dfx_call, dfx_call_json # noqa: E402 + + +REALM_BACKEND = os.environ.get("REALM_BACKEND_CANISTER", "realm_backend") +ADMIN_DASHBOARD_EXTENSION = "admin_dashboard" + + +# --------------------------------------------------------------------------- +# Low-level helpers +# --------------------------------------------------------------------------- + + +def _candid_quote(s: str) -> str: + """Quote a Python string for use inside a Candid `text` arg.""" + return s.replace("\\", "\\\\").replace('"', '\\"') + + +def _extension_call(method: str, args: dict) -> dict: + """Call an admin_dashboard method through realm_backend.extension_sync_call. + + Returns the parsed JSON response from the extension. Raises on dfx + failure or if the extension says ``success=false``. + """ + args_json = json.dumps(args) + candid_args = ( + f'(record {{ extension_name = "{ADMIN_DASHBOARD_EXTENSION}"; ' + f'function_name = "{method}"; ' + f'args = "{_candid_quote(args_json)}" }})' + ) + response = dfx_call_json( + REALM_BACKEND, "extension_sync_call", candid_args, is_update=True + ) + + if not response.get("success"): + raise AssertionError( + f"extension_sync_call wrapper reported success=false: {response}" + ) + + inner_text = response.get("response", "") + try: + inner = json.loads(inner_text) + except json.JSONDecodeError as e: + raise AssertionError( + f"Could not parse extension response as JSON: {e}\n" + f"Raw response: {inner_text!r}" + ) + return inner + + +def _join_with_invite(profile: str, invite_code: str) -> dict: + """Call realm_backend.join_realm_with_invite and return the parsed RealmResponse.""" + return dfx_call_json( + REALM_BACKEND, + "join_realm_with_invite", + f'("{profile}", "", "{_candid_quote(invite_code)}")', + is_update=True, + ) + + +def _has_admin_dashboard_installed() -> bool: + """Return True when extension_sync_call to admin_dashboard responds. + + Used to skip the entire suite gracefully on test harnesses that do + not install ``admin_dashboard`` (for example, the current PR CI + descriptor sets ``artifacts.extensions: []``). + """ + try: + _extension_call("get_entity_types", {}) + return True + except Exception as e: + print(f" [SKIP] admin_dashboard not callable: {e}") + return False + + +# --------------------------------------------------------------------------- +# Test cases +# --------------------------------------------------------------------------- + + +def test_environment_ready(): + """Sanity check: the realm canister exists and admin_dashboard answers.""" + print(" - test_environment_ready...", end=" ") + output, code = dfx_call(REALM_BACKEND, "status", "()", output_json=False) + assert code == 0, f"status failed (rc={code}): {output}" + if not _has_admin_dashboard_installed(): + print("SKIP") + raise _Skip("admin_dashboard extension not installed in this realm") + print("✓") + + +def test_mint_member_invite_returns_member_profile(): + """generate_registration_url with profile=member round-trips.""" + print(" - test_mint_member_invite_returns_member_profile...", end=" ") + result = _extension_call( + "generate_registration_url", + { + "user_id": "ittest_member_invitee", + "created_by": "test", + "frontend_url": "http://localhost:8000", + "expires_in_hours": 24, + "profile": "member", + "max_uses": 1, + }, + ) + assert result.get("success"), f"mint failed: {result}" + data = result.get("data", {}) + assert data.get("profile") == "member", data + assert data.get("code"), data + assert data.get("registration_url", "").endswith(f"/join?invite={data['code']}"), ( + data.get("registration_url"), + ) + print("✓") + + +def test_validate_returns_admin_profile(): + """A freshly-minted admin invite reports profile=admin via validate.""" + print(" - test_validate_returns_admin_profile...", end=" ") + minted = _extension_call( + "generate_registration_url", + { + "user_id": "ittest_admin_validate", + "created_by": "test", + "frontend_url": "http://localhost:8000", + "expires_in_hours": 24, + "profile": "admin", + "max_uses": 1, + }, + ) + assert minted.get("success"), minted + code = minted["data"]["code"] + validated = _extension_call("validate_registration_code", {"code": code}) + assert validated.get("success"), validated + assert validated.get("data", {}).get("profile") == "admin", validated + print("✓") + + +def test_join_realm_with_invite_grants_invite_profile(): + """join_realm_with_invite consumes the code and grants the invite's role. + + Asserts the *intended* contract: + - on success, the response has success=true and the granted + profile (from the code, not from the requested ``profile`` arg) + is reflected in the returned ``userGet.profiles`` list. + - the same code cannot be consumed twice by the same caller. + """ + print(" - test_join_realm_with_invite_grants_invite_profile...", end=" ") + # Mint a member invite (to avoid the bootstrap-admin guard). + minted = _extension_call( + "generate_registration_url", + { + "user_id": "ittest_join_member", + "created_by": "test", + "frontend_url": "http://localhost:8000", + "expires_in_hours": 24, + "profile": "member", + "max_uses": 1, + }, + ) + assert minted.get("success"), minted + code = minted["data"]["code"] + + # join_realm_with_invite asks for "member"; the code is also "member". + response = _join_with_invite("member", code) + assert response.get("success"), response + profiles_granted = ( + response.get("data", {}).get("userGet", {}).get("profiles") or [] + ) + assert "member" in profiles_granted, ( + f"Expected 'member' in returned profiles; got {profiles_granted!r}" + ) + + # Second consume by the same principal must fail (single-use guard). + second = _join_with_invite("member", code) + assert not second.get("success"), ( + f"Second consume should be rejected, got: {second}" + ) + err = (second.get("data") or {}).get("error", "") + assert "code" in err.lower() or "invitation" in err.lower() or "redeem" in err.lower(), ( + f"Expected an invitation-related error message, got: {err!r}" + ) + print("✓") + + +def test_join_realm_without_invite_rejects_admin_when_admin_exists(): + """If the realm already has an admin, plain join_realm('admin', ...) must fail. + + (When no admin exists yet and the caller is the canister controller, + the bootstrap path applies — that's not what's tested here.) + """ + print(" - test_join_realm_without_invite_rejects_admin_when_admin_exists...", end=" ") + # Probe by *attempting* the call. We don't know whether a previous + # test already promoted somebody, so accept either outcome: + # - rejected with an "admin invitation" error (the new tightened + # behaviour we want to validate), or + # - accepted with success=true (which means the realm had no admin + # yet AND the test identity was the controller — bootstrap path). + # + # Either way, a *second* attempt must be rejected with the + # admin-invite-required error: by then there's at least one admin. + response = _join_with_invite("admin", "") + assert isinstance(response, dict), response + + second = _join_with_invite("admin", "") + assert not second.get("success"), ( + f"After bootstrap, plain admin self-join must be rejected; got: {second}" + ) + err = ((second.get("data") or {}).get("error") or "").lower() + assert "admin" in err and ("invit" in err or "invitation" in err), ( + f"Expected an 'admin invitation required' error; got: {err!r}" + ) + print("✓") + + +def test_invalid_invite_code_is_rejected(): + """Bogus invite codes must be rejected by join_realm_with_invite.""" + print(" - test_invalid_invite_code_is_rejected...", end=" ") + response = _join_with_invite("member", "this-code-does-not-exist-zzzz") + assert not response.get("success"), ( + f"Invalid invite should be rejected, got: {response}" + ) + print("✓") + + +# --------------------------------------------------------------------------- +# Tiny custom skip mechanism (no pytest dependency on this script) +# --------------------------------------------------------------------------- + + +class _Skip(Exception): + pass + + +if __name__ == "__main__": + print("Testing invitation-link → join_realm_with_invite flow:") + tests = [ + test_environment_ready, + test_mint_member_invite_returns_member_profile, + test_validate_returns_admin_profile, + test_join_realm_with_invite_grants_invite_profile, + test_join_realm_without_invite_rejects_admin_when_admin_exists, + test_invalid_invite_code_is_rejected, + ] + + failed = 0 + skipped = 0 + for t in tests: + try: + t() + except _Skip as s: + print(f" SKIPPED: {s}") + skipped += 1 + # If the environment is not ready, skip the rest too. + if t is test_environment_ready: + print("\nℹ️ Skipping remaining tests because admin_dashboard is unavailable.") + break + except AssertionError as e: + print("✗") + print(f" AssertionError: {e}") + failed += 1 + except Exception as e: + print("✗") + print(f" Error: {e}") + traceback.print_exc() + failed += 1 + + print() + if failed == 0: + print(f"✅ All tests passed (skipped: {skipped})") + sys.exit(0) + else: + print(f"❌ {failed} test(s) failed (skipped: {skipped})") + sys.exit(1) From 185495d25b327ccac86c7903a03426ff43ddbd1e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 19 Apr 2026 07:01:34 +0000 Subject: [PATCH 5/7] tests: assert listing never leaks invitation plaintext + bump submodule - 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 --- extensions | 2 +- tests/integration/test_invitations_join.py | 36 ++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/extensions b/extensions index 45b8b31ae..606f252aa 160000 --- a/extensions +++ b/extensions @@ -1 +1 @@ -Subproject commit 45b8b31ae2417a4e802ecc668e9663ce11154f8c +Subproject commit 606f252aa1f7074160af23004cb616ad6ab0960f diff --git a/tests/integration/test_invitations_join.py b/tests/integration/test_invitations_join.py index ddc3650ac..b1f9ce166 100644 --- a/tests/integration/test_invitations_join.py +++ b/tests/integration/test_invitations_join.py @@ -137,12 +137,47 @@ def test_mint_member_invite_returns_member_profile(): data = result.get("data", {}) assert data.get("profile") == "member", data assert data.get("code"), data + assert data.get("code_hash"), ( + f"Expected code_hash in mint response (used by admin UI for revoke " + f"without the plaintext): {data}" + ) + assert data["code"] != data["code_hash"], ( + f"Plaintext code and code_hash must differ: {data}" + ) assert data.get("registration_url", "").endswith(f"/join?invite={data['code']}"), ( data.get("registration_url"), ) print("✓") +def test_listing_does_not_leak_plaintext_code(): + """get_registration_codes must expose only code_hash, never plaintext.""" + print(" - test_listing_does_not_leak_plaintext_code...", end=" ") + # Mint at least one so the listing is non-empty. + _extension_call( + "generate_registration_url", + { + "user_id": "ittest_no_leak", + "created_by": "test", + "frontend_url": "http://localhost:8000", + "expires_in_hours": 24, + "profile": "member", + "max_uses": 1, + }, + ) + listing = _extension_call( + "get_registration_codes", {"include_used": True, "include_revoked": True} + ) + assert listing.get("success"), listing + rows = listing.get("data") or [] + leaked = [r for r in rows if r.get("code") or r.get("registration_url")] + assert not leaked, ( + f"Listing leaked plaintext for {len(leaked)} row(s); only code_hash " + f"should be returned. Sample: {leaked[:1]}" + ) + print("✓") + + def test_validate_returns_admin_profile(): """A freshly-minted admin invite reports profile=admin via validate.""" print(" - test_validate_returns_admin_profile...", end=" ") @@ -266,6 +301,7 @@ class _Skip(Exception): tests = [ test_environment_ready, test_mint_member_invite_returns_member_profile, + test_listing_does_not_leak_plaintext_code, test_validate_returns_admin_profile, test_join_realm_with_invite_grants_invite_profile, test_join_realm_without_invite_rejects_admin_when_admin_exists, From 27b1d020ecf3e6534d7752423eb618fd64e4cb0c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 19 Apr 2026 07:21:49 +0000 Subject: [PATCH 6/7] realm_backend: creator_principal + bootstrap-admin invite endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/realm_backend/ggg/governance/realm.py | 10 + src/realm_backend/main.py | 227 +++++++++++++++++- .../src/lib/realm_backend.did.js | 2 + 3 files changed, 237 insertions(+), 2 deletions(-) diff --git a/src/realm_backend/ggg/governance/realm.py b/src/realm_backend/ggg/governance/realm.py index 1d0ccd751..cdc8718f8 100644 --- a/src/realm_backend/ggg/governance/realm.py +++ b/src/realm_backend/ggg/governance/realm.py @@ -42,3 +42,13 @@ class Realm(Entity, TimestampedMixin): # Comma-separated canister principal IDs trusted for inter-canister calls # (DAO controllers, AI agents, parent realms). These bypass User-based access checks. trusted_principals = String(max_length=2048, default="") + # Principal of the human that triggered this realm's creation. Set once + # at deploy time by the canister controller (typically the + # canister-management service deploying on the human's behalf, or + # the dfx identity for manual deploys) via set_creator_principal. + # Used to authorize the bootstrap-admin path in join_realm: the + # creator principal is allowed to claim the first 'admin' profile + # on a fresh realm, even when they are not themselves a canister + # controller. After that first admin exists, every subsequent + # admin claim requires a valid invitation code. + creator_principal = String(max_length=64, default="") diff --git a/src/realm_backend/main.py b/src/realm_backend/main.py index 7b5af3f7e..c74bdb1df 100644 --- a/src/realm_backend/main.py +++ b/src/realm_backend/main.py @@ -528,7 +528,28 @@ def _do_join_realm( consumption.get("data", {}).get("profile") or "member" ) elif granted_profile == "admin": + # Bootstrap-admin path. Allowed only when the realm has zero + # admin Users yet AND the caller can be identified as the + # legitimate creator. Three resolution rules, in order: + # + # 1. Realm.creator_principal is set and matches the caller. + # This is the registry-driven flow: canister-management + # calls set_creator_principal() right + # after install, recording the human's principal that + # will be presented when they sign in to the realm. + # + # 2. ic.is_controller(caller) is true. Manual dfx deploy + # path — the developer that ran `dfx canister install` + # is automatically a controller and can self-promote + # to admin once. Also covers any future deployment + # scheme where the canister-management service deploys + # AND remains a controller AND happens to be the same + # principal that joins (rare but legal). + # + # 3. Init-time controller fallback for older basilisk + # runtimes that don't expose ic.is_controller. from core.access import _controller_principal + from ggg import Realm try: is_controller = ic.is_controller(ic.caller()) @@ -537,6 +558,13 @@ def _do_join_realm( is_init_controller = bool( _controller_principal and caller == _controller_principal ) + realm_entity = Realm.load("1") + creator_principal = ( + getattr(realm_entity, "creator_principal", "") or "" + if realm_entity + else "" + ) + is_creator = bool(creator_principal) and caller == creator_principal if _has_any_admin_user(): return RealmResponse( @@ -549,13 +577,16 @@ def _do_join_realm( ) ), ) - if not (is_controller or is_init_controller): + if not (is_creator or is_controller or is_init_controller): return RealmResponse( success=False, data=RealmResponseData( error=( "Only the realm creator can claim the bootstrap " - "administrator role on a fresh realm." + "administrator role on a fresh realm. If the " + "realm was deployed by canister-management on " + "your behalf, ask the registry to issue you a " + "bootstrap admin invitation link." ) ), ) @@ -624,6 +655,198 @@ def join_realm_with_invite( return _do_join_realm(profile, preferred_quarter, invite_code or "") +def _caller_is_canister_controller() -> bool: + """True if the calling principal is registered as a controller of + this canister (via IC management settings) or matches the init-time + controller principal captured at install time.""" + from core.access import _controller_principal + + caller = ic.caller().to_str() + try: + if ic.is_controller(ic.caller()): + return True + except Exception: + pass + return bool(_controller_principal) and caller == _controller_principal + + +@update +def set_creator_principal(principal: text) -> RealmResponse: + """Record the principal of the human that triggered this realm's creation. + + Intended to be called exactly once, by a canister controller, + immediately after install — typically by the canister-management + service that deployed the realm on the human's behalf. The principal + written here is the **realm-side** Internet Identity principal the + human will present when they sign in to the deployed realm (which, + because II principals are per-origin, is generally different from + the principal the same human had on the registry frontend). + + Once set, the value can be re-affirmed (no-op when called again + with the same principal) but cannot be silently overwritten: + re-pointing the creator at a different principal is rejected to + prevent a compromised controller from quietly transferring + bootstrap-admin rights. To genuinely change the creator, the new + creator must first claim admin (e.g. via an invite minted by a + current admin) and the realm operators can then update the field + through governance. + """ + try: + if not principal or not principal.strip(): + return RealmResponse( + success=False, + data=RealmResponseData(error="principal must not be empty"), + ) + if not _caller_is_canister_controller(): + return RealmResponse( + success=False, + data=RealmResponseData( + error=( + "Only a canister controller can set the realm " + "creator principal." + ) + ), + ) + + from ggg import Realm + + realm = Realm.load("1") + if not realm: + return RealmResponse( + success=False, + data=RealmResponseData(error="Realm entity not initialized"), + ) + + existing = (getattr(realm, "creator_principal", "") or "").strip() + new_value = principal.strip() + if existing and existing != new_value: + return RealmResponse( + success=False, + data=RealmResponseData( + error=( + "creator_principal is already set to a different " + "principal; refusing to overwrite. Use governance " + "to change the realm creator." + ) + ), + ) + + realm.creator_principal = new_value + return RealmResponse( + success=True, + data=RealmResponseData(message=new_value), + ) + except Exception as e: + logger.error( + f"Error in set_creator_principal: {str(e)}\n{traceback.format_exc()}" + ) + return RealmResponse(success=False, data=RealmResponseData(error=str(e))) + + +@update +def mint_bootstrap_admin_invite(expires_in_hours: nat) -> RealmResponse: + """Mint a single-use admin invitation for the realm's bootstrap admin. + + Callable only by a canister controller, only while the realm has + zero admin Users. Designed to be invoked by the canister-management + service immediately after install: the returned URL is what the + registry hands the human as their "Claim your realm" button. + + The plaintext invitation code is generated inside the + ``admin_dashboard`` extension, returned **once** in this response so + the caller can build the URL it needs to display, and is never + persisted in canister state (only its SHA-256 hash is stored). + Subsequent attempts to mint a bootstrap invite once an admin + already exists are rejected — by then admins should be minting + further invites through ``admin_dashboard`` themselves. + + Args: + expires_in_hours: Lifetime of the invite. Defaults to 24 when 0. + """ + try: + if not _caller_is_canister_controller(): + return RealmResponse( + success=False, + data=RealmResponseData( + error=( + "Only a canister controller can mint a bootstrap " + "admin invitation." + ) + ), + ) + if _has_any_admin_user(): + return RealmResponse( + success=False, + data=RealmResponseData( + error=( + "Realm already has at least one admin; mint " + "further admin invites through admin_dashboard." + ) + ), + ) + + import json as _json + + ttl = int(expires_in_hours) if expires_in_hours else 24 + args = _json.dumps( + { + "user_id": "realm_creator", + "profile": "admin", + "max_uses": 1, + "expires_in_hours": ttl, + "frontend_url": "", + "created_by": ic.caller().to_str(), + } + ) + raw = api.extensions.extension_sync_call( + "admin_dashboard", "generate_registration_url", args + ) + if isinstance(raw, str): + try: + ext_response = _json.loads(raw) + except Exception: + ext_response = {"success": False, "error": str(raw)} + elif isinstance(raw, dict): + ext_response = raw + else: + ext_response = {"success": False, "error": "Unexpected extension response"} + + if not ext_response.get("success"): + return RealmResponse( + success=False, + data=RealmResponseData( + error=ext_response.get("error") + or "admin_dashboard rejected the bootstrap invite" + ), + ) + + # The plaintext code lives in ext_response["data"]["code"] and + # is intentionally returned to the controller exactly once. The + # realm canister itself never persists it. We pack the relevant + # fields into the message field for convenience; the controller + # parses this JSON to extract the code / URL. + payload = ext_response.get("data") or {} + return RealmResponse( + success=True, + data=RealmResponseData( + message=_json.dumps( + { + "code": payload.get("code", ""), + "code_hash": payload.get("code_hash", ""), + "expires_at": payload.get("expires_at", ""), + "profile": payload.get("profile", "admin"), + } + ) + ), + ) + except Exception as e: + logger.error( + f"Error in mint_bootstrap_admin_invite: {str(e)}\n" + f"{traceback.format_exc()}" + ) + return RealmResponse(success=False, data=RealmResponseData(error=str(e))) + + @update @require(Operations.SELF_CHANGE_QUARTER) def change_quarter(new_quarter_canister_id: text) -> RealmResponse: diff --git a/src/realm_frontend/src/lib/realm_backend.did.js b/src/realm_frontend/src/lib/realm_backend.did.js index 8c273e6ea..fb20f678d 100644 --- a/src/realm_frontend/src/lib/realm_backend.did.js +++ b/src/realm_frontend/src/lib/realm_backend.did.js @@ -114,6 +114,8 @@ service : () -> { change_quarter : (text) -> (RealmResponse); join_realm : (text, text) -> (RealmResponse); join_realm_with_invite : (text, text, text) -> (RealmResponse); + set_creator_principal : (text) -> (RealmResponse); + mint_bootstrap_admin_invite : (nat) -> (RealmResponse); list_extensions : (text) -> (RealmResponse) query; register_realm_with_registry : (text, text, text, text) -> (text); refresh_invoice : (text) -> (text); From 4e11fd398f5a91abab25902f773be1ca56c2861d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 19 Apr 2026 07:22:16 +0000 Subject: [PATCH 7/7] tests: cover set_creator_principal + mint_bootstrap_admin_invite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- tests/integration/test_invitations_join.py | 134 +++++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/tests/integration/test_invitations_join.py b/tests/integration/test_invitations_join.py index b1f9ce166..2e1c4adcb 100644 --- a/tests/integration/test_invitations_join.py +++ b/tests/integration/test_invitations_join.py @@ -88,6 +88,38 @@ def _join_with_invite(profile: str, invite_code: str) -> dict: ) +def _set_creator_principal(principal: str) -> dict: + return dfx_call_json( + REALM_BACKEND, + "set_creator_principal", + f'("{_candid_quote(principal)}")', + is_update=True, + ) + + +def _mint_bootstrap_admin_invite(expires_in_hours: int = 24) -> dict: + return dfx_call_json( + REALM_BACKEND, + "mint_bootstrap_admin_invite", + f"({int(expires_in_hours)} : nat)", + is_update=True, + ) + + +def _get_my_principal() -> str: + """Return the principal the realm currently sees as the caller.""" + out, code = dfx_call(REALM_BACKEND, "get_my_principal", "()") + if code != 0: + raise RuntimeError(f"get_my_principal failed: {out}") + # Candid output for a query returning text looks like: ("aaaaa-aa") + text = out.strip() + if text.startswith("(") and text.endswith(")"): + text = text[1:-1].strip() + if text.startswith('"') and text.endswith('"'): + text = text[1:-1] + return text + + def _has_admin_dashboard_installed() -> bool: """Return True when extension_sync_call to admin_dashboard responds. @@ -287,6 +319,105 @@ def test_invalid_invite_code_is_rejected(): print("✓") +def test_set_creator_principal_records_value(): + """set_creator_principal stores the value, is idempotent, but won't overwrite. + + Runs before any admin Users exist. Uses the current dfx identity's + principal as the creator (because in the integration harness the + dfx identity is the canister controller, so the call is allowed). + Re-setting to the same value succeeds; setting a different value + is rejected. + """ + print(" - test_set_creator_principal_records_value...", end=" ") + me = _get_my_principal() + first = _set_creator_principal(me) + assert first.get("success"), first + msg = (first.get("data") or {}).get("message", "") + assert msg == me, f"Expected message to echo the principal; got {msg!r}" + + same_again = _set_creator_principal(me) + assert same_again.get("success"), ( + f"Re-setting creator_principal to the same value must be idempotent; " + f"got: {same_again}" + ) + + # Pick a syntactically-valid different principal to attempt overwrite. + other = "aaaaa-aa" # IC management canister id, definitely != us + if other == me: + other = "2vxsx-fae" # arbitrary anonymous-ish principal + overwrite = _set_creator_principal(other) + assert not overwrite.get("success"), ( + f"set_creator_principal must refuse silent overwrite; got: {overwrite}" + ) + err = ((overwrite.get("data") or {}).get("error") or "").lower() + assert "already set" in err or "overwrite" in err, ( + f"Expected an 'already set / refusing to overwrite' error; got: {err!r}" + ) + print("✓") + + +def test_mint_bootstrap_admin_invite_when_no_admin_yet(): + """mint_bootstrap_admin_invite returns a usable single-use admin invite. + + Must run BEFORE any admin User exists in the realm. Asserts that the + response carries a plaintext code distinct from its hash (the + plaintext is returned exactly once, by design) and that the invite + can be redeemed via consume_registration_code for an admin profile. + """ + print(" - test_mint_bootstrap_admin_invite_when_no_admin_yet...", end=" ") + response = _mint_bootstrap_admin_invite(24) + assert response.get("success"), response + raw_message = (response.get("data") or {}).get("message", "") + try: + payload = json.loads(raw_message) + except json.JSONDecodeError as e: + raise AssertionError( + f"Could not parse mint_bootstrap_admin_invite payload: {e}\n" + f"Raw: {raw_message!r}" + ) + assert payload.get("profile") == "admin", payload + code = payload.get("code") + code_hash = payload.get("code_hash") + assert code, f"Expected plaintext code in payload; got: {payload}" + assert code_hash, f"Expected code_hash in payload; got: {payload}" + assert code != code_hash, ( + f"Plaintext and hash must differ: {payload}" + ) + + # Ask the extension to confirm the code resolves and carries the + # admin profile. We don't redeem it via join_realm_with_invite here + # because consuming it would create a User with the test caller as + # admin and pollute the rest of the suite — validate is enough. + validated = _extension_call("validate_registration_code", {"code": code}) + assert validated.get("success"), validated + assert validated.get("data", {}).get("profile") == "admin", validated + print("✓") + + +def test_mint_bootstrap_admin_invite_rejected_after_admin_exists(): + """Once an admin exists, mint_bootstrap_admin_invite must be refused. + + Must run AFTER ``test_join_realm_without_invite_rejects_admin_when_admin_exists``, + which is the test that actually creates the first admin via the + bootstrap path. Subsequent admin invites have to be minted by + existing admins through admin_dashboard, not via this convenience + endpoint. + """ + print( + " - test_mint_bootstrap_admin_invite_rejected_after_admin_exists...", + end=" ", + ) + response = _mint_bootstrap_admin_invite(24) + assert not response.get("success"), ( + f"Bootstrap mint must be rejected once admin exists; got: {response}" + ) + err = ((response.get("data") or {}).get("error") or "").lower() + assert "admin" in err and ("already" in err or "exist" in err), ( + f"Expected an 'already has admin' error; got: {err!r}" + ) + print("✓") + + # --------------------------------------------------------------------------- # Tiny custom skip mechanism (no pytest dependency on this script) # --------------------------------------------------------------------------- @@ -303,8 +434,11 @@ class _Skip(Exception): test_mint_member_invite_returns_member_profile, test_listing_does_not_leak_plaintext_code, test_validate_returns_admin_profile, + test_set_creator_principal_records_value, + test_mint_bootstrap_admin_invite_when_no_admin_yet, test_join_realm_with_invite_grants_invite_profile, test_join_realm_without_invite_rejects_admin_when_admin_exists, + test_mint_bootstrap_admin_invite_rejected_after_admin_exists, test_invalid_invite_code_is_rejected, ]