140 lines
5.8 KiB
TypeScript
140 lines
5.8 KiB
TypeScript
#!/usr/bin/env tsx
|
|
/**
|
|
* identity-no-swap — v1.8.13 (Ken).
|
|
*
|
|
* THE RULE: a surface must never assert an identity it does not yet know.
|
|
*
|
|
* `IdentityLabel` falls back to `@account` + identicon when it has no display
|
|
* name or avatar. That is the RIGHT answer for an account with no custom
|
|
* profile, and the WRONG one while a profile is still loading — it paints a
|
|
* confident, incorrect identity and rewrites it seconds later.
|
|
*
|
|
* Ken, on the orderbook (~7s swap): "i should NEVER see the default username
|
|
* and identicon if a custom display name and custom avatar have been set."
|
|
* And on chat: "imagine chatting with someone in the chatroom and then all of a
|
|
* sudden their avatar and/or display name changes on you like that. would you
|
|
* do a trade with that user? hell no."
|
|
*
|
|
* He is right that this is a TRUST defect rather than polish. An identity that
|
|
* mutates in front of you is indistinguishable from a swap attack, and on a
|
|
* marketplace the counterparty's identity IS the product.
|
|
*
|
|
* TWO ways to satisfy the rule:
|
|
* 1. Have the identity ALREADY — served inline with the row (the orderbook
|
|
* LEFT JOINs profiles), or it is the local user's own profile.
|
|
* 2. Pass `pending` while a fetch is in flight, so the component renders a
|
|
* neutral placeholder instead of a fallback it cannot justify.
|
|
*
|
|
* This smoke exists because I fixed the orderbook and chat by hand and Ken
|
|
* asked, correctly, "what about the order view page and other pages that i have
|
|
* not thought of?" Enumerating by hand is how surfaces get missed — so the
|
|
* check enumerates instead.
|
|
*/
|
|
|
|
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
import { dirname, join, relative } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
const WEB = join(HERE, '..');
|
|
const SRC = join(WEB, 'src');
|
|
|
|
/** Surfaces that already HOLD the identity when they render it. */
|
|
const EXEMPT = new Map<string, string>([
|
|
['src/lib/components/IdentityLabel.svelte', 'the component itself'],
|
|
['src/routes/[lang]/dev/icons/+page.svelte', 'dev-only icon gallery, hardcoded samples'],
|
|
['src/routes/[lang]/settings/+page.svelte', "the local user's OWN profile, held in memory"],
|
|
['src/routes/[lang]/onboarding/+page.svelte', "the user's own freshly-created identity"],
|
|
[
|
|
'src/routes/[lang]/onboarding/register-name/+page.svelte',
|
|
"the user's own name, typed in this form"
|
|
],
|
|
[
|
|
'src/lib/components/OrderPosterIdentity.svelte',
|
|
'a wrapper: forwards identity props, and its PARENTS are checked below'
|
|
],
|
|
[
|
|
'src/lib/components/OrderCard.svelte',
|
|
'a wrapper: forwards identity props, and its PARENTS are checked below'
|
|
],
|
|
['src/lib/components/ChatMessage.svelte', 'renders props supplied by ConversationView'],
|
|
[
|
|
'src/routes/[lang]/orderbook/+page.svelte',
|
|
'identity is served INLINE with each order row (profiles is LEFT JOINed into the ' +
|
|
'orderbook query), so the card is correct on first paint and there is nothing to ' +
|
|
'wait for. The batch hydrate that remains only REFRESHES an already-correct card.'
|
|
]
|
|
]);
|
|
|
|
/** A file fetches identity asynchronously if it calls one of these. */
|
|
const ASYNC_FETCH = /getProfilesBatch\(|getProfileCached\(|getProfileCachedDetailed\(/;
|
|
|
|
function walk(dir: string, out: string[] = []): string[] {
|
|
for (const e of readdirSync(dir)) {
|
|
if (e === 'node_modules' || e.startsWith('.')) continue;
|
|
const full = join(dir, e);
|
|
if (statSync(full).isDirectory()) walk(full, out);
|
|
else if (e.endsWith('.svelte')) out.push(full);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
let passed = 0;
|
|
let failed = 0;
|
|
const check = (name: string, cond: boolean, detail = ''): void => {
|
|
if (cond) {
|
|
console.log(` ✓ ${name}`);
|
|
passed++;
|
|
} else {
|
|
console.log(` ✗ ${name}${detail ? `: ${detail}` : ''}`);
|
|
failed++;
|
|
}
|
|
};
|
|
|
|
console.log('\n── identity-no-swap (v1.8.13) ────────────────────────\n');
|
|
|
|
check(
|
|
'IdentityLabel supports a pending state',
|
|
/pending\?: boolean;/.test(readFileSync(join(SRC, 'lib/components/IdentityLabel.svelte'), 'utf8')),
|
|
'without it a surface has no way to say "not known yet"'
|
|
);
|
|
|
|
const offenders: string[] = [];
|
|
let checkedSites = 0;
|
|
for (const file of walk(SRC)) {
|
|
const rel = relative(SRC, file).replace(/\\/g, '/');
|
|
const key = `src/${rel}`;
|
|
if (EXEMPT.has(key)) continue;
|
|
const src = readFileSync(file, 'utf8');
|
|
// Include WRAPPERS, not just direct IdentityLabel use. The order detail page
|
|
// renders <OrderPosterIdentity>, so it contains no <IdentityLabel at all and
|
|
// an earlier version of this check skipped it entirely — the very page Ken
|
|
// asked about ("what about the order view page and other pages that i have
|
|
// not thought of?"). A wrapper forwards identity props, so the surface that
|
|
// FETCHES is still the one that must declare pending.
|
|
const RENDERS_IDENTITY = /<IdentityLabel\b|<OrderPosterIdentity\b|<OrderCard\b/;
|
|
if (!RENDERS_IDENTITY.test(src)) continue;
|
|
if (!ASYNC_FETCH.test(src)) continue; // identity arrives inline — nothing to wait for
|
|
checkedSites++;
|
|
// Every IdentityLabel in a file that fetches identity must declare pending.
|
|
for (const m of src.matchAll(/<(?:IdentityLabel|OrderPosterIdentity|OrderCard)\b[\s\S]*?\/>/g)) {
|
|
if (!/\bpending=/.test(m[0])) {
|
|
offenders.push(key);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
check(`found identity surfaces that fetch asynchronously (${checkedSites})`, checkedSites > 0);
|
|
check(
|
|
'every asynchronously-hydrated surface passes `pending`',
|
|
offenders.length === 0,
|
|
offenders.length > 0
|
|
? `these render a fallback identity before they know it:\n ${[...new Set(offenders)].join('\n ')}\n → pass pending={<profile not yet loaded>} so the label waits instead of guessing`
|
|
: ''
|
|
);
|
|
|
|
console.log(
|
|
`\n${passed} passed, ${failed} failed\n${failed === 0 ? `✓ all ${passed} identity-no-swap checks passed` : '✗ identity-no-swap FAILED'}`
|
|
);
|
|
process.exit(failed === 0 ? 0 : 1);
|