diff --git a/packages/api-contract/README.md b/packages/api-contract/README.md new file mode 100644 index 0000000..de7f9d9 --- /dev/null +++ b/packages/api-contract/README.md @@ -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; +``` + +## 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) diff --git a/packages/api-contract/eslint.config.js b/packages/api-contract/eslint.config.js new file mode 100644 index 0000000..ee789e3 --- /dev/null +++ b/packages/api-contract/eslint.config.js @@ -0,0 +1,12 @@ +import { configs } from "@macalinao/eslint-config"; + +export default [ + ...configs.fast, + { + languageOptions: { + parserOptions: { + tsconfigRootDir: import.meta.dirname, + }, + }, + }, +]; diff --git a/packages/api-contract/package.json b/packages/api-contract/package.json new file mode 100644 index 0000000..6d32d8d --- /dev/null +++ b/packages/api-contract/package.json @@ -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:" + } +} diff --git a/packages/api-contract/src/contract.ts b/packages/api-contract/src/contract.ts new file mode 100644 index 0000000..27875df --- /dev/null +++ b/packages/api-contract/src/contract.ts @@ -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()), + }), + }), +}); diff --git a/packages/api-contract/src/index.ts b/packages/api-contract/src/index.ts new file mode 100644 index 0000000..ef84782 --- /dev/null +++ b/packages/api-contract/src/index.ts @@ -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"; diff --git a/packages/api-contract/src/schemas/admin.ts b/packages/api-contract/src/schemas/admin.ts new file mode 100644 index 0000000..b3e05db --- /dev/null +++ b/packages/api-contract/src/schemas/admin.ts @@ -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(), +}); diff --git a/packages/api-contract/src/schemas/auth.ts b/packages/api-contract/src/schemas/auth.ts new file mode 100644 index 0000000..d62322a --- /dev/null +++ b/packages/api-contract/src/schemas/auth.ts @@ -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), +}); diff --git a/packages/api-contract/src/schemas/common.ts b/packages/api-contract/src/schemas/common.ts new file mode 100644 index 0000000..d807841 --- /dev/null +++ b/packages/api-contract/src/schemas/common.ts @@ -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", + }); diff --git a/packages/api-contract/src/schemas/org.ts b/packages/api-contract/src/schemas/org.ts new file mode 100644 index 0000000..c503933 --- /dev/null +++ b/packages/api-contract/src/schemas/org.ts @@ -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(), +}); diff --git a/packages/api-contract/src/schemas/user.ts b/packages/api-contract/src/schemas/user.ts new file mode 100644 index 0000000..f7af74a --- /dev/null +++ b/packages/api-contract/src/schemas/user.ts @@ -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), +}); diff --git a/packages/api-contract/tsconfig.json b/packages/api-contract/tsconfig.json new file mode 100644 index 0000000..0ba618a --- /dev/null +++ b/packages/api-contract/tsconfig.json @@ -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"] +}