@profullstack/agenticjobs 0.11.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/README.md +136 -1
  2. package/dist/cli/args.js +4 -2
  3. package/dist/cli/args.js.map +1 -1
  4. package/dist/cli/index.js +227 -5
  5. package/dist/cli/index.js.map +1 -1
  6. package/dist/cli/jobfile.js +38 -2
  7. package/dist/cli/jobfile.js.map +1 -1
  8. package/dist/client/client.d.ts +157 -0
  9. package/dist/client/client.js +44 -0
  10. package/dist/client/client.js.map +1 -1
  11. package/dist/config.d.ts +35 -1
  12. package/dist/config.js +33 -1
  13. package/dist/config.js.map +1 -1
  14. package/dist/core/agentwriter.js +22 -5
  15. package/dist/core/agentwriter.js.map +1 -1
  16. package/dist/core/applications.js +1 -1
  17. package/dist/core/applications.js.map +1 -1
  18. package/dist/core/candidates.js +3 -5
  19. package/dist/core/candidates.js.map +1 -1
  20. package/dist/core/capacity.d.ts +1 -1
  21. package/dist/core/capacity.js +11 -19
  22. package/dist/core/capacity.js.map +1 -1
  23. package/dist/core/coinpay.d.ts +143 -0
  24. package/dist/core/coinpay.js +417 -0
  25. package/dist/core/coinpay.js.map +1 -0
  26. package/dist/core/import.js +19 -9
  27. package/dist/core/import.js.map +1 -1
  28. package/dist/core/inbox.d.ts +155 -0
  29. package/dist/core/inbox.js +353 -0
  30. package/dist/core/inbox.js.map +1 -0
  31. package/dist/core/invoices.d.ts +111 -0
  32. package/dist/core/invoices.js +292 -0
  33. package/dist/core/invoices.js.map +1 -0
  34. package/dist/core/jobs.d.ts +24 -6
  35. package/dist/core/jobs.js +116 -31
  36. package/dist/core/jobs.js.map +1 -1
  37. package/dist/core/mail.d.ts +16 -0
  38. package/dist/core/mail.js +22 -0
  39. package/dist/core/mail.js.map +1 -1
  40. package/dist/core/recommendations.d.ts +134 -0
  41. package/dist/core/recommendations.js +356 -0
  42. package/dist/core/recommendations.js.map +1 -0
  43. package/dist/core/updates.d.ts +15 -3
  44. package/dist/core/updates.js +58 -24
  45. package/dist/core/updates.js.map +1 -1
  46. package/dist/core/zip.js +29 -4
  47. package/dist/core/zip.js.map +1 -1
  48. package/dist/directory/federate.js +3 -1
  49. package/dist/directory/federate.js.map +1 -1
  50. package/dist/directory/fetch.d.ts +1 -1
  51. package/dist/markup/markdown.js +6 -2
  52. package/dist/markup/markdown.js.map +1 -1
  53. package/dist/markup/resume.js +18 -5
  54. package/dist/markup/resume.js.map +1 -1
  55. package/dist/mcp/tools.js +249 -7
  56. package/dist/mcp/tools.js.map +1 -1
  57. package/dist/schema/index.d.ts +1 -0
  58. package/dist/schema/index.js +1 -0
  59. package/dist/schema/index.js.map +1 -1
  60. package/dist/schema/instance.js +29 -0
  61. package/dist/schema/instance.js.map +1 -1
  62. package/dist/schema/job.d.ts +12 -0
  63. package/dist/schema/job.js +17 -3
  64. package/dist/schema/job.js.map +1 -1
  65. package/dist/schema/jsonld.js +7 -6
  66. package/dist/schema/jsonld.js.map +1 -1
  67. package/dist/schema/pay.d.ts +173 -0
  68. package/dist/schema/pay.js +668 -0
  69. package/dist/schema/pay.js.map +1 -0
  70. package/dist/server/app.d.ts +4 -3
  71. package/dist/server/app.js +10 -5
  72. package/dist/server/app.js.map +1 -1
  73. package/dist/server/deps.d.ts +5 -0
  74. package/dist/server/middleware.d.ts +11 -1
  75. package/dist/server/middleware.js +26 -3
  76. package/dist/server/middleware.js.map +1 -1
  77. package/dist/server/routes/api.js +437 -7
  78. package/dist/server/routes/api.js.map +1 -1
  79. package/dist/server/routes/discovery.js +29 -8
  80. package/dist/server/routes/discovery.js.map +1 -1
  81. package/dist/server/routes/inbox.d.ts +9 -0
  82. package/dist/server/routes/inbox.js +223 -0
  83. package/dist/server/routes/inbox.js.map +1 -0
  84. package/dist/server/routes/openapi.js +260 -2
  85. package/dist/server/routes/openapi.js.map +1 -1
  86. package/dist/server/routes/pages.d.ts +22 -0
  87. package/dist/server/routes/pages.js +244 -19
  88. package/dist/server/routes/pages.js.map +1 -1
  89. package/dist/tui/views.js +9 -3
  90. package/dist/tui/views.js.map +1 -1
  91. package/dist/views/candidates.d.ts +7 -0
  92. package/dist/views/candidates.js +3 -2
  93. package/dist/views/candidates.js.map +1 -1
  94. package/dist/views/docs.js +10 -1
  95. package/dist/views/docs.js.map +1 -1
  96. package/dist/views/inbox.d.ts +90 -0
  97. package/dist/views/inbox.js +73 -0
  98. package/dist/views/inbox.js.map +1 -0
  99. package/dist/views/jobs.d.ts +25 -0
  100. package/dist/views/jobs.js +35 -12
  101. package/dist/views/jobs.js.map +1 -1
  102. package/dist/views/layout.d.ts +24 -0
  103. package/dist/views/layout.js +20 -2
  104. package/dist/views/layout.js.map +1 -1
  105. package/dist/views/me.d.ts +10 -3
  106. package/dist/views/me.js +3 -2
  107. package/dist/views/me.js.map +1 -1
  108. package/dist/views/post.d.ts +18 -0
  109. package/dist/views/post.js +39 -7
  110. package/dist/views/post.js.map +1 -1
  111. package/dist/views/recommendations.d.ts +43 -0
  112. package/dist/views/recommendations.js +26 -0
  113. package/dist/views/recommendations.js.map +1 -0
  114. package/dist/views/updates.d.ts +13 -0
  115. package/dist/views/updates.js +6 -5
  116. package/dist/views/updates.js.map +1 -1
  117. package/docs/openjob.md +63 -0
  118. package/migrations/0014_inbox_and_billing.sql +138 -0
  119. package/migrations/0015_pay_lines.sql +38 -0
  120. package/migrations/0016_recommendations.sql +65 -0
  121. package/package.json +1 -1
  122. package/web/public/app.css +192 -2
  123. package/web/public/sw.js +1 -1
