Merge branch 'master' into workstream-h-v2
Resolve conflicts in router.ts by adopting master's modular architecture for me.* procedures while keeping meGet and setupProfile inline. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@
|
|||||||
"clean": "rm -rf dist .eslintcache"
|
"clean": "rm -rf dist .eslintcache"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@formatjs/intl-durationformat": "^0.9.2",
|
||||||
"@noble/hashes": "^2.0.1",
|
"@noble/hashes": "^2.0.1",
|
||||||
"@orpc/server": "^1.13.2",
|
"@orpc/server": "^1.13.2",
|
||||||
"@reviq/api-contract": "workspace:*",
|
"@reviq/api-contract": "workspace:*",
|
||||||
|
|||||||
@@ -36,13 +36,13 @@ export const createAuthMiddleware = () => {
|
|||||||
let tokenHash: string | undefined;
|
let tokenHash: string | undefined;
|
||||||
const sessionToken = getCookie(reqHeaders, COOKIE_NAMES.SESSION_TOKEN);
|
const sessionToken = getCookie(reqHeaders, COOKIE_NAMES.SESSION_TOKEN);
|
||||||
if (sessionToken) {
|
if (sessionToken) {
|
||||||
tokenHash = hashToken(sessionToken);
|
tokenHash = await hashToken(sessionToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to API key header (for CLI)
|
// Fall back to API key header (for CLI)
|
||||||
const apiKey = reqHeaders.get("x-api-key");
|
const apiKey = reqHeaders.get("x-api-key");
|
||||||
if (!tokenHash && apiKey) {
|
if (!tokenHash && apiKey) {
|
||||||
tokenHash = hashToken(apiKey);
|
tokenHash = await hashToken(apiKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!tokenHash) {
|
if (!tokenHash) {
|
||||||
|
|||||||
@@ -34,13 +34,13 @@ export const authMiddleware = os.middleware(async ({ context, next }) => {
|
|||||||
let tokenHash: string | undefined;
|
let tokenHash: string | undefined;
|
||||||
const sessionToken = getCookie(reqHeaders, COOKIE_NAMES.SESSION_TOKEN);
|
const sessionToken = getCookie(reqHeaders, COOKIE_NAMES.SESSION_TOKEN);
|
||||||
if (sessionToken) {
|
if (sessionToken) {
|
||||||
tokenHash = hashToken(sessionToken);
|
tokenHash = await hashToken(sessionToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to API key header (for CLI)
|
// Fall back to API key header (for CLI)
|
||||||
const apiKey = reqHeaders.get("x-api-key");
|
const apiKey = reqHeaders.get("x-api-key");
|
||||||
if (!tokenHash && apiKey) {
|
if (!tokenHash && apiKey) {
|
||||||
tokenHash = hashToken(apiKey);
|
tokenHash = await hashToken(apiKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!tokenHash) {
|
if (!tokenHash) {
|
||||||
|
|||||||
50
apps/api-server/src/procedures/me/delete.ts
Normal file
50
apps/api-server/src/procedures/me/delete.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* Delete account procedure - permanently deletes user account
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ORPCError } from "@orpc/server";
|
||||||
|
import { COOKIE_NAMES, deleteCookie } from "../../utils/cookies.js";
|
||||||
|
import { verifyPassword } from "../../utils/password.js";
|
||||||
|
import { authMiddleware, os } from "../base.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete account handler
|
||||||
|
* - Requires authentication
|
||||||
|
* - Requires password confirmation (passkey-only users must set password first)
|
||||||
|
* - Deletes user record (cascades to sessions, devices, passkeys, etc.)
|
||||||
|
* - Clears session cookie
|
||||||
|
*/
|
||||||
|
export const meDelete = os.me.delete
|
||||||
|
.use(authMiddleware)
|
||||||
|
.handler(async ({ input, context }) => {
|
||||||
|
const { password } = input;
|
||||||
|
|
||||||
|
// Fetch user with password hash
|
||||||
|
const user = await context.db
|
||||||
|
.selectFrom("users")
|
||||||
|
.select(["password_hash"])
|
||||||
|
.where("id", "=", context.user.id)
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
|
// Verify password (required for account deletion)
|
||||||
|
if (!user.password_hash) {
|
||||||
|
throw new ORPCError("BAD_REQUEST", {
|
||||||
|
message:
|
||||||
|
"Cannot delete account without a password. Please set a password first.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await verifyPassword(password, user.password_hash);
|
||||||
|
if (!valid) {
|
||||||
|
throw new ORPCError("BAD_REQUEST", { message: "Incorrect password" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete user (cascades to sessions, devices, passkeys, etc.)
|
||||||
|
await context.db
|
||||||
|
.deleteFrom("users")
|
||||||
|
.where("id", "=", context.user.id)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
// Clear session cookie
|
||||||
|
deleteCookie(context.resHeaders, COOKIE_NAMES.SESSION_TOKEN);
|
||||||
|
});
|
||||||
131
apps/api-server/src/procedures/me/devices.ts
Normal file
131
apps/api-server/src/procedures/me/devices.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
/**
|
||||||
|
* Device management procedures - getInfo, trust, listTrusted, untrust, revokeAll
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ORPCError } from "@orpc/server";
|
||||||
|
import { authMiddleware, os } from "../base.js";
|
||||||
|
import { defaultDeviceName, requireDeviceFingerprint } from "./helpers.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get device info handler
|
||||||
|
* - Requires authentication
|
||||||
|
* - Returns info for the current device based on fingerprint cookie
|
||||||
|
* @throws BAD_REQUEST if no device fingerprint found
|
||||||
|
* @throws NOT_FOUND if device doesn't exist
|
||||||
|
*/
|
||||||
|
export const getDeviceInfo = os.me.getDeviceInfo
|
||||||
|
.use(authMiddleware)
|
||||||
|
.handler(async ({ context }) => {
|
||||||
|
const fingerprint = requireDeviceFingerprint(context.reqHeaders);
|
||||||
|
|
||||||
|
const device = await context.db
|
||||||
|
.selectFrom("user_devices")
|
||||||
|
.selectAll()
|
||||||
|
.where("user_id", "=", context.user.id)
|
||||||
|
.where("device_fingerprint", "=", fingerprint)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
throw new ORPCError("NOT_FOUND", { message: "Device not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: Number(device.id),
|
||||||
|
name: device.name ?? defaultDeviceName(device.user_agent),
|
||||||
|
ip: device.ip_address ?? "",
|
||||||
|
city: device.city,
|
||||||
|
region: device.region,
|
||||||
|
country: device.country,
|
||||||
|
lastUsedAt: device.last_used_at,
|
||||||
|
isTrusted: device.is_trusted,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trust device handler
|
||||||
|
* - Requires authentication
|
||||||
|
* - Marks current device as trusted with a given name
|
||||||
|
* @throws BAD_REQUEST if no device fingerprint found
|
||||||
|
* @throws NOT_FOUND if device doesn't exist
|
||||||
|
*/
|
||||||
|
export const trustDevice = os.me.trustDevice
|
||||||
|
.use(authMiddleware)
|
||||||
|
.handler(async ({ input, context }) => {
|
||||||
|
const { name } = input;
|
||||||
|
const fingerprint = requireDeviceFingerprint(context.reqHeaders);
|
||||||
|
|
||||||
|
const result = await context.db
|
||||||
|
.updateTable("user_devices")
|
||||||
|
.set({ is_trusted: true, name })
|
||||||
|
.where("user_id", "=", context.user.id)
|
||||||
|
.where("device_fingerprint", "=", fingerprint)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
if (!result.numUpdatedRows || result.numUpdatedRows === 0n) {
|
||||||
|
throw new ORPCError("NOT_FOUND", { message: "Device not found" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List trusted devices handler
|
||||||
|
* - Requires authentication
|
||||||
|
* - Returns all trusted devices for the current user
|
||||||
|
*/
|
||||||
|
export const listTrustedDevices = os.me.listTrustedDevices
|
||||||
|
.use(authMiddleware)
|
||||||
|
.handler(async ({ context }) => {
|
||||||
|
const devices = await context.db
|
||||||
|
.selectFrom("user_devices")
|
||||||
|
.selectAll()
|
||||||
|
.where("user_id", "=", context.user.id)
|
||||||
|
.where("is_trusted", "=", true)
|
||||||
|
.orderBy("last_used_at", "desc")
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
return devices.map((d) => ({
|
||||||
|
id: Number(d.id),
|
||||||
|
name: d.name ?? "Unknown device",
|
||||||
|
ip: d.ip_address ?? "",
|
||||||
|
city: d.city,
|
||||||
|
region: d.region,
|
||||||
|
country: d.country,
|
||||||
|
lastUsedAt: d.last_used_at,
|
||||||
|
isTrusted: d.is_trusted,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Untrust device handler
|
||||||
|
* - Requires authentication
|
||||||
|
* - Marks device as untrusted by ID
|
||||||
|
* @throws NOT_FOUND if device doesn't exist
|
||||||
|
*/
|
||||||
|
export const untrustDevice = os.me.untrustDevice
|
||||||
|
.use(authMiddleware)
|
||||||
|
.handler(async ({ input, context }) => {
|
||||||
|
const result = await context.db
|
||||||
|
.updateTable("user_devices")
|
||||||
|
.set({ is_trusted: false })
|
||||||
|
.where("id", "=", String(input.deviceId))
|
||||||
|
.where("user_id", "=", context.user.id)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
if (!result.numUpdatedRows || result.numUpdatedRows === 0n) {
|
||||||
|
throw new ORPCError("NOT_FOUND", { message: "Device not found" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke all trusted devices handler
|
||||||
|
* - Requires authentication
|
||||||
|
* - Marks all devices as untrusted
|
||||||
|
*/
|
||||||
|
export const revokeAllTrustedDevices = os.me.revokeAllTrustedDevices
|
||||||
|
.use(authMiddleware)
|
||||||
|
.handler(async ({ context }) => {
|
||||||
|
await context.db
|
||||||
|
.updateTable("user_devices")
|
||||||
|
.set({ is_trusted: false })
|
||||||
|
.where("user_id", "=", context.user.id)
|
||||||
|
.execute();
|
||||||
|
});
|
||||||
40
apps/api-server/src/procedures/me/helpers.ts
Normal file
40
apps/api-server/src/procedures/me/helpers.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Helper functions for me.* procedures
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Users } from "@reviq/db-schema";
|
||||||
|
import type { Updateable } from "kysely";
|
||||||
|
import { ORPCError } from "@orpc/server";
|
||||||
|
import { COOKIE_NAMES, getCookie } from "../../utils/cookies.js";
|
||||||
|
|
||||||
|
// ===== Types =====
|
||||||
|
|
||||||
|
/** Fields that can be updated via updateProfile */
|
||||||
|
export type ProfileUpdate = Pick<
|
||||||
|
Updateable<Users>,
|
||||||
|
"display_name" | "full_name" | "phone_number" | "avatar_url" | "updated_at"
|
||||||
|
>;
|
||||||
|
|
||||||
|
// ===== Helper Functions =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get device fingerprint from request cookies
|
||||||
|
* @throws ORPCError if fingerprint is missing
|
||||||
|
*/
|
||||||
|
export const requireDeviceFingerprint = (reqHeaders: Headers): string => {
|
||||||
|
const fingerprint = getCookie(reqHeaders, COOKIE_NAMES.DEVICE_FINGERPRINT);
|
||||||
|
if (!fingerprint) {
|
||||||
|
throw new ORPCError("BAD_REQUEST", {
|
||||||
|
message: "No device fingerprint found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return fingerprint;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get default device name from user agent string
|
||||||
|
*/
|
||||||
|
export const defaultDeviceName = (userAgent: string): string => {
|
||||||
|
const part = userAgent.split("/")[0]?.trim();
|
||||||
|
return part ? `${part} device` : "Unknown device";
|
||||||
|
};
|
||||||
20
apps/api-server/src/procedures/me/index.ts
Normal file
20
apps/api-server/src/procedures/me/index.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* Me procedures - user profile and account management
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { meDelete } from "./delete.js";
|
||||||
|
export {
|
||||||
|
getDeviceInfo,
|
||||||
|
listTrustedDevices,
|
||||||
|
revokeAllTrustedDevices,
|
||||||
|
trustDevice,
|
||||||
|
untrustDevice,
|
||||||
|
} from "./devices.js";
|
||||||
|
export { deletePasskey, listPasskeys, renamePasskey } from "./passkeys.js";
|
||||||
|
export {
|
||||||
|
listSessions,
|
||||||
|
revokeAllSessions,
|
||||||
|
revokeSession,
|
||||||
|
} from "./sessions.js";
|
||||||
|
export { setPassword } from "./set-password.js";
|
||||||
|
export { updateProfile } from "./update-profile.js";
|
||||||
95
apps/api-server/src/procedures/me/passkeys.ts
Normal file
95
apps/api-server/src/procedures/me/passkeys.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* Passkey management procedures - list, rename, delete passkeys
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ORPCError } from "@orpc/server";
|
||||||
|
import { getUserPasskeys } from "../../utils/webauthn.js";
|
||||||
|
import { authMiddleware, os } from "../base.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List passkeys handler
|
||||||
|
* - Requires authentication
|
||||||
|
* - Returns all passkeys for the current user
|
||||||
|
*/
|
||||||
|
export const listPasskeys = os.me.passkeys.list
|
||||||
|
.use(authMiddleware)
|
||||||
|
.handler(async ({ context }) => {
|
||||||
|
const passkeys = await getUserPasskeys(context.db, context.user.id);
|
||||||
|
|
||||||
|
return passkeys.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
createdAt: p.createdAt,
|
||||||
|
lastUsedAt: p.lastUsedAt,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rename passkey handler
|
||||||
|
* - Requires authentication
|
||||||
|
* - Updates passkey name
|
||||||
|
* @throws NOT_FOUND if passkey doesn't exist
|
||||||
|
*/
|
||||||
|
export const renamePasskey = os.me.passkeys.rename
|
||||||
|
.use(authMiddleware)
|
||||||
|
.handler(async ({ input, context }) => {
|
||||||
|
const { passkeyId, name } = input;
|
||||||
|
|
||||||
|
const result = await context.db
|
||||||
|
.updateTable("passkeys")
|
||||||
|
.set({ name })
|
||||||
|
.where("id", "=", String(passkeyId))
|
||||||
|
.where("user_id", "=", context.user.id)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
if (!result.numUpdatedRows || result.numUpdatedRows === 0n) {
|
||||||
|
throw new ORPCError("NOT_FOUND", { message: "Passkey not found" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete passkey handler
|
||||||
|
* - Requires authentication
|
||||||
|
* - Prevents deleting last passkey if user has no password
|
||||||
|
* - Uses transaction to prevent race conditions
|
||||||
|
* @throws NOT_FOUND if passkey doesn't exist
|
||||||
|
* @throws BAD_REQUEST if trying to delete last passkey without password
|
||||||
|
*/
|
||||||
|
export const deletePasskey = os.me.passkeys.delete
|
||||||
|
.use(authMiddleware)
|
||||||
|
.handler(async ({ input, context }) => {
|
||||||
|
const { passkeyId } = input;
|
||||||
|
|
||||||
|
// Use transaction to prevent race condition when checking last passkey
|
||||||
|
await context.db.transaction().execute(async (trx) => {
|
||||||
|
// Check if this is the last passkey and user has no password
|
||||||
|
const user = await trx
|
||||||
|
.selectFrom("users")
|
||||||
|
.select(["password_hash"])
|
||||||
|
.where("id", "=", context.user.id)
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
|
const passkeyCount = await trx
|
||||||
|
.selectFrom("passkeys")
|
||||||
|
.select(trx.fn.countAll().as("count"))
|
||||||
|
.where("user_id", "=", context.user.id)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
if (!user.password_hash && Number(passkeyCount?.count ?? 0) <= 1) {
|
||||||
|
throw new ORPCError("BAD_REQUEST", {
|
||||||
|
message:
|
||||||
|
"Cannot delete the last passkey when you have no password set",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await trx
|
||||||
|
.deleteFrom("passkeys")
|
||||||
|
.where("id", "=", String(passkeyId))
|
||||||
|
.where("user_id", "=", context.user.id)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
if (!result.numDeletedRows || result.numDeletedRows === 0n) {
|
||||||
|
throw new ORPCError("NOT_FOUND", { message: "Passkey not found" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
86
apps/api-server/src/procedures/me/sessions.ts
Normal file
86
apps/api-server/src/procedures/me/sessions.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* Session management procedures - list, revoke, revokeAll sessions
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ORPCError } from "@orpc/server";
|
||||||
|
import { authMiddleware, os } from "../base.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List sessions handler
|
||||||
|
* - Requires authentication
|
||||||
|
* - Returns all sessions for the current user
|
||||||
|
* - Includes isCurrent flag to identify active session
|
||||||
|
*/
|
||||||
|
export const listSessions = os.me.listSessions
|
||||||
|
.use(authMiddleware)
|
||||||
|
.handler(async ({ context }) => {
|
||||||
|
const sessions = await context.db
|
||||||
|
.selectFrom("sessions")
|
||||||
|
.selectAll()
|
||||||
|
.where("user_id", "=", context.user.id)
|
||||||
|
.orderBy("created_at", "desc")
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
return sessions.map((s) => ({
|
||||||
|
id: Number(s.id),
|
||||||
|
ip: s.ip_address ?? "",
|
||||||
|
city: s.city,
|
||||||
|
region: s.region,
|
||||||
|
country: s.country,
|
||||||
|
userAgent: s.user_agent ?? "",
|
||||||
|
trustedMode: s.trusted_mode,
|
||||||
|
createdAt: s.created_at,
|
||||||
|
isCurrent: s.id === context.session.id,
|
||||||
|
revokedAt: s.revoked_at,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke session handler
|
||||||
|
* - Requires authentication
|
||||||
|
* - Cannot revoke current session (use logout instead)
|
||||||
|
* @throws NOT_FOUND if session doesn't exist
|
||||||
|
* @throws BAD_REQUEST if trying to revoke current session
|
||||||
|
*/
|
||||||
|
export const revokeSession = os.me.revokeSession
|
||||||
|
.use(authMiddleware)
|
||||||
|
.handler(async ({ input, context }) => {
|
||||||
|
const { sessionId } = input;
|
||||||
|
|
||||||
|
// Prevent revoking current session (use logout instead)
|
||||||
|
if (String(sessionId) === context.session.id) {
|
||||||
|
throw new ORPCError("BAD_REQUEST", {
|
||||||
|
message: "Cannot revoke current session. Use logout instead.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await context.db
|
||||||
|
.updateTable("sessions")
|
||||||
|
.set({ revoked_at: new Date() })
|
||||||
|
.where("id", "=", String(sessionId))
|
||||||
|
.where("user_id", "=", context.user.id)
|
||||||
|
.where("revoked_at", "is", null)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
if (!result.numUpdatedRows || result.numUpdatedRows === 0n) {
|
||||||
|
throw new ORPCError("NOT_FOUND", { message: "Session not found" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke all sessions handler
|
||||||
|
* - Requires authentication
|
||||||
|
* - Revokes all sessions except current
|
||||||
|
*/
|
||||||
|
export const revokeAllSessions = os.me.revokeAllSessions
|
||||||
|
.use(authMiddleware)
|
||||||
|
.handler(async ({ context }) => {
|
||||||
|
// Revoke all sessions except current
|
||||||
|
await context.db
|
||||||
|
.updateTable("sessions")
|
||||||
|
.set({ revoked_at: new Date() })
|
||||||
|
.where("user_id", "=", context.user.id)
|
||||||
|
.where("id", "!=", context.session.id)
|
||||||
|
.where("revoked_at", "is", null)
|
||||||
|
.execute();
|
||||||
|
});
|
||||||
61
apps/api-server/src/procedures/me/set-password.ts
Normal file
61
apps/api-server/src/procedures/me/set-password.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* Set password procedure - sets or changes user password
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ORPCError } from "@orpc/server";
|
||||||
|
import {
|
||||||
|
hashPassword,
|
||||||
|
validatePassword,
|
||||||
|
verifyPassword,
|
||||||
|
} from "../../utils/password.js";
|
||||||
|
import { authMiddleware, os } from "../base.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set password handler
|
||||||
|
* - Requires authentication
|
||||||
|
* - If user has existing password, currentPassword is required
|
||||||
|
* - Validates new password strength using zxcvbn
|
||||||
|
*/
|
||||||
|
export const setPassword = os.me.setPassword
|
||||||
|
.use(authMiddleware)
|
||||||
|
.handler(async ({ input, context }) => {
|
||||||
|
const { currentPassword, newPassword } = input;
|
||||||
|
|
||||||
|
// Fetch current password hash
|
||||||
|
const user = await context.db
|
||||||
|
.selectFrom("users")
|
||||||
|
.select(["password_hash"])
|
||||||
|
.where("id", "=", context.user.id)
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
|
// If user has a password, verify current password
|
||||||
|
if (user.password_hash) {
|
||||||
|
if (!currentPassword) {
|
||||||
|
throw new ORPCError("BAD_REQUEST", {
|
||||||
|
message: "Current password required",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const valid = await verifyPassword(currentPassword, user.password_hash);
|
||||||
|
if (!valid) {
|
||||||
|
throw new ORPCError("BAD_REQUEST", {
|
||||||
|
message: "Current password is incorrect",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate new password strength
|
||||||
|
const validation = validatePassword(newPassword, [context.user.email]);
|
||||||
|
if (!validation.valid) {
|
||||||
|
throw new ORPCError("BAD_REQUEST", {
|
||||||
|
message: validation.feedback[0] ?? "Password is too weak",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash and update
|
||||||
|
const newHash = await hashPassword(newPassword);
|
||||||
|
await context.db
|
||||||
|
.updateTable("users")
|
||||||
|
.set({ password_hash: newHash, updated_at: new Date() })
|
||||||
|
.where("id", "=", context.user.id)
|
||||||
|
.execute();
|
||||||
|
});
|
||||||
39
apps/api-server/src/procedures/me/update-profile.ts
Normal file
39
apps/api-server/src/procedures/me/update-profile.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Update profile procedure - updates user profile fields
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ProfileUpdate } from "./helpers.js";
|
||||||
|
import { authMiddleware, os } from "../base.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update profile handler
|
||||||
|
* - Requires authentication
|
||||||
|
* - Allows partial updates to display_name, full_name, phone_number, avatar_url
|
||||||
|
* - Automatically sets updated_at timestamp
|
||||||
|
*/
|
||||||
|
export const updateProfile = os.me.updateProfile
|
||||||
|
.use(authMiddleware)
|
||||||
|
.handler(async ({ input, context }) => {
|
||||||
|
const updates: Partial<ProfileUpdate> = {};
|
||||||
|
if (input.displayName !== undefined) {
|
||||||
|
updates.display_name = input.displayName;
|
||||||
|
}
|
||||||
|
if (input.fullName !== undefined) {
|
||||||
|
updates.full_name = input.fullName || null;
|
||||||
|
}
|
||||||
|
if (input.phoneNumber !== undefined) {
|
||||||
|
updates.phone_number = input.phoneNumber || null;
|
||||||
|
}
|
||||||
|
if (input.avatarUrl !== undefined) {
|
||||||
|
updates.avatar_url = input.avatarUrl || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(updates).length > 0) {
|
||||||
|
updates.updated_at = new Date();
|
||||||
|
await context.db
|
||||||
|
.updateTable("users")
|
||||||
|
.set(updates)
|
||||||
|
.where("id", "=", context.user.id)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -14,11 +14,30 @@ import {
|
|||||||
loginRequestMiddleware,
|
loginRequestMiddleware,
|
||||||
os,
|
os,
|
||||||
} from "./procedures/base.js";
|
} from "./procedures/base.js";
|
||||||
|
import { meDelete } from "./procedures/me/delete.js";
|
||||||
|
import {
|
||||||
|
getDeviceInfo,
|
||||||
|
listTrustedDevices,
|
||||||
|
revokeAllTrustedDevices,
|
||||||
|
trustDevice,
|
||||||
|
untrustDevice,
|
||||||
|
} from "./procedures/me/devices.js";
|
||||||
|
import {
|
||||||
|
deletePasskey,
|
||||||
|
listPasskeys,
|
||||||
|
renamePasskey,
|
||||||
|
} from "./procedures/me/passkeys.js";
|
||||||
|
import {
|
||||||
|
listSessions,
|
||||||
|
revokeAllSessions,
|
||||||
|
revokeSession,
|
||||||
|
} from "./procedures/me/sessions.js";
|
||||||
|
import { setPassword } from "./procedures/me/set-password.js";
|
||||||
|
import { updateProfile } from "./procedures/me/update-profile.js";
|
||||||
import {
|
import {
|
||||||
createAuthenticationOptions as createAuthOptions,
|
createAuthenticationOptions as createAuthOptions,
|
||||||
createRegistrationOptions as createRegOptions,
|
createRegistrationOptions as createRegOptions,
|
||||||
getRPInfo,
|
getRPInfo,
|
||||||
getUserPasskeys,
|
|
||||||
verifyAuthentication as verifyAuth,
|
verifyAuthentication as verifyAuth,
|
||||||
verifyRegistration as verifyReg,
|
verifyRegistration as verifyReg,
|
||||||
} from "./utils/webauthn.js";
|
} from "./utils/webauthn.js";
|
||||||
@@ -108,7 +127,6 @@ const verifyAuthentication = os.auth.webauthn.verifyAuthentication
|
|||||||
const meGet = os.me.get.use(authMiddleware).handler(async ({ context }) => {
|
const meGet = os.me.get.use(authMiddleware).handler(async ({ context }) => {
|
||||||
const user = await context.db
|
const user = await context.db
|
||||||
.selectFrom("users")
|
.selectFrom("users")
|
||||||
.where("id", "=", context.user.id)
|
|
||||||
.select([
|
.select([
|
||||||
"id",
|
"id",
|
||||||
"email",
|
"email",
|
||||||
@@ -119,6 +137,7 @@ const meGet = os.me.get.use(authMiddleware).handler(async ({ context }) => {
|
|||||||
"email_verified_at",
|
"email_verified_at",
|
||||||
"is_superuser",
|
"is_superuser",
|
||||||
])
|
])
|
||||||
|
.where("id", "=", context.user.id)
|
||||||
.executeTakeFirstOrThrow();
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -128,8 +147,8 @@ const meGet = os.me.get.use(authMiddleware).handler(async ({ context }) => {
|
|||||||
fullName: user.full_name,
|
fullName: user.full_name,
|
||||||
phoneNumber: user.phone_number,
|
phoneNumber: user.phone_number,
|
||||||
avatarUrl: user.avatar_url,
|
avatarUrl: user.avatar_url,
|
||||||
emailVerified: !!user.email_verified_at,
|
emailVerified: user.email_verified_at !== null,
|
||||||
needsSetup: !user.display_name,
|
needsSetup: user.display_name === null,
|
||||||
isSuperuser: user.is_superuser,
|
isSuperuser: user.is_superuser,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -137,164 +156,25 @@ const meGet = os.me.get.use(authMiddleware).handler(async ({ context }) => {
|
|||||||
const setupProfile = os.me.setupProfile
|
const setupProfile = os.me.setupProfile
|
||||||
.use(authMiddleware)
|
.use(authMiddleware)
|
||||||
.handler(async ({ input, context }) => {
|
.handler(async ({ input, context }) => {
|
||||||
|
const { displayName, fullName, phoneNumber } = input;
|
||||||
|
|
||||||
await context.db
|
await context.db
|
||||||
.updateTable("users")
|
.updateTable("users")
|
||||||
.set({
|
.set({
|
||||||
display_name: input.displayName,
|
display_name: displayName,
|
||||||
full_name: input.fullName ?? null,
|
full_name: fullName ?? null,
|
||||||
phone_number: input.phoneNumber ?? null,
|
phone_number: phoneNumber ?? null,
|
||||||
|
updated_at: new Date(),
|
||||||
})
|
})
|
||||||
.where("id", "=", context.user.id)
|
.where("id", "=", context.user.id)
|
||||||
.execute();
|
.execute();
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateProfile = os.me.updateProfile
|
// Me procedures imported from ./procedures/me/*
|
||||||
.use(authMiddleware)
|
// - updateProfile, setPassword, meDelete
|
||||||
.handler(async () => {
|
// - listPasskeys, renamePasskey, deletePasskey
|
||||||
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
// - listSessions, revokeSession, revokeAllSessions
|
||||||
});
|
// - getDeviceInfo, trustDevice, listTrustedDevices, untrustDevice, revokeAllTrustedDevices
|
||||||
|
|
||||||
const meDelete = os.me.delete.use(authMiddleware).handler(async () => {
|
|
||||||
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
|
||||||
});
|
|
||||||
|
|
||||||
const setPassword = os.me.setPassword.use(authMiddleware).handler(async () => {
|
|
||||||
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
|
||||||
});
|
|
||||||
|
|
||||||
const passkeysList = os.me.passkeys.list
|
|
||||||
.use(authMiddleware)
|
|
||||||
.handler(async ({ context }) => {
|
|
||||||
const passkeys = await getUserPasskeys(context.db, context.user.id);
|
|
||||||
|
|
||||||
return passkeys.map((p) => ({
|
|
||||||
id: p.id,
|
|
||||||
name: p.name,
|
|
||||||
createdAt: p.createdAt,
|
|
||||||
lastUsedAt: p.lastUsedAt,
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
const passkeysRename = os.me.passkeys.rename
|
|
||||||
.use(authMiddleware)
|
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { passkeyId, name } = input;
|
|
||||||
|
|
||||||
await context.db
|
|
||||||
.updateTable("passkeys")
|
|
||||||
.set({ name })
|
|
||||||
.where("id", "=", String(passkeyId))
|
|
||||||
.where("user_id", "=", context.user.id)
|
|
||||||
.execute();
|
|
||||||
});
|
|
||||||
|
|
||||||
const passkeysDelete = os.me.passkeys.delete
|
|
||||||
.use(authMiddleware)
|
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
const { passkeyId } = input;
|
|
||||||
|
|
||||||
// Check if this is the last passkey and user has no password
|
|
||||||
const user = await context.db
|
|
||||||
.selectFrom("users")
|
|
||||||
.select(["password_hash"])
|
|
||||||
.where("id", "=", context.user.id)
|
|
||||||
.executeTakeFirst();
|
|
||||||
|
|
||||||
const passkeyCount = await context.db
|
|
||||||
.selectFrom("passkeys")
|
|
||||||
.select(context.db.fn.countAll().as("count"))
|
|
||||||
.where("user_id", "=", context.user.id)
|
|
||||||
.executeTakeFirst();
|
|
||||||
|
|
||||||
if (!user?.password_hash && Number(passkeyCount?.count ?? 0) <= 1) {
|
|
||||||
throw new ORPCError("BAD_REQUEST", {
|
|
||||||
message: "Cannot delete the last passkey when you have no password set",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await context.db
|
|
||||||
.deleteFrom("passkeys")
|
|
||||||
.where("id", "=", String(passkeyId))
|
|
||||||
.where("user_id", "=", context.user.id)
|
|
||||||
.execute();
|
|
||||||
});
|
|
||||||
|
|
||||||
const listSessions = os.me.listSessions
|
|
||||||
.use(authMiddleware)
|
|
||||||
.handler(async () => {
|
|
||||||
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
|
||||||
});
|
|
||||||
|
|
||||||
const revokeSession = os.me.revokeSession
|
|
||||||
.use(authMiddleware)
|
|
||||||
.handler(async () => {
|
|
||||||
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
|
||||||
});
|
|
||||||
|
|
||||||
const revokeAllSessions = os.me.revokeAllSessions
|
|
||||||
.use(authMiddleware)
|
|
||||||
.handler(async () => {
|
|
||||||
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
|
||||||
});
|
|
||||||
|
|
||||||
const getDeviceInfo = os.me.getDeviceInfo
|
|
||||||
.use(authMiddleware)
|
|
||||||
.handler(async ({ context }) => {
|
|
||||||
const session = await context.db
|
|
||||||
.selectFrom("sessions")
|
|
||||||
.where("id", "=", context.session.id)
|
|
||||||
.select([
|
|
||||||
"ip_address",
|
|
||||||
"city",
|
|
||||||
"region",
|
|
||||||
"country",
|
|
||||||
"user_agent",
|
|
||||||
])
|
|
||||||
.executeTakeFirstOrThrow();
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: 0,
|
|
||||||
name: "Unknown Device",
|
|
||||||
ip: session.ip_address ?? "Unknown",
|
|
||||||
city: session.city,
|
|
||||||
region: session.region,
|
|
||||||
country: session.country,
|
|
||||||
lastUsedAt: new Date(),
|
|
||||||
isTrusted: context.session.trustedMode,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const trustDevice = os.me.trustDevice
|
|
||||||
.use(authMiddleware)
|
|
||||||
.handler(async ({ input, context }) => {
|
|
||||||
// Note: Sessions table doesn't have a device_name field
|
|
||||||
// The name parameter is accepted by the contract but not stored
|
|
||||||
await context.db
|
|
||||||
.updateTable("sessions")
|
|
||||||
.set({
|
|
||||||
trusted_mode: true,
|
|
||||||
})
|
|
||||||
.where("id", "=", context.session.id)
|
|
||||||
.execute();
|
|
||||||
});
|
|
||||||
|
|
||||||
const listTrustedDevices = os.me.listTrustedDevices
|
|
||||||
.use(authMiddleware)
|
|
||||||
.handler(async () => {
|
|
||||||
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
|
||||||
});
|
|
||||||
|
|
||||||
const untrustDevice = os.me.untrustDevice
|
|
||||||
.use(authMiddleware)
|
|
||||||
.handler(async () => {
|
|
||||||
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
|
||||||
});
|
|
||||||
|
|
||||||
const revokeAllTrustedDevices = os.me.revokeAllTrustedDevices
|
|
||||||
.use(authMiddleware)
|
|
||||||
.handler(async () => {
|
|
||||||
throw new ORPCError("NOT_IMPLEMENTED", { message: "Not implemented" });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Orgs procedures (all require auth)
|
// Orgs procedures (all require auth)
|
||||||
const orgsList = os.orgs.list.use(authMiddleware).handler(async () => {
|
const orgsList = os.orgs.list.use(authMiddleware).handler(async () => {
|
||||||
@@ -482,9 +362,9 @@ export const router = os.router({
|
|||||||
delete: meDelete,
|
delete: meDelete,
|
||||||
setPassword,
|
setPassword,
|
||||||
passkeys: {
|
passkeys: {
|
||||||
list: passkeysList,
|
list: listPasskeys,
|
||||||
rename: passkeysRename,
|
rename: renamePasskey,
|
||||||
delete: passkeysDelete,
|
delete: deletePasskey,
|
||||||
},
|
},
|
||||||
listSessions,
|
listSessions,
|
||||||
revokeSession,
|
revokeSession,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
import type { Database } from "@reviq/db-schema";
|
import type { Database } from "@reviq/db-schema";
|
||||||
import type { Kysely } from "kysely";
|
import type { Kysely } from "kysely";
|
||||||
import { sha256 } from "@noble/hashes/sha2.js";
|
import { hashToken } from "./crypto.js";
|
||||||
|
|
||||||
export interface AuthenticatedUser {
|
export interface AuthenticatedUser {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -12,13 +12,6 @@ export interface AuthenticatedUser {
|
|||||||
isSuperuser: boolean;
|
isSuperuser: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Hash a token using SHA-256
|
|
||||||
*/
|
|
||||||
export const hashToken = (token: string): string => {
|
|
||||||
return Buffer.from(sha256(Buffer.from(token))).toString("hex");
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authenticate a request using session token or API key
|
* Authenticate a request using session token or API key
|
||||||
* Returns the authenticated user or null if not authenticated
|
* Returns the authenticated user or null if not authenticated
|
||||||
@@ -34,7 +27,7 @@ export const authenticateRequest = async (
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokenHash = hashToken(token);
|
const tokenHash = await hashToken(token);
|
||||||
|
|
||||||
// Check sessions table
|
// Check sessions table
|
||||||
const session = await db
|
const session = await db
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import { createHash, randomBytes } from "node:crypto";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hash a token with SHA-256 for storage in database
|
* Hash a token with SHA-256 for storage in database
|
||||||
* Never store raw tokens - always hash first
|
* Never store raw tokens - always hash first
|
||||||
|
* Uses Web Crypto API for Cloudflare Workers compatibility
|
||||||
*/
|
*/
|
||||||
export const hashToken = (token: string): string => {
|
export const hashToken = async (token: string): Promise<string> => {
|
||||||
return createHash("sha256").update(token).digest("hex");
|
const encoder = new TextEncoder();
|
||||||
|
const data = encoder.encode(token);
|
||||||
|
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
||||||
|
const hashArray = new Uint8Array(hashBuffer);
|
||||||
|
return Array.from(hashArray)
|
||||||
|
.map((b) => b.toString(16).padStart(2, "0"))
|
||||||
|
.join("");
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,9 +30,14 @@ export const generateDeviceFingerprint = (): string => {
|
|||||||
/**
|
/**
|
||||||
* Generate a secure random token for email verification, password reset, etc.
|
* Generate a secure random token for email verification, password reset, etc.
|
||||||
* Uses 32 bytes (256 bits) of entropy
|
* Uses 32 bytes (256 bits) of entropy
|
||||||
|
* Uses Web Crypto API for Cloudflare Workers compatibility
|
||||||
*/
|
*/
|
||||||
export const generateSecureToken = (): string => {
|
export const generateSecureToken = (): string => {
|
||||||
return randomBytes(32).toString("hex");
|
const bytes = new Uint8Array(32);
|
||||||
|
crypto.getRandomValues(bytes);
|
||||||
|
return Array.from(bytes)
|
||||||
|
.map((b) => b.toString(16).padStart(2, "0"))
|
||||||
|
.join("");
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { OrgRole } from "@reviq/db-schema";
|
import type { OrgRole } from "@reviq/db-schema";
|
||||||
|
import { DurationFormat } from "@formatjs/intl-durationformat";
|
||||||
import { ServerClient } from "postmark";
|
import { ServerClient } from "postmark";
|
||||||
import {
|
import {
|
||||||
BASE_URL,
|
BASE_URL,
|
||||||
@@ -113,37 +114,24 @@ const sendEmail = async (params: SendEmailParams): Promise<EmailResult> => {
|
|||||||
|
|
||||||
// ===== Template Helpers =====
|
// ===== Template Helpers =====
|
||||||
|
|
||||||
const formatExpiryHours = (hours: number): string => {
|
const durationFormatter = new DurationFormat("en", { style: "long" });
|
||||||
if (hours === 1) {
|
|
||||||
return "1 hour";
|
const formatExpiryHours = (hours: number): string =>
|
||||||
}
|
durationFormatter.format({ hours });
|
||||||
return `${hours} hours`;
|
|
||||||
|
const formatExpiryMinutes = (minutes: number): string =>
|
||||||
|
durationFormatter.format({ minutes });
|
||||||
|
|
||||||
|
const formatExpiryDays = (days: number): string =>
|
||||||
|
durationFormatter.format({ days });
|
||||||
|
|
||||||
|
const roleLabels: Record<OrgRole, string> = {
|
||||||
|
owner: "Owner",
|
||||||
|
admin: "Admin",
|
||||||
|
member: "Member",
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatExpiryMinutes = (minutes: number): string => {
|
const formatRoleDisplay = (role: OrgRole): string => roleLabels[role];
|
||||||
if (minutes === 1) {
|
|
||||||
return "1 minute";
|
|
||||||
}
|
|
||||||
return `${minutes} minutes`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatExpiryDays = (days: number): string => {
|
|
||||||
if (days === 1) {
|
|
||||||
return "1 day";
|
|
||||||
}
|
|
||||||
return `${days} days`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatRoleDisplay = (role: OrgRole): string => {
|
|
||||||
switch (role) {
|
|
||||||
case "owner":
|
|
||||||
return "Owner";
|
|
||||||
case "admin":
|
|
||||||
return "Admin";
|
|
||||||
case "member":
|
|
||||||
return "Member";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the correct article (a/an) for a role
|
* Get the correct article (a/an) for a role
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export async function createSession(
|
|||||||
options: CreateSessionOptions,
|
options: CreateSessionOptions,
|
||||||
): Promise<SessionResult> {
|
): Promise<SessionResult> {
|
||||||
const token = generateSessionToken();
|
const token = generateSessionToken();
|
||||||
const tokenHash = hashToken(token);
|
const tokenHash = await hashToken(token);
|
||||||
const expiresAt = generateExpiry(COOKIE_DURATIONS.SESSION);
|
const expiresAt = generateExpiry(COOKIE_DURATIONS.SESSION);
|
||||||
|
|
||||||
const result = await db
|
const result = await db
|
||||||
|
|||||||
11
bun.lock
11
bun.lock
@@ -15,6 +15,7 @@
|
|||||||
"name": "api-server",
|
"name": "api-server",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@formatjs/intl-durationformat": "^0.9.2",
|
||||||
"@noble/hashes": "^2.0.1",
|
"@noble/hashes": "^2.0.1",
|
||||||
"@orpc/server": "^1.13.2",
|
"@orpc/server": "^1.13.2",
|
||||||
"@reviq/api-contract": "workspace:*",
|
"@reviq/api-contract": "workspace:*",
|
||||||
@@ -255,6 +256,14 @@
|
|||||||
|
|
||||||
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
|
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
|
||||||
|
|
||||||
|
"@formatjs/ecma402-abstract": ["@formatjs/ecma402-abstract@3.0.8", "", { "dependencies": { "@formatjs/fast-memoize": "3.0.3", "@formatjs/intl-localematcher": "0.7.5", "decimal.js": "^10.4.3", "tslib": "^2.8.0" } }, "sha512-NRiqvxAvhbARZRFSRFPjN0y8txxmVutv2vMYvW2HSdCVf58w9l4osLj6Ujif643vImwZBcbKqhiKE0IOhY+DvA=="],
|
||||||
|
|
||||||
|
"@formatjs/fast-memoize": ["@formatjs/fast-memoize@3.0.3", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-CArYtQKGLAOruCMeq5/RxCg6vUXFx3OuKBdTm30Wn/+gCefehmZ8Y2xSMxMrO2iel7hRyE3HKfV56t3vAU6D4Q=="],
|
||||||
|
|
||||||
|
"@formatjs/intl-durationformat": ["@formatjs/intl-durationformat@0.9.2", "", { "dependencies": { "@formatjs/ecma402-abstract": "3.0.8", "@formatjs/intl-localematcher": "0.7.5", "tslib": "^2.8.0" } }, "sha512-/QOJeY96qGj1j9saz32VANfgDYhChbbTRyjWLzjf7dc4OHIEWqGBIO4rQzUKDBVzqtRLJQMh4QKp37Uxkk0d8g=="],
|
||||||
|
|
||||||
|
"@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.7.5", "", { "dependencies": { "@formatjs/fast-memoize": "3.0.3", "tslib": "^2.8.0" } }, "sha512-7/nd90cn5CT7SVF71/ybUKAcnvBlr9nZlJJp8O8xIZHXFgYOC4SXExZlSdgHv2l6utjw1byidL06QzChvQMHwA=="],
|
||||||
|
|
||||||
"@hexagon/base64": ["@hexagon/base64@1.1.28", "", {}, "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="],
|
"@hexagon/base64": ["@hexagon/base64@1.1.28", "", {}, "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="],
|
||||||
|
|
||||||
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
||||||
@@ -549,6 +558,8 @@
|
|||||||
|
|
||||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||||
|
|
||||||
|
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
|
||||||
|
|
||||||
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
||||||
|
|
||||||
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
|
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
|
||||||
|
|||||||
@@ -2197,23 +2197,23 @@ All Phase 1 tasks can run in parallel.
|
|||||||
|
|
||||||
#### Workstream A: Database & Schema
|
#### Workstream A: Database & Schema
|
||||||
|
|
||||||
- [ ] **A1**: Create dbmate migration `001_initial_schema.sql` with all tables, enums, indexes
|
- [x] **A1**: Create dbmate migration `001_initial_schema.sql` with all tables, enums, indexes
|
||||||
- [ ] **A2**: Set up `@publisher-dashboard/db-schema` package with kysely-codegen
|
- [x] **A2**: Set up `@publisher-dashboard/db-schema` package with kysely-codegen
|
||||||
- [ ] **A3**: Set up `@publisher-dashboard/db` package with Kysely client
|
- [x] **A3**: Set up `@publisher-dashboard/db` package with Kysely client
|
||||||
|
|
||||||
#### Workstream B: API Contract
|
#### Workstream B: API Contract
|
||||||
|
|
||||||
- [ ] **B1**: Create `@publisher-dashboard/api-contract` package structure
|
- [x] **B1**: Create `@publisher-dashboard/api-contract` package structure
|
||||||
- [ ] **B2**: Define Zod schemas for all input/output types (auth, user, org, admin)
|
- [x] **B2**: Define Zod schemas for all input/output types (auth, user, org, admin)
|
||||||
- [ ] **B3**: Define oRPC contract with all procedure signatures
|
- [x] **B3**: Define oRPC contract with all procedure signatures
|
||||||
|
|
||||||
#### Workstream C: Project Infrastructure
|
#### Workstream C: Project Infrastructure
|
||||||
|
|
||||||
- [ ] **C1**: Initialize monorepo with workspace config (`package.json`, `bun.lockb`)
|
- [x] **C1**: Initialize monorepo with workspace config (`package.json`, `bun.lockb`)
|
||||||
- [ ] **C2**: Set up `apps/api-server` with Bun.serve entry point
|
- [x] **C2**: Set up `apps/api-server` with Bun.serve entry point
|
||||||
- [ ] **C3**: Set up `apps/publisher-dashboard` SvelteKit project with TanStack Query
|
- [x] **C3**: Set up `apps/publisher-dashboard` SvelteKit project with TanStack Query
|
||||||
- [ ] **C4**: Set up `apps/cli` with stricli framework
|
- [x] **C4**: Set up `apps/cli` with stricli framework
|
||||||
- [ ] **C5**: Create `devenv.nix` with scripts and environment variables
|
- [x] **C5**: Create `devenv.nix` with scripts and environment variables
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -2223,37 +2223,37 @@ All Phase 1 tasks can run in parallel.
|
|||||||
|
|
||||||
_Depends on: A3, B3, C2_
|
_Depends on: A3, B3, C2_
|
||||||
|
|
||||||
- [ ] **D1**: Implement auth middleware (session cookie + API key header)
|
- [x] **D1**: Implement auth middleware (session cookie + API key header)
|
||||||
- [ ] **D2**: Implement `auth.signup` (email + password or passkey)
|
- [x] **D2**: Implement `auth.signup` (email + password or passkey)
|
||||||
- [ ] **D3**: Implement `auth.createLoginRequest` with device/geo capture
|
- [x] **D3**: Implement `auth.createLoginRequest` with device/geo capture
|
||||||
- [ ] **D4**: Implement `auth.loginPassword` (trusted vs untrusted device flow)
|
- [x] **D4**: Implement `auth.loginPassword` (trusted vs untrusted device flow)
|
||||||
- [ ] **D5**: Implement `auth.loginPasswordConfirm` (email link handler)
|
- [x] **D5**: Implement `auth.loginPasswordConfirm` (email link handler)
|
||||||
- [ ] **D6**: Implement `auth.loginIfRequestIsCompleted` (polling + session creation)
|
- [x] **D6**: Implement `auth.loginIfRequestIsCompleted` (polling + session creation)
|
||||||
- [ ] **D7**: Implement `auth.logout`
|
- [x] **D7**: Implement `auth.logout`
|
||||||
- [ ] **D8**: Implement `auth.verifyEmail` and `auth.resendVerificationEmail`
|
- [x] **D8**: Implement `auth.verifyEmail` and `auth.resendVerificationEmail`
|
||||||
- [ ] **D9**: Implement `auth.forgotPassword` and `auth.resetPassword`
|
- [x] **D9**: Implement `auth.forgotPassword` and `auth.resetPassword`
|
||||||
|
|
||||||
#### Workstream E: WebAuthn Procedures (Backend)
|
#### Workstream E: WebAuthn Procedures (Backend)
|
||||||
|
|
||||||
_Depends on: A3, B3, C2_
|
_Depends on: A3, B3, C2_
|
||||||
_Can run parallel to D_
|
_Can run parallel to D_
|
||||||
|
|
||||||
- [ ] **E1**: Implement `auth.webauthn.createRegistrationOptions`
|
- [x] **E1**: Implement `auth.webauthn.createRegistrationOptions`
|
||||||
- [ ] **E2**: Implement `auth.webauthn.verifyRegistration`
|
- [x] **E2**: Implement `auth.webauthn.verifyRegistration`
|
||||||
- [ ] **E3**: Implement `auth.webauthn.createAuthenticationOptions`
|
- [x] **E3**: Implement `auth.webauthn.createAuthenticationOptions`
|
||||||
- [ ] **E4**: Implement `auth.webauthn.verifyAuthentication`
|
- [x] **E4**: Implement `auth.webauthn.verifyAuthentication`
|
||||||
|
|
||||||
#### Workstream F: User Procedures (Backend)
|
#### Workstream F: User Procedures (Backend)
|
||||||
|
|
||||||
_Depends on: D1 (auth middleware)_
|
_Depends on: D1 (auth middleware)_
|
||||||
|
|
||||||
- [ ] **F1**: Implement `me.get` and `me.setupProfile`
|
- [x] **F1**: Implement `me.get` and `me.setupProfile`
|
||||||
- [ ] **F2**: Implement `me.updateProfile`
|
- [x] **F2**: Implement `me.updateProfile`
|
||||||
- [ ] **F3**: Implement `me.setPassword`
|
- [x] **F3**: Implement `me.setPassword`
|
||||||
- [ ] **F4**: Implement `me.listPasskeys`, `me.createPasskey`, `me.renamePasskey`, `me.deletePasskey`
|
- [x] **F4**: Implement `me.listPasskeys`, `me.createPasskey`, `me.renamePasskey`, `me.deletePasskey`
|
||||||
- [ ] **F5**: Implement `me.listSessions`, `me.revokeSession`, `me.revokeAllSessions`
|
- [x] **F5**: Implement `me.listSessions`, `me.revokeSession`, `me.revokeAllSessions`
|
||||||
- [ ] **F6**: Implement `me.getDeviceInfo`, `me.trustDevice`, `me.listTrustedDevices`, `me.untrustDevice`, `me.revokeAllTrustedDevices`
|
- [x] **F6**: Implement `me.getDeviceInfo`, `me.trustDevice`, `me.listTrustedDevices`, `me.untrustDevice`, `me.revokeAllTrustedDevices`
|
||||||
- [ ] **F7**: Implement `me.delete` (account deletion)
|
- [x] **F7**: Implement `me.delete` (account deletion)
|
||||||
|
|
||||||
#### Workstream G: Email Service (Backend)
|
#### Workstream G: Email Service (Backend)
|
||||||
|
|
||||||
@@ -2316,11 +2316,11 @@ _Depends on: D1 (auth middleware)_
|
|||||||
_Depends on: D1 (auth middleware), J1_
|
_Depends on: D1 (auth middleware), J1_
|
||||||
_Can run parallel to J2-J6_
|
_Can run parallel to J2-J6_
|
||||||
|
|
||||||
- [ ] **K1**: Implement superuser middleware
|
- [x] **K1**: Implement superuser middleware
|
||||||
- [ ] **K2**: Implement `admin.orgs.*` procedures
|
- [x] **K2**: Implement `admin.orgs.*` procedures
|
||||||
- [ ] **K3**: Implement `admin.users.*` procedures
|
- [x] **K3**: Implement `admin.users.*` procedures
|
||||||
- [ ] **K4**: Implement `admin.orgs.addSite`, `admin.orgs.removeSite`
|
- [x] **K4**: Implement `admin.orgs.addSite`, `admin.orgs.removeSite`
|
||||||
- [ ] **K5**: Implement `admin.auth.completeLogin` (dev helper)
|
- [x] **K5**: Implement `admin.auth.completeLogin` (dev helper)
|
||||||
|
|
||||||
#### Workstream L: Org Pages (Frontend)
|
#### Workstream L: Org Pages (Frontend)
|
||||||
|
|
||||||
@@ -2349,47 +2349,47 @@ _Can run parallel to L_
|
|||||||
|
|
||||||
_Depends on: C4_
|
_Depends on: C4_
|
||||||
|
|
||||||
- [ ] **N1**: Set up stricli CLI structure with command routing
|
- [x] **N1**: Set up stricli CLI structure with command routing
|
||||||
- [ ] **N2**: Implement config file handling (`~/.config/reviq/credentials.json`)
|
- [x] **N2**: Implement config file handling (`~/.config/reviq/credentials.json`)
|
||||||
- [ ] **N3**: Implement API client wrapper for CLI (reads token from config)
|
- [x] **N3**: Implement API client wrapper for CLI (reads token from config)
|
||||||
|
|
||||||
#### Workstream N-Bootstrap: CLI Bootstrap (Direct DB)
|
#### Workstream N-Bootstrap: CLI Bootstrap (Direct DB)
|
||||||
|
|
||||||
_Depends on: A3, N1, N2_
|
_Depends on: A3, N1, N2_
|
||||||
|
|
||||||
- [ ] **N4**: Implement `reviq bootstrap` - create superuser with password
|
- [x] **N4**: Implement `reviq bootstrap` - create superuser with password
|
||||||
- [ ] **N5**: Implement `reviq bootstrap` - create "reviq" org with user as owner
|
- [x] **N5**: Implement `reviq bootstrap` - create "reviq" org with user as owner
|
||||||
- [ ] **N6**: Implement `reviq bootstrap` - generate API token and save to config
|
- [x] **N6**: Implement `reviq bootstrap` - generate API token and save to config
|
||||||
|
|
||||||
#### Workstream N-Auth: CLI Auth Commands
|
#### Workstream N-Auth: CLI Auth Commands
|
||||||
|
|
||||||
_Depends on: N1, N2, N3, D1-D9_
|
_Depends on: N1, N2, N3, D1-D9_
|
||||||
|
|
||||||
- [ ] **N7**: Implement `reviq auth login` (open browser, poll for token, save to config)
|
- [x] **N7**: Implement `reviq auth login` (open browser, poll for token, save to config)
|
||||||
- [ ] **N8**: Implement `reviq auth logout` (revoke token, delete from config)
|
- [x] **N8**: Implement `reviq auth logout` (revoke token, delete from config)
|
||||||
- [ ] **N9**: Implement `reviq auth status` (show current user, API URL, config path)
|
- [x] **N9**: Implement `reviq auth status` (show current user, API URL, config path)
|
||||||
|
|
||||||
#### Workstream N-User: CLI User Commands
|
#### Workstream N-User: CLI User Commands
|
||||||
|
|
||||||
_Depends on: N3, K3_
|
_Depends on: N3, K3_
|
||||||
|
|
||||||
- [ ] **N10**: Implement `reviq user create --email --name [--org --role]`
|
- [x] **N10**: Implement `reviq user create --email --name [--org --role]`
|
||||||
- [ ] **N11**: Implement `reviq user confirm-email --email` (dev helper)
|
- [x] **N11**: Implement `reviq user confirm-email --email` (dev helper)
|
||||||
|
|
||||||
#### Workstream N-Admin: CLI Admin Commands
|
#### Workstream N-Admin: CLI Admin Commands
|
||||||
|
|
||||||
_Depends on: N3, K5_
|
_Depends on: N3, K5_
|
||||||
|
|
||||||
- [ ] **N12**: Implement `reviq admin complete-login --email` (dev helper)
|
- [x] **N12**: Implement `reviq admin complete-login --email` (dev helper)
|
||||||
|
|
||||||
#### Workstream N-Org: CLI Org Commands
|
#### Workstream N-Org: CLI Org Commands
|
||||||
|
|
||||||
_Depends on: N3, K2, K4_
|
_Depends on: N3, K2, K4_
|
||||||
|
|
||||||
- [ ] **N13**: Implement `reviq org create --owner --name`
|
- [x] **N13**: Implement `reviq org create --owner --name`
|
||||||
- [ ] **N14**: Implement `reviq org list`
|
- [x] **N14**: Implement `reviq org list`
|
||||||
- [ ] **N15**: Implement `reviq org add-site --org --domain`
|
- [x] **N15**: Implement `reviq org add-site --org --domain`
|
||||||
- [ ] **N16**: Implement `reviq org remove-site --org --domain`
|
- [x] **N16**: Implement `reviq org remove-site --org --domain`
|
||||||
|
|
||||||
#### Workstream N-Completions: CLI Shell Completions
|
#### Workstream N-Completions: CLI Shell Completions
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import { configs } from "@macalinao/eslint-config";
|
|||||||
|
|
||||||
export default [
|
export default [
|
||||||
...configs.fast,
|
...configs.fast,
|
||||||
|
{
|
||||||
|
ignores: ["**/*.test.ts"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
|
"test": "bun test",
|
||||||
"clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache",
|
"clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache",
|
||||||
"lint": "eslint . --cache"
|
"lint": "eslint . --cache"
|
||||||
},
|
},
|
||||||
|
|||||||
81
packages/api-contract/src/schemas/common.test.ts
Normal file
81
packages/api-contract/src/schemas/common.test.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { nonEmptyString, optionalString } from "./common.js";
|
||||||
|
|
||||||
|
describe("nonEmptyString", () => {
|
||||||
|
const schema = nonEmptyString(100);
|
||||||
|
|
||||||
|
test("accepts valid non-empty string", () => {
|
||||||
|
expect(schema.parse("hello")).toBe("hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("trims whitespace", () => {
|
||||||
|
expect(schema.parse(" hello ")).toBe("hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects empty string", () => {
|
||||||
|
expect(() => schema.parse("")).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects whitespace-only string", () => {
|
||||||
|
expect(() => schema.parse(" ")).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects string exceeding max length", () => {
|
||||||
|
const shortSchema = nonEmptyString(5);
|
||||||
|
expect(() => shortSchema.parse("123456")).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts string at max length", () => {
|
||||||
|
const shortSchema = nonEmptyString(5);
|
||||||
|
expect(shortSchema.parse("12345")).toBe("12345");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("works without max length", () => {
|
||||||
|
const noMaxSchema = nonEmptyString();
|
||||||
|
expect(noMaxSchema.parse("a".repeat(1000))).toBe("a".repeat(1000));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("optionalString", () => {
|
||||||
|
const schema = optionalString(200);
|
||||||
|
|
||||||
|
test("accepts valid non-empty string", () => {
|
||||||
|
expect(schema.parse("hello")).toBe("hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("trims whitespace", () => {
|
||||||
|
expect(schema.parse(" hello ")).toBe("hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("transforms empty string to undefined", () => {
|
||||||
|
expect(schema.parse("")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("transforms whitespace-only string to undefined", () => {
|
||||||
|
expect(schema.parse(" ")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts undefined input", () => {
|
||||||
|
expect(schema.parse(undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects string exceeding max length", () => {
|
||||||
|
const shortSchema = optionalString(5);
|
||||||
|
expect(() => shortSchema.parse("123456")).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts string at max length", () => {
|
||||||
|
const shortSchema = optionalString(5);
|
||||||
|
expect(shortSchema.parse("12345")).toBe("12345");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("works without max length", () => {
|
||||||
|
const noMaxSchema = optionalString();
|
||||||
|
expect(noMaxSchema.parse("a".repeat(1000))).toBe("a".repeat(1000));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("transforms empty to undefined without max length", () => {
|
||||||
|
const noMaxSchema = optionalString();
|
||||||
|
expect(noMaxSchema.parse("")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,25 @@ import {
|
|||||||
} from "libphonenumber-js";
|
} from "libphonenumber-js";
|
||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Non-empty string schema - trims whitespace and ensures at least 1 char
|
||||||
|
* Use for required text fields that shouldn't be blank
|
||||||
|
*/
|
||||||
|
export const nonEmptyString = (maxLength?: number) => {
|
||||||
|
const base = z.string().trim().min(1);
|
||||||
|
return maxLength ? base.max(maxLength) : base;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional non-empty string - trims and converts empty/whitespace to undefined
|
||||||
|
* Use for optional text fields where blank should be treated as not provided
|
||||||
|
*/
|
||||||
|
export const optionalString = (maxLength?: number) => {
|
||||||
|
const base = z.string().trim();
|
||||||
|
const withMax = maxLength ? base.max(maxLength) : base;
|
||||||
|
return withMax.optional().transform((v) => (v === "" ? undefined : v));
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Email schema - validates email format and transforms to lowercase
|
* Email schema - validates email format and transforms to lowercase
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
import { phoneSchema } from "./common.js";
|
import { nonEmptyString, optionalString, phoneSchema } from "./common.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User profile schema
|
* User profile schema
|
||||||
@@ -22,8 +22,8 @@ export const userProfileSchema = z.object({
|
|||||||
* Used after signup to collect profile information
|
* Used after signup to collect profile information
|
||||||
*/
|
*/
|
||||||
export const setupProfileInputSchema = z.object({
|
export const setupProfileInputSchema = z.object({
|
||||||
displayName: z.string().min(1).max(100),
|
displayName: nonEmptyString(100),
|
||||||
fullName: z.string().max(200).optional(),
|
fullName: optionalString(200),
|
||||||
phoneNumber: phoneSchema,
|
phoneNumber: phoneSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -32,10 +32,10 @@ export const setupProfileInputSchema = z.object({
|
|||||||
* All fields optional for partial updates
|
* All fields optional for partial updates
|
||||||
*/
|
*/
|
||||||
export const updateProfileInputSchema = z.object({
|
export const updateProfileInputSchema = z.object({
|
||||||
displayName: z.string().min(1).max(100).optional(),
|
displayName: nonEmptyString(100).optional(),
|
||||||
fullName: z.string().max(200).optional(),
|
fullName: optionalString(200),
|
||||||
phoneNumber: phoneSchema,
|
phoneNumber: phoneSchema,
|
||||||
avatarUrl: z.string().optional(),
|
avatarUrl: optionalString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -95,5 +95,5 @@ export const deviceOutputSchema = z.object({
|
|||||||
* Used to name and trust the current device
|
* Used to name and trust the current device
|
||||||
*/
|
*/
|
||||||
export const trustDeviceInputSchema = z.object({
|
export const trustDeviceInputSchema = z.object({
|
||||||
name: z.string().min(1).max(100),
|
name: nonEmptyString(100),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,5 +2,6 @@
|
|||||||
"extends": "@macalinao/tsconfig/tsconfig.base.json",
|
"extends": "@macalinao/tsconfig/tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"isolatedDeclarations": false
|
"isolatedDeclarations": false
|
||||||
}
|
},
|
||||||
|
"exclude": ["**/*.test.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user