Skip to content

Commit eeb09fb

Browse files
committed
docs(todos): add round-6 review findings 103-115 for PR #2024
1 parent c7fadab commit eeb09fb

13 files changed

+848
-0
lines changed
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
---
2+
status: pending
3+
priority: p1
4+
issue_id: "103"
5+
tags: [code-review, security, payments]
6+
dependencies: []
7+
---
8+
9+
# `_debug` Object in `validateApiKey` Leaks All Valid API Keys
10+
11+
## Problem Statement
12+
13+
`api/_api-key.js` returns a `_debug` object on invalid key attempts that contains `envVarRaw` (the raw `WORLDMONITOR_VALID_KEYS` value) and `parsedKeys` (every valid key as a parsed array). Although the gateway currently only surfaces `keyCheck.error` (a string) in the HTTP response, the full `_debug` object is held in `keyCheck` in memory. Any future logging middleware, error reporter, or Sentry breadcrumb that serializes `keyCheck` would leak ALL valid API keys to external observers.
14+
15+
This is one refactor away from a full key disclosure incident.
16+
17+
## Findings
18+
19+
- `api/_api-key.js:59-65``_debug` object contains `envVarRaw`, `parsedKeys`, `receivedKey`, `receivedKeyLen`, `envVarLen`
20+
- `server/gateway.ts` currently only propagates `keyCheck.error` (string) to HTTP response — leakage is latent, not immediate
21+
- Identified by: security-sentinel (round-6 review)
22+
23+
## Proposed Solutions
24+
25+
### Option 1: Delete the `_debug` block entirely
26+
27+
**Approach:** Remove lines 59-65 from `api/_api-key.js`. The function returns only `{ valid, required, error }`.
28+
29+
**Pros:** Eliminates risk entirely. Simple change.
30+
**Cons:** Loses diagnostic data (not meaningful — logs should not contain secrets anyway).
31+
**Effort:** 5 minutes
32+
**Risk:** None
33+
34+
### Option 2: Gate `_debug` behind `ENABLE_KEY_DEBUG=true` env var
35+
36+
**Approach:** Only attach `_debug` when `process.env.ENABLE_KEY_DEBUG === 'true'`. Never set this in production.
37+
38+
**Pros:** Preserves debug capability for local development.
39+
**Cons:** Env var must never be set in production; adds a conditional that could be mistakenly enabled.
40+
**Effort:** 10 minutes
41+
**Risk:** Low (if the env var discipline holds)
42+
43+
## Recommended Action
44+
45+
Option 1. Delete the `_debug` block. No diagnostic value justifies storing all valid API keys in a runtime object.
46+
47+
## Technical Details
48+
49+
**Affected files:**
50+
- `api/_api-key.js:59-65` — remove `_debug` block
51+
52+
## Resources
53+
54+
- **PR:** koala73/worldmonitor#2024
55+
- **Identified by:** security-sentinel, round-6 review 2026-03-31
56+
57+
## Acceptance Criteria
58+
59+
- [ ] `_debug` block removed from `validateApiKey` return value
60+
- [ ] `validateApiKey` still returns `{ valid, required, error }` for invalid key cases
61+
- [ ] `tsc --noEmit` + `vitest run` pass
62+
63+
## Work Log
64+
65+
### 2026-03-31 - Identified
66+
67+
**By:** security-sentinel (round-6 /ce-review)
68+
69+
- Latent high — not in HTTP response today but one logging-middleware addition away from full disclosure
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
---
2+
status: pending
3+
priority: p1
4+
issue_id: "104"
5+
tags: [code-review, security, auth]
6+
dependencies: []
7+
---
8+
9+
# JWT Verification Missing `algorithms: ['RS256']``alg:none` Bypass Risk
10+
11+
## Problem Statement
12+
13+
`server/auth-session.ts` calls `jwtVerify(token, jwks, { issuer, audience })` without an `algorithms` field. Clerk issues RS256 tokens. Without pinning the algorithm, some versions of `jose` will accept tokens with `alg: none` or `alg: HS256`, bypassing signature verification entirely. This is in the direct path for Vercel edge gateway entitlement enforcement — a bypass here means an attacker can craft a JWT with any `sub` claim and access any tier-gated endpoint.
14+
15+
## Findings
16+
17+
- `server/auth-session.ts:35``jwtVerify` has no `algorithms` option
18+
- Identified in existing todo #035 (pending P1)
19+
- The entitlement enforcement path in `server/gateway.ts``resolveSessionUserId``verifyClerkToken``jwtVerify` is the critical path
20+
21+
## Proposed Solutions
22+
23+
### Option 1: Add `algorithms: ['RS256']` to `jwtVerify` options
24+
25+
```ts
26+
const { payload } = await jwtVerify(token, jwks, {
27+
issuer: issuerDomain,
28+
audience: undefined,
29+
algorithms: ['RS256'],
30+
});
31+
```
32+
33+
**Effort:** 2 minutes
34+
**Risk:** None — Clerk always issues RS256
35+
36+
## Recommended Action
37+
38+
Option 1. One-line fix. Zero risk.
39+
40+
## Technical Details
41+
42+
**Affected files:**
43+
- `server/auth-session.ts` — add `algorithms: ['RS256']` to `jwtVerify` options
44+
45+
## Resources
46+
47+
- **PR:** koala73/worldmonitor#2024
48+
- **Prior todo:** `todos/035-pending-p1-jwtverify-missing-algorithms-allowlist.md`
49+
- **Identified by:** learnings-researcher + security-sentinel
50+
51+
## Acceptance Criteria
52+
53+
- [ ] `jwtVerify` call includes `algorithms: ['RS256']`
54+
- [ ] Tokens with `alg: none` are rejected
55+
- [ ] `tsc --noEmit` + `vitest run` pass
56+
57+
## Work Log
58+
59+
### 2026-03-31 - Identified (round-6 review)
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
status: pending
3+
priority: p1
4+
issue_id: "105"
5+
tags: [code-review, auth, payments]
6+
dependencies: []
7+
---
8+
9+
# Auth Init Deadlock — `initAuthState()` Gated Behind `isProUser()` Blocks New Users
10+
11+
## Problem Statement
12+
13+
`initAuthState()` in `src/App.ts` is wrapped inside an `if (isProUser())` guard (or similar premium check). `isProUser()` returns false for all new users (no `wm-pro-key`/`wm-widget-key` in localStorage). This creates a bootstrapping deadlock: Clerk never loads, the sign-in button never appears, and new users can never sign in to migrate from anonymous to authenticated identity. The entire anonymous-purchase migration flow in this PR (claimSubscription) is unreachable for new users.
14+
15+
## Findings
16+
17+
- `src/App.ts``initAuthState()` / `setupAuthWidget()` guarded by premium check
18+
- `isProUser()` reads from localStorage, starts false for every new visitor
19+
- Identified in existing todo #034 (pending P1) and learnings-researcher
20+
- Direct blocker for the anon-to-Clerk migration path added in this PR
21+
22+
## Proposed Solutions
23+
24+
### Option 1: Remove the `isProUser()` guard from auth init
25+
26+
**Approach:** Call `initAuthState()` unconditionally on web (gate only on `!isDesktopRuntime()`). `initAuthState()` is cheap when no session exists — it checks for a token and returns quickly.
27+
28+
**Pros:** Fixes the deadlock entirely. New users see sign-in button. Migration path works.
29+
**Cons:** Clerk SDK loads for all web users (already bundled, negligible cost).
30+
**Effort:** 5 minutes
31+
**Risk:** Low
32+
33+
## Recommended Action
34+
35+
Option 1. Remove the `isProUser()` gate from `initAuthState()`.
36+
37+
## Technical Details
38+
39+
**Affected files:**
40+
- `src/App.ts` — remove `isProUser()` guard around `initAuthState()` / Clerk setup
41+
42+
## Resources
43+
44+
- **PR:** koala73/worldmonitor#2024
45+
- **Prior todo:** `todos/034-pending-p1-auth-init-gated-behind-isProUser-deadlock.md`
46+
- **Identified by:** learnings-researcher
47+
48+
## Acceptance Criteria
49+
50+
- [ ] New user (no localStorage keys) sees sign-in button on page load
51+
- [ ] `initAuthState()` runs unconditionally for web runtime
52+
- [ ] Clerk sign-in modal opens successfully for new unauthenticated users
53+
- [ ] `vitest run` passes
54+
55+
## Work Log
56+
57+
### 2026-03-31 - Identified (round-6 review)
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
---
2+
status: pending
3+
priority: p1
4+
issue_id: "106"
5+
tags: [code-review, typescript, payments]
6+
dependencies: []
7+
---
8+
9+
# `any` ctx Type in `getEntitlementsHandler` Breaks Convex Type Safety
10+
11+
## Problem Statement
12+
13+
`convex/entitlements.ts` defines `getEntitlementsHandler(ctx: { db: any }, userId: string)` with `db: any`. This bypasses all Convex-generated type checking for DB operations inside this function — TypeScript cannot catch typos in index names, field names, or return types. For a security-critical billing module this is unacceptable.
14+
15+
## Findings
16+
17+
- `convex/entitlements.ts:25-26``ctx: { db: any }` parameter type
18+
- The `(q: any)` cast on the index callback at line 31 compounds this
19+
- This function is called from both `query` and `internalQuery` handlers — the correct type is `QueryCtx`
20+
- Identified by: kieran-typescript-reviewer (round-6 review)
21+
22+
## Proposed Solutions
23+
24+
### Option 1: Use `QueryCtx` from `_generated/server`
25+
26+
```ts
27+
import type { QueryCtx } from "./_generated/server";
28+
29+
async function getEntitlementsHandler(ctx: QueryCtx, userId: string) {
30+
```
31+
32+
Remove the `(q: any)` cast — the index callback will be fully typed once `ctx` is `QueryCtx`.
33+
34+
**Effort:** 5 minutes
35+
**Risk:** None — tightens types, no behavior change
36+
37+
## Recommended Action
38+
39+
Option 1. Import `QueryCtx` and replace `{ db: any }`.
40+
41+
## Technical Details
42+
43+
**Affected files:**
44+
- `convex/entitlements.ts:25-26` — change parameter type
45+
- `convex/entitlements.ts:31` — remove `(q: any)` cast
46+
47+
## Resources
48+
49+
- **PR:** koala73/worldmonitor#2024
50+
- **Identified by:** kieran-typescript-reviewer
51+
52+
## Acceptance Criteria
53+
54+
- [ ] `getEntitlementsHandler` uses `QueryCtx` type from `_generated/server`
55+
- [ ] No `any` types in the function signature or body
56+
- [ ] `tsc --noEmit` produces zero errors
57+
58+
## Work Log
59+
60+
### 2026-03-31 - Identified (round-6 review)
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
---
2+
status: pending
3+
priority: p1
4+
issue_id: "107"
5+
tags: [code-review, architecture, payments, data-integrity]
6+
dependencies: []
7+
---
8+
9+
# Concurrent Webhook Events Can Produce Duplicate `entitlements` Rows
10+
11+
## Problem Statement
12+
13+
`upsertEntitlements` in `convex/payments/subscriptionHelpers.ts` performs a read-check-then-write. Two concurrent Convex mutations operating on different `subscriptions` documents for the same userId can both observe zero entitlement rows and both `ctx.db.insert()`. Convex has no uniqueness constraint on the `entitlements.by_userId` index — duplicate rows are possible.
14+
15+
`getEntitlementsForUser` uses `.first()` (not `.unique()`) so it won't throw, but which row wins is non-deterministic. A paying user's active plan might be shadowed by a stale duplicate row.
16+
17+
## Findings
18+
19+
- `convex/payments/subscriptionHelpers.ts:63-107` — read-then-write without uniqueness guarantee
20+
- `convex/schema.ts:129-142` — no unique index annotation on `entitlements` table
21+
- Narrow window but structurally present: `subscription.active` + `payment.succeeded` for same user can both call `upsertEntitlements` concurrently
22+
- Test `convex/__tests__/entitlements.test.ts:134` confirms query survives duplicates but does not assert which row wins
23+
- Identified by: architecture-strategist (round-6 review)
24+
25+
## Proposed Solutions
26+
27+
### Option 1: Application-level deduplication guard in `upsertEntitlements`
28+
29+
Before inserting, check again inside the mutation using `.first()`. If a row already exists by the time we reach the insert branch, patch it instead. This is safe because Convex mutations are serialized — the second one will see the first one's write.
30+
31+
The current code already does this check, but the race window is between the initial `.first()` returning null and the subsequent `insert`. Since Convex mutations are serialized *per document*, two mutations on different documents can race. The fix is to use a single Convex mutation that handles both cases atomically with `.withIndex("by_userId").first()` re-checked at write time (Convex's OCC will retry on conflict).
32+
33+
**Effort:** Small
34+
**Risk:** Low
35+
36+
### Option 2: Post-insert deduplication cleanup job
37+
38+
Schedule a cleanup action that periodically merges duplicate `entitlements` rows, keeping the one with the highest tier/latest `validUntil`.
39+
40+
**Effort:** Medium
41+
**Risk:** Low (can run alongside the fix from Option 1)
42+
43+
## Recommended Action
44+
45+
Option 1: Add a second `.first()` check immediately before insert in `upsertEntitlements`, and add a comment documenting the race window and Convex's OCC protection.
46+
47+
## Technical Details
48+
49+
**Affected files:**
50+
- `convex/payments/subscriptionHelpers.ts:63-107` — add post-read re-check before insert
51+
- `convex/schema.ts` — add comment that Convex doesn't support unique indexes natively
52+
53+
## Resources
54+
55+
- **PR:** koala73/worldmonitor#2024
56+
- **Identified by:** architecture-strategist
57+
58+
## Acceptance Criteria
59+
60+
- [ ] `upsertEntitlements` re-checks for existing row immediately before inserting
61+
- [ ] Concurrent duplicate webhook events for same userId result in exactly one entitlement row
62+
- [ ] Test added: seed duplicate rows, process `subscription.active`, verify single row result
63+
64+
## Work Log
65+
66+
### 2026-03-31 - Identified (round-6 review)
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
---
2+
status: pending
3+
priority: p2
4+
issue_id: "108"
5+
tags: [code-review, payments, data-integrity]
6+
dependencies: []
7+
---
8+
9+
# `dispute.lost` Revocation Uses `Date.now()` Instead of `eventTimestamp`
10+
11+
## Problem Statement
12+
13+
In `handleDisputeEvent`, when `dispute.lost` fires and revokes the user's entitlement, the code calls `Date.now()` three separate times for `validUntil`, `updatedAt`, and the Redis cache sync args. Every other handler in `subscriptionHelpers.ts` consistently uses `eventTimestamp` (the webhook timestamp) for these fields — specifically to support the out-of-order event guard `isNewerEvent`. Using `Date.now()` in the dispute.lost block breaks this protection: a later-replayed older event with a smaller `timestamp` will pass the `isNewerEvent` check and overwrite the revocation with restored entitlements. For a chargeback scenario, this is a meaningful correctness gap — paid access could be unintentionally restored.
14+
15+
## Findings
16+
17+
- `convex/payments/subscriptionHelpers.ts:638-649` — three `Date.now()` calls in `dispute.lost` block
18+
- All other handlers use `eventTimestamp` parameter passed to `handleDisputeEvent`
19+
- Identified by: kieran-typescript-reviewer + architecture-strategist (round-6)
20+
21+
## Proposed Solutions
22+
23+
### Option 1: Capture `eventTimestamp` once, use consistently
24+
25+
```ts
26+
const now = eventTimestamp; // consistent with all other handlers
27+
await ctx.db.patch(existing._id, {
28+
planKey: "free",
29+
features: getFeaturesForPlan("free"),
30+
validUntil: now,
31+
updatedAt: now,
32+
});
33+
// ...scheduler...
34+
validUntil: now,
35+
```
36+
37+
**Effort:** 2 minutes
38+
**Risk:** None
39+
40+
## Recommended Action
41+
42+
Option 1. Replace all three `Date.now()` calls with `eventTimestamp`.
43+
44+
## Technical Details
45+
46+
**Affected files:**
47+
- `convex/payments/subscriptionHelpers.ts:638,639,649` — replace `Date.now()` with `eventTimestamp`
48+
49+
## Resources
50+
51+
- **PR:** koala73/worldmonitor#2024
52+
- **Identified by:** kieran-typescript-reviewer, architecture-strategist
53+
54+
## Acceptance Criteria
55+
56+
- [ ] All `Date.now()` calls in `dispute.lost` block replaced with `eventTimestamp`
57+
- [ ] A replayed older event after `dispute.lost` does NOT restore entitlements
58+
- [ ] `vitest run` passes
59+
60+
## Work Log
61+
62+
### 2026-03-31 - Identified (round-6 review)

0 commit comments

Comments
 (0)