Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/identity-gate-acting-signer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"playground-cli": patch
---

Fix the "Join the competition first" notice shown to people who are simply not
logged in: it now says "Log in first" and points at `playground login`.

The builder-identity gate now checks the account that will actually sign when
you pass `--suri` or `--signer dev` (and `--suri` on `playground mod`), instead
of always looking for a phone login session. Headless and CI runs with a local
key are no longer blocked for having no session.
11 changes: 10 additions & 1 deletion .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,16 @@ jobs:
fi
rustup toolchain install nightly --profile minimal --component rust-src
rustup default nightly
curl -fsSL https://raw.githubusercontent.com/paritytech/contract-dependency-manager/main/install.sh | bash
# `cdm setup` builds cargo-pvm-contract with `cargo install`
# from OUTSIDE the clone, so the repo's own rust-toolchain.toml
# is ignored and the build gets our floating nightly. Nightly
# 1.101 (2026-09-28) changed never-type fallback (rust#148922)
# and `trie-db` no longer compiles under it. Build the tool on
# the toolchain its repo pins; contract builds stay on nightly.
pvm_toolchain="$(curl -fsSL https://raw.githubusercontent.com/paritytech/cargo-pvm-contract/main/rust-toolchain.toml | sed -n 's/^channel *= *"\(.*\)"/\1/p')"
test -n "$pvm_toolchain"
rustup toolchain install "$pvm_toolchain" --profile minimal
curl -fsSL https://raw.githubusercontent.com/paritytech/contract-dependency-manager/main/install.sh | RUSTUP_TOOLCHAIN="$pvm_toolchain" bash
echo "$HOME/.cdm/bin" >> "$GITHUB_PATH"
export PATH="$HOME/.cdm/bin:$PATH"
command -v cdm
Expand Down
32 changes: 14 additions & 18 deletions e2e/cli/fixtures/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

