@odeva/cli 0.0.5 → 0.0.7

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.
@@ -163,6 +163,13 @@ class AppDev extends Command {
163
163
  `install_url is ${installUrl} \u2014 installs from your org will redirect there, not to this tunnel. Update odeva.app.toml + \`odeva app config link\` if you want installs to hit the dev tunnel.`
164
164
  ));
165
165
  }
166
+ const adminEntryUrl = loaded.config.admin?.entry_url.trim();
167
+ const expectedAdmin = `${tunnelUrl}/admin`;
168
+ if (adminEntryUrl && !adminEntryUrl.startsWith(tunnelUrl) && !/localhost|127\.0\.0\.1/.test(adminEntryUrl)) {
169
+ this.log(ui.warn(
170
+ `admin.entry_url is ${adminEntryUrl} \u2014 the admin sidebar will iframe that URL, not this tunnel. Set it to ${expectedAdmin} and re-run \`odeva app config link\` for local embed testing.`
171
+ ));
172
+ }
166
173
  this.log("");
167
174
  }
168
175
  async registerDevWebhooks(api, loaded, tunnelUrl) {
@@ -61,7 +61,9 @@ class WebhookTrigger extends Command {
61
61
  hint: flags.url || configSubscription ? "Run `odeva app dev` to generate one, or pass --secret <value>, or use --no-sign for unsigned testing." : `Add a '${event}' subscription to odeva.app.toml, let \`odeva app dev\` reload, then try again.`
62
62
  });
63
63
  }
64
- headers["x-odeva-signature"] = signPayload(body, secret);
64
+ const timestamp = Math.floor(Date.now() / 1e3).toString();
65
+ headers["x-odeva-timestamp"] = timestamp;
66
+ headers["x-odeva-signature"] = signPayload(body, secret, timestamp);
65
67
  }
66
68
  this.log(`${ui.bold("POST")} ${url}`);
67
69
  this.log(` ${ui.dim("event:")} ${event}`);
@@ -20,6 +20,16 @@ const FIXTURES = {
20
20
  },
21
21
  changes: ["checkIn", "checkOut"]
22
22
  },
