@oxyhq/contracts 0.1.0 → 0.2.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.
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Canonical contract for the FedCM ID-token JWT payload.
3
+ *
4
+ * SINGLE SOURCE OF TRUTH for the decoded claims of the HS256 ID token the auth
5
+ * IdP (`auth.oxy.so`) signs and `POST /fedcm/exchange` consumes. The API decodes
6
+ * the JWT, verifies its signature, then validates the resulting claim object
7
+ * against this schema before trusting any field — a malformed payload (e.g. a
8
+ * forged token whose signature happened to match but whose body is the wrong
9
+ * shape) is rejected at the boundary instead of being cast and used.
10
+ *
11
+ * Validation philosophy — match the existing exchange behaviour exactly:
12
+ * - This schema validates the STRUCTURAL shape of the decoded claims only
13
+ * (types of the fields, not their presence or business-rule validity).
14
+ * - `sub` / `aud` / `nonce` / `iss` / `exp` presence + value checks remain in
15
+ * `fedcm.service.exchangeIdToken`, which returns the specific
16
+ * `missing_required_fields` / `invalid_issuer` / `token_expired` errors. So
17
+ * every field is `.optional()` here: a token missing `nonce` must still reach
18
+ * the `missing_required_fields` branch, not be rejected as a malformed token.
19
+ * - `.passthrough()` preserves any additional claims the IdP may add without a
20
+ * coordinated contract bump.
21
+ *
22
+ * Faithful to the producer:
23
+ * - `packages/auth/server/index.ts` `mintSessionForClient` — builds the
24
+ * assertion with `iss` (central issuer), `sub` (user id), `aud` (RP origin),
25
+ * `exp` / `iat` (numeric epoch seconds), and `nonce` (the server-minted,
26
+ * origin-bound nonce).
27
+ *
28
+ * Platform-agnostic — zod only, no react/react-native/expo. ESM-safe (no
29
+ * `require()`).
30
+ */
31
+ import { z } from 'zod';
32
+ /**
33
+ * Decoded FedCM ID-token claims. Every field is optional because presence is
34
+ * enforced downstream (see module doc); the schema's job is to guarantee that
35
+ * any present claim has the correct primitive type before it is read.
36
+ */
37
+ export const fedcmTokenPayloadSchema = z
38
+ .object({
39
+ iss: z.string().optional(),
40
+ sub: z.string().optional(),
41
+ aud: z.string().optional(),
42
+ exp: z.number().optional(),
43
+ iat: z.number().optional(),
44
+ nonce: z.string().optional(),
45
+ })
46
+ .passthrough();
package/dist/esm/index.js CHANGED
@@ -11,6 +11,15 @@
11
11
  */
12
12
  export {
13
13
  // Schemas
14
- userNameSchema, userResponseSchema, refreshAllAccountSchema, refreshAllResponseSchema, currentUserResponseSchema, deviceSessionAccountSchema, deviceSessionsResponseSchema,
14
+ userNameSchema, userResponseSchema, userProfileUpdateSchema, refreshAllAccountSchema, refreshAllResponseSchema, currentUserResponseSchema, deviceSessionAccountSchema, deviceSessionsResponseSchema,
15
15
  // Helpers
16
16
  resolveUserId, safeParseContract, } from './userResponse.js';
