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
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
-- Add UserSession table for JWT revocation and session tracking (issue #172)
CREATE TABLE "UserSession" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"tokenVersion" INTEGER NOT NULL DEFAULT 0,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "UserSession_pkey" PRIMARY KEY ("id")
);

-- Create indexes for efficient session lookups
CREATE INDEX "UserSession_userId_idx" ON "UserSession"("userId");
CREATE INDEX "UserSession_expiresAt_idx" ON "UserSession"("expiresAt");

-- Add foreign key constraint
ALTER TABLE "UserSession" ADD CONSTRAINT "UserSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
18 changes: 18 additions & 0 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ model User {
savedJobs SavedJob[]
notifications Notification[] @relation("UserNotifications")
messagesSent Message[] @relation("MessagesSent")
sessions UserSession[] @relation("UserSessions")
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -331,6 +332,23 @@ model AuthNonce {
@@index([expiresAt])
}

// ---------------------------------------------------------------------------
// UserSession — active JWT sessions for token revocation (issue #172)
// ---------------------------------------------------------------------------
model UserSession {
id String @id @default(cuid())
userId String
user User @relation("UserSessions", fields: [userId], references: [id], onDelete: Cascade)
/// Token version: incremented on logout to invalidate all tokens with lower versions
tokenVersion Int @default(0)
/// Session expires at this time (JWT expiry); after this, the row can be cleaned up
expiresAt DateTime
createdAt DateTime @default(now())

@@index([userId])
@@index([expiresAt])
}

// ---------------------------------------------------------------------------
// Notifications — persistent user alerts for contract and escrow events (#66)
// ---------------------------------------------------------------------------
Expand Down
45 changes: 39 additions & 6 deletions backend/src/lib/receipt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ export interface ReceiptMilestone {
status: string;
deadline: Date | null;
releasedAt: Date | null;
/** Amount released so far for partial releases (issue #171). */
releasedAmount: unknown;
}

/** A dispute as the receipt needs it. */
export interface ReceiptDispute {
freelancerShare: unknown | null;
clientShare: unknown | null;
}

/** The off-chain job behind an escrow, as the receipt needs it. */
Expand All @@ -39,6 +47,7 @@ export interface ReceiptJob {
client: { stellarAddress: string; displayName: string | null } | null;
freelancer: { stellarAddress: string; displayName: string | null } | null;
milestones: ReceiptMilestone[];
disputes?: ReceiptDispute[] | null;
}

/** An indexed contract event, as the receipt needs it (amount in base units). */
Expand Down Expand Up @@ -108,6 +117,15 @@ function normalizeAmount(amount: unknown, decimals: number): string {
* Build the receipt for one escrow from its off-chain job and its indexed
* events. Events are returned oldest-first, which is the order a receipt reads
* in.
*
* Released amount calculation (issue #171):
* - If a milestone has `releasedAmount` set, use that (tracks partial releases)
* - Otherwise, if status is `RELEASED`, use the full `amount`
* - Otherwise, use 0
*
* Dispute resolution: If the job completed via dispute with a resolved share,
* the freelancer received their portion and the client was refunded the rest.
* The receipt reflects the amounts actually paid out, accounting for this split.
*/
export function buildEscrowReceipt(
escrowId: bigint | number | string,
Expand All @@ -117,12 +135,27 @@ export function buildEscrowReceipt(
): EscrowReceipt {
const decimals = decimalsFor(job.asset);

const releasedBase = sumAmounts(
job.milestones
.filter((milestone) => milestone.status === "RELEASED")
.map((milestone) => String(milestone.amount)),
decimals,
);
// Calculate released amount accounting for partial releases and dispute resolution
let releasedBase = 0n;
for (const milestone of job.milestones) {
// Use releasedAmount if available, otherwise check status for full release
const releasedAmountStr = milestone.releasedAmount
? String(milestone.releasedAmount)
: milestone.status === "RELEASED"
? String(milestone.amount)
: "0";
releasedBase += toBaseUnits(releasedAmountStr, decimals);
}

// If dispute was resolved with a split, the freelancer received their share
// and the client was refunded. Adjust the released amount to reflect what
// the freelancer actually received (freelancerShare of the released amount).
const dispute = job.disputes?.[0];
if (dispute && dispute.freelancerShare !== null && dispute.freelancerShare !== undefined) {
const freelancerShare = toBaseUnits(String(dispute.freelancerShare), decimals);
releasedBase = (releasedBase * freelancerShare) / toBaseUnits("100", decimals);
}

const totalBase = toBaseUnits(String(job.totalAmount), decimals);
const outstandingBase = totalBase > releasedBase ? totalBase - releasedBase : 0n;

Expand Down
35 changes: 32 additions & 3 deletions backend/src/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,16 @@
* JWT middleware. Tokens are issued by POST /api/users/auth after the client
* signs a challenge with their Stellar private key (Freighter).
*
* The JWT payload carries { sub: stellarAddress }.
* The JWT payload carries { sub: stellarAddress, v: tokenVersion }.
*
* Sessions are tracked in the UserSession table for revocation support (issue #172).
* When a user logs out or signs in again, their token version is incremented,
* invalidating all tokens with a lower version number.
*/

import { RequestHandler } from "express";
import { jwtVerify } from "jose";
import { prisma } from "../db";
import { config } from "../config";
import { AppError } from "./errorHandler";

Expand All @@ -27,9 +32,33 @@ export const requireAuth: RequestHandler = async (req, res, next) => {
const token = header.slice(7);
const { payload } = await jwtVerify(token, secret);
if (typeof payload.sub !== "string") throw new Error();

// Check if token is revoked by comparing its version against the current session version
const tokenVersion = typeof payload.v === "number" ? payload.v : 0;
const user = await prisma.user.findUnique({
where: { stellarAddress: payload.sub },
select: { id: true, sessions: { select: { tokenVersion: true }, take: 1 } },
});

if (!user) {
throw new AppError(401, "User not found");
}

// If user has active sessions, validate token version
if (user.sessions.length > 0) {
const latestSession = user.sessions[0];
if (tokenVersion < latestSession.tokenVersion) {
throw new AppError(401, "Token has been revoked");
}
}

res.locals.stellarAddress = payload.sub;
next();
} catch {
next(new AppError(401, "Invalid or expired token"));
} catch (err) {
if (err instanceof AppError) {
next(err);
} else {
next(new AppError(401, "Invalid or expired token"));
}
}
};
48 changes: 39 additions & 9 deletions backend/src/routes/jobs.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
/**
* jobs.ts
*
* GET /api/jobs — list jobs (filterable by status, client, freelancer, tag)
* POST /api/jobs — create a job (off-chain record; escrow funded separately)
* GET /api/jobs/tags — the curated skill-tag list
* GET /api/jobs/saved — the caller's bookmarked jobs
* POST /api/jobs/:id/save — bookmark a job
* DELETE /api/jobs/:id/save — remove a bookmark
* GET /api/jobs/:id — get single job with milestones
* PUT /api/jobs/:id/assign — client assigns a freelancer
* GET /api/jobs — list jobs (filterable by status, client, freelancer, tag)
* GET /api/jobs/tags — the curated skill-tag list
* GET /api/jobs/by-escrow/:escrowId — lookup job by on-chain escrow ID (issue #173)
* GET /api/jobs/saved — the caller's bookmarked jobs
* POST /api/jobs/:id/save — bookmark a job
* DELETE /api/jobs/:id/save — remove a bookmark
* POST /api/jobs — create a job (off-chain record; escrow funded separately)
* GET /api/jobs/:id — get single job with milestones
* PUT /api/jobs/:id/assign — client assigns a freelancer
*
* Route order matters: the literal paths (`/tags`, `/saved`) are registered
* Route order matters: the literal paths (`/tags`, `/by-escrow`, `/saved`) are registered
* before `/:id`, or they would be read as job ids.
*/

Expand Down Expand Up @@ -113,6 +114,35 @@ jobsRouter.get("/tags", (_req, res) => {
res.json(SKILL_TAGS);
});

// GET /api/jobs/by-escrow/:escrowId — direct lookup by on-chain escrow ID.
//
// Frontend pages route by the on-chain escrowId, while most backend API routes
// require the off-chain job.id. This endpoint eliminates expensive multi-page
// scans when searching for a job by its escrow (issue #173).
jobsRouter.get("/by-escrow/:escrowId", async (req, res, next) => {
try {
const escrowIdStr = req.params.escrowId;

// Parse the escrow ID as a BigInt. Allow both string and number formats.
let escrowId: bigint;
try {
escrowId = BigInt(escrowIdStr);
} catch {
throw new AppError(400, "Invalid escrow ID format");
}

const job = await prisma.job.findUnique({
where: { escrowId },
include: { milestones: true, client: true, freelancer: true },
});

if (!job) throw new AppError(404, "Job not found");
res.json(job);
} catch (err) {
next(err);
}
});

// GET /api/jobs/saved — the caller's bookmarks, most recently saved first.
//
// Returns job rows (not bookmark rows) so the client can render them with the
Expand Down
39 changes: 38 additions & 1 deletion backend/src/routes/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* users.ts
*
* POST /api/users/auth — issue JWT after verifying a signed Stellar challenge
* POST /api/users/logout — revoke current session (auth required)
* GET /api/users/:addr — get public profile
* GET /api/users/:addr/transactions — on-chain history for the user's escrows
* GET /api/users/:addr/reminders — milestones that are due soon or overdue
Expand All @@ -16,6 +17,9 @@
*
* Nonces are stored in the database (single-use, 60s TTL) so they survive
* restarts and work across instances.
*
* Sessions are tracked in UserSession for token revocation (issue #172). Logout
* increments the token version, invalidating all tokens with a lower version.
*/

import { Router, type Request, type Response, type NextFunction } from "express";
Expand Down Expand Up @@ -82,7 +86,17 @@ usersRouter.post("/auth", validate(authSchema), async (req, res, next) => {
update: {},
});

const token = await new SignJWT({ sub: address })
// Create a new session for token revocation tracking (issue #172)
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
const session = await prisma.userSession.create({
data: {
userId: user.id,
tokenVersion: 0,
expiresAt,
},
});

const token = await new SignJWT({ sub: address, v: session.tokenVersion })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("7d")
.sign(secret);
Expand Down Expand Up @@ -389,3 +403,26 @@ usersRouter.put("/me", requireAuth, validate(updateSchema), async (req, res, nex
next(err);
}
});
// POST /api/users/logout — revoke current session (issue #172)
//
// Increments the token version for the user, invalidating all tokens with a
// lower version. The caller must discard their token after this call.
usersRouter.post("/logout", requireAuth, async (req, res, next) => {
try {
const user = await prisma.user.findUnique({
where: { stellarAddress: res.locals.stellarAddress },
select: { id: true },
});
if (!user) throw new AppError(404, "User not found");

// Increment token version for all sessions, invalidating previous tokens
await prisma.userSession.updateMany({
where: { userId: user.id },
data: { tokenVersion: { increment: 1 } },
});

res.json({ message: "Logged out successfully" });
} catch (err) {
next(err);
}
});
24 changes: 20 additions & 4 deletions frontend/src/lib/activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ function toAssetTotals(totals: Map<string, bigint>): AssetTotal[] {
* "Released" spans both roles on purpose — it is what the address has paid out
* as a client and been paid as a freelancer — so the role counts are reported
* alongside it rather than pretending the number means one of the two.
*
* A milestone can be partially released via `releasedAmount`: the amount
* released so far. Only if `releasedAmount` equals `amount` (or status is
* `RELEASED` with no `releasedAmount` set) is it fully released. Otherwise,
* the released portion goes to released, and the remainder to locked (issue #175).
*/
export function summarizeActivity({
asClient,
Expand All @@ -106,10 +111,21 @@ export function summarizeActivity({

for (const milestone of job.milestones ?? []) {
const base = toBaseUnits(String(milestone.amount), decimals);
if (milestone.status === MILESTONE_RELEASED) {
addTo(released, symbol, base);
} else if (isOpen) {
addTo(locked, symbol, base);

// Account for partial releases: releasedAmount tracks actual payments
const releasedAmountStr = milestone.releasedAmount
? String(milestone.releasedAmount)
: milestone.status === MILESTONE_RELEASED
? String(milestone.amount)
: "0";
const releasedBase = toBaseUnits(releasedAmountStr, decimals);
const lockedBase = base > releasedBase ? base - releasedBase : 0n;

if (releasedBase > 0n) {
addTo(released, symbol, releasedBase);
}
if (lockedBase > 0n && isOpen) {
addTo(locked, symbol, lockedBase);
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ export interface JobMilestoneApi {
status: string;
deadline?: string | null;
releasedAt?: string | null;
/** Amount released so far for partial releases (issue #175). */
releasedAmount?: string | number;
/** Deliverable files a freelancer attached (issue #65). */
deliverables?: DeliverableApi[];
}
Expand Down
Loading