Skip to content

Commit fcab7a0

Browse files
Merge pull request #413 from paritytech/feat/builder-identity-gate
feat: gate mod/deploy/decentralize/deploy-all behind a revealed builder identity
2 parents 80e26bc + b6d21f5 commit fcab7a0

12 files changed

Lines changed: 715 additions & 1 deletion

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"playground-cli": minor
3+
---
4+
5+
Gate `mod`, `init`, `deploy`, `decentralize`, and `deploy-all` behind a revealed
6+
builder identity. These commands now require you to be signed in (`playground
7+
login`) and to have joined the competition at playground.dot in your desktop
8+
app — the CLI reads your product account's on-chain identity binding from the
9+
playground registry (via the keyless read-only origin, no phone tap) and refuses
10+
to act for anonymous accounts. When you haven't joined yet, the command prints a
11+
friendly yellow notice explaining how to become a builder and exits without
12+
error. The check is signer-mode-agnostic: dev and `--suri` runs are gated too,
13+
but once you've revealed yourself they work as before.

src/commands/decentralize/index.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ import {
4848
type DecentralizeOutcome,
4949
type DecentralizeSource,
5050
} from "../../utils/decentralize/run.js";
51-
import { destroyConnection } from "../../utils/connection.js";
51+
import { getConnection, destroyConnection } from "../../utils/connection.js";
52+
import { enforceIdentityGate } from "../shared/gateOrNotice.js";
5253
import {
5354
ensureGitInstalled,
5455
ModdablePreflightError,
@@ -147,6 +148,18 @@ export const decentralizeCommand = new Command("decentralize")
147148
.action(async (opts: DecentralizeOpts) =>
148149
runCliCommand("decentralize", { hardExit: true }, async () => {
149150
const env: Env = resolveLegacyEnv(opts.env);
151+
152+
// Builder-identity gate (any signer mode): only revealed builders
153+
// who joined the competition may decentralize. Blocked is a soft
154+
// outcome (yellow box, exit 0); release the shared connection we
155+
// primed for the read before returning.
156+
const conn = await getConnection();
157+
if (await enforceIdentityGate(conn.raw.assetHub)) {
158+
destroyConnection();
159+
process.exitCode = 0;
160+
return;
161+
}
162+
150163
if (opts.site || opts.path) {
151164
await runHeadless({ env, opts });
152165
} else {

src/commands/deploy-all/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { Command, Option } from "commander";
3131
import { captureWarning, errorMessage, withSpan } from "../../telemetry.js";
3232
import { resolveSigner, SignerNotAvailableError, type ResolvedSigner } from "../../utils/signer.js";
3333
import { getConnection, destroyConnection } from "../../utils/connection.js";
34+
import { enforceIdentityGate } from "../shared/gateOrNotice.js";
3435
import { checkMapping } from "../../utils/account/mapping.js";
3536
import { readLoginStampMs, staleSessionWarning } from "../../utils/loginStamp.js";
3637
import { onProcessShutdown } from "../../utils/process-guard.js";
@@ -141,6 +142,16 @@ async function runDeployAll(opts: DeployAllOpts): Promise<void> {
141142
onProcessShutdown(cleanupOnce);
142143

143144
try {
145+
// Builder-identity gate (any signer mode): only revealed builders who
146+
// joined the competition may deploy. Runs before signer resolution;
147+
// reuses the shared connection. Blocked is a soft outcome (exit 0).
148+
const conn = await getConnection();
149+
if (await enforceIdentityGate(conn.raw.assetHub)) {
150+
cleanupOnce();
151+
process.exitCode = 0;
152+
return;
153+
}
154+
144155
userSigner = await preflightSigner({ env, mode, suri: opts.suri, publishToPlayground });
145156

146157
if (mode === "phone" && userSigner?.source !== "session") {

src/commands/deploy/index.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { captureWarning, errorMessage, withSpan } from "../../telemetry.js";
2626
import { readLoginStampMs, staleSessionWarning } from "../../utils/loginStamp.js";
2727
import { resolveSigner, SignerNotAvailableError, type ResolvedSigner } from "../../utils/signer.js";
2828
import { getConnection, destroyConnection } from "../../utils/connection.js";
29+
import { enforceIdentityGate } from "../shared/gateOrNotice.js";
2930
import { checkMapping } from "../../utils/account/mapping.js";
3031
import { onProcessShutdown } from "../../utils/process-guard.js";
3132
import { runCliCommand } from "../../cli-runtime.js";
@@ -157,6 +158,24 @@ export const deployCommand = new Command("deploy")
157158
})();
158159
onProcessShutdown(cleanupOnce);
159160

161+
// Builder-identity gate (any signer mode): only revealed builders
162+
// who joined the competition may deploy. Runs before signer
163+
// resolution / phone work; reuses the shared connection preflight
164+
// will use. Blocked is a soft outcome (yellow box, exit 0).
165+
try {
166+
const conn = await getConnection();
167+
if (await enforceIdentityGate(conn.raw.assetHub)) {
168+
cleanupOnce();
169+
process.exitCode = 0;
170+
return;
171+
}
172+
} catch (err) {
173+
process.stderr.write(`\n✖ ${errorMessage(err)}\n`);
174+
cleanupOnce();
175+
process.exitCode = 1;
176+
throw err;
177+
}
178+
160179
// `--yes` fills the fields the TUI would prompt for (signer ⇒ dev,
161180
// buildDir ⇒ default) and requires --domain. Resolve it BEFORE
162181
// preflight so the signer-resolution path sees the dev default and a

src/commands/mod/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { existsSync } from "node:fs";
2020
import { withSpan } from "../../telemetry.js";
2121
import { getConnection, destroyConnection } from "../../utils/connection.js";
2222
import { getReadOnlyRegistryContract } from "../../utils/registry.js";
23+
import { enforceIdentityGate } from "../shared/gateOrNotice.js";
2324
import { AppBrowser, type AppEntry } from "./AppBrowser.js";
2425
import { SetupScreen } from "./SetupScreen.js";
2526
import { QuestPicker } from "./QuestPicker.js";
@@ -59,6 +60,15 @@ export async function runModCommand(rawDomain: string | undefined): Promise<void
5960
getReadOnlyRegistryContract(client.raw.assetHub),
6061
);
6162

63+
// Builder-identity gate: modding is reserved for revealed builders who
64+
// joined the competition. This also gates `playground init`, which
65+
// delegates here. Reuse the registry we just resolved so the gate
66+
// doesn't re-resolve it. Blocked is a soft outcome (yellow box, exit 0).
67+
if (await enforceIdentityGate(client.raw.assetHub, registry)) {
68+
process.exitCode = 0;
69+
return;
70+
}
71+
6272
let domain: string;
6373
let metadata: AppEntry | null = null;
6474

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// Copyright (C) Parity Technologies (UK) Ltd.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
import React, { useEffect } from "react";
17+
import { render, Text } from "ink";
18+
import { Callout } from "../../utils/ui/theme/index.js";
19+
import type { BlockedIdentityStatus } from "../../utils/identity/identityGate.js";
20+
import { identityGateCopy } from "./identityGateCopy.js";
21+
22+
/**
23+
* One-shot yellow callout shown when the builder-identity gate blocks a
24+
* command. No interaction: it paints once, then signals the host to unmount.
25+
*/
26+
export function IdentityGateNotice({
27+
status,
28+
onDone,
29+
}: {
30+
status: BlockedIdentityStatus;
31+
onDone: () => void;
32+
}) {
33+
// Defer the unmount one tick so Ink flushes the frame before teardown.
34+
useEffect(() => {
35+
const t = setTimeout(onDone, 0);
36+
return () => clearTimeout(t);
37+
}, [onDone]);
38+
39+
const copy = identityGateCopy(status);
40+
return (
41+
<Callout tone="warning" title={copy.title}>
42+
{copy.lines.map((line, i) => (
43+
// Blank entries are intentional spacers; Ink collapses an empty
44+
// string, so render a single space (matches the drip screen).
45+
<Text key={i}>{line === "" ? " " : line}</Text>
46+
))}
47+
</Callout>
48+
);
49+
}
50+
51+
/**
52+
* Render the blocked-gate notice once and resolve after it has been shown and
53+
* torn down. Mirrors the `status`/`drip` render/`waitUntilExit` shape.
54+
*/
55+
export function renderIdentityGateNotice(status: BlockedIdentityStatus): Promise<void> {
56+
return new Promise((resolve) => {
57+
const app = render(
58+
React.createElement(IdentityGateNotice, {
59+
status,
60+
onDone: () => app.unmount(),
61+
}),
62+
);
63+
void app.waitUntilExit().then(() => resolve());
64+
});
65+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// Copyright (C) Parity Technologies (UK) Ltd.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
import { beforeEach, describe, expect, it, vi } from "vitest";
17+
18+
// Mock the gate decision + the Ink render so the test exercises only the
19+
// mapping contract enforceIdentityGate owns. withSpan is collapsed to a
20+
// pass-through so the span wrapper doesn't pull in Sentry.
21+
const { checkIdentityGateMock, renderNoticeMock } = vi.hoisted(() => ({
22+
checkIdentityGateMock: vi.fn(),
23+
renderNoticeMock: vi.fn().mockResolvedValue(undefined),
24+
}));
25+
26+
vi.mock("../../utils/identity/identityGate.js", () => ({
27+
checkIdentityGate: checkIdentityGateMock,
28+
}));
29+
30+
vi.mock("./IdentityGateNotice.js", () => ({
31+
renderIdentityGateNotice: renderNoticeMock,
32+
}));
33+
34+
vi.mock("../../telemetry.js", () => ({
35+
withSpan: (_op: string, _name: string, fn: () => unknown) => fn(),
36+
}));
37+
38+
import { enforceIdentityGate } from "./gateOrNotice.js";
39+
40+
const RAW = {} as any;
41+
const H160 = "0xbeefbeefbeefbeefbeefbeefbeefbeefbeefbeef" as `0x${string}`;
42+
43+
beforeEach(() => {
44+
vi.clearAllMocks();
45+
renderNoticeMock.mockResolvedValue(undefined);
46+
});
47+
48+
describe("enforceIdentityGate", () => {
49+
it("does not block a revealed builder and prints nothing", async () => {
50+
checkIdentityGateMock.mockResolvedValue({ status: "revealed", productH160: H160 });
51+
52+
const blocked = await enforceIdentityGate(RAW);
53+
54+
expect(blocked).toBe(false);
55+
expect(renderNoticeMock).not.toHaveBeenCalled();
56+
});
57+
58+
it.each(["not-logged-in", "anonymous", "unverifiable"] as const)(
59+
"blocks and renders the %s notice",
60+
async (status) => {
61+
checkIdentityGateMock.mockResolvedValue(
62+
status === "unverifiable" ? { status, detail: "x" } : { status, productH160: H160 },
63+
);
64+
65+
const blocked = await enforceIdentityGate(RAW);
66+
67+
expect(blocked).toBe(true);
68+
expect(renderNoticeMock).toHaveBeenCalledTimes(1);
69+
expect(renderNoticeMock).toHaveBeenCalledWith(status);
70+
},
71+
);
72+
73+
it("forwards a pre-resolved registry to the gate (so mod doesn't re-resolve)", async () => {
74+
checkIdentityGateMock.mockResolvedValue({ status: "revealed", productH160: H160 });
75+
const registry = { getRootAccount: { query: vi.fn() } };
76+
77+
await enforceIdentityGate(RAW, registry as any);
78+
79+
expect(checkIdentityGateMock).toHaveBeenCalledWith(RAW, { registry });
80+
});
81+
});
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Copyright (C) Parity Technologies (UK) Ltd.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
import type { PolkadotClient } from "polkadot-api";
17+
import { withSpan } from "../../telemetry.js";
18+
import { checkIdentityGate, type IdentityRegistry } from "../../utils/identity/identityGate.js";
19+
import { renderIdentityGateNotice } from "./IdentityGateNotice.js";
20+
21+
/**
22+
* Enforce the builder-identity gate for the signed-in session.
23+
*
24+
* Returns `true` when the user is BLOCKED — the caller should set
25+
* `process.exitCode = 0` (a soft, actionable outcome) and return after running
26+
* its own cleanup. Returns `false` when the user is a revealed builder and the
27+
* command may proceed; nothing is printed on that path (no flash on success).
28+
*/
29+
export async function enforceIdentityGate(
30+
rawAssetHubClient: PolkadotClient,
31+
registry?: IdentityRegistry,
32+
): Promise<boolean> {
33+
const result = await withSpan("cli.identity-gate", "check builder identity", () =>
34+
checkIdentityGate(rawAssetHubClient, { registry }),
35+
);
36+
if (result.status === "revealed") return false;
37+
await renderIdentityGateNotice(result.status);
38+
return true;
39+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// Copyright (C) Parity Technologies (UK) Ltd.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
import { describe, expect, it } from "vitest";
17+
import { identityGateCopy } from "./identityGateCopy.js";
18+
import type { BlockedIdentityStatus } from "../../utils/identity/identityGate.js";
19+
20+
const STATUSES: BlockedIdentityStatus[] = ["not-logged-in", "anonymous", "unverifiable"];
21+
22+
describe("identityGateCopy", () => {
23+
it("returns a non-empty title and body for every blocked status", () => {
24+
for (const status of STATUSES) {
25+
const copy = identityGateCopy(status);
26+
expect(copy.title.length).toBeGreaterThan(0);
27+
const body = copy.lines.filter((l) => l.trim().length > 0);
28+
expect(body.length).toBeGreaterThan(0);
29+
}
30+
});
31+
32+
it("points not-logged-in at `playground login`", () => {
33+
const copy = identityGateCopy("not-logged-in");
34+
expect(copy.lines.join(" ")).toContain("playground login");
35+
expect(copy.lines.join(" ")).toContain("playground.dot");
36+
});
37+
38+
it("tells anonymous builders to join the competition at playground.dot", () => {
39+
const copy = identityGateCopy("anonymous");
40+
expect(copy.lines.join(" ").toLowerCase()).toContain("become a builder");
41+
expect(copy.lines.join(" ")).toContain("playground.dot");
42+
});
43+
44+
it("tells unverifiable users to confirm they joined, then retry", () => {
45+
const copy = identityGateCopy("unverifiable");
46+
const body = copy.lines.join(" ");
47+
expect(body).toContain("playground.dot");
48+
expect(body.toLowerCase()).toContain("try again");
49+
});
50+
});

0 commit comments

Comments
 (0)