@oxyhq/contracts 0.1.1 → 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 -1
- package/dist/cjs/fedcmToken.js +49 -0
- package/dist/cjs/index.js +16 -1
- package/dist/cjs/recommendations.js +125 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/fedcmToken.js +46 -0
- package/dist/esm/index.js +6 -0
- package/dist/esm/recommendations.js +122 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/fedcmToken.d.ts +59 -0
- package/dist/types/index.d.ts +4 -0
- package/dist/types/recommendations.d.ts +535 -0
- 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
|
@@ -17,3 +17,9 @@ resolveUserId, safeParseContract, } from './userResponse.js';
|
|
|
17
17
|
export {
|
|
18
18
|
// Schemas
|
|
19
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' });
|