founder-schema 0.1.0 → 0.2.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 (54) hide show
  1. package/README.md +112 -40
  2. package/dist/chat.d.ts +63 -15
  3. package/dist/chat.d.ts.map +1 -1
  4. package/dist/chat.js +55 -17
  5. package/dist/chat.js.map +1 -1
  6. package/dist/common.d.ts +84 -8
  7. package/dist/common.d.ts.map +1 -1
  8. package/dist/common.js +109 -17
  9. package/dist/common.js.map +1 -1
  10. package/dist/discover.d.ts +98 -6
  11. package/dist/discover.d.ts.map +1 -1
  12. package/dist/discover.js +25 -11
  13. package/dist/discover.js.map +1 -1
  14. package/dist/errors.d.ts +85 -0
  15. package/dist/errors.d.ts.map +1 -0
  16. package/dist/errors.js +89 -0
  17. package/dist/errors.js.map +1 -0
  18. package/dist/feed.d.ts +400 -52
  19. package/dist/feed.d.ts.map +1 -1
  20. package/dist/feed.js +98 -68
  21. package/dist/feed.js.map +1 -1
  22. package/dist/founder-profile.d.ts +76 -46
  23. package/dist/founder-profile.d.ts.map +1 -1
  24. package/dist/founder-profile.js +46 -46
  25. package/dist/founder-profile.js.map +1 -1
  26. package/dist/index.d.ts +14 -9
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +20 -10
  29. package/dist/index.js.map +1 -1
  30. package/dist/limits.d.ts +76 -0
  31. package/dist/limits.d.ts.map +1 -0
  32. package/dist/limits.js +84 -0
  33. package/dist/limits.js.map +1 -0
  34. package/dist/onboarding.d.ts +407 -53
  35. package/dist/onboarding.d.ts.map +1 -1
  36. package/dist/onboarding.js +281 -234
  37. package/dist/onboarding.js.map +1 -1
  38. package/dist/responses/common.d.ts +122 -4
  39. package/dist/responses/common.d.ts.map +1 -1
  40. package/dist/responses/common.js +26 -3
  41. package/dist/responses/common.js.map +1 -1
  42. package/dist/responses/index.d.ts +1 -1
  43. package/dist/responses/index.d.ts.map +1 -1
  44. package/dist/responses/index.js +1 -1
  45. package/dist/responses/index.js.map +1 -1
  46. package/dist/uploads.d.ts +113 -0
  47. package/dist/uploads.d.ts.map +1 -0
  48. package/dist/uploads.js +106 -0
  49. package/dist/uploads.js.map +1 -0
  50. package/dist/validation.d.ts +136 -0
  51. package/dist/validation.d.ts.map +1 -0
  52. package/dist/validation.js +142 -0
  53. package/dist/validation.js.map +1 -0
  54. package/package.json +4 -3
package/README.md CHANGED
@@ -20,56 +20,116 @@ the package is right and the consumer is the bug.
20
20
 
21
21
  Consumed by:
22
22
 
23
- | Repo | What it uses today |
23
+ | Repo | What it uses |
24
24
  |---|---|
25
- | `founder-circle-backend` | The four enum arrays and `USERNAME_MAX`, as Mongoose `enum` and `maxlength` constraints |
26
- | `founder-circle-web-client` | Nothing yet |
25
+ | `founder-circle-backend` | Every write body to validate requests, the enums and limits as Mongoose constraints, `toValidationIssues` for every 400 |
26
+ | `founder-circle-web-client` | The same write bodies for its forms, the limits for counters, the read shapes for what comes back |
27
27
 
28
28
  ---
29
29
 
30
- ## Using it in a consumer, before the first publish
30
+ ## Installing it
31
31
 
32
- **This package is not on npm.** The name and the first publish are the user's
33
- decision and have not been made, so there is nothing to `npm install` yet.
34
-
35
- The interim path is the one TEAM.md documents: build this package, then copy its
36
- `dist/` into the consumer's `node_modules`. The backend already has a script that
37
- does exactly that.
32
+ On npm since 0.1.0. Pin the EXACT version, with `-E` so npm writes `0.2.0` and
33
+ not `^0.2.0` (a caret on `0.x` pins the minor; see Publishing):
38
34
 
39
35
  ```sh
40
- # 1. build here
41
- cd founder-circle-schema
42
- npm install
43
- npm run build
44
-
45
- # 2. link it into the consumer
46
- cd ../founder-circle-backend
47
- npm run schema:link
36
+ npm install -E founder-schema@0.2.0
48
37
  ```
49
38
 
50
- **A fresh clone has no build.** `dist/` is gitignored, which is right for a
51
- package that ships a build artifact to npm rather than to git, but it means
52
- `npm run schema:link` fails on a clone until `npm run build` has been run here at
53
- least once. That is the first step above and it is not optional. The link script
54
- checks for `dist/index.js` and tells you so rather than copying an empty folder.
55
-
56
- Three things about that link, each of which has bitten this family of repos:
39
+ **Working against a change that is not published yet** is the path TEAM.md
40
+ documents: build here, copy `dist/` over the consumer's
41
+ `node_modules/founder-schema/dist/`, and commit nothing that pins the new version
42
+ until it is on npm.
57
43
 
58
- - **Re-run `npm run schema:link` after every `npm install` in the consumer.** npm
59
- prunes packages it has no record of, so an install silently deletes the link and
60
- the next type-check fails on imports that were fine an hour ago.
44
+ - **Any `npm install` in the consumer undoes the copy.** npm restores what its
45
+ lockfile names, and the next type-check fails on imports that were fine an hour
46
+ ago. Copy again after every install.
61
47
  - **Never use `file:../` in a consumer's `package.json`.** A path link resolves to
62
- a different copy than a Docker build or a CI checkout sees, and it drags in a
63
- second Zod, which breaks `instanceof` checks in ways that look like random
64
- validation failures.
48
+ a different copy than a Docker build sees, and it drags in a second Zod, which
49
+ breaks `instanceof` checks in ways that look like random validation failures.
65
50
  - **Never commit a consumer whose `package.json` pins a version that is not on
66
51
  npm.** The pin has to follow the publish, never lead it.
67
52
 
53
+ ---
54
+
55
+ ## Validation: one set of rules, both sides (0.2.0)
56
+
57
+ The backend validates a request and the web client validates a form with the
58
+ SAME schema object, so the two can never disagree about what is allowed.
59
+
60
+ - **Every limit has a name** (`src/limits.ts`: `DISPLAY_NAME_MAX`,
61
+ `LINK_URL_MAX`, `UPLOAD_MAX_BYTES`...). A schema uses the constant; a consumer
62
+ imports the same constant for its counters or its Mongoose `maxlength`. Nobody
63
+ types the digits again. Three copies of one number is how image alt text came
64
+ to be 280 here and 500 in the backend.
65
+ - **A custom message is a KEY, never a sentence** (`VALIDATION_MESSAGE`:
66
+ `phoneInvalid`, `dobUnderAge`...). The client words it in English or Hindi.
67
+ Plain bounds carry no message at all: Zod's own `too_small` / `too_big` issue
68
+ already says which bound and what it was. A test fails if a sentence creeps back.
69
+ - **A 400 carries every issue** in `error.details.issues`, each with its `path`,
70
+ built by `toValidationIssues`. ⚠️ That helper copies a whitelist and never the
71
+ rejected `input`: these bodies hold a date of birth and a phone number. A test
72
+ forces Zod to attach the input and proves it does not leave.
73
+ - **Every issue says where it is**: `location` is `body`, `query` or `params`,
74
+ and `path` is relative to it (`['venture', 'name']`, never
75
+ `['body', 'venture', 'name']`).
76
+ - **Every error has a code from `API_ERROR_CODE_VALUES`.** A schema failure is
77
+ `VALIDATION_ERROR` with issues; a refusal by the state of things (a closed
78
+ poll, the edit window) has its own code. The client words the code; the
79
+ English `message` is for logs.
80
+ - **Every request part and every response has a schema here**: bodies, queries
81
+ (with `...Input` types for building URLs), route params (`contentParamsSchema`,
82
+ `engagementTargetParamsSchema`...), pages (`feedPageSchema`,
83
+ `discoverPageSchema`...), mutations (`idResponseSchema`). The backend builds
84
+ each response `satisfies` its type and tests it with its schema.
85
+ - **Onboarding validates a step with its slice of the finish**
86
+ (`ONBOARDING_STEP_SCHEMAS`), so a step the wizard lets through is a step the
87
+ finish accepts. The step list (`FOUNDER_ONBOARDING_STEPS`) is built from those
88
+ slices, including whether a step may be skipped.
89
+ - **No copy.** Step titles, help lines and topic categories are translation keys
90
+ (`titleKey`, `helpKey`, `labelKey`); the words live in each client.
91
+ - **Bodies are typed JSON; only queries coerce.** `teamSize: "5"` is refused in a
92
+ body, and `?limit=5` is read as 5.
93
+ - **Shapes are derived, never copied.** One `locationSchema`, one
94
+ `founderLinksSchema`, one `ventureSchema`, one `uploadKeySchema`; the other
95
+ places pick, omit or reuse them, and a test checks by object identity.
96
+
68
97
  `zod` is a **peer** dependency (`^4.4.0`), on purpose. The consumer owns the Zod
