Add oRPC API contract with Zod schemas
- Create @reviq/api-contract package - Define Zod schemas for all input/output types: - Auth schemas (signup, login, password reset, WebAuthn) - User/profile schemas - Organization schemas (CRUD, members, invites) - Admin procedure schemas - Define oRPC contract with full procedure signatures: - auth.* (signup, login, password reset, WebAuthn) - me.* (profile, sessions, devices, passkeys) - orgs.* (CRUD, members, invites, sites) - admin.* (orgs, users, auth management) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
79
packages/api-contract/README.md
Normal file
79
packages/api-contract/README.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# @reviq/api-contract
|
||||||
|
|
||||||
|
Contract-first API definitions using oRPC and Zod for the Publisher Dashboard authentication system.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This package defines the complete API contract for all RPC procedures served at `/api/v1/rpc`. It uses:
|
||||||
|
|
||||||
|
- **@orpc/contract** - Contract-first RPC framework
|
||||||
|
- **Zod** - Runtime type validation and schema definitions
|
||||||
|
- **libphonenumber-js** - Phone number validation and formatting
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── index.ts # Main exports
|
||||||
|
├── contract.ts # oRPC contract with all procedure signatures
|
||||||
|
└── schemas/
|
||||||
|
├── common.ts # Shared schemas (email, slug, phone)
|
||||||
|
├── auth.ts # Authentication schemas
|
||||||
|
├── user.ts # User profile and settings schemas
|
||||||
|
├── org.ts # Organization schemas
|
||||||
|
└── admin.ts # Admin operation schemas
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { contract } from "@reviq/api-contract";
|
||||||
|
import type { loginRequestInputSchema, loginRequestOutputSchema } from "@reviq/api-contract";
|
||||||
|
|
||||||
|
// Use the contract to implement server handlers
|
||||||
|
// Use the schemas for validation and type inference
|
||||||
|
type LoginRequestInput = z.infer<typeof loginRequestInputSchema>;
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Procedures
|
||||||
|
|
||||||
|
### Auth (`auth.*`)
|
||||||
|
- Signup, login, logout flows
|
||||||
|
- Email verification
|
||||||
|
- Password reset
|
||||||
|
- WebAuthn (passkey) support
|
||||||
|
|
||||||
|
### User (`me.*`)
|
||||||
|
- Profile management
|
||||||
|
- Password and passkey management
|
||||||
|
- Session and device management
|
||||||
|
|
||||||
|
### Organizations (`orgs.*`)
|
||||||
|
- Org CRUD operations
|
||||||
|
- Member management
|
||||||
|
- Invitations
|
||||||
|
- Site management
|
||||||
|
|
||||||
|
### Admin (`admin.*`)
|
||||||
|
- Superuser-only operations
|
||||||
|
- User and org management
|
||||||
|
- Site assignments
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build the package
|
||||||
|
bun run build
|
||||||
|
|
||||||
|
# Watch mode
|
||||||
|
bun run dev
|
||||||
|
|
||||||
|
# Type checking
|
||||||
|
bun run typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- All emails are automatically transformed to lowercase
|
||||||
|
- Phone numbers are stored in E.164 format
|
||||||
|
- Slugs follow domain name rules (2-63 chars, lowercase alphanumeric with hyphens)
|
||||||
12
packages/api-contract/eslint.config.js
Normal file
12
packages/api-contract/eslint.config.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { configs } from "@macalinao/eslint-config";
|
||||||
|
|
||||||
|
export default [
|
||||||
|
...configs.fast,
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
27
packages/api-contract/package.json
Normal file
27
packages/api-contract/package.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "@reviq/api-contract",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache",
|
||||||
|
"lint": "eslint . --cache"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@orpc/contract": "^1.13.2",
|
||||||
|
"libphonenumber-js": "^1.12.33",
|
||||||
|
"zod": "^4.3.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@macalinao/tsconfig": "catalog:",
|
||||||
|
"typescript": "catalog:"
|
||||||
|
}
|
||||||
|
}
|
||||||
228
packages/api-contract/src/contract.ts
Normal file
228
packages/api-contract/src/contract.ts
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
import { oc } from "@orpc/contract";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
signupInputSchema,
|
||||||
|
verifyEmailInputSchema,
|
||||||
|
loginRequestInputSchema,
|
||||||
|
loginRequestOutputSchema,
|
||||||
|
loginPasswordInputSchema,
|
||||||
|
loginStatusOutputSchema,
|
||||||
|
forgotPasswordInputSchema,
|
||||||
|
resetPasswordInputSchema,
|
||||||
|
} from "./schemas/auth.js";
|
||||||
|
import {
|
||||||
|
userProfileSchema,
|
||||||
|
setupProfileInputSchema,
|
||||||
|
updateProfileInputSchema,
|
||||||
|
setPasswordInputSchema,
|
||||||
|
passkeyOutputSchema,
|
||||||
|
sessionOutputSchema,
|
||||||
|
deviceOutputSchema,
|
||||||
|
trustDeviceInputSchema,
|
||||||
|
} from "./schemas/user.js";
|
||||||
|
import {
|
||||||
|
createOrgInputSchema,
|
||||||
|
orgOutputSchema,
|
||||||
|
orgMemberOutputSchema,
|
||||||
|
updateMemberRoleInputSchema,
|
||||||
|
createInviteInputSchema,
|
||||||
|
orgInviteOutputSchema,
|
||||||
|
orgSiteOutputSchema,
|
||||||
|
} from "./schemas/org.js";
|
||||||
|
import {
|
||||||
|
adminCreateOrgInputSchema,
|
||||||
|
adminCreateUserInputSchema,
|
||||||
|
adminUpdateUserInputSchema,
|
||||||
|
adminAddSiteInputSchema,
|
||||||
|
} from "./schemas/admin.js";
|
||||||
|
import { emailSchema, slugSchema } from "./schemas/common.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* oRPC API Contract for the Publisher Dashboard
|
||||||
|
* This defines all RPC procedure signatures served at /api/v1/rpc
|
||||||
|
*/
|
||||||
|
export const contract = oc.router({
|
||||||
|
auth: oc.router({
|
||||||
|
// Signup and verification
|
||||||
|
signup: oc.input(signupInputSchema).output(z.void()),
|
||||||
|
verifyEmail: oc.input(verifyEmailInputSchema).output(z.void()),
|
||||||
|
resendVerificationEmail: oc.output(z.void()),
|
||||||
|
|
||||||
|
// Login flow
|
||||||
|
createLoginRequest: oc
|
||||||
|
.input(loginRequestInputSchema)
|
||||||
|
.output(loginRequestOutputSchema),
|
||||||
|
loginPassword: oc.input(loginPasswordInputSchema).output(z.void()),
|
||||||
|
loginPasswordConfirm: oc
|
||||||
|
.input(z.object({ token: z.string() }))
|
||||||
|
.output(z.void()),
|
||||||
|
loginIfRequestIsCompleted: oc.output(loginStatusOutputSchema),
|
||||||
|
|
||||||
|
// Password reset
|
||||||
|
forgotPassword: oc.input(forgotPasswordInputSchema).output(z.void()),
|
||||||
|
resetPassword: oc.input(resetPasswordInputSchema).output(z.void()),
|
||||||
|
|
||||||
|
// Logout
|
||||||
|
logout: oc.output(z.void()),
|
||||||
|
|
||||||
|
// WebAuthn procedures
|
||||||
|
webauthn: oc.router({
|
||||||
|
createRegistrationOptions: oc
|
||||||
|
.input(z.object({ email: emailSchema }))
|
||||||
|
.output(
|
||||||
|
z.object({
|
||||||
|
challengeId: z.number(),
|
||||||
|
options: z.any(), // PublicKeyCredentialCreationOptionsJSON
|
||||||
|
})
|
||||||
|
),
|
||||||
|
verifyRegistration: oc
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
challengeId: z.number(),
|
||||||
|
response: z.any(), // RegistrationResponseJSON
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.output(z.void()),
|
||||||
|
createAuthenticationOptions: oc.output(
|
||||||
|
z.object({
|
||||||
|
challengeId: z.number(),
|
||||||
|
options: z.any(), // PublicKeyCredentialRequestOptionsJSON
|
||||||
|
})
|
||||||
|
),
|
||||||
|
verifyAuthentication: oc
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
challengeId: z.number(),
|
||||||
|
response: z.any(), // AuthenticationResponseJSON
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.output(z.void()),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
|
||||||
|
me: oc.router({
|
||||||
|
// Profile
|
||||||
|
get: oc.output(userProfileSchema),
|
||||||
|
setupProfile: oc.input(setupProfileInputSchema).output(z.void()),
|
||||||
|
updateProfile: oc.input(updateProfileInputSchema).output(z.void()),
|
||||||
|
delete: oc.input(z.object({ password: z.string() })).output(z.void()),
|
||||||
|
|
||||||
|
// Authentication settings
|
||||||
|
setPassword: oc.input(setPasswordInputSchema).output(z.void()),
|
||||||
|
listPasskeys: oc.output(z.array(passkeyOutputSchema)),
|
||||||
|
createPasskey: oc
|
||||||
|
.input(z.object({ name: z.string() }))
|
||||||
|
.output(
|
||||||
|
z.object({
|
||||||
|
challengeId: z.number(),
|
||||||
|
options: z.any(), // PublicKeyCredentialCreationOptionsJSON
|
||||||
|
})
|
||||||
|
),
|
||||||
|
renamePasskey: oc
|
||||||
|
.input(z.object({ passkeyId: z.number(), name: z.string() }))
|
||||||
|
.output(z.void()),
|
||||||
|
deletePasskey: oc.input(z.object({ passkeyId: z.number() })).output(z.void()),
|
||||||
|
|
||||||
|
// Sessions & devices
|
||||||
|
listSessions: oc.output(z.array(sessionOutputSchema)),
|
||||||
|
revokeSession: oc.input(z.object({ sessionId: z.number() })).output(z.void()),
|
||||||
|
revokeAllSessions: oc.output(z.void()),
|
||||||
|
getDeviceInfo: oc.output(deviceOutputSchema),
|
||||||
|
trustDevice: oc.input(trustDeviceInputSchema).output(z.void()),
|
||||||
|
listTrustedDevices: oc.output(z.array(deviceOutputSchema)),
|
||||||
|
untrustDevice: oc.input(z.object({ deviceId: z.number() })).output(z.void()),
|
||||||
|
revokeAllTrustedDevices: oc.output(z.void()),
|
||||||
|
}),
|
||||||
|
|
||||||
|
orgs: oc.router({
|
||||||
|
// Org management
|
||||||
|
list: oc.output(z.array(orgOutputSchema)),
|
||||||
|
create: oc
|
||||||
|
.input(createOrgInputSchema)
|
||||||
|
.output(z.object({ slug: z.string() })),
|
||||||
|
get: oc.input(z.object({ slug: slugSchema })).output(orgOutputSchema),
|
||||||
|
update: oc
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
slug: slugSchema,
|
||||||
|
displayName: z.string().optional(),
|
||||||
|
logoUrl: z.string().optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.output(z.void()),
|
||||||
|
delete: oc.input(z.object({ slug: slugSchema })).output(z.void()),
|
||||||
|
leave: oc.input(z.object({ slug: slugSchema })).output(z.void()),
|
||||||
|
|
||||||
|
// Members
|
||||||
|
members: oc.router({
|
||||||
|
list: oc
|
||||||
|
.input(z.object({ slug: slugSchema }))
|
||||||
|
.output(z.array(orgMemberOutputSchema)),
|
||||||
|
updateRole: oc.input(updateMemberRoleInputSchema).output(z.void()),
|
||||||
|
remove: oc
|
||||||
|
.input(z.object({ slug: slugSchema, userId: z.number() }))
|
||||||
|
.output(z.void()),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Invites
|
||||||
|
invites: oc.router({
|
||||||
|
list: oc
|
||||||
|
.input(z.object({ slug: slugSchema }))
|
||||||
|
.output(z.array(orgInviteOutputSchema)),
|
||||||
|
create: oc.input(createInviteInputSchema).output(z.void()),
|
||||||
|
cancel: oc
|
||||||
|
.input(z.object({ slug: slugSchema, inviteId: z.number() }))
|
||||||
|
.output(z.void()),
|
||||||
|
accept: oc.input(z.object({ token: z.string() })).output(z.void()),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Sites
|
||||||
|
sites: oc.router({
|
||||||
|
list: oc
|
||||||
|
.input(z.object({ slug: slugSchema }))
|
||||||
|
.output(z.array(orgSiteOutputSchema)),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
|
||||||
|
admin: oc.router({
|
||||||
|
// Admin org management
|
||||||
|
orgs: oc.router({
|
||||||
|
list: oc.output(z.array(orgOutputSchema)),
|
||||||
|
get: oc.input(z.object({ slug: slugSchema })).output(orgOutputSchema),
|
||||||
|
create: oc
|
||||||
|
.input(adminCreateOrgInputSchema)
|
||||||
|
.output(z.object({ slug: z.string() })),
|
||||||
|
update: oc
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
slug: slugSchema,
|
||||||
|
displayName: z.string().optional(),
|
||||||
|
logoUrl: z.string().optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.output(z.void()),
|
||||||
|
delete: oc.input(z.object({ slug: slugSchema })).output(z.void()),
|
||||||
|
listSites: oc
|
||||||
|
.input(z.object({ slug: slugSchema }))
|
||||||
|
.output(z.array(orgSiteOutputSchema)),
|
||||||
|
addSite: oc.input(adminAddSiteInputSchema).output(z.void()),
|
||||||
|
removeSite: oc
|
||||||
|
.input(z.object({ slug: slugSchema, domain: z.string() }))
|
||||||
|
.output(z.void()),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Admin user management
|
||||||
|
users: oc.router({
|
||||||
|
list: oc.output(z.array(userProfileSchema)),
|
||||||
|
get: oc.input(z.object({ email: emailSchema })).output(userProfileSchema),
|
||||||
|
create: oc.input(adminCreateUserInputSchema).output(z.void()),
|
||||||
|
update: oc.input(adminUpdateUserInputSchema).output(z.void()),
|
||||||
|
confirmEmail: oc.input(z.object({ email: emailSchema })).output(z.void()),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Admin auth management
|
||||||
|
auth: oc.router({
|
||||||
|
completeLogin: oc.input(z.object({ email: emailSchema })).output(z.void()),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
9
packages/api-contract/src/index.ts
Normal file
9
packages/api-contract/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// Export the contract
|
||||||
|
export { contract } from "./contract.js";
|
||||||
|
|
||||||
|
// Export all schemas
|
||||||
|
export * from "./schemas/common.js";
|
||||||
|
export * from "./schemas/auth.js";
|
||||||
|
export * from "./schemas/user.js";
|
||||||
|
export * from "./schemas/org.js";
|
||||||
|
export * from "./schemas/admin.js";
|
||||||
42
packages/api-contract/src/schemas/admin.ts
Normal file
42
packages/api-contract/src/schemas/admin.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { emailSchema, slugSchema } from "./common.js";
|
||||||
|
import { orgRoleSchema } from "./org.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin create org input schema
|
||||||
|
* Superusers can create orgs and assign an owner
|
||||||
|
*/
|
||||||
|
export const adminCreateOrgInputSchema = z.object({
|
||||||
|
slug: slugSchema,
|
||||||
|
displayName: z.string().min(1).max(100),
|
||||||
|
ownerEmail: emailSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin create user input schema
|
||||||
|
* Superusers can create passwordless users
|
||||||
|
*/
|
||||||
|
export const adminCreateUserInputSchema = z.object({
|
||||||
|
email: emailSchema,
|
||||||
|
name: z.string().optional(),
|
||||||
|
orgSlug: slugSchema.optional(),
|
||||||
|
orgRole: orgRoleSchema.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin update user input schema
|
||||||
|
* Superusers can update user properties like is_superuser
|
||||||
|
*/
|
||||||
|
export const adminUpdateUserInputSchema = z.object({
|
||||||
|
email: emailSchema,
|
||||||
|
isSuperuser: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin add site input schema
|
||||||
|
* Superusers can add sites to orgs
|
||||||
|
*/
|
||||||
|
export const adminAddSiteInputSchema = z.object({
|
||||||
|
slug: slugSchema,
|
||||||
|
domain: z.string(),
|
||||||
|
});
|
||||||
82
packages/api-contract/src/schemas/auth.ts
Normal file
82
packages/api-contract/src/schemas/auth.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { emailSchema } from "./common.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Passkey information schema for signup and registration
|
||||||
|
* challengeId references the webauthn_challenges table
|
||||||
|
* response is the RegistrationResponseJSON from @simplewebauthn/browser
|
||||||
|
*/
|
||||||
|
export const passkeyInfoSchema = z.object({
|
||||||
|
challengeId: z.number(),
|
||||||
|
response: z.any(), // RegistrationResponseJSON from @simplewebauthn/browser
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signup input schema
|
||||||
|
* Must provide either password or passkeyInfo
|
||||||
|
*/
|
||||||
|
export const signupInputSchema = z
|
||||||
|
.object({
|
||||||
|
email: emailSchema,
|
||||||
|
password: z.string().min(8).optional(),
|
||||||
|
passkeyInfo: passkeyInfoSchema.optional(),
|
||||||
|
})
|
||||||
|
.refine((data) => data.password || data.passkeyInfo, {
|
||||||
|
message: "Either password or passkeyInfo is required",
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login request input schema (step 1 of login)
|
||||||
|
*/
|
||||||
|
export const loginRequestInputSchema = z.object({
|
||||||
|
email: emailSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login request output schema
|
||||||
|
* Indicates available auth methods and device trust status
|
||||||
|
*/
|
||||||
|
export const loginRequestOutputSchema = z.object({
|
||||||
|
hasPasskey: z.boolean(),
|
||||||
|
hasPassword: z.boolean(),
|
||||||
|
isTrustedDevice: z.boolean(),
|
||||||
|
email: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login with password input schema
|
||||||
|
*/
|
||||||
|
export const loginPasswordInputSchema = z.object({
|
||||||
|
password: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login status output schema
|
||||||
|
* Used for polling login completion
|
||||||
|
*/
|
||||||
|
export const loginStatusOutputSchema = z.object({
|
||||||
|
status: z.enum(["pending", "completed", "expired"]),
|
||||||
|
redirectTo: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify email input schema
|
||||||
|
*/
|
||||||
|
export const verifyEmailInputSchema = z.object({
|
||||||
|
token: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forgot password input schema
|
||||||
|
*/
|
||||||
|
export const forgotPasswordInputSchema = z.object({
|
||||||
|
email: emailSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset password input schema
|
||||||
|
*/
|
||||||
|
export const resetPasswordInputSchema = z.object({
|
||||||
|
token: z.string(),
|
||||||
|
newPassword: z.string().min(8),
|
||||||
|
});
|
||||||
35
packages/api-contract/src/schemas/common.ts
Normal file
35
packages/api-contract/src/schemas/common.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { parsePhoneNumberWithError, isValidPhoneNumber } from "libphonenumber-js";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Email schema - validates email format and transforms to lowercase
|
||||||
|
*/
|
||||||
|
export const emailSchema = z.string().email().toLowerCase() satisfies z.ZodTypeAny;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slug schema for org slugs
|
||||||
|
* Must be 2-63 chars, lowercase alphanumeric with hyphens (not at start/end)
|
||||||
|
*/
|
||||||
|
export const slugSchema = z
|
||||||
|
.string()
|
||||||
|
.min(2)
|
||||||
|
.max(63)
|
||||||
|
.regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/, {
|
||||||
|
message: "Slug must be lowercase alphanumeric with hyphens (not at start/end)",
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phone number schema - validates and transforms to E.164 format
|
||||||
|
* Optional field, transforms to undefined if not provided
|
||||||
|
*/
|
||||||
|
export const phoneSchema = z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((val) => {
|
||||||
|
if (!val) return undefined;
|
||||||
|
const phone = parsePhoneNumberWithError(val);
|
||||||
|
return phone.format("E.164"); // +14155551234
|
||||||
|
})
|
||||||
|
.refine((val) => !val || isValidPhoneNumber(val), {
|
||||||
|
message: "Invalid phone number",
|
||||||
|
});
|
||||||
81
packages/api-contract/src/schemas/org.ts
Normal file
81
packages/api-contract/src/schemas/org.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { emailSchema, slugSchema } from "./common.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Org member role enum
|
||||||
|
*/
|
||||||
|
export const orgRoleSchema = z.enum(["owner", "admin", "member"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create org input schema
|
||||||
|
*/
|
||||||
|
export const createOrgInputSchema = z.object({
|
||||||
|
slug: slugSchema,
|
||||||
|
displayName: z.string().min(1).max(100),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Org output schema
|
||||||
|
* Returned by orgs.list, orgs.get, etc.
|
||||||
|
*/
|
||||||
|
export const orgOutputSchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
slug: z.string(),
|
||||||
|
displayName: z.string(),
|
||||||
|
logoUrl: z.string().nullable(),
|
||||||
|
createdAt: z.date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Org member output schema
|
||||||
|
* Returned by orgs.members.list
|
||||||
|
*/
|
||||||
|
export const orgMemberOutputSchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
userId: z.number(),
|
||||||
|
email: z.string(),
|
||||||
|
displayName: z.string().nullable(),
|
||||||
|
role: orgRoleSchema,
|
||||||
|
createdAt: z.date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update member role input schema
|
||||||
|
*/
|
||||||
|
export const updateMemberRoleInputSchema = z.object({
|
||||||
|
slug: slugSchema,
|
||||||
|
userId: z.number(),
|
||||||
|
role: orgRoleSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create invite input schema
|
||||||
|
*/
|
||||||
|
export const createInviteInputSchema = z.object({
|
||||||
|
slug: slugSchema,
|
||||||
|
email: emailSchema,
|
||||||
|
role: orgRoleSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Org invite output schema
|
||||||
|
* Returned by orgs.invites.list
|
||||||
|
*/
|
||||||
|
export const orgInviteOutputSchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
email: z.string(),
|
||||||
|
role: orgRoleSchema,
|
||||||
|
invitedBy: z.string(),
|
||||||
|
createdAt: z.date(),
|
||||||
|
expiresAt: z.date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Org site output schema
|
||||||
|
* Returned by orgs.sites.list
|
||||||
|
*/
|
||||||
|
export const orgSiteOutputSchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
domain: z.string(),
|
||||||
|
createdAt: z.date(),
|
||||||
|
});
|
||||||
99
packages/api-contract/src/schemas/user.ts
Normal file
99
packages/api-contract/src/schemas/user.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { phoneSchema } from "./common.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User profile schema
|
||||||
|
* Returned by me.get and other profile endpoints
|
||||||
|
*/
|
||||||
|
export const userProfileSchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
email: z.string(),
|
||||||
|
displayName: z.string().nullable(),
|
||||||
|
fullName: z.string().nullable(),
|
||||||
|
phoneNumber: z.string().nullable(),
|
||||||
|
avatarUrl: z.string().nullable(),
|
||||||
|
emailVerified: z.boolean(),
|
||||||
|
needsSetup: z.boolean(),
|
||||||
|
isSuperuser: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup profile input schema
|
||||||
|
* Used after signup to collect profile information
|
||||||
|
*/
|
||||||
|
export const setupProfileInputSchema = z.object({
|
||||||
|
displayName: z.string().min(1).max(100),
|
||||||
|
fullName: z.string().max(200).optional(),
|
||||||
|
phoneNumber: phoneSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update profile input schema
|
||||||
|
* All fields optional for partial updates
|
||||||
|
*/
|
||||||
|
export const updateProfileInputSchema = z.object({
|
||||||
|
displayName: z.string().min(1).max(100).optional(),
|
||||||
|
fullName: z.string().max(200).optional(),
|
||||||
|
phoneNumber: phoneSchema,
|
||||||
|
avatarUrl: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set password input schema
|
||||||
|
* currentPassword required if user already has a password
|
||||||
|
*/
|
||||||
|
export const setPasswordInputSchema = z.object({
|
||||||
|
currentPassword: z.string().optional(),
|
||||||
|
newPassword: z.string().min(8),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Passkey output schema
|
||||||
|
* Returned by me.listPasskeys
|
||||||
|
*/
|
||||||
|
export const passkeyOutputSchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
name: z.string(),
|
||||||
|
createdAt: z.date(),
|
||||||
|
lastUsedAt: z.date().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session output schema
|
||||||
|
* Returned by me.listSessions
|
||||||
|
*/
|
||||||
|
export const sessionOutputSchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
ip: z.string(),
|
||||||
|
city: z.string().nullable(),
|
||||||
|
region: z.string().nullable(),
|
||||||
|
country: z.string().nullable(),
|
||||||
|
userAgent: z.string(),
|
||||||
|
trustedMode: z.boolean(),
|
||||||
|
createdAt: z.date(),
|
||||||
|
isCurrent: z.boolean(),
|
||||||
|
revokedAt: z.date().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device output schema
|
||||||
|
* Returned by me.getDeviceInfo and me.listTrustedDevices
|
||||||
|
*/
|
||||||
|
export const deviceOutputSchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
name: z.string(),
|
||||||
|
ip: z.string(),
|
||||||
|
city: z.string().nullable(),
|
||||||
|
region: z.string().nullable(),
|
||||||
|
country: z.string().nullable(),
|
||||||
|
lastUsedAt: z.date(),
|
||||||
|
isTrusted: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trust device input schema
|
||||||
|
* Used to name and trust the current device
|
||||||
|
*/
|
||||||
|
export const trustDeviceInputSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100),
|
||||||
|
});
|
||||||
12
packages/api-contract/tsconfig.json
Normal file
12
packages/api-contract/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "@macalinao/tsconfig/tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"isolatedDeclarations": false
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user