@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.
- package/dist/cjs/.tsbuildinfo +1 -0
- package/dist/cjs/fedcmToken.js +49 -0
- package/dist/cjs/index.js +22 -1
- package/dist/cjs/recommendations.js +125 -0
- package/dist/cjs/sessionStatus.js +102 -0
- package/dist/cjs/userResponse.js +58 -31
- package/dist/esm/.tsbuildinfo +1 -0
- package/dist/esm/fedcmToken.js +46 -0
- package/dist/esm/index.js +10 -1
- package/dist/esm/recommendations.js +122 -0
- package/dist/esm/sessionStatus.js +99 -0
- package/dist/esm/userResponse.js +57 -30
- package/dist/types/.tsbuildinfo +1 -0
- package/dist/types/fedcmToken.d.ts +59 -0
- package/dist/types/index.d.ts +8 -2
- package/dist/types/recommendations.d.ts +535 -0
- package/dist/types/sessionStatus.d.ts +192 -0
- package/dist/types/userResponse.d.ts +340 -170
- package/package.json +1 -1
|
@@ -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
|
+
});
|
package/dist/esm/userResponse.js
CHANGED
|
@@ -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 }`
|
|
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`
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
|
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 —
|
|
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
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
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
|
|
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`).
|
|
98
|
-
*
|
|
99
|
-
*
|
|
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()
|
|
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.
|
|
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
|
|
123
|
-
* `
|
|
124
|
-
*
|
|
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,
|