founder-schema 0.1.0 → 0.3.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 (58) hide show
  1. package/README.md +112 -40
  2. package/dist/account.d.ts +40 -0
  3. package/dist/account.d.ts.map +1 -0
  4. package/dist/account.js +39 -0
  5. package/dist/account.js.map +1 -0
  6. package/dist/chat.d.ts +63 -15
  7. package/dist/chat.d.ts.map +1 -1
  8. package/dist/chat.js +55 -17
  9. package/dist/chat.js.map +1 -1
  10. package/dist/common.d.ts +84 -8
  11. package/dist/common.d.ts.map +1 -1
  12. package/dist/common.js +109 -17
  13. package/dist/common.js.map +1 -1
  14. package/dist/discover.d.ts +98 -6
  15. package/dist/discover.d.ts.map +1 -1
  16. package/dist/discover.js +25 -11
  17. package/dist/discover.js.map +1 -1
  18. package/dist/errors.d.ts +85 -0
  19. package/dist/errors.d.ts.map +1 -0
  20. package/dist/errors.js +89 -0
  21. package/dist/errors.js.map +1 -0
  22. package/dist/feed.d.ts +400 -52
  23. package/dist/feed.d.ts.map +1 -1
  24. package/dist/feed.js +98 -68
  25. package/dist/feed.js.map +1 -1
  26. package/dist/founder-profile.d.ts +76 -46
  27. package/dist/founder-profile.d.ts.map +1 -1
  28. package/dist/founder-profile.js +46 -46
  29. package/dist/founder-profile.js.map +1 -1
  30. package/dist/index.d.ts +15 -9
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +22 -10
  33. package/dist/index.js.map +1 -1
  34. package/dist/limits.d.ts +76 -0
  35. package/dist/limits.d.ts.map +1 -0
  36. package/dist/limits.js +84 -0
  37. package/dist/limits.js.map +1 -0
  38. package/dist/onboarding.d.ts +407 -53
  39. package/dist/onboarding.d.ts.map +1 -1
  40. package/dist/onboarding.js +281 -234
  41. package/dist/onboarding.js.map +1 -1
  42. package/dist/responses/common.d.ts +122 -4
  43. package/dist/responses/common.d.ts.map +1 -1
  44. package/dist/responses/common.js +26 -3
  45. package/dist/responses/common.js.map +1 -1
  46. package/dist/responses/index.d.ts +1 -1
  47. package/dist/responses/index.d.ts.map +1 -1
  48. package/dist/responses/index.js +1 -1
  49. package/dist/responses/index.js.map +1 -1
  50. package/dist/uploads.d.ts +113 -0
  51. package/dist/uploads.d.ts.map +1 -0
  52. package/dist/uploads.js +106 -0
  53. package/dist/uploads.js.map +1 -0
  54. package/dist/validation.d.ts +136 -0
  55. package/dist/validation.d.ts.map +1 -0
  56. package/dist/validation.js +142 -0
  57. package/dist/validation.js.map +1 -0
  58. package/package.json +4 -3
