From 52c389d92016924dae97adc1f13c3fd34d29e5eb Mon Sep 17 00:00:00 2001 From: Shawn Tabrizi Date: Tue, 19 May 2026 21:37:29 +0900 Subject: [PATCH 1/5] initial ideas --- src/commands/decentralize/index.ts | 230 +++++++++++++++++++++++++++ src/index.ts | 2 + src/telemetry-config.ts | 9 +- src/utils/decentralize/mirror.ts | 149 +++++++++++++++++ src/utils/decentralize/randomName.ts | 81 ++++++++++ 5 files changed, 470 insertions(+), 1 deletion(-) create mode 100644 src/commands/decentralize/index.ts create mode 100644 src/utils/decentralize/mirror.ts create mode 100644 src/utils/decentralize/randomName.ts diff --git a/src/commands/decentralize/index.ts b/src/commands/decentralize/index.ts new file mode 100644 index 0000000..9d0eacf --- /dev/null +++ b/src/commands/decentralize/index.ts @@ -0,0 +1,230 @@ +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * `dot decentralize` — point at a live static site, get back a .dot URL. + * + * dot decentralize --site=shawntabrizi.github.io --dot=shawntabrizi.dot + * + * Zero-account by default: we sign with //Bob on paseo-next-v2 so a first-time + * user can see end-to-end value with no wallet setup. The migration story is + * surfaced in the success footer — re-deploy from `dot deploy` with their own + * --suri or QR session and the same domain check at availability.ts:261 will + * recognise the "owned by you" path and update in place. + * + * Site cloning is `wget --mirror` (see utils/decentralize/mirror.ts). The + * upload + DotNS register flow re-uses `runStorageDeploy` exactly like + * `dot deploy`, so any improvements to the underlying primitives flow into + * this command for free. + */ + +import { Command } from "commander"; +import { rmSync } from "node:fs"; +import { runCliCommand } from "../../cli-runtime.js"; +import { errorMessage, withSpan } from "../../telemetry.js"; +import { + DEFAULT_ENV, + type Env, + getChainConfig, + resolveLegacyEnv, +} from "../../config.js"; +import { resolveSigner, type ResolvedSigner } from "../../utils/signer.js"; +import { runStorageDeploy } from "../../utils/deploy/storage.js"; +import { + checkDomainAvailability, + formatAvailability, +} from "../../utils/deploy/availability.js"; +import { normalizeDomain } from "../../utils/deploy/playground.js"; +import { mirrorSite } from "../../utils/decentralize/mirror.js"; +import { findAvailableRandomName } from "../../utils/decentralize/randomName.js"; + +interface DecentralizeOpts { + site: string; + dot?: string; + env: string; + suri?: string; +} + +/** + * //Bob is a hard-coded dev SURI. It only has funds / authorisation on the + * paseo-next-v2 testnet — using it on mainnet would silently fail at the + * funding step. Refusing here gives a friendly error instead. + */ +const DEFAULT_SURI = "//Bob"; + +export const decentralizeCommand = new Command("decentralize") + .description( + "Mirror a live static site to Polkadot Bulletin and register a .dot name pointing at it", + ) + .requiredOption("--site ", "URL of the static site to clone (http/https)") + .option( + "--dot ", + "DotNS domain (with or without `.dot`). Omit to auto-generate a free random name.", + ) + .option( + "--env ", + "Target environment (default: paseo-next-v2)", + DEFAULT_ENV, + ) + .option( + "--suri ", + "Sign with this SURI instead of the default //Bob test account", + ) + .action(async (opts: DecentralizeOpts) => + runCliCommand("decentralize", { hardExit: true }, async () => { + const env: Env = resolveLegacyEnv(opts.env); + const usingDefaultBob = !opts.suri; + + if (usingDefaultBob && getChainConfig(env).network !== "testnet") { + throw new Error( + `--env ${env} is a non-testnet network; //Bob has no funds there. ` + + `Pass --suri or use --env paseo-next-v2.`, + ); + } + + let signer: ResolvedSigner | null = null; + let mirrorDir: string | null = null; + + try { + signer = await withSpan( + "cli.decentralize.signer", + "resolve signer", + () => resolveSigner({ suri: opts.suri ?? DEFAULT_SURI }), + ); + + process.stdout.write( + `\n▸ Signing as ${signer.address} (${signer.source})\n`, + ); + + // ── 1. Pick a domain ──────────────────────────────────────── + let label: string; + let fullDomain: string; + if (opts.dot) { + const normalized = normalizeDomain(opts.dot); + label = normalized.label; + fullDomain = normalized.fullDomain; + + process.stdout.write(`\n▸ Checking ${fullDomain}…\n`); + const availability = await withSpan( + "cli.decentralize.availability", + "check domain availability", + () => + checkDomainAvailability(label, { + env, + ownerSs58Address: signer?.address, + }), + ); + if ( + availability.status === "reserved" || + availability.status === "taken" + ) { + throw new Error(formatAvailability(availability)); + } + if (availability.status === "unknown") { + process.stderr.write( + `\n⚠ ${formatAvailability(availability)} — continuing anyway.\n`, + ); + } + } else { + process.stdout.write( + `\n▸ Picking a free random .dot name…\n`, + ); + const chosen = await withSpan( + "cli.decentralize.random-name", + "find available random name", + () => + findAvailableRandomName({ + env, + ownerSs58Address: signer?.address, + }), + ); + label = chosen.label; + fullDomain = chosen.availability.fullDomain; + process.stdout.write(` → ${fullDomain}\n`); + } + + // ── 2. Mirror the site ────────────────────────────────────── + process.stdout.write(`\n▸ Mirroring ${opts.site}…\n`); + const mirror = await withSpan( + "cli.decentralize.mirror", + "mirror site", + () => + mirrorSite({ + url: opts.site, + onLine: (line) => + process.stdout.write(` ${line}\n`), + }), + ); + mirrorDir = mirror.directory; + process.stdout.write( + ` → ${mirror.fileCount} files in ${mirror.directory}\n`, + ); + + // ── 3. Upload to Bulletin + register DotNS ────────────────── + process.stdout.write( + `\n▸ Uploading to Bulletin and registering ${fullDomain}…\n`, + ); + const result = await withSpan( + "cli.decentralize.storage", + "bulletin upload + dotns register", + () => + runStorageDeploy({ + content: mirror.directory, + domainName: label, + auth: { + signer: signer?.signer, + signerAddress: signer?.address, + }, + env, + onLogEvent: (event) => + process.stdout.write(` • ${event.kind}\n`), + }), + ); + + // ── 4. Print success ──────────────────────────────────────── + const cfg = getChainConfig(env); + const appUrl = `https://${fullDomain}.li`; + const gatewayUrl = `${cfg.bulletinGateway}${result.cid}`; + process.stdout.write( + "\n✔ Decentralized!\n" + + ` Site ${appUrl}\n` + + ` IPFS CID ${result.cid}\n` + + ` Gateway ${gatewayUrl}\n`, + ); + if (usingDefaultBob) { + process.stdout.write( + "\n Owned by //Bob (testnet demo). To claim a name under your own\n" + + " account, run `dot init` to pair a wallet, then re-deploy with\n" + + " `dot deploy --domain .dot` from a project of your own.\n", + ); + } + process.stdout.write("\n"); + } catch (err) { + process.stderr.write(`\n✖ ${errorMessage(err)}\n`); + process.exitCode = 1; + throw err; + } finally { + signer?.destroy(); + if (mirrorDir) { + try { + rmSync(mirrorDir, { recursive: true, force: true }); + } catch { + // best-effort cleanup; tmpdir is OS-managed anyway + } + } + } + }), + ); + diff --git a/src/index.ts b/src/index.ts index c023a45..a1b6e5e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,7 @@ import pkg from "../package.json" with { type: "json" }; import { initCommand } from "./commands/init/index.js"; import { modCommand } from "./commands/mod/index.js"; import { buildCommand } from "./commands/build.js"; +import { decentralizeCommand } from "./commands/decentralize/index.js"; import { logoutCommand } from "./commands/logout/index.js"; import { updateCommand } from "./commands/update.js"; import { captureWarning, closeTelemetry, flushTelemetry, initTelemetry } from "./telemetry.js"; @@ -127,6 +128,7 @@ program.addCommand(initCommand); program.addCommand(modCommand); program.addCommand(buildCommand); program.addCommand(await createDeployCommand()); +program.addCommand(decentralizeCommand); program.addCommand(logoutCommand); program.addCommand(updateCommand); diff --git a/src/telemetry-config.ts b/src/telemetry-config.ts index 94b08f8..4cb541e 100644 --- a/src/telemetry-config.ts +++ b/src/telemetry-config.ts @@ -46,7 +46,14 @@ export interface InternalContextSignals { branch?: string; } -export type CliCommandName = "init" | "deploy" | "mod" | "build" | "update" | "logout"; +export type CliCommandName = + | "init" + | "deploy" + | "mod" + | "build" + | "update" + | "logout" + | "decentralize"; export type TelemetryAttribute = string | number | boolean | undefined; type EnvLike = Record; diff --git a/src/utils/decentralize/mirror.ts b/src/utils/decentralize/mirror.ts new file mode 100644 index 0000000..323806d --- /dev/null +++ b/src/utils/decentralize/mirror.ts @@ -0,0 +1,149 @@ +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { spawn } from "node:child_process"; +import { mkdtempSync, readdirSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +export interface MirrorOptions { + /** http(s) URL to mirror. Other schemes are rejected. */ + url: string; + /** Optional callback for streaming wget output, one line at a time. */ + onLine?: (line: string) => void; +} + +export interface MirrorResult { + /** Absolute path to the temp directory containing the mirrored site. */ + directory: string; + /** Number of files written under `directory`. */ + fileCount: number; +} + +export class WgetMissingError extends Error { + constructor() { + super( + "wget is required to mirror sites but was not found on PATH. " + + "Install it via `brew install wget` (macOS) or your package manager.", + ); + this.name = "WgetMissingError"; + } +} + +export class InvalidSiteUrlError extends Error { + constructor(url: string, reason: string) { + super(`Invalid --site URL "${url}": ${reason}`); + this.name = "InvalidSiteUrlError"; + } +} + +function validateUrl(input: string): string { + let parsed: URL; + try { + parsed = new URL(input); + } catch { + // Allow shorthand like "shawntabrizi.github.io" by adding https://. + try { + parsed = new URL(`https://${input}`); + } catch { + throw new InvalidSiteUrlError(input, "not a parseable URL"); + } + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new InvalidSiteUrlError(input, `unsupported scheme ${parsed.protocol}`); + } + return parsed.toString(); +} + +function countFiles(root: string): number { + let count = 0; + const walk = (dir: string) => { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + const st = statSync(full); + if (st.isDirectory()) walk(full); + else if (st.isFile()) count++; + } + }; + try { + walk(root); + } catch { + // ignore — caller validates non-empty via the returned count. + } + return count; +} + +/** + * Mirror a live HTTP(S) static site into a fresh temp directory using `wget`. + * + * Flags chosen for "useful default for static sites": + * --mirror recursive download + timestamping + infinite depth + * --convert-links rewrite absolute → relative so the local copy renders + * --adjust-extension add .html so links resolve from a flat filesystem + * --page-requisites pull CSS/JS/images that pages reference + * --no-parent don't climb above the URL's directory + * --no-host-directories drop the hostname segment from the output path + * --no-verbose one progress line per file; not silent so onLine works + * + * URL safety: passed as a separate execve argument, never spliced into a shell + * string, so a malicious URL cannot inject other flags or shell metacharacters. + */ +export async function mirrorSite(options: MirrorOptions): Promise { + const url = validateUrl(options.url); + const directory = mkdtempSync(join(tmpdir(), "dot-decentralize-")); + + const args = [ + "--mirror", + "--convert-links", + "--adjust-extension", + "--page-requisites", + "--no-parent", + "--no-host-directories", + "--no-verbose", + `--directory-prefix=${directory}`, + url, + ]; + + await new Promise((resolve, reject) => { + const proc = spawn("wget", args, { stdio: ["ignore", "pipe", "pipe"] }); + + proc.on("error", (err: NodeJS.ErrnoException) => { + if (err.code === "ENOENT") reject(new WgetMissingError()); + else reject(err); + }); + + const forward = (chunk: Buffer) => { + if (!options.onLine) return; + for (const line of chunk.toString("utf8").split("\n")) { + if (line.trim()) options.onLine(line); + } + }; + proc.stdout?.on("data", forward); + proc.stderr?.on("data", forward); + + proc.on("close", (code) => { + if (code === 0) resolve(); + else reject(new Error(`wget failed (exit ${code}) — site may be unreachable`)); + }); + }); + + const fileCount = countFiles(directory); + if (fileCount === 0) { + throw new Error( + `wget completed but no files were downloaded from ${url}. The site may be empty or block crawlers.`, + ); + } + return { directory, fileCount }; +} diff --git a/src/utils/decentralize/randomName.ts b/src/utils/decentralize/randomName.ts new file mode 100644 index 0000000..1fccd52 --- /dev/null +++ b/src/utils/decentralize/randomName.ts @@ -0,0 +1,81 @@ +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { randomBytes } from "node:crypto"; +import { + checkDomainAvailability, + type AvailabilityResult, +} from "../deploy/availability.js"; +import type { Env } from "../../config.js"; + +/** + * Random label prefix for the "free / not-great domain" tier. The base length + * (8 chars before the trailing digits) keeps us in DotNS's NoStatus bucket + * (`baseLength >= 9` after 2 trailing digits → no PoP required) so //Bob can + * self-register without a personhood credential. See `availability.ts`'s + * `classifyLabel` for the rule. + */ +const PREFIX = "decent-"; + +function randomLabel(): string { + // 6 random base36 chars + 2 trailing digits = "decent-xxxxxx12". + // Trailing digits keep us inside the "Available to all" classifier branch + // for short-ish names without needing PoP. + const suffix = randomBytes(4).toString("hex").slice(0, 6); + const digits = String(randomBytes(1)[0] % 90 + 10); // always 2 digits + return `${PREFIX}${suffix}${digits}`; +} + +export interface FindAvailableNameOptions { + env?: Env; + ownerSs58Address?: string; + /** Cap on attempts; defaults to 20. */ + maxAttempts?: number; +} + +/** + * Generate `decent-NN` candidates until one is `available` according to + * `checkDomainAvailability`. Returns the chosen label and the matching + * availability result so callers can reuse the embedded `DeployPlan`. + * + * Bails after `maxAttempts` with a descriptive error — collisions in this + * keyspace would indicate either a broken RNG or an attacker pre-claiming the + * keyspace, both of which are worth surfacing rather than looping forever. + */ +export async function findAvailableRandomName( + options: FindAvailableNameOptions = {}, +): Promise<{ label: string; availability: Extract }> { + const maxAttempts = options.maxAttempts ?? 20; + let lastFailure: AvailabilityResult | null = null; + + for (let i = 0; i < maxAttempts; i++) { + const candidate = randomLabel(); + const result = await checkDomainAvailability(candidate, { + env: options.env, + ownerSs58Address: options.ownerSs58Address, + }); + if (result.status === "available") { + return { label: candidate, availability: result }; + } + lastFailure = result; + } + + const reason = lastFailure + ? `last attempt was "${lastFailure.fullDomain}" (${lastFailure.status})` + : "no availability response"; + throw new Error( + `Could not find an available random domain after ${maxAttempts} attempts — ${reason}`, + ); +} From eb53e7a9ee80d3671de965c7a7b7041e0bc2f868 Mon Sep 17 00:00:00 2001 From: Shawn Tabrizi Date: Tue, 19 May 2026 22:17:29 +0900 Subject: [PATCH 2/5] fixes --- src/commands/decentralize/index.ts | 68 +++++++--------------------- src/utils/decentralize/randomName.ts | 7 +-- 2 files changed, 19 insertions(+), 56 deletions(-) diff --git a/src/commands/decentralize/index.ts b/src/commands/decentralize/index.ts index 9d0eacf..32b25f3 100644 --- a/src/commands/decentralize/index.ts +++ b/src/commands/decentralize/index.ts @@ -34,18 +34,10 @@ import { Command } from "commander"; import { rmSync } from "node:fs"; import { runCliCommand } from "../../cli-runtime.js"; import { errorMessage, withSpan } from "../../telemetry.js"; -import { - DEFAULT_ENV, - type Env, - getChainConfig, - resolveLegacyEnv, -} from "../../config.js"; +import { DEFAULT_ENV, type Env, getChainConfig, resolveLegacyEnv } from "../../config.js"; import { resolveSigner, type ResolvedSigner } from "../../utils/signer.js"; import { runStorageDeploy } from "../../utils/deploy/storage.js"; -import { - checkDomainAvailability, - formatAvailability, -} from "../../utils/deploy/availability.js"; +import { checkDomainAvailability, formatAvailability } from "../../utils/deploy/availability.js"; import { normalizeDomain } from "../../utils/deploy/playground.js"; import { mirrorSite } from "../../utils/decentralize/mirror.js"; import { findAvailableRandomName } from "../../utils/decentralize/randomName.js"; @@ -73,15 +65,8 @@ export const decentralizeCommand = new Command("decentralize") "--dot ", "DotNS domain (with or without `.dot`). Omit to auto-generate a free random name.", ) - .option( - "--env ", - "Target environment (default: paseo-next-v2)", - DEFAULT_ENV, - ) - .option( - "--suri ", - "Sign with this SURI instead of the default //Bob test account", - ) + .option("--env ", "Target environment (default: paseo-next-v2)", DEFAULT_ENV) + .option("--suri ", "Sign with this SURI instead of the default //Bob test account") .action(async (opts: DecentralizeOpts) => runCliCommand("decentralize", { hardExit: true }, async () => { const env: Env = resolveLegacyEnv(opts.env); @@ -98,15 +83,11 @@ export const decentralizeCommand = new Command("decentralize") let mirrorDir: string | null = null; try { - signer = await withSpan( - "cli.decentralize.signer", - "resolve signer", - () => resolveSigner({ suri: opts.suri ?? DEFAULT_SURI }), + signer = await withSpan("cli.decentralize.signer", "resolve signer", () => + resolveSigner({ suri: opts.suri ?? DEFAULT_SURI }), ); - process.stdout.write( - `\n▸ Signing as ${signer.address} (${signer.source})\n`, - ); + process.stdout.write(`\n▸ Signing as ${signer.address} (${signer.source})\n`); // ── 1. Pick a domain ──────────────────────────────────────── let label: string; @@ -126,10 +107,7 @@ export const decentralizeCommand = new Command("decentralize") ownerSs58Address: signer?.address, }), ); - if ( - availability.status === "reserved" || - availability.status === "taken" - ) { + if (availability.status === "reserved" || availability.status === "taken") { throw new Error(formatAvailability(availability)); } if (availability.status === "unknown") { @@ -138,9 +116,7 @@ export const decentralizeCommand = new Command("decentralize") ); } } else { - process.stdout.write( - `\n▸ Picking a free random .dot name…\n`, - ); + process.stdout.write(`\n▸ Picking a free random .dot name…\n`); const chosen = await withSpan( "cli.decentralize.random-name", "find available random name", @@ -157,25 +133,17 @@ export const decentralizeCommand = new Command("decentralize") // ── 2. Mirror the site ────────────────────────────────────── process.stdout.write(`\n▸ Mirroring ${opts.site}…\n`); - const mirror = await withSpan( - "cli.decentralize.mirror", - "mirror site", - () => - mirrorSite({ - url: opts.site, - onLine: (line) => - process.stdout.write(` ${line}\n`), - }), + const mirror = await withSpan("cli.decentralize.mirror", "mirror site", () => + mirrorSite({ + url: opts.site, + onLine: (line) => process.stdout.write(` ${line}\n`), + }), ); mirrorDir = mirror.directory; - process.stdout.write( - ` → ${mirror.fileCount} files in ${mirror.directory}\n`, - ); + process.stdout.write(` → ${mirror.fileCount} files in ${mirror.directory}\n`); // ── 3. Upload to Bulletin + register DotNS ────────────────── - process.stdout.write( - `\n▸ Uploading to Bulletin and registering ${fullDomain}…\n`, - ); + process.stdout.write(`\n▸ Uploading to Bulletin and registering ${fullDomain}…\n`); const result = await withSpan( "cli.decentralize.storage", "bulletin upload + dotns register", @@ -188,8 +156,7 @@ export const decentralizeCommand = new Command("decentralize") signerAddress: signer?.address, }, env, - onLogEvent: (event) => - process.stdout.write(` • ${event.kind}\n`), + onLogEvent: (event) => process.stdout.write(` • ${event.kind}\n`), }), ); @@ -227,4 +194,3 @@ export const decentralizeCommand = new Command("decentralize") } }), ); - diff --git a/src/utils/decentralize/randomName.ts b/src/utils/decentralize/randomName.ts index 1fccd52..80e7451 100644 --- a/src/utils/decentralize/randomName.ts +++ b/src/utils/decentralize/randomName.ts @@ -14,10 +14,7 @@ // limitations under the License. import { randomBytes } from "node:crypto"; -import { - checkDomainAvailability, - type AvailabilityResult, -} from "../deploy/availability.js"; +import { checkDomainAvailability, type AvailabilityResult } from "../deploy/availability.js"; import type { Env } from "../../config.js"; /** @@ -34,7 +31,7 @@ function randomLabel(): string { // Trailing digits keep us inside the "Available to all" classifier branch // for short-ish names without needing PoP. const suffix = randomBytes(4).toString("hex").slice(0, 6); - const digits = String(randomBytes(1)[0] % 90 + 10); // always 2 digits + const digits = String((randomBytes(1)[0] % 90) + 10); // always 2 digits return `${PREFIX}${suffix}${digits}`; } From b13154d08cfc72e366814ca4cffad5187fd62670 Mon Sep 17 00:00:00 2001 From: Shawn Tabrizi Date: Tue, 19 May 2026 22:22:38 +0900 Subject: [PATCH 3/5] Update toolchain.ts --- src/utils/toolchain.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/utils/toolchain.ts b/src/utils/toolchain.ts index 014d044..f6d2f47 100644 --- a/src/utils/toolchain.ts +++ b/src/utils/toolchain.ts @@ -172,6 +172,24 @@ export const TOOL_STEPS: ToolStep[] = [ }, manualHint: "https://git-scm.com/downloads", }, + { + // Required by `dot decentralize` (mirrors a live site via `wget --mirror`). + // macOS doesn't ship wget by default; Linux distros vary. + name: "wget", + check: () => commandExists("wget"), + install: async (onData) => { + if (platform() === "darwin" && (await commandExists("brew"))) { + await runPiped("brew install wget", onData); + } else if (platform() === "linux") { + await runPiped(`${sudo()}apt update && ${sudo()}apt install -y wget`, onData); + } else { + throw new Error( + "Cannot install wget automatically on this platform — install manually.", + ); + } + }, + manualHint: "brew install wget (macOS) or your distro's package manager", + }, { name: "foundry (polkadot)", check: () => hasFoundryPolkadot(), From f2023f5b8d838c9b32839d3278f075b87ecce46a Mon Sep 17 00:00:00 2001 From: Shawn Tabrizi Date: Tue, 19 May 2026 23:32:08 +0900 Subject: [PATCH 4/5] Update index.ts --- src/commands/decentralize/index.ts | 39 ++++++++++-------------------- 1 file changed, 13 insertions(+), 26 deletions(-) diff --git a/src/commands/decentralize/index.ts b/src/commands/decentralize/index.ts index 32b25f3..cc80943 100644 --- a/src/commands/decentralize/index.ts +++ b/src/commands/decentralize/index.ts @@ -18,11 +18,10 @@ * * dot decentralize --site=shawntabrizi.github.io --dot=shawntabrizi.dot * - * Zero-account by default: we sign with //Bob on paseo-next-v2 so a first-time - * user can see end-to-end value with no wallet setup. The migration story is - * surfaced in the success footer — re-deploy from `dot deploy` with their own - * --suri or QR session and the same domain check at availability.ts:261 will - * recognise the "owned by you" path and update in place. + * Default signer is the `dot init` session signer — the user owns the + * registered .dot name. Pass `--suri //Bob` (or any dev name / mnemonic) for + * an explicit signer; that's what the demo service at dot-decentralize uses + * to give first-time visitors a zero-setup path on paseo-next-v2. * * Site cloning is `wget --mirror` (see utils/decentralize/mirror.ts). The * upload + DotNS register flow re-uses `runStorageDeploy` exactly like @@ -49,13 +48,6 @@ interface DecentralizeOpts { suri?: string; } -/** - * //Bob is a hard-coded dev SURI. It only has funds / authorisation on the - * paseo-next-v2 testnet — using it on mainnet would silently fail at the - * funding step. Refusing here gives a friendly error instead. - */ -const DEFAULT_SURI = "//Bob"; - export const decentralizeCommand = new Command("decentralize") .description( "Mirror a live static site to Polkadot Bulletin and register a .dot name pointing at it", @@ -66,25 +58,21 @@ export const decentralizeCommand = new Command("decentralize") "DotNS domain (with or without `.dot`). Omit to auto-generate a free random name.", ) .option("--env ", "Target environment (default: paseo-next-v2)", DEFAULT_ENV) - .option("--suri ", "Sign with this SURI instead of the default //Bob test account") + .option( + "--suri ", + "Sign with this SURI (dev name like //Bob, or a BIP-39 mnemonic). " + + "Default: the session signer paired by `dot init`.", + ) .action(async (opts: DecentralizeOpts) => runCliCommand("decentralize", { hardExit: true }, async () => { const env: Env = resolveLegacyEnv(opts.env); - const usingDefaultBob = !opts.suri; - - if (usingDefaultBob && getChainConfig(env).network !== "testnet") { - throw new Error( - `--env ${env} is a non-testnet network; //Bob has no funds there. ` + - `Pass --suri or use --env paseo-next-v2.`, - ); - } let signer: ResolvedSigner | null = null; let mirrorDir: string | null = null; try { signer = await withSpan("cli.decentralize.signer", "resolve signer", () => - resolveSigner({ suri: opts.suri ?? DEFAULT_SURI }), + resolveSigner({ suri: opts.suri }), ); process.stdout.write(`\n▸ Signing as ${signer.address} (${signer.source})\n`); @@ -170,11 +158,10 @@ export const decentralizeCommand = new Command("decentralize") ` IPFS CID ${result.cid}\n` + ` Gateway ${gatewayUrl}\n`, ); - if (usingDefaultBob) { + if (signer.source === "dev") { process.stdout.write( - "\n Owned by //Bob (testnet demo). To claim a name under your own\n" + - " account, run `dot init` to pair a wallet, then re-deploy with\n" + - " `dot deploy --domain .dot` from a project of your own.\n", + "\n Owned by a development account (testnet demo). To claim a name\n" + + " under your own account, run `dot init` and re-deploy without --suri.\n", ); } process.stdout.write("\n"); From 4759b4e11dcea8ea2666ab3a373bd7769612a417 Mon Sep 17 00:00:00 2001 From: Shawn Tabrizi Date: Wed, 20 May 2026 00:09:14 +0900 Subject: [PATCH 5/5] feat(decentralize): derive auto names from site URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before, `dot decentralize --site=example.com` picked a random `decent-ffe0ab72.dot`. The label was unrecognisable in the resulting `.dot.li` URL and gave the user no clue which site they were mirroring. After, the auto-name starts with a dumb transliteration of the URL's hostname (lowercase, dots → hyphens, sanitised to `[a-z0-9-]`, capped at 30 chars), followed by a 4-letter random tail + 2 digits. No TLD or `www.` stripping — that requires the Public Suffix List, which we don't want as a dep; users who want a clean name pass `--dot` explicitly. Falls back to the legacy `decent-` shape for unparseable inputs. example.com → example-com-uslj17.dot shawntabrizi.com → shawntabrizi-com-byhq57.dot shawntabrizi.github.io → shawntabrizi-github-io-NN.dot Also fixes a latent classifier bug. The previous hex-based suffix produced labels with >2 trailing digits ~62% of the time (whenever the random hex happened to end in a digit), classified as RESERVED and silently masked by the 20-attempt retry loop. The new generator uses lowercase letters only in the variable middle so the trailing- digit invariant holds for every call. New test file covers the invariants across 200 iterations: exactly 2 trailing digits, base length ≥9 (NoStatus), normalizeDomain regex compatibility, plus the hostname-incorporation cases. --- src/commands/decentralize/index.ts | 3 +- src/utils/decentralize/randomName.test.ts | 147 ++++++++++++++++++++++ src/utils/decentralize/randomName.ts | 114 ++++++++++++++--- 3 files changed, 245 insertions(+), 19 deletions(-) create mode 100644 src/utils/decentralize/randomName.test.ts diff --git a/src/commands/decentralize/index.ts b/src/commands/decentralize/index.ts index cc80943..e91792d 100644 --- a/src/commands/decentralize/index.ts +++ b/src/commands/decentralize/index.ts @@ -104,7 +104,7 @@ export const decentralizeCommand = new Command("decentralize") ); } } else { - process.stdout.write(`\n▸ Picking a free random .dot name…\n`); + process.stdout.write(`\n▸ Picking a free .dot name from ${opts.site}…\n`); const chosen = await withSpan( "cli.decentralize.random-name", "find available random name", @@ -112,6 +112,7 @@ export const decentralizeCommand = new Command("decentralize") findAvailableRandomName({ env, ownerSs58Address: signer?.address, + siteUrl: opts.site, }), ); label = chosen.label; diff --git a/src/utils/decentralize/randomName.test.ts b/src/utils/decentralize/randomName.test.ts new file mode 100644 index 0000000..bd248be --- /dev/null +++ b/src/utils/decentralize/randomName.test.ts @@ -0,0 +1,147 @@ +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * Shape-invariant tests for `generateLabel`. Two things must hold for every + * generated label, regardless of input, or //Bob and other NoStatus signers + * will be rejected at the DotNS chain step: + * + * 1. Exactly 2 trailing digits (>2 is RESERVED for governance) + * 2. Base length (everything before the trailing digits) >= 9 + * + * Each invariant is checked across many iterations to catch RNG-dependent + * edge cases — the previous hex-suffix implementation produced >2 trailing + * digits ~62 % of the time, silently masked by the retry loop. + */ + +import { describe, expect, it } from "vitest"; +import { generateLabel } from "./randomName.js"; + +const ITERATIONS = 200; + +function trailingDigits(label: string): number { + return /[0-9]*$/.exec(label)?.[0].length ?? 0; +} + +function baseLength(label: string): number { + return label.length - trailingDigits(label); +} + +describe("generateLabel", () => { + it("incorporates the hostname verbatim (TLD kept)", () => { + expect(generateLabel("https://shawntabrizi.com")).toMatch(/^shawntabrizi-com-/); + expect(generateLabel("https://example.com")).toMatch(/^example-com-/); + }); + + it("preserves the www. prefix (we don't second-guess what the user typed)", () => { + expect(generateLabel("https://www.example.com")).toMatch(/^www-example-com-/); + }); + + it("preserves multi-segment public suffixes (we don't consult the PSL)", () => { + expect(generateLabel("https://example.co.uk")).toMatch(/^example-co-uk-/); + expect(generateLabel("https://shawntabrizi.github.io")).toMatch(/^shawntabrizi-github-io-/); + }); + + it("replaces dots with hyphens", () => { + expect(generateLabel("https://a.b.c.example.com")).toMatch(/^a-b-c-example-com-/); + }); + + it("ignores the path", () => { + const label = generateLabel("https://example.com/blog/post-1?x=y"); + expect(label).toMatch(/^example-com-/); + expect(label).not.toContain("blog"); + expect(label).not.toContain("post"); + }); + + it("accepts bare hostnames (no protocol)", () => { + expect(generateLabel("example.com")).toMatch(/^example-com-/); + }); + + it("falls back to decent- when the URL is unusable", () => { + // node:URL accepts plenty of weird inputs; an outright empty hostname + // should be the trigger. We exercise it via `garbage://` which parses + // but yields no hostname. + const label = generateLabel("garbage://"); + expect(label).toMatch(/^decent-/); + }); + + it("falls back to decent- when no siteUrl is provided", () => { + expect(generateLabel(undefined)).toMatch(/^decent-/); + }); + + it("always ends in exactly 2 trailing digits", () => { + for (let i = 0; i < ITERATIONS; i++) { + const label = generateLabel("https://example.com"); + expect(trailingDigits(label)).toBe(2); + } + }); + + it("produces NoStatus-compatible base length (>=9)", () => { + for (let i = 0; i < ITERATIONS; i++) { + const label = generateLabel("https://example.com"); + expect(baseLength(label)).toBeGreaterThanOrEqual(9); + } + }); + + it("pads short hostnames so the NoStatus base threshold is still met", () => { + // "a.b" → base "a-b" is only 3 chars; needs extra letters to reach 9. + for (let i = 0; i < ITERATIONS; i++) { + const label = generateLabel("https://a.b"); + expect(baseLength(label)).toBeGreaterThanOrEqual(9); + expect(trailingDigits(label)).toBe(2); + } + }); + + it("preserves shape invariants for the decent- fallback", () => { + for (let i = 0; i < ITERATIONS; i++) { + const label = generateLabel(undefined); + expect(baseLength(label)).toBeGreaterThanOrEqual(9); + expect(trailingDigits(label)).toBe(2); + } + }); + + it("caps absurdly long hostnames", () => { + const longHost = `${"a".repeat(80)}.com`; + const label = generateLabel(`https://${longHost}`); + // DNS labels max out at 63; we cap the host segment at 30 and add a + // short suffix, so the final length is well under that. + expect(label.length).toBeLessThanOrEqual(40); + }); + + it("produces labels matching normalizeDomain's regex", () => { + // playground.ts::normalizeDomain enforces /^[a-z0-9][a-z0-9-]*$/i. + const re = /^[a-z0-9][a-z0-9-]*$/; + for (const input of [ + "https://example.com", + "https://www.shawntabrizi.com", + "https://x.com", + "https://a.b", + "https://sub.domain.example.com", + ]) { + const label = generateLabel(input); + expect(label).toMatch(re); + } + }); + + it("varies on each call (RNG is wired)", () => { + const labels = new Set(); + for (let i = 0; i < 50; i++) { + labels.add(generateLabel("https://example.com")); + } + // Tail is 4 letters (26^4 ≈ 456k) + 2 digits, so 50 calls colliding + // would point at a broken RNG. + expect(labels.size).toBe(50); + }); +}); diff --git a/src/utils/decentralize/randomName.ts b/src/utils/decentralize/randomName.ts index 80e7451..517f845 100644 --- a/src/utils/decentralize/randomName.ts +++ b/src/utils/decentralize/randomName.ts @@ -18,34 +18,112 @@ import { checkDomainAvailability, type AvailabilityResult } from "../deploy/avai import type { Env } from "../../config.js"; /** - * Random label prefix for the "free / not-great domain" tier. The base length - * (8 chars before the trailing digits) keeps us in DotNS's NoStatus bucket - * (`baseLength >= 9` after 2 trailing digits → no PoP required) so //Bob can - * self-register without a personhood credential. See `availability.ts`'s - * `classifyLabel` for the rule. + * Label-generation rules (see `availability.ts::classifyLabel`): + * + * - `baseLength >= 9` + exactly 2 trailing digits → `POP_STATUS_NO_STATUS` + * (any signer, no personhood credential) + * - More than 2 trailing digits → `POP_STATUS_RESERVED` (unregistrable) + * - Base <= 5 chars → reserved for governance + * + * We aim for NoStatus so a fresh //Bob demo or any user without PoP can + * register the name. The variable middle uses lowercase letters only (no + * digits) so the "exactly 2 trailing digits" invariant holds no matter where + * the RNG lands — the earlier hex-suffix design could produce >2 trailing + * digits ~62 % of the time, silently rejected by the retry loop as RESERVED. + */ + +const MIN_BASE_LEN = 9; +const MIN_LETTERS = 4; +const FALLBACK_PREFIX = "decent-"; +/** Cap the derived host segment so the final label stays well under DNS's 63-char ceiling. */ +const MAX_HOST_LEN = 30; + +/** + * Sanitise a site URL into a domain-safe prefix derived from its hostname. + * Returns null if no usable base can be extracted; callers fall back to + * `FALLBACK_PREFIX`. + * + * We deliberately do NOT strip TLDs, public suffixes, or `www.` — they're + * part of the URL the user typed and we want the auto-generated name to be a + * predictable transliteration of it. Stripping `.com` requires a Public + * Suffix List to handle `co.uk`/`github.io`/`vercel.app` correctly, which is + * a dep we don't want. Users who want a clean name pass `--dot=`. + * + * https://www.shawntabrizi.com/blog → www-shawntabrizi-com + * https://example.com:8080 → example-com + * https://shawntabrizi.github.io → shawntabrizi-github-io + * https://example.co.uk → example-co-uk + * https://x.com → x-com + * https://garbage:// → null + */ +function deriveBase(siteUrl: string): string | null { + let parsed: URL; + try { + parsed = new URL(siteUrl); + } catch { + try { + parsed = new URL(`https://${siteUrl}`); + } catch { + return null; + } + } + + let s = parsed.hostname + .toLowerCase() + .replace(/\./g, "-") + .replace(/[^a-z0-9-]/g, ""); + s = s.replace(/-+/g, "-").replace(/^-+|-+$/g, ""); + if (!s) return null; + + // `normalizeDomain` (playground.ts) requires `^[a-z0-9]` as first char. + if (!/^[a-z0-9]/.test(s)) return null; + + if (s.length > MAX_HOST_LEN) s = s.slice(0, MAX_HOST_LEN).replace(/-+$/, ""); + + return s || null; +} + +/** + * Generate one candidate label. Each call produces fresh randomness, so the + * retry loop in `findAvailableRandomName` can resolve collisions. + * + * generateLabel("https://shawntabrizi.com") → "shawntabrizi-com-abcd42" + * generateLabel("https://x.com") → "x-com-abcd42" + * generateLabel(undefined) → "decent-abcd42" */ -const PREFIX = "decent-"; - -function randomLabel(): string { - // 6 random base36 chars + 2 trailing digits = "decent-xxxxxx12". - // Trailing digits keep us inside the "Available to all" classifier branch - // for short-ish names without needing PoP. - const suffix = randomBytes(4).toString("hex").slice(0, 6); - const digits = String((randomBytes(1)[0] % 90) + 10); // always 2 digits - return `${PREFIX}${suffix}${digits}`; +export function generateLabel(siteUrl?: string): string { + const base = siteUrl ? deriveBase(siteUrl) : null; + const prefix = base ? `${base}-` : FALLBACK_PREFIX; + + // Pad with lowercase letters so prefix + letters >= MIN_BASE_LEN. + const lettersLen = Math.max(MIN_LETTERS, MIN_BASE_LEN - prefix.length); + const letters = Array.from(randomBytes(lettersLen)) + .map((b) => String.fromCharCode(97 + (b % 26))) + .join(""); + + const digits = String((randomBytes(1)[0] % 90) + 10); // 10..99 + + return `${prefix}${letters}${digits}`; } export interface FindAvailableNameOptions { env?: Env; ownerSs58Address?: string; + /** + * URL of the site being decentralized. When provided, the generated + * candidates start with a sanitised version of the hostname (e.g. + * `shawntabrizi-com-abcd42` rather than `decent-abcd42`). Improves + * recognisability of the resulting `.dot.li` URL. + */ + siteUrl?: string; /** Cap on attempts; defaults to 20. */ maxAttempts?: number; } /** - * Generate `decent-NN` candidates until one is `available` according to - * `checkDomainAvailability`. Returns the chosen label and the matching - * availability result so callers can reuse the embedded `DeployPlan`. + * Generate URL-derived NoStatus candidates until one is `available`. Returns + * the chosen label and the matching availability result so callers can reuse + * the embedded `DeployPlan`. * * Bails after `maxAttempts` with a descriptive error — collisions in this * keyspace would indicate either a broken RNG or an attacker pre-claiming the @@ -58,7 +136,7 @@ export async function findAvailableRandomName( let lastFailure: AvailabilityResult | null = null; for (let i = 0; i < maxAttempts; i++) { - const candidate = randomLabel(); + const candidate = generateLabel(options.siteUrl); const result = await checkDomainAvailability(candidate, { env: options.env, ownerSs58Address: options.ownerSs58Address,