Adversarial audit — hardcoded strings & keys (2026-09-09)
Status: Active · Owner ask: "No more hardcoded strings or keys." Read-only adversarial audit; the fixes it drove shipped 2026-09-09 — see the rebuild log. Findings NOT yet acted on are listed as such at the end of each severity block.
Scope per owner law "No more hardcoded strings or keys." Nothing changed; read-only investigation. Findings ranked by severity (CRITICAL > HIGH > MEDIUM > LOW). Each finding: evidence (file:line), correct home, and the lint rule that would mechanically catch the class.
CRITICAL
C1. CATEGORY_LABEL_KO in the push edge fn has ALREADY DRIFTED from the client's category labels — live, in production, right now
- Client source of truth:
packages/app/src/config/i18n/ko/signals.ts:56-71(koSignals().signals.category) - Duplicate:
supabase/functions/send-push/notification-copy.ts:17-31(CATEGORY_LABEL_KO)
Six of thirteen categories already disagree between the push notification (fallback title / body-fallback) and the in-app signal feed:
| category | edge fn (notification-copy.ts) | client (ko/signals.ts) |
|---|---|---|
| session | 경기 일정 | 일정 |
| matchup | 대진표 | 대진 |
| rsvp | 참가 관련 | 참석 |
| rating | 랭킹 | 등급 |
| trust | 신뢰도 | 신뢰 |
| interclub | 클럽 간 대항 | 인터클럽 |
CATEGORY_LABEL_KO is the fallback used whenever a title_key isn't in TITLE_KO (formatSignalTitle, notification-copy.ts:104-106) and whenever a body has no informative payload fields (formatSignalBody's final ... || CATEGORY_LABEL_KO[row.category], line 208). A user who gets a push notification and then opens the app sees two different Korean words for the same thing.
Why the existing guard didn't catch it: push-copy-sync.test.ts (packages/app/src/domain/entities/tests/push-copy-sync.test.ts) only regex-extracts 'signal.*.title' string keys from the edge fn and asserts the key resolves on the client (lines 39-50, 58-71). It never touches CATEGORY_LABEL_KO at all — that map isn't keyed by signal.*.title so the extractor never sees it — and even for the maps it does touch (TITLE_KO) it checks key presence, never value equality.
- Fix: Delete
CATEGORY_LABEL_KOfrom the edge fn; derive it once (e.g. generate a small JSON the edge fn imports, or extend the existing sync test to diff values, not just keys) so there is one Korean string per category. - Prevention (mechanical): extend
push-copy-sync.test.tsto also extractCATEGORY_LABEL_KO's object literal (or anyRecord<SignalCategory/…, string>map in the fn directory) and assert value equality againstkoSignals().signals.category, not just key presence. A generic version: a new rule/test — "duplicated-record-literal-value-drift" — that pairs any twoRecord<K,string>literals sharing 100% of their keys across a designated file pair and diffs values in CI.
HIGH
H1. Push notification BODY copy is entirely hand-duplicated with zero guard (not even key-presence)
supabase/functions/send-push/notification-copy.ts:143-209(formatSignalBody) hardcodes Korean body text for 6 special-cased signal types (strike ${v}점 부과,5분 안에 입금하지 않으면 예약이 취소돼요, etc.) that mirror the templates inpackages/app/src/config/i18n/ko/signals.ts(e.g. line 349'5분 안에 입금하지 않으면 예약이 취소돼요').- These currently match by inspection, but nothing enforces it —
push-copy-sync.test.tsonly ever looks at.titlekeys (regexsignal\.[a-zA-Z0-9_.]+\.title), never bodies. A wording edit to either side (very plausible — copy review, i18n polish) silently diverges push-notification text from in-app text with no test failure and no lint warning. - Fix: same as C1 — either generate the edge-fn body strings from the client signals tree at build time, or add value-diff assertions.
- Prevention: generalize the push-copy-sync test to cover
.bodyliterals the same way it covers.titlekeys (it already has the regex-scan-the-directory infrastructure — extend the pattern toformatSignalBody's literal returns, or better: extract every literal Korean string in the file and assert it appears somewhere inkoSignals()).
H2. queryKey: ['user-location'] is a bare literal duplicated verbatim across two unrelated files
- Definition:
packages/app/src/presentation/hooks/queries/use-user-location.ts:49 - Invalidator:
packages/app/src/presentation/components/sessions/venue-finder/use-venue-finder-data.ts:209—queryClient.invalidateQueries({ queryKey: ['user-location'] })
Neither file imports a shared key. Every other domain (club, session, venue, ranking, profile, social, match, reference, venue-owner-claim, venue-contribution) has a *Keys module under packages/app/src/query-keys/; this one doesn't, so the literal was typed twice. Rename/typo either key and the invalidator silently stops matching — the location cache never busts, and nobody notices because there's no error, just stale distance data.
- Fix: add a one-line
userLocationKeys = { all: ['user-location'] as const }(or fold into an existing keys file) and import it in both places. - Prevention (mechanical rule):
@twomore/require-query-key-registry— flag anyqueryKey: [array literal (2+ string elements) inpresentation/**/features/**whose first element is a string literal not sourced from a*Keysimport, OR flag when the same literal array (by deep-equality of its literal segments) appears inqueryKey:/invalidateQueriescalls in more than one file without a shared identifier. The second form directly targets this bug shape.
H3. Route path literals duplicate routes.* registry entries at 4 call sites, with zero lint coverage
apps/mobile/app/profile/[userId].tsx:16—router.replace('/profile' as never)duplicatesroutes.profile(packages/app/src/navigation/routes.ts:44)apps/web/app/page.tsx:19—router.replace('/home')duplicatesroutes.home(routes.ts:33)apps/web/app/page.tsx:21—router.replace('/login')duplicatesroutes.login(routes.ts:29)apps/web/components/route-guard.tsx:51—router.replace(`/login?returnTo=${encodeURIComponent(path)}`)duplicatesroutes.login
routes.ts's own header comment says "All navigation calls should use these helpers instead of hardcoded strings" (routes.ts:3) — this is a documented, un-enforced convention. None of the 67 @twomore/eslint-plugin rules touch route strings (confirmed against the rule list — no route-named rule exists). If routes.home/routes.login/routes.profile are ever repointed (e.g. /login gains a locale prefix, or the profile tab root changes), these 4 literals silently keep the old path while every other call site correctly follows the rename.
- Fix: replace all 4 with
routes.home/routes.login/routes.profile. - Prevention (mechanical rule):
@twomore/no-raw-route-literal— inpackages/features/**,packages/app/src/presentation/**,apps/mobile/app/**,apps/web/app/**,apps/web/components/**: flag a string/template literal starting with/passed torouter.push/router.replace/router.navigate/<Redirect href=/<Link href=UNLESS it's a dynamic expo-router file path that has noroutes.*equivalent (the rule would need a small allowlist for the handful of expo-router<Redirect href="/(tabs)/home">group-syntax literals, whichroutes.tsdoesn't model).
H4. 'twomore-auth-store' — the sign-out identity-wipe key — is a bare literal duplicated across 4 files with only a comment enforcing sync
packages/app/src/presentation/stores/auth.store.ts:48—const key = 'twomore-auth-store'(the real persist name)packages/app/src/lib/storage-migration.ts:33— re-typed inPERSIST_KEYSpackages/app/src/lib/mmkv.ts:107— re-typed inWIPE_KEYS(the sign-out cleanup denylist)packages/app/src/lib/mmkv.web.ts:63— re-typed again, web variant
The only thing holding these in sync is a doc comment ("Keep in lockstep with… persist({ name: '...' })", storage-migration.ts:29-30). There is no test — unlike the analogous push-copy-sync.test.ts pattern the audit brief calls out as the existing guard for a different drift trap, this exact shape (client store name vs. a hand-maintained key list) has no equivalent guard. If auth.store.ts ever version-bumps its persist name (exactly what happened to two of its siblings — see H5), WIPE_KEYS silently stops matching and the previous user's session-identity key survives sign-out on a shared device — a real account-bleed risk, not just cosmetic drift.
- Fix: export the key from
auth.store.ts(or a sharedpersist-keys.tsconstants module) and import it inmmkv.ts,mmkv.web.ts,storage-migration.tsinstead of re-typing. - Prevention (mechanical rule): a Jest guard test (same style as
push-copy-sync.test.ts) that greps everypresentation/stores/*.tsfor itspersist({ name: '...' })literal and asserts each one is either present inmmkv.ts'sWIPE_KEYS/WIPE_PREFIXES(if identity-bearing) or explicitly listed as intentionally-excluded, catching both the H4 shape and the H5 stale-list shape below in one test.
MEDIUM
M1. storage-migration.ts's PERSIST_KEYS list has already drifted from the real persist names it claims to mirror
packages/app/src/lib/storage-migration.ts:32-47, comment: "All 14 persist-store keys. Keep in lockstep withpackages/app/src/presentation/stores/*.tspersist({ name: '...' })."- Lists
'twomore-club-wizard'— actual:packages/app/src/presentation/stores/club-wizard.store.ts:389→name: 'twomore-club-wizard-v10' - Lists
'twomore-session-wizard-draft'— actual:packages/app/src/presentation/stores/session-wizard.store.ts:314→name: 'twomore-session-wizard-draft-v7' - Also missing several stores that exist today and aren't in the 14:
twomore-admin-attention-ack(admin-attention-ack.store.ts:89),twomore-coachmark-seen(coachmark-seen.store.ts:61),twomore-home-period(home-period.store.ts:90),twomore-developer-mode-store(developer-mode.store.ts:36),twomore.dev-panel-prefs(dev-panel-prefs.store.ts:58),twomore-booking-alert-store/twomore-match-board-store(these two ARE in the list, so not all newer stores are missing — just the version-bumped and the dev/admin ones).
Impact is bounded (this is a one-time AsyncStorage→MMKV migration, hasMigrated()-gated, so it's mostly moot for anyone who already migrated) but it demonstrates the same "hand-maintained mirror list, zero test" pattern as H4, and for anyone who migrates fresh today, their pre-v10/pre-v7 club-wizard/session-wizard drafts get copied under a dead AsyncStorage key name that the current store will never read (silent draft loss, not data loss — AsyncStorage isn't cleared).
- Fix: same constants-module fix as H4; or generate
PERSIST_KEYSfrom a single manifest object each store registers itself into. - Prevention: the guard test proposed in H4 covers this too (compare
PERSIST_KEYSagainst every store's actualpersist({name})).
M2. mmkv.ts / mmkv.web.ts triplicate 'twomore-query-cache' inside themselves, on top of duplicating across the native/web pair
packages/app/src/lib/mmkv.ts:WIPE_KEYS(line 107) has'twomore-query-cache';clearQueryCacheMmkvStrict(line 147) re-types the same literal (key === 'twomore-query-cache') instead of reusingWIPE_KEYS.- Same duplication pattern in
packages/app/src/lib/mmkv.web.ts:63and:93. - The actual cache key is defined a fourth time as
CACHE_KEY = 'twomore-query-cache'inpackages/app/src/presentation/providers/query-persister.ts:15. - All four currently agree; nothing checks that they continue to.
- Fix: one
const QUERY_CACHE_MMKV_KEY = 'twomore-query-cache'exported frommmkv.ts, imported everywhere includingquery-persister.ts. - Prevention:
@twomore/no-duplicate-string-literal-in-module(or just a targeted test) — flag the same string literal used 2+ times in one file when it could be a singleconst. Lower priority than H4's guard test, which would also catch this if scoped to include the cache-key row.
M3. TIER_LABEL_KO is triplicated (common.ts, ko/signals.ts, edge fn) with zero guard
packages/app/src/config/i18n/ko/common.ts:348-351packages/app/src/config/i18n/ko/signals.ts:19-24(comment: "mirrored from common.ts'strust.{sprout,active,trusted,veteran}")supabase/functions/send-push/notification-copy.ts:96-101(comment: "mirrors … ko/signals.ts TIER_LABEL_KO")
Three independent Korean literal sets for the same 4-entry vocabulary, held in sync purely by comment discipline. Currently identical. Same drift shape as C1/H1, smaller vocabulary so lower severity — but it's the third instance of the identical anti-pattern in the same feature area (signals/push), suggesting this is a systemic habit in that subsystem, not a one-off.
- Fix/Prevention: fold into the same generalized push-copy-sync value-diff extension proposed in C1/H1 — extract every
Record<string,string>"label map" insend-push/and assert value equality against its named counterpart inko/signals.ts/ko/common.ts.
M4. Achievement type strings duplicated between the TS union and a seed-only SQL function, with no shared source and no CHECK constraint backstop
- Client source of truth:
ACHIEVEMENT_TYPESarray,packages/app/src/domain/entities/achievement.entity.ts:16-114(~55'UPPER_SNAKE'literals, validated viaz.enum(ACHIEVEMENT_TYPES)atachievement.entity.ts:170) - Duplicate:
supabase/migrations/00458_player_ratings_three_track.sql:1034-1120,CREATE OR REPLACE FUNCTION public.process_seed_achievements()— re-types 14 of the ~55 type strings ('FIRST_MATCH','MATCHES_10','ELO_1400', … lines 1072-1112) as raw SQL string literals. (The class's original instance,00089_process_achievements_rpc.sql, is superseded by 00458 — same duplication, same risk, just the live copy moved.) public.achievements.type(supabase/migrations/00021_achievements.sql:5) is a plainTEXT NOT NULLcolumn — no CHECK constraint, no enum, no FK — so a typo'd or renamed type string here inserts silently: no DB error, just an achievement row the client's i18n catalog (t().achievementCatalog[type]) can't resolve when it eventually reads it back.- This function is confirmed seed/QA-only (
COMMENT ON FUNCTION public.process_seed_achievements IS 'Seed-only achievement evaluator...', 00458:1120-1122;REVOKE ALL … GRANT EXECUTE … TO service_roleonly, lines 1090-1093) — it is not the production award path (the real client-driven award flow goes throughpackages/app/src/adapters/supabase/achievement.supabase.ts, which is TS-checked againstAchievementType). That confinement is why this is MEDIUM rather than HIGH: currently in sync, but a client-side rename (the file's own comments mention past renames — "ELO_1200 (renamed from ELO_INTERMEDIATE)") has no mechanism forcing a matching SQL edit, and a seeded QA world could start showing achievement rows with unresolvable types. - Fix: either generate the SQL literal list from
ACHIEVEMENT_TYPESat migration-authoring time (documented, checked-in generation script) or add aCHECK (type = ANY(ARRAY[...]))/ lookup-table constraint so a typo fails loudly at write time instead of silently. - Prevention (mechanical, matches the audit's push-copy-sync precedent): a pgTAP or Jest test that reads every
'UPPER_SNAKE'-shaped string literal out ofprocess_seed_achievements's SQL text (same directory-scan-and-regex technique aspush-copy-sync.test.ts) and asserts each one is a member ofACHIEVEMENT_TYPES.
M5. Domain-layer Korean literals are structurally invisible to no-korean-string-literal even though the components that render them ARE in-scope
This is a mechanism gap, not just a missing scope entry: the rule (packages/eslint-plugin/rules/no-korean-string-literal.js) inspects Literal/TemplateLiteral/JSXText nodes in the file being linted. A Korean string defined as a constant in an out-of-scope file (packages/app/src/domain/**, which is not in SCOPE_SUBSTRINGS, lines 53-60 of the rule) and then rendered via a property access (preset.name) in an in-scope file is invisible on both ends: the domain file is out of scope, and the in-scope file only contains a MemberExpression, not a string literal — no amount of widening SCOPE_SUBSTRINGS alone fixes this, because the violation never appears as a literal in a scoped file.
Concrete instances:
packages/app/src/domain/rules/rotation-presets.ts:31,38,50,71,87,101,115—name: '짝바꿈 복식 · 소셜'etc. (7 presets) plusROTATION_FAMILY_SUBTITLE = '매 게임 파트너 교체 · KDK 방식'(line 26) — all pure hardcoded Korean, no i18n, no locale switch possible for an English-locale user.- Rendered live:
packages/features/sessions/src/format-chooser-sheet.tsx:202(label={preset.name}) andpackages/features/sessions/src/add-round/style-picker.tsx:42(label={preset.name}).
- Rendered live:
packages/app/src/domain/entities/subscription.entity.ts:71,78,85,92,99—PLAN_META[plan].name('무료','플레이어','베이직','프로','클럽+'). Currently dead code — grepped zero consumers inpackages/features/**orpresentation/**(subscription screen not yet built, matches the roadmap's Phase-8-gated status) — so this is a latent trap: whoever builds the subscription screen will naturally writePLAN_META[plan].namein a scoped file and the rule will stay silent, exactly like rotation-presets did.Fix: move
name/description/label-shaped fields out of both entity files into i18n (the same treatmentachievement.catalog.tsalready got — its header comment literally documents this exact migration: "name/description/hintused to live inline on each entry below — moved to i18n … per the globalization review", achievement.catalog.ts:14-18.rotation-presets.tsandsubscription.entity.tsare the two entity files that review missed).Prevention (mechanical): this needs a second, structurally different rule rather than widening the existing one —
@twomore/no-user-facing-literal-in-domainthat flags a Hangul (or, per the English-asymmetry note below, any non-empty string) literal assigned to a property namedname/label/title/description/hintinsidepackages/app/src/domain/**andpackages/app/src/adapters/**entity/catalog files, mirroring theUI_PROP_KEYSset the existing rule already uses (no-korean-string-literal.js:75-94) but pointed at the layer the current rule explicitly excludes.
M6. apps/web/components/** is entirely outside no-korean-string-literal's scope
SCOPE_SUBSTRINGS(no-korean-string-literal.js:53-60) coversapps/web/app/andapps/web/lib/but notapps/web/components/— an easy-to-miss asymmetry with the mobile side, where the equivalent screen code sits underapps/mobile/app/(covered).- Live instance:
apps/web/components/open-in-app-button.tsx:11—const OPEN_IN_APP_LABEL = '앱에서 열기', rendered directly at line 21, not(). - Caveat: per project memory the public share-page cluster is intentionally Korean-only (i18n overhaul carve-out), so this specific string may be a sanctioned exception rather than a bug — but the directory-level gap is real regardless: nothing stops a future, unintentional Korean (or English) literal from landing anywhere in
apps/web/components/undetected. - Fix: add
apps/web/components/toSCOPE_SUBSTRINGS. If the share-page Korean-only carve-out needs to stay, add those specific files toEXEMPT_SUBSTRINGS(the rule already has this exemption mechanism, e.g.packages/ui/src/ui-strings.ts) so the exception is explicit and auditable instead of an accidental byproduct of an incomplete glob.
LOW / clean-with-caveats
L1. no-korean-string-literal never checks English literals — verified NOT currently exploited outside the dev panel
The rule's own doc comment (lines 1-30) restricts detection to Hangul by design. This means an English literal like <Text>Loading...</Text> in a bilingual (ko/en) app would never be flagged. I searched for this pattern (accessibilityLabel="...", placeholder="...", capitalized JSX text children) across packages/features/** and packages/app/src/presentation/** excluding dev-panel/: zero hits. The only English literal accessibilityLabels found are all inside packages/app/src/presentation/components/dev-panel/ (diagnostics-pane.tsx:366, logs-pane.tsx:118,130,164,176,226,238) — a developer-only surface, not shown to end users, which the audit brief itself allows as a legitimate exemption category.
- Verdict: the asymmetry is real and structural (report it as a gap in the mechanism), but there is no live violation to fix today. If it's worth mechanically closing, the fix is cheap: broaden
no-korean-string-literal's detection to "any non-empty string literal" gated the same wayUI_PROP_KEYS/JSX-position logic already works, with an allowlist for legitimately-invariant strings (test ids, icon names,Alert.alert's own English defaults if any) — but given zero current violations, this is a "nice to have," not urgent.
L2. supabase/functions/ Korean literals are almost entirely seed-fixture data, not user-facing app strings — mostly clean
grep -rlP '[Hangul]' supabase/functions returns ~100 files, but the overwhelming majority are under supabase/functions/seed-scenario/** — fixture data (fake club names, fake post bodies) used only for QA-seeded worlds, not the compiled app's UI strings. This is not a no-korean-string-literal-shaped violation (that rule is about UI strings rendered through JSX/t(); seed fixtures are test data, same category the rule's own doc comment exempts as "adapter-side data shape strings"). The two real exceptions are send-push/notification-copy.ts (covered under C1/H1/M3 above — a duplication problem, not a missing-i18n problem, since this fn genuinely can't call t()) and portone-webhook/verify-portone-payment — checked, their Korean strings are Sentry/log context strings, not shown to users.
- Verdict: report this category as "not a new gap" — the one real edge-fn UI-string surface (push copy) is already covered as a duplication problem (C1/H1/M3), not an i18n-coverage problem.
L3. Mutation keys and perf/telemetry operation names have no registry to drift from — not a violation of the audited class, but worth naming as a structural absence
- Confirmed: there is no
packages/app/src/mutation-keys/(or equivalent) analogous toquery-keys/. All 134mutationKey: [...]call sites (e.g.packages/app/src/presentation/hooks/mutations/use-rsvp.ts:129,374,use-club-guest-mutations.ts:25,44,63,75,93,110, etc.) are locally-scoped literals with no shared source, satisfying@twomore/require-mutation-key(presence-only) by construction. - Confirmed:
perf.reportSlow(...)op-name strings (packages/app/src/presentation/perf-reporter-init.ts:34,js-thread-monitor.ts:284,online-manager-setup.ts:51,query-client.ts:194,dev-panel/index.tsx:201,231,265) are free-form, ad hoc per call site — there is no canonical operation-name enum to duplicate. - Checked for the failure mode that would matter even without a registry — a literal
mutationKey/queryKeyfilter reused at a second call site viauseIsMutating/useMutationState— and found zero uses of either hook in the codebase, so there's no live drift risk from this absence today. - Verdict: not a violation of "key literal duplicates a registry entry" (there is no registry), so it doesn't belong in categories 2/3 as currently scoped. Flagging it only because a registry-less key space is exactly the state query-keys was in before
packages/app/src/query-keys/*was built — if mutation keys are ever consumed for cancellation/dedup matching (a natural next step), the same drift class as H2 becomes possible with no infrastructure to prevent it. No action recommended now; worth a one-line note indocs/canon/data-and-hooks.mdif/whenuseIsMutatingfilters are introduced.
L4. Feature-flag names — the category doesn't exist in this codebase
Searched for growthbook, GrowthBook, build_flags, featureFlags across the whole repo (excluding node_modules): zero matches. There is no feature-flag system in twomore-v2 today (the GrowthBook gate referenced in the user's global CLAUDE.md is aspirational/CI-related, not a runtime flag mechanism present in this repo). Nothing to audit; not a gap because there's no mechanism to have a gap in.
Summary table
| # | Finding | Severity | Guard exists? |
|---|---|---|---|
| C1 | CATEGORY_LABEL_KO push-vs-app drift (6/13 categories already wrong) | CRITICAL | No — untouched by push-copy-sync.test.ts |
| H1 | Push body copy hand-duplicated, zero coverage | HIGH | No |
| H2 | 'user-location' queryKey duplicated across 2 files | HIGH | No |
| H3 | 4 route literals bypass routes.ts | HIGH | No lint rule exists |
| H4 | 'twomore-auth-store' (sign-out wipe key) duplicated across 4 files, comment-only sync | HIGH | Comment only |
| M1 | PERSIST_KEYS migration list stale (2 version-bumped names, several missing stores) | MEDIUM | Comment only |
| M2 | 'twomore-query-cache' triplicated within mmkv.ts/mmkv.web.ts | MEDIUM | No |
| M3 | TIER_LABEL_KO triplicated (common/signals/edge-fn) | MEDIUM | Comment only |
| M4 | Achievement type strings duplicated client TS ↔ seed-only SQL, no CHECK constraint | MEDIUM | No |
| M5 | Domain-layer Korean literals (rotation-presets, subscription) invisible to the lint rule by construction | MEDIUM | Structural gap, not just missing scope |
| M6 | apps/web/components/** unscoped for Korean-literal detection | MEDIUM | Missing scope entry |
| L1 | English-literal blind spot — verified not currently exploited | LOW (latent) | By design |
| L2 | Edge-fn Korean = mostly seed fixtures, not a real gap | LOW / clean | N/A |
| L3 | No mutation-key / perf-op registry — not yet a violation | LOW / clean | N/A (no registry) |
| L4 | No feature-flag system exists | Clean | N/A |