@miosa/sdk 1.2.4 → 1.2.5

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.
@@ -66,6 +66,11 @@ export interface WebhookUpdateParams {
66
66
  [key: string]: unknown;
67
67
  }
68
68
 
69
+ export interface WebhookSignatureVerifyOptions {
70
+ /** Maximum age for the webhook timestamp in seconds. Defaults to 300. */
71
+ toleranceSeconds?: number;
72
+ }
73
+
69
74
  // ── Helpers ───────────────────────────────────────────────────────────────────
70
75
 
71
76
  function unwrap<T>(payload: unknown): T {
@@ -101,63 +106,73 @@ function idempotencyKey(key?: string): string {
101
106
  return key ?? randomUUID();
102
107
  }
103
108
 
104
- function bodyBuffer(body: Buffer | Uint8Array | string): Buffer {
105
- if (Buffer.isBuffer(body)) return body;
106
- if (typeof body === "string") return Buffer.from(body, "utf8");
107
- return Buffer.from(body);
109
+ function parseSignatureHeader(header: string): {
110
+ timestamp: number;
111
+ signatures: string[];
112
+ } | null {
113
+ const parts = header.split(",").map((part) => part.trim());
114
+ let timestamp: number | null = null;
115
+ const signatures: string[] = [];
116
+
117
+ for (const part of parts) {
118
+ const [key, value] = part.split("=", 2);
119
+ if (!key || !value) continue;
120
+ if (key === "t") {
121
+ const parsed = Number(value);
122
+ if (Number.isFinite(parsed)) timestamp = parsed;
123
+ } else if (key === "v1") {
124
+ signatures.push(value);
125
+ }
126
+ }
127
+
128
+ if (timestamp == null || signatures.length === 0) return null;
129
+ return { timestamp, signatures };
108
130
  }
109
131
 
110
132
  /**
111
- * Verify the `Miosa-Signature` webhook header.
133
+ * Verify a MIOSA webhook signature header.
112
134
  *
113
- * Header format: `t=<unix_seconds>,v1=<hex_hmac>`.
114
- * Signed payload: `<timestamp>.<raw_body>`.
135
+ * Header format: `t=<unix_seconds>,v1=<hex_hmac_sha256>`.
115
136
  */
116
137
  export function verifySignature(
117
- body: Buffer | Uint8Array | string,
138
+ body: string | Buffer | Uint8Array,
118
139
  header: string,
119
140
  secret: string,
120
- toleranceSec = 300,
141
+ options: WebhookSignatureVerifyOptions = {},
121
142
  ): boolean {
122
- const parts = Object.fromEntries(
123
- header
124
- .split(",")
125
- .map((chunk) => chunk.split("=", 2))
126
- .filter(([key, value]) => key && value),
127
- );
128
-
129
- const timestamp = parts.t;
130
- const received = parts.v1;
131
- if (!timestamp || !received || !secret) return false;
132
-
133
- const unixSeconds = Number(timestamp);
134
- if (!Number.isFinite(unixSeconds)) return false;
143
+ const parsed = parseSignatureHeader(header);
144
+ if (!parsed) return false;
135
145
 
136
- const ageSec = Math.abs(Date.now() / 1000 - unixSeconds);
137
- if (ageSec > toleranceSec) {
138
- throw new Error("webhook timestamp too old");
146
+ const toleranceSeconds = options.toleranceSeconds ?? 300;
147
+ const ageSeconds = Math.abs(Math.floor(Date.now() / 1000) - parsed.timestamp);
148
+ if (ageSeconds > toleranceSeconds) {
149
+ throw new Error("Webhook signature timestamp is too old");
139
150
  }
140
151
 
141
- const rawBody = bodyBuffer(body);
142
- const signed = Buffer.concat([Buffer.from(`${timestamp}.`), rawBody]);
143
- const expected = createHmac("sha256", secret).update(signed).digest("hex");
144
-
145
- try {
146
- return timingSafeEqual(
147
- Buffer.from(expected, "utf8"),
148
- Buffer.from(received, "utf8"),
149
- );
150
- } catch {
151
- return false;
152
- }
152
+ const bodyBuffer = Buffer.isBuffer(body) ? body : Buffer.from(body);
153
+ const signedPayload = Buffer.concat([
154
+ Buffer.from(`${parsed.timestamp}.`),
155
+ bodyBuffer,
156
+ ]);
157
+ const expected = createHmac("sha256", secret)
158
+ .update(signedPayload)
159
+ .digest("hex");
160
+ const expectedBuffer = Buffer.from(expected, "hex");
161
+
162
+ return parsed.signatures.some((signature) => {
163
+ const actualBuffer = Buffer.from(signature, "hex");
164
+ if (actualBuffer.length !== expectedBuffer.length) return false;
165
+ return timingSafeEqual(actualBuffer, expectedBuffer);
166
+ });
153
167
  }
154
168
 
155
169
  // ── Main resource ─────────────────────────────────────────────────────────────
156
170
 
157
171
  export class Webhooks {
158
- constructor(private readonly http: HttpClient) {}
159
-
160
172
  static verifySignature = verifySignature;
173
+ static verify_signature = verifySignature;
174
+
175
+ constructor(private readonly http: HttpClient) {}
161
176
 
162
177
  async list(params: WebhookListParams = {}): Promise<WebhookData[]> {
163
178
  const query = stripUndefined({ ...params }) as Record<
package/src/types.ts CHANGED
@@ -78,9 +78,9 @@ export interface ComputerData {
78
78
  id: ComputerId;
79
79
  name: string;
80
80
  /**
81
- * URL-safe identifier used in preview URLs: `https://{port}-{slug}.sandbox.{preview_domain}`.
82
- * Falls back to the computer id when no slug is assigned. The domain is the
83
- * tenant's white-label `preview_domain` (server-provided) never hardcode it.
81
+ * URL-safe identifier used in preview URLs:
82
+ * `https://{port}-{slug}.sandbox.{preview_domain}`.
83
+ * Falls back to the computer id when no slug is assigned.
84
84
  */
85
85
  slug: string;
86
86
  status: ComputerStatus;
@@ -91,9 +91,9 @@ export interface ComputerData {
91
91
  metadata: Record<string, string>;
92
92
  /** Controls who can access the HTTP preview URL. Defaults to `"public"`. */
93
93
  visibility: ComputerVisibility;
94
- /** Public ingress root, e.g. `https://<slug>.sandbox.<preview_domain>` (server-provided). */
94
+ /** Public ingress root, e.g. `https://<slug>.sandbox.<preview_domain>`. */
95
95
  sandbox_url?: string;
96
- /** Tenant's white-label preview/base domain (e.g. `cliniciq.com`). Use to build preview URLs. */
96
+ /** Tenant's white-label preview/base domain, e.g. `cliniciq.com`. */
97
97
  preview_domain?: string;
98
98
  /** KasmVNC URL for desktop templates. */
99
99
  desktop_url?: string;
@@ -1,102 +0,0 @@
1
- import { beforeEach, describe, expect, it, vi } from "vitest";
2
- import type { HttpClient } from "../http.js";
3
- import { DockerDeploy } from "./docker-deploy.js";
4
-
5
- const mockGet = vi.fn();
6
- const mockPost = vi.fn();
7
-
8
- function makeHttp(): HttpClient {
9
- const http = {} as HttpClient;
10
- http.get = mockGet;
11
- http.post = mockPost;
12
- return http;
13
- }
14
-
15
- beforeEach(() => {
16
- vi.clearAllMocks();
17
- });
18
-
19
- describe("DockerDeploy", () => {
20
- it("lists Docker Deploy hosts by workspace", async () => {
21
- mockGet.mockResolvedValue({
22
- data: [
23
- {
24
- id: "ddh_123",
25
- tenant_id: "ten_123",
26
- workspace_id: "ws_123",
27
- status: "bootstrapping",
28
- appliance_status: "starting",
29
- size: "medium",
30
- region: "us",
31
- },
32
- ],
33
- });
34
-
35
- const hosts = await new DockerDeploy(makeHttp()).listHosts({
36
- workspaceId: "ws_123",
37
- });
38
-
39
- expect(mockGet).toHaveBeenCalledWith("/docker-deploy/hosts", {
40
- workspace_id: "ws_123",
41
- });
42
- expect(hosts[0]?.id).toBe("ddh_123");
43
- });
44
-
45
- it("ensures a workspace Docker Deploy host", async () => {
46
- mockPost.mockResolvedValue({
47
- host: {
48
- id: "ddh_123",
49
- tenant_id: "ten_123",
50
- workspace_id: "ws_123",
51
- status: "pending",
52
- appliance_status: "not_installed",
53
- size: "medium",
54
- region: "us",
55
- },
56
- queued: true,
57
- });
58
-
59
- const result = await new DockerDeploy(makeHttp()).ensureHost({
60
- workspaceId: "ws_123",
61
- });
62
-
63
- expect(mockPost).toHaveBeenCalledWith("/docker-deploy/hosts/ensure", {
64
- workspace_id: "ws_123",
65
- });
66
- expect(result.queued).toBe(true);
67
- expect(result.host.status).toBe("pending");
68
- });
69
-
70
- it("lists Docker Deploy templates", async () => {
71
- mockGet.mockResolvedValue({
72
- data: [
73
- {
74
- id: "nextjs-app",
75
- name: "Next.js app",
76
- description: "App router starter",
77
- },
78
- ],
79
- });
80
-
81
- const templates = await new DockerDeploy(makeHttp()).listTemplates();
82
-
83
- expect(mockGet).toHaveBeenCalledWith("/docker-deploy/templates");
84
- expect(templates[0]?.id).toBe("nextjs-app");
85
- });
86
-
87
- it("gets a Docker Deploy template", async () => {
88
- mockGet.mockResolvedValue({
89
- template: {
90
- id: "compose-full-stack",
91
- name: "Compose full stack",
92
- },
93
- });
94
-
95
- const template = await new DockerDeploy(makeHttp()).getTemplate("compose-full-stack");
96
-
97
- expect(mockGet).toHaveBeenCalledWith(
98
- "/docker-deploy/templates/compose-full-stack",
99
- );
100
- expect(template.id).toBe("compose-full-stack");
101
- });
102
- });
@@ -1,183 +0,0 @@
1
- import type { HttpClient } from "../http.js";
2
-
3
- export type DockerDeployHostId = string & {
4
- readonly __brand: "DockerDeployHostId";
5
- };
6
-
7
- export type DockerDeployHostStatus =
8
- | "pending"
9
- | "provisioning"
10
- | "bootstrapping"
11
- | "active"
12
- | "degraded"
13
- | "suspended"
14
- | "retired"
15
- | "error";
16
-
17
- export type DockerDeployApplianceStatus =
18
- | "not_installed"
19
- | "installing"
20
- | "starting"
21
- | "healthy"
22
- | "unhealthy"
23
- | "unknown";
24
-
25
- export interface DockerDeployHostData {
26
- id: DockerDeployHostId;
27
- tenant_id: string;
28
- workspace_id: string;
29
- external_workspace_id?: string | null;
30
- computer_id?: string | null;
31
- fleet_node_id?: string | null;
32
- status: DockerDeployHostStatus;
33
- size: string;
34
- region: string;
35
- portal_domain?: string | null;
36
- runtime_base_url?: string | null;
37
- agent_base_url?: string | null;
38
- appliance_image?: string | null;
39
- appliance_version?: string | null;
40
- appliance_status: DockerDeployApplianceStatus;
41
- agent_last_seen_at?: string | null;
42
- metadata?: Record<string, unknown>;
43
- created_at?: string;
44
- updated_at?: string;
45
- }
46
-
47
- export interface DockerDeployHostListParams {
48
- workspace_id?: string;
49
- workspaceId?: string;
50
- }
51
-
52
- export interface DockerDeployHostEnsureParams {
53
- workspace_id?: string;
54
- workspaceId?: string;
55
- external_workspace_id?: string;
56
- externalWorkspaceId?: string;
57
- }
58
-
59
- export interface DockerDeployHostListResponse {
60
- data?: DockerDeployHostData[];
61
- hosts?: DockerDeployHostData[];
62
- }
63
-
64
- export interface DockerDeployHostResponse {
65
- data?: DockerDeployHostData;
66
- host?: DockerDeployHostData;
67
- queued?: boolean;
68
- }
69
-
70
- export interface DockerDeployTemplate {
71
- id: string;
72
- name: string;
73
- description?: string;
74
- category?: string;
75
- runtime?: string;
76
- tags?: string[];
77
- metadata?: Record<string, unknown>;
78
- [key: string]: unknown;
79
- }
80
-
81
- export interface DockerDeployTemplateListResponse {
82
- data?: DockerDeployTemplate[];
83
- templates?: DockerDeployTemplate[];
84
- }
85
-
86
- export interface DockerDeployTemplateResponse {
87
- data?: DockerDeployTemplate;
88
- template?: DockerDeployTemplate;
89
- }
90
-
91
- function workspaceId(params?: DockerDeployHostListParams): string | undefined {
92
- return params?.workspace_id ?? params?.workspaceId;
93
- }
94
-
95
- function ensureBody(params: DockerDeployHostEnsureParams): Record<string, string> {
96
- const body: Record<string, string> = {};
97
- const id = params.workspace_id ?? params.workspaceId;
98
- const externalId = params.external_workspace_id ?? params.externalWorkspaceId;
99
- if (id) body.workspace_id = id;
100
- if (externalId) body.external_workspace_id = externalId;
101
- return body;
102
- }
103
-
104
- function unwrapHost(response: DockerDeployHostResponse): DockerDeployHostData {
105
- const host = response.data ?? response.host;
106
- if (!host) {
107
- throw new Error("Docker Deploy host response was empty.");
108
- }
109
- return host;
110
- }
111
-
112
- function unwrapTemplates(response: DockerDeployTemplateListResponse): DockerDeployTemplate[] {
113
- return response.data ?? response.templates ?? [];
114
- }
115
-
116
- function unwrapTemplate(response: DockerDeployTemplateResponse): DockerDeployTemplate {
117
- const template = response.data ?? response.template;
118
- if (!template) {
119
- throw new Error("Docker Deploy template response was empty.");
120
- }
121
- return template;
122
- }
123
-
124
- export class DockerDeploy {
125
- constructor(private readonly http: HttpClient) {}
126
-
127
- /**
128
- * List Docker Deploy appliance hosts scoped to the current tenant.
129
- *
130
- * Pass a workspace ID to inspect the dedicated always-on appliance machine
131
- * for one white-label workspace.
132
- */
133
- async listHosts(
134
- params: DockerDeployHostListParams = {},
135
- ): Promise<DockerDeployHostData[]> {
136
- const res = await this.http.get<DockerDeployHostListResponse>(
137
- "/docker-deploy/hosts",
138
- { workspace_id: workspaceId(params) },
139
- );
140
- return res.data ?? res.hosts ?? [];
141
- }
142
-
143
- /**
144
- * Ensure a workspace has its dedicated Docker Deploy appliance host.
145
- *
146
- * The host may still be `pending`, `provisioning`, or `bootstrapping` after
147
- * this call. Treat `status === "active"` and `appliance_status === "healthy"`
148
- * as the ready condition before sending app/container traffic to it.
149
- */
150
- async ensureHost(
151
- params: DockerDeployHostEnsureParams = {},
152
- ): Promise<{ host: DockerDeployHostData; queued: boolean }> {
153
- const res = await this.http.post<DockerDeployHostResponse>(
154
- "/docker-deploy/hosts/ensure",
155
- ensureBody(params),
156
- );
157
- return { host: unwrapHost(res), queued: res.queued ?? false };
158
- }
159
-
160
- /** Fetch one Docker Deploy host by ID. */
161
- async getHost(hostId: string): Promise<DockerDeployHostData> {
162
- const res = await this.http.get<DockerDeployHostResponse>(
163
- `/docker-deploy/hosts/${hostId}`,
164
- );
165
- return unwrapHost(res);
166
- }
167
-
168
- /** List Docker Deploy starter templates. */
169
- async listTemplates(): Promise<DockerDeployTemplate[]> {
170
- const res = await this.http.get<DockerDeployTemplateListResponse>(
171
- "/docker-deploy/templates",
172
- );
173
- return unwrapTemplates(res);
174
- }
175
-
176
- /** Fetch one Docker Deploy starter template by ID. */
177
- async getTemplate(templateId: string): Promise<DockerDeployTemplate> {
178
- const res = await this.http.get<DockerDeployTemplateResponse>(
179
- `/docker-deploy/templates/${encodeURIComponent(templateId)}`,
180
- );
181
- return unwrapTemplate(res);
182
- }
183
- }