Skip to content

Harden provider, input, and session boundaries - #77

Open
ejmockler wants to merge 11 commits into
mainfrom
hardening/provider-input-boundaries
Open

Harden provider, input, and session boundaries#77
ejmockler wants to merge 11 commits into
mainfrom
hardening/provider-input-boundaries

Conversation

@ejmockler

@ejmockler ejmockler commented Jul 22, 2026

Copy link
Copy Markdown
Member

Extracts the cost- and input-hardening kernel onto main as one coherent change set: provider-call ceilings, request boundaries, fail-closed moderation, sealed session cookies, Convex write-path budgets, and CI hardening. No new infrastructure of any kind — no workers, queues, durable objects, or storage; one optional schema field; zero new tables.

What's in here (9 commits, reviewable independently)

  1. Boundary modules — credential-scrubbing provider error sanitizer; per-stage Gemini call ceilings (prompt bytes / output tokens / attempts / timeouts); structural public-URL validator; byte-capped JSON reads with shape budgets; per-route agent request envelopes.
  2. Agent clients — sanitizer wired into exa-search, gemini-embeddings, and the Exa/Firecrawl rate limiter; every generate/stream call site names a stage; SSRF guard is the first statement of readPage (covers Firecrawl and the Exa fallback).
  3. Moderation — shared bounded Groq transport; llama-guard/prompt-guard fail closed on provider failure, malformed classifier output, or missing GROQ_API_KEY. Prompt-guard keeps its 2,000-char truncation window (never rejects oversize).
  4. Sessions — HMAC-sealed auth-session cookie verified locally in hooks before any Convex call (garbage/tampered → anonymous, no query, no deletion); all issuance sites seal; validateSession returns a 30-field allowlisted projection instead of the full user doc (no encrypted*, passkey material, or stripeCustomerId in locals).
  5. Reputation — one canonical threshold module replaces three divergent maps; tier derived transactionally on campaign action after the dedup early-return.
  6. Convex write paths — per-field byte budgets on template metadata, email drafts, and position mutations; template source cache bound to a server-derived SHA-256 input hash, owner-only writes, shape-guarded reads that degrade to a miss.
  7. Rate limiter — atomic reserve() closes the check-then-add overshoot under concurrency; per-user rules for position/shadow-atlas endpoints.
  8. Routes — validate-before-reserve on embeddings / delegation parse-policy (previously had no rate limit on a Gemini endpoint) / generate-subject; debates argument validation; template create-field allowlist. Shipped input allowances preserved exactly (in-band tests incl. multibyte); guest access unchanged.
  9. CI — SHA-pinned actions, top-level contents: read, persist-credentials: false, coverage comment split into its own job. Required check id stays test.

Deliberate posture changes to ratify

  • Moderation is now fail-closed. A Groq outage or missing key blocks moderation-gated flows instead of passing content unmoderated. Previously fail-open by design; this inverts that stance on purpose.
  • Existing session cookies invalidate at cutover (raw session ids no longer parse). Users re-login; acceptable pre-launch.
  • Source-cache writes are owner-only. Non-owner generations no longer warm a shared template's cache (anti-poisoning trade).

Ops prerequisites before deploy

  • Provision SESSION_COOKIE_SIGNING_SECRET (≥32 bytes, distinct from SESSION_CREATION_SECRET; optional _PREVIOUS for rotation, must differ) on CF Pages prod+preview and local dev. Unset ⇒ all sessions resolve anonymous (fail-closed). Already in ci.yml test env and .env.example.
  • Confirm the production GROQ_API_KEY is valid (moderation now blocks without it).

Verification

  • Full vitest: 5,801 passed / 0 failed (with CI-equivalent env; the 26 locally-failing files without env fail identically on clean main — missing local secrets that CI provides).
  • svelte-check: 0 errors. tsc -p convex: clean.
  • Four independent adversarial review passes over the integrated diff (integration seams, end-to-end security, scope/residue, main-contract fidelity): zero blocking findings.
  • Untouched by design: public-discovery snapshot/KV design, deploy.yml, _secret contracts (all still required), direct-delivery disablement, crons/CRON_PROFILE, email requirement.

Summary by CodeRabbit

  • Security
    • Session cookies are now sealed/signature-verified with previous-key rotation support.
    • External URL access rejects private/internal destinations.
    • Provider errors/messages are sanitized to avoid credential leakage; safety/moderation now fails closed when unavailable.
  • Validation
    • Added bounded JSON/body parsing across agent and API requests, with stricter field whitelisting and byte/shape limits (including email/template/debate/position/embedding).
    • Templates now validate and version source-cache updates via an input-hash.
  • Reliability
    • Gemini/Groq calls use stage-based limits, bounded retries (single-attempt), cancellation, and safer failure paths.
    • Rate limiting uses an atomic reserve flow.
  • Data Accuracy
    • Verified actions atomically advance reputation/engagement tiers; campaign attribution and org histograms stay consistent.

Standalone hardening modules: credential-scrubbing provider error
sanitizer, per-stage Gemini call ceilings, structural public-URL
validation, streamed byte-capped JSON reads with shape budgets, and
per-route agent request envelopes.
Wire the sanitizer into exa-search, gemini-embeddings, and the shared
Exa/Firecrawl rate limiter; enforce stage envelopes in gemini-client;
require a named stage at every generate/stream call site; guard
caller-influenced page fetches against SSRF before any scrape.
Shared bounded Groq transport (30s timeout, 64KB response cap, single
attempt, sanitized errors) behind llama-guard and prompt-guard. Provider
failure, malformed classifier output, or a missing GROQ_API_KEY now
blocks instead of passing content unmoderated. Prompt-guard keeps its
2,000-char truncation window; oversized input is never rejected here.
HMAC-sealed session cookie envelope verified locally in hooks before
any Convex call: garbage or tampered cookies resolve anonymous without
a query and without deletion. All issuance sites seal. validateSession
returns a 30-field allowlisted projection instead of the full user doc.
Requires SESSION_COOKIE_SIGNING_SECRET (documented in .env.example).
One threshold module replaces three divergent maps (campaigns tierMap,
users REPUTATION_THRESHOLDS, submissions route ternaries). Campaign
actions derive the tier transactionally after the dedup early-return.
The supporter-stats ratchet now scans only production convex modules.
…s inputs

Per-field byte budgets on template metadata, email drafts, and position
mutations. The template source cache gains a server-derived SHA-256
input hash (stored and compared), owner-only writes with bounded
payloads, and a shape-guarded read that degrades to a cache miss.
Replace check-then-add with an atomic reserve() on the store interface,
closing the await-interleaving overshoot; add per-user rules for the
position and shadow-atlas endpoints. In-memory path only.
Bounded reads and envelope validation on the LLM-backed routes
(embeddings, delegation parse-policy, generate-subject) with a quota
entry for the previously unlimited delegation endpoint; debates
argument validation; template create-field allowlist. Shipped input
allowances preserved exactly, multibyte included; guest access
unchanged.
Pin third-party actions to commit SHAs, drop to contents: read at the
top level, disable credential persistence on checkout, and move the PR
coverage comment into its own job so the job executing PR code never
holds pull-requests: write. Required check id stays `test`.
@strix-security

Copy link
Copy Markdown

Strix is installed on this repository, but we couldn't run this PR security review because this workspace's trial has ended. Add a card to resume code reviews here.

@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 63b2802b-7f9e-4f0d-a2ea-f0b95e2e4390

📥 Commits

Reviewing files that changed from the base of the PR and between b5242f2 and 1a22065.

📒 Files selected for processing (22)
  • convex/campaigns.ts
  • convex/email-input-budget.convex.test.ts
  • convex/lib/emailInputBudget.ts
  • convex/reputation-action-invariant.convex.test.ts
  • convex/templates-source-cache.convex.test.ts
  • convex/templates.ts
  • src/lib/core/agents/gemini-client.ts
  • src/lib/core/agents/providers/gemini-provider.ts
  • src/lib/core/security/public-external-url.ts
  • src/lib/core/security/rate-limiter.ts
  • src/lib/server/auth/session-user.ts
  • src/routes/api/agents/generate-subject/+server.ts
  • src/routes/api/agents/stream-decision-makers/+server.ts
  • src/routes/api/agents/stream-message/+server.ts
  • src/routes/api/agents/stream-subject/+server.ts
  • src/routes/api/templates/+server.ts
  • tests/unit/agents/generate-subject-endpoint.test.ts
  • tests/unit/routes/delegation-parse-policy-endpoint.test.ts
  • tests/unit/routes/templates-api-auth.test.ts
  • tests/unit/security/rate-limiter.test.ts
  • tests/unit/server/session-authority.test.ts
  • tests/unit/server/session-cookie.test.ts
💤 Files with no reviewable changes (1)
  • tests/unit/server/session-cookie.test.ts
🚧 Files skipped from review as they are similar to previous changes (18)
  • src/lib/server/auth/session-user.ts
  • convex/templates-source-cache.convex.test.ts
  • tests/unit/routes/templates-api-auth.test.ts
  • src/routes/api/templates/+server.ts
  • tests/unit/server/session-authority.test.ts
  • convex/lib/emailInputBudget.ts
  • tests/unit/routes/delegation-parse-policy-endpoint.test.ts
  • convex/email-input-budget.convex.test.ts
  • tests/unit/security/rate-limiter.test.ts
  • convex/reputation-action-invariant.convex.test.ts
  • src/routes/api/agents/stream-message/+server.ts
  • src/routes/api/agents/generate-subject/+server.ts
  • convex/campaigns.ts
  • src/lib/core/agents/providers/gemini-provider.ts
  • src/lib/core/security/rate-limiter.ts
  • src/lib/core/security/public-external-url.ts
  • convex/templates.ts
  • src/lib/core/agents/gemini-client.ts

📝 Walkthrough

Walkthrough

The PR adds bounded request validation, signed session cookies, canonical reputation attribution, provider execution envelopes, sanitized provider errors, public-URL checks, atomic rate limiting, source-cache hashing, and Convex integration tests across authentication, APIs, templates, campaigns, moderation, and AI providers.

Changes

Platform hardening

Layer / File(s) Summary
Authentication and session authority
convex/authOps.ts, convex/lib/sessionUser.ts, src/lib/server/auth/*, src/hooks.server.ts, src/routes/api/auth/*
Session users are projected through an allowlist, cookies use signed envelopes with rotation support, and locals-user construction is centralized.
Convex integrity and persistence
convex/lib/reputationTier.ts, convex/campaigns.ts, convex/email.ts, convex/positions.ts, convex/templates.ts
Reputation attribution and template/cache persistence use shared calculations and bounded validation.
Provider and request controls
src/lib/core/agents/*, src/lib/core/server/moderation/*, src/lib/server/*, src/routes/api/*
Provider calls, JSON bodies, moderation inputs, external URLs, and rate-limit reservations receive explicit limits, sanitization, cancellation, and validation.
Validation and CI coverage
convex/*.test.ts, tests/unit/**/*, tests/integration/*, .github/workflows/ci.yml
Tests cover the new authentication, budget, reputation, provider, cache, URL, route, and admission behaviors; CI uploads coverage artifacts and pins actions.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 26.43% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main theme of provider, input, and session hardening across the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch hardening/provider-input-boundaries

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install timed out. The project may have too many dependencies for the sandbox.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/lib/core/agents/providers/gemini-provider.ts (1)

907-936: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Per-chunk page cap doesn't prioritize chunk-attributed pages over shared ones.

chunkPages filters pages that are either unattributed (attributedTo.length === 0) or attributed to this chunk, then slices to maxPagesPerSynthesisChunk (Line 916) in whatever order they appear in pagesForSynthesis. Since unattributed/shared pages pass the filter for every chunk, they can consume slots ahead of pages specifically attributed to this chunk's identities once the new cap is hit, silently starving a chunk of the contact pages it actually needs.

🐛 Proposed fix: prioritize chunk-attributed pages before shared pages
 		const chunkPages = pagesForSynthesis
-			.filter(p =>
-				p.attributedTo.length === 0 ||
-				p.attributedTo.some(idx => chunkGlobalIndices.includes(idx))
-			)
+			.filter(p =>
+				p.attributedTo.length === 0 ||
+				p.attributedTo.some(idx => chunkGlobalIndices.includes(idx))
+			)
+			.sort((a, b) => {
+				const aOwn = a.attributedTo.some(idx => chunkGlobalIndices.includes(idx)) ? 0 : 1;
+				const bOwn = b.attributedTo.some(idx => chunkGlobalIndices.includes(idx)) ? 0 : 1;
+				return aOwn - bOwn;
+			})
 			.slice(0, DECISION_MAKER_PROVIDER_LIMITS.maxPagesPerSynthesisChunk)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/core/agents/providers/gemini-provider.ts` around lines 907 - 936,
Update the chunkPages construction in the chunkWork mapping to order eligible
pages with chunk-attributed pages first and unattributed/shared pages afterward,
then apply maxPagesPerSynthesisChunk. Preserve the existing filtering,
truncation, and attributedTo remapping behavior while ensuring chunk-specific
pages are not displaced by shared pages.
🧹 Nitpick comments (7)
tests/unit/agents/stream-subject-endpoint.test.ts (1)

244-248: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert propagation of a real abort signal.

signal: undefined does not exercise the new cancellation path. Build the request with an AbortController signal and assert that exact signal is passed to generateStreamWithThoughts.

[recommendation] As per coding guidelines, “Ensure all existing tests are passing before submitting a pull request, and add corresponding tests for new features using npm test.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/agents/stream-subject-endpoint.test.ts` around lines 244 - 248,
Update the test request around generateStreamWithThoughts to create an
AbortController and pass its signal instead of undefined, then assert the exact
signal is propagated in the call expectations. Preserve the existing
subject-line, prompt, temperature, and thinkingLevel assertions.

Source: Coding guidelines

tests/unit/agents/generate-subject-endpoint.test.ts (1)

33-37: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use a typed event fixture backed by a real Request.

The any casts allow a request fake that only exposes json(), while this endpoint’s admission layer validates the actual request boundary. Use new Request(...) and a narrow typed fixture so byte-limit tests cover the production request shape.

As per coding guidelines, “Strive for strong type safety. Avoid using any whenever possible in TypeScript.”

Also applies to: 65-68

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/agents/generate-subject-endpoint.test.ts` around lines 33 - 37,
Update the event fixture function event to return the endpoint’s narrow event
type instead of any, and construct request with a real Request instance
containing the serialized body. Preserve the authenticated session behavior
while ensuring the fixture matches the production request boundary used by
admission and byte-limit validation.

Source: Coding guidelines

src/routes/api/debates/[debateId]/arguments/+server.ts (2)

99-99: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Avoid any in the parsed body cast.

Record<string, any> disables type checking across the whole handler (body.txHash, body.verifierDepth, destructured fields all become any). Prefer Record<string, unknown> and narrow at each use.

As per coding guidelines: "Avoid using any whenever possible in TypeScript".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/api/debates/`[debateId]/arguments/+server.ts at line 99, Update
the parsed body cast in the debate handler to use Record<string, unknown>
instead of Record<string, any>, then narrow or validate each accessed
field—including txHash, verifierDepth, and destructured values—before use so
type checking is preserved throughout the handler.

Source: Coding guidelines


10-10: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use readBoundedJsonRequest herereadBoundedJson only caps bytes and parses JSON, while the newer helper also enforces a shape budget against deeply nested or wide payloads.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/api/debates/`[debateId]/arguments/+server.ts at line 10, Replace
the `readBoundedJson` import and its usage in the debate arguments request
handler with `readBoundedJsonRequest`, preserving the existing request parsing
flow while applying both byte and shape limits.
src/lib/core/search/gemini-embeddings.ts (1)

50-56: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

dimensions is now effectively fixed at 768 — the field's doc/typing is misleading.

embeddingRequestConfig throws a RangeError for any dimensions !== EMBEDDING_CONFIG.dimensions, yet the interface still advertises dimensions?: number with "default: 768", implying it's configurable. A caller passing e.g. 1536 (a value the comment on line 27 lists as "recommended") now hits a runtime throw. Consider narrowing the type to 768 (mirroring the maxRetries?: 1 pattern) or removing the field to make the fixed-dimension contract explicit at compile time.

♻️ Suggested tightening
-	/** Output dimensions (default: 768) */
-	dimensions?: number;
+	/** Output dimensions; the reviewed envelope permits exactly 768. */
+	dimensions?: 768;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/core/search/gemini-embeddings.ts` around lines 50 - 56, Update the
embedding request options interface’s dimensions field to reflect the fixed
EMBEDDING_CONFIG.dimensions contract, narrowing it to the literal value 768 (or
removing it if callers should not provide it). Replace the misleading
configurable/default documentation while preserving the existing validation in
embeddingRequestConfig.
src/lib/core/agents/exa-search.ts (1)

17-18: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use relative imports instead of the $lib alias for same-tree modules.

Both files add new imports of provider-error/security helpers via the $lib alias, even though the importing files and the imported modules both live under src/lib. As per coding guidelines, "src/lib/**/*.{ts,tsx}: Use relative paths for imports within the src/lib directory."

  • src/lib/core/agents/exa-search.ts#L17-L18: change import { sanitizeProviderErrorMessage } from '$lib/core/agents/provider-error' to from './provider-error', and import { parsePublicHttpUrl } from '$lib/core/security/public-external-url' to from '../security/public-external-url'.
  • src/lib/server/exa/rate-limiter.ts#L21-L22: change import { sanitizeProviderErrorMessage } from '$lib/core/agents/provider-error' to from '../../core/agents/provider-error'.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/core/agents/exa-search.ts` around lines 17 - 18, Replace the
