@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.
package/src/auth.mjs ADDED
@@ -0,0 +1,282 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { CookieJar, Transport, apiHeaders, assertHttp, jsonResponse, boundedBody, trustedUrl } from './transport.mjs';
3
+ import { callback, gpCallback, origins } from './config.mjs';
4
+ import { NhsError, shape } from './errors.mjs';
5
+ import { resolveCredentials, parseCredentialsPayload } from './credentials.mjs';
6
+ import { resolveOtp } from './otp.mjs';
7
+
8
+ const trust = ['P5.Cp.Cd', 'P5.Cp.Ck', 'P5.Cm', 'P9.Cp.Cd', 'P9.Cp.Ck', 'P9.Cm'];
9
+ const accountDigest = email => createHash('sha256').update(email.trim().toLowerCase()).digest('hex');
10
+
11
+ function retryDelaySeconds(retryAfter, attempt, now) {
12
+ if (!retryAfter) return attempt;
13
+ if (/^\d+$/.test(retryAfter)) return Number(retryAfter);
14
+ return Math.max(0, (Date.parse(retryAfter) - now()) / 1000);
15
+ }
16
+
17
+ export function buildAuthorizeUrl(challenge, nonce, state) {
18
+ const url = new URL('/authorize', origins.authorize);
19
+ Object.entries({ response_type: 'code', client_id: 'nhs-online', scope: 'openid profile email profile_extended gp_registration_details', vtr: JSON.stringify(trust), code_challenge: challenge, code_challenge_method: 'S256', redirect_uri: callback, nonce, state }).forEach(([key, value]) => url.searchParams.set(key, value));
20
+ return url;
21
+ }
22
+
23
+ export function callbackCode(value, expectedState, expectedCallback = callback) {
24
+ const url = trustedUrl(value);
25
+ const target = new URL(expectedCallback);
26
+ if (url.origin !== target.origin || url.pathname !== target.pathname || url.hash || url.searchParams.getAll('state').length !== 1 || url.searchParams.get('state') !== expectedState || url.searchParams.getAll('code').length !== 1 || !url.searchParams.get('code')) throw new NhsError('oauth_validation_failed', 'NHS authorization callback failed destination or state validation.');
27
+ return url.searchParams.get('code');
28
+ }
29
+
30
+ /** @typedef {{allowLogin?: boolean, allowPrompt?: boolean, messages?: boolean, forceOtp?: boolean, saveCredentials?: boolean, env?: NodeJS.ProcessEnv, onPhase?: (phase: string) => void}} AuthOptions */
31
+ export class AuthClient {
32
+ /** @param {{store: import('./types.mjs').SecureStore, vault: import('./types.mjs').Vault, transport?: Transport, options?: AuthOptions, credentials?: typeof resolveCredentials, otp?: typeof resolveOtp, now?: () => number}} input */
33
+ constructor({ store, vault, transport = new Transport(), options = {}, credentials = resolveCredentials, otp = resolveOtp, now = Date.now }) {
34
+ this.store = store; this.vault = vault; this.transport = transport; this.options = options;
35
+ this.credentials = credentials; this.otp = otp; this.now = now; this.loginAttempts = 0;
36
+ this.jar = vault.session.cookies ? CookieJar.fromJSON(vault.session.cookies) : new CookieJar();
37
+ /** @type {string | undefined} Own-account identifier, held only for this command. */
38
+ this.nhsNumber = undefined;
39
+ if (!vault.session.cookies) {
40
+ for (const [name, value] of [['NHSO-Session-Id', vault.session.sessionId], ['NHSO-Session-Expiry', vault.session.sessionExpiry]]) {
41
+ if (value) this.jar.setCookieSync(`${name}=${value}; Secure; Path=/`, origins.api);
42
+ }
43
+ }
44
+ }
45
+ get session() { return this.vault.session; }
46
+ async persist() {
47
+ this.session.cookies = await this.jar.serialize();
48
+ const cookies = await this.jar.getCookies(origins.api);
49
+ this.session.sessionId = cookies.find(cookie => cookie.key === 'NHSO-Session-Id')?.value;
50
+ this.session.sessionExpiry = cookies.find(cookie => cookie.key === 'NHSO-Session-Expiry')?.value;
51
+ this.session.updatedAt = new Date(this.now()).toISOString();
52
+ await this.store.save(this.vault);
53
+ }
54
+ updateSession(data, required = false) {
55
+ shape(data && typeof data === 'object' && !Array.isArray(data));
56
+ if (required) shape(typeof data.token === 'string' && !!data.token && typeof data.patientSessionId === 'string' && !!data.patientSessionId);
57
+ for (const [input, output] of [['token', 'csrfToken'], ['patientSessionId', 'patientId'], ['accessToken', 'accessToken']]) {
58
+ if (data[input] !== undefined) { shape(typeof data[input] === 'string'); this.session[output] = data[input]; }
59
+ }
60
+ if (typeof data.hasGpSession === 'boolean') this.session.hasGpSession = data.hasGpSession;
61
+ if (typeof data.nhsNumber === 'string') this.nhsNumber = data.nhsNumber.replace(/\s/g, '');
62
+ if (typeof data.sessionTimeout === 'number' && data.sessionTimeout > 0) this.session.sessionTimeout = data.sessionTimeout;
63
+ this.session.checkedAt = this.now();
64
+ }
65
+ /** @param {string} path @param {{method?: string, body?: import('./types.mjs').JsonValue, bearer?: boolean, origin?: string, retry?: boolean, headers?: Record<string,string>}} options */
66
+ async request(path, { method = 'GET', body = undefined, bearer = false, origin = origins.api, retry = method === 'GET', headers = {} } = {}) {
67
+ const attempts = retry ? 3 : 1;
68
+ for (let attempt = 1; attempt <= attempts; attempt++) {
69
+ const response = await this.transport.raw(`${origin}${path}`, {
70
+ method, headers: { ...apiHeaders(this.session, bearer), ...headers }, jar: this.jar,
71
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
72
+ });
73
+ await this.persist();
74
+ if ([429, 502, 503, 504, 598].includes(response.status) && attempt < attempts) {
75
+ const retryAfter = response.headers.get('retry-after');
76
+ const seconds = retryDelaySeconds(retryAfter, attempt, () => this.now());
77
+ // Long server cooldowns are surfaced instead of blocking an agent indefinitely.
78
+ if (!Number.isFinite(seconds) || seconds > 5) return response;
79
+ await response.body?.cancel();
80
+ await this.transport.pause(seconds * 1000);
81
+ continue;
82
+ }
83
+ return response;
84
+ }
85
+ throw new NhsError('upstream_unavailable', 'NHS is temporarily unavailable.');
86
+ }
87
+ async ensure({ gp = false, reauth = false } = {}) {
88
+ const env = this.options.env || process.env;
89
+ const injected = env.NHS_CLI_CREDENTIALS || env.NHS_PRESCRIPTIONS_CREDENTIALS;
90
+ if (injected && this.vault.credentialAccount && accountDigest(parseCredentialsPayload(injected).email) !== this.vault.credentialAccount) throw new NhsError('account_changed', 'Injected credentials belong to a different saved login. Run nhs auth logout --forget before changing accounts.');
91
+ if (reauth || !this.session.csrfToken || !this.session.sessionId) {
92
+ await this.login();
93
+ } else {
94
+ this.options.onPhase?.('session');
95
+ const nearExpiry = this.session.checkedAt && this.session.sessionTimeout && this.now() - this.session.checkedAt > Math.max(0, this.session.sessionTimeout - 60) * 1000;
96
+ const response = await this.request('/v1/session');
97
+ if (response.status === 401) { await response.body?.cancel(); await this.login(); }
98
+ else {
99
+ await assertHttp(response);
100
+ this.updateSession(await jsonResponse(response), true);
101
+ await this.persist();
102
+ if (nearExpiry) {
103
+ const extended = await this.request('/v1/session/extend', { method: 'POST' });
104
+ await assertHttp(extended, 'session extension'); await extended.body?.cancel();
105
+ }
106
+ }
107
+ }
108
+ if (gp && !this.session.hasGpSession) await this.ensureGp();
109
+ }
110
+ async ensureBearer(force = false) {
111
+ let expiry = 0;
112
+ try { expiry = JSON.parse(Buffer.from(this.session.accessToken?.split('.')[1] || '', 'base64url').toString()).exp * 1000; } catch { /* absent or opaque tokens must be refreshed */ }
113
+ if (!force && Number.isFinite(expiry) && expiry > this.now() + 60000) return;
114
+ const response = await this.request('/v1/patient/authorization/access-token/refresh', { method: 'POST' });
115
+ await assertHttp(response, 'access-token refresh');
116
+ const data = await jsonResponse(response);
117
+ shape(typeof data?.token === 'string' && !!data.token);
118
+ this.session.accessToken = data.token; await this.persist();
119
+ }
120
+ /** Only read operations may be retried after reauthentication. */
121
+ /** @param {string} path @param {{method?: string, body?: import('./types.mjs').JsonValue, bearer?: boolean, gp?: boolean, origin?: string, headers?: Record<string,string>}} options */
122
+ async read(path, { bearer = false, gp = false, method = 'GET', body = undefined, origin = origins.api, headers = {} } = {}) {
123
+ try {
124
+ if (bearer) await this.ensureBearer();
125
+ const response = await this.request(path, { method, body, bearer, origin, headers, retry: method === 'GET' });
126
+ await assertHttp(response); return response;
127
+ } catch (error) {
128
+ if (error.code !== 'session_expired') throw error;
129
+ if (bearer) {
130
+ // A service-token rejection is not proof the cookie session expired.
131
+ const active = await this.request('/v1/session');
132
+ if (active.status !== 401) {
133
+ await assertHttp(active); this.updateSession(await jsonResponse(active), true); await this.persist();
134
+ try {
135
+ await this.ensureBearer(true);
136
+ const retried = await this.request(path, { method, body, bearer, origin, headers, retry: method === 'GET' });
137
+ await assertHttp(retried); return retried;
138
+ } catch (refreshError) {
139
+ if (refreshError.code === 'session_expired') throw new NhsError('access_denied', 'NHS rejected service authorization within a valid app session.');
140
+ throw refreshError;
141
+ }
142
+ }
143
+ await active.body?.cancel();
144
+ }
145
+ if (this.loginAttempts !== 0 || this.options.allowLogin === false) throw error;
146
+ await this.login();
147
+ if (gp) await this.ensureGp();
148
+ if (bearer) await this.ensureBearer();
149
+ const response = await this.request(path, { method, body, bearer, origin, headers, retry: method === 'GET' });
150
+ await assertHttp(response); return response;
151
+ }
152
+ }
153
+ async login() {
154
+ if (this.options.allowLogin === false || this.loginAttempts >= 1) throw new NhsError('auth_required', 'NHS authentication is required. Run nhs auth login in a terminal.');
155
+ this.loginAttempts++;
156
+ this.options.onPhase?.('credentials');
157
+ const { value } = await this.credentials(this.vault, { env: this.options.env, allowPrompt: this.options.allowPrompt });
158
+ const account = accountDigest(value.email);
159
+ if (this.vault.credentialAccount && this.vault.credentialAccount !== account) throw new NhsError('account_changed', 'Credentials belong to a different saved login. Run nhs auth logout --forget before changing accounts.');
160
+ // Prove secure writes work before contacting login or requesting a code.
161
+ await this.store.save(this.vault);
162
+ this.options.onPhase?.('authorization');
163
+ const verifier = randomBytes(32).toString('base64url');
164
+ const challenge = createHash('sha256').update(verifier).digest('base64url');
165
+ const nonce = randomBytes(32).toString('base64url'), state = randomBytes(32).toString('base64url');
166
+ const jar = new CookieJar();
167
+ const started = await this.transport.redirects(buildAuthorizeUrl(challenge, nonce, state), jar);
168
+ if (started.response) { await assertHttp(started.response, 'authorization'); await started.response.body?.cancel(); }
169
+ const authCookie = (await jar.getCookies(origins.access)).find(cookie => cookie.key === 'nhs-authorization-cookie');
170
+ let params;
171
+ try { params = JSON.parse(decodeURIComponent(authCookie?.value || '')); } catch { throw new NhsError('auth_flow_changed', 'NHS authorization cookie is missing or invalid.'); }
172
+ if (params.state !== state || params.nonce !== nonce || params.code_challenge !== challenge || params.redirect_uri !== callback || params.client_id !== 'nhs-online' || params.code_challenge_method !== 'S256' || typeof params.session_id !== 'string') throw new NhsError('oauth_validation_failed', 'NHS authorization parameters did not match this login request.');
173
+ if (this.session.rememberMyDevice && this.session.rememberMyDevice !== 'INVALID') await jar.setCookie(`remember_my_device=${this.session.rememberMyDevice}; Domain=login.nhs.uk; Secure; Path=/`, origins.login);
174
+ const headers = { 'Content-Type': 'application/json; charset=utf-8', Accept: 'application/json', Origin: origins.access, Referer: `${origins.access}/`, session_id: params.session_id };
175
+ const remembered = this.session.rememberMyDevice || this.session.rmdToken;
176
+ const signIn = await this.transport.raw(`${origins.login}/login/user-sign-in`, { method: 'POST', headers, jar, body: JSON.stringify({ ...value, rmd_token: remembered && remembered !== 'INVALID' ? remembered : null }) });
177
+ await assertHttp(signIn, 'sign-in');
178
+ const data = await jsonResponse(signIn);
179
+ if (data.rmd_token === 'INVALID') {
180
+ await jar.setCookie('remember_my_device=; Domain=login.nhs.uk; Secure; Path=/; Max-Age=0', origins.login);
181
+ delete this.session.rmdToken; delete this.session.rememberMyDevice;
182
+ await this.persist();
183
+ }
184
+ let code;
185
+ if (data.authentication_state === 'AUTHENTICATED') {
186
+ shape(typeof data.redirect_uri === 'string');
187
+ const redirected = await this.transport.redirects(data.redirect_uri, jar, url => url.origin === origins.app && url.pathname === '/auth-return');
188
+ await redirected.response?.body?.cancel();
189
+ code = callbackCode(redirected.url, state);
190
+ if (typeof data.rmd_token === 'string') this.session.rmdToken = data.rmd_token;
191
+ } else {
192
+ // Only a declared MFA challenge enters the OTP flow; changed responses fail closed.
193
+ if (data.authentication_state === 'UNREGISTERED' || data.authentication_methods?.totp || data.authentication_methods?.mobile === false) throw new NhsError('auth_required', 'NHS requires an interactive authentication method that this CLI does not support. Use the official NHS login.');
194
+ if (data.authentication_state !== 'VERIFIED') throw new NhsError('auth_flow_changed', 'NHS requires an unsupported authentication step. Use the official app and report the CLI error code.');
195
+ code = await this.handleOtp(jar, headers, params, state);
196
+ }
197
+ const rmdCookie = (await jar.getCookies(origins.login)).find(cookie => cookie.key === 'remember_my_device');
198
+ if (rmdCookie) this.session.rememberMyDevice = rmdCookie.value;
199
+ await this.persist();
200
+ this.options.onPhase?.('create');
201
+ // A new session gets a fresh jar; stale cookies must not affect creation.
202
+ this.jar = new CookieJar();
203
+ const response = await this.transport.raw(`${origins.api}/v1/session`, { method: 'POST', headers: apiHeaders({}), jar: this.jar, body: JSON.stringify({ authCode: code, codeVerifier: verifier, redirectUrl: callback, referrer: '', integrationReferrer: '', nonce }) });
204
+ await assertHttp(response, 'session creation');
205
+ this.session.hasGpSession = false;
206
+ delete this.session.accessToken;
207
+ this.updateSession(await jsonResponse(response), true);
208
+ this.vault.credentialAccount = account;
209
+ await this.persist();
210
+ shape(!!this.session.sessionId, 'NHS session creation did not provide a session cookie.');
211
+ if (this.options.saveCredentials) { this.vault.credentials = value; await this.persist(); }
212
+ }
213
+ async handleOtp(jar, headers, params, expectedState) {
214
+ if (!this.options.allowPrompt && !this.options.messages) throw new NhsError('auth_required', 'NHS requires a security code. Run nhs auth login in a terminal.');
215
+ const last = Date.parse(this.session.lastOtpTriggerAt || '');
216
+ if (!this.options.forceOtp && Number.isFinite(last) && this.now() - last < 600000) throw new NhsError('otp_recently_requested', 'An NHS code was requested within the last ten minutes. Wait before retrying.');
217
+ const since = this.now();
218
+ // Persist before the request: a lost response must not cause repeated SMS sends.
219
+ this.session.lastOtpTriggerAt = new Date(since).toISOString(); await this.persist();
220
+ const trigger = await this.transport.raw(`${origins.login}/login/trigger-otp`, { method: 'POST', headers, jar, body: JSON.stringify({ is_login: true, otp_type: 'mobile' }) });
221
+ await assertHttp(trigger, 'security-code request'); await trigger.body?.cancel();
222
+ this.options.onPhase?.('otp');
223
+ const otpCode = await this.otp({ since, messages: this.options.messages, allowPrompt: this.options.allowPrompt });
224
+ this.options.onPhase?.('verify');
225
+ const verified = await this.transport.raw(`${origins.login}/login/otp`, { method: 'POST', headers, jar, body: JSON.stringify({ client_id: 'nhs-online', session_id: params.session_id, otp_code: otpCode, otp_type: 'mobile' }) });
226
+ await assertHttp(verified, 'security-code verification');
227
+ const { id_token: idToken } = await jsonResponse(verified);
228
+ shape(typeof idToken === 'string' && !!idToken);
229
+ await jar.setCookie(`id_token=${idToken}; Domain=login.nhs.uk; Secure; Path=/`, origins.login);
230
+ const remember = await this.transport.raw(`${origins.login}/login/remember-my-device`, { method: 'POST', headers, jar, body: JSON.stringify({ remember_my_device: 'true' }) });
231
+ if (remember.ok) {
232
+ const content = await boundedBody(remember, 1024 * 1024);
233
+ let data;
234
+ try { data = content.length ? JSON.parse(content.toString('utf8')) : {}; } catch { throw new NhsError('invalid_response', 'NHS returned invalid remembered-device metadata.'); }
235
+ shape(data && typeof data === 'object' && !Array.isArray(data));
236
+ if (typeof data.rmd_token === 'string') this.session.rmdToken = data.rmd_token;
237
+ } else {
238
+ await remember.body?.cancel();
239
+ delete this.session.rmdToken;
240
+ delete this.session.rememberMyDevice;
241
+ await jar.setCookie('remember_my_device=; Domain=login.nhs.uk; Secure; Path=/; Max-Age=0', origins.login);
242
+ }
243
+ this.session.rememberMyDevice = (await jar.getCookies(origins.login)).find(cookie => cookie.key === 'remember_my_device')?.value;
244
+ await this.persist();
245
+ const exchanged = await this.transport.raw(`${origins.authorize}/authcode`, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: idToken, Origin: origins.access, Referer: `${origins.access}/` }, body: JSON.stringify(Object.fromEntries(['scope', 'response_type', 'client_id', 'redirect_uri', 'session_id', 'state', 'nonce', 'code_challenge', 'code_challenge_method', 'vtr'].map(key => [key, params[key]]))) });
246
+ await assertHttp(exchanged, 'authorization-code exchange');
247
+ const data = await jsonResponse(exchanged);
248
+ if (data.consent_required || data.terms_update_required) throw new NhsError('auth_required', 'NHS requires consent or updated terms in its official login.');
249
+ return callbackCode(data.Location, expectedState);
250
+ }
251
+ async ensureGp() {
252
+ this.options.onPhase?.('gp');
253
+ const identity = await this.request('/v1/patient/asserted-login-identity', { method: 'POST', body: { IntendedRelyingPartyUrl: 'www.nhsapp.service.nhs.uk' } });
254
+ await assertHttp(identity, 'GP identity');
255
+ const { token } = await jsonResponse(identity); shape(typeof token === 'string' && !!token);
256
+ const state = randomBytes(32).toString('base64url'), nonce = randomBytes(32).toString('base64url');
257
+ const url = new URL('/authorize', origins.authorize);
258
+ Object.entries({ asserted_login_identity: token, scope: 'openid profile email profile_extended nhs_app_credentials gp_registration_details', redirect_uri: gpCallback, client_id: 'nhs-online', state, vtr: JSON.stringify(trust), nonce, response_type: 'code' }).forEach(([key, value]) => url.searchParams.set(key, value));
259
+ const redirected = await this.transport.redirects(url, new CookieJar(), url => url.origin === origins.app && url.pathname === '/on-demand-gp-return');
260
+ await redirected.response?.body?.cancel();
261
+ const code = callbackCode(redirected.url, state, gpCallback);
262
+ const response = await this.request('/v1/session/gp-session-on-demand', { method: 'PUT', body: { authCode: code, redirectUrl: gpCallback, integrationReferrer: null, referrerOrigin: null } });
263
+ await assertHttp(response, 'GP session creation');
264
+ this.updateSession(await jsonResponse(response), true);
265
+ this.session.hasGpSession = true;
266
+ await this.persist();
267
+ }
268
+ async logout(forget = false) {
269
+ let revoked = false;
270
+ try {
271
+ if (this.session.sessionId) {
272
+ const response = await this.request('/v1/session', { method: 'DELETE' });
273
+ revoked = response.ok || response.status === 401;
274
+ await response.body?.cancel();
275
+ }
276
+ } catch { /* Local logout must still work during an outage. */ }
277
+ this.vault.session = {}; this.jar = new CookieJar();
278
+ if (forget) { delete this.vault.credentials; delete this.vault.credentialAccount; }
279
+ await this.store.save(this.vault);
280
+ return { ok: true, loggedOut: true, serverSessionRevoked: revoked, credentialsForgotten: forget };
281
+ }
282
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,189 @@
1
+ import { createUi } from './ui.mjs';
2
+ import { settings } from './config.mjs';
3
+ import { VaultStore, withLock, migrateLegacy, legacyCredentials } from './storage.mjs';
4
+ import { AuthClient } from './auth.mjs';
5
+ import { NhsServices, positive } from './domains.mjs';
6
+ import { readOtpFromMessages } from './otp.mjs';
7
+ import { NhsError, errorPayload } from './errors.mjs';
8
+ import { publicData, render, exportFile } from './output.mjs';
9
+ import packageInfo from '../package.json' with { type: 'json' };
10
+
11
+ export const HELP = `NHS CLI — unofficial NHS App client
12
+
13
+ Usage: nhs <command> [options]
14
+
15
+ auth login [--reauth] [--save-credentials] [--migrate-credentials]
16
+ auth status Local saved-login status
17
+ auth logout [--forget] Clear login; optionally credentials
18
+ doctor Local secure-storage diagnostics
19
+ capabilities Available account capabilities
20
+ prescriptions list | history [--from=YYYY-MM-DD]
21
+ prescriptions order --ids=id1,id2 --dry-run [--note=...]
22
+ prescriptions order --ids=id1,id2 --confirm [--note=...]
23
+ records [list] Available GP record sections
24
+ results list [--year=YYYY] | get <id>
25
+ appointments list | slots
26
+ messages list [--source=nhs|gp] [--index=0] [--count=20]
27
+ messages get <id> [--source=nhs|gp]
28
+ profile | pharmacy
29
+ documents list | get <id> | download <id> --output=<new-file>
30
+
31
+ Common: --json --no-login --no-prompt --messages-otp --force-otp
32
+ --output=<new-file> (explicit sensitive JSON export)
33
+ --help --version
34
+
35
+ Secure storage defaults to the OS keyring. Headless use must explicitly set
36
+ NHS_CLI_STORAGE=encrypted-file and inject NHS_CLI_STATE_KEY from a secret manager.
37
+ NHS_CLI_CREDENTIALS and legacy NHS_PRESCRIPTIONS_CREDENTIALS accept injected JSON.
38
+ Secrets must never be supplied in arguments. --debug never emits response bodies.
39
+ `;
40
+
41
+ const booleans = new Set(['json', 'no-login', 'no-prompt', 'messages-otp', 'force-otp', 'save-credentials', 'migrate-credentials', 'reauth', 'forget', 'dry-run', 'confirm', 'all-requestable', 'debug', 'help', 'version']);
42
+ const values = new Set(['ids', 'note', 'from', 'year', 'source', 'index', 'count', 'output', 'max-age-minutes']);
43
+ export function parseArgs(argv, legacy = false) {
44
+ /** @type {Map<string, string|boolean>} */
45
+ const flags = new Map(); const words = [];
46
+ for (let index = 0; index < argv.length; index++) {
47
+ const arg = argv[index];
48
+ if (arg === '-h') { flags.set('help', true); continue; }
49
+ if (!arg.startsWith('--')) { if (arg.startsWith('-')) throw new NhsError('usage', 'Unknown short option. Use --help.'); words.push(arg); continue; }
50
+ const equal = arg.indexOf('='), key = arg.slice(2, equal < 0 ? undefined : equal);
51
+ if (!booleans.has(key) && !values.has(key)) throw new NhsError('usage', 'Unknown option. Use --help for supported options.');
52
+ if (flags.has(key)) throw new NhsError('usage', 'Duplicate options are not supported.');
53
+ if (booleans.has(key)) {
54
+ if (equal >= 0) throw new NhsError('usage', 'Boolean flags do not accept values.');
55
+ flags.set(key, true);
56
+ } else {
57
+ const value = equal >= 0 ? arg.slice(equal + 1) : argv[++index];
58
+ if (value === undefined || value.startsWith('--') || !value) throw new NhsError('usage', 'An option value is missing.');
59
+ flags.set(key, value);
60
+ }
61
+ }
62
+ if (legacy) {
63
+ const command = words.shift() || 'status';
64
+ const aliases = { status: ['prescriptions', 'list'], list: ['prescriptions', 'list'], order: ['prescriptions', 'order'], login: ['auth', 'login'], doctor: ['doctor'], otp: ['otp'] };
65
+ if (!aliases[command]) throw new NhsError('usage', 'Unknown legacy command. Use nhs --help.');
66
+ words.unshift(...aliases[command]);
67
+ }
68
+ const [group = 'help', verb, id, ...extra] = words;
69
+ if (extra.length) throw new NhsError('usage', 'Too many positional arguments.');
70
+ return { group, verb, id, flags };
71
+ }
72
+
73
+ function commandSpecificOptions(group, verb) {
74
+ switch (group) {
75
+ case 'auth':
76
+ if (verb === 'login') return ['reauth', 'save-credentials', 'migrate-credentials'];
77
+ if (verb === 'logout') return ['forget'];
78
+ return [];
79
+ case 'prescriptions':
80
+ if (verb === 'order') return ['ids', 'all-requestable', 'confirm', 'dry-run', 'note'];
81
+ if (verb === 'history') return ['from'];
82
+ return [];
83
+ case 'results': return ['year'];
84
+ case 'messages': return ['source', 'index', 'count'];
85
+ case 'otp': return ['max-age-minutes'];
86
+ default: return [];
87
+ }
88
+ }
89
+
90
+ function validateCommand({ group, verb, id, flags }) {
91
+ const verbs = { auth: ['login', 'status', 'logout'], prescriptions: ['list', 'history', 'order'], records: ['list'], results: ['list', 'get'], appointments: ['list', 'slots'], messages: ['list', 'get'], documents: ['list', 'get', 'download'], profile: [], pharmacy: [], doctor: [], capabilities: [], otp: [] };
92
+ if (!Object.hasOwn(verbs, group)) throw new NhsError('usage', 'Unknown command. Use nhs --help.');
93
+ if ((verb && !verbs[group].includes(verb)) || (group === 'auth' && !verb)) throw new NhsError('usage', 'Unknown or missing subcommand. Use nhs --help.');
94
+ if (id && !['get', 'download'].includes(verb)) throw new NhsError('usage', 'This command does not accept an identifier.');
95
+ if (['get', 'download'].includes(verb) && !id) throw new NhsError('usage', 'This command requires an identifier.');
96
+ const allowed = new Set(['json', 'no-login', 'no-prompt', 'messages-otp', 'force-otp', 'debug', 'output']);
97
+ for (const key of commandSpecificOptions(group, verb)) allowed.add(key);
98
+ for (const key of flags.keys()) if (!allowed.has(key)) throw new NhsError('usage', 'An option is not supported for this command.');
99
+ if (group === 'documents' && verb === 'download' && !flags.has('output')) throw new NhsError('usage', 'Document download requires --output pointing to a new file.');
100
+ if (flags.has('no-login') && (flags.has('reauth') || flags.has('save-credentials'))) throw new NhsError('usage', '--no-login cannot be combined with --reauth or --save-credentials.');
101
+ if (group === 'prescriptions' && verb === 'order') {
102
+ if (flags.has('ids') === flags.has('all-requestable')) throw new NhsError('order_requires_scope', 'Choose either explicit --ids or --all-requestable.');
103
+ if (!flags.has('dry-run') && !flags.has('confirm')) throw new NhsError('order_requires_confirmation', 'Review a fresh dry-run preview and confirm the exact medicines before using --confirm.');
104
+ }
105
+ if (group === 'messages' && (verb === 'get' || flags.get('source') === 'gp') && (flags.has('index') || flags.has('count'))) throw new NhsError('usage', 'Pagination options apply only to the NHS inbox list.');
106
+ if (group === 'results' && verb === 'get' && flags.has('year')) throw new NhsError('usage', 'Choose a result ID or a historical year.');
107
+ }
108
+
109
+ /** No diagnostics probe writes, migration, healthcare requests or secret values. */
110
+ export async function doctor(store) {
111
+ const checks = [{ name: 'node', ok: [22, 24].includes(Number(process.versions.node.split('.')[0])), detail: process.version }];
112
+ try { await store.probe(); checks.push({ name: 'secureStorage', ok: true, detail: store.backend }); }
113
+ catch (error) { checks.push({ name: 'secureStorage', ok: false, detail: error instanceof NhsError ? error.message : 'Secure storage unavailable.' }); }
114
+ return { ok: checks.every(check => check.ok), checks, backend: store.backend };
115
+ }
116
+
117
+ export async function execute(command, { env = process.env, transport = undefined, store: suppliedStore = undefined, config: suppliedConfig = undefined, ui = createUi() } = {}) {
118
+ validateCommand(command);
119
+ const { group, verb, id, flags } = command;
120
+ const config = suppliedConfig || settings(env);
121
+ const store = suppliedStore || new VaultStore({ directory: config.dataDir, backend: config.backend, env });
122
+ if (group === 'doctor') return doctor(store);
123
+ if (group === 'auth' && verb === 'status') {
124
+ const vault = await store.load();
125
+ return { ok: true, sessionStored: !!vault.session.sessionId, credentialsStored: !!vault.credentials, rememberedDevice: !!(vault.session.rmdToken || vault.session.rememberMyDevice), backend: store.backend, sessionValidity: 'not-checked' };
126
+ }
127
+ if (group === 'otp') {
128
+ if (!flags.has('messages-otp')) throw new NhsError('usage', 'Messages lookup requires --messages-otp.');
129
+ const age = positive(flags.get('max-age-minutes'), 15, 60);
130
+ await readOtpFromMessages({ since: Date.now() - age * 60000 });
131
+ return { ok: true, found: true, maxAgeMinutes: age };
132
+ }
133
+ return withLock(config.dataDir, async () => {
134
+ const vault = await migrateLegacy(store, config.legacyPath);
135
+ if (flags.has('migrate-credentials')) { vault.credentials = await legacyCredentials(); await store.save(vault); }
136
+ const auth = new AuthClient({ store, vault, transport, options: { env, onPhase: ui.phase, allowLogin: !flags.has('no-login'), allowPrompt: !!process.stdin.isTTY && !flags.has('no-prompt'), messages: flags.has('messages-otp'), forceOtp: flags.has('force-otp'), saveCredentials: flags.has('save-credentials') } });
137
+ if (group === 'auth' && verb === 'logout') return auth.logout(flags.has('forget'));
138
+ await auth.ensure({ reauth: flags.has('reauth') || flags.has('save-credentials') });
139
+ if (group === 'auth') return { ok: true, loggedIn: true, statePath: store.path, backend: store.backend };
140
+ const services = new NhsServices(auth);
141
+ if (group === 'capabilities') return { ok: true, capabilities: await services.discover() };
142
+ let data;
143
+ if (group === 'prescriptions') {
144
+ if (verb === 'order') return services.order({ ids: flags.get('ids'), all: flags.has('all-requestable'), note: String(flags.get('note') || ''), confirm: flags.has('confirm'), dryRun: flags.has('dry-run') });
145
+ if (verb !== 'history') return services.courses();
146
+ data = await services.history(flags.get('from'));
147
+ } else if (group === 'records') data = await services.record();
148
+ else if (group === 'results') data = await services.results({ id, year: flags.get('year') });
149
+ else if (group === 'appointments') data = await services.appointments(verb === 'slots');
150
+ else if (group === 'messages') data = await services.messages({ source: String(flags.get('source') || 'nhs'), id, index: flags.has('index') ? Number(flags.get('index')) : 0, count: positive(flags.get('count'), 20) });
151
+ else if (group === 'profile') data = await services.profile();
152
+ else if (group === 'pharmacy') data = await services.pharmacy();
153
+ else if (group === 'documents') {
154
+ data = await services.documents(id, verb === 'download');
155
+ if (verb === 'download') {
156
+ const path = await exportFile(String(flags.get('output')), data.content);
157
+ return { ok: true, downloaded: true, path, contentType: data.contentType, bytes: data.content.length };
158
+ }
159
+ }
160
+ return { ok: true, resource: group, checkedAt: new Date().toISOString(), data: publicData(data) };
161
+ });
162
+ }
163
+
164
+ export async function main(argv = process.argv.slice(2), legacy = false) {
165
+ const json = argv.includes('--json') || !process.stdout.isTTY;
166
+ const interactive = !json && !!process.stdin.isTTY && !!process.stderr.isTTY && !argv.includes('--no-prompt');
167
+ const ui = createUi({ enabled: interactive });
168
+ try {
169
+ const command = parseArgs(argv, legacy);
170
+ if (command.flags.has('version')) { process.stdout.write(`${packageInfo.version}\n`); return; }
171
+ if (command.flags.has('help') || command.group === 'help') { process.stdout.write(HELP); return; }
172
+ const payload = await execute(command, { ui });
173
+ const login = command.group === 'auth' && command.verb === 'login';
174
+ if (command.flags.has('output') && !(command.group === 'documents' && command.verb === 'download')) {
175
+ const path = await exportFile(String(command.flags.get('output')), render(payload, true));
176
+ process.stdout.write(render({ ok: true, exported: true, path }, json));
177
+ ui.finish();
178
+ } else {
179
+ ui.finish(login);
180
+ if (!(interactive && login)) process.stdout.write(render(payload, json));
181
+ }
182
+ if (!payload.ok) process.exitCode = 1;
183
+ } catch (error) {
184
+ ui.fail();
185
+ const output = render(errorPayload(error), json);
186
+ (json ? process.stdout : process.stderr).write(output);
187
+ process.exitCode = 1;
188
+ }
189
+ }
package/src/config.mjs ADDED
@@ -0,0 +1,29 @@
1
+ import { homedir } from 'node:os';
2
+ import { join, isAbsolute } from 'node:path';
3
+ import { NhsError } from './errors.mjs';
4
+
5
+ // Observed public web client, 2026-09-06. Update together with docs/api-research.md.
6
+ export const compatibility = Object.freeze({
7
+ webVersion: 'v4.76.3 (commit:bfaf34f72f)',
8
+ nativeVersion: 'web',
9
+ userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/131.0.0.0 Safari/537.36',
10
+ });
11
+ export const origins = Object.freeze({
12
+ app: 'https://www.nhsapp.service.nhs.uk',
13
+ api: 'https://api.nhsapp.service.nhs.uk',
14
+ gpconnect: 'https://gpconnectapi.nhsapp.service.nhs.uk/api',
15
+ login: 'https://api.login.nhs.uk',
16
+ authorize: 'https://auth.login.nhs.uk',
17
+ access: 'https://access.login.nhs.uk',
18
+ });
19
+ export const callback = `${origins.app}/auth-return`;
20
+ export const gpCallback = `${origins.app}/on-demand-gp-return`;
21
+
22
+ export function settings(env = process.env) {
23
+ const dataHome = env.XDG_DATA_HOME || join(homedir(), '.local', 'share');
24
+ const dataDir = env.NHS_CLI_DATA_DIR || join(dataHome, 'nhs-cli');
25
+ if (!isAbsolute(dataDir)) throw new NhsError('configuration_error', 'NHS_CLI_DATA_DIR and XDG_DATA_HOME must be absolute paths.');
26
+ const backend = env.NHS_CLI_STORAGE || 'keyring';
27
+ if (!['keyring', 'encrypted-file'].includes(backend)) throw new NhsError('configuration_error', 'NHS_CLI_STORAGE must be keyring or encrypted-file.');
28
+ return { dataDir, backend, legacyPath: join(homedir(), '.local', 'share', 'nhs-prescriptions', 'state.json') };
29
+ }
@@ -0,0 +1,19 @@
1
+ import { NhsError } from './errors.mjs';
2
+ import { promptInput } from './ui.mjs';
3
+
4
+ export function parseCredentialsPayload(raw) {
5
+ let value;
6
+ try { value = JSON.parse(raw); } catch { throw new NhsError('invalid_credentials', 'Injected credentials must be a JSON object containing email and password.'); }
7
+ if (!value || typeof value.email !== 'string' || !value.email.trim() || typeof value.password !== 'string' || !value.password) throw new NhsError('invalid_credentials', 'Credentials require a non-empty email and password.');
8
+ return { email: value.email.trim(), password: value.password };
9
+ }
10
+
11
+ export async function resolveCredentials(vault, { env = process.env, allowPrompt = false, prompt = promptInput } = {}) {
12
+ const injected = env.NHS_CLI_CREDENTIALS || env.NHS_PRESCRIPTIONS_CREDENTIALS;
13
+ if (injected) return { value: parseCredentialsPayload(injected), source: 'injected' };
14
+ if (vault.credentials) return { value: parseCredentialsPayload(JSON.stringify(vault.credentials)), source: 'stored' };
15
+ if (!allowPrompt) throw new NhsError('auth_required', 'No saved credentials are available. Run nhs auth login --save-credentials or configure secret injection.');
16
+ const email = await prompt('NHS login email', { validate: value => value?.trim() ? undefined : 'Enter your NHS login email.' });
17
+ const password = await prompt('NHS login password', { secret: true, validate: value => value ? undefined : 'Enter your password.' });
18
+ return { value: parseCredentialsPayload(JSON.stringify({ email, password })), source: 'prompt' };
19
+ }