@profullstack/agenticjobs 0.1.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 (206) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +219 -0
  3. package/bin/agenticjobs-mcp.mjs +2 -0
  4. package/bin/agenticjobs.mjs +2 -0
  5. package/dist/cli/args.d.ts +17 -0
  6. package/dist/cli/args.js +103 -0
  7. package/dist/cli/args.js.map +1 -0
  8. package/dist/cli/format.d.ts +15 -0
  9. package/dist/cli/format.js +24 -0
  10. package/dist/cli/format.js.map +1 -0
  11. package/dist/cli/index.d.ts +13 -0
  12. package/dist/cli/index.js +620 -0
  13. package/dist/cli/index.js.map +1 -0
  14. package/dist/cli/jobfile.d.ts +15 -0
  15. package/dist/cli/jobfile.js +94 -0
  16. package/dist/cli/jobfile.js.map +1 -0
  17. package/dist/client/client.d.ts +105 -0
  18. package/dist/client/client.js +182 -0
  19. package/dist/client/client.js.map +1 -0
  20. package/dist/client/config.d.ts +33 -0
  21. package/dist/client/config.js +92 -0
  22. package/dist/client/config.js.map +1 -0
  23. package/dist/client/fanout.d.ts +35 -0
  24. package/dist/client/fanout.js +77 -0
  25. package/dist/client/fanout.js.map +1 -0
  26. package/dist/client/index.d.ts +4 -0
  27. package/dist/client/index.js +5 -0
  28. package/dist/client/index.js.map +1 -0
  29. package/dist/client/login.d.ts +23 -0
  30. package/dist/client/login.js +45 -0
  31. package/dist/client/login.js.map +1 -0
  32. package/dist/config.d.ts +40 -0
  33. package/dist/config.js +64 -0
  34. package/dist/config.js.map +1 -0
  35. package/dist/core/applications.d.ts +67 -0
  36. package/dist/core/applications.js +187 -0
  37. package/dist/core/applications.js.map +1 -0
  38. package/dist/core/auth.d.ts +120 -0
  39. package/dist/core/auth.js +246 -0
  40. package/dist/core/auth.js.map +1 -0
  41. package/dist/core/import.d.ts +52 -0
  42. package/dist/core/import.js +311 -0
  43. package/dist/core/import.js.map +1 -0
  44. package/dist/core/jobs.d.ts +91 -0
  45. package/dist/core/jobs.js +381 -0
  46. package/dist/core/jobs.js.map +1 -0
  47. package/dist/core/orgs.d.ts +21 -0
  48. package/dist/core/orgs.js +111 -0
  49. package/dist/core/orgs.js.map +1 -0
  50. package/dist/core/resumes.d.ts +51 -0
  51. package/dist/core/resumes.js +144 -0
  52. package/dist/core/resumes.js.map +1 -0
  53. package/dist/core/zip.d.ts +17 -0
  54. package/dist/core/zip.js +77 -0
  55. package/dist/core/zip.js.map +1 -0
  56. package/dist/db/index.d.ts +3 -0
  57. package/dist/db/index.js +3 -0
  58. package/dist/db/index.js.map +1 -0
  59. package/dist/db/migrate.d.ts +15 -0
  60. package/dist/db/migrate.js +67 -0
  61. package/dist/db/migrate.js.map +1 -0
  62. package/dist/db/pool.d.ts +13 -0
  63. package/dist/db/pool.js +68 -0
  64. package/dist/db/pool.js.map +1 -0
  65. package/dist/db/seed.d.ts +9 -0
  66. package/dist/db/seed.js +120 -0
  67. package/dist/db/seed.js.map +1 -0
  68. package/dist/directory/announce.d.ts +32 -0
  69. package/dist/directory/announce.js +59 -0
  70. package/dist/directory/announce.js.map +1 -0
  71. package/dist/directory/federate.d.ts +59 -0
  72. package/dist/directory/federate.js +126 -0
  73. package/dist/directory/federate.js.map +1 -0
  74. package/dist/directory/fetch.d.ts +35 -0
  75. package/dist/directory/fetch.js +138 -0
  76. package/dist/directory/fetch.js.map +1 -0
  77. package/dist/directory/index.d.ts +4 -0
  78. package/dist/directory/index.js +5 -0
  79. package/dist/directory/index.js.map +1 -0
  80. package/dist/directory/registry.d.ts +55 -0
  81. package/dist/directory/registry.js +155 -0
  82. package/dist/directory/registry.js.map +1 -0
  83. package/dist/index.d.ts +14 -0
  84. package/dist/index.js +14 -0
  85. package/dist/index.js.map +1 -0
  86. package/dist/markup/escape.d.ts +20 -0
  87. package/dist/markup/escape.js +56 -0
  88. package/dist/markup/escape.js.map +1 -0
  89. package/dist/markup/markdown.d.ts +35 -0
  90. package/dist/markup/markdown.js +279 -0
  91. package/dist/markup/markdown.js.map +1 -0
  92. package/dist/markup/resume.d.ts +72 -0
  93. package/dist/markup/resume.js +260 -0
  94. package/dist/markup/resume.js.map +1 -0
  95. package/dist/mcp/protocol.d.ts +56 -0
  96. package/dist/mcp/protocol.js +48 -0
  97. package/dist/mcp/protocol.js.map +1 -0
  98. package/dist/mcp/server.d.ts +21 -0
  99. package/dist/mcp/server.js +73 -0
  100. package/dist/mcp/server.js.map +1 -0
  101. package/dist/mcp/stdio.d.ts +13 -0
  102. package/dist/mcp/stdio.js +85 -0
  103. package/dist/mcp/stdio.js.map +1 -0
  104. package/dist/mcp/tools.d.ts +26 -0
  105. package/dist/mcp/tools.js +351 -0
  106. package/dist/mcp/tools.js.map +1 -0
  107. package/dist/schema/index.d.ts +5 -0
  108. package/dist/schema/index.js +6 -0
  109. package/dist/schema/index.js.map +1 -0
  110. package/dist/schema/instance.d.ts +85 -0
  111. package/dist/schema/instance.js +147 -0
  112. package/dist/schema/instance.js.map +1 -0
  113. package/dist/schema/job.d.ts +151 -0
  114. package/dist/schema/job.js +73 -0
  115. package/dist/schema/job.js.map +1 -0
  116. package/dist/schema/jsonld.d.ts +18 -0
  117. package/dist/schema/jsonld.js +120 -0
  118. package/dist/schema/jsonld.js.map +1 -0
  119. package/dist/schema/query.d.ts +47 -0
  120. package/dist/schema/query.js +103 -0
  121. package/dist/schema/query.js.map +1 -0
  122. package/dist/schema/text.d.ts +27 -0
  123. package/dist/schema/text.js +117 -0
  124. package/dist/schema/text.js.map +1 -0
  125. package/dist/server/app.d.ts +13 -0
  126. package/dist/server/app.js +57 -0
  127. package/dist/server/app.js.map +1 -0
  128. package/dist/server/deps.d.ts +20 -0
  129. package/dist/server/deps.js +2 -0
  130. package/dist/server/deps.js.map +1 -0
  131. package/dist/server/middleware.d.ts +39 -0
  132. package/dist/server/middleware.js +100 -0
  133. package/dist/server/middleware.js.map +1 -0
  134. package/dist/server/routes/api.d.ts +20 -0
  135. package/dist/server/routes/api.js +584 -0
  136. package/dist/server/routes/api.js.map +1 -0
  137. package/dist/server/routes/descriptor.d.ts +11 -0
  138. package/dist/server/routes/descriptor.js +31 -0
  139. package/dist/server/routes/descriptor.js.map +1 -0
  140. package/dist/server/routes/discovery.d.ts +12 -0
  141. package/dist/server/routes/discovery.js +305 -0
  142. package/dist/server/routes/discovery.js.map +1 -0
  143. package/dist/server/routes/mcp.d.ts +19 -0
  144. package/dist/server/routes/mcp.js +85 -0
  145. package/dist/server/routes/mcp.js.map +1 -0
  146. package/dist/server/routes/openapi.d.ts +11 -0
  147. package/dist/server/routes/openapi.js +297 -0
  148. package/dist/server/routes/openapi.js.map +1 -0
  149. package/dist/server/routes/pages.d.ts +11 -0
  150. package/dist/server/routes/pages.js +445 -0
  151. package/dist/server/routes/pages.js.map +1 -0
  152. package/dist/server/routes/passkey.d.ts +20 -0
  153. package/dist/server/routes/passkey.js +198 -0
  154. package/dist/server/routes/passkey.js.map +1 -0
  155. package/dist/server/routes/specs.d.ts +10 -0
  156. package/dist/server/routes/specs.js +42 -0
  157. package/dist/server/routes/specs.js.map +1 -0
  158. package/dist/server/serve.d.ts +11 -0
  159. package/dist/server/serve.js +79 -0
  160. package/dist/server/serve.js.map +1 -0
  161. package/dist/tui/index.d.ts +13 -0
  162. package/dist/tui/index.js +266 -0
  163. package/dist/tui/index.js.map +1 -0
  164. package/dist/tui/state.d.ts +63 -0
  165. package/dist/tui/state.js +136 -0
  166. package/dist/tui/state.js.map +1 -0
  167. package/dist/tui/types.d.ts +2 -0
  168. package/dist/tui/types.js +2 -0
  169. package/dist/tui/types.js.map +1 -0
  170. package/dist/tui/views.d.ts +11 -0
  171. package/dist/tui/views.js +197 -0
  172. package/dist/tui/views.js.map +1 -0
  173. package/dist/views/auth.d.ts +21 -0
  174. package/dist/views/auth.js +5 -0
  175. package/dist/views/auth.js.map +1 -0
  176. package/dist/views/docs.d.ts +17 -0
  177. package/dist/views/docs.js +40 -0
  178. package/dist/views/docs.js.map +1 -0
  179. package/dist/views/jobs.d.ts +49 -0
  180. package/dist/views/jobs.js +44 -0
  181. package/dist/views/jobs.js.map +1 -0
  182. package/dist/views/layout.d.ts +58 -0
  183. package/dist/views/layout.js +36 -0
  184. package/dist/views/layout.js.map +1 -0
  185. package/dist/views/me.d.ts +27 -0
  186. package/dist/views/me.js +6 -0
  187. package/dist/views/me.js.map +1 -0
  188. package/dist/views/network.d.ts +21 -0
  189. package/dist/views/network.js +17 -0
  190. package/dist/views/network.js.map +1 -0
  191. package/dist/views/post.d.ts +22 -0
  192. package/dist/views/post.js +20 -0
  193. package/dist/views/post.js.map +1 -0
  194. package/docs/openjob.md +200 -0
  195. package/docs/openresume.md +143 -0
  196. package/migrations/0001_init.sql +109 -0
  197. package/migrations/0002_auth.sql +82 -0
  198. package/migrations/0003_directory.sql +27 -0
  199. package/migrations/0004_resumes.sql +63 -0
  200. package/migrations/0005_application_drafts.sql +20 -0
  201. package/package.json +92 -0
  202. package/web/public/app.css +731 -0
  203. package/web/public/app.js +173 -0
  204. package/web/public/icon.svg +5 -0
  205. package/web/public/sw.js +64 -0
  206. package/web/public/tokens.css +153 -0
