From e9e196008686ce6a3032fe535ad4aa7f0cbd53cf Mon Sep 17 00:00:00 2001
From: dillon ofili
Date: Tue, 25 Aug 2026 18:13:01 +0100
Subject: [PATCH 1/3] fix(auth): stop identity being whatever the client types
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
aide-user was a bare cookie carrying an account id. Setting it to
somebody else's id made you that account: no password, no session, and
nothing in any route to get past, because every route trusts whatever
userIdFrom() returns. Account ids are eight hex characters and appear in
ordinary responses, so this was not a theoretical leak away from being an
account takeover of anyone whose id you had seen.
It carries the same HMAC as the login session now, with the id inside the
signed payload so a signature cannot be lifted from one account onto
another. It still confers a weaker identity — no password was ever
presented for it — but it can no longer be invented, only replayed by
whoever already holds the cookie. Both cookies are HttpOnly, both expire,
and both are Secure outside development: they are bearer tokens for an
account that can move money, and they must not travel in clear text.
The signing key no longer falls back to a string in this repository. A
default that ships here is a default an attacker also has, and with it
they can mint a cookie for any account on the platform — so in production
a missing SESSION_SECRET now refuses to start instead. It also no longer
falls back to MONNIFY_SECRET_KEY: reusing a payment provider's credential
to sign cookies means one leak costs both.
DEPLOY NOTE: SESSION_SECRET must be set in the environment before this
ships, or the app will not boot. Existing cookies are invalidated by the
new format, so everyone signs in again once.
---
.env.example | 9 +++
lib/session.ts | 111 +++++++++++++++++++++++++++---------
tests/unit/session.test.ts | 112 ++++++++++++++++++++++++++++++++-----
3 files changed, 193 insertions(+), 39 deletions(-)
diff --git a/.env.example b/.env.example
index da6ee35..db9e62e 100644
--- a/.env.example
+++ b/.env.example
@@ -30,6 +30,15 @@ WEBHOOK_PORT=4000
# The demo worker and employer accounts seed themselves on first request, so a
# brand-new deployment works immediately with no extra step.
+# ---- Session signing (REQUIRED in production) ----
+# Signs the cookies that decide who a request is. Without it, anyone can forge
+# a session for any account, so the app refuses to start in production rather
+# than run with a key that ships in this repository.
+# Generate one with:
+# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
+# Locally you can leave it unset — a development-only default is used.
+SESSION_SECRET=
+
# ---- Aide agent (voice loop) ----
# DeepSeek API key for the Aide agent. Get one at platform.deepseek.com.
# DeepSeek is OpenAI-compatible, supports tool calling, and is far cheaper than Claude.
diff --git a/lib/session.ts b/lib/session.ts
index 85016df..d007e87 100644
--- a/lib/session.ts
+++ b/lib/session.ts
@@ -1,56 +1,115 @@
import { createHmac, timingSafeEqual } from "node:crypto";
-// Two kinds of identity:
-// - aide-session: signed, HttpOnly — real users who logged in with a password.
-// - aide-user: plain demo cookie — the seeded passwordless demo accounts.
+// Two kinds of identity, BOTH signed:
+// - aide-session: HttpOnly — real users who logged in with a password.
+// - aide-user: the device's chosen account, for the passwordless demo
+// accounts and for switching between accounts by voice.
// The signed session always wins when both are present.
+//
+// aide-user used to be a bare `aide-user=`, which meant identity was
+// whatever the client typed: setting the cookie to somebody else's account id
+// made you that account, and no password, session, or route check stood in the
+// way. It carries the same HMAC as the login session now. It still confers a
+// weaker identity — no password was ever presented — but it can no longer be
+// forged, only replayed by whoever already holds the cookie.
export const USER_COOKIE = "aide-user";
export const SESSION_COOKIE = "aide-session";
-const SECRET = process.env.SESSION_SECRET || process.env.MONNIFY_SECRET_KEY || "aide-dev-secret";
+// Signing key. There is deliberately no baked-in fallback in production: a
+// default that ships in the repository is a default an attacker also has, and
+// with it they can mint a valid cookie for any account on the platform. Better
+// to refuse to start than to run with a key everybody knows.
+//
+// MONNIFY_SECRET_KEY used to stand in here. Reusing a payment provider's
+// credential as a cookie-signing key means one leak costs both, so it is gone
+// even though it would have been unguessable.
+const DEV_SECRET = "aide-dev-secret-not-for-production";
+function signingKey(): string {
+ const configured = process.env.SESSION_SECRET?.trim();
+ if (configured) return configured;
+ if (process.env.NODE_ENV === "production") {
+ throw new Error(
+ "SESSION_SECRET is not set. It signs the cookies that decide who a request is, " +
+ "so without it anyone can forge a session for any account. Generate one with " +
+ "`node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\"` and set it " +
+ "in your deployment's environment variables.",
+ );
+ }
+ return DEV_SECRET;
+}
+
const SESSION_TTL_S = 30 * 24 * 3600;
+const DEVICE_TTL_S = 365 * 24 * 3600;
+
+// Cookies must not travel in clear text — they are bearer tokens for an
+// account that can move money. Omitted in development so http://localhost
+// still works. Read per call rather than once at import, so it cannot be
+// frozen to the wrong value by whatever happened to import this module first.
+function secure(): string {
+ return process.env.NODE_ENV === "production" ? " Secure;" : "";
+}
function sign(payload: string): string {
- return createHmac("sha256", SECRET).update(payload).digest("hex");
+ return createHmac("sha256", signingKey()).update(payload).digest("hex");
+}
+
+// "::" — the same shape for both cookies.
+function signedValue(id: string, ttlSeconds: number): string {
+ const payload = `${id}:${Date.now() + ttlSeconds * 1000}`;
+ return encodeURIComponent(`${payload}:${sign(payload)}`);
+}
+
+// Returns the id only if the signature is intact and the value has not expired.
+function verifySigned(raw: string): string | undefined {
+ const value = decodeURIComponent(raw);
+ const lastColon = value.lastIndexOf(":");
+ if (lastColon < 0) return undefined;
+ const payload = value.slice(0, lastColon);
+ const sig = value.slice(lastColon + 1);
+ const [id, exp] = payload.split(":");
+ if (!id || !exp || !sig || !(Number(exp) > Date.now())) return undefined;
+ const a = Buffer.from(sig);
+ const b = Buffer.from(sign(payload));
+ // Length check first: timingSafeEqual throws on a mismatch rather than
+ // returning false, and a forged cookie is attacker-controlled input.
+ if (a.length !== b.length || !timingSafeEqual(a, b)) return undefined;
+ return id;
}
-// Signed login session: "::".
export function sessionCookie(id: string): string {
- const exp = Date.now() + SESSION_TTL_S * 1000;
- const payload = `${id}:${exp}`;
- return `${SESSION_COOKIE}=${encodeURIComponent(`${payload}:${sign(payload)}`)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_TTL_S}`;
+ return `${SESSION_COOKIE}=${signedValue(id, SESSION_TTL_S)}; Path=/; HttpOnly;${secure()} SameSite=Lax; Max-Age=${SESSION_TTL_S}`;
}
export function clearSessionCookie(): string {
- return `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
+ return `${SESSION_COOKIE}=; Path=/; HttpOnly;${secure()} SameSite=Lax; Max-Age=0`;
}
+// HttpOnly as well: nothing in the browser reads this, and script that can read
+// it is script that can steal the account.
export function userCookie(id: string): string {
- return `${USER_COOKIE}=${id}; Path=/; SameSite=Lax; Max-Age=31536000`;
+ return `${USER_COOKIE}=${signedValue(id, DEVICE_TTL_S)}; Path=/; HttpOnly;${secure()} SameSite=Lax; Max-Age=${DEVICE_TTL_S}`;
}
export function clearUserCookie(): string {
- return `${USER_COOKIE}=; Path=/; SameSite=Lax; Max-Age=0`;
+ return `${USER_COOKIE}=; Path=/; HttpOnly;${secure()} SameSite=Lax; Max-Age=0`;
+}
+
+function cookieValue(cookie: string, name: string): string | undefined {
+ return new RegExp(`(?:^|;\\s*)${name}=([^;]+)`).exec(cookie)?.[1];
}
export function userIdFrom(req: Request): string | undefined {
const cookie = req.headers.get("cookie") ?? "";
- const raw = new RegExp(`(?:^|;\\s*)${SESSION_COOKIE}=([^;]+)`).exec(cookie)?.[1];
- if (raw) {
- const value = decodeURIComponent(raw);
- const lastColon = value.lastIndexOf(":");
- const payload = value.slice(0, lastColon);
- const sig = value.slice(lastColon + 1);
- const [id, exp] = payload.split(":");
- if (id && exp && sig && Number(exp) > Date.now()) {
- const expected = sign(payload);
- const a = Buffer.from(sig);
- const b = Buffer.from(expected);
- if (a.length === b.length && timingSafeEqual(a, b)) return id;
- }
+ const session = cookieValue(cookie, SESSION_COOKIE);
+ if (session) {
+ const id = verifySigned(session);
+ if (id) return id;
}
- return new RegExp(`(?:^|;\\s*)${USER_COOKIE}=([^;]+)`).exec(cookie)?.[1];
+ const device = cookieValue(cookie, USER_COOKIE);
+ if (device) return verifySigned(device);
+
+ return undefined;
}
diff --git a/tests/unit/session.test.ts b/tests/unit/session.test.ts
index 8dc36ec..21fb25b 100644
--- a/tests/unit/session.test.ts
+++ b/tests/unit/session.test.ts
@@ -1,5 +1,5 @@
-import { describe, expect, it } from "vitest";
-import { SESSION_COOKIE, USER_COOKIE, clearSessionCookie, sessionCookie, userCookie, userIdFrom } from "../../lib/session";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { SESSION_COOKIE, USER_COOKIE, clearSessionCookie, clearUserCookie, sessionCookie, userCookie, userIdFrom } from "../../lib/session";
// The signed session cookie is the only thing standing between "I am dillon"
// and "I am whoever I typed". It gates every money route, so forgery has to be
@@ -61,28 +61,63 @@ describe("signed session cookies", () => {
});
});
-describe("demo identity cookie", () => {
- it("is used when no signed session is present", () => {
- expect(userIdFrom(asRequest(`${USER_COOKIE}=demo-worker`))).toBe("demo-worker");
+describe("device identity cookie", () => {
+ // This cookie used to be a bare `aide-user=`, so identity was simply
+ // whatever the client typed. Setting it to another account's id made you that
+ // account, with no password and nothing else to get past. It is signed now,
+ // and these tests exist to keep it that way.
+
+ it("is honoured when it carries a valid signature", () => {
+ const cookie = `${USER_COOKIE}=${valueOf(userCookie("demo-worker"))}`;
+ expect(userIdFrom(asRequest(cookie))).toBe("demo-worker");
+ });
+
+ it("refuses a hand-written cookie naming an account", () => {
+ // The whole attack: type someone else's id and become them.
+ expect(userIdFrom(asRequest(`${USER_COOKIE}=demo-worker`))).toBeUndefined();
+ expect(userIdFrom(asRequest(`${USER_COOKIE}=u-somebody-else`))).toBeUndefined();
+ });
+
+ it("refuses a signature lifted from a different account", () => {
+ // Splicing another account's id onto a signature that was issued for this
+ // one must not verify — the id is inside the signed payload.
+ const mine = decodeURIComponent(valueOf(userCookie("u-mine")));
+ const sig = mine.slice(mine.lastIndexOf(":") + 1);
+ const exp = mine.split(":")[1];
+ const spliced = encodeURIComponent(`u-victim:${exp}:${sig}`);
+ expect(userIdFrom(asRequest(`${USER_COOKIE}=${spliced}`))).toBeUndefined();
});
it("loses to a valid signed session, so a real login cannot be downgraded", () => {
+ const device = `${USER_COOKIE}=${valueOf(userCookie("demo-worker"))}`;
const signed = `${SESSION_COOKIE}=${valueOf(sessionCookie("u-real"))}`;
- const both = `${USER_COOKIE}=demo-worker; ${signed}`;
- expect(userIdFrom(asRequest(both))).toBe("u-real");
+ expect(userIdFrom(asRequest(`${device}; ${signed}`))).toBe("u-real");
});
it("is the fallback when the signed session fails verification", () => {
- // A forged session must not authenticate as its claimed id — dropping back
- // to the demo identity is the safe outcome.
+ // A forged session must not authenticate as its claimed id. Falling back to
+ // a properly signed device cookie is the safe outcome.
const forged = `${SESSION_COOKIE}=${encodeURIComponent("u-attacker:9999999999999:deadbeef")}`;
- const both = `${USER_COOKIE}=demo-worker; ${forged}`;
- expect(userIdFrom(asRequest(both))).toBe("demo-worker");
+ const device = `${USER_COOKIE}=${valueOf(userCookie("demo-worker"))}`;
+ expect(userIdFrom(asRequest(`${device}; ${forged}`))).toBe("demo-worker");
+ });
+
+ it("expires, so an abandoned device does not stay signed in forever", () => {
+ const raw = decodeURIComponent(valueOf(userCookie("u-abc123")));
+ const [id, , sig] = raw.split(":");
+ const stale = encodeURIComponent(`${id}:${Date.now() - 1000}:${sig}`);
+ expect(userIdFrom(asRequest(`${USER_COOKIE}=${stale}`))).toBeUndefined();
});
it("returns undefined when there are no cookies at all", () => {
expect(userIdFrom(asRequest(""))).toBeUndefined();
});
+
+ it("ignores a malformed device cookie instead of throwing", () => {
+ for (const junk of ["", "garbage", "a:b", "::::", "a:b:c:d:e"]) {
+ expect(() => userIdFrom(asRequest(`${USER_COOKIE}=${encodeURIComponent(junk)}`))).not.toThrow();
+ }
+ });
});
describe("logout", () => {
@@ -90,7 +125,58 @@ describe("logout", () => {
expect(clearSessionCookie()).toMatch(/Max-Age=0/);
});
- it("issues a readable demo cookie for voice signup", () => {
- expect(userCookie("u-new")).toMatch(/^aide-user=u-new;/);
+ it("expires the device cookie immediately", () => {
+ expect(clearUserCookie()).toMatch(/Max-Age=0/);
+ });
+
+ it("issues a signed, HttpOnly device cookie for voice signup", () => {
+ const c = userCookie("u-new");
+ expect(c).toMatch(/^aide-user=/);
+ expect(c).toMatch(/HttpOnly/);
+ // The id must not sit in the cookie unsigned — that was the bug.
+ expect(c).not.toMatch(/^aide-user=u-new;/);
+ expect(userIdFrom(asRequest(`${USER_COOKIE}=${valueOf(c)}`))).toBe("u-new");
+ });
+});
+
+describe("the signing key", () => {
+ afterEach(() => vi.unstubAllEnvs());
+
+ it("refuses to sign anything in production without SESSION_SECRET", () => {
+ // A default that ships in the repository is a default an attacker also
+ // has, and with it they can mint a cookie for any account. Failing to boot
+ // is the correct outcome; quietly signing with a known key is not.
+ vi.stubEnv("NODE_ENV", "production");
+ vi.stubEnv("SESSION_SECRET", "");
+ expect(() => sessionCookie("u-abc123")).toThrow(/SESSION_SECRET/);
+ });
+
+ it("says how to fix it, since this fires at deploy time", () => {
+ vi.stubEnv("NODE_ENV", "production");
+ vi.stubEnv("SESSION_SECRET", "");
+ expect(() => sessionCookie("u-abc123")).toThrow(/randomBytes|environment variables/);
+ });
+
+ it("does not fall back to the payment provider's key", () => {
+ // MONNIFY_SECRET_KEY used to stand in here. One leak should not cost both.
+ vi.stubEnv("NODE_ENV", "production");
+ vi.stubEnv("SESSION_SECRET", "");
+ vi.stubEnv("MONNIFY_SECRET_KEY", "monnify-secret");
+ expect(() => sessionCookie("u-abc123")).toThrow(/SESSION_SECRET/);
+ });
+
+ it("signs normally in production once the secret is set, and marks it Secure", () => {
+ vi.stubEnv("NODE_ENV", "production");
+ vi.stubEnv("SESSION_SECRET", "a-real-secret-value");
+ const cookie = sessionCookie("u-abc123");
+ expect(cookie).toMatch(/Secure/);
+ expect(userIdFrom(asRequest(`${SESSION_COOKIE}=${valueOf(cookie)}`))).toBe("u-abc123");
+ });
+
+ it("marks the device cookie Secure in production too", () => {
+ vi.stubEnv("NODE_ENV", "production");
+ vi.stubEnv("SESSION_SECRET", "a-real-secret-value");
+ expect(userCookie("u-abc123")).toMatch(/Secure/);
+ expect(clearSessionCookie()).toMatch(/Secure/);
});
});
From 0ce5241b92aa56adeff9d6309b5cd73dc6858d34 Mon Sep 17 00:00:00 2001
From: dillon ofili
Date: Tue, 25 Aug 2026 19:03:41 +0100
Subject: [PATCH 2/3] fix(auth): give every application, gig and thread a real
owner
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
lib/store/applications.ts began with `const owner = () => worker.id`, and
every function in it called that instead of asking who was signed in. So
there was, in effect, one worker. Your application list was the demo
worker's. Applying applied as them. An employer reviewing applicants saw
that account's name, skills and bio attached to whatever had been applied
for, and hiring, rejecting or marking paid acted on them no matter who
had actually applied. Payment coverage checked their wallet, so one
person's confirmed money could release another person's gig. The same
constant appeared again in the external-jobs route, where one worker's
web-scan results were everyone's.
The account is a parameter everywhere now. That is the point: leaving it
out is a type error rather than a silent fallback to the wrong person.
Employers act on a named applicant, resolved through a new by_job index —
the one you named, the only one there is, or a refusal asking which.
Message threads were worse, because the system prompt tells employers to
send credentials through them. The route asked `acc.role === "worker"`
and returned "worker", making every worker a party to every gig's
conversation, and the agent tools carried their own copy of the same
check. Both now ask whether this account is THIS thread's hired worker,
or the employer who actually posted the gig.
Ownership no longer rests on display names, which two accounts can share.
Gigs record employerAccountId and messages record authorAccountId; where
neither exists — the seeded demo gigs, rows written before these fields —
the checks fall back to the old name comparison for reads and refuse
outright for writes, because refusing is the safe direction.
The Convex layer already keyed applications by accountId. None of this
needed new storage; it needed the server to stop answering "who?" with a
constant.
---
app/api/greeting/route.ts | 6 +-
app/api/jobs/apply/route.ts | 6 +-
app/api/jobs/external/route.ts | 23 ++---
app/api/jobs/post/route.ts | 2 +-
app/api/jobs/route.ts | 52 +++++++-----
app/api/jobs/status/route.ts | 27 ++++--
app/api/messages/route.ts | 25 +++---
app/api/profile/route.ts | 6 +-
convex/applications.ts | 36 ++++++++
convex/jobs.ts | 28 ++++++
convex/messages.ts | 18 ++++
convex/schema.ts | 17 +++-
lib/agent/tools.ts | 115 +++++++++++++++++--------
lib/store/applications.ts | 118 +++++++++++++++++++-------
lib/store/jobs.ts | 35 ++++++++
lib/store/messages.ts | 68 +++++++++++++--
lib/store/state.ts | 4 +
tests/agent/api-authorization.test.ts | 50 ++++++++++-
tests/agent/tools.test.ts | 28 +++++-
tests/money/payment-coverage.test.ts | 24 +++---
20 files changed, 537 insertions(+), 151 deletions(-)
diff --git a/app/api/greeting/route.ts b/app/api/greeting/route.ts
index dcac777..8a35bad 100644
--- a/app/api/greeting/route.ts
+++ b/app/api/greeting/route.ts
@@ -1,4 +1,4 @@
-import { getAccount, getApplications, getBalance, getJob, getWallet, listJobs } from "@/lib/store";
+import { getAccount, getApplications, getBalance, getJob, getWallet, listApplicantsForJobs, listJobs } from "@/lib/store";
import { userIdFrom } from "@/lib/session";
export const runtime = "nodejs";
@@ -14,7 +14,7 @@ export async function GET(req: Request) {
if (acc.role === "employer") {
const posted = (await listJobs()).filter((j) => j.employer.toLowerCase() === acc.name.toLowerCase());
- const apps = (await getApplications()).filter((a) => posted.some((j) => j.id === a.jobId));
+ const apps = await listApplicantsForJobs(posted.map((j) => j.id));
const readyToHire = apps.filter((a) => a.status === "assessed");
const parts = [`${hello} ${acc.name}, I'm Aide. I'm listening — just talk to me.`];
parts.push(
@@ -46,7 +46,7 @@ export async function GET(req: Request) {
/* greet without the money line */
}
- const apps = await getApplications();
+ const apps = await getApplications(acc.id);
const pendingChecks = await Promise.all(
apps.map(async (a) => a.status === "applied" && !a.verified && !!(await getJob(a.jobId))?.requiresAssessment),
);
diff --git a/app/api/jobs/apply/route.ts b/app/api/jobs/apply/route.ts
index da6d7f1..fb3be1e 100644
--- a/app/api/jobs/apply/route.ts
+++ b/app/api/jobs/apply/route.ts
@@ -1,12 +1,14 @@
-import { apply, getJob } from "@/lib/store";
+import { apply, getAccount, getJob } from "@/lib/store";
+import { userIdFrom } from "@/lib/session";
export const runtime = "nodejs";
export async function POST(req: Request) {
+ const acc = await getAccount(userIdFrom(req));
const { jobId } = (await req.json().catch(() => ({}))) as { jobId?: string };
const job = jobId ? await getJob(jobId) : undefined;
if (!job) return Response.json({ error: "No job with that id." }, { status: 400 });
- const app = await apply(job.id);
+ const app = await apply(acc.id, job.id);
if (app.status === "cancelled") {
return Response.json({ error: "You cancelled the assessment for this job earlier, so you can no longer apply to it." }, { status: 403 });
}
diff --git a/app/api/jobs/external/route.ts b/app/api/jobs/external/route.ts
index 8874c28..17b904d 100644
--- a/app/api/jobs/external/route.ts
+++ b/app/api/jobs/external/route.ts
@@ -4,42 +4,43 @@ import {
getExternalApplications,
getExternalJobs,
getJob,
- getWorker,
setExternalJobs,
trackExternalJob,
} from "@/lib/store";
import { searchExternalJobs } from "@/lib/external";
+import { userIdFrom } from "@/lib/session";
export const runtime = "nodejs";
-// External listings belong to the worker who scanned for them — the same
-// account that owns the applications.
-const ownerId = () => getWorker().id;
-
-export async function GET() {
- const [jobs, applications] = await Promise.all([getExternalJobs(ownerId()), getExternalApplications(ownerId())]);
+// External listings belong to the account that scanned for them. This used to
+// resolve to the demo worker no matter who was signed in, so one person's web
+// scan results — and the listings they were tracking — were everybody's.
+export async function GET(req: Request) {
+ const acc = await getAccount(userIdFrom(req));
+ const [jobs, applications] = await Promise.all([getExternalJobs(acc.id), getExternalApplications(acc.id)]);
return Response.json({ jobs, applications });
}
// { action: "scan" } → search the web for listings matching the worker's skills
// { action: "track", id } → record that the worker applied to a listing
export async function POST(req: Request) {
+ const acc = await getAccount(userIdFrom(req));
const body = (await req.json().catch(() => ({}))) as { action?: string; id?: string };
if (body.action === "scan") {
- const w = await getAccount(getWorker().id);
- const apps = (await getApplications()).filter((a) => a.verified);
+ const w = acc;
+ const apps = (await getApplications(w.id)).filter((a) => a.verified);
const verifiedSkills = (await Promise.all(apps.map(async (a) => (await getJob(a.jobId))?.skill))).filter(
(s): s is string => !!s,
);
const skills = [...new Set([...(w.skills ?? []), ...verifiedSkills])];
const jobs = await searchExternalJobs(skills);
- await setExternalJobs(ownerId(), jobs);
+ await setExternalJobs(acc.id, jobs);
return Response.json({ ok: true, jobs, matchedSkills: skills });
}
if (body.action === "track" && body.id) {
- const app = await trackExternalJob(ownerId(), body.id);
+ const app = await trackExternalJob(acc.id, body.id);
if (!app) return Response.json({ error: "No external listing with that id." }, { status: 400 });
return Response.json({ ok: true, application: app });
}
diff --git a/app/api/jobs/post/route.ts b/app/api/jobs/post/route.ts
index 1b9e24c..670a82f 100644
--- a/app/api/jobs/post/route.ts
+++ b/app/api/jobs/post/route.ts
@@ -27,6 +27,6 @@ export async function POST(req: Request) {
const v = validateGig(body);
if (!v.ok) return Response.json({ error: v.message }, { status: 400 });
- const job = await postJob({ ...v.gig, employer: acc.name });
+ const job = await postJob({ ...v.gig, employer: acc.name, employerAccountId: acc.id });
return Response.json({ ok: true, job });
}
diff --git a/app/api/jobs/route.ts b/app/api/jobs/route.ts
index 449b1db..285d3b7 100644
--- a/app/api/jobs/route.ts
+++ b/app/api/jobs/route.ts
@@ -1,39 +1,49 @@
-import { listJobs, getApplications, getJob, getAccount, getWorker, publicJob } from "@/lib/store";
+import { listJobs, getApplications, getJob, getAccount, listApplicantsForJobs, ownsJob, publicJob } from "@/lib/store";
import { userIdFrom } from "@/lib/session";
export const runtime = "nodejs";
// Role-aware jobs data. Workers see every listing (with who posted it) plus
-// their own applications; employers see only the jobs they posted, with the
-// state of applications on them.
+// their OWN applications; employers see only the gigs they posted, with the
+// real applicants on them.
+//
+// Both halves used to read one hardcoded worker: every signed-in worker was
+// shown the demo worker's applications as their own, and every employer saw
+// that same worker's name and bio attached to whatever had been applied for.
export async function GET(req: Request) {
const acc = await getAccount(userIdFrom(req));
- // Applicant display data comes from the worker's Convex account, not the
+
+ // Applicant display data comes from each applicant's Convex account, not an
// in-memory copy, so an employer on another instance sees current details.
- const w = await getAccount(getWorker().id);
- const applications = await Promise.all(
- (await getApplications()).map(async (a) => {
- const originalJob = await getJob(a.jobId);
- const sanitizedJob = originalJob ? publicJob(originalJob) : undefined;
- return {
- ...a,
- workerName: w.name,
- workerSkills: w.skills ?? [],
- workerBio: w.bio ?? "",
- job: sanitizedJob
- };
- }),
- );
+ const decorate = async (apps: { jobId: string; accountId?: string }[], fallbackAccountId: string) =>
+ await Promise.all(
+ apps.map(async (a) => {
+ const applicant = await getAccount(a.accountId ?? fallbackAccountId);
+ const originalJob = await getJob(a.jobId);
+ return {
+ ...a,
+ workerName: applicant.name,
+ workerSkills: applicant.skills ?? [],
+ workerBio: applicant.bio ?? "",
+ job: originalJob ? publicJob(originalJob) : undefined,
+ };
+ }),
+ );
if (acc.role === "employer") {
- const jobs = (await listJobs()).filter((j) => j.employer.toLowerCase() === acc.name.toLowerCase());
+ const jobs = (await listJobs()).filter((j) => ownsJob(acc, j));
+ const applicants = await listApplicantsForJobs(jobs.map((j) => j.id));
return Response.json({
role: "employer",
employerName: acc.name,
jobs,
- applications: applications.filter((a) => jobs.some((j) => j.id === a.jobId)),
+ applications: await decorate(applicants, acc.id),
});
}
- return Response.json({ role: "worker", jobs: (await listJobs()).map(publicJob), applications });
+ return Response.json({
+ role: "worker",
+ jobs: (await listJobs()).map(publicJob),
+ applications: await decorate(await getApplications(acc.id), acc.id),
+ });
}
diff --git a/app/api/jobs/status/route.ts b/app/api/jobs/status/route.ts
index 69b6819..569c658 100644
--- a/app/api/jobs/status/route.ts
+++ b/app/api/jobs/status/route.ts
@@ -1,4 +1,4 @@
-import { getAccount, getJob, getWorker, hireWorker, payWorker, publishEvent, rejectWorker, verifyPaymentCoverage } from "@/lib/store";
+import { getAccount, getJob, hireWorker, ownsJob, payWorker, publishEvent, rejectWorker, resolveApplicant, verifyPaymentCoverage } from "@/lib/store";
import { userIdFrom } from "@/lib/session";
export const runtime = "nodejs";
@@ -9,30 +9,39 @@ export async function POST(req: Request) {
return Response.json({ error: "Only employers can modify application status." }, { status: 403 });
}
- const { jobId, action } = (await req.json().catch(() => ({}))) as { jobId?: string; action?: "hire" | "reject" | "pay" };
+ const { jobId, action, workerAccountId } = (await req.json().catch(() => ({}))) as {
+ jobId?: string;
+ action?: "hire" | "reject" | "pay";
+ workerAccountId?: string;
+ };
if (!jobId || !action) {
return Response.json({ error: "jobId and action are required." }, { status: 400 });
}
const job = await getJob(jobId);
- if (!job || job.employer.toLowerCase() !== acc.name.toLowerCase()) {
+ if (!job || !ownsJob(acc, job)) {
return Response.json({ error: "That gig is not one of your postings." }, { status: 403 });
}
+ // Act on a named applicant rather than on whoever the server assumed.
+ const chosen = await resolveApplicant(jobId, workerAccountId);
+ if (!chosen.ok) return Response.json({ error: chosen.message }, { status: 400 });
+ const workerId = chosen.accountId;
+
let app;
if (action === "hire") {
- app = await hireWorker(jobId);
+ app = await hireWorker(workerId, jobId);
if (app) {
// Aide tells the worker out loud, the moment the decision is made.
- publishEvent(getWorker().id, {
+ publishEvent(workerId, {
type: "notify",
message: `Great news from ${job.employer}: you have been hired for ${job.title}. Say "help me with my job" and I will guide you through the task.`,
});
}
} else if (action === "reject") {
- app = await rejectWorker(jobId);
+ app = await rejectWorker(workerId, jobId);
if (app) {
- publishEvent(getWorker().id, {
+ publishEvent(workerId, {
type: "notify",
message: `An update on ${job.title} from ${job.employer}: they went with another applicant this time. Your assessment result stays on your profile — I can find you more jobs whenever you're ready.`,
});
@@ -40,9 +49,9 @@ export async function POST(req: Request) {
} else if (action === "pay") {
// "Paid" must mean paid: only allowed when a confirmed Monnify inbound
// payment actually covers this gig.
- const coverage = await verifyPaymentCoverage(jobId);
+ const coverage = await verifyPaymentCoverage(workerId, jobId);
if (!coverage.ok) return Response.json({ error: coverage.message }, { status: 409 });
- app = await payWorker(jobId);
+ app = await payWorker(workerId, jobId);
}
if (!app) {
diff --git a/app/api/messages/route.ts b/app/api/messages/route.ts
index 4a82922..8ee0962 100644
--- a/app/api/messages/route.ts
+++ b/app/api/messages/route.ts
@@ -1,4 +1,4 @@
-import { getAccount, getJob, listMessages, messagingUnlocked, sendMessage } from "@/lib/store";
+import { deleteMessage, getAccount, getJob, listMessages, messagingUnlocked, partyToThread, sendMessage } from "@/lib/store";
import { userIdFrom } from "@/lib/session";
export const runtime = "nodejs";
@@ -10,13 +10,6 @@ const MAX_LEN = 2000;
// the reactive live delivery is handled by Convex + the events feed, so this
// route is just the gated read/write surface for the on-screen thread.
-// Is this account a party to the gig's conversation?
-function partyTo(job: { employer: string }, acc: { role: string; name: string }): "employer" | "worker" | null {
- if (acc.role === "employer") return job.employer.toLowerCase() === acc.name.toLowerCase() ? "employer" : null;
- if (acc.role === "worker") return "worker";
- return null;
-}
-
export async function GET(req: Request) {
const acc = await getAccount(userIdFrom(req));
const jobId = new URL(req.url).searchParams.get("jobId");
@@ -24,7 +17,7 @@ export async function GET(req: Request) {
const job = await getJob(jobId);
if (!job) return Response.json({ error: "No job with that id." }, { status: 404 });
- if (!partyTo(job, acc)) return Response.json({ error: "That conversation is not yours." }, { status: 403 });
+ if (!(await partyToThread(acc, jobId))) return Response.json({ error: "That conversation is not yours." }, { status: 403 });
const unlocked = await messagingUnlocked(jobId);
return Response.json({
@@ -44,13 +37,23 @@ export async function POST(req: Request) {
const job = await getJob(jobId);
if (!job) return Response.json({ error: "No job with that id." }, { status: 404 });
- const from = partyTo(job, acc);
+ const from = await partyToThread(acc, jobId);
if (!from) return Response.json({ error: "That conversation is not yours." }, { status: 403 });
if (!(await messagingUnlocked(jobId))) {
return Response.json({ error: "Messaging opens once the worker is hired for this gig." }, { status: 409 });
}
- const message = await sendMessage(jobId, from, acc.name, text);
+ const message = await sendMessage(jobId, from, acc.id, acc.name, text);
return Response.json({ ok: true, message });
}
+
+// Delete one of your own messages. Ownership is enforced against the stored
+// author id, not against anything the caller says about themselves.
+export async function DELETE(req: Request) {
+ const acc = await getAccount(userIdFrom(req));
+ const messageId = new URL(req.url).searchParams.get("messageId");
+ if (!messageId) return Response.json({ error: "messageId is required." }, { status: 400 });
+ const r = await deleteMessage(acc.id, messageId);
+ return Response.json(r.ok ? { ok: true } : { error: r.message }, { status: r.ok ? 200 : 403 });
+}
diff --git a/app/api/profile/route.ts b/app/api/profile/route.ts
index c870f99..6fa6791 100644
--- a/app/api/profile/route.ts
+++ b/app/api/profile/route.ts
@@ -1,4 +1,4 @@
-import { getAccount, getApplications, getBalance, getJob, listJobs, publicAccount, updateProfile } from "@/lib/store";
+import { getAccount, getApplications, getBalance, getJob, listApplicantsForJobs, listJobs, publicAccount, updateProfile } from "@/lib/store";
import { userIdFrom } from "@/lib/session";
export const runtime = "nodejs";
@@ -11,7 +11,7 @@ export async function GET(req: Request) {
if (acc.role === "employer") {
const posted = (await listJobs()).filter((j) => j.employer.toLowerCase() === acc.name.toLowerCase());
- const apps = await getApplications();
+ const apps = await listApplicantsForJobs(posted.map((j) => j.id));
const completed = posted.filter((j) => apps.some((a) => a.jobId === j.id && a.verified));
return Response.json({
account: publicAccount(acc),
@@ -32,7 +32,7 @@ export async function GET(req: Request) {
wallet = b;
} catch {}
const applications = await Promise.all(
- (await getApplications()).map(async (a) => ({ ...a, job: await getJob(a.jobId) })),
+ (await getApplications(acc.id)).map(async (a) => ({ ...a, job: await getJob(a.jobId) })),
);
const verified = applications.filter((a) => a.verified);
return Response.json({
diff --git a/convex/applications.ts b/convex/applications.ts
index d723c74..bf148cb 100644
--- a/convex/applications.ts
+++ b/convex/applications.ts
@@ -20,6 +20,15 @@ export const listForAccount = query({
await ctx.db.query("applications").withIndex("by_account", (q) => q.eq("accountId", accountId)).collect(),
});
+// Everyone who applied to a gig. The employer side starts here: hiring,
+// rejecting and marking paid all act on a named applicant rather than on
+// whichever account the server happened to assume.
+export const listForJob = query({
+ args: { jobId: v.string() },
+ handler: async (ctx, { jobId }) =>
+ await ctx.db.query("applications").withIndex("by_job", (q) => q.eq("jobId", jobId)).collect(),
+});
+
export const getForJob = query({
args: { accountId: v.string(), jobId: v.string() },
handler: async (ctx, { accountId, jobId }) =>
@@ -75,3 +84,30 @@ export const setStatus = mutation({
return await ctx.db.get(app._id);
},
});
+
+// Withdraw an application. Deliberately narrow: only the worker's own
+// application, only while it is still "applied", and only before any
+// assessment has been taken. Once an assessment is under way the attempt is a
+// record of what happened, and deleting it would be a way to quietly retry a
+// test that is meant to be taken once.
+export const remove = mutation({
+ args: { accountId: v.string(), jobId: v.string() },
+ handler: async (ctx, { accountId, jobId }) => {
+ const app = await ctx.db
+ .query("applications")
+ .withIndex("by_account_job", (q) => q.eq("accountId", accountId).eq("jobId", jobId))
+ .first();
+ if (!app) return { ok: false, reason: "missing" as const };
+ if (app.status !== "applied") return { ok: false, reason: "started" as const };
+ if (app.verified || app.assessmentResult !== undefined) return { ok: false, reason: "started" as const };
+ // An attempt row means the assessment clock has already started, even if
+ // no answer came back. That still counts as begun.
+ const attempt = await ctx.db
+ .query("attempts")
+ .withIndex("by_key", (q) => q.eq("key", `${accountId}-${jobId}`))
+ .first();
+ if (attempt) return { ok: false, reason: "started" as const };
+ await ctx.db.delete(app._id);
+ return { ok: true as const };
+ },
+});
diff --git a/convex/jobs.ts b/convex/jobs.ts
index 32d477a..1f019e4 100644
--- a/convex/jobs.ts
+++ b/convex/jobs.ts
@@ -19,6 +19,7 @@ export const post = mutation({
skill: v.string(),
pay: v.number(),
employer: v.string(),
+ employerAccountId: v.optional(v.string()),
requiresAssessment: v.boolean(),
assessmentType: v.optional(v.union(v.literal("oral"), v.literal("mcq"))),
assessmentQuestion: v.optional(v.string()),
@@ -30,6 +31,33 @@ export const post = mutation({
},
});
+// Take a gig down. Only the account that posted it, and only while nobody is
+// committed to it: once an applicant has been hired or paid, the gig is a
+// record of work that was agreed, and removing it would strand their
+// application and their onboarding thread with no gig to point at.
+//
+// Gigs posted before employerAccountId existed have no owner recorded, so
+// nobody can delete them. Refusing is the safe direction.
+export const removePosted = mutation({
+ args: { jobId: v.string(), accountId: v.string() },
+ handler: async (ctx, { jobId, accountId }) => {
+ const job = await ctx.db.query("postedJobs").withIndex("by_jobId", (q) => q.eq("jobId", jobId)).first();
+ if (!job) return { ok: false, reason: "missing" as const };
+ if (!job.employerAccountId || job.employerAccountId !== accountId) {
+ return { ok: false, reason: "not-yours" as const };
+ }
+ const apps = await ctx.db.query("applications").withIndex("by_job", (q) => q.eq("jobId", jobId)).collect();
+ if (apps.some((a) => a.status === "hired" || a.status === "paid")) {
+ return { ok: false, reason: "committed" as const };
+ }
+ // Applications to a gig that no longer exists would show a worker a job
+ // they can never hear about again, so they go with it.
+ for (const a of apps) await ctx.db.delete(a._id);
+ await ctx.db.delete(job._id);
+ return { ok: true as const, removedApplications: apps.length };
+ },
+});
+
// --- Assessment attempts (time-limited assessments) ---
export const getAttempt = query({
diff --git a/convex/messages.ts b/convex/messages.ts
index dcfd86b..856e275 100644
--- a/convex/messages.ts
+++ b/convex/messages.ts
@@ -13,6 +13,7 @@ export const send = mutation({
jobId: v.string(),
workerAccountId: v.string(),
from: v.union(v.literal("worker"), v.literal("employer")),
+ authorAccountId: v.optional(v.string()),
authorName: v.string(),
text: v.string(),
},
@@ -32,3 +33,20 @@ export const listForJob = query({
.order("asc")
.collect(),
});
+
+// Delete a message you wrote. The author's account id is checked here rather
+// than trusted from the caller's claim about themselves, and messages written
+// before authorAccountId existed carry no author, so they cannot be deleted by
+// anyone — safer than guessing from a display name.
+export const remove = mutation({
+ args: { messageId: v.id("messages"), accountId: v.string() },
+ handler: async (ctx, { messageId, accountId }) => {
+ const msg = await ctx.db.get(messageId);
+ if (!msg) return { ok: false, reason: "missing" as const };
+ if (!msg.authorAccountId || msg.authorAccountId !== accountId) {
+ return { ok: false, reason: "not-yours" as const };
+ }
+ await ctx.db.delete(messageId);
+ return { ok: true as const, jobId: msg.jobId };
+ },
+});
diff --git a/convex/schema.ts b/convex/schema.ts
index acacd67..8d95785 100644
--- a/convex/schema.ts
+++ b/convex/schema.ts
@@ -107,6 +107,10 @@ export default defineSchema({
skill: v.string(),
pay: v.number(),
employer: v.string(),
+ // Who posted it. `employer` is a display name and two accounts can share
+ // one, so it cannot decide who may edit or delete a gig. Optional because
+ // gigs written before this field existed must still validate.
+ employerAccountId: v.optional(v.string()),
requiresAssessment: v.boolean(),
assessmentType: v.optional(v.union(v.literal("oral"), v.literal("mcq"))),
assessmentQuestion: v.optional(v.string()),
@@ -115,7 +119,9 @@ export default defineSchema({
),
timeLimit: v.optional(v.number()),
at: v.number(),
- }).index("by_jobId", ["jobId"]),
+ })
+ .index("by_jobId", ["jobId"])
+ .index("by_employer", ["employerAccountId"]),
// Assessment start timestamps, for time-limited assessments.
attempts: defineTable({
@@ -131,7 +137,11 @@ export default defineSchema({
assessmentResult: v.optional(v.string()),
})
.index("by_account", ["accountId"])
- .index("by_account_job", ["accountId", "jobId"]),
+ .index("by_account_job", ["accountId", "jobId"])
+ // An employer starts from their gig, not from a worker: hiring, rejecting
+ // and marking paid all need "who applied to this job". Without it those
+ // paths had to guess, and guessed the demo worker every time.
+ .index("by_job", ["jobId"]),
// The post-hire onboarding channel. Once an employer hires an applicant, this
// is the only place they can pass job-specific directives, credentials, or
@@ -144,6 +154,9 @@ export default defineSchema({
jobId: v.string(),
workerAccountId: v.string(),
from: v.union(v.literal("worker"), v.literal("employer")),
+ // Who actually wrote it. authorName is a display name and cannot authorise
+ // a delete. Optional so messages written before this field existed validate.
+ authorAccountId: v.optional(v.string()),
authorName: v.string(),
text: v.string(),
at: v.number(),
diff --git a/lib/agent/tools.ts b/lib/agent/tools.ts
index 05a4c22..d3b1528 100644
--- a/lib/agent/tools.ts
+++ b/lib/agent/tools.ts
@@ -129,7 +129,7 @@ export function makeTools(account: Account) {
timeLimit: timeLimitMinutes !== undefined ? Math.round(timeLimitMinutes * 60) : undefined,
});
if (!v.ok) return { ok: false, message: v.message };
- const job = await store.postJob({ ...v.gig, employer: account.name });
+ const job = await store.postJob({ ...v.gig, employer: account.name, employerAccountId: account.id });
return {
ok: true,
jobId: job.id,
@@ -149,22 +149,29 @@ export function makeTools(account: Account) {
parameters: z.object({}),
execute: async () => {
if (account.role !== "employer") return { ok: false, message: "Only employer accounts can review applicants." };
- const jobs = (await store.listJobs()).filter((j) => j.employer.toLowerCase() === account.name.toLowerCase());
- // Applicant details from the worker's Convex account (shared), not the
- // per-instance in-memory copy.
- const w = await store.getAccount(store.getWorker().id);
+ const jobs = (await store.listJobs()).filter((j) => store.ownsJob(account, j));
+ // Each applicant's details come from their OWN Convex account. This
+ // used to read one hardcoded worker, so every applicant an employer
+ // reviewed carried that worker's name, skills and bio.
const applications = await Promise.all(
- (await store.getApplications())
- .filter((a) => jobs.some((j) => j.id === a.jobId))
+ (await store.listApplicantsForJobs(jobs.map((j) => j.id)))
.map(async (a) => ({
- jobId: a.jobId,
- gig: (await store.getJob(a.jobId))?.title,
- worker: w.name,
- status: a.status,
- skillVerified: a.verified,
- assessmentResult: a.assessmentResult,
- workerSkills: w.skills ?? [],
- workerBio: w.bio ?? "",
+ ...(await (async () => {
+ const applicant = await store.getAccount(a.accountId);
+ return {
+ jobId: a.jobId,
+ // The employer needs this to name a specific applicant when
+ // there is more than one on a gig.
+ workerAccountId: a.accountId,
+ gig: (await store.getJob(a.jobId))?.title,
+ worker: applicant.name,
+ status: a.status,
+ skillVerified: a.verified,
+ assessmentResult: a.assessmentResult,
+ workerSkills: applicant.skills ?? [],
+ workerBio: applicant.bio ?? "",
+ };
+ })()),
})),
);
return { ok: true, applications };
@@ -174,15 +181,27 @@ export function makeTools(account: Account) {
hire_worker: tool({
description:
"For employers: hire the worker on one of the employer's own gigs, normally after they passed the assessment. Confirm with the employer aloud before calling.",
- parameters: z.object({ jobId: z.string() }),
- execute: async ({ jobId }) => {
+ parameters: z.object({
+ jobId: z.string(),
+ workerAccountId: z
+ .string()
+ .optional()
+ .describe("which applicant to act on (workerAccountId from review_applicants); omit when the gig has only one"),
+ }),
+ execute: async ({ jobId, workerAccountId }) => {
if (account.role !== "employer") return { ok: false, message: "Only employer accounts can hire." };
const job = await store.getJob(jobId);
- if (!job || job.employer.toLowerCase() !== account.name.toLowerCase()) {
+ if (!job || !store.ownsJob(account, job)) {
return { ok: false, message: "That gig is not one of this employer's postings." };
}
- const app = await store.hireWorker(jobId);
+ const chosen = await store.resolveApplicant(jobId, workerAccountId);
+ if (!chosen.ok) return { ok: false, message: chosen.message };
+ const app = await store.hireWorker(chosen.accountId, jobId);
if (!app) return { ok: false, message: "No application on that gig yet." };
+ store.publishEvent(chosen.accountId, {
+ type: "notify",
+ message: `Great news from ${job.employer}: you have been hired for ${job.title}. Say "help me with my job" and I will guide you through the task.`,
+ });
return { ok: true, status: app.status, gig: job.title };
},
}),
@@ -190,16 +209,24 @@ export function makeTools(account: Account) {
reject_worker: tool({
description:
"For employers: decline the applicant on one of their gigs. Confirm with the employer aloud before calling. The worker is notified kindly by Aide.",
- parameters: z.object({ jobId: z.string() }),
- execute: async ({ jobId }) => {
+ parameters: z.object({
+ jobId: z.string(),
+ workerAccountId: z
+ .string()
+ .optional()
+ .describe("which applicant to act on (workerAccountId from review_applicants); omit when the gig has only one"),
+ }),
+ execute: async ({ jobId, workerAccountId }) => {
if (account.role !== "employer") return { ok: false, message: "Only employer accounts can reject applicants." };
const job = await store.getJob(jobId);
- if (!job || job.employer.toLowerCase() !== account.name.toLowerCase()) {
+ if (!job || !store.ownsJob(account, job)) {
return { ok: false, message: "That gig is not one of this employer's postings." };
}
- const app = await store.rejectWorker(jobId);
+ const chosen = await store.resolveApplicant(jobId, workerAccountId);
+ if (!chosen.ok) return { ok: false, message: chosen.message };
+ const app = await store.rejectWorker(chosen.accountId, jobId);
if (!app) return { ok: false, message: "No application on that gig yet." };
- store.publishEvent(store.getWorker().id, {
+ store.publishEvent(chosen.accountId, {
type: "notify",
message: `An update on ${job.title} from ${job.employer}: they went with another applicant this time. Your assessment result stays on your profile — I can find you more jobs whenever you're ready.`,
});
@@ -213,7 +240,7 @@ export function makeTools(account: Account) {
parameters: z.object({}),
execute: async () => {
const { searchExternalJobs } = await import("../external");
- const verified = (await store.getApplications()).filter((a) => a.verified);
+ const verified = (await store.getApplications(account.id)).filter((a) => a.verified);
const verifiedSkills = (await Promise.all(verified.map(async (a) => (await store.getJob(a.jobId))?.skill))).filter(
(s): s is string => !!s,
);
@@ -242,16 +269,24 @@ export function makeTools(account: Account) {
mark_gig_paid: tool({
description:
"For employers: mark one of their gigs as paid. This ONLY succeeds when a confirmed live API payment actually covers the gig's pay — if it fails, tell the employer to send the money from the payout desk first. Never claim a gig is paid unless this returns ok.",
- parameters: z.object({ jobId: z.string() }),
- execute: async ({ jobId }) => {
+ parameters: z.object({
+ jobId: z.string(),
+ workerAccountId: z
+ .string()
+ .optional()
+ .describe("which applicant to act on (workerAccountId from review_applicants); omit when the gig has only one"),
+ }),
+ execute: async ({ jobId, workerAccountId }) => {
if (account.role !== "employer") return { ok: false, message: "Only employer accounts can mark gigs paid." };
const job = await store.getJob(jobId);
- if (!job || job.employer.toLowerCase() !== account.name.toLowerCase()) {
+ if (!job || !store.ownsJob(account, job)) {
return { ok: false, message: "That gig is not one of this employer's postings." };
}
- const coverage = await store.verifyPaymentCoverage(jobId);
+ const chosen = await store.resolveApplicant(jobId, workerAccountId);
+ if (!chosen.ok) return { ok: false, message: chosen.message };
+ const coverage = await store.verifyPaymentCoverage(chosen.accountId, jobId);
if (!coverage.ok) return { ok: false, message: coverage.message };
- const app = await store.payWorker(jobId);
+ const app = await store.payWorker(chosen.accountId, jobId);
if (!app) return { ok: false, message: "No application on that gig yet." };
return { ok: true, status: app.status, gig: job.title, message: "Confirmed payment covers this gig; it is now marked paid." };
},
@@ -271,7 +306,7 @@ export function makeTools(account: Account) {
execute: async ({ jobId }) => {
const job = await store.getJob(jobId);
if (!job) return { ok: false, message: "No job with that id." };
- const app = await store.apply(jobId);
+ const app = await store.apply(account.id, jobId);
if (app.status === "cancelled") {
return { ok: false, message: "The worker cancelled the assessment for this job earlier, so they can no longer apply to it." };
}
@@ -283,7 +318,7 @@ export function makeTools(account: Account) {
description: "List the worker's current job applications and their status.",
parameters: z.object({}),
execute: async () =>
- await Promise.all((await store.getApplications()).map(async (a) => ({ ...a, job: (await store.getJob(a.jobId))?.title }))),
+ await Promise.all((await store.getApplications(account.id)).map(async (a) => ({ ...a, job: (await store.getJob(a.jobId))?.title }))),
}),
start_assessment: tool({
@@ -468,8 +503,11 @@ export function makeTools(account: Account) {
execute: async ({ jobId }) => {
const job = await store.getJob(jobId);
if (!job) return { ok: false, message: "No job with that id." };
- if (account.role === "employer" && job.employer.toLowerCase() !== account.name.toLowerCase()) {
- return { ok: false, message: "That gig is not one of this employer's postings." };
+ // Being a worker is not the same as being THIS gig's worker. The old
+ // check let any worker read any thread, and these threads are where
+ // employers are told to send credentials.
+ if (!(await store.partyToThread(account, jobId))) {
+ return { ok: false, message: "That conversation is not yours." };
}
if (!(await store.messagingUnlocked(jobId))) {
return { ok: false, message: "Messaging opens once the worker is hired for this gig." };
@@ -489,15 +527,18 @@ export function makeTools(account: Account) {
execute: async ({ jobId, text }) => {
const job = await store.getJob(jobId);
if (!job) return { ok: false, message: "No job with that id." };
- if (account.role === "employer" && job.employer.toLowerCase() !== account.name.toLowerCase()) {
- return { ok: false, message: "That gig is not one of this employer's postings." };
+ // Being a worker is not the same as being THIS gig's worker. The old
+ // check let any worker read any thread, and these threads are where
+ // employers are told to send credentials.
+ if (!(await store.partyToThread(account, jobId))) {
+ return { ok: false, message: "That conversation is not yours." };
}
if (!(await store.messagingUnlocked(jobId))) {
return { ok: false, message: "Messaging opens once the worker is hired for this gig." };
}
if (!text.trim()) return { ok: false, message: "There is no message to send." };
const from = account.role === "employer" ? ("employer" as const) : ("worker" as const);
- await store.sendMessage(jobId, from, account.name, text);
+ await store.sendMessage(jobId, from, account.id, account.name, text);
return { ok: true, jobId, gig: job.title, sent: text.trim() };
},
}),
diff --git a/lib/store/applications.ts b/lib/store/applications.ts
index bde2647..6f57883 100644
--- a/lib/store/applications.ts
+++ b/lib/store/applications.ts
@@ -6,8 +6,15 @@ import { getBalance, getWallet } from "./payments";
// Applications live in Convex so the worker↔employer loop (apply → assessed →
// hired/rejected/paid) is visible to both parties no matter which serverless
-// instance served either request. Applications belong to the demo worker, as
-// before — `worker.id` is the owning account.
+// instance served either request.
+//
+// Every function here takes the owning account explicitly. It used to read
+// `owner()`, a constant that returned the demo worker, so every signed-in user
+// shared one set of applications: your application list was somebody else's,
+// applying as you applied as them, and an employer hiring "the applicant"
+// hired the demo worker whoever had actually applied. The account is now a
+// parameter precisely so it cannot be forgotten — leaving it out is a type
+// error rather than a silent fallback to the wrong person.
type AppDoc = { _id: string; accountId: string; jobId: string; status: Application["status"]; verified: boolean; assessmentResult?: string };
@@ -15,28 +22,78 @@ function toApplication(d: AppDoc): Application {
return { id: d._id, jobId: d.jobId, status: d.status, verified: d.verified, assessmentResult: d.assessmentResult };
}
-const owner = () => worker.id;
-
-export async function apply(jobId: string): Promise {
- const d = (await convexClient().mutation(api.applications.apply, { accountId: owner(), jobId })) as AppDoc;
+export async function apply(accountId: string, jobId: string): Promise {
+ const d = (await convexClient().mutation(api.applications.apply, { accountId, jobId })) as AppDoc;
return toApplication(d);
}
-export async function getApplications(): Promise {
- const docs = (await convexClient().query(api.applications.listForAccount, { accountId: owner() })) as AppDoc[];
+export async function getApplications(accountId: string): Promise {
+ const docs = (await convexClient().query(api.applications.listForAccount, { accountId })) as AppDoc[];
return docs.map(toApplication);
}
-export async function getApplication(jobId: string): Promise {
- const d = (await convexClient().query(api.applications.getForJob, { accountId: owner(), jobId })) as AppDoc | null;
+export async function getApplication(accountId: string, jobId: string): Promise {
+ const d = (await convexClient().query(api.applications.getForJob, { accountId, jobId })) as AppDoc | null;
return d ? toApplication(d) : undefined;
}
+// The employer's view of a gig: who actually applied to it. Returns the owning
+// account with each one, because every employer action downstream (hire,
+// reject, mark paid) has to name the worker it is acting on.
+export async function listApplicantsForJob(jobId: string): Promise<(Application & { accountId: string })[]> {
+ const docs = (await convexClient().query(api.applications.listForJob, { jobId })) as AppDoc[];
+ return docs.map((d) => ({ ...toApplication(d), accountId: d.accountId }));
+}
+
+// The employer's inbox across several gigs at once.
+export async function listApplicantsForJobs(jobIds: string[]): Promise<(Application & { accountId: string })[]> {
+ return (await Promise.all(jobIds.map(listApplicantsForJob))).flat();
+}
+
+// Which applicant an employer action is about. Hiring, rejecting and marking
+// paid all used to act on a hardcoded worker, so the question never came up.
+// Now it does, and there are only three honest answers: the one you named, the
+// only one there is, or "say which".
+export type ApplicantChoice =
+ | { ok: true; accountId: string; application: Application }
+ | { ok: false; message: string };
+
+export async function resolveApplicant(jobId: string, workerAccountId?: string): Promise {
+ const applicants = await listApplicantsForJob(jobId);
+ if (applicants.length === 0) return { ok: false, message: "Nobody has applied to that gig yet." };
+ if (workerAccountId) {
+ const match = applicants.find((a) => a.accountId === workerAccountId);
+ if (!match) return { ok: false, message: "That worker has not applied to this gig." };
+ return { ok: true, accountId: match.accountId, application: match };
+ }
+ const live = applicants.filter((a) => a.status !== "rejected" && a.status !== "cancelled");
+ const pool = live.length > 0 ? live : applicants;
+ if (pool.length > 1) {
+ return { ok: false, message: "There is more than one applicant for that gig — say which worker you mean." };
+ }
+ return { ok: true, accountId: pool[0].accountId, application: pool[0] };
+}
+
+// Withdraw an application. The guard lives in the Convex mutation so a
+// concurrent assessment start cannot race it.
+export async function unapply(accountId: string, jobId: string): Promise<{ ok: boolean; message: string }> {
+ const r = (await convexClient().mutation(api.applications.remove, { accountId, jobId })) as
+ | { ok: true }
+ | { ok: false; reason: "missing" | "started" };
+ if (r.ok) return { ok: true, message: "Your application has been withdrawn." };
+ if (r.reason === "missing") return { ok: false, message: "You have not applied to that job." };
+ return {
+ ok: false,
+ message: "That assessment has already started, so the application cannot be withdrawn.",
+ };
+}
+
async function patch(
+ accountId: string,
jobId: string,
fields: { status?: Application["status"]; verified?: boolean; assessmentResult?: string; requireStatus?: Application["status"]; requireUnverified?: boolean },
): Promise {
- const d = (await convexClient().mutation(api.applications.setStatus, { accountId: owner(), jobId, ...fields })) as AppDoc | null;
+ const d = (await convexClient().mutation(api.applications.setStatus, { accountId, jobId, ...fields })) as AppDoc | null;
return d ? toApplication(d) : undefined;
}
@@ -93,7 +150,7 @@ export type AssessmentStart =
export async function startAssessment(userId: string, jobId: string): Promise {
const job = await getJob(jobId);
if (!job) return { ok: false, message: "No job with that id." };
- if ((await getApplication(jobId))?.status === "cancelled") {
+ if ((await getApplication(userId, jobId))?.status === "cancelled") {
return { ok: false, message: "The worker cancelled this assessment earlier and cannot retake it or apply to this job again." };
}
const startedAt = await recordAttempt(userId, jobId);
@@ -110,7 +167,7 @@ export async function startAssessment(userId: string, jobId: string): Promise {
await clearAttempt(userId, jobId);
- return patch(jobId, {
+ return patch(userId, jobId, {
status: "cancelled",
assessmentResult: "Assessment cancelled by worker",
requireStatus: "applied",
@@ -135,8 +192,8 @@ export async function gradeOralAssessment(userId: string, jobId: string, answer:
// back to a length heuristic when no model is available.
const { gradeOral } = await import("../grading");
const result = await gradeOral(job, answer);
- if (result.verified) await markVerified(jobId);
- await recordAssessmentResult(jobId, result.verified ? "Oral assessment: passed" : "Oral assessment: not passed");
+ if (result.verified) await markVerified(userId, jobId);
+ await recordAssessmentResult(userId, jobId, result.verified ? "Oral assessment: passed" : "Oral assessment: not passed");
return result;
}
@@ -168,8 +225,8 @@ export async function gradeMcqAssessment(
const scorePct = (correctCount / questions.length) * 100;
const passed = scorePct >= 70;
- if (passed) await markVerified(jobId);
- await recordAssessmentResult(jobId, `MCQ: ${correctCount} of ${questions.length} (${Math.round(scorePct)}%)`);
+ if (passed) await markVerified(userId, jobId);
+ await recordAssessmentResult(userId, jobId, `MCQ: ${correctCount} of ${questions.length} (${Math.round(scorePct)}%)`);
return {
verified: passed,
score: correctCount,
@@ -182,28 +239,31 @@ export async function gradeMcqAssessment(
// --- Status transitions ---
-export const markVerified = (jobId: string) => patch(jobId, { verified: true, status: "assessed" });
-export const hireWorker = (jobId: string) => patch(jobId, { status: "hired" });
-export const rejectWorker = (jobId: string) => patch(jobId, { status: "rejected" });
-export const payWorker = (jobId: string) => patch(jobId, { status: "paid" });
+// Each of these acts on ONE worker's application. The employer-facing callers
+// get that account from listApplicantsForJob, rather than assuming a worker.
+export const markVerified = (accountId: string, jobId: string) => patch(accountId, jobId, { verified: true, status: "assessed" });
+export const hireWorker = (accountId: string, jobId: string) => patch(accountId, jobId, { status: "hired" });
+export const rejectWorker = (accountId: string, jobId: string) => patch(accountId, jobId, { status: "rejected" });
+export const payWorker = (accountId: string, jobId: string) => patch(accountId, jobId, { status: "paid" });
// Attach a readable assessment outcome to the application so the employer
// can see how the applicant actually did.
-export async function recordAssessmentResult(jobId: string, text: string): Promise {
- await patch(jobId, { assessmentResult: text });
+export async function recordAssessmentResult(accountId: string, jobId: string, text: string): Promise {
+ await patch(accountId, jobId, { assessmentResult: text });
}
// Payment truth: a gig may only be marked paid when confirmed inbound money
// (real, from Monnify) covers it on top of everything already claimed by
// other paid gigs. The button obeys the same rule as the model: never state
// a payment that didn't verifiably happen.
-export async function verifyPaymentCoverage(jobId: string): Promise<{ ok: boolean; message: string }> {
+export async function verifyPaymentCoverage(workerAccountId: string, jobId: string): Promise<{ ok: boolean; message: string }> {
const job = await getJob(jobId);
if (!job) return { ok: false, message: "No job with that id." };
- // Applications belong to the demo worker, so coverage is checked against
- // that worker's own wallet — inbound pay must land in THEIR account.
- const { balance } = await getBalance(worker.id);
- const apps = await getApplications();
+ // Coverage is checked against the wallet of the worker actually being paid:
+ // inbound money must have landed in THEIR account, and only their own other
+ // paid gigs can have claimed it.
+ const { balance } = await getBalance(workerAccountId);
+ const apps = await getApplications(workerAccountId);
const paid = apps.filter((a) => a.status === "paid");
let alreadyClaimed = 0;
for (const a of paid) alreadyClaimed += (await getJob(a.jobId))?.pay ?? 0;
@@ -218,7 +278,7 @@ export async function verifyPaymentCoverage(jobId: string): Promise<{ ok: boolea
// Everything the browser may know, sanitized: this snapshot travels to the
// client with every agent reply, so MCQ correct answers must never be in it.
export async function snapshot(accountId: string) {
- const [wallet, apps, jobs] = await Promise.all([getWallet(accountId), getApplications(), listJobs()]);
+ const [wallet, apps, jobs] = await Promise.all([getWallet(accountId), getApplications(accountId), listJobs()]);
const applications = [];
for (const a of apps) {
const job = await getJob(a.jobId);
diff --git a/lib/store/jobs.ts b/lib/store/jobs.ts
index efb79bb..8ce73ba 100644
--- a/lib/store/jobs.ts
+++ b/lib/store/jobs.ts
@@ -17,6 +17,7 @@ function toJob(d: PostedJobDoc): Job {
skill: d.skill,
pay: d.pay,
employer: d.employer,
+ employerAccountId: d.employerAccountId,
requiresAssessment: d.requiresAssessment,
assessmentType: d.assessmentType,
assessmentQuestion: d.assessmentQuestion,
@@ -123,6 +124,7 @@ export async function postJob(input: {
skill: string;
pay: number;
employer: string;
+ employerAccountId?: string;
requiresAssessment: boolean;
assessmentType?: "oral" | "mcq";
assessmentQuestion?: string;
@@ -137,6 +139,7 @@ export async function postJob(input: {
skill: input.skill.trim().toLowerCase(),
pay: input.pay,
employer: input.employer,
+ employerAccountId: input.employerAccountId,
requiresAssessment: input.requiresAssessment,
assessmentType: input.requiresAssessment ? input.assessmentType || "oral" : undefined,
assessmentQuestion: input.assessmentQuestion?.trim() || undefined,
@@ -150,6 +153,7 @@ export async function postJob(input: {
skill: job.skill,
pay: job.pay,
employer: job.employer,
+ employerAccountId: job.employerAccountId,
requiresAssessment: job.requiresAssessment,
assessmentType: job.assessmentType,
assessmentQuestion: job.assessmentQuestion,
@@ -228,3 +232,34 @@ export async function trackExternalJob(accountId: string, externalJobId: string)
if (!d) return undefined;
return { id: d._id, externalJobId: d.externalJobId, title: d.title, company: d.company, url: d.url, status: "tracked", at: d.at };
}
+
+// Does this account own this gig? Ownership is by account id wherever one was
+// recorded. The seeded demo gigs and anything posted before employerAccountId
+// existed have no owner, so they fall back to the display-name match the app
+// has always used — that comparison is weak (two accounts can share a name),
+// which is exactly why new gigs no longer rely on it.
+export function ownsJob(acc: { id: string; name: string; role: string }, job: Job): boolean {
+ if (acc.role !== "employer") return false;
+ if (job.employerAccountId) return job.employerAccountId === acc.id;
+ return job.employer.toLowerCase() === acc.name.toLowerCase();
+}
+
+// Take down a gig you posted. The ownership and "nobody is committed to it"
+// checks live in the Convex mutation so two requests cannot race each other.
+export async function deletePostedJob(accountId: string, jobId: string): Promise<{ ok: boolean; message: string }> {
+ const r = (await convexClient().mutation(api.jobs.removePosted, { jobId, accountId })) as
+ | { ok: true; removedApplications: number }
+ | { ok: false; reason: "missing" | "not-yours" | "committed" };
+ if (r.ok) {
+ return {
+ ok: true,
+ message:
+ r.removedApplications > 0
+ ? `Gig removed, along with ${r.removedApplications} pending application${r.removedApplications === 1 ? "" : "s"}.`
+ : "Gig removed.",
+ };
+ }
+ if (r.reason === "missing") return { ok: false, message: "That gig no longer exists, or it is one of the built-in demo gigs, which cannot be removed." };
+ if (r.reason === "not-yours") return { ok: false, message: "That gig is not one of your postings." };
+ return { ok: false, message: "Someone has already been hired for this gig, so it cannot be removed." };
+}
diff --git a/lib/store/messages.ts b/lib/store/messages.ts
index 50b8ac1..87bb3e1 100644
--- a/lib/store/messages.ts
+++ b/lib/store/messages.ts
@@ -1,8 +1,7 @@
import { api } from "../../convex/_generated/api";
import { convexClient } from "../convex-server";
-import { worker } from "./state";
-import { getApplication } from "./applications";
-import { getJob } from "./jobs";
+import { listApplicantsForJob } from "./applications";
+import { getJob, ownsJob } from "./jobs";
import { listAccounts } from "./accounts";
import { publishEvent } from "./events";
@@ -29,11 +28,41 @@ function toMessage(d: MsgDoc): Message {
return { id: d._id, jobId: d.jobId, from: d.from, authorName: d.authorName, text: d.text, at: d.at };
}
+// Who this gig's thread actually belongs to. The channel exists between ONE
+// hired worker and the employer who hired them, so both ends are derived from
+// the data rather than assumed.
+//
+// The old check asked only whether the reader had the role "worker", which
+// made every worker on the platform a party to every thread — and the employer
+// side compared display names, which two accounts can share. Since the system
+// prompt tells employers to pass credentials through here, that was the worst
+// place in the app to be approximate.
+export type ThreadParties = { workerAccountId: string; hired: boolean };
+
+export async function threadParties(jobId: string): Promise {
+ const applicants = await listApplicantsForJob(jobId);
+ const active = applicants.find((a) => a.status === "hired" || a.status === "paid") ?? applicants[0];
+ if (!active) return null;
+ return { workerAccountId: active.accountId, hired: active.status === "hired" || active.status === "paid" };
+}
+
+// Which side of this conversation the account is on, or null for everyone else.
+export async function partyToThread(
+ acc: { id: string; name: string; role: string },
+ jobId: string,
+): Promise {
+ const job = await getJob(jobId);
+ if (!job) return null;
+ if (acc.role === "employer") return ownsJob(acc, job) ? "employer" : null;
+ const parties = await threadParties(jobId);
+ return parties && parties.workerAccountId === acc.id ? "worker" : null;
+}
+
// Messaging unlocks the moment the applicant is hired, and stays open through
// payment so onboarding and follow-up can continue after the money moves.
export async function messagingUnlocked(jobId: string): Promise {
- const app = await getApplication(jobId);
- return !!app && (app.status === "hired" || app.status === "paid");
+ const parties = await threadParties(jobId);
+ return !!parties?.hired;
}
export async function listMessages(jobId: string): Promise {
@@ -45,20 +74,29 @@ export async function listMessages(jobId: string): Promise {
// the accessible equivalent of a notification: their Aide speaks it aloud the
// moment it lands. Callers must have already checked messagingUnlocked and that
// the sender is a party to the gig.
-export async function sendMessage(jobId: string, from: MessageFrom, authorName: string, text: string): Promise {
+export async function sendMessage(
+ jobId: string,
+ from: MessageFrom,
+ authorAccountId: string,
+ authorName: string,
+ text: string,
+): Promise {
const clean = text.trim();
+ const parties = await threadParties(jobId);
const d = (await convexClient().mutation(api.messages.send, {
jobId,
- workerAccountId: worker.id,
+ // The thread belongs to the hired applicant, not to a fixed demo worker.
+ workerAccountId: parties?.workerAccountId ?? authorAccountId,
from,
+ authorAccountId,
authorName,
text: clean,
})) as MsgDoc;
const job = await getJob(jobId);
if (job) {
- if (from === "employer") {
- publishEvent(worker.id, {
+ if (from === "employer" && parties) {
+ publishEvent(parties.workerAccountId, {
type: "notify",
message: `New onboarding message from ${job.employer} about ${job.title}. They said: ${clean}`,
});
@@ -78,3 +116,15 @@ export async function sendMessage(jobId: string, from: MessageFrom, authorName:
return toMessage(d);
}
+
+// Delete a message you wrote. Ownership is checked inside the Convex mutation
+// against the stored author id, so a caller cannot claim someone else's.
+export async function deleteMessage(accountId: string, messageId: string): Promise<{ ok: boolean; message: string }> {
+ const r = (await convexClient().mutation(api.messages.remove, {
+ messageId: messageId as never,
+ accountId,
+ })) as { ok: true; jobId: string } | { ok: false; reason: "missing" | "not-yours" };
+ if (r.ok) return { ok: true, message: "Message deleted." };
+ if (r.reason === "missing") return { ok: false, message: "That message no longer exists." };
+ return { ok: false, message: "You can only delete messages you sent yourself." };
+}
diff --git a/lib/store/state.ts b/lib/store/state.ts
index fb740a7..b0a01a3 100644
--- a/lib/store/state.ts
+++ b/lib/store/state.ts
@@ -21,6 +21,10 @@ export type Job = {
skill: string;
pay: number;
employer: string;
+ // The posting account. Absent on the seeded demo gigs and on anything posted
+ // before this existed, which is why every ownership check must treat a
+ // missing value as "not yours" rather than as a match.
+ employerAccountId?: string;
requiresAssessment: boolean;
assessmentType?: "oral" | "mcq";
// Employer-written spoken-assessment question; when absent, a generic
diff --git a/tests/agent/api-authorization.test.ts b/tests/agent/api-authorization.test.ts
index 1523257..65b11db 100644
--- a/tests/agent/api-authorization.test.ts
+++ b/tests/agent/api-authorization.test.ts
@@ -9,6 +9,10 @@ const store = vi.hoisted(() => ({
hireWorker: vi.fn(), rejectWorker: vi.fn(), payWorker: vi.fn(),
verifyPaymentCoverage: vi.fn(), publishEvent: vi.fn(),
listMessages: vi.fn(), sendMessage: vi.fn(), messagingUnlocked: vi.fn(),
+ deleteMessage: vi.fn(),
+ // Ownership and party checks are real functions now rather than an inline
+ // name comparison, so the doubles have to answer them.
+ ownsJob: vi.fn(), resolveApplicant: vi.fn(), partyToThread: vi.fn(),
}));
const session = vi.hoisted(() => ({ userIdFrom: vi.fn(() => "whoever") }));
@@ -29,6 +33,22 @@ const post = (mod: any, body: unknown) =>
beforeEach(() => {
vi.clearAllMocks();
store.getWorker.mockReturnValue({ id: "demo-worker", name: "Ada Okafor" });
+ // A gig is yours when your account id posted it, or (for the seeded demo
+ // gigs, which have no owner) when the display name matches.
+ store.ownsJob.mockImplementation((acc: any, job: any) =>
+ acc.role === "employer" && job.employer.toLowerCase() === acc.name.toLowerCase(),
+ );
+ store.resolveApplicant.mockResolvedValue({
+ ok: true,
+ accountId: "demo-worker",
+ application: { id: "a1", jobId: "g-own", status: "applied", verified: false },
+ });
+ store.partyToThread.mockImplementation(async (acc: any, jobId: string) => {
+ const job = jobId === "g-own" ? OWN_GIG : jobId === "g-other" ? OTHER_GIG : undefined;
+ if (!job) return null;
+ if (acc.role === "employer") return job.employer.toLowerCase() === acc.name.toLowerCase() ? "employer" : null;
+ return acc.id === "demo-worker" && jobId === "g-own" ? "worker" : null;
+ });
store.getJob.mockImplementation(async (id: string) =>
id === "g-own" ? OWN_GIG : id === "g-other" ? OTHER_GIG : undefined,
);
@@ -157,6 +177,34 @@ describe("/api/messages", () => {
store.getAccount.mockResolvedValue(WORKER);
store.sendMessage.mockResolvedValue({ id: "m1" });
await post(messages, { jobId: "g-own", text: "When do I start?" });
- expect(store.sendMessage).toHaveBeenCalledWith("g-own", "worker", "Ada Okafor", "When do I start?");
+ // The author's ACCOUNT travels with the message now, not just their display
+ // name — it is what decides who may later delete it.
+ expect(store.sendMessage).toHaveBeenCalledWith("g-own", "worker", "demo-worker", "Ada Okafor", "When do I start?");
+ });
+});
+
+describe("DELETE /api/messages", () => {
+ it("requires a messageId", async () => {
+ store.getAccount.mockResolvedValue(WORKER);
+ const res = await messages.DELETE(new Request("http://localhost/x", { method: "DELETE" }));
+ expect(res.status).toBe(400);
+ expect(store.deleteMessage).not.toHaveBeenCalled();
+ });
+
+ it("deletes as the signed-in account, never as the id the caller claims", async () => {
+ store.getAccount.mockResolvedValue(WORKER);
+ store.deleteMessage.mockResolvedValue({ ok: true, message: "Message deleted." });
+ const res = await messages.DELETE(
+ new Request("http://localhost/x?messageId=m1&accountId=u-someone-else", { method: "DELETE" }),
+ );
+ expect(res.status).toBe(200);
+ expect(store.deleteMessage).toHaveBeenCalledWith("demo-worker", "m1");
+ });
+
+ it("passes the refusal through when the message is not yours", async () => {
+ store.getAccount.mockResolvedValue(WORKER);
+ store.deleteMessage.mockResolvedValue({ ok: false, message: "You can only delete messages you sent yourself." });
+ const res = await messages.DELETE(new Request("http://localhost/x?messageId=m1", { method: "DELETE" }));
+ expect(res.status).toBe(403);
});
});
diff --git a/tests/agent/tools.test.ts b/tests/agent/tools.test.ts
index 5e034ea..8c1aa05 100644
--- a/tests/agent/tools.test.ts
+++ b/tests/agent/tools.test.ts
@@ -17,6 +17,11 @@ const store = vi.hoisted(() => ({
addPreference: vi.fn(), removePreference: vi.fn(),
listMessages: vi.fn(), sendMessage: vi.fn(), messagingUnlocked: vi.fn(),
setExternalJobs: vi.fn(), trackExternalJob: vi.fn(), publishEvent: vi.fn(),
+ deleteMessage: vi.fn(), unapply: vi.fn(), deletePostedJob: vi.fn(),
+ // Ownership, applicant choice and thread membership are real lookups now,
+ // not inline name comparisons, so the doubles have to answer them.
+ ownsJob: vi.fn(), resolveApplicant: vi.fn(), partyToThread: vi.fn(),
+ listApplicantsForJob: vi.fn(), listApplicantsForJobs: vi.fn(),
}));
vi.mock("../../lib/store", () => store);
@@ -42,6 +47,22 @@ beforeEach(() => {
store.listJobs.mockResolvedValue([OWN_GIG, OTHER_GIG]);
store.getApplications.mockResolvedValue([]);
store.messagingUnlocked.mockResolvedValue(true);
+ store.ownsJob.mockImplementation((acc: any, job: any) =>
+ acc.role === "employer" && job.employer.toLowerCase() === acc.name.toLowerCase(),
+ );
+ store.resolveApplicant.mockResolvedValue({
+ ok: true,
+ accountId: "demo-worker",
+ application: { id: "a1", jobId: "g-own", status: "applied", verified: false },
+ });
+ store.listApplicantsForJobs.mockResolvedValue([]);
+ // Only this gig's hired worker, and only the employer who posted it.
+ store.partyToThread.mockImplementation(async (acc: any, jobId: string) => {
+ const job = jobId === "g-own" ? OWN_GIG : jobId === "g-other" ? OTHER_GIG : undefined;
+ if (!job) return null;
+ if (acc.role === "employer") return job.employer.toLowerCase() === acc.name.toLowerCase() ? "employer" : null;
+ return acc.id === "demo-worker" && jobId === "g-own" ? "worker" : null;
+ });
});
describe("employer-only actions are refused to workers", () => {
@@ -100,7 +121,9 @@ describe("marking a gig paid requires money to have actually arrived", () => {
store.payWorker.mockResolvedValue({ id: "a1", jobId: "g-own", status: "paid", verified: true });
const r = await run(employer, "mark_gig_paid", { jobId: "g-own" });
expect(r.ok).toBe(true);
- expect(store.payWorker).toHaveBeenCalledWith("g-own");
+ // Paying names the worker being paid. It used to name nobody, and the
+ // store filled in a hardcoded account.
+ expect(store.payWorker).toHaveBeenCalledWith("demo-worker", "g-own");
});
it("checks coverage before writing, never after", async () => {
@@ -140,7 +163,8 @@ describe("the onboarding channel stays shut until someone is hired", () => {
store.sendMessage.mockResolvedValue({});
const r = await run(employer, "send_message", { jobId: "g-own", text: secret });
expect(r.ok).toBe(true);
- expect(store.sendMessage).toHaveBeenCalledWith("g-own", "employer", "ClearVoice Media", secret);
+ // The author's account travels with the message, not just their name.
+ expect(store.sendMessage).toHaveBeenCalledWith("g-own", "employer", "u-emp", "ClearVoice Media", secret);
});
it("refuses an empty message", async () => {
diff --git a/tests/money/payment-coverage.test.ts b/tests/money/payment-coverage.test.ts
index aee193f..bcd42a7 100644
--- a/tests/money/payment-coverage.test.ts
+++ b/tests/money/payment-coverage.test.ts
@@ -70,33 +70,37 @@ beforeEach(async () => {
);
});
+// Coverage is now checked against a NAMED worker's wallet rather than a
+// hardcoded one, so every call has to say whose money it is talking about.
+const WORKER = "demo-worker";
+
describe("verifyPaymentCoverage", () => {
it("refuses when no money has arrived at all", async () => {
inbound = 0;
- const r = await verifyPaymentCoverage("g-a");
+ const r = await verifyPaymentCoverage(WORKER, "g-a");
expect(r.ok).toBe(false);
expect(r.message).toMatch(/no confirmed payment/i);
});
it("refuses when the money that arrived is not enough", async () => {
inbound = 5000; // gig pays 12000
- expect((await verifyPaymentCoverage("g-a")).ok).toBe(false);
+ expect((await verifyPaymentCoverage(WORKER, "g-a")).ok).toBe(false);
});
it("allows it once enough has genuinely landed", async () => {
inbound = 12000;
- expect((await verifyPaymentCoverage("g-a")).ok).toBe(true);
+ expect((await verifyPaymentCoverage(WORKER, "g-a")).ok).toBe(true);
});
it("allows it at the exact amount, not a naira more", async () => {
inbound = 12000;
- const r = await verifyPaymentCoverage("g-a");
+ const r = await verifyPaymentCoverage(WORKER, "g-a");
expect(r.ok).toBe(true);
});
it("refuses one naira short", async () => {
inbound = 11999;
- expect((await verifyPaymentCoverage("g-a")).ok).toBe(false);
+ expect((await verifyPaymentCoverage(WORKER, "g-a")).ok).toBe(false);
});
it("will not let one payment be claimed by two different gigs", async () => {
@@ -105,7 +109,7 @@ describe("verifyPaymentCoverage", () => {
// close out several gigs on a single transfer.
inbound = 12000;
paidApps = [{ _id: "a1", accountId: "demo-worker", jobId: "g-a", status: "paid", verified: true }];
- const r = await verifyPaymentCoverage("g-b");
+ const r = await verifyPaymentCoverage(WORKER, "g-b");
expect(r.ok).toBe(false);
expect(r.message).toMatch(/already claimed/i);
});
@@ -113,24 +117,24 @@ describe("verifyPaymentCoverage", () => {
it("allows the second gig once enough arrived to cover both", async () => {
inbound = 20000; // 12000 + 8000
paidApps = [{ _id: "a1", accountId: "demo-worker", jobId: "g-a", status: "paid", verified: true }];
- expect((await verifyPaymentCoverage("g-b")).ok).toBe(true);
+ expect((await verifyPaymentCoverage(WORKER, "g-b")).ok).toBe(true);
});
it("does not count gigs that are merely hired against the balance", async () => {
// Only "paid" claims money. A hired-but-unpaid gig has taken nothing yet.
inbound = 12000;
paidApps = [{ _id: "a1", accountId: "demo-worker", jobId: "g-b", status: "hired", verified: true }];
- expect((await verifyPaymentCoverage("g-a")).ok).toBe(true);
+ expect((await verifyPaymentCoverage(WORKER, "g-a")).ok).toBe(true);
});
it("says exactly how much more is needed, so the employer can act", async () => {
inbound = 5000;
- const r = await verifyPaymentCoverage("g-a");
+ const r = await verifyPaymentCoverage(WORKER, "g-a");
expect(r.message).toContain("7000");
});
it("refuses a gig that does not exist rather than defaulting to allowed", async () => {
inbound = 999999;
- expect((await verifyPaymentCoverage("nope")).ok).toBe(false);
+ expect((await verifyPaymentCoverage(WORKER, "nope")).ok).toBe(false);
});
});
From 38cc08383c499c327f8a575c970cde7cc7ca2288 Mon Sep 17 00:00:00 2001
From: dillon ofili
Date: Tue, 25 Aug 2026 19:11:27 +0100
Subject: [PATCH 3/3] feat(jobs): withdraw an application, take down a gig,
delete a message
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three things the platform could do in one direction only. Each one is a
write, so each one asks the same question first: is this yours?
Withdrawing an application is allowed while it is still just an
application. Once the assessment clock has started it is refused — an
attempt row means the questions have been handed out, and deleting the
record would be a way to quietly retake a test meant to be taken once.
Cancelled applications are refused too, or the permanent lockout could be
erased by withdrawing and applying again.
Removing a gig is refused once anyone has been hired or paid on it: at
that point the gig is the record of work that was agreed, and taking it
away would strand the worker's application and their onboarding thread
with nothing to point at. Pending applications go with the gig, because
an application to a listing that no longer exists is a job the worker can
never hear about again. The employer is told the count before they
confirm, aloud as well as on screen.
Messages can only be deleted by the account that wrote them, checked
against the stored author id rather than a display name. Rows written
before that field existed carry no author, so nobody can delete them —
refusing is the safe direction when ownership is unknown.
All three are reachable by voice as well as by button, since a worker who
cannot see the screen cannot press "Withdraw". Refusals are spoken, not
just displayed: a button that silently does nothing is worse than one
that is not there.
---
app/api/jobs/apply/route.ts | 13 +-
app/api/jobs/post/route.ts | 16 +-
app/jobs/employer-gigs.tsx | 37 +++++
app/jobs/message-thread.tsx | 23 +++
app/jobs/page.tsx | 32 ++++
lib/agent/system.ts | 1 +
lib/agent/tools.ts | 37 ++++-
tests/convex/ownership-and-removal.test.ts | 179 +++++++++++++++++++++
8 files changed, 335 insertions(+), 3 deletions(-)
create mode 100644 tests/convex/ownership-and-removal.test.ts
diff --git a/app/api/jobs/apply/route.ts b/app/api/jobs/apply/route.ts
index fb3be1e..32687e5 100644
--- a/app/api/jobs/apply/route.ts
+++ b/app/api/jobs/apply/route.ts
@@ -1,4 +1,4 @@
-import { apply, getAccount, getJob } from "@/lib/store";
+import { apply, getAccount, getJob, unapply } from "@/lib/store";
import { userIdFrom } from "@/lib/session";
export const runtime = "nodejs";
@@ -14,3 +14,14 @@ export async function POST(req: Request) {
}
return Response.json({ ok: true, application: app, requiresAssessment: job.requiresAssessment });
}
+
+// Withdraw an application. Allowed only while it is still just an application:
+// once the assessment has started there is a record of an attempt, and letting
+// it be deleted would be a way to quietly retake a test meant to be taken once.
+export async function DELETE(req: Request) {
+ const acc = await getAccount(userIdFrom(req));
+ const jobId = new URL(req.url).searchParams.get("jobId");
+ if (!jobId) return Response.json({ error: "jobId is required." }, { status: 400 });
+ const r = await unapply(acc.id, jobId);
+ return Response.json(r.ok ? { ok: true, message: r.message } : { error: r.message }, { status: r.ok ? 200 : 409 });
+}
diff --git a/app/api/jobs/post/route.ts b/app/api/jobs/post/route.ts
index 670a82f..452049c 100644
--- a/app/api/jobs/post/route.ts
+++ b/app/api/jobs/post/route.ts
@@ -1,4 +1,4 @@
-import { getAccount, postJob, validateGig, type McqQuestion } from "@/lib/store";
+import { getAccount, postJob, validateGig, type McqQuestion, deletePostedJob } from "@/lib/store";
import { userIdFrom } from "@/lib/session";
export const runtime = "nodejs";
@@ -30,3 +30,17 @@ export async function POST(req: Request) {
const job = await postJob({ ...v.gig, employer: acc.name, employerAccountId: acc.id });
return Response.json({ ok: true, job });
}
+
+// Take down a gig you posted. Refused once anyone has been hired or paid on
+// it: at that point the gig is the record of work that was agreed, and removing
+// it would strand the worker's application and their onboarding thread.
+export async function DELETE(req: Request) {
+ const acc = await getAccount(userIdFrom(req));
+ if (acc.role !== "employer") {
+ return Response.json({ error: "Only employers can remove gigs." }, { status: 403 });
+ }
+ const jobId = new URL(req.url).searchParams.get("jobId");
+ if (!jobId) return Response.json({ error: "jobId is required." }, { status: 400 });
+ const r = await deletePostedJob(acc.id, jobId);
+ return Response.json(r.ok ? { ok: true, message: r.message } : { error: r.message }, { status: r.ok ? 200 : 403 });
+}
diff --git a/app/jobs/employer-gigs.tsx b/app/jobs/employer-gigs.tsx
index b4e0726..d0c0f04 100644
--- a/app/jobs/employer-gigs.tsx
+++ b/app/jobs/employer-gigs.tsx
@@ -27,6 +27,32 @@ export function EmployerGigs({
const [openThread, setOpenThread] = useState(null);
const [error, setError] = useState(null);
+ // Take a gig down. Confirmed first, because it also withdraws every pending
+ // application on it — and refused outright by the server once anyone has been
+ // hired, so a worker's agreed job cannot vanish from under them.
+ const removeGig = async (job: Job, pendingCount: number) => {
+ const warning =
+ pendingCount > 0
+ ? `Remove "${job.title}"? This also withdraws ${pendingCount} pending application${pendingCount === 1 ? "" : "s"}. This cannot be undone.`
+ : `Remove "${job.title}"? This cannot be undone.`;
+ if (!window.confirm(warning)) return;
+ setBusyJob(job.id);
+ setError(null);
+ try {
+ const res = await fetch(`/api/jobs/post?jobId=${encodeURIComponent(job.id)}`, { method: "DELETE" });
+ const data = await res.json().catch(() => null);
+ if (!res.ok) throw new Error(data?.error || "Could not remove the gig.");
+ await reload();
+ speak(data?.message || "Gig removed.");
+ } catch (e) {
+ const msg = (e as Error).message;
+ setError(msg);
+ speak(msg);
+ } finally {
+ setBusyJob(null);
+ }
+ };
+
const changeStatus = async (jobId: string, action: "hire" | "reject" | "pay") => {
setBusyJob(jobId);
setError(null);
@@ -111,6 +137,17 @@ export function EmployerGigs({
{jobApps.length === 0 ? (
diff --git a/app/jobs/message-thread.tsx b/app/jobs/message-thread.tsx
index 26557b7..b2a1c7b 100644
--- a/app/jobs/message-thread.tsx
+++ b/app/jobs/message-thread.tsx
@@ -90,6 +90,20 @@ export function MessageThread({ jobId, role, id }: { jobId: string; role: "worke
}
};
+ // Only your own messages, and the server checks that against the stored
+ // author rather than trusting this component.
+ const remove = async (messageId: string, preview: string) => {
+ if (!window.confirm(`Delete your message "${preview}"? This cannot be undone.`)) return;
+ setError(null);
+ try {
+ const res = await fetch(`/api/messages?messageId=${encodeURIComponent(messageId)}`, { method: "DELETE" });
+ const data = await res.json().catch(() => null);
+ if (!res.ok) throw new Error(data?.error || "Could not delete the message.");
+ } catch (err) {
+ setError((err as Error).message);
+ }
+ };
+
return (