@oxyhq/contracts 0.1.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,26 @@
1
+ "use strict";
2
+ /**
3
+ * @oxyhq/contracts — single source of truth for API request/response contracts.
4
+ *
5
+ * Zod schemas plus their inferred types, shared by the backend (`@oxyhq/api`)
6
+ * and the client SDKs (`@oxyhq/core`, `@oxyhq/auth`, `@oxyhq/services`). The
7
+ * producer validates its output and every consumer validates its input against
8
+ * exactly the same definitions, so the wire shape cannot drift.
9
+ *
10
+ * Platform-agnostic — zod is the only runtime dependency. No react/react-native/
11
+ * expo, no `require()` in the ESM build.
12
+ */
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.safeParseContract = exports.resolveUserId = exports.deviceSessionsResponseSchema = exports.deviceSessionAccountSchema = exports.currentUserResponseSchema = exports.refreshAllResponseSchema = exports.refreshAllAccountSchema = exports.userResponseSchema = exports.userNameSchema = void 0;
15
+ var userResponse_1 = require("./userResponse");
16
+ // Schemas
17
+ Object.defineProperty(exports, "userNameSchema", { enumerable: true, get: function () { return userResponse_1.userNameSchema; } });
18
+ Object.defineProperty(exports, "userResponseSchema", { enumerable: true, get: function () { return userResponse_1.userResponseSchema; } });
19
+ Object.defineProperty(exports, "refreshAllAccountSchema", { enumerable: true, get: function () { return userResponse_1.refreshAllAccountSchema; } });
20
+ Object.defineProperty(exports, "refreshAllResponseSchema", { enumerable: true, get: function () { return userResponse_1.refreshAllResponseSchema; } });
21
+ Object.defineProperty(exports, "currentUserResponseSchema", { enumerable: true, get: function () { return userResponse_1.currentUserResponseSchema; } });
22
+ Object.defineProperty(exports, "deviceSessionAccountSchema", { enumerable: true, get: function () { return userResponse_1.deviceSessionAccountSchema; } });
23
+ Object.defineProperty(exports, "deviceSessionsResponseSchema", { enumerable: true, get: function () { return userResponse_1.deviceSessionsResponseSchema; } });
24
+ // Helpers
25
+ Object.defineProperty(exports, "resolveUserId", { enumerable: true, get: function () { return userResponse_1.resolveUserId; } });
26
+ Object.defineProperty(exports, "safeParseContract", { enumerable: true, get: function () { return userResponse_1.safeParseContract; } });
@@ -0,0 +1,157 @@
1
+ "use strict";
2
+ /**
3
+ * Canonical API user-response contracts.
4
+ *
5
+ * SINGLE SOURCE OF TRUTH for the wire shape of every user object the API emits
6
+ * and every consumer (the auth app, services, accounts) parses. The API
7
+ * validates its OUTPUT against these schemas; web/RN consumers validate their
8
+ * INPUT against the same schemas. Because there is exactly one definition, the
9
+ * producer and the consumers cannot drift — the class of bugs that motivated
10
+ * this module (the auth app's local Zod schema requiring `name` to be a plain
11
+ * string, dropping every account that had a structured name) is impossible.
12
+ *
13
+ * This package (`@oxyhq/contracts`) is the dedicated, zero-dependency home for
14
+ * these contracts so the backend (`@oxyhq/api`) and the client SDKs
15
+ * (`@oxyhq/core`, `@oxyhq/auth`, `@oxyhq/services`) can all depend on it without
16
+ * the backend having to depend on a client SDK to obtain its schemas.
17
+ *
18
+ * Faithful to the producers:
19
+ * - `packages/api/src/utils/userTransform.ts` `formatUserResponse` — the
20
+ * canonical serialization used by `/auth/refresh-all`, device sessions, etc.
21
+ * Emits `id` (NOT `_id`), forwards `username` verbatim (may be absent), and
22
+ * emits `name` as the structured `{ first, last, full }` subdocument.
23
+ * - `packages/api/src/models/User.ts` — `NameSchema` (`first`/`last` default
24
+ * `''`; `full` is a Mongoose VIRTUAL) and the `displayName` virtual. Because
25
+ * virtuals are only present when a query uses `.lean({ virtuals: true })` (or
26
+ * a hydrated doc), `name.full` and `displayName` MUST be treated as OPTIONAL.
27
+ * - The `/auth/refresh-all` handler in `packages/api/src/routes/auth.ts`, whose
28
+ * per-slot `authuser` is `number | null` (null = legacy un-suffixed `oxy_rt`
29
+ * cookie slot).
30
+ *
31
+ * Platform-agnostic — zod only, no react/react-native/expo. ESM-safe (no
32
+ * `require()`).
33
+ */
34
+ Object.defineProperty(exports, "__esModule", { value: true });
35
+ exports.deviceSessionsResponseSchema = exports.deviceSessionAccountSchema = exports.currentUserResponseSchema = exports.refreshAllResponseSchema = exports.refreshAllAccountSchema = exports.userResponseSchema = exports.userNameSchema = void 0;
36
+ exports.resolveUserId = resolveUserId;
37
+ exports.safeParseContract = safeParseContract;
38
+ const zod_1 = require("zod");
39
+ /**
40
+ * Structured human name subdocument. Mirrors `User.name` (`NameSchema`).
41
+ *
42
+ * - `first` / `last` default to `''` in Mongo, so they are optional on the wire.
43
+ * - `full` is a Mongoose virtual — ABSENT unless the query materialised virtuals.
44
+ *
45
+ * `.passthrough()` is intentional: it tolerates additive name fields without a
46
+ * coordinated contract bump, while the three known keys stay strongly typed.
47
+ */
48
+ exports.userNameSchema = zod_1.z
49
+ .object({
50
+ first: zod_1.z.string().optional(),
51
+ last: zod_1.z.string().optional(),
52
+ full: zod_1.z.string().optional(),
53
+ })
54
+ .passthrough();
55
+ /**
56
+ * The canonical user object emitted by `formatUserResponse`.
57
+ *
58
+ * Only `id` is guaranteed present (it is the early-return guard in
59
+ * `formatUserResponse`). Every other field is forwarded verbatim from the user
60
+ * document and may be absent depending on the query's `.select(...)`/`.lean()`
61
+ * projection — so all are optional/nullable to match reality. Both `id` and
62
+ * `_id` are accepted because RAW-document responses (e.g. `GET /users/me`,
63
+ * which does NOT pass through `formatUserResponse`) carry `_id` instead of `id`;
64
+ * resolve the identifier with {@link resolveUserId}.
65
+ *
66
+ * `.passthrough()` keeps the large tail of profile fields
67
+ * (`privacySettings`, `locations`, `links`, `linksMetadata`, `bio`,
68
+ * `description`, `language`, `verified`, timestamps, …) available to callers
69
+ * that need them without enumerating every nested shape here — the load-bearing
70
+ * identity/display fields are the ones we pin precisely.
71
+ */
72
+ exports.userResponseSchema = zod_1.z
73
+ .object({
74
+ /** MongoDB ObjectId as a string. Present on `formatUserResponse` output. */
75
+ id: zod_1.z.string().optional(),
76
+ /** Raw-document id (e.g. `GET /users/me`). Present when `id` is not. */
77
+ _id: zod_1.z.string().optional(),
78
+ publicKey: zod_1.z.string().optional(),
79
+ username: zod_1.z.string().optional(),
80
+ email: zod_1.z.string().optional(),
81
+ /** Avatar file id (string) or null. */
82
+ avatar: zod_1.z.string().nullable().optional(),
83
+ /** Named Bloom color preset (e.g. `"blue"`) or null. */
84
+ color: zod_1.z.string().nullable().optional(),
85
+ name: exports.userNameSchema.optional(),
86
+ /** Server `displayName` virtual (`username || truncatedKey`). Optional. */
87
+ displayName: zod_1.z.string().optional(),
88
+ verified: zod_1.z.boolean().optional(),
89
+ language: zod_1.z.string().optional(),
90
+ })
91
+ .passthrough();
92
+ /**
93
+ * Resolve the canonical user id from a {@link UserResponse}, accepting either
94
+ * the `formatUserResponse` `id` field or the raw-document `_id` field.
95
+ */
96
+ function resolveUserId(user) {
97
+ return user.id ?? user._id;
98
+ }
99
+ /**
100
+ * One rotated account entry from `POST /auth/refresh-all`.
101
+ *
102
+ * `authuser` is the device-local slot index (`0..N-1`). The server emits
103
+ * `authuser: null` for the legacy un-suffixed `oxy_rt` cookie slot — accept null
104
+ * so a browser holding only a legacy cookie is NOT dropped from the account
105
+ * chooser. `user` is the canonical {@link userResponseSchema} shape (the handler
106
+ * projects a whitelist and runs it through `formatUserResponse`).
107
+ */
108
+ exports.refreshAllAccountSchema = zod_1.z.object({
109
+ authuser: zod_1.z.number().int().nonnegative().nullable(),
110
+ accessToken: zod_1.z.string(),
111
+ expiresAt: zod_1.z.string(),
112
+ sessionId: zod_1.z.string(),
113
+ user: exports.userResponseSchema,
114
+ });
115
+ /**
116
+ * Wire shape of `POST /auth/refresh-all`: every valid device-local account,
117
+ * sorted by `authuser` ascending. An empty `accounts` array means "no signed-in
118
+ * accounts on this device" — the IdP must show the sign-in form. A 404 means the
119
+ * endpoint is not deployed and the caller falls back to single-account
120
+ * `/auth/refresh`.
121
+ */
122
+ exports.refreshAllResponseSchema = zod_1.z.object({
123
+ accounts: zod_1.z.array(exports.refreshAllAccountSchema),
124
+ });
125
+ /**
126
+ * Wire shape of `GET /users/me` — the API success envelope (`{ data: <user> }`)
127
+ * wrapping the RAW Mongo user document. It does NOT pass through
128
+ * `formatUserResponse`, so the id field is `_id` (resolve via
129
+ * {@link resolveUserId}) and virtuals (`name.full`, `displayName`) may be
130
+ * present when the query materialised them.
131
+ */
132
+ exports.currentUserResponseSchema = zod_1.z.object({
133
+ data: exports.userResponseSchema,
134
+ });
135
+ /**
136
+ * One entry of `GET /session/device/sessions/:sessionId` — the deduplicated
137
+ * accounts signed in on this physical device (one per user, most recent
138
+ * session). Backs the multi-account chooser. The embedded user mirrors
139
+ * `formatUserResponse`; it is nullable on slots that lost their user document.
140
+ */
141
+ exports.deviceSessionAccountSchema = zod_1.z.object({
142
+ sessionId: zod_1.z.string(),
143
+ isCurrent: zod_1.z.boolean().optional(),
144
+ user: exports.userResponseSchema.nullable().optional(),
145
+ });
146
+ /** Wire shape of `GET /session/device/sessions/:sessionId` (an array). */
147
+ exports.deviceSessionsResponseSchema = zod_1.z.array(exports.deviceSessionAccountSchema);
148
+ /**
149
+ * Safely parse a value against a contract schema. Returns the parsed (typed)
150
+ * value, or `null` when validation fails — the same ergonomics the auth app's
151
+ * local `safeParse` provided, now sourced from the contracts package so the
152
+ * parse helper and the schemas live together.
153
+ */
154
+ function safeParseContract(schema, data) {
155
+ const result = schema.safeParse(data);
156
+ return result.success ? result.data : null;
157
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * @oxyhq/contracts — single source of truth for API request/response contracts.
3
+ *
4
+ * Zod schemas plus their inferred types, shared by the backend (`@oxyhq/api`)
5
+ * and the client SDKs (`@oxyhq/core`, `@oxyhq/auth`, `@oxyhq/services`). The
6
+ * producer validates its output and every consumer validates its input against
7
+ * exactly the same definitions, so the wire shape cannot drift.
8
+ *
9
+ * Platform-agnostic — zod is the only runtime dependency. No react/react-native/
10
+ * expo, no `require()` in the ESM build.
11
+ */
12
+ export {
13
+ // Schemas
14
+ userNameSchema, userResponseSchema, refreshAllAccountSchema, refreshAllResponseSchema, currentUserResponseSchema, deviceSessionAccountSchema, deviceSessionsResponseSchema,
15
+ // Helpers
16
+ resolveUserId, safeParseContract, } from './userResponse.js';
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Canonical API user-response contracts.
3
+ *
4
+ * SINGLE SOURCE OF TRUTH for the wire shape of every user object the API emits
5
+ * and every consumer (the auth app, services, accounts) parses. The API
6
+ * validates its OUTPUT against these schemas; web/RN consumers validate their
7
+ * INPUT against the same schemas. Because there is exactly one definition, the
8
+ * producer and the consumers cannot drift — the class of bugs that motivated
9
+ * this module (the auth app's local Zod schema requiring `name` to be a plain
10
+ * string, dropping every account that had a structured name) is impossible.
11
+ *
12
+ * This package (`@oxyhq/contracts`) is the dedicated, zero-dependency home for
13
+ * these contracts so the backend (`@oxyhq/api`) and the client SDKs
14
+ * (`@oxyhq/core`, `@oxyhq/auth`, `@oxyhq/services`) can all depend on it without
15
+ * the backend having to depend on a client SDK to obtain its schemas.
16
+ *
17
+ * Faithful to the producers:
18
+ * - `packages/api/src/utils/userTransform.ts` `formatUserResponse` — the
19
+ * canonical serialization used by `/auth/refresh-all`, device sessions, etc.
20
+ * Emits `id` (NOT `_id`), forwards `username` verbatim (may be absent), and
21
+ * emits `name` as the structured `{ first, last, full }` subdocument.
22
+ * - `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.
26
+ * - 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).
29
+ *
30
+ * Platform-agnostic — zod only, no react/react-native/expo. ESM-safe (no
31
+ * `require()`).
32
+ */
33
+ import { z } from 'zod';
34
+ /**
35
+ * Structured human name subdocument. Mirrors `User.name` (`NameSchema`).
36
+ *
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.
39
+ *
40
+ * `.passthrough()` is intentional: it tolerates additive name fields without a
41
+ * coordinated contract bump, while the three known keys stay strongly typed.
42
+ */
43
+ export const userNameSchema = z
44
+ .object({
45
+ first: z.string().optional(),
46
+ last: z.string().optional(),
47
+ full: z.string().optional(),
48
+ })
49
+ .passthrough();
50
+ /**
51
+ * The canonical user object emitted by `formatUserResponse`.
52
+ *
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}.
60
+ *
61
+ * `.passthrough()` keeps the large tail of profile fields
62
+ * (`privacySettings`, `locations`, `links`, `linksMetadata`, `bio`,
63
+ * `description`, `language`, `verified`, timestamps, …) available to callers
64
+ * that need them without enumerating every nested shape here — the load-bearing
65
+ * identity/display fields are the ones we pin precisely.
66
+ */
67
+ export const userResponseSchema = z
68
+ .object({
69
+ /** MongoDB ObjectId as a string. Present on `formatUserResponse` output. */
70
+ id: z.string().optional(),
71
+ /** Raw-document id (e.g. `GET /users/me`). Present when `id` is not. */
72
+ _id: z.string().optional(),
73
+ publicKey: z.string().optional(),
74
+ username: z.string().optional(),
75
+ email: z.string().optional(),
76
+ /** Avatar file id (string) or null. */
77
+ avatar: z.string().nullable().optional(),
78
+ /** Named Bloom color preset (e.g. `"blue"`) or null. */
79
+ color: z.string().nullable().optional(),
80
+ name: userNameSchema.optional(),
81
+ /** Server `displayName` virtual (`username || truncatedKey`). Optional. */
82
+ displayName: z.string().optional(),
83
+ verified: z.boolean().optional(),
84
+ language: z.string().optional(),
85
+ })
86
+ .passthrough();
87
+ /**
88
+ * Resolve the canonical user id from a {@link UserResponse}, accepting either
89
+ * the `formatUserResponse` `id` field or the raw-document `_id` field.
90
+ */
91
+ export function resolveUserId(user) {
92
+ return user.id ?? user._id;
93
+ }
94
+ /**
95
+ * One rotated account entry from `POST /auth/refresh-all`.
96
+ *
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`).
102
+ */
103
+ export const refreshAllAccountSchema = z.object({
104
+ authuser: z.number().int().nonnegative().nullable(),
105
+ accessToken: z.string(),
106
+ expiresAt: z.string(),
107
+ sessionId: z.string(),
108
+ user: userResponseSchema,
109
+ });
110
+ /**
111
+ * Wire shape of `POST /auth/refresh-all`: every valid device-local account,
112
+ * 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`.
116
+ */
117
+ export const refreshAllResponseSchema = z.object({
118
+ accounts: z.array(refreshAllAccountSchema),
119
+ });
120
+ /**
121
+ * 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.
126
+ */
127
+ export const currentUserResponseSchema = z.object({
128
+ data: userResponseSchema,
129
+ });
130
+ /**
131
+ * One entry of `GET /session/device/sessions/:sessionId` — the deduplicated
132
+ * accounts signed in on this physical device (one per user, most recent
133
+ * session). Backs the multi-account chooser. The embedded user mirrors
134
+ * `formatUserResponse`; it is nullable on slots that lost their user document.
135
+ */
136
+ export const deviceSessionAccountSchema = z.object({
137
+ sessionId: z.string(),
138
+ isCurrent: z.boolean().optional(),
139
+ user: userResponseSchema.nullable().optional(),
140
+ });
141
+ /** Wire shape of `GET /session/device/sessions/:sessionId` (an array). */
142
+ export const deviceSessionsResponseSchema = z.array(deviceSessionAccountSchema);
143
+ /**
144
+ * Safely parse a value against a contract schema. Returns the parsed (typed)
145
+ * value, or `null` when validation fails — the same ergonomics the auth app's
146
+ * local `safeParse` provided, now sourced from the contracts package so the
147
+ * parse helper and the schemas live together.
148
+ */
149
+ export function safeParseContract(schema, data) {
150
+ const result = schema.safeParse(data);
151
+ return result.success ? result.data : null;
152
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * @oxyhq/contracts — single source of truth for API request/response contracts.
3
+ *
4
+ * Zod schemas plus their inferred types, shared by the backend (`@oxyhq/api`)
5
+ * and the client SDKs (`@oxyhq/core`, `@oxyhq/auth`, `@oxyhq/services`). The
6
+ * producer validates its output and every consumer validates its input against
7
+ * exactly the same definitions, so the wire shape cannot drift.
8
+ *
9
+ * Platform-agnostic — zod is the only runtime dependency. No react/react-native/
10
+ * expo, no `require()` in the ESM build.
11
+ */
12
+ export { userNameSchema, userResponseSchema, refreshAllAccountSchema, refreshAllResponseSchema, currentUserResponseSchema, deviceSessionAccountSchema, deviceSessionsResponseSchema, resolveUserId, safeParseContract, } from './userResponse';
13
+ export type { UserNameResponse, UserResponse, RefreshAllAccountResponse, RefreshAllResponseContract, CurrentUserResponseContract, DeviceSessionAccountResponse, DeviceSessionsResponseContract, } from './userResponse';