job-application-agent 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +179 -0
- package/bin/job-application-agent.mjs +7 -0
- package/installer/src/cli.mjs +85 -0
- package/installer/src/installer.mjs +110 -0
- package/installer/src/runner.mjs +23 -0
- package/installer/src/scheduler.mjs +79 -0
- package/job-application-agent/SKILL.md +88 -0
- package/job-application-agent/agents/openai.yaml +4 -0
- package/job-application-agent/references/ANALYTICS.md +65 -0
- package/job-application-agent/references/APPLICATION_GUIDANCE.md +12 -0
- package/job-application-agent/references/SCHEMAS.md +156 -0
- package/job-application-agent/scripts/job-application.mjs +890 -0
- package/job-application-agent/scripts/telemetry-client.mjs +176 -0
- package/job-application-agent/scripts/telemetry-schema.mjs +161 -0
- package/job-application-agent/tests/job-application.test.mjs +432 -0
- package/job-application-agent/tests/privacy-audit.test.mjs +42 -0
- package/job-application-agent/tests/telemetry-client.test.mjs +132 -0
- package/job-application-agent/tests/telemetry-schema.test.mjs +98 -0
- package/package.json +46 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, readdir, rename, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { createTelemetryEnvelope, jobIdentity, validateEvent } from './telemetry-schema.mjs';
|
|
5
|
+
|
|
6
|
+
export const SKILL_VERSION = '1.2.1';
|
|
7
|
+
export const DEFAULT_TELEMETRY_ENDPOINT = process.env.JOB_APPLICATION_AGENT_TELEMETRY_URL ?? 'https://job-application-agent-telemetry.varora1406.workers.dev';
|
|
8
|
+
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
|
+
|
|
10
|
+
const CONFIG_VERSION = 1;
|
|
11
|
+
const CONFIG_FILE = 'telemetry.json';
|
|
12
|
+
const EVENTS_WITH_DOMAIN = new Set(['job_discovered', 'job_assessed', 'application_submitted', 'outcome_recorded']);
|
|
13
|
+
|
|
14
|
+
export async function prepareTelemetryInput(input) {
|
|
15
|
+
if (!input || Array.isArray(input) || typeof input !== 'object') return validateEvent(input);
|
|
16
|
+
for (const key of Object.keys(input)) if (!['event', 'properties'].includes(key)) throw new Error(`Unknown telemetry input property: ${key}.`);
|
|
17
|
+
const properties = { ...(input.properties ?? {}) };
|
|
18
|
+
if ('jobUrl' in properties) {
|
|
19
|
+
const identity = await jobIdentity(properties.jobUrl);
|
|
20
|
+
delete properties.jobUrl;
|
|
21
|
+
properties.jobHash = identity.jobHash;
|
|
22
|
+
if (EVENTS_WITH_DOMAIN.has(input.event)) properties.domain = identity.domain;
|
|
23
|
+
}
|
|
24
|
+
return validateEvent({ event: input.event, properties });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function exists(file) {
|
|
28
|
+
try { await readFile(file); return true; } catch (error) { if (error.code === 'ENOENT') return false; throw error; }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function writePrivate(file, value) {
|
|
32
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
33
|
+
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
34
|
+
await rename(temporary, file);
|
|
35
|
+
await chmod(file, 0o600);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class TelemetryClient {
|
|
39
|
+
constructor({ stateDir, endpoint = DEFAULT_TELEMETRY_ENDPOINT, fetch: fetchFn = globalThis.fetch, stderr = (value) => process.stderr.write(value), now = () => new Date(), timeoutMs = Number(process.env.JOB_APPLICATION_AGENT_TELEMETRY_TIMEOUT_MS ?? 750) }) {
|
|
40
|
+
this.stateDir = stateDir;
|
|
41
|
+
this.endpoint = endpoint.replace(/\/$/, '');
|
|
42
|
+
this.fetch = fetchFn;
|
|
43
|
+
this.stderr = stderr;
|
|
44
|
+
this.now = now;
|
|
45
|
+
this.timeoutMs = timeoutMs;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
get configPath() { return join(this.stateDir, CONFIG_FILE); }
|
|
49
|
+
|
|
50
|
+
async ensureDirectory() {
|
|
51
|
+
await mkdir(this.stateDir, { recursive: true, mode: 0o700 });
|
|
52
|
+
await chmod(this.stateDir, 0o700);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async readConfig() {
|
|
56
|
+
try {
|
|
57
|
+
const value = JSON.parse(await readFile(this.configPath, 'utf8'));
|
|
58
|
+
return { version: CONFIG_VERSION, enabled: value.enabled !== false, disclosed: value.disclosed === true, graceConsumed: value.graceConsumed === true, installationEventPending: value.installationEventPending === true, installationId: value.installationId ?? null, token: value.token ?? null, tokenExpiresAt: value.tokenExpiresAt ?? null };
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if (error.code === 'ENOENT') return null;
|
|
61
|
+
return { version: CONFIG_VERSION, enabled: false, disclosed: true, graceConsumed: true, installationEventPending: false, installationId: null, token: null, tokenExpiresAt: null };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async saveConfig(config) {
|
|
66
|
+
await this.ensureDirectory();
|
|
67
|
+
await writePrivate(this.configPath, config);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async hasExistingPrivateState() {
|
|
71
|
+
await this.ensureDirectory();
|
|
72
|
+
const names = await readdir(this.stateDir);
|
|
73
|
+
return names.some((name) => name !== CONFIG_FILE && !name.endsWith('.tmp'));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async beginCommand(command) {
|
|
77
|
+
let config = await this.readConfig();
|
|
78
|
+
let allowSend = true;
|
|
79
|
+
if (!config) {
|
|
80
|
+
const existing = await this.hasExistingPrivateState();
|
|
81
|
+
config = { version: CONFIG_VERSION, enabled: true, disclosed: true, graceConsumed: !existing, installationEventPending: !existing, installationId: null, token: null, tokenExpiresAt: null };
|
|
82
|
+
this.stderr(TELEMETRY_NOTICE);
|
|
83
|
+
await this.saveConfig(config);
|
|
84
|
+
if (existing) {
|
|
85
|
+
allowSend = false;
|
|
86
|
+
config.graceConsumed = true;
|
|
87
|
+
await this.saveConfig(config);
|
|
88
|
+
}
|
|
89
|
+
} else if (!config.disclosed) {
|
|
90
|
+
this.stderr(TELEMETRY_NOTICE);
|
|
91
|
+
config.disclosed = true;
|
|
92
|
+
if (!config.graceConsumed) {
|
|
93
|
+
allowSend = false;
|
|
94
|
+
config.graceConsumed = true;
|
|
95
|
+
}
|
|
96
|
+
await this.saveConfig(config);
|
|
97
|
+
} else if (!config.graceConsumed) {
|
|
98
|
+
allowSend = false;
|
|
99
|
+
config.graceConsumed = true;
|
|
100
|
+
await this.saveConfig(config);
|
|
101
|
+
}
|
|
102
|
+
return { command, enabled: config.enabled, allowSend: config.enabled && allowSend, installationEventPending: config.installationEventPending === true };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async credentials(config) {
|
|
106
|
+
if (config.installationId && config.token && config.tokenExpiresAt && Date.parse(config.tokenExpiresAt) > this.now().getTime() + 60_000) return config;
|
|
107
|
+
const body = config.installationId && config.token ? { installationId: config.installationId, token: config.token } : {};
|
|
108
|
+
const response = await this.fetch(`${this.endpoint}/v1/install`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(this.timeoutMs) });
|
|
109
|
+
if (!response.ok) throw new Error('relay unavailable');
|
|
110
|
+
const identity = await response.json();
|
|
111
|
+
const next = { ...config, installationId: identity.installationId, token: identity.token, tokenExpiresAt: identity.expiresAt };
|
|
112
|
+
await this.saveConfig(next);
|
|
113
|
+
return next;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async record(input, session = { enabled: true, allowSend: true }, { strict = false } = {}) {
|
|
117
|
+
let event;
|
|
118
|
+
try {
|
|
119
|
+
event = await prepareTelemetryInput(input);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
if (strict) throw error;
|
|
122
|
+
return { sent: false, reason: 'invalid' };
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
if (!session.enabled || !session.allowSend) return { sent: false, reason: session.enabled ? 'grace' : 'disabled' };
|
|
126
|
+
if (session.unavailable) return { sent: false, reason: 'unavailable' };
|
|
127
|
+
let config = await this.readConfig();
|
|
128
|
+
if (!config?.enabled) return { sent: false, reason: 'disabled' };
|
|
129
|
+
config = await this.credentials(config);
|
|
130
|
+
const payload = createTelemetryEnvelope({ installationId: config.installationId, token: config.token, event, skillVersion: SKILL_VERSION });
|
|
131
|
+
let response = await this.fetch(`${this.endpoint}/v1/events`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload), signal: AbortSignal.timeout(this.timeoutMs) });
|
|
132
|
+
if (response.status === 401) {
|
|
133
|
+
config.tokenExpiresAt = null;
|
|
134
|
+
config = await this.credentials(config);
|
|
135
|
+
response = await this.fetch(`${this.endpoint}/v1/events`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(createTelemetryEnvelope({ installationId: config.installationId, token: config.token, event, skillVersion: SKILL_VERSION })), signal: AbortSignal.timeout(this.timeoutMs) });
|
|
136
|
+
}
|
|
137
|
+
if (!response.ok) {
|
|
138
|
+
session.unavailable = true;
|
|
139
|
+
return { sent: false, reason: 'unavailable' };
|
|
140
|
+
}
|
|
141
|
+
if (event.event === 'installation_started' && config.installationEventPending) {
|
|
142
|
+
config.installationEventPending = false;
|
|
143
|
+
await this.saveConfig(config);
|
|
144
|
+
session.installationEventPending = false;
|
|
145
|
+
}
|
|
146
|
+
return { sent: true };
|
|
147
|
+
} catch {
|
|
148
|
+
session.unavailable = true;
|
|
149
|
+
return { sent: false, reason: 'unavailable' };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async status() {
|
|
154
|
+
const config = await this.readConfig();
|
|
155
|
+
return { enabled: config?.enabled ?? true, disclosed: config?.disclosed ?? false, hasInstallationId: Boolean(config?.installationId), endpoint: this.endpoint, schemaVersion: 1 };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async configure(action) {
|
|
159
|
+
const current = await this.readConfig() ?? { version: CONFIG_VERSION, enabled: true, disclosed: false, graceConsumed: true, installationEventPending: true, installationId: null, token: null, tokenExpiresAt: null };
|
|
160
|
+
if (action === 'status') return this.status();
|
|
161
|
+
if (action === 'enable') {
|
|
162
|
+
current.enabled = true;
|
|
163
|
+
if (!current.installationId) current.installationEventPending = true;
|
|
164
|
+
}
|
|
165
|
+
else if (action === 'disable') current.enabled = false;
|
|
166
|
+
else if (action === 'reset') Object.assign(current, { enabled: false, disclosed: true, graceConsumed: true, installationEventPending: false, installationId: null, token: null, tokenExpiresAt: null });
|
|
167
|
+
else throw new Error('Telemetry action must be status, enable, disable, or reset.');
|
|
168
|
+
current.disclosed = true;
|
|
169
|
+
await this.saveConfig(current);
|
|
170
|
+
return this.status();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async preview(input) {
|
|
174
|
+
return prepareTelemetryInput(input);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
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 SHA256 = /^[0-9a-f]{64}$/;
|
|
3
|
+
const DOMAIN = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
|
|
4
|
+
const CURRENCY = /^[A-Z]{3}$/;
|
|
5
|
+
const VERSION = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
|
|
6
|
+
|
|
7
|
+
export const TELEMETRY_SCHEMA_VERSION = 1;
|
|
8
|
+
export const TELEMETRY_MAX_BYTES = 4096;
|
|
9
|
+
|
|
10
|
+
const values = (...items) => new Set(items);
|
|
11
|
+
const ATS = values('linkedin', 'greenhouse', 'lever', 'ashby', 'workable', 'comeet', 'workday', 'rippling', 'smartrecruiters', 'google-form', 'company', 'email', 'other');
|
|
12
|
+
const SOURCES = values('linkedin', 'x', 'hacker-news', 'yc', 'aggregator', 'company', 'email', 'referral', 'other');
|
|
13
|
+
const DURATIONS = values('under-1s', '1-5s', '5-30s', '30-60s', '1-2m', '2-5m', '5-15m', '15m-plus');
|
|
14
|
+
const COMMANDS = values('onboard', 'search', 'assess', 'apply', 'batch', 'round', 'outcome', 'review', 'resume', 'profile', 'telemetry', 'other');
|
|
15
|
+
const RESULTS = values('success', 'paused', 'skipped', 'abandoned', 'error');
|
|
16
|
+
const ELIGIBILITY = values('eligible', 'unclear', 'ineligible');
|
|
17
|
+
const DECISIONS = values('review', 'ask', 'skip', 'exclude');
|
|
18
|
+
const WORK_MODES = values('remote', 'hybrid', 'onsite', 'unspecified');
|
|
19
|
+
const SENIORITIES = values('junior', 'mid', 'senior', 'staff', 'principal', 'lead', 'manager', 'director', 'founding', 'unspecified');
|
|
20
|
+
const EMPLOYMENT = values('full-time', 'part-time', 'contract', 'temporary', 'internship', 'unspecified');
|
|
21
|
+
const ROLE_FAMILIES = values('frontend', 'backend', 'full-stack', 'product-engineering', 'ai-ml', 'platform', 'infrastructure', 'mobile', 'engineering-management', 'architecture', 'security', 'data', 'other');
|
|
22
|
+
const APPROVAL_MODES = values('review-each', 'routine-auto');
|
|
23
|
+
const SUBMISSION_MODES = values('review-each', 'routine-auto', 'unconfigured');
|
|
24
|
+
const STAGES = values('discovery', 'assessment', 'application', 'contact', 'resume', 'questions', 'legal', 'demographic', 'review', 'submission', 'confirmation', 'outcome');
|
|
25
|
+
const FIELD_CATEGORIES = values('contact', 'links', 'location', 'authorization', 'compensation', 'experience', 'resume', 'cover-letter', 'referral', 'short-answer', 'legal', 'demographic', 'other');
|
|
26
|
+
const PAUSE_REASONS = values('login', 'sso', 'mfa', 'captcha', 'legal', 'demographic', 'eligibility', 'compensation', 'sensitive-id', 'unverifiable', 'attachment', 'candidate-judgment', 'site-error', 'other');
|
|
27
|
+
const SKIP_REASONS = values('closed', 'duplicate', 'ineligible', 'low-fit', 'location', 'salary', 'seniority', 'candidate-choice', 'no-direct-link', 'other');
|
|
28
|
+
const OUTCOMES = values('interview', 'rejected', 'offer', 'withdrawn');
|
|
29
|
+
const INTERVIEW_QUALITIES = values('promising', 'viable', 'weak', 'dead');
|
|
30
|
+
const FAILURE_POINTS = values('role-scope', 'company-problem', 'constraints', 'interviewer', 'process', 'unknown');
|
|
31
|
+
const ERROR_CODES = values('invalid_input', 'network_failure', 'relay_unavailable', 'authentication_required', 'site_changed', 'upload_failed', 'submission_unconfirmed', 'rate_limited', 'internal_error');
|
|
32
|
+
const MATCH_TAGS = values('role_family', 'seniority', 'skills', 'industry', 'location', 'remote', 'salary', 'ai', 'product', 'leadership', 'authorization');
|
|
33
|
+
const GAP_TAGS = values('role_family', 'seniority', 'skills', 'industry', 'location', 'salary_unknown', 'salary_below', 'authorization_unclear', 'sponsorship', 'experience', 'domain', 'other');
|
|
34
|
+
|
|
35
|
+
const text = (max, identitySafe = false) => ({ kind: 'text', max, identitySafe });
|
|
36
|
+
const integer = (min, max) => ({ kind: 'integer', min, max });
|
|
37
|
+
const boolean = { kind: 'boolean' };
|
|
38
|
+
const enumValue = (set) => ({ kind: 'enum', set });
|
|
39
|
+
const enumArray = (set, max = 12) => ({ kind: 'enum-array', set, max });
|
|
40
|
+
|
|
41
|
+
const JOB = {
|
|
42
|
+
company: text(160, true), title: text(200, true), jobHash: { kind: 'hash' }, domain: { kind: 'domain' }, ats: enumValue(ATS),
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const EVENT_SCHEMAS = {
|
|
46
|
+
installation_started: { required: { osFamily: enumValue(values('macos', 'linux', 'windows', 'other')), nodeMajor: integer(20, 99), submissionMode: enumValue(SUBMISSION_MODES) } },
|
|
47
|
+
command_completed: { required: { command: enumValue(COMMANDS), result: enumValue(RESULTS), durationBucket: enumValue(DURATIONS) } },
|
|
48
|
+
job_discovered: { required: { ...JOB, source: enumValue(SOURCES), jobCountry: text(80, true), workMode: enumValue(WORK_MODES), seniority: enumValue(SENIORITIES), employmentType: enumValue(EMPLOYMENT), roleFamily: enumValue(ROLE_FAMILIES) }, optional: { salaryCurrency: { kind: 'currency' }, salaryMin: integer(0, 10000000), salaryMax: integer(0, 10000000) } },
|
|
49
|
+
job_assessed: { required: { ...JOB, fitScore: integer(0, 100), eligibility: enumValue(ELIGIBILITY), decision: enumValue(DECISIONS), matchTags: enumArray(MATCH_TAGS), gapTags: enumArray(GAP_TAGS) } },
|
|
50
|
+
application_started: { required: { jobHash: { kind: 'hash' }, ats: enumValue(ATS), approvalMode: enumValue(APPROVAL_MODES), requiredFieldCount: integer(0, 500), resumeRequired: boolean, coverLetterRequired: boolean, referralPresent: boolean } },
|
|
51
|
+
application_step: { required: { jobHash: { kind: 'hash' }, ats: enumValue(ATS), stage: enumValue(STAGES), fieldCategory: enumValue(FIELD_CATEGORIES), retryCount: integer(0, 20), durationBucket: enumValue(DURATIONS) } },
|
|
52
|
+
application_paused: { required: { jobHash: { kind: 'hash' }, ats: enumValue(ATS), stage: enumValue(STAGES), reason: enumValue(PAUSE_REASONS) } },
|
|
53
|
+
application_skipped: { required: { jobHash: { kind: 'hash' }, reason: enumValue(SKIP_REASONS), fitScore: integer(0, 100), eligibility: enumValue(ELIGIBILITY) } },
|
|
54
|
+
application_submitted: { required: { ...JOB, durationBucket: enumValue(DURATIONS), fieldsFilled: integer(0, 500), shortAnswerCount: integer(0, 100), resumeUploaded: boolean, approvalMode: enumValue(APPROVAL_MODES) } },
|
|
55
|
+
round_completed: { required: { requestedCount: integer(1, 1000), submittedCount: integer(0, 1000), assessedCount: integer(0, 10000), skippedCount: integer(0, 10000), pausedCount: integer(0, 10000), errorCount: integer(0, 10000), durationBucket: enumValue(DURATIONS) } },
|
|
56
|
+
outcome_recorded: {
|
|
57
|
+
required: { ...JOB, outcome: enumValue(OUTCOMES), daysSinceSubmission: integer(0, 3650) },
|
|
58
|
+
optional: { interviewQuality: enumValue(INTERVIEW_QUALITIES), failurePoint: enumValue(FAILURE_POINTS) },
|
|
59
|
+
},
|
|
60
|
+
review_generated: { required: { submissionCount: integer(0, 100000), interviewCount: integer(0, 100000), rejectionCount: integer(0, 100000), offerCount: integer(0, 100000), withdrawalCount: integer(0, 100000), reviewDue: boolean } },
|
|
61
|
+
skill_error: { required: { errorCode: enumValue(ERROR_CODES), stage: enumValue(STAGES), recoverable: boolean }, optional: { ats: enumValue(ATS), jobHash: { kind: 'hash' } } },
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export function containsDirectIdentity(value) {
|
|
65
|
+
if (typeof value !== 'string') return false;
|
|
66
|
+
return /(?:[^\s@]+@[^\s@]+\.[^\s@]+)|(?:https?:\/\/|www\.)|(?:linkedin\.com|github\.com)|(?:\+?\d[\d\s().-]{6,}\d)/i.test(value);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function validateProperty(name, value, rule) {
|
|
70
|
+
if (rule.kind === 'text') {
|
|
71
|
+
if (typeof value !== 'string' || !value.trim() || value.length > rule.max) throw new Error(`${name} must be a non-empty string no longer than ${rule.max} characters.`);
|
|
72
|
+
if (rule.identitySafe && containsDirectIdentity(value)) throw new Error(`${name} contains direct identity data.`);
|
|
73
|
+
if (/[\x00-\x1f\x7f]/.test(value)) throw new Error(`${name} contains control characters.`);
|
|
74
|
+
return value.trim();
|
|
75
|
+
}
|
|
76
|
+
if (rule.kind === 'integer') {
|
|
77
|
+
if (!Number.isInteger(value) || value < rule.min || value > rule.max) throw new Error(`${name} must be an integer from ${rule.min} to ${rule.max}.`);
|
|
78
|
+
return value;
|
|
79
|
+
}
|
|
80
|
+
if (rule.kind === 'boolean') {
|
|
81
|
+
if (typeof value !== 'boolean') throw new Error(`${name} must be a Boolean.`);
|
|
82
|
+
return value;
|
|
83
|
+
}
|
|
84
|
+
if (rule.kind === 'enum') {
|
|
85
|
+
if (typeof value !== 'string' || !rule.set.has(value)) throw new Error(`${name} is invalid.`);
|
|
86
|
+
return value;
|
|
87
|
+
}
|
|
88
|
+
if (rule.kind === 'enum-array') {
|
|
89
|
+
if (!Array.isArray(value) || value.length > rule.max || value.some((item) => typeof item !== 'string' || !rule.set.has(item))) throw new Error(`${name} must contain only documented tags.`);
|
|
90
|
+
return [...new Set(value)];
|
|
91
|
+
}
|
|
92
|
+
if (rule.kind === 'hash') {
|
|
93
|
+
if (typeof value !== 'string' || !SHA256.test(value)) throw new Error(`${name} must be a SHA-256 job hash.`);
|
|
94
|
+
return value;
|
|
95
|
+
}
|
|
96
|
+
if (rule.kind === 'domain') {
|
|
97
|
+
if (typeof value !== 'string' || !DOMAIN.test(value)) throw new Error(`${name} must be a canonical domain.`);
|
|
98
|
+
return value;
|
|
99
|
+
}
|
|
100
|
+
if (rule.kind === 'currency') {
|
|
101
|
+
if (typeof value !== 'string' || !CURRENCY.test(value)) throw new Error(`${name} must be an ISO-style currency code.`);
|
|
102
|
+
return value;
|
|
103
|
+
}
|
|
104
|
+
throw new Error(`Unsupported telemetry property ${name}.`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function validateEvent(input) {
|
|
108
|
+
if (!input || Array.isArray(input) || typeof input !== 'object') throw new Error('Telemetry event must be an object.');
|
|
109
|
+
const schema = EVENT_SCHEMAS[input.event];
|
|
110
|
+
if (!schema) throw new Error('Unknown telemetry event.');
|
|
111
|
+
if (!input.properties || Array.isArray(input.properties) || typeof input.properties !== 'object') throw new Error('Telemetry properties must be an object.');
|
|
112
|
+
const rules = { ...schema.required, ...(schema.optional ?? {}) };
|
|
113
|
+
for (const name of Object.keys(input.properties)) if (!rules[name]) throw new Error(`Unknown telemetry property: ${name}.`);
|
|
114
|
+
const properties = {};
|
|
115
|
+
for (const [name, rule] of Object.entries(schema.required)) {
|
|
116
|
+
if (!(name in input.properties)) throw new Error(`Missing telemetry property: ${name}.`);
|
|
117
|
+
properties[name] = validateProperty(name, input.properties[name], rule);
|
|
118
|
+
}
|
|
119
|
+
for (const [name, rule] of Object.entries(schema.optional ?? {})) if (name in input.properties) properties[name] = validateProperty(name, input.properties[name], rule);
|
|
120
|
+
if (input.event === 'outcome_recorded' && properties.failurePoint && !properties.interviewQuality) throw new Error('failurePoint requires interviewQuality.');
|
|
121
|
+
const result = { event: input.event, properties };
|
|
122
|
+
if (new TextEncoder().encode(JSON.stringify(result)).length > TELEMETRY_MAX_BYTES) throw new Error('Telemetry event exceeds 4 KB.');
|
|
123
|
+
return result;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function canonicalizeJobUrl(value) {
|
|
127
|
+
const url = new URL(value);
|
|
128
|
+
if (!['https:', 'http:'].includes(url.protocol)) throw new Error('Job URL must use HTTP or HTTPS.');
|
|
129
|
+
url.protocol = 'https:';
|
|
130
|
+
url.username = '';
|
|
131
|
+
url.password = '';
|
|
132
|
+
url.search = '';
|
|
133
|
+
url.hash = '';
|
|
134
|
+
url.hostname = url.hostname.toLowerCase();
|
|
135
|
+
url.pathname = url.pathname.replace(/\/+$/, '') || '/';
|
|
136
|
+
return url.toString().replace(/\/$/, '');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function jobIdentity(value) {
|
|
140
|
+
const canonical = canonicalizeJobUrl(value);
|
|
141
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(canonical));
|
|
142
|
+
return { jobHash: [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join(''), domain: new URL(canonical).hostname };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function createTelemetryEnvelope({ installationId, token, event, properties, skillVersion }) {
|
|
146
|
+
if (!UUID.test(installationId)) throw new Error('installationId must be an anonymous UUID.');
|
|
147
|
+
if (typeof token !== 'string' || token.length < 8 || token.length > 2048) throw new Error('token is invalid.');
|
|
148
|
+
if (typeof skillVersion !== 'string' || !VERSION.test(skillVersion)) throw new Error('skillVersion is invalid.');
|
|
149
|
+
const safe = validateEvent(typeof event === 'string' ? { event, properties } : event);
|
|
150
|
+
const envelope = { schemaVersion: TELEMETRY_SCHEMA_VERSION, skillVersion, installationId, token, ...safe };
|
|
151
|
+
if (new TextEncoder().encode(JSON.stringify(envelope)).length > TELEMETRY_MAX_BYTES) throw new Error('Telemetry payload exceeds 4 KB.');
|
|
152
|
+
return envelope;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function validateTelemetryEnvelope(input) {
|
|
156
|
+
if (!input || Array.isArray(input) || typeof input !== 'object') throw new Error('Telemetry payload must be an object.');
|
|
157
|
+
const allowed = new Set(['schemaVersion', 'skillVersion', 'installationId', 'token', 'event', 'properties']);
|
|
158
|
+
for (const key of Object.keys(input)) if (!allowed.has(key)) throw new Error(`Unknown telemetry envelope property: ${key}.`);
|
|
159
|
+
if (input.schemaVersion !== TELEMETRY_SCHEMA_VERSION) throw new Error('Unsupported telemetry schema version.');
|
|
160
|
+
return createTelemetryEnvelope(input);
|
|
161
|
+
}
|