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/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..32687e5 100644
--- a/app/api/jobs/apply/route.ts
+++ b/app/api/jobs/apply/route.ts
@@ -1,14 +1,27 @@
-import { apply, getJob } from "@/lib/store";
+import { apply, getAccount, getJob, unapply } 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 });
}
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/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..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";
@@ -27,6 +27,20 @@ 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 });
}
+
+// 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/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/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({
{job.task}
+ {!jobApps.some((a: any) => a.status === "hired" || a.status === "paid") && (
+ removeGig(job, jobApps.filter((a: any) => a.status === "applied" || a.status === "assessed").length)}
+ disabled={busyJob === job.id}
+ aria-label={`Remove the gig ${job.title}`}
+ className="mt-4 min-h-12 cursor-pointer rounded-lg border-2 border-[var(--line)] px-5 py-2 font-bold text-[var(--ink-soft)] disabled:cursor-not-allowed disabled:opacity-50"
+ >
+ {busyJob === job.id ? "Removing…" : "Remove gig"}
+
+ )}
+
Applications ({jobApps.length})
{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 (
Onboarding messages
@@ -119,6 +133,15 @@ export function MessageThread({ jobId, role, id }: { jobId: string; role: "worke
{mine ? "You" : m.authorName} · {time(m.at)}
{m.text}
+ {mine && (
+ remove(m._id, m.text.length > 40 ? `${m.text.slice(0, 40)}…` : m.text)}
+ aria-label={`Delete your message: ${m.text}`}
+ className="mt-2 cursor-pointer text-sm font-bold text-[var(--ink-soft)] underline"
+ >
+ Delete
+
+ )}
);
diff --git a/app/jobs/page.tsx b/app/jobs/page.tsx
index 34503f8..df47a4e 100644
--- a/app/jobs/page.tsx
+++ b/app/jobs/page.tsx
@@ -100,6 +100,28 @@ function JobsPageInner() {
const appFor = (jobId: string) => apps.find((a) => a.jobId === jobId);
+ // Withdraw an application. The server refuses once the assessment has begun;
+ // the button is hidden in that case too, but the server is what decides.
+ const withdrawFrom = async (job: Job) => {
+ setBusyJob(job.id);
+ setError(null);
+ try {
+ const res = await fetch(`/api/jobs/apply?jobId=${encodeURIComponent(job.id)}`, { method: "DELETE" });
+ const data = await res.json().catch(() => null);
+ if (!res.ok) throw new Error(data?.error || "Could not withdraw.");
+ await load();
+ speak(`Your application to ${job.title} has been withdrawn.`);
+ } catch (e) {
+ const msg = (e as Error).message;
+ setError(msg);
+ // Spoken as well as shown: a refusal the user cannot see is a button
+ // that silently did nothing.
+ speak(msg);
+ } finally {
+ setBusyJob(null);
+ }
+ };
+
const applyTo = async (job: Job) => {
setBusyJob(job.id);
setError(null);
@@ -415,6 +437,16 @@ function JobsPageInner() {
{app.verified ? "✓ Skill verified" : app.status === "cancelled" ? "Locked — assessment cancelled" : `Applied — ${app.status}`}
)}
+ {app && app.status === "applied" && !app.verified && (
+ withdrawFrom(job)}
+ disabled={busyJob === job.id}
+ aria-label={`Withdraw your application to ${job.title}`}
+ className="min-h-12 cursor-pointer rounded-lg border-2 border-[var(--line)] px-6 py-3 text-lg font-bold text-[var(--ink-soft)] disabled:cursor-not-allowed disabled:opacity-50"
+ >
+ {busyJob === job.id ? "Withdrawing…" : "Withdraw application"}
+
+ )}
{app && !app.verified && app.status !== "cancelled" && job.requiresAssessment && (
startAssessment(job)}
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/system.ts b/lib/agent/system.ts
index d7c61da..48f1950 100644
--- a/lib/agent/system.ts
+++ b/lib/agent/system.ts
@@ -18,6 +18,7 @@ export const SYSTEM_PROMPT = `You are Aide, a warm, calm voice assistant for a b
- When a worker is hired and asks for help with their job (for example "help me with my job"), get their applications, find the hired one, and coach them through the task step by step: break it down, answer questions about approach, and encourage them. Never do the paid work for them wholesale — guide.
- Post-hire onboarding messages: once a worker is hired for a gig, a private message channel opens between that employer and that worker. Both sides can use it entirely by voice. If the user asks to check, read, or hear their messages for a job, call read_messages with the jobId and read each one aloud saying who sent it — that gig's conversation also opens on their screen, since these threads sit closed until asked for. To send one, call send_message: for an employer this is how they pass onboarding directives, credentials, or next steps to the worker they hired; for a worker it is how they reply or ask a question. Before sending, read the exact wording back and get a spoken yes — especially for anything sensitive like passwords, credentials, or account details, which the user should be sure of before it is sent. The other party hears the message read aloud automatically when it arrives. This channel only exists after hiring — if it is not unlocked yet, say the worker must be hired first.
- If there aren't suitable gigs on the platform, offer to scan the open web: call scan_external_jobs, read out the best matches, and if they want one, call track_external_job and tell them the listing is on their jobs page under External jobs. Be clear you cannot fill the external site's forms — you find, they apply, you track.
+- Taking things back: a worker can withdraw an application with withdraw_application, but only before the assessment has started — if it returns a refusal, say plainly that the assessment has already begun and it cannot be withdrawn, never imply it worked. An employer can take down a gig with delete_gig, which also withdraws every pending application on it: say that out loud, and get an explicit yes, before calling. Either party can delete a message they themselves sent with delete_message — read the message back first, and note that only their own messages can be removed.
- Do exactly one thing at a time and confirm before anything irreversible.
- You cannot see anything the user can't. Everything you state about jobs, applications, balances, or payments must come from a tool result — never invent a number or a status.
- Money rules (strict): Never announce a payment or balance you did not get from a tool this turn.
diff --git a/lib/agent/tools.ts b/lib/agent/tools.ts
index 05a4c22..47d2220 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,42 @@ 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 }))),
+ }),
+
+ withdraw_application: tool({
+ description:
+ "Withdraw the worker's application to a job they applied for but have not started the assessment on. Confirm aloud first. If the assessment has already begun this is refused — say so plainly rather than implying it worked.",
+ parameters: z.object({ jobId: z.string() }),
+ execute: async ({ jobId }) => {
+ const job = await store.getJob(jobId);
+ if (!job) return { ok: false, message: "No job with that id." };
+ const r = await store.unapply(account.id, jobId);
+ return { ok: r.ok, gig: job.title, message: r.message };
+ },
+ }),
+
+ delete_gig: tool({
+ description:
+ "For employers: take down a gig they posted. IRREVERSIBLE, and it also withdraws any pending applications on it, so warn them of both and get an explicit spoken yes first. Refused once a worker has been hired or paid for the gig.",
+ parameters: z.object({ jobId: z.string() }),
+ execute: async ({ jobId }) => {
+ if (account.role !== "employer") return { ok: false, message: "Only employer accounts can remove gigs." };
+ const job = await store.getJob(jobId);
+ if (!job) return { ok: false, message: "No job with that id." };
+ const r = await store.deletePostedJob(account.id, jobId);
+ return { ok: r.ok, gig: job.title, message: r.message };
+ },
+ }),
+
+ delete_message: tool({
+ description:
+ "Delete a message the user themselves sent in a gig's onboarding thread. Read the message back and get a spoken yes first. Only their own messages can be deleted — pass the messageId from read_messages.",
+ parameters: z.object({ messageId: z.string() }),
+ execute: async ({ messageId }) => {
+ const r = await store.deleteMessage(account.id, messageId);
+ return { ok: r.ok, message: r.message };
+ },
}),
start_assessment: tool({
@@ -468,13 +538,16 @@ 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." };
}
- const messages = (await store.listMessages(jobId)).map((m) => ({ from: m.from, author: m.authorName, text: m.text }));
+ const messages = (await store.listMessages(jobId)).map((m) => ({ messageId: m.id, from: m.from, author: m.authorName, text: m.text }));
return { ok: true, jobId, gig: job.title, messages };
},
}),
@@ -489,15 +562,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/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/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/convex/ownership-and-removal.test.ts b/tests/convex/ownership-and-removal.test.ts
new file mode 100644
index 0000000..caf966e
--- /dev/null
+++ b/tests/convex/ownership-and-removal.test.ts
@@ -0,0 +1,179 @@
+import { convexTest } from "convex-test";
+import { describe, expect, it } from "vitest";
+import schema from "../../convex/schema";
+import { api } from "../../convex/_generated/api";
+
+// Taking something back is a write like any other, so it needs the same
+// question answered: whose is it? These guards live in the Convex mutations
+// rather than in the route, so two requests cannot race each other and no
+// second entry point (the voice tool, a curl) can skip them.
+const modules = import.meta.glob("../../convex/**/*.ts");
+
+const ME = "u-worker-1";
+const SOMEONE_ELSE = "u-worker-2";
+const EMPLOYER = "u-emp-1";
+const RIVAL = "u-emp-2";
+
+const gig = (accountId?: string) => ({
+ jobId: "g-1",
+ title: "Transcribe an interview",
+ task: "t",
+ skill: "transcription",
+ pay: 12000,
+ employer: "ClearVoice Media",
+ employerAccountId: accountId,
+ requiresAssessment: true,
+});
+
+describe("withdrawing an application", () => {
+ it("removes your own application while it is still just an application", async () => {
+ const t = convexTest(schema, modules);
+ await t.mutation(api.applications.apply, { accountId: ME, jobId: "g-1" });
+ expect(await t.mutation(api.applications.remove, { accountId: ME, jobId: "g-1" })).toEqual({ ok: true });
+ expect(await t.query(api.applications.getForJob, { accountId: ME, jobId: "g-1" })).toBeNull();
+ });
+
+ it("refuses once the assessment clock has started", async () => {
+ // An attempt row means the questions have been handed out. Allowing a
+ // withdrawal here would be a way to quietly retake a one-shot test.
+ const t = convexTest(schema, modules);
+ await t.mutation(api.applications.apply, { accountId: ME, jobId: "g-1" });
+ await t.mutation(api.jobs.recordAttempt, { key: `${ME}-g-1`, startedAt: Date.now() });
+ const r = await t.mutation(api.applications.remove, { accountId: ME, jobId: "g-1" });
+ expect(r).toEqual({ ok: false, reason: "started" });
+ expect(await t.query(api.applications.getForJob, { accountId: ME, jobId: "g-1" })).not.toBeNull();
+ });
+
+ it("refuses once the assessment has been graded", async () => {
+ const t = convexTest(schema, modules);
+ await t.mutation(api.applications.apply, { accountId: ME, jobId: "g-1" });
+ await t.mutation(api.applications.setStatus, {
+ accountId: ME,
+ jobId: "g-1",
+ verified: true,
+ status: "assessed",
+ });
+ expect(await t.mutation(api.applications.remove, { accountId: ME, jobId: "g-1" })).toEqual({
+ ok: false,
+ reason: "started",
+ });
+ });
+
+ it("refuses to withdraw a cancelled application, so the lockout survives", async () => {
+ // Cancelling bars you from the job forever. Deleting the record afterwards
+ // would let you apply again as though it never happened.
+ const t = convexTest(schema, modules);
+ await t.mutation(api.applications.apply, { accountId: ME, jobId: "g-1" });
+ await t.mutation(api.applications.setStatus, { accountId: ME, jobId: "g-1", status: "cancelled" });
+ expect(await t.mutation(api.applications.remove, { accountId: ME, jobId: "g-1" })).toEqual({
+ ok: false,
+ reason: "started",
+ });
+ });
+
+ it("never touches somebody else's application to the same gig", async () => {
+ const t = convexTest(schema, modules);
+ await t.mutation(api.applications.apply, { accountId: ME, jobId: "g-1" });
+ await t.mutation(api.applications.apply, { accountId: SOMEONE_ELSE, jobId: "g-1" });
+ await t.mutation(api.applications.remove, { accountId: ME, jobId: "g-1" });
+ expect(await t.query(api.applications.getForJob, { accountId: SOMEONE_ELSE, jobId: "g-1" })).not.toBeNull();
+ });
+});
+
+describe("removing a gig you posted", () => {
+ it("removes it, and the pending applications with it", async () => {
+ const t = convexTest(schema, modules);
+ await t.mutation(api.jobs.post, gig(EMPLOYER));
+ await t.mutation(api.applications.apply, { accountId: ME, jobId: "g-1" });
+ const r = await t.mutation(api.jobs.removePosted, { jobId: "g-1", accountId: EMPLOYER });
+ expect(r).toEqual({ ok: true, removedApplications: 1 });
+ expect(await t.query(api.jobs.listPosted, {})).toHaveLength(0);
+ // An application pointing at a gig that no longer exists would show a
+ // worker a job they can never hear about again.
+ expect(await t.query(api.applications.getForJob, { accountId: ME, jobId: "g-1" })).toBeNull();
+ });
+
+ it("refuses a rival employer", async () => {
+ const t = convexTest(schema, modules);
+ await t.mutation(api.jobs.post, gig(EMPLOYER));
+ expect(await t.mutation(api.jobs.removePosted, { jobId: "g-1", accountId: RIVAL })).toEqual({
+ ok: false,
+ reason: "not-yours",
+ });
+ expect(await t.query(api.jobs.listPosted, {})).toHaveLength(1);
+ });
+
+ it("refuses a gig with no recorded owner, rather than guessing", async () => {
+ // Gigs posted before employerAccountId existed. Refusing is the safe
+ // direction: nobody can delete them, instead of anybody being able to.
+ const t = convexTest(schema, modules);
+ await t.mutation(api.jobs.post, gig(undefined));
+ expect(await t.mutation(api.jobs.removePosted, { jobId: "g-1", accountId: EMPLOYER })).toEqual({
+ ok: false,
+ reason: "not-yours",
+ });
+ });
+
+ it("refuses once somebody has been hired", async () => {
+ // At that point the gig is the record of work that was agreed. Removing it
+ // would strand the worker's application and their onboarding thread.
+ const t = convexTest(schema, modules);
+ await t.mutation(api.jobs.post, gig(EMPLOYER));
+ await t.mutation(api.applications.apply, { accountId: ME, jobId: "g-1" });
+ await t.mutation(api.applications.setStatus, { accountId: ME, jobId: "g-1", status: "hired" });
+ expect(await t.mutation(api.jobs.removePosted, { jobId: "g-1", accountId: EMPLOYER })).toEqual({
+ ok: false,
+ reason: "committed",
+ });
+ expect(await t.query(api.jobs.listPosted, {})).toHaveLength(1);
+ });
+
+ it("refuses once somebody has been paid", async () => {
+ const t = convexTest(schema, modules);
+ await t.mutation(api.jobs.post, gig(EMPLOYER));
+ await t.mutation(api.applications.apply, { accountId: ME, jobId: "g-1" });
+ await t.mutation(api.applications.setStatus, { accountId: ME, jobId: "g-1", status: "paid" });
+ expect(await t.mutation(api.jobs.removePosted, { jobId: "g-1", accountId: EMPLOYER })).toEqual({
+ ok: false,
+ reason: "committed",
+ });
+ });
+});
+
+describe("deleting a message", () => {
+ const send = async (t: any, authorAccountId?: string) =>
+ await t.mutation(api.messages.send, {
+ jobId: "g-1",
+ workerAccountId: ME,
+ from: "employer" as const,
+ authorAccountId,
+ authorName: "ClearVoice Media",
+ text: "The login is admin / hunter2",
+ });
+
+ it("deletes your own message", async () => {
+ const t = convexTest(schema, modules);
+ const m = await send(t, EMPLOYER);
+ expect(await t.mutation(api.messages.remove, { messageId: m._id, accountId: EMPLOYER })).toMatchObject({ ok: true });
+ expect(await t.query(api.messages.listForJob, { jobId: "g-1" })).toHaveLength(0);
+ });
+
+ it("refuses somebody else's message, even the other party to the thread", async () => {
+ const t = convexTest(schema, modules);
+ const m = await send(t, EMPLOYER);
+ expect(await t.mutation(api.messages.remove, { messageId: m._id, accountId: ME })).toEqual({
+ ok: false,
+ reason: "not-yours",
+ });
+ expect(await t.query(api.messages.listForJob, { jobId: "g-1" })).toHaveLength(1);
+ });
+
+ it("refuses a message with no recorded author, rather than guessing from a name", async () => {
+ const t = convexTest(schema, modules);
+ const m = await send(t, undefined);
+ expect(await t.mutation(api.messages.remove, { messageId: m._id, accountId: EMPLOYER })).toEqual({
+ ok: false,
+ reason: "not-yours",
+ });
+ });
+});
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);
});
});
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/);
});
});