Merge origin/master into reviq-auth-login-command

Resolved conflicts:
- apps/api-server/src/router.ts: Use meRoutes from master
- packages/api-contract/src/contract.ts: Keep master's nested sessions/devices/invites structure, add apiTokens

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
igm
2026-01-10 19:03:37 +08:00
67 changed files with 5091 additions and 713 deletions

View File

@@ -0,0 +1,63 @@
/**
* Me routes - consolidated exports for os.router()
*/
import { createApiToken, deleteApiToken, listApiTokens } from "./api-tokens.js";
import { meAuthStatus } from "./auth-status.js";
import { meDelete } from "./delete.js";
import {
getDeviceInfo,
listTrustedDevices,
revokeAllTrustedDevices,
trustDevice,
untrustDevice,
} from "./devices.js";
import { meGet } from "./get.js";
import {
acceptInvite,
declineInvite,
getInvite,
listInvites,
} from "./invites.js";
import { deletePasskey, listPasskeys, renamePasskey } from "./passkeys.js";
import { listSessions, revokeAllSessions, revokeSession } from "./sessions.js";
import { setPassword } from "./set-password.js";
import { setupProfile } from "./setup-profile.js";
import { updateProfile } from "./update-profile.js";
export const meRoutes = {
get: meGet,
authStatus: meAuthStatus,
setupProfile,
updateProfile,
delete: meDelete,
setPassword,
passkeys: {
list: listPasskeys,
rename: renamePasskey,
delete: deletePasskey,
},
invites: {
list: listInvites,
get: getInvite,
accept: acceptInvite,
decline: declineInvite,
},
sessions: {
list: listSessions,
revoke: revokeSession,
revokeAll: revokeAllSessions,
},
devices: {
getInfo: getDeviceInfo,
trust: trustDevice,
listTrusted: listTrustedDevices,
untrust: untrustDevice,
revokeAll: revokeAllTrustedDevices,
},
apiTokens: {
list: listApiTokens,
create: createApiToken,
delete: deleteApiToken,
},
};

View File

@@ -0,0 +1,41 @@
/**
* Get current user auth status
*/
import { authMiddleware, os } from "../base.js";
export const meAuthStatus = os.me.authStatus
.use(authMiddleware)
.handler(async ({ context }) => {
const user = await context.db
.selectFrom("users")
.select([
"id",
"email",
"display_name",
"full_name",
"phone_number",
"avatar_url",
"email_verified_at",
"is_superuser",
"password_hash",
])
.where("id", "=", context.user.id)
.executeTakeFirstOrThrow();
return {
user: {
id: user.id,
email: user.email,
displayName: user.display_name,
fullName: user.full_name,
phoneNumber: user.phone_number,
avatarUrl: user.avatar_url,
emailVerified: user.email_verified_at !== null,
needsSetup: user.display_name === null,
isSuperuser: user.is_superuser,
hasPassword: user.password_hash !== null,
},
auth: context.auth,
};
});

View File

