File size: 3,802 Bytes
97ee7cb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
import { v } from "convex/values";

export const channelTypeValidator = v.union(
  v.literal("telegram"),
  v.literal("slack"),
  v.literal("email"),
  v.literal("discord"),
  v.literal("webhook"),
  v.literal("web_push"),
);

export const sensitivityValidator = v.union(
  v.literal("all"),
  v.literal("high"),
  v.literal("critical"),
);

export const quietHoursOverrideValidator = v.union(
  v.literal("critical_only"),
  v.literal("silence_all"),
  v.literal("batch_on_wake"),
);

export const digestModeValidator = v.union(
  v.literal("realtime"),
  v.literal("daily"),
  v.literal("twice_daily"),
  v.literal("weekly"),
);

export const proActivationStepIdValidator = v.union(
  v.literal("brief"),
  v.literal("alerts"),
  v.literal("power"),
);

export const CURRENT_PREFS_SCHEMA_VERSION = 1;

export const MAX_PREFS_BLOB_SIZE = 65536;

// Cloud preference writes are low-latency but public to authenticated Convex
// clients. Keep the authoritative guard inside the mutation so direct
// *.convex.site callers cannot bypass the Vercel edge limiter. Keep these
// values in lockstep with api/user-prefs.ts; tests/user-prefs-rate-limit.test.mts
// guards the duplicated Edge/Convex constants from drifting. Unlike the
// Redis-backed Vercel pre-gate, this mutation-local backstop intentionally
// fails closed if its own counter storage fails: failing open here would
// reopen the direct-Convex bypass this guard exists to close.
export const USER_PREFS_WRITE_RATE_LIMIT = 30;
export const USER_PREFS_WRITE_RATE_WINDOW_MS = 60 * 1000;

// Followed-countries (watchlist primitive) constants. See
// docs/plans/2026-05-02-001-feat-followed-countries-watchlist-primitive-plan.md
// (U12) for context. These three constants are consumed by the
// `convex/followedCountries.ts` mutations/queries (U13/U14).

// Free-tier ceiling on the number of countries a single user can follow.
// Enforced authoritatively in `followCountry` and `mergeAnonymousLocal`.
// PRO users (entitlement tier >= 1) are unlimited. The cap is also the
// "grandfather floor" — existing rows above the cap on downgrade are
// never auto-deleted; only NEW follows are blocked while free.
export const FREE_TIER_FOLLOW_LIMIT = 3;

// Defensive ceiling on `mergeAnonymousLocal({ countries })` input length.
// Prevents quadratic-cost abuse via patched localStorage shipping a
// pathological array. Inputs larger than this are rejected with
// `ConvexError({ kind: 'INPUT_TOO_LARGE' })`.
export const MAX_MERGE_INPUT = 100;

// Privacy floor for the public `countFollowers` query: counts strictly
// below this threshold are returned as `0` to public callers so a single
// follower can't be deanonymized via the count endpoint.
export const COUNTRY_COUNT_PRIVACY_FLOOR = 5;

// Number of pre-seeded lock shards in `followedCountriesShards`. Every
// `followCountry` / `unfollowCountry` / `mergeAnonymousLocal` mutation
// reads + patches the shard row at `userIdToShard(userId)` so Convex's
// per-document OCC serializes concurrent same-user mutations against an
// always-existing row. Without a pre-seeded shard table, the lazy-create
// path on `followedCountriesUserMeta` had a nested TOCTOU: two parallel
// first-ever mutations from the same brand-new user could both read
// `meta=undefined` and both INSERT, producing duplicate meta rows that
// break the `.unique()` read and re-open the cap-bypass window
// (Codex round-4 P0 v2).
//
// SHARD_COUNT is fixed at deploy time — changing it would require
// re-seeding ALL shard rows AND draining all in-flight mutations. Set
// once and treat as immutable. 64 is enough headroom that two random
// users colliding on the same shard is rare; collisions are correctness-
// preserving (just an extra serialization point), not a bug.
export const SHARD_COUNT = 64;