job-application-agent 3.1.2 → 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.
@@ -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
 
@@ -71,6 +78,139 @@ function hostnameMatches(hostname, suffix) {
71
78
  return hostname === suffix || hostname.endsWith(`.${suffix}`);
72
79
  }
73
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
+
74
214
  export function isRepeatableCommunitySourceRoute(url) {
75
215
  const hostname = url.hostname.toLowerCase();
76
216
  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.0';
@@ -108,6 +108,7 @@ test('scores a matching role from candidate preferences', () => {
108
108
  assert.equal(result.autoEligible, true);
109
109
  assert.equal(result.mustHaveCoverage, 83);
110
110
  assert.ok(result.score >= 80);
111
+ assert.equal(scoreJob({ ...matchingJob, discoverySourceId: 'community-abcdef1234567890' }, target).decision, 'review');
111
112
  });
112
113
 
113
114
  test('applies posting, eligibility, work-mode, seniority and evidence gates before auto-submit', () => {
@@ -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,101 @@ 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://linkedin.com/in/some-person',
57
+ 'https://company.example/candidate/9876543210',
58
+ 'https://company.example/referral/12345678-1234-4123-8123-123456789abc',
59
+ 'https://candidate-14155550100.example.org/jobs/123',
60
+ 'https://company.example/jobs/+1-415-555-0100',
61
+ 'https://company.example/jobs/access-token=abcdefghijklmnop',
62
+ 'https://user:password@company.example/jobs/123',
63
+ ];
64
+ for (const url of rejected) assert.throws(() => normalizeCommunityJob({ ...job, url }), /public HTTPS|personal|credential/i, url);
65
+ });
66
+
67
+ test('preserves only stable query job identifiers while removing referral data', async () => {
68
+ const first = normalizeCommunityJob({
69
+ ...job,
70
+ url: 'https://company.example/viewjob?jobId=111&utm_source=private&ref=candidate@example.com',
71
+ });
72
+ const second = normalizeCommunityJob({
73
+ ...job,
74
+ url: 'https://company.example/viewjob?jobId=222&utm_source=private',
75
+ });
76
+
77
+ assert.equal(first.url, 'https://company.example/viewjob?jobid=111');
78
+ assert.equal(second.url, 'https://company.example/viewjob?jobid=222');
79
+ assert.notEqual(await communityJobId(first), await communityJobId(second));
80
+ assert.equal(first.url.includes('candidate@example.com'), false);
81
+ assert.equal(normalizeCommunityJob({ ...job, url: 'https://boards.example/jobs?gh_jid=7654321&utm_campaign=secret' }).url, 'https://boards.example/jobs?gh_jid=7654321');
82
+ });
83
+
84
+ test('community job IDs deduplicate tracking variants and contribution envelopes validate strictly', async () => {
85
+ const first = await communityJobId(job);
86
+ const second = await communityJobId({ ...job, url: `${job.url.split('?')[0]}?ref=another#details` });
87
+ assert.equal(first, second);
88
+ assert.match(first, /^community-job-[0-9a-f]{16}$/);
89
+
90
+ const envelope = createCommunityJobContributionEnvelope({
91
+ installationId: '12345678-1234-4123-8123-123456789abc',
92
+ token: 'signed-token',
93
+ job,
94
+ skillVersion: '3.2.0',
95
+ });
96
+ assert.deepEqual(validateCommunityJobContributionEnvelope(envelope), envelope);
97
+ assert.throws(() => validateCommunityJobContributionEnvelope({ ...envelope, answers: { private: true } }), /Unknown community job contribution property/i);
98
+ });
99
+
100
+ test('validates paginated public community job responses without accepting extra data', async () => {
101
+ const jobId = await communityJobId(job);
102
+ const response = {
103
+ version: 1,
104
+ jobs: [{
105
+ jobId,
106
+ ...normalizeCommunityJob(job),
107
+ firstSeenAt: '2026-08-24T10:00:00.000Z',
108
+ lastSeenAt: '2026-08-24T11:00:00.000Z',
109
+ contributionCount: 2,
110
+ }],
111
+ nextCursor: 'opaque-cursor',
112
+ };
113
+
114
+ assert.deepEqual(validateCommunityJobList(response), response);
115
+ assert.throws(() => validateCommunityJobList({ ...response, installationId: 'private' }), /Unknown community job list property/i);
116
+ assert.throws(() => validateCommunityJobList({ ...response, jobs: [{ ...response.jobs[0], score: 90 }] }), /Unknown community job entry property/i);
117
+ });
118
+
15
119
  test('rejects known ATS and network job-detail routes', () => {
16
120
  const detailUrls = [
17
121
  '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
+ });