69
98
  instance; this package must not bring its own.
70
99
 
71
100
  ---
72
101
 
102
+ ## Breaking in 0.2.0
103
+
104
+ No export was removed; these change BEHAVIOUR or a field's shape. Every one is a
105
+ correction of something the backend, the web or the data already disagreed on.
106
+
107
+ | What | Before | Now |
108
+ |---|---|---|
109
+ | Custom messages | English sentences | keys from `VALIDATION_MESSAGE` |
110
+ | `apiErrorSchema.code` | any string | `ApiErrorCode` |
111
+ | `apiErrorSchema.details` | record of strings (never sent) | `{ issues }`, each with `location` |
112
+ | `locationSchema.country` | defaulted to `'India'` | required, no default |
113
+ | `ventureSchema.teamSize`, `.foundedYear` | coerced | numbers only |
114
+ | `httpUrlSchema` | uncapped, Zod's English error | capped at `LINK_URL_MAX`, key `urlInvalid` |
115
+ | Link preview `url`, `imageUrl` | `httpUrlSchema` | `safeUrlSchema` (no cap) |
116
+ | `completeOnboardingBodySchema.guidelinesVersion` | any string | exactly `GUIDELINES_VERSION` |
117
+ | `completeOnboardingBodySchema.avatarUploadKey` | any string | `uploadKeySchema` |
118
+ | `onboardingDraftSchema` | any keys | the form's keys only (values still opaque) |
119
+ | `uploadPresignBodySchema` | `{ extension }` | `{ extension, contentLength }` |
120
+ | `feedQuerySchema` | `authorId` (ignored by the server) | removed; `interests` added |
121
+ | `setReactionResponseSchema`, `createReportResponseSchema` `.contentKind` | post, poll | post, poll, comment |
122
+ | `FOUNDER_ONBOARDING_STEPS[i]` | `title`, `helpText` | `titleKey`, `helpKey` |
123
+ | `TOPIC_CATEGORIES[i]` | `label` | `labelKey` |
124
+ | `discoverFacetsSchema` options | `{ value, label, count }` | `{ value, count }` |
125
+ | `updateUsernameBodySchema` | format only | format, and not reserved |
126
+
127
+ Deprecated, removed in 0.3.0: `chatInvitationBodySchema` (use
128
+ `openChatBodySchema`) and `chatInvitationSchema` (describes nothing that
129
+ exists).
130
+
131
+ ---
132
+
73
133
  ## Publishing
74
134
 
75
135
  **The version only ever moves on `dev`.** Never bump `version` on a feat branch
@@ -92,12 +152,23 @@ Two things that follow from it:
92
152
 
93
153
  ```
94
154
  src/
95
- common.ts username rules, the four enums, location, pagination
96
- founder-profile.ts the profile, the peer-visible card, the write bodies
97
- discover.ts the Discover query and its facets
98
- responses/common.ts success / error / paginated envelopes
155
+ limits.ts every length, size and count limit, by name (imports nothing)
156
+ validation.ts message keys, the wire shape of an issue, toValidationIssues
157
+ errors.ts every API error code (API_ERROR_CODE_VALUES)
158
+ uploads.ts the upload key, its extensions and content types, presign
159
+ common.ts usernames (and the reserved list), the enums, URLs, phone,
160
+ location, coordinates, pagination
161
+ founder-profile.ts the profile, links, venture, the card, the write bodies
162
+ onboarding.ts the steps, their per-step schemas, the draft and the finish
163
+ feed.ts posts, polls, comments, reactions, reports, params, pages
164
+ discover.ts the Discover query, its page and its facets
165
+ chat.ts config, token, opening a conversation, participants
166
+ responses/common.ts success / error / paginated / { id } envelopes
99
167
  index.ts the public surface. Everything a consumer imports.
100
168
  tests/
169
+ ssot.test.ts derived not copied, no body coercion, URLs, uploads, codes
170
+ validation.test.ts keys not sentences, no input leaves, limits applied
171
+ onboarding-steps.test.ts steps, step schemas and the finish body agree
101
172
  responses.test.ts the envelopes, pinned
102
173
  public-surface.test.ts export-removal tripwire + the values the database is pinned to
103
174
  ```
@@ -119,7 +190,9 @@ import { founderCardSchema, type FounderCard } from 'founder-schema';
119
190
  | `npm test` | Vitest, run once |
120
191
  | `npm run test:watch` | Vitest, watching |
121
192
  | `npm run lint` | Biome lint |
122
- | `npm run check` | type-check, then `biome check --write` |
193
+ | `npm run check` | type-check, then `biome check` (read-only, so a publish never rewrites a file) |
194
+ | `npm run check:fix` | type-check, then `biome check --write` |
195
+ | `prepublishOnly` | `check`, then the tests, then the build. A publish that fails any of them does not happen |
123
196
 
124
197
  There is no `postinstall` script and there must not be one. They break the
125
198
  Dockerized builds in this family.
@@ -176,11 +249,10 @@ bearing, so append at the end and never insert.
176
249
 
177
250
  ---
178
251
 
179
- ## Open, and not ours to decide
252
+ ## Settled since the scaffold
180
253
 
181
- - **The npm package name and the first publish.** Publishing a name is public and
182
- effectively permanent, so it waits for the user.
183
- - **The onboarding steps.** The mechanism is settled; the questions are not.
254
+ - **The npm name and the first publish:** `founder-schema`, 0.1.0, by the user.
255
+ - **The onboarding steps:** seven, ruled 2026-09-22; see `src/onboarding.ts`.
184
256
 
185
257
  ## Contract decisions, 2026-09-21
186
258
 
package/dist/chat.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * founder-schema — Chat: config, auth token, invitation
2
+ * founder-schema — Chat: config, auth token, opening a conversation, participants
3
3
  *
4
4
  * Founder Circle does not run a chat server. It uses the shared one, which
5
5
  * serves several products, and that is the whole reason this file is careful:
@@ -32,6 +32,7 @@ import { z } from 'zod';
32
32
  */
33
33
  export declare const CHAT_PRODUCT_ID: "founder-circle";
34
34
  export declare const chatProductIdSchema: z.ZodLiteral<"founder-circle">;
