Skip to content
Merged
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
135 changes: 135 additions & 0 deletions apps/backend/src/routes/devices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ import { db } from '../db/index.js';
import { devices, signedPreKeys, oneTimePreKeys } from '../db/schema.js';
import { requireAuth, type AuthRequest } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js';
import { userDevices, conversationMembers, messages } from '../db/schema.js';
import { getSocketServer } from '../lib/socket.js';
import { invalidateConversationCaches } from '../lib/conversationCache.js';
import { SignedPreKeyEntrySchema, PreKeyEntrySchema, verifyEd25519Signature } from '../lib/keys.js';

export const devicesRouter: RouterType = Router();
Expand All @@ -28,6 +31,14 @@ const UploadPreKeysSchema = z.object({
oneTimePreKeys: z.array(PreKeyEntrySchema).min(1, 'At least one one-time prekey is required'),
});

const RegisterDeviceSchema = z.object({
deviceId: z.string().min(1, 'deviceId is required'),
deviceName: z.string().min(1, 'deviceName is required'),
platform: z.enum(['web', 'ios', 'android']),
identityPublicKey: z.string().min(1, 'identityPublicKey is required'),
registrationId: z.number().int().nonnegative().optional(),
});

/** Maximum number of stored one-time prekeys per device. */
const OTP_CAP = 200;

Expand Down Expand Up @@ -273,3 +284,127 @@ devicesRouter.post('/:id/prekeys', validate(UploadPreKeysSchema), async (req: Au
capped: trimmedBatch.length < otpBatch.length,
});
});

// ─── POST /devices — register a new device for an existing user --------------

devicesRouter.post('/', validate(RegisterDeviceSchema), async (req: AuthRequest, res) => {
const body = req.body as z.infer<typeof RegisterDeviceSchema>;
const userId = req.auth!.userId;

// Validate identityPublicKey is base64 and 32 bytes when decoded (X25519)
try {
const key = Buffer.from(body.identityPublicKey, 'base64');
if (key.length !== 32) {
res.status(400).json({ error: 'identityPublicKey must be 32 bytes (base64-encoded)' });
return;
}
} catch {
res.status(400).json({ error: 'identityPublicKey must be valid base64' });
return;
}

// Reject duplicate (userId, deviceId)
const existing = await db.query.userDevices.findFirst({
where: eq(userDevices.deviceId, body.deviceId),
});

if (existing && existing.userId === userId) {
res.status(409).json({ error: 'Device already registered for this user' });
return;
}

try {
const [row] = await db
.insert(userDevices)
.values({
userId,
deviceId: body.deviceId,
deviceName: body.deviceName,
platform: body.platform,
identityPublicKey: body.identityPublicKey,
registrationId: body.registrationId ?? undefined,
})
.returning({
id: userDevices.id,
deviceId: userDevices.deviceId,
createdAt: userDevices.createdAt,
});

// Emit system event to each conversation the user belongs to
void emitDeviceChangeEvent(userId, 'device_added');

res.status(201).json({ id: row.id, deviceId: row.deviceId, createdAt: row.createdAt });
} catch (err) {
console.error('Failed to register device:', err);
res.status(500).json({ error: 'Failed to register device' });
}
});

// ─── DELETE /devices/:id — revoke a device for the authenticated user --------
devicesRouter.delete('/:id', async (req: AuthRequest, res) => {
const userId = req.auth!.userId;
const deviceId = req.params['id'] as string;

try {
const result = await db
.update(userDevices)
.set({ revokedAt: new Date() })
.where(eq(userDevices.deviceId, deviceId))
.returning();

if (!result || result.length === 0) {
res.status(404).json({ error: 'Device not found' });
return;
}

// Only emit if the device belonged to the user (safety: check last row)
if (result[0].userId !== userId) {
res.status(403).json({ error: 'Not allowed to revoke this device' });
return;
}

// Emit system event to each conversation the user belongs to
void emitDeviceChangeEvent(userId, 'device_revoked');

res.status(200).json({ revoked: true });
} catch (err) {
console.error('Failed to revoke device:', err);
res.status(500).json({ error: 'Failed to revoke device' });
}
});

