Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions apps/backend/drizzle/0007_user_devices.sql
Original file line number Diff line number Diff line change
@@ -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;
7 changes: 7 additions & 0 deletions apps/backend/drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
]
}
157 changes: 157 additions & 0 deletions apps/backend/src/__tests__/devices.test.ts
Original file line number Diff line number Diff line change
@@ -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' });
});
});
2 changes: 2 additions & 0 deletions apps/backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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 });
Expand Down
54 changes: 53 additions & 1 deletion apps/backend/src/db/schema.ts
Original file line number Diff line number Diff line change
@@ -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', {
Expand Down Expand Up @@ -91,13 +101,49 @@ 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 }) => ({
wallets: many(wallets),
memberships: many(conversationMembers),
messages: many(messages),
transfers: many(tokenTransfers),
devices: many(userDevices),
}));

export const walletsRelations = relations(wallets, ({ one }) => ({
Expand Down Expand Up @@ -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;
Expand All @@ -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;
3 changes: 3 additions & 0 deletions apps/backend/src/lib/jwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
54 changes: 54 additions & 0 deletions apps/backend/src/routes/devices.ts
Original file line number Diff line number Diff line change
@@ -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' });
}
});
Loading