founder-schema 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.md +18 -15
  2. package/dist/access/access.d.ts +76 -0
  3. package/dist/access/access.d.ts.map +1 -0
  4. package/dist/access/access.js +66 -0
  5. package/dist/access/access.js.map +1 -0
  6. package/dist/access/permissions.d.ts +153 -0
  7. package/dist/access/permissions.d.ts.map +1 -0
  8. package/dist/access/permissions.js +80 -0
  9. package/dist/access/permissions.js.map +1 -0
  10. package/dist/access/roles.d.ts +71 -0
  11. package/dist/access/roles.d.ts.map +1 -0
  12. package/dist/access/roles.js +72 -0
  13. package/dist/access/roles.js.map +1 -0
  14. package/dist/chat.d.ts +4 -4
  15. package/dist/chat.d.ts.map +1 -1
  16. package/dist/chat.js +7 -2
  17. package/dist/chat.js.map +1 -1
  18. package/dist/core/common.d.ts +304 -0
  19. package/dist/core/common.d.ts.map +1 -0
  20. package/dist/core/common.js +340 -0
  21. package/dist/core/common.js.map +1 -0
  22. package/dist/core/errors.d.ts +105 -0
  23. package/dist/core/errors.d.ts.map +1 -0
  24. package/dist/core/errors.js +115 -0
  25. package/dist/core/errors.js.map +1 -0
  26. package/dist/core/limits.d.ts +99 -0
  27. package/dist/core/limits.d.ts.map +1 -0
  28. package/dist/core/limits.js +109 -0
  29. package/dist/core/limits.js.map +1 -0
  30. package/dist/core/responses.d.ts +195 -0
  31. package/dist/core/responses.d.ts.map +1 -0
  32. package/dist/core/responses.js +73 -0
  33. package/dist/core/responses.js.map +1 -0
  34. package/dist/core/validation.d.ts +144 -0
  35. package/dist/core/validation.d.ts.map +1 -0
  36. package/dist/core/validation.js +150 -0
  37. package/dist/core/validation.js.map +1 -0
  38. package/dist/domains/account/account.d.ts +40 -0
  39. package/dist/domains/account/account.d.ts.map +1 -0
  40. package/dist/domains/account/account.js +39 -0
  41. package/dist/domains/account/account.js.map +1 -0
  42. package/dist/domains/audit/audit.d.ts +169 -0
  43. package/dist/domains/audit/audit.d.ts.map +1 -0
  44. package/dist/domains/audit/audit.js +84 -0
  45. package/dist/domains/audit/audit.js.map +1 -0
  46. package/dist/domains/chat/chat.d.ts +187 -0
  47. package/dist/domains/chat/chat.d.ts.map +1 -0
  48. package/dist/domains/chat/chat.js +189 -0
  49. package/dist/domains/chat/chat.js.map +1 -0
  50. package/dist/domains/dashboard/dashboard.d.ts +48 -0
  51. package/dist/domains/dashboard/dashboard.d.ts.map +1 -0
  52. package/dist/domains/dashboard/dashboard.js +58 -0
  53. package/dist/domains/dashboard/dashboard.js.map +1 -0
  54. package/dist/domains/discover/discover.d.ts +245 -0
  55. package/dist/domains/discover/discover.d.ts.map +1 -0
  56. package/dist/domains/discover/discover.js +68 -0
  57. package/dist/domains/discover/discover.js.map +1 -0
  58. package/dist/domains/feed/feed.d.ts +1658 -0
  59. package/dist/domains/feed/feed.d.ts.map +1 -0
  60. package/dist/domains/feed/feed.js +623 -0
  61. package/dist/domains/feed/feed.js.map +1 -0
  62. package/dist/domains/members/members.d.ts +175 -0
  63. package/dist/domains/members/members.d.ts.map +1 -0
  64. package/dist/domains/members/members.js +106 -0
  65. package/dist/domains/members/members.js.map +1 -0
  66. package/dist/domains/moderation/moderation.d.ts +184 -0
  67. package/dist/domains/moderation/moderation.d.ts.map +1 -0
  68. package/dist/domains/moderation/moderation.js +90 -0
  69. package/dist/domains/moderation/moderation.js.map +1 -0
  70. package/dist/domains/onboarding/onboarding.d.ts +1018 -0
  71. package/dist/domains/onboarding/onboarding.d.ts.map +1 -0
  72. package/dist/domains/onboarding/onboarding.js +717 -0
  73. package/dist/domains/onboarding/onboarding.js.map +1 -0
  74. package/dist/domains/profile/founder-profile.d.ts +387 -0
  75. package/dist/domains/profile/founder-profile.d.ts.map +1 -0
  76. package/dist/domains/profile/founder-profile.js +180 -0
  77. package/dist/domains/profile/founder-profile.js.map +1 -0
  78. package/dist/domains/support/support.d.ts +305 -0
  79. package/dist/domains/support/support.d.ts.map +1 -0
  80. package/dist/domains/support/support.js +129 -0
  81. package/dist/domains/support/support.js.map +1 -0
  82. package/dist/domains/team/team.d.ts +79 -0
  83. package/dist/domains/team/team.d.ts.map +1 -0
  84. package/dist/domains/team/team.js +54 -0
  85. package/dist/domains/team/team.js.map +1 -0
  86. package/dist/domains/uploads/uploads.d.ts +113 -0
  87. package/dist/domains/uploads/uploads.d.ts.map +1 -0
  88. package/dist/domains/uploads/uploads.js +106 -0
  89. package/dist/domains/uploads/uploads.js.map +1 -0
  90. package/dist/env.d.ts +85 -0
  91. package/dist/env.d.ts.map +1 -0
  92. package/dist/env.js +150 -0
  93. package/dist/env.js.map +1 -0
  94. package/dist/index.d.ts +21 -12
  95. package/dist/index.d.ts.map +1 -1
  96. package/dist/index.js +29 -18
  97. package/dist/index.js.map +1 -1
  98. package/dist/onboarding.d.ts +58 -32
  99. package/dist/onboarding.d.ts.map +1 -1
  100. package/dist/onboarding.js +155 -32
  101. package/dist/onboarding.js.map +1 -1
  102. package/package.json +14 -2