@@ -16,7 +16,7 @@
16
16
  import { Hono } from 'hono';
17
17
  import { approveDeviceCode, createSession, ensureUser, normaliseEmail, pollDeviceAuth, requireAdmin, safeRedirect, startDeviceAuth, startMagicLink, } from "../../core/auth.js";
18
18
  import { createApplication, decideApplication, listApplications, listDraftApplications, recentApplicationCount, submitApplication, validateApplication, } from "../../core/applications.js";
19
- import { countJobs, createJob, getJobBySlug, getJobBySourceUrl, normaliseInput, searchJobs, setStatus, editJob, updateJobFromImport, } from "../../core/jobs.js";
19
+ import { countJobs, createJob, getJobBySlug, getJobBySourceUrl, normaliseInput, publishProblem, searchJobs, setStatus, editJob, updateJobFromImport, } from "../../core/jobs.js";
20
20
  import { extractJob, JobImportProblem } from "../../core/import-job.js";
21
21
  import { createOrg, deleteOrg, getOrgBySlug, isMember, listOrgs, listOrgsForUser, updateOrg, } from "../../core/orgs.js";
22
22
  import { createResume, deleteResume, getPublicResume, getResume, listPublicResumes, getResumeById, getSharedResume, isVisibility, listResumes, updateResume, } from "../../core/resumes.js";
@@ -24,6 +24,7 @@ import { importDocument, ImportProblem, MAX_UPLOAD_BYTES } from "../../core/impo
24
24
  import { deliverMagicLink } from "../../core/mail.js";
25
25
  import { resumeForViewer, tagsFrom, toCandidateSummary, withTags, } from "../../core/candidates.js";
26
26
  import { candidateSlugFor, deleteUpdate, follow, followerCount, isFollowing, listFollowedUpdates, listFollowing, listScoped, postUpdate, scopeFrom, unfollow, userForCandidate, } from "../../core/updates.js";
27
+ import { decide as decideRecommendation, listApproved, listGiven, listReceived, notifySubject, resolve as resolveRecommendation, withdraw as withdrawRecommendation, writeRecommendation, } from "../../core/recommendations.js";
27
28
  import { sameOrigin } from "../../config.js";