35
+ export type ChatProductId = z.infer<typeof chatProductIdSchema>;
35
36
  /**
36
37
  * What the client needs before it can open a socket.
37
38
  *
@@ -110,30 +111,77 @@ export declare const chatAuthTokenSchema: z.ZodObject<{
110
111
  }, z.core.$strip>;
111
112
  export type ChatAuthToken = z.infer<typeof chatAuthTokenSchema>;
112
113
  /**
113
- * Asking our own backend for permission to start a conversation with somebody.
114
+ * `POST /api/v1/chat/open`: start (or find) the direct conversation with
115
+ * another member.
114
116
  *
115
117
  * Our backend decides who may talk to whom, because only it knows this
116
- * product's rules. The chat server is deliberately not in that decision.
118
+ * product's rules, and it does the whole exchange with the chat server itself:
119
+ * it mints the viewer's token and an invitation naming both participants, and
120
+ * redeems them server to server. No credential reaches the browser.
117
121
  */
118
- export declare const chatInvitationBodySchema: z.ZodObject<{
122
+ export declare const openChatBodySchema: z.ZodObject<{
119
123
  userId: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
120
124
  }, z.core.$strip>;
121
- export type ChatInvitationBody = z.infer<typeof chatInvitationBodySchema>;
125
+ export type OpenChatBody = z.infer<typeof openChatBodySchema>;
122
126
  /**
123
- * The invitation itself.
124
- *
125
- * ⚠️ THE INVITATION IS THE CREDENTIAL, and the call that redeems it at the chat
126
- * server deliberately carries NO Authorization header. The token already names
127
- * both participants, which is precisely what keeps the chat server out of
128
- * deciding who may talk to whom.
127
+ * The conversation to navigate to. `201` with `wasCreated: true` the first
128
+ * time, `200` and `false` on every later open of the same pair.
129
+ */
130
+ export declare const openChatResponseSchema: z.ZodObject<{
131
+ conversationId: z.ZodString;
132
+ wasCreated: z.ZodBoolean;
133
+ }, z.core.$strip>;
134
+ export type OpenChatResponse = z.infer<typeof openChatResponseSchema>;
135
+ /**
136
+ * `POST /api/v1/chat/participants`: names and avatars for the people in the
137
+ * viewer's conversations.
129
138
  *
130
- * That looks like an oversight and is not. Anyone "fixing" it by attaching a
131
- * bearer would move the authorisation decision to a server that serves several
132
- * products and does not know this one's rules. If it ever needs changing, the
133
- * thing to change is who issues the invitation, not what accompanies it.
139
+ * The chat server knows ids and nothing about who anybody is, because it serves
140
+ * several products and identity belongs to each; so the conversation list asks
141
+ * here, once per screen. Bounded by `CHAT_PARTICIPANTS_MAX`, so it cannot page
142
+ * through the directory.
143
+ */
144
+ export declare const chatParticipantsBodySchema: z.ZodObject<{
145
+ userIds: z.ZodArray<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>>;
146
+ }, z.core.$strip>;
147
+ export type ChatParticipantsBody = z.infer<typeof chatParticipantsBodySchema>;
148
+ /** The public card fields any member already sees, and nothing more. */
149
+ export declare const chatParticipantSchema: z.ZodObject<{
150
+ displayName: z.ZodString;
151
+ username: z.ZodString;
152
+ avatarUrl: z.ZodNullable<z.ZodURL>;
153
+ }, z.core.$strip>;
154
+ export type ChatParticipant = z.infer<typeof chatParticipantSchema>;
155
+ /**
156
+ * Keyed by user id. ⚠ An id with no active, onboarded profile is ABSENT, not
157
+ * an error: the client renders it as a former member rather than guessing.
158
+ */
159
+ export declare const chatParticipantsResponseSchema: z.ZodObject<{
160
+ participants: z.ZodRecord<z.ZodString, z.ZodObject<{
161
+ displayName: z.ZodString;
162
+ username: z.ZodString;
163
+ avatarUrl: z.ZodNullable<z.ZodURL>;
164
+ }, z.core.$strip>>;
165
+ }, z.core.$strip>;
166
+ export type ChatParticipantsResponse = z.infer<typeof chatParticipantsResponseSchema>;
167
+ /**
168
+ * @deprecated Since 0.2.0: use `openChatBodySchema`, the same shape under the
169
+ * name of the route that takes it. Removed in 0.3.0.
170
+ */
171
+ export declare const chatInvitationBodySchema: z.ZodObject<{
172
+ userId: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
173
+ }, z.core.$strip>;
174
+ /** @deprecated Since 0.2.0: use `OpenChatBody`. Removed in 0.3.0. */
175
+ export type ChatInvitationBody = OpenChatBody;
176
+ /**
177
+ * @deprecated Since 0.2.0, and it describes nothing that exists. The browser
178
+ * never holds an invitation: `/chat/open` mints and redeems it server to
179
+ * server. The old `/invitation` route that handed one out could never have
180
+ * worked and is gone. Removed in 0.3.0.
134
181
  */
135
182
  export declare const chatInvitationSchema: z.ZodObject<{
136
183
  invitationToken: z.ZodString;
137
184
  }, z.core.$strip>;
185
+ /** @deprecated Since 0.2.0. Removed in 0.3.0. */
138
186
  export type ChatInvitation = z.infer<typeof chatInvitationSchema>;
139
187
  //# sourceMappingURL=chat.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"chat.d.ts","sourceRoot":"","sources":["../src/chat.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB;;;;;;;;GAQG;AACH,eAAO,MAAM,eAAe,EAAG,gBAAyB,CAAC;AACzD,eAAO,MAAM,mBAAmB,gCAA6B,CAAC;AAI9D;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,gBAAgB;;;;;iBAQ3B,CAAC;AACH,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAI1D;;;;;;;GAOG;AACH,eAAO,MAAM,iCAAiC,KAAK,CAAC;AAEpD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,0BAA0B,QAAU,CAAC;AAElD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,eAAO,MAAM,mBAAmB;;;;;iBAQ9B,CAAC;AACH,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAIhE;;;;;GAKG;AACH,eAAO,MAAM,wBAAwB;;iBAGnC,CAAC;AACH,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,wBAAwB,CAAC,CAAC;AAE1E;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,oBAAoB;;iBAE/B,CAAC;AACH,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC"}
1
+ {"version":3,"file":"chat.d.ts","sourceRoot":"","sources":["../src/chat.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAIxB;;;;;;;;GAQG;AACH,eAAO,MAAM,eAAe,EAAG,gBAAyB,CAAC;AACzD,eAAO,MAAM,mBAAmB,gCAA6B,CAAC;AAC9D,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAIhE;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,gBAAgB;;;;;iBAQ3B,CAAC;AACH,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAI1D;;;;;;;GAOG;AACH,eAAO,MAAM,iCAAiC,KAAK,CAAC;AAEpD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,0BAA0B,QAAU,CAAC;AAElD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,eAAO,MAAM,mBAAmB;;;;;iBAQ9B,CAAC;AACH,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAIhE;;;;;;;;GAQG;AACH,eAAO,MAAM,kBAAkB;;iBAG7B,CAAC;AACH,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAE9D;;;GAGG;AACH,eAAO,MAAM,sBAAsB;;;iBAGjC,CAAC;AACH,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,sBAAsB,CAAC,CAAC;AAItE;;;;;;;;GAQG;AACH,eAAO,MAAM,0BAA0B;;iBAErC,CAAC;AACH,MAAM,MAAM,oBAAoB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,0BAA0B,CAAC,CAAC;AAE9E,wEAAwE;AACxE,eAAO,MAAM,qBAAqB;;;;iBAKhC,CAAC;AACH,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,qBAAqB,CAAC,CAAC;AAEpE;;;GAGG;AACH,eAAO,MAAM,8BAA8B;;;;;;iBAEzC,CAAC;AACH,MAAM,MAAM,wBAAwB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,8BAA8B,CAAC,CAAC;AAItF;;;GAGG;AACH,eAAO,MAAM,wBAAwB;;iBAAqB,CAAC;AAC3D,qEAAqE;AACrE,MAAM,MAAM,kBAAkB,GAAG,YAAY,CAAC;AAE9C;;;;;GAKG;AACH,eAAO,MAAM,oBAAoB;;iBAE/B,CAAC;AACH,iDAAiD;AACjD,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC"}
package/dist/chat.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * founder-schema — Chat: config, auth token, invitation
2
+ * founder-schema — Chat: config, auth token, opening a conversation, participants
3
3
  *
4
4
  * Founder Circle does not run a chat server. It uses the shared one, which
5
5
  * serves several products, and that is the whole reason this file is careful:
@@ -21,7 +21,8 @@
21
21
  * @module founder-schema/chat
22
22
  */
23
23
  import { z } from 'zod';
24
- import { objectIdSchema } from './common.js';
24
+ import { objectIdSchema, usernameSchema } from './common.js';
25
+ import { CHAT_PARTICIPANTS_MAX } from './limits.js';
25
26
  /**
26
27
  * The product this package speaks for.
27
28
  *
@@ -116,29 +117,66 @@ export const chatAuthTokenSchema = z.object({
116
117
  /** The product this token is scoped to. A mismatch must fail loudly. */
117
118
  productId: chatProductIdSchema,
118
119
  });
