Skip to content

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:

categoryedge 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_KO from 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.ts to also extract CATEGORY_LABEL_KO's object literal (or any Record<SignalCategory/…, string> map in the fn directory) and assert value equality against koSignals().signals.category, not just key presence. A generic version: a new rule/test — "duplicated-record-literal-value-drift" — that pairs any two Record<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 in packages/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.ts only ever looks at .title keys (regex signal\.[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 .body literals the same way it covers .title keys (it already has the regex-scan-the-directory infrastructure — extend the pattern to formatSignalBody's literal returns, or better: extract every literal Korean string in the file and assert it appears somewhere in koSignals()).

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:209queryClient.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 any queryKey: [ array literal (2+ string elements) in presentation/**/features/** whose first element is a string literal not sourced from a *Keys import, OR flag when the same literal array (by deep-equality of its literal segments) appears in queryKey:/invalidateQueries calls 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:16router.replace('/profile' as never) duplicates routes.profile (packages/app/src/navigation/routes.ts:44)
  • apps/web/app/page.tsx:19router.replace('/home') duplicates routes.home (routes.ts:33)
  • apps/web/app/page.tsx:21router.replace('/login') duplicates routes.login (routes.ts:29)
  • apps/web/components/route-guard.tsx:51router.replace(`/login?returnTo=${encodeURIComponent(path)}`) duplicates routes.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 — in packages/features/**, packages/app/src/presentation/**, apps/mobile/app/**, apps/web/app/**, apps/web/components/**: flag a string/template literal starting with / passed to router.push/router.replace/router.navigate/<Redirect href=/<Link href= UNLESS it's a dynamic expo-router file path that has no routes.* equivalent (the rule would need a small allowlist for the handful of expo-router <Redirect href="/(tabs)/home"> group-syntax literals, which routes.ts doesn'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:48const key = 'twomore-auth-store' (the real persist name)
  • packages/app/src/lib/storage-migration.ts:33 — re-typed in PERSIST_KEYS
  • packages/app/src/lib/mmkv.ts:107 — re-typed in WIPE_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 shared persist-keys.ts constants module) and import it in mmkv.ts, mmkv.web.ts, storage-migration.ts instead of re-typing.
  • Prevention (mechanical rule): a Jest guard test (same style as push-copy-sync.test.ts) that greps every presentation/stores/*.ts for its persist({ name: '...' }) literal and asserts each one is either present in mmkv.ts's WIPE_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 with packages/app/src/presentation/stores/*.ts persist({ name: '...' })."
  • Lists 'twomore-club-wizard' — actual: packages/app/src/presentation/stores/club-wizard.store.ts:389name: 'twomore-club-wizard-v10'
  • Lists 'twomore-session-wizard-draft' — actual: packages/app/src/presentation/stores/session-wizard.store.ts:314name: '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_KEYS from a single manifest object each store registers itself into.
  • Prevention: the guard test proposed in H4 covers this too (compare PERSIST_KEYS against every store's actual persist({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 reusing WIPE_KEYS.
  • Same duplication pattern in packages/app/src/lib/mmkv.web.ts:63 and :93.
  • The actual cache key is defined a fourth time as CACHE_KEY = 'twomore-query-cache' in packages/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 from mmkv.ts, imported everywhere including query-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 single const. 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-351
  • packages/app/src/config/i18n/ko/signals.ts:19-24 (comment: "mirrored from common.ts's trust.{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" in send-push/ and assert value equality against its named counterpart in ko/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_TYPES array, packages/app/src/domain/entities/achievement.entity.ts:16-114 (~55 'UPPER_SNAKE' literals, validated via z.enum(ACHIEVEMENT_TYPES) at achievement.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 plain TEXT NOT NULL column — 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_role only, lines 1090-1093) — it is not the production award path (the real client-driven award flow goes through packages/app/src/adapters/supabase/achievement.supabase.ts, which is TS-checked against AchievementType). 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_TYPES at migration-authoring time (documented, checked-in generation script) or add a CHECK (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 of process_seed_achievements's SQL text (same directory-scan-and-regex technique as push-copy-sync.test.ts) and asserts each one is a member of ACHIEVEMENT_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,115name: '짝바꿈 복식 · 소셜' etc. (7 presets) plus ROTATION_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}) and packages/features/sessions/src/add-round/style-picker.tsx:42 (label={preset.name}).
  • packages/app/src/domain/entities/subscription.entity.ts:71,78,85,92,99PLAN_META[plan].name ('무료', '플레이어', '베이직', '프로', '클럽+'). Currently dead code — grepped zero consumers in packages/features/** or presentation/** (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 write PLAN_META[plan].name in 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 treatment achievement.catalog.ts already got — its header comment literally documents this exact migration: "name/description/hint used to live inline on each entry below — moved to i18n … per the globalization review", achievement.catalog.ts:14-18. rotation-presets.ts and subscription.entity.ts are 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-domain that flags a Hangul (or, per the English-asymmetry note below, any non-empty string) literal assigned to a property named name/label/title/description/hint inside packages/app/src/domain/** and packages/app/src/adapters/** entity/catalog files, mirroring the UI_PROP_KEYS set 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) covers apps/web/app/ and apps/web/lib/ but not apps/web/components/ — an easy-to-miss asymmetry with the mobile side, where the equivalent screen code sits under apps/mobile/app/ (covered).
  • Live instance: apps/web/components/open-in-app-button.tsx:11const OPEN_IN_APP_LABEL = '앱에서 열기', rendered directly at line 21, no t().
  • 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/ to SCOPE_SUBSTRINGS. If the share-page Korean-only carve-out needs to stay, add those specific files to EXEMPT_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 way UI_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 to query-keys/. All 134 mutationKey: [...] 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/queryKey filter reused at a second call site via useIsMutating/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 in docs/canon/data-and-hooks.md if/when useIsMutating filters 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

#FindingSeverityGuard exists?
C1CATEGORY_LABEL_KO push-vs-app drift (6/13 categories already wrong)CRITICALNo — untouched by push-copy-sync.test.ts
H1Push body copy hand-duplicated, zero coverageHIGHNo
H2'user-location' queryKey duplicated across 2 filesHIGHNo
H34 route literals bypass routes.tsHIGHNo lint rule exists
H4'twomore-auth-store' (sign-out wipe key) duplicated across 4 files, comment-only syncHIGHComment only
M1PERSIST_KEYS migration list stale (2 version-bumped names, several missing stores)MEDIUMComment only
M2'twomore-query-cache' triplicated within mmkv.ts/mmkv.web.tsMEDIUMNo
M3TIER_LABEL_KO triplicated (common/signals/edge-fn)MEDIUMComment only
M4Achievement type strings duplicated client TS ↔ seed-only SQL, no CHECK constraintMEDIUMNo
M5Domain-layer Korean literals (rotation-presets, subscription) invisible to the lint rule by constructionMEDIUMStructural gap, not just missing scope
M6apps/web/components/** unscoped for Korean-literal detectionMEDIUMMissing scope entry
L1English-literal blind spot — verified not currently exploitedLOW (latent)By design
L2Edge-fn Korean = mostly seed fixtures, not a real gapLOW / cleanN/A
L3No mutation-key / perf-op registry — not yet a violationLOW / cleanN/A (no registry)
L4No feature-flag system existsCleanN/A

Markdown remains the source of truth. Run yarn docs:check before handoff.