28
29
  import { announce, Blocked, listInstances, listTopics } from "../../directory/registry.js";
29
30
  import { federatedSearch, targetsFromDescriptors } from "../../directory/federate.js";
@@ -33,6 +34,10 @@ import { APPLICATION_DECISIONS, isApplicationDecision } from "../../schema/job.j
33
34
  import { jobPostingJsonLd } from "../../schema/jsonld.js";
34
35
  import { parseResume } from "../../markup/resume.js";
35
36
  import { renderMarkdown } from "../../markup/markdown.js";
37
+ import { counterpartyFrom, getThread, listThreads, markRead, notifyParticipants, sendMessage, startThread, } from "../../core/inbox.js";
38
+ import { applyWebhook, cancelInvoice, getInvoice, listInvoicesFor, listInvoicesIn, requestPayment, sendInvoice, syncInvoice, } from "../../core/invoices.js";
39
+ import { disconnect, finishConnect, getAccount } from "../../core/coinpay.js";
40
+ import { coinpayRedirectUri } from "./pages.js";
36
41
  import { descriptorFor } from "./descriptor.js";
37
42
  import { openApiDocument } from "./openapi.js";
38
43
  function fail(c, status, code, message, fields) {
@@ -42,6 +47,9 @@ function fail(c, status, code, message, fields) {
42
47
  function viewerOf(c) {
43
48
  return c.get('viewer');
44
49
  }
50
+ function isUuid(value) {
51
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
52
+ }
45
53
  /** Body from JSON or a form, so curl, a browser and a model all work. */
46
54
  async function readBody(c) {
47
55
  const type = c.req.header('content-type') ?? '';
@@ -59,6 +67,38 @@ async function readBody(c) {
59
67
  return {};
60
68
  }
61
69
  }
70
+ /**
71
+ * The pay fields of a PATCH, merged onto what the listing already says.
72
+ *
73
+ * Any pay field in the body replaces the whole of the pay: `pay` (lines, in
74
+ * either form), the flat salary fields, `payMethod`, `unpaid`. A body with
75
+ * none of them keeps the listing's pay as it is, so editing a description
76
+ * does not quietly clear a rate.
77
+ */
78
+ function payPatch(body, job) {
79
+ const sent = (key) => body[key] !== undefined && body[key] !== null;
80
+ const hasLines = sent('pay') || sent('payLines') || sent('salaryMin') || sent('salaryMax') ||
81
+ sent('salaryPeriod') || sent('salaryCurrency');
82
+ const hasUnpaid = sent('salaryUnpaid') || sent('unpaid') || sent('payUnpaid');
83
+ return {
84
+ ...(hasLines
85
+ ? {
86
+ pay: body['pay'] ?? body['payLines'],
87
+ salaryMin: body['salaryMin'],
88
+ salaryMax: body['salaryMax'],
89
+ salaryPeriod: body['salaryPeriod'],
90
+ salaryCurrency: body['salaryCurrency'],
91
+ }
92
+ : { pay: { lines: job.pay.lines } }),
93
+ salaryUnpaid: hasUnpaid
94
+ ? (body['salaryUnpaid'] ?? body['unpaid'] ?? body['payUnpaid'])
95
+ : hasLines
96
+ ? false
97
+ : job.pay.unpaid,
98
+ payMethod: body['payMethod'] ?? body['paymentMethod'] ?? body['paymentCoin'] ?? job.pay.method,
99
+ payEquity: body['payEquity'] ?? body['salaryEquity'] ?? body['equity'] ?? job.pay.equity,
100
+ };
101
+ }
62
102
  export function apiRoutes() {
63
103
  const api = new Hono();
64
104
  // --- jobs -------------------------------------------------------------
@@ -188,11 +228,19 @@ export function apiRoutes() {
188
228
  const input = normaliseInput(body, org.id);
189
229
  if (typeof input === 'string')
190
230
  return fail(c, 400, 'invalid', input);
191
- const job = await createJob(pool, input);
192
231
  // A job posted through the API arrives as a draft like any other, unless
193
232
  // the caller asked for it to go live. An agent that posts a job the author
194
233
  // has not read is the failure mode worth designing against.
195
- if (body['publish'] === true || body['publish'] === 'true') {
234
+ const publish = body['publish'] === true || body['publish'] === 'true';
235
+ if (publish) {
236
+ // Checked before the insert: a caller who asked for a live listing and
237
+ // gets a 400 should not find a draft they did not ask for either.
238
+ const problem = publishProblem(input);
239
+ if (problem !== null)
240
+ return fail(c, 400, 'pay_required', problem);
241
+ }
242
+ const job = await createJob(pool, input);
243
+ if (publish) {
196
244
  const published = await setStatus(pool, job.id, 'published');
197
245
  return c.json({ job: published ?? job }, 201);
198
246
  }
@@ -321,13 +369,17 @@ export function apiRoutes() {
321
369
  requirements: body['requirements'] ?? job.requirements,
322
370
  responsibilities: body['responsibilities'] ?? job.responsibilities,
323
371
  agentPolicy: body['agentPolicy'] ?? job.agentPolicy,
324
- salaryMin: body['salaryMin'] ?? job.salary.min ?? undefined,
325
- salaryMax: body['salaryMax'] ?? job.salary.max ?? undefined,
326
- salaryCurrency: body['salaryCurrency'] ?? job.salary.currency,
327
- salaryPeriod: body['salaryPeriod'] ?? job.salary.period,
372
+ ...payPatch(body, job),
328
373
  }, job.org.id);
329
374
  if (typeof merged === 'string')
330
375
  return fail(c, 400, 'invalid', merged);
376
+ // A live listing stays held to the publish rule: an edit that removes
377
+ // the pay would leave it public without one.
378
+ if (job.status === 'published') {
379
+ const problem = publishProblem(merged);
380
+ if (problem !== null)
381
+ return fail(c, 400, 'pay_required', problem);
382
+ }
331
383
  const updated = await editJob(pool, job.id, merged);
332
384
  return c.json({ job: updated ?? job });
333
385
  });
@@ -344,6 +396,13 @@ export function apiRoutes() {
344
396
  }
345
397
  const action = c.req.param('action');
346
398
  const status = action === 'close' ? 'closed' : 'published';
399
+ if (status === 'published') {
400
+ // Pay is required to go live. The message says what to send, because
401
+ // the caller is as likely to be an agent as a person.
402
+ const problem = publishProblem(job);
403
+ if (problem !== null)
404
+ return fail(c, 400, 'pay_required', problem);
405
+ }
347
406
  const updated = await setStatus(pool, job.id, status);
348
407
  return c.json({ job: updated });
349
408
  });
