@oxyhq/contracts 0.20.0 → 0.22.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.
@@ -1,11 +1,71 @@
1
1
  /**
2
- * Account graph wire contracts — organization taxonomy and create-account input.
2
+ * Account graph wire contracts — the account-kind vocabulary, organization
3
+ * taxonomy, and create-account input.
3
4
  *
4
5
  * `organizationCategory` classifies `kind: 'organization'` accounts (agency,
5
6
  * cooperative, landlord, …) without polluting `User.kind`. Meaningful only when
6
7
  * `kind === 'organization'`.
7
8
  */
8
9
  import { z } from 'zod';
10
+ /**
11
+ * The union is spelled out above and the array proves coverage BOTH ways
12
+ * (`satisfies` here, the `Gap` alias below) — the same shape this package's
13
+ * `ORGANIZATION_CATEGORIES` / `TRUST_TIERS` pairs use, and the one
14
+ * `db/schema/users.ts` mirrors to keep the `users_kind_check` CHECK honest.
15
+ *
16
+ * Deriving the union from the array instead would cost nothing here and be paid
17
+ * by consumers: `kind` travels into `@oxyhq/services` through
18
+ * `SwitchableAccount`, where an indexed-access type is materially more
19
+ * expensive to check than a literal union.
20
+ */
21
+ export const ACCOUNT_KINDS = [
22
+ 'personal',
23
+ 'organization',
24
+ 'project',
25
+ 'bot',
26
+ 'channel',
27
+ ];
28
+ export const accountKindSchema = z.enum(ACCOUNT_KINDS);
29
+ export const CHILD_ACCOUNT_KINDS = [
30
+ 'organization',
31
+ 'project',
32
+ 'bot',
33
+ 'channel',
34
+ ];
35
+ export const childAccountKindSchema = z.enum(CHILD_ACCOUNT_KINDS);
36
+ /**
37
+ * Whether an operator may ACT AS an account of this kind — switch the whole app
38
+ * into it (`POST /accounts/:id/switch`) or authorise an app to act as it
39
+ * (an OAuth delegated subject).
40
+ *
41
+ * Two kinds are refused, for opposite reasons:
42
+ *
43
+ * - `personal` is a human login, so assuming it would be impersonation.
44
+ * - `channel` is a CONTENT identity, not an operating one. A channel exists so
45
+ * that posts can be authored BY it; it is never a seat anybody occupies. Its
46
+ * operators act on it through their own membership, and an application
47
+ * publishes to it with its own credential. Refusing act-as is what makes
48
+ * "no login, ever" structural rather than incidental: no session can be
49
+ * minted whose subject is a channel, so no bearer exists that could add an
50
+ * auth method to one (every auth-method write resolves its target from the
51
+ * authenticated subject, never from a parameter).
52
+ *
53
+ * Consumers must gate on this predicate rather than testing `kind === 'personal'`,
54
+ * which silently admits every kind added after it was written.
55
+ */
56
+ export function isActAsEligibleKind(kind) {
57
+ return kind === 'organization' || kind === 'project' || kind === 'bot';
58
+ }
59
+ /**
60
+ * Narrow an unknown value to an {@link AccountKind}.
61
+ *
62
+ * The user-DTO serializers read from structurally-permissive `unknown` sources
63
+ * (a Drizzle row, a Mongo document, an already-formatted object), so each one
64
+ * would otherwise hand-roll this check and they would drift on what counts.
65
+ */
66
+ export function isAccountKind(value) {
67
+ return typeof value === 'string' && ACCOUNT_KINDS.includes(value);
68
+ }
9
69
  export const ORGANIZATION_CATEGORIES = [
10
70
  'agency',
11
71
  'cooperative',
@@ -13,10 +73,25 @@ export const ORGANIZATION_CATEGORIES = [
13
73
  'other',
14
74
  ];
15
75
  export const organizationCategorySchema = z.enum(ORGANIZATION_CATEGORIES);
76
+ /**
77
+ * An account's name on the create/update wire.
78
+ *
79
+ * `displayName` is EXPLICIT and stored, not derived. `first`/`last` model a
80
+ * human name, and composing a display string from them is right for a person —
81
+ * but a non-personal account has a TITLE, not a given and family name. Without
82
+ * this field the only way to name a channel "Notas de Nate" was to put the whole
83
+ * title in `first`, which renders correctly by accident while recording it as
84
+ * somebody's given name.
85
+ *
86
+ * When present it wins over the composed `first`/`last` (see the API's
87
+ * `composeDisplayName`, which already preferred an explicit value — only the
88
+ * storage for one was missing).
89
+ */
16
90
  const accountNameSchema = z
17
91
  .object({
18
92
  first: z.string().trim().max(100).optional(),
19
93
  last: z.string().trim().max(100).optional(),
94
+ displayName: z.string().trim().max(100).optional(),
20
95
  })
21
96
  .optional();
22
97
  /**
@@ -26,7 +101,7 @@ const accountNameSchema = z
26
101
  export const createAccountRequestSchema = z
27
102
  .object({
28
103
  parentAccountId: z.string().trim().min(1).optional(),
29
- kind: z.enum(['organization', 'project', 'bot']),
104
+ kind: childAccountKindSchema,
30
105
  username: z.string().trim().min(1).max(100),
31
106
  name: accountNameSchema,
32
107
  bio: z.string().trim().max(500).optional(),
package/dist/esm/index.js CHANGED
@@ -9,7 +9,7 @@
9
9
  * Platform-agnostic — zod is the only runtime dependency. No react/react-native/
10
10
  * expo, no `require()` in the ESM build.
11
11
  */
12
- export { ORGANIZATION_CATEGORIES, organizationCategorySchema, createAccountRequestSchema, } from './accountGraph.js';
12
+ export { ACCOUNT_KINDS, accountKindSchema, CHILD_ACCOUNT_KINDS, childAccountKindSchema, isAccountKind, isActAsEligibleKind, ORGANIZATION_CATEGORIES, organizationCategorySchema, createAccountRequestSchema, } from './accountGraph.js';
13
13
  export {
14
14
  // Schemas
15
15
  userNameSchema, userRelationshipSchema, themePreferenceSchema, userResponseSchema, userProfileUpdateSchema, currentUserResponseSchema, deviceLinkedSessionSchema, deviceLinkedSessionsResponseSchema,
@@ -24,6 +24,7 @@ export {
24
24
  // enum, and the SDK's `denyCommonsSignIn`.
25
25
  COMMONS_DENY_REASONS, commonsDenyReasonSchema, IDENTITY_APPROVAL_PUSH_CHANNEL, } from './commonsSignIn.js';
26
26
  export { INBOX_EMAIL_PUSH_CHANNEL, INBOX_EMAIL_PUSH_TYPE, inboxEmailPushDataSchema, } from './inboxPush.js';
27
+ export { OXY_USER_INVALIDATION_CHANNEL, OXY_USER_CHANGE_REASONS, OXY_PUBLISHED_USER_CHANGE_REASONS, isPublishedOxyUserChangeReason, oxyUserInvalidationEventSchema, } from './userInvalidation.js';
27
28
  export {
28
29
  // Schemas
29
30
  recommendationExcludeTypeSchema, recommendationBoostSchema, recommendationSignalWeightsSchema, recommendationRequestSchema, recommendationCountSchema, recommendationItemSchema, recommendationResponseSchema, appEndorsementInputSchema, appInterestInputSchema, appUserSignalIngestSchema, appAffinityEventTypeSchema, appAffinityEventSchema, appAffinityEventsIngestSchema, } from './recommendations.js';
@@ -55,6 +56,17 @@ awardReputationSchema, createReputationDisputeSchema, resolveReputationDisputeSc
55
56
  // Narrows the two balance views apart at runtime.
56
57
  isFullReputationBalance, } from './reputation.js';
57
58
  export {
59
+ // Closed value sets — the moderation reputation bridge (CrowdSource → Oxy Trust).
60
+ MODERATION_SEVERITIES, MODERATION_FINDING_SCOPES, MODERATION_ATTRIBUTIONS, MODERATION_DECISION_STATUSES, MODERATION_EFFECT_TYPES, MODERATION_EFFECT_STATUSES, MODERATION_EFFECT_SKIP_REASONS, CONDUCT_STRIKE_STATUSES, CONDUCT_STANDINGS, CONTRIBUTION_TIERS, PERSONHOOD_STATUSES, IDENTITY_BINDING_TYPES, IDENTITY_BINDING_STATUSES, APPLICATION_MODERATION_STANDINGS,
61
+ // Schemas — closed value sets
62
+ moderationSeveritySchema, moderationFindingScopeSchema, moderationAttributionSchema, moderationDecisionStatusSchema, moderationEffectTypeSchema, moderationEffectStatusSchema, moderationEffectSkipReasonSchema, conductStrikeStatusSchema, conductStandingSchema, contributionTierSchema, personhoodStatusSchema, identityBindingTypeSchema, identityBindingStatusSchema, applicationModerationStandingSchema,
63
+ // Schemas — the event and its receipt
64
+ moderationFindingSchema, moderationDecisionEventSubjectSchema, moderationPolicyVersionsSchema, moderationDecisionEventSchema, finalizeModerationDecisionSchema, reverseModerationEffectSchema, moderationEffectSchema, applyModerationDecisionResultSchema, reverseModerationEffectResultSchema,
65
+ // Schemas — identity binding
66
+ registerIdentityBindingSchema, identityBindingSchema,
67
+ // Schemas — the derived V2 axes
68
+ reputationPersonhoodSchema, reputationContributionSchema, reputationConductSchema, reputationReportingSchema, reputationReviewingSchema, reputationContextualInfluenceSchema, applicationModerationTrustSchema, } from './moderationReputation.js';
69
+ export {
58
70
  // Schemas
59
71
  linkPreviewSchema, linkPreviewBatchRequestSchema, linkPreviewBatchResponseSchema, linkPreviewResponseSchema, } from './links.js';
60
72
  export { sessionAccountSchema, deviceSessionStateSchema, activeTokenSchema, deviceSessionSyncSchema, deviceTokenMintRequestSchema, deviceTokenMintResponseSchema, deviceBackgroundCredentialResponseSchema, deviceBackgroundTokenRequestSchema, deviceBackgroundTokenResponseSchema, SESSION_ACCOUNTS_CHANGED_EVENT, sessionAccountsChangedReasonSchema, sessionAccountsChangedEventSchema, } from './deviceSession.js';
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Oxy Trust — the moderation reputation bridge (CrowdSource → Oxy Trust).
3
+ *
4
+ * SINGLE SOURCE OF TRUTH for the wire shapes crossing the one-way boundary
5
+ * between a participatory-moderation service and the Oxy reputation ledger.
6
+ *
7
+ * The direction is not negotiable: a moderation service NEVER writes reputation.
8
+ * It emits an authenticated internal event describing a decision it published,
9
+ * and Oxy's own consequence engine validates that event and derives the effect.
10
+ * Everything in this module is therefore either (a) the event, (b) the receipt
11
+ * the engine returns, or (c) the derived state the engine publishes back to the
12
+ * subject.
13
+ *
14
+ * Design anchors, all load-bearing:
15
+ *
16
+ * - **Conduct is a separate axis from contribution.** A conduct penalty raises
17
+ * `activeRisk` and creates a strike; positive contribution points can never
18
+ * cancel a strike, because standing is derived from active risk and not from
19
+ * the point total. See {@link ReputationConduct}.
20
+ * - **The reporting axis carries only reporting signals.** `abuseScore` on the
21
+ * legacy reliability block conflated rejected reports with every negative
22
+ * transaction; {@link ReputationReporting} exists so a conduct penalty can
23
+ * never inflate a report-abuse figure.
24
+ * - **No binding proof, no effect.** {@link ModerationDecisionEventSubject}
25
+ * requires a `bindingProofId`, and the engine rejects an event whose binding
26
+ * does not resolve to the claimed principal at or before `occurredAt`. An
27
+ * application cannot move a reputation figure by naming a user id.
28
+ * - **One penalty per incident.** The idempotency key is
29
+ * `moderation:<incidentId>:<decisionRevision>:<effectType>`; a hundred
30
+ * reports about the same material produce one effect.
31
+ * - **Every effect carries the policy version it was decided under**, so a
32
+ * consequence can be recomputed under the original policy rather than under
33
+ * whatever the current tuning happens to be.
34
+ *
35
+ * Platform-agnostic — zod only. ESM-safe (no `require()`).
36
+ */
37
+ import { z } from 'zod';
38
+ /* -------------------------------------------------------------------------- */
39
+ /* Closed value sets */
40
+ /* -------------------------------------------------------------------------- */
41
+ /**
42
+ * Severity band of a moderation finding, lowest → highest.
43
+ *
44
+ * The band — not the taxonomy code — is what the consequence engine consumes:
45
+ * points, active risk and expiry are all keyed by severity in the versioned
46
+ * conduct policy, so a new taxonomy code needs no engine change and no
47
+ * intimate category ever reaches the ledger.
48
+ */
49
+ export const MODERATION_SEVERITIES = ['low', 'medium', 'high', 'critical'];
50
+ export const moderationSeveritySchema = z.enum(MODERATION_SEVERITIES);
51
+ /**
52
+ * How far a finding reaches.
53
+ *
54
+ * - `application_local` — the application enforces locally; Oxy Trust is NOT
55
+ * touched. Emitted for completeness; the engine rejects the effect.
56
+ * - `oxy_network` — conduct against the Oxy network as a whole.
57
+ * - `identity_integrity` — impersonation, sybil behaviour, credential abuse.
58
+ *
59
+ * Only `oxy_network` and `identity_integrity` can produce a global effect.
60
+ */
61
+ export const MODERATION_FINDING_SCOPES = [
62
+ 'application_local',
63
+ 'oxy_network',
64
+ 'identity_integrity',
65
+ ];
66
+ export const moderationFindingScopeSchema = z.enum(MODERATION_FINDING_SCOPES);
67
+ /** Which participant in the reported material the finding attributes to. */
68
+ export const MODERATION_ATTRIBUTIONS = ['author', 'sharer', 'reporter', 'reviewer'];
69
+ export const moderationAttributionSchema = z.enum(MODERATION_ATTRIBUTIONS);
70
+ /**
71
+ * Lifecycle of the decision the event describes.
72
+ *
73
+ * `inconclusive` is its own outcome and never collapses into "no violation";
74
+ * it simply produces no effect. `superseded` and `corrected` describe a
75
+ * revision that a later one replaced — an event in either state is rejected,
76
+ * because applying it would resurrect a consequence the appeal removed.
77
+ */
78
+ export const MODERATION_DECISION_STATUSES = [
79
+ 'provisional',
80
+ 'final',
81
+ 'inconclusive',
82
+ 'superseded',
83
+ 'corrected',
84
+ ];
85
+ export const moderationDecisionStatusSchema = z.enum(MODERATION_DECISION_STATUSES);
86
+ /**
87
+ * The kind of consequence an effect carries. Each is its own axis, and the
88
+ * idempotency key includes it — one incident may legitimately produce a conduct
89
+ * effect for the author AND a report-abuse effect for a malicious reporter.
90
+ */
91
+ export const MODERATION_EFFECT_TYPES = [
92
+ 'conduct_penalty',
93
+ 'report_abuse_penalty',
94
+ 'review_abuse_penalty',
95
+ ];
96
+ export const moderationEffectTypeSchema = z.enum(MODERATION_EFFECT_TYPES);
97
+ /** Lifecycle of a stored effect. */
98
+ export const MODERATION_EFFECT_STATUSES = ['applied', 'reversed'];
99
+ export const moderationEffectStatusSchema = z.enum(MODERATION_EFFECT_STATUSES);
100
+ /** Lifecycle of a conduct strike. Only `active` strikes carry active risk. */
101
+ export const CONDUCT_STRIKE_STATUSES = ['active', 'expired', 'reversed'];
102
+ export const conductStrikeStatusSchema = z.enum(CONDUCT_STRIKE_STATUSES);
103
+ /**
104
+ * Conduct standing, derived from ACTIVE RISK and nothing else.
105
+ *
106
+ * Deliberately independent of the point total: a person may hold a high
107
+ * contribution tier and a `limited` standing at the same time, and earning
108
+ * points cannot move standing back toward `good`. Only expiry or reversal can.
109
+ */
110
+ export const CONDUCT_STANDINGS = ['good', 'watch', 'limited', 'restricted'];
111
+ export const conductStandingSchema = z.enum(CONDUCT_STANDINGS);
112
+ /** Contribution tier, derived from contribution points only. */
113
+ export const CONTRIBUTION_TIERS = ['new', 'trusted', 'high_trust'];
114
+ export const contributionTierSchema = z.enum(CONTRIBUTION_TIERS);
115
+ /** Personhood status. Being a real person proves neither conduct nor competence. */
116
+ export const PERSONHOOD_STATUSES = ['unknown', 'probable', 'verified'];
117
+ export const personhoodStatusSchema = z.enum(PERSONHOOD_STATUSES);
118
+ /**
119
+ * How an Oxy identity was bound to the actor an application reported.
120
+ *
121
+ * - `oauth_grant` — the user authorized the application through Oxy's own
122
+ * OAuth flow. Oxy wrote the record; the application asserts nothing.
123
+ * - `session_proof` — the application presented the USER'S OWN Oxy access
124
+ * token alongside its service credential, proving the user was present in
125
+ * that application under a named local principal id.
126
+ * - `commons_signature` — a DID-verifiable signature over a server-issued nonce.
127
+ * - `federated_actor` — a resolvable, authorized federated actor link.
128
+ */
129
+ export const IDENTITY_BINDING_TYPES = [
130
+ 'oauth_grant',
131
+ 'session_proof',
132
+ 'commons_signature',
133
+ 'federated_actor',
134
+ ];
135
+ export const identityBindingTypeSchema = z.enum(IDENTITY_BINDING_TYPES);
136
+ /** Binding lifecycle. A revoked binding proves nothing about a later action. */
137
+ export const IDENTITY_BINDING_STATUSES = ['active', 'revoked'];
138
+ export const identityBindingStatusSchema = z.enum(IDENTITY_BINDING_STATUSES);
139
+ /**
140
+ * An application's own moderation standing. An external application can abuse
141
+ * the system too, so it carries standing exactly like a person does.
142
+ *
143
+ * `sandbox` applications moderate locally and produce NO global effect.
144
+ */
145
+ export const APPLICATION_MODERATION_STANDINGS = ['sandbox', 'trusted', 'restricted'];
146
+ export const applicationModerationStandingSchema = z.enum(APPLICATION_MODERATION_STANDINGS);
147
+ /**
148
+ * Why the engine declined to apply an effect.
149
+ *
150
+ * Returned rather than thrown for the cases that are a legitimate outcome of a
151
+ * well-formed event (a sandboxed application, a local-only finding, an
152
+ * inconclusive decision): the emitter must be able to record "delivered, no
153
+ * effect" and stop retrying. Malformed or unauthorized events are HTTP errors,
154
+ * not skip reasons.
155
+ */
156
+ export const MODERATION_EFFECT_SKIP_REASONS = [
157
+ 'no_binding_proof',
158
+ 'binding_after_action',
159
+ 'binding_principal_mismatch',
160
+ 'binding_revoked',
161
+ 'decision_not_effective',
162
+ 'decision_superseded',
163
+ 'finding_scope_local',
164
+ 'finding_not_in_policy',
165
+ 'application_not_permitted',
166
+ 'no_effective_finding',
167
+ ];
168
+ export const moderationEffectSkipReasonSchema = z.enum(MODERATION_EFFECT_SKIP_REASONS);
169
+ export const moderationFindingSchema = z.object({
170
+ code: z.string().trim().min(1).max(200),
171
+ severity: moderationSeveritySchema,
172
+ scope: moderationFindingScopeSchema,
173
+ attribution: moderationAttributionSchema,
174
+ family: z.string().trim().min(1).max(100),
175
+ });
176
+ export const moderationDecisionEventSubjectSchema = z.object({
177
+ principalType: z.literal('oxy_user'),
178
+ principalId: z.string().trim().min(1),
179
+ bindingProofId: z.string().trim().min(1),
180
+ });
181
+ export const moderationPolicyVersionsSchema = z.object({
182
+ universal: z.string().trim().min(1).max(100),
183
+ application: z.string().trim().min(1).max(100),
184
+ oxyConduct: z.string().trim().min(1).max(100),
185
+ });
186
+ export const moderationDecisionEventSchema = z.object({
187
+ eventId: z.string().trim().min(1).max(200),
188
+ reportedApplicationId: z.string().trim().min(1).max(200),
189
+ type: z.string().trim().min(1).max(200),
190
+ caseId: z.string().trim().min(1).max(200),
191
+ incidentId: z.string().trim().min(1).max(200),
192
+ decisionId: z.string().trim().min(1).max(200),
193
+ decisionRevision: z.number().int().min(1),
194
+ subject: moderationDecisionEventSubjectSchema,
195
+ findings: z.array(moderationFindingSchema).min(1).max(20),
196
+ decisionStatus: moderationDecisionStatusSchema,
197
+ policyVersions: moderationPolicyVersionsSchema,
198
+ occurredAt: z.string().trim().min(1),
199
+ proofHash: z.string().trim().min(1).max(200),
200
+ });
201
+ export const finalizeModerationDecisionSchema = z.object({
202
+ decisionId: z.string().trim().min(1).max(200),
203
+ decisionRevision: z.number().int().min(1),
204
+ });
205
+ export const reverseModerationEffectSchema = z.object({
206
+ decisionId: z.string().trim().min(1).max(200),
207
+ decisionRevision: z.number().int().min(1),
208
+ reason: z.string().trim().min(1).max(500),
209
+ });
210
+ export const moderationEffectSchema = z.object({
211
+ id: z.string(),
212
+ incidentId: z.string(),
213
+ caseId: z.string(),
214
+ decisionId: z.string(),
215
+ decisionRevision: z.number(),
216
+ principalId: z.string(),
217
+ effectType: moderationEffectTypeSchema,
218
+ status: moderationEffectStatusSchema,
219
+ points: z.number(),
220
+ activeRisk: z.number(),
221
+ severity: moderationSeveritySchema,
222
+ repetitionMultiplier: z.number(),
223
+ multiFindingMultiplier: z.number(),
224
+ idempotencyKey: z.string(),
225
+ transactionId: z.string(),
226
+ strikeId: z.string().optional(),
227
+ reversalTransactionId: z.string().optional(),
228
+ policyVersions: moderationPolicyVersionsSchema,
229
+ appliedAt: z.string(),
230
+ reversedAt: z.string().optional(),
231
+ });
232
+ export const applyModerationDecisionResultSchema = z.object({
233
+ applied: z.boolean(),
234
+ effect: moderationEffectSchema.optional(),
235
+ skipReason: moderationEffectSkipReasonSchema.optional(),
236
+ idempotent: z.boolean(),
237
+ });
238
+ export const reverseModerationEffectResultSchema = z.object({
239
+ reversed: z.array(moderationEffectSchema),
240
+ idempotent: z.boolean(),
241
+ });
242
+ export const registerIdentityBindingSchema = z.object({
243
+ localPrincipalId: z.string().trim().min(1).max(200),
244
+ userProofToken: z.string().trim().min(1),
245
+ });
246
+ export const identityBindingSchema = z.object({
247
+ id: z.string(),
248
+ applicationId: z.string(),
249
+ userId: z.string(),
250
+ localPrincipalId: z.string(),
251
+ bindingType: identityBindingTypeSchema,
252
+ status: identityBindingStatusSchema,
253
+ verifiedAt: z.string(),
254
+ createdAt: z.string(),
255
+ });
256
+ export const reputationPersonhoodSchema = z.object({
257
+ status: personhoodStatusSchema,
258
+ score: z.number(),
259
+ });
260
+ export const reputationContributionSchema = z.object({
261
+ points: z.number(),
262
+ tier: contributionTierSchema,
263
+ });
264
+ export const reputationConductSchema = z.object({
265
+ standing: conductStandingSchema,
266
+ activeRisk: z.number(),
267
+ activeStrikes: z.number(),
268
+ nextExpiryAt: z.string().optional(),
269
+ });
270
+ export const reputationReportingSchema = z.object({
271
+ reliability: z.number(),
272
+ confidence: z.number(),
273
+ confirmed: z.number(),
274
+ rejected: z.number(),
275
+ malicious: z.number(),
276
+ });
277
+ export const reputationReviewingSchema = z.object({
278
+ globalReliability: z.number(),
279
+ categoryReliability: z.record(z.number()),
280
+ languageReliability: z.record(z.number()),
281
+ });
282
+ export const reputationContextualInfluenceSchema = z.object({
283
+ reportPriorityWeight: z.number(),
284
+ reviewSelectionWeight: z.number(),
285
+ rankingWeight: z.number(),
286
+ });
287
+ export const applicationModerationTrustSchema = z.object({
288
+ applicationId: z.string(),
289
+ standing: applicationModerationStandingSchema,
290
+ evidenceIntegrity: z.number(),
291
+ identityBindingReliability: z.number(),
292
+ decisionOverturnRate: z.number(),
293
+ policyQuality: z.number(),
294
+ globalReputationEffectsAllowed: z.boolean(),
295
+ });
@@ -41,6 +41,7 @@
41
41
  */
42
42
  import { z } from 'zod';
43
43
  import { userNameSchema } from './userResponse.js';
44
+ import { reputationConductSchema, reputationContextualInfluenceSchema, reputationContributionSchema, reputationPersonhoodSchema, reputationReportingSchema, reputationReviewingSchema, } from './moderationReputation.js';
44
45
  /* -------------------------------------------------------------------------- */
45
46
  /* Closed value sets */
46
47
  /* -------------------------------------------------------------------------- */
@@ -177,11 +178,22 @@ export const reputationBalanceSchema = z.object({
177
178
  reliability: reputationReliabilitySchema,
178
179
  recalculatedAt: z.string(),
179
180
  updatedAt: z.string(),
181
+ personhood: reputationPersonhoodSchema.optional(),
182
+ contribution: reputationContributionSchema.optional(),
183
+ conduct: reputationConductSchema.optional(),
184
+ reporting: reputationReportingSchema.optional(),
185
+ reviewing: reputationReviewingSchema.optional(),
186
+ contextualInfluence: reputationContextualInfluenceSchema.optional(),
180
187
  });
181
188
  /**
182
- * Every field the full {@link ReputationBalance} carries beyond the public
183
- * {@link ReputationBalanceSummary}. The runtime discriminant between the two
184
- * views the API sends this set all-or-nothing.
189
+ * The fields the full {@link ReputationBalance} carries beyond the public
190
+ * {@link ReputationBalanceSummary} that the API sends ALL-OR-NOTHING. The
191
+ * runtime discriminant between the two views.
192
+ *
193
+ * The V2 blocks (`conduct`, `contribution`, …) are deliberately NOT listed:
194
+ * they are optional on the wire, so requiring them here would make a balance
195
+ * from a server that predates them fail to narrow, hiding the whole private
196
+ * view. Read a V2 block by checking that block.
185
197
  */
186
198
  const FULL_BALANCE_FIELDS = [
187
199
  'positive',
@@ -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 `@oxyhq/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
+ });
@@ -30,7 +30,7 @@
30
30
  */
31
31
  import { z } from 'zod';
32
32
  import { verifiedDomainSchema } from './identity.js';
33
- import { organizationCategorySchema } from './accountGraph.js';
33
+ import { accountKindSchema, organizationCategorySchema } from './accountGraph.js';
34
34
  export const userNameSchema = z
35
35
  .object({
36
36
  first: z.string().optional(),
@@ -100,9 +100,23 @@ export const userResponseSchema = z
100
100
  * entry; present only when the account has verified at least one domain.
101
101
  */
102
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(),
103
117
  /**
104
118
  * Real-estate / team taxonomy for `kind: 'organization'` accounts.
105
- * Absent on personal, project, and bot accounts.
119
+ * Absent on personal, project, bot, and channel accounts.
106
120
  */
107
121
  organizationCategory: organizationCategorySchema.optional(),
108
122
  /**
@@ -126,6 +140,12 @@ export const userProfileUpdateSchema = z
126
140
  .object({
127
141
  first: z.string().optional(),
128
142
  last: z.string().optional(),
143
+ /**
144
+ * Explicit display name, stored rather than composed. Wins over
145
+ * `first`/`last` when set; send `''` to clear it and fall back
146
+ * to the composed pair.
147
+ */
148
+ displayName: z.string().optional(),
129
149
  })
130
150
  .optional(),
131
151
  username: z.string().optional(),