job-application-agent 3.1.1 → 3.2.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.
@@ -0,0 +1,338 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { chmod, mkdir, open, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { setTimeout as delay } from 'node:timers/promises';
5
+
6
+ import { SKILL_VERSION } from './telemetry-client.mjs';
7
+ import {
8
+ createCommunityJobContributionEnvelope,
9
+ createSourceContributionEnvelope,
10
+ normalizeCommunityJob,
11
+ normalizeCommunitySource,
12
+ validateCommunityJobList,
13
+ validateCommunitySourceList,
14
+ } from './source-community-schema.mjs';
15
+
16
+ export const DEFAULT_SOURCE_COMMUNITY_ENDPOINT = process.env.JOB_APPLICATION_AGENT_SOURCE_COMMUNITY_URL ?? process.env.JOB_APPLICATION_AGENT_TELEMETRY_URL ?? 'https://job-application-agent-telemetry.varora1406.workers.dev';
17
+ export const SOURCE_SHARING_NOTICE = 'Community sharing is enabled by default. Confirmed public job links are logged privately and enter maintainer review before publication, as do repeatable job boards and hiring feeds. Personal, answer, resume, score, referral, and candidate timing data are never sent. Run `sources sharing disable` to opt out.\n';
18
+
19
+ const CONFIG_FILE = 'source-sharing.json';
20
+ const CONFIG_LOCK_FILE = '.source-sharing.lock';
21
+ const CONFIG_LOCK_TIMEOUT_MS = 15_000;
22
+ const CONFIG_LOCK_STALE_MS = 60_000;
23
+
24
+ function defaultConfig({ jobSharingGraceConsumed = true } = {}) {
25
+ return { version: 1, enabled: true, disclosed: false, jobSharingDisclosed: false, jobSharingGraceConsumed, installationId: null, token: null, tokenExpiresAt: null };
26
+ }
27
+
28
+ function sameCredentialState(left, right) {
29
+ return left.enabled === right.enabled
30
+ && left.installationId === right.installationId
31
+ && left.token === right.token
32
+ && left.tokenExpiresAt === right.tokenExpiresAt;
33
+ }
34
+
35
+ function processIsAlive(pid) {
36
+ if (!Number.isSafeInteger(pid) || pid <= 0) return false;
37
+ if (pid === process.pid) return true;
38
+ try {
39
+ process.kill(pid, 0);
40
+ return true;
41
+ } catch (error) {
42
+ if (error.code === 'ESRCH') return false;
43
+ return true;
44
+ }
45
+ }
46
+
47
+ async function writePrivate(file, value) {
48
+ const temporary = `${file}.${process.pid}.tmp`;
49
+ await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
50
+ await rename(temporary, file);
51
+ await chmod(file, 0o600);
52
+ }
53
+
54
+ export class SourceCommunityClient {
55
+ constructor({ stateDir, endpoint = DEFAULT_SOURCE_COMMUNITY_ENDPOINT, fetch: fetchFn = globalThis.fetch, stderr = (value) => process.stderr.write(value), now = () => new Date(), timeoutMs = Number(process.env.JOB_APPLICATION_AGENT_SOURCE_COMMUNITY_TIMEOUT_MS ?? 3000), historicalApplicationsAtStart = null }) {
56
+ this.stateDir = stateDir;
57
+ this.endpoint = endpoint.replace(/\/$/, '');
58
+ this.fetch = fetchFn;
59
+ this.stderr = stderr;
60
+ this.now = now;
61
+ this.timeoutMs = timeoutMs;
62
+ this.historicalApplicationsAtStart = historicalApplicationsAtStart ?? existsSync(join(stateDir, 'applications.ndjson'));
63
+ }
64
+
65
+ initialConfig() { return defaultConfig({ jobSharingGraceConsumed: !this.historicalApplicationsAtStart }); }
66
+
67
+ get configPath() { return join(this.stateDir, CONFIG_FILE); }
68
+ get configLockPath() { return join(this.stateDir, CONFIG_LOCK_FILE); }
69
+
70
+ async ensureDirectory() {
71
+ await mkdir(this.stateDir, { recursive: true, mode: 0o700 });
72
+ await chmod(this.stateDir, 0o700);
73
+ }
74
+
75
+ async readConfig() {
76
+ try {
77
+ const value = JSON.parse(await readFile(this.configPath, 'utf8'));
78
+ return { version: 1, enabled: value.enabled !== false, disclosed: value.disclosed === true, jobSharingDisclosed: value.jobSharingDisclosed === true, jobSharingGraceConsumed: value.jobSharingGraceConsumed === true, installationId: value.installationId ?? null, token: value.token ?? null, tokenExpiresAt: value.tokenExpiresAt ?? null };
79
+ } catch (error) {
80
+ if (error.code === 'ENOENT') return null;
81
+ return { version: 1, enabled: false, disclosed: true, jobSharingDisclosed: true, jobSharingGraceConsumed: true, installationId: null, token: null, tokenExpiresAt: null };
82
+ }
83
+ }
84
+
85
+ async saveConfigUnlocked(config) {
86
+ await this.ensureDirectory();
87
+ await writePrivate(this.configPath, config);
88
+ }
89
+
90
+ async removeStaleConfigLock() {
91
+ let contents;
92
+ let metadata;
93
+ try {
94
+ [contents, metadata] = await Promise.all([
95
+ readFile(this.configLockPath, 'utf8'),
96
+ stat(this.configLockPath),
97
+ ]);
98
+ } catch (error) {
99
+ if (error.code === 'ENOENT') return true;
100
+ throw error;
101
+ }
102
+ const trimmed = contents.trim();
103
+ const pid = /^\d+$/.test(trimmed) ? Number(trimmed) : null;
104
+ const ownerIsDead = pid !== null && !processIsAlive(pid);
105
+ const lockExpired = Date.now() - metadata.mtimeMs >= CONFIG_LOCK_STALE_MS;
106
+ if (!ownerIsDead && !lockExpired) return false;
107
+ try {
108
+ if (await readFile(this.configLockPath, 'utf8') !== contents) return false;
109
+ await unlink(this.configLockPath);
110
+ return true;
111
+ } catch (error) {
112
+ if (error.code === 'ENOENT') return true;
113
+ throw error;
114
+ }
115
+ }
116
+
117
+ async withConfigLock(operation) {
118
+ await this.ensureDirectory();
119
+ const startedAt = Date.now();
120
+ let handle;
121
+ while (!handle) {
122
+ try {
123
+ handle = await open(this.configLockPath, 'wx', 0o600);
124
+ } catch (error) {
125
+ if (error.code !== 'EEXIST') throw error;
126
+ if (await this.removeStaleConfigLock()) continue;
127
+ if (Date.now() - startedAt >= CONFIG_LOCK_TIMEOUT_MS) throw new Error('Could not acquire source-sharing config lock.');
128
+ await delay(20);
129
+ }
130
+ }
131
+ try {
132
+ await handle.writeFile(`${process.pid}\n`);
133
+ } catch (error) {
134
+ await handle.close();
135
+ await unlink(this.configLockPath).catch(() => {});
136
+ throw error;
137
+ }
138
+ try {
139
+ return await operation();
140
+ } finally {
141
+ await handle.close();
142
+ await unlink(this.configLockPath).catch((error) => { if (error.code !== 'ENOENT') throw error; });
143
+ }
144
+ }
145
+
146
+ async updateConfig(transform) {
147
+ return this.withConfigLock(async () => {
148
+ const current = await this.readConfig() ?? this.initialConfig();
149
+ const next = transform(current);
150
+ await this.saveConfigUnlocked(next);
151
+ return next;
152
+ });
153
+ }
154
+
155
+ async config() {
156
+ return this.withConfigLock(async () => {
157
+ const existing = await this.readConfig();
158
+ if (existing) return existing;
159
+ const config = this.initialConfig();
160
+ await this.saveConfigUnlocked(config);
161
+ return config;
162
+ });
163
+ }
164
+
165
+ async status() {
166
+ const config = await this.readConfig();
167
+ return { enabled: config?.enabled ?? true, disclosed: config?.disclosed ?? false, jobSharingDisclosed: config?.jobSharingDisclosed ?? false, hasInstallationId: Boolean(config?.installationId), endpoint: this.endpoint, schemaVersion: 1 };
168
+ }
169
+
170
+ async configure(action) {
171
+ if (action === 'status') return this.status();
172
+ let discloseJobSharing = false;
173
+ await this.updateConfig((config) => {
174
+ if (action === 'enable') {
175
+ config.enabled = true;
176
+ if (!config.jobSharingDisclosed) discloseJobSharing = true;
177
+ config.jobSharingDisclosed = true;
178
+ config.jobSharingGraceConsumed = true;
179
+ }
180
+ else if (action === 'disable') config.enabled = false;
181
+ else if (action === 'reset') Object.assign(config, { enabled: false, disclosed: true, jobSharingDisclosed: false, jobSharingGraceConsumed: false, installationId: null, token: null, tokenExpiresAt: null });
182
+ else throw new Error('Source sharing action must be status, enable, disable, or reset.');
183
+ config.disclosed = true;
184
+ return config;
185
+ });
186
+ if (discloseJobSharing) this.stderr(SOURCE_SHARING_NOTICE);
187
+ return this.status();
188
+ }
189
+
190
+ async credentials(config) {
191
+ if (config.installationId && config.token && config.tokenExpiresAt && Date.parse(config.tokenExpiresAt) > this.now().getTime() + 60_000) return config;
192
+ let expectedState = config;
193
+ let body = config.installationId && config.token ? { installationId: config.installationId, token: config.token } : {};
194
+ let response = await this.fetch(`${this.endpoint}/v1/install`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(this.timeoutMs) });
195
+ if (response.status === 401 && body.installationId) {
196
+ config = await this.updateConfig((current) => {
197
+ if (current.installationId === body.installationId) Object.assign(current, { installationId: null, token: null, tokenExpiresAt: null });
198
+ return current;
199
+ });
200
+ expectedState = config;
201
+ body = {};
202
+ response = await this.fetch(`${this.endpoint}/v1/install`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(this.timeoutMs) });
203
+ }
204
+ if (!response.ok) throw new Error('community relay unavailable');
205
+ const identity = await response.json();
206
+ return this.updateConfig((current) => sameCredentialState(current, expectedState)
207
+ ? { ...current, installationId: identity.installationId, token: identity.token, tokenExpiresAt: identity.expiresAt }
208
+ : current);
209
+ }
210
+
211
+ async preview(input) {
212
+ return normalizeCommunitySource(input);
213
+ }
214
+
215
+ async contribute(input) {
216
+ const source = normalizeCommunitySource(input);
217
+ try {
218
+ let config = await this.config();
219
+ if (!config.enabled) return { shared: false, reason: 'disabled' };
220
+ if (!config.disclosed) {
221
+ this.stderr(SOURCE_SHARING_NOTICE);
222
+ config = await this.updateConfig((current) => ({ ...current, disclosed: true, jobSharingDisclosed: true, jobSharingGraceConsumed: true }));
223
+ if (!config.enabled) return { shared: false, reason: 'disabled' };
224
+ }
225
+ config = await this.credentials(config);
226
+ let sent = await this.sendContribution(config, source);
227
+ if (sent.disabled) return { shared: false, reason: 'disabled' };
228
+ let response = sent.response;
229
+ if (response.status === 401) {
230
+ config = await this.updateConfig((current) => ({ ...current, tokenExpiresAt: null }));
231
+ config = await this.credentials(config);
232
+ sent = await this.sendContribution(config, source);
233
+ if (sent.disabled) return { shared: false, reason: 'disabled' };
234
+ response = sent.response;
235
+ }
236
+ if (!response.ok) {
237
+ return { shared: false, reason: 'unavailable' };
238
+ }
239
+ const result = await response.json();
240
+ const allowed = new Set(['accepted', 'sourceId', 'publicationStatus', 'uniqueContributors']);
241
+ if (!result || typeof result !== 'object' || Array.isArray(result) || Object.keys(result).some((key) => !allowed.has(key))) return { shared: false, reason: 'unavailable' };
242
+ if (result.accepted !== true || !/^community-[0-9a-f]{16}$/.test(result.sourceId)) return { shared: false, reason: 'unavailable' };
243
+ if (!['pending', 'published', 'rejected'].includes(result.publicationStatus)) return { shared: false, reason: 'unavailable' };
244
+ if (!Number.isSafeInteger(result.uniqueContributors) || result.uniqueContributors < 1 || result.uniqueContributors > 1_000_000_000) return { shared: false, reason: 'unavailable' };
245
+ return { shared: true, sourceId: result.sourceId, publicationStatus: result.publicationStatus, uniqueContributors: result.uniqueContributors };
246
+ } catch {
247
+ return { shared: false, reason: 'unavailable' };
248
+ }
249
+ }
250
+
251
+ async sendContribution(config, source) {
252
+ return this.withConfigLock(async () => {
253
+ const current = await this.readConfig() ?? config;
254
+ if (!current.enabled) return { disabled: true };
255
+ const envelope = createSourceContributionEnvelope({ installationId: current.installationId, token: current.token, source, skillVersion: SKILL_VERSION });
256
+ const response = await this.fetch(`${this.endpoint}/v1/sources`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(envelope), signal: AbortSignal.timeout(this.timeoutMs) });
257
+ return { disabled: false, response };
258
+ });
259
+ }
260
+
261
+ async contributeJob(input) {
262
+ const job = normalizeCommunityJob(input);
263
+ try {
264
+ let config = await this.config();
265
+ if (!config.enabled) return { shared: false, reason: 'disabled' };
266
+ if (!config.jobSharingDisclosed) {
267
+ this.stderr(SOURCE_SHARING_NOTICE);
268
+ const grace = !config.jobSharingGraceConsumed;
269
+ config = await this.updateConfig((current) => ({ ...current, disclosed: true, jobSharingDisclosed: true, jobSharingGraceConsumed: true }));
270
+ if (!config.enabled) return { shared: false, reason: 'disabled' };
271
+ if (grace) return { shared: false, reason: 'grace' };
272
+ } else if (!config.jobSharingGraceConsumed) {
273
+ config = await this.updateConfig((current) => ({ ...current, jobSharingGraceConsumed: true }));
274
+ if (!config.enabled) return { shared: false, reason: 'disabled' };
275
+ return { shared: false, reason: 'grace' };
276
+ }
277
+ config = await this.credentials(config);
278
+ let sent = await this.sendJobContribution(config, job);
279
+ if (sent.disabled) return { shared: false, reason: 'disabled' };
280
+ let response = sent.response;
281
+ if (response.status === 401) {
282
+ config = await this.updateConfig((current) => ({ ...current, tokenExpiresAt: null }));
283
+ config = await this.credentials(config);
284
+ sent = await this.sendJobContribution(config, job);
285
+ if (sent.disabled) return { shared: false, reason: 'disabled' };
286
+ response = sent.response;
287
+ }
288
+ if (!response.ok) return { shared: false, reason: 'unavailable' };
289
+ const result = await response.json();
290
+ const allowed = new Set(['accepted', 'jobId', 'publicationStatus', 'contributionCount']);
291
+ if (!result || typeof result !== 'object' || Array.isArray(result) || Object.keys(result).some((key) => !allowed.has(key))) return { shared: false, reason: 'unavailable' };
292
+ if (result.accepted !== true || !/^community-job-[0-9a-f]{16}$/.test(result.jobId)) return { shared: false, reason: 'unavailable' };
293
+ if (!['pending', 'published', 'rejected'].includes(result.publicationStatus)) return { shared: false, reason: 'unavailable' };
294
+ if (!Number.isSafeInteger(result.contributionCount) || result.contributionCount < 1 || result.contributionCount > 1_000_000_000) return { shared: false, reason: 'unavailable' };
295
+ return { shared: true, jobId: result.jobId, publicationStatus: result.publicationStatus, contributionCount: result.contributionCount };
296
+ } catch {
297
+ return { shared: false, reason: 'unavailable' };
298
+ }
299
+ }
300
+
301
+ async sendJobContribution(config, job) {
302
+ return this.withConfigLock(async () => {
303
+ const current = await this.readConfig() ?? config;
304
+ if (!current.enabled) return { disabled: true };
305
+ const envelope = createCommunityJobContributionEnvelope({ installationId: current.installationId, token: current.token, job, skillVersion: SKILL_VERSION });
306
+ const response = await this.fetch(`${this.endpoint}/v1/jobs`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(envelope), signal: AbortSignal.timeout(this.timeoutMs) });
307
+ return { disabled: false, response };
308
+ });
309
+ }
310
+
311
+ async list() {
312
+ if (this.sourceReadUnavailable) return [];
313
+ try {
314
+ const response = await this.fetch(`${this.endpoint}/v1/sources`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(this.timeoutMs) });
315
+ if (!response.ok) return [];
316
+ return validateCommunitySourceList(await response.json());
317
+ } catch {
318
+ this.sourceReadUnavailable = true;
319
+ return [];
320
+ }
321
+ }
322
+
323
+ async listJobs({ limit = 50, cursor = null } = {}) {
324
+ if (this.jobReadUnavailable) return { version: 1, jobs: [], nextCursor: null };
325
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) throw new Error('Community job limit must be between 1 and 100.');
326
+ if (cursor !== null && (typeof cursor !== 'string' || !cursor || cursor.length > 1024)) throw new Error('Community job cursor is invalid.');
327
+ try {
328
+ const query = new URLSearchParams({ limit: String(limit) });
329
+ if (cursor !== null) query.set('cursor', cursor);
330
+ const response = await this.fetch(`${this.endpoint}/v1/jobs?${query}`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(this.timeoutMs) });
331
+ if (!response.ok) return { version: 1, jobs: [], nextCursor: null };
332
+ return validateCommunityJobList(await response.json());
333
+ } catch {
334
+ this.jobReadUnavailable = true;
335
+ return { version: 1, jobs: [], nextCursor: null };
336
+ }
337
+ }
338
+ }
@@ -0,0 +1,320 @@
1
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
2
+ const SOURCE_ID = /^community-[0-9a-f]{16}$/;
3
+ const JOB_ID = /^community-job-[0-9a-f]{16}$/;
4
+ export const SOURCE_KINDS = new Set(['direct-employer', 'professional-network', 'social-feed', 'startup-network', 'community-thread', 'job-board', 'curated-board', 'inbound', 'user-supplied']);
5
+ export const COMMUNITY_JOB_CHANNELS = new Set(['linkedin', 'greenhouse', 'lever', 'ashby', 'workable', 'comeet', 'workday', 'rippling', 'smartrecruiters', 'google-form', 'company', 'email', 'other']);
6
+ export const COMMUNITY_JOB_DISCOVERY_SOURCES = new Set(['direct-company', 'linkedin', 'x', 'yc', 'hacker-news', 'job-board', 'email', 'user-supplied', 'web-search', 'other']);
7
+
8
+ function record(value, label) {
9
+ if (!value || Array.isArray(value) || typeof value !== 'object') throw new Error(`${label} must be an object.`);
10
+ return value;
11
+ }
12
+
13
+ function boundedString(value, label, max) {
14
+ if (typeof value !== 'string' || !value.trim() || value.length > max) throw new Error(`${label} must be a non-empty string no longer than ${max} characters.`);
15
+ return value.trim();
16
+ }
17
+
18
+ function containsIdentityLike(value) {
19
+ const normalized = value.normalize('NFKC');
20
+ return /[^\s/@]+@(?:[^\s./@]+\.)+[^\s./@]+/u.test(normalized)
21
+ || /\+?\p{Nd}[\p{Nd}\s().-]{7,}/u.test(normalized);
22
+ }
23
+
24
+ function containsEmailLike(value) {
25
+ return /[^\s/@]+@(?:[^\s./@]+\.)+[^\s./@]+/u.test(value.normalize('NFKC'));
26
+ }
27
+
28
+ function terms(value, label) {
29
+ if (!Array.isArray(value) || value.length === 0 || value.length > 12) throw new Error(`${label} must be a non-empty array with at most 12 values.`);
30
+ const normalized = value.map((item, index) => boundedString(item, `${label}[${index}]`, 40).toLowerCase());
31
+ if (normalized.some((item) => containsIdentityLike(item) || /https?:\/\//i.test(item))) throw new Error(`${label} must not contain identity-like content.`);
32
+ return [...new Set(normalized)].sort();
33
+ }
34
+
35
+ function looksPersonal(url) {
36
+ return (/(^|\.)linkedin\.com$/i.test(url.hostname) && /^\/in\//i.test(url.pathname))
37
+ || (/(^|\.)github\.com$/i.test(url.hostname) && /^\/[^/]+\/?$/i.test(url.pathname))
38
+ || (/(^|\.)x\.com$/i.test(url.hostname) && /^\/(?!home(?:\/|$)|jobs(?:\/|$)|search(?:\/|$)|i\/)[^/]+(?:\/|$)/i.test(url.pathname));
39
+ }
40
+
41
+ function looksIdentityPath(pathname) {
42
+ const segments = pathname.split('/').filter(Boolean).map((segment) => segment.toLowerCase());
43
+ const namespaces = new Set(['user', 'users', 'profile', 'profiles', 'member', 'members', 'author', 'authors', 'person', 'people', 'candidate', 'candidates', 'referral', 'referrals', 'referrer', 'referrers']);
44
+ return segments.some((segment, index) => namespaces.has(segment) && index < segments.length - 1);
45
+ }
46
+
47
+ function decodedPathname(pathname) {
48
+ let current = pathname;
49
+ for (let pass = 0; pass < 5; pass += 1) {
50
+ let decoded;
51
+ try { decoded = decodeURIComponent(current); } catch { throw new Error('community source.baseUrl path encoding is invalid.'); }
52
+ if (decoded === current) return decoded;
53
+ current = decoded;
54
+ }
55
+ throw new Error('community source.baseUrl path encoding is too deeply nested.');
56
+ }
57
+
58
+ function looksCredentialLikePath(pathname) {
59
+ return pathname.split('/').filter(Boolean).some((segment) => {
60
+ const normalized = segment.normalize('NFKC');
61
+ if (/^[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}$/.test(normalized)) return true;
62
+ if (/^(?:access[-_]?token|api[-_]?key|auth(?:orization)?|bearer|client[-_]?secret|password|secret|token)(?:[=:.]|\s+)(?:bearer\s+)?[A-Za-z0-9._~+/=-]{8,}$/i.test(normalized)) return true;
63
+ if (/^(?:cfat_|github_pat_|gh[pousr]_|[spr]k_(?:live|test)_|xox[baprs]-)[A-Za-z0-9_-]{8,}$/i.test(normalized)) return true;
64
+ const opaque = normalized.replace(/=+$/, '');
65
+ if (!/^[A-Za-z0-9_-]{20,}$/.test(opaque)) return false;
66
+ return (/[a-z]/.test(opaque) && /[A-Z]/.test(opaque)) || /\d/.test(opaque) || /[-_]/.test(opaque);
67
+ });
68
+ }
69
+
70
+ function isPublicHostname(hostname) {
71
+ const value = hostname.toLowerCase();
72
+ if (value === 'localhost' || value.endsWith('.localhost') || value.endsWith('.local') || value.endsWith('.internal')) return false;
73
+ if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(value) || value.startsWith('[')) return false;
74
+ return value.includes('.');
75
+ }
76
+
77
+ function hostnameMatches(hostname, suffix) {
78
+ return hostname === suffix || hostname.endsWith(`.${suffix}`);
79
+ }
80
+
81
+ function publicJobLabel(value, label, max) {
82
+ const result = boundedString(value, label, max);
83
+ if (containsIdentityLike(result) || /https?:\/\//i.test(result) || /[\x00-\x1f\x7f]/.test(result)) throw new Error(`${label} must not contain identity-like content.`);
84
+ return result;
85
+ }
86
+
87
+ function explicitCredentialPath(pathname) {
88
+ return pathname.split('/').filter(Boolean).some((segment) => {
89
+ const normalized = segment.normalize('NFKC');
90
+ return /^[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}$/.test(normalized)
91
+ || /^(?:access[-_]?token|api[-_]?key|auth(?:orization)?|bearer|client[-_]?secret|password|secret|token)(?:[=:.]|\s+)(?:bearer\s+)?[A-Za-z0-9._~+/=-]{8,}$/i.test(normalized)
92
+ || /^(?:cfat_|github_pat_|gh[pousr]_|[spr]k_(?:live|test)_|xox[baprs]-)[A-Za-z0-9_-]{8,}$/i.test(normalized);
93
+ });
94
+ }
95
+
96
+ function containsPhoneLikeLocation(hostname, pathname) {
97
+ if (containsIdentityLike(hostname)) return true;
98
+ return pathname.split('/').filter(Boolean).some((segment) => {
99
+ const normalized = segment.normalize('NFKC');
100
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(normalized)) return false;
101
+ const digits = normalized.match(/\p{Nd}/gu)?.length ?? 0;
102
+ const separators = normalized.match(/[\s().-]/gu)?.length ?? 0;
103
+ return digits >= 8 && (/\+\p{Nd}/u.test(normalized) || separators >= 2);
104
+ });
105
+ }
106
+
107
+ const STABLE_JOB_QUERY_KEYS = new Set([
108
+ 'gh_jid', 'jk', 'job', 'job_id', 'jobid', 'req', 'req_id', 'reqid',
109
+ 'requisition', 'requisition_id', 'requisitionid',
110
+ ]);
111
+
112
+ function stableJobQuery(searchParams) {
113
+ const identifiers = new Map();
114
+ for (const [rawKey, rawValue] of searchParams) {
115
+ const key = rawKey.toLowerCase();
116
+ if (!STABLE_JOB_QUERY_KEYS.has(key)) continue;
117
+ const value = rawValue.normalize('NFKC').trim();
118
+ if (!/^[A-Za-z0-9._~-]{1,128}$/.test(value)) continue;
119
+ if (identifiers.has(key) && identifiers.get(key) !== value) throw new Error(`community job.url contains conflicting ${key} identifiers.`);
120
+ identifiers.set(key, value);
121
+ }
122
+ return [...identifiers].sort(([left], [right]) => left.localeCompare(right));
123
+ }
124
+
125
+ function providerUrl(url) {
126
+ const hostname = url.hostname.toLowerCase();
127
+ const segments = url.pathname.split('/').filter(Boolean);
128
+ if (hostnameMatches(hostname, 'greenhouse.io') && segments[0]) return `${url.origin}/${segments[0]}`;
129
+ if (['jobs.lever.co', 'jobs.ashbyhq.com', 'apply.workable.com', 'jobs.smartrecruiters.com'].includes(hostname) && segments[0]) return `${url.origin}/${segments[0]}`;
130
+ if (hostnameMatches(hostname, 'linkedin.com')) return `${url.origin}/jobs`;
131
+ return url.origin;
132
+ }
133
+
134
+ export function normalizeCommunityJob(input) {
135
+ const value = record(input, 'community job');
136
+ const allowed = new Set(['url', 'company', 'role', 'applicationChannel', 'discoverySource', 'providerUrl']);
137
+ for (const key of Object.keys(value)) if (!allowed.has(key)) throw new Error(`Unknown community job property: ${key}.`);
138
+ const url = new URL(boundedString(value.url, 'community job.url', 2048));
139
+ if (url.protocol !== 'https:' || url.username || url.password) throw new Error('community job.url must be a public HTTPS URL.');
140
+ url.hostname = url.hostname.replace(/\.+$/, '').toLowerCase();
141
+ if (!isPublicHostname(url.hostname)) throw new Error('community job.url must use a public HTTPS hostname.');
142
+ const pathname = decodedPathname(url.pathname).normalize('NFKC');
143
+ url.pathname = pathname;
144
+ if (containsEmailLike(pathname) || containsPhoneLikeLocation(url.hostname, pathname) || looksIdentityPath(pathname) || looksPersonal(url)) throw new Error('community job.url must not be a personal URL.');
145
+ if (explicitCredentialPath(pathname)) throw new Error('community job.url must not contain credential-like path segments.');
146
+ const identifiers = stableJobQuery(url.searchParams);
147
+ url.pathname = pathname.replace(/\/+$/, '') || '/';
148
+ url.search = '';
149
+ for (const [key, identifier] of identifiers) url.searchParams.append(key, identifier);
150
+ url.hash = '';
151
+ const applicationChannel = boundedString(value.applicationChannel, 'community job.applicationChannel', 40).toLowerCase();
152
+ if (!COMMUNITY_JOB_CHANNELS.has(applicationChannel)) throw new Error('community job.applicationChannel is invalid.');
153
+ const discoverySource = value.discoverySource == null ? null : boundedString(value.discoverySource, 'community job.discoverySource', 40).toLowerCase();
154
+ if (discoverySource != null && !COMMUNITY_JOB_DISCOVERY_SOURCES.has(discoverySource)) throw new Error('community job.discoverySource is invalid.');
155
+ const derivedProviderUrl = providerUrl(url);
156
+ if (value.providerUrl != null && value.providerUrl !== derivedProviderUrl) throw new Error('community job.providerUrl must match the derived provider URL.');
157
+ return {
158
+ url: url.toString().replace(/\/+$/, ''),
159
+ company: publicJobLabel(value.company, 'community job.company', 160),
160
+ role: publicJobLabel(value.role, 'community job.role', 200),
161
+ applicationChannel,
162
+ ...(discoverySource == null ? {} : { discoverySource }),
163
+ providerUrl: derivedProviderUrl,
164
+ };
165
+ }
166
+
167
+ export async function communityJobId(job) {
168
+ const normalized = normalizeCommunityJob(job);
169
+ const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(normalized.url)));
170
+ return `community-job-${[...digest].map((value) => value.toString(16).padStart(2, '0')).join('').slice(0, 16)}`;
171
+ }
172
+
173
+ export function createCommunityJobContributionEnvelope({ installationId, token, job, skillVersion }) {
174
+ return validateCommunityJobContributionEnvelope({ schemaVersion: 1, skillVersion, installationId, token, job });
175
+ }
176
+
177
+ export function validateCommunityJobContributionEnvelope(input) {
178
+ const value = record(input, 'community job contribution');
179
+ const allowed = new Set(['schemaVersion', 'skillVersion', 'installationId', 'token', 'job']);
180
+ for (const key of Object.keys(value)) if (!allowed.has(key)) throw new Error(`Unknown community job contribution property: ${key}.`);
181
+ if (value.schemaVersion !== 1) throw new Error('Unsupported community job contribution schema version.');
182
+ const installationId = boundedString(value.installationId, 'community job contribution.installationId', 36);
183
+ if (!UUID.test(installationId)) throw new Error('community job contribution.installationId is invalid.');
184
+ return {
185
+ schemaVersion: 1,
186
+ skillVersion: boundedString(value.skillVersion, 'community job contribution.skillVersion', 40),
187
+ installationId,
188
+ token: boundedString(value.token, 'community job contribution.token', 2048),
189
+ job: normalizeCommunityJob(value.job),
190
+ };
191
+ }
192
+
193
+ export function validateCommunityJobList(input) {
194
+ const value = record(input, 'community job list');
195
+ const allowed = new Set(['version', 'jobs', 'nextCursor']);
196
+ for (const key of Object.keys(value)) if (!allowed.has(key)) throw new Error(`Unknown community job list property: ${key}.`);
197
+ if (value.version !== 1 || !Array.isArray(value.jobs) || value.jobs.length > 100) throw new Error('Invalid community job list.');
198
+ if (value.nextCursor != null && (typeof value.nextCursor !== 'string' || !value.nextCursor || value.nextCursor.length > 1024)) throw new Error('community job list.nextCursor is invalid.');
199
+ const jobs = value.jobs.map((entry) => {
200
+ const job = record(entry, 'community job entry');
201
+ const entryAllowed = new Set(['jobId', 'url', 'company', 'role', 'applicationChannel', 'discoverySource', 'providerUrl', 'firstSeenAt', 'lastSeenAt', 'contributionCount']);
202
+ for (const key of Object.keys(job)) if (!entryAllowed.has(key)) throw new Error(`Unknown community job entry property: ${key}.`);
203
+ if (!JOB_ID.test(job.jobId)) throw new Error('community job entry.jobId is invalid.');
204
+ if (!Number.isSafeInteger(job.contributionCount) || job.contributionCount < 1 || job.contributionCount > 1_000_000_000) throw new Error('community job entry.contributionCount is invalid.');
205
+ for (const field of ['firstSeenAt', 'lastSeenAt']) {
206
+ if (typeof job[field] !== 'string' || Number.isNaN(Date.parse(job[field]))) throw new Error(`community job entry.${field} must be an ISO date.`);
207
+ }
208
+ const normalized = normalizeCommunityJob(Object.fromEntries(['url', 'company', 'role', 'applicationChannel', 'discoverySource', 'providerUrl'].filter((key) => job[key] != null).map((key) => [key, job[key]])));
209
+ return { jobId: job.jobId, ...normalized, firstSeenAt: job.firstSeenAt, lastSeenAt: job.lastSeenAt, contributionCount: job.contributionCount };
210
+ });
211
+ return { version: 1, jobs, nextCursor: value.nextCursor ?? null };
212
+ }
213
+
214
+ export function isRepeatableCommunitySourceRoute(url) {
215
+ const hostname = url.hostname.toLowerCase();
216
+ const segments = url.pathname.split('/').filter(Boolean).map((segment) => segment.toLowerCase());
217
+ if (segments.length === 0) return true;
218
+
219
+ const path = `/${segments.join('/')}`;
220
+ const last = segments.at(-1) ?? '';
221
+ const uuid = /[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/i;
222
+ if (uuid.test(path) || /^\d{4,}(?:[-_].*)?$/.test(last) || segments.some((segment) => /^(apply|application)$/.test(segment))) return false;
223
+
224
+ if (hostnameMatches(hostname, 'myworkdayjobs.com') && segments.some((segment, index) => segment === 'job' && index < segments.length - 1)) return false;
225
+ if (hostnameMatches(hostname, 'linkedin.com') && segments[0] === 'jobs' && segments[1] === 'view') return false;
226
+ if (hostnameMatches(hostname, 'greenhouse.io') && segments.some((segment, index) => segment === 'jobs' && index < segments.length - 1)) return false;
227
+ if (hostname === 'jobs.lever.co' && segments.length >= 2) return false;
228
+ if (hostname === 'jobs.ashbyhq.com' && segments.length >= 2) return false;
229
+ if (hostname === 'apply.workable.com' && segments.some((segment, index) => segment === 'j' && index < segments.length - 1)) return false;
230
+ if (hostname === 'jobs.smartrecruiters.com' && segments.length >= 2) return false;
231
+ if (segments.some((segment, index) => segment === 'job' && index < segments.length - 1)) return false;
232
+
233
+ if (hostnameMatches(hostname, 'myworkdayjobs.com')) return segments.includes('jobs') || ['external', 'internal', 'careers'].includes(last);
234
+ if (hostnameMatches(hostname, 'linkedin.com')) return segments[0] === 'jobs' && (segments.length === 1 || ['search', 'collections'].includes(segments[1]));
235
+ if (hostnameMatches(hostname, 'greenhouse.io')) return segments.length === 1 || last === 'jobs';
236
+ if (hostname === 'jobs.lever.co' || hostname === 'jobs.ashbyhq.com' || hostname === 'jobs.smartrecruiters.com') return segments.length === 1;
237
+ if (hostname === 'apply.workable.com') return segments.length === 1 || last === 'jobs';
238
+
239
+ if (/\.(?:rss|atom|xml|json)$/i.test(last)) return /(?:feed|jobs?|openings|careers)/i.test(path);
240
+ const collectionCues = new Set(['careers', 'openings', 'positions', 'vacancies', 'opportunities', 'jobs', 'job-search', 'job-listings', 'job-index', 'directory', 'feed', 'rss', 'atom', 'open-roles', 'available-jobs']);
241
+ const collectionQualifiers = new Set(['search', 'list', 'index', 'directory', 'feed', 'openings', 'engineering', 'product', 'design', 'sales', 'marketing', 'operations', 'finance', 'legal', 'people', 'remote']);
242
+ return segments.some((segment, index) => collectionCues.has(segment)
243
+ && (index === segments.length - 1 || (index === segments.length - 2 && collectionQualifiers.has(segments[index + 1]))));
244
+ }
245
+
246
+ export function normalizeCommunitySource(input) {
247
+ const value = record(input, 'community source');
248
+ const allowed = new Set(['name', 'baseUrl', 'kind', 'regions', 'roleFamilies', 'requiresSession']);
249
+ for (const key of Object.keys(value)) if (!allowed.has(key)) throw new Error(`Unknown community source property: ${key}.`);
250
+ const name = boundedString(value.name, 'community source.name', 120);
251
+ if (containsIdentityLike(name) || /https?:\/\//i.test(name)) throw new Error('community source.name must not contain identity-like content.');
252
+ const url = new URL(boundedString(value.baseUrl, 'community source.baseUrl', 1000));
253
+ if (url.protocol !== 'https:' || url.username || url.password) throw new Error('community source.baseUrl must be a public HTTPS URL.');
254
+ url.hostname = url.hostname.replace(/\.+$/, '');
255
+ if (!isPublicHostname(url.hostname)) throw new Error('community source.baseUrl must use a public internet hostname.');
256
+ if (containsIdentityLike(url.hostname)) throw new Error('community source.baseUrl must not contain identity-like content.');
257
+ const decodedPath = decodedPathname(url.pathname).normalize('NFKC');
258
+ if (containsIdentityLike(decodedPath)) throw new Error('community source.baseUrl must not contain identity-like content.');
259
+ if (looksIdentityPath(decodedPath)) throw new Error('community source.baseUrl must not contain an identity-like path.');
260
+ url.pathname = decodedPath;
261
+ if (looksPersonal(url)) throw new Error('community source.baseUrl must not be a profile or personal URL.');
262
+ if (!isRepeatableCommunitySourceRoute(url)) throw new Error('community source.baseUrl must identify a repeatable discovery surface, not a one-off job.');
263
+ if (looksCredentialLikePath(decodedPath)) throw new Error('community source.baseUrl must not contain credential-like path segments.');
264
+ url.search = '';
265
+ url.hash = '';
266
+ const kind = boundedString(value.kind, 'community source.kind', 40).toLowerCase();
267
+ if (!SOURCE_KINDS.has(kind)) throw new Error('community source.kind is invalid.');
268
+ if (typeof value.requiresSession !== 'boolean') throw new Error('community source.requiresSession must be a Boolean.');
269
+ return {
270
+ name,
271
+ baseUrl: url.toString().replace(/\/+$/, ''),
272
+ kind,
273
+ regions: terms(value.regions, 'community source.regions'),
274
+ roleFamilies: terms(value.roleFamilies, 'community source.roleFamilies'),
275
+ requiresSession: value.requiresSession,
276
+ };
277
+ }
278
+
279
+ export function createSourceContributionEnvelope({ installationId, token, source, skillVersion }) {
280
+ return validateSourceContributionEnvelope({ schemaVersion: 1, skillVersion, installationId, token, source });
281
+ }
282
+
283
+ export function validateSourceContributionEnvelope(input) {
284
+ const value = record(input, 'source contribution');
285
+ const allowed = new Set(['schemaVersion', 'skillVersion', 'installationId', 'token', 'source']);
286
+ for (const key of Object.keys(value)) if (!allowed.has(key)) throw new Error(`Unknown source contribution property: ${key}.`);
287
+ if (value.schemaVersion !== 1) throw new Error('Unsupported source contribution schema version.');
288
+ const installationId = boundedString(value.installationId, 'source contribution.installationId', 36);
289
+ if (!UUID.test(installationId)) throw new Error('source contribution.installationId is invalid.');
290
+ return {
291
+ schemaVersion: 1,
292
+ skillVersion: boundedString(value.skillVersion, 'source contribution.skillVersion', 40),
293
+ installationId,
294
+ token: boundedString(value.token, 'source contribution.token', 2048),
295
+ source: normalizeCommunitySource(value.source),
296
+ };
297
+ }
298
+
299
+ export async function communitySourceId(source) {
300
+ const normalized = normalizeCommunitySource(source);
301
+ const bytes = new TextEncoder().encode(normalized.baseUrl);
302
+ const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes));
303
+ return `community-${[...digest].map((value) => value.toString(16).padStart(2, '0')).join('').slice(0, 16)}`;
304
+ }
305
+
306
+ export function validateCommunitySourceList(input) {
307
+ const value = record(input, 'community source list');
308
+ for (const key of Object.keys(value)) if (!['version', 'sources'].includes(key)) throw new Error(`Unknown community source list property: ${key}.`);
309
+ if (value.version !== 1 || !Array.isArray(value.sources) || value.sources.length > 500) throw new Error('Invalid community source list.');
310
+ return value.sources.map((entry) => {
311
+ const source = record(entry, 'community source entry');
312
+ const allowed = new Set(['sourceId', 'name', 'baseUrl', 'kind', 'regions', 'roleFamilies', 'requiresSession', 'registryStatus', 'contributionCount']);
313
+ for (const key of Object.keys(source)) if (!allowed.has(key)) throw new Error(`Unknown community source entry property: ${key}.`);
314
+ if (!SOURCE_ID.test(source.sourceId)) throw new Error('community source entry.sourceId is invalid.');
315
+ if (source.registryStatus !== 'community-reviewed') throw new Error('community source entry.registryStatus is invalid.');
316
+ if (!Number.isSafeInteger(source.contributionCount) || source.contributionCount < 1 || source.contributionCount > 1_000_000_000) throw new Error('community source entry.contributionCount is invalid.');
317
+ const normalized = normalizeCommunitySource(Object.fromEntries(['name', 'baseUrl', 'kind', 'regions', 'roleFamilies', 'requiresSession'].map((key) => [key, source[key]])));
318
+ return { sourceId: source.sourceId, ...normalized, registryStatus: source.registryStatus, contributionCount: source.contributionCount };
319
+ });
320
+ }