23
+ "reservation.confirmed": {
24
+ reservation: {
25
+ id: "res_test_abc123",
26
+ reservationNumber: "R-2026-0001",
27
+ status: "confirmed",
28
+ checkIn: "2026-07-01",
29
+ checkOut: "2026-07-05",
30
+ total: { amount: "480.00", currency: "EUR" }
31
+ }
32
+ },
23
33
  "reservation.cancelled": {
24
34
  reservation: {
25
35
  id: "res_test_abc123",
@@ -47,8 +57,9 @@ function buildFixture(event, overrides) {
47
57
  data
48
58
  };
49
59
  }
50
- function signPayload(rawBody, secret) {
51
- return createHmac("sha256", secret).update(rawBody).digest("hex");
60
+ function signPayload(rawBody, secret, timestamp) {
61
+ const digest = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
62
+ return `sha256=${digest}`;
52
63
  }
53
64
  function listFixtureEvents() {
54
65
  return Object.keys(FIXTURES);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@odeva/cli",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
4
4
  "description": "Build apps on the Odeva booking platform — scaffold, develop, deploy.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -13,14 +13,15 @@ odeva app dev # local server + tunnel + webhook delivery
13
13
  In another terminal:
14
14
 
15
15
  ```sh
16
- odeva webhook trigger reservation.created
16
+ odeva webhook trigger reservation.confirmed
17
+ odeva webhook trigger payment.succeeded
17
18
  ```
18
19
 
19
20
  ## Project layout
20
21
 
21
22
  - `odeva.app.toml` — app config (name, slug, webhooks, scopes)
22
23
  - `src/index.ts` — Hono server with a webhook handler stub
23
- - `src/webhook.ts` — HMAC signature verification
24
+ - `src/webhook.ts` — timestamped `sha256=` HMAC signature verification
24
25
  - `src/admin.ts` — iframe session-token verification
25
26
 
26
27
  ## Embed in the merchant admin
@@ -21,8 +21,12 @@ api_version = "2026-01"
21
21
  # tunnel URL. List supported events with: `odeva webhook list --available`.
22
22
  #
23
23
  # [[webhooks.subscriptions]]
24
- # topic = "reservation.created"
25
- # uri = "/webhooks/reservation.created"
24
+ # topic = "reservation.confirmed"
25
+ # uri = "/webhooks/reservation.confirmed"
26
+ #
27
+ # [[webhooks.subscriptions]]
28
+ # topic = "payment.succeeded"
29
+ # uri = "/webhooks/payment.succeeded"
26
30
 
27
31
  # Uncomment to embed this app inside the merchant admin sidebar.
28
32
  # The merchant admin will iframe `entry_url?session_token=<short-lived JWT>`
@@ -57,19 +57,20 @@ app.post("/webhooks/:topic", async (c) => {
57
57
  const topic = c.req.param("topic");
58
58
  const rawBody = await c.req.text();
59
59
  const signature = c.req.header("x-odeva-signature");
60
+ const timestamp = c.req.header("x-odeva-timestamp");
60
61
  const secret =
61
62
  process.env[`ODEVA_WEBHOOK_SECRET__${topic.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}`] ??
62
63
  process.env.ODEVA_WEBHOOK_SECRET;
63
64
 
64
- if (secret && !verifyWebhook(rawBody, signature, secret)) {
65
+ if (secret && !verifyWebhook(rawBody, signature, secret, timestamp)) {
65
66
  return c.json({ error: "invalid signature" }, 401);
66
67
  }
67
68
 
68
69
  const payload = JSON.parse(rawBody) as Record<string, unknown>;
69
70
  logger.info(`[webhook] ${topic}`, payload);
70
71
 
71
- // TODO: handle the event.
72
- // Tip: run `odeva webhook trigger <topic>` to fire a sample payload at this handler.
72
+ // Route work by topic here. Tip: run `odeva webhook trigger <topic>` to fire
73
+ // a signed sample payload at this handler.
73
74
 
74
75
  return c.json({ received: true });
75
76
  });
@@ -27,7 +27,7 @@ const EXCHANGE_MUTATION = `
27
27
  `;
28
28
 
29
29
  interface ExchangeResponse {
30
- data: {
30
+ data?: {
31
31
  exchangeAppInstallCode: {
32
32
  appInstallation: {
33
33
  id: string;
@@ -37,10 +37,14 @@ interface ExchangeResponse {
37
37
  rawKey: string | null;
38
38
  errors: string[];
39
39
  } | null;
40
- };
40
+ } | null;
41
41
  errors?: Array<{ message: string }>;
42
42
  }
43
43
 
44
+ function isExchangeResponse(value: unknown): value is ExchangeResponse {
45
+ return typeof value === "object" && value !== null;
46
+ }
47
+
44
48
  export function makeInstallHandler(store: InstallationStore) {
45
49
  return async (c: Context): Promise<Response> => {
46
50
  const installCode = c.req.query("install_code");
@@ -68,13 +72,23 @@ export function makeInstallHandler(store: InstallationStore) {
68
72
  }),
69
73
  });
70
74
 
71
- const body = (await response.json()) as ExchangeResponse;
75
+ let body: ExchangeResponse;
76
+ try {
77
+ const parsed: unknown = await response.json();
78
+ if (!isExchangeResponse(parsed)) {
79
+ return c.text(`Install failed: Odeva returned an unexpected response (${response.status}).`, 502);
80
+ }
81
+ body = parsed;
82
+ } catch {
83
+ return c.text(`Install failed: Could not parse Odeva response (${response.status}).`, 502);
84
+ }
85
+
72
86
  const topErrors = body.errors?.map((e) => e.message) ?? [];
73
87
  const payload = body.data?.exchangeAppInstallCode ?? null;
74
88
  const userErrors = payload?.errors ?? [];
75
89
 
76
90
  if (topErrors.length > 0 || userErrors.length > 0 || !payload?.rawKey || !payload.appInstallation) {
77
- const msg = [...topErrors, ...userErrors].join("; ") || "Exchange failed";
91
+ const msg = [...topErrors, ...userErrors].join("; ") || `Exchange failed (${response.status})`;
78
92
  return c.text(`Install failed: ${msg}`, 400);
79
93
  }
80
94
 
@@ -3,12 +3,19 @@ import { createHmac, timingSafeEqual } from "node:crypto";
3
3
  /**
4
4
  * Verify a webhook signature from the Odeva platform.
5
5
  *
6
- * The CLI's `odeva webhook trigger` and the platform itself both sign payloads
7
- * with HMAC-SHA256(secret, rawBody), hex-encoded, sent as `x-odeva-signature`.
6
+ * Webhooks are signed as HMAC-SHA256(secret, `${timestamp}.${rawBody}`),
7
+ * hex-encoded and sent as `x-odeva-signature: sha256=<digest>`.
8
8
  */
9
- export function verifyWebhook(rawBody: string, signature: string | undefined, secret: string): boolean {
9
+ export function verifyWebhook(
10
+ rawBody: string,
11
+ signature: string | undefined,
12
+ secret: string,
13
+ timestamp: string | undefined,
14
+ ): boolean {
10
15
  if (!signature) return false;
11
- const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
16
+ if (!timestamp) return false;
17
+ const digest = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
18
+ const expected = `sha256=${digest}`;
12
19
  const a = Buffer.from(expected, "utf8");
13
20
  const b = Buffer.from(signature, "utf8");
14
21
  if (a.length !== b.length) return false;