@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.
@@ -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
- const outroLines = [
73
- ui.ok(`Created ${ui.bold(displayName)}`),
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
- ` ${ui.dim("Tip:")} ${ui.code("odeva autocomplete --install")} ${ui.dim("to enable tab-completion.")}`
86
- );
87
- }
88
- p.outro(outroLines.join("\n"));
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, Flags } from "@oclif/core";
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 or install a shell completion script for the odeva CLI";
6
+ static description = "Print a shell completion script for the odeva CLI";
11
7
  static examples = [
12
- "$ odeva autocomplete --install # detect shell and install",
13
- "$ odeva autocomplete fish --install # explicit shell",
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 (auto-detected from $SHELL when omitted with --install)",
13
+ description: "Target shell",
19
14
  options: [...SUPPORTED_SHELLS],
20
- required: false
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, flags } = await this.parse(Autocomplete);
19
+ const { args } = await this.parse(Autocomplete);
31
20
  await withErrorHandling(this, async () => {
32
- const bin = this.config.bin;
33
- const shell = args.shell ?? (flags.install ? detectShell() : void 0);
34
- if (!shell) {
35
- if (flags.install) {
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
- if (!flags.install) {
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
- 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.6",
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;