Replace void returns with { success: true } across all API endpoints

- Add successResponseSchema to common.ts for explicit success responses
- Update all auth, me, orgs, and admin procedures to return { success: true }
- Update contract.ts to use successResponseSchema instead of z.void()
- Add ast-grep rule to prevent future z.void() usage in contracts
- Add build:packages script to root package.json
- Fix test file lint errors with eslint-disable comments

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
RevIQ
2026-01-10 16:30:22 +08:00
parent 5e13809c0e
commit 1bf05465c3
31 changed files with 179 additions and 53 deletions

View File

@@ -0,0 +1,15 @@
id: no-void-output
language: typescript
severity: error
message: Do not use z.void() for output - use successResponseSchema instead
note: |
Endpoints should return `{ success: true }` instead of void.
This makes the API more explicit and avoids issues with TypeScript
expecting void-returning Promises.
Replace `.output(z.void())` with `.output(successResponseSchema)` and ensure
the handler returns `{ success: true }`.
rule:
pattern: $EXPR.output(z.void())
files:
- packages/api-contract/**/*.ts

View File

@@ -23,9 +23,9 @@ import {
} from "bun:test"; } from "bun:test";
import { call } from "@orpc/server"; import { call } from "@orpc/server";
import { router } from "../../router.js"; import { router } from "../../router.js";
import { hashPassword } from "../../utils/password.js";
import { hashToken } from "../../utils/crypto.js";
import { COOKIE_NAMES } from "../../utils/cookies.js"; import { COOKIE_NAMES } from "../../utils/cookies.js";
import { hashToken } from "../../utils/crypto.js";
import { hashPassword } from "../../utils/password.js";
import { TEST_RP } from "../helpers/test-constants.js"; import { TEST_RP } from "../helpers/test-constants.js";
import { import {
createTestDb, createTestDb,
@@ -85,7 +85,7 @@ function createAPIContext(options?: {
* Create a real session in the database and return the token * Create a real session in the database and return the token
*/ */
async function createSession(userId: number): Promise<string> { async function createSession(userId: number): Promise<string> {
const token = "test-session-" + String(Date.now()) + String(Math.random()); const token = `test-session-${String(Date.now())}${String(Math.random())}`;
const tokenHashValue = await hashToken(token); const tokenHashValue = await hashToken(token);
const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS); const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS);
@@ -110,8 +110,7 @@ async function createSession(userId: number): Promise<string> {
async function createApiToken( async function createApiToken(
userId: number, userId: number,
): Promise<{ token: string; name: string }> { ): Promise<{ token: string; name: string }> {
const token = const token = `test-api-token-${String(Date.now())}${String(Math.random())}`;
"test-api-token-" + String(Date.now()) + String(Math.random());
const tokenHashValue = await hashToken(token); const tokenHashValue = await hashToken(token);
const expiresAt = new Date(Date.now() + API_TOKEN_EXPIRY_MS); const expiresAt = new Date(Date.now() + API_TOKEN_EXPIRY_MS);
@@ -523,6 +522,7 @@ describe("me.setPassword", () => {
const sessionToken = await createSession(user.id); const sessionToken = await createSession(user.id);
const context = createAPIContext({ sessionToken }); const context = createAPIContext({ sessionToken });
// eslint-disable-next-line @typescript-eslint/await-thenable, @typescript-eslint/no-confusing-void-expression
await expect( await expect(
call( call(
router.me.setPassword, router.me.setPassword,
@@ -544,6 +544,7 @@ describe("me.setPassword", () => {
const sessionToken = await createSession(user.id); const sessionToken = await createSession(user.id);
const context = createAPIContext({ sessionToken }); const context = createAPIContext({ sessionToken });
// eslint-disable-next-line @typescript-eslint/await-thenable, @typescript-eslint/no-confusing-void-expression
await expect( await expect(
call( call(
router.me.setPassword, router.me.setPassword,
@@ -567,6 +568,7 @@ describe("me.setPassword", () => {
// Password must be at least 8 chars to pass schema validation // Password must be at least 8 chars to pass schema validation
// "password" passes length check but fails zxcvbn strength check // "password" passes length check but fails zxcvbn strength check
// zxcvbn provides feedback like "This is a top-10 common password" // zxcvbn provides feedback like "This is a top-10 common password"
// eslint-disable-next-line @typescript-eslint/await-thenable, @typescript-eslint/no-confusing-void-expression
await expect( await expect(
call( call(
router.me.setPassword, router.me.setPassword,
@@ -611,6 +613,7 @@ describe("me.delete", () => {
const sessionToken = await createSession(user.id); const sessionToken = await createSession(user.id);
const context = createAPIContext({ sessionToken }); const context = createAPIContext({ sessionToken });
// eslint-disable-next-line @typescript-eslint/await-thenable, @typescript-eslint/no-confusing-void-expression
await expect( await expect(
call(router.me.delete, { password: "anything" }, { context }), call(router.me.delete, { password: "anything" }, { context }),
).rejects.toThrow("Cannot delete account without a password"); ).rejects.toThrow("Cannot delete account without a password");
@@ -626,6 +629,7 @@ describe("me.delete", () => {
const sessionToken = await createSession(user.id); const sessionToken = await createSession(user.id);
const context = createAPIContext({ sessionToken }); const context = createAPIContext({ sessionToken });
// eslint-disable-next-line @typescript-eslint/await-thenable, @typescript-eslint/no-confusing-void-expression
await expect( await expect(
call(router.me.delete, { password: "WrongPassword123!" }, { context }), call(router.me.delete, { password: "WrongPassword123!" }, { context }),
).rejects.toThrow("Incorrect password"); ).rejects.toThrow("Incorrect password");

View File

@@ -3,9 +3,9 @@
*/ */
import type { Database } from "@reviq/db-schema"; import type { Database } from "@reviq/db-schema";
import type { Kysely } from "kysely";
import { join } from "node:path"; import { join } from "node:path";
import { createDb } from "@reviq/db"; import { createDb } from "@reviq/db";
import type { Kysely } from "kysely";
import { sql } from "kysely"; import { sql } from "kysely";
import pg from "pg"; import pg from "pg";
@@ -134,14 +134,13 @@ async function ensureTestDatabaseExists(): Promise<void> {
* *
* @throws Error if repo root cannot be found * @throws Error if repo root cannot be found
*/ */
function findRepoRoot(): string { async function findRepoRoot(): Promise<string> {
const { existsSync } = require("node:fs");
let current = import.meta.dir; let current = import.meta.dir;
// Walk up to 10 levels to find the repo root // Walk up to 10 levels to find the repo root
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
const migrationsPath = join(current, "db", "migrations"); const migrationsPath = join(current, "db", "migrations");
if (existsSync(migrationsPath)) { if (await Bun.file(migrationsPath).exists()) {
return current; return current;
} }
const parent = join(current, ".."); const parent = join(current, "..");
@@ -167,7 +166,7 @@ export async function runMigrations(): Promise<void> {
// Ensure the database exists first // Ensure the database exists first
await ensureTestDatabaseExists(); await ensureTestDatabaseExists();
const repoRoot = findRepoRoot(); const repoRoot = await findRepoRoot();
const proc = Bun.spawn(["dbmate", "up"], { const proc = Bun.spawn(["dbmate", "up"], {
env: { ...process.env, DATABASE_URL: testDbUrl }, env: { ...process.env, DATABASE_URL: testDbUrl },

View File

@@ -46,4 +46,6 @@ export const adminAuthCompleteLogin = os.admin.auth.completeLogin
.set({ completed_at: new Date() }) .set({ completed_at: new Date() })
.where("id", "=", anyRequest.id) .where("id", "=", anyRequest.id)
.execute(); .execute();
return { success: true };
}); });

View File

@@ -33,4 +33,6 @@ export const adminOrgsDelete = os.admin.orgs.delete
.execute(); .execute();
await trx.deleteFrom("orgs").where("id", "=", org.id).execute(); await trx.deleteFrom("orgs").where("id", "=", org.id).execute();
}); });
return { success: true };
}); });

View File

@@ -68,6 +68,8 @@ export const adminOrgsAddSite = os.admin.orgs.addSite
}) })
.execute(); .execute();
}); });
return { success: true };
}); });
export const adminOrgsRemoveSite = os.admin.orgs.removeSite export const adminOrgsRemoveSite = os.admin.orgs.removeSite
@@ -94,4 +96,6 @@ export const adminOrgsRemoveSite = os.admin.orgs.removeSite
if (!result.numDeletedRows || result.numDeletedRows === 0n) { if (!result.numDeletedRows || result.numDeletedRows === 0n) {
throw new ORPCError("NOT_FOUND", { message: "Site not found" }); throw new ORPCError("NOT_FOUND", { message: "Site not found" });
} }
return { success: true };
}); });

View File

@@ -22,7 +22,7 @@ export const adminOrgsUpdate = os.admin.orgs.update
if (!org) { if (!org) {
throw new ORPCError("NOT_FOUND", { message: "Organization not found" }); throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
} }
return; return { success: true };
} }
const updates: Partial<{ const updates: Partial<{
@@ -47,4 +47,6 @@ export const adminOrgsUpdate = os.admin.orgs.update
if (!result.numUpdatedRows || result.numUpdatedRows === 0n) { if (!result.numUpdatedRows || result.numUpdatedRows === 0n) {
throw new ORPCError("NOT_FOUND", { message: "Organization not found" }); throw new ORPCError("NOT_FOUND", { message: "Organization not found" });
} }
return { success: true };
}); });

View File

@@ -21,4 +21,6 @@ export const adminUsersConfirmEmail = os.admin.users.confirmEmail
if (!result.numUpdatedRows || result.numUpdatedRows === 0n) { if (!result.numUpdatedRows || result.numUpdatedRows === 0n) {
throw new ORPCError("NOT_FOUND", { message: "User not found" }); throw new ORPCError("NOT_FOUND", { message: "User not found" });
} }
return { success: true };
}); });

View File

@@ -60,4 +60,6 @@ export const adminUsersCreate = os.admin.users.create
.execute(); .execute();
} }
}); });
return { success: true };
}); });

