@oxy.so/contracts 1.0.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 (147) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +16 -0
  3. package/dist/cjs/.tsbuildinfo +1 -0
  4. package/dist/cjs/accountGraph.js +489 -0
  5. package/dist/cjs/agency.js +439 -0
  6. package/dist/cjs/browserHub.js +215 -0
  7. package/dist/cjs/civic.js +163 -0
  8. package/dist/cjs/commonsSignIn.js +59 -0
  9. package/dist/cjs/deviceBoot.js +50 -0
  10. package/dist/cjs/deviceDirectory.js +189 -0
  11. package/dist/cjs/devicePairing.js +138 -0
  12. package/dist/cjs/deviceSession.js +164 -0
  13. package/dist/cjs/emailAgentContext.js +32 -0
  14. package/dist/cjs/followGraph.js +28 -0
  15. package/dist/cjs/identity.js +258 -0
  16. package/dist/cjs/inboxPush.js +24 -0
  17. package/dist/cjs/index.js +618 -0
  18. package/dist/cjs/inference/accountBilling.js +334 -0
  19. package/dist/cjs/inference/aliaModelRelease.js +262 -0
  20. package/dist/cjs/inference/attribution.js +106 -0
  21. package/dist/cjs/inference/catalogue.js +487 -0
  22. package/dist/cjs/inference/entitlement.js +217 -0
  23. package/dist/cjs/inference/errors.js +309 -0
  24. package/dist/cjs/inference/identifiers.js +224 -0
  25. package/dist/cjs/inference/inbox.js +105 -0
  26. package/dist/cjs/inference/modelDocumentation.js +433 -0
  27. package/dist/cjs/inference/money.js +188 -0
  28. package/dist/cjs/inference/priceVersion.js +110 -0
  29. package/dist/cjs/inference/providerConnection.js +455 -0
  30. package/dist/cjs/inference/request.js +477 -0
  31. package/dist/cjs/inference/routingPolicy.js +318 -0
  32. package/dist/cjs/inference/streamEvents.js +258 -0
  33. package/dist/cjs/inference/usage.js +329 -0
  34. package/dist/cjs/inference/version.js +105 -0
  35. package/dist/cjs/keyRecovery.js +91 -0
  36. package/dist/cjs/keyRotation.js +75 -0
  37. package/dist/cjs/links.js +68 -0
  38. package/dist/cjs/moderationReputation.js +298 -0
  39. package/dist/cjs/oauth.js +66 -0
  40. package/dist/cjs/oxyRecordTypes.js +71 -0
  41. package/dist/cjs/protocol.js +53 -0
  42. package/dist/cjs/recommendations.js +168 -0
  43. package/dist/cjs/reputation.js +297 -0
  44. package/dist/cjs/sessionStatus.js +121 -0
  45. package/dist/cjs/transparency.js +89 -0
  46. package/dist/cjs/updates.js +252 -0
  47. package/dist/cjs/userInvalidation.js +89 -0
  48. package/dist/cjs/userResponse.js +245 -0
  49. package/dist/cjs/username.js +290 -0
  50. package/dist/cjs/webauthn.js +71 -0
  51. package/dist/esm/.tsbuildinfo +1 -0
  52. package/dist/esm/accountGraph.js +480 -0
  53. package/dist/esm/agency.js +436 -0
  54. package/dist/esm/browserHub.js +212 -0
  55. package/dist/esm/civic.js +160 -0
  56. package/dist/esm/commonsSignIn.js +56 -0
  57. package/dist/esm/deviceBoot.js +47 -0
  58. package/dist/esm/deviceDirectory.js +186 -0
  59. package/dist/esm/devicePairing.js +135 -0
  60. package/dist/esm/deviceSession.js +161 -0
  61. package/dist/esm/emailAgentContext.js +29 -0
  62. package/dist/esm/followGraph.js +27 -0
  63. package/dist/esm/identity.js +255 -0
  64. package/dist/esm/inboxPush.js +21 -0
  65. package/dist/esm/index.js +172 -0
  66. package/dist/esm/inference/accountBilling.js +331 -0
  67. package/dist/esm/inference/aliaModelRelease.js +259 -0
  68. package/dist/esm/inference/attribution.js +103 -0
  69. package/dist/esm/inference/catalogue.js +484 -0
  70. package/dist/esm/inference/entitlement.js +214 -0
  71. package/dist/esm/inference/errors.js +306 -0
  72. package/dist/esm/inference/identifiers.js +221 -0
  73. package/dist/esm/inference/inbox.js +102 -0
  74. package/dist/esm/inference/modelDocumentation.js +430 -0
  75. package/dist/esm/inference/money.js +185 -0
  76. package/dist/esm/inference/priceVersion.js +107 -0
  77. package/dist/esm/inference/providerConnection.js +452 -0
  78. package/dist/esm/inference/request.js +474 -0
  79. package/dist/esm/inference/routingPolicy.js +315 -0
  80. package/dist/esm/inference/streamEvents.js +255 -0
  81. package/dist/esm/inference/usage.js +326 -0
  82. package/dist/esm/inference/version.js +102 -0
  83. package/dist/esm/keyRecovery.js +88 -0
  84. package/dist/esm/keyRotation.js +72 -0
  85. package/dist/esm/links.js +65 -0
  86. package/dist/esm/moderationReputation.js +295 -0
  87. package/dist/esm/oauth.js +63 -0
  88. package/dist/esm/oxyRecordTypes.js +68 -0
  89. package/dist/esm/protocol.js +50 -0
  90. package/dist/esm/recommendations.js +165 -0
  91. package/dist/esm/reputation.js +293 -0
  92. package/dist/esm/sessionStatus.js +118 -0
  93. package/dist/esm/transparency.js +86 -0
  94. package/dist/esm/updates.js +249 -0
  95. package/dist/esm/userInvalidation.js +85 -0
  96. package/dist/esm/userResponse.js +240 -0
  97. package/dist/esm/username.js +283 -0
  98. package/dist/esm/webauthn.js +68 -0
  99. package/dist/types/.tsbuildinfo +1 -0
  100. package/dist/types/accountGraph.d.ts +378 -0
  101. package/dist/types/agency.d.ts +2162 -0
  102. package/dist/types/browserHub.d.ts +856 -0
  103. package/dist/types/civic.d.ts +338 -0
  104. package/dist/types/commonsSignIn.d.ts +58 -0
  105. package/dist/types/deviceBoot.d.ts +74 -0
  106. package/dist/types/deviceDirectory.d.ts +1317 -0
  107. package/dist/types/devicePairing.d.ts +130 -0
  108. package/dist/types/deviceSession.d.ts +411 -0
  109. package/dist/types/emailAgentContext.d.ts +248 -0
  110. package/dist/types/followGraph.d.ts +150 -0
  111. package/dist/types/identity.d.ts +402 -0
  112. package/dist/types/inboxPush.d.ts +30 -0
  113. package/dist/types/index.d.ts +100 -0
  114. package/dist/types/inference/accountBilling.d.ts +738 -0
  115. package/dist/types/inference/aliaModelRelease.d.ts +609 -0
  116. package/dist/types/inference/attribution.d.ts +176 -0
  117. package/dist/types/inference/catalogue.d.ts +1618 -0
  118. package/dist/types/inference/entitlement.d.ts +519 -0
  119. package/dist/types/inference/errors.d.ts +242 -0
  120. package/dist/types/inference/identifiers.d.ts +182 -0
  121. package/dist/types/inference/inbox.d.ts +374 -0
  122. package/dist/types/inference/modelDocumentation.d.ts +1603 -0
  123. package/dist/types/inference/money.d.ts +185 -0
  124. package/dist/types/inference/priceVersion.d.ts +182 -0
  125. package/dist/types/inference/providerConnection.d.ts +968 -0
  126. package/dist/types/inference/request.d.ts +2800 -0
  127. package/dist/types/inference/routingPolicy.d.ts +616 -0
  128. package/dist/types/inference/streamEvents.d.ts +950 -0
  129. package/dist/types/inference/usage.d.ts +1164 -0
  130. package/dist/types/inference/version.d.ts +102 -0
  131. package/dist/types/keyRecovery.d.ts +138 -0
  132. package/dist/types/keyRotation.d.ts +103 -0
  133. package/dist/types/links.d.ts +96 -0
  134. package/dist/types/moderationReputation.d.ts +487 -0
  135. package/dist/types/oauth.d.ts +86 -0
  136. package/dist/types/oxyRecordTypes.d.ts +62 -0
  137. package/dist/types/protocol.d.ts +86 -0
  138. package/dist/types/recommendations.d.ts +542 -0
  139. package/dist/types/reputation.d.ts +457 -0
  140. package/dist/types/sessionStatus.d.ts +231 -0
  141. package/dist/types/transparency.d.ts +392 -0
  142. package/dist/types/updates.d.ts +545 -0
  143. package/dist/types/userInvalidation.d.ts +94 -0
  144. package/dist/types/userResponse.d.ts +1706 -0
  145. package/dist/types/username.d.ts +265 -0
  146. package/dist/types/webauthn.d.ts +77 -0
  147. package/package.json +87 -0