@@ -0,0 +1,4 @@
1
+ export * from './config.ts';
2
+ export * from './client.ts';
3
+ export * from './login.ts';
4
+ export * from './fanout.ts';
@@ -0,0 +1,5 @@
1
+ export * from "./config.js";
2
+ export * from "./client.js";
3
+ export * from "./login.js";
4
+ export * from "./fanout.js";
5
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/client/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAC;AAC5B,cAAc,aAAa,CAAC;AAC5B,cAAc,YAAY,CAAC;AAC3B,cAAc,aAAa,CAAC"}
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The device flow, without a UI.
3
+ *
4
+ * Anything that can block - the CLI, a script, an MCP server being set up -
5
+ * uses this rather than writing the polling loop again. The TUI drives the
6
+ * same two client calls itself, interleaved with its own event loop.
7
+ */
8
+ import { type BoardClient } from './client.ts';
9
+ export interface LoginOptions {
10
+ /** What the token is called in the member's session list. */
11
+ label: string;
12
+ /** Called once, with the code and the page a human has to open. */
13
+ onPrompt: (grant: {
14
+ userCode: string;
15
+ verifyUrl: string;
16
+ expiresAt: number;
17
+ }) => void;
18
+ /** Overridable so tests do not wait in real time. */
19
+ sleep?: (ms: number) => Promise<void>;
20
+ }
21
+ export declare class LoginError extends Error {
22
+ }
23
+ export declare function login(client: BoardClient, options: LoginOptions): Promise<string>;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The device flow, without a UI.
3
+ *
4
+ * Anything that can block - the CLI, a script, an MCP server being set up -
5
+ * uses this rather than writing the polling loop again. The TUI drives the
6
+ * same two client calls itself, interleaved with its own event loop.
7
+ */
8
+ import { ApiError } from "./client.js";
9
+ export class LoginError extends Error {
10
+ }
11
+ export async function login(client, options) {
12
+ const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
13
+ let grant;
14
+ try {
15
+ grant = await client.startDeviceAuth(options.label);
16
+ }
17
+ catch (error) {
18
+ // A server that does not answer the device endpoint is either not a board
19
+ // or is too old for one, and "404" alone does not say which.
20
+ if (error instanceof ApiError && error.status === 404) {
21
+ throw new LoginError(`${client.server} has no device sign-in endpoint. Is it an agenticjobs board, and recent enough?`);
22
+ }
23
+ throw error;
24
+ }
25
+ options.onPrompt({
26
+ userCode: grant.userCode,
27
+ verifyUrl: grant.verifyUrl,
28
+ expiresAt: grant.expiresAt,
29
+ });
30
+ // The board says how often it wants to be asked. Ignoring that interval is
31
+ // how a client gets itself rate-limited on the one request it cannot skip.
32
+ const interval = Math.max(1, grant.interval || 2) * 1000;
33
+ for (;;) {
34
+ await sleep(interval);
35
+ const poll = await client.pollDeviceAuth(grant.deviceCode);
36
+ if (poll.status === 'approved' && typeof poll.token === 'string') {
37
+ client.setToken(poll.token);
38
+ return poll.token;
39
+ }
40
+ if (poll.status === 'expired') {
41
+ throw new LoginError('That code expired before it was approved. Run the command again.');
42
+ }
43
+ }
44
+ }
45
+ //# sourceMappingURL=login.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"login.js","sourceRoot":"","sources":["../../src/client/login.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAAE,QAAQ,EAAoB,MAAM,aAAa,CAAC;AAWzD,MAAM,OAAO,UAAW,SAAQ,KAAK;CAAG;AAExC,MAAM,CAAC,KAAK,UAAU,KAAK,CAAC,MAAmB,EAAE,OAAqB;IACpE,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,IAAI,CAAC,CAAC,EAAU,EAAE,EAAE,CAAC,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,UAAU,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC;IAEnG,IAAI,KAA0D,CAAC;IAC/D,IAAI,CAAC;QACH,KAAK,GAAG,MAAM,MAAM,CAAC,eAAe,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;IACtD,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,0EAA0E;QAC1E,6DAA6D;QAC7D,IAAI,KAAK,YAAY,QAAQ,IAAI,KAAK,CAAC,MAAM,KAAK,GAAG,EAAE,CAAC;YACtD,MAAM,IAAI,UAAU,CAClB,GAAG,MAAM,CAAC,MAAM,iFAAiF,CAClG,CAAC;QACJ,CAAC;QACD,MAAM,KAAK,CAAC;IACd,CAAC;IAED,OAAO,CAAC,QAAQ,CAAC;QACf,QAAQ,EAAE,KAAK,CAAC,QAAQ;QACxB,SAAS,EAAE,KAAK,CAAC,SAAS;QAC1B,SAAS,EAAE,KAAK,CAAC,SAAS;KAC3B,CAAC,CAAC;IAEH,2EAA2E;IAC3E,2EAA2E;IAC3E,MAAM,QAAQ,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,KAAK,CAAC,QAAQ,IAAI,CAAC,CAAC,GAAG,IAAI,CAAC;IAEzD,SAAS,CAAC;QACR,MAAM,KAAK,CAAC,QAAQ,CAAC,CAAC;QACtB,MAAM,IAAI,GAAG,MAAM,MAAM,CAAC,cAAc,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC;QAC3D,IAAI,IAAI,CAAC,MAAM,KAAK,UAAU,IAAI,OAAO,IAAI,CAAC,KAAK,KAAK,QAAQ,EAAE,CAAC;YACjE,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;YAC5B,OAAO,IAAI,CAAC,KAAK,CAAC;QACpB,CAAC;QACD,IAAI,IAAI,CAAC,MAAM,KAAK,SAAS,EAAE,CAAC;YAC9B,MAAM,IAAI,UAAU,CAAC,kEAAkE,CAAC,CAAC;QAC3F,CAAC;IACH,CAAC;AACH,CAAC"}
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Everything this instance needs to know about itself, read once at boot.
3
+ *
4
+ * Reading `process.env` at the point of use is how one surface ends up on a
5
+ * different port than another, so it happens here and nowhere else.
6
+ */
7
+ export interface Config {
8
+ databaseUrl: string;
9
+ port: number;
10
+ /** Interface to bind. `::` accepts IPv6 and IPv4-mapped addresses alike. */
11
+ host: string;
12
+ /** Public origin, no trailing slash. Every absolute URL is built from it. */
13
+ publicUrl: string;
14
+ boardName: string;
15
+ boardTagline: string;
16
+ topics: string[];
17
+ secret: string;
18
+ /** Directory to announce to, or null to stay unlisted. */
19
+ directoryUrl: string | null;
20
+ announce: boolean;
21
+ /** Whether this instance also runs a directory. */
22
+ isDirectory: boolean;
23
+ smtpUrl: string | null;
24
+ mailFrom: string;
25
+ version: string;
26
+ /** True when SECRET was generated rather than supplied. */
27
+ ephemeralSecret: boolean;
28
+ }
29
+ export declare function loadConfig(env?: NodeJS.ProcessEnv): Config;
30
+ /** Kept in step with package.json by the release script. */
31
+ export declare const VERSION = "0.1.0";
32
+ export declare const SOFTWARE_NAME = "agenticjobs";
33
+ /**
34
+ * Tokens are stored as a hash of what was handed out, so this is the only
35
+ * place that turns one into the other. Plain SHA-256 rather than a password
36
+ * KDF on purpose: these are 256-bit random values, not something a person
37
+ * chose, so there is no dictionary to slow an attacker down against.
38
+ */
39
+ export declare function hashToken(token: string): string;
40
+ export declare function mintToken(): string;
package/dist/config.js ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Everything this instance needs to know about itself, read once at boot.
3
+ *
4
+ * Reading `process.env` at the point of use is how one surface ends up on a
5
+ * different port than another, so it happens here and nowhere else.
6
+ */
7
+ import { createHash, randomBytes } from 'node:crypto';
8
+ function trimSlash(value) {
9
+ return value.replace(/\/+$/, '');
10
+ }
11
+ function flag(value, fallback = false) {
12
+ if (value === undefined || value.trim() === '')
13
+ return fallback;
14
+ return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase());
15
+ }
16
+ export function loadConfig(env = process.env) {
17
+ const port = Number.parseInt(env['PORT'] ?? '8787', 10) || 8787;
18
+ const publicUrl = trimSlash(env['PUBLIC_URL']?.trim() || `http://localhost:${port}`);
19
+ const supplied = env['SECRET']?.trim() ?? '';
20
+ // A missing secret is survivable for a laptop run and fatal for a deploy:
21
+ // every session and device code minted before a restart stops verifying.
22
+ // Generating one keeps `pnpm dev` working without making the failure quiet.
23
+ const ephemeralSecret = supplied === '';
24
+ const secret = ephemeralSecret ? randomBytes(32).toString('hex') : supplied;
25
+ const directoryRaw = env['DIRECTORY_URL']?.trim() ?? '';
26
+ return {
27
+ host: env['HOST']?.trim() || '0.0.0.0',
28
+ databaseUrl: env['DATABASE_URL']?.trim() ||
29
+ 'postgres://agenticjobs:agenticjobs@localhost:5432/agenticjobs',
30
+ port,
31
+ publicUrl,
32
+ boardName: env['BOARD_NAME']?.trim() || 'Agentic Jobs',
33
+ boardTagline: env['BOARD_TAGLINE']?.trim() || 'Jobs posted here, not scraped from somewhere else.',
34
+ topics: (env['BOARD_TOPICS'] ?? 'agentic,ai,engineering')
35
+ .split(',')
36
+ .map((topic) => topic.trim().toLowerCase())
37
+ .filter((topic) => topic !== '')
38
+ .slice(0, 12),
39
+ secret,
40
+ ephemeralSecret,
41
+ directoryUrl: directoryRaw === '' ? null : trimSlash(directoryRaw),
42
+ announce: flag(env['ANNOUNCE']),
43
+ isDirectory: flag(env['DIRECTORY']),
44
+ smtpUrl: env['SMTP_URL']?.trim() || null,
45
+ mailFrom: env['MAIL_FROM']?.trim() || 'jobs@localhost',
46
+ version: env['npm_package_version']?.trim() || VERSION,
47
+ };
48
+ }
49
+ /** Kept in step with package.json by the release script. */
50
+ export const VERSION = '0.1.0';
51
+ export const SOFTWARE_NAME = 'agenticjobs';
52
+ /**
53
+ * Tokens are stored as a hash of what was handed out, so this is the only
54
+ * place that turns one into the other. Plain SHA-256 rather than a password
55
+ * KDF on purpose: these are 256-bit random values, not something a person
56
+ * chose, so there is no dictionary to slow an attacker down against.
57
+ */
58
+ export function hashToken(token) {
59
+ return createHash('sha256').update(token).digest('hex');
60
+ }
61
+ export function mintToken() {
62
+ return randomBytes(32).toString('base64url');
63
+ }
64
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Applications.
3
+ *
4
+ * The board's own application form is the one an agent can complete, so this
5
+ * is where the "agentic" part of the board is actually load-bearing: the same
6
+ * validator runs whether the answers arrived from an HTML form, a REST call or
7
+ * an MCP tool, and the agent disclosure is a first-class field rather than a
8
+ * sentence someone might write in the cover letter.
9
+ */
10
+ import type pg from 'pg';
11
+ import { type AgentDisclosure, type Application, type ApplicationStatus, type ApplySchema, type Job } from '../schema/index.ts';
12
+ export interface ValidationProblem {
13
+ field: string;
14
+ message: string;
15
+ }
16
+ export interface ValidatedApplication {
17
+ answers: Record<string, string>;
18
+ agent: AgentDisclosure | null;
19
+ }
20
+ /**
21
+ * Check answers against the schema the job published.
22
+ *
23
+ * Every problem is collected rather than thrown at the first one. A caller
24
+ * that is a model gets one round trip to fix everything instead of one per
25
+ * mistake, and a caller that is a person gets a form that does not lose four
26
+ * fields to reveal a fifth.
27
+ */
28
+ export declare function validateApplication(schema: ApplySchema, input: Record<string, unknown>, policy: Job['agentPolicy']): {
29
+ ok: true;
30
+ value: ValidatedApplication;
31
+ } | {
32
+ ok: false;
33
+ problems: ValidationProblem[];
34
+ };
35
+ /**
36
+ * Create an application, sent or held.
37
+ *
38
+ * `submit: false` writes a draft: an agent has prepared it and the candidate
39
+ * has not released it yet. A draft is not an application - the employer never
40
+ * sees one - which is the point. It is the same seam a job posting has, where
41
+ * an employer's agent writes the listing and a person publishes it.
42
+ */
43
+ export declare function createApplication(pool: pg.Pool, jobId: string, value: ValidatedApplication, options?: {
44
+ submit?: boolean;
45
+ }): Promise<Application>;
46
+ /**
47
+ * Release a draft the candidate has read.
48
+ *
49
+ * Scoped to the owner and to the draft state in one statement, so a second
50
+ * click cannot send it twice and nobody can release someone else's.
51
+ */
52
+ export declare function submitApplication(pool: pg.Pool, id: string, userId: string): Promise<boolean>;
53
+ export declare function listDraftApplications(pool: pg.Pool, userId: string): Promise<(Application & {
54
+ jobTitle: string;
55
+ jobSlug: string;
56
+ })[]>;
57
+ export declare function listApplications(pool: pg.Pool, jobId: string, limit?: number): Promise<Application[]>;
58
+ export declare function setApplicationStatus(pool: pg.Pool, id: string, status: ApplicationStatus): Promise<boolean>;
59
+ /**
60
+ * How many applications an address has sent to one board recently.
61
+ *
62
+ * Used as a rate limit. Counting by the answer to the email field rather than
63
+ * by IP is deliberate: an agent applying on someone's behalf legitimately runs
64
+ * from a different address every time, and the thing worth limiting is one
65
+ * person flooding a board, not one network.
66
+ */
67
+ export declare function recentApplicationCount(pool: pg.Pool, email: string, withinMinutes?: number): Promise<number>;
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Applications.
3
+ *
4
+ * The board's own application form is the one an agent can complete, so this
5
+ * is where the "agentic" part of the board is actually load-bearing: the same
6
+ * validator runs whether the answers arrived from an HTML form, a REST call or
7
+ * an MCP tool, and the agent disclosure is a first-class field rather than a
8
+ * sentence someone might write in the cover letter.
9
+ */
10
+ import { APPLICATION_STATUSES, } from "../schema/index.js";
11
+ import { clean } from "../schema/text.js";
12
+ function toApplication(row) {
13
+ return {
14
+ id: row.id,
15
+ jobId: row.job_id,
16
+ answers: row.answers ?? {},
17
+ agent: row.agent,
18
+ status: APPLICATION_STATUSES.includes(row.status)
19
+ ? row.status
20
+ : 'new',
21
+ submittedAt: row.submitted_at ?? null,
22
+ createdAt: row.created_at,
23
+ };
24
+ }
25
+ /**
26
+ * Check answers against the schema the job published.
27
+ *
28
+ * Every problem is collected rather than thrown at the first one. A caller
29
+ * that is a model gets one round trip to fix everything instead of one per
30
+ * mistake, and a caller that is a person gets a form that does not lose four
31
+ * fields to reveal a fifth.
32
+ */
33
+ export function validateApplication(schema, input, policy) {
34
+ const problems = [];
35
+ const answers = {};
36
+ for (const field of schema.fields) {
37
+ const raw = input[field.name];
38
+ const value = clean(raw, field.maxLength ?? 2000);
39
+ if (value === '') {
40
+ if (field.required)
41
+ problems.push({ field: field.name, message: `${field.label} is required.` });
42
+ continue;
43
+ }
44
+ if (field.type === 'email' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
45
+ problems.push({ field: field.name, message: `${field.label} is not a valid email address.` });
46
+ continue;
47
+ }
48
+ if (field.type === 'url') {
49
+ try {
50
+ const url = new URL(value.includes('://') ? value : `https://${value}`);
51
+ if (url.protocol !== 'https:' && url.protocol !== 'http:')
52
+ throw new Error('scheme');
53
+ answers[field.name] = url.toString();
54
+ continue;
55
+ }
56
+ catch {
57
+ problems.push({ field: field.name, message: `${field.label} is not a valid link.` });
58
+ continue;
59
+ }
60
+ }
61
+ if (field.type === 'select' && field.options !== undefined && field.options.length > 0) {
62
+ const match = field.options.find((option) => option.toLowerCase() === value.toLowerCase());
63
+ if (match === undefined) {
64
+ problems.push({
65
+ field: field.name,
66
+ message: `${field.label} must be one of: ${field.options.join(', ')}.`,
67
+ });
68
+ continue;
69
+ }
70
+ answers[field.name] = match;
71
+ continue;
72
+ }
73
+ answers[field.name] = value;
74
+ }
75
+ const agent = parseDisclosure(input['agent']);
76
+ // The policy is enforced here rather than at the edge so that it holds for
77
+ // the HTML form and the API alike. `human-only` is a request, not a
78
+ // technical control, and saying so plainly is more honest than pretending
79
+ // the board can tell.
80
+ if (policy === 'disclose' && agent === null && input['agent'] !== undefined) {
81
+ problems.push({
82
+ field: 'agent',
83
+ message: 'This employer asks applications written with an agent to say so.',
84
+ });
85
+ }
86
+ if (policy === 'human-only' && agent !== null) {
87
+ problems.push({
88
+ field: 'agent',
89
+ message: 'This employer asks for applications written by a person.',
90
+ });
91
+ }
92
+ if (problems.length > 0)
93
+ return { ok: false, problems };
94
+ return { ok: true, value: { answers, agent } };
95
+ }
96
+ function parseDisclosure(value) {
97
+ if (value === null || value === undefined || value === false || value === '')
98
+ return null;
99
+ if (typeof value === 'string') {
100
+ const name = clean(value, 120);
101
+ return name === '' ? null : { name, supervised: false };
102
+ }
103
+ if (typeof value !== 'object')
104
+ return null;
105
+ const record = value;
106
+ const name = clean(record['name'], 120);
107
+ if (name === '')
108
+ return null;
109
+ return { name, supervised: record['supervised'] === true };
110
+ }
111
+ /**
112
+ * Create an application, sent or held.
113
+ *
114
+ * `submit: false` writes a draft: an agent has prepared it and the candidate
115
+ * has not released it yet. A draft is not an application - the employer never
116
+ * sees one - which is the point. It is the same seam a job posting has, where
117
+ * an employer's agent writes the listing and a person publishes it.
118
+ */
119
+ export async function createApplication(pool, jobId, value, options = {}) {
120
+ const submit = options.submit !== false;
121
+ const result = await pool.query(`insert into applications (job_id, answers, agent, status, submitted_at)
122
+ values ($1, $2::jsonb, $3::jsonb, $4, case when $5 then now() else null end)
123
+ returning id, job_id, answers, agent, status, created_at, submitted_at`, [
124
+ jobId,
125
+ JSON.stringify(value.answers),
126
+ value.agent === null ? null : JSON.stringify(value.agent),
127
+ submit ? 'new' : 'draft',
128
+ submit,
129
+ ]);
130
+ const row = result.rows[0];
131
+ if (row === undefined)
132
+ throw new Error('application insert returned no row');
133
+ return toApplication(row);
134
+ }
135
+ /**
136
+ * Release a draft the candidate has read.
137
+ *
138
+ * Scoped to the owner and to the draft state in one statement, so a second
139
+ * click cannot send it twice and nobody can release someone else's.
140
+ */
141
+ export async function submitApplication(pool, id, userId) {
142
+ const result = await pool.query(`update applications
143
+ set status = 'new', submitted_at = now()
144
+ where id = $1 and user_id = $2 and submitted_at is null`, [id, userId]);
145
+ return (result.rowCount ?? 0) > 0;
146
+ }
147
+ export async function listDraftApplications(pool, userId) {
148
+ const result = await pool.query(`select a.id, a.job_id, a.answers, a.agent, a.status, a.created_at, a.submitted_at,
149
+ j.title as job_title, j.slug as job_slug
150
+ from applications a join jobs j on j.id = a.job_id
151
+ where a.user_id = $1 and a.submitted_at is null
152
+ order by a.created_at desc limit 100`, [userId]);
153
+ return result.rows.map((row) => ({
154
+ ...toApplication(row),
155
+ jobTitle: row.job_title,
156
+ jobSlug: row.job_slug,
157
+ }));
158
+ }
159
+ export async function listApplications(pool, jobId, limit = 100) {
160
+ const result = await pool.query(`select id, job_id, answers, agent, status, created_at, submitted_at
161
+ from applications
162
+ where job_id = $1 and submitted_at is not null
163
+ order by created_at desc
164
+ limit $2`, [jobId, Math.min(500, Math.max(1, limit))]);
165
+ return result.rows.map(toApplication);
166
+ }
167
+ export async function setApplicationStatus(pool, id, status) {
168
+ const result = await pool.query(`update applications set status = $2 where id = $1`, [id, status]);
169
+ return (result.rowCount ?? 0) > 0;
170
+ }
171
+ /**
172
+ * How many applications an address has sent to one board recently.
173
+ *
174
+ * Used as a rate limit. Counting by the answer to the email field rather than
175
+ * by IP is deliberate: an agent applying on someone's behalf legitimately runs
176
+ * from a different address every time, and the thing worth limiting is one
177
+ * person flooding a board, not one network.
178
+ */
179
+ export async function recentApplicationCount(pool, email, withinMinutes = 60) {
180
+ const result = await pool.query(`select count(*)::bigint as count
181
+ from applications
182
+ where created_at > now() - ($2 || ' minutes')::interval
183
+ and submitted_at is not null
184
+ and lower(answers ->> 'email') = lower($1)`, [email, String(withinMinutes)]);
185
+ return result.rows[0]?.count ?? 0;
186
+ }
187
+ //# sourceMappingURL=applications.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"applications.js","sourceRoot":"","sources":["../../src/core/applications.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Who someone is, established four ways: a magic link, a passkey, an existing
3
+ * session cookie, or the device flow for anything without a browser.
4
+ *
5
+ * No passwords anywhere. That is the house rule and it is also the reason the
6
+ * device flow exists: a terminal cannot follow a magic link, so it asks the
7
+ * board for a short code instead and a browser approves it.
8
+ */
9
+ import type pg from 'pg';
10
+ export interface Viewer {
11
+ id: string;
12
+ email: string;
13
+ name: string | null;
14
+ isAdmin: boolean;
15
+ /**
16
+ * True when the caller authenticated with a device-flow token rather than a
17
+ * browser session. Such a caller is never an administrator, whatever the
18
+ * account says — see `requireAdmin`.
19
+ */
20
+ viaToken: boolean;
21
+ }
22
+ export declare const SESSION_COOKIE = "aj_session";
23
+ export declare function normaliseEmail(value: unknown): string | null;
24
+ interface UserRow {
25
+ id: string;
26
+ email: string;
27
+ name: string | null;
28
+ is_admin: boolean;
29
+ }
30
+ /**
31
+ * Find or create the account for an address.
32
+ *
33
+ * The first account on a board becomes its administrator. Any other rule
34
+ * requires a setup step someone has to be told about, and an installer that
35
+ * leaves the operator locked out of their own board is not an installer.
36
+ */
37
+ export declare function ensureUser(pool: pg.Pool, email: string, name?: string): Promise<UserRow>;
38
+ export declare function createSession(pool: pg.Pool, userId: string, options?: {
39
+ label?: string;
40
+ viaToken?: boolean;
41
+ ttlMs?: number;
42
+ }): Promise<string>;
43
+ export declare function viewerFromToken(pool: pg.Pool, token: string): Promise<Viewer | null>;
44
+ export declare function revokeSession(pool: pg.Pool, token: string): Promise<void>;
45
+ /**
46
+ * A device-flow token is never an administrator.
47
+ *
48
+ * The token was typed into a terminal and lives in a file on disk; a browser
49
+ * session was established in front of the person it belongs to. Treating them
50
+ * as equivalent would make every CLI token a full admin credential.
51
+ */
52
+ export declare function requireAdmin(viewer: Viewer | null): viewer is Viewer;
53
+ export interface MagicLink {
54
+ token: string;
55
+ expiresAt: string;
56
+ }
57
+ /** Only same-origin paths. An open redirect on a login link is a phish. */
58
+ export declare function safeRedirect(value: unknown): string | null;
59
+ export declare function startMagicLink(pool: pg.Pool, email: string, redirect: string | null): Promise<MagicLink>;
60
+ export interface ConsumedLink {
61
+ user: UserRow;
62
+ redirect: string | null;
63
+ }
64
+ /**
65
+ * Spend a magic link.
66
+ *
67
+ * The update is what claims it, not a read followed by a write: two clicks
68
+ * arriving together (a mail client prefetching the link, then the person
69
+ * clicking it) would both pass a read-then-write check.
70
+ */
71
+ export declare function consumeMagicLink(pool: pg.Pool, token: string): Promise<ConsumedLink | null>;
72
+ export interface DeviceGrant {
73
+ deviceCode: string;
74
+ userCode: string;
75
+ verifyUrl: string;
76
+ interval: number;
77
+ expiresAt: number;
78
+ }
79
+ export declare function startDeviceAuth(pool: pg.Pool, label: string, publicUrl: string): Promise<DeviceGrant>;
80
+ export type DevicePoll = {
81
+ status: 'pending';
82
+ } | {
83
+ status: 'expired';
84
+ } | {
85
+ status: 'approved';
86
+ token: string;
87
+ };
88
+ /**
89
+ * Collect the token for an approved code.
90
+ *
91
+ * The token is cleared in the same statement that returns it, so it is handed
92
+ * over exactly once: a second poll with the same device code gets nothing,
93
+ * which is what makes a leaked poll response useless.
94
+ *
95
+ * An unknown or expired code reports `expired` rather than `pending`. A client
96
+ * told `pending` for a code the server has forgotten polls until its own
97
+ * timeout with no way to tell that it is waiting for nothing.
98
+ */
99
+ export declare function pollDeviceAuth(pool: pg.Pool, deviceCode: string): Promise<DevicePoll>;
100
+ export interface PendingDevice {
101
+ userCode: string;
102
+ label: string;
103
+ }
104
+ export declare function findDeviceCode(pool: pg.Pool, code: string): Promise<PendingDevice | null>;
105
+ export declare function approveDeviceCode(pool: pg.Pool, code: string, userId: string): Promise<boolean>;
106
+ export declare function storeChallenge(pool: pg.Pool, challenge: string, purpose: 'register' | 'login', who: {
107
+ email?: string;
108
+ userId?: string;
109
+ }): Promise<void>;
110
+ export interface StoredChallenge {
111
+ challenge: string;
112
+ email: string | null;
113
+ userId: string | null;
114
+ purpose: string;
115
+ }
116
+ /** Claimed on read: a challenge is single use by definition. */
117
+ export declare function takeChallenge(pool: pg.Pool, challenge: string): Promise<StoredChallenge | null>;
118
+ /** Swept opportunistically rather than on a timer, so there is no worker. */
119
+ export declare function sweepExpired(pool: pg.Pool): Promise<void>;
120
+ export {};