@@ -845,6 +904,100 @@ export function apiRoutes() {
845
904
  const items = await listFollowing(pool, viewer.id);
846
905
  return c.json({ items, total: items.length });
847
906
  });
907
+ // --- recommendations ----------------------------------------------------
908
+ /**
909
+ * Written by somebody who worked with the subject, shown once the subject
910
+ * approves. Reading is public: an approved recommendation is on the page.
911
+ */
912
+ api.get('/candidates/:slug/recommendations', async (c) => {
913
+ const { pool } = c.get('deps');
914
+ const userId = await userForCandidate(pool, c.req.param('slug'));
915
+ if (userId === null)
916
+ return fail(c, 404, 'not_found', `Nobody here is "${c.req.param('slug')}".`);
917
+ const items = await listApproved(pool, { kind: 'candidate', userId });
918
+ return c.json({ items, total: items.length });
919
+ });
920
+ api.get('/orgs/:slug/recommendations', async (c) => {
921
+ const { pool } = c.get('deps');
922
+ const org = await getOrgBySlug(pool, c.req.param('slug'));
923
+ if (org === null)
924
+ return fail(c, 404, 'not_found', `No employer here is "${c.req.param('slug')}".`);
925
+ const items = await listApproved(pool, { kind: 'employer', orgId: org.id });
926
+ return c.json({ items, total: items.length });
927
+ });
928
+ /**
929
+ * Write one. As yourself, which needs a published resume so it is signed
930
+ * by a page, or as an employer you post for ("as"). It is pending until
931
+ * the subject approves it, and writing again replaces it.
932
+ */
933
+ const recommend = async (c, target) => {
934
+ const { pool, config, mailer } = c.get('deps');
935
+ const viewer = viewerOf(c);
936
+ if (viewer === null)
937
+ return fail(c, 401, 'unauthorised', 'Sign in to write a recommendation.');
938
+ const body = await readBody(c);
939
+ const resolved = await resolveRecommendation(pool, viewer.id, {
940
+ ...target,
941
+ ...(typeof body['as'] === 'string' ? { as: body['as'] } : {}),
942
+ });
943
+ if (typeof resolved === 'string') {
944
+ const notFound = /^Nobody here|^No employer/.test(resolved);
945
+ const noPage = /Publish a resume|signed by a page/.test(resolved);
946
+ return fail(c, notFound ? 404 : noPage ? 403 : 400, notFound ? 'not_found' : noPage ? 'no_profile' : 'invalid', resolved);
947
+ }
948
+ const written = await writeRecommendation(pool, viewer.id, {
949
+ as: resolved.as,
950
+ subject: resolved.subject,
951
+ body: body['body'],
952
+ relationship: body['relationship'],
953
+ });
954
+ if (typeof written === 'string') {
955
+ return fail(c, /limit/.test(written) ? 429 : 400, /limit/.test(written) ? 'rate_limited' : 'invalid', written);
956
+ }
957
+ void notifySubject({
958
+ pool,
959
+ mailer,
960
+ boardName: config.boardName,
961
+ publicUrl: config.publicUrl,
962
+ recommendation: written,
963
+ subject: resolved.subject,
964
+ }).catch(() => undefined);
965
+ return c.json({ recommendation: written }, 201);
966
+ };
967
+ api.post('/candidates/:slug/recommendations', (c) => recommend(c, { candidate: c.req.param('slug') }));
968
+ api.post('/orgs/:slug/recommendations', (c) => recommend(c, { org: c.req.param('slug') }));
969
+ /** About you and your employers, every status, and what you wrote. */
970
+ api.get('/me/recommendations', async (c) => {
971
+ const { pool } = c.get('deps');
972
+ const viewer = viewerOf(c);
973
+ if (viewer === null)
974
+ return fail(c, 401, 'unauthorised', 'Sign in to see your recommendations.');
975
+ const [received, given] = await Promise.all([listReceived(pool, viewer.id), listGiven(pool, viewer.id)]);
976
+ return c.json({ received, given, pending: received.filter((item) => item.status === 'pending').length });
977
+ });
978
+ /**
979
+ * Approve or reject one written about you, or withdraw one you wrote. A
980
+ * 404 covers "no such recommendation" and "not yours to decide", which
981
+ * are the same fact to a caller who should not be able to tell.
982
+ */
983
+ api.post('/recommendations/:id/:action{approve|reject|withdraw}', async (c) => {
984
+ const { pool } = c.get('deps');
985
+ const viewer = viewerOf(c);
986
+ if (viewer === null)
987
+ return fail(c, 401, 'unauthorised', 'Sign in first.');
988
+ const id = c.req.param('id');
989
+ const action = c.req.param('action');
990
+ if (action === 'withdraw') {
991
+ const gone = await withdrawRecommendation(pool, viewer.id, id);
992
+ if (!gone)
993
+ return fail(c, 404, 'not_found', 'No such recommendation, or it is not yours to withdraw.');
994
+ return c.json({ withdrawn: true });
995
+ }
996
+ const decided = await decideRecommendation(pool, viewer.id, id, action === 'approve' ? 'approved' : 'rejected');
997
+ if (decided === null)
998
+ return fail(c, 404, 'not_found', 'No such recommendation, or it is not yours to decide.');
999
+ return c.json({ recommendation: decided });
1000
+ });
848
1001
  // --- sign in ----------------------------------------------------------