@@ -0,0 +1,340 @@
1
+ /**
2
+ * founder-schema — Shared helpers and core enums
3
+ *
4
+ * The stable building blocks reused across every Founder Circle schema.
5
+ * Anything that both the backend and a client need to agree on lives here.
6
+ *
7
+ * @module founder-schema/core/common
8
+ */
9
+ import { z } from 'zod';
10
+ import { CITY_MAX, COUNTRY_MAX, LINK_URL_MAX, PAGE_SIZE_DEFAULT, PAGE_SIZE_MAX, STATE_MAX, } from './limits.js';
11
+ import { VALIDATION_MESSAGE } from './validation.js';
12
+ // ─── String helpers ──────────────────────────────────────────────────────────
13
+ /**
14
+ * Converts empty / whitespace-only strings to `undefined` so Zod `.optional()`
15
+ * treats them as "not provided".
16
+ *
17
+ * Why this exists: an HTML form submits `""` for a cleared field, and without
18
+ * this an optional field would be stored as an empty string rather than left
19
+ * alone. Jansathi shipped that bug in dating onboarding.
20
+ */
21
+ export const emptyStringToUndefined = (value) => {
22
+ if (typeof value === 'string' && value.trim() === '')
23
+ return undefined;
24
+ return value;
25
+ };
26
+ /** Optional trimmed string. Empty strings are coerced to `undefined`. */
27
+ export const optionalTrimmedString = (maxLength = 100) => z.preprocess(emptyStringToUndefined, z.string().trim().max(maxLength).optional());
28
+ /** A Mongo ObjectId as it crosses the wire: 24 lowercase hex characters. */
29
+ export const objectIdSchema = z
30
+ .string()
31
+ .regex(/^[0-9a-f]{24}$/i, VALIDATION_MESSAGE.invalidId)
32
+ .transform((v) => v.toLowerCase());
33
+ // ─── URLs ────────────────────────────────────────────────────────────────────
34
+ /**
35
+ * A URL safe to store and, after the consumer's own check, to render.
36
+ *
37
+ * Use this for EVERY member-supplied URL. Never bare `z.url()`: it accepts any
38
+ * scheme the URL parser accepts, so `javascript:alert(document.cookie)`,
39
+ * `data:text/html,<script>...`, `vbscript:` and `file:///etc/passwd` all pass.
40
+ * Each of those is stored XSS the moment a client renders it as an `href`, and
41
+ * `data:` is the same in an `<img src>`.
42
+ *
43
+ * `hostname` is restricted too, not only the scheme. A bare host like
44
+ * `localhost` or a literal IP is refused, which closes server-side request
45
+ * forgery: this product runs on EC2 with an instance role, so a member-supplied
46
+ * `http://169.254.169.254/latest/meta-data/` that anything server-side ever
47
+ * fetches is a credential read.
48
+ *
49
+ * Validating here is necessary and not sufficient. A consumer still treats a
50
+ * stored URL as untrusted at render time, because rows written before this
51
+ * existed were never checked.
52
+ *
53
+ * ⚠️ IF THIS EVER GROWS A HOST ALLOWLIST, compare the PARSED hostname for
54
+ * exact equality. Never `includes`, never `endsWith`, never a regex over the
55
+ * whole URL. Each of these is a real bypass of a naive "is it YouTube" check,
56
+ * and all three read as obviously fine:
57
+ *
58
+ * https://evil.example/?next=youtube.com/watch contains the string
59
+ * youtube.com.evil.example starts with it
60
+ * youtu.be.evil.example a suffix check passes it
61
+ *
62
+ * The same mistake in the opposite direction to the `javascript:` hole: there
63
+ * the scheme was too permissive, here the host check only LOOKS strict.
64
+ * (krish hit this rendering inline video, 2026-09-21, and handles it by parsing
65
+ * and comparing the hostname exactly.)
66
+ */
67
+ export const safeUrlSchema = z.url({
68
+ protocol: /^https?$/,
69
+ hostname: z.regexes.domain,
70
+ error: VALIDATION_MESSAGE.urlInvalid,
71
+ });
72
+ /**
73
+ * A URL a member TYPES: `safeUrlSchema` plus the `LINK_URL_MAX` ceiling, so
74
+ * every member URL carries the same cap and no consumer has to remember it.
75
+ *
76
+ * Two schemas, not one, because the cap is about what a person types and is
77
+ * wrong for what the server reads from elsewhere. Until 0.2.0 there was only
78
+ * this one, and it capped a link preview's CDN image URL too: one 314-character
79
+ * `imageUrl` would have failed a whole feed page.
80
+ */
81
+ export const httpUrlSchema = safeUrlSchema.max(LINK_URL_MAX);
82
+ // ─── Username ────────────────────────────────────────────────────────────────
83
+ /**
84
+ * Public handle. Derived by the server at onboarding, changeable later.
85
+ *
86
+ * Rules are deliberately narrow because the handle appears in URLs and in
87
+ * mentions: lowercase letters, digits, underscore and a single dot, 3 to 30
88
+ * characters, must start with a letter.
89
+ *
90
+ * This is the FORMAT, and it is what every read shape uses. A handle being
91
+ * CHOSEN must also not be reserved: that is `chosenUsernameSchema`.
92
+ */
93
+ export const USERNAME_MIN = 3;
94
+ export const USERNAME_MAX = 30;
95
+ export const usernameSchema = z
96
+ .string()
97
+ .trim()
98
+ .toLowerCase()
99
+ // Plain bounds: the client words them from the issue's own minimum/maximum.
100
+ .min(USERNAME_MIN)
101
+ .max(USERNAME_MAX)
102
+ .regex(/^[a-z][a-z0-9_]*(\.[a-z0-9_]+)?$/, VALIDATION_MESSAGE.usernameInvalid);
103
+ /**
104
+ * Handles this product keeps for itself: route names a handle would shadow,
105
+ * and words that would let a member pass as staff.
106
+ *
107
+ * ⚠ HERE, IN THE CONTRACT, since 0.2.0. It used to live in the backend so it
108
+ * could grow without a publish, and nothing ever read it: the server derived
109
+ * handles without checking it, so a member called "Support" was handed
110
+ * `support`. One list, read by the server when it derives a handle and by any
111
+ * form that lets a member choose one, is worth a publish per addition.
112
+ *
113
+ * Matched exactly, after lowercasing.
114
+ */
115
+ export const RESERVED_USERNAMES = [
116
+ 'admin',
117
+ 'about',
118
+ 'api',
119
+ 'chat',
120
+ 'discover',
121
+ 'feed',
122
+ 'founder',
123
+ 'foundercircle',
124
+ 'help',
125
+ 'home',
126
+ 'me',
127
+ 'meetup',
128
+ 'moderator',
129
+ 'onboarding',
130
+ 'settings',
131
+ 'staff',
132
+ 'superadmin',
133
+ 'support',
134
+ 'system',
135
+ 'team',
136
+ ];
137
+ const RESERVED = new Set(RESERVED_USERNAMES);
138
+ export function isReservedUsername(handle) {
139
+ return RESERVED.has(handle.trim().toLowerCase());
140
+ }
141
+ /**
142
+ * A handle a member is CHOOSING: the format, and not reserved.
143
+ *
144
+ * ⚠ Not used on read shapes, deliberately. A handle stored before the reserved
145
+ * check existed must still READ, or one member's old handle would fail every
146
+ * page that shows their name.
147
+ */
148
+ export const chosenUsernameSchema = usernameSchema.refine((u) => !isReservedUsername(u), {
149
+ error: VALIDATION_MESSAGE.usernameReserved,
150
+ });
151
+ // ─── Founder-facing enums ────────────────────────────────────────────────────
152
+ /**
153
+ * Stage of the founder's current venture.
154
+ *
155
+ * Ordered from earliest to latest so a range filter can compare by index. Add
156
+ * new stages at the END or the stored ordinal of existing rows shifts.
157
+ */
158
+ export const STARTUP_STAGE_VALUES = [
159
+ 'idea',
160
+ 'validation',
161
+ 'mvp',
162
+ 'pre_seed',
163
+ 'seed',
164
+ 'series_a_plus',
165
+ 'profitable',
166
+ 'exited',
167
+ ];
168
+ export const startupStageSchema = z.enum(STARTUP_STAGE_VALUES);
169
+ /**
170
+ * Industry the venture operates in. Kept as a closed list so Discover filters
171
+ * are a single indexed equality match rather than a text search.
172
+ */
173
+ export const INDUSTRY_VALUES = [
174
+ 'agritech',
175
+ 'ai_ml',
176
+ 'b2b_saas',
177
+ 'consumer',
178
+ 'climate',
179
+ 'd2c',
180
+ 'deeptech',
181
+ 'edtech',
182
+ 'fintech',
183
+ 'gaming',
184
+ 'healthtech',
185
+ 'logistics',
186
+ 'manufacturing',
187
+ 'marketplace',
188
+ 'media',
189
+ 'mobility',
190
+ 'proptech',
191
+ 'robotics',
192
+ 'services',
193
+ 'space',
194
+ 'other',
195
+ ];
196
+ export const industrySchema = z.enum(INDUSTRY_VALUES);
197
+ /**
198
+ * What the founder brings. Free-text skills were considered and rejected: two
199
+ * people typing "growth" and "Growth Marketing" would never match each other in
200
+ * a filter, and the Discover tab is the whole point of the product.
201
+ */
202
+ export const SKILL_VALUES = [
203
+ 'engineering',
204
+ 'product',
205
+ 'design',
206
+ 'data',
207
+ 'growth',
208
+ 'sales',
209
+ 'marketing',
210
+ 'operations',
211
+ 'finance',
212
+ 'legal',
213
+ 'hr_people',
214
+ 'supply_chain',
215
+ 'research',
216
+ 'fundraising',
217
+ 'community',
218
+ ];
219
+ export const skillSchema = z.enum(SKILL_VALUES);
220
+ /**
221
+ * What the member is here for. ONE list, replacing three.
222
+ *
223
+ * This was `lookingFor` with six values. The design draws eight "Goals &
224
+ * Passion" tiles and four more on the business step; all three were the same
225
+ * idea at three grains, and shipping them together would have meant nobody
226
+ * could say which one Discover filters on - the answer settled by whichever
227
+ * query got written first. Merged on hary's ruling, 2026-09-21, while there
228
+ * were still no rows in any database.
229
+ *
230
+ * ⚠️ Stored per member and used as a Mongoose enum. Append only; removing a
231
+ * value orphans documents already saved with it.
232
+ *
233
+ * ⚠️ `start_company` READS ODDLY next to its neighbours and is correct. It was
234
+ * queried on the grounds that it restates `venture.stage: 'idea'`; it does not,
235
+ * and the reason is worth keeping so nobody re-opens it. Inferring it from the
236
+ * venture works only for a member who ALREADY HAS a venture row, and somebody
237
+ * who wants to start a company is precisely the member with no venture at all,
238
+ * so there is nothing to infer from. It also cannot express a founder with a
239
+ * running company who wants to start another. Every other value is an ask of
240
+ * other people and this one is an ask of the product, which is the whole of the
241
+ * oddness. (hary, 2026-09-21.)
242
+ */
243
+ export const GOAL_VALUES = [
244
+ 'find_cofounder',
245
+ 'find_investors',
246
+ 'find_customers',
247
+ 'hiring',
248
+ 'partnerships',
249
+ 'mentorship',
250
+ 'grow_network',
251
+ 'business_ideas',
252
+ 'market_insights',
253
+ 'learn_startup',
254
+ 'start_company',
255
+ ];
256
+ export const goalSchema = z.enum(GOAL_VALUES);
257
+ // ─── Gender ──────────────────────────────────────────────────────────────────
258
+ /**
259
+ * Demographic only. Nothing here computes anything from it, unlike the sibling
260
+ * gym product where gender feeds body-metric maths.
261
+ *
262
+ * Takes the family's three values and ADDS one rather than renaming any: a
263
+ * network for founders should let somebody decline the question without having
264
+ * to describe themselves as "other".
265
+ */
266
+ export const GENDER_VALUES = ['male', 'female', 'other', 'prefer_not_to_say'];
267
+ export const genderSchema = z.enum(GENDER_VALUES);
268
+ // ─── Phone ───────────────────────────────────────────────────────────────────
269
+ /**
270
+ * Indian mobile number: ten raw digits, no country code, matching the standard
271
+ * used across this family. Display adds the `+91`.
272
+ *
273
+ * ⚠️ WHERE IT IS STORED IS NOT A SCHEMA QUESTION, and it has been ruled: the
274
+ * phone lives on the account mirror, never as a second copy on the profile.
275
+ * Two stored copies drift the first time somebody edits one, and the profile's
276
+ * would be the copy nobody keeps in sync.
277
+ */
278
+ export const phoneSchema = z
279
+ .string()
280
+ .trim()
281
+ .regex(/^[6-9]\d{9}$/, VALIDATION_MESSAGE.phoneInvalid);
282
+ // ─── Location ────────────────────────────────────────────────────────────────
283
+ /**
284
+ * Where the founder is based. City and country are what Discover filters on.
285
+ *
286
+ * ONE SHAPE for the profile, the Me tab's patch, onboarding's finish and the
287
+ * location suggestion. It was written out three times until 0.2.0, the same
288
+ * kind of copy that let the profile model lose Instagram.
289
+ *
290
+ * ⚠ `country` is REQUIRED and has NO default. A default here (it used to be
291
+ * `'India'`) is destructive on every write path: a founder in Berlin who
292
+ * corrects only their city would be moved to India by a value they never saw.
293
+ * A body that carries a location carries the whole location. Every stored
294
+ * location has a country, because the model requires one, so the read side
295
+ * loses nothing.
296
+ *
297
+ * This is a DECLARED location, not a GPS reading. Jansathi's rule applies
298
+ * here too: never write a declared location from a GPS capture.
299
+ */
300
+ export const locationSchema = z.object({
301
+ city: z.string().trim().min(1).max(CITY_MAX),
302
+ state: optionalTrimmedString(STATE_MAX),
303
+ country: z.string().trim().min(1).max(COUNTRY_MAX),
304
+ });
305
+ /**
306
+ * `GET /api/v1/location/areas/reverse?lat=&lng=`: plain decimal degrees.
307
+ *
308
+ * A QUERY, so coercion is right here: everything in a URL is a string. An
309
+ * emptied parameter is "missing", never 0, because 0,0 is a real place in the
310
+ * Gulf of Guinea and would be looked up rather than refused.
311
+ */
312
+ export const coordinatesQuerySchema = z.object({
313
+ lat: z.preprocess(emptyStringToUndefined, z.coerce.number().min(-90).max(90)),
314
+ lng: z.preprocess(emptyStringToUndefined, z.coerce.number().min(-180).max(180)),
315
+ });
316
+ /**
317
+ * The city these coordinates look like, for the member to CONFIRM, or `null`
318
+ * when the lookup could not place them confidently (or they are outside India,
319
+ * whose area data is all there is). A suggestion, never a write: only what the
320
+ * member then submits becomes their location.
321
+ */
322
+ export const suggestedLocationSchema = locationSchema.nullable();
323
+ // ─── Pagination ──────────────────────────────────────────────────────────────
324
+ /**
325
+ * Cursor pagination query. Feed and Discover both use it.
326
+ *
327
+ * Cursor, not page number: the community feed changes under the reader, and an
328
+ * offset would repeat or skip posts as new ones land.
329
+ */
330
+ export const paginationQuerySchema = z.object({
331
+ /**
332
+ * `?limit=` with nothing after it is treated as "not provided", not as a
333
+ * validation failure. A client that clears the control sends the empty
334
+ * string, and `z.coerce.number()` turns that into 0, which then fails
335
+ * `.min(1)`: the member sees a 400 for a page they never asked to size.
336
+ */
337
+ limit: z.preprocess(emptyStringToUndefined, z.coerce.number().int().min(1).max(PAGE_SIZE_MAX).default(PAGE_SIZE_DEFAULT)),
338
+ cursor: z.string().trim().min(1).optional(),
339
+ });
340
+ //# sourceMappingURL=common.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"common.js","sourceRoot":"","sources":["../../src/core/common.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * founder-schema — Shared helpers and core enums\n *\n * The stable building blocks reused across every Founder Circle schema.\n * Anything that both the backend and a client need to agree on lives here.\n *\n * @module founder-schema/core/common\n */\n\nimport { z } from 'zod';\nimport {\n CITY_MAX,\n COUNTRY_MAX,\n LINK_URL_MAX,\n PAGE_SIZE_DEFAULT,\n PAGE_SIZE_MAX,\n STATE_MAX,\n} from './limits.js';\nimport { VALIDATION_MESSAGE } from './validation.js';\n\n// ─── String helpers ──────────────────────────────────────────────────────────\n\n/**\n * Converts empty / whitespace-only strings to `undefined` so Zod `.optional()`\n * treats them as \"not provided\".\n *\n * Why this exists: an HTML form submits `\"\"` for a cleared field, and without\n * this an optional field would be stored as an empty string rather than left\n * alone. Jansathi shipped that bug in dating onboarding.\n */\nexport const emptyStringToUndefined = (value: unknown): unknown => {\n if (typeof value === 'string' && value.trim() === '') return undefined;\n return value;\n};\n\n/** Optional trimmed string. Empty strings are coerced to `undefined`. */\nexport const optionalTrimmedString = (maxLength = 100) =>\n z.preprocess(emptyStringToUndefined, z.string().trim().max(maxLength).optional());\n\n/** A Mongo ObjectId as it crosses the wire: 24 lowercase hex characters. */\nexport const objectIdSchema = z\n .string()\n .regex(/^[0-9a-f]{24}$/i, VALIDATION_MESSAGE.invalidId)\n .transform((v) => v.toLowerCase());\n/** Named for what it is on the wire, so it never collides with Mongoose's `ObjectId`. */\nexport type ObjectIdString = z.infer<typeof objectIdSchema>;\n\n// ─── URLs ────────────────────────────────────────────────────────────────────\n\n/**\n * A URL safe to store and, after the consumer's own check, to render.\n *\n * Use this for EVERY member-supplied URL. Never bare `z.url()`: it accepts any\n * scheme the URL parser accepts, so `javascript:alert(document.cookie)`,\n * `data:text/html,<script>...`, `vbscript:` and `file:///etc/passwd` all pass.\n * Each of those is stored XSS the moment a client renders it as an `href`, and\n * `data:` is the same in an `<img src>`.\n *\n * `hostname` is restricted too, not only the scheme. A bare host like\n * `localhost` or a literal IP is refused, which closes server-side request\n * forgery: this product runs on EC2 with an instance role, so a member-supplied\n * `http://169.254.169.254/latest/meta-data/` that anything server-side ever\n * fetches is a credential read.\n *\n * Validating here is necessary and not sufficient. A consumer still treats a\n * stored URL as untrusted at render time, because rows written before this\n * existed were never checked.\n *\n * ⚠️ IF THIS EVER GROWS A HOST ALLOWLIST, compare the PARSED hostname for\n * exact equality. Never `includes`, never `endsWith`, never a regex over the\n * whole URL. Each of these is a real bypass of a naive \"is it YouTube\" check,\n * and all three read as obviously fine:\n *\n * https://evil.example/?next=youtube.com/watch contains the string\n * youtube.com.evil.example starts with it\n * youtu.be.evil.example a suffix check passes it\n *\n * The same mistake in the opposite direction to the `javascript:` hole: there\n * the scheme was too permissive, here the host check only LOOKS strict.\n * (krish hit this rendering inline video, 2026-09-21, and handles it by parsing\n * and comparing the hostname exactly.)\n */\nexport const safeUrlSchema = z.url({\n protocol: /^https?$/,\n hostname: z.regexes.domain,\n error: VALIDATION_MESSAGE.urlInvalid,\n});\nexport type SafeUrl = z.infer<typeof safeUrlSchema>;\n\n/**\n * A URL a member TYPES: `safeUrlSchema` plus the `LINK_URL_MAX` ceiling, so\n * every member URL carries the same cap and no consumer has to remember it.\n *\n * Two schemas, not one, because the cap is about what a person types and is\n * wrong for what the server reads from elsewhere. Until 0.2.0 there was only\n * this one, and it capped a link preview's CDN image URL too: one 314-character\n * `imageUrl` would have failed a whole feed page.\n */\nexport const httpUrlSchema = safeUrlSchema.max(LINK_URL_MAX);\nexport type HttpUrl = z.infer<typeof httpUrlSchema>;\n\n// ─── Username ────────────────────────────────────────────────────────────────\n\n/**\n * Public handle. Derived by the server at onboarding, changeable later.\n *\n * Rules are deliberately narrow because the handle appears in URLs and in\n * mentions: lowercase letters, digits, underscore and a single dot, 3 to 30\n * characters, must start with a letter.\n *\n * This is the FORMAT, and it is what every read shape uses. A handle being\n * CHOSEN must also not be reserved: that is `chosenUsernameSchema`.\n */\nexport const USERNAME_MIN = 3;\nexport const USERNAME_MAX = 30;\n\nexport const usernameSchema = z\n .string()\n .trim()\n .toLowerCase()\n // Plain bounds: the client words them from the issue's own minimum/maximum.\n .min(USERNAME_MIN)\n .max(USERNAME_MAX)\n .regex(/^[a-z][a-z0-9_]*(\\.[a-z0-9_]+)?$/, VALIDATION_MESSAGE.usernameInvalid);\nexport type Username = z.infer<typeof usernameSchema>;\n\n/**\n * Handles this product keeps for itself: route names a handle would shadow,\n * and words that would let a member pass as staff.\n *\n * ⚠ HERE, IN THE CONTRACT, since 0.2.0. It used to live in the backend so it\n * could grow without a publish, and nothing ever read it: the server derived\n * handles without checking it, so a member called \"Support\" was handed\n * `support`. One list, read by the server when it derives a handle and by any\n * form that lets a member choose one, is worth a publish per addition.\n *\n * Matched exactly, after lowercasing.\n */\nexport const RESERVED_USERNAMES = [\n 'admin',\n 'about',\n 'api',\n 'chat',\n 'discover',\n 'feed',\n 'founder',\n 'foundercircle',\n 'help',\n 'home',\n 'me',\n 'meetup',\n 'moderator',\n 'onboarding',\n 'settings',\n 'staff',\n 'superadmin',\n 'support',\n 'system',\n 'team',\n] as const;\n\nconst RESERVED = new Set<string>(RESERVED_USERNAMES);\n\nexport function isReservedUsername(handle: string): boolean {\n return RESERVED.has(handle.trim().toLowerCase());\n}\n\n/**\n * A handle a member is CHOOSING: the format, and not reserved.\n *\n * ⚠ Not used on read shapes, deliberately. A handle stored before the reserved\n * check existed must still READ, or one member's old handle would fail every\n * page that shows their name.\n */\nexport const chosenUsernameSchema = usernameSchema.refine((u) => !isReservedUsername(u), {\n error: VALIDATION_MESSAGE.usernameReserved,\n});\n\n// ─── Founder-facing enums ────────────────────────────────────────────────────\n\n/**\n * Stage of the founder's current venture.\n *\n * Ordered from earliest to latest so a range filter can compare by index. Add\n * new stages at the END or the stored ordinal of existing rows shifts.\n */\nexport const STARTUP_STAGE_VALUES = [\n 'idea',\n 'validation',\n 'mvp',\n 'pre_seed',\n 'seed',\n 'series_a_plus',\n 'profitable',\n 'exited',\n] as const;\nexport const startupStageSchema = z.enum(STARTUP_STAGE_VALUES);\nexport type StartupStage = z.infer<typeof startupStageSchema>;\n\n/**\n * Industry the venture operates in. Kept as a closed list so Discover filters\n * are a single indexed equality match rather than a text search.\n */\nexport const INDUSTRY_VALUES = [\n 'agritech',\n 'ai_ml',\n 'b2b_saas',\n 'consumer',\n 'climate',\n 'd2c',\n 'deeptech',\n 'edtech',\n 'fintech',\n 'gaming',\n 'healthtech',\n 'logistics',\n 'manufacturing',\n 'marketplace',\n 'media',\n 'mobility',\n 'proptech',\n 'robotics',\n 'services',\n 'space',\n 'other',\n] as const;\nexport const industrySchema = z.enum(INDUSTRY_VALUES);\nexport type Industry = z.infer<typeof industrySchema>;\n\n/**\n * What the founder brings. Free-text skills were considered and rejected: two\n * people typing \"growth\" and \"Growth Marketing\" would never match each other in\n * a filter, and the Discover tab is the whole point of the product.\n */\nexport const SKILL_VALUES = [\n 'engineering',\n 'product',\n 'design',\n 'data',\n 'growth',\n 'sales',\n 'marketing',\n 'operations',\n 'finance',\n 'legal',\n 'hr_people',\n 'supply_chain',\n 'research',\n 'fundraising',\n 'community',\n] as const;\nexport const skillSchema = z.enum(SKILL_VALUES);\nexport type Skill = z.infer<typeof skillSchema>;\n\n/**\n * What the member is here for. ONE list, replacing three.\n *\n * This was `lookingFor` with six values. The design draws eight \"Goals &\n * Passion\" tiles and four more on the business step; all three were the same\n * idea at three grains, and shipping them together would have meant nobody\n * could say which one Discover filters on - the answer settled by whichever\n * query got written first. Merged on hary's ruling, 2026-09-21, while there\n * were still no rows in any database.\n *\n * ⚠️ Stored per member and used as a Mongoose enum. Append only; removing a\n * value orphans documents already saved with it.\n *\n * ⚠️ `start_company` READS ODDLY next to its neighbours and is correct. It was\n * queried on the grounds that it restates `venture.stage: 'idea'`; it does not,\n * and the reason is worth keeping so nobody re-opens it. Inferring it from the\n * venture works only for a member who ALREADY HAS a venture row, and somebody\n * who wants to start a company is precisely the member with no venture at all,\n * so there is nothing to infer from. It also cannot express a founder with a\n * running company who wants to start another. Every other value is an ask of\n * other people and this one is an ask of the product, which is the whole of the\n * oddness. (hary, 2026-09-21.)\n */\nexport const GOAL_VALUES = [\n 'find_cofounder',\n 'find_investors',\n 'find_customers',\n 'hiring',\n 'partnerships',\n 'mentorship',\n 'grow_network',\n 'business_ideas',\n 'market_insights',\n 'learn_startup',\n 'start_company',\n] as const;\nexport const goalSchema = z.enum(GOAL_VALUES);\nexport type Goal = z.infer<typeof goalSchema>;\n\n// ─── Gender ──────────────────────────────────────────────────────────────────\n\n/**\n * Demographic only. Nothing here computes anything from it, unlike the sibling\n * gym product where gender feeds body-metric maths.\n *\n * Takes the family's three values and ADDS one rather than renaming any: a\n * network for founders should let somebody decline the question without having\n * to describe themselves as \"other\".\n */\nexport const GENDER_VALUES = ['male', 'female', 'other', 'prefer_not_to_say'] as const;\nexport const genderSchema = z.enum(GENDER_VALUES);\nexport type Gender = z.infer<typeof genderSchema>;\n\n// ─── Phone ───────────────────────────────────────────────────────────────────\n\n/**\n * Indian mobile number: ten raw digits, no country code, matching the standard\n * used across this family. Display adds the `+91`.\n *\n * ⚠️ WHERE IT IS STORED IS NOT A SCHEMA QUESTION, and it has been ruled: the\n * phone lives on the account mirror, never as a second copy on the profile.\n * Two stored copies drift the first time somebody edits one, and the profile's\n * would be the copy nobody keeps in sync.\n */\nexport const phoneSchema = z\n .string()\n .trim()\n .regex(/^[6-9]\\d{9}$/, VALIDATION_MESSAGE.phoneInvalid);\nexport type Phone = z.infer<typeof phoneSchema>;\n\n// ─── Location ────────────────────────────────────────────────────────────────\n\n/**\n * Where the founder is based. City and country are what Discover filters on.\n *\n * ONE SHAPE for the profile, the Me tab's patch, onboarding's finish and the\n * location suggestion. It was written out three times until 0.2.0, the same\n * kind of copy that let the profile model lose Instagram.\n *\n * ⚠ `country` is REQUIRED and has NO default. A default here (it used to be\n * `'India'`) is destructive on every write path: a founder in Berlin who\n * corrects only their city would be moved to India by a value they never saw.\n * A body that carries a location carries the whole location. Every stored\n * location has a country, because the model requires one, so the read side\n * loses nothing.\n *\n * This is a DECLARED location, not a GPS reading. Jansathi's rule applies\n * here too: never write a declared location from a GPS capture.\n */\nexport const locationSchema = z.object({\n city: z.string().trim().min(1).max(CITY_MAX),\n state: optionalTrimmedString(STATE_MAX),\n country: z.string().trim().min(1).max(COUNTRY_MAX),\n});\nexport type FounderLocation = z.infer<typeof locationSchema>;\n\n/**\n * `GET /api/v1/location/areas/reverse?lat=&lng=`: plain decimal degrees.\n *\n * A QUERY, so coercion is right here: everything in a URL is a string. An\n * emptied parameter is \"missing\", never 0, because 0,0 is a real place in the\n * Gulf of Guinea and would be looked up rather than refused.\n */\nexport const coordinatesQuerySchema = z.object({\n lat: z.preprocess(emptyStringToUndefined, z.coerce.number().min(-90).max(90)),\n lng: z.preprocess(emptyStringToUndefined, z.coerce.number().min(-180).max(180)),\n});\nexport type CoordinatesQuery = z.infer<typeof coordinatesQuerySchema>;\nexport type CoordinatesQueryInput = z.input<typeof coordinatesQuerySchema>;\n\n/**\n * The city these coordinates look like, for the member to CONFIRM, or `null`\n * when the lookup could not place them confidently (or they are outside India,\n * whose area data is all there is). A suggestion, never a write: only what the\n * member then submits becomes their location.\n */\nexport const suggestedLocationSchema = locationSchema.nullable();\nexport type SuggestedLocation = z.infer<typeof suggestedLocationSchema>;\n\n// ─── Pagination ──────────────────────────────────────────────────────────────\n\n/**\n * Cursor pagination query. Feed and Discover both use it.\n *\n * Cursor, not page number: the community feed changes under the reader, and an\n * offset would repeat or skip posts as new ones land.\n */\nexport const paginationQuerySchema = z.object({\n /**\n * `?limit=` with nothing after it is treated as \"not provided\", not as a\n * validation failure. A client that clears the control sends the empty\n * string, and `z.coerce.number()` turns that into 0, which then fails\n * `.min(1)`: the member sees a 400 for a page they never asked to size.\n */\n limit: z.preprocess(\n emptyStringToUndefined,\n z.coerce.number().int().min(1).max(PAGE_SIZE_MAX).default(PAGE_SIZE_DEFAULT),\n ),\n cursor: z.string().trim().min(1).optional(),\n});\nexport type PaginationQuery = z.infer<typeof paginationQuerySchema>;\n/** What a client may put IN the URL: every field optional, numbers as strings. */\nexport type PaginationQueryInput = z.input<typeof paginationQuerySchema>;\n"]}
@@ -0,0 +1,105 @@
1
+ /**
2
+ * founder-schema — Every error code the API answers with
3
+ *
4
+ * THE CODE IS WHAT A CLIENT ACTS ON; the `message` beside it is English for
5
+ * logs and for a client too old to know the code. A client words each code in
6
+ * the member's language, and a test on the web side asserts every value here
7
+ * has a message in every locale it ships.
8
+ *
9
+ * Before 0.2.0 the backend kept this list to itself, and every domain refusal
10
+ * (a closed poll, a reply to a reply, the edit window) went out as
11
+ * `VALIDATION_ERROR` with an English sentence, so the only way for a client to
12
+ * tell them apart was to match on the wording.
13
+ *
14
+ * WHEN TO USE WHICH:
15
+ * - A request that does not match its schema: `VALIDATION_ERROR`, with every
16
+ * problem in `error.details.issues`.
17
+ * - A well-formed request the current STATE of things refuses: its own code
18
+ * below. These carry no issues; the code says it all.
19
+ *
20
+ * ⚠ Append freely; remove deliberately. A code removed here that an older
21
+ * server still sends fails the client's parse of the error envelope.
22
+ *
23
+ * @module founder-schema/core/errors
24
+ */
25
+ import { z } from 'zod';
26
+ /**
27
+ * As a tuple, the way `GENDER_VALUES` is, so a consumer can iterate it (a
28
+ * translation-coverage test does exactly that) and TypeScript keeps the union.
29
+ */
30
+ export declare const API_ERROR_CODE_VALUES: readonly ["INTERNAL_ERROR", "VALIDATION_ERROR", "NOT_FOUND", "CONFLICT", "RATE_LIMITED", "SERVICE_UNAVAILABLE", "UNAUTHORIZED", "INVALID_TOKEN", "TOKEN_EXPIRED", "SESSION_INVALIDATED", "FORBIDDEN", "ONBOARDING_INCOMPLETE", "ALREADY_ONBOARDED", "UNDER_AGE", "UPLOAD_NOT_ATTACHABLE", "UPLOAD_TOO_LARGE", "UPLOAD_WRONG_TYPE", "EDIT_WINDOW_CLOSED", "POLL_NOT_EDITABLE", "POLL_CLOSED", "POLL_CHOICE_INVALID", "REPLY_TOO_DEEP", "CANNOT_REPORT_OWN", "CHAT_WITH_SELF", "ACCOUNT_SUSPENDED", "ROLE_NOT_HELD", "MPIN_NOT_SET", "MPIN_ALREADY_SET", "MPIN_INCORRECT", "MPIN_LOCKED", "CANNOT_CHANGE_OWN_ROLES", "ROLE_NOT_GRANTABLE", "TICKET_FULL", "SAVED_REPLIES_FULL"];
31
+ export declare const apiErrorCodeSchema: z.ZodEnum<{
32
+ INTERNAL_ERROR: "INTERNAL_ERROR";
33
+ VALIDATION_ERROR: "VALIDATION_ERROR";
34
+ NOT_FOUND: "NOT_FOUND";
35
+ CONFLICT: "CONFLICT";
36
+ RATE_LIMITED: "RATE_LIMITED";
37
+ SERVICE_UNAVAILABLE: "SERVICE_UNAVAILABLE";
38
+ UNAUTHORIZED: "UNAUTHORIZED";
39
+ INVALID_TOKEN: "INVALID_TOKEN";
40
+ TOKEN_EXPIRED: "TOKEN_EXPIRED";
41
+ SESSION_INVALIDATED: "SESSION_INVALIDATED";
42
+ FORBIDDEN: "FORBIDDEN";
43
+ ONBOARDING_INCOMPLETE: "ONBOARDING_INCOMPLETE";
44
+ ALREADY_ONBOARDED: "ALREADY_ONBOARDED";
45
+ UNDER_AGE: "UNDER_AGE";
46
+ UPLOAD_NOT_ATTACHABLE: "UPLOAD_NOT_ATTACHABLE";
47
+ UPLOAD_TOO_LARGE: "UPLOAD_TOO_LARGE";
48
+ UPLOAD_WRONG_TYPE: "UPLOAD_WRONG_TYPE";
49
+ EDIT_WINDOW_CLOSED: "EDIT_WINDOW_CLOSED";
50
+ POLL_NOT_EDITABLE: "POLL_NOT_EDITABLE";
51
+ POLL_CLOSED: "POLL_CLOSED";
52
+ POLL_CHOICE_INVALID: "POLL_CHOICE_INVALID";
53
+ REPLY_TOO_DEEP: "REPLY_TOO_DEEP";
54
+ CANNOT_REPORT_OWN: "CANNOT_REPORT_OWN";
55
+ CHAT_WITH_SELF: "CHAT_WITH_SELF";
56
+ ACCOUNT_SUSPENDED: "ACCOUNT_SUSPENDED";
57
+ ROLE_NOT_HELD: "ROLE_NOT_HELD";
58
+ MPIN_NOT_SET: "MPIN_NOT_SET";
59
+ MPIN_ALREADY_SET: "MPIN_ALREADY_SET";
60
+ MPIN_INCORRECT: "MPIN_INCORRECT";
61
+ MPIN_LOCKED: "MPIN_LOCKED";
62
+ CANNOT_CHANGE_OWN_ROLES: "CANNOT_CHANGE_OWN_ROLES";
63
+ ROLE_NOT_GRANTABLE: "ROLE_NOT_GRANTABLE";
64
+ TICKET_FULL: "TICKET_FULL";
65
+ SAVED_REPLIES_FULL: "SAVED_REPLIES_FULL";
66
+ }>;
67
+ export type ApiErrorCode = z.infer<typeof apiErrorCodeSchema>;
68
+ /** The same codes by name: `API_ERROR_CODE.POLL_CLOSED`. */
69
+ export declare const API_ERROR_CODE: {
70
+ INTERNAL_ERROR: "INTERNAL_ERROR";
71
+ VALIDATION_ERROR: "VALIDATION_ERROR";
72
+ NOT_FOUND: "NOT_FOUND";
73
+ CONFLICT: "CONFLICT";
74
+ RATE_LIMITED: "RATE_LIMITED";
75
+ SERVICE_UNAVAILABLE: "SERVICE_UNAVAILABLE";
76
+ UNAUTHORIZED: "UNAUTHORIZED";
77
+ INVALID_TOKEN: "INVALID_TOKEN";
78
+ TOKEN_EXPIRED: "TOKEN_EXPIRED";
79
+ SESSION_INVALIDATED: "SESSION_INVALIDATED";
80
+ FORBIDDEN: "FORBIDDEN";
81
+ ONBOARDING_INCOMPLETE: "ONBOARDING_INCOMPLETE";
82
+ ALREADY_ONBOARDED: "ALREADY_ONBOARDED";
83
+ UNDER_AGE: "UNDER_AGE";
84
+ UPLOAD_NOT_ATTACHABLE: "UPLOAD_NOT_ATTACHABLE";
85
+ UPLOAD_TOO_LARGE: "UPLOAD_TOO_LARGE";
86
+ UPLOAD_WRONG_TYPE: "UPLOAD_WRONG_TYPE";
87
+ EDIT_WINDOW_CLOSED: "EDIT_WINDOW_CLOSED";
88
+ POLL_NOT_EDITABLE: "POLL_NOT_EDITABLE";
89
+ POLL_CLOSED: "POLL_CLOSED";
90
+ POLL_CHOICE_INVALID: "POLL_CHOICE_INVALID";
91
+ REPLY_TOO_DEEP: "REPLY_TOO_DEEP";
92
+ CANNOT_REPORT_OWN: "CANNOT_REPORT_OWN";
93
+ CHAT_WITH_SELF: "CHAT_WITH_SELF";
94
+ ACCOUNT_SUSPENDED: "ACCOUNT_SUSPENDED";
95
+ ROLE_NOT_HELD: "ROLE_NOT_HELD";
96
+ MPIN_NOT_SET: "MPIN_NOT_SET";
97
+ MPIN_ALREADY_SET: "MPIN_ALREADY_SET";
98
+ MPIN_INCORRECT: "MPIN_INCORRECT";
99
+ MPIN_LOCKED: "MPIN_LOCKED";
100
+ CANNOT_CHANGE_OWN_ROLES: "CANNOT_CHANGE_OWN_ROLES";
101
+ ROLE_NOT_GRANTABLE: "ROLE_NOT_GRANTABLE";
102
+ TICKET_FULL: "TICKET_FULL";
103
+ SAVED_REPLIES_FULL: "SAVED_REPLIES_FULL";
104
+ };
105
+ //# sourceMappingURL=errors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../src/core/errors.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB;;;GAGG;AACH,eAAO,MAAM,qBAAqB,+oBAyFxB,CAAC;AAEX,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAAgC,CAAC;AAChE,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAE9D,4DAA4D;AAC5D,eAAO,MAAM,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAA0B,CAAC"}
@@ -0,0 +1,115 @@
1
+ /**
2
+ * founder-schema — Every error code the API answers with
3
+ *
4
+ * THE CODE IS WHAT A CLIENT ACTS ON; the `message` beside it is English for
5
+ * logs and for a client too old to know the code. A client words each code in
6
+ * the member's language, and a test on the web side asserts every value here
7
+ * has a message in every locale it ships.
8
+ *
9
+ * Before 0.2.0 the backend kept this list to itself, and every domain refusal
10
+ * (a closed poll, a reply to a reply, the edit window) went out as
11
+ * `VALIDATION_ERROR` with an English sentence, so the only way for a client to
12
+ * tell them apart was to match on the wording.
13
+ *
14
+ * WHEN TO USE WHICH:
15
+ * - A request that does not match its schema: `VALIDATION_ERROR`, with every
16
+ * problem in `error.details.issues`.
17
+ * - A well-formed request the current STATE of things refuses: its own code
18
+ * below. These carry no issues; the code says it all.
19
+ *
20
+ * ⚠ Append freely; remove deliberately. A code removed here that an older
21
+ * server still sends fails the client's parse of the error envelope.
22
+ *
23
+ * @module founder-schema/core/errors
24
+ */
25
+ import { z } from 'zod';
26
+ /**
27
+ * As a tuple, the way `GENDER_VALUES` is, so a consumer can iterate it (a
28
+ * translation-coverage test does exactly that) and TypeScript keeps the union.
29
+ */
30
+ export const API_ERROR_CODE_VALUES = [
31
+ // ── Anything ──
32
+ /** A bug or an outage on our side. The message says nothing more on purpose. */
33
+ 'INTERNAL_ERROR',
34
+ /** The request did not match its schema. `details.issues` says where. */
35
+ 'VALIDATION_ERROR',
36
+ /** No such thing, or not one you may see. Deliberately the same answer. */
37
+ 'NOT_FOUND',
38
+ /** Two writes raced for something unique; retrying usually succeeds. */
39
+ 'CONFLICT',
40
+ /** Too many requests from this member or address. Wait, then retry. */
41
+ 'RATE_LIMITED',
42
+ /** Something we depend on (the chat server) is down or not switched on. */
43
+ 'SERVICE_UNAVAILABLE',
44
+ // ── Who you are ──
45
+ /** No credential at all. */
46
+ 'UNAUTHORIZED',
47
+ /** A credential that is not ours, or not readable. */
48
+ 'INVALID_TOKEN',
49
+ /** A credential that was ours and has expired. Refresh and retry. */
50
+ 'TOKEN_EXPIRED',
51
+ /** Signed out everywhere, or the account changed. Sign in again. */
52
+ 'SESSION_INVALIDATED',
53
+ /** Signed in, and not allowed to do this. */
54
+ 'FORBIDDEN',
55
+ // ── Onboarding ──
56
+ /** Finish onboarding first. The client routes here instead of showing an error. */
57
+ 'ONBOARDING_INCOMPLETE',
58
+ /** Onboarding is already finished; there is nothing to save or finish again. */
59
+ 'ALREADY_ONBOARDED',
60
+ /** Under `MIN_AGE_YEARS`. Refused at the draft and again at finish. */
61
+ 'UNDER_AGE',
62
+ // ── Uploads, at the moment one is attached ──
63
+ /** Not an upload of yours, or nothing was ever uploaded under that key. */
64
+ 'UPLOAD_NOT_ATTACHABLE',
65
+ /** Larger than `UPLOAD_MAX_BYTES`. */
66
+ 'UPLOAD_TOO_LARGE',
67
+ /** Not one of the image types this product accepts, or not an image at all. */
68
+ 'UPLOAD_WRONG_TYPE',
69
+ // ── The feed ──
70
+ /** The post is older than `EDIT_WINDOW_MS`. */
71
+ 'EDIT_WINDOW_CLOSED',
72
+ /** A poll is never editable: people have already voted on it. */
73
+ 'POLL_NOT_EDITABLE',
74
+ /** The poll has closed, by the server's clock. */
75
+ 'POLL_CLOSED',
76
+ /** No option, several on a single-choice poll, or an option that does not exist. */
77
+ 'POLL_CHOICE_INVALID',
78
+ /** A reply to a reply. One level of nesting only. */
79
+ 'REPLY_TOO_DEEP',
80
+ /** Reporting your own post, poll or comment. */
81
+ 'CANNOT_REPORT_OWN',
82
+ // ── Chat ──
83
+ /** Opening a conversation with yourself. */
84
+ 'CHAT_WITH_SELF',
85
+ // ── The account ──
86
+ /** Staff suspended this account: nothing in the product works until it is lifted. */
87
+ 'ACCOUNT_SUSPENDED',
88
+ // ── Roles and the MPIN ──
89
+ /**
90
+ * Switching to a role this person does not hold (or no longer holds), or
91
+ * setting an MPIN while holding no staff role at all.
92
+ */
93
+ 'ROLE_NOT_HELD',
94
+ /** A staff role needs an MPIN, and none has been set yet. Set one first. */
95
+ 'MPIN_NOT_SET',
96
+ /** An MPIN is set once; changing it needs the current one. */
97
+ 'MPIN_ALREADY_SET',
98
+ /** The MPIN is wrong. `details.attemptsLeft` says how many tries remain. */
99
+ 'MPIN_INCORRECT',
100
+ /** Too many wrong MPINs. `details.lockedUntil` says when to try again. */
101
+ 'MPIN_LOCKED',
102
+ /** Changing your own roles. Someone else has to. */
103
+ 'CANNOT_CHANGE_OWN_ROLES',
104
+ /** Granting or revoking a role the role you act as may not touch. */
105
+ 'ROLE_NOT_GRANTABLE',
106
+ // ── Support ──
107
+ /** The ticket holds `TICKET_MESSAGES_MAX` messages. Open a new one. */
108
+ 'TICKET_FULL',
109
+ /** The team keeps `SAVED_REPLIES_MAX` saved replies. Delete one first. */
110
+ 'SAVED_REPLIES_FULL',
111
+ ];
112
+ export const apiErrorCodeSchema = z.enum(API_ERROR_CODE_VALUES);
113
+ /** The same codes by name: `API_ERROR_CODE.POLL_CLOSED`. */
114
+ export const API_ERROR_CODE = apiErrorCodeSchema.enum;
115
+ //# sourceMappingURL=errors.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.js","sourceRoot":"","sources":["../../src/core/errors.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB;;;GAGG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG;IACnC,iBAAiB;IACjB,gFAAgF;IAChF,gBAAgB;IAChB,yEAAyE;IACzE,kBAAkB;IAClB,2EAA2E;IAC3E,WAAW;IACX,wEAAwE;IACxE,UAAU;IACV,uEAAuE;IACvE,cAAc;IACd,2EAA2E;IAC3E,qBAAqB;IAErB,oBAAoB;IACpB,4BAA4B;IAC5B,cAAc;IACd,sDAAsD;IACtD,eAAe;IACf,qEAAqE;IACrE,eAAe;IACf,oEAAoE;IACpE,qBAAqB;IACrB,6CAA6C;IAC7C,WAAW;IAEX,mBAAmB;IACnB,mFAAmF;IACnF,uBAAuB;IACvB,gFAAgF;IAChF,mBAAmB;IACnB,uEAAuE;IACvE,WAAW;IAEX,+CAA+C;IAC/C,2EAA2E;IAC3E,uBAAuB;IACvB,sCAAsC;IACtC,kBAAkB;IAClB,+EAA+E;IAC/E,mBAAmB;IAEnB,iBAAiB;IACjB,+CAA+C;IAC/C,oBAAoB;IACpB,iEAAiE;IACjE,mBAAmB;IACnB,kDAAkD;IAClD,aAAa;IACb,oFAAoF;IACpF,qBAAqB;IACrB,qDAAqD;IACrD,gBAAgB;IAChB,gDAAgD;IAChD,mBAAmB;IAEnB,aAAa;IACb,4CAA4C;IAC5C,gBAAgB;IAEhB,oBAAoB;IACpB,qFAAqF;IACrF,mBAAmB;IAEnB,2BAA2B;IAC3B;;;OAGG;IACH,eAAe;IACf,4EAA4E;IAC5E,cAAc;IACd,8DAA8D;IAC9D,kBAAkB;IAClB,4EAA4E;IAC5E,gBAAgB;IAChB,0EAA0E;IAC1E,aAAa;IACb,oDAAoD;IACpD,yBAAyB;IACzB,qEAAqE;IACrE,oBAAoB;IAEpB,gBAAgB;IAChB,uEAAuE;IACvE,aAAa;IACb,0EAA0E;IAC1E,oBAAoB;CACZ,CAAC;AAEX,MAAM,CAAC,MAAM,kBAAkB,GAAG,CAAC,CAAC,IAAI,CAAC,qBAAqB,CAAC,CAAC;AAGhE,4DAA4D;AAC5D,MAAM,CAAC,MAAM,cAAc,GAAG,kBAAkB,CAAC,IAAI,CAAC","sourcesContent":["/**\n * founder-schema — Every error code the API answers with\n *\n * THE CODE IS WHAT A CLIENT ACTS ON; the `message` beside it is English for\n * logs and for a client too old to know the code. A client words each code in\n * the member's language, and a test on the web side asserts every value here\n * has a message in every locale it ships.\n *\n * Before 0.2.0 the backend kept this list to itself, and every domain refusal\n * (a closed poll, a reply to a reply, the edit window) went out as\n * `VALIDATION_ERROR` with an English sentence, so the only way for a client to\n * tell them apart was to match on the wording.\n *\n * WHEN TO USE WHICH:\n * - A request that does not match its schema: `VALIDATION_ERROR`, with every\n * problem in `error.details.issues`.\n * - A well-formed request the current STATE of things refuses: its own code\n * below. These carry no issues; the code says it all.\n *\n * ⚠ Append freely; remove deliberately. A code removed here that an older\n * server still sends fails the client's parse of the error envelope.\n *\n * @module founder-schema/core/errors\n */\n\nimport { z } from 'zod';\n\n/**\n * As a tuple, the way `GENDER_VALUES` is, so a consumer can iterate it (a\n * translation-coverage test does exactly that) and TypeScript keeps the union.\n */\nexport const API_ERROR_CODE_VALUES = [\n // ── Anything ──\n /** A bug or an outage on our side. The message says nothing more on purpose. */\n 'INTERNAL_ERROR',\n /** The request did not match its schema. `details.issues` says where. */\n 'VALIDATION_ERROR',\n /** No such thing, or not one you may see. Deliberately the same answer. */\n 'NOT_FOUND',\n /** Two writes raced for something unique; retrying usually succeeds. */\n 'CONFLICT',\n /** Too many requests from this member or address. Wait, then retry. */\n 'RATE_LIMITED',\n /** Something we depend on (the chat server) is down or not switched on. */\n 'SERVICE_UNAVAILABLE',\n\n // ── Who you are ──\n /** No credential at all. */\n 'UNAUTHORIZED',\n /** A credential that is not ours, or not readable. */\n 'INVALID_TOKEN',\n /** A credential that was ours and has expired. Refresh and retry. */\n 'TOKEN_EXPIRED',\n /** Signed out everywhere, or the account changed. Sign in again. */\n 'SESSION_INVALIDATED',\n /** Signed in, and not allowed to do this. */\n 'FORBIDDEN',\n\n // ── Onboarding ──\n /** Finish onboarding first. The client routes here instead of showing an error. */\n 'ONBOARDING_INCOMPLETE',\n /** Onboarding is already finished; there is nothing to save or finish again. */\n 'ALREADY_ONBOARDED',\n /** Under `MIN_AGE_YEARS`. Refused at the draft and again at finish. */\n 'UNDER_AGE',\n\n // ── Uploads, at the moment one is attached ──\n /** Not an upload of yours, or nothing was ever uploaded under that key. */\n 'UPLOAD_NOT_ATTACHABLE',\n /** Larger than `UPLOAD_MAX_BYTES`. */\n 'UPLOAD_TOO_LARGE',\n /** Not one of the image types this product accepts, or not an image at all. */\n 'UPLOAD_WRONG_TYPE',\n\n // ── The feed ──\n /** The post is older than `EDIT_WINDOW_MS`. */\n 'EDIT_WINDOW_CLOSED',\n /** A poll is never editable: people have already voted on it. */\n 'POLL_NOT_EDITABLE',\n /** The poll has closed, by the server's clock. */\n 'POLL_CLOSED',\n /** No option, several on a single-choice poll, or an option that does not exist. */\n 'POLL_CHOICE_INVALID',\n /** A reply to a reply. One level of nesting only. */\n 'REPLY_TOO_DEEP',\n /** Reporting your own post, poll or comment. */\n 'CANNOT_REPORT_OWN',\n\n // ── Chat ──\n /** Opening a conversation with yourself. */\n 'CHAT_WITH_SELF',\n\n // ── The account ──\n /** Staff suspended this account: nothing in the product works until it is lifted. */\n 'ACCOUNT_SUSPENDED',\n\n // ── Roles and the MPIN ──\n /**\n * Switching to a role this person does not hold (or no longer holds), or\n * setting an MPIN while holding no staff role at all.\n */\n 'ROLE_NOT_HELD',\n /** A staff role needs an MPIN, and none has been set yet. Set one first. */\n 'MPIN_NOT_SET',\n /** An MPIN is set once; changing it needs the current one. */\n 'MPIN_ALREADY_SET',\n /** The MPIN is wrong. `details.attemptsLeft` says how many tries remain. */\n 'MPIN_INCORRECT',\n /** Too many wrong MPINs. `details.lockedUntil` says when to try again. */\n 'MPIN_LOCKED',\n /** Changing your own roles. Someone else has to. */\n 'CANNOT_CHANGE_OWN_ROLES',\n /** Granting or revoking a role the role you act as may not touch. */\n 'ROLE_NOT_GRANTABLE',\n\n // ── Support ──\n /** The ticket holds `TICKET_MESSAGES_MAX` messages. Open a new one. */\n 'TICKET_FULL',\n /** The team keeps `SAVED_REPLIES_MAX` saved replies. Delete one first. */\n 'SAVED_REPLIES_FULL',\n] as const;\n\nexport const apiErrorCodeSchema = z.enum(API_ERROR_CODE_VALUES);\nexport type ApiErrorCode = z.infer<typeof apiErrorCodeSchema>;\n\n/** The same codes by name: `API_ERROR_CODE.POLL_CLOSED`. */\nexport const API_ERROR_CODE = apiErrorCodeSchema.enum;\n"]}