@oxyhq/contracts 0.7.0 → 0.9.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,145 @@
1
+ /**
2
+ * Device-first bootstrap & token contracts (auth centralization, wave 1).
3
+ *
4
+ * SINGLE SOURCE OF TRUTH for the wire shape of the new device-first session
5
+ * bootstrap: the top-level `#oxy_boot=…` fragment the API hands back from
6
+ * `GET /auth/device/bootstrap`, the token bundle a boot code / web-session
7
+ * fast-path exchanges into, the persisted-refresh rotation, the native
8
+ * device-token issuance, the IdP chooser's device-resolve, and the first-party
9
+ * password login result (2FA arm vs. session arm). The API validates its OUTPUT
10
+ * against these schemas; every consumer (`@oxyhq/core`'s device-boot mixin, the
11
+ * SDK cold boot, the IdP chooser) validates its INPUT against the same
12
+ * definitions, so producer and consumers cannot drift.
13
+ *
14
+ * Design anchors (from the auth-centralization plan):
15
+ * - The bootstrap fragment carries NO tokens and NO deviceId — only a
16
+ * `state` echo (CSRF), a `reason`, a short-lived single-use `code`, and an
17
+ * opaque `deviceToken`. Tokens are obtained by exchanging the `code` at
18
+ * `POST /auth/device/exchange` (origin-bound GETDEL burn).
19
+ * - Refresh is ONE rotating, single-use family shared by web and native.
20
+ * - `loginResult` mirrors what `POST /auth/login` returns today
21
+ * (`buildSessionAuthResponse` in the API's `session.controller.ts`): either a
22
+ * 2FA challenge (`{ twoFactorRequired: true, loginToken }`) or a session
23
+ * payload. The session arm matches `SessionAuthResponse` EXACTLY, plus an
24
+ * optional `refreshToken` the new server adds for the persisted-refresh lane.
25
+ *
26
+ * Nested-object response shapes are declared as explicit `interface`s with the
27
+ * runtime schema annotated `z.ZodType<Interface>` — the same rationale as
28
+ * `identity.ts` / `userResponse.ts`: a `z.infer<>` of a nested object schema can
29
+ * degrade to `{}` under a consumer's `moduleResolution: "node"` (node10), so the
30
+ * load-bearing shapes are pinned by literal interfaces. Flat request/response
31
+ * shapes (no nested-object hazard) are inferred via `z.infer<>`.
32
+ *
33
+ * Platform-agnostic — zod only, no react/react-native/expo. ESM-safe (no
34
+ * `require()`).
35
+ */
36
+ import { z } from 'zod';
37
+ import { userResponseSchema } from './userResponse.js';
38
+ /* -------------------------------------------------------------------------- */
39
+ /* Bootstrap fragment */
40
+ /* -------------------------------------------------------------------------- */
41
+ /**
42
+ * Why the bootstrap hop resolved the way it did.
43
+ * - `session` — the device cookie resolved an active session; a `code` is
44
+ * present to exchange for tokens.
45
+ * - `no_session` — the device is known but has no active session; no `code`.
46
+ * - `new_device` — first contact; the cookie was just planted, no session yet.
47
+ */
48
+ export const deviceBootReasonSchema = z.enum(['session', 'no_session', 'new_device']);
49
+ /**
50
+ * The `#oxy_boot=<json>` fragment `GET /auth/device/bootstrap` appends to the
51
+ * `return_to` URL. Carries the CSRF `state` echo, the resolution `reason`, an
52
+ * optional single-use exchange `code` (present iff `reason === 'session'`), and
53
+ * the opaque `deviceToken`. NEVER carries tokens or a deviceId.
54
+ */
55
+ export const deviceBootFragmentSchema = z.object({
56
+ v: z.literal(1),
57
+ state: z.string().min(1).max(256),
58
+ reason: deviceBootReasonSchema,
59
+ code: z.string().min(20).max(128).optional(),
60
+ deviceToken: z.string().min(20).max(512),
61
+ });
62
+ /* -------------------------------------------------------------------------- */
63
+ /* Boot-code exchange */
64
+ /* -------------------------------------------------------------------------- */
65
+ /** Request body for `POST /auth/device/exchange` — the single-use boot code. */
66
+ export const deviceExchangeRequestSchema = z.object({
67
+ code: z.string().min(20).max(128),
68
+ });
69
+ export const authTokenBundleSchema = z.object({
70
+ sessionId: z.string(),
71
+ accessToken: z.string(),
72
+ refreshToken: z.string(),
73
+ expiresAt: z.string(),
74
+ user: userResponseSchema,
75
+ });
76
+ /* -------------------------------------------------------------------------- */
77
+ /* Refresh-token rotation (web + native, one implementation) */
78
+ /* -------------------------------------------------------------------------- */
79
+ /** Request body for `POST /auth/refresh-token` — the current refresh token. */
80
+ export const tokenRefreshRequestSchema = z.object({
81
+ refreshToken: z.string().min(20),
82
+ });
83
+ /**
84
+ * Wire shape of `POST /auth/refresh-token`: the rotated (single-use) family —
85
+ * a new access token, the next refresh token, the new access-token expiry, and
86
+ * the owning session id. `expiresAt` is an ISO string.
87
+ */
88
+ export const tokenRefreshResponseSchema = z.object({
89
+ accessToken: z.string(),
90
+ refreshToken: z.string(),
91
+ expiresAt: z.string(),
92
+ sessionId: z.string(),
93
+ });
94
+ /* -------------------------------------------------------------------------- */
95
+ /* Native device-token issuance */
96
+ /* -------------------------------------------------------------------------- */
97
+ /**
98
+ * Wire shape of `POST /auth/device/token` — issues (or rotates) the opaque
99
+ * device token for the native channel. The deviceId is taken from the bearer
100
+ * JWT claims server-side; only the token comes back.
101
+ */
102
+ export const deviceTokenIssueResponseSchema = z.object({
103
+ deviceToken: z.string(),
104
+ });
105
+ const loginTwoFactorRequiredSchema = z.object({
106
+ twoFactorRequired: z.literal(true),
107
+ loginToken: z.string(),
108
+ });
109
+ const loginSessionResultSchema = z.object({
110
+ sessionId: z.string(),
111
+ deviceId: z.string(),
112
+ expiresAt: z.string(),
113
+ accessToken: z.string().optional(),
114
+ refreshToken: z.string().optional(),
115
+ user: z.object({
116
+ id: z.string(),
117
+ username: z.string().optional(),
118
+ avatar: z.string().optional(),
119
+ }),
120
+ });
121
+ export const loginResultSchema = z.union([
122
+ loginTwoFactorRequiredSchema,
123
+ loginSessionResultSchema,
124
+ ]);
125
+ /* -------------------------------------------------------------------------- */
126
+ /* IdP chooser device-resolve */
127
+ /* -------------------------------------------------------------------------- */
128
+ /**
129
+ * Request body for `POST /auth/device/resolve` (X-Oxy-Internal, called by the
130
+ * IdP chooser) — the device key the chooser read from the first-party
131
+ * `oxy_device` cookie.
132
+ */
133
+ export const deviceResolveRequestSchema = z.object({
134
+ deviceKey: z.string().min(20),
135
+ });
136
+ const deviceResolveAccountSchema = z.object({
137
+ user: userResponseSchema,
138
+ sessionId: z.string(),
139
+ accessToken: z.string(),
140
+ expiresAt: z.string(),
141
+ });
142
+ export const deviceResolveResponseSchema = z.object({
143
+ activeAccountId: z.string().nullable(),
144
+ accounts: z.array(deviceResolveAccountSchema),
145
+ });
@@ -0,0 +1,22 @@
1
+ import { z } from 'zod';
2
+ export const sessionAccountSchema = z.object({
3
+ accountId: z.string(),
4
+ sessionId: z.string(),
5
+ authuser: z.number().int().nonnegative(),
6
+ operatedByUserId: z.string().optional(),
7
+ });
8
+ export const deviceSessionStateSchema = z.object({
9
+ deviceId: z.string(),
10
+ accounts: z.array(sessionAccountSchema),
11
+ activeAccountId: z.string().nullable(),
12
+ revision: z.number().int().nonnegative(),
13
+ updatedAt: z.number(),
14
+ });
15
+ export const activeTokenSchema = z.object({
16
+ accessToken: z.string(),
17
+ expiresAt: z.string(),
18
+ });
19
+ export const deviceSessionSyncSchema = z.object({
20
+ state: deviceSessionStateSchema,
21
+ activeToken: activeTokenSchema.nullable(),
22
+ });
@@ -42,5 +42,12 @@ export const fedcmTokenPayloadSchema = z
42
42
  exp: z.number().optional(),