async function emitDeviceChangeEvent(userId: string, change: 'device_added' | 'device_revoked') {
try {
const memberships = await db.query.conversationMembers.findMany({
where: eq(conversationMembers.userId, userId),
columns: { conversationId: true },
});

if (memberships.length === 0) return;

for (const m of memberships) {
const [msg] = await db
.insert(messages)
.values({
conversationId: m.conversationId,
senderId: userId,
content: JSON.stringify({ userId, change }),
})
.returning();

const io = getSocketServer();
if (io) {
io.to(m.conversationId).emit('new_message', msg);
}

// invalidate caches for conversation members
const members = await db.query.conversationMembers.findMany({
where: eq(conversationMembers.conversationId, m.conversationId),
columns: { userId: true },
});
await invalidateConversationCaches(members.map((mm) => mm.userId));
}
} catch (err) {
console.error('emitDeviceChangeEvent error:', err);
}
}
12 changes: 6 additions & 6 deletions apps/web/src/app/app/devices/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -176,8 +176,8 @@ export default function DevicesPage() {
<div className="mb-6 rounded-lg border border-[var(--border)] bg-[var(--card)]/40 p-4">
<h3 className="text-sm font-semibold">Link a new device</h3>
<p className="mt-1 text-sm text-[var(--foreground)]/45">
Open Clicked on the new device and connect the same wallet. It registers automatically
and shows up in the list below the moment it signs in.
Open Clicked on the new device and connect the same wallet. It registers automatically and
shows up in the list below the moment it signs in.
</p>
</div>

Expand Down Expand Up @@ -240,10 +240,10 @@ export default function DevicesPage() {
title="Revoke this device?"
>
<p className="text-sm text-[var(--foreground)]/60">
This device will be signed out immediately. Because your identity keys change for
anyone who messages you on this account, contacts may see a key-change notice the next
time they message you — that&apos;s expected and confirms the old device can no longer
read new messages.
This device will be signed out immediately. Because your identity keys change for anyone
who messages you on this account, contacts may see a key-change notice the next time they
message you — that&apos;s expected and confirms the old device can no longer read new
messages.
</p>
<div className="mt-6 flex justify-end gap-3">
<button
Expand Down
30 changes: 15 additions & 15 deletions apps/web/src/components/ui/ProposalCard.test.tsx
Original file line number Diff line number Diff line change
@@ -1,42 +1,42 @@
import { render, screen } from "@testing-library/react";
import { ProposalCard } from "../ProposalCard";
import { render, screen } from '@testing-library/react';
import { ProposalCard } from '../ProposalCard';

describe("ProposalCard Action Requirements", () => {
it("renders Execute button only if user is a member and proposal is approved", () => {
describe('ProposalCard Action Requirements', () => {
it('renders Execute button only if user is a member and proposal is approved', () => {
const { rerender } = render(
<ProposalCard
proposal={{ id: "1", status: "approved", expiryLedger: 100 }}
proposal={{ id: '1', status: 'approved', expiryLedger: 100 }}
currentLedger={50}
isMember={true}
onExecute={() => {}}
onFinalize={() => {}}
/>
/>,
);
expect(screen.getByText("Execute Withdrawal")).toBeInTheDocument();
expect(screen.getByText('Execute Withdrawal')).toBeInTheDocument();

// Re-render with membership set to false
rerender(
<ProposalCard
proposal={{ id: "1", status: "approved", expiryLedger: 100 }}
proposal={{ id: '1', status: 'approved', expiryLedger: 100 }}
currentLedger={50}
isMember={false}
onExecute={() => {}}
onFinalize={() => {}}
/>
/>,
);
expect(screen.queryByText("Execute Withdrawal")).toBeNull();
expect(screen.queryByText('Execute Withdrawal')).toBeNull();
});

it("shows Finalize button instead of choice operations on expired entries", () => {
it('shows Finalize button instead of choice operations on expired entries', () => {
render(
<ProposalCard
proposal={{ id: "2", status: "expired", expiryLedger: 40 }}
proposal={{ id: '2', status: 'expired', expiryLedger: 40 }}
currentLedger={50}
isMember={true}
onExecute={() => {}}
onFinalize={() => {}}
/>
/>,
);
expect(screen.getByText("Finalize")).toBeInTheDocument();
expect(screen.getByText('Finalize')).toBeInTheDocument();
});
});
});
44 changes: 25 additions & 19 deletions apps/web/src/components/ui/ProposalCard.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { useState, useEffect } from "react";
import React, { useState, useEffect } from 'react';

export type ProposalStatus = "pending" | "approved" | "executed" | "rejected" | "expired";
export type ProposalStatus = 'pending' | 'approved' | 'executed' | 'rejected' | 'expired';

interface ProposalCardProps {
proposal: {
Expand All @@ -22,17 +22,17 @@ export const ProposalCard: React.FC<ProposalCardProps> = ({
onFinalize,
}) => {
const [isCollapsed, setIsCollapsed] = useState(
proposal.status === "executed" || proposal.status === "rejected"
proposal.status === 'executed' || proposal.status === 'rejected',
);
const [timeLeft, setTimeLeft] = useState("");
const [timeLeft, setTimeLeft] = useState('');

// Calculate countdown: 1 ledger ≈ 5s. Updates every minute.
useEffect(() => {
const calculateTime = () => {
if (proposal.status !== "pending") return;
if (proposal.status !== 'pending') return;
const ledgersLeft = proposal.expiryLedger - currentLedger;
if (ledgersLeft <= 0) {
setTimeLeft("Expired");
setTimeLeft('Expired');
return;
}
const totalSeconds = ledgersLeft * 5;
Expand All @@ -47,11 +47,11 @@ export const ProposalCard: React.FC<ProposalCardProps> = ({

// Map explicitly defined color configurations
const badgeColors: Record<ProposalStatus, string> = {
pending: "bg-yellow-500 text-black",
approved: "bg-blue-500 text-white",
executed: "bg-green-500 text-white",
rejected: "bg-red-500 text-white",
expired: "bg-gray-500 text-white",
pending: 'bg-yellow-500 text-black',
approved: 'bg-blue-500 text-white',
executed: 'bg-green-500 text-white',
rejected: 'bg-red-500 text-white',
expired: 'bg-gray-500 text-white',
};

return (
Expand All @@ -64,32 +64,38 @@ export const ProposalCard: React.FC<ProposalCardProps> = ({
</div>

{/* Expiry Countdown for pending proposals */}
{proposal.status === "pending" && <p className="text-sm text-gray-500 mt-1">{timeLeft}</p>}
{proposal.status === 'pending' && <p className="text-sm text-gray-500 mt-1">{timeLeft}</p>}

{/* Collapsible content section toggle wrapper */}
{(proposal.status === "executed" || proposal.status === "rejected") && (
{(proposal.status === 'executed' || proposal.status === 'rejected') && (
<button className="text-xs underline mt-2" onClick={() => setIsCollapsed(!isCollapsed)}>
{isCollapsed ? "Show Past Details" : "Hide Details"}
{isCollapsed ? 'Show Past Details' : 'Hide Details'}
</button>
)}

{!isCollapsed && (
<div className="mt-4 flex gap-2">
{/* Execute button only visible to verified members when status is approved */}
{proposal.status === "approved" && isMember && (
<button className="btn bg-blue-600 text-white px-4 py-2" onClick={() => onExecute(proposal.id)}>
{proposal.status === 'approved' && isMember && (
<button
className="btn bg-blue-600 text-white px-4 py-2"
onClick={() => onExecute(proposal.id)}
>
Execute Withdrawal
</button>
)}

{/* Expired proposals replace approve/reject with a Finalize button */}
{proposal.status === "expired" && (
<button className="btn bg-gray-700 text-white px-4 py-2" onClick={() => onFinalize(proposal.id)}>
{proposal.status === 'expired' && (
<button
className="btn bg-gray-700 text-white px-4 py-2"
onClick={() => onFinalize(proposal.id)}
>
Finalize
</button>
)}
</div>
)}
</div>
);
};
};
5 changes: 4 additions & 1 deletion apps/web/src/lib/x3dh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ function bundleFrom(
signature: toBase64(responder.signedPreKey.signature),
},
oneTimePreKey: includeOtp
? { keyId: responder.oneTimePreKey.keyId, publicKey: toBase64(responder.oneTimePreKey.publicKey) }
? {
keyId: responder.oneTimePreKey.keyId,
publicKey: toBase64(responder.oneTimePreKey.publicKey),
}
: null,
};
}
Expand Down
5 changes: 1 addition & 4 deletions apps/web/src/lib/x3dh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,10 +130,7 @@ function concatBytes(...chunks: Uint8Array[]): Uint8Array {
* Initiator side: establish a session with `bundle` fetched from
* GET /devices/:id/bundle. Verifies the signed prekey before using it.
*/
export function initiateSession(
bundle: PreKeyBundle,
myIdentity: IdentityKeyPair,
): X3dhSession {
export function initiateSession(bundle: PreKeyBundle, myIdentity: IdentityKeyPair): X3dhSession {
const theirIdentityRawEd = spkiToRawEd25519PublicKey(fromBase64(bundle.identityPublicKey));
const theirSpkPub = fromBase64(bundle.signedPreKey.publicKey);
const theirSpkSig = fromBase64(bundle.signedPreKey.signature);
Expand Down
Loading