@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.
- package/LICENSE +21 -0
- package/README.md +219 -0
- package/bin/agenticjobs-mcp.mjs +2 -0
- package/bin/agenticjobs.mjs +2 -0
- package/dist/cli/args.d.ts +17 -0
- package/dist/cli/args.js +103 -0
- package/dist/cli/args.js.map +1 -0
- package/dist/cli/format.d.ts +15 -0
- package/dist/cli/format.js +24 -0
- package/dist/cli/format.js.map +1 -0
- package/dist/cli/index.d.ts +13 -0
- package/dist/cli/index.js +620 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/jobfile.d.ts +15 -0
- package/dist/cli/jobfile.js +94 -0
- package/dist/cli/jobfile.js.map +1 -0
- package/dist/client/client.d.ts +105 -0
- package/dist/client/client.js +182 -0
- package/dist/client/client.js.map +1 -0
- package/dist/client/config.d.ts +33 -0
- package/dist/client/config.js +92 -0
- package/dist/client/config.js.map +1 -0
- package/dist/client/fanout.d.ts +35 -0
- package/dist/client/fanout.js +77 -0
- package/dist/client/fanout.js.map +1 -0
- package/dist/client/index.d.ts +4 -0
- package/dist/client/index.js +5 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/login.d.ts +23 -0
- package/dist/client/login.js +45 -0
- package/dist/client/login.js.map +1 -0
- package/dist/config.d.ts +40 -0
- package/dist/config.js +64 -0
- package/dist/config.js.map +1 -0
- package/dist/core/applications.d.ts +67 -0
- package/dist/core/applications.js +187 -0
- package/dist/core/applications.js.map +1 -0
- package/dist/core/auth.d.ts +120 -0
- package/dist/core/auth.js +246 -0
- package/dist/core/auth.js.map +1 -0
- package/dist/core/import.d.ts +52 -0
- package/dist/core/import.js +311 -0
- package/dist/core/import.js.map +1 -0
- package/dist/core/jobs.d.ts +91 -0
- package/dist/core/jobs.js +381 -0
- package/dist/core/jobs.js.map +1 -0
- package/dist/core/orgs.d.ts +21 -0
- package/dist/core/orgs.js +111 -0
- package/dist/core/orgs.js.map +1 -0
- package/dist/core/resumes.d.ts +51 -0
- package/dist/core/resumes.js +144 -0
- package/dist/core/resumes.js.map +1 -0
- package/dist/core/zip.d.ts +17 -0
- package/dist/core/zip.js +77 -0
- package/dist/core/zip.js.map +1 -0
- package/dist/db/index.d.ts +3 -0
- package/dist/db/index.js +3 -0
- package/dist/db/index.js.map +1 -0
- package/dist/db/migrate.d.ts +15 -0
- package/dist/db/migrate.js +67 -0
- package/dist/db/migrate.js.map +1 -0
- package/dist/db/pool.d.ts +13 -0
- package/dist/db/pool.js +68 -0
- package/dist/db/pool.js.map +1 -0
- package/dist/db/seed.d.ts +9 -0
- package/dist/db/seed.js +120 -0
- package/dist/db/seed.js.map +1 -0
- package/dist/directory/announce.d.ts +32 -0
- package/dist/directory/announce.js +59 -0
- package/dist/directory/announce.js.map +1 -0
- package/dist/directory/federate.d.ts +59 -0
- package/dist/directory/federate.js +126 -0
- package/dist/directory/federate.js.map +1 -0
- package/dist/directory/fetch.d.ts +35 -0
- package/dist/directory/fetch.js +138 -0
- package/dist/directory/fetch.js.map +1 -0
- package/dist/directory/index.d.ts +4 -0
- package/dist/directory/index.js +5 -0
- package/dist/directory/index.js.map +1 -0
- package/dist/directory/registry.d.ts +55 -0
- package/dist/directory/registry.js +155 -0
- package/dist/directory/registry.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/markup/escape.d.ts +20 -0
- package/dist/markup/escape.js +56 -0
- package/dist/markup/escape.js.map +1 -0
- package/dist/markup/markdown.d.ts +35 -0
- package/dist/markup/markdown.js +279 -0
- package/dist/markup/markdown.js.map +1 -0
- package/dist/markup/resume.d.ts +72 -0
- package/dist/markup/resume.js +260 -0
- package/dist/markup/resume.js.map +1 -0
- package/dist/mcp/protocol.d.ts +56 -0
- package/dist/mcp/protocol.js +48 -0
- package/dist/mcp/protocol.js.map +1 -0
- package/dist/mcp/server.d.ts +21 -0
- package/dist/mcp/server.js +73 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/mcp/stdio.d.ts +13 -0
- package/dist/mcp/stdio.js +85 -0
- package/dist/mcp/stdio.js.map +1 -0
- package/dist/mcp/tools.d.ts +26 -0
- package/dist/mcp/tools.js +351 -0
- package/dist/mcp/tools.js.map +1 -0
- package/dist/schema/index.d.ts +5 -0
- package/dist/schema/index.js +6 -0
- package/dist/schema/index.js.map +1 -0
- package/dist/schema/instance.d.ts +85 -0
- package/dist/schema/instance.js +147 -0
- package/dist/schema/instance.js.map +1 -0
- package/dist/schema/job.d.ts +151 -0
- package/dist/schema/job.js +73 -0
- package/dist/schema/job.js.map +1 -0
- package/dist/schema/jsonld.d.ts +18 -0
- package/dist/schema/jsonld.js +120 -0
- package/dist/schema/jsonld.js.map +1 -0
- package/dist/schema/query.d.ts +47 -0
- package/dist/schema/query.js +103 -0
- package/dist/schema/query.js.map +1 -0
- package/dist/schema/text.d.ts +27 -0
- package/dist/schema/text.js +117 -0
- package/dist/schema/text.js.map +1 -0
- package/dist/server/app.d.ts +13 -0
- package/dist/server/app.js +57 -0
- package/dist/server/app.js.map +1 -0
- package/dist/server/deps.d.ts +20 -0
- package/dist/server/deps.js +2 -0
- package/dist/server/deps.js.map +1 -0
- package/dist/server/middleware.d.ts +39 -0
- package/dist/server/middleware.js +100 -0
- package/dist/server/middleware.js.map +1 -0
- package/dist/server/routes/api.d.ts +20 -0
- package/dist/server/routes/api.js +584 -0
- package/dist/server/routes/api.js.map +1 -0
- package/dist/server/routes/descriptor.d.ts +11 -0
- package/dist/server/routes/descriptor.js +31 -0
- package/dist/server/routes/descriptor.js.map +1 -0
- package/dist/server/routes/discovery.d.ts +12 -0
- package/dist/server/routes/discovery.js +305 -0
- package/dist/server/routes/discovery.js.map +1 -0
- package/dist/server/routes/mcp.d.ts +19 -0
- package/dist/server/routes/mcp.js +85 -0
- package/dist/server/routes/mcp.js.map +1 -0
- package/dist/server/routes/openapi.d.ts +11 -0
- package/dist/server/routes/openapi.js +297 -0
- package/dist/server/routes/openapi.js.map +1 -0
- package/dist/server/routes/pages.d.ts +11 -0
- package/dist/server/routes/pages.js +445 -0
- package/dist/server/routes/pages.js.map +1 -0
- package/dist/server/routes/passkey.d.ts +20 -0
- package/dist/server/routes/passkey.js +198 -0
- package/dist/server/routes/passkey.js.map +1 -0
- package/dist/server/routes/specs.d.ts +10 -0
- package/dist/server/routes/specs.js +42 -0
- package/dist/server/routes/specs.js.map +1 -0
- package/dist/server/serve.d.ts +11 -0
- package/dist/server/serve.js +79 -0
- package/dist/server/serve.js.map +1 -0
- package/dist/tui/index.d.ts +13 -0
- package/dist/tui/index.js +266 -0
- package/dist/tui/index.js.map +1 -0
- package/dist/tui/state.d.ts +63 -0
- package/dist/tui/state.js +136 -0
- package/dist/tui/state.js.map +1 -0
- package/dist/tui/types.d.ts +2 -0
- package/dist/tui/types.js +2 -0
- package/dist/tui/types.js.map +1 -0
- package/dist/tui/views.d.ts +11 -0
- package/dist/tui/views.js +197 -0
- package/dist/tui/views.js.map +1 -0
- package/dist/views/auth.d.ts +21 -0
- package/dist/views/auth.js +5 -0
- package/dist/views/auth.js.map +1 -0
- package/dist/views/docs.d.ts +17 -0
- package/dist/views/docs.js +40 -0
- package/dist/views/docs.js.map +1 -0
- package/dist/views/jobs.d.ts +49 -0
- package/dist/views/jobs.js +44 -0
- package/dist/views/jobs.js.map +1 -0
- package/dist/views/layout.d.ts +58 -0
- package/dist/views/layout.js +36 -0
- package/dist/views/layout.js.map +1 -0
- package/dist/views/me.d.ts +27 -0
- package/dist/views/me.js +6 -0
- package/dist/views/me.js.map +1 -0
- package/dist/views/network.d.ts +21 -0
- package/dist/views/network.js +17 -0
- package/dist/views/network.js.map +1 -0
- package/dist/views/post.d.ts +22 -0
- package/dist/views/post.js +20 -0
- package/dist/views/post.js.map +1 -0
- package/docs/openjob.md +200 -0
- package/docs/openresume.md +143 -0
- package/migrations/0001_init.sql +109 -0
- package/migrations/0002_auth.sql +82 -0
- package/migrations/0003_directory.sql +27 -0
- package/migrations/0004_resumes.sql +63 -0
- package/migrations/0005_application_drafts.sql +20 -0
- package/package.json +92 -0
- package/web/public/app.css +731 -0
- package/web/public/app.js +173 -0
- package/web/public/icon.svg +5 -0
- package/web/public/sw.js +64 -0
- package/web/public/tokens.css +153 -0
|
@@ -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"}
|
package/dist/config.d.ts
ADDED
|
@@ -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 {};
|