same-tree $lib aliases with relative imports: in
src/lib/core/agents/exa-search.ts lines 17-18, use ./provider-error and
../security/public-external-url; in src/lib/server/exa/rate-limiter.ts lines
21-22, use ../../core/agents/provider-error. Update only the affected imports
while preserving their symbols.

Source: Coding guidelines

src/lib/core/agents/providers/gemini-provider.ts (1)

590-606: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Planning prompt lacks the same UTF-8 truncation applied to synthesis prompts.

Phase 2b's planningUser embeds identity.name/title/organization (Line 594) with no truncateUtf8 bound, while the same fields are truncated before Stage-4 synthesis (Lines 942-945). If any of these values are unexpectedly long (LLM/search-derived), generate()'s prompt-ceiling check will throw and fall back to template queries — functionally safe, but it undermines the bounded-input goal this PR establishes elsewhere and causes an avoidable failure+fallback cycle instead of a graceful truncation.

♻️ Proposed fix
 	const planningUser = `Plan search queries for these ${uncached.length} identities:\n\n` +
 		uncached.map((entry, i) => {
 			const { identity } = entry;
-			const nameStr = identity.name === 'UNKNOWN' ? '(name unknown)' : identity.name;
-			return `[${i}] ${nameStr} — ${identity.title} @ ${identity.organization}`;
+			const nameStr = identity.name === 'UNKNOWN' ? '(name unknown)' : truncateUtf8(identity.name, 256);
+			return `[${i}] ${nameStr} — ${truncateUtf8(identity.title, 512)} @ ${truncateUtf8(identity.organization, 512)}`;
 		}).join('\n');
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/core/agents/providers/gemini-provider.ts` around lines 590 - 606,
Apply the established truncateUtf8 bounds to identity.name, identity.title, and
identity.organization when constructing planningUser in the phase-2b
query-planning flow. Reuse the same limits or truncation approach already used
by the stage-4 synthesis prompt, while preserving the unknown-name handling and
planningUser format.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@convex/lib/emailInputBudget.ts`:
- Around line 47-72: Update assertEmailDraftPatch so the fromName branch also
applies the CRLF/null-byte validation used by assertEmailDraftInput, while
retaining its existing byte-budget check. Ensure patched fromName values receive
the same injection protection as values handled during blast creation.

In `@convex/positions.ts`:
- Around line 372-383: Ensure the recipient email validated in the recipients
loop is handled consistently: persist r.email to
positionDeliveries.recipientEmail in the corresponding insert, or remove r.email
and its assertBoundedOptional validation from the input contract. Update the
relevant position delivery creation flow while preserving existing encrypted
email handling.

In `@src/lib/core/agents/gemini-client.ts`:
- Around line 383-396: Update the catch block in the generateContent retry flow
to check options.signal?.aborted before terminalProviderError(error, attempts),
and immediately rethrow abortReason(options.signal). Preserve the existing
terminal error conversion and retry behavior for non-aborted failures.

In `@src/lib/core/security/rate-limiter.ts`:
- Around line 247-278: The RedisStore.reserve method must make pruning,
counting, and conditional insertion atomic to prevent concurrent requests from
exceeding maxRequests. Replace the separate zRemRangeByScore, zRange, and
conditional zAdd round-trips in reserve with one server-side EVAL script or
WATCH/MULTI/EXEC transaction, preserving the existing
allowed/count/oldestTimestamp results and key expiry behavior.

In `@src/lib/server/auth/session-user.ts`:
- Line 1: Update the deriveTrustTier import in session-user.ts to use the
relative path ../../core/identity/authority-level instead of the $lib alias,
while leaving the imported symbol and surrounding code unchanged.

In `@src/routes/api/templates/`+server.ts:
- Around line 624-632: Update the maxStringBytes setting in the
readBoundedJsonRequest options to cover the worst-case UTF-8 byte expansion of
the 10,000-character message_body limit, while remaining compatible with the 32
KB request cap. Preserve the existing shared validator so the character-based
limit continues to enforce the documented field length.

In `@tests/unit/routes/delegation-parse-policy-endpoint.test.ts`:
- Around line 47-56: Update the test around POST to spy on
candidate.request.body!.getReader() instead of candidate.request.text(), so it
detects streamed-body consumption by readBoundedJsonRequest(). Keep the existing
403 assertion and rate-limit assertion unchanged.

---

Outside diff comments:
In `@src/lib/core/agents/providers/gemini-provider.ts`:
- Around line 907-936: Update the chunkPages construction in the chunkWork
mapping to order eligible pages with chunk-attributed pages first and
unattributed/shared pages afterward, then apply maxPagesPerSynthesisChunk.
Preserve the existing filtering, truncation, and attributedTo remapping behavior
while ensuring chunk-specific pages are not displaced by shared pages.

---

Nitpick comments:
In `@src/lib/core/agents/exa-search.ts`:
- Around line 17-18: Replace the same-tree $lib aliases with relative imports:
in src/lib/core/agents/exa-search.ts lines 17-18, use ./provider-error and
../security/public-external-url; in src/lib/server/exa/rate-limiter.ts lines
21-22, use ../../core/agents/provider-error. Update only the affected imports
while preserving their symbols.

In `@src/lib/core/agents/providers/gemini-provider.ts`:
- Around line 590-606: Apply the established truncateUtf8 bounds to
identity.name, identity.title, and identity.organization when constructing
planningUser in the phase-2b query-planning flow. Reuse the same limits or
truncation approach already used by the stage-4 synthesis prompt, while
preserving the unknown-name handling and planningUser format.

In `@src/lib/core/search/gemini-embeddings.ts`:
- Around line 50-56: Update the embedding request options interface’s dimensions
field to reflect the fixed EMBEDDING_CONFIG.dimensions contract, narrowing it to
the literal value 768 (or removing it if callers should not provide it). Replace
the misleading configurable/default documentation while preserving the existing
validation in embeddingRequestConfig.

In `@src/routes/api/debates/`[debateId]/arguments/+server.ts:
- Line 99: Update the parsed body cast in the debate handler to use
Record<string, unknown> instead of Record<string, any>, then narrow or validate
each accessed field—including txHash, verifierDepth, and destructured
values—before use so type checking is preserved throughout the handler.
- Line 10: Replace the `readBoundedJson` import and its usage in the debate
arguments request handler with `readBoundedJsonRequest`, preserving the existing
request parsing flow while applying both byte and shape limits.

In `@tests/unit/agents/generate-subject-endpoint.test.ts`:
- Around line 33-37: Update the event fixture function event to return the
endpoint’s narrow event type instead of any, and construct request with a real
Request instance containing the serialized body. Preserve the authenticated
session behavior while ensuring the fixture matches the production request
boundary used by admission and byte-limit validation.

In `@tests/unit/agents/stream-subject-endpoint.test.ts`:
- Around line 244-248: Update the test request around generateStreamWithThoughts
to create an AbortController and pass its signal instead of undefined, then
assert the exact signal is propagated in the call expectations. Preserve the
existing subject-line, prompt, temperature, and thinkingLevel assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d07aa294-636b-4b74-91e0-8e7ab69b3134

📥 Commits

Reviewing files that changed from the base of the PR and between 4bff0a0 and b5242f2.

