morphit/apps/web/scripts/identity-label-policy-smoke.ts

194 lines
6.3 KiB
TypeScript

#!/usr/bin/env tsx
/**
* Smoke for the IdentityLabel render policy.
*
* Project policy (per `apps/web/src/lib/components/IdentityLabel.svelte`):
* every place a Blurt account name appears in user-facing UI must
* be rendered through `<IdentityLabel>` so the identicon appears
* alongside the username. This protects against display-name
* spoofing — `@morphit` vs `@morph1t` collide textually but their
* identicons cannot.
*
* Tier 2.6 of the grandma-friendly investigation closed three
* drift call sites in `apps/web/src/routes/[lang]/settings/+page.svelte`
* (Part 97). This smoke runs a regex sweep over the route +
* component source to flag any new `@{account}` / `@{author}` /
* `@{seller}` / `@{buyer}` raw renders that don't live in an
* accepted-exception file.
*
* Accepted exceptions (allow-list):
*
* - `IdentityLabel.svelte` itself (the policy docstring quotes
* the pattern, doesn't render it).
* - `SeedBackupPrint.svelte` — print-only output of the user's
* own seed backup; identicons add no value on paper, and the
* user is viewing their OWN identity (no spoofing surface).
* - Comment-only references in `.svelte`/`.ts` files (JSDoc
* route patterns like `/@{account}/{permlink}`, mention in
* publish.ts of a seller-name template that's expanded
* elsewhere).
*
* The smoke is greedy on detection (false-positive permissive) —
* any `@\{[a-zA-Z]+\}` pattern outside the allow-list is flagged.
* If a new genuine exception arises (e.g. a future printable
* card), add it to the allow-list with a comment justifying why.
*/
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const REPO_ROOT = join(__dirname, '..', '..', '..');
const SCAN_DIRS = ['apps/web/src/routes', 'apps/web/src/lib/components'];
const ACCEPTED_FILES = new Set<string>([
// The policy doc itself quotes the pattern.
'apps/web/src/lib/components/IdentityLabel.svelte',
// Print-only output of the user's OWN seed backup. No
// spoofing surface (user viewing self), identicons add no
// value on paper, the card is intentionally minimal.
'apps/web/src/lib/components/SeedBackupPrint.svelte',
// Profile-page hero (already renders a 64px identicon
// adjacent to the username; would be visually redundant
// to wrap in IdentityLabel, which has its own avatar).
'apps/web/src/routes/[lang]/explorer/account/[name=account]/+page.svelte'
]);
// Ignore comment-context matches. A real raw render is one that
// appears outside a `*` line, `//` line, or template-literal-only
// JSDoc block. This is a heuristic, not perfect, but it catches
// the most common comment patterns.
function lineIsCodeRender(line: string): boolean {
const trimmed = line.trim();
if (trimmed.startsWith('*')) return false;
if (trimmed.startsWith('//')) return false;
if (trimmed.startsWith('/*')) return false;
// Inside a Svelte <script lang="ts"> block, `*` lines also count
// as JSDoc-style comments and should be ignored.
return true;
}
interface Finding {
readonly file: string;
readonly line: number;
readonly text: string;
}
function scanFile(absPath: string, relPath: string, findings: Finding[]): void {
if (ACCEPTED_FILES.has(relPath)) return;
if (!absPath.endsWith('.svelte') && !absPath.endsWith('.ts')) return;
const content = readFileSync(absPath, 'utf8');
const lines = content.split('\n');
const PATTERN = /@\{[a-zA-Z][a-zA-Z]*\}/;
for (let i = 0; i < lines.length; i++) {
const line = lines[i]!;
if (!PATTERN.test(line)) continue;
if (!lineIsCodeRender(line)) continue;
findings.push({
file: relPath,
line: i + 1,
text: line.trim().slice(0, 120)
});
}
}
function walk(dir: string, repoRoot: string, findings: Finding[]): void {
for (const entry of readdirSync(dir)) {
const abs = join(dir, entry);
const rel = abs.slice(repoRoot.length + 1);
const st = statSync(abs);
if (st.isDirectory()) {
if (entry === 'node_modules' || entry === '.svelte-kit' || entry === 'dist') continue;
walk(abs, repoRoot, findings);
} else {
scanFile(abs, rel, findings);
}
}
}
console.log('');
console.log('── identity-label-policy smoke ─────────────────────────');
console.log('');
const findings: Finding[] = [];
for (const dir of SCAN_DIRS) {
walk(join(REPO_ROOT, dir), REPO_ROOT, findings);
}
const scenarios = [
{
name: 'no raw @{...} renders outside accepted-exception files',
ok: findings.length === 0
},
{
name: 'IdentityLabel component imported in settings',
ok: readFileSync(
join(REPO_ROOT, 'apps/web/src/routes/[lang]/settings/+page.svelte'),
'utf8'
).includes("import IdentityLabel from '$components/IdentityLabel.svelte'")
},
{
name: 'IdentityLabel renders in hidden-accounts list',
ok: /\{#each hiddenList[\s\S]*?<IdentityLabel/.test(
readFileSync(
join(REPO_ROOT, 'apps/web/src/routes/[lang]/settings/+page.svelte'),
'utf8'
)
)
},
{
name: 'IdentityLabel renders in blocked-accounts list',
ok: /\{#each blockedList[\s\S]*?<IdentityLabel/.test(
readFileSync(
join(REPO_ROOT, 'apps/web/src/routes/[lang]/settings/+page.svelte'),
'utf8'
)
)
},
{
name: 'IdentityLabel renders for the user\'s own account in settings',
ok: /<IdentityLabel\b[\s\S]{0,200}account=\{accountSaved\}/.test(
readFileSync(
join(REPO_ROOT, 'apps/web/src/routes/[lang]/settings/+page.svelte'),
'utf8'
)
)
},
{
name: 'allow-list is non-empty (sanity)',
ok: ACCEPTED_FILES.size >= 3
}
];
let passed = 0;
let failed = 0;
for (const s of scenarios) {
if (s.ok) {
passed++;
} else {
failed++;
console.log(`${s.name}`);
}
}
if (findings.length > 0) {
console.log('');
console.log(' Findings:');
for (const f of findings) {
console.log(` ${f.file}:${f.line} ${f.text}`);
}
}
console.log('');
console.log('────────────────────────────────────────────────────────');
if (failed === 0) {
console.log(`✓ all ${passed} scenarios passed`);
process.exit(0);
} else {
console.log(`${failed} of ${passed + failed} scenarios failed`);
process.exit(1);
}