@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,249 @@
1
+ /**
2
+ * Oxy Updates (self-hosted expo-updates protocol) — publish/admin API contracts.
3
+ *
4
+ * SINGLE SOURCE OF TRUTH for the wire shape of the AUTHENTICATED publish/admin
5
+ * surface that the `oxy-ship` CLI and the console Updates tab call. The PUBLIC
6
+ * manifest endpoint (`GET /updates/v1/apps/:clientId/manifest`) speaks the
7
+ * expo-updates v1 protocol verbatim (multipart/mixed, signed) and is therefore
8
+ * NOT modelled here — its shape is dictated by the Expo spec, not by us.
9
+ *
10
+ * The API validates its OUTPUT against these schemas; every consumer (the ship
11
+ * CLI, the console hook) validates its INPUT against the same definitions, so
12
+ * producer and consumers cannot drift.
13
+ *
14
+ * Domain model (mirrors the Mongoose models in `@oxy.so/api`):
15
+ * - A `channel` (e.g. `production`, `preview`, `pr-123`) is a named release
16
+ * track for one application.
17
+ * - An `update` is one published bundle for a single `(channel, runtimeVersion,
18
+ * platform)`. Its `updateId` is a UUIDv4 (the client parses it as a UUID).
19
+ * The HEAD of a track is the newest `published` update for that tuple.
20
+ * - An `asset` is content-addressed by its `sha256`; assets are shared across
21
+ * updates and applications (an unchanged JS bundle is uploaded once).
22
+ *
23
+ * Platform-agnostic — zod only, no react/react-native/expo. ESM-safe (no
24
+ * `require()`).
25
+ */
26
+ import { z } from 'zod';
27
+ /* -------------------------------------------------------------------------- */
28
+ /* Shared primitives */
29
+ /* -------------------------------------------------------------------------- */
30
+ /** The two platforms the expo-updates protocol addresses. */
31
+ export const updatePlatformSchema = z.enum(['ios', 'android']);
32
+ /** Lifecycle of a single published update row. */
33
+ export const updateStatusSchema = z.enum(['published', 'superseded', 'rolled_back']);
34
+ /** Upload lifecycle of a content-addressed asset. */
35
+ export const updateAssetStatusSchema = z.enum(['pending', 'uploaded']);
36
+ /** Lowercase-hex SHA-256 content hash (64 hex chars). */
37
+ export const sha256HexSchema = z
38
+ .string()
39
+ .regex(/^[a-f0-9]{64}$/, 'sha256 must be 64 lowercase hex characters');
40
+ /**
41
+ * A channel name. Constrained to a URL/path-safe slug so it can appear in the
42
+ * `expo-channel-name` header and be used CI-friendly for `pr-<n>` tracks. No
43
+ * colon (BullMQ/id safety) and no slash (path safety).
44
+ */
45
+ export const channelNameSchema = z
46
+ .string()
47
+ .min(1)
48
+ .max(100)
49
+ .regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/, 'channel name must be a URL-safe slug');
50
+ /** A runtime version string (expo-updates `runtimeVersion`, e.g. an appVersion). */
51
+ export const runtimeVersionSchema = z.string().min(1).max(255);
52
+ /** A rollout percentage: 0 rolls out to nobody, 100 to everybody. */
53
+ export const rolloutPercentSchema = z.number().int().min(0).max(100);
54
+ /* -------------------------------------------------------------------------- */
55
+ /* Assets: init + complete */
56
+ /* -------------------------------------------------------------------------- */
57
+ /**
58
+ * One asset the client intends to upload as part of an update. `sha256` is the
59
+ * content hash (dedup key); `contentType` and `size` describe the object the
60
+ * server will accept at the presigned URL.
61
+ */
62
+ export const assetInitItemSchema = z.object({
63
+ sha256: sha256HexSchema,
64
+ contentType: z.string().min(1).max(255),
65
+ size: z.number().int().positive(),
66
+ });
67
+ /**
68
+ * `POST /updates/v1/assets/init` request. Declares the full asset set of an
69
+ * update; the server replies with a presigned PUT for every asset it does NOT
70
+ * already hold (content-addressed dedup — unchanged assets are never re-uploaded).
71
+ */
72
+ export const assetInitRequestSchema = z.object({
73
+ applicationId: z.string().min(1),
74
+ assets: z.array(assetInitItemSchema).min(1).max(2000),
75
+ });
76
+ /** One presigned upload the client must PUT its bytes to before completing. */
77
+ export const assetUploadTicketSchema = z.object({
78
+ sha256: sha256HexSchema,
79
+ /** Presigned S3 PUT URL. The client PUTs the exact bytes here. */
80
+ uploadUrl: z.string(),
81
+ /** The S3 key the object will live at (`public/updates/assets/<sha256>`). */
82
+ storageKey: z.string(),
83
+ /** The Content-Type the presigned URL was signed for; echo it on the PUT. */
84
+ contentType: z.string(),
85
+ /**
86
+ * The Cache-Control the presigned URL was signed for; the client MUST send it
87
+ * verbatim on the PUT so the SigV4 signature matches and the stored object
88
+ * carries the long immutable cache header (assets are content-addressed).
89
+ */
90
+ cacheControl: z.string(),
91
+ /** Base64 SHA-256 value required in the presigned PUT's checksum header. */
92
+ checksumSHA256: z.string(),
93
+ });
94
+ /**
95
+ * `POST /updates/v1/assets/init` response. `missing` holds a presigned upload
96
+ * for each asset the server does not yet have; `existing` lists the sha256s it
97
+ * already holds (the client skips those).
98
+ */
99
+ export const assetInitResponseSchema = z.object({
100
+ missing: z.array(assetUploadTicketSchema),
101
+ existing: z.array(sha256HexSchema),
102
+ });
103
+ /**
104
+ * `POST /updates/v1/assets/complete` request. Sent after the client has PUT
105
+ * every `missing` asset. The server HEADs each object and flips it to
106
+ * `uploaded`; an object that is absent or size-mismatched is rejected.
107
+ */
108
+ export const assetCompleteRequestSchema = z.object({
109
+ applicationId: z.string().min(1),
110
+ sha256s: z.array(sha256HexSchema).min(1).max(2000),
111
+ });
112
+ /** Per-asset verification outcome from `assets/complete`. */
113
+ export const assetCompleteResultItemSchema = z.object({
114
+ sha256: sha256HexSchema,
115
+ status: updateAssetStatusSchema,
116
+ size: z.number().int().nonnegative(),
117
+ });
118
+ export const assetCompleteResponseSchema = z.object({
119
+ assets: z.array(assetCompleteResultItemSchema),
120
+ });
121
+ /* -------------------------------------------------------------------------- */
122
+ /* Create update */
123
+ /* -------------------------------------------------------------------------- */
124
+ /**
125
+ * A single asset reference inside a create-update request. `key` is the
126
+ * expo-export asset key (the md5-basename the client uses to look the asset up
127
+ * and skip embedded ones); `fileExtension` is the suggested on-disk extension.
128
+ */
129
+ export const updateAssetRefSchema = z.object({
130
+ sha256: sha256HexSchema,
131
+ /** expo-export asset key (md5 basename) — how app code references the asset. */
132
+ key: z.string().min(1),
133
+ contentType: z.string().min(1),
134
+ /** Suggested file extension including the leading dot (e.g. `.js`, `.png`). */
135
+ fileExtension: z.string().optional(),
136
+ });
137
+ /**
138
+ * `POST /updates/v1/updates` request. Publishes one bundle for one platform to a
139
+ * channel (the channel is created on demand — CI-friendly for `pr-<n>`). All
140
+ * referenced assets MUST already be `uploaded` (via init/complete). `extra`
141
+ * MUST carry `expoClient` so `Constants.expoConfig` works after an OTA update.
142
+ */
143
+ export const createUpdateRequestSchema = z.object({
144
+ applicationId: z.string().min(1),
145
+ channel: channelNameSchema,
146
+ runtimeVersion: runtimeVersionSchema,
147
+ platform: updatePlatformSchema,
148
+ /** The launch (entry-point) asset — its `fileExtension` is ignored by clients. */
149
+ launchAsset: updateAssetRefSchema,
150
+ assets: z.array(updateAssetRefSchema),
151
+ /**
152
+ * Opaque `extra` blob embedded verbatim in the signed manifest. MUST contain
153
+ * `expoClient` (the public expo config) so `Constants.expoConfig` resolves
154
+ * after an OTA update; MAY carry other third-party config.
155
+ */
156
+ extra: z
157
+ .object({ expoClient: z.record(z.string(), z.unknown()) })
158
+ .catchall(z.unknown()),
159
+ /** String→string metadata dict; filtered client-side via manifest filters. */
160
+ metadata: z.record(z.string(), z.string()).optional(),
161
+ /** Initial rollout percentage (default 100 — full rollout). */
162
+ rolloutPercent: rolloutPercentSchema.optional(),
163
+ /** Git commit the bundle was built from (audit / console display). */
164
+ gitCommit: z.string().max(100).optional(),
165
+ /** Git branch the bundle was built from (audit / console display). */
166
+ gitBranch: z.string().max(200).optional(),
167
+ /** Human-readable publish message (console display). */
168
+ message: z.string().max(500).optional(),
169
+ });
170
+ export const updateSchema = z.object({
171
+ id: z.string(),
172
+ applicationId: z.string(),
173
+ channel: z.string(),
174
+ runtimeVersion: z.string(),
175
+ platform: updatePlatformSchema,
176
+ status: updateStatusSchema,
177
+ rolloutPercent: rolloutPercentSchema,
178
+ launchAssetSha256: sha256HexSchema,
179
+ assetSha256s: z.array(sha256HexSchema),
180
+ gitCommit: z.string().optional(),
181
+ gitBranch: z.string().optional(),
182
+ message: z.string().optional(),
183
+ promotedFromUpdateId: z.string().optional(),
184
+ createdAt: z.string(),
185
+ updatedAt: z.string(),
186
+ });
187
+ export const createUpdateResponseSchema = z.object({
188
+ update: updateSchema,
189
+ });
190
+ export const rollbackToEmbeddedEntrySchema = z.object({
191
+ runtimeVersion: z.string(),
192
+ platform: updatePlatformSchema,
193
+ commitTime: z.string(),
194
+ });
195
+ export const channelSchema = z.object({
196
+ id: z.string(),
197
+ applicationId: z.string(),
198
+ name: z.string(),
199
+ rollbacksToEmbedded: z.array(rollbackToEmbeddedEntrySchema),
200
+ createdAt: z.string(),
201
+ updatedAt: z.string(),
202
+ });
203
+ export const channelListResponseSchema = z.object({
204
+ channels: z.array(channelSchema),
205
+ });
206
+ export const updateListResponseSchema = z.object({
207
+ updates: z.array(updateSchema),
208
+ });
209
+ /* -------------------------------------------------------------------------- */
210
+ /* Rollback / rollback-to-embedded / promote / rollout */
211
+ /* -------------------------------------------------------------------------- */
212
+ /**
213
+ * `POST /updates/v1/channels/:channel/rollback` request. Marks the current head
214
+ * for `(runtimeVersion, platform)` `rolled_back` so the previous published
215
+ * update (if any) becomes head again. Nothing is deleted.
216
+ */
217
+ export const rollbackRequestSchema = z.object({
218
+ applicationId: z.string().min(1),
219
+ runtimeVersion: runtimeVersionSchema,
220
+ platform: updatePlatformSchema,
221
+ });
222
+ /**
223
+ * `POST /updates/v1/channels/:channel/rollback-to-embedded` request. Records a
224
+ * `rollBackToEmbedded` directive so clients on this `(runtimeVersion, platform)`
225
+ * fall back to the update embedded in their binary.
226
+ */
227
+ export const rollbackToEmbeddedRequestSchema = z.object({
228
+ applicationId: z.string().min(1),
229
+ runtimeVersion: runtimeVersionSchema,
230
+ platform: updatePlatformSchema,
231
+ });
232
+ /**
233
+ * `POST /updates/v1/channels/:channel/promote` request. Promotes an existing
234
+ * update (by `updateId`) into the target channel by creating a NEW update (new
235
+ * UUID) pointing at the SAME assets. `toChannel` defaults to the path channel.
236
+ */
237
+ export const promoteRequestSchema = z.object({
238
+ applicationId: z.string().min(1),
239
+ updateId: z.string().min(1),
240
+ /** Target channel to promote into. Defaults to the path `:channel`. */
241
+ toChannel: channelNameSchema.optional(),
242
+ /** Rollout percentage for the promoted update (default 100). */
243
+ rolloutPercent: rolloutPercentSchema.optional(),
244
+ });
245
+ /** `PATCH /updates/v1/updates/:updateId` request — adjust a rollout in place. */
246
+ export const updateRolloutPatchSchema = z.object({
247
+ applicationId: z.string().min(1),
248
+ rolloutPercent: rolloutPercentSchema,
249
+ });
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Canonical contract for the Oxy user-invalidation broadcast.
3
+ *
4
+ * Oxy owns identity, but consumers cache it: Mention keeps a Redis summary per
5
+ * post author, and every backend using `@oxy.so/core` holds the SDK's own GET
6
+ * response cache. Both go stale the moment a profile is edited, and neither has
7
+ * any way to find out — the writer is a different process in a different repo.
8
+ * This is the signal that tells them.
9
+ *
10
+ * The channel name and the payload shape are wire contracts between oxy-api (the
11
+ * publisher) and every consuming backend (the subscribers), so they live here
12
+ * rather than in either side. A hand-typed copy of the channel name fails as
13
+ * "the invalidation never arrives" — silently, because pub/sub has no delivery
14
+ * receipt and a message nobody is listening for is indistinguishable from a
15
+ * message nobody sent.
16
+ *
17
+ * DELIVERY IS AT-MOST-ONCE, AND THAT IS THE DESIGN. Every consumer's cache still
18
+ * carries its own TTL, so a dropped message degrades to exactly the behaviour
19
+ * before this signal existed and never to something worse. That property is what
20
+ * makes a bare Redis PUBLISH sufficient here and an outbox, retries, delivery
21
+ * receipts and payload signatures unnecessary. Do not treat a received event as
22
+ * authoritative for anything except "re-read this user from Oxy".
23
+ *
24
+ * PRIVACY — the payload carries NO user data, only an id, a reason and a
25
+ * timestamp. The channel rides the shared Valkey that every Oxy backend can
26
+ * subscribe to, so anything placed on it is readable by every service in the
27
+ * ecosystem. Never add a name, handle, email, avatar or any profile field: a
28
+ * subscriber that wants the new values re-reads them from Oxy through its normal
29
+ * authenticated path, where the usual authorization applies.
30
+ *
31
+ * Platform-agnostic — zod only, no react/react-native/expo.
32
+ */
33
+ import { z } from 'zod';
34
+ /** Redis pub/sub channel carrying user-invalidation events. */
35
+ export const OXY_USER_INVALIDATION_CHANNEL = 'oxy:user:invalidate';
36
+ /**
37
+ * Why a user record changed, as classified by the writer in oxy-api.
38
+ *
39
+ * - `profile` — anything a consumer renders or caches as IDENTITY: display name,
40
+ * username, avatar, bio, verification, federation fields, account status. This
41
+ * is the DEFAULT for every writer, so a site that forgets to classify itself
42
+ * over-invalidates (correct, marginally slower) rather than under-invalidates
43
+ * (silently wrong). Keep that asymmetry if you add a reason.
44
+ * - `graph` — follow-edge churn only (follower/following counts). High frequency,
45
+ * and bulk follow/unfollow moves up to 200 edges in one call. Nothing renders
46
+ * identity from it and a stale count is harmless to ranking, so it is NOT
47
+ * broadcast — see {@link OXY_PUBLISHED_USER_CHANGE_REASONS}.
48
+ */
49
+ export const OXY_USER_CHANGE_REASONS = ['profile', 'graph'];
50
+ /**
51
+ * The reasons that are actually put on the wire.
52
+ *
53
+ * A reason absent from this list is a local cache eviction in oxy-api and
54
+ * nothing more: no message is published at all, rather than a message every
55
+ * subscriber receives and discards. The distinction matters at bulk-follow
56
+ * scale, where the discarded variant is a 200-message burst on a channel every
57
+ * Oxy backend is subscribed to.
58
+ *
59
+ * This is deliberately a shared list rather than a check inside the publisher:
60
+ * a subscriber needs to know what it can receive, and the schema below rejects
61
+ * anything else, so publisher and subscriber cannot drift into disagreeing about
62
+ * which events exist. Adding a reason therefore forces an explicit decision about
63
+ * whether it broadcasts.
64
+ */
65
+ export const OXY_PUBLISHED_USER_CHANGE_REASONS = ['profile'];
66
+ /** Whether a change of this kind is broadcast to consumers at all. */
67
+ export function isPublishedOxyUserChangeReason(reason) {
68
+ return OXY_PUBLISHED_USER_CHANGE_REASONS.includes(reason);
69
+ }
70
+ /**
71
+ * A single user-invalidation event.
72
+ *
73
+ * `at` is the publisher's epoch-ms clock, carried for diagnosis (measuring
74
+ * end-to-end propagation, spotting a wedged subscriber) — never for ordering or
75
+ * conflict resolution. Two Oxy tasks publish from unsynchronised clocks, and the
76
+ * event says only "re-read this user", which is idempotent and order-independent.
77
+ */
78
+ export const oxyUserInvalidationEventSchema = z.object({
79
+ /** The Oxy user whose record changed. */
80
+ userId: z.string().min(1),
81
+ /** Why it changed. Only broadcast reasons appear on the wire. */
82
+ reason: z.enum(OXY_PUBLISHED_USER_CHANGE_REASONS),
83
+ /** Publisher's epoch-ms timestamp. Diagnostic only. */
84
+ at: z.number().int().nonnegative(),
85
+ });
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Canonical API user-response contracts.
3
+ *
4
+ * SINGLE SOURCE OF TRUTH for the wire shape of every user object the API emits
5
+ * and every consumer (the auth app, services, accounts) parses. The API
6
+ * validates its OUTPUT against these schemas; web/RN consumers validate their
7
+ * INPUT against the same schemas. Because there is exactly one definition, the
8
+ * producer and the consumers cannot drift — the class of bugs that motivated
9
+ * this module (the auth app's local Zod schema requiring `name` to be a plain
10
+ * string, dropping every account that had a structured name) is impossible.
11
+ *
12
+ * This package (`@oxy.so/contracts`) is the dedicated, zero-dependency home for
13
+ * these contracts so the backend (`@oxy.so/api`) and the client SDKs
14
+ * (`@oxy.so/core`, `@oxy.so/services`) can all depend on it without
15
+ * the backend having to depend on a client SDK to obtain its schemas.
16
+ *
17
+ * Faithful to the producers:
18
+ * - `packages/api/src/utils/userTransform.ts` `formatUserResponse` — the
19
+ * canonical serialization used by login/signup, device sessions, etc.
20
+ * Emits `id` (NOT `_id`), forwards `username` verbatim (may be absent), and
21
+ * emits `name` as the structured `{ first, last, full, displayName }`
22
+ * subdocument.
23
+ * - `packages/api/src/models/User.ts` — `NameSchema` (`first`/`last` default
24
+ * `''`; `full` and `displayName` are Mongoose VIRTUALS. Formatted API
25
+ * responses compose both fields, while raw-document responses may omit the
26
+ * virtuals if the query did not materialise them.
27
+ *
28
+ * Platform-agnostic — zod only, no react/react-native/expo. ESM-safe (no
29
+ * `require()`).
30
+ */
31
+ import { z } from 'zod';
32
+ import { verifiedDomainSchema } from './identity.js';
33
+ import { accountCategoriesSchema, accountKindSchema } from './accountGraph.js';
34
+ export const userNameSchema = z
35
+ .object({
36
+ first: z.string().optional(),
37
+ last: z.string().optional(),
38
+ full: z.string().optional(),
39
+ displayName: z.string().optional(),
40
+ })
41
+ .passthrough();
42
+ export const userRelationshipSchema = z.object({
43
+ isFollowing: z.boolean(),
44
+ followsYou: z.boolean(),
45
+ });
46
+ export const themePreferenceSchema = z.object({
47
+ mode: z.enum(['light', 'dark', 'system']),
48
+ colorPreset: z.string(),
49
+ });
50
+ /**
51
+ * The canonical user object emitted by `formatUserResponse`.
52
+ *
53
+ * `id` is present on formatted user DTOs. `name.displayName` is OPTIONAL on the
54
+ * contract — the API still synthesizes a default today, but consumers must not
55
+ * assume it is present and should fall back to a handle when it is absent. The
56
+ * rest is forwarded from the user document and may be absent depending on the query's
57
+ * `.select(...)`/`.lean()` projection. Both `id` and `_id` are accepted because
58
+ * some raw-document responses carry `_id` instead of `id`; resolve the
59
+ * identifier with {@link resolveUserId}.
60
+ *
61
+ * `.passthrough()` keeps the large tail of profile fields
62
+ * (`privacySettings`, `locations`, `links`, `linksMetadata`, `bio`,
63
+ * `description`, `languages`, `verified`, timestamps, …) available to callers
64
+ * that need them without enumerating every nested shape here — the load-bearing
65
+ * identity/display fields are the ones we pin precisely.
66
+ */
67
+ export const userResponseSchema = z
68
+ .object({
69
+ /** MongoDB ObjectId as a string. Present on `formatUserResponse` output. */
70
+ id: z.string().optional(),
71
+ /** Raw-document id (e.g. `GET /users/me`). Present when `id` is not. */
72
+ _id: z.string().optional(),
73
+ publicKey: z.string().optional(),
74
+ username: z.string().optional(),
75
+ email: z.string().optional(),
76
+ phone: z.string().optional(),
77
+ address: z.string().optional(),
78
+ birthday: z.string().optional(),
79
+ /** Avatar file id (string) or null. */
80
+ avatar: z.string().nullable().optional(),
81
+ /** Named Bloom color preset (e.g. `"blue"`) or null. */
82
+ color: z.string().nullable().optional(),
83
+ name: userNameSchema,
84
+ verified: z.boolean().optional(),
85
+ /**
86
+ * The account's languages as full BCP-47 locales (`language-REGION`,
87
+ * e.g. `es-ES`, `en-US`, `pt-BR`), ordered with the PRIMARY (UI) locale
88
+ * first. `languages[0]` is the primary locale — there is no singular
89
+ * `language` field.
90
+ */
91
+ languages: z.array(z.string()).optional(),
92
+ /**
93
+ * The account's self-sovereign identifier
94
+ * (`did:web:<FEDERATION_DOMAIN>:u:<userId>`). Surfaced as a `User`
95
+ * virtual; present on formatted DTOs once the identity layer is live.
96
+ */
97
+ did: z.string().optional(),
98
+ /**
99
+ * Proven domain-ownership badges. Each is a {@link verifiedDomainSchema}
100
+ * entry; present only when the account has verified at least one domain.
101
+ */
102
+ verifiedDomains: z.array(verifiedDomainSchema).optional(),
103
+ /**
104
+ * Account-graph classification — what KIND of account this is.
105
+ *
106
+ * ORTHOGONAL to `type` (`local` / `federated` / `agent` / `automated`),
107
+ * which says where the account lives and how it is driven; the two
108
+ * coexist and neither substitutes for the other. A `channel` is a
109
+ * publishing identity nobody can act as, so a consumer that renders
110
+ * authored content reads THIS to tell a channel's post from a person's.
111
+ *
112
+ * Optional because a DTO produced from a source that never carried the
113
+ * column omits it; absent should be read as `personal`, the column's
114
+ * default, not as unknown.
115
+ */
116
+ kind: accountKindSchema.optional(),
117
+ /**
118
+ * What this account is about — the field a profile screen RENDERS.
119
+ *
120
+ * **Ordered, primary first.** `accountCategories[0]` is the primary
121
+ * category; there is deliberately no sibling `primaryCategory` field,
122
+ * because two representations of one fact can disagree (see rule 2 in
123
+ * `accountGraph.ts`). Nothing downstream may sort, de-duplicate or
124
+ * otherwise reorder this array.
125
+ *
126
+ * **Ids, never labels.** Each element is a stable slug; the visible text
127
+ * comes from the reader's own translation catalogue, keyed
128
+ * `accounts.accountCategory.<id>`. A label on the wire would paint every
129
+ * profile in the language of whoever picked it.
130
+ *
131
+ * Absent when the account has none — which is every `personal` account,
132
+ * and any non-personal one that has not chosen. A renderer reads
133
+ * `user.accountCategories ?? []`.
134
+ */
135
+ accountCategories: accountCategoriesSchema.optional(),
136
+ /**
137
+ * The authenticated viewer's relationship to this profile. Present ONLY
138
+ * on single-profile fetches (`GET /profiles/username/:username`,
139
+ * `GET /users/:userId`) when the request is authenticated; OMITTED for
140
+ * anonymous requests and for the bulk `POST /users/by-ids` fan-out.
141
+ */
142
+ relationship: userRelationshipSchema.optional(),
143
+ /**
144
+ * Portable theme preference. Rides the self/session payload (cold boot),
145
+ * so it is present on the current-user DTO (`GET /users/me`,
146
+ * `GET /session/user/:sessionId`) and absent until the user sets it.
147
+ */
148
+ themePreference: themePreferenceSchema.optional(),
149
+ })
150
+ .passthrough();
151
+ export const userProfileUpdateSchema = z
152
+ .object({
153
+ name: z
154
+ .object({
155
+ first: z.string().optional(),
156
+ last: z.string().optional(),
157
+ /**
158
+ * Explicit display name, stored rather than composed. Wins over
159
+ * `first`/`last` when set; send `''` to clear it and fall back
160
+ * to the composed pair.
161
+ */
162
+ displayName: z.string().optional(),
163
+ })
164
+ .optional(),
165
+ username: z.string().optional(),
166
+ email: z.string().optional(),
167
+ avatar: z.string().optional(),
168
+ color: z.string().nullable().optional(),
169
+ bio: z.string().optional(),
170
+ description: z.string().optional(),
171
+ phone: z.string().optional(),
172
+ address: z.string().optional(),
173
+ birthday: z.string().optional(),
174
+ locations: z.array(z.unknown()).optional(),
175
+ links: z.array(z.string()).optional(),
176
+ linksMetadata: z
177
+ .array(z.object({
178
+ url: z.string(),
179
+ title: z.string().optional(),
180
+ description: z.string().optional(),
181
+ image: z.string().optional(),
182
+ id: z.string().optional(),
183
+ }))
184
+ .optional(),
185
+ /**
186
+ * Ordered account locales (`language-REGION`), primary first. Replaces
187
+ * the eliminated singular `language`; `languages[0]` is the primary UI
188
+ * locale.
189
+ */
190
+ languages: z.array(z.string()).optional(),
191
+ accountExpiresAfterInactivityDays: z.number().nullable().optional(),
192
+ notificationPreferences: z.record(z.unknown()).optional(),
193
+ userPreferences: z.record(z.unknown()).optional(),
194
+ privacySettings: z.record(z.unknown()).optional(),
195
+ /**
196
+ * Portable theme preference. Written through the same `PUT /users/me`
197
+ * settings-update path as `languages`/`userPreferences`.
198
+ */
199
+ themePreference: themePreferenceSchema.optional(),
200
+ })
201
+ .passthrough();
202
+ /**
203
+ * Resolve the canonical user id from a {@link UserResponse}, accepting either
204
+ * the `formatUserResponse` `id` field or the raw-document `_id` field.
205
+ */
206
+ export function resolveUserId(user) {
207
+ return user.id ?? user._id;
208
+ }
209
+ /**
210
+ * Wire shape of `GET /users/me` — the API success envelope (`{ data: <user> }`)
211
+ * wrapping the current-user DTO. Some older producers use `_id` instead of
212
+ * `id`; resolve via {@link resolveUserId}. The display name still lives under
213
+ * `name.displayName`.
214
+ */
215
+ export const currentUserResponseSchema = z.object({
216
+ data: userResponseSchema,
217
+ });
218
+ /**
219
+ * One entry of `GET /session/device/sessions/:sessionId` — the deduplicated
220
+ * accounts signed in on this physical device (one per user, most recent
221
+ * session). Backs the multi-account chooser. The embedded user mirrors
222
+ * `formatUserResponse`; it is nullable on slots that lost their user document.
223
+ */
224
+ export const deviceLinkedSessionSchema = z.object({
225
+ sessionId: z.string(),
226
+ isCurrent: z.boolean().optional(),
227
+ user: userResponseSchema.nullable().optional(),
228
+ });
229
+ /** Wire shape of `GET /session/device/sessions/:sessionId` (an array). */
230
+ export const deviceLinkedSessionsResponseSchema = z.array(deviceLinkedSessionSchema);
231
+ /**
232
+ * Safely parse a value against a contract schema. Returns the parsed (typed)
233
+ * value, or `null` when validation fails — the same ergonomics the auth app's
234
+ * local `safeParse` provided, now sourced from the contracts package so the
235
+ * parse helper and the schemas live together.
236
+ */
237
+ export function safeParseContract(schema, data) {
238
+ const result = schema.safeParse(data);
239
+ return result.success ? result.data : null;
240
+ }