@odeva/cli 0.0.6 → 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/app/init.js +10 -17
- package/dist/commands/autocomplete.js +13 -70
- 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) {
|
|
@@ -9,7 +9,6 @@ import { withErrorHandling } from "../../lib/run.js";
|
|
|
9
9
|
import { isValidSlug, slugify } from "../../lib/slug.js";
|
|
10
10
|
import { listTemplates, renderTemplate } from "../../lib/templates.js";
|
|
11
11
|
import { ui } from "../../lib/ui.js";
|
|
12
|
-
import { detectShell, isCompletionInstalled } from "../autocomplete.js";
|
|
13
12
|
const DEFAULT_TEMPLATE = "hono-bun";
|
|
14
13
|
class AppInit extends Command {
|
|
15
14
|
static description = "Scaffold a new Odeva app";
|
|
@@ -69,23 +68,17 @@ class AppInit extends Command {
|
|
|
69
68
|
}
|
|
70
69
|
});
|
|
71
70
|
spinner.stop(`Wrote ${filesWritten} file${filesWritten === 1 ? "" : "s"} from template '${template}'.`);
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
" Next steps:",
|
|
76
|
-
` ${ui.code(`cd ${basename(directory)}`)}`,
|
|
77
|
-
` ${ui.code("bun install")}`,
|
|
78
|
-
` ${ui.code("odeva app config link")} ${ui.dim("# register on Odeva")}`,
|
|
79
|
-
` ${ui.code("odeva app dev")} ${ui.dim("# start local dev with a tunnel")}`
|
|
80
|
-
];
|
|
81
|
-
const shell = detectShell();
|
|
82
|
-
if (shell && !isCompletionInstalled(shell, "odeva")) {
|
|
83
|
-
outroLines.push(
|
|
71
|
+
p.outro(
|
|
72
|
+
[
|
|
73
|
+
ui.ok(`Created ${ui.bold(displayName)}`),
|
|
84
74
|
"",
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
75
|
+
" Next steps:",
|
|
76
|
+
` ${ui.code(`cd ${basename(directory)}`)}`,
|
|
77
|
+
` ${ui.code("bun install")}`,
|
|
78
|
+
` ${ui.code("odeva app config link")} ${ui.dim("# register on Odeva")}`,
|
|
79
|
+
` ${ui.code("odeva app dev")} ${ui.dim("# start local dev with a tunnel")}`
|
|
80
|
+
].join("\n")
|
|
81
|
+
);
|
|
89
82
|
});
|
|
90
83
|
}
|
|
91
84
|
async resolveDirectory(nameArg, skipPrompts) {
|
|
@@ -1,90 +1,36 @@
|
|
|
1
|
-
import { Args, Command
|
|
2
|
-
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { homedir } from "node:os";
|
|
4
|
-
import { basename, dirname, join } from "node:path";
|
|
1
|
+
import { Args, Command } from "@oclif/core";
|
|
5
2
|
import { CliError } from "../lib/errors.js";
|
|
6
3
|
import { withErrorHandling } from "../lib/run.js";
|
|
7
|
-
import { ui } from "../lib/ui.js";
|
|
8
4
|
const SUPPORTED_SHELLS = ["fish", "zsh"];
|
|
9
5
|
class Autocomplete extends Command {
|
|
10
|
-
static description = "Print
|
|
6
|
+
static description = "Print a shell completion script for the odeva CLI";
|
|
11
7
|
static examples = [
|
|
12
|
-
"$ odeva autocomplete
|
|
13
|
-
"$ odeva autocomplete
|
|
14
|
-
"$ odeva autocomplete fish > ~/.config/fish/completions/odeva.fish"
|
|
8
|
+
"$ odeva autocomplete fish > ~/.config/fish/completions/odeva.fish",
|
|
9
|
+
"$ odeva autocomplete zsh > ~/.odeva/_odeva # then source it from .zshrc"
|
|
15
10
|
];
|
|
16
11
|
static args = {
|
|
17
12
|
shell: Args.string({
|
|
18
|
-
description: "Target shell
|
|
13
|
+
description: "Target shell",
|
|
19
14
|
options: [...SUPPORTED_SHELLS],
|
|
20
|
-
required:
|
|
21
|
-
})
|
|
22
|
-
};
|
|
23
|
-
static flags = {
|
|
24
|
-
install: Flags.boolean({
|
|
25
|
-
description: "Write the completion script to the conventional path for the shell",
|
|
26
|
-
default: false
|
|
15
|
+
required: true
|
|
27
16
|
})
|
|
28
17
|
};
|
|
29
18
|
async run() {
|
|
30
|
-
const { args
|
|
19
|
+
const { args } = await this.parse(Autocomplete);
|
|
31
20
|
await withErrorHandling(this, async () => {
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
throw new CliError("Couldn't detect shell from $SHELL.", {
|
|
37
|
-
hint: `Pass it explicitly: \`${bin} autocomplete <${SUPPORTED_SHELLS.join("|")}> --install\`.`
|
|
38
|
-
});
|
|
39
|
-
}
|
|
40
|
-
throw new CliError("A shell is required.", {
|
|
41
|
-
hint: `Usage: \`${bin} autocomplete <${SUPPORTED_SHELLS.join("|")}>\`. Add --install to write the script to the conventional path.`
|
|
21
|
+
const shell = args.shell;
|
|
22
|
+
if (!SUPPORTED_SHELLS.includes(shell)) {
|
|
23
|
+
throw new CliError(`Unsupported shell '${shell}'.`, {
|
|
24
|
+
hint: `Supported: ${SUPPORTED_SHELLS.join(", ")}.`
|
|
42
25
|
});
|
|
43
26
|
}
|
|
27
|
+
const bin = this.config.bin;
|
|
44
28
|
const tree = buildCommandTree(this.config.commands, bin);
|
|
45
29
|
const script = shell === "fish" ? renderFish(bin, tree) : renderZsh(bin, tree);
|
|
46
|
-
|
|
47
|
-
process.stdout.write(script);
|
|
48
|
-
return;
|
|
49
|
-
}
|
|
50
|
-
const target = completionInstallPath(shell, bin);
|
|
51
|
-
mkdirSync(dirname(target), { recursive: true });
|
|
52
|
-
writeFileSync(target, script, { mode: 420 });
|
|
53
|
-
this.log(ui.ok(`Wrote ${shell} completion to ${ui.code(target)}`));
|
|
54
|
-
if (shell === "fish") {
|
|
55
|
-
this.log(ui.dim(" Open a new fish shell \u2014 completions auto-load from this path."));
|
|
56
|
-
} else {
|
|
57
|
-
const fpathDir = dirname(target);
|
|
58
|
-
this.log("");
|
|
59
|
-
this.log(" Add this to your ~/.zshrc (if you haven't already):");
|
|
60
|
-
this.log("");
|
|
61
|
-
this.log(ui.code(` fpath=(${fpathDir} $fpath)`));
|
|
62
|
-
this.log(ui.code(` autoload -U compinit && compinit`));
|
|
63
|
-
this.log("");
|
|
64
|
-
this.log(ui.dim(" Then open a new zsh shell."));
|
|
65
|
-
}
|
|
30
|
+
process.stdout.write(script);
|
|
66
31
|
});
|
|
67
32
|
}
|
|
68
33
|
}
|
|
69
|
-
function detectShell(env = process.env) {
|
|
70
|
-
const shell = env["SHELL"];
|
|
71
|
-
if (!shell) return null;
|
|
72
|
-
const name = basename(shell);
|
|
73
|
-
if (name === "fish" || name === "zsh") return name;
|
|
74
|
-
return null;
|
|
75
|
-
}
|
|
76
|
-
function completionInstallPath(shell, bin, env = process.env) {
|
|
77
|
-
const home = env["HOME"] || homedir();
|
|
78
|
-
if (shell === "fish") {
|
|
79
|
-
const config = env["XDG_CONFIG_HOME"] || join(home, ".config");
|
|
80
|
-
return join(config, "fish", "completions", `${bin}.fish`);
|
|
81
|
-
}
|
|
82
|
-
const data = env["XDG_DATA_HOME"] || join(home, ".local", "share");
|
|
83
|
-
return join(data, bin, "completions", `_${bin}`);
|
|
84
|
-
}
|
|
85
|
-
function isCompletionInstalled(shell, bin, env = process.env) {
|
|
86
|
-
return existsSync(completionInstallPath(shell, bin, env));
|
|
87
|
-
}
|
|
88
34
|
function buildCommandTree(commands, binName) {
|
|
89
35
|
const root = { name: "", description: "", children: /* @__PURE__ */ new Map() };
|
|
90
36
|
for (const cmd of commands) {
|
|
@@ -206,10 +152,7 @@ _${bin} "$@"
|
|
|
206
152
|
}
|
|
207
153
|
export {
|
|
208
154
|
buildCommandTree,
|
|
209
|
-
completionInstallPath,
|
|
210
155
|
Autocomplete as default,
|
|
211
|
-
detectShell,
|
|
212
|
-
isCompletionInstalled,
|
|
213
156
|
renderFish,
|
|
214
157
|
renderZsh
|
|
215
158
|
};
|
|
@@ -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;
|