fix: harden module-id validation, parallelize registry loading, and redact third-party ids in telemetry

- Add `isLikelyNamespaceTypo` and `isValidModuleId` to `@stanza/registry` and gate both `add` and `remove` on them before any registry fetch or disk write — bare `@ns` specs and path-traversal-style ids now fail fast with a targeted hint instead of an opaque 404
- Add `captureModule` to the telemetry client: mirrors `capture("cli_module", ...)` but redacts the module id when the namespace is not `@stanza` so proprietary ids never leave the user's machine; replace all `capture("cli_module", ...)` call sites in `add`, `remove`, and `init`
- Parallelize `loadRegistries` via `Promise.all` so custom-namespace index fetches happen concurrently; split `tryFetchIndex` into three discrete try/catch blocks that differentiate URL-build errors (surfaced as warnings), network failures (silent), 404s (legitimate no-index), and schema-invalid 200s (warned)
- Re-stamp `version` to `CURRENT_MANIFEST_VERSION` in `readManifest` so older manifests are transparently upgraded in memory and the next `writeManifest` persists the new version; export `CURRENT_MANIFEST_VERSION` and `SUPPORTED_MANIFEST_VERSIONS` from `@stanza/registry`
- Move codemod-catalog id validation before `ensureSlotPackage` in `applyModule` so an unknown codemod id can't bootstrap a slot package and leave it orphaned on a mid-flight throw; validation still runs on `--dry-run`
- Add a dedicated empty-state branch in `search` for namespaces that are configured but expose no browsable index, directing users to `stanza add <category> <ns>/<id>` instead of a generic "No modules found"
This commit is contained in:
2026-05-26 21:03:53 -04:00
parent f195505a48
commit 1fc133c7dc
19 changed files with 402 additions and 61 deletions
+29 -4
View File
@@ -4,7 +4,9 @@ import {
categoryHome,
DEFAULT_NAMESPACE,
isCategoryId,
isLikelyNamespaceTypo,
isMulti,
isValidModuleId,
KNOWN_CATEGORIES,
parseModuleSpec,
resolveAdapter,
@@ -52,10 +54,33 @@ export async function cmdAdd(args: CliArgs): Promise<void> {
const category = slot;
const group = category;
// Catch the `@bare` typo before parsing — without this, the spec falls
// through to a literal id of "@bare" and the registry returns an opaque
// 404. Explicit hint is friendlier.
if (isLikelyNamespaceTypo(rawModuleId)) {
p.log.error(
`"${rawModuleId}" looks like a namespace but is missing the module id. ` +
`Did you mean \`${rawModuleId}/<id>\`?`,
);
process.exitCode = 1;
return;
}
// Split `@ns/id` into a namespace + id. Bare ids implicitly mean `@stanza`,
// which we leave as `undefined` on the record (omitted = default).
const { namespace, id: moduleId } = parseModuleSpec(rawModuleId);
// The id is about to be interpolated into a registry URL — reject anything
// that could escape its segment (path traversal, query strings, encoded
// bytes). See `isValidModuleId` in @stanza/registry for the exact shape.
if (!isValidModuleId(moduleId)) {
p.log.error(
`Invalid module id "${moduleId}". Ids must be alphanumeric segments ` +
`(letters, digits, dashes, underscores) joined by "/".`,
);
process.exitCode = 1;
return;
}
const projectRoot = findProjectRoot();
if (!projectRoot) {
p.log.error("No stanza.json found in this or any parent directory.");
@@ -189,10 +214,10 @@ export async function cmdAdd(args: CliArgs): Promise<void> {
}
// Always counted in the aggregate install total; the `namespace` property
// lets the stats page exclude third-party modules from per-category
// leaderboards (where ranking private/proprietary ids alongside first-party
// ones would be misleading).
telemetry.capture("cli_module", {
// lets the stats page bucket per-namespace. `captureModule` redacts the
// module id for non-@stanza namespaces so private/proprietary ids never
// leave the user's machine.
telemetry.captureModule({
action: "install",
group,
module: mod.id,
+1 -1
View File
@@ -184,7 +184,7 @@ export async function cmdInit(args: CliArgs): Promise<void> {
// Init always installs from the first-party `@stanza` registry.
// Mirrors the event shape `add`/`remove` use so the stats query can
// bucket on `properties.namespace` uniformly.
telemetry.capture("cli_module", {
telemetry.captureModule({
action: "install",
group: category,
module: mod.id,
+22 -3
View File
@@ -9,7 +9,9 @@ import {
categoryHome,
DEFAULT_NAMESPACE,
isCategoryId,
isLikelyNamespaceTypo,
isMulti,
isValidModuleId,
PACKAGE_DIRS,
parseModuleSpec,
selectedAll,
@@ -59,7 +61,23 @@ export async function cmdRemove(args: CliArgs): Promise<void> {
// Accept `@ns/id` so users can disambiguate when two registries ship a
// module under the same id. We match against `record.id` (the namespace
// hint is informational + persisted on the record on install).
if (rawModuleId && isLikelyNamespaceTypo(rawModuleId)) {
p.log.error(
`"${rawModuleId}" looks like a namespace but is missing the module id. ` +
`Did you mean \`${rawModuleId}/<id>\`?`,
);
process.exitCode = 1;
return;
}
const moduleId = rawModuleId ? parseModuleSpec(rawModuleId).id : undefined;
if (moduleId !== undefined && !isValidModuleId(moduleId)) {
p.log.error(
`Invalid module id "${moduleId}". Ids must be alphanumeric segments ` +
`(letters, digits, dashes, underscores) joined by "/".`,
);
process.exitCode = 1;
return;
}
if (!isCategoryId(slot)) {
p.log.error(`Unknown category: ${slot}`);
process.exitCode = 1;
@@ -253,9 +271,10 @@ export async function cmdRemove(args: CliArgs): Promise<void> {
p.log.warn("Skipped README.md refresh (user-modified). Delete the file to regenerate.");
}
// Mirrors `add`: the namespace property lets the stats page bucket
// first-party vs third-party correctly without losing the aggregate count.
telemetry.capture("cli_module", {
// Mirrors `add`: `captureModule` redacts the module id for third-party
// namespaces so private/proprietary ids never leave the user's machine,
// while still bucketing aggregate counts per namespace.
telemetry.captureModule({
action: "remove",
group,
module: installed.id,
+18 -10
View File
@@ -1,4 +1,4 @@
import { DEFAULT_NAMESPACE, parseModuleSpec } from "@stanza/registry";
import { DEFAULT_NAMESPACE, parseModuleSpec, type StanzaManifest } from "@stanza/registry";
import { defineCommand } from "citty";
import pc from "picocolors";
@@ -25,15 +25,14 @@ export async function cmdSearch(args: CliArgs): Promise<void> {
// is searchable. A malformed manifest just disables the fan-out — don't
// break `search` because of an unrelated parse error.
const projectRoot = findProjectRoot();
const manifest = projectRoot
? (() => {
try {
return readManifest(projectRoot);
} catch {
return undefined;
}
})()
: undefined;
let manifest: StanzaManifest | undefined;
if (projectRoot) {
try {
manifest = readManifest(projectRoot);
} catch {
// Leave `manifest` undefined — fan-out disabled, @stanza still works.
}
}
const registry = await loadRegistries(manifest);
const raw = typeof args.query === "string" ? args.query.trim() : "";
@@ -66,6 +65,15 @@ export async function cmdSearch(args: CliArgs): Promise<void> {
if (hits.length === 0) {
if (nsFilter && !registry.namespaces().includes(nsFilter)) {
console.log(pc.dim(`Unknown registry "${nsFilter}". Add it to stanza.json.`));
} else if (nsFilter && !indices.some(({ namespace }) => namespace === nsFilter)) {
// Namespace IS configured but exposes no browsable index (no `indexUrl`,
// or it 404'd / parse-failed). Modules are still reachable by name.
console.log(
pc.dim(
`Registry "${nsFilter}" has no browsable index — use ` +
`\`stanza add <category> ${nsFilter}/<id>\` to fetch by name.`,
),
);
} else {
console.log(pc.dim("No modules found."));
}
+13 -9
View File
@@ -82,6 +82,19 @@ export async function applyModule(args: {
let manifest = args.manifest;
const touchedFiles = new Set<string>();
// Validate catalog ids before any disk writes — third-party modules may
// reference first-party codemod ids but can't ship new ones, and we don't
// want `ensureSlotPackage` (below) to bootstrap a slot package only to
// throw mid-flight and leave it orphaned. Runs on dry-run too, so users
// catch typos without --apply.
for (const invocation of adapter.codemods ?? []) {
if (!CODEMOD_CATALOG[invocation.id]) {
throw new Error(
`Codemod "${invocation.id}" referenced by ${module.category}/${module.id} (adapter "${adapter.key}") is not in the catalog. Add it to packages/codemods/src/builtins/ and register in builtins/index.ts.`,
);
}
}
const home = categoryHome(module.category);
if (home.kind === "app" && targetApps.length !== 1) {
throw new Error(
@@ -317,15 +330,6 @@ export async function applyModule(args: {
touchedFiles.add(".env.example");
}
// Validate up front so dry-run catches missing ids too.
for (const invocation of adapter.codemods ?? []) {
if (!CODEMOD_CATALOG[invocation.id]) {
throw new Error(
`Codemod "${invocation.id}" referenced by ${module.category}/${module.id} (adapter "${adapter.key}") is not in the catalog. Add it to packages/codemods/src/builtins/ and register in builtins/index.ts.`,
);
}
}
if (!dryRun) {
const record = recordFor(module, adapter, targetApps, namespace);
// Push into the category's array, replacing any same-(id, apps-key) record
+5 -1
View File
@@ -3,6 +3,7 @@ import path from "node:path";
import {
type AppSpec,
CURRENT_MANIFEST_VERSION,
emptyManifest,
MANIFEST_SCHEMA_URL,
StanzaManifestSchema,
@@ -28,7 +29,10 @@ export function readManifest(projectRoot: string): StanzaManifest {
.join("\n")}`,
);
}
return parsed.data;
// Re-stamp the version so the in-memory object always reflects the current
// schema. Past-version manifests parsed via SUPPORTED_MANIFEST_VERSIONS get
// upgraded transparently; the next `writeManifest` persists it.
return { ...parsed.data, version: CURRENT_MANIFEST_VERSION };
}
export function writeManifest(projectRoot: string, manifest: StanzaManifest): void {
+51 -13
View File
@@ -61,13 +61,21 @@ type NamespaceLoader = {
* `stanza init` that only ever hit the default namespace.
*/
export async function loadRegistries(manifest?: StanzaManifest): Promise<Registries> {
const loaders = new Map<string, NamespaceLoader>();
loaders.set(DEFAULT_NAMESPACE, await buildDefaultLoader());
const customEntries = Object.entries(manifest?.registries ?? {}).filter(
// Schema also forbids this; double-guard so a hand-edited manifest can't
// shadow the default.
([ns]) => ns !== DEFAULT_NAMESPACE,
);
// Build default + every custom loader in parallel — each one issues an
// independent index fetch and there's no ordering dependency between them.
const [defaultLoader, ...customLoaders] = await Promise.all([
buildDefaultLoader(),
...customEntries.map(([ns, cfg]) => buildCustomLoader(ns, cfg)),
]);
for (const [ns, cfg] of Object.entries(manifest?.registries ?? {})) {
if (ns === DEFAULT_NAMESPACE) continue; // schema also forbids this; double-guard.
loaders.set(ns, await buildCustomLoader(cfg));
}
const loaders = new Map<string, NamespaceLoader>();
loaders.set(DEFAULT_NAMESPACE, defaultLoader);
customEntries.forEach(([ns], i) => loaders.set(ns, customLoaders[i]!));
return {
namespaces: () => [...loaders.keys()],
@@ -140,11 +148,11 @@ async function buildDefaultLoader(): Promise<NamespaceLoader> {
return loadHttpRegistry(DEFAULT_REGISTRY_URL);
}
async function buildCustomLoader(cfg: RegistryConfig): Promise<NamespaceLoader> {
async function buildCustomLoader(namespace: string, cfg: RegistryConfig): Promise<NamespaceLoader> {
const resolved = resolveConfig(cfg);
// Try to grab an index up front; absent or 404 just means the namespace is
// fetch-by-name only (won't appear in `searchableIndices`).
const index = await tryFetchIndex(resolved);
const index = await tryFetchIndex(namespace, resolved);
return {
index,
async loadModule(category, id) {
@@ -208,14 +216,44 @@ function appendParams(url: string, params?: Record<string, string>): string {
return u.toString();
}
async function tryFetchIndex(cfg: ResolvedConfig): Promise<RegistryIndex | undefined> {
async function tryFetchIndex(
namespace: string,
cfg: ResolvedConfig,
): Promise<RegistryIndex | undefined> {
if (!cfg.indexUrl) return undefined;
let url: string;
try {
const url = appendParams(cfg.indexUrl, cfg.params);
const res = await fetch(url, { headers: buildHeaders(cfg.headers) });
if (!res.ok) return undefined;
return RegistryIndexSchema.parse(await res.json());
url = appendParams(cfg.indexUrl, cfg.params);
} catch (err) {
// appendParams throws on unset env vars — surface that since it's a
// config error the user can fix, not a missing-index condition.
console.warn(
`Registry "${namespace}" index skipped: ${err instanceof Error ? err.message : String(err)}`,
);
return undefined;
}
let res: Response;
try {
res = await fetch(url, { headers: buildHeaders(cfg.headers) });
} catch {
// Network failure (DNS, offline, etc.) — the namespace stays
// fetch-by-name-only this run. Silent: typical when offline.
return undefined;
}
// 404 means "no index advertised" — legitimate for fetch-by-name-only
// registries. Other non-OK statuses (401, 403, 5xx) likely indicate a
// misconfiguration the user should see.
if (res.status === 404) return undefined;
if (!res.ok) {
console.warn(`Registry "${namespace}" index returned ${res.status} ${res.statusText}.`);
return undefined;
}
try {
return RegistryIndexSchema.parse(await res.json());
} catch (err) {
// 200 but schema-invalid → config error worth telling the user about.
const detail = err instanceof Error ? err.message.split("\n")[0] : String(err);
console.warn(`Registry "${namespace}" index is malformed: ${detail}`);
return undefined;
}
}
+24
View File
@@ -70,6 +70,30 @@ export function capture(event: string, properties: Record<string, unknown> = {})
});
}
const DEFAULT_NAMESPACE = "@stanza";
/**
* Module install/remove event with namespace-aware id redaction. The actual
* `module` string is only emitted for first-party `@stanza` installs;
* third-party module ids (potentially private/proprietary) are replaced with
* `"<redacted>"` so per-namespace install counts still aggregate without
* leaking customer-internal names to first-party analytics.
*/
export function captureModule(args: {
action: "install" | "remove";
group: string;
module: string;
namespace: string;
}): void {
const isFirstParty = args.namespace === DEFAULT_NAMESPACE;
capture("cli_module", {
action: args.action,
group: args.group,
module: isFirstParty ? args.module : "<redacted>",
namespace: args.namespace,
});
}
export async function flush(): Promise<void> {
if (disabled || queue.length === 0) return;
const url = process.env.STANZA_TELEMETRY_URL ?? DEFAULT_TELEMETRY_URL;
@@ -5,6 +5,7 @@ import { useLocation, useNavigate } from "@tanstack/react-router";
import type { ReactNode } from "react";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { FILE_TREE_ICONS } from "@/components/builder/file-tree-icons";
import { useTheme } from "@/components/theme-provider";
import { Card } from "@/components/ui/card";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
@@ -141,6 +142,7 @@ export function FilePreview({
paths: filePaths,
search: false,
unsafeCSS: TRUNCATE_FIX_CSS,
icons: FILE_TREE_ICONS,
onSelectionChange,
});
modelRef.current = model;
@@ -0,0 +1,20 @@
import type { FileTreeIcons } from "@pierre/trees";
// Note: The outer <svg> needs `width="0" height="0"` (and `aria-hidden`),
// without them the browser gives the wrapper its default 300×150 box and the
// tree gets shoved down.
const SPRITE_SHEET = `
<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" aria-hidden="true">
<symbol id="stanza" viewBox="0 0 24 24">
<path style="fill: var(--brand)" d="M10.975 3.002a1 1 0 0 1-.754 1.196a8 8 0 1 0 8.446 3.379a1 1 0 1 1 1.666-1.107A9.96 9.96 0 0 1 22 12c0 5.523-4.477 10-10 10S2 17.523 2 12c0-4.76 3.325-8.742 7.779-9.752a1 1 0 0 1 1.196.754M13 3.014a1.01 1.01 0 0 1 1.214-.99l.115.031l2.987.996a1 1 0 0 1-.52 1.928l-.112-.03L15 4.387V12a3 3 0 1 1-2.19-2.89l.19.06V3.015Z"/>
</symbol>
</svg>
`;
export const FILE_TREE_ICONS = {
set: "complete",
spriteSheet: SPRITE_SHEET,
byFileName: {
"stanza.json": "stanza",
},
} satisfies FileTreeIcons;
+39
View File
@@ -0,0 +1,39 @@
import { useEffect, useState } from "react";
const relativeFormatter = new Intl.RelativeTimeFormat("en-US", { numeric: "auto" });
const MINUTE = 60;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
const MONTH = 30 * DAY;
const YEAR = 365 * DAY;
function format(iso: string, now: Date): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return iso;
const diffSec = Math.round((date.getTime() - now.getTime()) / 1000);
const abs = Math.abs(diffSec);
if (abs < MINUTE) return relativeFormatter.format(diffSec, "second");
if (abs < HOUR) return relativeFormatter.format(Math.round(diffSec / MINUTE), "minute");
if (abs < DAY) return relativeFormatter.format(Math.round(diffSec / HOUR), "hour");
if (abs < MONTH) return relativeFormatter.format(Math.round(diffSec / DAY), "day");
if (abs < YEAR) return relativeFormatter.format(Math.round(diffSec / MONTH), "month");
return relativeFormatter.format(Math.round(diffSec / YEAR), "year");
}
/**
* Reactive `Intl.RelativeTimeFormat` — re-renders on a fixed interval so the
* label stays fresh while the tab is open. SSR returns a value computed against
* server time; gate the rendered element on client-only state if exact
* second-level accuracy at hydration matters.
*/
export function useTimeAgo(iso: string, intervalMs = 30_000): string {
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const id = setInterval(() => setNow(new Date()), intervalMs);
return () => clearInterval(id);
}, [intervalMs]);
return format(iso, now);
}
+27 -13
View File
@@ -4,9 +4,11 @@ import { createFileRoute, Link, useLoaderData } from "@tanstack/react-router";
import { lazy, Suspense, useEffect, useState } from "react";
import { ModuleLogo } from "@/components/module-logo";
import { BarList } from "@/components/ui/bar-list";
import { BarList } from "@/components/stats/bar-list";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useTimeAgo } from "@/hooks/use-time-ago";
import { buildHead } from "@/lib/seo";
import { getStats, type Stats } from "@/server/stats.functions";
@@ -43,8 +45,26 @@ function formatGeneratedAt(iso: string): string {
});
}
function moduleHref(category: CategoryId, id: string): string {
return `/registry/${category}/${id}`;
function LastRefreshed({ iso }: { iso: string }) {
const ago = useTimeAgo(iso);
return (
<p>
<Tooltip>
<TooltipTrigger
nativeButton={false}
render={
<time
dateTime={iso}
className="cursor-help font-mono text-xs text-muted-foreground/70 tabular-nums"
/>
}
>
Last refreshed {ago}
</TooltipTrigger>
<TooltipContent>{formatGeneratedAt(iso)} UTC</TooltipContent>
</Tooltip>
</p>
);
}
function StatsPage() {
@@ -79,21 +99,15 @@ function StatsPage() {
return (
<div className="mx-auto max-w-7xl px-4 py-10 sm:px-6">
<header className="mb-8 max-w-2xl">
<h1 className="text-3xl font-semibold tracking-tight">Stats</h1>
<p className="mt-2 text-pretty text-muted-foreground">
<h1 className="mb-2 text-3xl font-semibold tracking-tight">Stats</h1>
<p className="mb-4 text-pretty text-muted-foreground">
What modules developers actually pick, aggregated from anonymous CLI telemetry {" "}
<a href="#telemetry" className="text-primary underline underline-offset-3">
see exactly what&rsquo;s collected
</a>
.
</p>
{stats ? (
<p className="mt-4 font-mono text-xs text-muted-foreground/70 tabular-nums">
Last refreshed {formatGeneratedAt(stats.generatedAt)} UTC
</p>
) : (
<Skeleton className="mt-4 h-4 w-72" />
)}
{stats ? <LastRefreshed iso={stats.generatedAt} /> : <Skeleton className="h-4 w-48" />}
</header>
<section className="mb-4 grid gap-4 sm:grid-cols-2">
@@ -179,7 +193,7 @@ function StatsPage() {
leading: <ModuleLogo logo={summary?.logo} label={label} size="sm" />,
trailingSecondary: numberFormatter.format(entry.count),
trailing: percentFormatter.format(entry.share),
href: moduleHref(category, entry.id),
href: `/registry/${category}/${entry.id}`,
};
})}
/>
+3
View File
@@ -55,6 +55,7 @@
--color-sidebar-ring: var(--sidebar-ring);
--color-selection: var(--selection);
--color-selection-foreground: var(--selection-foreground);
--color-brand: var(--brand);
}
:root {
@@ -93,6 +94,7 @@
--sidebar-ring: oklch(0.708 0 0);
--selection: oklch(0.145 0 0);
--selection-foreground: oklch(1 0 0);
--brand: oklch(0.55 0.19 258);
}
.dark {
@@ -130,6 +132,7 @@
--sidebar-ring: oklch(0.556 0 0);
--selection: oklch(0.922 0 0);
--selection-foreground: oklch(0.205 0 0);
--brand: oklch(0.75 0.13 248);
}
@layer base {
+3
View File
@@ -43,6 +43,7 @@ export type {
export {
StanzaManifestSchema,
CURRENT_MANIFEST_VERSION,
SUPPORTED_MANIFEST_VERSIONS,
MANIFEST_SCHEMA_URL,
declaredEnvNames,
defaultWebApp,
@@ -58,7 +59,9 @@ export type { RegistryConfig } from "./registry-config";
export {
DEFAULT_NAMESPACE,
expandEnv,
isLikelyNamespaceTypo,
isNamespace,
isValidModuleId,
parseModuleSpec,
RegistriesSchema,
RegistryConfigSchema,
+32
View File
@@ -136,6 +136,38 @@ describe("StanzaManifestSchema", () => {
});
});
describe("version compatibility", () => {
it("accepts a manifest tagged with a prior schema version", () => {
// 0.3 is structurally a strict subset of 0.4 (the new fields are
// optional), so the schema should accept it. readManifest re-stamps
// to CURRENT_MANIFEST_VERSION on load so callers always see current.
const v03 = {
version: "0.3",
projectShape: "monorepo",
packageManager: "pnpm",
name: "acme",
apps: [{ id: "web", dir: "apps/web", kind: "web" }],
modules: {},
regions: {},
};
const parsed = StanzaManifestSchema.parse(v03);
expect(parsed.version).toBe("0.3");
});
it("rejects an unknown schema version", () => {
const future = {
version: "9.9",
projectShape: "monorepo",
packageManager: "pnpm",
name: "acme",
apps: [{ id: "web", dir: "apps/web", kind: "web" }],
modules: {},
regions: {},
};
expect(() => StanzaManifestSchema.parse(future)).toThrow(/version/i);
});
});
describe("third-party registries", () => {
it("round-trips a manifest with a registries map and namespaced records", () => {
const manifest = {
+19 -2
View File
@@ -13,6 +13,15 @@ import { type RegistryConfig, RegistriesSchema } from "./registry-config";
export const CURRENT_MANIFEST_VERSION = "0.4" as const;
/**
* Past schema versions a new CLI can still read. Since each bump so far has
* only added optional fields, older manifests parse cleanly under the current
* schema — `readManifest` re-stamps the version to {@link CURRENT_MANIFEST_VERSION}
* on load so the in-memory object is always current, and the next write
* persists the upgrade. Add prior versions here as the schema evolves.
*/
export const SUPPORTED_MANIFEST_VERSIONS = ["0.3", "0.4"] as const;
/** Canonical public URL of the published `stanza.json` JSON Schema. */
export const MANIFEST_SCHEMA_URL = "https://stanza.tools/schema.json";
@@ -72,7 +81,13 @@ export type RegionOwnership = Record<string, RegionMap>;
export type StanzaManifest = {
/** Editor-facing pointer to the published JSON Schema. */
$schema?: string;
version: typeof CURRENT_MANIFEST_VERSION;
/**
* Schema version of this manifest. Always {@link CURRENT_MANIFEST_VERSION}
* on write — {@link SUPPORTED_MANIFEST_VERSIONS} lists the prior versions
* the reader still accepts, which get re-stamped to the current version
* on the next save.
*/
version: (typeof SUPPORTED_MANIFEST_VERSIONS)[number];
projectShape: "monorepo";
packageManager: "pnpm" | "bun" | "npm";
/** Display name; usually the repo root name. */
@@ -117,7 +132,9 @@ const appSpecSchema = z.object({
export const StanzaManifestSchema = z
.object({
$schema: z.string().optional(),
version: z.literal(CURRENT_MANIFEST_VERSION),
// Accept past versions on read (schema is purely additive so far); the
// CLI re-stamps to CURRENT_MANIFEST_VERSION on the next write.
version: z.enum(SUPPORTED_MANIFEST_VERSIONS),
projectShape: z.literal("monorepo"),
packageManager: z.enum(["pnpm", "bun", "npm"]),
name: z.string(),
+61 -2
View File
@@ -3,7 +3,9 @@ import { describe, expect, it } from "vite-plus/test";
import {
DEFAULT_NAMESPACE,
expandEnv,
isLikelyNamespaceTypo,
isNamespace,
isValidModuleId,
parseModuleSpec,
RegistriesSchema,
RegistryConfigSchema,
@@ -22,12 +24,63 @@ describe("parseModuleSpec", () => {
expect(parseModuleSpec("@acme/foo/bar")).toEqual({ namespace: "@acme", id: "foo/bar" });
});
it("rejects an unscoped @ id (no slash)", () => {
// No slash → treated as a bare id (whoever consumes it will fail on the `@`).
it("preserves the lenient behavior for an unscoped @ id (no slash)", () => {
// parseModuleSpec stays lenient — callers wanting strictness should
// check isLikelyNamespaceTypo first.
expect(parseModuleSpec("@bare")).toEqual({ id: "@bare" });
});
});
describe("isLikelyNamespaceTypo", () => {
it("flags a leading-@ input with no slash", () => {
expect(isLikelyNamespaceTypo("@bare")).toBe(true);
expect(isLikelyNamespaceTypo("@acme")).toBe(true);
});
it("accepts well-formed @ns/id specs", () => {
expect(isLikelyNamespaceTypo("@acme/foo")).toBe(false);
expect(isLikelyNamespaceTypo("@acme/foo/bar")).toBe(false);
});
it("accepts bare ids", () => {
expect(isLikelyNamespaceTypo("vitest")).toBe(false);
expect(isLikelyNamespaceTypo("better-auth")).toBe(false);
});
});
describe("isValidModuleId", () => {
it("accepts single-segment ids", () => {
expect(isValidModuleId("vitest")).toBe(true);
expect(isValidModuleId("better-auth")).toBe(true);
expect(isValidModuleId("a1")).toBe(true);
});
it("accepts nested segments", () => {
expect(isValidModuleId("foo/bar")).toBe(true);
expect(isValidModuleId("a/b/c")).toBe(true);
});
it("rejects path traversal and url-injection patterns", () => {
expect(isValidModuleId("..")).toBe(false);
expect(isValidModuleId("foo/..")).toBe(false);
expect(isValidModuleId("../etc")).toBe(false);
expect(isValidModuleId("foo?x=1")).toBe(false);
expect(isValidModuleId("foo#frag")).toBe(false);
expect(isValidModuleId("foo%20bar")).toBe(false);
expect(isValidModuleId("foo bar")).toBe(false);
});
it("rejects leading/trailing/double slashes", () => {
expect(isValidModuleId("/foo")).toBe(false);
expect(isValidModuleId("foo/")).toBe(false);
expect(isValidModuleId("foo//bar")).toBe(false);
});
it("rejects empty input", () => {
expect(isValidModuleId("")).toBe(false);
});
});
describe("isNamespace", () => {
it("accepts valid scopes", () => {
expect(isNamespace("@stanza")).toBe(true);
@@ -53,6 +106,12 @@ describe("expandEnv", () => {
expect(expandEnv("a=${A} b=${B}", { A: "1" })).toBeNull();
});
it("returns null when a var is set to the empty string", () => {
// Otherwise `Authorization: Bearer ${TOKEN}` with TOKEN="" produces
// a literal `Bearer ` header with a trailing space.
expect(expandEnv("Bearer ${TOKEN}", { TOKEN: "" })).toBeNull();
});
it("passes input through when there are no tokens", () => {
expect(expandEnv("static", {})).toBe("static");
});
+33 -3
View File
@@ -20,6 +20,10 @@ const SPEC_RE = /^(@[a-zA-Z0-9][a-zA-Z0-9-_]*[a-zA-Z0-9])\/(.+)$/;
* Split a CLI module identifier. `"@acme/foo"` →
* `{ namespace: "@acme", id: "foo" }`; a bare id → `{ id }` (default namespace
* applies). The id portion may itself contain slashes.
*
* Lenient: a leading-`@` input with no slash (e.g. `"@bare"`) returns
* `{ id: "@bare" }`. Callers that require a real module spec should check
* {@link isLikelyNamespaceTypo} first and surface a clearer error.
*/
export function parseModuleSpec(spec: string): { namespace?: string; id: string } {
const m = SPEC_RE.exec(spec);
@@ -27,6 +31,30 @@ export function parseModuleSpec(spec: string): { namespace?: string; id: string
return { id: spec };
}
/**
* Heuristic for "you typed a namespace but forgot the module id".
* `"@acme"` → true; `"@acme/foo"` → false; `"vitest"` → false.
* `add`/`remove` should reject these with a clear hint; `search` treats
* them as a literal search string and leaves them alone.
*/
export function isLikelyNamespaceTypo(spec: string): boolean {
return spec.startsWith("@") && !spec.includes("/");
}
/**
* Module ids are interpolated into registry URLs via `{id}` substitution.
* Restricting them to a strict, slash-segmented identifier shape closes off
* path traversal (`..`) and URL-injection (`?`, `#`, encoded chars) attacks
* via crafted CLI args or manifest entries. Each segment must start with an
* alphanumeric and contain only `[a-zA-Z0-9-_]`; nested segments use `/`.
*/
const MODULE_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9-_]*(?:\/[a-zA-Z0-9][a-zA-Z0-9-_]*)*$/;
/** True when `id` is a syntactically safe module id. */
export function isValidModuleId(id: string): boolean {
return MODULE_ID_RE.test(id);
}
/** True when `value` is a syntactically valid namespace key. */
export function isNamespace(value: string): boolean {
return NAMESPACE_RE.test(value);
@@ -34,14 +62,16 @@ export function isNamespace(value: string): boolean {
/**
* Replace `${VAR}` tokens with values from `env`. Returns `null` when any
* referenced variable is unset, leaving the caller to react: header builders
* drop the header (matches shadcn), URL builders treat it as an error.
* referenced variable is unset OR set to the empty string, leaving the caller
* to react: header builders drop the header, URL builders treat it as an
* error. Treating `""` as missing avoids sending a literal `Authorization:
* Bearer ` header with a trailing space when `TOKEN=""`.
*/
export function expandEnv(input: string, env: NodeJS.ProcessEnv = process.env): string | null {
let missing = false;
const result = input.replace(/\$\{(\w+)\}/g, (_, name: string) => {
const v = env[name];
if (v === undefined) {
if (v === undefined || v === "") {
missing = true;
return "";
}