17
+ export {
18
+ // Schemas
19
+ applicationTypeSchema, publicApplicationSchema, sessionStatusSchema, } from './sessionStatus.js';
20
+ export {
21
+ // Schemas
22
+ fedcmTokenPayloadSchema, } from './fedcmToken.js';
23
+ export {
24
+ // Schemas
25
+ recommendationExcludeTypeSchema, recommendationBoostSchema, recommendationSignalWeightsSchema, recommendationRequestSchema, recommendationCountSchema, recommendationItemSchema, recommendationResponseSchema, appEndorsementInputSchema, appInterestInputSchema, appUserSignalIngestSchema, } from './recommendations.js';
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Recommendation-engine API contracts.
3
+ *
4
+ * SINGLE SOURCE OF TRUTH for the wire shape of the reputation-weighted
5
+ * profile-recommendation surface (`POST /profiles/recommendations`) and the
6
+ * cross-app signal-ingest endpoint (`POST /app-signals/ingest`). The API
7
+ * validates its INPUT/OUTPUT against these schemas; consumer SDKs validate the
8
+ * same definitions, so the producer and every consumer cannot drift.
9
+ *
10
+ * Platform-agnostic — zod is the only runtime dependency (no react / react-native
11
+ * / expo, ESM-safe).
12
+ */
13
+ import { z } from 'zod';
14
+ import { userNameSchema } from './userResponse.js';
15
+ /** User-type filters a caller may exclude from the recommendation surface. */
16
+ export const recommendationExcludeTypeSchema = z.enum([
17
+ 'federated',
18
+ 'agent',
19
+ 'automated',
20
+ ]);
21
+ /**
22
+ * A caller-supplied editorial boost. `userIds` are nudged up (or down, for a
23
+ * negative weight) in the ranking; the optional `reason` is for audit/telemetry
24
+ * only and never surfaced to end users. Boost members still pass the eligibility
25
+ * gate — a boost cannot resurrect a private/restricted/ineligible account.
26
+ */
27
+ export const recommendationBoostSchema = z.object({
28
+ userIds: z.array(z.string().trim().min(1)).min(1).max(200),
29
+ weight: z.number().min(-5).max(5),
30
+ reason: z.string().trim().max(120).optional(),
31
+ });
32
+ /**
33
+ * Per-request overrides for the scoring signal weights. Every key is optional
34
+ * and clamped server-side to the resolved weight profile's allowed range — a
35
+ * caller can re-weight signals but never escape the profile's bounds.
36
+ */
37
+ export const recommendationSignalWeightsSchema = z
38
+ .object({
39
+ graph: z.number().min(0).max(10).optional(),
40
+ completeness: z.number().min(0).max(10).optional(),
41
+ verified: z.number().min(0).max(10).optional(),
42
+ curation: z.number().min(0).max(10).optional(),
43
+ interest: z.number().min(0).max(10).optional(),
44
+ appBoost: z.number().min(0).max(10).optional(),
45
+ repCandidate: z.number().min(0).max(10).optional(),
46
+ })
47
+ .partial();
48
+ /**
49
+ * Request body for `POST /profiles/recommendations`.
50
+ *
51
+ * `clientId` selects the per-app weight profile (the Application `_id`); when
52
+ * omitted the default profile is used. `excludeIds` removes accounts the caller
53
+ * has already seen/handled; `boosts` and `signalWeights` let the caller bias the
54
+ * ranking within server-enforced bounds.
55
+ */
56
+ export const recommendationRequestSchema = z.object({
57
+ clientId: z.string().trim().min(1).optional(),
58
+ limit: z.number().int().min(1).max(100).optional(),
59
+ offset: z.number().int().min(0).optional(),
60
+ excludeTypes: z.array(recommendationExcludeTypeSchema).optional(),
61
+ excludeIds: z.array(z.string().trim().min(1)).max(500).optional(),
62
+ boosts: z.array(recommendationBoostSchema).max(50).optional(),
63
+ signalWeights: recommendationSignalWeightsSchema.optional(),
64
+ });
65
+ /** Follower/following counts attached to a recommendation item. */
66
+ export const recommendationCountSchema = z.object({
67
+ followers: z.number().int().nonnegative(),
68
+ following: z.number().int().nonnegative(),
69
+ });
70
+ /**
71
+ * A single recommended profile.
72
+ *
73
+ * `name` reuses the canonical {@link userNameSchema} so `name.displayName` is the
74
+ * already-resolved server-side value. `score` and `matchedSignals` are present
75
+ * only on the scored (v2) path; `mutualCount` and `_count` are always present.
76
+ */
77
+ export const recommendationItemSchema = z
78
+ .object({
79
+ id: z.string(),
80
+ username: z.string().optional(),
81
+ name: userNameSchema,
82
+ avatar: z.string().nullable().optional(),
83
+ description: z.string().nullable().optional(),
84
+ verified: z.boolean().optional(),
85
+ trustTier: z.string().optional(),
86
+ mutualCount: z.number().int().nonnegative(),
87
+ score: z.number().optional(),
88
+ matchedSignals: z.array(z.string()).optional(),
89
+ isFederated: z.boolean().optional(),
90
+ isAgent: z.boolean().optional(),
91
+ isAutomated: z.boolean().optional(),
92
+ instance: z.string().optional(),
93
+ _count: recommendationCountSchema,
94
+ })
95
+ .passthrough();
96
+ /** Wire shape of the recommendation response — an array of items. */
97
+ export const recommendationResponseSchema = z.array(recommendationItemSchema);
98
+ /** One endorsement edge an app reports: `ownerId` endorses `memberId`. */
99
+ export const appEndorsementInputSchema = z.object({
100
+ ownerId: z.string().trim().min(1),
101
+ memberId: z.string().trim().min(1),
102
+ op: z.enum(['add', 'remove']).default('add'),
103
+ sourceId: z.string().trim().min(1).optional(),
104
+ });
105
+ /** One interest signal an app reports: how interested `userId` is in a topic. */
106
+ export const appInterestInputSchema = z.object({
107
+ userId: z.string().trim().min(1),
108
+ interestScore: z.number().min(0).max(1),
109
+ });
110
+ /**
111
+ * Request body for `POST /app-signals/ingest` (service token, `signals:write`).
112
+ *
113
+ * At least one of `endorsements` / `interests` must be non-empty — an ingest
114
+ * with neither is a no-op and rejected so a misconfigured caller is surfaced
115
+ * rather than silently succeeding.
116
+ */
117
+ export const appUserSignalIngestSchema = z
118
+ .object({
119
+ endorsements: z.array(appEndorsementInputSchema).max(500).optional(),
120
+ interests: z.array(appInterestInputSchema).max(500).optional(),
121
+ })
122
+ .refine((value) => (value.endorsements?.length ?? 0) > 0 || (value.interests?.length ?? 0) > 0, { message: 'At least one of endorsements or interests must be non-empty' });
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Canonical contract for `GET /auth/session/status/:sessionToken`.
3
+ *
4
+ * SINGLE SOURCE OF TRUTH for the wire shape of the cross-app device-flow
5
+ * session-status payload and the sanitized public application identity it
6
+ * embeds. The API validates its OUTPUT against these schemas; the auth app
7
+ * (consent UI) validates its INPUT against the same schemas. Because there is
8
+ * exactly one definition, the producer and the consumer cannot drift.
9
+ *
10
+ * The class of bug that motivated moving this into `@oxyhq/contracts`: the auth
11
+ * app's LOCAL `sessionStatusSchema` typed `sessionId` as a non-nullable
12
+ * `z.string().optional()`. The producer emits `sessionId: authorizedSessionId ||
13
+ * null`, so a PENDING session (not yet authorized) carries `sessionId: null` —
14
+ * `.optional()` permits `undefined`/missing but REJECTS `null`, so `safeParse`
15
+ * failed, the whole response collapsed to `null`, and the consent screen showed
16
+ * "Unable to identify the requesting application". Pinning the nullability in one
17
+ * shared place makes that drift impossible.
18
+ *
19
+ * Faithful to the producers:
20
+ * - `packages/api/src/utils/serializeApplication.ts` `serializePublicApplication`
21
+ * — the ONLY shape returned to an unauthenticated consent UI. Optional fields
22
+ * (`description`, `icon`, `websiteUrl`, `developerName`) are OMITTED when
23
+ * absent (never serialized as `null`), so they are `.optional()` — NOT
24
+ * `.nullable()`. `type` is the `Application.type` enum.
25
+ * - `packages/api/src/routes/auth.ts` `GET /session/status/:sessionToken` — the
26
+ * inner object of the API's `{ data: ... }` success envelope. The handler
27
+ * ALWAYS emits `status`, `authorized` (`status === 'authorized'`),
28
+ * `sessionToken`, `expiresAt` (ISO string), and `application` (resolved object
29
+ * OR `null`). It ALWAYS emits `sessionId` / `publicKey` / `userId`, each as a
30
+ * string value OR `null` (`authorizedSessionId || null`, `authorizedBy ||
31
+ * null`, `authorizedUserId?.toString() || null`).
32
+ *
33
+ * Platform-agnostic — zod only, no react/react-native/expo. ESM-safe (no
34
+ * `require()`).
35
+ */
36
+ import { z } from 'zod';
37
+ /**
38
+ * Application `type` enum. Mirrors `APPLICATION_TYPES` in
39
+ * `packages/api/src/models/Application.ts` (`first_party` | `third_party` |
40
+ * `internal` | `system`).
41
+ */
42
+ export const applicationTypeSchema = z.enum([
43
+ 'first_party',
44
+ 'third_party',
45
+ 'internal',
46
+ 'system',
47
+ ]);
48
+ /**
49
+ * The display-safe public identity of a requesting application, exactly as
50
+ * `serializePublicApplication` emits it. Returned by the API inside
51
+ * `GET /auth/session/status/:sessionToken` (device flow) and
52
+ * `GET /auth/oauth/client/:clientId` (OAuth code flow).
53
+ *
54
+ * Optional fields are `.optional()` (NOT `.nullable()`): the serializer OMITS
55
+ * `description` / `icon` / `websiteUrl` / `developerName` when the underlying
56
+ * value is absent — it never writes `null` for them. `developerName` is only
57
+ * attached for non-official apps when a name could be resolved.
58
+ */
59
+ export const publicApplicationSchema = z.object({
60
+ id: z.string(),
61
+ name: z.string(),
62
+ description: z.string().optional(),
63
+ icon: z.string().optional(),
64
+ websiteUrl: z.string().optional(),
65
+ type: applicationTypeSchema,
66
+ isOfficial: z.boolean(),
67
+ isInternal: z.boolean(),
68
+ scopes: z.array(z.string()),
69
+ developerName: z.string().optional(),
70
+ });
71
+ /**
72
+ * The inner object of `GET /auth/session/status/:sessionToken` (inside the API's
73
+ * `{ data: ... }` envelope).
74
+ *
75
+ * `application` is the resolved {@link publicApplicationSchema} identity of the
76
+ * requesting application, or `null` when the bound app was hard-deleted / is no
77
+ * longer `active` (defensive — normally always present).
78
+ *
79
+ * `sessionId` / `publicKey` / `userId` are `.nullable().optional()`: the producer
80
+ * ALWAYS emits the key, with a string for an AUTHORIZED session or `null` for a
81
+ * PENDING one. `.nullable()` accepts the PENDING `null`; `.optional()` is belt-
82
+ * and-braces so a consumer is never broken by a future projection that drops the
83
+ * key. (`.optional()` alone would REJECT the PENDING `null` — that was the bug.)
84
+ *
85
+ * `authorized` / `sessionToken` / `expiresAt` are emitted unconditionally by the
86
+ * current producer and are never `null`, but stay `.optional()` so the contract
87
+ * tolerates leaner shapes from other producers of this same payload without a
88
+ * coordinated bump.
89
+ */
90
+ export const sessionStatusSchema = z.object({
91
+ status: z.string(),
92
+ authorized: z.boolean().optional(),
93
+ sessionToken: z.string().optional(),
94
+ application: publicApplicationSchema.nullable().optional(),
95
+ expiresAt: z.string().optional(),
96
+ sessionId: z.string().nullable().optional(),
97
+ publicKey: z.string().nullable().optional(),
98
+ userId: z.string().nullable().optional(),
99
+ });
@@ -18,14 +18,14 @@
18
18
  * - `packages/api/src/utils/userTransform.ts` `formatUserResponse` — the
