trackly-cli
Advanced tools
+7
-67
| 'use strict'; | ||
| const { createHash } = require('node:crypto'); | ||
| const { z } = require('zod'); | ||
@@ -23,21 +22,2 @@ const { apiRequest } = require('../lib/client'); | ||
| function sensitiveRevocationConfirmation(profileResponse) { | ||
| const profile = profileResponse?.profile || {}; | ||
| const currentRevision = Number(profile.revision); | ||
| const fields = profile.fields || {}; | ||
| // Stored answer rows serialize an `encrypted` boolean plus their PERSISTED | ||
| // row sensitivity — the same predicate the backend DELETE uses. Profile-column | ||
| // and backfilled entries never carry `encrypted` and are never deleted. | ||
| const affectedKeys = Object.keys(fields) | ||
| .filter((key) => { | ||
| const entry = fields[key]; | ||
| return entry && typeof entry.encrypted === 'boolean' | ||
| && (entry.sensitivity === 'sensitive' || entry.sensitivity === 'restricted'); | ||
| }) | ||
| .sort(); | ||
| const confirmationToken = createHash('sha256') | ||
| .update(`trackly:sensitive-revocation:v1:${currentRevision}:${affectedKeys.join(',')}`, 'utf8') | ||
| .digest('hex'); | ||
| return { currentRevision, affectedKeys, confirmationToken }; | ||
| } | ||
| const SAFE_OBSERVATION_CODE = /^[a-z0-9][a-z0-9_:-]{0,99}$/; | ||
@@ -178,3 +158,3 @@ const SAFE_IDEMPOTENCY_KEY = /^[\x20-\x7e]+$/; | ||
| 'trackly_update_application_profile', | ||
| 'Update confirmed profile answers with optimistic concurrency. Use global scope only for an explicit always-answer preference. Setting sensitiveStorageConsent=false permanently deletes every stored sensitive and restricted answer and is a two-step action: the first call saves nothing and returns a confirmation challenge; retry with the echoed sensitiveRevocationConfirmToken to proceed.', | ||
| 'Update confirmed profile answers with optimistic concurrency. Use global scope only for an explicit always-answer preference. Setting sensitiveStorageConsent=false deletes every stored sensitive and restricted answer (an admin-recoverable archive is kept for 30 days, then purged) and is a two-step action: the first call saves nothing and returns a confirmation challenge; retry with the echoed sensitiveRevocationConfirmToken to proceed.', | ||
| { | ||
@@ -212,48 +192,8 @@ expectedRevision: z.number().int().min(1), | ||
| wrapTool(async (params) => { | ||
| const { sensitiveRevocationConfirmToken, ...body } = params; | ||
| if (body.sensitiveStorageConsent === false) { | ||
| const profileResponse = await apiRequest('GET', '/api/jobscout/application-profile', null, false, false, MCP_USER_AGENT); | ||
| const rawRevision = Number(profileResponse?.profile?.revision); | ||
| if (!Number.isSafeInteger(rawRevision) || rawRevision < 1) { | ||
| throw { | ||
| status: 502, | ||
| code: 'invalid_profile_revision', | ||
| error: 'Trackly did not return a valid profile revision. No changes were saved.', | ||
| }; | ||
| } | ||
| const profileFieldsShape = profileResponse?.profile?.fields; | ||
| const profileEntriesValid = !!profileFieldsShape | ||
| && typeof profileFieldsShape === 'object' | ||
| && !Array.isArray(profileFieldsShape) | ||
| && Object.keys(profileFieldsShape).length > 0 | ||
| && Object.values(profileFieldsShape).every((entry) => entry | ||
| && typeof entry === 'object' && !Array.isArray(entry) | ||
| && typeof entry.state === 'string' | ||
| && (entry.sensitivity === 'standard' || entry.sensitivity === 'sensitive' || entry.sensitivity === 'restricted') | ||
| && (!('encrypted' in entry) || typeof entry.encrypted === 'boolean')); | ||
| if (!profileEntriesValid) { | ||
| throw { | ||
| status: 502, | ||
| code: 'invalid_profile_response', | ||
| error: 'Trackly returned an incomplete profile, so the deletion scope cannot be verified. No changes were saved.', | ||
| }; | ||
| } | ||
| const { currentRevision, affectedKeys, confirmationToken } = | ||
| sensitiveRevocationConfirmation(profileResponse); | ||
| if (sensitiveRevocationConfirmToken !== confirmationToken || body.expectedRevision !== currentRevision) { | ||
| throw { | ||
| status: 409, | ||
| code: 'sensitive_revocation_confirmation_required', | ||
| error: 'Revoking sensitive storage consent permanently deletes every stored sensitive and restricted answer. No changes were saved.', | ||
| confirmation: { | ||
| currentRevision, | ||
| affectedKeys, | ||
| alsoDeletes: 'Every provider- and company-scoped sensitive or restricted answer is also deleted, along with any stored rows not visible in this profile view; those keys are not listed here.', | ||
| confirmationToken, | ||
| instructions: 'Do not retry automatically. Show the user the affected keys and get explicit confirmation, then retry with the FULL original request body (including any changes, education, or confirmProfile fields) plus expectedRevision=currentRevision and sensitiveRevocationConfirmToken=confirmationToken. If the original call carried other changes, re-read the profile first and reconcile them against the current revision instead of resending stale changes blindly. The token becomes invalid whenever the profile revision changes.', | ||
| }, | ||
| }; | ||
| } | ||
| } | ||
| return apiRequest('PATCH', '/api/jobscout/application-profile', body, false, false, MCP_USER_AGENT); | ||
| // The revocation guard lives in the backend service layer (#1294): it | ||
| // recomputes the persisted-sensitivity inventory in-transaction and 409s | ||
| // with the challenge unless the token matches. Forward the full params — | ||
| // including sensitiveRevocationConfirmToken — so no client-side copy of | ||
| // the guard can drift or be bypassed by version skew. | ||
| return apiRequest('PATCH', '/api/jobscout/application-profile', params, false, false, MCP_USER_AGENT); | ||
| }, 'Failed to update application profile') | ||
@@ -260,0 +200,0 @@ ); |
+5
-0
@@ -102,2 +102,7 @@ 'use strict'; | ||
| if (error?.status === 409 && error?.error === 'sensitive_revocation_confirmation_required' && error?.confirmation) { | ||
| payload.code = error.error; | ||
| payload.confirmation = error.confirmation; | ||
| } | ||
| if (error?.status === 409 && error?.error === 'preference_revision_conflict') { | ||
@@ -104,0 +109,0 @@ payload.preferences = error.preferences; |
| { | ||
| "name": "trackly-cli", | ||
| "version": "0.10.1", | ||
| "version": "0.10.2", | ||
| "lockfileVersion": 3, | ||
@@ -9,3 +9,3 @@ "requires": true, | ||
| "name": "trackly-cli", | ||
| "version": "0.10.1", | ||
| "version": "0.10.2", | ||
| "license": "MIT", | ||
@@ -12,0 +12,0 @@ "dependencies": { |
+1
-1
| { | ||
| "name": "trackly-cli", | ||
| "version": "0.10.1", | ||
| "version": "0.10.2", | ||
| "mcpName": "io.github.trackly-app/trackly", | ||
@@ -5,0 +5,0 @@ "description": "AI job search CLI + hosted MCP server with OAuth. 128K+ jobs, 1,900+ companies, 40+ ATS. Works with Claude, ChatGPT, Cursor, Windsurf, Codex via hosted streamable-http or local stdio.", |
+2
-2
@@ -20,3 +20,3 @@ { | ||
| ], | ||
| "version": "0.10.1", | ||
| "version": "0.10.2", | ||
| "packages": [ | ||
@@ -26,3 +26,3 @@ { | ||
| "identifier": "trackly-cli", | ||
| "version": "0.10.1", | ||
| "version": "0.10.2", | ||
| "runtimeHint": "npx", | ||
@@ -29,0 +29,0 @@ "runtimeArguments": [ |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
390085
-0.84%4583
-1.23%