@@ -0,0 +1,106 @@
1
+ /**
2
+ * founder-schema — Uploads: the key a client carries, and asking for a slot
3
+ *
4
+ * Its own module so that both `feed` (a post's media) and `onboarding` (the
5
+ * avatar) can use the one key rule. It lived in `feed` until 0.2.0, and
6
+ * `onboarding` could not import it from there without a cycle (`feed` imports
7
+ * `onboarding` for the topic list), so the avatar key was a bare string and
8
+ * `'../../etc/passwd'` passed.
9
+ *
10
+ * Imports only the limits and the message keys.
11
+ *
12
+ * @module founder-schema/uploads
13
+ */
14
+ import { z } from 'zod';
15
+ import { UPLOAD_MAX_BYTES } from './limits.js';
16
+ import { VALIDATION_MESSAGE } from './validation.js';
17
+ /**
18
+ * ⚠ IMAGES ONLY, and that is a product decision rather than an oversight.
19
+ *
20
+ * The asymmetry was real and was raised: `feedVideoSchema` is in the read union
21
+ * and a card can render a video, while nothing could ever upload one. The user
22
+ * settled it on 2026-09-21: members upload images, and share video by putting a
23
+ * link in the post text, which the client renders inline. So the minter stays
24
+ * images-only by intent.
25
+ *
26
+ * `feedVideoSchema` is deliberately KEPT. Widening the write path later is
27
+ * additive; removing video from the read union once a client handles it is not.
28
+ * A test named for refusing a video key holds this visible, so whoever adds
29
+ * video uploads has to delete a test that says out loud why it exists.
30
+ */
31
+ export const UPLOAD_KEY_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp', 'gif'];
32
+ export const uploadKeyExtensionSchema = z.enum(UPLOAD_KEY_EXTENSIONS);
33
+ /**
34
+ * The content type each extension is signed for, and the only types an
35
+ * attached object may turn out to have. The server signs the PUT with this
36
+ * type and refuses to attach an object whose stored type is anything else.
37
+ */
38
+ export const UPLOAD_CONTENT_TYPES = {
39
+ jpg: 'image/jpeg',
40
+ jpeg: 'image/jpeg',
41
+ png: 'image/png',
42
+ webp: 'image/webp',
43
+ gif: 'image/gif',
44
+ };
45
+ export const UPLOAD_KEY_MAX_CHARS = 300;
46
+ /**
47
+ * An attachment on the way IN is a KEY, never a URL. FORMAT, minted
48
+ * server-side and never by a client:
49
+ *
50
+ * media/<userId>/<uuid>.<ext>
51
+ *
52
+ * Built from `UPLOAD_KEY_EXTENSIONS`, so the extension list exists once. The
53
+ * backend reads the owner segment with this same pattern.
54
+ *
55
+ * ⚠ THIS IS THE FIRST OF THREE CHECKS AND THE WEAKEST. It proves the key is
56
+ * well FORMED. It cannot prove the two things that actually matter:
57
+ *
58
+ * 1. OWNERSHIP. The `userId` segment is what makes "is this your object"
59
+ * checkable, and the backend compares it against the authenticated caller.
60
+ * A pattern happily accepts a well-formed key belonging to somebody else.
61
+ * 2. EXISTENCE. The backend reads the object before attaching it, so a key
62
+ * for an object nobody uploaded cannot be committed.
63
+ */
64
+ export const UPLOAD_KEY_PATTERN = new RegExp(`^media/[0-9a-f]{24}/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\.(${UPLOAD_KEY_EXTENSIONS.join('|')})$`);
65
+ /**
66
+ * The length ceiling and the traversal refusal are redundant against the
67
+ * pattern, which is anchored and admits no dots outside the extension. They are
68
+ * kept on purpose: they are what still holds if the pattern is ever loosened.
69
+ */
70
+ export const uploadKeySchema = z
71
+ .string()
72
+ .trim()
73
+ .min(1)
74
+ .max(UPLOAD_KEY_MAX_CHARS)
75
+ .regex(UPLOAD_KEY_PATTERN, VALIDATION_MESSAGE.uploadKeyInvalid)
76
+ .refine((k) => !k.includes('..'), { error: VALIDATION_MESSAGE.uploadKeyInvalid });
77
+ /**
78
+ * Asking for an upload slot: `POST /api/v1/uploads/presign`.
79
+ *
80
+ * The server chooses the key (so it carries the caller's id), the content type
81
+ * and the expiry; a client that could name its own key could aim it at
82
+ * someone else's folder.
83
+ *
84
+ * `contentLength` is the file's exact size in bytes, and it is SIGNED INTO the
85
+ * URL: storage refuses a PUT whose body is any other size. Until 0.2.0 nothing
86
+ * capped the size at upload, so a member could put 40 MB into the bucket and
87
+ * only learn at attach time that it was refused.
88
+ */
89
+ export const uploadPresignBodySchema = z.object({
90
+ extension: uploadKeyExtensionSchema,
91
+ contentLength: z.number().int().min(1).max(UPLOAD_MAX_BYTES),
92
+ });
93
+ /**
94
+ * The upload slot. `headers` are part of the signature, not advice: send every
95
+ * one of them verbatim on the PUT, with a body of exactly `contentLength`
96
+ * bytes, or storage answers with a signature mismatch. `key` is what a create
97
+ * body then carries, exactly as given.
98
+ */
99
+ export const uploadPresignResponseSchema = z.object({
100
+ key: uploadKeySchema,
101
+ url: z.url(),
102
+ headers: z.record(z.string(), z.string()),
103
+ /** Seconds the URL stays valid. */
104
+ expiresIn: z.number().int().positive(),
105
+ });
106
+ //# sourceMappingURL=uploads.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"uploads.js","sourceRoot":"","sources":["../src/uploads.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAC/C,OAAO,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAC;AAErD;;;;;;;;;;;;;GAaG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,CAAU,CAAC;AACpF,MAAM,CAAC,MAAM,wBAAwB,GAAG,CAAC,CAAC,IAAI,CAAC,qBAAqB,CAAC,CAAC;AAGtE;;;;GAIG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG;IAClC,GAAG,EAAE,YAAY;IACjB,IAAI,EAAE,YAAY;IAClB,GAAG,EAAE,WAAW;IAChB,IAAI,EAAE,YAAY;IAClB,GAAG,EAAE,WAAW;CACgD,CAAC;AAEnE,MAAM,CAAC,MAAM,oBAAoB,GAAG,GAAG,CAAC;AAExC;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG,IAAI,MAAM,CAC1C,uFAAuF,qBAAqB,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,CAC3H,CAAC;AAEF;;;;GAIG;AACH,MAAM,CAAC,MAAM,eAAe,GAAG,CAAC;KAC7B,MAAM,EAAE;KACR,IAAI,EAAE;KACN,GAAG,CAAC,CAAC,CAAC;KACN,GAAG,CAAC,oBAAoB,CAAC;KACzB,KAAK,CAAC,kBAAkB,EAAE,kBAAkB,CAAC,gBAAgB,CAAC;KAC9D,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,EAAE,KAAK,EAAE,kBAAkB,CAAC,gBAAgB,EAAE,CAAC,CAAC;AAGpF;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,MAAM,uBAAuB,GAAG,CAAC,CAAC,MAAM,CAAC;IAC9C,SAAS,EAAE,wBAAwB;IACnC,aAAa,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,gBAAgB,CAAC;CAC7D,CAAC,CAAC;AAGH;;;;;GAKG;AACH,MAAM,CAAC,MAAM,2BAA2B,GAAG,CAAC,CAAC,MAAM,CAAC;IAClD,GAAG,EAAE,eAAe;IACpB,GAAG,EAAE,CAAC,CAAC,GAAG,EAAE;IACZ,OAAO,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC;IACzC,mCAAmC;IACnC,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,QAAQ,EAAE;CACvC,CAAC,CAAC","sourcesContent":["/**\n * founder-schema — Uploads: the key a client carries, and asking for a slot\n *\n * Its own module so that both `feed` (a post's media) and `onboarding` (the\n * avatar) can use the one key rule. It lived in `feed` until 0.2.0, and\n * `onboarding` could not import it from there without a cycle (`feed` imports\n * `onboarding` for the topic list), so the avatar key was a bare string and\n * `'../../etc/passwd'` passed.\n *\n * Imports only the limits and the message keys.\n *\n * @module founder-schema/uploads\n */\n\nimport { z } from 'zod';\nimport { UPLOAD_MAX_BYTES } from './limits.js';\nimport { VALIDATION_MESSAGE } from './validation.js';\n\n/**\n * ⚠ IMAGES ONLY, and that is a product decision rather than an oversight.\n *\n * The asymmetry was real and was raised: `feedVideoSchema` is in the read union\n * and a card can render a video, while nothing could ever upload one. The user\n * settled it on 2026-09-21: members upload images, and share video by putting a\n * link in the post text, which the client renders inline. So the minter stays\n * images-only by intent.\n *\n * `feedVideoSchema` is deliberately KEPT. Widening the write path later is\n * additive; removing video from the read union once a client handles it is not.\n * A test named for refusing a video key holds this visible, so whoever adds\n * video uploads has to delete a test that says out loud why it exists.\n */\nexport const UPLOAD_KEY_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp', 'gif'] as const;\nexport const uploadKeyExtensionSchema = z.enum(UPLOAD_KEY_EXTENSIONS);\nexport type UploadKeyExtension = z.infer<typeof uploadKeyExtensionSchema>;\n\n/**\n * The content type each extension is signed for, and the only types an\n * attached object may turn out to have. The server signs the PUT with this\n * type and refuses to attach an object whose stored type is anything else.\n */\nexport const UPLOAD_CONTENT_TYPES = {\n jpg: 'image/jpeg',\n jpeg: 'image/jpeg',\n png: 'image/png',\n webp: 'image/webp',\n gif: 'image/gif',\n} as const satisfies Record<UploadKeyExtension, `image/${string}`>;\n\nexport const UPLOAD_KEY_MAX_CHARS = 300;\n\n/**\n * An attachment on the way IN is a KEY, never a URL. FORMAT, minted\n * server-side and never by a client:\n *\n * media/<userId>/<uuid>.<ext>\n *\n * Built from `UPLOAD_KEY_EXTENSIONS`, so the extension list exists once. The\n * backend reads the owner segment with this same pattern.\n *\n * ⚠ THIS IS THE FIRST OF THREE CHECKS AND THE WEAKEST. It proves the key is\n * well FORMED. It cannot prove the two things that actually matter:\n *\n * 1. OWNERSHIP. The `userId` segment is what makes \"is this your object\"\n * checkable, and the backend compares it against the authenticated caller.\n * A pattern happily accepts a well-formed key belonging to somebody else.\n * 2. EXISTENCE. The backend reads the object before attaching it, so a key\n * for an object nobody uploaded cannot be committed.\n */\nexport const UPLOAD_KEY_PATTERN = new RegExp(\n `^media/[0-9a-f]{24}/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\\\.(${UPLOAD_KEY_EXTENSIONS.join('|')})$`,\n);\n\n/**\n * The length ceiling and the traversal refusal are redundant against the\n * pattern, which is anchored and admits no dots outside the extension. They are\n * kept on purpose: they are what still holds if the pattern is ever loosened.\n */\nexport const uploadKeySchema = z\n .string()\n .trim()\n .min(1)\n .max(UPLOAD_KEY_MAX_CHARS)\n .regex(UPLOAD_KEY_PATTERN, VALIDATION_MESSAGE.uploadKeyInvalid)\n .refine((k) => !k.includes('..'), { error: VALIDATION_MESSAGE.uploadKeyInvalid });\nexport type UploadKey = z.infer<typeof uploadKeySchema>;\n\n/**\n * Asking for an upload slot: `POST /api/v1/uploads/presign`.\n *\n * The server chooses the key (so it carries the caller's id), the content type\n * and the expiry; a client that could name its own key could aim it at\n * someone else's folder.\n *\n * `contentLength` is the file's exact size in bytes, and it is SIGNED INTO the\n * URL: storage refuses a PUT whose body is any other size. Until 0.2.0 nothing\n * capped the size at upload, so a member could put 40 MB into the bucket and\n * only learn at attach time that it was refused.\n */\nexport const uploadPresignBodySchema = z.object({\n extension: uploadKeyExtensionSchema,\n contentLength: z.number().int().min(1).max(UPLOAD_MAX_BYTES),\n});\nexport type UploadPresignBody = z.infer<typeof uploadPresignBodySchema>;\n\n/**\n * The upload slot. `headers` are part of the signature, not advice: send every\n * one of them verbatim on the PUT, with a body of exactly `contentLength`\n * bytes, or storage answers with a signature mismatch. `key` is what a create\n * body then carries, exactly as given.\n */\nexport const uploadPresignResponseSchema = z.object({\n key: uploadKeySchema,\n url: z.url(),\n headers: z.record(z.string(), z.string()),\n /** Seconds the URL stays valid. */\n expiresIn: z.number().int().positive(),\n});\nexport type UploadPresignResponse = z.infer<typeof uploadPresignResponseSchema>;\n"]}
@@ -0,0 +1,136 @@
1
+ /**
2
+ * founder-schema — How a validation failure is described, on both sides
3
+ *
4
+ * TWO RULES, and they are what let the web client show any error, its own or
5
+ * the server's, on the right field in the member's language:
6
+ *
7
+ * 1. A CUSTOM MESSAGE IS A KEY, NEVER A SENTENCE. Where a schema needs its own
8
+ * message (a refine, a regex), the message is one of `VALIDATION_MESSAGE`,
9
+ * such as `phoneInvalid`. The client turns the key into text in English or
10
+ * Hindi. Before 0.2.0 these were English sentences, so a Hindi screen showed
11
+ * an English error, and a client that wanted to translate one had to match
12
+ * on the wording.
13
+ *
14
+ * Plain bounds carry NO custom message. `.min(1)` and `.max(80)` produce
15
+ * Zod's own `too_small` / `too_big` issues, which already say which bound
16
+ * and what it was (`minimum`, `maximum`, `origin`), and the client words
17
+ * them from those. A hand-written "at most 80 characters" would only
18
+ * restate the number in one language.
19
+ *
20
+ * 2. THE SERVER SENDS EVERY ISSUE, IN THIS SHAPE. A 400 answers with
21
+ * `error.details.issues`, built by `toValidationIssues`, so the client can
22
+ * put each one under its field. Until 0.2.0 the backend sent only the
23
+ * first issue's message, with no path at all.
24
+ *
25
+ * ⚠️ `toValidationIssues` COPIES A WHITELIST OF FIELDS, it never spreads an
26
+ * issue. Zod can carry the rejected `input` on an issue, and these bodies hold a
27
+ * date of birth and a phone number. Echoing them into an error response sends
28
+ * them back to the client, into logs and into any proxy in between. A test
29
+ * proves `input` never survives.
30
+ *
31
+ * @module founder-schema/validation
32
+ */
33
+ import { z } from 'zod';
34
+ /**
35
+ * Every custom message a schema in this package can produce. Each is a
36
+ * translation key the clients define in every language they ship.
37
+ *
38
+ * ⚠️ Append only, and add the translation in the same change. Renaming one
39
+ * shows the raw key to every member until each client ships the new name.
40
+ */
41
+ export declare const VALIDATION_MESSAGE: {
42
+ /** Not a 24-hex Mongo id. */
43
+ readonly invalidId: "invalidId";
44
+ /** Handle: wrong characters, or a dot in the wrong place. */
45
+ readonly usernameInvalid: "usernameInvalid";
46
+ /** Not a 10-digit Indian mobile number. */
47
+ readonly phoneInvalid: "phoneInvalid";
48
+ /** Not a `YYYY-MM-DD` date. */
49
+ readonly dateInvalid: "dateInvalid";
50
+ /** A date that does not exist, such as 30 February. */
51
+ readonly dobInvalid: "dobInvalid";
52
+ /** Under the minimum age. */
53
+ readonly dobUnderAge: "dobUnderAge";
54
+ /** Over the sanity ceiling: almost always a mistyped year. */
55
+ readonly dobTooOld: "dobTooOld";
56
+ /** A founding year after this year. */
57
+ readonly foundedYearFuture: "foundedYearFuture";
58
+ /** An upload key that is not one this product mints. */
59
+ readonly uploadKeyInvalid: "uploadKeyInvalid";
60
+ /** A post with neither text nor media. */
61
+ readonly postEmpty: "postEmpty";
62
+ /** Two poll options with the same label. */
63
+ readonly pollOptionsDuplicate: "pollOptionsDuplicate";
64
+ /** An empty string sent to clear a patch field; clearing is `null`. */
65
+ readonly clearWithNull: "clearWithNull";
66
+ /** Not a plain http(s) URL on a real domain (`javascript:`, `localhost`, an IP...). */
67
+ readonly urlInvalid: "urlInvalid";
68
+ /** A handle this product keeps for itself (`admin`, `support`...). */
69
+ readonly usernameReserved: "usernameReserved";
70
+ /** Agreed to a version of the guidelines that is not the current one. */
71
+ readonly guidelinesOutdated: "guidelinesOutdated";
72
+ };
73
+ export type ValidationMessageKey = (typeof VALIDATION_MESSAGE)[keyof typeof VALIDATION_MESSAGE];
74
+ /**
75
+ * Which part of the request an issue is about.
76
+ *
77
+ * ONE PATH CONVENTION: `path` is ALWAYS relative to this part, so a body field
78
+ * reads `['venture', 'name']` with `location: 'body'`, never
79
+ * `['body', 'venture', 'name']`. A form maps body issues straight onto its
80
+ * fields without stripping a prefix, and a client that sees `query` or
81
+ * `params` knows the problem is the URL, not anything the member typed.
82
+ */
83
+ export declare const REQUEST_PART_VALUES: readonly ["body", "query", "params"];
84
+ export declare const requestPartSchema: z.ZodEnum<{
85
+ body: "body";
86
+ query: "query";
87
+ params: "params";
88
+ }>;
89
+ export type RequestPart = z.infer<typeof requestPartSchema>;
90
+ /**
91
+ * One problem with one field, as it crosses the wire.
92
+ *
93
+ * `code` is Zod's issue code (`too_small`, `too_big`, `invalid_type`,
94
+ * `invalid_format`, `custom`...). `message` is either one of
95
+ * `VALIDATION_MESSAGE` or, for a plain bound, Zod's own English text, which the
96
+ * client ignores in favour of `code` plus `minimum` / `maximum`.
97
+ */
98
+ export declare const validationIssueSchema: z.ZodObject<{
99
+ location: z.ZodOptional<z.ZodEnum<{
100
+ body: "body";
101
+ query: "query";
102
+ params: "params";
103
+ }>>;
104
+ path: z.ZodArray<z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>>;
105
+ code: z.ZodString;
106
+ message: z.ZodString;
107
+ minimum: z.ZodOptional<z.ZodNumber>;
108
+ maximum: z.ZodOptional<z.ZodNumber>;
109
+ origin: z.ZodOptional<z.ZodString>;
110
+ format: z.ZodOptional<z.ZodString>;
111
+ }, z.core.$strip>;
112
+ export type ValidationIssue = z.infer<typeof validationIssueSchema>;
113
+ /** The loosest shape this needs from a Zod issue, so it works across 4.x. */
114
+ interface ZodIssueLike {
115
+ path: readonly PropertyKey[];
116
+ code: string;
117
+ message: string;
118
+ minimum?: unknown;
119
+ maximum?: unknown;
120
+ origin?: unknown;
121
+ format?: unknown;
122
+ }
123
+ /**
124
+ * A Zod error as the wire shape. Use this on the server for every 400, and on
125
+ * the client to treat its own errors and the server's the same way.
126
+ *
127
+ * `location` is stamped on every issue when given; the server always gives it.
128
+ *
129
+ * Copies a WHITELIST of fields (see the module note). A path segment that is a
130
+ * symbol, which JSON cannot carry, becomes its description.
131
+ */
132
+ export declare function toValidationIssues(error: {
133
+ issues: readonly ZodIssueLike[];
134
+ }, location?: RequestPart): ValidationIssue[];
135
+ export {};
136
+ //# sourceMappingURL=validation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validation.d.ts","sourceRoot":"","sources":["../src/validation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB;;;;;;GAMG;AACH,eAAO,MAAM,kBAAkB;IAC7B,6BAA6B;;IAE7B,6DAA6D;;IAE7D,2CAA2C;;IAE3C,+BAA+B;;IAE/B,uDAAuD;;IAEvD,6BAA6B;;IAE7B,8DAA8D;;IAE9D,uCAAuC;;IAEvC,wDAAwD;;IAExD,0CAA0C;;IAE1C,4CAA4C;;IAE5C,uEAAuE;;IAEvE,uFAAuF;;IAEvF,sEAAsE;;IAEtE,yEAAyE;;CAEjE,CAAC;AACX,MAAM,MAAM,oBAAoB,GAAG,CAAC,OAAO,kBAAkB,CAAC,CAAC,MAAM,OAAO,kBAAkB,CAAC,CAAC;AAEhG;;;;;;;;GAQG;AACH,eAAO,MAAM,mBAAmB,sCAAuC,CAAC;AACxE,eAAO,MAAM,iBAAiB;;;;EAA8B,CAAC;AAC7D,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAE5D;;;;;;;GAOG;AACH,eAAO,MAAM,qBAAqB;;;;;;;;;;;;;iBAkBhC,CAAC;AACH,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,qBAAqB,CAAC,CAAC;AAEpE,6EAA6E;AAC7E,UAAU,YAAY;IACpB,IAAI,EAAE,SAAS,WAAW,EAAE,CAAC;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAKD;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAChC,KAAK,EAAE;IAAE,MAAM,EAAE,SAAS,YAAY,EAAE,CAAA;CAAE,EAC1C,QAAQ,CAAC,EAAE,WAAW,GACrB,eAAe,EAAE,CAkBnB"}
@@ -0,0 +1,142 @@
1
+ /**
2
+ * founder-schema — How a validation failure is described, on both sides
3
+ *
4
+ * TWO RULES, and they are what let the web client show any error, its own or
5
+ * the server's, on the right field in the member's language:
6
+ *
7
+ * 1. A CUSTOM MESSAGE IS A KEY, NEVER A SENTENCE. Where a schema needs its own
8
+ * message (a refine, a regex), the message is one of `VALIDATION_MESSAGE`,
9
+ * such as `phoneInvalid`. The client turns the key into text in English or
10
+ * Hindi. Before 0.2.0 these were English sentences, so a Hindi screen showed
11
+ * an English error, and a client that wanted to translate one had to match
12
+ * on the wording.
13
+ *
14
+ * Plain bounds carry NO custom message. `.min(1)` and `.max(80)` produce
15
+ * Zod's own `too_small` / `too_big` issues, which already say which bound
16
+ * and what it was (`minimum`, `maximum`, `origin`), and the client words
17
+ * them from those. A hand-written "at most 80 characters" would only
18
+ * restate the number in one language.
19
+ *
20
+ * 2. THE SERVER SENDS EVERY ISSUE, IN THIS SHAPE. A 400 answers with
21
+ * `error.details.issues`, built by `toValidationIssues`, so the client can
22
+ * put each one under its field. Until 0.2.0 the backend sent only the
23
+ * first issue's message, with no path at all.
24
+ *
25
+ * ⚠️ `toValidationIssues` COPIES A WHITELIST OF FIELDS, it never spreads an
26
+ * issue. Zod can carry the rejected `input` on an issue, and these bodies hold a
27
+ * date of birth and a phone number. Echoing them into an error response sends
28
+ * them back to the client, into logs and into any proxy in between. A test
29
+ * proves `input` never survives.
30
+ *
31
+ * @module founder-schema/validation
32
+ */
33
+ import { z } from 'zod';
34
+ /**
35
+ * Every custom message a schema in this package can produce. Each is a
36
+ * translation key the clients define in every language they ship.
37
+ *
38
+ * ⚠️ Append only, and add the translation in the same change. Renaming one
39
+ * shows the raw key to every member until each client ships the new name.
40
+ */
41
+ export const VALIDATION_MESSAGE = {
42
+ /** Not a 24-hex Mongo id. */
43
+ invalidId: 'invalidId',
44
+ /** Handle: wrong characters, or a dot in the wrong place. */
45
+ usernameInvalid: 'usernameInvalid',
46
+ /** Not a 10-digit Indian mobile number. */
47
+ phoneInvalid: 'phoneInvalid',
48
+ /** Not a `YYYY-MM-DD` date. */
49
+ dateInvalid: 'dateInvalid',
50
+ /** A date that does not exist, such as 30 February. */
51
+ dobInvalid: 'dobInvalid',
52
+ /** Under the minimum age. */
53
+ dobUnderAge: 'dobUnderAge',
54
+ /** Over the sanity ceiling: almost always a mistyped year. */
55
+ dobTooOld: 'dobTooOld',
56
+ /** A founding year after this year. */
57
+ foundedYearFuture: 'foundedYearFuture',
58
+ /** An upload key that is not one this product mints. */
59
+ uploadKeyInvalid: 'uploadKeyInvalid',
60
+ /** A post with neither text nor media. */
61
+ postEmpty: 'postEmpty',
62
+ /** Two poll options with the same label. */
63
+ pollOptionsDuplicate: 'pollOptionsDuplicate',
64
+ /** An empty string sent to clear a patch field; clearing is `null`. */
65
+ clearWithNull: 'clearWithNull',
66
+ /** Not a plain http(s) URL on a real domain (`javascript:`, `localhost`, an IP...). */
67
+ urlInvalid: 'urlInvalid',
68
+ /** A handle this product keeps for itself (`admin`, `support`...). */
69
+ usernameReserved: 'usernameReserved',
70
+ /** Agreed to a version of the guidelines that is not the current one. */
71
+ guidelinesOutdated: 'guidelinesOutdated',
72
+ };
73
+ /**
74
+ * Which part of the request an issue is about.
75
+ *
76
+ * ONE PATH CONVENTION: `path` is ALWAYS relative to this part, so a body field
77
+ * reads `['venture', 'name']` with `location: 'body'`, never
78
+ * `['body', 'venture', 'name']`. A form maps body issues straight onto its
79
+ * fields without stripping a prefix, and a client that sees `query` or
80
+ * `params` knows the problem is the URL, not anything the member typed.
81
+ */
82
+ export const REQUEST_PART_VALUES = ['body', 'query', 'params'];
83
+ export const requestPartSchema = z.enum(REQUEST_PART_VALUES);
84
+ /**
85
+ * One problem with one field, as it crosses the wire.
86
+ *
87
+ * `code` is Zod's issue code (`too_small`, `too_big`, `invalid_type`,
88
+ * `invalid_format`, `custom`...). `message` is either one of
89
+ * `VALIDATION_MESSAGE` or, for a plain bound, Zod's own English text, which the
90
+ * client ignores in favour of `code` plus `minimum` / `maximum`.
91
+ */
92
+ export const validationIssueSchema = z.object({
93
+ /** Set by the server on every issue it sends; absent on a client's own parse. */
94
+ location: requestPartSchema.optional(),
95
+ /**
96
+ * Where the problem is, RELATIVE TO `location`: `['venture', 'name']`,
97
+ * `['options', 2, 'label']`, `['contentId']`.
98
+ */
99
+ path: z.array(z.union([z.string(), z.number()])),
100
+ code: z.string(),
101
+ message: z.string(),
102
+ /** `too_small`: the bound that was missed. */
103
+ minimum: z.number().optional(),
104
+ /** `too_big`: the bound that was passed. */
105
+ maximum: z.number().optional(),
106
+ /** What was measured: `string`, `array`, `number`... */
107
+ origin: z.string().optional(),
108
+ /** `invalid_format`: which format, e.g. `url`, `regex`, `email`. */
109
+ format: z.string().optional(),
110
+ });
111
+ const toNumber = (v) => typeof v === 'number' ? v : typeof v === 'bigint' ? Number(v) : undefined;
112
+ /**
113
+ * A Zod error as the wire shape. Use this on the server for every 400, and on
114
+ * the client to treat its own errors and the server's the same way.
115
+ *
116
+ * `location` is stamped on every issue when given; the server always gives it.
117
+ *
118
+ * Copies a WHITELIST of fields (see the module note). A path segment that is a
119
+ * symbol, which JSON cannot carry, becomes its description.
120
+ */
121
+ export function toValidationIssues(error, location) {
122
+ return error.issues.map((issue) => {
123
+ const out = {
124
+ ...(location ? { location } : {}),
125
+ path: issue.path.map((p) => typeof p === 'number' ? p : typeof p === 'string' ? p : String(p.description ?? '')),
126
+ code: issue.code,
127
+ message: issue.message,
128
+ };
129
+ const minimum = toNumber(issue.minimum);
130
+ const maximum = toNumber(issue.maximum);
131
+ if (minimum !== undefined)
132
+ out.minimum = minimum;
133
+ if (maximum !== undefined)
134
+ out.maximum = maximum;
135
+ if (typeof issue.origin === 'string')
136
+ out.origin = issue.origin;
137
+ if (typeof issue.format === 'string')
138
+ out.format = issue.format;
139
+ return out;
140
+ });
141
+ }
142
+ //# sourceMappingURL=validation.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validation.js","sourceRoot":"","sources":["../src/validation.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * founder-schema — How a validation failure is described, on both sides\n *\n * TWO RULES, and they are what let the web client show any error, its own or\n * the server's, on the right field in the member's language:\n *\n * 1. A CUSTOM MESSAGE IS A KEY, NEVER A SENTENCE. Where a schema needs its own\n * message (a refine, a regex), the message is one of `VALIDATION_MESSAGE`,\n * such as `phoneInvalid`. The client turns the key into text in English or\n * Hindi. Before 0.2.0 these were English sentences, so a Hindi screen showed\n * an English error, and a client that wanted to translate one had to match\n * on the wording.\n *\n * Plain bounds carry NO custom message. `.min(1)` and `.max(80)` produce\n * Zod's own `too_small` / `too_big` issues, which already say which bound\n * and what it was (`minimum`, `maximum`, `origin`), and the client words\n * them from those. A hand-written \"at most 80 characters\" would only\n * restate the number in one language.\n *\n * 2. THE SERVER SENDS EVERY ISSUE, IN THIS SHAPE. A 400 answers with\n * `error.details.issues`, built by `toValidationIssues`, so the client can\n * put each one under its field. Until 0.2.0 the backend sent only the\n * first issue's message, with no path at all.\n *\n * ⚠️ `toValidationIssues` COPIES A WHITELIST OF FIELDS, it never spreads an\n * issue. Zod can carry the rejected `input` on an issue, and these bodies hold a\n * date of birth and a phone number. Echoing them into an error response sends\n * them back to the client, into logs and into any proxy in between. A test\n * proves `input` never survives.\n *\n * @module founder-schema/validation\n */\n\nimport { z } from 'zod';\n\n/**\n * Every custom message a schema in this package can produce. Each is a\n * translation key the clients define in every language they ship.\n *\n * ⚠️ Append only, and add the translation in the same change. Renaming one\n * shows the raw key to every member until each client ships the new name.\n */\nexport const VALIDATION_MESSAGE = {\n /** Not a 24-hex Mongo id. */\n invalidId: 'invalidId',\n /** Handle: wrong characters, or a dot in the wrong place. */\n usernameInvalid: 'usernameInvalid',\n /** Not a 10-digit Indian mobile number. */\n phoneInvalid: 'phoneInvalid',\n /** Not a `YYYY-MM-DD` date. */\n dateInvalid: 'dateInvalid',\n /** A date that does not exist, such as 30 February. */\n dobInvalid: 'dobInvalid',\n /** Under the minimum age. */\n dobUnderAge: 'dobUnderAge',\n /** Over the sanity ceiling: almost always a mistyped year. */\n dobTooOld: 'dobTooOld',\n /** A founding year after this year. */\n foundedYearFuture: 'foundedYearFuture',\n /** An upload key that is not one this product mints. */\n uploadKeyInvalid: 'uploadKeyInvalid',\n /** A post with neither text nor media. */\n postEmpty: 'postEmpty',\n /** Two poll options with the same label. */\n pollOptionsDuplicate: 'pollOptionsDuplicate',\n /** An empty string sent to clear a patch field; clearing is `null`. */\n clearWithNull: 'clearWithNull',\n /** Not a plain http(s) URL on a real domain (`javascript:`, `localhost`, an IP...). */\n urlInvalid: 'urlInvalid',\n /** A handle this product keeps for itself (`admin`, `support`...). */\n usernameReserved: 'usernameReserved',\n /** Agreed to a version of the guidelines that is not the current one. */\n guidelinesOutdated: 'guidelinesOutdated',\n} as const;\nexport type ValidationMessageKey = (typeof VALIDATION_MESSAGE)[keyof typeof VALIDATION_MESSAGE];\n\n/**\n * Which part of the request an issue is about.\n *\n * ONE PATH CONVENTION: `path` is ALWAYS relative to this part, so a body field\n * reads `['venture', 'name']` with `location: 'body'`, never\n * `['body', 'venture', 'name']`. A form maps body issues straight onto its\n * fields without stripping a prefix, and a client that sees `query` or\n * `params` knows the problem is the URL, not anything the member typed.\n */\nexport const REQUEST_PART_VALUES = ['body', 'query', 'params'] as const;\nexport const requestPartSchema = z.enum(REQUEST_PART_VALUES);\nexport type RequestPart = z.infer<typeof requestPartSchema>;\n\n/**\n * One problem with one field, as it crosses the wire.\n *\n * `code` is Zod's issue code (`too_small`, `too_big`, `invalid_type`,\n * `invalid_format`, `custom`...). `message` is either one of\n * `VALIDATION_MESSAGE` or, for a plain bound, Zod's own English text, which the\n * client ignores in favour of `code` plus `minimum` / `maximum`.\n */\nexport const validationIssueSchema = z.object({\n /** Set by the server on every issue it sends; absent on a client's own parse. */\n location: requestPartSchema.optional(),\n /**\n * Where the problem is, RELATIVE TO `location`: `['venture', 'name']`,\n * `['options', 2, 'label']`, `['contentId']`.\n */\n path: z.array(z.union([z.string(), z.number()])),\n code: z.string(),\n message: z.string(),\n /** `too_small`: the bound that was missed. */\n minimum: z.number().optional(),\n /** `too_big`: the bound that was passed. */\n maximum: z.number().optional(),\n /** What was measured: `string`, `array`, `number`... */\n origin: z.string().optional(),\n /** `invalid_format`: which format, e.g. `url`, `regex`, `email`. */\n format: z.string().optional(),\n});\nexport type ValidationIssue = z.infer<typeof validationIssueSchema>;\n\n/** The loosest shape this needs from a Zod issue, so it works across 4.x. */\ninterface ZodIssueLike {\n path: readonly PropertyKey[];\n code: string;\n message: string;\n minimum?: unknown;\n maximum?: unknown;\n origin?: unknown;\n format?: unknown;\n}\n\nconst toNumber = (v: unknown): number | undefined =>\n typeof v === 'number' ? v : typeof v === 'bigint' ? Number(v) : undefined;\n\n/**\n * A Zod error as the wire shape. Use this on the server for every 400, and on\n * the client to treat its own errors and the server's the same way.\n *\n * `location` is stamped on every issue when given; the server always gives it.\n *\n * Copies a WHITELIST of fields (see the module note). A path segment that is a\n * symbol, which JSON cannot carry, becomes its description.\n */\nexport function toValidationIssues(\n error: { issues: readonly ZodIssueLike[] },\n location?: RequestPart,\n): ValidationIssue[] {\n return error.issues.map((issue) => {\n const out: ValidationIssue = {\n ...(location ? { location } : {}),\n path: issue.path.map((p) =>\n typeof p === 'number' ? p : typeof p === 'string' ? p : String(p.description ?? ''),\n ),\n code: issue.code,\n message: issue.message,\n };\n const minimum = toNumber(issue.minimum);\n const maximum = toNumber(issue.maximum);\n if (minimum !== undefined) out.minimum = minimum;\n if (maximum !== undefined) out.maximum = maximum;\n if (typeof issue.origin === 'string') out.origin = issue.origin;\n if (typeof issue.format === 'string') out.format = issue.format;\n return out;\n });\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "founder-schema",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Shared Zod schemas for Founder Circle — single source of truth for API contracts, validation, and TypeScript types (backend + web client + app client).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -25,9 +25,10 @@
25
25
  "test:watch": "vitest",
26
26
  "lint": "biome lint .",
27
27
  "format": "biome format --write .",
28
- "check": "npm run type-check && biome check --write .",
28
+ "check": "npm run type-check && biome check .",
29
+ "check:fix": "npm run type-check && biome check --write .",
29
30
  "check:staged": "biome check --staged --write --no-errors-on-unmatched",
30
- "prepublishOnly": "npm run build && npm run check"
31
+ "prepublishOnly": "npm run check && npm test && npm run build"
31
32
  },
32
33
  "keywords": [
33
34
  "zod",