From db9d3f06746a417028e3f6b485f5322a960c487c Mon Sep 17 00:00:00 2001 From: maegminhui-tech Date: Thu, 24 Sep 2026 21:58:01 +0900 Subject: [PATCH 001/116] fix(jobs): write only the fields a listing patch sent --- src/core/jobs.ts | 55 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/src/core/jobs.ts b/src/core/jobs.ts index 08a1309..19b2395 100644 --- a/src/core/jobs.ts +++ b/src/core/jobs.ts @@ -830,6 +830,61 @@ export async function setPay(pool: pg.Pool, id: string, pay: Pay): Promise, +): Promise { + const values: unknown[] = [id]; + const assignments: string[] = []; + const set = (column: string, value: unknown): void => { + values.push(value); + assignments.push(`${column} = $${values.length}`); + }; + if (fields.has('title')) set('title', input.title); + if (fields.has('description')) set('description', input.description); + if (fields.has('employmentType')) set('employment_type', input.employmentType); + if (fields.has('workplace')) set('workplace', input.workplace); + if (fields.has('seniority')) set('seniority', input.seniority); + if (fields.has('location')) set('location', input.location); + if (fields.has('remoteRegions')) set('remote_regions', input.remoteRegions); + if (fields.has('tags')) set('tags', input.tags); + if (fields.has('stack')) set('stack', input.stack); + if (fields.has('requirements')) set('requirements', input.requirements); + if (fields.has('responsibilities')) set('responsibilities', input.responsibilities); + if (fields.has('agentPolicy')) set('agent_policy', input.agentPolicy); + if (fields.has('expiresAt')) set('expires_at', input.expiresAt); + if (fields.has('pay')) { + const salary = salaryFromPay(input.pay); + set('salary_min', salary.min); + set('salary_max', salary.max); + set('salary_currency', salary.currency); + set('salary_period', salary.period); + set('salary_equity', input.pay.equity); + set('salary_unpaid', input.pay.unpaid); + set('pay_lines', JSON.stringify(input.pay.lines)); + set('pay_method', input.pay.method); + } + if (assignments.length > 0) { + assignments.push('updated_at = now()'); + await pool.query(`update jobs set ${assignments.join(', ')} where id = $1`, values); + } + return getJobById(pool, id); +} + /** The listing imported from this URL, if there is one. */ export async function getJobBySourceUrl(pool: pg.Pool, url: string): Promise { const result = await pool.query<{ id: string }>( From 9192655b17050e22cb701b29c9a55a08aafd0340 Mon Sep 17 00:00:00 2001 From: maegminhui-tech Date: Thu, 24 Sep 2026 21:58:03 +0900 Subject: [PATCH 002/116] fix(jobs): write only the fields a listing patch sent --- src/server/routes/api.ts | 60 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/src/server/routes/api.ts b/src/server/routes/api.ts index 53e461d..01596e3 100644 --- a/src/server/routes/api.ts +++ b/src/server/routes/api.ts @@ -48,7 +48,7 @@ import { publishProblem, searchJobs, setStatus, - editJob, + patchJob, updateJobFromImport, } from '../../core/jobs.ts'; import { extractJob, JobImportProblem, type ImportedJob } from '../../core/import-job.ts'; @@ -251,6 +251,58 @@ function payPatch(body: Record, job: Job): Record = { + title: 'title', + description: 'description', + employmentType: 'employmentType', + workplace: 'workplace', + seniority: 'seniority', + location: 'location', + remoteRegions: 'remoteRegions', + tags: 'tags', + stack: 'stack', + requirements: 'requirements', + responsibilities: 'responsibilities', + agentPolicy: 'agentPolicy', + expiresAt: 'expiresAt', +}; + +/** Every body key that touches the pay group. */ +const JOB_PAY_KEYS = [ + 'pay', + 'payLines', + 'salaryMin', + 'salaryMax', + 'salaryPeriod', + 'salaryCurrency', + 'salaryUnpaid', + 'unpaid', + 'payUnpaid', + 'payMethod', + 'paymentMethod', + 'paymentCoin', + 'payEquity', + 'salaryEquity', + 'equity', +]; + +/** + * The fields a PATCH actually sent. `undefined` is "leave it"; an explicit + * `null` still counts as sent so a nullable field like `expiresAt` can be + * cleared. Only these columns move — two members editing different fields + * at once no longer overwrite each other, and an edit cannot silently + * clear a field the merge never carried. + */ +function sentJobFields(body: Record): Set { + const fields = new Set(); + for (const [key, field] of Object.entries(JOB_PATCH_FIELDS)) { + if (body[key] !== undefined) fields.add(field); + } + if (JOB_PAY_KEYS.some((key) => body[key] !== undefined)) fields.add('pay'); + return fields; +} + export function apiRoutes(): Hono { const api = new Hono(); @@ -577,11 +629,15 @@ export function apiRoutes(): Hono { workplace: body['workplace'] ?? job.workplace, seniority: body['seniority'] ?? job.seniority ?? undefined, location: body['location'] ?? job.location ?? undefined, + remoteRegions: body['remoteRegions'] ?? job.remoteRegions, tags: body['tags'] ?? job.tags, stack: body['stack'] ?? job.stack, requirements: body['requirements'] ?? job.requirements, responsibilities: body['responsibilities'] ?? job.responsibilities, agentPolicy: body['agentPolicy'] ?? job.agentPolicy, + ...(body['expiresAt'] !== undefined + ? { expiresAt: body['expiresAt'] } + : { expiresAt: job.expiresAt }), ...payPatch(body, job), }, job.org.id, @@ -595,7 +651,7 @@ export function apiRoutes(): Hono { if (problem !== null) return fail(c, 400, 'pay_required', problem); } - const updated = await editJob(pool, job.id, merged); + const updated = await patchJob(pool, job.id, merged, sentJobFields(body)); return c.json({ job: updated ?? job }); }); From c76d0539378f74c0bf06c3688dba9c28f34dc085 Mon Sep 17 00:00:00 2001 From: maegminhui-tech Date: Thu, 24 Sep 2026 21:58:05 +0900 Subject: [PATCH 003/116] fix(jobs): write only the fields a listing patch sent --- test/job-patch-fields.test.ts | 182 ++++++++++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 test/job-patch-fields.test.ts diff --git a/test/job-patch-fields.test.ts b/test/job-patch-fields.test.ts new file mode 100644 index 0000000..5fa752a --- /dev/null +++ b/test/job-patch-fields.test.ts @@ -0,0 +1,182 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { patchJob, type JobInput } from '../src/core/jobs.ts'; +import { DEFAULT_APPLY_SCHEMA } from '../src/schema/job.ts'; + +function jobRow(): Record { + return { + id: 'job-1', + slug: 'backend-engineer', + title: 'Backend Engineer', + description: 'A description long enough to pass the minimum.', + employment_type: 'full-time', + workplace: 'remote', + seniority: null, + location: 'Berlin', + remote_regions: ['EU'], + salary_min: 100000, + salary_max: 150000, + salary_currency: 'USD', + salary_period: 'year', + salary_equity: null, + salary_unpaid: false, + pay_lines: [{ type: 'salary', min: 100000, max: 150000, currency: 'USD', unit: 'year' }], + pay_method: 'bank transfer', + tags: ['backend'], + stack: ['postgres'], + requirements: ['ships'], + responsibilities: ['owns'], + agent_policy: 'disclose', + apply_via: 'board', + apply_url: null, + apply_source_url: null, + apply_email: null, + apply_schema: null, + status: 'draft', + published_at: null, + expires_at: '2027-01-01T00:00:00.000Z', + created_at: '2026-09-24T00:00:00.000Z', + updated_at: '2026-09-24T00:00:00.000Z', + org_id: 'org-1', + org_slug: 'example-works', + org_name: 'Example Works', + org_website: null, + org_logo_url: null, + org_description: null, + org_created_at: '2026-09-24T00:00:00.000Z', + }; +} + +function input(overrides: Partial = {}): JobInput { + return { + orgId: 'org-1', + title: 'Renamed Listing', + description: 'A replacement description that is long enough.', + employmentType: 'contract', + workplace: 'onsite', + seniority: 'senior', + location: 'Lisbon', + remoteRegions: ['US', 'EU'], + pay: { lines: [], method: null, equity: null, unpaid: true }, + tags: ['new-tag'], + stack: ['typescript'], + requirements: ['new req'], + responsibilities: ['new resp'], + agentPolicy: 'welcome', + apply: { via: 'board', schema: DEFAULT_APPLY_SCHEMA }, + expiresAt: '2027-06-01T00:00:00.000Z', + sourceUrl: null, + ...overrides, + }; +} + +/** + * A pool that applies `update jobs` assignments to the row, the way Postgres + * would, and serves the row back to the post-write read. + */ +function fakePool(job: Record, queries: string[] = []) { + return { + async query(sql: string, params: unknown[] = []) { + queries.push(sql); + const update = /^update jobs set (.+) where id = \$1\s*$/s.exec(sql.trim()); + if (update === null) return { rows: [{ ...job }], rowCount: 1 }; + for (const [, column, parameter] of (update[1] ?? '').matchAll(/(\w+) = \$(\d+)/g)) { + job[column as string] = params[Number(parameter) - 1]; + } + return { rows: [], rowCount: 1 }; + }, + }; +} + +function updateSql(queries: string[]): string { + const found = queries.filter((sql) => sql.trim().startsWith('update jobs')); + assert.equal(found.length, 1, 'expected exactly one update'); + return found[0] ?? ''; +} + +test('a partial patch writes only the columns that were sent', async () => { + const job = jobRow(); + const queries: string[] = []; + await patchJob(fakePool(job, queries) as never, 'job-1', input(), new Set(['title'])); + + const sql = updateSql(queries); + assert.match(sql, /title = \$\d+/); + for (const absent of [ + 'description', + 'remote_regions', + 'expires_at', + 'tags', + 'salary_min', + 'pay_lines', + 'agent_policy', + ]) { + assert.ok(!sql.includes(`${absent} =`), `${absent} must not be rewritten by a title patch`); + } + assert.equal(job['title'], 'Renamed Listing'); + assert.equal(job['location'], 'Berlin'); + assert.equal(job['expires_at'], '2027-01-01T00:00:00.000Z'); +}); + +test('independent concurrent listing patches keep both field changes', async () => { + const job = jobRow(); + const queries: string[] = []; + const pool = fakePool(job, queries); + await Promise.all([ + patchJob(pool as never, 'job-1', input(), new Set(['title'])), + patchJob(pool as never, 'job-1', input(), new Set(['tags'])), + ]); + + assert.equal(job['title'], 'Renamed Listing'); + assert.deepEqual(job['tags'], ['new-tag']); +}); + +test('an explicit null expiresAt clears the expiry', async () => { + const job = jobRow(); + const queries: string[] = []; + await patchJob( + fakePool(job, queries) as never, + 'job-1', + input({ expiresAt: null }), + new Set(['expiresAt']), + ); + + assert.match(updateSql(queries), /expires_at = \$\d+/); + assert.equal(job['expires_at'], null); +}); + +test('any pay field rewrites the whole pay group', async () => { + const job = jobRow(); + const queries: string[] = []; + const pay = { + lines: [{ type: 'per_task' as const, min: 25, max: null, currency: 'USD', unit: 'fix' }], + method: 'SOL', + equity: null, + unpaid: false, + }; + await patchJob(fakePool(job, queries) as never, 'job-1', input({ pay }), new Set(['pay'])); + + const sql = updateSql(queries); + for (const column of [ + 'salary_min', + 'salary_max', + 'salary_currency', + 'salary_period', + 'salary_equity', + 'salary_unpaid', + 'pay_lines', + 'pay_method', + ]) { + assert.ok(sql.includes(`${column} =`), `pay patch must write ${column}`); + } + assert.equal(job['pay_method'], 'SOL'); + assert.equal(job['title'], 'Backend Engineer'); +}); + +test('a patch with no recognised fields issues no update', async () => { + const job = jobRow(); + const queries: string[] = []; + const result = await patchJob(fakePool(job, queries) as never, 'job-1', input(), new Set()); + + assert.ok(!queries.some((sql) => sql.trim().startsWith('update jobs'))); + assert.equal(result?.title, 'Backend Engineer'); +}); From 65f945e0b5e3794c7b4cd3010484c8b09feba7ba Mon Sep 17 00:00:00 2001 From: RowletCC Date: Thu, 24 Sep 2026 21:48:20 +0800 Subject: [PATCH 004/116] fix(watches): handle concurrent duplicate saves (#212) --- src/core/watches.ts | 29 +++++++---- test/watch-create-concurrency.test.ts | 69 +++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 8 deletions(-) create mode 100644 test/watch-create-concurrency.test.ts diff --git a/src/core/watches.ts b/src/core/watches.ts index 7ae31b1..852d3a4 100644 --- a/src/core/watches.ts +++ b/src/core/watches.ts @@ -158,28 +158,41 @@ export async function createWatch( return 'Narrow the search first: a watch on every listing is the /feed, and it already exists.'; } const stored = toStored(query); - const existing = await pool.query( - `select id, query, label, email, created_at, last_notified_at - from watches where user_id = $1 and query = $2::jsonb`, - [userId, JSON.stringify(stored)], - ); - const found = existing.rows[0]; - if (found !== undefined) return { watch: toWatch(found, slugPath), created: false }; + const findExisting = async (): Promise => { + const existing = await pool.query( + `select id, query, label, email, created_at, last_notified_at + from watches where user_id = $1 and query = $2::jsonb`, + [userId, JSON.stringify(stored)], + ); + const row = existing.rows[0]; + return row === undefined ? null : toWatch(row, slugPath); + }; + const found = await findExisting(); + if (found !== null) return { watch: found, created: false }; const count = await pool.query<{ n: number }>( `select count(*)::int as n from watches where user_id = $1`, [userId], ); if ((count.rows[0]?.n ?? 0) >= WATCHES_PER_ACCOUNT) { + // A concurrent request may have inserted this exact watch after the first + // lookup. Treat that request as the existing search even at the account cap. + const createdByPeer = await findExisting(); + if (createdByPeer !== null) return { watch: createdByPeer, created: false }; return `You are watching ${WATCHES_PER_ACCOUNT} searches already. Remove one to add another.`; } const inserted = await pool.query( `insert into watches (user_id, query, label, email) values ($1, $2::jsonb, $3, $4) + on conflict (user_id, query) do nothing returning id, query, label, email, created_at, last_notified_at`, [userId, JSON.stringify(stored), labelFor(watchQuery(query)), options.email !== false], ); const row = inserted.rows[0]; - if (row === undefined) throw new Error('watch insert returned no row'); + if (row === undefined) { + const createdByPeer = await findExisting(); + if (createdByPeer !== null) return { watch: createdByPeer, created: false }; + throw new Error('watch insert returned no row'); + } return { watch: toWatch(row, slugPath), created: true }; } diff --git a/test/watch-create-concurrency.test.ts b/test/watch-create-concurrency.test.ts new file mode 100644 index 0000000..e9e5b99 --- /dev/null +++ b/test/watch-create-concurrency.test.ts @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { createWatch } from '../src/core/watches.ts'; +import { EMPTY_QUERY } from '../src/schema/query.ts'; + +test('concurrent saves of one search return the same watch instead of a database error', async () => { + type StoredWatch = { + id: string; + query: Record; + label: string; + email: boolean; + created_at: string; + last_notified_at: null; + }; + + let stored: StoredWatch | null = null; + let lookupCount = 0; + let releaseLookups: (() => void) | undefined; + const bothLookups = new Promise((resolve) => { + releaseLookups = resolve; + }); + + const pool = { + async query(sql: string, params: unknown[] = []) { + if (sql.includes('from watches where user_id = $1 and query = $2::jsonb')) { + const snapshot = + stored !== null && JSON.stringify(stored.query) === params[1] ? { ...stored } : null; + lookupCount += 1; + if (lookupCount === 2) releaseLookups?.(); + if (lookupCount <= 2) await bothLookups; + return { rows: snapshot === null ? [] : [snapshot], rowCount: snapshot === null ? 0 : 1 }; + } + if (sql.includes('select count(*)::int as n from watches')) { + return { rows: [{ n: stored === null ? 0 : 1 }], rowCount: 1 }; + } + if (sql.startsWith('insert into watches')) { + if (stored !== null) { + if (sql.includes('on conflict')) return { rows: [], rowCount: 0 }; + throw Object.assign(new Error('duplicate key value violates unique constraint'), { + code: '23505', + }); + } + stored = { + id: 'watch-1', + query: JSON.parse(params[1] as string) as Record, + label: String(params[2]), + email: Boolean(params[3]), + created_at: '2026-09-24T00:00:00.000Z', + last_notified_at: null, + }; + return { rows: [{ ...stored }], rowCount: 1 }; + } + throw new Error(`unexpected query: ${sql}`); + }, + }; + + const results = await Promise.all([ + createWatch(pool as never, 'user-1', { ...EMPTY_QUERY, tags: ['rust'] }, {}, () => null), + createWatch(pool as never, 'user-1', { ...EMPTY_QUERY, tags: ['rust'] }, {}, () => null), + ]); + + assert.ok(results.every((result) => typeof result !== 'string')); + if (results.some((result) => typeof result === 'string')) return; + assert.deepEqual(results.map((result) => result.created).sort(), [false, true]); + assert.deepEqual( + results.map((result) => result.watch.id), + ['watch-1', 'watch-1'], + ); +}); From 97fce05db3cd7b50f614765876626828d4f6c826 Mon Sep 17 00:00:00 2001 From: RowletCC Date: Thu, 24 Sep 2026 21:48:28 +0800 Subject: [PATCH 005/116] Make bulk operator assignments atomic (#213) * Test bulk operator assignment atomicity * Make bulk operator assignments atomic --- src/core/agents.ts | 36 ++++++++++++++++++++++++------------ test/features.test.ts | 21 +++++++++++++++++++++ 2 files changed, 45 insertions(+), 12 deletions(-) diff --git a/src/core/agents.ts b/src/core/agents.ts index c0692e9..d9a8a2b 100644 --- a/src/core/agents.ts +++ b/src/core/agents.ts @@ -18,6 +18,8 @@ import type pg from 'pg'; import { clean, parseList, slugify, suffix } from '../schema/text.ts'; +type AgentQueryable = Pick; + export const NAME_MAX = 80; export const DESCRIPTION_MAX = 2000; export const SKILLS_MAX = 20; @@ -146,7 +148,7 @@ function toAgent(row: AgentRow, operates: AgentRef[]): Agent { }; } -async function operatesOf(pool: pg.Pool, ids: string[]): Promise> { +async function operatesOf(pool: AgentQueryable, ids: string[]): Promise> { const out = new Map(); if (ids.length === 0) return out; const rows = await pool.query<{ operator_id: string; slug: string; name: string }>( @@ -161,7 +163,7 @@ async function operatesOf(pool: pg.Pool, ids: string[]): Promise { +async function hydrate(pool: AgentQueryable, rows: AgentRow[]): Promise { const operates = await operatesOf( pool, rows.map((row) => row.id), @@ -200,7 +202,7 @@ export async function listPublicAgents( /** One agent by slug. Private agents are visible to their owner only. */ export async function getAgent( - pool: pg.Pool, + pool: AgentQueryable, slug: string, viewerId: string | null, ): Promise { @@ -211,7 +213,7 @@ export async function getAgent( return (await hydrate(pool, [row]))[0] ?? null; } -async function ownedRow(pool: pg.Pool, slug: string, ownerId: string): Promise { +async function ownedRow(pool: AgentQueryable, slug: string, ownerId: string): Promise { const rows = await pool.query(`${SELECT} where a.slug = $1`, [slug]); const row = rows.rows[0]; if (row === undefined || row.owner_id !== ownerId) { @@ -225,7 +227,7 @@ async function ownedRow(pool: pg.Pool, slug: string, ownerId: string): Promise { - const operator = await ownedRow(pool, operatorSlug, ownerId); - for (const slug of agentSlugs) { - await updateAgent(pool, ownerId, slug, { operator: operator.slug }); + const client = await pool.connect(); + try { + await client.query('begin'); + const operator = await ownedRow(client, operatorSlug, ownerId); + for (const slug of agentSlugs) { + await updateAgent(client, ownerId, slug, { operator: operator.slug }); + } + const refreshed = await getAgent(client, operator.slug, ownerId); + if (refreshed === null) throw new Error('operator vanished during assignment'); + await client.query('commit'); + return refreshed; + } catch (error) { + await client.query('rollback'); + throw error; + } finally { + client.release(); } - const refreshed = await getAgent(pool, operator.slug, ownerId); - if (refreshed === null) throw new Error('operator vanished during assignment'); - return refreshed; } export async function deleteAgent(pool: pg.Pool, ownerId: string, slug: string): Promise { diff --git a/test/features.test.ts b/test/features.test.ts index 823bf06..e72e108 100644 --- a/test/features.test.ts +++ b/test/features.test.ts @@ -191,6 +191,27 @@ describe('agents, watches, landing pages and rankings', { skip: !database }, () ); assert.equal(mine.items.length, 2); + const childResponse = await request('/api/v1/agents', owner, 'POST', { + name: `Atomic child ${suffix}`, + skills: ['testing'], + }); + assert.equal(childResponse.status, 201, await childResponse.clone().text()); + const child = (await json<{ agent: { slug: string; operator: unknown } }>(childResponse)).agent; + assert.equal(child.operator, null); + + // A failed bulk assignment must leave every earlier target untouched. + const partialAssignment = await request( + `/api/v1/agents/${dispatcher.agent.slug}/operates`, + owner, + 'POST', + { agents: [child.slug, `missing-${suffix}`] }, + ); + assert.equal(partialAssignment.status, 404); + const unchangedChild = await json<{ agent: { operator: unknown } }>( + await request(`/api/v1/agents/${child.slug}`, owner), + ); + assert.equal(unchangedChild.agent.operator, null); + // One call names a sysop for several agents. const operates = await json<{ agent: { operates: { slug: string }[] } }>( await request(`/api/v1/agents/${dispatcher.agent.slug}/operates`, owner, 'POST', { From 69be531710e3fdc86b5a06fd913e38eb05695f03 Mon Sep 17 00:00:00 2001 From: RowletCC Date: Thu, 24 Sep 2026 21:48:39 +0800 Subject: [PATCH 006/116] fix(inbox): open long threads at recent messages (#217) --- src/core/inbox.ts | 7 ++++- test/inbox-recent-messages.test.ts | 41 ++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 test/inbox-recent-messages.test.ts diff --git a/src/core/inbox.ts b/src/core/inbox.ts index 747512f..244b40a 100644 --- a/src/core/inbox.ts +++ b/src/core/inbox.ts @@ -408,7 +408,12 @@ export async function getThread( sender_id: string; }>( `select id, kind, body, invoice_id, created_at, sender_id - from messages where thread_id = $1 order by created_at asc limit 500`, + from ( + select id, kind, body, invoice_id, created_at, sender_id + from messages where thread_id = $1 + order by created_at desc, id desc limit 500 + ) recent + order by created_at asc, id asc`, [threadId], ); diff --git a/test/inbox-recent-messages.test.ts b/test/inbox-recent-messages.test.ts new file mode 100644 index 0000000..cef5b8f --- /dev/null +++ b/test/inbox-recent-messages.test.ts @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { getThread } from '../dist/core/inbox.js'; + +test('a long conversation opens with its newest 500 messages in reading order', async () => { + const messages = Array.from({ length: 501 }, (_, index) => ({ + id: `message-${index + 1}`, + kind: 'text' as const, + body: `Message ${index + 1}`, + invoice_id: null, + created_at: new Date(Date.UTC(2026, 0, 1, 0, 0, index)).toISOString(), + sender_id: index % 2 === 0 ? 'viewer' : 'other', + })); + const participants = [ + { user_id: 'viewer', org_id: null, user_name: 'Ada', email: 'ada@example.test', public_slug: 'ada', org_name: null, org_slug: null }, + { user_id: 'other', org_id: null, user_name: 'Bob', email: 'bob@example.test', public_slug: 'bob', org_name: null, org_slug: null }, + ]; + const pool = { + async query(sql: string) { + if (sql.includes('from thread_participants tp')) return { rows: participants }; + if (sql.includes('from threads t left join jobs')) { + return { rows: [{ id: 'thread', subject: 'Work', created_at: '2026-01-01T00:00:00Z', job_slug: null, job_title: null }] }; + } + if (sql.includes('from messages where thread_id')) { + // Model the database's LIMIT after its ORDER BY. The outer query, if + // present, restores chronological order for display. + const recent = /order by created_at desc/i.test(sql); + const selected = recent ? [...messages].reverse().slice(0, 500) : messages.slice(0, 500); + const restored = /\) recent\s+order by created_at asc/i.test(sql); + return { rows: restored ? selected.reverse() : selected }; + } + throw new Error(`Unexpected query: ${sql}`); + }, + }; + + const thread = await getThread(pool as never, 'thread', 'viewer'); + assert.ok(thread); + assert.equal(thread.messages.length, 500); + assert.equal(thread.messages[0]?.body, 'Message 2'); + assert.equal(thread.messages.at(-1)?.body, 'Message 501'); +}); From cd4d11167338f9b20c5220463eef5a185ef9ef97 Mon Sep 17 00:00:00 2001 From: RowletCC Date: Thu, 24 Sep 2026 21:48:47 +0800 Subject: [PATCH 007/116] fix(tracker): reject invalid calendar dates (#218) --- src/core/tracker.ts | 6 ++++++ test/tracker.test.ts | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/src/core/tracker.ts b/src/core/tracker.ts index 7db9391..62ba22a 100644 --- a/src/core/tracker.ts +++ b/src/core/tracker.ts @@ -104,10 +104,16 @@ export function parseEvents(value: unknown, currency: string): TrackerEvent[] { 'Event currency differs from the fleet. Import into a fleet with the matching currency.', ); const at = e['at'] ?? null; + const datePart = typeof at === 'string' ? /^\d{4}-\d{2}-\d{2}/.exec(at)?.[0] : undefined; + const validCalendarDate = + datePart !== undefined && + Number.isFinite(Date.parse(`${datePart}T00:00:00.000Z`)) && + new Date(`${datePart}T00:00:00.000Z`).toISOString().slice(0, 10) === datePart; if ( at !== null && (typeof at !== 'string' || !/^\d{4}-\d{2}-\d{2}T/.test(at) || + !validCalendarDate || !Number.isFinite(Date.parse(at))) ) throw new TrackerProblem('Event time must be an ISO timestamp or null.'); diff --git a/test/tracker.test.ts b/test/tracker.test.ts index d20ed8a..f09b892 100644 --- a/test/tracker.test.ts +++ b/test/tracker.test.ts @@ -85,6 +85,12 @@ test('parser rejects foreign currencies, nonfinite or negative money, extra meta assert.throws(() => parseEvents([event('a', 'receipt', 1, { at: 'not-a-date' })], 'USD')); }); +test('parser rejects calendar-invalid event timestamps instead of moving them to another date', () => { + assert.throws(() => + parseEvents([event('invalid-date', 'receipt', 1, { at: '2026-02-30T12:00:00.000Z' })], 'USD'), + ); +}); + const costReport = { since: 1700000000000, sessions: [ From 2fa3b775db188baf3bdbf88ed5467085cff4b49b Mon Sep 17 00:00:00 2001 From: RowletCC Date: Thu, 24 Sep 2026 21:48:55 +0800 Subject: [PATCH 008/116] fix(recommendations): keep rewrite signature selected (#219) --- src/views/recommendations.tsx | 19 +++++++++++++++++-- test/recommendations.test.ts | 13 +++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/views/recommendations.tsx b/src/views/recommendations.tsx index 5fd3d0a..88a333a 100644 --- a/src/views/recommendations.tsx +++ b/src/views/recommendations.tsx @@ -86,6 +86,18 @@ export interface RecommendFormProps { error?: string; } +function selectedSignature( + existing: Recommendation | null, + values: Record, + asOptions: { slug: string; name: string }[], +): string { + if (values['as'] !== undefined) return values['as']; + const employerSlug = existing?.author.kind === 'employer' ? existing.author.slug : null; + return employerSlug !== null && asOptions.some((option) => option.slug === employerSlug) + ? employerSlug + : ''; +} + export const RecommendForm: FC = ({ action, asOptions, @@ -128,12 +140,15 @@ export const RecommendForm: FC = ({ > - -
- - - It will not invent a salary. Pay comes from what you write here, or stays empty. - + + +
+ + + It will not invent a salary. Pay comes from what you write here, or stays empty. + +
- - )} + )} -
')); }); +test('the application form shows the missing agent disclosure error beside its field', () => { + const html = String( + JobDetail({ + job: { ...job, agentPolicy: 'disclose' }, + html: '', + publicUrl: 'https://x.test', + problems: [ + { + field: 'agent', + message: 'This employer asks applications written with an agent to say so.', + }, + ], + values: {}, + signedIn: false, + resumes: [], + } as never), + ); + + assert.match( + html, + /

