job-application-agent 3.1.2 → 3.2.1

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.
@@ -1,6 +1,9 @@
1
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
2
  const SOURCE_ID = /^community-[0-9a-f]{16}$/;
3
+ const JOB_ID = /^community-job-[0-9a-f]{16}$/;
3
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']);
4
7
 
5
8
  function record(value, label) {
6
9
  if (!value || Array.isArray(value) || typeof value !== 'object') throw new Error(`${label} must be an object.`);
@@ -18,6 +21,10 @@ function containsIdentityLike(value) {
18
21
  || /\+?\p{Nd}[\p{Nd}\s().-]{7,}/u.test(normalized);
19
22
  }
20
23
 
24
+ function containsEmailLike(value) {
25
+ return /[^\s/@]+@(?:[^\s./@]+\.)+[^\s./@]+/u.test(value.normalize('NFKC'));
26
+ }
27
+
21
28
  function terms(value, label) {
22
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.`);
23
30
  const normalized = value.map((item, index) => boundedString(item, `${label}[${index}]`, 40).toLowerCase());
@@ -33,7 +40,7 @@ function looksPersonal(url) {
33
40
 
34
41
  function looksIdentityPath(pathname) {
35
42
  const segments = pathname.split('/').filter(Boolean).map((segment) => segment.toLowerCase());
36
- const namespaces = new Set(['user', 'users', 'profile', 'profiles', 'member', 'members', 'author', 'authors', 'person', 'people']);
43
+ const namespaces = new Set(['user', 'users', 'profile', 'profiles', 'member', 'members', 'author', 'authors', 'person', 'people', 'candidate', 'candidates', 'referral', 'referrals', 'referrer', 'referrers']);
37
44
  return segments.some((segment, index) => namespaces.has(segment) && index < segments.length - 1);
38
45
  }
39
46
 
@@ -67,10 +74,149 @@ function isPublicHostname(hostname) {
67
74
  return value.includes('.');
68
75
  }
69
76
 
77
+ function isReservedExampleHostname(hostname) {
78
+ const value = hostname.toLowerCase();
79
+ return ['example.com', 'example.net', 'example.org'].some((suffix) => value === suffix || value.endsWith(`.${suffix}`));
80
+ }
81
+
70
82
  function hostnameMatches(hostname, suffix) {
71
83
  return hostname === suffix || hostname.endsWith(`.${suffix}`);
72
84
  }
73
85
 
86
+ function publicJobLabel(value, label, max) {
87
+ const result = boundedString(value, label, max);
88
+ if (containsIdentityLike(result) || /https?:\/\//i.test(result) || /[\x00-\x1f\x7f]/.test(result)) throw new Error(`${label} must not contain identity-like content.`);
89
+ return result;
90
+ }
91
+
92
+ function explicitCredentialPath(pathname) {
93
+ return pathname.split('/').filter(Boolean).some((segment) => {
94
+ const normalized = segment.normalize('NFKC');
95
+ return /^[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}$/.test(normalized)
96
+ || /^(?:access[-_]?token|api[-_]?key|auth(?:orization)?|bearer|client[-_]?secret|password|secret|token)(?:[=:.]|\s+)(?:bearer\s+)?[A-Za-z0-9._~+/=-]{8,}$/i.test(normalized)
97
+ || /^(?:cfat_|github_pat_|gh[pousr]_|[spr]k_(?:live|test)_|xox[baprs]-)[A-Za-z0-9_-]{8,}$/i.test(normalized);
98
+ });
99
+ }
100
+
101
+ function containsPhoneLikeLocation(hostname, pathname) {
102
+ if (containsIdentityLike(hostname)) return true;
103
+ return pathname.split('/').filter(Boolean).some((segment) => {
104
+ const normalized = segment.normalize('NFKC');
105
+ 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;
106
+ const digits = normalized.match(/\p{Nd}/gu)?.length ?? 0;
107
+ const separators = normalized.match(/[\s().-]/gu)?.length ?? 0;
108
+ return digits >= 8 && (/\+\p{Nd}/u.test(normalized) || separators >= 2);
109
+ });
110
+ }
111
+
112
+ const STABLE_JOB_QUERY_KEYS = new Set([
113
+ 'gh_jid', 'jk', 'job', 'job_id', 'jobid', 'req', 'req_id', 'reqid',
114
+ 'requisition', 'requisition_id', 'requisitionid',
115
+ ]);
116
+
117
+ function stableJobQuery(searchParams) {
118
+ const identifiers = new Map();
119
+ for (const [rawKey, rawValue] of searchParams) {
120
+ const key = rawKey.toLowerCase();
121
+ if (!STABLE_JOB_QUERY_KEYS.has(key)) continue;
122
+ const value = rawValue.normalize('NFKC').trim();
123
+ if (!/^[A-Za-z0-9._~-]{1,128}$/.test(value)) continue;
124
+ if (identifiers.has(key) && identifiers.get(key) !== value) throw new Error(`community job.url contains conflicting ${key} identifiers.`);
125
+ identifiers.set(key, value);
126
+ }
127
+ return [...identifiers].sort(([left], [right]) => left.localeCompare(right));
128
+ }
129
+
130
+ function providerUrl(url) {
131
+ const hostname = url.hostname.toLowerCase();
132
+ const segments = url.pathname.split('/').filter(Boolean);
133
+ if (hostnameMatches(hostname, 'greenhouse.io') && segments[0]) return `${url.origin}/${segments[0]}`;
134
+ if (['jobs.lever.co', 'jobs.ashbyhq.com', 'apply.workable.com', 'jobs.smartrecruiters.com'].includes(hostname) && segments[0]) return `${url.origin}/${segments[0]}`;
135
+ if (hostnameMatches(hostname, 'linkedin.com')) return `${url.origin}/jobs`;
136
+ return url.origin;
137
+ }
138
+
139
+ export function normalizeCommunityJob(input) {
140
+ const value = record(input, 'community job');
141
+ const allowed = new Set(['url', 'company', 'role', 'applicationChannel', 'discoverySource', 'providerUrl']);
142
+ for (const key of Object.keys(value)) if (!allowed.has(key)) throw new Error(`Unknown community job property: ${key}.`);
143
+ const url = new URL(boundedString(value.url, 'community job.url', 2048));
144
+ if (url.protocol !== 'https:' || url.username || url.password) throw new Error('community job.url must be a public HTTPS URL.');
145
+ url.hostname = url.hostname.replace(/\.+$/, '').toLowerCase();
146
+ if (!isPublicHostname(url.hostname)) throw new Error('community job.url must use a public HTTPS hostname.');
147
+ if (isReservedExampleHostname(url.hostname)) throw new Error('community job.url must not use a reserved example hostname.');
148
+ const pathname = decodedPathname(url.pathname).normalize('NFKC');
149
+ url.pathname = pathname;
150
+ if (containsEmailLike(pathname) || containsPhoneLikeLocation(url.hostname, pathname) || looksIdentityPath(pathname) || looksPersonal(url)) throw new Error('community job.url must not be a personal URL.');
151
+ if (explicitCredentialPath(pathname)) throw new Error('community job.url must not contain credential-like path segments.');
152
+ const identifiers = stableJobQuery(url.searchParams);
153
+ url.pathname = pathname.replace(/\/+$/, '') || '/';
154
+ url.search = '';
155
+ for (const [key, identifier] of identifiers) url.searchParams.append(key, identifier);
156
+ url.hash = '';
157
+ const applicationChannel = boundedString(value.applicationChannel, 'community job.applicationChannel', 40).toLowerCase();
158
+ if (!COMMUNITY_JOB_CHANNELS.has(applicationChannel)) throw new Error('community job.applicationChannel is invalid.');
159
+ const discoverySource = value.discoverySource == null ? null : boundedString(value.discoverySource, 'community job.discoverySource', 40).toLowerCase();
160
+ if (discoverySource != null && !COMMUNITY_JOB_DISCOVERY_SOURCES.has(discoverySource)) throw new Error('community job.discoverySource is invalid.');
161
+ const derivedProviderUrl = providerUrl(url);
162
+ if (value.providerUrl != null && value.providerUrl !== derivedProviderUrl) throw new Error('community job.providerUrl must match the derived provider URL.');
163
+ return {
164
+ url: url.toString().replace(/\/+$/, ''),
165
+ company: publicJobLabel(value.company, 'community job.company', 160),
166
+ role: publicJobLabel(value.role, 'community job.role', 200),
167
+ applicationChannel,
168
+ ...(discoverySource == null ? {} : { discoverySource }),
169
+ providerUrl: derivedProviderUrl,
170
+ };
171
+ }
172
+
173
+ export async function communityJobId(job) {
174
+ const normalized = normalizeCommunityJob(job);
175
+ const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(normalized.url)));
176
+ return `community-job-${[...digest].map((value) => value.toString(16).padStart(2, '0')).join('').slice(0, 16)}`;
177
+ }
178
+
179
+ export function createCommunityJobContributionEnvelope({ installationId, token, job, skillVersion }) {
180
+ return validateCommunityJobContributionEnvelope({ schemaVersion: 1, skillVersion, installationId, token, job });
181
+ }
182
+
183
+ export function validateCommunityJobContributionEnvelope(input) {
184
+ const value = record(input, 'community job contribution');
185
+ const allowed = new Set(['schemaVersion', 'skillVersion', 'installationId', 'token', 'job']);
186
+ for (const key of Object.keys(value)) if (!allowed.has(key)) throw new Error(`Unknown community job contribution property: ${key}.`);
187
+ if (value.schemaVersion !== 1) throw new Error('Unsupported community job contribution schema version.');
188
+ const installationId = boundedString(value.installationId, 'community job contribution.installationId', 36);
189
+ if (!UUID.test(installationId)) throw new Error('community job contribution.installationId is invalid.');
190
+ return {
191
+ schemaVersion: 1,
192
+ skillVersion: boundedString(value.skillVersion, 'community job contribution.skillVersion', 40),
193
+ installationId,
194
+ token: boundedString(value.token, 'community job contribution.token', 2048),
195
+ job: normalizeCommunityJob(value.job),
196
+ };
197
+ }
198
+
199
+ export function validateCommunityJobList(input) {
200
+ const value = record(input, 'community job list');
201
+ const allowed = new Set(['version', 'jobs', 'nextCursor']);
202
+ for (const key of Object.keys(value)) if (!allowed.has(key)) throw new Error(`Unknown community job list property: ${key}.`);
203
+ if (value.version !== 1 || !Array.isArray(value.jobs) || value.jobs.length > 100) throw new Error('Invalid community job list.');
204
+ if (value.nextCursor != null && (typeof value.nextCursor !== 'string' || !value.nextCursor || value.nextCursor.length > 1024)) throw new Error('community job list.nextCursor is invalid.');
205
+ const jobs = value.jobs.map((entry) => {
206
+ const job = record(entry, 'community job entry');
207
+ const entryAllowed = new Set(['jobId', 'url', 'company', 'role', 'applicationChannel', 'discoverySource', 'providerUrl', 'firstSeenAt', 'lastSeenAt', 'contributionCount']);
208
+ for (const key of Object.keys(job)) if (!entryAllowed.has(key)) throw new Error(`Unknown community job entry property: ${key}.`);
209
+ if (!JOB_ID.test(job.jobId)) throw new Error('community job entry.jobId is invalid.');
210
+ if (!Number.isSafeInteger(job.contributionCount) || job.contributionCount < 1 || job.contributionCount > 1_000_000_000) throw new Error('community job entry.contributionCount is invalid.');
211
+ for (const field of ['firstSeenAt', 'lastSeenAt']) {
212
+ if (typeof job[field] !== 'string' || Number.isNaN(Date.parse(job[field]))) throw new Error(`community job entry.${field} must be an ISO date.`);
213
+ }
214
+ const normalized = normalizeCommunityJob(Object.fromEntries(['url', 'company', 'role', 'applicationChannel', 'discoverySource', 'providerUrl'].filter((key) => job[key] != null).map((key) => [key, job[key]])));
215
+ return { jobId: job.jobId, ...normalized, firstSeenAt: job.firstSeenAt, lastSeenAt: job.lastSeenAt, contributionCount: job.contributionCount };
216
+ });
217
+ return { version: 1, jobs, nextCursor: value.nextCursor ?? null };
218
+ }
219
+
74
220
  export function isRepeatableCommunitySourceRoute(url) {
75
221
  const hostname = url.hostname.toLowerCase();
76
222
  const segments = url.pathname.split('/').filter(Boolean).map((segment) => segment.toLowerCase());
@@ -2,8 +2,10 @@ import { chmod, mkdir, readFile, readdir, rename, writeFile } from 'node:fs/prom
2
2
  import { join } from 'node:path';
3
3
 
4
4
  import { createTelemetryEnvelope, jobIdentity, validateEvent } from './telemetry-schema.mjs';
5
+ import { SKILL_VERSION } from './version.mjs';
6
+
7
+ export { SKILL_VERSION };
5
8
 
6
- export const SKILL_VERSION = '1.2.1';
7
9
  export const DEFAULT_TELEMETRY_ENDPOINT = process.env.JOB_APPLICATION_AGENT_TELEMETRY_URL ?? 'https://job-application-agent-telemetry.varora1406.workers.dev';
8
10
  export const TELEMETRY_NOTICE = 'Anonymous usage analytics are enabled by default. They include structured job and workflow metrics, but never your identity, resume, prompts, form answers, browser data, or candidate profile. Run `telemetry disable` to opt out or `telemetry preview` to inspect an event.\n';
9
11
 
@@ -0,0 +1 @@
1
+ export const SKILL_VERSION = '3.2.1';
@@ -48,6 +48,14 @@ const matchingJob = {
48
48
  ],
49
49
  };
50
50
 
51
+ function isolatedCliEnv(directory) {
52
+ return {
53
+ ...process.env,
54
+ JOB_APPLICATION_AGENT_STATE_DIR: directory,
55
+ JOB_APPLICATION_AGENT_SOURCE_COMMUNITY_URL: 'http://127.0.0.1:9',
56
+ };
57
+ }
58
+
51
59
  function runCli(script, args, input, env) {
52
60
  return new Promise((resolve) => {
53
61
  const child = spawn(process.execPath, [script, ...args], { env, stdio: ['pipe', 'pipe', 'pipe'] });
@@ -73,7 +81,7 @@ test('returns the canonical resume path for direct browser uploads', async (t) =
73
81
  const resume = join(directory, 'resume.pdf');
74
82
  await writeFile(join(directory, 'telemetry.json'), JSON.stringify({ version: 1, enabled: false, disclosed: true, graceConsumed: true, installationEventPending: false }));
75
83
  await writeFile(resume, '%PDF-1.7\ncanonical resume fixture');
76
- const env = { ...process.env, JOB_APPLICATION_AGENT_STATE_DIR: directory };
84
+ const env = isolatedCliEnv(directory);
77
85
 
78
86
  const result = JSON.parse(execFileSync(process.execPath, [script, 'resume', 'path'], { env, encoding: 'utf8' }));
79
87
 
@@ -108,6 +116,7 @@ test('scores a matching role from candidate preferences', () => {
108
116
  assert.equal(result.autoEligible, true);
109
117
  assert.equal(result.mustHaveCoverage, 83);
110
118
  assert.ok(result.score >= 80);
119
+ assert.equal(scoreJob({ ...matchingJob, discoverySourceId: 'community-abcdef1234567890' }, target).decision, 'review');
111
120
  });
112
121
 
113
122
  test('applies posting, eligibility, work-mode, seniority and evidence gates before auto-submit', () => {
@@ -245,7 +254,7 @@ test('deduplicates ledger entries by normalized URL', async (t) => {
245
254
  telemetry: { durationBucket: '5-15m', fieldsFilled: 14, shortAnswerCount: 2, resumeUploaded: true },
246
255
  };
247
256
  await writeFile(join(directory, 'telemetry.json'), JSON.stringify({ version: 1, enabled: false, disclosed: true, graceConsumed: true, installationEventPending: false }));
248
- const env = { ...process.env, JOB_APPLICATION_AGENT_STATE_DIR: directory };
257
+ const env = isolatedCliEnv(directory);
249
258
  execFileSync(process.execPath, [script, 'ledger', 'add', '--stdin'], { input: JSON.stringify(entry), env, encoding: 'utf8' });
250
259
  const duplicate = JSON.parse(execFileSync(process.execPath, [script, 'ledger', 'check', '--stdin'], { input: JSON.stringify({ id: 'different', url: 'https://jobs.example.com/123?ref=friend' }), env, encoding: 'utf8' }));
251
260
  assert.equal(duplicate.duplicate, true);
@@ -262,7 +271,7 @@ test('warns on same-company role matches and serializes concurrent duplicate sub
262
271
  t.after(() => rm(directory, { recursive: true, force: true }));
263
272
  const script = fileURLToPath(new URL('../scripts/job-application.mjs', import.meta.url));
264
273
  await writeFile(join(directory, 'telemetry.json'), JSON.stringify({ version: 1, enabled: false, disclosed: true, graceConsumed: true, installationEventPending: false }));
265
- const env = { ...process.env, JOB_APPLICATION_AGENT_STATE_DIR: directory };
274
+ const env = isolatedCliEnv(directory);
266
275
  const base = {
267
276
  company: 'Example', role: 'Senior Product Engineer', url: 'https://jobs.example.com/123', source: 'company', score: 88,
268
277
  status: 'submitted', submittedAt: '2026-01-15T10:00:00Z', approval: 'STANDING AUTHORIZATION', answers: {}, employerJobId: 'example:123',
@@ -290,7 +299,7 @@ test('records structured outcomes idempotently without duplicate rows', async (t
290
299
  id: 'example-role-1', company: 'Example', role: 'Senior Product Engineer', url: 'https://jobs.example.com/123', source: 'company', score: 88,
291
300
  status: 'submitted', submittedAt: '2026-01-15T10:00:00Z', approval: 'STANDING AUTHORIZATION', answers: {},
292
301
  })}\n`);
293
- const env = { ...process.env, JOB_APPLICATION_AGENT_STATE_DIR: directory };
302
+ const env = isolatedCliEnv(directory);
294
303
  const outcome = { id: 'example-role-1', status: 'rejected', occurredAt: '2026-01-20T09:00:00Z', note: 'No sponsorship' };
295
304
  const enriched = { ...outcome, reasons: [{ category: 'eligibility', evidence: 'explicit' }] };
296
305
  const first = JSON.parse(execFileSync(process.execPath, [script, 'ledger', 'outcome', '--stdin'], { input: JSON.stringify(outcome), env, encoding: 'utf8' }));
@@ -313,7 +322,7 @@ test('records bounded interview quality and failure-point enrichment idempotentl
313
322
  id: 'example-role-1', company: 'Example', role: 'Staff Product Engineer', url: 'https://jobs.example.com/123', source: 'company', score: 91,
314
323
  status: 'submitted', submittedAt: '2026-01-15T10:00:00Z', approval: 'STANDING AUTHORIZATION', answers: {},
315
324
  })}\n`);
316
- const env = { ...process.env, JOB_APPLICATION_AGENT_STATE_DIR: directory };
325
+ const env = isolatedCliEnv(directory);
317
326
  const base = { id: 'example-role-1', status: 'interview', occurredAt: '2026-01-20T09:00:00Z' };
318
327
  const enriched = { ...base, interviewQuality: 'weak', failurePoint: 'role-scope' };
319
328
  const first = JSON.parse(execFileSync(process.execPath, [script, 'ledger', 'outcome', '--stdin'], { input: JSON.stringify(base), env, encoding: 'utf8' }));
@@ -399,7 +408,7 @@ test('acknowledges a generated review only through the explicit CLI command', as
399
408
  source: 'company', score: 80, status: 'submitted', submittedAt: '2026-01-01T10:00:00Z', approval: 'STANDING AUTHORIZATION', answers: {},
400
409
  }));
401
410
  await writeFile(join(directory, 'applications.ndjson'), `${entries.map(JSON.stringify).join('\n')}\n`);
402
- const env = { ...process.env, JOB_APPLICATION_AGENT_STATE_DIR: directory };
411
+ const env = isolatedCliEnv(directory);
403
412
  const before = JSON.parse(execFileSync(process.execPath, [script, 'ledger', 'review'], { env, encoding: 'utf8' }));
404
413
  assert.equal(before.reviewDue, true);
405
414
  assert.equal(await readFile(join(directory, 'reviews.ndjson'), 'utf8').catch(() => ''), '');
@@ -3,7 +3,7 @@ import test from 'node:test';
3
3
 
4
4
  import { prepareTelemetryInput } from '../scripts/telemetry-client.mjs';
5
5
  import { validateEvent } from '../scripts/telemetry-schema.mjs';
6
- import { normalizeCommunitySource } from '../scripts/source-community-schema.mjs';
6
+ import { createCommunityJobContributionEnvelope, normalizeCommunityJob, normalizeCommunitySource } from '../scripts/source-community-schema.mjs';
7
7
 
8
8
  const forbiddenProperties = [
9
9
  'name', 'email', 'phone', 'exactAddress', 'linkedin', 'github', 'portfolio', 'candidateLocation',
@@ -56,3 +56,26 @@ test('privacy audit strips identity-bearing source parameters and rejects person
56
56
  assert.throws(() => normalizeCommunitySource({ ...source, name: 'candidate@example.com' }), /identity/i);
57
57
  assert.throws(() => normalizeCommunitySource({ ...source, baseUrl: 'https://linkedin.com/in/candidate' }), /profile or personal/i);
58
58
  });
59
+
60
+ test('privacy audit permits only sanitized public fields in community jobs', () => {
61
+ const job = normalizeCommunityJob({
62
+ url: 'https://jobs.ashbyhq.com/example/12345678-1234-4123-8123-123456789abc?email=candidate@example.com&token=secret#private',
63
+ company: 'Example',
64
+ role: 'Senior Product Engineer',
65
+ applicationChannel: 'ashby',
66
+ discoverySource: 'job-board',
67
+ });
68
+ const envelope = createCommunityJobContributionEnvelope({
69
+ installationId: '11111111-1111-4111-8111-111111111111',
70
+ token: 'anonymous-relay-token',
71
+ skillVersion: '3.2.0',
72
+ job,
73
+ });
74
+ const serialized = JSON.stringify(envelope.job);
75
+ assert.equal(serialized.includes('candidate@example.com'), false);
76
+ assert.equal(serialized.includes('secret'), false);
77
+ assert.deepEqual(Object.keys(envelope.job).sort(), ['applicationChannel', 'company', 'discoverySource', 'providerUrl', 'role', 'url']);
78
+ assert.throws(() => normalizeCommunityJob({ ...job, answers: { private: true } }), /unknown/i);
79
+ assert.throws(() => normalizeCommunityJob({ ...job, company: 'candidate@example.com' }), /identity/i);
80
+ assert.throws(() => normalizeCommunityJob({ ...job, url: 'https://linkedin.com/in/candidate' }), /personal/i);
81
+ });
@@ -15,6 +15,14 @@ const source = {
15
15
  requiresSession: false,
16
16
  };
17
17
 
18
+ const job = {
19
+ url: 'https://jobs.ashbyhq.com/example/12345678-1234-4123-8123-123456789abc?ref=candidate@example.com#apply',
20
+ company: 'Example',
21
+ role: 'Senior Product Engineer',
22
+ applicationChannel: 'ashby',
23
+ discoverySource: 'job-board',
24
+ };
25
+
18
26
  function relay() {
19
27
  const requests = [];
20
28
  const community = [{
@@ -28,10 +36,24 @@ function relay() {
28
36
  registryStatus: 'community-reviewed',
29
37
  contributionCount: 2,
30
38
  }];
39
+ const jobs = [{
40
+ jobId: 'community-job-abcdef1234567890',
41
+ url: 'https://jobs.ashbyhq.com/example/12345678-1234-4123-8123-123456789abc',
42
+ company: 'Example',
43
+ role: 'Senior Product Engineer',
44
+ applicationChannel: 'ashby',
45
+ discoverySource: 'job-board',
46
+ providerUrl: 'https://jobs.ashbyhq.com/example',
47
+ firstSeenAt: '2026-08-24T00:00:00.000Z',
48
+ lastSeenAt: '2026-08-24T00:00:00.000Z',
49
+ contributionCount: 2,
50
+ }];
31
51
  const fetch = async (url, options = {}) => {
32
52
  requests.push({ url, options, body: options.body ? JSON.parse(options.body) : null });
33
53
  if (url.endsWith('/v1/install')) return Response.json({ installationId: '11111111-1111-4111-8111-111111111111', token: 'source-token', expiresAt: '2099-01-01T00:00:00.000Z' }, { status: 201 });
34
54
  if (url.endsWith('/v1/sources') && options.method === 'POST') return Response.json({ accepted: true, sourceId: 'community-abcdef1234567890', publicationStatus: 'pending', uniqueContributors: 1 }, { status: 202 });
55
+ if (url.endsWith('/v1/jobs') && options.method === 'POST') return Response.json({ accepted: true, jobId: 'community-job-abcdef1234567890', publicationStatus: 'pending', contributionCount: 2 }, { status: 202 });
56
+ if (url.includes('/v1/jobs')) return Response.json({ version: 1, jobs, nextCursor: 'next-page' });
35
57
  return Response.json({ version: 1, sources: community });
36
58
  };
37
59
  return { fetch, requests };
@@ -47,7 +69,7 @@ test('source sharing is enabled by default, disclosed, sanitized, and sent immed
47
69
  const result = await client.contribute(source);
48
70
 
49
71
  assert.deepEqual(result, { shared: true, sourceId: 'community-abcdef1234567890', publicationStatus: 'pending', uniqueContributors: 1 });
50
- assert.match(notice, /community source sharing is enabled by default/i);
72
+ assert.match(notice, /community sharing is enabled by default/i);
51
73
  assert.equal(network.requests.length, 2);
52
74
  assert.equal(network.requests[1].url, 'https://relay.example.com/v1/sources');
53
75
  assert.equal(network.requests[1].body.source.baseUrl, 'https://jobs.example.org/openings/engineering');
@@ -55,9 +77,89 @@ test('source sharing is enabled by default, disclosed, sanitized, and sent immed
55
77
  const stored = JSON.parse(await readFile(join(directory, 'source-sharing.json'), 'utf8'));
56
78
  assert.equal(stored.enabled, true);
57
79
  assert.equal(stored.disclosed, true);
80
+ assert.equal(stored.jobSharingDisclosed, true);
58
81
  if (process.platform !== 'win32') assert.equal((await stat(join(directory, 'source-sharing.json'))).mode & 0o777, 0o600);
59
82
  });
60
83
 
84
+ test('existing source-sharing users receive one command to opt out before job backfill', async (t) => {
85
+ const directory = await mkdtemp(join(tmpdir(), 'source-community-upgrade-disclosure-'));
86
+ t.after(() => rm(directory, { recursive: true, force: true }));
87
+ await writeFile(join(directory, 'source-sharing.json'), JSON.stringify({
88
+ version: 1,
89
+ enabled: true,
90
+ disclosed: true,
91
+ installationId: '11111111-1111-4111-8111-111111111111',
92
+ token: 'source-token',
93
+ tokenExpiresAt: '2099-01-01T00:00:00.000Z',
94
+ }));
95
+ const network = relay();
96
+ let notice = '';
97
+ const client = new SourceCommunityClient({ stateDir: directory, endpoint: 'https://relay.example.com', fetch: network.fetch, stderr: (value) => { notice += value; } });
98
+
99
+ assert.deepEqual(await client.contributeJob(job), { shared: false, reason: 'grace' });
100
+ assert.match(notice, /confirmed public job links/i);
101
+ assert.equal(JSON.parse(await readFile(join(directory, 'source-sharing.json'), 'utf8')).jobSharingDisclosed, true);
102
+ assert.equal(network.requests.length, 0);
103
+ notice = '';
104
+ assert.equal((await client.contributeJob(job)).shared, true);
105
+ assert.equal(notice, '');
106
+ });
107
+
108
+ test('an interrupted job-sharing disclosure still preserves the grace command', async (t) => {
109
+ const directory = await mkdtemp(join(tmpdir(), 'source-community-job-grace-recovery-'));
110
+ t.after(() => rm(directory, { recursive: true, force: true }));
111
+ await writeFile(join(directory, 'source-sharing.json'), JSON.stringify({
112
+ version: 1,
113
+ enabled: true,
114
+ disclosed: true,
115
+ jobSharingDisclosed: true,
116
+ jobSharingGraceConsumed: false,
117
+ installationId: null,
118
+ token: null,
119
+ tokenExpiresAt: null,
120
+ }));
121
+ const network = relay();
122
+ const client = new SourceCommunityClient({ stateDir: directory, endpoint: 'https://relay.example.com', fetch: network.fetch, stderr: () => {} });
123
+
124
+ assert.deepEqual(await client.contributeJob(job), { shared: false, reason: 'grace' });
125
+ assert.equal(network.requests.length, 0);
126
+ assert.equal((await client.contributeJob(job)).shared, true);
127
+ });
128
+
129
+ test('historical applications without a source config receive the disclosure grace command', async (t) => {
130
+ const directory = await mkdtemp(join(tmpdir(), 'source-community-historical-no-config-'));
131
+ t.after(() => rm(directory, { recursive: true, force: true }));
132
+ await writeFile(join(directory, 'applications.ndjson'), '{"historical":true}\n');
133
+ const network = relay();
134
+ const client = new SourceCommunityClient({ stateDir: directory, endpoint: 'https://relay.example.com', fetch: network.fetch, stderr: () => {} });
135
+
136
+ assert.deepEqual(await client.contributeJob(job), { shared: false, reason: 'grace' });
137
+ assert.equal(network.requests.length, 0);
138
+ });
139
+
140
+ test('confirmed jobs are sanitized, shared anonymously, and public listings are validated', async (t) => {
141
+ const directory = await mkdtemp(join(tmpdir(), 'source-community-job-'));
142
+ t.after(() => rm(directory, { recursive: true, force: true }));
143
+ const network = relay();
144
+ const client = new SourceCommunityClient({ stateDir: directory, endpoint: 'https://relay.example.com', fetch: network.fetch, stderr: () => {} });
145
+
146
+ const contributed = await client.contributeJob(job);
147
+ assert.deepEqual(contributed, { shared: true, jobId: 'community-job-abcdef1234567890', publicationStatus: 'pending', contributionCount: 2 });
148
+ const posted = network.requests.find((request) => request.url.endsWith('/v1/jobs') && request.options.method === 'POST');
149
+ assert.equal(posted.body.job.url, 'https://jobs.ashbyhq.com/example/12345678-1234-4123-8123-123456789abc');
150
+ assert.equal(posted.body.job.providerUrl, 'https://jobs.ashbyhq.com/example');
151
+ assert.equal(JSON.stringify(posted.body).includes('candidate@example.com'), false);
152
+
153
+ const listed = await client.listJobs({ limit: 25, cursor: 'current-page' });
154
+ assert.equal(listed.jobs[0].jobId, 'community-job-abcdef1234567890');
155
+ assert.equal(listed.jobs[0].contributionCount, 2);
156
+ assert.equal(listed.nextCursor, 'next-page');
157
+ assert.equal(network.requests.at(-1).url, 'https://relay.example.com/v1/jobs?limit=25&cursor=current-page');
158
+ const requestsBeforeInvalidInput = network.requests.length;
159
+ await assert.rejects(() => client.listJobs({ limit: 0 }), /between 1 and 100/i);
160
+ assert.equal(network.requests.length, requestsBeforeInvalidInput);
161
+ });
162
+
61
163
  test('community source listing validates the public response before reuse', async (t) => {
62
164
  const directory = await mkdtemp(join(tmpdir(), 'source-community-list-'));
63
165
  t.after(() => rm(directory, { recursive: true, force: true }));
@@ -74,12 +176,15 @@ test('source sharing can be disabled independently and never blocks local collec
74
176
  const directory = await mkdtemp(join(tmpdir(), 'source-community-disabled-'));
75
177
  t.after(() => rm(directory, { recursive: true, force: true }));
76
178
  const network = relay();
77
- const client = new SourceCommunityClient({ stateDir: directory, endpoint: 'https://relay.example.com', fetch: network.fetch, stderr: () => {} });
179
+ let notice = '';
180
+ const client = new SourceCommunityClient({ stateDir: directory, endpoint: 'https://relay.example.com', fetch: network.fetch, stderr: (value) => { notice += value; } });
78
181
 
79
182
  assert.equal((await client.configure('disable')).enabled, false);
80
183
  assert.deepEqual(await client.contribute(source), { shared: false, reason: 'disabled' });
184
+ assert.deepEqual(await client.contributeJob(job), { shared: false, reason: 'disabled' });
81
185
  assert.equal(network.requests.length, 0);
82
186
  assert.equal((await client.configure('enable')).enabled, true);
187
+ assert.match(notice, /confirmed public job links/i);
83
188
  assert.equal((await client.contribute(source)).shared, true);
84
189
  });
85
190
 
@@ -186,6 +291,7 @@ test('concurrent reset is not undone by an in-flight credential refresh', async
186
291
  assert.deepEqual(await settings.configure('reset'), {
187
292
  enabled: false,
188
293
  disclosed: true,
294
+ jobSharingDisclosed: false,
189
295
  hasInstallationId: false,
190
296
  endpoint: 'https://relay.example.com',
191
297
  schemaVersion: 1,
@@ -1,7 +1,16 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
3
 
4
- import { communitySourceId, isRepeatableCommunitySourceRoute, normalizeCommunitySource } from '../scripts/source-community-schema.mjs';
4
+ import {
5
+ communityJobId,
6
+ communitySourceId,
7
+ createCommunityJobContributionEnvelope,
8
+ isRepeatableCommunitySourceRoute,
9
+ normalizeCommunityJob,
10
+ normalizeCommunitySource,
11
+ validateCommunityJobContributionEnvelope,
12
+ validateCommunityJobList,
13
+ } from '../scripts/source-community-schema.mjs';
5
14
 
6
15
  const source = {
7
16
  name: 'Example Jobs',
@@ -12,6 +21,102 @@ const source = {
12
21
  requiresSession: false,
13
22
  };
14
23
 
24
+ const job = {
25
+ url: 'https://jobs.ashbyhq.com/example/12345678-1234-4123-8123-123456789abc?utm_source=private#apply',
26
+ company: 'Example',
27
+ role: 'Senior Product Engineer',
28
+ applicationChannel: 'ashby',
29
+ discoverySource: 'job-board',
30
+ };
31
+
32
+ test('normalizes an applied job and derives its reusable provider without referral data', () => {
33
+ const normalized = normalizeCommunityJob(job);
34
+
35
+ assert.deepEqual(normalized, {
36
+ url: 'https://jobs.ashbyhq.com/example/12345678-1234-4123-8123-123456789abc',
37
+ company: 'Example',
38
+ role: 'Senior Product Engineer',
39
+ applicationChannel: 'ashby',
40
+ discoverySource: 'job-board',
41
+ providerUrl: 'https://jobs.ashbyhq.com/example',
42
+ });
43
+ });
44
+
45
+ test('accepts public job detail identifiers but rejects private, personal, and credential-bearing routes', () => {
46
+ const accepted = [
47
+ 'https://job-boards.greenhouse.io/example/jobs/1234567',
48
+ 'https://jobs.lever.co/example/12345678-1234-4123-8123-123456789abc',
49
+ 'https://jobs.ashbyhq.com/example/12345678-1234-4123-8123-123456789abc',
50
+ 'https://company.example/careers/senior-product-engineer',
51
+ ];
52
+ for (const url of accepted) assert.equal(normalizeCommunityJob({ ...job, url }).url, url);
53
+
54
+ const rejected = [
55
+ 'http://localhost/jobs/123',
56
+ 'https://jobs.example.com/123',
57
+ 'https://linkedin.com/in/some-person',
58
+ 'https://company.example/candidate/9876543210',
59
+ 'https://company.example/referral/12345678-1234-4123-8123-123456789abc',
60
+ 'https://candidate-14155550100.example.org/jobs/123',
61
+ 'https://company.example/jobs/+1-415-555-0100',
62
+ 'https://company.example/jobs/access-token=abcdefghijklmnop',
63
+ 'https://user:password@company.example/jobs/123',
64
+ ];
65
+ for (const url of rejected) assert.throws(() => normalizeCommunityJob({ ...job, url }), /public HTTPS|personal|credential|reserved example/i, url);
66
+ });
67
+
68
+ test('preserves only stable query job identifiers while removing referral data', async () => {
69
+ const first = normalizeCommunityJob({
70
+ ...job,
71
+ url: 'https://company.example/viewjob?jobId=111&utm_source=private&ref=candidate@example.com',
72
+ });
73
+ const second = normalizeCommunityJob({
74
+ ...job,
75
+ url: 'https://company.example/viewjob?jobId=222&utm_source=private',
76
+ });
77
+
78
+ assert.equal(first.url, 'https://company.example/viewjob?jobid=111');
79
+ assert.equal(second.url, 'https://company.example/viewjob?jobid=222');
80
+ assert.notEqual(await communityJobId(first), await communityJobId(second));
81
+ assert.equal(first.url.includes('candidate@example.com'), false);
82
+ assert.equal(normalizeCommunityJob({ ...job, url: 'https://boards.example/jobs?gh_jid=7654321&utm_campaign=secret' }).url, 'https://boards.example/jobs?gh_jid=7654321');
83
+ });
84
+
85
+ test('community job IDs deduplicate tracking variants and contribution envelopes validate strictly', async () => {
86
+ const first = await communityJobId(job);
87
+ const second = await communityJobId({ ...job, url: `${job.url.split('?')[0]}?ref=another#details` });
88
+ assert.equal(first, second);
89
+ assert.match(first, /^community-job-[0-9a-f]{16}$/);
90
+
91
+ const envelope = createCommunityJobContributionEnvelope({
92
+ installationId: '12345678-1234-4123-8123-123456789abc',
93
+ token: 'signed-token',
94
+ job,
95
+ skillVersion: '3.2.0',
96
+ });
97
+ assert.deepEqual(validateCommunityJobContributionEnvelope(envelope), envelope);
98
+ assert.throws(() => validateCommunityJobContributionEnvelope({ ...envelope, answers: { private: true } }), /Unknown community job contribution property/i);
99
+ });
100
+
101
+ test('validates paginated public community job responses without accepting extra data', async () => {
102
+ const jobId = await communityJobId(job);
103
+ const response = {
104
+ version: 1,
105
+ jobs: [{
106
+ jobId,
107
+ ...normalizeCommunityJob(job),
108
+ firstSeenAt: '2026-08-24T10:00:00.000Z',
109
+ lastSeenAt: '2026-08-24T11:00:00.000Z',
110
+ contributionCount: 2,
111
+ }],
112
+ nextCursor: 'opaque-cursor',
113
+ };
114
+
115
+ assert.deepEqual(validateCommunityJobList(response), response);
116
+ assert.throws(() => validateCommunityJobList({ ...response, installationId: 'private' }), /Unknown community job list property/i);
117
+ assert.throws(() => validateCommunityJobList({ ...response, jobs: [{ ...response.jobs[0], score: 90 }] }), /Unknown community job entry property/i);
118
+ });
119
+
15
120
  test('rejects known ATS and network job-detail routes', () => {
16
121
  const detailUrls = [
17
122
  'https://example.wd5.myworkdayjobs.com/en-US/jobs/job/Bengaluru/Senior-Engineer_R-12345',
@@ -0,0 +1,12 @@
1
+ import assert from 'node:assert/strict';
2
+ import { readFile } from 'node:fs/promises';
3
+ import test from 'node:test';
4
+
5
+ import { SKILL_VERSION as telemetryVersion } from '../scripts/telemetry-client.mjs';
6
+ import { SKILL_VERSION } from '../scripts/version.mjs';
7
+
8
+ test('the packaged skill and telemetry report the npm package version', async () => {
9
+ const manifest = JSON.parse(await readFile(new URL('../../package.json', import.meta.url), 'utf8'));
10
+ assert.equal(SKILL_VERSION, manifest.version);
11
+ assert.equal(telemetryVersion, manifest.version);
12
+ });