import { getTestClient } from "../helpers/chain.js";
import { getReadOnlyRegistryContract } from "../../../src/utils/registry.js";
import { getAppMetadataUri } from "../../../src/utils/mod/metadataUri.js";
import { resolveSigner } from "../../../src/utils/signer.js";
import { publishToPlayground } from "../../../src/utils/deploy/playground.js";
import { SIGNER } from "./accounts.js";
Expand Down Expand Up @@ -53,25 +54,18 @@ async function getRegistry(): Promise<Registry> {
* Query the registry for an app entry by domain.
* Returns null if not found.
*
* `getMetadataUri` returns an `Option<String>` shaped as `{ isSome, value }` —
* always a truthy object regardless of registration. The `isSome` flag is the
* real discriminator; check it explicitly. (See cdm.json's getMetadataUri ABI.)
* Decodes through `getAppMetadataUri`, the CLI's single `getMetadataUri`
* decoder. The v2 registry returns a plain string (empty = absent); this
* helper used to narrow on a pre-v2 `Option`'s `isSome`, which reads
* `undefined` on every call — so every app looked missing and globalSetup
* re-registered the template on every run, in every cell at once.
* An unexpected shape throws instead of reading as "not found".
*/
export async function getApp(domain: string): Promise<AppEntry | null> {
try {
const registry = await getRegistry();
const res = await registry.getMetadataUri.query(domain);
if (!res.success) return null;
const tuple = res.value as { isSome?: boolean; value?: string } | undefined;
if (!tuple?.isSome) return null;
return {
domain,
owner: "",
metadataUri: String(tuple.value ?? ""),
};
} catch {
return null;
}
const registry = await getRegistry();
const metadataUri = await getAppMetadataUri(registry, domain);
if (metadataUri === null) return null;
return { domain, owner: "", metadataUri };
}

/**
Expand Down Expand Up @@ -129,7 +123,9 @@ export async function getAppCount(): Promise<number> {
export async function waitForApp(domain: string, timeoutMs = 30_000): Promise<AppEntry> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const entry = await getApp(domain);
// Tolerate a transient dry-run failure while polling; the final
// timeout still reports the miss.
const entry = await getApp(domain).catch(() => null);
if (entry) return entry;
await new Promise((r) => setTimeout(r, 2_000));
}
Expand Down
20 changes: 12 additions & 8 deletions e2e/cli/mod.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node
import { join } from "node:path";
import { tmpdir } from "node:os";
import { dot } from "./helpers/dot.js";
import { ALICE, E2E_TLD } from "./fixtures/accounts.js";
import { SIGNER, E2E_TLD } from "./fixtures/accounts.js";
import { TEST_DOMAIN } from "./fixtures/templates.js";

const tempDirs: string[] = [];
Expand All @@ -56,7 +56,7 @@ describe("dot mod — clone", () => {
{ timeout: 240_000 },
async () => {
const cwd = makeTempDir("dot-e2e-mod-cwd-");
const result = await dot(["mod", TEST_DOMAIN, "--suri", ALICE.suri], {
const result = await dot(["mod", TEST_DOMAIN, "--suri", SIGNER.suri], {
cwd,
timeout: 240_000,
});
Expand Down Expand Up @@ -133,17 +133,21 @@ describe("dot mod — clone", () => {
);

test(
"exits non-zero for unknown domain with no prior session (mod is signer-less)",
"asks a caller with no login and no --suri to log in first",
{ timeout: 60_000 },
async () => {
const tempHome = makeTempDir("dot-e2e-mod-home-");
const cwd = makeTempDir("dot-e2e-mod-cwd-");
const result = await dot(["mod", `some-app.${E2E_TLD}`], { home: tempHome, cwd, timeout: 60_000 });
expect(result.exitCode).not.toBe(0);
const output = result.stdout + result.stderr;
// dot mod is signer-less — it proceeds directly to the registry lookup.
// An unknown domain produces: App `some-app.${E2E_TLD}` not found in registry
expect(output).toContain("not found in registry");
// The builder-identity gate has no account to check: a soft block
// (exit 0) that points at `playground login`, before any registry
// lookup. Not the "Join the competition" notice, which is for a
// signed-in user the verifier rejects.
expect(result.exitCode, output).toBe(0);
expect(output).toContain("Log in first");
expect(output).toContain("playground login");
expect(output).not.toContain("not found in registry");
},
);
});
Expand All @@ -153,7 +157,7 @@ describe("dot mod — registry miss", () => {
const cwd = makeTempDir("dot-e2e-mod-unknown-");
const domain = `nonexistent-domain-xyz-12345.${E2E_TLD}`;
const result = await dot(
["mod", domain, "--suri", ALICE.suri],
["mod", domain, "--suri", SIGNER.suri],
{ cwd, timeout: 120_000 },
);
const output = result.stdout + result.stderr;
Expand Down
20 changes: 11 additions & 9 deletions e2e/cli/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,10 @@ describe("session management", () => {
});

test("corrupted session file does not produce a valid signer", async () => {
const sessionFile = join(tempHome, ".polkadot-apps", "dot-cli_SsoSessions.json");
// The live session list (host-papp 0.8.7+ renamed it to V3; the older
// `dot-cli_SsoSessions.json` name is never read, so corrupting it
// tested nothing).
const sessionFile = join(tempHome, ".polkadot-apps", "dot-cli_SsoSessionsV3.json");
writeFileSync(sessionFile, "CORRUPT_DATA_HERE");

const result = await dot(
Expand All @@ -71,15 +74,14 @@ describe("session management", () => {
],
{ home: tempHome, timeout: 30_000 },
);
// Must fail with the deliberate no-session notice. `--signer phone`
// with no valid session surfaces NO_SESSION_NOTICE_BODY from
// src/commands/deploy/signerNotice.ts — match its distinctive text so a
// generic "session" mention in an unrelated stack trace can't satisfy
// this. (Pre-0.9 this path emitted SignerNotAvailableError's "No signer
// available"; the phone path now shows the friendlier guidance.)
expect(result.exitCode).not.toBe(0);
// A corrupt session reads as "no session", and the builder-identity
// gate runs before signer resolution: it has no account to check, so
// it soft-blocks (exit 0) with the log-in notice and nothing is signed.
const output = result.stdout + result.stderr;
expect(output).toContain("Mobile (phone) signing needs a logged-in session");
expect(result.exitCode, output).toBe(0);
expect(output).toContain("Log in first");
expect(output).toContain("playground login");
expect(output).not.toContain("Deploy complete");
});

test("build does not create or modify session files", async () => {
Expand Down
13 changes: 12 additions & 1 deletion e2e/cli/setup/global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
* 3. Log test accounts
*/

import { destroyTestClient } from "../helpers/chain.js";
import { destroyTestClient, getTestClient } from "../helpers/chain.js";
import { ensureAllowance } from "../../../src/utils/account/allowance.js";
import { fundDeployerIfLow } from "./fund.js";
import { ensureTemplateRegistered } from "../fixtures/registry.js";
import { SIGNER, BOB } from "../fixtures/accounts.js";
Expand Down Expand Up @@ -60,6 +61,16 @@ export async function setup() {
}
}

// SIGNER also signs the Bulletin metadata `store` for template registration
// and dev-mode deploys. Native PAS is not enough: with no Bulletin
// authorization the store is rejected as `Invalid.Payment` even at a huge
// balance. Soft-fail so a Bulletin hiccup only degrades the tests that use it.
try {
await ensureAllowance(await getTestClient(), SIGNER.address);
} catch (err) {
console.warn(`[e2e setup] Bulletin allowance check failed: ${err}`);
}

// Template registration is only consumed by one test (`dot mod` happy
// path), which uses `.skipIf(!TEST_DOMAIN)`. A failure here doesn't
// block the rest of the suite — log and continue.
Expand Down
2 changes: 1 addition & 1 deletion src/commands/decentralize/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ export const decentralizeCommand = new Command("decentralize")
// outcome (yellow box, exit 0); release the shared connection we
// primed for the read before returning.
const conn = await getConnection();
if (await enforceIdentityGate(conn.raw.assetHub)) {
if (await enforceIdentityGate(conn.raw.assetHub, { suri: opts.suri })) {
destroyConnection();
process.exitCode = 0;
return;
Expand Down
2 changes: 1 addition & 1 deletion src/commands/deploy-all/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ async function runDeployAll(opts: DeployAllOpts): Promise<void> {
// joined the competition may deploy. Runs before signer resolution;
// reuses the shared connection. Blocked is a soft outcome (exit 0).
const conn = await getConnection();
if (await enforceIdentityGate(conn.raw.assetHub)) {
if (await enforceIdentityGate(conn.raw.assetHub, { suri: opts.suri, signer: mode })) {
cleanupOnce();
process.exitCode = 0;
return;
Expand Down
7 changes: 6 additions & 1 deletion src/commands/deploy/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,12 @@ export const deployCommand = new Command("deploy")
// will use. Blocked is a soft outcome (yellow box, exit 0).
try {
const conn = await getConnection();
if (await enforceIdentityGate(conn.raw.assetHub)) {
if (
await enforceIdentityGate(conn.raw.assetHub, {
suri: opts.suri,
signer: opts.signer,
})
) {
cleanupOnce();
process.exitCode = 0;
return;
Expand Down
18 changes: 10 additions & 8 deletions src/commands/mod/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,16 +45,18 @@ interface FetchedAppMetadata {
export const modCommand = new Command("mod")
.description("Mod a playground app — clone the source as a fresh project to customise")
.argument("[domain]", "App domain (interactive picker if omitted)")
// --suri is retained as a no-op for backcompat. `playground mod` is fully
// read-only on the chain side now (browse + metadata lookups go through
// `playground mod` never signs: browse + metadata lookups go through
// getReadOnlyRegistryContract with the keyless pallet-revive dry-run
// origin), so there's no signer to feed.
.option("--suri <suri>", "(deprecated, no-op) Signer secret URI")
.action(async (rawDomain: string | undefined, _opts: { suri?: string }) =>
runCliCommand("mod", { watchdog: true, hardExit: true }, () => runModCommand(rawDomain)),
// origin. --suri only chooses WHICH account the builder-identity gate
// checks (default: the logged-in phone session), for headless/CI use.
.option("--suri <suri>", "Check builder status for this account instead of the login session")
.action(async (rawDomain: string | undefined, opts: { suri?: string }) =>
runCliCommand("mod", { watchdog: true, hardExit: true }, () =>
runModCommand(rawDomain, opts.suri),
),
);

export async function runModCommand(rawDomain: string | undefined): Promise<void> {
export async function runModCommand(rawDomain: string | undefined, suri?: string): Promise<void> {
try {
const client = await withSpan("cli.mod.connection", "connect to registry chain", () =>
getConnection(),
Expand All @@ -67,7 +69,7 @@ export async function runModCommand(rawDomain: string | undefined): Promise<void
// personhood verifier accepts. This also gates `playground init`,
// which delegates here. Blocked is a soft outcome (yellow box, exit 0).
//
if (await enforceIdentityGate(client.raw.assetHub)) {
if (await enforceIdentityGate(client.raw.assetHub, { suri })) {
process.exitCode = 0;
return;
}
Expand Down
17 changes: 15 additions & 2 deletions src/commands/shared/gateOrNotice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,15 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
// Mock the gate decision + the Ink render so the test exercises only the
// mapping contract enforceIdentityGate owns. withSpan is collapsed to a
// pass-through so the span wrapper doesn't pull in Sentry.
const { checkIdentityGateMock, renderNoticeMock } = vi.hoisted(() => ({
const { checkIdentityGateMock, resolveActingH160Mock, renderNoticeMock } = vi.hoisted(() => ({
checkIdentityGateMock: vi.fn(),
resolveActingH160Mock: vi.fn().mockResolvedValue(undefined),
renderNoticeMock: vi.fn().mockResolvedValue(undefined),
}));

vi.mock("../../utils/identity/identityGate.js", () => ({
checkIdentityGate: checkIdentityGateMock,
resolveActingH160: resolveActingH160Mock,
}));

vi.mock("./IdentityGateNotice.js", () => ({
Expand All @@ -43,6 +45,7 @@ const H160 = "0xbeefbeefbeefbeefbeefbeefbeefbeefbeefbeef" as `0x${string}`;
beforeEach(() => {
vi.clearAllMocks();
renderNoticeMock.mockResolvedValue(undefined);
resolveActingH160Mock.mockResolvedValue(undefined);
});

describe("enforceIdentityGate", () => {
Expand Down Expand Up @@ -83,6 +86,16 @@ describe("enforceIdentityGate", () => {

await enforceIdentityGate(RAW);

expect(checkIdentityGateMock).toHaveBeenCalledWith(RAW);
expect(checkIdentityGateMock).toHaveBeenCalledWith(RAW, {});
});

it("checks the explicitly chosen signer's account instead of the session", async () => {
resolveActingH160Mock.mockResolvedValue(H160);
checkIdentityGateMock.mockResolvedValue({ status: "revealed", productH160: H160 });

await enforceIdentityGate(RAW, { suri: "//Bob" });

expect(resolveActingH160Mock).toHaveBeenCalledWith({ suri: "//Bob" });
expect(checkIdentityGateMock).toHaveBeenCalledWith(RAW, { account: H160 });
});
});
18 changes: 13 additions & 5 deletions src/commands/shared/gateOrNotice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@

import type { PolkadotClient } from "polkadot-api";
import { withSpan } from "../../telemetry.js";
import { checkIdentityGate } from "../../utils/identity/identityGate.js";
import {
checkIdentityGate,
resolveActingH160,
type ActingAs,
} from "../../utils/identity/identityGate.js";
import { renderIdentityGateNotice } from "./IdentityGateNotice.js";

/**
Expand All @@ -26,10 +30,14 @@ import { renderIdentityGateNotice } from "./IdentityGateNotice.js";
* its own cleanup. Returns `false` when the user is a revealed builder and the
* command may proceed; nothing is printed on that path (no flash on success).
*/
export async function enforceIdentityGate(rawAssetHubClient: PolkadotClient): Promise<boolean> {
const result = await withSpan("cli.identity-gate", "check builder identity", () =>
checkIdentityGate(rawAssetHubClient),
);
export async function enforceIdentityGate(
rawAssetHubClient: PolkadotClient,
acting?: ActingAs,
): Promise<boolean> {
const result = await withSpan("cli.identity-gate", "check builder identity", async () => {
const account = await resolveActingH160(acting);
return checkIdentityGate(rawAssetHubClient, account ? { account } : {});
});
if (result.status === "revealed") return false;
await renderIdentityGateNotice(result.status);
return true;
Expand Down
6 changes: 3 additions & 3 deletions src/commands/shared/identityGateCopy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,11 @@ export function identityGateCopy(status: BlockedIdentityStatus): GateNoticeCopy
switch (status) {
case "not-logged-in":
return {
title: "Join the competition first",
title: "Log in first",
lines: [
"Playground commands are for builders who've joined the competition, so you need to be signed in first.",
"You're not signed in yet. Playground commands are for builders who've joined the competition, and we need to know who you are to check.",
"",
"Run `playground login` and scan the QR code with your Polkadot mobile app, then become a builder and join the competition at playground.dot in your desktop app.",
"Run `playground login` and scan the QR code with your Polkadot mobile app. If you haven't yet, become a builder and join the competition at playground.dot in your desktop app.",
],
};
case "anonymous":
Expand Down
Loading
Loading