19
19
  * canonical serialization used by `/auth/refresh-all`, device sessions, etc.
20
20
  * Emits `id` (NOT `_id`), forwards `username` verbatim (may be absent), and
21
- * emits `name` as the structured `{ first, last, full }` subdocument.
21
+ * emits `name` as the structured `{ first, last, full, displayName }`
22
+ * subdocument.
22
23
  * - `packages/api/src/models/User.ts` — `NameSchema` (`first`/`last` default
23
- * `''`; `full` is a Mongoose VIRTUAL) and the `displayName` virtual. Because
24
- * virtuals are only present when a query uses `.lean({ virtuals: true })` (or
25
- * a hydrated doc), `name.full` and `displayName` MUST be treated as OPTIONAL.
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.
26
27
  * - The `/auth/refresh-all` handler in `packages/api/src/routes/auth.ts`, whose
27
- * per-slot `authuser` is `number | null` (null = legacy un-suffixed `oxy_rt`
28
- * cookie slot).
28
+ * per-slot `authuser` is the numeric `oxy_rt_${authuser}` cookie slot.
29
29
  *
30
30
  * Platform-agnostic — zod only, no react/react-native/expo. ESM-safe (no
31
31
  * `require()`).
@@ -35,7 +35,9 @@ import { z } from 'zod';
35
35
  * Structured human name subdocument. Mirrors `User.name` (`NameSchema`).