849
1002
  api.post('/auth/magic-link', async (c) => {
850
1003
  const { pool, config, mailer } = c.get('deps');
@@ -954,6 +1107,283 @@ export function apiRoutes() {
954
1107
  const result = await federatedSearch(targetsFromDescriptors(instances.map((instance) => instance.descriptor)), query);
955
1108
  return c.json(result);
956
1109
  });
1110
+ // --- inbox ------------------------------------------------------------
1111
+ /**
1112
+ * Private conversations. Everything here needs the caller's identity, and
1113
+ * nothing here is visible to anyone outside the conversation: a thread id
1114
+ * that is not yours is a 404, not a 403, so ids cannot be probed.
1115
+ */
1116
+ api.get('/inbox', async (c) => {
1117
+ const { pool } = c.get('deps');
1118
+ const viewer = viewerOf(c);
1119
+ if (viewer === null)
1120
+ return fail(c, 401, 'unauthenticated', 'Sign in to read your inbox.');
1121
+ const items = await listThreads(pool, viewer.id);
1122
+ return c.json({ items, total: items.length, unread: items.filter((t) => t.unread > 0).length });
1123
+ });
1124
+ /**
1125
+ * Start a conversation, or continue the one these two parties already have.
1126
+ *
1127
+ * `candidate` or `employer` names who it is to, by slug. `as` is an employer
1128
+ * slug to write as, for a member writing on the company's behalf. `job` ties
1129
+ * it to a listing.
1130
+ */
1131
+ api.post('/inbox', async (c) => {
1132
+ const { pool, config, mailer } = c.get('deps');
1133
+ const viewer = viewerOf(c);
1134
+ if (viewer === null)
1135
+ return fail(c, 401, 'unauthenticated', 'Sign in to send a message.');
1136
+ const body = await readBody(c);
1137
+ const to = await counterpartyFrom(pool, {
1138
+ candidate: typeof body['candidate'] === 'string' ? body['candidate'] : null,
1139
+ employer: typeof body['employer'] === 'string' ? body['employer'] : null,
1140
+ });
1141
+ if (to === null) {
1142
+ return fail(c, 404, 'not_found', 'Name a candidate or an employer by slug. Nobody here matches.');
1143
+ }
1144
+ const jobSlug = typeof body['job'] === 'string' ? body['job'].trim() : '';
1145
+ const job = jobSlug === '' ? null : await getJobBySlug(pool, jobSlug);
1146
+ if (jobSlug !== '' && job === null)
1147
+ return fail(c, 404, 'not_found', `No job with the slug "${jobSlug}".`);
1148
+ const asSlug = typeof body['as'] === 'string' ? body['as'].trim() : '';
1149
+ const asOrg = asSlug === '' ? null : await getOrgBySlug(pool, asSlug);
1150
+ if (asSlug !== '' && asOrg === null)
1151
+ return fail(c, 404, 'not_found', `No employer with the slug "${asSlug}".`);
1152
+ const started = await startThread(pool, viewer.id, to, {
1153
+ subject: body['subject'],
1154
+ body: body['body'],
1155
+ jobId: job?.id ?? null,
1156
+ as: asOrg?.id ?? null,
1157
+ });
1158
+ if (typeof started === 'string') {
1159
+ return fail(c, started.includes('conversations today') ? 429 : 400, 'rejected', started);
1160
+ }
1161
+ await notifyParticipants(pool, {
1162
+ mailer,
1163
+ boardName: config.boardName,
1164
+ publicUrl: config.publicUrl,
1165
+ threadId: started.threadId,
1166
+ senderId: viewer.id,
1167
+ kind: 'text',
1168
+ });
1169
+ return c.json({ ...started, url: `${config.publicUrl}/inbox/${started.threadId}` }, started.created ? 201 : 200);
1170
+ });
1171
+ api.get('/inbox/:id', async (c) => {
1172
+ const { pool, coinpay } = c.get('deps');
1173
+ const viewer = viewerOf(c);
1174
+ if (viewer === null)
1175
+ return fail(c, 401, 'unauthenticated', 'Sign in to read your inbox.');
1176
+ const id = c.req.param('id');
1177
+ if (!isUuid(id))
1178
+ return fail(c, 404, 'not_found', 'No such conversation.');
1179
+ const thread = await getThread(pool, id, viewer.id);
1180
+ if (thread === null)
1181
+ return fail(c, 404, 'not_found', 'No such conversation.');
1182
+ const invoices = await Promise.all((await listInvoicesIn(pool, coinpay, id)).map((invoice) => syncInvoice(pool, coinpay, invoice)));
1183
+ await markRead(pool, id, viewer.id);
1184
+ return c.json({ thread, invoices });
1185
+ });
1186
+ api.post('/inbox/:id/messages', async (c) => {
1187
+ const { pool, config, mailer } = c.get('deps');
1188
+ const viewer = viewerOf(c);
1189
+ if (viewer === null)
1190
+ return fail(c, 401, 'unauthenticated', 'Sign in to send a message.');
1191
+ const id = c.req.param('id');
1192
+ if (!isUuid(id))
1193
+ return fail(c, 404, 'not_found', 'No such conversation.');
1194
+ const body = await readBody(c);
1195
+ const sent = await sendMessage(pool, id, viewer.id, body['body']);
1196
+ if (typeof sent === 'string') {
1197
+ return fail(c, sent.includes('not in that') ? 404 : 400, 'rejected', sent);
1198
+ }
1199
+ await notifyParticipants(pool, {
1200
+ mailer,
1201
+ boardName: config.boardName,
1202
+ publicUrl: config.publicUrl,
1203
+ threadId: id,
1204
+ senderId: viewer.id,
1205
+ kind: 'text',
1206
+ });
1207
+ return c.json({ message: sent }, 201);
1208
+ });
1209
+ // --- invoices and billing ------------------------------------------------
1210
+ /**
1211
+ * Send an invoice into a conversation. The caller is the payee; the money
1212
+ * settles to the wallet on their connected CoinPay account, in `currency`
1213
+ * (a chain they have a wallet on). Omit `currency` when there is one wallet.
1214
+ */
1215
+ api.post('/inbox/:id/invoices', async (c) => {
1216
+ const { pool, config, mailer, coinpay } = c.get('deps');
1217
+ const viewer = viewerOf(c);
1218
+ if (viewer === null)
1219
+ return fail(c, 401, 'unauthenticated', 'Sign in to send an invoice.');
1220
+ const id = c.req.param('id');
1221
+ if (!isUuid(id))
1222
+ return fail(c, 404, 'not_found', 'No such conversation.');
1223
+ const body = await readBody(c);
1224
+ const invoice = await sendInvoice(pool, coinpay, {
1225
+ threadId: id,
1226
+ payeeId: viewer.id,
1227
+ amount: body['amount'],
1228
+ currency: body['currency'],
1229
+ description: body['description'],
1230
+ });
1231
+ if (typeof invoice === 'string') {
1232
+ return fail(c, invoice.includes('not in that') ? 404 : 400, 'rejected', invoice);
1233
+ }
1234
+ await notifyParticipants(pool, {
1235
+ mailer,
1236
+ boardName: config.boardName,
1237
+ publicUrl: config.publicUrl,
1238
+ threadId: id,
1239
+ senderId: viewer.id,
1240
+ kind: 'invoice',
1241
+ });
1242
+ return c.json({ invoice }, 201);
1243
+ });
1244
+ /** Every invoice the caller sent or can pay, newest first. */
1245
+ api.get('/invoices', async (c) => {
1246
+ const { pool, coinpay } = c.get('deps');
1247
+ const viewer = viewerOf(c);
1248
+ if (viewer === null)
1249
+ return fail(c, 401, 'unauthenticated', 'Sign in to read your invoices.');
1250
+ const items = await listInvoicesFor(pool, coinpay, viewer.id);
1251
+ return c.json({ items, total: items.length });
1252
+ });
1253
+ api.get('/invoices/:id', async (c) => {
1254
+ const { pool, coinpay } = c.get('deps');
1255
+ const viewer = viewerOf(c);
1256
+ if (viewer === null)
1257
+ return fail(c, 401, 'unauthenticated', 'Sign in to read an invoice.');
1258
+ const id = c.req.param('id');
1259
+ if (!isUuid(id))
1260
+ return fail(c, 404, 'not_found', 'No such invoice.');
1261
+ const invoice = await getInvoice(pool, coinpay, id, viewer.id);
1262
+ if (invoice === null)
1263
+ return fail(c, 404, 'not_found', 'No such invoice.');
1264
+ return c.json({ invoice: await syncInvoice(pool, coinpay, invoice) });
1265
+ });
1266
+ /**
1267
+ * Get a live quote to pay an invoice. The response carries `payment.url`,
1268
+ * the CoinPay page to pay on, and `payment.address` plus `amountCrypto` for
1269
+ * a wallet that would rather pay directly.
1270
+ */
1271
+ api.post('/invoices/:id/pay', async (c) => {
1272
+ const { pool, config, coinpay } = c.get('deps');
1273
+ const viewer = viewerOf(c);
1274
+ if (viewer === null)
1275
+ return fail(c, 401, 'unauthenticated', 'Sign in to pay an invoice.');
1276
+ const id = c.req.param('id');
1277
+ if (!isUuid(id))
1278
+ return fail(c, 404, 'not_found', 'No such invoice.');
1279
+ const result = await requestPayment(pool, coinpay, {
1280
+ invoiceId: id,
1281
+ payerId: viewer.id,
1282
+ publicUrl: config.publicUrl,
1283
+ });
1284
+ if (typeof result === 'string') {
1285
+ return fail(c, result === 'No such invoice.' ? 404 : 400, 'rejected', result);
1286
+ }
1287
+ return c.json({ invoice: result });
1288
+ });
1289
+ api.post('/invoices/:id/cancel', async (c) => {
1290
+ const { pool, coinpay } = c.get('deps');
1291
+ const viewer = viewerOf(c);
1292
+ if (viewer === null)
1293
+ return fail(c, 401, 'unauthenticated', 'Sign in first.');
1294
+ const id = c.req.param('id');
1295
+ if (!isUuid(id))
1296
+ return fail(c, 404, 'not_found', 'No such invoice.');
1297
+ const invoice = await getInvoice(pool, coinpay, id, viewer.id);
1298
+ if (invoice === null)
1299
+ return fail(c, 404, 'not_found', 'No such invoice.');
1300
+ const cancelled = await cancelInvoice(pool, id, viewer.id);
1301
+ if (!cancelled) {
1302
+ return fail(c, 409, 'not_cancellable', 'Only the sender can cancel an invoice, and only while it is unpaid.');
1303
+ }
1304
+ return c.json({ ok: true });
1305
+ });
1306
+ /**
1307
+ * The caller's CoinPay connection: whether this board has billing at all,
1308
+ * and whether this account is connected well enough to be paid.
1309
+ */
1310
+ api.get('/coinpay', async (c) => {
1311
+ const { pool, config, coinpay } = c.get('deps');
1312
+ const viewer = viewerOf(c);
1313
+ if (viewer === null)
1314
+ return fail(c, 401, 'unauthenticated', 'Sign in first.');
1315
+ if (coinpay === null)
1316
+ return c.json({ configured: false, account: null });
1317
+ const account = await getAccount(pool, viewer.id);
1318
+ return c.json({
1319
+ configured: true,
1320
+ account,
1321
+ // Connecting needs a browser: it is CoinPay's consent screen.
1322
+ connectUrl: `${config.publicUrl}/me/coinpay/connect`,
1323
+ });
1324
+ });
1325
+ api.delete('/coinpay', async (c) => {
1326
+ const { pool } = c.get('deps');
1327
+ const viewer = viewerOf(c);
1328
+ if (viewer === null)
1329
+ return fail(c, 401, 'unauthenticated', 'Sign in first.');
1330
+ return c.json({ ok: await disconnect(pool, viewer.id) });
1331
+ });
1332
+ /**
1333
+ * Where CoinPay sends the person after consent. Under /api/v1 because that
1334
+ * is the path registered on the OAuth client; it answers a browser, with a
1335
+ * redirect, not JSON.
1336
+ */
1337
+ api.get('/coinpay/callback', async (c) => {
1338
+ const { pool, config, coinpay } = c.get('deps');
1339
+ const params = new URL(c.req.url).searchParams;
1340
+ const code = params.get('code') ?? '';
1341
+ const state = params.get('state') ?? '';
1342
+ if (params.get('error') !== null) {
1343
+ const said = params.get('error_description') ?? params.get('error') ?? 'refused';
1344
+ return c.redirect(`/me?coinpay=${encodeURIComponent(`CoinPay said: ${said}`)}#billing`, 303);
1345
+ }
1346
+ if (code === '' || state === '')
1347
+ return fail(c, 400, 'bad_request', 'Missing code or state.');
1348
+ if (coinpay === null)
1349
+ return fail(c, 404, 'not_found', 'This board has no payment rail configured.');
1350
+ const done = await finishConnect(pool, coinpay, {
1351
+ state,
1352
+ code,
1353
+ redirectUri: coinpayRedirectUri(config.publicUrl),
1354
+ });
1355
+ if (typeof done === 'string') {
1356
+ return c.redirect(`/me?coinpay=${encodeURIComponent(done)}#billing`, 303);
1357
+ }
1358
+ const back = done.redirect ?? '/me#billing';
1359
+ if (back.startsWith('/me'))
1360
+ return c.redirect('/me?coinpay=connected#billing', 303);
1361
+ return c.redirect(`${back}${back.includes('?') ? '&' : '?'}coinpay=connected`, 303);
1362
+ });
1363
+ /**
1364
+ * CoinPay telling us a payment settled. Verified against the business's
1365
+ * webhook secret; an unsigned or mis-signed body is dropped with a 401 and
1366
+ * the invoice waits for the next page load to ask CoinPay directly.
1367
+ */
1368
+ api.post('/coinpay/webhook', async (c) => {
1369
+ const { pool, coinpay } = c.get('deps');
1370
+ if (coinpay === null)
1371
+ return fail(c, 404, 'not_found', 'This board has no payment rail configured.');
1372
+ const raw = await c.req.text();
1373
+ if (!coinpay.verifyWebhook(raw, c.req.header('x-coinpay-signature'))) {
1374
+ return fail(c, 401, 'bad_signature', "The signature does not match this board's webhook secret.");
1375
+ }
1376
+ let payload;
1377
+ try {
1378
+ const parsed = JSON.parse(raw);
1379
+ payload = typeof parsed === 'object' && parsed !== null ? parsed : {};
1380
+ }
1381
+ catch {
1382
+ return fail(c, 400, 'bad_request', 'The body is not JSON.');
1383
+ }
1384
+ const outcome = await applyWebhook(pool, payload);
1385
+ return c.json({ received: true, outcome });
1386
+ });
957
1387
  // --- this instance ----------------------------------------------------
958
1388
  api.get('/openapi.json', (c) => {
959
1389
  const { config } = c.get('deps');