43
43
  iat: z.number().optional(),
44
44
  nonce: z.string().optional(),
45
+ /**
46
+ * An explicit central deviceId minted by the IdP, threaded through so the
47
+ * RP session can inherit a unified device id instead of deriving one from
48
+ * the (userId, RP origin) stableDeviceKey. Optional and additive — omitted
49
+ * tokens fall back to the existing stableDeviceKey/UA-IP derivation.
50
+ */
51
+ deviceId: z.string().optional(),
45
52
  })
46
53
  .passthrough();
@@ -38,12 +38,27 @@
38
38
  * `require()`).
39
39
  */
40
40
  import { z } from 'zod';
41
- export const verificationMethodSchema = z.object({
41
+ // The option schemas are left UN-annotated so they keep their concrete
42
+ // `ZodObject` type — `z.discriminatedUnion` requires object options and an
43
+ // explicit `z.ZodType<>` annotation would erase the shape it discriminates on.
44
+ // `z.object` already infers each option's type exactly (id/type/controller +
45
+ // the key field), so the union is structurally `VerificationMethod`.
46
+ const secp256k1VerificationMethodSchema = z.object({
42
47
  id: z.string(),
43
48
  type: z.literal('EcdsaSecp256k1VerificationKey2019'),
44
49
  controller: z.string(),
45
50
  publicKeyHex: z.string(),
46
51
  });
52
+ const multikeyVerificationMethodSchema = z.object({
53
+ id: z.string(),
54
+ type: z.literal('Multikey'),
55
+ controller: z.string(),
56
+ publicKeyMultibase: z.string(),
57
+ });
58
+ export const verificationMethodSchema = z.discriminatedUnion('type', [
59
+ secp256k1VerificationMethodSchema,
60
+ multikeyVerificationMethodSchema,
61
+ ]);
47
62
  export const didServiceSchema = z.object({
48
63
  id: z.string(),
49
64
  type: z.string(),
package/dist/esm/index.js CHANGED
@@ -22,7 +22,7 @@ export {
22
22
  fedcmTokenPayloadSchema, } from './fedcmToken.js';
23
23
  export {
24
24
  // Schemas
25
- recommendationExcludeTypeSchema, recommendationBoostSchema, recommendationSignalWeightsSchema, recommendationRequestSchema, recommendationCountSchema, recommendationItemSchema, recommendationResponseSchema, appEndorsementInputSchema, appInterestInputSchema, appUserSignalIngestSchema, } from './recommendations.js';
25
+ recommendationExcludeTypeSchema, recommendationBoostSchema, recommendationSignalWeightsSchema, recommendationRequestSchema, recommendationCountSchema, recommendationItemSchema, recommendationResponseSchema, appEndorsementInputSchema, appInterestInputSchema, appUserSignalIngestSchema, appAffinityEventTypeSchema, appAffinityEventSchema, appAffinityEventsIngestSchema, } from './recommendations.js';
26
26
  export {
27
27
  // Schemas
28
28
  verificationMethodSchema, didServiceSchema, didDocumentSchema, signedRecordEnvelopeSchema, verifiedDomainSchema, domainVerificationRequestSchema, domainVerificationInstructionsSchema, authMethodEntrySchema, authMethodsResponseSchema, exportAttestationSchema, exportBundleSchema, } from './identity.js';
@@ -40,3 +40,7 @@ credentialRecordSchema, verifiableCredentialResponseSchema, credentialIssueResul
40
40
  export {
41
41
  // Schemas
42
42
  linkPreviewSchema, linkPreviewBatchRequestSchema, linkPreviewBatchResponseSchema, linkPreviewResponseSchema, } from './links.js';
43
+ export { sessionAccountSchema, deviceSessionStateSchema, activeTokenSchema, deviceSessionSyncSchema, } from './deviceSession.js';
44
+ export {
45
+ // Schemas
46
+ deviceBootReasonSchema, deviceBootFragmentSchema, deviceExchangeRequestSchema, authTokenBundleSchema, tokenRefreshRequestSchema, tokenRefreshResponseSchema, deviceTokenIssueResponseSchema, loginResultSchema, deviceResolveRequestSchema, deviceResolveResponseSchema, } from './deviceBoot.js';
@@ -43,6 +43,7 @@ export const recommendationSignalWeightsSchema = z
43
43
  interest: z.number().min(0).max(10).optional(),
44
44
  appBoost: z.number().min(0).max(10).optional(),
45
45
  repCandidate: z.number().min(0).max(10).optional(),
46
+ affinity: z.number().min(0).max(10).optional(),
46
47
  })
47
48
  .partial();
48
49
  /**
@@ -120,3 +121,45 @@ export const appUserSignalIngestSchema = z
120
121
  interests: z.array(appInterestInputSchema).max(500).optional(),
121
122
  })
122
123
  .refine((value) => (value.endorsements?.length ?? 0) > 0 || (value.interests?.length ?? 0) > 0, { message: 'At least one of endorsements or interests must be non-empty' });
124
+ /**
125
+ * The directed interaction types a consuming app may report between two users.
126
+ * Each type carries a server-side default weight (see the API's
127
+ * `AFFINITY_EVENT_WEIGHTS`); a caller may override the applied weight per event.
128
+ */
129
+ export const appAffinityEventTypeSchema = z.enum([
130
+ 'like',
131
+ 'reply',
132
+ 'boost',
133
+ 'follow',
134
+ 'mention',
135
+ 'profile_view',
136
+ 'quote',
137
+ 'repost',
138
+ ]);
139
+ /**
140
+ * One directed interaction event: `fromUserId` interacted with `toUserId`
141
+ * (`type`) at `occurredAt`. The Oxy affinity-graph folds these into a per-app,
142
+ * time-decayed directed affinity edge (`fromUserId → toUserId`).
143
+ *
144
+ * - `weight` (optional) overrides the per-type default weight for this event.
145
+ * - `occurredAt` (optional, ISO) is the event time; absent means "now" at ingest.
146
+ * - `eventId` (optional) makes an event idempotent — a repeated `eventId` for the
147
+ * same application is folded at most once (bounded dedup window).
148
+ */
149
+ export const appAffinityEventSchema = z.object({
150
+ fromUserId: z.string().trim().min(1),
151
+ toUserId: z.string().trim().min(1),
152
+ type: appAffinityEventTypeSchema,
153
+ weight: z.number().min(0).max(100).optional(),
154
+ occurredAt: z.string().datetime().optional(),
155
+ eventId: z.string().trim().min(1).max(200).optional(),
156
+ });
157
+ /**
158
+ * Request body for `POST /app-signals/events` (service token, `signals:write`).
159
+ *
160
+ * A non-empty batch (1..1000) of directed interaction events for the requesting
161
+ * application. Self-edges (`fromUserId === toUserId`) are dropped server-side.
162
+ */
163
+ export const appAffinityEventsIngestSchema = z.object({
164
+ events: z.array(appAffinityEventSchema).min(1).max(1000),
165
+ });