founder-schema 0.1.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.
- package/README.md +200 -0
- package/dist/chat.d.ts +139 -0
- package/dist/chat.d.ts.map +1 -0
- package/dist/chat.js +146 -0
- package/dist/chat.js.map +1 -0
- package/dist/common.d.ts +228 -0
- package/dist/common.d.ts.map +1 -0
- package/dist/common.js +248 -0
- package/dist/common.js.map +1 -0
- package/dist/discover.d.ts +153 -0
- package/dist/discover.d.ts.map +1 -0
- package/dist/discover.js +54 -0
- package/dist/discover.js.map +1 -0
- package/dist/feed.d.ts +1310 -0
- package/dist/feed.d.ts.map +1 -0
- package/dist/feed.js +593 -0
- package/dist/feed.js.map +1 -0
- package/dist/founder-profile.d.ts +357 -0
- package/dist/founder-profile.d.ts.map +1 -0
- package/dist/founder-profile.js +180 -0
- package/dist/founder-profile.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +23 -0
- package/dist/index.js.map +1 -0
- package/dist/onboarding.d.ts +638 -0
- package/dist/onboarding.d.ts.map +1 -0
- package/dist/onboarding.js +547 -0
- package/dist/onboarding.js.map +1 -0
- package/dist/responses/common.d.ts +49 -0
- package/dist/responses/common.d.ts.map +1 -0
- package/dist/responses/common.js +44 -0
- package/dist/responses/common.js.map +1 -0
- package/dist/responses/index.d.ts +7 -0
- package/dist/responses/index.d.ts.map +1 -0
- package/dist/responses/index.js +7 -0
- package/dist/responses/index.js.map +1 -0
- package/package.json +51 -0
package/README.md
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# founder-schema
|
|
2
|
+
|
|
3
|
+
The API contract for Founder Circle, written once as Zod schemas and shared by
|
|
4
|
+
every part of the product.
|
|
5
|
+
|
|
6
|
+
**Three names here differ, on purpose, so do not "correct" any of them.** The
|
|
7
|
+
npm package is `founder-schema`, the folder on disk is `founder-circle-schema`,
|
|
8
|
+
and the GitLab repo is `founder-schema` inside a group called
|
|
9
|
+
`entrepreneur-connect`. The product is Founder Circle and its `productId`, JWT
|
|
10
|
+
audience and database name all stay `founder-circle`; the group name is how the
|
|
11
|
+
work is filed, not what the product is called (settled with the user,
|
|
12
|
+
2026-09-21). Anything that resolves this package by NAME must read it from
|
|
13
|
+
`package.json`, never assume it matches the directory.
|
|
14
|
+
|
|
15
|
+
One definition of a founder profile, one definition of the Discover query, one
|
|
16
|
+
definition of the response envelope. The backend validates incoming requests
|
|
17
|
+
with these, the web client validates what comes back, and both get their
|
|
18
|
+
TypeScript types from the same place. When the package and a consumer disagree,
|
|
19
|
+
the package is right and the consumer is the bug.
|
|
20
|
+
|
|
21
|
+
Consumed by:
|
|
22
|
+
|
|
23
|
+
| Repo | What it uses today |
|
|
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 |
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Using it in a consumer, before the first publish
|
|
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.
|
|
38
|
+
|
|
39
|
+
```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
|
|
48
|
+
```
|
|
49
|
+
|
|
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:
|
|
57
|
+
|
|
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.
|
|
61
|
+
- **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.
|
|
65
|
+
- **Never commit a consumer whose `package.json` pins a version that is not on
|
|
66
|
+
npm.** The pin has to follow the publish, never lead it.
|
|
67
|
+
|
|
68
|
+
`zod` is a **peer** dependency (`^4.4.0`), on purpose. The consumer owns the Zod
|
|
69
|
+
instance; this package must not bring its own.
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## Publishing
|
|
74
|
+
|
|
75
|
+
**The version only ever moves on `dev`.** Never bump `version` on a feat branch
|
|
76
|
+
and never publish from one. The full procedure and the mandatory no-loss check
|
|
77
|
+
are in `personas/TEAM.md`, rule 5a. The short version of why: npm `latest` is one
|
|
78
|
+
global counter and a publish ships the whole package, so publishing from a branch
|
|
79
|
+
silently deletes from `latest` anything published since that branch was cut.
|
|
80
|
+
|
|
81
|
+
Two things that follow from it:
|
|
82
|
+
|
|
83
|
+
- A caret on a `0.x` version pins the **minor**: `^0.1.0` resolves `>=0.1.0 <0.2.0`
|
|
84
|
+
and will never pick up `0.2.0`. Bump consumers to the exact version explicitly.
|
|
85
|
+
- Removing or renaming an export is the only change here that can break a consumer
|
|
86
|
+
that already compiles. `tests/public-surface.test.ts` fails on any removal, which
|
|
87
|
+
is the cheap version of the publish-time no-loss check.
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## Layout
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
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
|
|
99
|
+
index.ts the public surface. Everything a consumer imports.
|
|
100
|
+
tests/
|
|
101
|
+
responses.test.ts the envelopes, pinned
|
|
102
|
+
public-surface.test.ts export-removal tripwire + the values the database is pinned to
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
A consumer imports from the package root and never from a deep path:
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
import { founderCardSchema, type FounderCard } from 'founder-schema';
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## Scripts
|
|
114
|
+
|
|
115
|
+
| Command | What it does |
|
|
116
|
+
|---|---|
|
|
117
|
+
| `npm run build` | Emits `dist/` with declarations and source maps |
|
|
118
|
+
| `npm run type-check` | `tsc --noEmit` over `src`, then again over `src` **and** `tests` |
|
|
119
|
+
| `npm test` | Vitest, run once |
|
|
120
|
+
| `npm run test:watch` | Vitest, watching |
|
|
121
|
+
| `npm run lint` | Biome lint |
|
|
122
|
+
| `npm run check` | type-check, then `biome check --write` |
|
|
123
|
+
|
|
124
|
+
There is no `postinstall` script and there must not be one. They break the
|
|
125
|
+
Dockerized builds in this family.
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## Traps that are specific to this package
|
|
130
|
+
|
|
131
|
+
**`.partial()` does not switch off `.default()`.** A patch body built as
|
|
132
|
+
`someSchema.pick({...}).partial()` still materialises every defaulted field, so a
|
|
133
|
+
member who edits one field has the others overwritten with the default. `tsc`
|
|
134
|
+
cannot see it and the request looks correct in the network tab. Build a write body
|
|
135
|
+
as its own object with no defaults, and have the client send only the keys the
|
|
136
|
+
member actually changed.
|
|
137
|
+
|
|
138
|
+
**A default inside a nested object is the same trap one level down.** A patch
|
|
139
|
+
carrying `{ location: { city: 'Berlin' } }` picks up `country: 'India'` from
|
|
140
|
+
`locationSchema`.
|
|
141
|
+
|
|
142
|
+
**A patch field the member can clear has three states, not two.** Absent means
|
|
143
|
+
do not touch. `null` means clear, and the backend maps it to `$unset`. An empty
|
|
144
|
+
string is **rejected**, deliberately: a form that forgets to convert a cleared
|
|
145
|
+
input to `null` fails loudly at the boundary instead of being silently dropped,
|
|
146
|
+
which would leave the member looking at text they just deleted.
|
|
147
|
+
`optionalTrimmedString` does that dropping, so it belongs in a create body or a
|
|
148
|
+
query, never in a patch body.
|
|
149
|
+
|
|
150
|
+
**Never `z.url()` for anything a member typed.** The one exception is
|
|
151
|
+
`avatarUrl`, which no write body accepts, so it takes a plain `z.url()` rather
|
|
152
|
+
than refusing hosts we serve avatars from ourselves. That exception is only as
|
|
153
|
+
true as the invariant under it, so `tests/public-surface.test.ts` asserts
|
|
154
|
+
`avatarUrl` stays out of every write body. Adding an avatar field to a form means
|
|
155
|
+
putting `httpUrlSchema` back first.
|
|
156
|
+
|
|
157
|
+
It accepts every scheme the URL
|
|
158
|
+
parser accepts, `javascript:`, `data:`, `vbscript:` and `file:` included, and
|
|
159
|
+
each of those is stored XSS as soon as a client renders it. Use `httpUrlSchema`,
|
|
160
|
+
which pins the scheme to http or https and the host to a real domain. The host
|
|
161
|
+
restriction is not cosmetic: this product runs on EC2 with an instance role, so a
|
|
162
|
+
stored `http://169.254.169.254/latest/meta-data/` that anything server-side ever
|
|
163
|
+
fetches is a credential read. A consumer still treats stored URLs as untrusted at
|
|
164
|
+
render time.
|
|
165
|
+
|
|
166
|
+
**A bound that reads the clock belongs in a `.refine()`, not a `.max()`.**
|
|
167
|
+
`z.number().max(new Date().getUTCFullYear())` is evaluated once, when the module
|
|
168
|
+
is imported, so a server process still up on 1 January rejects the new year until
|
|
169
|
+
somebody restarts it.
|
|
170
|
+
|
|
171
|
+
**Changing a member of an enum array is a data change, not a type change.** The
|
|
172
|
+
four `*_VALUES` arrays are Mongoose `enum` constraints in the backend. Removing or
|
|
173
|
+
renaming a value orphans documents already saved with it. Appending is safe.
|
|
174
|
+
`STARTUP_STAGE_VALUES` is ordered earliest to latest and that order is load
|
|
175
|
+
bearing, so append at the end and never insert.
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
## Open, and not ours to decide
|
|
180
|
+
|
|
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.
|
|
184
|
+
|
|
185
|
+
## Contract decisions, 2026-09-21
|
|
186
|
+
|
|
187
|
+
Seven defects were found in the first scaffold, proven by parsing the build, and
|
|
188
|
+
all seven are fixed. Recorded here because each one settled a rule that applies to
|
|
189
|
+
everything added later, and because the tests that pin them read as arbitrary
|
|
190
|
+
without the reason.
|
|
191
|
+
|
|
192
|
+
| What it was | The rule now |
|
|
193
|
+
|---|---|
|
|
194
|
+
| The patch body was `pick().partial()`, so every request rewrote `skills`, `lookingFor` and `links` with their defaults | A write body is its own object with no defaults. Defaults live on the read side |
|
|
195
|
+
| A member could not clear `headline` or `about` | Absent, `null` and `""` are three different things. See the trap above |
|
|
196
|
+
| `z.url()` accepted `javascript:`, `data:`, `vbscript:`, `file:` | `httpUrlSchema` everywhere a member supplies a URL |
|
|
197
|
+
| `foundedYear`'s ceiling was captured at import | Read the clock per parse, in a `.refine()` |
|
|
198
|
+
| The paginated envelope's comment contradicted its schema | `nextCursor` is required and nullable. A page always states whether more exist |
|
|
199
|
+
| A patch carrying a city inherited `country: 'India'` | The patch location has no country default and requires the country |
|
|
200
|
+
| An emptied search box answered 400 | An emptied filter means "not provided" |
|
package/dist/chat.d.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* founder-schema — Chat: config, auth token, invitation
|
|
3
|
+
*
|
|
4
|
+
* Founder Circle does not run a chat server. It uses the shared one, which
|
|
5
|
+
* serves several products, and that is the whole reason this file is careful:
|
|
6
|
+
* when one server serves many tenants, the failure mode is SILENT. A push once
|
|
7
|
+
* returned success having sent nothing, because the caller held the wrong
|
|
8
|
+
* product's key and nothing in the exchange said which product it was for.
|
|
9
|
+
*
|
|
10
|
+
* So the rule these shapes exist to hold: TENANCY COMES FROM THE CREDENTIAL,
|
|
11
|
+
* never from a request body. The token says which product it is for, the chat
|
|
12
|
+
* server resolves the product from the token and from nothing else, and a
|
|
13
|
+
* Founder Circle token cannot address another product's conversation even when
|
|
14
|
+
* handed a valid-looking id.
|
|
15
|
+
*
|
|
16
|
+
* ⚠️ TEXT ONLY. There is deliberately no `callsEnabled` flag here, and its
|
|
17
|
+
* absence is the feature: the user ruled chat is text only, there is no call
|
|
18
|
+
* code to branch on, and the browser is told to deny camera and microphone
|
|
19
|
+
* outright. A boolean that can never be true is an invitation to turn it on.
|
|
20
|
+
*
|
|
21
|
+
* @module founder-schema/chat
|
|
22
|
+
*/
|
|
23
|
+
import { z } from 'zod';
|
|
24
|
+
/**
|
|
25
|
+
* The product this package speaks for.
|
|
26
|
+
*
|
|
27
|
+
* Typed as a LITERAL on purpose. If the shared chat server ever hands back a
|
|
28
|
+
* config or a token minted for a different product, parsing fails loudly here
|
|
29
|
+
* rather than the client quietly talking to the wrong tenant. That is exactly
|
|
30
|
+
* the class of bug this file exists to prevent, so it should be impossible to
|
|
31
|
+
* carry past the first parse.
|
|
32
|
+
*/
|
|
33
|
+
export declare const CHAT_PRODUCT_ID: "founder-circle";
|
|
34
|
+
export declare const chatProductIdSchema: z.ZodLiteral<"founder-circle">;
|
|
35
|
+
/**
|
|
36
|
+
* What the client needs before it can open a socket.
|
|
37
|
+
*
|
|
38
|
+
* ⚠️ `enabled` IS MANDATORY AND IS NOT DERIVED FROM `url`. "Messaging is not
|
|
39
|
+
* switched on yet" is a first-class state, not a failure: a 404 is the expected
|
|
40
|
+
* answer today, and the client collapses a disabled flag, a null url, a non-2xx
|
|
41
|
+
* and a thrown fetch to the same quiet "not on yet" rather than an error. If
|
|
42
|
+
* `enabled` were inferred from a missing url, "off" and "broken" would be the
|
|
43
|
+
* same wire shape and the inbox could not tell a member which one it was.
|
|
44
|
+
*
|
|
45
|
+
* ⚠️ `userId` is required and is the field nothing works without. The client
|
|
46
|
+
* needs to know WHO THE VIEWER IS to decide which side a bubble sits on, to
|
|
47
|
+
* filter the viewer out of presence and typing, to pick the other participant
|
|
48
|
+
* for a row's name and avatar, and to tell its own optimistic message from one
|
|
49
|
+
* echoed back. Without it every conversation renders as though the viewer were
|
|
50
|
+
* nobody. It was missing from the first sketch of this endpoint.
|
|
51
|
+
*/
|
|
52
|
+
export declare const chatConfigSchema: z.ZodObject<{
|
|
53
|
+
enabled: z.ZodBoolean;
|
|
54
|
+
url: z.ZodNullable<z.ZodURL>;
|
|
55
|
+
userId: z.ZodString;
|
|
56
|
+
productId: z.ZodLiteral<"founder-circle">;
|
|
57
|
+
}, z.core.$strip>;
|
|
58
|
+
export type ChatConfig = z.infer<typeof chatConfigSchema>;
|
|
59
|
+
/**
|
|
60
|
+
* How long before expiry the client re-mints, in seconds.
|
|
61
|
+
*
|
|
62
|
+
* Exported because it is a contract detail and not an implementation one: a
|
|
63
|
+
* token handed over with two seconds left is a socket that authenticates and is
|
|
64
|
+
* closed again immediately, so the minting side needs to know the consuming
|
|
65
|
+
* side will not use the last minute of a token's life.
|
|
66
|
+
*/
|
|
67
|
+
export declare const CHAT_TOKEN_REFRESH_MARGIN_SECONDS = 60;
|
|
68
|
+
/**
|
|
69
|
+
* How often the live socket re-authenticates, in seconds.
|
|
70
|
+
*
|
|
71
|
+
* Auth is an EVENT on the open socket rather than a handshake parameter, so
|
|
72
|
+
* re-authenticating does not drop the connection.
|
|
73
|
+
*
|
|
74
|
+
* ⚠️ THIS MUST STAY BELOW THE REAL TOKEN LIFETIME, by more than the refresh
|
|
75
|
+
* margin. It assumes a 15 minute token. A re-auth interval longer than the
|
|
76
|
+
* lifetime is a socket that dies every quarter of an hour with no explanation,
|
|
77
|
+
* and the symptom appears far from the cause. If the issued lifetime changes,
|
|
78
|
+
* this number moves with it or the two silently disagree.
|
|
79
|
+
*/
|
|
80
|
+
export declare const CHAT_SOCKET_REAUTH_SECONDS: number;
|
|
81
|
+
/**
|
|
82
|
+
* A short-lived token for the shared chat server.
|
|
83
|
+
*
|
|
84
|
+
* ⚠️ BOTH TIME FIELDS ARE KEPT, and they are not redundant, because two
|
|
85
|
+
* different parties read them with two different clocks:
|
|
86
|
+
*
|
|
87
|
+
* `expiresAt` an absolute instant, for the SERVER. The chat server holds the
|
|
88
|
+
* authoritative clock and must reject an expired token on its
|
|
89
|
+
* own, whatever any client believes.
|
|
90
|
+
* `expiresIn` a duration in seconds, for the CLIENT. Scheduling a refresh
|
|
91
|
+
* from `receivedAt + expiresIn` uses only the client's own
|
|
92
|
+
* elapsed time, so a device whose absolute clock is wrong still
|
|
93
|
+
* refreshes correctly. Scheduling from `expiresAt` instead means
|
|
94
|
+
* a browser an hour fast treats every fresh token as already
|
|
95
|
+
* expired and re-mints forever.
|
|
96
|
+
*
|
|
97
|
+
* Dropping either one breaks the party that reads it. This is the same reason
|
|
98
|
+
* OAuth carries `expires_in` on the wire while the token itself carries `exp`.
|
|
99
|
+
*
|
|
100
|
+
* ⚠️ AND THE CLIENT IS NOT THE ENFORCER. Its margin is an optimisation to avoid
|
|
101
|
+
* a pointless round trip. A wrong clock, a tab asleep for an hour, or a modified
|
|
102
|
+
* client all defeat anything the client does, so the chat server rejects an
|
|
103
|
+
* expired token on its own.
|
|
104
|
+
*/
|
|
105
|
+
export declare const chatAuthTokenSchema: z.ZodObject<{
|
|
106
|
+
token: z.ZodString;
|
|
107
|
+
expiresAt: z.ZodISODateTime;
|
|
108
|
+
expiresIn: z.ZodNumber;
|
|
109
|
+
productId: z.ZodLiteral<"founder-circle">;
|
|
110
|
+
}, z.core.$strip>;
|
|
111
|
+
export type ChatAuthToken = z.infer<typeof chatAuthTokenSchema>;
|
|
112
|
+
/**
|
|
113
|
+
* Asking our own backend for permission to start a conversation with somebody.
|
|
114
|
+
*
|
|
115
|
+
* 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.
|
|
117
|
+
*/
|
|
118
|
+
export declare const chatInvitationBodySchema: z.ZodObject<{
|
|
119
|
+
userId: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
|
|
120
|
+
}, z.core.$strip>;
|
|
121
|
+
export type ChatInvitationBody = z.infer<typeof chatInvitationBodySchema>;
|
|
122
|
+
/**
|
|
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.
|
|
129
|
+
*
|
|
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.
|
|
134
|
+
*/
|
|
135
|
+
export declare const chatInvitationSchema: z.ZodObject<{
|
|
136
|
+
invitationToken: z.ZodString;
|
|
137
|
+
}, z.core.$strip>;
|
|
138
|
+
export type ChatInvitation = z.infer<typeof chatInvitationSchema>;
|
|
139
|
+
//# sourceMappingURL=chat.d.ts.map
|
|
@@ -0,0 +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"}
|
package/dist/chat.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* founder-schema — Chat: config, auth token, invitation
|
|
3
|
+
*
|
|
4
|
+
* Founder Circle does not run a chat server. It uses the shared one, which
|
|
5
|
+
* serves several products, and that is the whole reason this file is careful:
|
|
6
|
+
* when one server serves many tenants, the failure mode is SILENT. A push once
|
|
7
|
+
* returned success having sent nothing, because the caller held the wrong
|
|
8
|
+
* product's key and nothing in the exchange said which product it was for.
|
|
9
|
+
*
|
|
10
|
+
* So the rule these shapes exist to hold: TENANCY COMES FROM THE CREDENTIAL,
|
|
11
|
+
* never from a request body. The token says which product it is for, the chat
|
|
12
|
+
* server resolves the product from the token and from nothing else, and a
|
|
13
|
+
* Founder Circle token cannot address another product's conversation even when
|
|
14
|
+
* handed a valid-looking id.
|
|
15
|
+
*
|
|
16
|
+
* ⚠️ TEXT ONLY. There is deliberately no `callsEnabled` flag here, and its
|
|
17
|
+
* absence is the feature: the user ruled chat is text only, there is no call
|
|
18
|
+
* code to branch on, and the browser is told to deny camera and microphone
|
|
19
|
+
* outright. A boolean that can never be true is an invitation to turn it on.
|
|
20
|
+
*
|
|
21
|
+
* @module founder-schema/chat
|
|
22
|
+
*/
|
|
23
|
+
import { z } from 'zod';
|
|
24
|
+
import { objectIdSchema } from './common.js';
|
|
25
|
+
/**
|
|
26
|
+
* The product this package speaks for.
|
|
27
|
+
*
|
|
28
|
+
* Typed as a LITERAL on purpose. If the shared chat server ever hands back a
|
|
29
|
+
* config or a token minted for a different product, parsing fails loudly here
|
|
30
|
+
* rather than the client quietly talking to the wrong tenant. That is exactly
|
|
31
|
+
* the class of bug this file exists to prevent, so it should be impossible to
|
|
32
|
+
* carry past the first parse.
|
|
33
|
+
*/
|
|
34
|
+
export const CHAT_PRODUCT_ID = 'founder-circle';
|
|
35
|
+
export const chatProductIdSchema = z.literal(CHAT_PRODUCT_ID);
|
|
36
|
+
// ─── Config ──────────────────────────────────────────────────────────────────
|
|
37
|
+
/**
|
|
38
|
+
* What the client needs before it can open a socket.
|
|
39
|
+
*
|
|
40
|
+
* ⚠️ `enabled` IS MANDATORY AND IS NOT DERIVED FROM `url`. "Messaging is not
|
|
41
|
+
* switched on yet" is a first-class state, not a failure: a 404 is the expected
|
|
42
|
+
* answer today, and the client collapses a disabled flag, a null url, a non-2xx
|
|
43
|
+
* and a thrown fetch to the same quiet "not on yet" rather than an error. If
|
|
44
|
+
* `enabled` were inferred from a missing url, "off" and "broken" would be the
|
|
45
|
+
* same wire shape and the inbox could not tell a member which one it was.
|
|
46
|
+
*
|
|
47
|
+
* ⚠️ `userId` is required and is the field nothing works without. The client
|
|
48
|
+
* needs to know WHO THE VIEWER IS to decide which side a bubble sits on, to
|
|
49
|
+
* filter the viewer out of presence and typing, to pick the other participant
|
|
50
|
+
* for a row's name and avatar, and to tell its own optimistic message from one
|
|
51
|
+
* echoed back. Without it every conversation renders as though the viewer were
|
|
52
|
+
* nobody. It was missing from the first sketch of this endpoint.
|
|
53
|
+
*/
|
|
54
|
+
export const chatConfigSchema = z.object({
|
|
55
|
+
enabled: z.boolean(),
|
|
56
|
+
/** Null whenever chat is not switched on. Never a placeholder string. */
|
|
57
|
+
url: z.url().nullable(),
|
|
58
|
+
/** The VIEWER's id, as the chat server knows them. */
|
|
59
|
+
userId: z.string(),
|
|
60
|
+
/** Which product this config is for. See `chatProductIdSchema`. */
|
|
61
|
+
productId: chatProductIdSchema,
|
|
62
|
+
});
|
|
63
|
+
// ─── Auth token ──────────────────────────────────────────────────────────────
|
|
64
|
+
/**
|
|
65
|
+
* How long before expiry the client re-mints, in seconds.
|
|
66
|
+
*
|
|
67
|
+
* Exported because it is a contract detail and not an implementation one: a
|
|
68
|
+
* token handed over with two seconds left is a socket that authenticates and is
|
|
69
|
+
* closed again immediately, so the minting side needs to know the consuming
|
|
70
|
+
* side will not use the last minute of a token's life.
|
|
71
|
+
*/
|
|
72
|
+
export const CHAT_TOKEN_REFRESH_MARGIN_SECONDS = 60;
|
|
73
|
+
/**
|
|
74
|
+
* How often the live socket re-authenticates, in seconds.
|
|
75
|
+
*
|
|
76
|
+
* Auth is an EVENT on the open socket rather than a handshake parameter, so
|
|
77
|
+
* re-authenticating does not drop the connection.
|
|
78
|
+
*
|
|
79
|
+
* ⚠️ THIS MUST STAY BELOW THE REAL TOKEN LIFETIME, by more than the refresh
|
|
80
|
+
* margin. It assumes a 15 minute token. A re-auth interval longer than the
|
|
81
|
+
* lifetime is a socket that dies every quarter of an hour with no explanation,
|
|
82
|
+
* and the symptom appears far from the cause. If the issued lifetime changes,
|
|
83
|
+
* this number moves with it or the two silently disagree.
|
|
84
|
+
*/
|
|
85
|
+
export const CHAT_SOCKET_REAUTH_SECONDS = 10 * 60;
|
|
86
|
+
/**
|
|
87
|
+
* A short-lived token for the shared chat server.
|
|
88
|
+
*
|
|
89
|
+
* ⚠️ BOTH TIME FIELDS ARE KEPT, and they are not redundant, because two
|
|
90
|
+
* different parties read them with two different clocks:
|
|
91
|
+
*
|
|
92
|
+
* `expiresAt` an absolute instant, for the SERVER. The chat server holds the
|
|
93
|
+
* authoritative clock and must reject an expired token on its
|
|
94
|
+
* own, whatever any client believes.
|
|
95
|
+
* `expiresIn` a duration in seconds, for the CLIENT. Scheduling a refresh
|
|
96
|
+
* from `receivedAt + expiresIn` uses only the client's own
|
|
97
|
+
* elapsed time, so a device whose absolute clock is wrong still
|
|
98
|
+
* refreshes correctly. Scheduling from `expiresAt` instead means
|
|
99
|
+
* a browser an hour fast treats every fresh token as already
|
|
100
|
+
* expired and re-mints forever.
|
|
101
|
+
*
|
|
102
|
+
* Dropping either one breaks the party that reads it. This is the same reason
|
|
103
|
+
* OAuth carries `expires_in` on the wire while the token itself carries `exp`.
|
|
104
|
+
*
|
|
105
|
+
* ⚠️ AND THE CLIENT IS NOT THE ENFORCER. Its margin is an optimisation to avoid
|
|
106
|
+
* a pointless round trip. A wrong clock, a tab asleep for an hour, or a modified
|
|
107
|
+
* client all defeat anything the client does, so the chat server rejects an
|
|
108
|
+
* expired token on its own.
|
|
109
|
+
*/
|
|
110
|
+
export const chatAuthTokenSchema = z.object({
|
|
111
|
+
token: z.string().min(1),
|
|
112
|
+
/** ISO 8601. Parsed with `Date.parse`, never an epoch number. */
|
|
113
|
+
expiresAt: z.iso.datetime(),
|
|
114
|
+
/** Seconds from issue. See the note above on why both exist. */
|
|
115
|
+
expiresIn: z.number().int().positive(),
|
|
116
|
+
/** The product this token is scoped to. A mismatch must fail loudly. */
|
|
117
|
+
productId: chatProductIdSchema,
|
|
118
|
+
});
|
|
119
|
+
// ─── Invitation ──────────────────────────────────────────────────────────────
|
|
120
|
+
/**
|
|
121
|
+
* Asking our own backend for permission to start a conversation with somebody.
|
|
122
|
+
*
|
|
123
|
+
* 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.
|
|
125
|
+
*/
|
|
126
|
+
export const chatInvitationBodySchema = z.object({
|
|
127
|
+
/** The OTHER participant. The viewer is taken from the credential. */
|
|
128
|
+
userId: objectIdSchema,
|
|
129
|
+
});
|
|
130
|
+
/**
|
|
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.
|
|
137
|
+
*
|
|
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.
|
|
142
|
+
*/
|
|
143
|
+
export const chatInvitationSchema = z.object({
|
|
144
|
+
invitationToken: z.string().min(1),
|
|
145
|
+
});
|
|
146
|
+
//# sourceMappingURL=chat.js.map
|
package/dist/chat.js.map
ADDED
|
@@ -0,0 +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"]}
|