⛔ Files ignored due to path filters (2)
  • convex/_generated/api.d.ts is excluded by !**/_generated/**
  • convex/_generated/api.js is excluded by !**/_generated/**
📒 Files selected for processing (82)
  • .env.example
  • .github/workflows/ci.yml
  • convex/authOps.ts
  • convex/campaigns.ts
  • convex/email-input-budget.convex.test.ts
  • convex/email.ts
  • convex/lib/emailInputBudget.ts
  • convex/lib/reputationTier.ts
  • convex/lib/sessionUser.ts
  • convex/lib/templateInputBudget.ts
  • convex/positions-input-budget.convex.test.ts
  • convex/positions.ts
  • convex/reputation-action-invariant.convex.test.ts
  • convex/reputation-recompute.convex.test.ts
  • convex/schema.ts
  • convex/templates-input-budget.convex.test.ts
  • convex/templates-source-cache.convex.test.ts
  • convex/templates.ts
  • convex/users.ts
  • src/app.d.ts
  • src/hooks.server.ts
  • src/lib/core/agents/agents/decision-maker-accountability.ts
  • src/lib/core/agents/agents/message-writer.ts
  • src/lib/core/agents/agents/source-evaluator.ts
  • src/lib/core/agents/agents/subject-line.ts
  • src/lib/core/agents/exa-search.ts
  • src/lib/core/agents/gemini-client.ts
  • src/lib/core/agents/provider-call-envelope.ts
  • src/lib/core/agents/provider-error.ts
  • src/lib/core/agents/providers/gemini-provider.ts
  • src/lib/core/agents/types.ts
  • src/lib/core/auth/oauth-callback-handler.ts
  • src/lib/core/search/gemini-embeddings.ts
  • src/lib/core/security/public-external-url.ts
  • src/lib/core/security/rate-limiter.ts
  • src/lib/core/server/moderation/groq-transport.ts
  • src/lib/core/server/moderation/llama-guard.ts
  • src/lib/core/server/moderation/prompt-guard-budget.ts
  • src/lib/core/server/moderation/prompt-guard.ts
  • src/lib/server/agent-request-envelope.ts
  • src/lib/server/auth/session-cookie.ts
  • src/lib/server/auth/session-user.ts
  • src/lib/server/bounded-json-request.ts
  • src/lib/server/delegation/parse-policy.ts
  • src/lib/server/exa/rate-limiter.ts
  • src/lib/server/llm-cost-protection.ts
  • src/lib/server/source-cache-key.ts
  • src/routes/api/admin/backfill-embeddings/+server.ts
  • src/routes/api/agents/generate-subject/+server.ts
  • src/routes/api/agents/stream-message/+server.ts
  • src/routes/api/agents/stream-subject/+server.ts
  • src/routes/api/auth/passkey/authenticate/+server.ts
  • src/routes/api/debates/[debateId]/arguments/+server.ts
  • src/routes/api/delegation/parse-policy/+server.ts
  • src/routes/api/embeddings/generate/+server.ts
  • src/routes/api/internal/dev-login/+server.ts
  • src/routes/api/submissions/create/+server.ts
  • src/routes/api/templates/+server.ts
  • tests/integration/agent-trace-pipeline.test.ts
  • tests/unit/agents/exa-search.test.ts
  • tests/unit/agents/gemini-embeddings-envelope.test.ts
  • tests/unit/agents/gemini-embeddings-error.test.ts
  • tests/unit/agents/gemini-provider.test.ts
  • tests/unit/agents/generate-subject-endpoint.test.ts
  • tests/unit/agents/provider-call-envelope.test.ts
  • tests/unit/agents/provider-error.test.ts
  • tests/unit/agents/provider-request-envelope.test.ts
  • tests/unit/agents/source-cache-key.test.ts
  • tests/unit/agents/stream-subject-endpoint.test.ts
  • tests/unit/api/dev-login.test.ts
  • tests/unit/convex/template-input-budget.test.ts
  • tests/unit/moderation/groq-provider-envelope.test.ts
  • tests/unit/moderation/llama-guard-fail-closed.test.ts
  • tests/unit/org/supporter-stats-writer-coverage.test.ts
  • tests/unit/routes/debate-arguments-validation.test.ts
  • tests/unit/routes/delegation-parse-policy-endpoint.test.ts
  • tests/unit/routes/embedding-generate-endpoint.test.ts
  • tests/unit/routes/templates-api-auth.test.ts
  • tests/unit/security/public-external-url.test.ts
  • tests/unit/security/rate-limiter.test.ts
  • tests/unit/server/session-authority.test.ts
  • tests/unit/server/session-cookie.test.ts

Comment thread convex/lib/emailInputBudget.ts
Comment thread convex/positions.ts
Comment thread src/lib/core/agents/gemini-client.ts
Comment thread src/lib/core/security/rate-limiter.ts
Comment thread src/lib/server/auth/session-user.ts Outdated
Comment thread src/routes/api/templates/+server.ts
Comment thread tests/unit/routes/delegation-parse-policy-endpoint.test.ts

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🪓 Brutalist Review

Chunk 1/2: All three completing critics (claude-native, agy/Gemini, claude/glm) independently converged on the same two headline issues: (1) RedisStore.reserve() is advertised as atomic but performs an un-pipelined read-then-write with a TOCTOU gap, and (2) the SSRF guard is host-literal-only with no DNS resolution. claude-native and glm additionally flag a genuine integrity hole introduced by this PR: createCampaignAction now durably writes another user's actionCount/reputationTier gated on a public, email-keyed, postal-code-"verified" submission with no email-ownership proof — an inflation/griefing vector in the very anti-astroturf system this PR strengthens. Disagreement is mostly on severity framing of the SSRF and moderation-throw paths (both depend on downstream context the critics couldn't fully trace). I discarded agy's CRITICAL claim that SESSION_COOKIE_SIGNING_SECRET is misdeclared under outputs: — the real ci.yml places it under env: (line 46); that was an artifact of the summarized diff. The session-cookie crypto, projectSessionUser allowlist pattern, and CI privilege split are well-built.

Chunk 2/2: Both surviving critics (native Claude and agy/Gemini) independently converge on the same headline: these are competent security tests that pass honestly, but two of them sell more assurance than they deliver. The strongest agreement is on (1) the misnamed 'atomic under concurrency' rate-limiter test — verified against source, InMemoryStore.reserve is fully synchronous, so Promise.all cannot interleave and the production Redis path is entirely untested; and (2) the readFileSync-plus-regex 'source contracts' block, whose negative guards match a single exact spelling and are evaded by any variable rename or helper extraction. They diverge on severity and framing: agy escalated the source-regex and allowlist issues to Critical/High and treats the config-mirror tests as pure waste, while native Claude (correctly) notes those config tests still catch rule-ordering regressions via exact toEqual, and rates everything as low-to-moderate test debt rather than a live vulnerability. Net: no falsely-passing test was found; the actionable work is renaming/re-scoping the concurrency test, replacing the source-grep guards with behavioral or AST checks, and decoupling the projection assertion from the implementation's own allowlist so an unsafe field addition fails. Codex (rate-limited) and the GLM-routed Claude client (timeout) contributed nothing.

Inline comments: 10 (3 🟠 high · 7 🟡 medium)

Per-CLI breakdown

✅ Claude (default, 486245ms)

Native Claude. Flagged unauthenticated reputation attribution (High) and Redis rate-limiter non-atomicity (High); rated SSRF guard Low (calls out to third-party fetchers, not first-party network); praised session-cookie crypto, projectSessionUser, and CI split as solid.

Native Claude critic. Verdict: the three test files are mostly correct and pass for the right reasons — no test asserts a falsehood. Real issues are overclaiming test names and evadable guards: (1) the 'atomic under concurrency' test runs against a synchronous InMemoryStore so no interleaving occurs and the production Redis path is untested; (2) source-string grep 'contracts' are one-rename evadable; (3) forged-batch test only half-reaches the HMAC branch; (4) the projection/hooks tests are largely tautological. Praised the crypto envelope round-trip/rotation/tamper vectors and secret-hygiene tests as the strongest, precise part of the PR.

✅ agy (Gemini 3.5 Flash (Medium), 151730ms)

Flagged Redis TOCTOU (Critical), SSRF DNS-rebinding (High), empty-string secret crash (High), hot-path key import (Medium), moderation contract asymmetry (Medium). Its CRITICAL CI 'outputs' finding was FALSE — the real ci.yml uses env:, not outputs:; discarded.

Antigravity/Gemini critic. Flagged static source-code regex 'contracts' (rated Critical), tautological SESSION_USER_FIELDS comparison and ROUTE_RATE_LIMITS mirroring, the single-threaded 'concurrency' test, and missing unauthenticated-fallback / extra cookie attack-vector coverage. Severities were inflated (Critical/High on test-only code); downgraded here after verification, but the substantive observations overlap and reinforce native Claude's.

✅ glm (Claude) (glm-5.1, 1800018ms)

Deepest trace. Confirmed Redis non-atomicity + Math.random member collision, SSRF literal-host gap feeding a real Firecrawl fetch via prompt-injection→Gemini-URL chain, reputation inflation via postal-code 'verified' public path, uncaught classifySafety throw at moderation/index.ts:106, session-cookie audience-binding gap, and possible client exposure of identityCommitment/passkeyCredentialId via projectSessionUser.

Custom GLM-routed Claude client (clientId=glm) timed out after 1,800,000ms and produced no output. No findings contributed.

❌ Codex (default, 28704ms)

Codex critic did not complete — hit a rate/usage limit before producing output. No findings contributed.

Out-of-diff findings (12)

security

  • 🟠 high convex/campaigns.tsglm (Claude) [unanchored]: 'verified' means self-asserted postal code, not verified engagement — feeds durable reputation
  • 🔵 low convex/lib/sessionUser.tsglm (Claude) [unanchored]: projectSessionUser allowlist still includes correlation-sensitive identifiers — confirm it is server-only
  • 🔵 low src/lib/server/auth/session-cookie.tsglm (Claude) [unanchored]: Session cookie signing input has no origin/audience binding — cross-environment replay if a secret is shared
  • 🔵 low tests/unit/server/session-cookie.test.tsagy [unanchored]: Cookie attack-vector table omits null-byte, malformed-separator, and constant-time-comparison cases

correctness

  • 🟡 medium src/lib/core/server/moderation/index.tsglm (Claude) [unanchored]: classifySafety now throws, but its caller does not catch — fail-closed only if the outer route maps the throw to reject
  • 🔵 low src/lib/core/security/rate-limiter.tsglm (Claude) [sub-threshold]: Sorted-set member collision can silently drop a legitimate admission
  • 🔵 low src/lib/server/auth/session-cookie.tsagy [sub-threshold]: verifySessionCookie throws on an empty-string previousSecret instead of skipping rotation

testing

  • 🔵 low tests/unit/security/rate-limiter.test.tsagy [unanchored]: No coverage for the unauthenticated keyStrategy: 'user' fallback path
  • 🔵 low tests/unit/server/session-cookie.test.tsClaude [sub-threshold]: Forged-batch test only partially reaches the HMAC-verify branch it implies
  • 🔵 low tests/unit/server/session-authority.test.tsClaude [sub-threshold]: "hooks populates locals identically" is largely tautological by construction

maintainability

  • 🔵 low src/lib/core/server/moderation/llama-guard.tsagy [sub-threshold]: Moderation layers use inconsistent failure contracts (throw vs sentinel)
  • 🔵 low tests/unit/security/rate-limiter.test.tsagy [sub-threshold]: ROUTE_RATE_LIMITS tests duplicate the config array — mirror assertions with no enforcement verification

Brutalist orchestrator schemaVersion=1 · context_id=0a2eec99-8608-4261-a9f1-a4b80979138b

Comment thread convex/campaigns.ts
// are constructed. The enclosing mutation makes the user patch, action
// insert, histogram, and events one OCC-serialized commit.
let effectiveEngagementTier = args.engagementTier;
if (args.userId && args.verified) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🪓 Brutalist — 1 critic, rollup: 🟠 high

[Claude 🟠 high] security — Reputation write to arbitrary user gated on public, email-keyed, postal-code 'verified' submission

The new block durably mutates a user record (actionCount, reputationTier) inside createCampaignAction. Trace the inputs: userId comes from getUserTrustTier({ email: normalizedEmail }) at line 1661/1682 — a users-table lookup by submitter-typed email. verified is districtVerified || !!args.postalCode (line 1666), where districtVerified only requires a format-valid districtCode matching /^[A-Z]{2}-(\d{2}|AL)$/. Both are trivially attacker-supplied, and submitAction carries no email-ownership proof (no confirmation token). An unauthenticated actor can submit campaign actions under a victim's email with any postal code to advance that victim's reputationTier, or self-farm +1 per campaign per org to inflate their own tier without ever authenticating. This is an escalation introduced by this PR: pre-change the email lookup only stamped engagementTier onto the immutable action row; the diff turns it into a durable write to another user's identity record. Gate the user-record write on an authenticated session / real trust tier (>=2, i.e. district or address verified), not on the public submission's verified flag.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The escalation claim doesn't hold against main: at 4bff0a0, createCampaignAction already durably patched the user row (actionCount) under the identical args.userId && args.verified gate (see the T10-1 block main carried), and submitAction already passed userId: userData?.userId from the same email lookup. This PR derives reputationTier from that same counter in the same commit — the tier the nightly recompute would produce from it — so no new write capability or attribution surface is introduced. The underlying weakness (email-keyed attribution with verified = districtVerified || !!postalCode, no ownership proof) is real but pre-existing; it is now logged as a launch-gating follow-up: require session-bound attribution or email confirmation before actions count toward reputation.

const cutoff = timestamp - config.windowMs;

// Remove old entries first
await client.zRemRangeByScore(key, '-inf', cutoff.toString());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🪓 Brutalist — 1 critic, rollup: 🟠 high

[agy 🟠 high] security — Redis rate limiter check-then-act race admits bursts past the configured cap

The prune (zRemRangeByScore) → read (zRange) → check → add (zAdd) sequence is four sequential un-pipelined async commands. 50 concurrent requests in the same event-loop tick all execute the prune and read, observe the same pre-add count, pass the threshold check, and all execute zAdd — a Time-of-Check-to-Time-of-Use race that renders Redis-backed rate limiting non-atomic under concurrency. Replace with an atomic Lua EVAL/EVALSHA that prunes, reads, and conditionally adds in one Redis atomic block. (Severity: this shrinks the vulnerable window vs. the old check/record split but does not close it, so it is a soft-limit weakening rather than a full bypass.)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged and addressed as documentation rather than a Lua rewrite (68c71ab): Redis was dropped from the operating stack (2026-05) and this store is an unconfigured escape hatch; production posture is the in-memory store, whose reserve() is atomic per isolate. The best-effort cross-round-trip semantics are now documented at the implementation and the interface JSDoc no longer claims atomicity for this path.

await client.zRemRangeByScore(key, '-inf', cutoff.toString());

// Get all remaining entries (they're all within window)
const members = await client.zRange(key, 0, -1);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🪓 Brutalist — 1 critic, rollup: 🟠 high

[Claude 🟠 high] security — RedisStore.reserve is not atomic — TOCTOU between count read and zAdd contradicts the commit

reserve() performs four separate awaited round-trips with no MULTI/EXEC, WATCH, or Lua EVAL: zRemRangeByScore (prune), zRange (read count), a JS threshold check, then zAdd (write). Node yields at each await, so two concurrent requests can both observe count === maxRequests-1, both pass the check, and both add — admitting maxRequests+concurrency requests. InMemoryStore.reserve is atomic (synchronous single-isolate), so the atomicity guarantee the commit advertises holds only for the dev backend, not the production Redis backend it is ostensibly hardening. Over-admission under burst on any deployment with REDIS_URL. Collapse prune+count+conditional-add into a single Lua script (ZREMRANGEBYSCORE + ZCARD + conditional ZADD + EXPIRE in one evalsha).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same disposition as the sibling finding: documented best-effort semantics + corrected JSDoc in 68c71ab; an atomic Lua reservation belongs with any future decision to operate Redis (currently unconfigured; in-memory is the production path).

// SSRF guard: callers pass URLs sourced from search results and LLM
// output. Reject anything whose literal host is not structurally public
// before it reaches Firecrawl or the Exa contents fallback.
if (!parsePublicHttpUrl(url)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🪓 Brutalist — 1 critic, rollup: 🟡 medium

[glm (Claude) 🟡 medium] security — SSRF guard is literal-host only and gates a real server-side fetch reachable via prompt-injection

readPage() rejects on parsePublicHttpUrl(url) then hands the URL to Firecrawl/Exa. parsePublicHttpUrl does only string/regex structural checks — no DNS resolution, no resolved-IP pinning, no post-redirect re-check (its own doc says 'literal host'). An attacker-controlled domain with an A record pointing at 169.254.169.254 or 127.0.0.1 passes the literal check and gets scraped. The URLs originate from search results and Gemini output, and the decision-maker pipeline feeds user-controlled subjectLine/coreMessage/topics into Gemini whose output URLs become readPage targets — a live prompt-injection → malicious-URL → server-side-fetch chain. Severity depends on Firecrawl egress: SaaS Firecrawl lands the SSRF on their network (Medium); self-hosted/inline Firecrawl reaches internal metadata/localhost (High). After the literal check, resolve the host and reject private/loopback/link-local A/AAAA records, then pin the resolved IP for the fetch — or document loudly that this is not a complete SSRF control.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed as annotation in 68c71ab: the validator now states it is structural-only and not a complete standalone SSRF boundary. On the severity fork the finding itself poses: egress is SaaS Firecrawl/Exa (no self-hosted scrape path is configured), so a rebinding host lands on their egress, not this app's network — the Medium framing. Resolved-IP pinning is noted in the annotation as a prerequisite before any first-party fetch adopts this guard.

return raw;
}

/** Parse an HTTP(S) URL only when its literal host is structurally public. */

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🪓 Brutalist — 1 critic, rollup: 🟡 medium

[agy 🟡 medium] security — parsePublicHttpUrl performs no DNS resolution or IP pinning — insufficient as a standalone SSRF boundary

The function evaluates the literal string form of the hostname (IP-octet ranges, IPv6 embeddings, denied TLD/wildcard suffixes) but never resolves DNS or pins the resolved socket IP. A domain that resolves to a public IP at check time (or has a short TTL) can rebind to 127.0.0.1 / 169.254.169.254 before the outgoing fetch. The IPv4/IPv6 literal coverage here is genuinely thorough and the denylist approach is reasonable defense-in-depth, but string parsing alone cannot claim SSRF protection. If this is intended as the SSRF boundary, enforce a custom HTTP-agent lookup or post-DNS IP validation with connection-time pinning; otherwise annotate that it is structural-only.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed on the boundary characterization — 68c71ab annotates parsePublicHttpUrl as structural-only (no DNS resolution / connection-time pinning) with the adoption caveat for any future first-party fetch. Current callers egress via third-party scrape APIs.

