@steve228uk/nhs-cli 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,184 @@
1
+ import { NhsError, shape } from './errors.mjs';
2
+ import { jsonResponse, assertHttp } from './transport.mjs';
3
+ import { origins } from './config.mjs';
4
+
5
+ export function parsePositiveInteger(value, fallback) {
6
+ if (value === undefined) return fallback;
7
+ return /^\d+$/.test(String(value)) && Number.isSafeInteger(Number(value)) && Number(value) > 0 ? Number(value) : fallback;
8
+ }
9
+ export function positive(value, fallback, max = 100) {
10
+ if (value === undefined) return fallback;
11
+ const n = parsePositiveInteger(value, 0);
12
+ if (!n || n > max) throw new NhsError('usage', 'Numeric option is outside its supported range.');
13
+ return n;
14
+ }
15
+ export function identifier(value) {
16
+ if (typeof value !== 'string' || !value || value.length > 512 || /[\x00-\x20]/.test(value) || value === '.' || value === '..') throw new NhsError('usage', 'A valid resource identifier is required.');
17
+ return encodeURIComponent(value);
18
+ }
19
+
20
+ export function buildStatusPayload(data) {
21
+ shape(data && Array.isArray(data.courses));
22
+ const courses = data.courses.map(course => {
23
+ shape(course && ['string', 'number'].includes(typeof course.id) && typeof course.name === 'string' && typeof course.requestable === 'boolean' && (course.details === undefined || typeof course.details === 'string'));
24
+ return { id: course.id, name: course.name, details: course.details ?? '', requestable: course.requestable };
25
+ });
26
+ const available = courses.filter(course => course.requestable);
27
+ return { ok: true, checkedAt: new Date().toISOString(), summary: { total: courses.length, requestable: available.length, requestableNames: available.map(course => course.name), specialRequestNecessity: data.specialRequestNecessity ?? 'None' }, courses };
28
+ }
29
+
30
+ function capability(enabled, supported = true) {
31
+ let status = 'unsupported';
32
+ if (enabled === false) status = 'unavailable';
33
+ else if (enabled === true && supported) status = 'available';
34
+ return { status, evidence: 'client-observed' };
35
+ }
36
+ function providerCapability(provider) {
37
+ if (provider === 'none') return capability(false);
38
+ if (provider === 'im1') return capability(true);
39
+ return capability(undefined);
40
+ }
41
+ export function capabilitiesFrom(data) {
42
+ shape(data?.journeys && typeof data.journeys === 'object' && !Array.isArray(data.journeys));
43
+ const rules = data.journeys;
44
+ const provider = name => rules[name]?.provider;
45
+ const record = ['1', '2'].includes(String(rules.medicalRecord?.version));
46
+ let records = capability(undefined);
47
+ if (rules.medicalRecord?.version === null) records = capability(false);
48
+ else if (record) records = capability(true);
49
+ return {
50
+ prescriptions: providerCapability(provider('prescriptions')),
51
+ records,
52
+ results: capability(record && [null, '1', '2'].includes(rules.im1TestResults?.version) ? true : undefined),
53
+ appointments: providerCapability(provider('appointments')),
54
+ nhsMessages: capability(rules.messaging), gpMessages: capability(rules.im1Messaging?.isEnabled),
55
+ pharmacy: capability(rules.nominatedPharmacy), documents: capability(rules.documents), profile: capability(true),
56
+ };
57
+ }
58
+
59
+ export class NhsServices {
60
+ /** @param {import('./auth.mjs').AuthClient} auth */
61
+ constructor(auth) { this.auth = auth; this.capabilities = null; }
62
+ /** @param {string} path @param {Parameters<import('./auth.mjs').AuthClient['read']>[1]} options */
63
+ async #readJson(path, options = {}) {
64
+ return jsonResponse(await this.auth.read(path, options));
65
+ }
66
+ async discover() {
67
+ if (!this.capabilities) {
68
+ const data = await this.#readJson('/v1/patient/journey-configuration');
69
+ this.capabilities = capabilitiesFrom(data);
70
+ }
71
+ return this.capabilities;
72
+ }
73
+ async require(name, gp = false) {
74
+ const capabilities = await this.discover();
75
+ const cap = capabilities[name];
76
+ if (!cap || cap.status !== 'available') throw new NhsError(cap?.status === 'unavailable' ? 'capability_unavailable' : 'unsupported', 'This capability is not available through a supported provider for this account.', { capability: name });
77
+ if (gp && !this.auth.session.hasGpSession) await this.auth.ensureGp();
78
+ }
79
+ async courses() {
80
+ await this.require('prescriptions', true);
81
+ let response;
82
+ try { response = await this.auth.read('/v1/patient/courses', { gp: true }); }
83
+ catch (error) {
84
+ if (error.details?.status !== 598) throw error;
85
+ await this.auth.ensureGp();
86
+ response = await this.auth.read('/v1/patient/courses', { gp: true });
87
+ }
88
+ return buildStatusPayload(await jsonResponse(response));
89
+ }
90
+ async order({ ids = undefined, all = false, note = '', confirm = false, dryRun = false } = {}) {
91
+ if ((!ids && !all) || (ids && all)) throw new NhsError('order_requires_scope', 'Choose either explicit --ids or --all-requestable.');
92
+ if (typeof note !== 'string' || note.length > 1000) throw new NhsError('usage', 'The prescription note must contain at most 1000 characters.');
93
+ if (!dryRun && !confirm) throw new NhsError('order_requires_confirmation', 'Review a fresh dry-run preview and confirm the exact medicines before using --confirm.');
94
+ const status = await this.courses();
95
+ const wanted = ids ? String(ids).split(',').map(id => id.trim()).filter(Boolean) : [];
96
+ if (ids && (!wanted.length || new Set(wanted).size !== wanted.length)) throw new NhsError('order_invalid_scope', 'Prescription IDs must be non-empty and unique.');
97
+ const selected = wanted.length ? wanted.map(id => status.courses.find(course => String(course.id) === id)) : status.courses.filter(course => course.requestable);
98
+ if (selected.some(course => !course?.requestable)) throw new NhsError('order_invalid_scope', 'Some supplied medication IDs are unknown or not currently requestable.');
99
+ if (!selected.length) return { ...status, order: { submitted: false, reason: 'no_requestable_courses' } };
100
+ if (dryRun) return { ...status, order: { submitted: false, dryRun: true, courseIds: selected.map(course => course.id), selectedNames: selected.map(course => course.name), note } };
101
+ let response;
102
+ try { response = await this.auth.request('/v1/patient/prescriptions', { method: 'POST', body: { CourseIds: selected.map(course => course.id), SpecialRequest: note } }); }
103
+ catch { throw new NhsError('order_unknown', 'The prescription request outcome is unknown. Check the official NHS App before submitting again.'); }
104
+ if (response.status >= 500 || response.status === 202 || response.status === 408) {
105
+ await response.body?.cancel();
106
+ throw new NhsError('order_unknown', 'The prescription request outcome is unknown. Check the official NHS App before submitting again.');
107
+ }
108
+ await assertHttp(response, 'prescription request');
109
+ if (response.status !== 201) {
110
+ await response.body?.cancel();
111
+ throw new NhsError('order_unknown', 'NHS returned an unrecognised submission acknowledgement. Check the official NHS App before submitting again.');
112
+ }
113
+ await response.body?.cancel();
114
+ return { ...status, order: { submitted: true, result: { submitted: true }, orderedNames: selected.map(course => course.name) } };
115
+ }
116
+ async history(from) {
117
+ await this.require('prescriptions', true);
118
+ const date = from ? new Date(from) : new Date(new Date().setMonth(new Date().getMonth() - 6));
119
+ if (!Number.isFinite(date.getTime())) throw new NhsError('usage', '--from must be an ISO date.');
120
+ const data = await this.#readJson(`/v1/patient/prescriptions?${new URLSearchParams({ fromDate: date.toISOString() })}`, { gp: true });
121
+ shape(Array.isArray(data?.prescriptions) && Array.isArray(data?.courses)); return data;
122
+ }
123
+ async record() {
124
+ await this.require('records', true);
125
+ const data = await this.#readJson('/v1/patient/my-record', { gp: true });
126
+ shape(data?.response && typeof data.response === 'object');
127
+ if (data.response.hasSummaryRecordAccess === false && data.response.hasDetailedRecordAccess !== true) throw new NhsError('capability_unavailable', 'The GP has not enabled access to this medical record.');
128
+ return data.response;
129
+ }
130
+ async results({ id = undefined, year = undefined } = {}) {
131
+ await this.require('results', true);
132
+ if (id && year) throw new NhsError('usage', 'Choose a result ID or a historical year.');
133
+ if (!id && !year) {
134
+ const record = await this.record(); shape(record.testResults !== undefined); return record.testResults;
135
+ }
136
+ if (year && (!/^\d{4}$/.test(String(year)) || Number(year) < 1900 || Number(year) > new Date().getFullYear())) throw new NhsError('usage', '--year must be a year between 1900 and the current year.');
137
+ const path = id ? `/v1/patient/test-result?${new URLSearchParams({ testResultId: String(id) })}` : `/v1/patient/historic-test-results/${year}`;
138
+ const data = await this.#readJson(path, { gp: true });
139
+ shape(data?.response !== undefined); return data.response;
140
+ }
141
+ async appointments(slots = false) {
142
+ await this.require('appointments', true);
143
+ const data = await this.#readJson(slots ? '/v1/patient/appointment-slots' : '/v1/patient/appointments', { gp: true });
144
+ shape(data && typeof data === 'object');
145
+ if (!slots) shape(Array.isArray(data.upcomingAppointments) || Array.isArray(data.pastAppointments));
146
+ else shape(Array.isArray(data.slots));
147
+ return data;
148
+ }
149
+ async messages({ source = 'nhs', id = undefined, index = 0, count = 20 } = {}) {
150
+ if (!['nhs', 'gp'].includes(source)) throw new NhsError('usage', '--source must be nhs or gp.');
151
+ await this.require(source === 'nhs' ? 'nhsMessages' : 'gpMessages', source === 'gp');
152
+ if (!Number.isSafeInteger(index) || index < 0 || index > 100000 || !Number.isInteger(count) || count < 1 || count > 100) throw new NhsError('usage', 'Message index must be 0–100000 and count 1–100.');
153
+ const path = source === 'nhs' ? id ? `/v1/api/users/me/messages/${identifier(id)}` : `/v2/api/users/me/messages?${new URLSearchParams({ index: String(index), count: String(count) })}` : id ? `/v1/patient/messages/${identifier(id)}` : '/v1/patient/messages';
154
+ const data = await this.#readJson(path, { bearer: source === 'nhs', gp: source === 'gp' });
155
+ shape(data && typeof data === 'object');
156
+ if (!id && source === 'nhs') shape(Array.isArray(data.messages) && typeof data.canLoadMore === 'boolean');
157
+ if (!id && source === 'gp') shape(Array.isArray(data.messageSummaries));
158
+ if (id && source === 'gp') shape(data.messageDetails && typeof data.messageDetails === 'object');
159
+ return data;
160
+ }
161
+ async profile() {
162
+ const data = await this.#readJson('/v1/patient/demographics');
163
+ shape(data && typeof data === 'object' && !Array.isArray(data)); return data;
164
+ }
165
+ async pharmacy() {
166
+ await this.require('pharmacy');
167
+ const data = await this.#readJson('/v1/patient/nominated-pharmacy');
168
+ shape(data === null || typeof data === 'object'); return data;
169
+ }
170
+ async documents(id = undefined, download = false) {
171
+ await this.require('documents', true);
172
+ if (!this.auth.nhsNumber || !/^\d{10}$/.test(this.auth.nhsNumber)) throw new NhsError('unsupported', 'NHS did not provide the own-account identifier required for document access.');
173
+ const list = await this.#readJson(`/v1/AccessDocuments/Patient/${this.auth.nhsNumber}/DocumentReference`, { origin: origins.gpconnect, gp: true });
174
+ shape(Array.isArray(list?.patientDocuments));
175
+ if (!id) return { documents: list.patientDocuments };
176
+ const metadata = list.patientDocuments.find(doc => String(doc.id) === String(id));
177
+ if (!metadata) throw new NhsError('not_found', 'That document is not in this account’s available document list.');
178
+ if (!download) return metadata;
179
+ const data = await this.#readJson(`/v1/AccessDocuments/Download/${identifier(id)}`, { origin: origins.gpconnect, gp: true, headers: { Prefer: 'statuscode=200' } });
180
+ shape(typeof data?.content === 'string' && typeof data?.contentType === 'string');
181
+ if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(data.content) || !data.content) throw new NhsError('invalid_response', 'Document content is missing or invalid base64.');
182
+ return { content: Buffer.from(data.content, 'base64'), contentType: data.contentType, metadata };
183
+ }
184
+ }
package/src/errors.mjs ADDED
@@ -0,0 +1,31 @@
1
+ export class NhsError extends Error {
2
+ constructor(code, message, details = {}) {
3
+ super(message);
4
+ this.name = 'NhsError';
5
+ this.code = code;
6
+ this.details = details;
7
+ }
8
+ }
9
+
10
+ export function redact(value) {
11
+ return String(value ?? '')
12
+ .replace(/[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]+/g, '[redacted-jwt]')
13
+ .replace(/\b[A-Fa-f0-9]{32,}\b/g, '[redacted-token]')
14
+ .replace(/\b\d{6}\b/g, '[redacted-code]')
15
+ .replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, '[redacted-email]')
16
+ .replace(/((?:NHSO-Session-[\w-]+|authorization|cookie|csrfToken|email|id_token|password|patientId|patientSessionId|phone_number|phoneNumber|sessionId|sessionExpiry|token|rmdToken|rememberMyDevice|code|state|nonce)["']?\s*[:=]\s*)[^\s&,;}]+/gi, '$1[redacted]');
17
+ }
18
+
19
+ // Upstream bodies and native exception messages can contain arbitrary secrets.
20
+ // Only our own constant messages and explicitly selected metadata reach output.
21
+ export function errorPayload(error) {
22
+ if (!(error instanceof NhsError)) return { ok: false, code: 'unexpected_error', message: 'Unexpected failure. Run nhs doctor to check local prerequisites.' };
23
+ const details = {};
24
+ if (Number.isInteger(error.details?.status)) details.status = error.details.status;
25
+ if (typeof error.details?.capability === 'string') details.capability = error.details.capability;
26
+ return { ok: false, code: error.code, message: redact(error.message), ...details };
27
+ }
28
+
29
+ export function shape(condition, message = 'NHS returned an unsupported response structure.') {
30
+ if (!condition) throw new NhsError('invalid_response', message);
31
+ }
package/src/otp.mjs ADDED
@@ -0,0 +1,46 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { NhsError } from './errors.mjs';
6
+ import { promptInput } from './ui.mjs';
7
+
8
+ const exec = promisify(execFile);
9
+ export function isLikelyNhsOtpText(text) {
10
+ return /\bNHS\b/i.test(text) && /\b(login|security|verification|code|passcode|OTP)\b/i.test(text) && /\b\d{6}\b/.test(text);
11
+ }
12
+ export function extractOtpCode(text) { return isLikelyNhsOtpText(text) ? text.match(/\b(\d{6})\b/)?.[1] ?? null : null; }
13
+
14
+ export async function readOtpFromMessages({ since = Date.now(), execImpl = exec, platform = process.platform } = {}) {
15
+ if (platform !== 'darwin') throw new NhsError('otp_messages_unsupported', 'Messages OTP lookup requires macOS.');
16
+ if (!Number.isFinite(since)) throw new NhsError('otp_invalid', 'The security-code challenge time is invalid.');
17
+ const sql = `WITH nhs_messages AS (SELECT text, CASE WHEN date > 1000000000000 THEN date / 1000000000.0 + 978307200 ELSE date + 978307200 END AS received_unix FROM message WHERE is_from_me = 0 AND text LIKE '%NHS%') SELECT text, received_unix FROM nhs_messages WHERE received_unix >= ${since / 1000} ORDER BY received_unix DESC LIMIT 30;`;
18
+ let rows;
19
+ try {
20
+ const { stdout } = await execImpl('sqlite3', ['-readonly', '-json', join(homedir(), 'Library', 'Messages', 'chat.db'), sql], { timeout: 5000, maxBuffer: 128 * 1024 });
21
+ rows = JSON.parse(String(stdout) || '[]');
22
+ } catch { throw new NhsError('otp_messages_unavailable', 'Messages OTP lookup is unavailable. Enter the code directly in the terminal or enable Messages access.'); }
23
+ const now = Date.now();
24
+ for (const row of rows) {
25
+ const received = Number(row.received_unix) * 1000;
26
+ if (!Number.isFinite(received) || received < since || received > now + 1000) continue;
27
+ const code = extractOtpCode(row.text || ''); if (code) return code;
28
+ }
29
+ throw new NhsError('otp_not_found', 'No NHS code received after this login challenge was found.');
30
+ }
31
+
32
+ export async function resolveOtp({ since, messages = false, allowPrompt = false, prompt = promptInput, readMessages = readOtpFromMessages, pause = ms => new Promise(resolve => setTimeout(resolve, ms)) }) {
33
+ if (messages) {
34
+ for (let attempt = 0; attempt < 6; attempt++) {
35
+ try { return await readMessages({ since }); }
36
+ catch (error) {
37
+ if (error.code !== 'otp_not_found') { if (!allowPrompt) throw error; break; }
38
+ if (attempt < 5) await pause(10000);
39
+ }
40
+ }
41
+ }
42
+ if (!allowPrompt) throw new NhsError('auth_required', 'NHS requires a security code. Run nhs auth login in a terminal.');
43
+ const value = String(await prompt('NHS security code', { validate: value => /^\d{6}$/.test(value?.trim() || '') ? undefined : 'Enter the six-digit NHS security code.' })).trim();
44
+ if (!/^\d{6}$/.test(value)) throw new NhsError('otp_invalid', 'The NHS security code must contain six digits.');
45
+ return value;
46
+ }
package/src/output.mjs ADDED
@@ -0,0 +1,32 @@
1
+ import { NhsError } from './errors.mjs';
2
+ import { open, unlink } from 'node:fs/promises';
3
+ import { resolve } from 'node:path';
4
+
5
+ const secretKey = /^(?:authorization|cookie|cookies|set-cookie|password|csrfToken|token|accessToken|refreshToken|id_token|rmd_token|rmdToken|rememberMyDevice|remember_my_device|sessionId|patientId|patientSessionId|sessionExpiry|codeVerifier|nonce)$/i;
6
+ /** Strip protocol secrets from domain output; clinical data remains intentionally visible. */
7
+ export function publicData(value) {
8
+ if (Array.isArray(value)) return value.map(publicData);
9
+ if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).filter(([key]) => !secretKey.test(key)).map(([key, item]) => [key, publicData(item)]));
10
+ return value;
11
+ }
12
+ function terminalText(text) { return String(text).replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, ''); }
13
+ export function render(payload, json) {
14
+ const data = publicData(payload);
15
+ if (json) return `${JSON.stringify(data, null, 2)}\n`;
16
+ if (Array.isArray(data.checks)) return data.checks.map(check => `${check.ok ? '[OK]' : '[FAIL]'} ${check.name}: ${terminalText(check.detail)}`).join('\n') + '\n';
17
+ if (data.courses && !data.order) return data.courses.map(course => `${course.requestable ? '[REQUESTABLE]' : '[not available]'} ${terminalText(course.name)}${course.details ? `\n ${terminalText(course.details).replace(/\n/g, '\n ')}` : ''}`).join('\n') + `\n\n${data.summary.requestable} of ${data.summary.total} medication(s) available to order.\n`;
18
+ if (data.ok === false) return `Error [${data.code}]: ${terminalText(data.message)}\n`;
19
+ // Nested record sections retain their labels and structure in human output.
20
+ return `${JSON.stringify(data, null, 2)}\n`;
21
+ }
22
+ export async function exportFile(path, content) {
23
+ const absolute = resolve(path); let handle; let created = false;
24
+ try {
25
+ handle = await open(absolute, 'wx', 0o600); created = true;
26
+ await handle.writeFile(content); await handle.sync();
27
+ return absolute;
28
+ } catch (error) {
29
+ if (created) await unlink(absolute).catch(() => {});
30
+ throw new NhsError('export_failed', 'Could not create the export. Choose a new file path in an existing directory; existing files are never overwritten.');
31
+ } finally { await handle?.close(); }
32
+ }
@@ -0,0 +1,211 @@
1
+ import { randomBytes, createHash, createCipheriv, createDecipheriv, timingSafeEqual } from 'node:crypto';
2
+ import { constants } from 'node:fs';
3
+ import { chmod, lstat, mkdir, open, rename, unlink, rm } from 'node:fs/promises';
4
+ import { dirname, join } from 'node:path';
5
+ import { userInfo } from 'node:os';
6
+ import { setTimeout as sleep } from 'node:timers/promises';
7
+ import { NhsError } from './errors.mjs';
8
+
9
+ const SERVICE = 'nhs-cli';
10
+ const ACCOUNT = 'vault-key-v1';
11
+ const MAX_BYTES = 1024 * 1024;
12
+ const AAD = Buffer.from('nhs-cli:vault:v1');
13
+ const storageError = () => new NhsError('secure_storage_unavailable', 'Secure storage is unavailable or locked. Unlock Keychain/Secret Service, or explicitly configure encrypted-file storage with an injected key.');
14
+
15
+ /** Explicit legacy import only; backend access remains inside storage. */
16
+ export async function legacyCredentials({ loadNative = () => import('@napi-rs/keyring'), platform = process.platform } = {}) {
17
+ if (platform !== 'darwin') throw new NhsError('unsupported', 'Legacy credential migration is only available on macOS.');
18
+ try {
19
+ const { AsyncEntry } = await loadNative();
20
+ const email = await new AsyncEntry('openclaw-nhs-email', userInfo().username).getPassword();
21
+ const password = await new AsyncEntry('openclaw-nhs-password', userInfo().username).getPassword();
22
+ if (typeof email !== 'string' || !email.trim() || typeof password !== 'string' || !password) throw new Error();
23
+ return { email: email.trim(), password };
24
+ } catch { throw new NhsError('legacy_credentials_unavailable', 'Legacy Keychain credentials could not be read. Configure credentials with nhs auth login --save-credentials instead.'); }
25
+ }
26
+
27
+ export async function privateDirectory(path) {
28
+ await mkdir(path, { recursive: true, mode: 0o700 });
29
+ const info = await lstat(path);
30
+ if (!info.isDirectory() || info.isSymbolicLink() || info.uid !== process.getuid?.()) throw new NhsError('unsafe_storage_path', 'Storage directory must be owned by the current user and must not be a symlink.');
31
+ await chmod(path, 0o700);
32
+ }
33
+
34
+ export async function readPrivate(path) {
35
+ let handle;
36
+ try {
37
+ handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
38
+ const stat = await handle.stat();
39
+ if (!stat.isFile() || stat.uid !== process.getuid?.() || (stat.mode & 0o077) || stat.size > MAX_BYTES) throw new NhsError('unsafe_storage_file', 'Storage file has unsafe ownership, permissions, type, or size.');
40
+ return await handle.readFile('utf8');
41
+ } catch (error) {
42
+ if (error.code === 'ENOENT') return null;
43
+ if (error instanceof NhsError) throw error;
44
+ throw new NhsError('storage_read_failed', 'Could not safely read the storage file.');
45
+ } finally { await handle?.close(); }
46
+ }
47
+
48
+ export async function atomicWrite(path, data, { replace = rename } = {}) {
49
+ const temporary = `${path}.${randomBytes(12).toString('hex')}.tmp`;
50
+ let file;
51
+ try {
52
+ file = await open(temporary, 'wx', 0o600);
53
+ await file.writeFile(data);
54
+ await file.sync();
55
+ await file.close(); file = undefined;
56
+ await replace(temporary, path);
57
+ const directory = await open(dirname(path), 'r');
58
+ try { await directory.sync(); } finally { await directory.close(); }
59
+ } finally {
60
+ await file?.close();
61
+ await unlink(temporary).catch(() => {});
62
+ }
63
+ }
64
+
65
+ /** Serializes the entire command, including auth renewal, migration and writes. */
66
+ export async function withLock(directory, run, { timeoutMs = 10000 } = {}) {
67
+ await privateDirectory(directory);
68
+ const path = join(directory, 'operation.lock');
69
+ const deadline = Date.now() + timeoutMs;
70
+ let held = false;
71
+ while (!held) {
72
+ try {
73
+ await mkdir(path, { mode: 0o700 }); held = true;
74
+ await atomicWrite(join(path, 'owner.json'), JSON.stringify({ pid: process.pid }));
75
+ } catch (error) {
76
+ if (held) { await rm(path, { recursive: true, force: true }); throw error; }
77
+ if (error.code !== 'EEXIST') throw new NhsError('storage_lock_failed', 'Could not acquire the storage lock.');
78
+ // Never steal a live or unidentifiable lock. A crashed process requires explicit repair.
79
+ if (Date.now() >= deadline) throw new NhsError('storage_busy', 'Another nhs command holds the storage lock. If it crashed, verify no nhs process is running before removing operation.lock.');
80
+ await sleep(100);
81
+ }
82
+ }
83
+ try { return await run(); } finally { await rm(path, { recursive: true, force: true }); }
84
+ }
85
+
86
+ export function injectedKey(env = process.env) {
87
+ const raw = env.NHS_CLI_STATE_KEY;
88
+ if (!raw || !/^[A-Za-z0-9+/]{43}=$/.test(raw)) throw new NhsError('storage_key_required', 'Encrypted-file storage requires NHS_CLI_STATE_KEY containing a base64-encoded random 32-byte key from your secret manager.');
89
+ const key = Buffer.from(raw, 'base64');
90
+ if (key.length !== 32) throw new NhsError('storage_key_required', 'NHS_CLI_STATE_KEY must decode to exactly 32 bytes.');
91
+ return key;
92
+ }
93
+
94
+ /**
95
+ * Linux enumeration is implemented directly against Secret Service in pinned
96
+ * @napi-rs/keyring 2.0.0. A non-secret probe proves a NEW Entry is bound to that
97
+ * store before we ever give it a secret. Reuse that Entry for the key write:
98
+ * constructing another Entry would re-run the library's fallback selection.
99
+ */
100
+ export class NativeKeyProvider {
101
+ constructor({ platform = process.platform, loadNative = () => import('@napi-rs/keyring'), service = SERVICE } = {}) {
102
+ this.platform = platform; this.loadNative = loadNative; this.service = service;
103
+ }
104
+ async key(create = false) {
105
+ if (!['darwin', 'linux'].includes(this.platform)) throw storageError();
106
+ try {
107
+ const native = await this.loadNative();
108
+ let value;
109
+ if (this.platform === 'linux') {
110
+ const rows = await native.findCredentialsAsync(this.service);
111
+ const matches = rows.filter(row => row.account === ACCOUNT);
112
+ if (matches.length > 1) throw storageError();
113
+ value = matches[0]?.password;
114
+ } else {
115
+ value = await new native.AsyncEntry(this.service, ACCOUNT).getPassword();
116
+ }
117
+ if (this.platform === 'linux' && /^nhs-cli-storage-probe:[0-9a-f]{32}$/.test(value || '')) value = undefined;
118
+ if (value) {
119
+ const key = Buffer.from(value, 'base64');
120
+ if (key.length !== 32 || key.toString('base64') !== value) throw storageError();
121
+ return key;
122
+ }
123
+ if (!create) return null;
124
+ const entry = new native.AsyncEntry(this.service, ACCOUNT);
125
+ if (this.platform === 'linux') {
126
+ const probe = `nhs-cli-storage-probe:${randomBytes(16).toString('hex')}`;
127
+ await entry.setPassword(probe);
128
+ try {
129
+ const rows = await native.findCredentialsAsync(this.service);
130
+ if (!rows.some(row => row.account === ACCOUNT && row.password === probe)) throw storageError();
131
+ } catch {
132
+ await entry.deleteCredential().catch(() => {});
133
+ throw storageError();
134
+ }
135
+ }
136
+ const key = randomBytes(32);
137
+ await entry.setPassword(key.toString('base64'));
138
+ const verified = await this.key(false);
139
+ if (!verified || !timingSafeEqual(verified, key)) throw storageError();
140
+ return key;
141
+ } catch { throw storageError(); }
142
+ }
143
+ }
144
+
145
+ /** @typedef {import('./types.mjs').SecureStore} SecureStore */
146
+ /** @implements {SecureStore} */
147
+ export class VaultStore {
148
+ constructor({ directory, backend = 'keyring', keyProvider = new NativeKeyProvider(), env = process.env }) {
149
+ this.directory = directory; this.backend = backend; this.keyProvider = keyProvider; this.env = env;
150
+ this.path = join(directory, 'vault.enc');
151
+ }
152
+ async getKey(create = false) {
153
+ return this.backend === 'encrypted-file' ? injectedKey(this.env) : this.keyProvider.key(create);
154
+ }
155
+ async probe() { await this.getKey(false); }
156
+ /** @returns {Promise<import('./types.mjs').Vault>} */
157
+ async load() {
158
+ const key = await this.getKey(false);
159
+ const raw = await readPrivate(this.path);
160
+ if (raw === null) return { version: 1, session: {} };
161
+ if (!key) throw new NhsError('storage_key_missing', 'An encrypted vault exists but its key is unavailable. Restore the original key; the vault has not been overwritten.');
162
+ try {
163
+ const envelope = JSON.parse(raw);
164
+ if (envelope.version !== 1) throw new Error();
165
+ const iv = Buffer.from(envelope.iv, 'base64'), tag = Buffer.from(envelope.tag, 'base64');
166
+ if (iv.length !== 12 || tag.length !== 16) throw new Error();
167
+ const decipher = createDecipheriv('aes-256-gcm', key, iv);
168
+ decipher.setAAD(AAD); decipher.setAuthTag(tag);
169
+ const value = JSON.parse(Buffer.concat([decipher.update(Buffer.from(envelope.data, 'base64')), decipher.final()]).toString('utf8'));
170
+ if (value.version !== 1 || !value.session || typeof value.session !== 'object' || Array.isArray(value.session)) throw new Error();
171
+ return value;
172
+ } catch { throw new NhsError('storage_corrupt', 'Could not authenticate the encrypted vault. Check the injected key or restore the encrypted vault; it has not been overwritten.'); }
173
+ }
174
+ /** @param {import('./types.mjs').Vault} value */
175
+ async save(value) {
176
+ const serialized = JSON.stringify(value);
177
+ if (Buffer.byteLength(serialized) > MAX_BYTES / 2) throw new NhsError('storage_too_large', 'Authentication state exceeds the supported vault size.');
178
+ const key = await this.getKey(true);
179
+ if (!key) throw storageError();
180
+ const iv = randomBytes(12), cipher = createCipheriv('aes-256-gcm', key, iv);
181
+ cipher.setAAD(AAD);
182
+ const data = Buffer.concat([cipher.update(serialized, 'utf8'), cipher.final()]);
183
+ await privateDirectory(this.directory);
184
+ await atomicWrite(this.path, JSON.stringify({ version: 1, iv: iv.toString('base64'), tag: cipher.getAuthTag().toString('base64'), data: data.toString('base64') }));
185
+ }
186
+ }
187
+
188
+ /** Run only under withLock. Local status and diagnostics never migrate. */
189
+ export async function migrateLegacy(store, path) {
190
+ const vault = await store.load();
191
+ const raw = await readPrivate(path);
192
+ if (raw === null) return vault;
193
+ const digest = createHash('sha256').update(raw).digest('hex');
194
+ if (vault.legacyMigrated) {
195
+ // A crash may happen after verified secure storage but before unlink. Never
196
+ // reimport old sessions over rotated state, or remove a different source.
197
+ if (vault.legacyMigrationDigest !== digest) throw new NhsError('migration_conflict', 'Legacy state changed after migration. The secure vault and legacy file have both been preserved.');
198
+ await unlink(path);
199
+ return vault;
200
+ }
201
+ let old;
202
+ try { old = JSON.parse(raw); } catch { throw new NhsError('migration_failed', 'Legacy state is invalid; it has not been changed.'); }
203
+ const allowed = ['csrfToken', 'patientId', 'sessionId', 'sessionExpiry', 'rememberMyDevice', 'rmdToken', 'lastOtpTriggerAt', 'updatedAt'];
204
+ const session = Object.fromEntries(allowed.filter(key => typeof old[key] === 'string').map(key => [key, old[key]]));
205
+ const next = { ...vault, session: { ...session, ...vault.session }, legacyMigrated: true, legacyMigrationDigest: digest };
206
+ await store.save(next);
207
+ const verified = await store.load();
208
+ if (JSON.stringify(verified) !== JSON.stringify(next)) throw new NhsError('migration_failed', 'Secure migration verification failed; the legacy file has been preserved.');
209
+ await unlink(path);
210
+ return next;
211
+ }
@@ -0,0 +1,116 @@
1
+ import { CookieJar } from 'tough-cookie';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { setTimeout as sleep } from 'node:timers/promises';
4
+ import { compatibility, origins } from './config.mjs';
5
+ import { NhsError } from './errors.mjs';
6
+
7
+ const allowedOrigins = new Set(Object.values(origins).map(value => new URL(value).origin));
8
+ /** @param {string|URL} value @param {string} base */
9
+ export function trustedUrl(value, base = origins.app) {
10
+ let url;
11
+ try { url = new URL(value, base); } catch { throw new NhsError('invalid_redirect', 'NHS returned an invalid redirect URL.'); }
12
+ if (url.protocol !== 'https:' || url.username || url.password || !allowedOrigins.has(url.origin)) throw new NhsError('untrusted_redirect', 'NHS returned a redirect outside the supported HTTPS origins.');
13
+ return url;
14
+ }
15
+
16
+ export class Transport {
17
+ constructor({ fetchImpl = fetch, timeoutMs = 30000, pause = sleep } = {}) {
18
+ this.fetchImpl = fetchImpl; this.timeoutMs = timeoutMs; this.pause = pause;
19
+ }
20
+ /** @param {string|URL} value @param {RequestInit & {jar?: CookieJar}} options */
21
+ async raw(value, options = {}) {
22
+ const url = trustedUrl(value);
23
+ const { jar, ...init } = options;
24
+ const headers = new Headers(init.headers);
25
+ headers.set('User-Agent', compatibility.userAgent);
26
+ if (jar) {
27
+ const cookie = await jar.getCookieString(url.toString());
28
+ if (cookie) headers.set('Cookie', cookie);
29
+ }
30
+ let response;
31
+ try {
32
+ response = await this.fetchImpl(url.toString(), { ...init, headers, redirect: 'manual', signal: AbortSignal.timeout(this.timeoutMs) });
33
+ } catch {
34
+ throw new NhsError('network_error', 'NHS could not be reached within the request deadline.');
35
+ }
36
+ if (jar) {
37
+ for (const cookie of response.headers.getSetCookie()) {
38
+ try { await jar.setCookie(cookie, url.toString()); } catch { throw new NhsError('invalid_response', 'NHS returned an invalid cookie.'); }
39
+ }
40
+ }
41
+ return response;
42
+ }
43
+ async redirects(url, jar, stop = (_url) => false) {
44
+ let current = trustedUrl(url);
45
+ for (let count = 0; count < 10; count++) {
46
+ if (stop(current)) return { url: current, response: null };
47
+ const response = await this.raw(current, { jar });
48
+ if (response.status < 300 || response.status >= 400) return { url: current, response };
49
+ const location = response.headers.get('location');
50
+ await response.body?.cancel();
51
+ if (!location) throw new NhsError('invalid_redirect', 'NHS returned a redirect without a destination.');
52
+ current = trustedUrl(location, current.toString());
53
+ }
54
+ throw new NhsError('redirect_limit', 'NHS authentication exceeded the redirect limit.');
55
+ }
56
+ }
57
+
58
+ export async function jsonResponse(response) {
59
+ try {
60
+ const text = await boundedBody(response, 8 * 1024 * 1024);
61
+ return JSON.parse(text.toString('utf8'));
62
+ } catch (error) {
63
+ if (error instanceof NhsError) throw error;
64
+ throw new NhsError('invalid_response', 'NHS returned invalid JSON.');
65
+ }
66
+ }
67
+
68
+ export async function boundedBody(response, limit) {
69
+ if (Number(response.headers.get('content-length')) > limit) {
70
+ await response.body?.cancel();
71
+ throw new NhsError('response_too_large', 'NHS response exceeds the supported size.');
72
+ }
73
+ const reader = response.body?.getReader();
74
+ if (!reader) return Buffer.alloc(0);
75
+ const chunks = []; let size = 0;
76
+ try {
77
+ while (true) {
78
+ const { done, value } = await reader.read(); if (done) break;
79
+ size += value.length;
80
+ if (size > limit) { await reader.cancel(); throw new NhsError('response_too_large', 'NHS response exceeds the supported size.'); }
81
+ chunks.push(value);
82
+ }
83
+ return Buffer.concat(chunks);
84
+ } catch (error) {
85
+ if (error instanceof NhsError) throw error;
86
+ throw new NhsError('network_error', 'NHS response could not be read within the request deadline.');
87
+ } finally { reader.releaseLock(); }
88
+ }
89
+
90
+ export async function assertHttp(response, operation = 'request') {
91
+ if (response.ok) return;
92
+ await response.body?.cancel();
93
+ if (response.status === 401) throw new NhsError('session_expired', 'NHS session has expired.');
94
+ if (response.status === 403) throw new NhsError('access_denied', 'NHS denied access to this operation.');
95
+ if (response.status === 404 || response.status === 501) throw new NhsError('unsupported', 'This NHS operation is unavailable for this account or client version.');
96
+ if (response.status === 429) throw new NhsError('rate_limited', 'NHS is limiting requests. Wait before trying again.');
97
+ if (response.status >= 500) throw new NhsError('upstream_unavailable', 'NHS or the GP provider is temporarily unavailable.', { status: response.status });
98
+ throw new NhsError('request_failed', `NHS ${operation} failed.`, { status: response.status });
99
+ }
100
+
101
+ /** @param {import('./types.mjs').Session} session */
102
+ export function apiHeaders(session, bearer = false) {
103
+ /** @type {Record<string, string>} */
104
+ const headers = {
105
+ Accept: 'application/json', 'Content-Type': 'application/json',
106
+ 'NHSO-Request-ID': randomUUID(), 'NHSO-Web-Version-Tag': compatibility.webVersion,
107
+ 'NHSO-Native-Version-Tag': compatibility.nativeVersion,
108
+ Origin: origins.app, Referer: `${origins.app}/`,
109
+ };
110
+ if (session.csrfToken) headers['X-CSRF-TOKEN'] = session.csrfToken;
111
+ if (session.patientId) headers['NHSO-Patient-Id'] = session.patientId;
112
+ if (bearer && session.accessToken) headers.Authorization = `Bearer ${session.accessToken}`;
113
+ return headers;
114
+ }
115
+
116
+ export { CookieJar };
package/src/types.mjs ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * @typedef {null | boolean | number | string | JsonValue[] | {[key: string]: JsonValue}} JsonValue
3
+ * @typedef {{email: string, password: string}} Credentials
4
+ * @typedef {{csrfToken?: string, patientId?: string, sessionId?: string,
5
+ * sessionExpiry?: string, accessToken?: string, rememberMyDevice?: string,
6
+ * rmdToken?: string, lastOtpTriggerAt?: string, updatedAt?: string,
7
+ * hasGpSession?: boolean, sessionTimeout?: number, checkedAt?: number,
8
+ * cookies?: import('tough-cookie').SerializedCookieJar}} Session
9
+ * @typedef {{version: 1, session: Session, credentials?: Credentials, credentialAccount?: string, legacyMigrated?: boolean, legacyMigrationDigest?: string}} Vault
10
+ * @typedef {{load(): Promise<Vault>, save(value: Vault): Promise<void>,
11
+ * probe(): Promise<void>, backend: string}} SecureStore
12
+ */
13
+ export {};