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:
RevIQ
2026-01-09 11:44:52 +08:00
parent 392d976812
commit cc5fba0fc7
11 changed files with 706 additions and 0 deletions

View 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()),
}),
}),
});

View 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";

View 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(),
});

View 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),
});

View 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",
});

View 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(),
});

View 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),
});