Skills<\/h3>/, 'the resume sections stay on the page'); const detail = (await (await get(`/api/v1/candidates/${slug}`)).json()) as { markdown: string; listed: boolean; From d03e6a2d3212915df5013d68b9912f9944a6736c Mon Sep 17 00:00:00 2001 From: RowletCC Date: Thu, 24 Sep 2026 23:10:17 +0800 Subject: [PATCH 047/116] fix(apply): keep drafts from sending to closed jobs (#258) --- src/core/applications.ts | 33 ++++++++++++++---- src/server/routes/api.ts | 10 +++++- src/server/routes/openapi.ts | 4 ++- test/api.test.ts | 66 ++++++++++++++++++++++++++++++++++++ test/applications.test.ts | 20 ++++++++++- 5 files changed, 123 insertions(+), 10 deletions(-) diff --git a/src/core/applications.ts b/src/core/applications.ts index ace5351..654efb6 100644 --- a/src/core/applications.ts +++ b/src/core/applications.ts @@ -224,17 +224,36 @@ export async function submitApplication( pool: pg.Pool, id: string, userId: string, -): Promise { +): Promise<'submitted' | 'job_not_open' | 'not_found'> { // Same guard as decideApplication: the id arrives from a URL, and a string // that is not a uuid makes Postgres raise rather than match nothing. - if (!UUID.test(id)) return false; - const result = await pool.query( - `update applications - set status = 'new', submitted_at = now() - where id = $1 and user_id = $2 and submitted_at is null`, + if (!UUID.test(id)) return 'not_found'; + const result = await pool.query<{ outcome: 'submitted' | 'job_not_open' | 'not_found' }>( + `with draft as ( + select a.id, + exists ( + select 1 from jobs j + where j.id = a.job_id and j.status = 'published' + and j.published_at is not null and j.published_at <= now() + and (j.expires_at is null or j.expires_at > now()) + ) as accepting + from applications a + where a.id = $1 and a.user_id = $2 and a.submitted_at is null + ), submitted as ( + update applications a + set status = 'new', submitted_at = now() + from draft d + where a.id = d.id and a.submitted_at is null and d.accepting + returning a.id + ) + select case + when exists (select 1 from submitted) then 'submitted' + when exists (select 1 from draft where not accepting) then 'job_not_open' + else 'not_found' + end as outcome`, [id, userId], ); - return (result.rowCount ?? 0) > 0; + return result.rows[0]?.outcome ?? 'not_found'; } export async function listDraftApplications( diff --git a/src/server/routes/api.ts b/src/server/routes/api.ts index 18f3da8..1e7a9c6 100644 --- a/src/server/routes/api.ts +++ b/src/server/routes/api.ts @@ -706,7 +706,15 @@ export function apiRoutes(): Hono { const viewer = viewerOf(c); if (viewer === null) return fail(c, 401, 'unauthenticated', 'Sign in first.'); const sent = await submitApplication(pool, c.req.param('id'), viewer.id); - if (!sent) { + if (sent === 'job_not_open') { + return fail( + c, + 409, + 'job_not_open', + 'This listing is no longer accepting applications. Your draft is still saved.', + ); + } + if (sent === 'not_found') { return fail( c, 404, diff --git a/src/server/routes/openapi.ts b/src/server/routes/openapi.ts index 0bf15dd..a7455a7 100644 --- a/src/server/routes/openapi.ts +++ b/src/server/routes/openapi.ts @@ -316,9 +316,11 @@ export function openApiDocument(config: Config): Record { post: { tags: ['apply'], summary: 'Send a draft application.', + description: + 'The draft is sent only while its listing is published and unexpired. If the listing has closed or expired, the draft stays saved and the response is 409.', security: [{ bearer: [] }], parameters: [pathParam('id')], - responses: { 200: ok('Sent.'), 401: err(), 404: err() }, + responses: { 200: ok('Sent.'), 401: err(), 404: err(), 409: err() }, }, }, '/api/v1/orgs': { diff --git a/test/api.test.ts b/test/api.test.ts index bdcd169..4a93cf4 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -1213,6 +1213,72 @@ describe('the API', { skip: reason === '' ? false : `no database: ${reason}` }, assert.doesNotMatch(submitted, /Sent 2 days ago|Not sent to the employer/); }); + test('a prepared application stays a draft after its listing closes', async () => { + if (pool === null) return; + const { createSession, ensureUser } = await import('../dist/core/auth.js'); + const { createOrg } = await import('../dist/core/orgs.js'); + const stamp = `${Date.now()}${Math.random().toString(36).slice(2, 7)}`; + const employer = await ensureUser(pool as never, `closed+${stamp}@example.com`, 'Employer'); + const employerToken = await createSession(pool as never, employer.id, { label: 'web' }); + const org = await createOrg(pool as never, employer.id, { name: `Closed ${stamp}` }); + if (typeof org === 'string') throw new Error(org); + const employerAuth = { authorization: `Bearer ${employerToken}` }; + const candidate = await ensureUser( + pool as never, + `candidate+${stamp}@example.com`, + 'Candidate', + ); + const candidateToken = await createSession(pool as never, candidate.id, { label: 'web' }); + const candidateAuth = { authorization: `Bearer ${candidateToken}` }; + + const created = (await ( + await post( + '/api/v1/jobs', + { + org: org.slug, + title: `Closing ${stamp}`, + description: 'A listing that takes applications on the board.', + agentPolicy: 'welcome', + pay: ['$100 an hour'], + }, + employerAuth, + ) + ).json()) as { job: { slug: string } }; + const slug = created.job.slug; + await post(`/api/v1/jobs/${slug}/publish`, {}, employerAuth); + const prepared = (await ( + await post( + `/api/v1/jobs/${slug}/apply`, + { + name: 'Candidate', + email: `candidate+${stamp}@example.com`, + cover: 'Please consider me.', + submit: false, + }, + candidateAuth, + ) + ).json()) as { applicationId: string }; + + await post(`/api/v1/jobs/${slug}/close`, {}, employerAuth); + const submitted = await post( + `/api/v1/applications/${prepared.applicationId}/submit`, + {}, + candidateAuth, + ); + assert.equal(submitted.status, 409, await submitted.text()); + + const drafts = (await (await get('/api/v1/applications/drafts', candidateAuth)).json()) as { + items: { id: string }[]; + }; + assert.ok(drafts.items.some((item) => item.id === prepared.applicationId)); + const inbox = (await ( + await get(`/api/v1/jobs/${slug}/applications`, employerAuth) + ).json()) as { + items: { id: string }[]; + }; + assert.ok(!inbox.items.some((item) => item.id === prepared.applicationId)); + }); + test('a draft nobody sent cannot be decided on', async () => { // An employer cannot see a draft, so an employer cannot reject one out // from under the candidate who has not released it yet. diff --git a/test/applications.test.ts b/test/applications.test.ts index eeefaaf..99ccaeb 100644 --- a/test/applications.test.ts +++ b/test/applications.test.ts @@ -44,10 +44,28 @@ test('submitting a draft with a malformed id is a miss, not a database error', a 'not-a-uuid', '00000000-0000-4000-8000-000000000000', ); - assert.equal(sent, false); + assert.equal(sent, 'not_found'); assert.equal(reached, false, 'a malformed id never reaches the database'); }); +test('submitting a draft reports when its job has stopped accepting applications', async () => { + let sql = ''; + const pool = { + query: async (statement: string) => { + sql = statement; + return { rows: [{ outcome: 'job_not_open' }], rowCount: 0 }; + }, + }; + const sent = await submitApplication( + pool as never, + '00000000-0000-4000-8000-000000000001', + '00000000-0000-4000-8000-000000000002', + ); + assert.equal(sent, 'job_not_open'); + assert.match(sql, /j\.status = 'published'/); + assert.match(sql, /j\.expires_at is null or j\.expires_at > now\(\)/); +}); + test('an application field named __proto__ keeps its submitted answer', () => { const result = validateApplication( { From 23b9468db0ed62072bddde54ee5a240132e08a12 Mon Sep 17 00:00:00 2001 From: RowletCC Date: Thu, 24 Sep 2026 23:10:25 +0800 Subject: [PATCH 048/116] fix(directory): redact phone numbers from profile summaries (#259) --- src/core/candidates.ts | 30 ++++++++++++------------- src/markup/resume.ts | 8 +++++++ test/redaction.test.ts | 50 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 16 deletions(-) diff --git a/src/core/candidates.ts b/src/core/candidates.ts index 2c6b2a6..85225f6 100644 --- a/src/core/candidates.ts +++ b/src/core/candidates.ts @@ -10,7 +10,12 @@ import type { Resume } from './resumes.ts'; import type { CandidateSummary } from '../views/candidates.tsx'; import { parseCapacity } from './capacity.ts'; -import { type OpenResume, parseResume, redactContactChannels } from '../markup/resume.ts'; +import { + type OpenResume, + hasContactChannel, + parseResume, + redactContactChannels, +} from '../markup/resume.ts'; /** Contact keys that read as a place rather than an address. */ const LOCATION_KEYS = /^(location|based|city|where|region)$/i; @@ -86,20 +91,13 @@ function plainName(value: string): string { } } -/** - * "Looks like an email address", loosely — the check a public field needs, - * not a validator. Used anywhere directory text could carry a channel. - */ -const HAS_ADDRESS = /[^\s@]+@[^\s@]+\.[^\s@]+/; - /** * The name shown in the directory. * * A resume with no usable h1 falls back to its title, which its owner wrote - * and which is at least theirs. It never falls back to an email address: - * publishing a resume should not mean publishing an address as the headline. - * An h1 is free text, so "Jane Doe jane@example.com" is a name the parser - * returns without complaint — the address check has to live here, on the + * and which is at least theirs. It never falls back to a contact channel: + * publishing a resume should not mean publishing an address or phone number + * as the headline. An h1 is free text, so the check has to live here, on the * field, the same way it does for the headline. */ export function nameOf(resume: Resume): string { @@ -109,12 +107,12 @@ export function nameOf(resume: Resume): string { parsed !== undefined && parsed !== '' && parsed.length <= NAME_MAX && - !HAS_ADDRESS.test(parsed) + !hasContactChannel(parsed) ) { return parsed; } const title = resume.title.trim(); - return title === '' || title.length > NAME_MAX || HAS_ADDRESS.test(title) + return title === '' || title.length > NAME_MAX || hasContactChannel(title) ? 'Candidate' : title; } @@ -167,15 +165,15 @@ export function resumeForViewer( * A backfill would repair those rows, but it would not stop the next one: any * resume saved by an older build, restored from a backup, or written straight * into the column arrives here the same way. This is a public directory page - * and the field is one line of text, so it is checked on the way out. Cheap, - * and it cannot go stale. + * and the field is one line of text, so it is checked for contact channels on + * the way out. Cheap, and it cannot go stale. */ function headlineOf(resume: Resume): string | null { const headline = resume.parsed?.headline; if (headline === null || headline === undefined) return null; const cleaned = headline.replace(/\*\*|__/g, '').trim(); if (cleaned === '') return null; - return HAS_ADDRESS.test(cleaned) ? null : cleaned; + return hasContactChannel(cleaned) ? null : cleaned; } export function toCandidateSummary(resume: Resume): CandidateSummary { diff --git a/src/markup/resume.ts b/src/markup/resume.ts index 08a2608..fe45b6d 100644 --- a/src/markup/resume.ts +++ b/src/markup/resume.ts @@ -479,6 +479,14 @@ const EMAIL_IN_TEXT = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g; const PHONE_IN_TEXT = /(?:\+\d[\d ()./-]{6,}\d|\(\d{3}\)[\d ()./-]{5,}\d|\b\d{3}[-.\s]\d{3}[-.\s]\d{4}\b)/g; +/** Whether plain summary text contains a channel the public resume withholds. */ +export function hasContactChannel(text: string): boolean { + return ( + new RegExp(EMAIL_IN_TEXT.source).test(text) || + new RegExp(PHONE_IN_TEXT.source).test(text) + ); +} + /** * The contact block, minus every way to actually reach the person. * diff --git a/test/redaction.test.ts b/test/redaction.test.ts index 607fe2a..9be6837 100644 --- a/test/redaction.test.ts +++ b/test/redaction.test.ts @@ -218,6 +218,56 @@ test('a name holding an address is dropped on the directory card', () => { assert.equal(summary.name, 'Jane Doe'); }); +test('a phone in the name line is dropped on the directory card', () => { + const summary = toCandidateSummary({ + id: 'r1', + userId: 'u1', + slug: 'jane', + title: 'Jane Doe', + markdown: '# x\n', + parsed: { + name: 'Jane Doe +1 (408) 555-0100', + headline: null, + contact: [], + sections: [], + markdown: '', + warnings: [], + }, + visibility: 'public', + publicSlug: 'jane', + sourceName: null, + createdAt: '2026-09-09T00:00:00.000Z', + updatedAt: '2026-09-09T00:00:00.000Z', + } as never); + + assert.equal(summary.name, 'Jane Doe'); +}); + +test('a phone in the headline is dropped from the directory card', () => { + const summary = toCandidateSummary({ + id: 'r1', + userId: 'u1', + slug: 'jane', + title: 'Jane Doe', + markdown: '# Jane Doe\n', + parsed: { + name: 'Jane Doe', + headline: 'Call me at +1 (408) 555-0100', + contact: [], + sections: [], + markdown: '', + warnings: [], + }, + visibility: 'public', + publicSlug: 'jane', + sourceName: null, + createdAt: '2026-09-09T00:00:00.000Z', + updatedAt: '2026-09-09T00:00:00.000Z', + } as never); + + assert.equal(summary.headline, null); +}); + test('a title holding an address is not the fallback either', () => { const name = nameOf({ id: 'r1', From c3e63e529d11f181cf2086435323f5e0576a1e95 Mon Sep 17 00:00:00 2001 From: RowletCC Date: Thu, 24 Sep 2026 23:10:33 +0800 Subject: [PATCH 049/116] fix(cli): return updated notifications after marking read (#260) --- src/cli/watches.ts | 21 ++++++++--- test/cli-notifications-mark-read.test.ts | 47 ++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 5 deletions(-) create mode 100644 test/cli-notifications-mark-read.test.ts diff --git a/src/cli/watches.ts b/src/cli/watches.ts index db6915a..7b2beef 100644 --- a/src/cli/watches.ts +++ b/src/cli/watches.ts @@ -82,14 +82,25 @@ export async function runNotifications( out: (human: string, machine: unknown) => number, ): Promise { const unreadOnly = flagBool(args, 'unread'); - const result = await client.notifications({ unreadOnly }); - if (flagBool(args, 'read')) await client.markNotificationsRead(); + let result = await client.notifications({ unreadOnly }); + let marked = 0; + if (flagBool(args, 'read')) { + ({ read: marked } = await client.markNotificationsRead()); + const updated = await client.notifications(); + const displayed = new Set(result.items.map((item) => item.id)); + result = { + ...updated, + items: unreadOnly ? updated.items.filter((item) => displayed.has(item.id)) : updated.items, + }; + } if (result.items.length === 0) return out(unreadOnly ? 'Nothing unread.' : 'No notifications yet.', result); const head = - result.unread === 0 - ? '' - : `${bold(`${result.unread} unread`)}${flagBool(args, 'read') ? dim(', now marked read') : dim(' (--read marks them read)')}\n\n`; + marked > 0 + ? `${bold(`${marked} unread`)}${dim(', now marked read')}\n\n` + : result.unread === 0 + ? '' + : `${bold(`${result.unread} unread`)}${dim(flagBool(args, 'read') ? ' (new since marking read)' : ' (--read marks them read)')}\n\n`; return out(head + result.items.map(notificationLine).join('\n\n'), result); } diff --git a/test/cli-notifications-mark-read.test.ts b/test/cli-notifications-mark-read.test.ts new file mode 100644 index 0000000..4e18539 --- /dev/null +++ b/test/cli-notifications-mark-read.test.ts @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { runNotifications } from '../dist/cli/watches.js'; + +test('notifications --read returns the updated unread count and read timestamps', async () => { + const before = notification(null); + const after = notification('2026-09-24T12:00:00.000Z'); + let reads = 0; + const client = { + async notifications() { + reads += 1; + return reads === 1 ? { items: [before], unread: 1 } : { items: [after], unread: 0 }; + }, + async markNotificationsRead() { + return { read: 1 }; + }, + }; + let human = ''; + let machine: unknown; + + const code = await runNotifications( + { command: 'notifications', positional: [], flags: { read: true } }, + client as never, + (text, data) => { + human = text; + machine = data; + return 0; + }, + ); + + assert.equal(code, 0); + assert.match(human, /now marked read/); + assert.deepEqual(machine, { items: [after], unread: 0 }); + assert.equal(reads, 2); +}); + +function notification(readAt: string | null) { + return { + id: 'notification-1', + kind: 'watch', + title: 'Rust engineer', + body: 'A new remote job matched your watch.', + url: '/jobs/rust-engineer', + createdAt: '2026-09-24T11:00:00.000Z', + readAt, + }; +} From 15bf687976a8dc675ed450e4fe634664065853f9 Mon Sep 17 00:00:00 2001 From: RowletCC Date: Thu, 24 Sep 2026 23:10:40 +0800 Subject: [PATCH 050/116] fix(jobs): preserve omitted settings in patches (#261) --- src/server/routes/api.ts | 2 ++ test/api.test.ts | 51 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/src/server/routes/api.ts b/src/server/routes/api.ts index 1e7a9c6..e40cf25 100644 --- a/src/server/routes/api.ts +++ b/src/server/routes/api.ts @@ -578,6 +578,8 @@ export function apiRoutes(): Hono { requirements: body['requirements'] ?? job.requirements, responsibilities: body['responsibilities'] ?? job.responsibilities, agentPolicy: body['agentPolicy'] ?? job.agentPolicy, + applySchema: Object.hasOwn(body, 'applySchema') ? body['applySchema'] : job.apply.schema, + expiresAt: Object.hasOwn(body, 'expiresAt') ? body['expiresAt'] : job.expiresAt, ...payPatch(body, job), }, job.org.id, diff --git a/test/api.test.ts b/test/api.test.ts index 4a93cf4..d448f69 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -2947,6 +2947,57 @@ describe('the API', { skip: reason === '' ? false : `no database: ${reason}` }, assert.equal(edited.job.title, created.job.title); }); + test('an edit that omits the application schema and expiry preserves both', async () => { + if (pool === null) return; + const { createSession } = await import('../dist/core/auth.js'); + const owner = await pool.query(`select user_id from memberships limit 1`); + const token = await createSession(pool as never, owner.rows[0]?.['user_id'], { label: 't' }); + const auth = { authorization: `Bearer ${token}` }; + const expiresAt = '2099-01-01T00:00:00.000Z'; + + const created = (await ( + await post( + '/api/v1/jobs', + { + org: 'example-works', + title: `Preserve settings ${Date.now()}`, + description: 'A listing with a custom question and an expiry date.', + agentPolicy: 'welcome', + expiresAt, + applySchema: { + fields: [ + { + name: 'portfolio', + label: 'Portfolio link', + type: 'url', + required: true, + maxLength: 500, + }, + ], + }, + }, + auth, + ) + ).json()) as { job: { slug: string } }; + + const edited = (await ( + await patch( + `/api/v1/jobs/${created.job.slug}`, + { description: 'A corrected description.' }, + auth, + ) + ).json()) as { + job: { + expiresAt: string | null; + apply: { schema: { fields: { name: string; label: string }[] } }; + }; + }; + assert.equal(edited.job.expiresAt, expiresAt); + assert.deepEqual(edited.job.apply.schema.fields, [ + { name: 'portfolio', label: 'Portfolio link', type: 'url', required: true, maxLength: 500 }, + ]); + }); + test("editing someone else's listing is refused", async () => { if (pool === null) return; const { createSession, ensureUser } = await import('../dist/core/auth.js'); From 5ca428931859d2e23398034fab4385bff826a6d4 Mon Sep 17 00:00:00 2001 From: RowletCC Date: Thu, 24 Sep 2026 23:10:45 +0800 Subject: [PATCH 051/116] fix(import): detect mislabeled Word resumes (#262) --- src/core/import.ts | 15 ++++++++++++--- src/core/zip.ts | 5 +++++ test/import-document.test.ts | 8 ++++++++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/core/import.ts b/src/core/import.ts index 6f2dfef..6a09a99 100644 --- a/src/core/import.ts +++ b/src/core/import.ts @@ -23,7 +23,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { promisify } from 'node:util'; -import { readZipEntry, ZipProblem } from './zip.ts'; +import { hasZipEntry, readZipEntry, ZipProblem } from './zip.ts'; const run = promisify(execFile); @@ -78,8 +78,17 @@ function classify(extension: string, mime: string, bytes: Buffer): Kind { // The magic bytes win over the extension. A resume renamed from .pdf to // .docx is a mistake someone makes once, and the file itself is not lying. if (bytes.subarray(0, 5).toString('latin1') === '%PDF-') return 'pdf'; - if (bytes.subarray(0, 2).toString('latin1') === 'PK' && (extension === 'docx' || mime.includes('wordprocessingml'))) { - return 'docx'; + if (bytes.subarray(0, 2).toString('latin1') === 'PK') { + if (extension === 'docx' || mime.includes('wordprocessingml')) return 'docx'; + // Word documents are ZIP archives. A renamed .docx still identifies + // itself through its document entry, even when the extension and browser + // MIME type describe something else. + try { + if (hasZipEntry(bytes, 'word/document.xml')) return 'docx'; + } catch { + // An unrelated or malformed ZIP is not a Word document; keep checking + // the filename and MIME type below for the useful import error. + } } if (['md', 'markdown', 'mdown', 'mkd'].includes(extension)) return 'markdown'; if (extension === 'txt' || mime === 'text/plain') return 'text'; diff --git a/src/core/zip.ts b/src/core/zip.ts index 66c145e..51b5711 100644 --- a/src/core/zip.ts +++ b/src/core/zip.ts @@ -89,6 +89,11 @@ function readEntries(buffer: Buffer): Entry[] { return entries; } +/** Whether a structurally valid archive contains a named entry. */ +export function hasZipEntry(buffer: Buffer, wanted: string): boolean { + return readEntries(buffer).some((entry) => entry.name === wanted); +} + export function readZipEntry(buffer: Buffer, wanted: string): Buffer | null { const entry = readEntries(buffer).find((item) => item.name === wanted); if (entry === undefined) return null; diff --git a/test/import-document.test.ts b/test/import-document.test.ts index 136133e..ff53380 100644 --- a/test/import-document.test.ts +++ b/test/import-document.test.ts @@ -32,3 +32,11 @@ test('a Word import preserves inline controls and decodes XML text exactly once' warnings: [], }); }); + +test('a Word resume still imports when its filename says PDF', async () => { + const bytes = await readFile(new URL('./fixtures/docx-inline-controls.docx', import.meta.url)); + const result = await importDocument('resume.pdf', bytes, 'application/pdf'); + + assert.equal(result.via, 'docx'); + assert.match(result.markdown, /Plain & escaped /); +}); From cc20958a9dac9371c834420054cc136af85bcca5 Mon Sep 17 00:00:00 2001 From: RowletCC Date: Thu, 24 Sep 2026 23:10:52 +0800 Subject: [PATCH 052/116] fix(search): require every requested job tag (#263) --- src/core/jobs.ts | 13 ++++++++--- src/server/routes/openapi.ts | 5 ++++- test/api.test.ts | 43 ++++++++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/src/core/jobs.ts b/src/core/jobs.ts index ccd37d0..6be280c 100644 --- a/src/core/jobs.ts +++ b/src/core/jobs.ts @@ -264,9 +264,16 @@ function conditions( } if (query.tags.length > 0) { params.push(query.tags); - // Matching either list means "python" finds a job that called it a tag and - // one that called it part of the stack. - where.push(`(j.tags && $${params.length}::text[] or j.stack && $${params.length}::text[])`); + // Every requested skill narrows the search, whether a listing put it in + // tags or stack. A single array-overlap test made `rust,typescript` mean + // either skill and returned unrelated jobs from a search that looked exact. + where.push( + `((j.tags && $${params.length}::text[]) or (j.stack && $${params.length}::text[])) + and not exists ( + select 1 from unnest($${params.length}::text[]) as requested(tag) + where not (requested.tag = any(j.tags) or requested.tag = any(j.stack)) + )`, + ); } if (query.salaryMin !== null) { params.push(query.salaryMin); diff --git a/src/server/routes/openapi.ts b/src/server/routes/openapi.ts index a7455a7..7dcf63e 100644 --- a/src/server/routes/openapi.ts +++ b/src/server/routes/openapi.ts @@ -154,7 +154,10 @@ export function openApiDocument(config: Config): Record { param('workplace', 'remote, hybrid, onsite'), param('seniority', 'intern, junior, mid, senior, staff, principal, lead'), param('agentPolicy', 'welcome, disclose, human-only'), - param('tag', 'Repeatable. Matches tags and stack.'), + param( + 'tag', + 'Repeatable, or comma-separated as tags=. Every requested tag must occur in tags or stack.', + ), param( 'salaryMin', 'Minimum annualised salary, compared against the top of each range.', diff --git a/test/api.test.ts b/test/api.test.ts index d448f69..0fd995f 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -3288,6 +3288,49 @@ describe('the API', { skip: reason === '' ? false : `no database: ${reason}` }, assert.ok(comma.total > 0, `expected ${tag} to match something`); }); + test('multiple job tags narrow across both tags and stack', async () => { + if (pool === null) return; + const { createSession } = await import('../dist/core/auth.js'); + const owner = await pool.query(`select user_id from memberships limit 1`); + const token = await createSession(pool as never, owner.rows[0]?.['user_id'], { label: 't' }); + const auth = { authorization: `Bearer ${token}` }; + const stamp = Date.now().toString(36); + const first = `need-a-${stamp}`; + const second = `need-b-${stamp}`; + const postPublished = async (title: string, tags: string[], stack: string[]) => { + const response = await post( + '/api/v1/jobs', + { + org: 'example-works', + title: `${title} ${stamp}`, + description: 'A paid listing used to check combined skill filtering.', + agentPolicy: 'welcome', + tags, + stack, + pay: ['$1 per task'], + payMethod: 'SOL', + publish: true, + }, + auth, + ); + const body = await response.text(); + assert.equal(response.status, 201, body); + return (JSON.parse(body) as { job: { slug: string } }).job.slug; + }; + + const matching = await postPublished('Both skills', [first], [second]); + await postPublished('Only first skill', [first], []); + await postPublished('Only second skill', [], [second]); + + const page = (await ( + await get(`/api/v1/jobs?org=example-works&tags=${first},${second}&limit=100`) + ).json()) as { items: { slug: string }[] }; + assert.deepEqual( + page.items.map((job) => job.slug), + [matching], + ); + }); + test('one query, every representation', async () => { // A filter written once should work whichever way you read the board. // /jobs.rss and /feed.rss built their query from an EMPTY From 1d279cde74d31217f603b49e69fbb190e9d25b5f Mon Sep 17 00:00:00 2001 From: RowletCC Date: Thu, 24 Sep 2026 23:10:59 +0800 Subject: [PATCH 053/116] fix(discovery): omit inactive jobs from sitemaps (#264) --- src/server/routes/discovery.ts | 5 ++++- test/api.test.ts | 12 ++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/server/routes/discovery.ts b/src/server/routes/discovery.ts index a1a23ec..d184c6d 100644 --- a/src/server/routes/discovery.ts +++ b/src/server/routes/discovery.ts @@ -525,7 +525,9 @@ export function discoveryRoutes(): Hono { const months = await pool.query<{ month: string; latest: string }>( `select to_char(published_at, 'YYYY-MM') as month, max(published_at)::text as latest from jobs - where status = 'published' and published_at is not null + where status = 'published' + and published_at is not null and published_at <= now() + and (expires_at is null or expires_at > now()) group by 1 order by 1 desc`, ); const entries = months.rows @@ -570,6 +572,7 @@ export function discoveryRoutes(): Hono { const jobs = await pool.query<{ slug: string; updated_at: string }>( `select slug, updated_at from jobs where status = 'published' and published_at is not null + and published_at <= now() and (expires_at is null or expires_at > now()) and to_char(published_at, 'YYYY-MM') = $1 order by published_at desc limit 50000`, [name], diff --git a/test/api.test.ts b/test/api.test.ts index 0fd995f..7397577 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -705,6 +705,18 @@ describe('the API', { skip: reason === '' ? false : `no database: ${reason}` }, const expired = await createPublished(`Expired ${stamp}`, '2000-01-01T00:00:00.000Z'); const live = await createPublished(`Live ${stamp}`, '2099-01-01T00:00:00.000Z'); + // A published row can outlive its public listing. Keep it in a month + // with no other jobs so both the sitemap index and its monthly chunk + // prove they describe only URLs readers can open. + await pool.query(`update jobs set published_at = $2 where id = $1`, [ + expired.id, + '1900-01-15T00:00:00.000Z', + ]); + const sitemapIndex = await (await get('/sitemap.xml')).text(); + assert.ok(!sitemapIndex.includes('/sitemaps/1900-01.xml')); + const expiredMonth = await (await get('/sitemaps/1900-01.xml')).text(); + assert.ok(!expiredMonth.includes(`/jobs/${expired.slug}`)); + const search = (await (await get(`/api/v1/jobs?org=${org.slug}&limit=100`)).json()) as { items: { slug: string }[]; }; From 441607d7f0621e4123ba79f0e4ce6f52a0225561 Mon Sep 17 00:00:00 2001 From: RowletCC Date: Thu, 24 Sep 2026 23:12:32 +0800 Subject: [PATCH 054/116] fix(inbox): reuse threads for concurrent first messages (#265) --- src/core/inbox.ts | 82 ++++++++++++++++++++------------------ test/inbox-parties.test.ts | 58 +++++++++++++++++++++++++-- 2 files changed, 99 insertions(+), 41 deletions(-) diff --git a/src/core/inbox.ts b/src/core/inbox.ts index 2ebd982..1edc5dd 100644 --- a/src/core/inbox.ts +++ b/src/core/inbox.ts @@ -193,47 +193,53 @@ export async function startThread( } const jobId = input.jobId ?? null; - - // Find the existing conversation between these parties about this listing. - // "These parties" includes which identity the sender is using: a member of - // two employers has one row per thread, and `me.org_id` says whether that - // row is them personally or them speaking for one employer. Matching only - // on `me.user_id` would deliver a message written as Org B into a thread - // with Org A, shown to the other side as Org A and readable by Org A's - // members - and a personal message into an employer's thread the same way. - const existing = await pool.query<{ id: string }>( - `select t.id from threads t - where t.job_id is not distinct from $3 - and exists (select 1 from thread_participants me - where me.thread_id = t.id and me.user_id = $1 - and me.org_id is not distinct from $4) - and exists (select 1 from thread_participants them - where them.thread_id = t.id - and ${to.kind === 'candidate' ? 'them.user_id = $2 and them.org_id is null' : 'them.org_id = $2'}) - order by t.last_message_at desc limit 1`, - [senderId, to.kind === 'candidate' ? to.userId : to.orgId, jobId, asOrg], - ); - const found = existing.rows[0]; - if (found !== undefined) { - const message = await sendMessage(pool, found.id, senderId, body); - if (typeof message === 'string') return message; - return { threadId: found.id, messageId: message.id, created: false }; - } - - // The limit is on *new* conversations: replying in one you already have is - // never what a spammer is doing. - const recent = await pool.query<{ count: number }>( - `select count(*)::int as count from threads - where created_by = $1 and created_at > now() - interval '1 day'`, - [senderId], - ); - if ((recent.rows[0]?.count ?? 0) >= DAILY_THREADS) { - return `That is ${DAILY_THREADS} new conversations today. Reply in one you have, or come back tomorrow.`; - } - + const senderParty = asOrg === null ? `candidate:${senderId}` : `employer:${asOrg}`; + const recipientParty = + to.kind === 'candidate' ? `candidate:${to.userId}` : `employer:${to.orgId}`; + const conversationKey = JSON.stringify([jobId, ...[senderParty, recipientParty].sort()]); const client = await pool.connect(); try { await client.query('begin'); + // Two first messages can otherwise both miss the lookup below and create + // duplicate threads. Lock the conversation identity before checking it. + await client.query('select pg_advisory_xact_lock(hashtextextended($1, 0))', [conversationKey]); + + // Find the existing conversation between these parties about this listing. + // "These parties" includes which identity the sender is using: a member of + // two employers has one row per thread, and `me.org_id` says whether that + // person is themself or speaking for one employer. + const existing = await client.query<{ id: string }>( + `select t.id from threads t + where t.job_id is not distinct from $3 + and exists (select 1 from thread_participants me + where me.thread_id = t.id and me.user_id = $1 + and me.org_id is not distinct from $4) + and exists (select 1 from thread_participants them + where them.thread_id = t.id + and ${to.kind === 'candidate' ? 'them.user_id = $2 and them.org_id is null' : 'them.org_id = $2'}) + order by t.last_message_at desc limit 1`, + [senderId, to.kind === 'candidate' ? to.userId : to.orgId, jobId, asOrg], + ); + const found = existing.rows[0]; + if (found !== undefined) { + await client.query('commit'); + const message = await sendMessage(pool, found.id, senderId, body); + if (typeof message === 'string') return message; + return { threadId: found.id, messageId: message.id, created: false }; + } + + // The limit is on new conversations: replying in one you have is never + // what a spammer is doing. + const recent = await client.query<{ count: number }>( + `select count(*)::int as count from threads + where created_by = $1 and created_at > now() - interval '1 day'`, + [senderId], + ); + if ((recent.rows[0]?.count ?? 0) >= DAILY_THREADS) { + await client.query('rollback'); + return `That is ${DAILY_THREADS} new conversations today. Reply in one you have, or come back tomorrow.`; + } + const thread = await client.query<{ id: string }>( `insert into threads (subject, job_id, created_by) values ($1, $2, $3) returning id`, [subject === '' ? preview(body).slice(0, SUBJECT_MAX) : subject, jobId, senderId], diff --git a/test/inbox-parties.test.ts b/test/inbox-parties.test.ts index c233f6f..25892e9 100644 --- a/test/inbox-parties.test.ts +++ b/test/inbox-parties.test.ts @@ -42,11 +42,17 @@ function makePool() { }[], }; let serial = 0; + let lockQueue: Promise = Promise.resolve(); const nextId = () => `00000000-0000-4000-8000-${String(++serial).padStart(12, '0')}`; const now = () => ++serial + 1_000_000; - const query = async (sql: string, params: unknown[] = []) => { - if (sql === 'begin' || sql === 'commit' || sql === 'rollback') return { rows: [] }; + const query = async (sql: string, params: unknown[] = [], unlock?: () => void) => { + if (sql === 'begin') return { rows: [] }; + if (sql === 'commit' || sql === 'rollback') { + unlock?.(); + return { rows: [] }; + } + if (sql.includes('pg_advisory_xact_lock')) return { rows: [] }; // sendMessage's CTE: insert + touch last_message_at + mark sender read. if (sql.includes('with m as (')) { @@ -180,7 +186,24 @@ function makePool() { const pool = { query, - connect: async () => ({ query, release: () => {} }), + connect: async () => { + let unlock: (() => void) | undefined; + return { + query: async (sql: string, params: unknown[] = []) => { + if (sql.includes('pg_advisory_xact_lock')) { + const previous = lockQueue; + let release!: () => void; + lockQueue = new Promise((resolve) => { + release = resolve; + }); + await previous; + unlock = release; + } + return query(sql, params, unlock); + }, + release: () => unlock?.(), + }; + }, }; return { pool, model }; } @@ -279,6 +302,35 @@ test('writing as the employer the thread is with still finds it', async () => { assert.equal(reply.threadId, first.threadId); }); +test('concurrent first messages for the same parties and job share one thread', async () => { + const { pool, model } = makePool(); + seed({ pool, model }); + + const results = await Promise.all([ + startThread(pool as never, carol, { kind: 'employer', orgId: orgA }, { + subject: 'About the platform role', + body: 'I would like to discuss this role.', + jobId: 'cccccccc-0000-4000-8000-000000000001', + }), + startThread(pool as never, carol, { kind: 'employer', orgId: orgA }, { + subject: 'About the platform role', + body: 'I would like to discuss this role.', + jobId: 'cccccccc-0000-4000-8000-000000000001', + }), + ]); + + assert.equal(model.threads.length, 1); + assert.equal( + new Set(results.map((result) => (typeof result === 'string' ? result : result.threadId))).size, + 1, + ); + assert.deepEqual( + results.map((result) => (typeof result === 'string' ? null : result.created)).sort(), + [false, true], + ); + assert.equal(model.messages.length, 2); +}); + test('a body-derived subject never carries a lone surrogate into the insert', async () => { const { pool, model } = makePool(); seed({ pool, model }); From 7b35e97ce6036b4c9b993ec61ef0c4f4e44c5d1e Mon Sep 17 00:00:00 2001 From: kepler-ops-maker Date: Fri, 25 Sep 2026 13:51:33 +0900 Subject: [PATCH 055/116] test: fix idempotency suite skip guard for empty Postgres error messages A failed pg connection can produce an error with an empty .message, so reason === '' and the no-database skip guard never engages; the four subtests then crash with "Cannot read properties of null (reading 'query')". Track readiness with an explicit flag instead: 741 pass / 0 fail / 4 skipped without a database (was 741/4/0), and all 745 pass with one. (#268) --- test/idempotency.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/idempotency.test.ts b/test/idempotency.test.ts index 6da4b2b..0cef908 100644 --- a/test/idempotency.test.ts +++ b/test/idempotency.test.ts @@ -22,6 +22,7 @@ let pool: { } | null = null; let closePool: (() => Promise) | null = null; let reason = ''; +let ready = false; try { const db = await import('../dist/db/pool.js'); @@ -40,6 +41,7 @@ try { SECRET: 'test-secret', }); app = createApp(created, config, { send: async () => true }) as never; + ready = true; } catch (error) { reason = error instanceof Error ? error.message : String(error); } @@ -87,7 +89,7 @@ async function countJobs(orgId: string): Promise { return Number(rows.rows[0]?.['count'] ?? '0'); } -describe('idempotent posting', { skip: reason === '' ? false : `no database: ${reason}` }, () => { +describe('idempotent posting', { skip: ready ? false : `no database: ${reason || 'setup failed'}` }, () => { test('a repeat under the same key returns the listing already made, not a second one', async () => { const { org, stamp, auth } = await employer('Retry Co'); const listing = { From 93d0258afea4364d2780bd0245596796bd5ca4e1 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 01:57:24 -0700 Subject: [PATCH 056/116] Tracker: show zero and name what is missing instead of "Unknown" (#269) A fleet with only cost imports showed "Unknown" on five of six cards, which read as broken. The dashboard now shows 0 / USD 0 / 0.0% and says under each card what is missing (no work imported, no receipts/commissions/fees/affiliate payouts, cost entries with no amount). Incomplete profit shows zero with the costs imported so far as a note, not a negative number made of estimates. Adds a "How to improve" list: apply to more jobs, post jobs, list on the leaderboard, raise a rate below cost + target, bill hours that have costs, invoice tracked hours, and close a gap to the retained target. summarize() is unchanged, so the API and CLI still report null for no data. Co-authored-by: Claude Opus 5.5 (1M context) --- src/views/tracker.tsx | 157 ++++++++++++++++++++++++++++++--- test/tracker-dashboard.test.ts | 92 +++++++++++++++++++ test/tracker.test.ts | 3 +- web/public/app.css | 2 + web/public/sw.js | 2 +- 5 files changed, 243 insertions(+), 13 deletions(-) create mode 100644 test/tracker-dashboard.test.ts diff --git a/src/views/tracker.tsx b/src/views/tracker.tsx index d9acf27..a3527d1 100644 --- a/src/views/tracker.tsx +++ b/src/views/tracker.tsx @@ -2,15 +2,109 @@ import type { FC } from 'hono/jsx'; import type { Fleet, fleetReport, leaderboard } from '../core/tracker.ts'; type Report = Awaited>; +type Summary = Report['summary']; +// The dashboard shows nothing-imported-yet as zero and names what is missing +// beside it. The summary itself keeps null, so the API still tells "no data" +// apart from a reported zero. const money = (amount: number | null, currency: string): string => - amount === null - ? 'Unknown' - : `${currency} ${amount.toLocaleString('en-US', { maximumFractionDigits: 6 })}`; -const Metric: FC<{ label: string; value: string; note?: string }> = ({ label, value, note }) => ( + `${currency} ${(amount ?? 0).toLocaleString('en-US', { maximumFractionDigits: 6 })}`; +const LEDGER = ['cost', 'receipt', 'commission', 'fee', 'affiliate'] as const; +const NOUN = { + cost: ['costs', 'Cost'], + receipt: ['receipts', 'Receipt'], + commission: ['commissions', 'Commission'], + fee: ['fees', 'Fee'], + affiliate: ['affiliate payouts', 'Affiliate payout'], +} as const; +const either = (items: string[]) => + items.length < 2 ? items.join('') : `${items.slice(0, -1).join(', ')} or ${items.at(-1)}`; + +/** What keeps the billable-hours figure from being complete, or null. */ +export function hoursGap(s: Summary): string | null { + if (s.categories.work.count === 0) return 'No work imported yet.'; + if (s.missingWorkEntries > 0) + return `Billable work entries missing time or agent counts: ${s.missingWorkEntries}.`; + if (s.billableAgentHours === null) return 'No billable work imported yet.'; + return null; +} + +/** What keeps profit from being complete: feeds never imported, then entries without an amount. */ +export function ledgerGaps(s: Summary): string[] { + const none = LEDGER.filter((k) => s.categories[k].count === 0).map((k) => NOUN[k][0]); + const gaps = none.length ? [`No ${either(none)} imported yet.`] : []; + for (const k of LEDGER) { + const c = s.categories[k]; + if (c.count > c.known) gaps.push(`${NOUN[k][1]} entries with no amount: ${c.count - c.known}.`); + else if (c.partial) gaps.push(`${NOUN[k][1]} entries marked partial: ${c.partial}.`); + } + return gaps; +} + +export interface Tip { + text: string; + href?: string; + link?: string; +} + +/** Ways to improve this fleet's numbers, most direct first. */ +export function improvements({ fleet, summary: s }: Pick): Tip[] { + const tips: Tip[] = [ + { + text: 'Apply to more jobs. Every accepted job is billable agent-hours your fleet is not earning yet.', + href: '/', + link: 'Browse open jobs', + }, + { + text: 'Post jobs. When you have more work than your fleet can take, hire other agents for it and keep the margin.', + href: '/post-a-job', + link: 'Post a job', + }, + ]; + if (!fleet.publicListing) + tips.push({ + text: 'List your fleet on the public capacity leaderboard so employers can find it. Only its name, operator, agent count and rate are shown.', + href: '/tracker/leaderboard', + link: 'See the leaderboard', + }); + const floor = fleet.assumedDirectCost + fleet.retainedTarget; + if (fleet.rate < floor) + tips.push({ + text: `Raise your rate to at least ${money(floor, fleet.currency)}/agent-hour. At ${money(fleet.rate, fleet.currency)} it does not cover your ${money(fleet.assumedDirectCost, fleet.currency)} assumed cost plus your ${money(fleet.retainedTarget, fleet.currency)} retained target.`, + }); + const hours = s.billableAgentHours ?? 0; + const cost = s.categories.cost.amount ?? 0; + if (hours === 0 && cost > 0) + tips.push({ + text: `You have ${money(cost, fleet.currency)} of costs and no billable hours. Assign your Moshcode timers to a client and import them so that work gets billed.`, + }); + else if (hours > 0 && cost / hours > fleet.assumedDirectCost) + tips.push({ + text: `Your costs run ${money(cost / hours, fleet.currency)} per billable agent-hour against ${money(fleet.assumedDirectCost, fleet.currency)} assumed. Move routine work to cheaper models or raise your rate.`, + }); + if (s.categories.receipt.count === 0 && hours > 0) + tips.push({ + text: 'Invoice the hours you have tracked, then import the payments as receipts so profit reflects money received.', + href: '/inbox', + link: 'Open your inbox', + }); + if (s.retainedPerAgentHour !== null && s.retainedPerAgentHour < fleet.retainedTarget) + tips.push({ + text: `You retain ${money(s.retainedPerAgentHour, fleet.currency)} per billable agent-hour against a ${money(fleet.retainedTarget, fleet.currency)} target. Raise your rate or cut cost per hour.`, + }); + return tips; +} + +const Metric: FC<{ label: string; value: string; missing?: string | null; note?: string }> = ({ + label, + value, + missing, + note, +}) => (

{label}

{value}

+ {missing &&

{missing}

} {note &&

{note}

}
@@ -184,25 +278,35 @@ const FleetDashboard: FC<{ report: Report }> = ({
= ({ />
+
+

How to improve

+
+

Coverage and reported amounts

- Known amounts are subtotals of imported events, not proof of complete business accounting. - Missing feeds stay unknown; import an explicit zero only when the source confirms zero. + Known amounts are subtotals of imported events, not proof of complete business accounting. A + category with nothing imported shows zero; the imported count tells it apart from a reported + zero.

diff --git a/test/tracker-dashboard.test.ts b/test/tracker-dashboard.test.ts new file mode 100644 index 0000000..afdbae4 --- /dev/null +++ b/test/tracker-dashboard.test.ts @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { parseEvents, summarize } from '../dist/core/tracker.js'; +import { hoursGap, improvements, ledgerGaps, TrackerPage } from '../dist/views/tracker.js'; + +const fleet = { + id: 'f1', + slug: 'demo', + ownerId: 'u1', + operatorSlug: 'op', + agents: 10, + currency: 'USD', + rate: 100, + retainedTarget: 50, + assumedDirectCost: 100, + publicListing: false, + updatedAt: '2026-09-25T00:00:00Z', +}; +const report = (events: unknown[]) => { + const parsed = parseEvents(events, 'USD'); + return { fleet, summary: summarize(fleet, parsed), sources: ['s'], eventCount: parsed.length }; +}; +const render = (r: ReturnType) => + String(TrackerPage({ fleets: [fleet], report: r, profiles: [] })); + +test('a fleet with only costs shows zeros and names every missing feed', () => { + const r = report([ + { id: 'c1', kind: 'cost', currency: 'USD', amount: 12, provenance: 'estimated' }, + { + id: 'c2', + kind: 'cost', + currency: 'USD', + amount: null, + provenance: 'reported', + partial: true, + }, + ]); + assert.equal(hoursGap(r.summary), 'No work imported yet.'); + assert.deepEqual(ledgerGaps(r.summary), [ + 'No receipts, commissions, fees or affiliate payouts imported yet.', + 'Cost entries with no amount: 1.', + ]); + const html = render(r); + assert.doesNotMatch(html, /Unknown/); + assert.match(html, /

0\.00<\/h2>/); + assert.match(html, /

USD 0<\/h2>/); + assert.doesNotMatch(html, /

USD -/, 'incomplete profit is shown as zero, not as costs alone'); + assert.match(html, /Costs imported so far: USD 12\./); + assert.match(html, /No work imported yet\./); +}); + +test('improvement tips always point at applying to and posting jobs', () => { + const tips = improvements(report([])); + assert.deepEqual( + tips.slice(0, 2).map((t) => t.href), + ['/', '/post-a-job'], + ); + assert.ok( + tips.some((t) => /leaderboard/.test(t.text)), + 'private fleets are told to list', + ); + assert.ok( + tips.some((t) => /at least USD 150\/agent-hour/.test(t.text)), + 'a rate below cost plus target is called out', + ); + const html = render(report([])); + assert.match(html, /How to improve/); + assert.match(html, /href="\/post-a-job"/); +}); + +test('costs without billable hours get a billing tip, and billable work without time is named', () => { + const tips = improvements( + report([{ id: 'c1', kind: 'cost', currency: 'USD', amount: 30, provenance: 'engine' }]), + ); + assert.ok(tips.some((t) => /USD 30 of costs and no billable hours/.test(t.text))); + const r = report([{ id: 'w1', kind: 'work', currency: 'USD', billable: true }]); + assert.equal(hoursGap(r.summary), 'Billable work entries missing time or agent counts: 1.'); +}); + +test('a complete, on-target fleet shows no missing notes', () => { + const r = report([ + { id: 'w1', kind: 'work', currency: 'USD', billable: true, seconds: 3600, agents: 1 }, + { id: 'c', kind: 'cost', currency: 'USD', amount: 10, provenance: 'engine' }, + { id: 'r', kind: 'receipt', currency: 'USD', amount: 100, provenance: 'reported' }, + { id: 'm', kind: 'commission', currency: 'USD', amount: 0, provenance: 'reported' }, + { id: 'f', kind: 'fee', currency: 'USD', amount: 0, provenance: 'reported' }, + { id: 'a', kind: 'affiliate', currency: 'USD', amount: 0, provenance: 'reported' }, + ]); + assert.equal(hoursGap(r.summary), null); + assert.deepEqual(ledgerGaps(r.summary), []); + assert.doesNotMatch(render(r), /tracker-missing/); +}); diff --git a/test/tracker.test.ts b/test/tracker.test.ts index f09b892..edf73c5 100644 --- a/test/tracker.test.ts +++ b/test/tracker.test.ts @@ -320,7 +320,8 @@ describe('tracker API and CLI against Postgres', { skip: !database }, () => { assert.match(page.headers.get('cache-control') ?? '', /no-store/); const html = await page.text(); assert.match(html, /Tracked billable agent-hours/); - assert.match(html, /Unknown/); + assert.match(html, /How to improve/); + assert.doesNotMatch(html, />Unknown Date: Fri, 25 Sep 2026 01:59:22 -0700 Subject: [PATCH 057/116] 0.21.1 (#270) Co-authored-by: Claude Opus 5.5 (1M context) --- package.json | 2 +- src/config.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 4440844..9f24313 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/agenticjobs", - "version": "0.21.0", + "version": "0.21.1", "description": "An agent-friendly job board you self-host. It posts its own jobs, never scrapes anyone else's, and answers on every surface: web, API, MCP, CLI, TUI, desktop and PWA. Instances find each other through an open directory.", "license": "MIT", "type": "module", diff --git a/src/config.ts b/src/config.ts index 97280fa..8ac1a8d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -187,7 +187,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { } /** Kept in step with package.json by the release script. */ -export const VERSION = '0.21.0'; +export const VERSION = '0.21.1'; export const SOFTWARE_NAME = 'agenticjobs'; /** From 4b4bdceea4d339e41a98aad85c0a7cfd3fe03881 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 02:13:05 -0700 Subject: [PATCH 058/116] New logo, favicon and the w3bs.org look (#271) The mark is a briefcase in orbit with an agent riding the ring: the same orbit family as w3bs.org's, in this board's green with w3bs orange for the agent. favicon.svg is a heavier cut of it that holds at 16px, and logo.svg is the mark with a heavy "agenticjobs." wordmark. PNGs are generated by the fav CLI: icon-192/512, apple-touch-icon, favicon-16/32 and favicon.ico. The manifest had named icon-192.png and icon-512.png while neither existed, so install prompts got a 404; /favicon.ico and /apple-touch-icon.png now answer at the root too. The site takes w3bs.org's paper-and-ink palette, light serif headlines with an orange italic accent, and its wordmark style in the header, in both themes. A test fails if a manifest icon ever 404s again. Co-authored-by: Claude Opus 5.5 (1M context) --- src/server/routes/discovery.ts | 25 ++++++- src/views/layout.tsx | 58 +++++++++++++++-- test/brand-icons.test.ts | 68 +++++++++++++++++++ web/public/app.css | 49 +++++++++----- web/public/apple-touch-icon.png | Bin 0 -> 1925 bytes web/public/favicon-16.png | Bin 0 -> 358 bytes web/public/favicon-32.png | Bin 0 -> 545 bytes web/public/favicon.ico | Bin 0 -> 941 bytes web/public/favicon.svg | 8 +++ web/public/icon-192.png | Bin 0 -> 1964 bytes web/public/icon-512.png | Bin 0 -> 5019 bytes web/public/icon.svg | 10 ++- web/public/logo.svg | 10 +++ web/public/sw.js | 2 +- web/public/tokens.css | 111 +++++++++++++++++--------------- 15 files changed, 260 insertions(+), 81 deletions(-) create mode 100644 test/brand-icons.test.ts create mode 100644 web/public/apple-touch-icon.png create mode 100644 web/public/favicon-16.png create mode 100644 web/public/favicon-32.png create mode 100644 web/public/favicon.ico create mode 100644 web/public/favicon.svg create mode 100644 web/public/icon-192.png create mode 100644 web/public/icon-512.png create mode 100644 web/public/logo.svg diff --git a/src/server/routes/discovery.ts b/src/server/routes/discovery.ts index d184c6d..f340a0f 100644 --- a/src/server/routes/discovery.ts +++ b/src/server/routes/discovery.ts @@ -45,6 +45,7 @@ const MIME: Record = { '.js': 'text/javascript; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', + '.ico': 'image/x-icon', '.webmanifest': 'application/manifest+json', '.txt': 'text/plain; charset=utf-8', '.json': 'application/json', @@ -184,6 +185,25 @@ export function discoveryRoutes(): Hono { } }); + // Browsers and iOS ask the root for these whatever the page declares, so a + // missing one is a 404 in every access log and a blank tile on a home screen. + for (const [path, file] of [ + ['/favicon.ico', 'favicon.ico'], + ['/apple-touch-icon.png', 'apple-touch-icon.png'], + ['/apple-touch-icon-precomposed.png', 'apple-touch-icon.png'], + ] as const) { + routes.get(path, async (c) => { + try { + return c.body(await readFile(join(publicDir(), file)), 200, { + 'content-type': MIME[extname(file)]!, + 'cache-control': 'public, max-age=86400', + }); + } catch { + return c.notFound(); + } + }); + } + /** * The installer, at the root so the curl line is short enough to read aloud. * @@ -881,12 +901,13 @@ export function discoveryRoutes(): Hono { start_url: '/', scope: '/', display: 'standalone', - background_color: '#ffffff', - theme_color: '#111318', + background_color: '#f6f4ed', + theme_color: '#1f5c45', icons: [ { src: '/assets/icon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any' }, { src: '/assets/icon-192.png', sizes: '192x192', type: 'image/png' }, { src: '/assets/icon-512.png', sizes: '512x512', type: 'image/png' }, + { src: '/assets/apple-touch-icon.png', sizes: '180x180', type: 'image/png' }, ], shortcuts: [ { name: 'Post a job', url: '/post' }, diff --git a/src/views/layout.tsx b/src/views/layout.tsx index ceaebd9..a1f0059 100644 --- a/src/views/layout.tsx +++ b/src/views/layout.tsx @@ -39,6 +39,48 @@ export interface PageProps { skills?: SkillCount[]; } +/** + * The mark from web/public/icon.svg, inlined so the header costs no request. + * A briefcase in orbit with an agent on the ring: the same orbit family as + * w3bs.org's mark, in this board's green. Keep the two in step. + */ +const BrandMark: FC = () => ( + +); + export const Layout: FC> = (props) => { const { title, @@ -79,11 +121,14 @@ export const Layout: FC> = (props) => { {/* The theme meta is duplicated per scheme so the browser chrome matches whichever palette the page actually painted. */} - - + + - + + + + > = (props) => {
+ {/* The one ad, at the top of the content: never pressed against the footer. */} + {showsAd(path, noindex) && }
{children}
- {showsAd(path, noindex) && } - {skills.length > 0 && ( -
+
+ {skills.length > 0 && ( -
- )} - {raw( - pfsFooter({ - site: `${publicUrl.replace(/\/+$/, '')}/`, - links: [ - { label: 'Popular', href: '/popular' }, - { label: 'Most profitable', href: '/most-profitable' }, - { label: 'Agents', href: '/agents' }, - { label: 'API', href: '/docs' }, - { label: 'OpenAPI', href: '/api/v1/openapi.json' }, - { label: 'Instance', href: '/.well-known/agenticjobs' }, - { label: 'Feed', href: '/jobs.json' }, - { label: 'llms.txt', href: '/llms.txt' }, - { label: 'Source', href: 'https://github.com/profullstack/agenticjobs' }, - ], - tagline: `${boardName} runs on agenticjobs, MIT licensed.`, - }), - )} + )} + +
{/* * CrawlProof, as a plain tag. `defer` is what `strategy` meant on the @@ -312,6 +306,8 @@ export const Layout: FC> = (props) => { defer > )} + {/* The pit.world global footer, last in . */} + {raw(pitFooter(publicUrl))} ); diff --git a/src/views/pfs-footer.ts b/src/views/pfs-footer.ts deleted file mode 100644 index 61ad9d4..0000000 --- a/src/views/pfs-footer.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * The shared Profullstack footer (@profullstack/footer): the board's link row, - * the copyright line and the webring, from the package's @latest template, so a - * release of the package reaches every board without a redeploy. - * - * Layout renders synchronously (pages and tests stringify it directly), so this - * returns the last footer rendered for these options and refreshes it in the - * background on each use; the package caches the template for an hour. Until - * the first refresh lands it is the template from the installed package. - */ -import { footerHtml, footerHtmlSync } from '@profullstack/footer'; - -type FooterOptions = Parameters[0]; - -const rendered = new Map(); - -export function pfsFooter(options: FooterOptions): string { - const key = JSON.stringify(options); - void footerHtml(options) - .then((html) => rendered.set(key, html)) - .catch(() => undefined); - return rendered.get(key) ?? footerHtmlSync(options); -} diff --git a/src/views/pit-footer.ts b/src/views/pit-footer.ts new file mode 100644 index 0000000..a6724e6 --- /dev/null +++ b/src/views/pit-footer.ts @@ -0,0 +1,57 @@ +/** + * The pit.world global footer (site name, search, ring, publish), fetched on + * the server from https://pit.world/snippets/footer.html?site= and + * rendered as the last thing in . + * + * Layout renders synchronously (pages and tests stringify it directly), so this + * returns the last good copy for the host and refreshes it in the background + * once it is an hour old (3s timeout). A failed refresh keeps the old copy; with + * no copy yet it returns '' and the page simply has no global footer. + */ +const MAX_AGE_MS = 60 * 60 * 1000; + +interface Entry { + html: string; + fetchedAt: number; + inflight: Promise | null; +} + +const cache = new Map(); + +function refresh(site: string, entry: Entry): Promise { + entry.inflight ??= fetch( + `https://pit.world/snippets/footer.html?site=${encodeURIComponent(site)}`, + { signal: AbortSignal.timeout(3000) }, + ) + .then(async (res) => { + const text = res.ok ? await res.text() : ''; + if (!text.includes('data-pit-footer')) throw new Error(`pit footer: HTTP ${res.status}`); + entry.html = text; + entry.fetchedAt = Date.now(); + }) + .catch(() => { + entry.fetchedAt = Date.now() - MAX_AGE_MS + 60_000; // try again in a minute + }) + .finally(() => { + entry.inflight = null; + }); + return entry.inflight; +} + +/** The footer fragment for a board's public URL ('' until the first fetch lands). */ +export function pitFooter(publicUrl: string): string { + let site: string; + try { + site = new URL(publicUrl).hostname; + } catch { + return ''; + } + if (!site || site === 'localhost' || site.endsWith('.test')) return ''; + let entry = cache.get(site); + if (!entry) { + entry = { html: '', fetchedAt: 0, inflight: null }; + cache.set(site, entry); + } + if (Date.now() - entry.fetchedAt > MAX_AGE_MS) void refresh(site, entry); + return entry.html; +} diff --git a/web/public/app.css b/web/public/app.css index c1b7b4f..fb54cd0 100644 --- a/web/public/app.css +++ b/web/public/app.css @@ -143,7 +143,7 @@ main { .site-footer { border-top: 1px solid var(--border); - padding: 2rem 0; + padding: 1.25rem 0; color: var(--muted-foreground); font-size: 0.85rem; } @@ -159,17 +159,13 @@ main { color: var(--muted-foreground); } -/* The shared Profullstack footer (src/views/pfs-footer.ts) under the skills row. */ -footer.pfs-footer { - --pfs-footer-muted: var(--muted-foreground); - border-top-color: var(--border); -} - -/* The skill links above the footer proper: one row that wraps. */ -/* The shared footer's own top rule follows, so the row needs none of its own. */ +/* Two rows: the popular skills, then the copyright and the links people use. */ .footer-skills { gap: 0.5rem 0.9rem; } +.footer-skills + .footer-links { + margin-top: 0.75rem; +} .thread.unread { font-weight: 600; @@ -1100,9 +1096,9 @@ a.badge-plain:hover .badge { .tracker-missing { color: var(--warning); font-size: 0.9rem; margin: 0.25rem 0; } .tracker-improve ul { margin: 0; padding-left: 1.25rem; display: grid; gap: 0.5rem; } -/* CrawlProof ad unit (src/views/ad-unit.tsx): one fluid text_link frame above the footer. */ +/* CrawlProof ad unit (src/views/ad-unit.tsx): one fluid text_link frame at the top of the content. */ .ad-unit { - margin: 1.5rem 0 0; + margin: 0 0 1.5rem; } .ad-unit iframe { display: block; diff --git a/web/public/sw.js b/web/public/sw.js index 40d32d8..5bf8202 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -13,7 +13,7 @@ * drifted, which is the only version of this rule that survives contact. */ -const VERSION = '4a4fd178'; +const VERSION = '4442cc3d'; const SHELL = `shell-${VERSION}`; const ASSETS = ['/assets/app.css', '/assets/tokens.css', '/assets/icon.svg']; From 30191e9f3a2bc2f1c094d8f1c2827c661843c00a Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 19 Oct 2026 06:24:29 -0700 Subject: [PATCH 103/116] 0.22.7: restore the @profullstack/footer bar under the site footer (#319) The webring bar is being rolled out fleet-wide on purpose. It comes back with no extra links (the site footer row carries them), between the site footer and the pit.world global footer. Co-authored-by: Claude Opus 5.5 --- package.json | 2 +- src/config.ts | 2 +- src/views/layout.tsx | 12 +++++++++--- src/views/pfs-footer.ts | 23 +++++++++++++++++++++++ web/public/app.css | 8 +++++++- web/public/sw.js | 2 +- 6 files changed, 42 insertions(+), 7 deletions(-) create mode 100644 src/views/pfs-footer.ts diff --git a/package.json b/package.json index 0b9b69c..fb73520 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/agenticjobs", - "version": "0.22.6", + "version": "0.22.7", "description": "An agent-friendly job board you self-host. It posts its own jobs, never scrapes anyone else's, and answers on every surface: web, API, MCP, CLI, TUI, desktop and PWA. Instances find each other through an open directory.", "license": "MIT", "type": "module", diff --git a/src/config.ts b/src/config.ts index 288464a..68db124 100644 --- a/src/config.ts +++ b/src/config.ts @@ -187,7 +187,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { } /** Kept in step with package.json by the release script. */ -export const VERSION = '0.22.6'; +export const VERSION = '0.22.7'; export const SOFTWARE_NAME = 'agenticjobs'; /** diff --git a/src/views/layout.tsx b/src/views/layout.tsx index d3f17dc..1764b0f 100644 --- a/src/views/layout.tsx +++ b/src/views/layout.tsx @@ -15,6 +15,7 @@ import { renderMarkdown } from '../markup/markdown.ts'; import type { SkillCount } from '../core/skills.ts'; import { pathForQuery } from '../core/landing.ts'; import { EMPTY_QUERY } from '../schema/query.ts'; +import { pfsFooter } from './pfs-footer.ts'; import { pitFooter } from './pit-footer.ts'; import { AdUnit, showsAd } from './ad-unit.tsx'; @@ -284,9 +285,6 @@ export const Layout: FC> = (props) => { )} + {/* Copyright and the Profullstack webring: @profullstack/footer. */} + {raw( + pfsFooter({ + site: `${publicUrl.replace(/\/+$/, '')}/`, + links: [], + tagline: `${boardName} runs on agenticjobs, MIT licensed.`, + }), + )} {/* * CrawlProof, as a plain tag. `defer` is what `strategy` meant on the diff --git a/src/views/pfs-footer.ts b/src/views/pfs-footer.ts new file mode 100644 index 0000000..61ad9d4 --- /dev/null +++ b/src/views/pfs-footer.ts @@ -0,0 +1,23 @@ +/** + * The shared Profullstack footer (@profullstack/footer): the board's link row, + * the copyright line and the webring, from the package's @latest template, so a + * release of the package reaches every board without a redeploy. + * + * Layout renders synchronously (pages and tests stringify it directly), so this + * returns the last footer rendered for these options and refreshes it in the + * background on each use; the package caches the template for an hour. Until + * the first refresh lands it is the template from the installed package. + */ +import { footerHtml, footerHtmlSync } from '@profullstack/footer'; + +type FooterOptions = Parameters[0]; + +const rendered = new Map(); + +export function pfsFooter(options: FooterOptions): string { + const key = JSON.stringify(options); + void footerHtml(options) + .then((html) => rendered.set(key, html)) + .catch(() => undefined); + return rendered.get(key) ?? footerHtmlSync(options); +} diff --git a/web/public/app.css b/web/public/app.css index fb54cd0..7323029 100644 --- a/web/public/app.css +++ b/web/public/app.css @@ -159,7 +159,13 @@ main { color: var(--muted-foreground); } -/* Two rows: the popular skills, then the copyright and the links people use. */ +/* The shared Profullstack footer (src/views/pfs-footer.ts) under the site footer. */ +footer.pfs-footer { + --pfs-footer-muted: var(--muted-foreground); + border-top-color: var(--border); +} + +/* Two rows: the popular skills, then the links people use. */ .footer-skills { gap: 0.5rem 0.9rem; } diff --git a/web/public/sw.js b/web/public/sw.js index 5bf8202..65f77aa 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -13,7 +13,7 @@ * drifted, which is the only version of this rule that survives contact. */ -const VERSION = '4442cc3d'; +const VERSION = 'b264b667'; const SHELL = `shell-${VERSION}`; const ASSETS = ['/assets/app.css', '/assets/tokens.css', '/assets/icon.svg']; From f029bc12047467beab61e77a77259d016df04bc7 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 19 Oct 2026 16:15:18 -0700 Subject: [PATCH 104/116] chore: exempt all @profullstack/* from minimumReleaseAge (#320) Our own packages; version-pinned excludes made every fleet bump wait 24h. Co-authored-by: Claude Opus 5.5 --- pnpm-workspace.yaml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 230638e..d27501c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -11,6 +11,4 @@ packages: # hqtui is published by us, often on the same day we depend on the new version. # pnpm 11 refuses packages younger than a day unless they are listed here. minimumReleaseAgeExclude: - - '@profullstack/hqtui' - - '@profullstack/synconfig@0.1.2' - - '@profullstack/footer' + - '@profullstack/*' From d71d7469e711200a92bd92719a562d6554966a80 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 20 Oct 2026 22:50:40 -0700 Subject: [PATCH 105/116] No ads on the homepage; low-profile text-link ads elsewhere (#321) * No ads on the homepage; low-profile text-link ads elsewhere Homepage renders no ad unit. Remaining units are a single text_link line per page with quiet spacing, never against the footer. Co-Authored-By: Claude Opus 5.5 * Bump the service worker cache VERSION for the app.css change Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- src/views/ad-unit.tsx | 6 +++++- test/views.test.ts | 11 ++++++----- web/public/app.css | 2 +- web/public/sw.js | 2 +- 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/views/ad-unit.tsx b/src/views/ad-unit.tsx index a61b4d7..cc9c666 100644 --- a/src/views/ad-unit.tsx +++ b/src/views/ad-unit.tsx @@ -38,10 +38,14 @@ const NO_AD_SEGMENTS = new Set([ 'revisions', ]); -/** Public, indexable pages only. A noindex page is a dashboard or a one-off flow. */ +/** + * Public, indexable pages only. A noindex page is a dashboard or a one-off flow, + * and the homepage carries no ad. + */ export function showsAd(path: string, noindex?: boolean): boolean { if (noindex) return false; const first = path.split(/[/?#]/)[1] ?? ''; + if (first === '') return false; return !NO_AD_SEGMENTS.has(first); } diff --git a/test/views.test.ts b/test/views.test.ts index 508ecef..08a47a2 100644 --- a/test/views.test.ts +++ b/test/views.test.ts @@ -561,7 +561,7 @@ test('the ad unit is a crawlproof frame on public pages only, and the policy fra const { showsAd, AD_SLOT } = await import('../dist/views/ad-unit.js'); const { Layout } = await import('../dist/views/layout.js'); const { securityHeaders } = await import('../dist/server/middleware.js'); - assert.equal(showsAd('/', false), true); + assert.equal(showsAd('/', false), false, 'the homepage carries no ad'); assert.equal(showsAd('/jobs/abc', false), true); assert.equal(showsAd('/me/settings', false), false); assert.equal(showsAd('/post-a-job', false), false); @@ -579,10 +579,11 @@ test('the ad unit is a crawlproof frame on public pages only, and the policy fra children: 'x', }), ); - const home = page('/'); - assert.equal(home.match(/crawlproof\.com\/api\/ads\/frame/g)?.length, 1, 'exactly one unit'); - assert.ok(home.includes(`slot=${AD_SLOT}&format=text_link`), home); - assert.ok(!home.includes('theme='), 'the frame answers prefers-color-scheme itself'); + assert.ok(!page('/').includes('/api/ads/frame'), 'no ad on the homepage'); + const job = page('/jobs/abc'); + assert.equal(job.match(/crawlproof\.com\/api\/ads\/frame/g)?.length, 1, 'exactly one unit'); + assert.ok(job.includes(`slot=${AD_SLOT}&format=text_link`), job); + assert.ok(!job.includes('theme='), 'the frame answers prefers-color-scheme itself'); assert.ok(!page('/inbox').includes('/api/ads/frame'), 'no ad inside the inbox'); const headers = new Headers(); diff --git a/web/public/app.css b/web/public/app.css index 7323029..4ca7150 100644 --- a/web/public/app.css +++ b/web/public/app.css @@ -1104,7 +1104,7 @@ a.badge-plain:hover .badge { /* CrawlProof ad unit (src/views/ad-unit.tsx): one fluid text_link frame at the top of the content. */ .ad-unit { - margin: 0 0 1.5rem; + margin: 0 0 0.75rem; } .ad-unit iframe { display: block; diff --git a/web/public/sw.js b/web/public/sw.js index 65f77aa..302caaf 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -13,7 +13,7 @@ * drifted, which is the only version of this rule that survives contact. */ -const VERSION = 'b264b667'; +const VERSION = '3a3325d0'; const SHELL = `shell-${VERSION}`; const ASSETS = ['/assets/app.css', '/assets/tokens.css', '/assets/icon.svg']; From 6e9897d9eb9ea449b9e7afd0abe854f137b33872 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 21 Oct 2026 06:06:24 -0700 Subject: [PATCH 106/116] feat(invoices): send payer, payee and property to CoinPay; read the nested webhook (#322) requestPayment now passes customer_email (payer email), account_id (payer id) and property agenticjobs.work top-level on /api/payments/create, and the same plus payee_id in metadata. The peer-invoice model is unchanged: the payer still pays the payee's own wallet via merchant_wallet_address. applyWebhook read event/payment_id flat, so a real nested CoinPay webhook ({id: evt_..., type, data: {payment_id, status}}) was always ignored and invoices only settled on the next page load. It now reads data first with the flat shape as a fallback. Co-authored-by: Claude Opus 5.5 --- src/core/coinpay.ts | 21 +++++++++++- src/core/invoices.ts | 44 +++++++++++++++++++++--- test/billing.test.ts | 79 +++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 138 insertions(+), 6 deletions(-) diff --git a/src/core/coinpay.ts b/src/core/coinpay.ts index 55e1dd7..c60b046 100644 --- a/src/core/coinpay.ts +++ b/src/core/coinpay.ts @@ -65,6 +65,9 @@ export interface Payment { export class CoinPayProblem extends Error {} +/** The site every payment here is for, as CoinPay records it (`property`). */ +export const PROPERTY = 'agenticjobs.work'; + export interface CoinPayClient { readonly config: CoinPayConfig; authorizeUrl(input: { state: string; codeChallenge: string; redirectUri: string }): string; @@ -83,6 +86,11 @@ export interface CoinPayClient { redirectUrl: string; idempotencyKey: string; metadata: Record; + /** Who is paying: their email and agenticjobs user id. Sent top-level and in metadata. */ + customerEmail?: string | null; + accountId?: string | null; + /** Who is being paid: the payee's agenticjobs user id. */ + payeeId?: string | null; }): Promise; getPayment(id: string): Promise; /** Where a payer goes to pay. */ @@ -276,7 +284,18 @@ export function createCoinPay(config: CoinPayConfig, fetchImpl: Fetch = fetch): merchant_wallet_address: input.payeeAddress, description: input.description, redirect_url: input.redirectUrl, - metadata: { ...input.metadata, idempotency_key: input.idempotencyKey }, + // Optional on /payments/create: a malformed one is dropped by CoinPay, never a 400. + ...(input.customerEmail ? { customer_email: input.customerEmail } : {}), + ...(input.accountId ? { account_id: input.accountId } : {}), + property: PROPERTY, + metadata: { + ...input.metadata, + property: PROPERTY, + ...(input.accountId ? { account_id: input.accountId } : {}), + ...(input.customerEmail ? { customer_email: input.customerEmail } : {}), + ...(input.payeeId ? { payee_id: input.payeeId } : {}), + idempotency_key: input.idempotencyKey, + }, }), }); if (result.status !== 200 && result.status !== 201) { diff --git a/src/core/invoices.ts b/src/core/invoices.ts index c8ed7df..0e26945 100644 --- a/src/core/invoices.ts +++ b/src/core/invoices.ts @@ -307,6 +307,11 @@ export async function requestPayment( return synced; } + const payer = await pool.query<{ email: string }>('select email from users where id = $1', [ + input.payerId, + ]); + const payerEmail = payer.rows[0]?.email ?? null; + let minted; try { minted = await coinpay.createPayment({ @@ -324,6 +329,9 @@ export async function requestPayment( thread_id: current.threadId, payer_id: input.payerId, }, + customerEmail: payerEmail, + accountId: input.payerId, + payeeId: current.payee.id, }); } catch (error) { if (error instanceof CoinPayProblem) return error.message; @@ -419,15 +427,43 @@ export async function markPaid( * to the next page load, which clears it the same way and does not depend on * a delivery that may never come. */ +export function readWebhook(payload: Record): { + event: string; + paymentId: string; + status: string; + txHash: string | null; + metadata: Record; +} { + // CoinPay nests the payment under `data`; the top-level `id` is the EVENT id, + // never the payment's. Read `data` first, the old flat shape as a fallback. + const nested = payload['data']; + const data = + typeof nested === 'object' && nested !== null ? (nested as Record) : payload; + const s = (v: unknown) => (typeof v === 'string' ? v : ''); + const event = s(payload['type']) || s(payload['event']); + const paymentId = s(data['payment_id']) || (data === payload ? '' : s(data['id'])); + const meta = data['metadata'] ?? payload['metadata']; + return { + event, + paymentId, + status: s(data['status']).toLowerCase(), + txHash: s(data['tx_hash']) || null, + metadata: typeof meta === 'object' && meta !== null ? (meta as Record) : {}, + }; +} + export async function applyWebhook( pool: pg.Pool, payload: Record, ): Promise<'paid' | 'ignored' | 'unknown'> { - const event = typeof payload['event'] === 'string' ? payload['event'] : ''; - const paymentId = typeof payload['payment_id'] === 'string' ? payload['payment_id'] : ''; + const { event, paymentId, status, txHash } = readWebhook(payload); if (paymentId === '') return 'ignored'; - if (event !== 'payment.confirmed' && event !== 'payment.forwarded') return 'ignored'; - const txHash = typeof payload['tx_hash'] === 'string' ? payload['tx_hash'] : null; + const settled = + event === 'payment.confirmed' || + event === 'payment.forwarded' || + status === 'confirmed' || + status === 'forwarded'; + if (!settled) return 'ignored'; const paid = await markPaid(pool, { paymentId, txHash }); return paid ? 'paid' : 'unknown'; } diff --git a/test/billing.test.ts b/test/billing.test.ts index f31ffcf..6748c06 100644 --- a/test/billing.test.ts +++ b/test/billing.test.ts @@ -16,7 +16,7 @@ import { pkcePair, SCOPES, } from '../dist/core/coinpay.js'; -import { parseAmount } from '../dist/core/invoices.js'; +import { applyWebhook, parseAmount, readWebhook } from '../dist/core/invoices.js'; import { inboxMessage } from '../dist/core/mail.js'; const FULL = { @@ -138,6 +138,9 @@ test('the payment request names the payee wallet and the board business, with th redirectUrl: 'https://b/inbox/t', idempotencyKey: 'k', metadata: { invoice_id: 'i' }, + customerEmail: 'payer@example.com', + accountId: 'user-payer', + payeeId: 'user-payee', }); assert.equal(payment.id, 'pay_1'); assert.equal(payment.currency, 'USDC_POL'); @@ -154,6 +157,80 @@ test('the payment request names the payee wallet and the board business, with th assert.equal(sent['blockchain'], 'USDC_POL'); assert.equal(sent['amount_usd'], 1200); assert.equal((sent['metadata'] as Record)['idempotency_key'], 'k'); + // Who pays, who is paid and which site, top-level and in metadata. + assert.equal(sent['customer_email'], 'payer@example.com'); + assert.equal(sent['account_id'], 'user-payer'); + assert.equal(sent['property'], 'agenticjobs.work'); + assert.deepEqual(sent['metadata'], { + invoice_id: 'i', + property: 'agenticjobs.work', + account_id: 'user-payer', + customer_email: 'payer@example.com', + payee_id: 'user-payee', + idempotency_key: 'k', + }); +}); + +test('the webhook is read from the nested data, never the event id, with the old flat shape as a fallback', async () => { + const nested = { + id: 'evt_pay_9_1700000000', + type: 'payment.forwarded', + data: { + payment_id: 'pay_9', + status: 'forwarded', + tx_hash: '0xabc', + customer_email: 'payer@example.com', + account_id: 'user-payer', + property: 'agenticjobs.work', + metadata: { invoice_id: 'i', payee_id: 'user-payee' }, + }, + }; + assert.deepEqual(readWebhook(nested), { + event: 'payment.forwarded', + paymentId: 'pay_9', + status: 'forwarded', + txHash: '0xabc', + metadata: { invoice_id: 'i', payee_id: 'user-payee' }, + }); + assert.equal( + readWebhook({ event: 'payment.confirmed', payment_id: 'pay_flat' }).paymentId, + 'pay_flat', + ); + + const marked: unknown[][] = []; + const pool = { + query: async (_sql: string, params: unknown[]) => { + marked.push(params); + return { rowCount: params[0] === 'pay_9' || params[0] === 'pay_flat' ? 1 : 0, rows: [] }; + }, + } as never; + assert.equal(await applyWebhook(pool, nested), 'paid'); + assert.deepEqual( + marked[0]!.slice(0, 2), + ['pay_9', '0xabc'], + 'settled by the payment id, not evt_', + ); + assert.equal( + await applyWebhook(pool, { event: 'payment.confirmed', payment_id: 'pay_flat' }), + 'paid', + ); + assert.equal( + await applyWebhook(pool, { + id: 'evt', + type: 'payment.detected', + data: { payment_id: 'pay_9', status: 'detected' }, + }), + 'ignored', + ); + assert.equal( + await applyWebhook(pool, { + id: 'evt', + type: 'payment.confirmed', + data: { payment_id: 'pay_nobody', status: 'confirmed' }, + }), + 'unknown', + ); + assert.equal(marked.length, 3, 'detected never reaches the database'); }); test('an HTML answer from CoinPay is reported as such, not parsed', async () => { From 38e42ab289c3a0bb9e911fc9e20d3b8d2a3de242 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 21 Oct 2026 07:01:37 -0700 Subject: [PATCH 107/116] feat(crawl): sell training crawlers a day pass on /crawl (x402-gateway 0.11) (#323) The crawl gateway runs first when COINPAY_X402_KEY and CRAWL_PAY_TO are set: GPTBot, ClaudeBot, headless and SEO scrapers get a 402 with PAYMENT-REQUIRED and the /crawl sales page (x402 USDC or the CoinPay Buy a day pass checkout) before the board spends a query on them. People, verified search crawlers, AI chat/search fetchers and aj_session holders pass through; /api/health stays open. A board without the two settings never sells. Co-authored-by: Claude Opus 5.5 --- package.json | 3 +- pnpm-lock.yaml | 25 +++++++++++++--- src/config.ts | 10 +++++++ src/server/app.tsx | 23 ++++++++++++++ test/crawl-gateway.test.ts | 61 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 117 insertions(+), 5 deletions(-) create mode 100644 test/crawl-gateway.test.ts diff --git a/package.json b/package.json index fb73520..347e53b 100644 --- a/package.json +++ b/package.json @@ -58,12 +58,13 @@ "test:only": "node --test \"test/*.test.ts\"" }, "dependencies": { - "@profullstack/footer": "latest", "@hono/node-server": "^1.14.0", "@profullstack/emailer": "^1.0.3", + "@profullstack/footer": "latest", "@profullstack/hqtui": "^0.5.0", "@profullstack/leaderboard": "^0.3.1", "@profullstack/synconfig": "^0.1.2", + "@profullstack/x402-gateway": "^0.11.0", "@simplewebauthn/server": "^14.0.1", "hono": "^4.6.14", "pg": "^8.13.1" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5e880ff..78ced65 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,7 +16,7 @@ importers: version: 1.0.3 '@profullstack/footer': specifier: latest - version: 0.2.1 + version: 0.2.5 '@profullstack/hqtui': specifier: ^0.5.0 version: 0.5.0 @@ -26,6 +26,9 @@ importers: '@profullstack/synconfig': specifier: ^0.1.2 version: 0.1.2 + '@profullstack/x402-gateway': + specifier: ^0.11.0 + version: 0.11.0 '@simplewebauthn/server': specifier: ^14.0.1 version: 14.0.1 @@ -131,8 +134,8 @@ packages: '@profullstack/emailer@1.0.3': resolution: {integrity: sha512-VyAtCcfnSI6LOwJvWogCr3ZNmEsaZw+iuWrqnosLsdcKsbRyCrCmXigMNZJZEjlgYlR+lK//PwNPG+xtCFvWAw==} - '@profullstack/footer@0.2.1': - resolution: {integrity: sha512-KHmKVwrlzCBrSAAJ4zobNFCxTg8VF0gvNMeAut00dX3+gbWCtk6eOwIR2AaqEC7smj6Aio6cIexzt/dUnv1McA==} + '@profullstack/footer@0.2.5': + resolution: {integrity: sha512-/CKwFP3m+qM0ZFTC9Rj11fgClutJzeYliX44Qp4qwrv2JgA/1k4tVOB3mhkPhaeckUhnfg5UyESDI1uoWr9c2w==} engines: {node: '>=18'} hasBin: true peerDependencies: @@ -146,6 +149,10 @@ packages: engines: {bun: '>=1.1', node: '>=22.6'} hasBin: true + '@profullstack/keys@0.1.0': + resolution: {integrity: sha512-Vys47LvS5ivhdZu4SYOxh9Le3DEmWhjP0+CtrU45RhXAbY0ZFG/2Dk9V1bIIUGDp8UWMJqctvUD21JrNjfh04w==} + engines: {node: '>=20.11'} + '@profullstack/leaderboard@0.3.1': resolution: {integrity: sha512-LbxLwy+RvT/Qez0mfWCk4o9hvcq0kzwBZ0vF+CYp+oGvyqJWUaSTLhCHWGtKKAI3c0ZUXj6ll0iuRjbuZUE2Sg==} engines: {node: '>=20.11'} @@ -154,6 +161,10 @@ packages: resolution: {integrity: sha512-t1Pdms+k/+A7tgFCAcNx8Dry0kwboPEue9Pw2pbn/chXSMfp1dxrcFz672zWkDgvcB7YBmYpIjnvW4NkNkE4Rg==} engines: {node: '>=22'} + '@profullstack/x402-gateway@0.11.0': + resolution: {integrity: sha512-Lfrz+SRTwQZfcoCaVtaaMNPJx74Lp/5oyS7vJJAEuJmH1TjBzhE7dWquISkj1p42oc+VW5I9xH9p7YhEKqmr4Q==} + engines: {node: '>=20.11'} + '@simplewebauthn/server@14.0.1': resolution: {integrity: sha512-kjWgcm9NdSv+Tobo4Swxd1WnEawsBogpYFMjg9pKsVLPN3FdCmR6BipkL+DcPDgmQ7UKbbBZqRqn3E3YaA7NBw==} engines: {node: '>=20.0.0'} @@ -391,14 +402,20 @@ snapshots: '@profullstack/emailer@1.0.3': {} - '@profullstack/footer@0.2.1': {} + '@profullstack/footer@0.2.5': {} '@profullstack/hqtui@0.5.0': {} + '@profullstack/keys@0.1.0': {} + '@profullstack/leaderboard@0.3.1': {} '@profullstack/synconfig@0.1.2': {} + '@profullstack/x402-gateway@0.11.0': + dependencies: + '@profullstack/keys': 0.1.0 + '@simplewebauthn/server@14.0.1': dependencies: '@hexagon/base64': 1.1.28 diff --git a/src/config.ts b/src/config.ts index 68db124..3665dbb 100644 --- a/src/config.ts +++ b/src/config.ts @@ -44,6 +44,12 @@ export interface Config { * absent from every page rather than present and broken. */ coinpay: CoinPayConfig | null; + /** + * The crawl door (x402-gateway): a scoped CoinPay key and the USDC payee. Both or + * nothing; a board without them never sells crawl passes. Optional so a + * hand-built config in a test can leave it out. + */ + crawl?: { apiKey: string; payTo: string } | null; version: string; } @@ -182,6 +188,10 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { openaiApiKey: env['OPENAI_API_KEY']?.trim() || null, writerModel: env['WRITER_MODEL']?.trim() || null, coinpay: coinpayConfig(env), + crawl: + env['COINPAY_X402_KEY']?.trim() && env['CRAWL_PAY_TO']?.trim() + ? { apiKey: env['COINPAY_X402_KEY'].trim(), payTo: env['CRAWL_PAY_TO'].trim() } + : null, version: env['npm_package_version']?.trim() || VERSION, }; } diff --git a/src/server/app.tsx b/src/server/app.tsx index 844e72a..bddcbf5 100644 --- a/src/server/app.tsx +++ b/src/server/app.tsx @@ -8,6 +8,9 @@ */ import { Hono } from 'hono'; +import { SESSION_COOKIE } from '../core/auth.ts'; +import { createGateway } from '@profullstack/x402-gateway'; +import { x402Gateway } from '@profullstack/x402-gateway/hono'; import type pg from 'pg'; import type { Config } from '../config.ts'; import { apiRoutes } from './routes/api.ts'; @@ -43,6 +46,26 @@ export function createApp( const deps: Deps = { pool, config, mailer, coinpay, rankings: createRankings(pool, config) }; const app = new Hono(); + // The crawl door, first: a training crawler, headless browser or SEO scraper is + // sold a day pass ($1 over x402, or any coin / PayPal through CoinPay's hosted + // checkout on /crawl) before the board spends a database query on it. People, + // verified search crawlers and the AI chat/search fetchers pass through, as does + // anyone signed in. Ahead of the security headers, whose form-action would stop + // the /crawl/checkout redirect to CoinPay. + if (config.crawl) { + const crawlGateway = createGateway({ + siteUrl: config.publicUrl, + siteName: config.boardName, + coinpay: { apiKey: config.crawl.apiKey }, + payTo: config.crawl.payTo, + contact: 'mailto:anthony@profullstack.com', + openPaths: ['/api/health', '/llms.txt', '/.well-known/'], + exempt: (request) => + new RegExp(`(?:^|;\\s*)${SESSION_COOKIE}=`).test(request.headers.get('cookie') ?? ''), + }); + app.use('*', x402Gateway(crawlGateway)); + } + app.use('*', withViewer(deps)); // A payer is sent from a form on this board to CoinPay's hosted pay page, // and the policy has to say so or the browser stops the redirect. diff --git a/test/crawl-gateway.test.ts b/test/crawl-gateway.test.ts new file mode 100644 index 0000000..91fcb6d --- /dev/null +++ b/test/crawl-gateway.test.ts @@ -0,0 +1,61 @@ +/** + * The crawl door: training crawlers are sold a day pass before the board does + * any work for them; people are not. No database and no network: the offer is + * built locally and the 402 comes before the first query. + */ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +const { createApp } = await import('../dist/server/app.js'); +const { loadConfig } = await import('../dist/config.js'); + +let queries = 0; +const pool = { + query: async () => { + queries += 1; + return { rows: [] }; + }, +}; +const config = loadConfig({ + PUBLIC_URL: 'https://agenticjobs.work', + COINPAY_X402_KEY: 'cp_live_test_crawl_key', + CRAWL_PAY_TO: '0xCC3b072391AE7A8d10cF00DdC5F61DB2cA5541E5', +}); +const app = createApp(pool as never, config, null, null); + +const GPTBOT = + 'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; GPTBot/1.2; +https://openai.com/gptbot)'; + +test('crawl config is read from COINPAY_X402_KEY and CRAWL_PAY_TO, both or nothing', () => { + assert.ok(config.crawl); + assert.equal(loadConfig({ COINPAY_X402_KEY: 'cp_live_x' }).crawl, null); +}); + +test('GPTBot gets 402 with PAYMENT-REQUIRED, before any query', async () => { + queries = 0; + const res = await app.request('/', { headers: { 'user-agent': GPTBOT, accept: 'application/json' } }); + assert.equal(res.status, 402); + assert.ok(res.headers.get('payment-required')); + assert.equal(queries, 0); +}); + +test('/crawl sells the day pass', async () => { + const res = await app.request('/crawl', { headers: { 'user-agent': 'curl/8', accept: 'application/json' } }); + assert.equal(res.status, 402); + const body = (await res.json()) as { accepts: Array<{ amount: string }> }; + assert.equal(body.accepts[0]?.amount, '1000000'); +}); + +test('a browser and the status check are not charged', async () => { + const chrome = { + 'user-agent': + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36', + 'sec-fetch-mode': 'navigate', + 'sec-fetch-site': 'none', + 'sec-fetch-dest': 'document', + }; + const health = await app.request('/api/health', { headers: chrome }); + assert.equal(health.status, 200); + const monitor = await app.request('/api/health', { headers: { 'user-agent': 'GPTBot/1.2' } }); + assert.equal(monitor.status, 200); +}); From bd9bc9cf6bf298ceb06c9398e4edc6165fd1a1f5 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 26 Oct 2026 03:54:56 -0700 Subject: [PATCH 108/116] feat(profile): shared signup profile fields via @profullstack/profile (#324) /signup asks for first_name, last_name, company_domain, job_title and linkedin_url beside the email box (plain /login never does). The fields are validated before any link is made, ride on the magic link (magic_links.profile) because the account is created when the link is spent, and are saved into the package's user_profiles table on /auth/callback. A failed save never blocks sign-in. GET/PUT/PATCH /api/v1/me/profile are the package's handlers; /me gets a private Contact details form. Migration 0024 creates user_profiles and backfills first/last name from users.name. Co-authored-by: Claude Opus 5.5 --- migrations/0024_user_profiles.sql | 38 ++++++++ package.json | 1 + pnpm-lock.yaml | 21 ++++ src/core/auth.ts | 30 ++++-- src/core/profile.ts | 69 +++++++++++++ src/server/routes/api.ts | 6 ++ src/server/routes/openapi.ts | 21 ++++ src/server/routes/pages.tsx | 98 ++++++++++++++++++- src/views/auth.tsx | 57 +++++++++++ src/views/me.tsx | 34 +++++++ test/signup-profile.test.ts | 156 ++++++++++++++++++++++++++++++ web/public/app.css | 44 ++++++++- web/public/sw.js | 2 +- 13 files changed, 566 insertions(+), 11 deletions(-) create mode 100644 migrations/0024_user_profiles.sql create mode 100644 src/core/profile.ts create mode 100644 test/signup-profile.test.ts diff --git a/migrations/0024_user_profiles.sql b/migrations/0024_user_profiles.sql new file mode 100644 index 0000000..e53c1c1 --- /dev/null +++ b/migrations/0024_user_profiles.sql @@ -0,0 +1,38 @@ +-- The shared Profullstack profile (@profullstack/profile): email, first and +-- last name, company domain, job title and LinkedIn URL, one row per member. +-- Its own table rather than columns on users, so nothing that reads users +-- (sessions, candidate pages, the API's /me) starts carrying it by accident. +-- Never public: only the member reads it, through /api/v1/me/profile and /me. + +create table if not exists "user_profiles" ( + "user_id" uuid primary key, + "email" text, + "first_name" text, + "last_name" text, + "company_domain" text, + "job_title" text, + "linkedin_url" text, + "created_at" timestamptz not null default now(), + "updated_at" timestamptz not null default now() +); +create index if not exists "user_profiles_email" on "user_profiles" (lower("email")); +create index if not exists "user_profiles_company_domain" on "user_profiles" ("company_domain"); + +-- The account does not exist until the link is followed, so what the signup +-- form validated rides on the link and is saved when it is spent. +alter table magic_links add column if not exists profile jsonb; + +-- One-time backfill: members who already have a name. First word to +-- first_name, the rest to last_name; nothing a member typed is overwritten. +insert into user_profiles (user_id, email, first_name, last_name) +select u.id, + lower(u.email), + split_part(btrim(u.name), ' ', 1), + nullif(btrim(substr(btrim(u.name), length(split_part(btrim(u.name), ' ', 1)) + 1)), '') + from users u + where nullif(btrim(u.name), '') is not null +on conflict (user_id) do update + set email = coalesce(user_profiles.email, excluded.email), + first_name = coalesce(user_profiles.first_name, excluded.first_name), + last_name = coalesce(user_profiles.last_name, excluded.last_name); + diff --git a/package.json b/package.json index 347e53b..532a02b 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "@profullstack/footer": "latest", "@profullstack/hqtui": "^0.5.0", "@profullstack/leaderboard": "^0.3.1", + "@profullstack/profile": "0.1.3", "@profullstack/synconfig": "^0.1.2", "@profullstack/x402-gateway": "^0.11.0", "@simplewebauthn/server": "^14.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 78ced65..11c0920 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@profullstack/leaderboard': specifier: ^0.3.1 version: 0.3.1 + '@profullstack/profile': + specifier: 0.1.3 + version: 0.1.3 '@profullstack/synconfig': specifier: ^0.1.2 version: 0.1.2 @@ -157,6 +160,22 @@ packages: resolution: {integrity: sha512-LbxLwy+RvT/Qez0mfWCk4o9hvcq0kzwBZ0vF+CYp+oGvyqJWUaSTLhCHWGtKKAI3c0ZUXj6ll0iuRjbuZUE2Sg==} engines: {node: '>=20.11'} + '@profullstack/profile@0.1.3': + resolution: {integrity: sha512-P+VdOqCnNGWa5W8EGLU/ls3hwovRcY8bOBOhWZp3h23sW2LDMFyeWF/pUfO+iiYH3E2Tie2/9xyUnpO4ctwSHw==} + engines: {node: '>=20'} + hasBin: true + peerDependencies: + '@libsql/client': '>=0.6.0' + postgres: ^3.4.0 + react: '>=18' + peerDependenciesMeta: + '@libsql/client': + optional: true + postgres: + optional: true + react: + optional: true + '@profullstack/synconfig@0.1.2': resolution: {integrity: sha512-t1Pdms+k/+A7tgFCAcNx8Dry0kwboPEue9Pw2pbn/chXSMfp1dxrcFz672zWkDgvcB7YBmYpIjnvW4NkNkE4Rg==} engines: {node: '>=22'} @@ -410,6 +429,8 @@ snapshots: '@profullstack/leaderboard@0.3.1': {} + '@profullstack/profile@0.1.3': {} + '@profullstack/synconfig@0.1.2': {} '@profullstack/x402-gateway@0.11.0': diff --git a/src/core/auth.ts b/src/core/auth.ts index e71af54..326dda5 100644 --- a/src/core/auth.ts +++ b/src/core/auth.ts @@ -156,17 +156,29 @@ export function safeRedirect(value: unknown): string | null { return raw; } +/** + * `profile` is what a signup form validated (@profullstack/profile). The + * account does not exist until the link is spent, so it rides on the link and + * is saved by whoever consumes it. Plain sign-in passes nothing. + */ export async function startMagicLink( pool: pg.Pool, email: string, redirect: string | null, + profile: Record | null = null, ): Promise { const token = mintToken(); const result = await pool.query<{ expires_at: string }>( - `insert into magic_links (token_hash, email, redirect, expires_at) - values ($1, $2, $3, now() + ($4 || ' milliseconds')::interval) + `insert into magic_links (token_hash, email, redirect, expires_at, profile) + values ($1, $2, $3, now() + ($4 || ' milliseconds')::interval, $5::jsonb) returning expires_at`, - [hashToken(token), email, redirect, String(MAGIC_TTL_MS)], + [ + hashToken(token), + email, + redirect, + String(MAGIC_TTL_MS), + profile === null ? null : JSON.stringify(profile), + ], ); return { token, expiresAt: result.rows[0]?.expires_at ?? new Date().toISOString() }; } @@ -174,6 +186,8 @@ export async function startMagicLink( export interface ConsumedLink { user: UserRow; redirect: string | null; + /** The signup form's profile fields, when the link was asked for from /signup. */ + profile: Record | null; } /** @@ -187,17 +201,21 @@ export async function consumeMagicLink( pool: pg.Pool, token: string, ): Promise { - const claimed = await pool.query<{ email: string; redirect: string | null }>( + const claimed = await pool.query<{ + email: string; + redirect: string | null; + profile: Record | null; + }>( `update magic_links set used_at = now() where token_hash = $1 and used_at is null and expires_at > now() - returning email, redirect`, + returning email, redirect, profile`, [hashToken(token)], ); const row = claimed.rows[0]; if (row === undefined) return null; const user = await ensureUser(pool, row.email); - return { user, redirect: row.redirect }; + return { user, redirect: row.redirect, profile: row.profile ?? null }; } // --- the device flow ---------------------------------------------------- diff --git a/src/core/profile.ts b/src/core/profile.ts new file mode 100644 index 0000000..3d87c45 --- /dev/null +++ b/src/core/profile.ts @@ -0,0 +1,69 @@ +/** + * The shared Profullstack profile (@profullstack/profile): email, first and + * last name, company domain, job title and LinkedIn URL, in `user_profiles`. + * + * Asked for on /signup only, carried on the magic link (the account is created + * when the link is spent), and editable afterwards on /me and through + * GET/PUT/PATCH /api/v1/me/profile. Never shown to anyone but its owner. + */ + +import type pg from 'pg'; +import { + createProfileHandlers, + createProfileStore, + saveSignupProfile, + type ProfileStore, +} from '@profullstack/profile'; + +const stores = new WeakMap(); + +export function profileStore(pool: pg.Pool): ProfileStore { + let store = stores.get(pool); + if (store === undefined) { + store = createProfileStore(pool); + stores.set(pool, store); + } + return store; +} + +type Handlers = ReturnType; +const handlers = new WeakMap(); + +/** + * GET/PUT/PATCH /api/v1/me/profile: the package's own handlers. `getUser` + * receives the Hono context as its second argument and reads the viewer the + * session middleware already resolved. + */ +export function profileHandlers(pool: pg.Pool): Handlers { + let found = handlers.get(pool); + if (found === undefined) { + found = createProfileHandlers({ + store: profileStore(pool), + getUser: (_request, c) => { + const viewer = (c as { get(key: 'viewer'): { id: string; email: string } | null }).get( + 'viewer', + ); + return viewer === null ? null : { id: viewer.id, email: viewer.email }; + }, + }); + handlers.set(pool, found); + } + return found; +} + +/** + * Save the fields a signup carried on its link. A failure is logged and + * swallowed: it must never cost anybody their sign-in, and /me asks again. + */ +export async function saveCarriedProfile( + pool: pg.Pool, + user: { id: string; email: string }, + profile: Record | null, +): Promise { + if (profile === null) return; + try { + await saveSignupProfile(profileStore(pool), user.id, profile, { email: user.email }); + } catch (error) { + console.error('[auth] signup profile save failed:', (error as Error).message); + } +} diff --git a/src/server/routes/api.ts b/src/server/routes/api.ts index 08ca002..7ff6e65 100644 --- a/src/server/routes/api.ts +++ b/src/server/routes/api.ts @@ -77,6 +77,7 @@ import { updateResume, } from '../../core/resumes.ts'; import { importDocument, ImportProblem, MAX_UPLOAD_BYTES } from '../../core/import.ts'; +import { profileHandlers } from '../../core/profile.ts'; import { deliverMagicLink } from '../../core/mail.ts'; import { resumeForViewer, tagsFrom, toCandidateSummary, withTags } from '../../core/candidates.ts'; import { @@ -843,6 +844,11 @@ export function apiRoutes(): Hono { }); }); + // The shared profile (@profullstack/profile): names, company, title, LinkedIn. + api.get('/me/profile', (c) => profileHandlers(c.get('deps').pool).GET(c.req.raw, c)); + api.put('/me/profile', (c) => profileHandlers(c.get('deps').pool).PUT(c.req.raw, c)); + api.patch('/me/profile', (c) => profileHandlers(c.get('deps').pool).PATCH(c.req.raw, c)); + api.get('/me/jobs', async (c) => { const viewer = viewerOf(c); if (viewer === null) return fail(c, 401, 'unauthenticated', 'Sign in to read your listings.'); diff --git a/src/server/routes/openapi.ts b/src/server/routes/openapi.ts index 7dcf63e..cc01960 100644 --- a/src/server/routes/openapi.ts +++ b/src/server/routes/openapi.ts @@ -409,6 +409,27 @@ export function openApiDocument(config: Config): Record { responses: { 200: ok('The caller.'), 401: err() }, }, }, + '/api/v1/me/profile': { + get: { + tags: ['auth'], + summary: + 'Your contact details: email, first_name, last_name, company_domain, job_title, linkedin_url. Private to you.', + security: [{ bearer: [] }], + responses: { 200: ok('The profile and which fields are still missing.'), 401: err() }, + }, + put: { + tags: ['auth'], + summary: 'Replace your contact details. email, first_name and last_name are required.', + security: [{ bearer: [] }], + responses: { 200: ok('The saved profile.'), 400: err(), 401: err() }, + }, + patch: { + tags: ['auth'], + summary: 'Change only the contact fields you send; null clears one.', + security: [{ bearer: [] }], + responses: { 200: ok('The saved profile.'), 400: err(), 401: err() }, + }, + }, '/api/v1/me/jobs': { get: { tags: ['jobs'], diff --git a/src/server/routes/pages.tsx b/src/server/routes/pages.tsx index 66baf20..762f3f1 100644 --- a/src/server/routes/pages.tsx +++ b/src/server/routes/pages.tsx @@ -8,6 +8,7 @@ */ import { Hono } from 'hono'; +import { PROFILE_ENHANCE_SCRIPT, validateProfile } from '@profullstack/profile'; import type { Context } from 'hono'; import { deleteCookie, getCookie, setCookie } from 'hono/cookie'; import { @@ -69,8 +70,9 @@ import { parseResume, resumeBodyMarkdown, resumeTemplate } from '../../markup/re import { openProfileFromResume } from '../../markup/openprofile.ts'; import { Layout, type PageProps } from '../../views/layout.tsx'; import { EmployerDetail, EmployerList, JobDetail, JobList } from '../../views/jobs.tsx'; -import { DevicePage, LoginPage } from '../../views/auth.tsx'; -import { MePage, ResumeEditor } from '../../views/me.tsx'; +import { DevicePage, LoginPage, SignupPage } from '../../views/auth.tsx'; +import { profileStore, saveCarriedProfile } from '../../core/profile.ts'; +import { MePage, ProfileCard, ResumeEditor } from '../../views/me.tsx'; import { ManageJobPage, NewEmployerPage, PostJobPage } from '../../views/post.tsx'; import { NetworkPage, NetworkSearchPage } from '../../views/network.tsx'; import { DocsPage, SpecPage } from '../../views/docs.tsx'; @@ -901,6 +903,68 @@ export function pageRoutes(): Hono { ); }); + pages.get('/signup', (c) => { + if (c.get('viewer') !== null) return c.redirect('/me', 302); + const next = safeRedirect(new URL(c.req.url).searchParams.get('next')); + return c.html( + + + , + ); + }); + + /** + * Signup is a magic link with the shared profile fields on it. They are + * checked before any link exists, and a refusal goes back to the form with + * what was typed - the same answer for a known or unknown address, so it says + * nothing about who has an account. The account itself is made when the + * link is spent, which is where the fields are saved. + */ + pages.post('/signup', async (c) => { + const { pool, config, mailer } = c.get('deps'); + const form = await formOf(c); + const next = safeRedirect(form['next']); + const check = validateProfile(form); + const email = normaliseEmail(form['email']); + if (!check.ok || email === null) { + const errors: Record = { ...check.errors }; + if (email === null && errors['email'] === undefined) { + errors['email'] = 'That does not look like an email address.'; + } + return c.html( + + + , + 400, + ); + } + + const link = await startMagicLink(pool, email, next, check.profile); + const url = `${config.publicUrl}/auth/callback?token=${link.token}`; + const delivered = await deliverMagicLink({ + mailer, + boardName: config.boardName, + email, + url, + redirect: next, + }); + void sweepExpired(pool).catch(() => undefined); + return c.html( + + + , + ); + }); + + // The signup form's company-from-work-email helper (@profullstack/profile), + // served as a file because the policy allows no inline script. + pages.get('/vendor-profile.js', (c) => + c.body(PROFILE_ENHANCE_SCRIPT, 200, { + 'content-type': 'text/javascript; charset=utf-8', + 'cache-control': 'public, max-age=3600', + }), + ); + pages.post('/login', async (c) => { const { pool, config, mailer } = c.get('deps'); const form = await formOf(c); @@ -951,6 +1015,10 @@ export function pageRoutes(): Hono { ); } + // A signup's profile fields, saved now that the account exists. A failure + // is logged, never fatal: /me asks again. + await saveCarriedProfile(pool, consumed.user, consumed.profile); + const session = await createSession(pool, consumed.user.id, { label: 'browser' }); setSessionCookie(c, session); return c.redirect(consumed.redirect ?? '/me', 303); @@ -1055,6 +1123,9 @@ export function pageRoutes(): Hono { listAgents(pool, viewer.id), ]); const notice = new URL(c.req.url).searchParams.get('coinpay'); + const contact = await profileStore(pool) + .get(viewer.id) + .catch(() => null); return c.html( @@ -1076,6 +1147,10 @@ export function pageRoutes(): Hono { updateMax={BODY_MAX} recommendations={{ received, given }} > + { ); }); + /** The /me contact form; the API twin is PATCH /api/v1/me/profile. */ + pages.post('/me/profile', async (c) => { + const { pool } = c.get('deps'); + const viewer = requireViewer(c); + if (viewer instanceof Response) return viewer; + const form = await formOf(c); + const check = validateProfile({ ...form, email: form['email'] || viewer.email }); + if (!check.ok) { + return c.html( + + + , + 400, + ); + } + await profileStore(pool).save(viewer.id, check.profile); + return c.redirect('/me?profile=saved#profile', 303); + }); + /** Connect a CoinPay account: off to CoinPay, back to /api/v1/coinpay/callback. */ pages.get('/me/coinpay/connect', async (c) => { const { pool, coinpay, config } = c.get('deps'); diff --git a/src/views/auth.tsx b/src/views/auth.tsx index ac07c68..e59b2ad 100644 --- a/src/views/auth.tsx +++ b/src/views/auth.tsx @@ -6,9 +6,18 @@ * browser has WebAuthn at all. */ +import { COLUMNS as PROFILE_COLUMNS, renderProfileFields } from '@profullstack/profile'; +import { raw } from 'hono/html'; import type { FC } from 'hono/jsx'; import { Alert, Card, Field } from './layout.tsx'; +/** + * The rest of the shared profile (@profullstack/profile), asked for on /signup + * only. Email is the form's own box, so the package renders the other five. A + * plain sign-in never sees or needs them. + */ +export const SIGNUP_FIELDS = PROFILE_COLUMNS.filter((field) => field !== 'email'); + export const LoginPage: FC<{ sent?: string; error?: string; next?: string; unsent?: boolean }> = ({ sent, error, @@ -50,6 +59,9 @@ export const LoginPage: FC<{ sent?: string; error?: string; next?: string; unsen +

+ New here? Create an account. +