@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.
- package/dist/commands/app/dev.js +7 -0
- package/dist/commands/webhook/trigger.js +3 -1
- package/dist/lib/webhook-fixtures.js +13 -2
- package/package.json +1 -1
- package/templates/hono-bun/README.md.tmpl +3 -2
- package/templates/hono-bun/odeva.app.toml.tmpl +6 -2
- package/templates/hono-bun/src/index.ts +4 -3
- package/templates/hono-bun/src/install.ts +18 -4
- package/templates/hono-bun/src/webhook.ts +11 -4
package/dist/commands/app/dev.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
@@ -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.
|
|
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.
|
|
25
|
-
# uri = "/webhooks/reservation.
|
|
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
|
-
//
|
|
72
|
-
//
|
|
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
|
-
|
|
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("; ") ||
|
|
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
|
-
*
|
|
7
|
-
*
|
|
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(
|
|
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
|
-
|
|
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;
|