Comment thread src/lib/core/security/rate-limiter.ts Outdated
interface RateLimitStore {
/**
* Add a timestamp and clean up old entries
* Atomically prune the window and reserve a slot when capacity remains.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🪓 Brutalist — 1 critic, rollup: 🟡 medium

[glm (Claude) 🟡 medium] maintainability — JSDoc claims 'atomically' for a non-atomic Redis path — security-property comment that misleads reviewers

The interface doc asserts 'Atomically prune the window and reserve a slot when capacity remains.' The RedisStore implementation does not deliver this (no MULTI/EXEC/Lua). Comments and commit messages asserting security properties the code does not deliver are worse than silence: each reassuring comment makes the next reviewer/auditor/incident-responder trust a boundary that has a hole in it. Either make the Redis path a real Lua script or remove 'atomic' from the contract name/doc and commit message.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 68c71ab: the interface JSDoc now states the split honestly (in-memory atomic per isolate; Redis best-effort across round-trips) and the implementation carries the same note.

expect(r3.remaining).toBe(2);
});

it('should atomically admit only the configured maximum under concurrency', async () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🪓 Brutalist — 2 critics, rollup: 🟡 medium

[agy 🟡 medium] testing — Concurrency assertion runs against a single-threaded in-memory stub — no real race exercised

agy independently flagged the same concurrency test as native Claude: Promise.all over InMemoryStore operations executes strictly sequentially in one thread (confirmed: reserve() has no await, rate-limiter.ts:116-146), so it tests single-threaded array accounting, not race conditions or distributed Redis atomicity. agy additionally notes the exact-order toEqual([1, 0]) assertion bakes in deterministic promise-resolution order that would not hold under a real async/Redis backend. High per agy; aligned with the medium rating here.

[Claude 🟡 medium] testing — "Atomic under concurrency" test exercises zero concurrency — InMemoryStore.reserve is fully synchronous

The test fires 20 limiter.check() calls through Promise.all and asserts exactly 2 admitted, claiming to prove atomic admission under concurrency. But the limiter under test uses InMemoryStore, whose reserve() (rate-limiter.ts:116-146) has NO await in its body — the entire prune→read→push→set runs to completion before check()'s await ever yields. The 20 calls therefore execute their critical sections strictly sequentially in creation order; there is no interleaving for the test to catch, and the [1, 0] remaining assertion is deterministic for the same reason. It is a legitimate regression guard for the in-memory path (it would catch reintroducing an await that splits read from write), but the RedisStore.reserve path (rate-limiter.ts:247) — which has real awaits and is where atomicity actually requires a Lua/MULTI-EXEC guarantee, and which is what production runs — gets zero coverage. The test named for concurrency covers the one backend where concurrency is impossible and skips the one where it's the whole problem. Verified: InMemoryStore.reserve body is synchronous.

Suggested change
it('should atomically admit only the configured maximum under concurrency', async () => {
Rename to reflect it guards the in-memory critical section, and add a RedisStore-backed test (fake Redis) that interleaves at the await points to give the "atomic under concurrency" claim meaning for production.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 68c71ab: the test is retitled to claim what it proves — exactly-N admission across interleaved in-memory callers. It remains a legitimate regression guard for the shipped path: it fails if a future refactor reintroduces an await between read and write in InMemoryStore.reserve. No distributed-atomicity claim is made anymore (Redis is unconfigured; see the sibling threads).


const projected = projectSessionUser(user);

expect(Object.keys(projected).sort()).toEqual([...SESSION_USER_FIELDS].sort());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🪓 Brutalist — 1 critic, rollup: 🟡 medium

[agy 🟡 medium] security — Projection test compares against the implementation's own allowlist constant — a sensitive field added to SESSION_USER_FIELDS still passes

The key-equality assertion imports SESSION_USER_FIELDS from the implementation and checks Object.keys(projected).sort() against it. This verifies that projectSessionUser matches its own allowlist — but if a developer adds a sensitive field (e.g. stripeCustomerId, encryptedEntropy) INTO SESSION_USER_FIELDS, the projection dutifully includes it and this test still passes. The test guards against a projection that diverges from the allowlist, not against an unsafe allowlist. The sensitiveExtras deny-list (lines 13-22) is a hand-curated 8-field list that will drift as the schema grows. Valid gap, though narrower than agy framed it: projectSessionUser being allowlist-based still prevents accidental leakage of a NEWLY added schema field that is not in the allowlist — the residual risk is specifically someone adding a sensitive field to the allowlist itself.

Suggested change
expect(Object.keys(projected).sort()).toEqual([...SESSION_USER_FIELDS].sort());
Assert the projected keys against a hardcoded literal list of public session fields (independent of SESSION_USER_FIELDS), so widening the allowlist to include a sensitive field fails the test.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 68c71ab: the projection assertion now checks against a hardcoded 30-field literal list independent of SESSION_USER_FIELDS, so widening the implementation allowlist to a sensitive field fails the test. The denylist spot-checks are retained on top.


describe('session cookie source contracts', () => {
it('seals every auth-session setter and verifies hooks cookies before Convex authority', () => {
const hooks = source('src/hooks.server.ts');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🪓 Brutalist — 1 critic, rollup: 🟡 medium

[agy 🟡 medium] testing — Static source-code regex "contracts" are fragile change-detectors that bypass real security verification

agy (Gemini 3.5 Flash) flagged the readFileSync-plus-regex block as an anti-pattern (agy rated it Critical; downgraded here to medium since this is a test-only file with no runtime impact — the substantive risk is false confidence and refactor fragility, not a live vulnerability). Concrete failure modes: (1) a commented-out or logged occurrence of await sealSessionCookie( satisfies the positive toContain even if sealing is disabled; (2) extracting cookie logic into a helper like setAuthSessionCookie(cookies, session), reformatting whitespace, or adding a new auth entry point silently evades or breaks the guard without any behavioral regression detected; (3) file paths are hardcoded relative to process.cwd() and will fail if files move. This overlaps native Claude's finding on the same block. Note the positive assertions currently pass honestly against real source.

Suggested change
const hooks = source('src/hooks.server.ts');
Delete the source-contract describe block and replace with integration tests that invoke the SvelteKit handlers / hooks.server.ts with mock events and assert the emitted Set-Cookie is a sealed envelope.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 68c71ab: the source-contract describe block is deleted. Behavioral coverage carries the contract — the module-level seal/verify vectors (tamper, strip, wrong-secret, oversize, garbage), the hooks behavior tests (invalid envelope resolves anonymous with zero Convex calls and no deletion), and the dev-login route test asserting the emitted cookie is a sealed v1.… envelope.


for (const setter of [oauth, passkey, devLogin]) {
expect(setter).toContain('await sealSessionCookie(');
expect(setter).not.toMatch(/cookies\.set\(['"]auth-session['"],\s*session\.sessionId/);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🪓 Brutalist — 1 critic, rollup: 🟡 medium

[Claude 🟡 medium] testing — Source-string grep guards are trivially evadable — they assert on source text, not behavior

The session cookie source contracts block readFileSyncs four server files and asserts on their text. The assertions match reality today (honest pass), but the NEGATIVE guards only fire on one exact spelling. .not.toMatch(/cookies\.set\(['"]auth-session['"],\s*session\.sessionId/) triggers only on the literal session.sessionId; a reintroduced raw write as cookies.set('auth-session', session.id), cookies.set('auth-session', sess.sessionId), or via a helper variable would sail past the guard while restoring the exact vulnerability the test claims to prevent. Likewise the cookies.delete(...) === 2 count breaks on any benign refactor and says nothing about the deletes' correctness. These are lint rules wearing a unit-test costume, coupling the suite to source formatting and giving false confidence that a raw-cookie regression is impossible. The behavioral half of this file already proves the sealing/verification contract properly.

Suggested change
expect(setter).not.toMatch(/cookies\.set\(['"]auth-session['"],\s*session\.sessionId/);
Move these to an AST/lint check, or replace with a behavioral test that imports each +server.ts handler and asserts the Set-Cookie value is a sealed `v1.…` envelope which renaming a variable cannot evade.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 68c71ab: block deleted (same disposition as the sibling finding) — behavioral tests retained as the enforcement surface.

… edges

CodeRabbit + brutalist review responses: reject CR/LF fromName in the
email patch path; rethrow aborts before the terminal provider wrap;
prioritize chunk-attributed pages under the synthesis cap; size template
string caps for UTF-8 worst case with an in-band multibyte test; spy the
streamed reader in the delegation guard test; assert the session
projection against an independent literal field list; drop the grep-based
source-contract tests in favor of the behavioral coverage; document the
best-effort Redis reserve semantics and correct the interface JSDoc;
annotate the URL validator as structural-only.
@ejmockler

Copy link
Copy Markdown
Member Author

Review responses pushed as 68c71ab — 8 findings fixed (fromName patch-path injection guard, abort identity preservation, chunk-attributed page priority under the synthesis cap [outside-diff finding], UTF-8-worst-case template string cap + multibyte test, streamed-reader spy, independent literal projection allowlist, source-grep contracts replaced by behavioral coverage, honest rate-limiter atomicity docs) and 3 declined with grounding on the threads (recipient email persistence is main's pre-existing hash-only contract; Redis Lua reservation deferred — Redis is unconfigured and in-memory is the production path, now documented as such). The campaigns reputation-write finding is refuted with evidence on its thread (main already performed the durable write under the identical gate); the underlying pre-existing email-attribution weakness is logged as a launch-gating follow-up.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🪓 Brutalist Review

Chunk 1/2: Three critics (Claude native, Antigravity/Gemini, and a GLM-routed Claude) reviewed PR #77; Codex failed on an expired token. All three agree the primitives are well-built (HMAC cookie envelope, bounded-JSON reader, provider-error redaction, CI privilege split) and that the real defects live at integration seams. The strongest cross-CLI agreement: (1) the fail-closed prompt-guard sentinel {safe:false,score:-1} is consumed by generate-subject as a hard 403 PROMPT_INJECTION_DETECTED, so a Groq outage masquerades as an attack spike (Claude=HIGH, agy=HIGH); (2) the relocated reputation write in createCampaignAction is gated only on userId && verified with no assertion that a dedup key is present, and is reachable via the unauthenticated email-keyed submitAction path (Claude/agy/glm). Notable disagreements I adjudicated against the code: agy's 'octal IPv4 SSRF bypass' is FALSE — WHATWG new URL('http://0177.0.0.1') normalizes to 127.0.0.1, which the guard's isNonPublicIpv4 correctly rejects (verified by execution); agy's 'CRITICAL renewSession auth failure' targets pre-existing context (the serverMutation(renewSession) call is not a changed line in this diff) and was not corroborated by the two critics who traced the same path, so I dropped it. Headline: fail-closed→403 misattribution and the un-asserted reputation-write invariant are the two things to fix before merge; everything else is low/defensible hardening debt.

Chunk 2/2: All three completing critics converge on the same headline: the public-external-url and session-cookie suites are strong, genuinely adversarial security tests, while the rate-limiter additions carry the real weakness. Two independent critics (native Claude and GLM) — plus agy on the concurrency angle — agree the 'interleaved in-memory callers' test cannot exercise concurrency (InMemoryStore.reserve is async with a synchronous body), so it advertises coverage of a DoS control it does not provide, while the documented non-atomic Redis path is never reached; that false confidence is the most actionable issue. Claude and GLM also agree the three ROUTE_RATE_LIMITS.find tests are change-detector tautologies duplicating the findRateLimitConfig block, and that the URL accept cases assert only .protocol and skip the host round-trip. Important adjudication: agy's two top-severity findings (a 'hollow' cookie suite with one vector, and an unasserted authority-query guard) were artifacts of the truncated diff and are contradicted by the on-disk file (10 vectors, queryAuthority.not.toHaveBeenCalled() per vector, plus the missing-cookie case) — discarded. Net: no test is broken or unsafe to merge, but the rate-limiter test names/comments should be corrected and the redundant config tests trimmed.

Inline comments: 8 (2 🟠 high · 6 🟡 medium)

Per-CLI breakdown

✅ Claude (default, 268745ms)

Native Claude critic. Read the actual changed files and traced cookie seal/verify, the hook integration, the Convex projection, reputation mutation, moderation fail-closed, SSRF guard, and CI. No Critical; 1 High (fail-closed sentinel → 403 PROMPT_INJECTION_DETECTED), 2 Medium (reputation dedup-key not asserted; buildLocalsUser 'novice' non-canonical default), plus Low findings (session TTL vs 91-day parse-bound coupling; stale rate-limiter/ordering comments). Explicitly praised the CI privilege split and the session-cookie crypto, and correctly noted WHATWG URL normalization makes the SSRF guard more robust than it looks.

Native Claude critic. Strong overall verdict: the public-external-url and session-cookie suites are genuinely good adversarial security tests. Real issues are precision debt: obfuscated-IPv4 SSRF vectors are neutralized by new URL() not the module (medium/mislabeled), accept cases assert only .protocol, the 'interleaved concurrent' rate-limiter test is nominal not concurrent, ROUTE_RATE_LIMITS.find trio is redundant, toMatchObject/toEqual inconsistency, and a wasteful 1000-iteration forged loop. Explicitly corrected the truncated-diff artifact.

✅ agy (Gemini 3.5 Flash (Medium), 247052ms)

Antigravity/Gemini critic. Produced a dependency map and 8 findings. Corroborated the fail-closed→403 misattribution (High) and the reputation dedup bypass (Low), and flagged the documented Redis non-atomic reserve and shared-IP lockout on user-keyed routes. Two headline claims did not survive verification: the 'octal IPv4 SSRF bypass' is false (WHATWG URL normalizes octal to 127.0.0.1, which the guard rejects), and the 'CRITICAL renewSession auth failure' targets pre-existing unchanged code and was uncorroborated — both moved to outOfDiff as rejected/downgraded.

Antigravity/Gemini critic. Valid overlapping findings: in-memory concurrency test masks the non-atomic Redis reserve TOCTOU path, and weak protocol-only URL assertions. HOWEVER its two highest-severity findings (Critical 'hollow cookie suite with only 1 vector' and Medium 'unasserted authority query gatekeeping') were based on the truncated diff and are FACTUALLY WRONG — the on-disk file has all 10 vectors and asserts queryAuthority.not.toHaveBeenCalled() plus a missing-cookie case. Those were discarded. Its authority-field-erasure claim is speculative and filed out-of-diff at low severity.

✅ glm (Claude) (glm-5.1, 558058ms)

GLM-5.1 routed through the Claude CLI. Deepest read: traced every load-bearing path end-to-end. Strongest unique findings: getSourceCache is an unauthenticated IDOR (Medium), reputation now has two writers with a now-false single-writer comment (Medium), unauthenticated email-keyed reputation writes raise astroturf stakes (Medium-High), and weak from-address domain validation (Low). Confirmed the fail-closed posture actually holds and praised the bounded-JSON reader, provider-error redaction, source-cache integrity, and CI hardening as genuinely good.

Claude-routed GLM-5.1 client. Highest-signal on the rate-limiter rot: ROUTE_RATE_LIMITS.find tests are pure change-detector tautologies duplicated across two describe blocks; the 'interleaved' test cannot fail and gives false concurrency assurance on a DoS control. Also flagged the protocol-only accept assertion, redundant verify+resolve per cookie vector, and the mislabeled 'hooks' test. Recommends shipping the URL/cookie suites and rewriting the rate-limiter additions.

❌ Codex (default, 33368ms)

Failed to run: CODEX OAuth token expired/rotated. No output produced; re-capture the token or provision OPENAI_API_KEY to include Codex in future reviews.

Codex hit a rate/usage limit and produced no critique.

Out-of-diff findings (18)

security

  • 🔵 low convex/lib/emailInputBudget.tsglm (Claude) [sub-threshold]: from-address domain validation accepts non-FQDN / all-numeric / hyphen-edge domains
  • 🔵 low src/lib/core/security/rate-limiter.tsagy [sub-threshold]: RedisStore.reserve is non-atomic across three round-trips (documented overshoot)
  • ⚪ nit src/lib/core/security/public-external-url.tsagy [unanchored]: REJECTED: claimed octal-IPv4 SSRF bypass does not reproduce

correctness

  • 🔵 low src/hooks.server.tsagy [unanchored]: DOWNGRADED: 'CRITICAL renewSession auth failure' targets pre-existing, unchanged code
  • 🔵 low src/lib/server/auth/session-cookie.tsClaude [sub-threshold]: Cookie 91-day parse bound is coupled to session TTL with no enforcing assertion

maintainability

  • 🔵 low src/lib/core/server/moderation/prompt-guard.tsglm (Claude) [unanchored]: Two divergent fail-closed contracts, and agent routes skip the S1/S4 safety classifier
  • 🔵 low src/lib/core/security/rate-limiter.tsglm (Claude) [sub-threshold]: In-memory reserve() is atomic only within one isolate; route table oversells brute-force protection
  • 🔵 low tests/unit/server/session-cookie.test.tsglm (Claude) [sub-threshold]: Per-vector standalone verifySessionCookie assertion is subsumed by the resolveSessionFromCookie assertion
  • ⚪ nit tests/unit/server/session-cookie.test.tsClaude [sub-threshold]: 1,000-iteration forged-cookie loop is runtime cost without added coverage

design

  • 🔵 low tests/unit/server/session-authority.test.tsagy [unanchored]: Speculative: projectSessionUser strips authorityLevel/trustTier — assert intended handling

testing

  • 🔵 low tests/unit/security/public-external-url.test.tsClaude [sub-threshold]: Accept-case assertion only checks .protocol, never the normalized host round-trip
  • 🔵 low tests/unit/security/public-external-url.test.tsagy [sub-threshold]: Positive URL test only checks protocol, not hostname/href
  • 🔵 low tests/unit/security/public-external-url.test.tsglm (Claude) [sub-threshold]: Accept tests assert only .protocol, not the host round-trip
  • 🔵 low tests/unit/security/public-external-url.test.tsClaude [sub-threshold]: Byte-ceiling test asserts an unobservable ordering claim and omits the boundary case
  • 🔵 low tests/unit/security/rate-limiter.test.tsClaude [sub-threshold]: ROUTE_RATE_LIMITS.find(...) tests are weaker duplicates of the findRateLimitConfig(...) tests above
  • 🔵 low tests/unit/security/rate-limiter.test.tsglm (Claude) [sub-threshold]: ROUTE_RATE_LIMITS.find(...) tests are pure change-detector tautologies duplicated across two describe blocks
  • 🔵 low tests/unit/server/session-authority.test.tsglm (Claude) [sub-threshold]: Test named 'hooks populates locals' exercises no hook — misleading label
  • ⚪ nit tests/unit/security/rate-limiter.test.tsClaude [sub-threshold]: Inconsistent matchers: toMatchObject (partial) for three configs, toEqual (exact) for engagement

Brutalist orchestrator schemaVersion=1 · context_id=75bb7080-2fd0-413d-b4ac-f7874bc91bad

const injectionCheck = await moderatePromptOnly(
agentPromptGuardContent('generate-subject', body)
);
if (!injectionCheck.safe) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🪓 Brutalist — 1 critic, rollup: 🟠 high

[agy 🟠 high] security — Third-party moderation outage classified as a client security violation

When Groq is unreachable, detectPromptInjection returns {safe:false, score:-1}. This handler checks if (!injectionCheck.safe) and returns 403 with code PROMPT_INJECTION_DETECTED, transforming an upstream 5xx/timeout into an explicit client-side security violation. Legitimate users are locked out during provider outages and the security logs fill with false prompt-injection alerts, so an incident responder sees an 'attack' where the real fault is an infrastructure dependency. The failure should surface as 503/service-unavailable, distinguishable from a genuine detection.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1a22065: the sentinel (score === -1) now returns 503 SAFETY_UNAVAILABLE at this route — and at stream-subject, stream-decision-makers, and stream-message, which share the same consumer pattern — so an outage is fail-closed but never classified as a client attack. Genuine detections keep the 403; both paths are test-pinned.

);
if (!injectionCheck.safe) {
return json(
{ error: 'Content flagged by safety filter', code: 'PROMPT_INJECTION_DETECTED' },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🪓 Brutalist — 1 critic, rollup: 🟠 high

[Claude 🟠 high] security — Fail-closed moderation sentinel is reported to users as PROMPT_INJECTION_DETECTED (403)

prompt-guard.ts documents that unavailable moderation returns the sentinel {safe:false, score:-1} and that 'Pipeline wrappers convert that sentinel into an availability error.' That wrapper does not exist on this path: moderatePromptOnly (moderation/index.ts:167) returns detectPromptInjection's result verbatim, and this endpoint only branches on .safe. When Groq is down, rate-limited, or returns garbage, every legitimate user is rejected with 403 PROMPT_INJECTION_DETECTED. Two problems: the doc-asserted sentinel→error conversion is never performed (nobody checks score === -1), and a third-party outage becomes indistinguishable from an attack spike in your security logs/dashboards — the only signal is a buried console.error. classifySafety in llama-guard.ts already handles this correctly by throwing 'Safety moderation service unavailable'; prompt-guard's sentinel-return is the odd one out. Fix: treat score === -1 as an availability failure and return 503/SAFETY_UNAVAILABLE (still fail closed), not 403/PROMPT_INJECTION_DETECTED.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1a22065 (same change as the sibling finding): score === -1 branches to 503 SAFETY_UNAVAILABLE across all four moderatePromptOnly consumers, with tests asserting outage→503 and genuine-detection→403. The stream-message path also traces SAFETY_UNAVAILABLE distinctly so dashboards separate outages from attack spikes.

Comment thread convex/campaigns.ts
// are constructed. The enclosing mutation makes the user patch, action
// insert, histogram, and events one OCC-serialized commit.
let effectiveEngagementTier = args.engagementTier;
if (args.userId && args.verified) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🪓 Brutalist — 3 critics, rollup: 🟡 medium

[Claude 🟡 medium] correctness — Reputation increment idempotency depends on a dedup key the type signature does not require

The relocated reputation write is gated only on args.userId && args.verified. Its idempotency rests entirely on the dedup guard above (campaigns.ts:1275-1291), which returns alreadySubmitted only when a supporterId OR congressionalSubmissionId is present — otherwise it is unconditionally null and dedup is skipped. createCampaignAction is an internalMutation where supporterId, congressionalSubmissionId, and userId are all independently v.optional. A caller supplying userId+verified but neither dedup key increments actionCount → reputationTier → engagementTier on every call. reputationTier gates engagementTier, which is the immutable attribution weight flowing into the org billing histogram and webhook payload — exactly the surface anti-astroturf controls protect. The current caller (submitCampaignAction) always threads supporterId, so it holds today, but nothing asserts it. Fix: if (args.userId && args.verified && !args.supporterId && !args.congressionalSubmissionId) throw new Error('CAMPAIGN_ACTION_MISSING_DEDUP_KEY').

[glm (Claude) 🟡 medium] security — Reputation counters now synchronously written from an unauthenticated, email-attributed path

submitAction is a public path gated only by the internal secret (which the SvelteKit backend always holds); the submitter is never authenticated, and the user is resolved by a by_email lookup on a client-supplied email with verified satisfiable by a postal code. This PR raises the stakes of that pre-existing attribution-by-email by making createCampaignAction synchronously write reputationTier and bind it to the action's immutable engagementTier (which flows into org actionTierCounts and the webhook). Dedup on (campaignId, supporterId) bounds but does not eliminate cross-campaign/cross-org farming, so anyone who knows a registered user's email can shape that user's actionCount/reputationTier. Reputation signals that gate trust should not be writable from an unauthenticated path keyed on guessable PII — gate the actionCount++ on a real authenticated session rather than an email match.

[agy 🔵 low] security — Deduplication skipped for actions with userId but no supporter/congressional key

The alreadySubmitted query only executes when congressionalSubmissionId or supporterId is present; otherwise it resolves to null and dedup is bypassed. An invocation carrying args.userId without either key increments user.actionCount (and now reputationTier) on every call, inflating reputation tiers via unattributed action invocations. Bounded today by the sole caller always passing supporterId, but unenforced at the mutation boundary.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1a22065: the reputation write now throws CAMPAIGN_ACTION_MISSING_DEDUP_KEY when a verified action carries userId but neither supporterId nor congressionalSubmissionId, with a convex test asserting the row stays unincremented. The invariant your dedup-guard trace identified is now asserted rather than assumed.

Comment thread convex/campaigns.ts
const nextUserReputation = reputationStateForActionCount(nextUserActionCount);
await ctx.db.patch(args.userId, {
actionCount: nextUserActionCount,
reputationTier: nextUserReputation.reputationTier,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🪓 Brutalist — 1 critic, rollup: 🟡 medium

[glm (Claude) 🟡 medium] maintainability — reputationTier now has two writers; the 'cron is the only writer' contract is now false

The transactional relocation (user patch + action insert in one OCC commit) is correct and closes the old post-insert crash window. But createCampaignAction is now a synchronous writer of the canonical reputationTier label while the nightly recompute cron (convex/users.ts recomputeAllReputationTiers) also writes it, and comments elsewhere still assert the cron is the sole writer. Both writers agree today only because they call the same pure reputationStateForActionCount on the same counter. The structural hazard: any future non-action signal added to the cron's promotion logic (peer endorsements, template adoption — fields already on the user doc) will be silently overwritten on the next verified action, since this path derives reputationTier from actionCount alone. Either make reputationTier a pure derived view of actionCount (no stored label, single source), or document the precedence rule and delete the now-false single-writer comment.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 1a22065 as documentation of precedence: the write block now states actionCount is the single source of truth with reputationTier always derived via reputationStateForActionCount — on action here, and in recomputeAllReputationTiers as repair/backfill. No stale sole-writer claim survives in the tree (the cron comment referenced legacy-string migration, not exclusivity). The deeper suggestion (pure derived view, no stored label) is a schema-shape change logged to the deferred ledger.

Comment thread convex/templates.ts
return {
cachedSources: template.cachedSources ?? null,
sourcesCachedAt: template.sourcesCachedAt ?? null,
sourceCacheInputHash: template.sourceCacheInputHash ?? null,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🪓 Brutalist — 1 critic, rollup: 🟡 medium

[glm (Claude) 🟡 medium] security — getSourceCache is an unauthenticated, unvisibility-gated IDOR

This query has no requireAuth, no requireOrgRole, and no status/isPublic filter — it returns cachedSources (scraped URLs, titles, snippets, decision-maker targeting research) for any templateId. Contrast updateSourceCache in the same file, which this PR correctly hardened with template.userId === userId (TEMPLATE_SOURCE_CACHE_FORBIDDEN). Convex IDs are not secret — they travel in URLs and API responses — so a draft/private template's research set is reachable by anyone who has seen or can enumerate an ID, and stream-message/+server.ts passes a client-supplied template_id straight in. Add the same ownership/visibility gate to the read, or restrict cachedSources to published/public templates.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1a22065: getSourceCache now returns null for draft/non-public templates unless the authenticated caller is the template author (same identity resolution as requireAuth), matching the write-side ownership gate. Null keeps the route's cache-miss degrade semantics. Test-pinned: anonymous and non-author reads of a draft observe null; the author round-trips; published templates stay readable.

profile_visibility: user.profileVisibility ?? 'private',
// Reputation
trust_score: user.trustScore ?? 0,
reputation_tier: user.reputationTier ?? 'novice',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🪓 Brutalist — 1 critic, rollup: 🟡 medium

[Claude 🟡 medium] correctness — buildLocalsUser defaults reputation_tier to a non-canonical 'novice' label

The canonical tier set (convex/lib/reputationTier.ts) is 'new' | 'active' | 'established' | 'veteran' | 'pillar'. 'novice' is not a member. Because actionCount is deliberately excluded from SESSION_USER_FIELDS and reputationTier is absent for genuinely new accounts, this default fires for every pre-first-action user, surfacing a tier string no other subsystem recognizes. engagementTierForReputationTier('novice') finds no match and returns 0 — correct only by accident — while any UI switch, tier comparison, or analytics grouping keyed on the canonical set mishandles it. Fix: default to 'new', or derive via reputationStateForActionCount(0).reputationTier for a single source of truth. (Note: several new test fixtures in this PR also seed reputationTier: 'novice', propagating the non-canonical value.)

Suggested change
reputation_tier: user.reputationTier ?? 'novice',
reputation_tier: user.reputationTier ?? 'new',

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declining the default change, with grounding: ?? 'novice' is byte-parity with shipped main (hooks.server.ts:185 at 4bff0a0), and 'novice' is a legacy value main still carries at several sites (src/lib/types/user.ts fallback, oauth-callback-handler, fixtures) with a crons migration comment explicitly naming 'verified'/'novice' as pre-T10-3 legacy strings. Changing only this one site would create more inconsistency than it cures; the coherent fix is a small sweep of all legacy-label sites together, which is now logged as a follow-up. Your observation that engagementTierForReputationTier('novice') returns 0 'correct only by accident' is fair — noted in the ledger entry.

'http://service.internal/admin',
'http://metadata.google.internal/computeMetadata/v1',
'http://127.0.0.1/admin',
'http://2130706433/admin',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🪓 Brutalist — 1 critic, rollup: 🟡 medium

[Claude 🟡 medium] security — Obfuscated-IPv4 SSRF vectors are rejected by new URL() normalization, not by the module — mislabeled coverage

The numeric-host reject vectors (http://2130706433, http://0x7f000001, http://017700000001) pass, but not because of the module's logic. The WHATWG URL constructor normalizes decimal/hex/octal integer hosts to 127.0.0.1 before normalizedPublicHostname/parseIpv4 ever run — and parseIpv4's regex ^\d+.\d+.\d+.\d+$ structurally cannot match a bare integer, so that path is dead for these inputs. The tests therefore validate composed browser+module behavior (fine as defense-in-depth) but read as if they exercise the module's numeric-host handling. If someone later fed raw strings past URL, or swapped to a lenient parser, these 'green' tests would stop protecting the numeric path they appear to cover. Add a comment noting rejection happens at the URL-normalization layer, and if custom numeric handling is intended, test parseIpv4/normalizedPublicHostname directly.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — the vectors are retained deliberately as regression pins on the composed behavior (WHATWG normalization + guard), which is what production traffic traverses; if a runtime ever swaps in a non-normalizing URL parser, these are the vectors that catch it first. The guard-layer attribution point is fair and the adjudicator's execution check confirmed the rejection is real either way.

expect(r3.remaining).toBe(2);
});

it('admits exactly the configured maximum across interleaved in-memory callers', async () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🪓 Brutalist — 3 critics, rollup: 🟡 medium

[agy 🟡 medium] security — In-memory concurrency test masks the non-atomic Redis reserve TOCTOU path

Promise.all over Array.from({length:20}, ...) executes all calls synchronously on the single-threaded event loop before any yield, so it does not test concurrency. Crucially, RedisStore.reserve executes separate async Redis calls (zRemRangeByScore, zRange, zAdd); under real concurrent traffic multiple requests can issue zRange before any calls zAdd, allowing overshoot beyond maxRequests. The test only exercises InMemoryStore and thus structurally cannot reach the documented Redis race. (Downgraded from the critic's 'High' — the source already documents the Redis non-atomicity; this is a test-scope/labeling gap, not a newly introduced production bug.) Consider an EVAL/Lua atomic reservation for the Redis path and an honest test name.

[Claude 🟡 medium] testing — 'interleaved concurrent' rate-limiter test is nominal, not concurrent — false concurrency assurance

The test fires 20 check() calls under Promise.all and expects exactly 2 admitted. But InMemoryStore.reserve is async with a fully synchronous body — no await between the read and the write. Under single-threaded JS the reservations run to completion in call order during array construction; they cannot interleave, so overshoot is structurally impossible here. Its real, legitimate value is as a regression guard against reintroducing a yield point between count-read and slot-write. But it would NOT catch a race in the genuinely-async Redis store (RedisStore.reserve issues prune/count/add as separate round-trips and is documented as non-atomic). Fix the name/comment so it isn't read as proof the limiter is race-free in production.

[glm (Claude) 🟡 medium] testing — 'interleaved in-memory callers' test cannot fail and gives false concurrency assurance on a DoS control

InMemoryStore.reserve is marked async but contains no await — its body runs synchronously to completion. When Promise.all invokes 20 check() calls, each calls reserve() synchronously before its await suspends, so all 20 reservations execute sequentially in a single event-loop sweep. There is no interleaving; overshoot is structurally impossible. The name implies concurrency-safety it cannot prove, while the Redis backend — explicitly documented as non-atomic ('concurrent requests can briefly overshoot maxRequests') — is the backend with the real gap and is never reached. Rename to an honest description or add a real Redis-path overshoot test.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test's claim was narrowed in 68c71ab (retitled to 'interleaved in-memory callers' — the shipped production path, where reserve() is genuinely atomic per isolate) and the Redis TOCTOU is now documented at both the interface and implementation rather than masked: Redis is an unconfigured escape hatch dropped from the operating stack in 2026-05. An atomic Lua reservation is the recorded prerequisite for ever operating Redis; CodeRabbit reached the same disposition on the sibling thread.

…gaps

Brutalist round-two responses: the prompt-guard outage sentinel now
returns 503 SAFETY_UNAVAILABLE at all four agent routes instead of
masquerading as a 403 injection detection; createCampaignAction fails
loud when a verified reputation-bearing action arrives without a dedup
key; getSourceCache gates draft/private research to the template author;
the reputation write block documents actionCount as the single source
with the cron recompute as repair.
@ejmockler

Copy link
Copy Markdown
Member Author

Brutalist round-two responses pushed as 1a22065 — the converged HIGH (moderation outage surfacing as 403 PROMPT_INJECTION_DETECTED) is fixed across all four agent routes with a distinct 503 SAFETY_UNAVAILABLE, test-pinned against genuine detections; the reputation dedup-key invariant is now asserted (CAMPAIGN_ACTION_MISSING_DEDUP_KEY); getSourceCache gains the author/visibility gate matching the write side; writer precedence documented. One decline with evidence on its thread ('novice' default is byte-parity with shipped main's legacy labels — coherent fix is a full-site sweep, now ledgered). Required test check remains green.

@github-actions

Copy link
Copy Markdown

Code Coverage

Package Line Rate Branch Rate Health
packages.sdk-typescript.src 74% 64%
src 0% 0%
src.lib 100% 100%
src.lib.components.action 0% 0%
src.lib.components.activation 0% 0%
src.lib.components.auth 0% 0%
src.lib.components.auth.address-steps 0% 0%
src.lib.components.auth.parts 0% 0%
src.lib.components.automation 0% 0%
src.lib.components.billing 0% 0%
src.lib.components.crypto 0% 0%
src.lib.components.debate 0% 0%
src.lib.components.error 0% 0%
src.lib.components.events 0% 0%
src.lib.components.fundraising 0% 0%
src.lib.components.geographic 0% 0%
src.lib.components.identity 0% 0%
src.lib.components.layout 0% 0%
src.lib.components.layout.header 0% 0%
src.lib.components.modals 0% 0%
src.lib.components.networks 0% 0%
src.lib.components.onboarding 0% 0%
src.lib.components.org 2% 5%
src.lib.components.org.os 17% 17%
src.lib.components.org.studio 26% 23%
src.lib.components.profile 0% 0%
src.lib.components.scorecard 0% 0%
src.lib.components.segments 0% 0%
src.lib.components.setup 0% 0%
src.lib.components.sms 0% 0%
src.lib.components.submission 0% 0%
src.lib.components.template 0% 0%
src.lib.components.template-browser 0% 0%
src.lib.components.template-browser.parts 0% 0%
src.lib.components.template-browser.relation 0% 0%
src.lib.components.template-browser.spectrum 0% 0%
src.lib.components.template.creator 0% 0%
src.lib.components.template.parts 0% 0%
src.lib.components.thoughts 0% 0%
src.lib.components.ui 0% 0%
src.lib.components.verify 0% 0%
src.lib.components.visualization 0% 0%
src.lib.components.wallet 0% 0%
src.lib.components.wallet.debate 0% 0%
src.lib.config 53% 53%
src.lib.constants 25% 100%
src.lib.core 3% 5%
src.lib.core.agents 92% 83%
src.lib.core.agents.agents 49% 40%
src.lib.core.agents.prompts 10% 0%
src.lib.core.agents.providers 28% 29%
src.lib.core.agents.types 100% 100%
src.lib.core.agents.utils 63% 55%
src.lib.core.analytics 31% 12%
src.lib.core.api 0% 0%
src.lib.core.auth 42% 40%
src.lib.core.blockchain 26% 25%
src.lib.core.census 100% 100%
src.lib.core.crypto 91% 72%
src.lib.core.email 98% 100%
src.lib.core.encoding 100% 100%
src.lib.core.gas 0% 0%
src.lib.core.identity 62% 60%
src.lib.core.legislative 100% 100%
src.lib.core.locale 0% 0%
src.lib.core.location 55% 56%
src.lib.core.location.resolvers 100% 90%
src.lib.core.near 0% 0%
src.lib.core.org 91% 92%
src.lib.core.privacy 100% 100%
src.lib.core.proof 2% 6%
src.lib.core.search 14% 30%
src.lib.core.security 70% 80%
src.lib.core.server 57% 70%
src.lib.core.server.moderation 74% 66%
src.lib.core.shadow-atlas 66% 60%
src.lib.core.shadow-atlas.shared-vectors 100% 100%
src.lib.core.targets 100% 70%
src.lib.core.thoughts 0% 0%
src.lib.core.tools 2% 0%
src.lib.core.topic 98% 79%
src.lib.core.wallet 6% 7%
src.lib.core.zkp 50% 57%
src.lib.data 98% 87%
src.lib.design 0% 0%
src.lib.server 82% 75%
src.lib.server.agents 0% 0%
src.lib.server.api-v1 68% 75%
src.lib.server.auth 96% 88%
src.lib.server.billing 50% 31%
src.lib.server.billing.providers 100% 88%
src.lib.server.calls 0% 0%
src.lib.server.delegation 100% 100%
src.lib.server.email 84% 65%
src.lib.server.events 90% 67%
src.lib.server.exa 85% 73%
src.lib.server.firecrawl 14% 0%
src.lib.server.geographic 100% 100%
src.lib.server.ground 62% 58%
src.lib.server.identity 98% 89%
src.lib.server.internal 91% 79%
src.lib.server.legislation 0% 0%
src.lib.server.legislation.ingest 100% 100%
src.lib.server.legislation.receipts 0% 0%
src.lib.server.legislation.scorecard 100% 100%
src.lib.server.platform-sync 100% 91%
src.lib.server.reducto 0% 0%
src.lib.server.shims 0% 100%
src.lib.server.sms 55% 39%
src.lib.server.smt 77% 62%
src.lib.server.tee 96% 91%
src.lib.server.workflows 0% 0%
src.lib.services 25% 14%
src.lib.services.ai 100% 86%
src.lib.stores 31% 27%
src.lib.types 19% 18%
src.lib.types.analytics 48% 0%
src.lib.utils 25% 20%
src.routes 2% 1%
src.routes..well-known.jwks.json 0% 0%
src.routes.about.integrity 0% 0%
src.routes.accountability.[id] 0% 0%
src.routes.api.(dev).dev-login 0% 0%
src.routes.api.admin.backfill-embeddings 89% 69%
src.routes.api.admin.reconcile-registrations 0% 0%
src.routes.api.agents.generate-subject 86% 71%
src.routes.api.agents.message-jobs.[jobId] 0% 0%
src.routes.api.agents.stream-decision-makers 0% 0%
src.routes.api.agents.stream-message 78% 75%
src.routes.api.agents.stream-subject 76% 63%
src.routes.api.agents.traces.[traceId] 0% 0%
src.routes.api.analytics.increment 0% 0%
src.routes.api.auth.passkey 100% 83%
src.routes.api.auth.passkey.authenticate 0% 0%
src.routes.api.auth.passkey.current 0% 0%
src.routes.api.auth.passkey.register 0% 0%
src.routes.api.automation.process 0% 0%
src.routes.api.billing.checkout 0% 0%
src.routes.api.billing.checkout-individual 0% 0%
src.routes.api.billing.portal 0% 0%
src.routes.api.blast.[blastId].dispatch-claim 0% 0%
src.routes.api.blast.[blastId].unsubscribe-tokens 0% 0%
src.routes.api.c.[slug].stats 0% 0%
src.routes.api.c.[slug].verify-district 0% 0%
src.routes.api.campaigns.[id].debate 0% 0%
src.routes.api.d.[campaignId].checkout 0% 0%
src.routes.api.d.[campaignId].stats 0% 0%
src.routes.api.debates.[debateId].ai-resolution 0% 0%
src.routes.api.debates.[debateId].appeal 0% 0%
src.routes.api.debates.[debateId].arguments 69% 67%
src.routes.api.debates.[debateId].claim 0% 0%
src.routes.api.debates.[debateId].commit 0% 0%
src.routes.api.debates.[debateId].cosign 0% 0%
src.routes.api.debates.[debateId].governance-resolve 0% 0%
src.routes.api.debates.[debateId].position-proof 0% 0%
src.routes.api.debates.[debateId].resolve 0% 0%
src.routes.api.debates.[debateId].reveal 0% 0%
src.routes.api.debates.[debateId].settle 0% 0%
src.routes.api.debates.[debateId].stream 0% 0%
src.routes.api.debates.by-template.[templateId] 0% 0%
src.routes.api.debates.create 0% 0%
src.routes.api.delegation 0% 0%
src.routes.api.delegation.[id] 0% 0%
src.routes.api.delegation.parse-policy 77% 73%
src.routes.api.delegation.review.[reviewId] 0% 0%
src.routes.api.deliveries.record 100% 100%
src.routes.api.dm.[id].scorecard 0% 0%
src.routes.api.dm.scorecard.compare 0% 0%
src.routes.api.e.[id].checkin 0% 0%
src.routes.api.e.[id].rsvp 0% 0%
src.routes.api.e.[id].stats 0% 0%
src.routes.api.email.confirm.[token] 0% 0%
src.routes.api.emails.report-bounce 0% 0%
src.routes.api.embed.scorecard.[id] 100% 61%
src.routes.api.embeddings.generate 96% 95%
src.routes.api.geographic.infer-scope 92% 100%
src.routes.api.geographic.resolve 0% 0%
src.routes.api.ground.bundle 0% 0%
src.routes.api.ground.restore-state 0% 0%
src.routes.api.ground.state 0% 0%
src.routes.api.ground.wrapper 0% 0%
src.routes.api.health 96% 82%
src.routes.api.identity.delete-blob 0% 100%
src.routes.api.identity.retrieve-blob 0% 100%
src.routes.api.identity.store-blob 0% 100%
src.routes.api.identity.verify-address 69% 56%
src.routes.api.identity.verify-mdl 0% 0%
src.routes.api.identity.verify-mdl.start 76% 69%
src.routes.api.identity.verify-mdl.verify 51% 38%
src.routes.api.internal.alert 0% 0%
src.routes.api.internal.anchor-incidents 0% 0%
src.routes.api.internal.anchor-proof 0% 0%
src.routes.api.internal.billing.report-usage 82% 30%
src.routes.api.internal.dev-login 94% 63%
src.routes.api.internal.emit-revocation 87% 87%
src.routes.api.internal.health.empty-tree-root 88% 82%
src.routes.api.internal.identity.mdl-readiness 92% 73%
src.routes.api.internal.metrics.client-event 88% 79%
src.routes.api.internal.revocation-root 0% 0%
src.routes.api.live 100% 100%
src.routes.api.location.ip-lookup 0% 0%
src.routes.api.location.resolve 0% 0%
src.routes.api.location.resolve-address 96% 75%
src.routes.api.location.search 0% 0%
src.routes.api.moderation.check 92% 92%
src.routes.api.moderation.personalization 0% 0%
src.routes.api.org 0% 0%
src.routes.api.org.[slug] 0% 0%
src.routes.api.org.[slug].alerts 0% 0%
src.routes.api.org.[slug].alerts.[id] 0% 0%
src.routes.api.org.[slug].bills.[billId].watch 0% 0%
src.routes.api.org.[slug].bills.browse 0% 0%
src.routes.api.org.[slug].bills.search 0% 0%
src.routes.api.org.[slug].bills.watching 0% 0%
src.routes.api.org.[slug].branding 0% 0%
src.routes.api.org.[slug].calls 0% 0%
src.routes.api.org.[slug].campaigns 0% 0%
src.routes.api.org.[slug].campaigns.[campaignId].receipts 0% 0%
src.routes.api.org.[slug].campaigns.[campaignId].responses 0% 0%
src.routes.api.org.[slug].campaigns.[campaignId].stream 0% 0%
src.routes.api.org.[slug].campaigns.targeting 0% 0%
src.routes.api.org.[slug].decision-makers.[dmId].activity 0% 0%
src.routes.api.org.[slug].decision-makers.[dmId].follow 0% 0%
src.routes.api.org.[slug].decision-makers.feed 0% 0%
src.routes.api.org.[slug].decision-makers.following 0% 0%
src.routes.api.org.[slug].dm.receipts 0% 0%
src.routes.api.org.[slug].dm.receipts.export.csv 0% 0%
src.routes.api.org.[slug].endorsements 0% 0%
src.routes.api.org.[slug].events 0% 0%
src.routes.api.org.[slug].events.[id] 0% 0%
src.routes.api.org.[slug].fundraising 0% 0%
src.routes.api.org.[slug].fundraising.[id] 0% 0%
src.routes.api.org.[slug].fundraising.[id].donors 0% 0%
src.routes.api.org.[slug].invites 0% 0%
src.routes.api.org.[slug].issue-domains 0% 0%
src.routes.api.org.[slug].issue-domains.rescore 0% 0%
src.routes.api.org.[slug].members 0% 0%
src.routes.api.org.[slug].networks 0% 0%
src.routes.api.org.[slug].networks.[networkId] 0% 0%
src.routes.api.org.[slug].networks.[networkId].accept 0% 0%
src.routes.api.org.[slug].networks.[networkId].decline 0% 0%
src.routes.api.org.[slug].networks.[networkId].invite 0% 0%
src.routes.api.org.[slug].networks.[networkId].leave 0% 0%
src.routes.api.org.[slug].networks.[networkId].members.[orgId] 0% 0%
src.routes.api.org.[slug].networks.[networkId].report 0% 0%
src.routes.api.org.[slug].profile 0% 0%
src.routes.api.org.[slug].representatives 0% 0%
src.routes.api.org.[slug].representatives.resolve 0% 0%
src.routes.api.org.[slug].scorecards 0% 0%
src.routes.api.org.[slug].scorecards.export 85% 84%
src.routes.api.org.[slug].segments 0% 0%
src.routes.api.org.[slug].ses-token 0% 0%
src.routes.api.org.[slug].settings.alert-preferences 0% 0%
src.routes.api.org.[slug].sms 0% 0%
src.routes.api.org.[slug].sms.[id] 0% 0%
src.routes.api.org.[slug].sms.[id].messages 0% 0%
src.routes.api.org.[slug].sms.audience-count 0% 0%
src.routes.api.org.[slug].workflows 89% 88%
src.routes.api.org.[slug].workflows.[id] 100% 90%
src.routes.api.org.[slug].workflows.[id].executions 100% 50%
src.routes.api.org.check-slug 0% 0%
src.routes.api.positions.batch-register 0% 0%
src.routes.api.positions.confirm-send 0% 0%
src.routes.api.positions.count.[templateId] 0% 0%
src.routes.api.positions.engagement-by-district.[templateId] 0% 0%
src.routes.api.positions.register 0% 0%
src.routes.api.proofs.revocation-witness 0% 0%
src.routes.api.shadow-atlas.engagement 0% 0%
src.routes.api.shadow-atlas.register 0% 0%
src.routes.api.submissions.[id].retry 0% 0%
src.routes.api.submissions.[id].status 0% 0%
src.routes.api.submissions.create 62% 54%
src.routes.api.tee.public-key 0% 0%
src.routes.api.tee.resolve 92% 89%
src.routes.api.templates 74% 68%
src.routes.api.templates.check-slug 0% 0%
src.routes.api.templates.search 0% 0%
src.routes.api.user.profile 0% 0%
src.routes.api.user.templates 0% 0%
src.routes.api.v1 100% 100%
src.routes.api.v1.activity 0% 0%
src.routes.api.v1.calls 0% 0%
src.routes.api.v1.campaigns 18% 9%
src.routes.api.v1.campaigns.[id] 0% 0%
src.routes.api.v1.campaigns.[id].actions 0% 0%
src.routes.api.v1.docs 67% 50%
src.routes.api.v1.donations 0% 0%
src.routes.api.v1.donations.[id] 0% 0%
src.routes.api.v1.events 0% 0%
src.routes.api.v1.events.[id] 0% 0%
src.routes.api.v1.keys 0% 0%
src.routes.api.v1.keys.[id] 0% 0%
src.routes.api.v1.networks 0% 0%
src.routes.api.v1.networks.[id] 0% 0%
src.routes.api.v1.networks.[id].stats 0% 0%
src.routes.api.v1.orgs 0% 0%
src.routes.api.v1.representatives 0% 0%
src.routes.api.v1.resolve-address 100% 89%
src.routes.api.v1.sms 0% 0%
src.routes.api.v1.stream 0% 0%
src.routes.api.v1.supporters 30% 18%
src.routes.api.v1.supporters.[id] 0% 0%
src.routes.api.v1.tags 0% 0%
src.routes.api.v1.tags.[id] 0% 0%
src.routes.api.v1.usage 0% 0%
src.routes.api.v1.webhooks 0% 0%
src.routes.api.v1.webhooks.[id] 0% 0%
src.routes.api.v1.webhooks.[id].rotate-secret 0% 0%
src.routes.api.v1.webhooks.[id].test-fire 0% 0%
src.routes.api.v1.workflows 100% 88%
src.routes.api.v1.workflows.[id] 100% 58%
src.routes.api.waitlist 0% 0%
src.routes.api.wallet 0% 0%
src.routes.api.wallet.balance 0% 0%
src.routes.api.wallet.connect 0% 0%
src.routes.api.wallet.disconnect 0% 0%
src.routes.api.wallet.near.sponsor 0% 0%
src.routes.api.wallet.nonce 0% 0%
src.routes.api.wallet.sponsor-userop 99% 77%
src.routes.api.wallet.status 0% 0%
src.routes.auth.coinbase 0% 0%
src.routes.auth.coinbase.callback 0% 0%
src.routes.auth.discord 0% 100%
src.routes.auth.discord.callback 0% 100%
src.routes.auth.facebook 0% 0%
src.routes.auth.facebook.callback 0% 100%
src.routes.auth.google 0% 0%
src.routes.auth.google.callback 0% 100%
src.routes.auth.linkedin 0% 0%
src.routes.auth.linkedin.callback 0% 100%
src.routes.auth.logout 0% 0%
src.routes.auth.prepare 0% 0%
src.routes.auth.twitter 0% 100%
src.routes.auth.twitter.callback 0% 100%
src.routes.browse 0% 0%
src.routes.c.[slug] 0% 0%
src.routes.d.[campaignId] 0% 0%
src.routes.deliberation 0% 0%
src.routes.developers 0% 0%
src.routes.directory 0% 0%
src.routes.dm.[id] 0% 0%
src.routes.dm.[id].scorecard 0% 0%
src.routes.e.[id] 0% 0%
src.routes.embed 0% 100%
src.routes.embed.campaign.[slug] 0% 0%
src.routes.governance 0% 0%
src.routes.help.verification 0% 0%
src.routes.migrate 0% 0%
src.routes.n.[slug] 0% 0%
src.routes.og.campaign.[id] 0% 0%
src.routes.og.integrity 0% 100%
src.routes.og.org 0% 100%
src.routes.og.org-for.[segment] 0% 0%
src.routes.org 0% 0%
src.routes.org.[slug] 0% 0%
src.routes.org.[slug].calls 0% 0%
src.routes.org.[slug].campaigns 0% 0%
src.routes.org.[slug].campaigns.[id] 0% 0%
src.routes.org.[slug].campaigns.[id].report 0% 0%
src.routes.org.[slug].campaigns.[id].report.email-html 0% 0%
src.routes.org.[slug].campaigns.new 0% 0%
src.routes.org.[slug].emails 0% 0%
src.routes.org.[slug].emails.[blastId] 0% 0%
src.routes.org.[slug].emails.[blastId].receipts 0% 0%
src.routes.org.[slug].emails.compose 0% 0%
src.routes.org.[slug].events 0% 0%
src.routes.org.[slug].events.[id] 0% 0%
src.routes.org.[slug].events.[id].attendees.csv 0% 0%
src.routes.org.[slug].events.[id].calendar.ics 0% 0%
src.routes.org.[slug].events.new 0% 0%
src.routes.org.[slug].fundraising 0% 0%
src.routes.org.[slug].fundraising.[id] 0% 0%
src.routes.org.[slug].fundraising.new 0% 0%
src.routes.org.[slug].legislation 0% 0%
src.routes.org.[slug].networks 0% 0%
src.routes.org.[slug].networks.[networkId] 0% 0%
src.routes.org.[slug].networks.new 0% 0%
src.routes.org.[slug].representatives 0% 0%
src.routes.org.[slug].representatives.[repId] 0% 0%
src.routes.org.[slug].results 0% 100%
src.routes.org.[slug].scorecards 0% 0%
src.routes.org.[slug].settings 0% 0%
src.routes.org.[slug].settings.webhooks 0% 0%
src.routes.org.[slug].sms 0% 0%
src.routes.org.[slug].sms.[id] 0% 0%
src.routes.org.[slug].sms.new 0% 0%
src.routes.org.[slug].studio 0% 0%
src.routes.org.[slug].supporters 0% 0%
src.routes.org.[slug].supporters.[id] 0% 0%
src.routes.org.[slug].supporters.import 0% 0%
src.routes.org.[slug].supporters.import.action-network 0% 100%
src.routes.org.[slug].supporters.import.platform-api 0% 0%
src.routes.org.[slug].workflows 0% 0%
src.routes.org.[slug].workflows.[id] 0% 0%
src.routes.org.[slug].workflows.new 0% 0%
src.routes.org.for 0% 100%
src.routes.org.for.agency-rulemaking 0% 0%
src.routes.org.for.local-government 0% 0%
src.routes.org.for.state-legislature 0% 0%
src.routes.org.invite.[token] 0% 0%
src.routes.org.new 0% 0%
src.routes.profile 5% 8%
src.routes.profile.receipts 0% 0%
src.routes.profile.security 5% 6%
src.routes.record 100% 100%
src.routes.record.vol-1.issue-1 0% 0%
src.routes.s.[slug] 2% 1%
src.routes.s.[slug].debate.[debateId] 0% 0%
src.routes.s.[slug].og-image 0% 0%
src.routes.settings.delegation 0% 0%
src.routes.spec 0% 0%
src.routes.template-modal.[slug] 19% 21%
src.routes.unsubscribe 0% 0%
src.routes.unsubscribe.[supporterId].[orgId].[token] 0% 0%
src.routes.v.[hash] 0% 0%
src.routes.verify.[hash] 0% 0%
src.routes.verify.receipt.[id] 0% 0%
Summary 21% (11032 / 51350) 20% (8059 / 40504)

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🪓 Brutalist Review

Chunk 1/6: Both Claude critics (native + glm-routed) independently converge on one headline: the moderation fail-open→fail-closed migration is incomplete. prompt-guard.ts now returns a {safe:false, score:-1} outage sentinel and documents that 'pipeline wrappers convert that sentinel into an availability error,' but moderateTemplate/moderatePersonalization in the (unchanged) moderation/index.ts never branch on score === -1 — verified: no such check exists, and the summary literally renders '-100.0%'. During a Groq outage every template creation and personalization send is rejected and mislabeled as a user prompt-injection attack, which is exactly the problem the commit claimed to solve. Both also agree handleAuth misclassifies a missing/short signing secret (a permanent config error) as 'transient,' silently logging out all users. Secondary agreements: per-request HMAC key import (perf), a documented-but-armed Redis rate-limiter TOCTOU, and that the reputation attribution / cookie crypto / Gemini envelope / source-cache binding are directionally sound. Notable disagreement I adjudicated: glm alleged an octal-IP SSRF bypass (0177.0.0.1); I discarded it — new URL() canonicalizes octal/hex IPv4 before the guard's regex runs, the native critic traced the same input as blocked, and the module's own test suite explicitly rejects those forms. Net: the primitives are defensively correct; the real, verified debt is in integration seams — an incomplete moderation migration and a config-error-as-transient catch — both of which read as 'finished' in the commit message but aren't.

Chunk 2/6: No review was produced. This pass was pinned to the single native critic codex, which failed authentication on two consecutive attempts (initial roast + one force_refresh retry) with an expired/rotated OAuth token, never analyzing the diff. The non-selected claude/glm client was also unavailable (HTTP 429 quota), but it is out of scope for this codex-pinned pass regardless. Zero findings are reported because the pinned critic yielded zero output; inventing findings or borrowing from an unselected critic would violate attribution and no-fabrication rules. Action required: refresh the CODEX_AUTH token (or set OPENAI_API_KEY) and re-run the roast to obtain an actual review of PR #77.

Chunk 3/6: This review pass was pinned to the single native critic agy. The codebase roast completed successfully at the transport level, but agy (Gemini 3.5 Flash, Medium) refused to analyze the diff, returning a generic safety refusal instead of a critique. It produced no findings, no file/line citations, and no verbatim quotes. Because the pass is restricted to the agy section, no findings are submitted. Recommend re-running the agy critic (e.g., with less adversarial phrasing) to obtain actionable output.

Chunk 4/6: Only the agy critic produced output (the glm/Claude-routed client was rate-limited at 429). agy's verdict is that this new session-cookie test suite is solid, well-structured defense-in-depth with a LOW technical-debt rating; every actionable item is a test-quality or coverage gap rather than a correctness or security defect. The strongest points: the single monolithic vector loop aborts on the first failure and masks subsequent regressions, and expiry/secret boundary conditions (exact now threshold, the 91-day cap edge, empty-string previousSecret, multi-byte UTF-8 secrets) go unexercised. One agy item — the flipSignatureChar critique — was discarded as self-contradictory (its suggested fix is identical to the code already present). Net: no blocking issues; a handful of low/nit coverage improvements.

Chunk 5/6: Only the custom glm (Claude-routed) critic ran this pass; there is no cross-CLI disagreement to weigh. Its headline judgment is that the PR's architecture is sound and shippable, but two operability/correctness issues should be addressed before the patterns propagate to chunk 2: (1) auth cookie-secret misconfiguration is discovered per-request and swallowed as a 'transient' error, which would mask a total auth outage, and (2) the Redis rate-limit store's reserve() is non-atomic across four round-trips, so the only cross-isolate store cannot actually enforce the limits in ROUTE_RATE_LIMITS. The remaining findings are genuine but low-severity hardening/consistency gaps. The security-positive changes (fail-closed prompt guard, session-field allowlist, constant-time cookie verification) were independently confirmed in the files.

Chunk 6/6: No analysis was produced. This review pass was pinned to the custom Claude-routed client 'glm' only (clis: []), and that client returned HTTP 429 (gateway over quota) on both the initial codebase roast and a forced-refresh retry. With the sole configured critic unavailable, there are zero verifiable findings to report on PR #77 chunk 2/2. Recommend re-running once the gateway quota resets.

Inline comments: 3 (1 🟠 high · 2 🟡 medium)

Per-CLI breakdown

✅ Claude (default, 632042ms)

Native Claude critic. Read the actual repository files end-to-end. Headline: the moderation fail-open→fail-closed flip shipped the sentinel and docstring contract but not the pipeline-wrapper conversion or the test, so a Groq outage rejects every template/personalization as a '-100.0%' prompt injection (High). Also flagged handleAuth silently disabling auth on secret misconfig (Medium) and per-request importKey (Low). Explicitly validated as SOUND: reputation transactional attribution, rate-limiter reserve() refactor, Gemini envelope/retry, SSRF guard (traced octal/hex/IPv4-mapped inputs as correctly blocked), and source-cache hash binding.

✅ glm (Claude) (glm-5.1, 760656ms)

Claude CLI routed to glm-5.1. Same headline (moderation migration half-finished, 4/6 paths honor the sentinel). Added: session-cookie missing-secret misclassified as 'transient' with cookie never cleared; reputation dual-derivation (getUserTrustTier from label vs createCampaignAction from actionCount); reputationStateForActionCount throwing fails the whole action; Redis TOCTOU trap; Gemini SDK retry-contract has no canary. Confirmed all login paths seal cookies correctly (no broken-auth regression) and the cookie crypto is clean. Raised an octal-IP SSRF differential (0177.0.0.1) that I discarded — refuted by new URL() canonicalization and the module's own passing tests.

glm (Claude-routed) failed pre-flight: gateway over quota / rate-limited (HTTP 429). Produced no critique.

glm (Claude-routed) reviewed PR #77 chunk 1/2 on the codebase domain. Endorsed the core architecture (HMAC-signed cookie envelope as a local pre-filter with Convex validateSession as authoritative), and praised projectSessionUser and the cookie crypto. Flagged two medium issues — auth-secret misconfiguration surfaced only as a swallowed 'transient' error, and RedisStore.reserve being non-atomic so the only global rate-limit store cannot enforce its advertised limits — plus several low-severity correctness/security gaps (unenforced SSRF contract, missing cross-secret separation, invalid cookies never cleared, streaming retry omission, terminalProviderError code-shape mismatch, dead engagementTier argument, and an unasserted reputationTier↔actionCount invariant).

Custom Claude-routed client 'glm' failed pre-flight on both the initial roast and a forced-refresh retry: gateway over quota / rate-limited (HTTP 429). No critique was produced, so no findings could be extracted for this pinned client.

❌ Codex (default, 30444ms)

codex native critic failed to run on both the initial roast and one force_refresh retry. Cause: 'CODEX OAuth token expired/rotated. Re-capture it (codex login → gh secret set CODEX_AUTH < ~/.codex/auth.json) or provision OPENAI_API_KEY.' The critic never reached the codebase, so it produced no critique to parse. No findings were emitted because none exist — fabricating findings would violate the hard rules. Re-run once codex credentials are refreshed.

✅ agy (Gemini 3.5 Flash (Medium), 55383ms)

The agy (Gemini 3.5 Flash) critic declined to review the PR #77 diff, returning a model-safety refusal ('Sorry, I cannot fulfill your request to analyze the provided code snippets or codebase for security vulnerabilities') and producing zero findings. No substantive, citeable observations were emitted, so there is nothing to anchor to the diff. A re-run with reworded, non-adversarial framing would likely be needed to obtain a usable agy review.

agy rated the suite's technical-debt interest rate LOW/well-controlled, calling it strong defense-in-depth coverage of sealing, parsing, timing-safe verification, and secret hygiene. Its actionable notes are test-quality gaps, not defects: the monolithic vector loop aborts on first failure, exact expiry boundaries are untested, empty/whitespace previousSecret and multi-byte UTF-8 secrets are uncovered, and the 1,000-iteration forgery loop is heavier than needed. One raised item (flipSignatureChar) was dropped as incoherent — its proposed fix is verbatim the existing code and the helper already always yields a differing first char.

Out-of-diff findings (20)

maintainability

  • 🟡 medium src/hooks.server.tsglm (Claude) [unanchored]: Missing/misconfigured cookie signing secret fails auth for everyone but is logged as 'transient'
  • 🔵 low convex/campaigns.tsglm (Claude) [sub-threshold]: engagementTier argument is dead for registered users and creates a two-path drift trap

correctness

  • 🟠 high src/lib/core/server/moderation/index.tsClaude [unanchored]: Concrete defect location: moderateTemplate/moderatePersonalization return 'prompt_injection' for the -1 outage sentinel (file unchanged by this PR)
  • 🔵 low convex/campaigns.tsglm (Claude) [sub-threshold]: reputationStateForActionCount throws on a non-integer actionCount, failing the whole verified action rather than just the tier
  • 🔵 low src/lib/core/agents/gemini-client.tsglm (Claude) [sub-threshold]: terminalProviderError matches only string codes while isRetryableGeminiError handles numeric gRPC codes
  • 🔵 low src/lib/core/agents/gemini-client.tsglm (Claude) [sub-threshold]: Streaming paths do not inherit the new one-shot transient retry policy
  • 🔵 low convex/lib/reputationTier.tsglm (Claude) [sub-threshold]: No invariant assertion that stored reputationTier equals reputationStateForActionCount(actionCount)

perf

  • 🔵 low src/lib/server/auth/session-cookie.tsClaude [sub-threshold]: Session-cookie HMAC key is re-imported via crypto.subtle.importKey on every authenticated request
  • 🔵 low src/lib/core/security/rate-limiter.tsClaude [sub-threshold]: RedisStore.reserve is TOCTOU across three round-trips — armed trap if anyone flips REDIS_URL in prod
  • 🔵 low src/hooks.server.tsglm (Claude) [sub-threshold]: Signature-invalid cookies are never deleted, re-paying HMAC verification on every request
  • ⚪ nit tests/unit/server/session-cookie.test.tsagy [sub-threshold]: 1,000-iteration sequential forgery loop adds test latency for marginal signal

design

  • 🔵 low convex/campaigns.tsglm (Claude) [sub-threshold]: Two divergent engagement-tier derivations for the same number; 'actionCount is the single source of truth' is not enforced at this read site

testing

  • 🔵 low src/lib/core/agents/gemini-client.tsglm (Claude) [sub-threshold]: Gemini cost-protection rests on an unpinned SDK contract: no canary that retryOptions.attempts is actually honored
  • 🔵 low tests/unit/server/session-cookie.test.tsagy [sub-threshold]: Monolithic vector loop aborts on first failure, hiding secondary regressions
  • 🔵 low tests/unit/server/session-cookie.test.tsagy [sub-threshold]: Expiry boundary never tested at the exact accept/reject thresholds
  • 🔵 low tests/unit/server/session-cookie.test.tsagy [sub-threshold]: Empty-string previousSecret path is untested and throws mid-verification instead of returning invalid
  • ⚪ nit tests/unit/server/session-cookie.test.tsagy [sub-threshold]: Secret-length assertions only exercise ASCII, never multi-byte UTF-8

security

  • 🔵 low src/lib/core/security/public-external-url.tsglm (Claude) [sub-threshold]: parsePublicHttpUrl is a literal-host check only; the 'no first-party fetch' SSRF contract is unenforced
  • 🔵 low src/lib/server/auth/session-cookie.tsglm (Claude) [sub-threshold]: Cookie signing secret is not asserted distinct from SESSION_CREATION_SECRET, contradicting .env.example
  • 🔵 low src/lib/core/server/moderation/prompt-guard.tsglm (Claude) [sub-threshold]: prompt-guard now fails closed (correct) — verify blast radius and downstream sentinel handling under Groq outage

Brutalist orchestrator schemaVersion=1 · context_id=3577cf83-a07f-4eee-b1c0-677370f0c9a6

* The guard protects agents from manipulation — it is not a user-blocking gate.
* Fail-closed design: if GROQ is down, rate-limited, or returns garbage,
* the function returns safe=false with score=-1 (sentinel). Pipeline wrappers
* convert that sentinel into an availability error, so unavailable moderation

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🪓 Brutalist — 2 critics, rollup: 🟠 high

[Claude 🟠 high] correctness — Fail-closed moderation sentinel is never converted to an availability error in the template/personalization pipeline — outages are mislabeled as user 'prompt injection'

The fail-open→fail-closed flip in unavailableResult (now { safe:false, score:-1 }) shipped with a docstring promising 'Pipeline wrappers convert that sentinel into an availability error,' but neither pipeline wrapper honors score === -1. In src/lib/core/server/moderation/index.ts, moderateTemplate (line 81) and moderatePersonalization (line 197) both do if (!promptGuard.safe) → return rejection_reason: 'prompt_injection' with summary: ...(score: ${(promptGuard.score * 100).toFixed(1)}%). On the -1 sentinel this (a) rejects EVERY template creation and personalization send during a Groq outage or the 403 model-permission block the code itself labels 'LAYER 1 MODERATION DISABLED', (b) attributes the outage to the user as a prompt-injection attack — indistinguishable from a real attack in metrics/rejection reason, which is the exact problem this commit claimed to solve, and (c) renders the user-facing string 'score: -100.0%'. The sibling layer classifySafety takes a different posture (it throws 'Safety moderation service unavailable'), so the two moderation layers disagree on outage handling and neither uses the new sentinel consistently. Fix: branch on score === -1 in both wrappers and emit a distinct rejection_reason: 'safety_unavailable' (or throw an availability error) mirroring the agent-stream endpoints.

[glm (Claude) 🟠 high] correctness — Moderation fail-closed migration is half-finished: 4/6 call paths honor the sentinel, template + personalization do not

The commit distinguishes moderation outages from detections in the four agent-stream endpoints (stream-message, generate-subject, stream-subject, stream-decision-makers all map score === -1503 SAFETY_UNAVAILABLE), but the moderation pipeline backing POST /api/templates was never taught the sentinel. moderateTemplate (index.ts:78-97) and moderatePersonalization (index.ts:195-211) return rejection_reason: 'prompt_injection' with a literal '... (score: -100.0%)' summary during any Groq outage. Result: fail-closed (safe) but indistinguishable from a real injection wave in metrics and in the reason shown to creators — precisely what the PR claimed to fix. The score === -1 sentinel is an untyped magic number threaded by convention across six files; promote it to a discriminated union ({status:'unavailable'} | {status:'scored', score}) so the half-finished migration becomes unrepresentable rather than one forgotten === -1 away in every new caller.

Comment thread src/hooks.server.ts
return resolve(event);
}

const { activeSecret, previousSecret } = resolveSessionCookieSecrets({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🪓 Brutalist — 2 critics, rollup: 🟡 medium

[Claude 🟡 medium] correctness — handleAuth silently disables authentication on cookie-secret misconfiguration

resolveSessionCookieSecrets throws (SESSION_COOKIE_SIGNING_SECRET_NOT_CONFIGURED / _TOO_SHORT) and is called inside the handleAuth try block (line 101). The catch (hooks.server.ts:212-220) treats ANY error as transient, downgrades the request to anonymous, and does NOT delete the cookie. So if the Pages signing secret is unset/short/mis-rotated in production, every request carrying a cookie is silently logged out, observable only in logs — never as a boot-time or request-time hard error. The unauthenticated path is unaffected (early return at :95-99), so impact is 'all logged-in users appear logged out,' not a security bypass. Given the envelope's whole purpose is that forged cookies never reach Convex, a misconfigured signing secret degrading to 'auth silently off' deserves a louder signal: validate at boot, or distinguish config errors from transient I/O errors in the catch.

[glm (Claude) 🟡 medium] maintainability — Missing signing secret is misclassified as 'transient' and the cookie is never cleared — a permanent deploy hazard logged as a hiccup

The throw from resolveSessionCookieSecrets (line 101) is caught at hooks.server.ts:212-221, which logs '[Hooks] Session validation error (transient)', nulls the user, and does not delete the cookie. A missing/short secret is permanent, not transient: every authenticated request from every existing session replays this misdiagnosed 'transient' error forever, silently logging out the whole user base while spamming logs with the wrong label. The 'don't delete on transient' instinct is correct for a Convex hiccup but actively harmful for a config error. Split *_NOT_CONFIGURED/*_TOO_SHORT from I/O errors in the catch so a missing Pages secret fails loudly instead of turning into a war-room.

const cutoff = timestamp - config.windowMs;

// Remove old entries first
await client.zRemRangeByScore(key, '-inf', cutoff.toString());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🪓 Brutalist — 1 critic, rollup: 🟡 medium

[glm (Claude) 🟡 medium] correctness — RedisStore.reserve is non-atomic — the only global store cannot enforce advertised rate limits

reserve() issues zRemRangeByScore → zRange → zAdd → expire as four independent round-trips (rate-limiter.ts:263-276). Under concurrency, N requests can all observe count < maxRequests and all zAdd, overshooting maxRequests. The added comment (:249-253) acknowledges this. This is the only store that could provide cross-isolate limits (InMemoryStore is per-isolate), so anyone who sets REDIS_URL expecting the ROUTE_RATE_LIMITS table (e.g. '3 req/hour' on /api/legislative/submit) to be globally enforced gets a materially weaker guarantee than the config implies. Fix: implement a single atomic Lua script (prune+count+conditional-add), or downgrade the config table's language so Redis is not presented as a hard global limiter it cannot be.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant