diff --git a/apps/backend/drizzle/0007_user_devices.sql b/apps/backend/drizzle/0007_user_devices.sql new file mode 100644 index 0000000..a98bc1a --- /dev/null +++ b/apps/backend/drizzle/0007_user_devices.sql @@ -0,0 +1,17 @@ +CREATE TYPE "public"."device_platform" AS ENUM('web', 'ios', 'android');--> statement-breakpoint +CREATE TABLE "user_devices" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "device_id" text NOT NULL, + "device_name" text NOT NULL, + "platform" "device_platform" NOT NULL, + "identity_public_key" text NOT NULL, + "registration_id" integer, + "last_seen_at" timestamp, + "revoked_at" timestamp, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "user_devices" ADD CONSTRAINT "user_devices_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "user_devices_user_id_device_id_unique" ON "user_devices" USING btree ("user_id","device_id");--> statement-breakpoint +CREATE INDEX "user_devices_user_id_active_idx" ON "user_devices" USING btree ("user_id") WHERE "revoked_at" IS NULL; diff --git a/apps/backend/drizzle/meta/_journal.json b/apps/backend/drizzle/meta/_journal.json index 8fea85e..a58ae36 100644 --- a/apps/backend/drizzle/meta/_journal.json +++ b/apps/backend/drizzle/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1780560000000, "tag": "0006_add_conversation_avatar_url", "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1782345600000, + "tag": "0007_user_devices", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/backend/src/__tests__/devices.test.ts b/apps/backend/src/__tests__/devices.test.ts new file mode 100644 index 0000000..309053e --- /dev/null +++ b/apps/backend/src/__tests__/devices.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import request from 'supertest'; +import express from 'express'; +import { signToken } from '../lib/jwt.js'; + +vi.mock('../db/index.js', () => ({ + db: { + query: { + userDevices: { + findMany: vi.fn(), + }, + }, + }, +})); + +const { devicesRouter } = await import('../routes/devices.js'); +const { db } = await import('../db/index.js'); + +const app = express(); +app.use(express.json()); +app.use('/devices', devicesRouter); + +const USER_ID = 'auth-user-id'; +const CURRENT_DEVICE_ID = 'device-web-1'; +const TOKEN = signToken({ userId: USER_ID, walletAddress: 'GAUTH', deviceId: CURRENT_DEVICE_ID }); +const AUTH_HEADER = `Bearer ${TOKEN}`; + +const CREATED_AT = new Date('2026-05-31T12:00:00.000Z'); +const LAST_SEEN_AT = new Date('2026-06-20T08:30:00.000Z'); +const REVOKED_AT = new Date('2026-06-10T09:00:00.000Z'); + +// As the DB orders them: active devices first, then revoked. +const ROWS = [ + { + id: 'row-1', + deviceId: CURRENT_DEVICE_ID, + deviceName: 'Chrome on Mac', + platform: 'web', + lastSeenAt: LAST_SEEN_AT, + createdAt: CREATED_AT, + revokedAt: null, + }, + { + id: 'row-2', + deviceId: 'device-ios-1', + deviceName: 'iPhone', + platform: 'ios', + lastSeenAt: null, + createdAt: CREATED_AT, + revokedAt: null, + }, + { + id: 'row-3', + deviceId: 'device-android-old', + deviceName: 'Old Pixel', + platform: 'android', + lastSeenAt: null, + createdAt: CREATED_AT, + revokedAt: REVOKED_AT, + }, +]; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('GET /devices', () => { + it('returns 401 when no Authorization header is provided', async () => { + const res = await request(app).get('/devices'); + expect(res.status).toBe(401); + }); + + it('returns 401 when the token is invalid', async () => { + const res = await request(app).get('/devices').set('Authorization', 'Bearer not.a.token'); + expect(res.status).toBe(401); + }); + + it('scopes the query to the authenticated user only', async () => { + vi.mocked(db.query.userDevices.findMany).mockResolvedValue([] as never); + + await request(app).get('/devices').set('Authorization', AUTH_HEADER); + + const arg = vi.mocked(db.query.userDevices.findMany).mock.calls[0]?.[0]; + expect(arg).toBeDefined(); + expect(arg).toHaveProperty('where'); + expect(arg).toHaveProperty('orderBy'); + }); + + it('returns the devices including revoked ones, preserving active-first order', async () => { + vi.mocked(db.query.userDevices.findMany).mockResolvedValue(ROWS as never); + + const res = await request(app).get('/devices').set('Authorization', AUTH_HEADER); + + expect(res.status).toBe(200); + expect(res.body).toHaveLength(3); + expect(res.body.map((d: { id: string }) => d.id)).toEqual(['row-1', 'row-2', 'row-3']); + + // Revoked device is present with its revokedAt timestamp set. + expect(res.body[2].revokedAt).toBe(REVOKED_AT.toISOString()); + expect(res.body[0].revokedAt).toBeNull(); + }); + + it('flags only the device from the caller JWT as current', async () => { + vi.mocked(db.query.userDevices.findMany).mockResolvedValue(ROWS as never); + + const res = await request(app).get('/devices').set('Authorization', AUTH_HEADER); + + expect(res.status).toBe(200); + expect(res.body[0]).toMatchObject({ deviceId: CURRENT_DEVICE_ID, current: true }); + expect(res.body[1].current).toBe(false); + expect(res.body[2].current).toBe(false); + }); + + it('marks every device not-current when the JWT carries no deviceId', async () => { + vi.mocked(db.query.userDevices.findMany).mockResolvedValue(ROWS as never); + const tokenNoDevice = signToken({ userId: USER_ID, walletAddress: 'GAUTH' }); + + const res = await request(app).get('/devices').set('Authorization', `Bearer ${tokenNoDevice}`); + + expect(res.status).toBe(200); + expect(res.body.every((d: { current: boolean }) => d.current === false)).toBe(true); + }); + + it('returns the exact response shape with no leaked internal fields', async () => { + vi.mocked(db.query.userDevices.findMany).mockResolvedValue([ + { ...ROWS[0], userId: USER_ID, identityPublicKey: 'SECRET', registrationId: 42 }, + ] as never); + + const res = await request(app).get('/devices').set('Authorization', AUTH_HEADER); + + expect(res.status).toBe(200); + expect(Object.keys(res.body[0]).sort()).toEqual( + [ + 'createdAt', + 'current', + 'deviceId', + 'deviceName', + 'id', + 'lastSeenAt', + 'platform', + 'revokedAt', + ].sort(), + ); + expect(res.body[0]).not.toHaveProperty('userId'); + expect(res.body[0]).not.toHaveProperty('identityPublicKey'); + expect(res.body[0]).not.toHaveProperty('registrationId'); + }); + + it('returns 500 when the database query fails', async () => { + vi.mocked(db.query.userDevices.findMany).mockRejectedValue(new Error('db down')); + + const res = await request(app).get('/devices').set('Authorization', AUTH_HEADER); + + expect(res.status).toBe(500); + expect(res.body).toEqual({ error: 'Failed to list devices' }); + }); +}); diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index ede35cf..70bb0f5 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -9,6 +9,7 @@ import { authRouter } from './routes/auth.js'; import { conversationsRouter } from './routes/conversations.js'; import { messagesRouter } from './routes/messages.js'; import { usersRouter } from './routes/users.js'; +import { devicesRouter } from './routes/devices.js'; import { requireAuth, type AuthRequest } from './middleware/auth.js'; const packageJson = JSON.parse( @@ -47,6 +48,7 @@ app.use('/auth', authRouter); app.use('/conversations', conversationsRouter); app.use('/messages', messagesRouter); app.use('/users', usersRouter); +app.use('/devices', devicesRouter); app.get('/me', requireAuth, (req, res) => { res.json({ user: (req as AuthRequest).auth }); diff --git a/apps/backend/src/db/schema.ts b/apps/backend/src/db/schema.ts index 419c59f..b49c0f7 100644 --- a/apps/backend/src/db/schema.ts +++ b/apps/backend/src/db/schema.ts @@ -1,4 +1,14 @@ -import { pgTable, text, timestamp, uuid, boolean, pgEnum, index } from 'drizzle-orm/pg-core'; +import { + pgTable, + text, + timestamp, + uuid, + boolean, + integer, + pgEnum, + index, + uniqueIndex, +} from 'drizzle-orm/pg-core'; import { relations, sql } from 'drizzle-orm'; export const users = pgTable('users', { @@ -91,6 +101,41 @@ export const tokenTransfers = pgTable('token_transfers', { createdAt: timestamp('created_at').notNull().defaultNow(), }); +// ─── User devices (#153) ────────────────────────────────────────────────────── +// +// Device identity registry for end-to-end encryption. Each row is one device a +// user has registered, holding its long-term identity public key. A device is +// never hard-deleted — revoking sets `revokedAt` so historical sessions stay +// auditable. `(userId, deviceId)` is unique so a client re-registering the same +// device upserts instead of duplicating, and the partial index keeps lookups of +// a user's *active* devices fast. + +export const devicePlatformEnum = pgEnum('device_platform', ['web', 'ios', 'android']); + +export const userDevices = pgTable( + 'user_devices', + { + id: uuid('id').primaryKey().defaultRandom(), + userId: uuid('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + deviceId: text('device_id').notNull(), + deviceName: text('device_name').notNull(), + platform: devicePlatformEnum('platform').notNull(), + identityPublicKey: text('identity_public_key').notNull(), + registrationId: integer('registration_id'), + lastSeenAt: timestamp('last_seen_at'), + revokedAt: timestamp('revoked_at'), + createdAt: timestamp('created_at').notNull().defaultNow(), + }, + (table) => [ + uniqueIndex('user_devices_user_id_device_id_unique').on(table.userId, table.deviceId), + index('user_devices_user_id_active_idx') + .on(table.userId) + .where(sql`${table.revokedAt} IS NULL`), + ], +); + // ─── Relations ──────────────────────────────────────────────────────────────── export const usersRelations = relations(users, ({ many }) => ({ @@ -98,6 +143,7 @@ export const usersRelations = relations(users, ({ many }) => ({ memberships: many(conversationMembers), messages: many(messages), transfers: many(tokenTransfers), + devices: many(userDevices), })); export const walletsRelations = relations(wallets, ({ one }) => ({ @@ -137,6 +183,10 @@ export const tokenTransfersRelations = relations(tokenTransfers, ({ one }) => ({ }), })); +export const userDevicesRelations = relations(userDevices, ({ one }) => ({ + user: one(users, { fields: [userDevices.userId], references: [users.id] }), +})); + // ─── Types ──────────────────────────────────────────────────────────────────── export type User = typeof users.$inferSelect; @@ -150,3 +200,5 @@ export type Message = typeof messages.$inferSelect; export type NewMessage = typeof messages.$inferInsert; export type TokenTransfer = typeof tokenTransfers.$inferSelect; export type NewTokenTransfer = typeof tokenTransfers.$inferInsert; +export type UserDevice = typeof userDevices.$inferSelect; +export type NewUserDevice = typeof userDevices.$inferInsert; diff --git a/apps/backend/src/lib/jwt.ts b/apps/backend/src/lib/jwt.ts index b9bf3ba..57955b1 100644 --- a/apps/backend/src/lib/jwt.ts +++ b/apps/backend/src/lib/jwt.ts @@ -11,6 +11,9 @@ const JWT_SECRET: string = SECRET; export interface JwtPayload { userId: string; walletAddress: string; + // Present once the session is bound to a registered device (see user_devices). + // Used to flag the requesting device as `current` in device listings. + deviceId?: string; } export function signToken(payload: JwtPayload): string { diff --git a/apps/backend/src/routes/devices.ts b/apps/backend/src/routes/devices.ts new file mode 100644 index 0000000..920315c --- /dev/null +++ b/apps/backend/src/routes/devices.ts @@ -0,0 +1,54 @@ +import { Router, type Router as RouterType } from 'express'; +import { eq, desc, sql } from 'drizzle-orm'; +import { db } from '../db/index.js'; +import { userDevices } from '../db/schema.js'; +import { requireAuth, type AuthRequest } from '../middleware/auth.js'; + +export const devicesRouter: RouterType = Router(); + +devicesRouter.use(requireAuth); + +// GET /devices — list the caller's own devices. +// +// Returns every device registered to the authenticated user, including revoked +// ones (with `revokedAt` set) so clients can show device history. Active devices +// are listed first, then most recently registered. The device whose id is bound +// to the caller's JWT is flagged with `current: true`. +devicesRouter.get('/', async (req: AuthRequest, res) => { + const { userId, deviceId: currentDeviceId } = req.auth!; + + try { + const devices = await db.query.userDevices.findMany({ + where: eq(userDevices.userId, userId), + columns: { + id: true, + deviceId: true, + deviceName: true, + platform: true, + lastSeenAt: true, + createdAt: true, + revokedAt: true, + }, + // Active devices (revoked_at IS NULL) first, then newest registration first. + orderBy: [ + sql`case when ${userDevices.revokedAt} is null then 0 else 1 end`, + desc(userDevices.createdAt), + ], + }); + + res.json( + devices.map((device) => ({ + id: device.id, + deviceId: device.deviceId, + deviceName: device.deviceName, + platform: device.platform, + lastSeenAt: device.lastSeenAt, + createdAt: device.createdAt, + revokedAt: device.revokedAt, + current: currentDeviceId !== undefined && device.deviceId === currentDeviceId, + })), + ); + } catch { + res.status(500).json({ error: 'Failed to list devices' }); + } +});