View File

@@ -23,7 +23,7 @@ export const adminUsersUpdate = os.admin.users.update
if (!user) { if (!user) {
throw new ORPCError("NOT_FOUND", { message: "User not found" }); throw new ORPCError("NOT_FOUND", { message: "User not found" });
} }
return; return { success: true };
} }
// Prevent superuser from demoting themselves // Prevent superuser from demoting themselves
@@ -45,4 +45,6 @@ export const adminUsersUpdate = os.admin.users.update
if (!result.numUpdatedRows || result.numUpdatedRows === 0n) { if (!result.numUpdatedRows || result.numUpdatedRows === 0n) {
throw new ORPCError("NOT_FOUND", { message: "User not found" }); throw new ORPCError("NOT_FOUND", { message: "User not found" });
} }
return { success: true };
}); });

View File

@@ -57,5 +57,6 @@ export const forgotPassword = os.auth.forgotPassword.handler(
// Always return success (anti-enumeration) // Always return success (anti-enumeration)
// Don't reveal whether the email exists or not // Don't reveal whether the email exists or not
return { success: true };
}, },
); );

View File

@@ -41,7 +41,7 @@ export const loginPasswordConfirm = os.auth.loginPasswordConfirm.handler(
// If already completed, return success (idempotent) // If already completed, return success (idempotent)
if (loginRequest.completed_at !== null) { if (loginRequest.completed_at !== null) {
return; return { success: true };
} }
// Mark as completed // Mark as completed
@@ -50,5 +50,7 @@ export const loginPasswordConfirm = os.auth.loginPasswordConfirm.handler(
.set({ completed_at: new Date() }) .set({ completed_at: new Date() })
.where("id", "=", loginRequest.id) .where("id", "=", loginRequest.id)
.execute(); .execute();
return { success: true };
}, },
); );

