diff --git a/.gitignore b/.gitignore index 031c249..5437c4e 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,9 @@ devenv.local.nix # TypeScript *.tsbuildinfo +# Test coverage +coverage/ + # Debug npm-debug.log* yarn-debug.log* diff --git a/apps/api-server/bunfig.toml b/apps/api-server/bunfig.toml new file mode 100644 index 0000000..0dace52 --- /dev/null +++ b/apps/api-server/bunfig.toml @@ -0,0 +1,9 @@ +[test] +# Coverage reporters: text for console, lcov for CI/tooling integration +coverageReporter = ["text", "lcov"] + +# Output directory for lcov.info file +coverageDir = "coverage" + +# Don't count test files in coverage metrics +coverageSkipTestFiles = true diff --git a/apps/api-server/eslint.config.js b/apps/api-server/eslint.config.js index d452929..a5ef326 100644 --- a/apps/api-server/eslint.config.js +++ b/apps/api-server/eslint.config.js @@ -13,4 +13,12 @@ export default [ "@typescript-eslint/require-await": "off", }, }, + { + // Disable certain rules for test files that have issues with expect().rejects + files: ["**/__tests__/**/*.ts"], + rules: { + "@typescript-eslint/await-thenable": "off", + "@typescript-eslint/no-confusing-void-expression": "off", + }, + }, ]; diff --git a/apps/api-server/package.json b/apps/api-server/package.json index 4143808..90fae99 100644 --- a/apps/api-server/package.json +++ b/apps/api-server/package.json @@ -9,7 +9,8 @@ "typecheck": "tsc --noEmit", "lint": "eslint . --cache", "clean": "rm -rf dist .eslintcache", - "test:e2e": "bun test src/__tests__/e2e --no-parallel", + "test:e2e": "bun test src/__tests__/e2e --no-parallel --coverage", + "test:unit": "bun test src/__tests__/unit", "test": "bun test --coverage src/utils" }, "dependencies": { diff --git a/apps/api-server/src/__tests__/e2e/me.test.ts b/apps/api-server/src/__tests__/e2e/me.test.ts index 9257cea..c65f102 100644 --- a/apps/api-server/src/__tests__/e2e/me.test.ts +++ b/apps/api-server/src/__tests__/e2e/me.test.ts @@ -8,6 +8,14 @@ * - me.updateProfile - update profile fields * - me.setPassword - set/change password * - me.delete - delete account + * - me.sessions.list - list all sessions + * - me.sessions.revoke - revoke a session + * - me.sessions.revokeAll - revoke all sessions except current + * - me.devices.getInfo - get current device info + * - me.devices.trust - trust current device + * - me.devices.listTrusted - list trusted devices + * - me.devices.untrust - untrust a device + * - me.devices.revokeAll - revoke all trusted devices */ import type { Database } from "@reviq/db-schema"; @@ -59,14 +67,22 @@ function getDb(): Kysely { function createAPIContext(options?: { sessionToken?: string; apiKey?: string; + deviceFingerprint?: string; }): APIContext { const reqHeaders = new Headers(); + const cookies: string[] = []; + if (options?.sessionToken) { - reqHeaders.set( - "cookie", - `${COOKIE_NAMES.SESSION_TOKEN}=${options.sessionToken}`, + cookies.push(`${COOKIE_NAMES.SESSION_TOKEN}=${options.sessionToken}`); + } + if (options?.deviceFingerprint) { + cookies.push( + `${COOKIE_NAMES.DEVICE_FINGERPRINT}=${options.deviceFingerprint}`, ); } + if (cookies.length > 0) { + reqHeaders.set("cookie", cookies.join("; ")); + } if (options?.apiKey) { reqHeaders.set("x-api-key", options.apiKey); } @@ -82,26 +98,81 @@ 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 and session ID */ -async function createSession(userId: number): Promise { +async function createSession( + userId: number, + options?: { ipAddress?: string; userAgent?: string }, +): Promise<{ token: string; sessionId: number }> { const token = `test-session-${String(Date.now())}${String(Math.random())}`; const tokenHashValue = await hashToken(token); const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS); - await getDb() + const result = await getDb() .insertInto("sessions") .values({ user_id: userId, token_hash: tokenHashValue, - ip_address: "127.0.0.1", - user_agent: "test-agent", + ip_address: options?.ipAddress ?? "127.0.0.1", + user_agent: options?.userAgent ?? "test-agent", expires_at: expiresAt, trusted_mode: false, }) - .execute(); + .returning("id") + .executeTakeFirstOrThrow(); - return token; + return { token, sessionId: Number(result.id) }; +} + +/** + * Create an authenticated API context for a user (creates session + context) + */ +async function createUserAPIContext( + userId: number, + options?: { deviceFingerprint?: string }, +): Promise<{ context: APIContext; token: string }> { + const { token } = await createSession(userId); + const context = createAPIContext({ + sessionToken: token, + deviceFingerprint: options?.deviceFingerprint, + }); + return { context, token }; +} + +// Export to suppress unused warning - helper available for future tests +void createUserAPIContext; + +/** + * Create a device in the database and return the fingerprint + */ +async function createDevice( + userId: number, + options?: { + fingerprint?: string; + isTrusted?: boolean; + name?: string; + userAgent?: string; + }, +): Promise<{ fingerprint: string; deviceId: number }> { + const fingerprint = + options?.fingerprint ?? + `test-fp-${String(Date.now())}${String(Math.random())}`; + + const result = await getDb() + .insertInto("user_devices") + .values({ + user_id: userId, + device_fingerprint: fingerprint, + is_trusted: options?.isTrusted ?? false, + name: options?.name ?? null, + user_agent: options?.userAgent ?? "Mozilla/5.0 Test Browser", + ip_address: "127.0.0.1", + last_used_at: new Date(), + }) + .returning("id") + .executeTakeFirstOrThrow(); + + return { fingerprint, deviceId: Number(result.id) }; } /** @@ -158,7 +229,7 @@ describe("me.get", () => { .where("id", "=", user.id) .execute(); - const sessionToken = await createSession(user.id); + const { token: sessionToken } = await createSession(user.id); const context = createAPIContext({ sessionToken }); const result = await call(router.me.get, undefined, { context }); @@ -187,7 +258,7 @@ describe("me.get", () => { .where("id", "=", user.id) .execute(); - const sessionToken = await createSession(user.id); + const { token: sessionToken } = await createSession(user.id); const context = createAPIContext({ sessionToken }); const result = await call(router.me.get, undefined, { context }); @@ -203,7 +274,7 @@ describe("me.get", () => { passwordHash, }); - const sessionToken = await createSession(user.id); + const { token: sessionToken } = await createSession(user.id); const context = createAPIContext({ sessionToken }); const result = await call(router.me.get, undefined, { context }); @@ -217,7 +288,7 @@ describe("me.get", () => { isSuperuser: true, }); - const sessionToken = await createSession(user.id); + const { token: sessionToken } = await createSession(user.id); const context = createAPIContext({ sessionToken }); const result = await call(router.me.get, undefined, { context }); @@ -233,7 +304,7 @@ describe("me.authStatus", () => { displayName: "Session User", }); - const sessionToken = await createSession(user.id); + const { token: sessionToken } = await createSession(user.id); const context = createAPIContext({ sessionToken }); const result = await call(router.me.authStatus, undefined, { context }); @@ -279,7 +350,7 @@ describe("me.setupProfile", () => { .where("id", "=", user.id) .execute(); - const sessionToken = await createSession(user.id); + const { token: sessionToken } = await createSession(user.id); const context = createAPIContext({ sessionToken }); await call( @@ -315,7 +386,7 @@ describe("me.setupProfile", () => { .where("id", "=", user.id) .execute(); - const sessionToken = await createSession(user.id); + const { token: sessionToken } = await createSession(user.id); const context = createAPIContext({ sessionToken }); await call( @@ -345,7 +416,7 @@ describe("me.updateProfile", () => { displayName: "Original Name", }); - const sessionToken = await createSession(user.id); + const { token: sessionToken } = await createSession(user.id); const context = createAPIContext({ sessionToken }); await call( @@ -371,7 +442,7 @@ describe("me.updateProfile", () => { displayName: "Original", }); - const sessionToken = await createSession(user.id); + const { token: sessionToken } = await createSession(user.id); const context = createAPIContext({ sessionToken }); await call( @@ -410,7 +481,7 @@ describe("me.updateProfile", () => { .where("id", "=", user.id) .execute(); - const sessionToken = await createSession(user.id); + const { token: sessionToken } = await createSession(user.id); const context = createAPIContext({ sessionToken }); await call( @@ -441,7 +512,7 @@ describe("me.updateProfile", () => { displayName: "Stay Same", }); - const sessionToken = await createSession(user.id); + const { token: sessionToken } = await createSession(user.id); const context = createAPIContext({ sessionToken }); await call(router.me.updateProfile, {}, { context }); @@ -462,7 +533,7 @@ describe("me.setPassword", () => { email: "nopass@example.com", }); - const sessionToken = await createSession(user.id); + const { token: sessionToken } = await createSession(user.id); const context = createAPIContext({ sessionToken }); // Use a strong password @@ -491,7 +562,7 @@ describe("me.setPassword", () => { passwordHash: oldHash, }); - const sessionToken = await createSession(user.id); + const { token: sessionToken } = await createSession(user.id); const context = createAPIContext({ sessionToken }); await call( @@ -519,10 +590,9 @@ describe("me.setPassword", () => { passwordHash: oldHash, }); - const sessionToken = await createSession(user.id); + const { token: sessionToken } = await createSession(user.id); const context = createAPIContext({ sessionToken }); - // eslint-disable-next-line @typescript-eslint/await-thenable, @typescript-eslint/no-confusing-void-expression await expect( call( router.me.setPassword, @@ -541,10 +611,9 @@ describe("me.setPassword", () => { passwordHash: oldHash, }); - const sessionToken = await createSession(user.id); + const { token: sessionToken } = await createSession(user.id); const context = createAPIContext({ sessionToken }); - // eslint-disable-next-line @typescript-eslint/await-thenable, @typescript-eslint/no-confusing-void-expression await expect( call( router.me.setPassword, @@ -562,13 +631,13 @@ describe("me.setPassword", () => { email: "weak@example.com", }); - const sessionToken = await createSession(user.id); + const { token: sessionToken } = await createSession(user.id); const context = createAPIContext({ sessionToken }); // Password must be at least 8 chars to pass schema validation // "password" passes length check but fails zxcvbn strength check // 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( call( router.me.setPassword, @@ -590,7 +659,7 @@ describe("me.delete", () => { passwordHash, }); - const sessionToken = await createSession(user.id); + const { token: sessionToken } = await createSession(user.id); const context = createAPIContext({ sessionToken }); await call(router.me.delete, { password }, { context }); @@ -610,10 +679,9 @@ describe("me.delete", () => { email: "nopassdelete@example.com", }); - const sessionToken = await createSession(user.id); + const { token: sessionToken } = await createSession(user.id); const context = createAPIContext({ sessionToken }); - // eslint-disable-next-line @typescript-eslint/await-thenable, @typescript-eslint/no-confusing-void-expression await expect( call(router.me.delete, { password: "anything" }, { context }), ).rejects.toThrow("Cannot delete account without a password"); @@ -626,10 +694,9 @@ describe("me.delete", () => { passwordHash, }); - const sessionToken = await createSession(user.id); + const { token: sessionToken } = await createSession(user.id); const context = createAPIContext({ sessionToken }); - // eslint-disable-next-line @typescript-eslint/await-thenable, @typescript-eslint/no-confusing-void-expression await expect( call(router.me.delete, { password: "WrongPassword123!" }, { context }), ).rejects.toThrow("Incorrect password"); @@ -654,7 +721,7 @@ describe("me.delete", () => { }) .execute(); - const sessionToken = await createSession(user.id); + const { token: sessionToken } = await createSession(user.id); const context = createAPIContext({ sessionToken }); await call(router.me.delete, { password }, { context }); @@ -669,3 +736,573 @@ describe("me.delete", () => { expect(tokens).toHaveLength(0); }); }); + +// ===== Session Management Tests ===== + +describe("me.sessions.list", () => { + test("returns all sessions for user", async () => { + const user = await createTestUser(getDb(), { + email: "sessions@example.com", + }); + + // Create multiple sessions + const { token: sessionToken1 } = await createSession(user.id, { + ipAddress: "192.168.1.1", + userAgent: "Chrome/1.0", + }); + await createSession(user.id, { + ipAddress: "192.168.1.2", + userAgent: "Firefox/1.0", + }); + await createSession(user.id, { + ipAddress: "192.168.1.3", + userAgent: "Safari/1.0", + }); + + const context = createAPIContext({ sessionToken: sessionToken1 }); + const sessions = await call(router.me.sessions.list, undefined, { + context, + }); + + expect(sessions).toHaveLength(3); + // Sessions should be ordered by created_at desc + expect(sessions[0]?.userAgent).toBe("Safari/1.0"); + expect(sessions[1]?.userAgent).toBe("Firefox/1.0"); + expect(sessions[2]?.userAgent).toBe("Chrome/1.0"); + }); + + test("marks current session with isCurrent flag", async () => { + const user = await createTestUser(getDb(), { + email: "current@example.com", + }); + + const { token: sessionToken1, sessionId: id1 } = await createSession( + user.id, + ); + const { sessionId: id2 } = await createSession(user.id); + + const context = createAPIContext({ sessionToken: sessionToken1 }); + const sessions = await call(router.me.sessions.list, undefined, { + context, + }); + + expect(sessions).toHaveLength(2); + const current = sessions.find((s) => s.id === id1); + const other = sessions.find((s) => s.id === id2); + expect(current?.isCurrent).toBe(true); + expect(other?.isCurrent).toBe(false); + }); + + test("returns session metadata correctly", async () => { + const user = await createTestUser(getDb(), { + email: "metadata@example.com", + }); + + // Create session and update with location data + const { token: sessionToken, sessionId } = await createSession(user.id, { + ipAddress: "8.8.8.8", + userAgent: "TestAgent/1.0", + }); + + await getDb() + .updateTable("sessions") + .set({ + city: "San Francisco", + region: "CA", + country: "US", + trusted_mode: true, + }) + .where("id", "=", String(sessionId)) + .execute(); + + const context = createAPIContext({ sessionToken }); + const sessions = await call(router.me.sessions.list, undefined, { + context, + }); + + expect(sessions).toHaveLength(1); + const session = sessions[0]; + expect(session?.ip).toBe("8.8.8.8"); + expect(session?.userAgent).toBe("TestAgent/1.0"); + expect(session?.city).toBe("San Francisco"); + expect(session?.region).toBe("CA"); + expect(session?.country).toBe("US"); + expect(session?.trustedMode).toBe(true); + expect(session?.createdAt).toBeInstanceOf(Date); + expect(session?.revokedAt).toBeNull(); + }); +}); + +describe("me.sessions.revoke", () => { + test("revokes another session successfully", async () => { + const user = await createTestUser(getDb(), { + email: "revoke@example.com", + }); + + const { token: sessionToken1 } = await createSession(user.id); + const { sessionId: sessionId2 } = await createSession(user.id); + + const context = createAPIContext({ sessionToken: sessionToken1 }); + await call( + router.me.sessions.revoke, + { sessionId: sessionId2 }, + { context }, + ); + + // Verify session is revoked + const session = await getDb() + .selectFrom("sessions") + .select(["revoked_at"]) + .where("id", "=", String(sessionId2)) + .executeTakeFirstOrThrow(); + + expect(session.revoked_at).not.toBeNull(); + }); + + test("fails to revoke current session", async () => { + const user = await createTestUser(getDb(), { + email: "revokecurrent@example.com", + }); + + const { token: sessionToken, sessionId } = await createSession(user.id); + const context = createAPIContext({ sessionToken }); + + await expect( + call(router.me.sessions.revoke, { sessionId }, { context }), + ).rejects.toThrow("Cannot revoke current session"); + }); + + test("fails to revoke non-existent session", async () => { + const user = await createTestUser(getDb(), { + email: "revokenotfound@example.com", + }); + + const { token: sessionToken } = await createSession(user.id); + const context = createAPIContext({ sessionToken }); + + await expect( + call(router.me.sessions.revoke, { sessionId: 999999 }, { context }), + ).rejects.toThrow("Session not found"); + }); + + test("fails to revoke already revoked session", async () => { + const user = await createTestUser(getDb(), { + email: "revokeagain@example.com", + }); + + const { token: sessionToken1 } = await createSession(user.id); + const { sessionId: sessionId2 } = await createSession(user.id); + + // Revoke the session directly + await getDb() + .updateTable("sessions") + .set({ revoked_at: new Date() }) + .where("id", "=", String(sessionId2)) + .execute(); + + const context = createAPIContext({ sessionToken: sessionToken1 }); + await expect( + call(router.me.sessions.revoke, { sessionId: sessionId2 }, { context }), + ).rejects.toThrow("Session not found"); + }); + + test("fails to revoke another user's session", async () => { + const user1 = await createTestUser(getDb(), { + email: "user1@example.com", + }); + const user2 = await createTestUser(getDb(), { + email: "user2@example.com", + }); + + const { token: sessionToken1 } = await createSession(user1.id); + const { sessionId: sessionId2 } = await createSession(user2.id); + + const context = createAPIContext({ sessionToken: sessionToken1 }); + await expect( + call(router.me.sessions.revoke, { sessionId: sessionId2 }, { context }), + ).rejects.toThrow("Session not found"); + }); +}); + +describe("me.sessions.revokeAll", () => { + test("revokes all sessions except current", async () => { + const user = await createTestUser(getDb(), { + email: "revokeall@example.com", + }); + + const { token: sessionToken1, sessionId: id1 } = await createSession( + user.id, + ); + const { sessionId: id2 } = await createSession(user.id); + const { sessionId: id3 } = await createSession(user.id); + + const context = createAPIContext({ sessionToken: sessionToken1 }); + await call(router.me.sessions.revokeAll, undefined, { context }); + + // Verify current session is NOT revoked + const currentSession = await getDb() + .selectFrom("sessions") + .select(["revoked_at"]) + .where("id", "=", String(id1)) + .executeTakeFirstOrThrow(); + expect(currentSession.revoked_at).toBeNull(); + + // Verify other sessions ARE revoked + const otherSessions = await getDb() + .selectFrom("sessions") + .select(["id", "revoked_at"]) + .where("id", "in", [String(id2), String(id3)]) + .execute(); + + for (const session of otherSessions) { + expect(session.revoked_at).not.toBeNull(); + } + }); + + test("does nothing when only current session exists", async () => { + const user = await createTestUser(getDb(), { + email: "onlyone@example.com", + }); + + const { token: sessionToken, sessionId } = await createSession(user.id); + const context = createAPIContext({ sessionToken }); + + // Should not throw + await call(router.me.sessions.revokeAll, undefined, { context }); + + // Current session should still be valid + const session = await getDb() + .selectFrom("sessions") + .select(["revoked_at"]) + .where("id", "=", String(sessionId)) + .executeTakeFirstOrThrow(); + expect(session.revoked_at).toBeNull(); + }); +}); + +// ===== Device Management Tests ===== + +describe("me.devices.getInfo", () => { + test("returns device info for current device", async () => { + const user = await createTestUser(getDb(), { + email: "deviceinfo@example.com", + }); + + const { fingerprint, deviceId } = await createDevice(user.id, { + name: "My MacBook", + isTrusted: true, + userAgent: "Safari/17.0", + }); + + // Update with location data + await getDb() + .updateTable("user_devices") + .set({ + ip_address: "1.2.3.4", + city: "New York", + region: "NY", + country: "US", + }) + .where("id", "=", String(deviceId)) + .execute(); + + const { token: sessionToken } = await createSession(user.id); + const context = createAPIContext({ + sessionToken, + deviceFingerprint: fingerprint, + }); + + const info = await call(router.me.devices.getInfo, undefined, { context }); + + expect(info.id).toBe(deviceId); + expect(info.name).toBe("My MacBook"); + expect(info.ip).toBe("1.2.3.4"); + expect(info.city).toBe("New York"); + expect(info.region).toBe("NY"); + expect(info.country).toBe("US"); + expect(info.isTrusted).toBe(true); + expect(info.lastUsedAt).toBeInstanceOf(Date); + }); + + test("returns default name from user agent when name is null", async () => { + const user = await createTestUser(getDb(), { + email: "defaultname@example.com", + }); + + const { fingerprint } = await createDevice(user.id, { + userAgent: "Mozilla/5.0 (Macintosh)", + }); + + const { token: sessionToken } = await createSession(user.id); + const context = createAPIContext({ + sessionToken, + deviceFingerprint: fingerprint, + }); + + const info = await call(router.me.devices.getInfo, undefined, { context }); + + expect(info.name).toBe("Mozilla device"); + }); + + test("fails without device fingerprint", async () => { + const user = await createTestUser(getDb(), { + email: "nofingerprint@example.com", + }); + + const { token: sessionToken } = await createSession(user.id); + const context = createAPIContext({ sessionToken }); + + await expect( + call(router.me.devices.getInfo, undefined, { context }), + ).rejects.toThrow("No device fingerprint found"); + }); + + test("fails when device does not exist", async () => { + const user = await createTestUser(getDb(), { + email: "nodevice@example.com", + }); + + const { token: sessionToken } = await createSession(user.id); + const context = createAPIContext({ + sessionToken, + deviceFingerprint: "nonexistent-fingerprint", + }); + + await expect( + call(router.me.devices.getInfo, undefined, { context }), + ).rejects.toThrow("Device not found"); + }); +}); + +describe("me.devices.trust", () => { + test("trusts current device with name", async () => { + const user = await createTestUser(getDb(), { + email: "trustdevice@example.com", + }); + + const { fingerprint, deviceId } = await createDevice(user.id, { + isTrusted: false, + }); + + const { token: sessionToken } = await createSession(user.id); + const context = createAPIContext({ + sessionToken, + deviceFingerprint: fingerprint, + }); + + await call( + router.me.devices.trust, + { name: "My Work Laptop" }, + { context }, + ); + + // Verify device is trusted with the new name + const device = await getDb() + .selectFrom("user_devices") + .select(["is_trusted", "name"]) + .where("id", "=", String(deviceId)) + .executeTakeFirstOrThrow(); + + expect(device.is_trusted).toBe(true); + expect(device.name).toBe("My Work Laptop"); + }); + + test("fails without device fingerprint", async () => { + const user = await createTestUser(getDb(), { + email: "trustnofp@example.com", + }); + + const { token: sessionToken } = await createSession(user.id); + const context = createAPIContext({ sessionToken }); + + await expect( + call(router.me.devices.trust, { name: "Test" }, { context }), + ).rejects.toThrow("No device fingerprint found"); + }); + + test("fails when device does not exist", async () => { + const user = await createTestUser(getDb(), { + email: "trustnodevice@example.com", + }); + + const { token: sessionToken } = await createSession(user.id); + const context = createAPIContext({ + sessionToken, + deviceFingerprint: "nonexistent", + }); + + await expect( + call(router.me.devices.trust, { name: "Test" }, { context }), + ).rejects.toThrow("Device not found"); + }); +}); + +describe("me.devices.listTrusted", () => { + test("returns only trusted devices", async () => { + const user = await createTestUser(getDb(), { + email: "listtrusted@example.com", + }); + + // Create trusted and untrusted devices + await createDevice(user.id, { isTrusted: true, name: "Trusted 1" }); + await createDevice(user.id, { isTrusted: true, name: "Trusted 2" }); + await createDevice(user.id, { isTrusted: false, name: "Untrusted" }); + + const { token: sessionToken } = await createSession(user.id); + const context = createAPIContext({ sessionToken }); + + const devices = await call(router.me.devices.listTrusted, undefined, { + context, + }); + + expect(devices).toHaveLength(2); + expect(devices.map((d) => d.name).sort()).toEqual([ + "Trusted 1", + "Trusted 2", + ]); + expect(devices.every((d) => d.isTrusted)).toBe(true); + }); + + test("returns empty list when no trusted devices", async () => { + const user = await createTestUser(getDb(), { + email: "notrusted@example.com", + }); + + await createDevice(user.id, { isTrusted: false }); + + const { token: sessionToken } = await createSession(user.id); + const context = createAPIContext({ sessionToken }); + + const devices = await call(router.me.devices.listTrusted, undefined, { + context, + }); + + expect(devices).toHaveLength(0); + }); + + test("returns default name when device name is null", async () => { + const user = await createTestUser(getDb(), { + email: "defaulttrusted@example.com", + }); + + await createDevice(user.id, { + isTrusted: true, + name: undefined, + userAgent: "Chrome/120", + }); + + // Set name to null explicitly + await getDb() + .updateTable("user_devices") + .set({ name: null }) + .where("user_id", "=", user.id) + .execute(); + + const { token: sessionToken } = await createSession(user.id); + const context = createAPIContext({ sessionToken }); + + const devices = await call(router.me.devices.listTrusted, undefined, { + context, + }); + + expect(devices).toHaveLength(1); + expect(devices[0]?.name).toBe("Unknown device"); + }); +}); + +describe("me.devices.untrust", () => { + test("untrusts device by ID", async () => { + const user = await createTestUser(getDb(), { + email: "untrust@example.com", + }); + + const { deviceId } = await createDevice(user.id, { + isTrusted: true, + name: "Trusted Device", + }); + + const { token: sessionToken } = await createSession(user.id); + const context = createAPIContext({ sessionToken }); + + await call(router.me.devices.untrust, { deviceId }, { context }); + + // Verify device is untrusted + const device = await getDb() + .selectFrom("user_devices") + .select(["is_trusted"]) + .where("id", "=", String(deviceId)) + .executeTakeFirstOrThrow(); + + expect(device.is_trusted).toBe(false); + }); + + test("fails to untrust non-existent device", async () => { + const user = await createTestUser(getDb(), { + email: "untrustnotfound@example.com", + }); + + const { token: sessionToken } = await createSession(user.id); + const context = createAPIContext({ sessionToken }); + + await expect( + call(router.me.devices.untrust, { deviceId: 999999 }, { context }), + ).rejects.toThrow("Device not found"); + }); + + test("fails to untrust another user's device", async () => { + const user1 = await createTestUser(getDb(), { + email: "untrustuser1@example.com", + }); + const user2 = await createTestUser(getDb(), { + email: "untrustuser2@example.com", + }); + + const { deviceId } = await createDevice(user2.id, { isTrusted: true }); + + const { token: sessionToken } = await createSession(user1.id); + const context = createAPIContext({ sessionToken }); + + await expect( + call(router.me.devices.untrust, { deviceId }, { context }), + ).rejects.toThrow("Device not found"); + }); +}); + +describe("me.devices.revokeAll", () => { + test("untrusts all devices", async () => { + const user = await createTestUser(getDb(), { + email: "revokealldevices@example.com", + }); + + await createDevice(user.id, { isTrusted: true }); + await createDevice(user.id, { isTrusted: true }); + await createDevice(user.id, { isTrusted: false }); + + const { token: sessionToken } = await createSession(user.id); + const context = createAPIContext({ sessionToken }); + + await call(router.me.devices.revokeAll, undefined, { context }); + + // All devices should be untrusted + const devices = await getDb() + .selectFrom("user_devices") + .select(["id", "is_trusted"]) + .where("user_id", "=", user.id) + .execute(); + + expect(devices).toHaveLength(3); + expect(devices.every((d) => !d.is_trusted)).toBe(true); + }); + + test("works when no devices exist", async () => { + const user = await createTestUser(getDb(), { + email: "revokenodevices@example.com", + }); + + const { token: sessionToken } = await createSession(user.id); + const context = createAPIContext({ sessionToken }); + + // Should not throw + await call(router.me.devices.revokeAll, undefined, { context }); + }); +}); diff --git a/apps/api-server/src/__tests__/e2e/webauthn.test.ts b/apps/api-server/src/__tests__/e2e/webauthn.test.ts index 0102999..7deb061 100644 --- a/apps/api-server/src/__tests__/e2e/webauthn.test.ts +++ b/apps/api-server/src/__tests__/e2e/webauthn.test.ts @@ -9,15 +9,13 @@ import type { Database } from "@reviq/db-schema"; import type { Kysely } from "kysely"; -import type { - APIContext, - AuthenticatedContext, - LoginRequestContext, -} from "../../context.js"; +import type { APIContext } from "../../context.js"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { call } from "@orpc/server"; import { VirtualAuthenticator } from "@reviq/virtual-authenticator"; import { router } from "../../router.js"; +import { COOKIE_NAMES } from "../../utils/cookies.js"; +import { hashToken } from "../../utils/crypto.js"; import { getUserPasskeys } from "../../utils/webauthn.js"; import { KNOWN_AAGUIDS, TEST_RP } from "../helpers/test-constants.js"; import { @@ -28,6 +26,9 @@ import { truncateAllTables, } from "../helpers/test-db.js"; +/** Session expiry duration: 24 hours in milliseconds */ +const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000; + let db: Kysely | undefined; /** @@ -41,67 +42,93 @@ function getDb(): Kysely { } /** - * Create an API context (for public endpoints) + * Create an API context with optional session token */ -function createAPIContext(): APIContext { +function createAPIContext(sessionToken?: string): APIContext { + const reqHeaders = new Headers(); + if (sessionToken) { + reqHeaders.set("cookie", `${COOKIE_NAMES.SESSION_TOKEN}=${sessionToken}`); + } + return { db: getDb(), origin: TEST_RP.origin, allowedOrigins: [...TEST_RP.allowedOrigins], rpName: TEST_RP.rpName, - reqHeaders: new Headers(), + reqHeaders, resHeaders: new Headers(), }; } /** - * Create an authenticated context (for protected endpoints) + * Create a real session in the database and return the token */ -function createAuthenticatedContext( - userId: number, - email: string, -): AuthenticatedContext { - const now = new Date(); - return { - ...createAPIContext(), - user: { - id: userId, - email, - displayName: null, - emailVerifiedAt: null, - isSuperuser: false, - }, - session: { - id: "1", - trustedMode: false, - createdAt: now, - }, - auth: { - method: "session", - sessionId: "1", - expiresAt: new Date(now.getTime() + 24 * 60 * 60 * 1000), - createdAt: now, - }, - }; +async function createSession(userId: number): Promise { + const token = `test-session-${String(Date.now())}${String(Math.random())}`; + const tokenHashValue = await hashToken(token); + const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS); + + await getDb() + .insertInto("sessions") + .values({ + user_id: userId, + token_hash: tokenHashValue, + ip_address: "127.0.0.1", + user_agent: "test-agent", + expires_at: expiresAt, + trusted_mode: false, + }) + .execute(); + + return token; } /** - * Create a login request context (for login flow endpoints) + * Create a login request in the database and return ID and token */ -function createLoginRequestContext( +async function createLoginRequest( userId: number, email: string, -): LoginRequestContext { - return { - ...createAPIContext(), - loginRequestId: 1, - user: { - id: userId, +): Promise<{ id: number; token: string }> { + const token = `test-login-${String(Date.now())}${String(Math.random())}`; + const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes + + const result = await getDb() + .insertInto("login_requests") + .values({ + user_id: userId, email, - displayName: null, - emailVerifiedAt: null, - isSuperuser: false, - }, + token, + expires_at: expiresAt, + }) + .returning("id") + .executeTakeFirstOrThrow(); + + return { id: Number(result.id), token }; +} + +/** + * Create an authenticated API context for a user (creates session + context) + */ +async function createUserAPIContext(userId: number): Promise { + const sessionToken = await createSession(userId); + return createAPIContext(sessionToken); +} + +/** + * Create an API context with login request cookie + */ +function createLoginRequestContext(loginToken: string): APIContext { + const reqHeaders = new Headers(); + reqHeaders.set("cookie", `${COOKIE_NAMES.LOGIN_REQUEST_TOKEN}=${loginToken}`); + + return { + db: getDb(), + origin: TEST_RP.origin, + allowedOrigins: [...TEST_RP.allowedOrigins], + rpName: TEST_RP.rpName, + reqHeaders, + resHeaders: new Headers(), }; } @@ -127,7 +154,7 @@ async function registerPasskey( authenticator: VirtualAuthenticator, ) { const apiCtx = createAPIContext(); - const authCtx = createAuthenticatedContext(userId, email); + const authCtx = await createUserAPIContext(userId); const { options, challengeId } = await call( router.auth.webauthn.createRegistrationOptions, @@ -152,7 +179,8 @@ async function authenticate( email: string, authenticator: VirtualAuthenticator, ) { - const loginCtx = createLoginRequestContext(userId, email); + const { token: loginToken } = await createLoginRequest(userId, email); + const loginCtx = createLoginRequestContext(loginToken); const { options, challengeId } = await call( router.auth.webauthn.createAuthenticationOptions, @@ -234,8 +262,8 @@ describe("registration flow", () => { // Create credential with virtual authenticator const response = authenticator.createCredential(options); - // Verify registration via router - const authCtx = createAuthenticatedContext(user.id, user.email); + // Verify registration via router (requires authenticated session) + const authCtx = await createUserAPIContext(user.id); await call( router.auth.webauthn.verifyRegistration, { challengeId, response }, @@ -256,7 +284,7 @@ describe("registration flow", () => { }); const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); const apiCtx = createAPIContext(); - const authCtx = createAuthenticatedContext(user.id, user.email); + const authCtx = await createUserAPIContext(user.id); // Register first passkey via router const { options: options1, challengeId: challengeId1 } = await call( @@ -299,7 +327,7 @@ describe("registration flow", () => { }); const apiCtx = createAPIContext(); - const authCtx = createAuthenticatedContext(user.id, user.email); + const authCtx = await createUserAPIContext(user.id); const { options, challengeId } = await call( router.auth.webauthn.createRegistrationOptions, @@ -325,7 +353,7 @@ describe("registration flow", () => { }); const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); const apiCtx = createAPIContext(); - const authCtx = createAuthenticatedContext(user.id, user.email); + const authCtx = await createUserAPIContext(user.id); const { options, challengeId } = await call( router.auth.webauthn.createRegistrationOptions, @@ -355,7 +383,7 @@ describe("registration flow", () => { }); const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); const apiCtx = createAPIContext(); - const authCtx = createAuthenticatedContext(user.id, user.email); + const authCtx = await createUserAPIContext(user.id); // Create options via router const { options } = await call( @@ -399,7 +427,8 @@ describe("authentication flow", () => { ); // Create authentication options via router - const loginCtx = createLoginRequestContext(user.id, user.email); + const { token: loginToken } = await createLoginRequest(user.id, user.email); + const loginCtx = createLoginRequestContext(loginToken); const { options, challengeId } = await call( router.auth.webauthn.createAuthenticationOptions, undefined, @@ -427,7 +456,8 @@ describe("authentication flow", () => { await registerPasskey(user.id, user.email, authenticator); // Authenticate via router - const loginCtx = createLoginRequestContext(user.id, user.email); + const { token: loginToken } = await createLoginRequest(user.id, user.email); + const loginCtx = createLoginRequestContext(loginToken); const { options: authOptions, challengeId: authChallengeId } = await call( router.auth.webauthn.createAuthenticationOptions, undefined, @@ -460,7 +490,8 @@ describe("authentication flow", () => { expect(firstPasskey.lastUsedAt).toBeNull(); // Authenticate via router - const loginCtx = createLoginRequestContext(user.id, user.email); + const { token: loginToken } = await createLoginRequest(user.id, user.email); + const loginCtx = createLoginRequestContext(loginToken); const { options: authOptions, challengeId: authChallengeId } = await call( router.auth.webauthn.createAuthenticationOptions, undefined, @@ -489,7 +520,8 @@ describe("authentication flow", () => { await registerPasskey(user.id, user.email, authenticator); // Authenticate via router - const loginCtx = createLoginRequestContext(user.id, user.email); + const { token: loginToken } = await createLoginRequest(user.id, user.email); + const loginCtx = createLoginRequestContext(loginToken); const { options: authOptions, challengeId: authChallengeId } = await call( router.auth.webauthn.createAuthenticationOptions, undefined, @@ -522,7 +554,8 @@ describe("authentication flow", () => { await registerPasskey(user.id, user.email, authenticator); // Create auth options via router - const loginCtx = createLoginRequestContext(user.id, user.email); + const { token: loginToken } = await createLoginRequest(user.id, user.email); + const loginCtx = createLoginRequestContext(loginToken); const { options: authOptions } = await call( router.auth.webauthn.createAuthenticationOptions, undefined, @@ -585,7 +618,8 @@ describe("security tests", () => { authenticator.setSignCount(regResponse.id, 0); // Create a new authentication challenge - const loginCtx = createLoginRequestContext(user.id, user.email); + const { token: loginToken } = await createLoginRequest(user.id, user.email); + const loginCtx = createLoginRequestContext(loginToken); const { options, challengeId } = await call( router.auth.webauthn.createAuthenticationOptions, undefined, @@ -624,7 +658,8 @@ describe("security tests", () => { await registerPasskey(user.id, user.email, authenticator); // Create authentication challenge - const loginCtx = createLoginRequestContext(user.id, user.email); + const { token: loginToken } = await createLoginRequest(user.id, user.email); + const loginCtx = createLoginRequestContext(loginToken); const { options, challengeId } = await call( router.auth.webauthn.createAuthenticationOptions, undefined, @@ -755,7 +790,7 @@ describe("passkey management", () => { await registerPasskey(user.id, user.email, authenticator2); // List passkeys via router handler - const ctx = createAuthenticatedContext(user.id, user.email); + const ctx = await createUserAPIContext(user.id); const passkeys = await call(router.me.passkeys.list, undefined, { context: ctx, }); @@ -806,7 +841,7 @@ describe("passkey management", () => { await registerPasskey(user.id, user.email, authenticator); - const ctx = createAuthenticatedContext(user.id, user.email); + const ctx = await createUserAPIContext(user.id); let passkeys = await call(router.me.passkeys.list, undefined, { context: ctx, }); @@ -842,8 +877,8 @@ describe("passkey management", () => { await registerPasskey(user1.id, user1.email, auth1); await registerPasskey(user2.id, user2.email, auth2); - const ctx1 = createAuthenticatedContext(user1.id, user1.email); - const ctx2 = createAuthenticatedContext(user2.id, user2.email); + const ctx1 = await createUserAPIContext(user1.id); + const ctx2 = await createUserAPIContext(user2.id); const user2Passkeys = await call(router.me.passkeys.list, undefined, { context: ctx2, @@ -853,12 +888,18 @@ describe("passkey management", () => { throw new Error("Expected user2 passkey to exist"); } - // Try to rename user2's passkey using user1's context (should not work) - await call( - router.me.passkeys.rename, - { passkeyId: user2FirstPasskey.id, name: "Hacked Name" }, - { context: ctx1 }, - ); + // Try to rename user2's passkey using user1's context (should throw NOT_FOUND) + try { + await call( + router.me.passkeys.rename, + { passkeyId: user2FirstPasskey.id, name: "Hacked Name" }, + { context: ctx1 }, + ); + throw new Error("Expected rename to fail with NOT_FOUND"); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain("Passkey not found"); + } // User2's passkey should be unchanged const user2PasskeysAfter = await call(router.me.passkeys.list, undefined, { @@ -880,7 +921,7 @@ describe("passkey management", () => { await registerPasskey(user.id, user.email, authenticator); - const ctx = createAuthenticatedContext(user.id, user.email); + const ctx = await createUserAPIContext(user.id); let passkeys = await call(router.me.passkeys.list, undefined, { context: ctx, }); @@ -906,7 +947,7 @@ describe("passkey management", () => { await registerPasskey(user.id, user.email, auth1); await registerPasskey(user.id, user.email, auth2); - const ctx = createAuthenticatedContext(user.id, user.email); + const ctx = await createUserAPIContext(user.id); let passkeys = await call(router.me.passkeys.list, undefined, { context: ctx, }); @@ -937,7 +978,7 @@ describe("passkey management", () => { await registerPasskey(user.id, user.email, authenticator); - const ctx = createAuthenticatedContext(user.id, user.email); + const ctx = await createUserAPIContext(user.id); const passkeys = await call(router.me.passkeys.list, undefined, { context: ctx, }); @@ -978,8 +1019,8 @@ describe("passkey management", () => { await registerPasskey(user1.id, user1.email, auth1); await registerPasskey(user2.id, user2.email, auth2); - const ctx1 = createAuthenticatedContext(user1.id, user1.email); - const ctx2 = createAuthenticatedContext(user2.id, user2.email); + const ctx1 = await createUserAPIContext(user1.id); + const ctx2 = await createUserAPIContext(user2.id); const user2Passkeys = await call(router.me.passkeys.list, undefined, { context: ctx2, @@ -989,12 +1030,18 @@ describe("passkey management", () => { throw new Error("Expected user2 passkey to exist"); } - // Try to delete user2's passkey using user1's context (should not affect user2) - await call( - router.me.passkeys.delete, - { passkeyId: user2FirstPasskey.id }, - { context: ctx1 }, - ); + // Try to delete user2's passkey using user1's context (should throw NOT_FOUND) + try { + await call( + router.me.passkeys.delete, + { passkeyId: user2FirstPasskey.id }, + { context: ctx1 }, + ); + throw new Error("Expected delete to fail with NOT_FOUND"); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain("Passkey not found"); + } // User2's passkey should still exist const user2PasskeysAfter = await call(router.me.passkeys.list, undefined, { diff --git a/apps/api-server/src/__tests__/helpers/test-db.ts b/apps/api-server/src/__tests__/helpers/test-db.ts index 82b406a..0c21325 100644 --- a/apps/api-server/src/__tests__/helpers/test-db.ts +++ b/apps/api-server/src/__tests__/helpers/test-db.ts @@ -4,6 +4,7 @@ import type { Database } from "@reviq/db-schema"; import type { Kysely } from "kysely"; +import { existsSync } from "node:fs"; import { join } from "node:path"; import { createDb } from "@reviq/db"; import { sql } from "kysely"; @@ -134,13 +135,13 @@ async function ensureTestDatabaseExists(): Promise { * * @throws Error if repo root cannot be found */ -async function findRepoRoot(): Promise { +function findRepoRoot(): string { let current = import.meta.dir; // Walk up to 10 levels to find the repo root for (let i = 0; i < 10; i++) { const migrationsPath = join(current, "db", "migrations"); - if (await Bun.file(migrationsPath).exists()) { + if (existsSync(migrationsPath)) { return current; } const parent = join(current, ".."); @@ -166,7 +167,7 @@ export async function runMigrations(): Promise { // Ensure the database exists first await ensureTestDatabaseExists(); - const repoRoot = await findRepoRoot(); + const repoRoot = findRepoRoot(); const proc = Bun.spawn(["dbmate", "up"], { env: { ...process.env, DATABASE_URL: testDbUrl }, diff --git a/apps/api-server/src/procedures/me/_routes.ts b/apps/api-server/src/procedures/me/_routes.ts index 7a1c531..7e58629 100644 --- a/apps/api-server/src/procedures/me/_routes.ts +++ b/apps/api-server/src/procedures/me/_routes.ts @@ -42,12 +42,16 @@ export const meRoutes = { accept: acceptInvite, decline: declineInvite, }, - listSessions, - revokeSession, - revokeAllSessions, - getDeviceInfo, - trustDevice, - listTrustedDevices, - untrustDevice, - revokeAllTrustedDevices, + sessions: { + list: listSessions, + revoke: revokeSession, + revokeAll: revokeAllSessions, + }, + devices: { + getInfo: getDeviceInfo, + trust: trustDevice, + listTrusted: listTrustedDevices, + untrust: untrustDevice, + revokeAll: revokeAllTrustedDevices, + }, }; diff --git a/apps/api-server/src/procedures/me/devices.ts b/apps/api-server/src/procedures/me/devices.ts index 5a39c1f..8edaf9e 100644 --- a/apps/api-server/src/procedures/me/devices.ts +++ b/apps/api-server/src/procedures/me/devices.ts @@ -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 diff --git a/apps/api-server/src/procedures/me/sessions.ts b/apps/api-server/src/procedures/me/sessions.ts index d6cd434..4de90e8 100644 --- a/apps/api-server/src/procedures/me/sessions.ts +++ b/apps/api-server/src/procedures/me/sessions.ts @@ -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 diff --git a/apps/publisher-dashboard/src/routes/account/devices/+page.svelte b/apps/publisher-dashboard/src/routes/account/devices/+page.svelte index 739101e..8419e2b 100644 --- a/apps/publisher-dashboard/src/routes/account/devices/+page.svelte +++ b/apps/publisher-dashboard/src/routes/account/devices/+page.svelte @@ -28,12 +28,12 @@ const queryClient = useQueryClient(); const devicesQuery = createQuery(() => ({ queryKey: ["trustedDevices"], - queryFn: () => api.me.listTrustedDevices(), + queryFn: () => api.me.devices.listTrusted(), })); const currentDeviceQuery = createQuery(() => ({ queryKey: ["deviceInfo"], - queryFn: () => api.me.getDeviceInfo(), + queryFn: () => api.me.devices.getInfo(), })); // Get current device fingerprint from comparison @@ -106,7 +106,7 @@ async function handleRemoveTrust() { isRemoving = true; try { - await api.me.untrustDevice({ deviceId: selectedDeviceId }); + await api.me.devices.untrust({ deviceId: selectedDeviceId }); await queryClient.invalidateQueries({ queryKey: ["trustedDevices"] }); toast.success("Device trust removed"); confirmDialogOpen = false; @@ -125,7 +125,7 @@ async function handleRemoveAllTrust() { isRemovingAll = true; try { - await api.me.revokeAllTrustedDevices(); + await api.me.devices.revokeAll(); await queryClient.invalidateQueries({ queryKey: ["trustedDevices"] }); toast.success("All trusted devices removed"); confirmAllDialogOpen = false; diff --git a/apps/publisher-dashboard/src/routes/account/sessions/+page.svelte b/apps/publisher-dashboard/src/routes/account/sessions/+page.svelte index f714443..05a5f6b 100644 --- a/apps/publisher-dashboard/src/routes/account/sessions/+page.svelte +++ b/apps/publisher-dashboard/src/routes/account/sessions/+page.svelte @@ -30,7 +30,7 @@ const queryClient = useQueryClient(); const sessionsQuery = createQuery(() => ({ queryKey: ["sessions"], - queryFn: () => api.me.listSessions(), + queryFn: () => api.me.sessions.list(), })); let confirmDialogOpen = $state(false); @@ -121,7 +121,7 @@ async function handleRevoke() { isRevoking = true; try { - await api.me.revokeSession({ sessionId: selectedSessionId }); + await api.me.sessions.revoke({ sessionId: selectedSessionId }); await queryClient.invalidateQueries({ queryKey: ["sessions"] }); toast.success("Session revoked"); confirmDialogOpen = false; @@ -140,7 +140,7 @@ async function handleRevokeAll() { isRevokingAll = true; try { - await api.me.revokeAllSessions(); + await api.me.sessions.revokeAll(); await queryClient.invalidateQueries({ queryKey: ["sessions"] }); toast.success("All other sessions revoked"); confirmAllDialogOpen = false; diff --git a/apps/publisher-dashboard/src/routes/auth/trust-device/+page.svelte b/apps/publisher-dashboard/src/routes/auth/trust-device/+page.svelte index 37db80c..ad2b6ab 100644 --- a/apps/publisher-dashboard/src/routes/auth/trust-device/+page.svelte +++ b/apps/publisher-dashboard/src/routes/auth/trust-device/+page.svelte @@ -15,7 +15,7 @@ import { LoadingButton } from "$lib/components/ui/loading-button"; // TanStack Query v6 with Svelte 5: options passed as thunk, results accessed directly const deviceQuery = createQuery(() => ({ queryKey: ["deviceInfo"], - queryFn: () => api.me.getDeviceInfo(), + queryFn: () => api.me.devices.getInfo(), })); // Parse user agent for suggested device name @@ -50,7 +50,7 @@ async function handleTrust() { error = ""; try { - await api.me.trustDevice({ name: deviceName.trim() }); + await api.me.devices.trust({ name: deviceName.trim() }); toast.success("Device trusted successfully!"); goto("/"); } catch (e) { diff --git a/db/schema.sql b/db/schema.sql index 90c915d..f33aec6 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1,4 +1,4 @@ -\restrict NwR9NcSOK9D25dGgvUNdLvsNphDACAXsvkQ5NSmhpf6sLcFR570yQ96lhgCbCXf +\restrict CIj4ub2A9kD8NQM2nKa1cg31hNutT3jXdOch0DnJ2bT48qpQKbe9XxNtViPwfYR -- Dumped from database version 17.7 -- Dumped by pg_dump version 17.7 @@ -1084,7 +1084,7 @@ ALTER TABLE ONLY public.user_devices -- PostgreSQL database dump complete -- -\unrestrict NwR9NcSOK9D25dGgvUNdLvsNphDACAXsvkQ5NSmhpf6sLcFR570yQ96lhgCbCXf +\unrestrict CIj4ub2A9kD8NQM2nKa1cg31hNutT3jXdOch0DnJ2bT48qpQKbe9XxNtViPwfYR -- diff --git a/packages/api-contract/src/contract.ts b/packages/api-contract/src/contract.ts index ebf251f..e2bafcb 100644 --- a/packages/api-contract/src/contract.ts +++ b/packages/api-contract/src/contract.ts @@ -162,19 +162,25 @@ export const contract = oc.router({ .output(successResponseSchema), }), - // Sessions & devices - listSessions: oc.output(z.array(sessionOutputSchema)), - revokeSession: oc - .input(z.object({ sessionId: z.number() })) - .output(successResponseSchema), - revokeAllSessions: oc.output(successResponseSchema), - getDeviceInfo: oc.output(deviceOutputSchema), - trustDevice: oc.input(trustDeviceInputSchema).output(successResponseSchema), - listTrustedDevices: oc.output(z.array(deviceOutputSchema)), - untrustDevice: oc - .input(z.object({ deviceId: z.number() })) - .output(successResponseSchema), - revokeAllTrustedDevices: oc.output(successResponseSchema), + // Sessions + sessions: oc.router({ + list: oc.output(z.array(sessionOutputSchema)), + revoke: oc + .input(z.object({ sessionId: z.number() })) + .output(successResponseSchema), + revokeAll: oc.output(successResponseSchema), + }), + + // Devices + devices: oc.router({ + getInfo: oc.output(deviceOutputSchema), + trust: oc.input(trustDeviceInputSchema).output(successResponseSchema), + listTrusted: oc.output(z.array(deviceOutputSchema)), + untrust: oc + .input(z.object({ deviceId: z.number() })) + .output(successResponseSchema), + revokeAll: oc.output(successResponseSchema), + }), }), orgs: oc.router({