119
- // ─── Invitation ──────────────────────────────────────────────────────────────
120
+ // ─── Opening a conversation ──────────────────────────────────────────────────
120
121
  /**
121
- * Asking our own backend for permission to start a conversation with somebody.
122
+ * `POST /api/v1/chat/open`: start (or find) the direct conversation with
123
+ * another member.
122
124
  *
123
125
  * Our backend decides who may talk to whom, because only it knows this
124
- * product's rules. The chat server is deliberately not in that decision.
126
+ * product's rules, and it does the whole exchange with the chat server itself:
127
+ * it mints the viewer's token and an invitation naming both participants, and
128
+ * redeems them server to server. No credential reaches the browser.
125
129
  */
126
- export const chatInvitationBodySchema = z.object({
127
- /** The OTHER participant. The viewer is taken from the credential. */
130
+ export const openChatBodySchema = z.object({
131
+ /** The OTHER participant. The viewer is taken from the credential, never the body. */
128
132
  userId: objectIdSchema,
129
133
  });
130
134
  /**
131
- * The invitation itself.
132
- *
133
- * ⚠️ THE INVITATION IS THE CREDENTIAL, and the call that redeems it at the chat
134
- * server deliberately carries NO Authorization header. The token already names
135
- * both participants, which is precisely what keeps the chat server out of
136
- * deciding who may talk to whom.
135
+ * The conversation to navigate to. `201` with `wasCreated: true` the first
136
+ * time, `200` and `false` on every later open of the same pair.
137
+ */
138
+ export const openChatResponseSchema = z.object({
139
+ conversationId: z.string().min(1),
140
+ wasCreated: z.boolean(),
141
+ });
142
+ // ─── Participants ────────────────────────────────────────────────────────────
143
+ /**
144
+ * `POST /api/v1/chat/participants`: names and avatars for the people in the
145
+ * viewer's conversations.
137
146
  *
138
- * That looks like an oversight and is not. Anyone "fixing" it by attaching a
139
- * bearer would move the authorisation decision to a server that serves several
140
- * products and does not know this one's rules. If it ever needs changing, the
141
- * thing to change is who issues the invitation, not what accompanies it.
147
+ * The chat server knows ids and nothing about who anybody is, because it serves
148
+ * several products and identity belongs to each; so the conversation list asks
149
+ * here, once per screen. Bounded by `CHAT_PARTICIPANTS_MAX`, so it cannot page
150
+ * through the directory.
151
+ */
152
+ export const chatParticipantsBodySchema = z.object({
153
+ userIds: z.array(objectIdSchema).min(1).max(CHAT_PARTICIPANTS_MAX),
154
+ });
155
+ /** The public card fields any member already sees, and nothing more. */
156
+ export const chatParticipantSchema = z.object({
157
+ displayName: z.string(),
158
+ username: usernameSchema,
159
+ /** Null when the member has no photo. Ours, or from the login provider. */
160
+ avatarUrl: z.url().nullable(),
161
+ });
162
+ /**
163
+ * Keyed by user id. ⚠ An id with no active, onboarded profile is ABSENT, not
164
+ * an error: the client renders it as a former member rather than guessing.
165
+ */
166
+ export const chatParticipantsResponseSchema = z.object({
167
+ participants: z.record(z.string(), chatParticipantSchema),
168
+ });
169
+ // ─── Deprecated ──────────────────────────────────────────────────────────────
170
+ /**
171
+ * @deprecated Since 0.2.0: use `openChatBodySchema`, the same shape under the
172
+ * name of the route that takes it. Removed in 0.3.0.
173
+ */
174
+ export const chatInvitationBodySchema = openChatBodySchema;
175
+ /**
176
+ * @deprecated Since 0.2.0, and it describes nothing that exists. The browser
177
+ * never holds an invitation: `/chat/open` mints and redeems it server to
178
+ * server. The old `/invitation` route that handed one out could never have
179
+ * worked and is gone. Removed in 0.3.0.
142
180
  */
143
181
  export const chatInvitationSchema = z.object({
144
182
  invitationToken: z.string().min(1),
package/dist/chat.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"chat.js","sourceRoot":"","sources":["../src/chat.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAE7C;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,eAAe,GAAG,gBAAyB,CAAC;AACzD,MAAM,CAAC,MAAM,mBAAmB,GAAG,CAAC,CAAC,OAAO,CAAC,eAAe,CAAC,CAAC;AAE9D,gFAAgF;AAEhF;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAC,CAAC,MAAM,CAAC;IACvC,OAAO,EAAE,CAAC,CAAC,OAAO,EAAE;IACpB,yEAAyE;IACzE,GAAG,EAAE,CAAC,CAAC,GAAG,EAAE,CAAC,QAAQ,EAAE;IACvB,sDAAsD;IACtD,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE;IAClB,mEAAmE;IACnE,SAAS,EAAE,mBAAmB;CAC/B,CAAC,CAAC;AAGH,gFAAgF;AAEhF;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,iCAAiC,GAAG,EAAE,CAAC;AAEpD;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,MAAM,0BAA0B,GAAG,EAAE,GAAG,EAAE,CAAC;AAElD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG,CAAC,CAAC,MAAM,CAAC;IAC1C,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC;IACxB,iEAAiE;IACjE,SAAS,EAAE,CAAC,CAAC,GAAG,CAAC,QAAQ,EAAE;IAC3B,gEAAgE;IAChE,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,QAAQ,EAAE;IACtC,wEAAwE;IACxE,SAAS,EAAE,mBAAmB;CAC/B,CAAC,CAAC;AAGH,gFAAgF;AAEhF;;;;;GAKG;AACH,MAAM,CAAC,MAAM,wBAAwB,GAAG,CAAC,CAAC,MAAM,CAAC;IAC/C,sEAAsE;IACtE,MAAM,EAAE,cAAc;CACvB,CAAC,CAAC;AAGH;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG,CAAC,CAAC,MAAM,CAAC;IAC3C,eAAe,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC;CACnC,CAAC,CAAC","sourcesContent":["/**\n * founder-schema — Chat: config, auth token, invitation\n *\n * Founder Circle does not run a chat server. It uses the shared one, which\n * serves several products, and that is the whole reason this file is careful:\n * when one server serves many tenants, the failure mode is SILENT. A push once\n * returned success having sent nothing, because the caller held the wrong\n * product's key and nothing in the exchange said which product it was for.\n *\n * So the rule these shapes exist to hold: TENANCY COMES FROM THE CREDENTIAL,\n * never from a request body. The token says which product it is for, the chat\n * server resolves the product from the token and from nothing else, and a\n * Founder Circle token cannot address another product's conversation even when\n * handed a valid-looking id.\n *\n * ⚠️ TEXT ONLY. There is deliberately no `callsEnabled` flag here, and its\n * absence is the feature: the user ruled chat is text only, there is no call\n * code to branch on, and the browser is told to deny camera and microphone\n * outright. A boolean that can never be true is an invitation to turn it on.\n *\n * @module founder-schema/chat\n */\n\nimport { z } from 'zod';\nimport { objectIdSchema } from './common.js';\n\n/**\n * The product this package speaks for.\n *\n * Typed as a LITERAL on purpose. If the shared chat server ever hands back a\n * config or a token minted for a different product, parsing fails loudly here\n * rather than the client quietly talking to the wrong tenant. That is exactly\n * the class of bug this file exists to prevent, so it should be impossible to\n * carry past the first parse.\n */\nexport const CHAT_PRODUCT_ID = 'founder-circle' as const;\nexport const chatProductIdSchema = z.literal(CHAT_PRODUCT_ID);\n\n// ─── Config ──────────────────────────────────────────────────────────────────\n\n/**\n * What the client needs before it can open a socket.\n *\n * ⚠️ `enabled` IS MANDATORY AND IS NOT DERIVED FROM `url`. \"Messaging is not\n * switched on yet\" is a first-class state, not a failure: a 404 is the expected\n * answer today, and the client collapses a disabled flag, a null url, a non-2xx\n * and a thrown fetch to the same quiet \"not on yet\" rather than an error. If\n * `enabled` were inferred from a missing url, \"off\" and \"broken\" would be the\n * same wire shape and the inbox could not tell a member which one it was.\n *\n * ⚠️ `userId` is required and is the field nothing works without. The client\n * needs to know WHO THE VIEWER IS to decide which side a bubble sits on, to\n * filter the viewer out of presence and typing, to pick the other participant\n * for a row's name and avatar, and to tell its own optimistic message from one\n * echoed back. Without it every conversation renders as though the viewer were\n * nobody. It was missing from the first sketch of this endpoint.\n */\nexport const chatConfigSchema = z.object({\n enabled: z.boolean(),\n /** Null whenever chat is not switched on. Never a placeholder string. */\n url: z.url().nullable(),\n /** The VIEWER's id, as the chat server knows them. */\n userId: z.string(),\n /** Which product this config is for. See `chatProductIdSchema`. */\n productId: chatProductIdSchema,\n});\nexport type ChatConfig = z.infer<typeof chatConfigSchema>;\n\n// ─── Auth token ──────────────────────────────────────────────────────────────\n\n/**\n * How long before expiry the client re-mints, in seconds.\n *\n * Exported because it is a contract detail and not an implementation one: a\n * token handed over with two seconds left is a socket that authenticates and is\n * closed again immediately, so the minting side needs to know the consuming\n * side will not use the last minute of a token's life.\n */\nexport const CHAT_TOKEN_REFRESH_MARGIN_SECONDS = 60;\n\n/**\n * How often the live socket re-authenticates, in seconds.\n *\n * Auth is an EVENT on the open socket rather than a handshake parameter, so\n * re-authenticating does not drop the connection.\n *\n * ⚠️ THIS MUST STAY BELOW THE REAL TOKEN LIFETIME, by more than the refresh\n * margin. It assumes a 15 minute token. A re-auth interval longer than the\n * lifetime is a socket that dies every quarter of an hour with no explanation,\n * and the symptom appears far from the cause. If the issued lifetime changes,\n * this number moves with it or the two silently disagree.\n */\nexport const CHAT_SOCKET_REAUTH_SECONDS = 10 * 60;\n\n/**\n * A short-lived token for the shared chat server.\n *\n * ⚠️ BOTH TIME FIELDS ARE KEPT, and they are not redundant, because two\n * different parties read them with two different clocks:\n *\n * `expiresAt` an absolute instant, for the SERVER. The chat server holds the\n * authoritative clock and must reject an expired token on its\n * own, whatever any client believes.\n * `expiresIn` a duration in seconds, for the CLIENT. Scheduling a refresh\n * from `receivedAt + expiresIn` uses only the client's own\n * elapsed time, so a device whose absolute clock is wrong still\n * refreshes correctly. Scheduling from `expiresAt` instead means\n * a browser an hour fast treats every fresh token as already\n * expired and re-mints forever.\n *\n * Dropping either one breaks the party that reads it. This is the same reason\n * OAuth carries `expires_in` on the wire while the token itself carries `exp`.\n *\n * ⚠️ AND THE CLIENT IS NOT THE ENFORCER. Its margin is an optimisation to avoid\n * a pointless round trip. A wrong clock, a tab asleep for an hour, or a modified\n * client all defeat anything the client does, so the chat server rejects an\n * expired token on its own.\n */\nexport const chatAuthTokenSchema = z.object({\n token: z.string().min(1),\n /** ISO 8601. Parsed with `Date.parse`, never an epoch number. */\n expiresAt: z.iso.datetime(),\n /** Seconds from issue. See the note above on why both exist. */\n expiresIn: z.number().int().positive(),\n /** The product this token is scoped to. A mismatch must fail loudly. */\n productId: chatProductIdSchema,\n});\nexport type ChatAuthToken = z.infer<typeof chatAuthTokenSchema>;\n\n// ─── Invitation ──────────────────────────────────────────────────────────────\n\n/**\n * Asking our own backend for permission to start a conversation with somebody.\n *\n * Our backend decides who may talk to whom, because only it knows this\n * product's rules. The chat server is deliberately not in that decision.\n */\nexport const chatInvitationBodySchema = z.object({\n /** The OTHER participant. The viewer is taken from the credential. */\n userId: objectIdSchema,\n});\nexport type ChatInvitationBody = z.infer<typeof chatInvitationBodySchema>;\n\n/**\n * The invitation itself.\n *\n * ⚠️ THE INVITATION IS THE CREDENTIAL, and the call that redeems it at the chat\n * server deliberately carries NO Authorization header. The token already names\n * both participants, which is precisely what keeps the chat server out of\n * deciding who may talk to whom.\n *\n * That looks like an oversight and is not. Anyone \"fixing\" it by attaching a\n * bearer would move the authorisation decision to a server that serves several\n * products and does not know this one's rules. If it ever needs changing, the\n * thing to change is who issues the invitation, not what accompanies it.\n */\nexport const chatInvitationSchema = z.object({\n invitationToken: z.string().min(1),\n});\nexport type ChatInvitation = z.infer<typeof chatInvitationSchema>;\n"]}
1
+ {"version":3,"file":"chat.js","sourceRoot":"","sources":["../src/chat.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,EAAE,cAAc,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAC7D,OAAO,EAAE,qBAAqB,EAAE,MAAM,aAAa,CAAC;AAEpD;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,eAAe,GAAG,gBAAyB,CAAC;AACzD,MAAM,CAAC,MAAM,mBAAmB,GAAG,CAAC,CAAC,OAAO,CAAC,eAAe,CAAC,CAAC;AAG9D,gFAAgF;AAEhF;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAC,CAAC,MAAM,CAAC;IACvC,OAAO,EAAE,CAAC,CAAC,OAAO,EAAE;IACpB,yEAAyE;IACzE,GAAG,EAAE,CAAC,CAAC,GAAG,EAAE,CAAC,QAAQ,EAAE;IACvB,sDAAsD;IACtD,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE;IAClB,mEAAmE;IACnE,SAAS,EAAE,mBAAmB;CAC/B,CAAC,CAAC;AAGH,gFAAgF;AAEhF;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,iCAAiC,GAAG,EAAE,CAAC;AAEpD;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,MAAM,0BAA0B,GAAG,EAAE,GAAG,EAAE,CAAC;AAElD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG,CAAC,CAAC,MAAM,CAAC;IAC1C,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC;IACxB,iEAAiE;IACjE,SAAS,EAAE,CAAC,CAAC,GAAG,CAAC,QAAQ,EAAE;IAC3B,gEAAgE;IAChE,SAAS,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,QAAQ,EAAE;IACtC,wEAAwE;IACxE,SAAS,EAAE,mBAAmB;CAC/B,CAAC,CAAC;AAGH,gFAAgF;AAEhF;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG,CAAC,CAAC,MAAM,CAAC;IACzC,sFAAsF;IACtF,MAAM,EAAE,cAAc;CACvB,CAAC,CAAC;AAGH;;;GAGG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAG,CAAC,CAAC,MAAM,CAAC;IAC7C,cAAc,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC;IACjC,UAAU,EAAE,CAAC,CAAC,OAAO,EAAE;CACxB,CAAC,CAAC;AAGH,gFAAgF;AAEhF;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,0BAA0B,GAAG,CAAC,CAAC,MAAM,CAAC;IACjD,OAAO,EAAE,CAAC,CAAC,KAAK,CAAC,cAAc,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,qBAAqB,CAAC;CACnE,CAAC,CAAC;AAGH,wEAAwE;AACxE,MAAM,CAAC,MAAM,qBAAqB,GAAG,CAAC,CAAC,MAAM,CAAC;IAC5C,WAAW,EAAE,CAAC,CAAC,MAAM,EAAE;IACvB,QAAQ,EAAE,cAAc;IACxB,2EAA2E;IAC3E,SAAS,EAAE,CAAC,CAAC,GAAG,EAAE,CAAC,QAAQ,EAAE;CAC9B,CAAC,CAAC;AAGH;;;GAGG;AACH,MAAM,CAAC,MAAM,8BAA8B,GAAG,CAAC,CAAC,MAAM,CAAC;IACrD,YAAY,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,EAAE,EAAE,qBAAqB,CAAC;CAC1D,CAAC,CAAC;AAGH,gFAAgF;AAEhF;;;GAGG;AACH,MAAM,CAAC,MAAM,wBAAwB,GAAG,kBAAkB,CAAC;AAI3D;;;;;GAKG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG,CAAC,CAAC,MAAM,CAAC;IAC3C,eAAe,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC;CACnC,CAAC,CAAC","sourcesContent":["/**\n * founder-schema — Chat: config, auth token, opening a conversation, participants\n *\n * Founder Circle does not run a chat server. It uses the shared one, which\n * serves several products, and that is the whole reason this file is careful:\n * when one server serves many tenants, the failure mode is SILENT. A push once\n * returned success having sent nothing, because the caller held the wrong\n * product's key and nothing in the exchange said which product it was for.\n *\n * So the rule these shapes exist to hold: TENANCY COMES FROM THE CREDENTIAL,\n * never from a request body. The token says which product it is for, the chat\n * server resolves the product from the token and from nothing else, and a\n * Founder Circle token cannot address another product's conversation even when\n * handed a valid-looking id.\n *\n * ⚠️ TEXT ONLY. There is deliberately no `callsEnabled` flag here, and its\n * absence is the feature: the user ruled chat is text only, there is no call\n * code to branch on, and the browser is told to deny camera and microphone\n * outright. A boolean that can never be true is an invitation to turn it on.\n *\n * @module founder-schema/chat\n */\n\nimport { z } from 'zod';\nimport { objectIdSchema, usernameSchema } from './common.js';\nimport { CHAT_PARTICIPANTS_MAX } from './limits.js';\n\n/**\n * The product this package speaks for.\n *\n * Typed as a LITERAL on purpose. If the shared chat server ever hands back a\n * config or a token minted for a different product, parsing fails loudly here\n * rather than the client quietly talking to the wrong tenant. That is exactly\n * the class of bug this file exists to prevent, so it should be impossible to\n * carry past the first parse.\n */\nexport const CHAT_PRODUCT_ID = 'founder-circle' as const;\nexport const chatProductIdSchema = z.literal(CHAT_PRODUCT_ID);\nexport type ChatProductId = z.infer<typeof chatProductIdSchema>;\n\n// ─── Config ──────────────────────────────────────────────────────────────────\n\n/**\n * What the client needs before it can open a socket.\n *\n * ⚠️ `enabled` IS MANDATORY AND IS NOT DERIVED FROM `url`. \"Messaging is not\n * switched on yet\" is a first-class state, not a failure: a 404 is the expected\n * answer today, and the client collapses a disabled flag, a null url, a non-2xx\n * and a thrown fetch to the same quiet \"not on yet\" rather than an error. If\n * `enabled` were inferred from a missing url, \"off\" and \"broken\" would be the\n * same wire shape and the inbox could not tell a member which one it was.\n *\n * ⚠️ `userId` is required and is the field nothing works without. The client\n * needs to know WHO THE VIEWER IS to decide which side a bubble sits on, to\n * filter the viewer out of presence and typing, to pick the other participant\n * for a row's name and avatar, and to tell its own optimistic message from one\n * echoed back. Without it every conversation renders as though the viewer were\n * nobody. It was missing from the first sketch of this endpoint.\n */\nexport const chatConfigSchema = z.object({\n enabled: z.boolean(),\n /** Null whenever chat is not switched on. Never a placeholder string. */\n url: z.url().nullable(),\n /** The VIEWER's id, as the chat server knows them. */\n userId: z.string(),\n /** Which product this config is for. See `chatProductIdSchema`. */\n productId: chatProductIdSchema,\n});\nexport type ChatConfig = z.infer<typeof chatConfigSchema>;\n\n// ─── Auth token ──────────────────────────────────────────────────────────────\n\n/**\n * How long before expiry the client re-mints, in seconds.\n *\n * Exported because it is a contract detail and not an implementation one: a\n * token handed over with two seconds left is a socket that authenticates and is\n * closed again immediately, so the minting side needs to know the consuming\n * side will not use the last minute of a token's life.\n */\nexport const CHAT_TOKEN_REFRESH_MARGIN_SECONDS = 60;\n\n/**\n * How often the live socket re-authenticates, in seconds.\n *\n * Auth is an EVENT on the open socket rather than a handshake parameter, so\n * re-authenticating does not drop the connection.\n *\n * ⚠️ THIS MUST STAY BELOW THE REAL TOKEN LIFETIME, by more than the refresh\n * margin. It assumes a 15 minute token. A re-auth interval longer than the\n * lifetime is a socket that dies every quarter of an hour with no explanation,\n * and the symptom appears far from the cause. If the issued lifetime changes,\n * this number moves with it or the two silently disagree.\n */\nexport const CHAT_SOCKET_REAUTH_SECONDS = 10 * 60;\n\n/**\n * A short-lived token for the shared chat server.\n *\n * ⚠️ BOTH TIME FIELDS ARE KEPT, and they are not redundant, because two\n * different parties read them with two different clocks:\n *\n * `expiresAt` an absolute instant, for the SERVER. The chat server holds the\n * authoritative clock and must reject an expired token on its\n * own, whatever any client believes.\n * `expiresIn` a duration in seconds, for the CLIENT. Scheduling a refresh\n * from `receivedAt + expiresIn` uses only the client's own\n * elapsed time, so a device whose absolute clock is wrong still\n * refreshes correctly. Scheduling from `expiresAt` instead means\n * a browser an hour fast treats every fresh token as already\n * expired and re-mints forever.\n *\n * Dropping either one breaks the party that reads it. This is the same reason\n * OAuth carries `expires_in` on the wire while the token itself carries `exp`.\n *\n * ⚠️ AND THE CLIENT IS NOT THE ENFORCER. Its margin is an optimisation to avoid\n * a pointless round trip. A wrong clock, a tab asleep for an hour, or a modified\n * client all defeat anything the client does, so the chat server rejects an\n * expired token on its own.\n */\nexport const chatAuthTokenSchema = z.object({\n token: z.string().min(1),\n /** ISO 8601. Parsed with `Date.parse`, never an epoch number. */\n expiresAt: z.iso.datetime(),\n /** Seconds from issue. See the note above on why both exist. */\n expiresIn: z.number().int().positive(),\n /** The product this token is scoped to. A mismatch must fail loudly. */\n productId: chatProductIdSchema,\n});\nexport type ChatAuthToken = z.infer<typeof chatAuthTokenSchema>;\n\n// ─── Opening a conversation ──────────────────────────────────────────────────\n\n/**\n * `POST /api/v1/chat/open`: start (or find) the direct conversation with\n * another member.\n *\n * Our backend decides who may talk to whom, because only it knows this\n * product's rules, and it does the whole exchange with the chat server itself:\n * it mints the viewer's token and an invitation naming both participants, and\n * redeems them server to server. No credential reaches the browser.\n */\nexport const openChatBodySchema = z.object({\n /** The OTHER participant. The viewer is taken from the credential, never the body. */\n userId: objectIdSchema,\n});\nexport type OpenChatBody = z.infer<typeof openChatBodySchema>;\n\n/**\n * The conversation to navigate to. `201` with `wasCreated: true` the first\n * time, `200` and `false` on every later open of the same pair.\n */\nexport const openChatResponseSchema = z.object({\n conversationId: z.string().min(1),\n wasCreated: z.boolean(),\n});\nexport type OpenChatResponse = z.infer<typeof openChatResponseSchema>;\n\n// ─── Participants ────────────────────────────────────────────────────────────\n\n/**\n * `POST /api/v1/chat/participants`: names and avatars for the people in the\n * viewer's conversations.\n *\n * The chat server knows ids and nothing about who anybody is, because it serves\n * several products and identity belongs to each; so the conversation list asks\n * here, once per screen. Bounded by `CHAT_PARTICIPANTS_MAX`, so it cannot page\n * through the directory.\n */\nexport const chatParticipantsBodySchema = z.object({\n userIds: z.array(objectIdSchema).min(1).max(CHAT_PARTICIPANTS_MAX),\n});\nexport type ChatParticipantsBody = z.infer<typeof chatParticipantsBodySchema>;\n\n/** The public card fields any member already sees, and nothing more. */\nexport const chatParticipantSchema = z.object({\n displayName: z.string(),\n username: usernameSchema,\n /** Null when the member has no photo. Ours, or from the login provider. */\n avatarUrl: z.url().nullable(),\n});\nexport type ChatParticipant = z.infer<typeof chatParticipantSchema>;\n\n/**\n * Keyed by user id. ⚠ An id with no active, onboarded profile is ABSENT, not\n * an error: the client renders it as a former member rather than guessing.\n */\nexport const chatParticipantsResponseSchema = z.object({\n participants: z.record(z.string(), chatParticipantSchema),\n});\nexport type ChatParticipantsResponse = z.infer<typeof chatParticipantsResponseSchema>;\n\n// ─── Deprecated ──────────────────────────────────────────────────────────────\n\n/**\n * @deprecated Since 0.2.0: use `openChatBodySchema`, the same shape under the\n * name of the route that takes it. Removed in 0.3.0.\n */\nexport const chatInvitationBodySchema = openChatBodySchema;\n/** @deprecated Since 0.2.0: use `OpenChatBody`. Removed in 0.3.0. */\nexport type ChatInvitationBody = OpenChatBody;\n\n/**\n * @deprecated Since 0.2.0, and it describes nothing that exists. The browser\n * never holds an invitation: `/chat/open` mints and redeems it server to\n * server. The old `/invitation` route that handed one out could never have\n * worked and is gone. Removed in 0.3.0.\n */\nexport const chatInvitationSchema = z.object({\n invitationToken: z.string().min(1),\n});\n/** @deprecated Since 0.2.0. Removed in 0.3.0. */\nexport type ChatInvitation = z.infer<typeof chatInvitationSchema>;\n"]}
package/dist/common.d.ts CHANGED
@@ -20,6 +20,8 @@ export declare const emptyStringToUndefined: (value: unknown) => unknown;
20
20
  export declare const optionalTrimmedString: (maxLength?: number) => z.ZodPreprocess<z.ZodOptional<z.ZodString>, unknown>;
21
21
  /** A Mongo ObjectId as it crosses the wire: 24 lowercase hex characters. */
22
22
  export declare const objectIdSchema: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
23
+ /** Named for what it is on the wire, so it never collides with Mongoose's `ObjectId`. */
24
+ export type ObjectIdString = z.infer<typeof objectIdSchema>;
23
25
  /**
24
26
  * A URL safe to store and, after the consumer's own check, to render.
25
27
  *
@@ -53,19 +55,55 @@ export declare const objectIdSchema: z.ZodPipe<z.ZodString, z.ZodTransform<strin
53
55
  * (krish hit this rendering inline video, 2026-09-21, and handles it by parsing
54
56
  * and comparing the hostname exactly.)
55
57
  */
58
+ export declare const safeUrlSchema: z.ZodURL;
59
+ export type SafeUrl = z.infer<typeof safeUrlSchema>;
60
+ /**
61
+ * A URL a member TYPES: `safeUrlSchema` plus the `LINK_URL_MAX` ceiling, so
62
+ * every member URL carries the same cap and no consumer has to remember it.
63
+ *
64
+ * Two schemas, not one, because the cap is about what a person types and is
65
+ * wrong for what the server reads from elsewhere. Until 0.2.0 there was only
66
+ * this one, and it capped a link preview's CDN image URL too: one 314-character
67
+ * `imageUrl` would have failed a whole feed page.
68
+ */
56
69
  export declare const httpUrlSchema: z.ZodURL;
70
+ export type HttpUrl = z.infer<typeof httpUrlSchema>;
57
71
  /**
58
- * Public handle a founder picks and can change later.
72
+ * Public handle. Derived by the server at onboarding, changeable later.
59
73
  *
60
74
  * Rules are deliberately narrow because the handle appears in URLs and in
61
75
  * mentions: lowercase letters, digits, underscore and a single dot, 3 to 30
62
- * characters, must start with a letter. Reserved words are refused by the
63
- * backend, not here, so the list can change without a schema publish.
76
+ * characters, must start with a letter.
77
+ *
78
+ * This is the FORMAT, and it is what every read shape uses. A handle being
79
+ * CHOSEN must also not be reserved: that is `chosenUsernameSchema`.
64
80
  */
65
81
  export declare const USERNAME_MIN = 3;
66
82
  export declare const USERNAME_MAX = 30;
67
83
  export declare const usernameSchema: z.ZodString;
68
84
  export type Username = z.infer<typeof usernameSchema>;
85
+ /**
86
+ * Handles this product keeps for itself: route names a handle would shadow,
87
+ * and words that would let a member pass as staff.
88
+ *
89
+ * ⚠ HERE, IN THE CONTRACT, since 0.2.0. It used to live in the backend so it
90
+ * could grow without a publish, and nothing ever read it: the server derived
91
+ * handles without checking it, so a member called "Support" was handed
92
+ * `support`. One list, read by the server when it derives a handle and by any
93
+ * form that lets a member choose one, is worth a publish per addition.
94
+ *
95
+ * Matched exactly, after lowercasing.
96
+ */
97
+ export declare const RESERVED_USERNAMES: readonly ["admin", "about", "api", "chat", "discover", "feed", "founder", "foundercircle", "help", "home", "me", "meetup", "moderator", "onboarding", "settings", "staff", "superadmin", "support", "system", "team"];
98
+ export declare function isReservedUsername(handle: string): boolean;
99
+ /**
100
+ * A handle a member is CHOOSING: the format, and not reserved.
101
+ *
102
+ * ⚠ Not used on read shapes, deliberately. A handle stored before the reserved
103
+ * check existed must still READ, or one member's old handle would fail every
104
+ * page that shows their name.
105
+ */
106
+ export declare const chosenUsernameSchema: z.ZodString;
69
107
  /**
70
108
  * Stage of the founder's current venture.
71
109
  *
@@ -201,19 +239,55 @@ export type Gender = z.infer<typeof genderSchema>;
201
239
  * would be the copy nobody keeps in sync.
202
240
  */
203
241
  export declare const phoneSchema: z.ZodString;
242
+ export type Phone = z.infer<typeof phoneSchema>;
204
243
  /**
205
- * Where the founder is based. City and country are what Discover filters on;
206
- * coordinates are optional and only present when the member granted location.
244
+ * Where the founder is based. City and country are what Discover filters on.
207
245
  *
208
- * Note for later: this is a DECLARED location, not a GPS reading. Jansathi's
209
- * rule applies here too — never write a declared location from a GPS capture.
246
+ * ONE SHAPE for the profile, the Me tab's patch, onboarding's finish and the
247
+ * location suggestion. It was written out three times until 0.2.0, the same
248
+ * kind of copy that let the profile model lose Instagram.
249
+ *
250
+ * ⚠ `country` is REQUIRED and has NO default. A default here (it used to be
251
+ * `'India'`) is destructive on every write path: a founder in Berlin who
252
+ * corrects only their city would be moved to India by a value they never saw.
253
+ * A body that carries a location carries the whole location. Every stored
254
+ * location has a country, because the model requires one, so the read side
255
+ * loses nothing.
256
+ *
257
+ * This is a DECLARED location, not a GPS reading. Jansathi's rule applies
258
+ * here too: never write a declared location from a GPS capture.
210
259
  */
211
260
  export declare const locationSchema: z.ZodObject<{
212
261
  city: z.ZodString;
213
262
  state: z.ZodPreprocess<z.ZodOptional<z.ZodString>, unknown>;
214
- country: z.ZodDefault<z.ZodString>;
263
+ country: z.ZodString;
215
264
  }, z.core.$strip>;
216
265
  export type FounderLocation = z.infer<typeof locationSchema>;
266
+ /**
267
+ * `GET /api/v1/location/areas/reverse?lat=&lng=`: plain decimal degrees.
268
+ *
269
+ * A QUERY, so coercion is right here: everything in a URL is a string. An
270
+ * emptied parameter is "missing", never 0, because 0,0 is a real place in the
271
+ * Gulf of Guinea and would be looked up rather than refused.
272
+ */
273
+ export declare const coordinatesQuerySchema: z.ZodObject<{
274
+ lat: z.ZodPreprocess<z.ZodCoercedNumber<unknown>, unknown>;
275
+ lng: z.ZodPreprocess<z.ZodCoercedNumber<unknown>, unknown>;
276
+ }, z.core.$strip>;
277
+ export type CoordinatesQuery = z.infer<typeof coordinatesQuerySchema>;
278
+ export type CoordinatesQueryInput = z.input<typeof coordinatesQuerySchema>;
279
+ /**
280
+ * The city these coordinates look like, for the member to CONFIRM, or `null`
281
+ * when the lookup could not place them confidently (or they are outside India,
282
+ * whose area data is all there is). A suggestion, never a write: only what the
283
+ * member then submits becomes their location.
284
+ */
285
+ export declare const suggestedLocationSchema: z.ZodNullable<z.ZodObject<{
286
+ city: z.ZodString;
287
+ state: z.ZodPreprocess<z.ZodOptional<z.ZodString>, unknown>;
288
+ country: z.ZodString;
289
+ }, z.core.$strip>>;
290
+ export type SuggestedLocation = z.infer<typeof suggestedLocationSchema>;
217
291
  /**
218
292
  * Cursor pagination query. Feed and Discover both use it.
219
293
  *
@@ -225,4 +299,6 @@ export declare const paginationQuerySchema: z.ZodObject<{
225
299
  cursor: z.ZodOptional<z.ZodString>;
226
300
  }, z.core.$strip>;
227
301
  export type PaginationQuery = z.infer<typeof paginationQuerySchema>;
302
+ /** What a client may put IN the URL: every field optional, numbers as strings. */
303
+ export type PaginationQueryInput = z.input<typeof paginationQuerySchema>;
228
304
  //# sourceMappingURL=common.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"common.d.ts","sourceRoot":"","sources":["../src/common.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAIxB;;;;;;;GAOG;AACH,eAAO,MAAM,sBAAsB,GAAI,OAAO,OAAO,KAAG,OAGvD,CAAC;AAEF,yEAAyE;AACzE,eAAO,MAAM,qBAAqB,GAAI,kBAAe,yDAC8B,CAAC;AAEpF,4EAA4E;AAC5E,eAAO,MAAM,cAAc,wDAGS,CAAC;AAIrC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,eAAO,MAAM,aAAa,UAGxB,CAAC;AAIH;;;;;;;GAOG;AACH,eAAO,MAAM,YAAY,IAAI,CAAC;AAC9B,eAAO,MAAM,YAAY,KAAK,CAAC;AAE/B,eAAO,MAAM,cAAc,aASxB,CAAC;AACJ,MAAM,MAAM,QAAQ,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,cAAc,CAAC,CAAC;AAItD;;;;;GAKG;AACH,eAAO,MAAM,oBAAoB,qGASvB,CAAC;AACX,eAAO,MAAM,kBAAkB;;;;;;;;;EAA+B,CAAC;AAC/D,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAE9D;;;GAGG;AACH,eAAO,MAAM,eAAe,2PAsBlB,CAAC;AACX,eAAO,MAAM,cAAc;;;;;;;;;;;;;;;;;;;;;;EAA0B,CAAC;AACtD,MAAM,MAAM,QAAQ,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,cAAc,CAAC,CAAC;AAEtD;;;;GAIG;AACH,eAAO,MAAM,YAAY,8LAgBf,CAAC;AACX,eAAO,MAAM,WAAW;;;;;;;;;;;;;;;;EAAuB,CAAC;AAChD,MAAM,MAAM,KAAK,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEhD;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,eAAO,MAAM,WAAW,gMAYd,CAAC;AACX,eAAO,MAAM,UAAU;;;;;;;;;;;;EAAsB,CAAC;AAC9C,MAAM,MAAM,IAAI,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,UAAU,CAAC,CAAC;AAI9C;;;;;;;GAOG;AACH,eAAO,MAAM,aAAa,2DAA4D,CAAC;AACvF,eAAO,MAAM,YAAY;;;;;EAAwB,CAAC;AAClD,MAAM,MAAM,MAAM,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,YAAY,CAAC,CAAC;AAIlD;;;;;;;;GAQG;AACH,eAAO,MAAM,WAAW,aAGkC,CAAC;AAI3D;;;;;;GAMG;AACH,eAAO,MAAM,cAAc;;;;iBAIzB,CAAC;AACH,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,cAAc,CAAC,CAAC;AAI7D;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB;;;iBAShC,CAAC;AACH,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,qBAAqB,CAAC,CAAC"}
1
+ {"version":3,"file":"common.d.ts","sourceRoot":"","sources":["../src/common.ts"],"names":[],"mappings":"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"}