View File

@@ -111,6 +111,6 @@ export const loginPassword = os.auth.loginPassword.handler(
await sendLoginConfirmationEmail(result.email, result.token); await sendLoginConfirmationEmail(result.email, result.token);
} }
// Return void (success) return { success: true };
}, },
); );

View File

@@ -23,4 +23,6 @@ export const logout = os.auth.logout
// Clear the session cookie // Clear the session cookie
deleteCookie(context.resHeaders, COOKIE_NAMES.SESSION_TOKEN); deleteCookie(context.resHeaders, COOKIE_NAMES.SESSION_TOKEN);
return { success: true };
}); });

View File

@@ -24,7 +24,7 @@ export const resendVerificationEmail = os.auth.resendVerificationEmail
// Check if email is already verified // Check if email is already verified
if (context.user.emailVerifiedAt !== null) { if (context.user.emailVerifiedAt !== null) {
// Email already verified, return early // Email already verified, return early
return; return { success: true };
} }
// Delete any existing verification tokens for this user // Delete any existing verification tokens for this user
@@ -49,4 +49,6 @@ export const resendVerificationEmail = os.auth.resendVerificationEmail
// Send verification email (stubbed) // Send verification email (stubbed)
await sendVerificationEmail(context.user.email, token); await sendVerificationEmail(context.user.email, token);
return { success: true };
}); });