36
36
  *
37
37
  * - `first` / `last` default to `''` in Mongo, so they are optional on the wire.
38
- * - `full` is a Mongoose virtual — ABSENT unless the query materialised virtuals.
38
+ * - `full` is a Mongoose virtual — absent unless the query materialised
39
+ * virtuals or the serializer composed it.
40
+ * - `displayName` is the required canonical app-facing display string.
39
41
  *
40
42
  * `.passthrough()` is intentional: it tolerates additive name fields without a
41
43
  * coordinated contract bump, while the three known keys stay strongly typed.
@@ -45,18 +47,17 @@ export const userNameSchema = z
45
47
  first: z.string().optional(),
46
48
  last: z.string().optional(),
47
49
  full: z.string().optional(),
50
+ displayName: z.string(),
48
51
  })
49
52
  .passthrough();
50
53
  /**
51
54
  * The canonical user object emitted by `formatUserResponse`.
52
55
  *
53
- * Only `id` is guaranteed present (it is the early-return guard in
54
- * `formatUserResponse`). Every other field is forwarded verbatim from the user
55
- * document and may be absent depending on the query's `.select(...)`/`.lean()`
56
- * projection so all are optional/nullable to match reality. Both `id` and
57
- * `_id` are accepted because RAW-document responses (e.g. `GET /users/me`,
58
- * which does NOT pass through `formatUserResponse`) carry `_id` instead of `id`;
59
- * resolve the identifier with {@link resolveUserId}.
56
+ * `id` and `name.displayName` are guaranteed on formatted user DTOs. The rest
57
+ * is forwarded from the user document and may be absent depending on the query's
58
+ * `.select(...)`/`.lean()` projection. Both `id` and `_id` are accepted because
59
+ * some raw-document responses carry `_id` instead of `id`; resolve the
60
+ * identifier with {@link resolveUserId}.
60
61
  *
61
62
  * `.passthrough()` keeps the large tail of profile fields
62
63
  * (`privacySettings`, `locations`, `links`, `linksMetadata`, `bio`,
@@ -77,13 +78,44 @@ export const userResponseSchema = z
77
78
  avatar: z.string().nullable().optional(),
78
79
  /** Named Bloom color preset (e.g. `"blue"`) or null. */
