@ovok/cli 0.0.0-stage → 0.1.0-dev.13

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/README.md CHANGED
@@ -1,3 +1,20 @@
1
- # Temporary Holding Version
1
+ # @ovok/cli
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ ```bash
4
+ printf %s "$SECRET" | ovok login --base-url https://api.sandbox.ovok.com --client-id <id> --client-secret-stdin
5
+ ovok listen --fhir Observation --forward-to http://localhost:3000/fhir
6
+ ovok listen --signals-url https://<your-tunnel>/signals --signals-event alert.triggered
7
+ ovok webhooks list
8
+ ```
9
+
10
+ - `listen` opens a FHIR WebSocket Subscription for each `--fhir` criteria and posts each resource to
11
+ `--forward-to`, signed with `X-Signature` the way a rest-hook Subscription is.
12
+ - With `--signals-url` it also creates a temporary Signals webhook that reaches every resident,
13
+ or with `--signals-reach attached` only the residents attached to it.
14
+ Signals posts to that URL itself, so it must be public, such as a tunnel to your machine.
15
+ - Ctrl+C removes everything `listen` created and names anything it could not remove. A second
16
+ Ctrl+C quits at once, leaving whatever was not removed yet.
17
+ - `webhooks` lists, creates, deletes, tests, enables, disables and rotates Signals webhooks.
18
+ - Signals routes need a manager app; FHIR Subscriptions need an app whose policy can create one.
19
+ - Profiles live in `~/.config/ovok/profiles.json`, readable by you only. In CI, set
20
+ `OVOK_BASE_URL`, `OVOK_CLIENT_ID` and `OVOK_CLIENT_SECRET` instead of logging in.
@@ -0,0 +1,120 @@
1
+ import { OvokApiError } from "./ovok-api.js";
2
+ import { bindMessage, readBinding, readNotification } from "./websocket-protocol.js";
3
+ const SUBSCRIPTION_PATH = "fhir/R4/Subscription";
4
+ const HANDSHAKE_TIMEOUT_MS = 10_000;
5
+ const BINDING_RENEWAL_MARGIN_MS = 60_000;
6
+ const NOT_FOUND = 404;
7
+ /** FHIR WebSocket Subscriptions through Ovok's API host, as `ovok listen` opens them. */
8
+ export class FhirWebSocketSubscriptions {
9
+ #api;
10
+ #openSocket;
11
+ constructor(api, openSocket = (url) => new WebSocket(url)) {
12
+ this.#api = api;
13
+ this.#openSocket = openSocket;
14
+ }
15
+ async open(criteria, listener) {
16
+ const created = await this.#api.request("POST", SUBSCRIPTION_PATH, websocketSubscription(criteria));
17
+ if (typeof created?.id !== "string")
18
+ throw new Error("Ovok's Subscription answer had no id.");
19
+ const subscription = new LiveSubscription(this.#api, created.id, listener);
20
+ try {
21
+ await subscription.connect(this.#openSocket);
22
+ return subscription;
23
+ }
24
+ catch (error) {
25
+ await subscription.close().catch(() => undefined);
26
+ throw error;
27
+ }
28
+ }
29
+ }
30
+ function websocketSubscription(criteria) {
31
+ return {
32
+ resourceType: "Subscription",
33
+ status: "active",
34
+ reason: "ovok listen",
35
+ criteria,
36
+ channel: { type: "websocket" },
37
+ };
38
+ }
39
+ class LiveSubscription {
40
+ id;
41
+ #api;
42
+ #listener;
43
+ #socket = null;
44
+ #renewal = null;
45
+ #closing = false;
46
+ constructor(api, id, listener) {
47
+ this.#api = api;
48
+ this.id = id;
49
+ this.#listener = listener;
50
+ }
51
+ async connect(openSocket) {
52
+ const binding = await this.#binding();
53
+ const socket = openSocket(binding.websocketUrl);
54
+ this.#socket = socket;
55
+ await handshake(socket, binding.token);
56
+ socket.addEventListener("message", (event) => this.#receive(event.data));
57
+ socket.addEventListener("close", (event) => this.#closed(event.code));
58
+ this.#scheduleRenewal(binding);
59
+ }
60
+ /** Medplum drops a websocket Subscription once its socket closes, so the delete may find nothing. */
61
+ async close() {
62
+ this.#closing = true;
63
+ if (this.#renewal !== null)
64
+ clearTimeout(this.#renewal);
65
+ this.#socket?.close();
66
+ await this.#api.request("DELETE", `${SUBSCRIPTION_PATH}/${this.id}`).catch(ignoreNotFound);
67
+ }
68
+ async #binding() {
69
+ const path = `${SUBSCRIPTION_PATH}/${this.id}/$get-ws-binding-token`;
70
+ return readBinding(await this.#api.request("GET", path));
71
+ }
72
+ #receive(data) {
73
+ const notification = readNotification(data);
74
+ if (notification?.type !== "event-notification")
75
+ return;
76
+ for (const resource of notification.resources)
77
+ this.#listener.onResource(resource);
78
+ }
79
+ #closed(code) {
80
+ if (!this.#closing)
81
+ this.#listener.onLost(`Ovok closed the subscription socket (code ${code}).`);
82
+ }
83
+ #scheduleRenewal(binding) {
84
+ if (Number.isNaN(binding.expiresAt))
85
+ return;
86
+ const delay = Math.max(binding.expiresAt - Date.now() - BINDING_RENEWAL_MARGIN_MS, 0);
87
+ this.#renewal = setTimeout(() => void this.#renew(), delay);
88
+ }
89
+ async #renew() {
90
+ try {
91
+ const binding = await this.#binding();
92
+ this.#socket?.send(bindMessage(binding.token));
93
+ this.#scheduleRenewal(binding);
94
+ }
95
+ catch (error) {
96
+ this.#listener.onLost(`The subscription binding could not be renewed: ${messageOf(error)}`);
97
+ }
98
+ }
99
+ }
100
+ function handshake(socket, token) {
101
+ const { promise, resolve, reject } = Promise.withResolvers();
102
+ const settled = new AbortController();
103
+ const timer = setTimeout(() => reject(new Error("Ovok did not confirm the subscription in time.")), HANDSHAKE_TIMEOUT_MS);
104
+ const options = { signal: settled.signal };
105
+ socket.addEventListener("open", () => socket.send(bindMessage(token)), options);
106
+ socket.addEventListener("message", (event) => readNotification(event.data)?.type === "handshake" && resolve(), options);
107
+ socket.addEventListener("error", () => reject(new Error("The subscription socket failed.")), options);
108
+ socket.addEventListener("close", () => reject(new Error("The subscription socket closed.")), options);
109
+ return promise.finally(() => {
110
+ clearTimeout(timer);
111
+ settled.abort();
112
+ });
113
+ }
114
+ function ignoreNotFound(error) {
115
+ if (!(error instanceof OvokApiError && error.status === NOT_FOUND))
116
+ throw error;
117
+ }
118
+ function messageOf(error) {
119
+ return error instanceof Error ? error.message : String(error);
120
+ }
@@ -0,0 +1,31 @@
1
+ import { createHmac } from "node:crypto";
2
+ const FORWARD_TIMEOUT_MS = 10_000;
3
+ /** Posts each resource the way a FHIR rest-hook Subscription delivers it, signed with `X-Signature`. */
4
+ export class HttpForwarder {
5
+ #target;
6
+ #secret;
7
+ #fetch;
8
+ constructor(target, secret, fetchImpl = fetch) {
9
+ this.#target = target;
10
+ this.#secret = secret;
11
+ this.#fetch = fetchImpl;
12
+ }
13
+ async forward(resource) {
14
+ const body = JSON.stringify(resource);
15
+ const response = await this.#fetch(this.#target, {
16
+ method: "POST",
17
+ headers: {
18
+ "content-type": "application/fhir+json",
19
+ "x-signature": signatureOf(body, this.#secret),
20
+ },
21
+ body,
22
+ signal: AbortSignal.timeout(FORWARD_TIMEOUT_MS),
23
+ });
24
+ await response.body?.cancel();
25
+ return { status: response.status };
26
+ }
27
+ }
28
+ /** Medplum's rest-hook signature: the hex HMAC-SHA256 of the body under the Subscription's secret. */
29
+ export function signatureOf(body, secret) {
30
+ return createHmac("sha256", secret).update(body).digest("hex");
31
+ }
@@ -0,0 +1,99 @@
1
+ const TOKEN_PATH = "oauth2/token";
2
+ const TOKEN_RENEWAL_MARGIN_MS = 60_000;
3
+ const FALLBACK_TOKEN_LIFETIME_SECONDS = 3600;
4
+ const REQUEST_TIMEOUT_MS = 30_000;
5
+ export class OvokApiError extends Error {
6
+ status;
7
+ constructor(status, message) {
8
+ super(message);
9
+ this.status = status;
10
+ }
11
+ }
12
+ /** Ovok's HTTP API as one client application, holding its token until shortly before it expires. */
13
+ export class OvokApi {
14
+ #profile;
15
+ #fetch;
16
+ #now;
17
+ #token = null;
18
+ constructor(profile, fetchImpl = fetch, now = Date.now) {
19
+ this.#profile = profile;
20
+ this.#fetch = fetchImpl;
21
+ this.#now = now;
22
+ }
23
+ async verify() {
24
+ await this.accessToken();
25
+ }
26
+ async accessToken() {
27
+ if (this.#token === null || this.#now() >= this.#token.expiresAt - TOKEN_RENEWAL_MARGIN_MS) {
28
+ this.#token = await this.#requestToken();
29
+ }
30
+ return this.#token.value;
31
+ }
32
+ async request(method, path, body) {
33
+ const json = body !== undefined;
34
+ const response = await this.#fetch(this.#profile.baseUrl.resolve(path), {
35
+ method,
36
+ headers: {
37
+ authorization: `Bearer ${await this.accessToken()}`,
38
+ accept: "application/json, application/fhir+json",
39
+ ...(json ? { "content-type": "application/json" } : {}),
40
+ },
41
+ body: json ? JSON.stringify(body) : null,
42
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
43
+ });
44
+ return (await readJson(response, `${method} ${path}`));
45
+ }
46
+ async #requestToken() {
47
+ const response = await this.#fetch(this.#profile.baseUrl.resolve(TOKEN_PATH), {
48
+ method: "POST",
49
+ headers: { "content-type": "application/x-www-form-urlencoded" },
50
+ body: new URLSearchParams({
51
+ grant_type: "client_credentials",
52
+ client_id: this.#profile.clientId,
53
+ client_secret: this.#profile.clientSecret,
54
+ }),
55
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
56
+ });
57
+ const token = (await readJson(response, "Sign-in"));
58
+ if (typeof token?.access_token !== "string") {
59
+ throw new OvokApiError(response.status, "Ovok's token answer had no access token.");
60
+ }
61
+ const lifetimeSeconds = typeof token.expires_in === "number" ? token.expires_in : FALLBACK_TOKEN_LIFETIME_SECONDS;
62
+ return { value: token.access_token, expiresAt: this.#now() + lifetimeSeconds * 1000 };
63
+ }
64
+ }
65
+ async function readJson(response, action) {
66
+ const text = await response.text();
67
+ const body = text === "" ? null : parseJson(text);
68
+ if (!response.ok) {
69
+ throw new OvokApiError(response.status, `${action} failed (${response.status}): ${detailOf(body)}`);
70
+ }
71
+ return body;
72
+ }
73
+ function parseJson(text) {
74
+ try {
75
+ return JSON.parse(text);
76
+ }
77
+ catch {
78
+ return text;
79
+ }
80
+ }
81
+ /** The readable part of an error body: problem+json, a FHIR OperationOutcome, an OAuth error or plain text. */
82
+ export function detailOf(body) {
83
+ if (typeof body === "string")
84
+ return body;
85
+ if (typeof body !== "object" || body === null)
86
+ return "no details";
87
+ const fields = body;
88
+ const issue = Array.isArray(fields.issue) ? fields.issue[0] : null;
89
+ const candidates = [
90
+ fields.detail,
91
+ fields.error_description,
92
+ issue?.diagnostics,
93
+ issue?.details?.text,
94
+ fields.title,
95
+ fields.message,
96
+ ];
97
+ const found = candidates.find((candidate) => typeof candidate === "string");
98
+ return typeof found === "string" ? found : "no details";
99
+ }
@@ -0,0 +1,60 @@
1
+ import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { ApiBaseUrl } from "../domain/api-base-url.js";
5
+ const OWNER_ONLY_DIRECTORY = 0o700;
6
+ const OWNER_ONLY_FILE = 0o600;
7
+ export function configDirectory(env) {
8
+ return join(env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "ovok");
9
+ }
10
+ /** Profiles in one JSON file that only its owner can read, as client secrets are in it. */
11
+ export class ProfileFileStore {
12
+ #directory;
13
+ #file;
14
+ constructor(directory) {
15
+ this.#directory = directory;
16
+ this.#file = join(directory, "profiles.json");
17
+ }
18
+ async find(name) {
19
+ const stored = (await this.#read()).profiles[name];
20
+ return stored === undefined ? null : toProfile(name, stored);
21
+ }
22
+ async save(profile) {
23
+ const all = await this.#read();
24
+ all.profiles[profile.name] = toStored(profile);
25
+ await this.#write(all);
26
+ }
27
+ async remove(name) {
28
+ const { profiles } = await this.#read();
29
+ if (!Object.hasOwn(profiles, name))
30
+ return false;
31
+ const remaining = Object.fromEntries(Object.entries(profiles).filter(([key]) => key !== name));
32
+ await this.#write({ profiles: remaining });
33
+ return true;
34
+ }
35
+ async #read() {
36
+ try {
37
+ return JSON.parse(await readFile(this.#file, "utf8"));
38
+ }
39
+ catch (error) {
40
+ if (error.code === "ENOENT")
41
+ return { profiles: {} };
42
+ throw error;
43
+ }
44
+ }
45
+ async #write(all) {
46
+ await mkdir(this.#directory, { recursive: true, mode: OWNER_ONLY_DIRECTORY });
47
+ await writeFile(this.#file, `${JSON.stringify(all, null, 2)}\n`, { mode: OWNER_ONLY_FILE });
48
+ await chmod(this.#file, OWNER_ONLY_FILE);
49
+ }
50
+ }
51
+ function toProfile(name, stored) {
52
+ return { ...stored, name, baseUrl: ApiBaseUrl.parse(stored.baseUrl) };
53
+ }
54
+ function toStored(profile) {
55
+ return {
56
+ baseUrl: profile.baseUrl.toString(),
57
+ clientId: profile.clientId,
58
+ clientSecret: profile.clientSecret,
59
+ };
60
+ }
@@ -0,0 +1,58 @@
1
+ const USERS_PATH = "v1/signals/api/user";
2
+ const SUBSCRIBER_PATH = "v1/signals/api/subscriber";
3
+ const SUBSCRIBER_ROLE = "SUBSCRIBER";
4
+ const LIST_PAGE_SIZE = 100;
5
+ /** Signals subscribers through Ovok's Signals gateway, which only lets a manager touch subscribers. */
6
+ export class SignalsGatewayWebhooks {
7
+ #api;
8
+ constructor(api) {
9
+ this.#api = api;
10
+ }
11
+ async list() {
12
+ const page = await this.#api.request("GET", `${USERS_PATH}?limit=${LIST_PAGE_SIZE}`);
13
+ return (page.items ?? []).filter(isSubscriber).map(toSubscriber);
14
+ }
15
+ async create(request) {
16
+ const created = await this.#api.request("POST", USERS_PATH, {
17
+ role: SUBSCRIBER_ROLE,
18
+ name: request.name,
19
+ webhookUrl: request.webhookUrl,
20
+ events: request.events,
21
+ reach: request.reach,
22
+ });
23
+ const signingSecret = typeof created.signingSecret === "string" ? created.signingSecret : null;
24
+ return { ...toSubscriber(created), signingSecret };
25
+ }
26
+ async remove(id) {
27
+ await this.#api.request("DELETE", `${USERS_PATH}/${encodeURIComponent(id)}`);
28
+ }
29
+ test(id) {
30
+ return this.#api.request("POST", webhookPath(id, "test"));
31
+ }
32
+ rotateSecret(id) {
33
+ return this.#api.request("POST", webhookPath(id, "rotate-secret"));
34
+ }
35
+ async enable(id) {
36
+ await this.#api.request("POST", webhookPath(id, "enable"));
37
+ }
38
+ async disable(id) {
39
+ await this.#api.request("POST", webhookPath(id, "disable"));
40
+ }
41
+ }
42
+ function webhookPath(id, action) {
43
+ return `${SUBSCRIBER_PATH}/${encodeURIComponent(id)}/webhook/${action}`;
44
+ }
45
+ function isSubscriber(user) {
46
+ return String(user.role).toUpperCase() === SUBSCRIBER_ROLE;
47
+ }
48
+ export function toSubscriber(user) {
49
+ if (typeof user.id !== "string")
50
+ throw new Error("Signals answered a subscriber without an id.");
51
+ return {
52
+ id: user.id,
53
+ name: typeof user.name === "string" ? user.name : null,
54
+ webhookUrl: typeof user.webhookUrl === "string" ? user.webhookUrl : null,
55
+ events: Array.isArray(user.events) ? user.events.map(String) : [],
56
+ reach: user.reach === "all" ? "all" : "attached",
57
+ };
58
+ }
@@ -0,0 +1,36 @@
1
+ /** One line per event, naming resources by type and id only: their bodies can carry health data. */
2
+ export class TerminalListenOutput {
3
+ #print;
4
+ constructor(print) {
5
+ this.#print = print;
6
+ }
7
+ subscriptionOpened(subscription, criteria) {
8
+ this.#print(`Opened FHIR Subscription/${subscription.id} for "${criteria}".`);
9
+ }
10
+ signalsWebhookCreated(subscriber) {
11
+ this.#print(`Created Signals webhook ${subscriber.id} for ${subscriber.events.join(", ")} -> ${subscriber.webhookUrl ?? "no URL"}.`);
12
+ if (subscriber.signingSecret !== null) {
13
+ this.#print(`Signals signs each delivery (Standard Webhooks) with ${subscriber.signingSecret}.`);
14
+ }
15
+ }
16
+ forwarded(resource, delivery) {
17
+ this.#print(`${timestamp()} ${referenceOf(resource)} -> ${delivery.status}`);
18
+ }
19
+ forwardFailed(resource, error) {
20
+ this.#print(`${timestamp()} ${referenceOf(resource)} -> failed: ${messageOf(error)}`);
21
+ }
22
+ cleanedUp(outcome) {
23
+ this.#print(outcome.error === null
24
+ ? `Removed ${outcome.label}.`
25
+ : `Could not remove ${outcome.label}: ${messageOf(outcome.error)}. Remove it yourself.`);
26
+ }
27
+ }
28
+ function referenceOf(resource) {
29
+ return `${resource.resourceType}/${resource.id ?? "?"}`;
30
+ }
31
+ function timestamp() {
32
+ return new Date().toISOString().slice(11, 19);
33
+ }
34
+ function messageOf(error) {
35
+ return error instanceof Error ? error.message : String(error);
36
+ }
@@ -0,0 +1,43 @@
1
+ export function bindMessage(token) {
2
+ return JSON.stringify({ type: "bind-with-token", payload: { token } });
3
+ }
4
+ export function readBinding(parameters) {
5
+ const list = parameters?.parameter ?? [];
6
+ const parameterValue = (name) => {
7
+ const parameter = list.find((entry) => entry.name === name);
8
+ return parameter?.valueString ?? parameter?.valueUrl ?? parameter?.valueDateTime;
9
+ };
10
+ const [token, websocketUrl, expiration] = [
11
+ parameterValue("token"),
12
+ parameterValue("websocket-url"),
13
+ parameterValue("expiration"),
14
+ ];
15
+ if (typeof token !== "string" || typeof websocketUrl !== "string") {
16
+ throw new Error("Ovok's binding token answer had no token or websocket URL.");
17
+ }
18
+ const expiresAt = typeof expiration === "string" ? Date.parse(expiration) : Number.NaN;
19
+ return { token, websocketUrl, expiresAt };
20
+ }
21
+ export function readNotification(data) {
22
+ const message = typeof data === "string" ? parseObject(data) : null;
23
+ if (message?.resourceType !== "Bundle" || !Array.isArray(message.entry))
24
+ return null;
25
+ const [statusEntry, ...resourceEntries] = message.entry;
26
+ const status = statusEntry?.resource;
27
+ if (status?.resourceType !== "SubscriptionStatus" || typeof status.type !== "string")
28
+ return null;
29
+ return { type: status.type, resources: resourceEntries.flatMap(resourceOf) };
30
+ }
31
+ function resourceOf(entry) {
32
+ const resource = entry.resource;
33
+ return typeof resource?.resourceType === "string" ? [resource] : [];
34
+ }
35
+ function parseObject(text) {
36
+ try {
37
+ const value = JSON.parse(text);
38
+ return typeof value === "object" && value !== null ? value : null;
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ }
@@ -0,0 +1,52 @@
1
+ import { Cleanup } from "../domain/cleanup.js";
2
+ /** Everything a listen session sets up on Ovok, and its removal when the session stops. */
3
+ export class ListenSession {
4
+ #deps;
5
+ #cleanup = new Cleanup();
6
+ #lost = Promise.withResolvers();
7
+ #forwarding = Promise.resolve();
8
+ constructor(deps) {
9
+ this.#deps = deps;
10
+ }
11
+ /** Settles when Ovok drops a subscription, so the session can stop instead of hearing nothing. */
12
+ get lost() {
13
+ return this.#lost.promise;
14
+ }
15
+ async openFhir(criteria, forwarder) {
16
+ const subscription = await this.#deps.fhir.open(criteria, {
17
+ onResource: (resource) => this.#enqueue(resource, forwarder),
18
+ onLost: (reason) => this.#lost.resolve(reason),
19
+ });
20
+ this.#cleanup.add({
21
+ label: `FHIR Subscription/${subscription.id}`,
22
+ run: () => subscription.close(),
23
+ });
24
+ this.#deps.output.subscriptionOpened(subscription, criteria);
25
+ }
26
+ /** Signals posts to the URL itself, so it must be one Signals can reach, such as a tunnel. */
27
+ async openSignals(request) {
28
+ const subscriber = await this.#deps.signals.create(request);
29
+ this.#cleanup.add({
30
+ label: `Signals webhook ${subscriber.id}`,
31
+ run: () => this.#deps.signals.remove(subscriber.id),
32
+ });
33
+ this.#deps.output.signalsWebhookCreated(subscriber);
34
+ }
35
+ async stop() {
36
+ for (const outcome of await this.#cleanup.run()) {
37
+ this.#deps.output.cleanedUp(outcome);
38
+ }
39
+ await this.#forwarding;
40
+ }
41
+ #enqueue(resource, forwarder) {
42
+ this.#forwarding = this.#forwarding.then(() => this.#forwardOne(resource, forwarder));
43
+ }
44
+ async #forwardOne(resource, forwarder) {
45
+ try {
46
+ this.#deps.output.forwarded(resource, await forwarder.forward(resource));
47
+ }
48
+ catch (error) {
49
+ this.#deps.output.forwardFailed(resource, error);
50
+ }
51
+ }
52
+ }
@@ -0,0 +1,5 @@
1
+ /** Keeps a profile only once Ovok has accepted its credentials. */
2
+ export async function login(profile, verifier, store) {
3
+ await verifier.verify();
4
+ await store.save(profile);
5
+ }
@@ -0,0 +1,23 @@
1
+ import { ApiBaseUrl } from "../domain/api-base-url.js";
2
+ export const ENVIRONMENT_PROFILE = "environment";
3
+ export class NotSignedInError extends Error {
4
+ }
5
+ /** CI sets all three variables; anything less falls back to a saved profile. */
6
+ export function profileFromEnvironment(env) {
7
+ const { OVOK_BASE_URL, OVOK_CLIENT_ID, OVOK_CLIENT_SECRET } = env;
8
+ if (!OVOK_BASE_URL || !OVOK_CLIENT_ID || !OVOK_CLIENT_SECRET)
9
+ return null;
10
+ return {
11
+ name: ENVIRONMENT_PROFILE,
12
+ baseUrl: ApiBaseUrl.parse(OVOK_BASE_URL),
13
+ clientId: OVOK_CLIENT_ID,
14
+ clientSecret: OVOK_CLIENT_SECRET,
15
+ };
16
+ }
17
+ export async function resolveProfile(name, store, env) {
18
+ const profile = profileFromEnvironment(env) ?? (await store.find(name));
19
+ if (profile === null) {
20
+ throw new NotSignedInError(`No profile "${name}". Run "ovok login" first.`);
21
+ }
22
+ return profile;
23
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,11 @@
1
+ /** The caller asked for something the command cannot read; the exit code says so. */
2
+ export class UsageError extends Error {
3
+ }
4
+ export async function readAll(input) {
5
+ const decoder = new TextDecoder();
6
+ let text = "";
7
+ for await (const chunk of input) {
8
+ text += typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true });
9
+ }
10
+ return text.trim();
11
+ }
@@ -0,0 +1,94 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { parseArgs } from "node:util";
3
+ import { FhirWebSocketSubscriptions } from "../adapters/fhir-websocket-subscriptions.js";
4
+ import { HttpForwarder } from "../adapters/http-forwarder.js";
5
+ import { SignalsGatewayWebhooks } from "../adapters/signals-gateway-webhooks.js";
6
+ import { TerminalListenOutput } from "../adapters/terminal-listen-output.js";
7
+ import { ListenSession } from "../application/listen.js";
8
+ import { resolveProfile } from "../application/profiles.js";
9
+ import { DEFAULT_PROFILE } from "../domain/profile.js";
10
+ import { UsageError } from "./command.js";
11
+ import { reachOf } from "./reach-option.js";
12
+ const SIGNING_SECRET_BYTES = 32;
13
+ const DEFAULT_SIGNALS_EVENTS = ["alert.*"];
14
+ export const listenCommand = async (args, context) => {
15
+ const options = listenOptionsOf(args);
16
+ const api = context.connect(await resolveProfile(options.profile, context.profiles, context.env));
17
+ const session = new ListenSession({
18
+ fhir: new FhirWebSocketSubscriptions(api),
19
+ signals: new SignalsGatewayWebhooks(api),
20
+ output: new TerminalListenOutput(context.print),
21
+ });
22
+ const stop = context.stopSignal();
23
+ let lostReason = null;
24
+ try {
25
+ await openFhir(session, options, context);
26
+ await openSignals(session, options);
27
+ context.print("Ready. Press Ctrl+C to stop; what is listed above is removed then.");
28
+ lostReason = await Promise.race([stop.stopped.then(() => null), session.lost]);
29
+ }
30
+ finally {
31
+ await session.stop();
32
+ stop.dispose();
33
+ }
34
+ if (lostReason !== null)
35
+ throw new Error(lostReason);
36
+ };
37
+ function listenOptionsOf(args) {
38
+ const { values } = parseArgs({
39
+ args,
40
+ options: {
41
+ profile: { type: "string", default: DEFAULT_PROFILE },
42
+ fhir: { type: "string", multiple: true, default: [] },
43
+ "forward-to": { type: "string" },
44
+ "signals-url": { type: "string" },
45
+ "signals-event": { type: "string", multiple: true, default: DEFAULT_SIGNALS_EVENTS },
46
+ "signals-reach": { type: "string", default: "all" },
47
+ },
48
+ });
49
+ const options = {
50
+ profile: values.profile,
51
+ fhir: values.fhir,
52
+ forwardTo: values["forward-to"] === undefined ? null : URL.parse(values["forward-to"]),
53
+ signalsUrl: values["signals-url"] ?? null,
54
+ signalsEvents: values["signals-event"],
55
+ signalsReach: reachOf(values["signals-reach"], "--signals-reach"),
56
+ };
57
+ assertListensToSomething(options);
58
+ return options;
59
+ }
60
+ function assertListensToSomething(options) {
61
+ if (options.fhir.length === 0 && options.signalsUrl === null) {
62
+ throw new UsageError("Pass --fhir <criteria> with --forward-to <url>, --signals-url <url>, or both.");
63
+ }
64
+ if (options.fhir.length > 0 && options.forwardTo === null) {
65
+ throw new UsageError("--fhir needs --forward-to <url>, such as http://localhost:3000/fhir.");
66
+ }
67
+ }
68
+ async function openFhir(session, options, context) {
69
+ if (options.forwardTo === null)
70
+ return;
71
+ const secret = signingSecretOf(context);
72
+ const forwarder = new HttpForwarder(options.forwardTo, secret.value);
73
+ for (const criteria of options.fhir)
74
+ await session.openFhir(criteria, forwarder);
75
+ context.print(`Forwarding FHIR events to ${options.forwardTo}, signed with X-Signature: ${secret.description}.`);
76
+ }
77
+ async function openSignals(session, options) {
78
+ if (options.signalsUrl === null)
79
+ return;
80
+ await session.openSignals({
81
+ name: `ovok listen ${new Date().toISOString()}`,
82
+ webhookUrl: options.signalsUrl,
83
+ events: options.signalsEvents,
84
+ reach: options.signalsReach,
85
+ });
86
+ }
87
+ function signingSecretOf(context) {
88
+ const fromEnvironment = context.env.OVOK_LISTEN_SECRET;
89
+ if (fromEnvironment) {
90
+ return { value: fromEnvironment, description: "the secret in OVOK_LISTEN_SECRET" };
91
+ }
92
+ const value = randomBytes(SIGNING_SECRET_BYTES).toString("hex");
93
+ return { value, description: `HMAC-SHA256 with secret ${value}` };
94
+ }
@@ -0,0 +1,40 @@
1
+ import { parseArgs } from "node:util";
2
+ import { login } from "../application/login.js";
3
+ import { ApiBaseUrl } from "../domain/api-base-url.js";
4
+ import { DEFAULT_PROFILE } from "../domain/profile.js";
5
+ import { readAll, UsageError } from "./command.js";
6
+ const DEFAULT_BASE_URL = "https://api.sandbox.ovok.com";
7
+ export const loginCommand = async (args, context) => {
8
+ const { values } = parseArgs({
9
+ args,
10
+ options: {
11
+ profile: { type: "string", default: DEFAULT_PROFILE },
12
+ "base-url": { type: "string", default: DEFAULT_BASE_URL },
13
+ "client-id": { type: "string" },
14
+ "client-secret-stdin": { type: "boolean", default: false },
15
+ },
16
+ });
17
+ const profile = {
18
+ name: values.profile,
19
+ baseUrl: ApiBaseUrl.parse(values["base-url"]),
20
+ clientId: clientIdOf(values["client-id"], context),
21
+ clientSecret: await clientSecretOf(values["client-secret-stdin"], context),
22
+ };
23
+ await login(profile, context.connect(profile), context.profiles);
24
+ context.print(`Signed in to ${profile.baseUrl} as ${profile.clientId}.`);
25
+ context.print(`Saved as profile "${profile.name}".`);
26
+ };
27
+ function clientIdOf(flag, context) {
28
+ const clientId = flag ?? context.env.OVOK_CLIENT_ID;
29
+ if (!clientId)
30
+ throw new UsageError("Pass --client-id, or set OVOK_CLIENT_ID.");
31
+ return clientId;
32
+ }
33
+ /** Never a flag value: those end up in shell history and process lists. */
34
+ async function clientSecretOf(fromStdin, context) {
35
+ const secret = fromStdin ? await readAll(context.stdin) : context.env.OVOK_CLIENT_SECRET;
36
+ if (!secret) {
37
+ throw new UsageError("Pipe the client secret in with --client-secret-stdin, or set OVOK_CLIENT_SECRET.");
38
+ }
39
+ return secret;
40
+ }
@@ -0,0 +1,10 @@
1
+ import { parseArgs } from "node:util";
2
+ import { DEFAULT_PROFILE } from "../domain/profile.js";
3
+ export const logoutCommand = async (args, context) => {
4
+ const { values } = parseArgs({
5
+ args,
6
+ options: { profile: { type: "string", default: DEFAULT_PROFILE } },
7
+ });
8
+ const removed = await context.profiles.remove(values.profile);
9
+ context.print(removed ? `Removed profile "${values.profile}".` : `There is no profile "${values.profile}".`);
10
+ };
@@ -0,0 +1,8 @@
1
+ import { UsageError } from "./command.js";
2
+ const REACHES = ["all", "attached"];
3
+ export function reachOf(raw, flag) {
4
+ const reach = REACHES.find((candidate) => candidate === raw);
5
+ if (reach === undefined)
6
+ throw new UsageError(`${flag} is ${REACHES.join(" or ")}.`);
7
+ return reach;
8
+ }
@@ -0,0 +1,90 @@
1
+ import { parseArgs } from "node:util";
2
+ import { SignalsGatewayWebhooks } from "../adapters/signals-gateway-webhooks.js";
3
+ import { resolveProfile } from "../application/profiles.js";
4
+ import { DEFAULT_PROFILE } from "../domain/profile.js";
5
+ import { UsageError } from "./command.js";
6
+ import { reachOf } from "./reach-option.js";
7
+ const DEFAULT_EVENTS = ["alert.*"];
8
+ const ACTIONS = {
9
+ list: async (webhooks, _input, print) => {
10
+ const subscribers = await webhooks.list();
11
+ if (subscribers.length === 0)
12
+ print("No Signals webhooks.");
13
+ for (const subscriber of subscribers)
14
+ print(describe(subscriber));
15
+ },
16
+ create: async (webhooks, input, print) => {
17
+ const created = await webhooks.create({
18
+ name: input.name,
19
+ webhookUrl: required(input.url, "--url <url>"),
20
+ events: input.events,
21
+ reach: input.reach,
22
+ });
23
+ print(`Created ${describe(created)}`);
24
+ if (created.signingSecret !== null) {
25
+ print(`Signing secret, shown this once: ${created.signingSecret}`);
26
+ }
27
+ },
28
+ delete: async (webhooks, input, print) => {
29
+ const id = required(input.id, "<id>");
30
+ await webhooks.remove(id);
31
+ print(`Deleted Signals webhook ${id}.`);
32
+ },
33
+ test: async (webhooks, input, print) => {
34
+ const delivery = await webhooks.test(required(input.id, "<id>"));
35
+ const answer = delivery.statusCode ?? delivery.error ?? "no answer";
36
+ print(`${delivery.delivered ? "Delivered" : "Not delivered"}: ${answer} in ${delivery.durationMs} ms.`);
37
+ },
38
+ "rotate-secret": async (webhooks, input, print) => {
39
+ const rotated = await webhooks.rotateSecret(required(input.id, "<id>"));
40
+ print(`New signing secret, shown this once: ${rotated.secret}`);
41
+ if (rotated.previousSecretExpiresAt !== null) {
42
+ print(`The previous secret keeps signing until ${rotated.previousSecretExpiresAt}.`);
43
+ }
44
+ },
45
+ enable: async (webhooks, input, print) => {
46
+ await webhooks.enable(required(input.id, "<id>"));
47
+ print("Enabled.");
48
+ },
49
+ disable: async (webhooks, input, print) => {
50
+ await webhooks.disable(required(input.id, "<id>"));
51
+ print("Disabled.");
52
+ },
53
+ };
54
+ export const webhooksCommand = async (args, context) => {
55
+ const { values, positionals } = parseArgs({
56
+ args,
57
+ allowPositionals: true,
58
+ options: {
59
+ profile: { type: "string", default: DEFAULT_PROFILE },
60
+ url: { type: "string" },
61
+ event: { type: "string", multiple: true, default: DEFAULT_EVENTS },
62
+ name: { type: "string", default: "ovok webhooks create" },
63
+ reach: { type: "string", default: "all" },
64
+ },
65
+ });
66
+ const [actionName, id] = positionals;
67
+ const action = actionName === undefined ? undefined : ACTIONS[actionName];
68
+ if (action === undefined) {
69
+ throw new UsageError(`Name an action: ${Object.keys(ACTIONS).join(", ")}.`);
70
+ }
71
+ const input = {
72
+ id,
73
+ url: values.url,
74
+ events: values.event,
75
+ name: values.name,
76
+ reach: reachOf(values.reach, "--reach"),
77
+ };
78
+ const profile = await resolveProfile(values.profile, context.profiles, context.env);
79
+ const webhooks = new SignalsGatewayWebhooks(context.connect(profile));
80
+ await action(webhooks, input, context.print);
81
+ };
82
+ function required(value, name) {
83
+ if (value === undefined)
84
+ throw new UsageError(`This action needs ${name}.`);
85
+ return value;
86
+ }
87
+ function describe(subscriber) {
88
+ const url = subscriber.webhookUrl ?? "no URL";
89
+ return `${subscriber.id} ${url} [${subscriber.events.join(", ")}] reach ${subscriber.reach}`;
90
+ }
@@ -0,0 +1,28 @@
1
+ const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
2
+ export class InvalidBaseUrlError extends Error {
3
+ }
4
+ /** Where an Ovok API lives; plain http only reaches this machine, so a client secret never crosses a network unencrypted. */
5
+ export class ApiBaseUrl {
6
+ #url;
7
+ constructor(url) {
8
+ this.#url = url;
9
+ }
10
+ static parse(raw) {
11
+ const url = URL.parse(raw);
12
+ if (url === null)
13
+ throw new InvalidBaseUrlError(`"${raw}" is not a URL.`);
14
+ const encrypted = url.protocol === "https:";
15
+ const local = url.protocol === "http:" && LOCAL_HOSTS.has(url.hostname);
16
+ if (!encrypted && !local) {
17
+ throw new InvalidBaseUrlError(`${url.origin} must use https.`);
18
+ }
19
+ url.pathname = url.pathname.endsWith("/") ? url.pathname : `${url.pathname}/`;
20
+ return new ApiBaseUrl(url);
21
+ }
22
+ resolve(path) {
23
+ return new URL(path, this.#url);
24
+ }
25
+ toString() {
26
+ return this.#url.href;
27
+ }
28
+ }
@@ -0,0 +1,24 @@
1
+ /** Undoes what a session set up, newest first; a failing step never stops the rest. */
2
+ export class Cleanup {
3
+ #tasks = [];
4
+ add(task) {
5
+ this.#tasks.push(task);
6
+ }
7
+ async run() {
8
+ const pending = this.#tasks.splice(0).reverse();
9
+ const outcomes = [];
10
+ for (const task of pending) {
11
+ outcomes.push(await runTask(task));
12
+ }
13
+ return outcomes;
14
+ }
15
+ }
16
+ async function runTask(task) {
17
+ try {
18
+ await task.run();
19
+ return { label: task.label, error: null };
20
+ }
21
+ catch (error) {
22
+ return { label: task.label, error };
23
+ }
24
+ }
@@ -0,0 +1 @@
1
+ export const DEFAULT_PROFILE = "default";
package/dist/main.js ADDED
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env node
2
+ import { OvokApi } from "./adapters/ovok-api.js";
3
+ import { configDirectory, ProfileFileStore } from "./adapters/profile-file-store.js";
4
+ import { UsageError } from "./commands/command.js";
5
+ import { listenCommand } from "./commands/listen.js";
6
+ import { loginCommand } from "./commands/login.js";
7
+ import { logoutCommand } from "./commands/logout.js";
8
+ import { webhooksCommand } from "./commands/webhooks.js";
9
+ const EXIT_FAILURE = 1;
10
+ const EXIT_USAGE = 2;
11
+ const KEEP_ALIVE_INTERVAL_MS = 60_000;
12
+ const COMMANDS = {
13
+ listen: listenCommand,
14
+ login: loginCommand,
15
+ logout: logoutCommand,
16
+ webhooks: webhooksCommand,
17
+ };
18
+ const USAGE = `Usage: ovok <command> [options]
19
+
20
+ Commands:
21
+ login Sign in as a client application and save it as a profile
22
+ --base-url <url> --client-id <id> --client-secret-stdin [--profile <name>]
23
+ logout Forget a saved profile [--profile <name>]
24
+ listen Until Ctrl+C, forward FHIR events to a local URL and point a temporary Signals
25
+ webhook at a URL Signals can reach; both are removed when it stops
26
+ [--fhir <criteria>... --forward-to <url>]
27
+ [--signals-url <url> --signals-event <type>... --signals-reach all|attached]
28
+ webhooks Manage Signals webhooks (subscribers) through Ovok
29
+ list | create --url <url> [--event <type>...] [--reach all|attached] [--name <name>]
30
+ | delete <id> | test <id> | rotate-secret <id> | enable <id> | disable <id>
31
+
32
+ Every command takes --profile <name> (default "default"). OVOK_BASE_URL, OVOK_CLIENT_ID and
33
+ OVOK_CLIENT_SECRET, all three set, take the place of a saved profile.`;
34
+ async function main(argv) {
35
+ const [name, ...args] = argv;
36
+ const command = name === undefined ? undefined : COMMANDS[name];
37
+ if (command === undefined) {
38
+ console.log(USAGE);
39
+ return name === undefined || name === "help" || name === "--help" ? 0 : EXIT_USAGE;
40
+ }
41
+ try {
42
+ await command(args, createContext());
43
+ return 0;
44
+ }
45
+ catch (error) {
46
+ return reportFailure(error);
47
+ }
48
+ }
49
+ function createContext() {
50
+ return {
51
+ env: process.env,
52
+ profiles: new ProfileFileStore(configDirectory(process.env)),
53
+ connect: (profile) => new OvokApi(profile),
54
+ stdin: process.stdin,
55
+ print: (line) => console.log(line),
56
+ stopSignal,
57
+ };
58
+ }
59
+ /** Signal listeners alone do not keep Node running, so an idle timer does until the stop. */
60
+ function stopSignal() {
61
+ const { promise, resolve } = Promise.withResolvers();
62
+ const keepAlive = setInterval(() => { }, KEEP_ALIVE_INTERVAL_MS);
63
+ const stop = () => resolve();
64
+ process.once("SIGINT", stop);
65
+ process.once("SIGTERM", stop);
66
+ return {
67
+ stopped: promise,
68
+ dispose: () => {
69
+ clearInterval(keepAlive);
70
+ process.off("SIGINT", stop);
71
+ process.off("SIGTERM", stop);
72
+ },
73
+ };
74
+ }
75
+ function reportFailure(error) {
76
+ const message = error instanceof Error ? error.message : String(error);
77
+ console.error(`Error: ${message}`);
78
+ return isUsageError(error) ? EXIT_USAGE : EXIT_FAILURE;
79
+ }
80
+ function isUsageError(error) {
81
+ const code = error.code;
82
+ return (error instanceof UsageError || (typeof code === "string" && code.startsWith("ERR_PARSE_ARGS")));
83
+ }
84
+ process.exitCode = await main(process.argv.slice(2));
package/package.json CHANGED
@@ -1,6 +1,21 @@
1
1
  {
2
2
  "name": "@ovok/cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0-dev.13",
4
+ "description": "Work with Ovok from the terminal: listen to FHIR and Signals events and manage Signals webhooks.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "ovok": "dist/main.js"
9
+ },
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "engines": {
14
+ "node": ">=22.12"
15
+ },
16
+ "scripts": {
17
+ "build": "tsc -p tsconfig.build.json",
18
+ "typecheck": "tsc -p tsconfig.json",
19
+ "test": "node --test \"src/**/*.test.ts\" \"test/**/*.test.ts\""
20
+ }
21
+ }