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
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
 
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The member's Founder Circle account
3
+ * ===================================
4
+ * Deleting it is PER PRODUCT. It removes the member from Founder Circle and
5
+ * leaves their central-auth account, and every other product they use,
6
+ * exactly as it was. Central auth deleting the whole account arrives here as
7
+ * its `user.deleted` webhook and has the same outcome.
8
+ *
9
+ * @module founder-schema/account
10
+ */
11
+ import { z } from 'zod';
12
+ /**
13
+ * `DELETE /api/v1/users/me`. No body: the confirmation (typing the account's
14
+ * email) is the client's, and the server's check is the signed-in session.
15
+ *
16
+ * What it does, all of it before it answers:
17
+ * - the founder profile stops being listed or reachable;
18
+ * - the member's posts, polls and comments are deleted, and the comment
19
+ * counts on other people's posts go down with them;
20
+ * - their reactions, bookmarks and their votes on polls still open are
21
+ * withdrawn (a closed poll's result is final and is not rewritten);
22
+ * - their messages are erased from chat and they leave every conversation;
23
+ * - the onboarding draft is discarded.
24
+ *
25
+ * Signing in to Founder Circle again later starts a NEW, empty account.
26
+ *
27
+ * Refusals: `SERVICE_UNAVAILABLE` (503) when the chat server could not erase
28
+ * the messages. NOTHING was deleted then, and retrying is safe.
29
+ * `RATE_LIMITED` (429). `UNAUTHORIZED` / `TOKEN_EXPIRED` /
30
+ * `SESSION_INVALIDATED` (401) and `FORBIDDEN` (403, an account that is not
31
+ * active) as for every signed-in call.
32
+ *
33
+ * The client signs out after a success: the account is gone, the cookies on
34
+ * the device are not.
35
+ */
36
+ export declare const deleteAccountResponseSchema: z.ZodObject<{
37
+ deleted: z.ZodLiteral<true>;
38
+ }, z.core.$strip>;
39
+ export type DeleteAccountResponse = z.infer<typeof deleteAccountResponseSchema>;
40
+ //# sourceMappingURL=account.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"account.d.ts","sourceRoot":"","sources":["../src/account.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,eAAO,MAAM,2BAA2B;;iBAEtC,CAAC;AACH,MAAM,MAAM,qBAAqB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,2BAA2B,CAAC,CAAC"}
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The member's Founder Circle account
3
+ * ===================================
4
+ * Deleting it is PER PRODUCT. It removes the member from Founder Circle and
5
+ * leaves their central-auth account, and every other product they use,
6
+ * exactly as it was. Central auth deleting the whole account arrives here as
7
+ * its `user.deleted` webhook and has the same outcome.
8
+ *
9
+ * @module founder-schema/account
10
+ */
11
+ import { z } from 'zod';
12
+ /**
13
+ * `DELETE /api/v1/users/me`. No body: the confirmation (typing the account's
14
+ * email) is the client's, and the server's check is the signed-in session.
15
+ *
16
+ * What it does, all of it before it answers:
17
+ * - the founder profile stops being listed or reachable;
18
+ * - the member's posts, polls and comments are deleted, and the comment
19
+ * counts on other people's posts go down with them;
20
+ * - their reactions, bookmarks and their votes on polls still open are
21
+ * withdrawn (a closed poll's result is final and is not rewritten);
22
+ * - their messages are erased from chat and they leave every conversation;
23
+ * - the onboarding draft is discarded.
24
+ *
25
+ * Signing in to Founder Circle again later starts a NEW, empty account.
26
+ *
27
+ * Refusals: `SERVICE_UNAVAILABLE` (503) when the chat server could not erase
28
+ * the messages. NOTHING was deleted then, and retrying is safe.
29
+ * `RATE_LIMITED` (429). `UNAUTHORIZED` / `TOKEN_EXPIRED` /
30
+ * `SESSION_INVALIDATED` (401) and `FORBIDDEN` (403, an account that is not
31
+ * active) as for every signed-in call.
32
+ *
33
+ * The client signs out after a success: the account is gone, the cookies on
34
+ * the device are not.
35
+ */
36
+ export const deleteAccountResponseSchema = z.object({
37
+ deleted: z.literal(true),
38
+ });
39
+ //# sourceMappingURL=account.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"account.js","sourceRoot":"","sources":["../src/account.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,MAAM,CAAC,MAAM,2BAA2B,GAAG,CAAC,CAAC,MAAM,CAAC;IAClD,OAAO,EAAE,CAAC,CAAC,OAAO,CAAC,IAAI,CAAC;CACzB,CAAC,CAAC","sourcesContent":["/**\n * The member's Founder Circle account\n * ===================================\n * Deleting it is PER PRODUCT. It removes the member from Founder Circle and\n * leaves their central-auth account, and every other product they use,\n * exactly as it was. Central auth deleting the whole account arrives here as\n * its `user.deleted` webhook and has the same outcome.\n *\n * @module founder-schema/account\n */\n\nimport { z } from 'zod';\n\n/**\n * `DELETE /api/v1/users/me`. No body: the confirmation (typing the account's\n * email) is the client's, and the server's check is the signed-in session.\n *\n * What it does, all of it before it answers:\n * - the founder profile stops being listed or reachable;\n * - the member's posts, polls and comments are deleted, and the comment\n * counts on other people's posts go down with them;\n * - their reactions, bookmarks and their votes on polls still open are\n * withdrawn (a closed poll's result is final and is not rewritten);\n * - their messages are erased from chat and they leave every conversation;\n * - the onboarding draft is discarded.\n *\n * Signing in to Founder Circle again later starts a NEW, empty account.\n *\n * Refusals: `SERVICE_UNAVAILABLE` (503) when the chat server could not erase\n * the messages. NOTHING was deleted then, and retrying is safe.\n * `RATE_LIMITED` (429). `UNAUTHORIZED` / `TOKEN_EXPIRED` /\n * `SESSION_INVALIDATED` (401) and `FORBIDDEN` (403, an account that is not\n * active) as for every signed-in call.\n *\n * The client signs out after a success: the account is gone, the cookies on\n * the device are not.\n */\nexport const deleteAccountResponseSchema = z.object({\n deleted: z.literal(true),\n});\nexport type DeleteAccountResponse = z.infer<typeof deleteAccountResponseSchema>;\n"]}
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"]}