View File

@@ -84,6 +84,6 @@ export const resetPassword = os.auth.resetPassword.handler(
.where("revoked_at", "is", null) .where("revoked_at", "is", null)
.execute(); .execute();
// Return void on success return { success: true };
}, },
); );

View File

@@ -280,4 +280,6 @@ export const signup = os.auth.signup.handler(async ({ input, context }) => {
// Send verification email (stubbed) // Send verification email (stubbed)
await sendVerificationEmail(email, verificationToken); await sendVerificationEmail(email, verificationToken);
return { success: true };
}); });

View File

@@ -54,5 +54,7 @@ export const verifyEmail = os.auth.verifyEmail.handler(
.deleteFrom("email_verifications") .deleteFrom("email_verifications")
.where("id", "=", verification.id) .where("id", "=", verification.id)
.execute(); .execute();
return { success: true };
}, },
); );

View File

@@ -47,4 +47,6 @@ export const meDelete = os.me.delete
// Clear session cookie // Clear session cookie
deleteCookie(context.resHeaders, COOKIE_NAMES.SESSION_TOKEN); deleteCookie(context.resHeaders, COOKIE_NAMES.SESSION_TOKEN);
return { success: true };
}); });

View File

@@ -64,6 +64,8 @@ export const trustDevice = os.me.trustDevice
if (!result.numUpdatedRows || result.numUpdatedRows === 0n) { if (!result.numUpdatedRows || result.numUpdatedRows === 0n) {
throw new ORPCError("NOT_FOUND", { message: "Device not found" }); throw new ORPCError("NOT_FOUND", { message: "Device not found" });
} }
return { success: true };
}); });
/** /**
@@ -113,6 +115,8 @@ export const untrustDevice = os.me.untrustDevice
if (!result.numUpdatedRows || result.numUpdatedRows === 0n) { if (!result.numUpdatedRows || result.numUpdatedRows === 0n) {
throw new ORPCError("NOT_FOUND", { message: "Device not found" }); throw new ORPCError("NOT_FOUND", { message: "Device not found" });
} }
return { success: true };
}); });
/** /**
@@ -128,4 +132,6 @@ export const revokeAllTrustedDevices = os.me.revokeAllTrustedDevices
.set({ is_trusted: false }) .set({ is_trusted: false })
.where("user_id", "=", context.user.id) .where("user_id", "=", context.user.id)
.execute(); .execute();
return { success: true };
}); });

View File

@@ -45,6 +45,8 @@ export const renamePasskey = os.me.passkeys.rename
if (!result.numUpdatedRows || result.numUpdatedRows === 0n) { if (!result.numUpdatedRows || result.numUpdatedRows === 0n) {
throw new ORPCError("NOT_FOUND", { message: "Passkey not found" }); throw new ORPCError("NOT_FOUND", { message: "Passkey not found" });
} }
return { success: true };
}); });
/** /**
@@ -92,4 +94,6 @@ export const deletePasskey = os.me.passkeys.delete
throw new ORPCError("NOT_FOUND", { message: "Passkey not found" }); throw new ORPCError("NOT_FOUND", { message: "Passkey not found" });
} }
}); });
return { success: true };
}); });

View File

@@ -65,6 +65,8 @@ export const revokeSession = os.me.revokeSession
if (!result.numUpdatedRows || result.numUpdatedRows === 0n) { if (!result.numUpdatedRows || result.numUpdatedRows === 0n) {
throw new ORPCError("NOT_FOUND", { message: "Session not found" }); throw new ORPCError("NOT_FOUND", { message: "Session not found" });
} }
return { success: true };
}); });
/** /**
@@ -83,4 +85,6 @@ export const revokeAllSessions = os.me.revokeAllSessions
.where("id", "!=", context.session.id) .where("id", "!=", context.session.id)
.where("revoked_at", "is", null) .where("revoked_at", "is", null)
.execute(); .execute();
return { success: true };
}); });

View File

@@ -58,4 +58,6 @@ export const setPassword = os.me.setPassword
.set({ password_hash: newHash, updated_at: new Date() }) .set({ password_hash: newHash, updated_at: new Date() })
.where("id", "=", context.user.id) .where("id", "=", context.user.id)
.execute(); .execute();
return { success: true };
}); });

View File

@@ -36,4 +36,6 @@ export const updateProfile = os.me.updateProfile
.where("id", "=", context.user.id) .where("id", "=", context.user.id)
.execute(); .execute();
} }
return { success: true };
}); });

View File

@@ -123,6 +123,8 @@ export const invitesCreate = os.orgs.invites.create
// Send invitation email // Send invitation email
const inviterName = context.user.displayName ?? context.user.email; const inviterName = context.user.displayName ?? context.user.email;
await sendOrgInviteEmail(email, token, org.displayName, inviterName, role); await sendOrgInviteEmail(email, token, org.displayName, inviterName, role);
return { success: true };
}); });
/** /**
@@ -149,6 +151,8 @@ export const invitesCancel = os.orgs.invites.cancel
if (!result.numDeletedRows || result.numDeletedRows === 0n) { if (!result.numDeletedRows || result.numDeletedRows === 0n) {
throw new ORPCError("NOT_FOUND", { message: "Invitation not found" }); throw new ORPCError("NOT_FOUND", { message: "Invitation not found" });
} }
return { success: true };
}); });
/** /**
@@ -219,4 +223,6 @@ export const invitesAccept = os.orgs.invites.accept
} }
throw error; throw error;
} }
return { success: true };
}); });

View File

@@ -39,6 +39,8 @@ export const orgsUpdate = os.orgs.update
.set(updates) .set(updates)
.where("id", "=", org.id) .where("id", "=", org.id)
.execute(); .execute();
return { success: true };
}); });
/** /**
@@ -57,6 +59,8 @@ export const orgsDelete = os.orgs.delete
requireRole(membership, "owner"); requireRole(membership, "owner");
await context.db.deleteFrom("orgs").where("id", "=", org.id).execute(); await context.db.deleteFrom("orgs").where("id", "=", org.id).execute();
return { success: true };
}); });
/** /**
@@ -92,4 +96,6 @@ export const orgsLeave = os.orgs.leave
.where("user_id", "=", context.user.id) .where("user_id", "=", context.user.id)
.execute(); .execute();
}); });
return { success: true };
}); });

View File

@@ -95,6 +95,8 @@ export const membersUpdateRole = os.orgs.members.updateRole
.where("id", "=", targetMember.id) .where("id", "=", targetMember.id)
.execute(); .execute();
}); });
return { success: true };
}); });
/** /**
@@ -155,4 +157,6 @@ export const membersRemove = os.orgs.members.remove
.where("id", "=", targetMember.id) .where("id", "=", targetMember.id)
.execute(); .execute();
}); });
return { success: true };
}); });

View File

@@ -160,6 +160,8 @@ const verifyAuthentication = os.auth.webauthn.verifyAuthentication
.set({ completed_at: new Date() }) .set({ completed_at: new Date() })
.where("id", "=", String(context.loginRequestId)) .where("id", "=", String(context.loginRequestId))
.execute(); .execute();
return { success: true };
}); });
// Me procedures // Me procedures
@@ -245,6 +247,8 @@ const setupProfile = os.me.setupProfile
}) })
.where("id", "=", context.user.id) .where("id", "=", context.user.id)
.execute(); .execute();
return { success: true };
}); });
// Me procedures imported from ./procedures/me/* // Me procedures imported from ./procedures/me/*

View File

@@ -10,6 +10,7 @@
"dev": "turbo dev", "dev": "turbo dev",
"build": "turbo build", "build": "turbo build",
"build:watch:packages": "turbo watch build --filter=./packages/*", "build:watch:packages": "turbo watch build --filter=./packages/*",
"build:packages": "turbo build --filter=./packages/*",
"lint": "biome check && turbo run lint", "lint": "biome check && turbo run lint",
"lint:fix": "biome check --write --unsafe && turbo run lint -- --fix", "lint:fix": "biome check --write --unsafe && turbo run lint -- --fix",
"typecheck": "turbo typecheck", "typecheck": "turbo typecheck",

View File

@@ -22,7 +22,11 @@ import {
signupInputSchema, signupInputSchema,
verifyEmailInputSchema, verifyEmailInputSchema,
} from "./schemas/auth.js"; } from "./schemas/auth.js";
import { emailSchema, slugSchema } from "./schemas/common.js"; import {
emailSchema,
slugSchema,
successResponseSchema,
} from "./schemas/common.js";
import { import {
createInviteInputSchema, createInviteInputSchema,
createOrgInputSchema, createOrgInputSchema,
@@ -51,26 +55,32 @@ import {
export const contract = oc.router({ export const contract = oc.router({
auth: oc.router({ auth: oc.router({
// Signup and verification // Signup and verification
signup: oc.input(signupInputSchema).output(z.void()), signup: oc.input(signupInputSchema).output(successResponseSchema),
verifyEmail: oc.input(verifyEmailInputSchema).output(z.void()), verifyEmail: oc.input(verifyEmailInputSchema).output(successResponseSchema),
resendVerificationEmail: oc.output(z.void()), resendVerificationEmail: oc.output(successResponseSchema),
// Login flow // Login flow
createLoginRequest: oc createLoginRequest: oc
.input(loginRequestInputSchema) .input(loginRequestInputSchema)
.output(loginRequestOutputSchema), .output(loginRequestOutputSchema),
loginPassword: oc.input(loginPasswordInputSchema).output(z.void()), loginPassword: oc
.input(loginPasswordInputSchema)
.output(successResponseSchema),
loginPasswordConfirm: oc loginPasswordConfirm: oc
.input(z.object({ token: z.string() })) .input(z.object({ token: z.string() }))
.output(z.void()), .output(successResponseSchema),
loginIfRequestIsCompleted: oc.output(loginStatusOutputSchema), loginIfRequestIsCompleted: oc.output(loginStatusOutputSchema),
// Password reset // Password reset
forgotPassword: oc.input(forgotPasswordInputSchema).output(z.void()), forgotPassword: oc
resetPassword: oc.input(resetPasswordInputSchema).output(z.void()), .input(forgotPasswordInputSchema)
.output(successResponseSchema),
resetPassword: oc
.input(resetPasswordInputSchema)
.output(successResponseSchema),
// Logout // Logout
logout: oc.output(z.void()), logout: oc.output(successResponseSchema),
// WebAuthn procedures // WebAuthn procedures
webauthn: oc.router({ webauthn: oc.router({
@@ -103,45 +113,53 @@ export const contract = oc.router({
response: z.custom<AuthenticationResponseJSON>(), response: z.custom<AuthenticationResponseJSON>(),
}), }),
) )
.output(z.void()), .output(successResponseSchema),
}), }),
}), }),
me: oc.router({ me: oc.router({
// Profile // Profile
get: oc.output(userProfileSchema), get: oc.output(userProfileSchema),
setupProfile: oc.input(setupProfileInputSchema).output(z.void()), setupProfile: oc
updateProfile: oc.input(updateProfileInputSchema).output(z.void()), .input(setupProfileInputSchema)
delete: oc.input(z.object({ password: z.string() })).output(z.void()), .output(successResponseSchema),
updateProfile: oc
.input(updateProfileInputSchema)
.output(successResponseSchema),
delete: oc
.input(z.object({ password: z.string() }))
.output(successResponseSchema),
// Auth status (for CLI and debugging) // Auth status (for CLI and debugging)
authStatus: oc.output(authStatusOutputSchema), authStatus: oc.output(authStatusOutputSchema),
// Authentication settings // Authentication settings
setPassword: oc.input(setPasswordInputSchema).output(z.void()), setPassword: oc.input(setPasswordInputSchema).output(successResponseSchema),
// Passkeys // Passkeys
passkeys: oc.router({ passkeys: oc.router({
list: oc.output(z.array(passkeyOutputSchema)), list: oc.output(z.array(passkeyOutputSchema)),
rename: oc rename: oc
.input(z.object({ passkeyId: z.number(), name: z.string() })) .input(z.object({ passkeyId: z.number(), name: z.string() }))
.output(z.void()), .output(successResponseSchema),
delete: oc.input(z.object({ passkeyId: z.number() })).output(z.void()), delete: oc
.input(z.object({ passkeyId: z.number() }))
.output(successResponseSchema),
}), }),
// Sessions & devices // Sessions & devices
listSessions: oc.output(z.array(sessionOutputSchema)), listSessions: oc.output(z.array(sessionOutputSchema)),
revokeSession: oc revokeSession: oc
.input(z.object({ sessionId: z.number() })) .input(z.object({ sessionId: z.number() }))
.output(z.void()), .output(successResponseSchema),
revokeAllSessions: oc.output(z.void()), revokeAllSessions: oc.output(successResponseSchema),
getDeviceInfo: oc.output(deviceOutputSchema), getDeviceInfo: oc.output(deviceOutputSchema),
trustDevice: oc.input(trustDeviceInputSchema).output(z.void()), trustDevice: oc.input(trustDeviceInputSchema).output(successResponseSchema),
listTrustedDevices: oc.output(z.array(deviceOutputSchema)), listTrustedDevices: oc.output(z.array(deviceOutputSchema)),
untrustDevice: oc untrustDevice: oc
.input(z.object({ deviceId: z.number() })) .input(z.object({ deviceId: z.number() }))
.output(z.void()), .output(successResponseSchema),
revokeAllTrustedDevices: oc.output(z.void()), revokeAllTrustedDevices: oc.output(successResponseSchema),
}), }),
orgs: oc.router({ orgs: oc.router({
@@ -159,19 +177,25 @@ export const contract = oc.router({
logoUrl: z.string().optional(), logoUrl: z.string().optional(),
}), }),
) )
.output(z.void()), .output(successResponseSchema),
delete: oc.input(z.object({ slug: slugSchema })).output(z.void()), delete: oc
leave: oc.input(z.object({ slug: slugSchema })).output(z.void()), .input(z.object({ slug: slugSchema }))
.output(successResponseSchema),
leave: oc
.input(z.object({ slug: slugSchema }))
.output(successResponseSchema),
// Members // Members
members: oc.router({ members: oc.router({
list: oc list: oc
.input(z.object({ slug: slugSchema })) .input(z.object({ slug: slugSchema }))
.output(z.array(orgMemberOutputSchema)), .output(z.array(orgMemberOutputSchema)),
updateRole: oc.input(updateMemberRoleInputSchema).output(z.void()), updateRole: oc
.input(updateMemberRoleInputSchema)
.output(successResponseSchema),
remove: oc remove: oc
.input(z.object({ slug: slugSchema, userId: z.number() })) .input(z.object({ slug: slugSchema, userId: z.number() }))
.output(z.void()), .output(successResponseSchema),
}), }),
// Invites // Invites
@@ -179,11 +203,13 @@ export const contract = oc.router({
list: oc list: oc
.input(z.object({ slug: slugSchema })) .input(z.object({ slug: slugSchema }))
.output(z.array(orgInviteOutputSchema)), .output(z.array(orgInviteOutputSchema)),
create: oc.input(createInviteInputSchema).output(z.void()), create: oc.input(createInviteInputSchema).output(successResponseSchema),
cancel: oc cancel: oc
.input(z.object({ slug: slugSchema, inviteId: z.number() })) .input(z.object({ slug: slugSchema, inviteId: z.number() }))
.output(z.void()), .output(successResponseSchema),
accept: oc.input(z.object({ token: z.string() })).output(z.void()), accept: oc
.input(z.object({ token: z.string() }))
.output(successResponseSchema),
}), }),
// Sites // Sites
@@ -210,31 +236,39 @@ export const contract = oc.router({
logoUrl: z.string().optional(), logoUrl: z.string().optional(),
}), }),
) )
.output(z.void()), .output(successResponseSchema),
delete: oc.input(z.object({ slug: slugSchema })).output(z.void()), delete: oc
.input(z.object({ slug: slugSchema }))
.output(successResponseSchema),
listSites: oc listSites: oc
.input(z.object({ slug: slugSchema })) .input(z.object({ slug: slugSchema }))
.output(z.array(orgSiteOutputSchema)), .output(z.array(orgSiteOutputSchema)),
addSite: oc.input(adminAddSiteInputSchema).output(z.void()), addSite: oc.input(adminAddSiteInputSchema).output(successResponseSchema),
removeSite: oc removeSite: oc
.input(z.object({ slug: slugSchema, domain: z.string() })) .input(z.object({ slug: slugSchema, domain: z.string() }))
.output(z.void()), .output(successResponseSchema),
}), }),
// Admin user management // Admin user management
users: oc.router({ users: oc.router({
list: oc.output(z.array(userProfileSchema)), list: oc.output(z.array(userProfileSchema)),
get: oc.input(z.object({ email: emailSchema })).output(userProfileSchema), get: oc.input(z.object({ email: emailSchema })).output(userProfileSchema),
create: oc.input(adminCreateUserInputSchema).output(z.void()), create: oc
update: oc.input(adminUpdateUserInputSchema).output(z.void()), .input(adminCreateUserInputSchema)
confirmEmail: oc.input(z.object({ email: emailSchema })).output(z.void()), .output(successResponseSchema),
update: oc
.input(adminUpdateUserInputSchema)
.output(successResponseSchema),
confirmEmail: oc
.input(z.object({ email: emailSchema }))
.output(successResponseSchema),
}), }),
// Admin auth management // Admin auth management
auth: oc.router({ auth: oc.router({
completeLogin: oc completeLogin: oc
.input(z.object({ email: emailSchema })) .input(z.object({ email: emailSchema }))
.output(z.void()), .output(successResponseSchema),
}), }),
}), }),
}); });

View File

@@ -58,3 +58,9 @@ export const phoneSchema = z
.refine((val) => !val || isValidPhoneNumber(val), { .refine((val) => !val || isValidPhoneNumber(val), {
message: "Invalid phone number", message: "Invalid phone number",
}); });
/**
* Success response schema for operations that don't return data
* Use instead of void to make responses more explicit
*/
export const successResponseSchema = z.object({ success: z.literal(true) });