@@ -0,0 +1,480 @@
1
+ /**
2
+ * Account graph wire contracts — the account-kind vocabulary, the account
3
+ * category taxonomy, and the create-account input.
4
+ *
5
+ * `accountCategories` classifies a NON-PERSONAL account — what it is about, what
6
+ * it does — without polluting `User.kind`. See the block above
7
+ * {@link ACCOUNT_CATEGORY_IDS} for the four rules that govern it.
8
+ */
9
+ import { z } from 'zod';
10
+ import { usernameSchema, usernameSchemaForAccountKind } from './username.js';
11
+ /**
12
+ * The union is spelled out above and the array proves coverage BOTH ways
13
+ * (`satisfies` here, the `Gap` alias below) — the same shape this package's
14
+ * `ACCOUNT_CATEGORY_IDS` / `TRUST_TIERS` pairs use, and the one
15
+ * `db/schema/users.ts` mirrors to keep the `users_kind_check` CHECK honest.
16
+ *
17
+ * Deriving the union from the array instead would cost nothing here and be paid
18
+ * by consumers: `kind` travels into `@oxy.so/services` on every device-directory
19
+ * context (`deviceContextSchema.kind` → `DeviceContext` → the switcher rows),
20
+ * and an indexed-access type is materially more expensive to check there than a
21
+ * literal union.
22
+ */
23
+ export const ACCOUNT_KINDS = [
24
+ 'personal',
25
+ 'organization',
26
+ 'project',
27
+ 'bot',
28
+ 'channel',
29
+ ];
30
+ export const accountKindSchema = z.enum(ACCOUNT_KINDS);
31
+ export const CHILD_ACCOUNT_KINDS = [
32
+ 'organization',
33
+ 'project',
34
+ 'bot',
35
+ 'channel',
36
+ ];
37
+ export const childAccountKindSchema = z.enum(CHILD_ACCOUNT_KINDS);
38
+ /**
39
+ * Whether an account of this kind may be the SUBJECT OF A DELEGATION — an
40
+ * application acting as it on some person's authority. `POST /internal/accounts/
41
+ * :id/service-switch` and the OAuth delegated subject both gate on this.
42
+ *
43
+ * It is NOT the question an account switcher asks. See
44
+ * {@link isOperatorSwitchTargetKind}, and the note below on why the difference
45
+ * is `bot`.
46
+ *
47
+ * Two kinds are refused, for opposite reasons:
48
+ *
49
+ * - `personal` is a human login, so assuming it would be impersonation.
50
+ * - `channel` is a CONTENT identity, not an operating one. A channel exists so
51
+ * that posts can be authored BY it; it is never a seat anybody occupies. Its
52
+ * operators act on it through their own membership, and an application
53
+ * publishes to it with its own credential. Refusing act-as is what makes
54
+ * "no login, ever" structural rather than incidental: no session can be
55
+ * minted whose subject is a channel, so no bearer exists that could add an
56
+ * auth method to one (every auth-method write resolves its target from the
57
+ * authenticated subject, never from a parameter).
58
+ *
59
+ * Consumers must gate on this predicate rather than testing `kind === 'personal'`,
60
+ * which silently admits every kind added after it was written.
61
+ */
62
+ export function isDelegatedActAsEligibleKind(kind) {
63
+ return kind === 'organization' || kind === 'project' || kind === 'bot';
64
+ }
65
+ /**
66
+ * Whether a PERSON may switch into an account of this kind — become it, in an
67
+ * account switcher, for the rest of their session.
68
+ *
69
+ * ## Why this is not the same question as {@link isDelegatedActAsEligibleKind}
70
+ *
71
+ * The two differ on exactly one kind, `bot`, and that difference is the whole
72
+ * reason both exist.
73
+ *
74
+ * **A bot is not something you become. It is something that operates on your
75
+ * behalf.** Its whole purpose is to act while nobody is present: an application
76
+ * holds a credential, names the human whose authority it borrows, and speaks as
77
+ * the bot. That is delegation, and it is what
78
+ * {@link isDelegatedActAsEligibleKind} admits it for.
79
+ *
80
+ * Handing a person the bot's seat instead inverts that. It puts a human inside
81
+ * the identity that exists to act without one, and it does so on the human's own
82
+ * device, next to their personal login — which is precisely what happened: a
83
+ * `bot` account held a live session on a person's device, offered to them by a
84
+ * switcher that had asked the delegation question by mistake.
85
+ *
86
+ * `channel` is refused here as well, for the reason set out above, and
87
+ * `personal` because assuming somebody else's login is impersonation.
88
+ *
89
+ * ## This is the narrower predicate, deliberately
90
+ *
91
+ * Everything a person may become, a service may also act as; the reverse does
92
+ * not hold. A caller that is unsure which question it is asking wants THIS one:
93
+ * being wrong here withholds an affordance, while being wrong the other way
94
+ * hands out a seat.
95
+ */
96
+ export function isOperatorSwitchTargetKind(kind) {
97
+ return kind === 'organization' || kind === 'project';
98
+ }
99
+ /**
100
+ * Narrow an unknown value to an {@link AccountKind}.
101
+ *
102
+ * The user-DTO serializers read from structurally-permissive `unknown` sources
103
+ * (a Drizzle row, a Mongo document, an already-formatted object), so each one
104
+ * would otherwise hand-roll this check and they would drift on what counts.
105
+ */
106
+ export function isAccountKind(value) {
107
+ return typeof value === 'string' && ACCOUNT_KINDS.includes(value);
108
+ }
109
+ // ===========================================================================
110
+ // Account categories
111
+ //
112
+ // Four rules govern this taxonomy. Each one is here because the obvious
113
+ // alternative fails silently rather than loudly.
114
+ //
115
+ // 1. AN ID IS AN OPAQUE, IMMUTABLE SLUG — the LABEL is not stored anywhere.
116
+ // Every value below is an identifier that no rename may ever change. The
117
+ // human-readable label lives in each client's own translation catalogue,
118
+ // keyed by the id. Re-labelling `agency` from "Real estate agency" to
119
+ // "Agency" is therefore a one-line locale edit that touches no row; moving
120
+ // the label into the column or the DTO would make the same edit a data
121
+ // migration, and would pin every reader to the language of whoever chose it.
122
+ //
123
+ // 2. THE PRIMARY CATEGORY IS THE FIRST ELEMENT of an ordered list. Not a
124
+ // separate flag, and not a second field: a flag admits two primaries and
125
+ // admits none, and ordering makes both unrepresentable. The cost is that
126
+ // ORDER IS DATA — anything that re-serializes, sorts or de-duplicates this
127
+ // list can change which category is primary without erroring, so no layer
128
+ // between the column and the client may reorder it.
129
+ //
130
+ // 3. THE VOCABULARY IS APPEND-ONLY. `ACCOUNT_CATEGORY_IDS` may gain ids and may
131
+ // never lose one, because rows already carry the ids it holds. Withdrawing a
132
+ // category means listing it in {@link RETIRED_ACCOUNT_CATEGORY_IDS}, which
133
+ // removes it from the picker while leaving it readable and re-writable. The
134
+ // database enforces the same asymmetry: its CHECK is re-evaluated on EVERY
135
+ // update to a row, so narrowing the allowed set makes an unrelated write —
136
+ // saving a bio — fail on any account that had picked the withdrawn value.
137
+ // Measured on a real Postgres, `NOT VALID` included; it does not help.
138
+ //
139
+ // 4. THE SET IS CLOSED. An id nobody can validate is an id no client can render:
140
+ // the label comes from a translation key derived from the id, so an invented
141
+ // value paints as a blank or a raw slug in every app at once. Closing it also
142
+ // costs nothing that rule 3 does not already charge — adding a category needs
143
+ // a migration to widen the CHECK whether or not the enum exists, so an open
144
+ // list would buy back no work, only the validation.
145
+ // ===========================================================================
146
+ /**
147
+ * Every account category, by stable id.
148
+ *
149
+ * Grouped by comment for readability only; the storage, the wire and the picker
150
+ * all treat this as one flat list. `other` is the escape hatch for an account
151
+ * that fits nothing here.
152
+ *
153
+ * TO ADD ONE: append an id (lowercase ASCII, `snake_case`) here, publish
154
+ * `@oxy.so/contracts`, then ship a migration that widens
155
+ * `users_account_categories_check` — never edit an existing migration — and add
156
+ * an `accounts.accountCategory.<id>` label to each client's locales.
157
+ *
158
+ * TO WITHDRAW ONE: leave the id here and add it to
159
+ * {@link RETIRED_ACCOUNT_CATEGORY_IDS}. See rule 3 above.
160
+ */
161
+ export const ACCOUNT_CATEGORY_IDS = [
162
+ // ---- media & public information -----------------------------------------
163
+ 'news',
164
+ 'politics',
165
+ // ---- business & economy --------------------------------------------------
166
+ 'business',
167
+ 'startup',
168
+ 'finance',
169
+ 'crypto',
170
+ 'marketplace',
171
+ 'retail',
172
+ // The four ids the single-valued `organizationCategory` field used to hold,
173
+ // carried forward VERBATIM. Their labels may be rewritten freely; their ids
174
+ // may not, because live rows hold them.
175
+ 'real_estate',
176
+ 'agency',
177
+ 'landlord',
178
+ 'cooperative',
179
+ 'architecture',
180
+ // ---- technology ----------------------------------------------------------
181
+ 'technology',
182
+ 'software',
183
+ 'ai',
184
+ 'security',
185
+ 'automation',
186
+ // ---- knowledge -----------------------------------------------------------
187
+ 'science',
188
+ 'education',
189
+ 'books',
190
+ // ---- health --------------------------------------------------------------
191
+ 'health',
192
+ 'fitness',
193
+ // ---- sport & play --------------------------------------------------------
194
+ 'sports',
195
+ 'gaming',
196
+ // ---- culture & entertainment ---------------------------------------------
197
+ //
198
+ // There is deliberately NO generic `entertainment` here, and re-adding one is
199
+ // a regression rather than a gap. It is the only id this list ever carried
200
+ // that was dominated by its own specifics — `film`, `music`, `gaming` and
201
+ // `comedy` all exist — and a generic drawer sitting beside its four
202
+ // concretions collects the lazy pick, which degrades the data for all four at
203
+ // once: the accounts that would have said `film` say `entertainment` instead,
204
+ // and `film` stops meaning what it meant.
205
+ //
206
+ // The other overlaps in this vocabulary are NOT the same case and must not be
207
+ // merged on this reasoning: `sports`/`fitness`, `art`/`photography`,
208
+ // `business`/`startup`, `finance`/`crypto`, `home_garden`/`diy` and
209
+ // `technology`/`software`/`security` are genuinely different audiences, and
210
+ // with a cap of four the granularity is cheap.
211
+ 'music',
212
+ 'film',
213
+ 'podcast',
214
+ 'art',
215
+ 'photography',
216
+ 'comedy',
217
+ // ---- everyday life -------------------------------------------------------
218
+ 'food',
219
+ 'travel',
220
+ 'fashion',
221
+ 'home_garden',
222
+ 'diy',
223
+ 'automotive',
224
+ 'animals',
225
+ 'family',
226
+ // ---- society -------------------------------------------------------------
227
+ 'nonprofit',
228
+ 'government',
229
+ 'community',
230
+ 'activism',
231
+ 'environment',
232
+ 'religion',
233
+ // ---- fallback ------------------------------------------------------------
234
+ 'other',
235
+ ];
236
+ /**
237
+ * Accepts EVERY id, withdrawn ones included — see rule 3.
238
+ *
239
+ * A schema that rejected a withdrawn id would 400 the whole request whenever a
240
+ * client round-trips the categories it was served, so an account that had
241
+ * picked one could no longer save its bio either. That is the same failure the
242
+ * nullable `bio` / `avatar` fix addressed, wearing a different hat.
243
+ */
244
+ export const accountCategoryIdSchema = z.enum(ACCOUNT_CATEGORY_IDS);
245
+ /**
246
+ * Ids withdrawn from the picker. Empty today.
247
+ *
248
+ * A withdrawn id keeps working everywhere it is already stored: it validates,
249
+ * it survives a round-trip save, it still renders from its label key, and it
250
+ * stays PRIMARY if it was primary. Nothing rewrites a stored list — a read-time
251
+ * or migration-time demotion would silently replace a choice its owner made,
252
+ * which is precisely what stable ids exist to prevent. The owner drops it on
253
+ * their next edit; until then it is honoured.
254
+ *
255
+ * What withdrawal changes is only this: the id leaves
256
+ * {@link SELECTABLE_ACCOUNT_CATEGORY_IDS}, so no picker offers it, and
257
+ * {@link newlyAddedRetiredCategories} refuses to let a write ADD it to an
258
+ * account that did not already have it.
259
+ */
260
+ export const RETIRED_ACCOUNT_CATEGORY_IDS = [];
261
+ /** Whether a category may still be OFFERED. A stored one is readable either way. */
262
+ export function isSelectableAccountCategoryId(id) {
263
+ return !RETIRED_ACCOUNT_CATEGORY_IDS.includes(id);
264
+ }
265
+ /** The ids a picker may offer, in declaration order. */
266
+ export const SELECTABLE_ACCOUNT_CATEGORY_IDS = ACCOUNT_CATEGORY_IDS.filter(isSelectableAccountCategoryId);
267
+ /**
268
+ * Which of `next` are withdrawn ids the account did not already carry — i.e.
269
+ * the ones a write must be refused for.
270
+ *
271
+ * `retired` is a parameter rather than a module read so the rule can be
272
+ * exercised against a non-empty set while the production one is empty; a test
273
+ * over `RETIRED_ACCOUNT_CATEGORY_IDS` alone would pass vacuously today and stay
274
+ * passing if the rule were deleted.
275
+ */
276
+ export function newlyAddedRetiredCategories(next, previous, retired) {
277
+ return next.filter((id) => retired.includes(id) && !previous.includes(id));
278
+ }
279
+ /**
280
+ * How many categories one account may carry.
281
+ *
282
+ * Four, not "as many as you like". Three reasons, in the order they bind:
283
+ *
284
+ * - The primary has to MEAN something. At ten categories the first element
285
+ * reads as a sort artifact rather than a choice, and rule 2 above is the
286
+ * entire mechanism by which a primary exists.
287
+ * - The profile RENDERS them as a row of chips; four labels of this length is
288
+ * what fits a phone-width profile header before the row wraps or truncates.
289
+ * - Four is enough to place a genuinely compound account without a tag cloud:
290
+ * a housing cooperative that is also a non-profit serving a local community
291
+ * spends `cooperative`, `nonprofit`, `community`, `real_estate` — and is the
292
+ * most compound real example in the ecosystem.
293
+ *
294
+ * One constant, read by the wire schema, the database CHECK and the picker, so
295
+ * changing it is one edit plus a migration.
296
+ */
297
+ export const MAX_ACCOUNT_CATEGORIES = 4;
298
+ /**
299
+ * An account's categories on the wire. ORDER IS MEANINGFUL — index 0 is the
300
+ * primary (rule 2).
301
+ *
302
+ * A duplicate is REJECTED rather than silently collapsed. De-duplicating would
303
+ * rewrite the caller's list, and any rewrite of this list can move which id sits
304
+ * at index 0 — so the one repair available here is the one that would break the
305
+ * property the list exists to carry. A duplicate only ever comes from a client
306
+ * bug, and a 400 naming the index is how that bug gets found.
307
+ */
308
+ export const accountCategoriesSchema = z
309
+ .array(accountCategoryIdSchema)
310
+ .max(MAX_ACCOUNT_CATEGORIES)
311
+ .superRefine((ids, ctx) => {
312
+ const seen = new Set();
313
+ ids.forEach((id, index) => {
314
+ if (seen.has(id)) {
315
+ ctx.addIssue({
316
+ code: z.ZodIssueCode.custom,
317
+ message: `Duplicate account category "${id}"`,
318
+ path: [index],
319
+ });
320
+ }
321
+ seen.add(id);
322
+ });
323
+ });
324
+ /**
325
+ * Kinds that may carry categories: every kind EXCEPT `personal`.
326
+ *
327
+ * A person has interests, not a sector — and their interests are not a
328
+ * classification anybody else gets to read off their profile. Spelled out
329
+ * positively, like {@link isDelegatedActAsEligibleKind} and for the same reason: a `kind
330
+ * !== 'personal'` test silently admits every kind invented after it was
331
+ * written, whereas this list forces whoever adds one to decide.
332
+ */
333
+ export const ACCOUNT_CATEGORY_KINDS = [
334
+ 'organization',
335
+ 'project',
336
+ 'bot',
337
+ 'channel',
338
+ ];
339
+ /**
340
+ * Whether an account of this kind may carry categories.
341
+ *
342
+ * The API refuses the write and the `users_account_categories_kind_check`
343
+ * constraint makes it unrepresentable; both derive from
344
+ * {@link ACCOUNT_CATEGORY_KINDS}, so they cannot disagree.
345
+ */
346
+ export function kindAcceptsAccountCategories(kind) {
347
+ return ACCOUNT_CATEGORY_KINDS.includes(kind ?? '');
348
+ }
349
+ /**
350
+ * An account's name on the create/update wire.
351
+ *
352
+ * `displayName` is EXPLICIT and stored, not derived. `first`/`last` model a
353
+ * human name, and composing a display string from them is right for a person —
354
+ * but a non-personal account has a TITLE, not a given and family name. Without
355
+ * this field the only way to name a channel "Notas de Nate" was to put the whole
356
+ * title in `first`, which renders correctly by accident while recording it as
357
+ * somebody's given name.
358
+ *
359
+ * When present it wins over the composed `first`/`last` (see the API's
360
+ * `composeDisplayName`, which already preferred an explicit value — only the
361
+ * storage for one was missing).
362
+ */
363
+ const accountNameSchema = z
364
+ .object({
365
+ first: z.string().trim().max(100).optional(),
366
+ last: z.string().trim().max(100).optional(),
367
+ displayName: z.string().trim().max(100).optional(),
368
+ })
369
+ .optional();
370
+ /**
371
+ * POST /accounts — create a non-personal account under the caller's tree.
372
+ *
373
+ * No cross-field refinement guards `accountCategories`, and that is not an
374
+ * omission: `kind` here is a CHILD kind, and every child kind is in
375
+ * {@link ACCOUNT_CATEGORY_KINDS}, so `personal` is already unrepresentable on
376
+ * this route. The refinement the single-valued predecessor needed disappeared
377
+ * along with the restriction that made it necessary. A child kind that does NOT
378
+ * accept categories would break that reasoning silently, so
379
+ * `__tests__/accountGraph.test.ts` asserts the two lists agree.
380
+ */
381
+ export const createAccountRequestSchema = z.object({
382
+ parentAccountId: z.string().trim().min(1).optional(),
383
+ kind: childAccountKindSchema,
384
+ /**
385
+ * The SAME policy a person's handle is held to. `users.username` is one unique
386
+ * index, so a managed account may not reserve a name a person could not ask
387
+ * for — and this route's predecessor (`.min(1).max(100)` here, `^[\w.-]+$`
388
+ * with no ceiling in the service) is how a one-character or dotted or
389
+ * 100-character handle became reachable for bots alone.
390
+ *
391
+ * A `bot` is held to that AND to the label its handle must end in. That half
392
+ * cannot live on this field — it depends on `kind`, a sibling — so it is in the
393
+ * `superRefine` below, which reports its issue against this path.
394
+ */
395
+ username: usernameSchema,
396
+ name: accountNameSchema,
397
+ bio: z.string().trim().max(500).optional(),
398
+ avatar: z.string().optional(),
399
+ description: z.string().trim().max(1000).optional(),
400
+ /**
401
+ * A named color preset KEY (`"blue"`, `"mint"`, …), never a hex value.
402
+ *
403
+ * Here at CREATION for the reason `isPrivateAccount` is, in miniature: for a
404
+ * managed account the color is a visual identity, and an account that is
405
+ * discoverable without one and acquires it on a second request is a face that
406
+ * changes by itself. One statement, one row, born looking like what its owner
407
+ * chose.
408
+ *
409
+ * The VALUE is checked in the API rather than here. The vocabulary is
410
+ * `USER_COLOR_PRESETS`, which is declared next to the `users_color_check` CHECK
411
+ * that is rendered from it — pinning the list a second time in this package
412
+ * would be a second source of truth for what the database accepts, and the two
413
+ * would drift apart silently. What this shape does is keep an over-long or
414
+ * non-string value from reaching the service at all.
415
+ */
416
+ color: z.string().trim().max(32).optional(),
417
+ /** Ordered, PRIMARY FIRST — see rule 2 above {@link ACCOUNT_CATEGORY_IDS}. */
418
+ accountCategories: accountCategoriesSchema.optional(),
419
+ /**
420
+ * Create the account already opted OUT of discovery.
421
+ *
422
+ * ## Why this belongs at CREATION and not only on the privacy route
423
+ *
424
+ * Every account is born discoverable: the column defaults to `false` and
425
+ * nothing on the create path wrote it, so a new account appears in people
426
+ * search the instant it exists. For a human signing themselves up that is the
427
+ * right default and it is NOT changed here. For an account a program creates
428
+ * on someone's behalf — an agent, an unlaunched project, an organization for
429
+ * something not yet announced — it publishes the thing before its owner ever
430
+ * decided to.
431
+ *
432
+ * The alternative is a second call right after create, which is a window in
433
+ * which the account IS public, and a window whose closing depends on a second
434
+ * request succeeding. A field here has neither: one statement, one row, born
435
+ * in the state the caller asked for.
436
+ *
437
+ * ## It reuses the existing flag deliberately
438
+ *
439
+ * This is `privacy_is_private_account`, the same one `PUT /users/:id/privacy`
440
+ * toggles — not a new "published" column. A second visibility flag would be a
441
+ * second source of truth for one question, and the two would disagree.
442
+ *
443
+ * Inherited semantics, stated because reusing a flag means inheriting ALL of
444
+ * it: the account is kept out of people search, out of the follow-graph lists
445
+ * (`followers` / `following` / `mutuals`), out of `/similar` and out of the
446
+ * recommendation candidate pools, and its non-public, non-unlisted media
447
+ * becomes follower-gated. It does NOT hide the profile from someone who knows
448
+ * the handle, and it carries NO follow-approval flow — following is immediate
449
+ * and unilateral whatever this says, so nothing here creates a request queue
450
+ * nobody attends.
451
+ *
452
+ * ## Not conditioned on `kind`, on purpose
453
+ *
454
+ * The same reasoning as `accountCategories` above: this object does not
455
+ * refine on kind, and an unlaunched organization has exactly the problem an
456
+ * unpublished agent does. The discovery predicate never reads `kind`, so the
457
+ * remedy must not either.
458
+ */
459
+ isPrivateAccount: z.boolean().optional(),
460
+ }).superRefine((request, ctx) => {
461
+ // The ONE place `kind` and `username` arrive in the same object, so it is the
462
+ // only place a wire schema CAN apply the per-kind half of the policy: a bot's
463
+ // handle must end in `bot`. Not a second rule — it asks
464
+ // `usernameSchemaForAccountKind`, the same declaration the service asks.
465
+ //
466
+ // It is here rather than only in the API because this schema is exported for
467
+ // CLIENTS: an agent-creation flow that validates its request and is then 400ed
468
+ // by the server is the "propose, then refuse" defect the minimum length
469
+ // already caused once. The service check stays regardless — it also governs
470
+ // renames and the service-provisioned channel route, where the kind comes from
471
+ // the stored row and never from this object.
472
+ const parsed = usernameSchemaForAccountKind(request.kind).safeParse(request.username);
473
+ if (!parsed.success) {
474
+ ctx.addIssue({
475
+ code: z.ZodIssueCode.custom,
476
+ path: ['username'],
477
+ message: parsed.error.issues[0].message,
478
+ });
479
+ }
480
+ });