@miosa/sdk 1.2.1 → 1.2.2

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.
@@ -2,7 +2,7 @@
2
2
  * Webhooks resource — tenant outgoing event delivery.
3
3
  */
4
4
 
5
- import { randomUUID } from "node:crypto";
5
+ import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
6
6
 
7
7
  import type { HttpClient } from "../http.js";
8
8
 
@@ -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,9 +106,72 @@ function idempotencyKey(key?: string): string {
101
106
  return key ?? randomUUID();
102
107
  }
103
108
 
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 };
130
+ }
131
+
132
+ /**
133
+ * Verify a MIOSA webhook signature header.
134
+ *
135
+ * Header format: `t=<unix_seconds>,v1=<hex_hmac_sha256>`.
136
+ */
137
+ export function verifySignature(
138
+ body: string | Buffer | Uint8Array,
139
+ header: string,
140
+ secret: string,
141
+ options: WebhookSignatureVerifyOptions = {},
142
+ ): boolean {
143
+ const parsed = parseSignatureHeader(header);
144
+ if (!parsed) return false;
145
+
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");
150
+ }
151
+
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
+ });
167
+ }
168
+
104
169
  // ── Main resource ─────────────────────────────────────────────────────────────
105
170
 
106
171
  export class Webhooks {
172
+ static verifySignature = verifySignature;
173
+ static verify_signature = verifySignature;
174
+
107
175
  constructor(private readonly http: HttpClient) {}
108
176
 
109
177
  async list(params: WebhookListParams = {}): Promise<WebhookData[]> {
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:
82
- * `https://{port}-{slug}.sandbox.{preview_domain}`.
83
- * Falls back to the computer id when no slug is assigned.
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.
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>`. */
94
+ /** Public ingress root, e.g. `https://<slug>.sandbox.<preview_domain>` (server-provided). */
95
95
  sandbox_url?: string;
96
- /** Tenant's white-label preview/base domain, e.g. `cliniciq.com`. */
96
+ /** Tenant's white-label preview/base domain (e.g. `cliniciq.com`). Use to build preview URLs. */
97
97
  preview_domain?: string;
98
98
  /** KasmVNC URL for desktop templates. */
99
99
  desktop_url?: string;