@@ -13,7 +13,7 @@ import { defaultDeviceName, requireDeviceFingerprint } from "./helpers.js";
* @throws BAD_REQUEST if no device fingerprint found
* @throws NOT_FOUND if device doesn't exist
*/
export const getDeviceInfo = os.me.getDeviceInfo
export const getDeviceInfo = os.me.devices.getInfo
.use(authMiddleware)
.handler(async ({ context }) => {
const fingerprint = requireDeviceFingerprint(context.reqHeaders);
@@ -48,7 +48,7 @@ export const getDeviceInfo = os.me.getDeviceInfo
* @throws BAD_REQUEST if no device fingerprint found
* @throws NOT_FOUND if device doesn't exist
*/
export const trustDevice = os.me.trustDevice
export const trustDevice = os.me.devices.trust
.use(authMiddleware)
.handler(async ({ input, context }) => {
const { name } = input;
@@ -73,7 +73,7 @@ export const trustDevice = os.me.trustDevice
* - Requires authentication
* - Returns all trusted devices for the current user
*/
export const listTrustedDevices = os.me.listTrustedDevices
export const listTrustedDevices = os.me.devices.listTrusted
.use(authMiddleware)
.handler(async ({ context }) => {
const devices = await context.db
@@ -102,7 +102,7 @@ export const listTrustedDevices = os.me.listTrustedDevices
* - Marks device as untrusted by ID
* @throws NOT_FOUND if device doesn't exist
*/
export const untrustDevice = os.me.untrustDevice
export const untrustDevice = os.me.devices.untrust
.use(authMiddleware)
.handler(async ({ input, context }) => {
const result = await context.db
@@ -124,7 +124,7 @@ export const untrustDevice = os.me.untrustDevice
* - Requires authentication
* - Marks all devices as untrusted
*/
export const revokeAllTrustedDevices = os.me.revokeAllTrustedDevices
export const revokeAllTrustedDevices = os.me.devices.revokeAll
.use(authMiddleware)
.handler(async ({ context }) => {
await context.db

View File

@@ -0,0 +1,38 @@
/**
* Get current user profile
*/
import { authMiddleware, os } from "../base.js";
export const meGet = os.me.get
.use(authMiddleware)
.handler(async ({ context }) => {
const user = await context.db
.selectFrom("users")
.select([
"id",
"email",
"display_name",
"full_name",
"phone_number",
"avatar_url",
"email_verified_at",
"is_superuser",
"password_hash",
])
.where("id", "=", context.user.id)
.executeTakeFirstOrThrow();
return {
id: user.id,
email: user.email,
displayName: user.display_name,
fullName: user.full_name,
phoneNumber: user.phone_number,
avatarUrl: user.avatar_url,
emailVerified: user.email_verified_at !== null,
needsSetup: user.display_name === null,
isSuperuser: user.is_superuser,
hasPassword: user.password_hash !== null,
};
});

View File

@@ -10,6 +10,12 @@ export {
trustDevice,
untrustDevice,
} from "./devices.js";
export {
acceptInvite,
declineInvite,
getInvite,
listInvites,
} from "./invites.js";
export { deletePasskey, listPasskeys, renamePasskey } from "./passkeys.js";
export {
listSessions,

View File

@@ -0,0 +1,211 @@
/**
* User invite procedures - list, get, decline invites for the current user
*/
import { ORPCError } from "@orpc/server";
import { authMiddleware, os } from "../base.js";
/**
* List pending invites for the current user
* Only returns invites where the user's email matches and email is verified
*/
export const listInvites = os.me.invites.list
.use(authMiddleware)
.handler(async ({ context }) => {
// Only show invites if email is verified
if (!context.user.emailVerifiedAt) {
return [];
}
// Get non-expired invites matching user's email
const invites = await context.db
.selectFrom("org_invites")
.innerJoin("orgs", "orgs.id", "org_invites.org_id")
.innerJoin("users", "users.id", "org_invites.invited_by")
.where("org_invites.email", "=", context.user.email.toLowerCase())
.where("org_invites.expires_at", ">", new Date())
.select([
"org_invites.id",
"org_invites.role",
"org_invites.created_at",
"org_invites.expires_at",
"orgs.id as org_id",
"orgs.slug as org_slug",
"orgs.display_name as org_display_name",
"orgs.logo_url as org_logo_url",
"users.display_name as inviter_name",
"users.email as inviter_email",
])
.orderBy("org_invites.created_at", "desc")
.execute();
return invites.map((i) => ({
id: i.id,
org: {
id: i.org_id,
slug: i.org_slug,
displayName: i.org_display_name,
logoUrl: i.org_logo_url,
},
role: i.role,
invitedBy: i.inviter_name ?? i.inviter_email,
createdAt: i.created_at,
expiresAt: i.expires_at,
}));
});
/**
* Get a specific invite by ID
* Only returns if the invite belongs to the current user's email
*/
export const getInvite = os.me.invites.get
.use(authMiddleware)
.handler(async ({ input, context }) => {
const { inviteId } = input;
// Only show invite if email is verified
if (!context.user.emailVerifiedAt) {
throw new ORPCError("FORBIDDEN", {
message: "Please verify your email to view invitations",
});
}
// Get the invite matching user's email
const invite = await context.db
.selectFrom("org_invites")
.innerJoin("orgs", "orgs.id", "org_invites.org_id")
.innerJoin("users", "users.id", "org_invites.invited_by")
.where("org_invites.id", "=", inviteId)
.where("org_invites.email", "=", context.user.email.toLowerCase())
.where("org_invites.expires_at", ">", new Date())
.select([
"org_invites.id",
"org_invites.role",
"org_invites.created_at",
"org_invites.expires_at",
"orgs.id as org_id",
"orgs.slug as org_slug",
"orgs.display_name as org_display_name",
"orgs.logo_url as org_logo_url",
"users.display_name as inviter_name",
"users.email as inviter_email",
])
.executeTakeFirst();
if (!invite) {
throw new ORPCError("NOT_FOUND", {
message: "Invitation not found or expired",
});
}
return {
id: invite.id,
org: {
id: invite.org_id,
slug: invite.org_slug,
displayName: invite.org_display_name,
logoUrl: invite.org_logo_url,
},
role: invite.role,
invitedBy: invite.inviter_name ?? invite.inviter_email,
createdAt: invite.created_at,
expiresAt: invite.expires_at,
};
});
/**
* Accept an invite by ID
* Adds user to org and deletes the invite
*/
export const acceptInvite = os.me.invites.accept
.use(authMiddleware)
.handler(async ({ input, context }) => {
const { inviteId } = input;
// Only allow accepting if email is verified
if (!context.user.emailVerifiedAt) {
throw new ORPCError("FORBIDDEN", {
message: "Please verify your email to accept invitations",
});
}
// Get the invite matching user's email
const invite = await context.db
.selectFrom("org_invites")
.where("id", "=", inviteId)
.where("email", "=", context.user.email.toLowerCase())
.where("expires_at", ">", new Date())
.select(["id", "org_id", "role"])
.executeTakeFirst();
if (!invite) {
throw new ORPCError("NOT_FOUND", {
message: "Invitation not found or expired",
});
}
try {
// Accept the invite in a transaction
await context.db.transaction().execute(async (trx) => {
// Add user as a member
await trx
.insertInto("org_members")
.values({
org_id: invite.org_id,
user_id: context.user.id,
role: invite.role,
})
.execute();
// Delete the invite
await trx
.deleteFrom("org_invites")
.where("id", "=", invite.id)
.execute();
});
} catch (error) {
// Handle unique constraint violation (user is already a member)
if (
error instanceof Error &&
error.message.includes("org_members_org_id_user_id_key")
) {
// Clean up the invite since user is already a member
await context.db
.deleteFrom("org_invites")
.where("id", "=", invite.id)
.execute();
throw new ORPCError("CONFLICT", {
message: "You are already a member of this organization",
});
}
throw error;
}
return { success: true };
});
/**
* Decline an invite
* Deletes the invite if it belongs to the current user's email
*/
export const declineInvite = os.me.invites.decline
.use(authMiddleware)
.handler(async ({ input, context }) => {
const { inviteId } = input;
// Delete the invite only if it matches user's email
const result = await context.db
.deleteFrom("org_invites")
.where("id", "=", inviteId)
.where("email", "=", context.user.email.toLowerCase())
.executeTakeFirst();
if (!result.numDeletedRows || result.numDeletedRows === 0n) {
throw new ORPCError("NOT_FOUND", {
message: "Invitation not found",
});
}
return { success: true };
});

View File

@@ -11,7 +11,7 @@ import { authMiddleware, os } from "../base.js";
* - Returns all sessions for the current user
* - Includes isCurrent flag to identify active session
*/
export const listSessions = os.me.listSessions
export const listSessions = os.me.sessions.list
.use(authMiddleware)
.handler(async ({ context }) => {
const sessions = await context.db
@@ -42,7 +42,7 @@ export const listSessions = os.me.listSessions
* @throws NOT_FOUND if session doesn't exist
* @throws BAD_REQUEST if trying to revoke current session
*/
export const revokeSession = os.me.revokeSession
export const revokeSession = os.me.sessions.revoke
.use(authMiddleware)
.handler(async ({ input, context }) => {
const { sessionId } = input;
@@ -74,7 +74,7 @@ export const revokeSession = os.me.revokeSession
* - Requires authentication
* - Revokes all sessions except current
*/
export const revokeAllSessions = os.me.revokeAllSessions
export const revokeAllSessions = os.me.sessions.revokeAll
.use(authMiddleware)
.handler(async ({ context }) => {
// Revoke all sessions except current

View File

@@ -0,0 +1,24 @@
/**
* Setup user profile (initial setup after signup)
*/
import { authMiddleware, os } from "../base.js";
export const setupProfile = os.me.setupProfile
.use(authMiddleware)
.handler(async ({ input, context }) => {
const { displayName, fullName, phoneNumber } = input;
await context.db
.updateTable("users")
.set({
display_name: displayName,
full_name: fullName ?? null,
phone_number: phoneNumber ?? null,
updated_at: new Date(),
})
.where("id", "=", context.user.id)
.execute();
return { success: true };
});