79
80
  color: z.string().nullable().optional(),
80
- name: userNameSchema.optional(),
81
- /** Server `displayName` virtual (`username || truncatedKey`). Optional. */
82
- displayName: z.string().optional(),
81
+ name: userNameSchema,
83
82
  verified: z.boolean().optional(),
84
83
  language: z.string().optional(),
85
84
  })
86
85
  .passthrough();
86
+ export const userProfileUpdateSchema = z
87
+ .object({
88
+ name: z
89
+ .object({
90
+ first: z.string().optional(),
91
+ last: z.string().optional(),
92
+ })
93
+ .optional(),
94
+ username: z.string().optional(),
95
+ email: z.string().optional(),
96
+ avatar: z.string().optional(),
97
+ color: z.string().nullable().optional(),
98
+ bio: z.string().optional(),
99
+ description: z.string().optional(),
100
+ location: z.string().optional(),
101
+ locations: z.array(z.unknown()).optional(),
102
+ links: z.array(z.string()).optional(),
103
+ linksMetadata: z
104
+ .array(z.object({
105
+ url: z.string(),
106
+ title: z.string().optional(),
107
+ description: z.string().optional(),
108
+ image: z.string().optional(),
109
+ id: z.string().optional(),
110
+ }))
111
+ .optional(),
112
+ language: z.string().optional(),
113
+ accountExpiresAfterInactivityDays: z.number().nullable().optional(),
114
+ notificationPreferences: z.record(z.unknown()).optional(),
115
+ userPreferences: z.record(z.unknown()).optional(),
116
+ privacySettings: z.record(z.unknown()).optional(),
117
+ })
118
+ .passthrough();
87
119
  /**
88
120
  * Resolve the canonical user id from a {@link UserResponse}, accepting either
89
121
  * the `formatUserResponse` `id` field or the raw-document `_id` field.
@@ -94,14 +126,12 @@ export function resolveUserId(user) {
94
126
  /**
95
127
  * One rotated account entry from `POST /auth/refresh-all`.
96
128
  *
97
- * `authuser` is the device-local slot index (`0..N-1`). The server emits
98
- * `authuser: null` for the legacy un-suffixed `oxy_rt` cookie slot accept null
99
- * so a browser holding only a legacy cookie is NOT dropped from the account
100
- * chooser. `user` is the canonical {@link userResponseSchema} shape (the handler
101
- * projects a whitelist and runs it through `formatUserResponse`).
129
+ * `authuser` is the device-local slot index (`0..N-1`). `user` is the canonical
130
+ * {@link userResponseSchema} shape (the handler projects a whitelist and runs it
131
+ * through `formatUserResponse`).
102
132
  */
