@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.
- package/LICENSE +202 -0
- package/NOTICE +16 -0
- package/dist/cjs/.tsbuildinfo +1 -0
- package/dist/cjs/accountGraph.js +489 -0
- package/dist/cjs/agency.js +439 -0
- package/dist/cjs/browserHub.js +215 -0
- package/dist/cjs/civic.js +163 -0
- package/dist/cjs/commonsSignIn.js +59 -0
- package/dist/cjs/deviceBoot.js +50 -0
- package/dist/cjs/deviceDirectory.js +189 -0
- package/dist/cjs/devicePairing.js +138 -0
- package/dist/cjs/deviceSession.js +164 -0
- package/dist/cjs/emailAgentContext.js +32 -0
- package/dist/cjs/followGraph.js +28 -0
- package/dist/cjs/identity.js +258 -0
- package/dist/cjs/inboxPush.js +24 -0
- package/dist/cjs/index.js +618 -0
- package/dist/cjs/inference/accountBilling.js +334 -0
- package/dist/cjs/inference/aliaModelRelease.js +262 -0
- package/dist/cjs/inference/attribution.js +106 -0
- package/dist/cjs/inference/catalogue.js +487 -0
- package/dist/cjs/inference/entitlement.js +217 -0
- package/dist/cjs/inference/errors.js +309 -0
- package/dist/cjs/inference/identifiers.js +224 -0
- package/dist/cjs/inference/inbox.js +105 -0
- package/dist/cjs/inference/modelDocumentation.js +433 -0
- package/dist/cjs/inference/money.js +188 -0
- package/dist/cjs/inference/priceVersion.js +110 -0
- package/dist/cjs/inference/providerConnection.js +455 -0
- package/dist/cjs/inference/request.js +477 -0
- package/dist/cjs/inference/routingPolicy.js +318 -0
- package/dist/cjs/inference/streamEvents.js +258 -0
- package/dist/cjs/inference/usage.js +329 -0
- package/dist/cjs/inference/version.js +105 -0
- package/dist/cjs/keyRecovery.js +91 -0
- package/dist/cjs/keyRotation.js +75 -0
- package/dist/cjs/links.js +68 -0
- package/dist/cjs/moderationReputation.js +298 -0
- package/dist/cjs/oauth.js +66 -0
- package/dist/cjs/oxyRecordTypes.js +71 -0
- package/dist/cjs/protocol.js +53 -0
- package/dist/cjs/recommendations.js +168 -0
- package/dist/cjs/reputation.js +297 -0
- package/dist/cjs/sessionStatus.js +121 -0
- package/dist/cjs/transparency.js +89 -0
- package/dist/cjs/updates.js +252 -0
- package/dist/cjs/userInvalidation.js +89 -0
- package/dist/cjs/userResponse.js +245 -0
- package/dist/cjs/username.js +290 -0
- package/dist/cjs/webauthn.js +71 -0
- package/dist/esm/.tsbuildinfo +1 -0
- package/dist/esm/accountGraph.js +480 -0
- package/dist/esm/agency.js +436 -0
- package/dist/esm/browserHub.js +212 -0
- package/dist/esm/civic.js +160 -0
- package/dist/esm/commonsSignIn.js +56 -0
- package/dist/esm/deviceBoot.js +47 -0
- package/dist/esm/deviceDirectory.js +186 -0
- package/dist/esm/devicePairing.js +135 -0
- package/dist/esm/deviceSession.js +161 -0
- package/dist/esm/emailAgentContext.js +29 -0
- package/dist/esm/followGraph.js +27 -0
- package/dist/esm/identity.js +255 -0
- package/dist/esm/inboxPush.js +21 -0
- package/dist/esm/index.js +172 -0
- package/dist/esm/inference/accountBilling.js +331 -0
- package/dist/esm/inference/aliaModelRelease.js +259 -0
- package/dist/esm/inference/attribution.js +103 -0
- package/dist/esm/inference/catalogue.js +484 -0
- package/dist/esm/inference/entitlement.js +214 -0
- package/dist/esm/inference/errors.js +306 -0
- package/dist/esm/inference/identifiers.js +221 -0
- package/dist/esm/inference/inbox.js +102 -0
- package/dist/esm/inference/modelDocumentation.js +430 -0
- package/dist/esm/inference/money.js +185 -0
- package/dist/esm/inference/priceVersion.js +107 -0
- package/dist/esm/inference/providerConnection.js +452 -0
- package/dist/esm/inference/request.js +474 -0
- package/dist/esm/inference/routingPolicy.js +315 -0
- package/dist/esm/inference/streamEvents.js +255 -0
- package/dist/esm/inference/usage.js +326 -0
- package/dist/esm/inference/version.js +102 -0
- package/dist/esm/keyRecovery.js +88 -0
- package/dist/esm/keyRotation.js +72 -0
- package/dist/esm/links.js +65 -0
- package/dist/esm/moderationReputation.js +295 -0
- package/dist/esm/oauth.js +63 -0
- package/dist/esm/oxyRecordTypes.js +68 -0
- package/dist/esm/protocol.js +50 -0
- package/dist/esm/recommendations.js +165 -0
- package/dist/esm/reputation.js +293 -0
- package/dist/esm/sessionStatus.js +118 -0
- package/dist/esm/transparency.js +86 -0
- package/dist/esm/updates.js +249 -0
- package/dist/esm/userInvalidation.js +85 -0
- package/dist/esm/userResponse.js +240 -0
- package/dist/esm/username.js +283 -0
- package/dist/esm/webauthn.js +68 -0
- package/dist/types/.tsbuildinfo +1 -0
- package/dist/types/accountGraph.d.ts +378 -0
- package/dist/types/agency.d.ts +2162 -0
- package/dist/types/browserHub.d.ts +856 -0
- package/dist/types/civic.d.ts +338 -0
- package/dist/types/commonsSignIn.d.ts +58 -0
- package/dist/types/deviceBoot.d.ts +74 -0
- package/dist/types/deviceDirectory.d.ts +1317 -0
- package/dist/types/devicePairing.d.ts +130 -0
- package/dist/types/deviceSession.d.ts +411 -0
- package/dist/types/emailAgentContext.d.ts +248 -0
- package/dist/types/followGraph.d.ts +150 -0
- package/dist/types/identity.d.ts +402 -0
- package/dist/types/inboxPush.d.ts +30 -0
- package/dist/types/index.d.ts +100 -0
- package/dist/types/inference/accountBilling.d.ts +738 -0
- package/dist/types/inference/aliaModelRelease.d.ts +609 -0
- package/dist/types/inference/attribution.d.ts +176 -0
- package/dist/types/inference/catalogue.d.ts +1618 -0
- package/dist/types/inference/entitlement.d.ts +519 -0
- package/dist/types/inference/errors.d.ts +242 -0
- package/dist/types/inference/identifiers.d.ts +182 -0
- package/dist/types/inference/inbox.d.ts +374 -0
- package/dist/types/inference/modelDocumentation.d.ts +1603 -0
- package/dist/types/inference/money.d.ts +185 -0
- package/dist/types/inference/priceVersion.d.ts +182 -0
- package/dist/types/inference/providerConnection.d.ts +968 -0
- package/dist/types/inference/request.d.ts +2800 -0
- package/dist/types/inference/routingPolicy.d.ts +616 -0
- package/dist/types/inference/streamEvents.d.ts +950 -0
- package/dist/types/inference/usage.d.ts +1164 -0
- package/dist/types/inference/version.d.ts +102 -0
- package/dist/types/keyRecovery.d.ts +138 -0
- package/dist/types/keyRotation.d.ts +103 -0
- package/dist/types/links.d.ts +96 -0
- package/dist/types/moderationReputation.d.ts +487 -0
- package/dist/types/oauth.d.ts +86 -0
- package/dist/types/oxyRecordTypes.d.ts +62 -0
- package/dist/types/protocol.d.ts +86 -0
- package/dist/types/recommendations.d.ts +542 -0
- package/dist/types/reputation.d.ts +457 -0
- package/dist/types/sessionStatus.d.ts +231 -0
- package/dist/types/transparency.d.ts +392 -0
- package/dist/types/updates.d.ts +545 -0
- package/dist/types/userInvalidation.d.ts +94 -0
- package/dist/types/userResponse.d.ts +1706 -0
- package/dist/types/username.d.ts +265 -0
- package/dist/types/webauthn.d.ts +77 -0
- 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
|
+
}
|