103
133
  export const refreshAllAccountSchema = z.object({
104
- authuser: z.number().int().nonnegative().nullable(),
134
+ authuser: z.number().int().nonnegative(),
105
135
  accessToken: z.string(),
106
136
  expiresAt: z.string(),
107
137
  sessionId: z.string(),
@@ -110,19 +140,16 @@ export const refreshAllAccountSchema = z.object({
110
140
  /**
111
141
  * Wire shape of `POST /auth/refresh-all`: every valid device-local account,
112
142
  * sorted by `authuser` ascending. An empty `accounts` array means "no signed-in
113
- * accounts on this device" — the IdP must show the sign-in form. A 404 means the
114
- * endpoint is not deployed and the caller falls back to single-account
115
- * `/auth/refresh`.
143
+ * accounts on this device" — the IdP must show the sign-in form.
116
144
  */
117
145
  export const refreshAllResponseSchema = z.object({
118
146
  accounts: z.array(refreshAllAccountSchema),
119
147
  });
120
148
  /**
121
149
  * Wire shape of `GET /users/me` — the API success envelope (`{ data: <user> }`)
122
- * wrapping the RAW Mongo user document. It does NOT pass through
123
- * `formatUserResponse`, so the id field is `_id` (resolve via
124
- * {@link resolveUserId}) and virtuals (`name.full`, `displayName`) may be
125
- * present when the query materialised them.
150
+ * wrapping the current-user DTO. Some older producers use `_id` instead of
151
+ * `id`; resolve via {@link resolveUserId}. The display name still lives under
152
+ * `name.displayName`.
126
153
  */
127
154
  export const currentUserResponseSchema = z.object({
128
155
  data: userResponseSchema,