@odeva/cli 0.0.1 → 0.0.3

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.
@@ -1,5 +1,6 @@
1
1
  import { Command, Flags } from "@oclif/core";
2
2
  import * as p from "@clack/prompts";
3
+ import { unwatchFile, watchFile } from "node:fs";
3
4
  import { OdevaApi } from "../../lib/api.js";
4
5
  import { loadAppConfig } from "../../lib/config.js";
5
6
  import { loadAppEnv } from "../../lib/app-env.js";
@@ -7,8 +8,10 @@ import { startQuickTunnel } from "../../lib/cloudflared.js";
7
8
  import {
8
9
  cleanupSubscriptions,
9
10
  preflightChecks,
11
+ registeredWebhookEnv,
10
12
  registerWebhookSubscriptions,
11
13
  spawnDevServer,
14
+ watchedDevInputPaths,
12
15
  writeDevEnvFile
13
16
  } from "../../lib/dev-runner.js";
14
17
  import { CliError } from "../../lib/errors.js";
@@ -31,7 +34,6 @@ class AppDev extends Command {
31
34
  const loaded = loadAppConfig();
32
35
  const appEnv = loadAppEnv(loaded.path);
33
36
  const port = flags.port ?? loaded.config.build?.port ?? 3e3;
34
- const subscriptions = loaded.config.webhooks?.subscriptions ?? [];
35
37
  for (const warning of preflightChecks(loaded.root).warnings) {
36
38
  this.log(ui.warn(warning));
37
39
  }
@@ -54,24 +56,7 @@ class AppDev extends Command {
54
56
  const tunnel = await startQuickTunnel(port);
55
57
  tunnelSpinner.stop(`Tunnel ready at ${ui.code(tunnel.url)}`);
56
58
  let registered = [];
57
- if (subscriptions.length > 0) {
58
- const whSpinner = p.spinner();
59
- whSpinner.start(`Registering ${subscriptions.length} webhook subscription${subscriptions.length === 1 ? "" : "s"}`);
60
- try {
61
- registered = await registerWebhookSubscriptions(api, loaded.config.name, tunnel.url, subscriptions);
62
- whSpinner.stop(`Registered ${registered.length} subscription${registered.length === 1 ? "" : "s"}`);
63
- } catch (err) {
64
- whSpinner.stop(ui.err("Webhook registration failed"));
65
- await tunnel.stop();
66
- throw err instanceof CliError ? err : new CliError(`Webhook registration failed: ${err.message}`);
67
- }
68
- const envPath = writeDevEnvFile(loaded.root, registered);
69
- if (envPath) {
70
- this.log(ui.ok(`Wrote ${ui.code(envPath)} (add to .gitignore \u2014 it contains secrets)`));
71
- }
72
- } else {
73
- this.log(ui.dim("No webhook subscriptions in odeva.app.toml \u2014 skipping registration."));
74
- }
59
+ registered = await this.registerDevWebhooks(api, loaded, tunnel.url);
75
60
  this.log("");
76
61
  this.log(`${ui.bold("Tunnel:")} ${tunnel.url}`);
77
62
  this.log(`${ui.bold("Local:")} http://localhost:${port}`);
@@ -81,18 +66,68 @@ class AppDev extends Command {
81
66
  this.log("");
82
67
  this.log(ui.dim("Press Ctrl-C to stop. Subscriptions will be cleaned up on exit."));
83
68
  this.log("");
84
- const server = spawnDevServer({
69
+ let server = spawnDevServer({
85
70
  cwd: loaded.root,
86
71
  config: loaded.config,
87
72
  env: {
88
73
  ...appEnv,
89
74
  PORT: String(port),
90
75
  ODEVA_TUNNEL_URL: tunnel.url,
91
- ...registered[0] ? { ODEVA_WEBHOOK_SECRET: registered[0].secret } : {}
76
+ ...registeredWebhookEnv(registered)
92
77
  }
93
78
  });
79
+ let shuttingDown = false;
80
+ let restartingServer = false;
81
+ let serverExited = () => {
82
+ };
83
+ const serverExit = new Promise((resolve) => {
84
+ serverExited = resolve;
85
+ });
86
+ const watchServerExit = (devServer) => {
87
+ devServer.process.once("exit", (code) => {
88
+ if (!restartingServer && !shuttingDown) serverExited(code);
89
+ });
90
+ };
91
+ watchServerExit(server);
92
+ let reloading = Promise.resolve();
93
+ const stopWatcher = this.watchDevInputs(loaded.path, async () => {
94
+ reloading = reloading.then(async () => {
95
+ this.log("\n" + ui.dim("Reloading app dev config..."));
96
+ const nextLoaded = loadAppConfig(loaded.root);
97
+ const nextPort = flags.port ?? nextLoaded.config.build?.port ?? 3e3;
98
+ if (nextPort !== port) {
99
+ this.log(ui.warn(`Port changed to ${nextPort}; restart \`odeva app dev\` to recreate the tunnel.`));
100
+ }
101
+ const nextRegistered = await this.registerDevWebhooks(api, nextLoaded, tunnel.url);
102
+ await cleanupSubscriptions(api, registered);
103
+ registered = nextRegistered;
104
+ const nextAppEnv = loadAppEnv(nextLoaded.path);
105
+ restartingServer = true;
106
+ await server.stop();
107
+ restartingServer = false;
108
+ const nextServer = spawnDevServer({
109
+ cwd: nextLoaded.root,
110
+ config: nextLoaded.config,
111
+ env: {
112
+ ...nextAppEnv,
113
+ PORT: String(port),
114
+ ODEVA_TUNNEL_URL: tunnel.url,
115
+ ...registeredWebhookEnv(registered)
116
+ }
117
+ });
118
+ server = nextServer;
119
+ watchServerExit(server);
120
+ this.log(ui.ok("Reloaded app dev config."));
121
+ }).catch((err) => {
122
+ restartingServer = false;
123
+ this.log(ui.err(`Reload failed: ${err.message}`));
124
+ });
125
+ });
94
126
  installShutdownHandler(async () => {
127
+ shuttingDown = true;
95
128
  this.log("\n" + ui.dim("Cleaning up..."));
129
+ stopWatcher();
130
+ await reloading;
96
131
  await Promise.allSettled([
97
132
  server.stop(),
98
133
  tunnel.stop(),
@@ -100,10 +135,51 @@ class AppDev extends Command {
100
135
  ]);
101
136
  this.log(ui.ok("Done."));
102
137
  });
103
- await server.waitForExit();
138
+ await serverExit;
139
+ shuttingDown = true;
140
+ stopWatcher();
141
+ await reloading;
104
142
  await Promise.allSettled([tunnel.stop(), cleanupSubscriptions(api, registered)]);
105
143
  });
106
144
  }
145
+ async registerDevWebhooks(api, loaded, tunnelUrl) {
146
+ const subscriptions = loaded.config.webhooks?.subscriptions ?? [];
147
+ if (subscriptions.length === 0) {
148
+ this.log(ui.dim("No webhook subscriptions in odeva.app.toml \u2014 skipping registration."));
149
+ return [];
150
+ }
151
+ const whSpinner = p.spinner();
152
+ whSpinner.start(`Registering ${subscriptions.length} webhook subscription${subscriptions.length === 1 ? "" : "s"}`);
153
+ try {
154
+ const registered = await registerWebhookSubscriptions(api, loaded.config.name, tunnelUrl, subscriptions);
155
+ whSpinner.stop(`Registered ${registered.length} subscription${registered.length === 1 ? "" : "s"}`);
156
+ const envPath = writeDevEnvFile(loaded.root, registered);
157
+ if (envPath) {
158
+ this.log(ui.ok(`Wrote ${ui.code(envPath)} (add to .gitignore \u2014 it contains secrets)`));
159
+ }
160
+ return registered;
161
+ } catch (err) {
162
+ whSpinner.stop(ui.err("Webhook registration failed"));
163
+ throw err instanceof CliError ? err : new CliError(`Webhook registration failed: ${err.message}`);
164
+ }
165
+ }
166
+ watchDevInputs(appConfigPath, onChange) {
167
+ const paths = watchedDevInputPaths(appConfigPath);
168
+ let timer;
169
+ const queue = () => {
170
+ if (timer) clearTimeout(timer);
171
+ timer = setTimeout(onChange, 250);
172
+ };
173
+ for (const path of paths) {
174
+ watchFile(path, { interval: 500 }, (curr, prev) => {
175
+ if (curr.mtimeMs !== prev.mtimeMs || curr.size !== prev.size) queue();
176
+ });
177
+ }
178
+ return () => {
179
+ if (timer) clearTimeout(timer);
180
+ for (const path of paths) unwatchFile(path);
181
+ };
182
+ }
107
183
  }
108
184
  let shutdownInstalled = false;
109
185
  function installShutdownHandler(handler) {
@@ -10,6 +10,8 @@ import { pickOrganization } from "../../lib/org-picker.js";
10
10
  import { withErrorHandling } from "../../lib/run.js";
11
11
  import { ui } from "../../lib/ui.js";
12
12
  const POLL_TIMEOUT_MS = 15 * 60 * 1e3;
13
+ const MIN_POLL_INTERVAL_MS = 10 * 1e3;
14
+ const RATE_LIMIT_BACKOFF_MS = 30 * 1e3;
13
15
  class AuthLogin extends Command {
14
16
  static description = "Authenticate the CLI with your Odeva account";
15
17
  static examples = [
@@ -42,6 +44,7 @@ class AuthLogin extends Command {
42
44
  const api = new OdevaApi({ apiUrl });
43
45
  p.intro(ui.brand("odeva auth login"));
44
46
  const begin = await api.cliAuthBegin(osHostname());
47
+ const pollIntervalMs = Math.max(begin.interval * 1e3, MIN_POLL_INTERVAL_MS);
45
48
  p.note(
46
49
  [
47
50
  `Verify this code matches the one shown on the approval page:`,
@@ -58,18 +61,28 @@ class AuthLogin extends Command {
58
61
  this.log(ui.warn(`Could not open a browser automatically. Visit the URL above to approve.`));
59
62
  }
60
63
  }
61
- const token = await this.pollUntilResolved(api, begin.deviceCode, begin.interval);
64
+ const token = await this.pollUntilResolved(api, begin.deviceCode, pollIntervalMs);
62
65
  await this.completeWithToken(token, apiUrl);
63
66
  });
64
67
  }
65
- async pollUntilResolved(api, deviceCode, intervalSec) {
68
+ async pollUntilResolved(api, deviceCode, intervalMs) {
66
69
  const spinner = p.spinner();
67
70
  spinner.start("Waiting for approval in your browser\u2026");
68
71
  const deadline = Date.now() + POLL_TIMEOUT_MS;
69
72
  try {
70
73
  while (Date.now() < deadline) {
71
- await sleep(intervalSec * 1e3);
72
- const result = await api.cliAuthPoll(deviceCode);
74
+ await sleep(intervalMs);
75
+ let result;
76
+ try {
77
+ result = await api.cliAuthPoll(deviceCode);
78
+ } catch (err) {
79
+ if (isRateLimitError(err)) {
80
+ spinner.message("Waiting for approval in your browser\u2026");
81
+ await sleep(RATE_LIMIT_BACKOFF_MS);
82
+ continue;
83
+ }
84
+ throw err;
85
+ }
73
86
  switch (result.status) {
74
87
  case "approved":
75
88
  if (!result.token) {
@@ -131,6 +144,15 @@ class AuthLogin extends Command {
131
144
  function sleep(ms) {
132
145
  return new Promise((resolve) => setTimeout(resolve, ms));
133
146
  }
147
+ function isRateLimitError(err) {
148
+ if (!(err instanceof ApiError)) return false;
149
+ return err.graphqlErrors?.some((gqlError) => {
150
+ const extensions = gqlError.extensions;
151
+ if (!extensions || typeof extensions !== "object") return false;
152
+ const code = extensions.code;
153
+ return typeof code === "string" && code.toUpperCase() === "RATE_LIMITED";
154
+ }) ?? /rate limit/i.test(err.message);
155
+ }
134
156
  export {
135
157
  AuthLogin as default
136
158
  };
@@ -5,7 +5,7 @@ import { loadAppConfig, findAppConfigPath } from "../../lib/config.js";
5
5
  import { buildFixture, signPayload } from "../../lib/webhook-fixtures.js";
6
6
  import { CliError } from "../../lib/errors.js";
7
7
  import { withErrorHandling } from "../../lib/run.js";
8
- import { readEnvFile } from "../../lib/dev-runner.js";
8
+ import { readEnvFile, webhookSecretForEvent } from "../../lib/dev-runner.js";
9
9
  import { ui } from "../../lib/ui.js";
10
10
  import { join } from "node:path";
11
11
  class WebhookTrigger extends Command {
@@ -45,10 +45,11 @@ class WebhookTrigger extends Command {
45
45
  const { args, flags } = await this.parse(WebhookTrigger);
46
46
  await withErrorHandling(this, async () => {
47
47
  const event = args.event;
48
- const url = flags.url ?? this.urlFromConfig(event, flags.port);
48
+ const configSubscription = this.subscriptionFromConfig(event);
49
+ const url = flags.url ?? this.urlFromConfig(event, flags.port, configSubscription?.uri);
49
50
  const payload = flags.payload ? this.readPayload(flags.payload, event) : buildFixture(event);
50
51
  const body = JSON.stringify(payload);
51
- const secret = flags.secret ?? this.secretFromEnvFile();
52
+ const secret = flags.secret ?? this.secretFromEnvFile(event);
52
53
  const headers = {
53
54
  "content-type": "application/json",
54
55
  "x-odeva-event": event,
@@ -57,7 +58,7 @@ class WebhookTrigger extends Command {
57
58
  if (!flags["no-sign"]) {
58
59
  if (!secret) {
59
60
  throw new CliError("No webhook secret available.", {
60
- hint: "Run `odeva app dev` first to generate one, or pass --secret <value>, or use --no-sign for unsigned testing."
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.`
61
62
  });
62
63
  }
63
64
  headers["x-odeva-signature"] = signPayload(body, secret);
@@ -83,7 +84,13 @@ class WebhookTrigger extends Command {
83
84
  if (text) this.log(ui.dim(text.length > 500 ? `${text.slice(0, 500)}\u2026` : text));
84
85
  });
85
86
  }
86
- urlFromConfig(event, portFlag) {
87
+ subscriptionFromConfig(event) {
88
+ const cfgPath = findAppConfigPath();
89
+ if (!cfgPath) return void 0;
90
+ const loaded = loadAppConfig();
91
+ return loaded.config.webhooks?.subscriptions?.find((s) => s.topic === event);
92
+ }
93
+ urlFromConfig(event, portFlag, subscriptionUri) {
87
94
  const cfgPath = findAppConfigPath();
88
95
  if (!cfgPath) {
89
96
  throw new CliError("No odeva.app.toml found, and no --url provided.", {
@@ -91,17 +98,16 @@ class WebhookTrigger extends Command {
91
98
  });
92
99
  }
93
100
  const loaded = loadAppConfig();
94
- const sub = loaded.config.webhooks?.subscriptions?.find((s) => s.topic === event);
95
101
  const port = portFlag ?? loaded.config.build?.port ?? 3e3;
96
- const path = sub?.uri ?? `/webhooks/${event}`;
102
+ const path = subscriptionUri ?? `/webhooks/${event}`;
97
103
  return `http://localhost:${port}${path.startsWith("/") ? path : `/${path}`}`;
98
104
  }
99
- secretFromEnvFile() {
105
+ secretFromEnvFile(event) {
100
106
  const cfgPath = findAppConfigPath();
101
107
  if (!cfgPath) return void 0;
102
108
  const envPath = join(resolve(cfgPath, ".."), ".env.odeva.local");
103
109
  const env = readEnvFile(envPath);
104
- return env["ODEVA_WEBHOOK_SECRET"];
110
+ return webhookSecretForEvent(event, env);
105
111
  }
106
112
  readPayload(path, event) {
107
113
  const abs = resolve(process.cwd(), path);
package/dist/lib/api.js CHANGED
@@ -246,7 +246,7 @@ class OdevaApi {
246
246
  appInstallationId: $appInstallationId
247
247
  ) {
248
248
  webhookSubscription { id name endpointUrl eventTypes status }
249
- webhookSecret
249
+ signingSecret
250
250
  errors
251
251
  }
252
252
  }
@@ -257,7 +257,10 @@ class OdevaApi {
257
257
  if (payload.errors?.length) {
258
258
  throw new ApiError(`Could not create webhook subscription: ${payload.errors.join(", ")}`);
259
259
  }
260
- return { subscription: payload.webhookSubscription, webhookSecret: payload.webhookSecret };
260
+ if (!payload.signingSecret) {
261
+ throw new ApiError("Could not create webhook subscription: no signing secret returned");
262
+ }
263
+ return { subscription: payload.webhookSubscription, signingSecret: payload.signingSecret };
261
264
  }
262
265
  async deleteWebhookSubscription(id) {
263
266
  await this.request(
@@ -42,6 +42,23 @@ function validateConfig(config, path) {
42
42
  { hint: `Edit ${path}` }
43
43
  );
44
44
  }
45
+ if (config.webhooks?.subscriptions) {
46
+ const rawSubscriptions = config.webhooks.subscriptions;
47
+ config.webhooks.subscriptions = rawSubscriptions.map((subscription) => {
48
+ if (typeof subscription === "string") {
49
+ return { topic: subscription, uri: `/webhooks/${subscription}` };
50
+ }
51
+ if (!subscription.topic || typeof subscription.topic !== "string") {
52
+ throw new CliError(`Invalid ${APP_CONFIG_FILE}: webhook subscription missing 'topic'.`, {
53
+ hint: `Edit ${path}`
54
+ });
55
+ }
56
+ return {
57
+ ...subscription,
58
+ uri: subscription.uri || `/webhooks/${subscription.topic}`
59
+ };
60
+ });
61
+ }
45
62
  }
46
63
  export {
47
64
  findAppConfigPath,
@@ -1,16 +1,17 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
- import { join } from "node:path";
3
+ import { dirname, join } from "node:path";
4
+ import { APP_ENV_FILE } from "./app-env.js";
4
5
  async function registerWebhookSubscriptions(api, appName, tunnelUrl, subscriptions) {
5
6
  const created = [];
6
7
  for (const sub of subscriptions) {
7
8
  const fullUrl = joinUrl(tunnelUrl, sub.uri);
8
- const { subscription, webhookSecret } = await api.createWebhookSubscription({
9
+ const { subscription, signingSecret } = await api.createWebhookSubscription({
9
10
  name: `${appName} (dev) \u2014 ${sub.topic}`,
10
11
  endpointUrl: fullUrl,
11
12
  eventTypes: [sub.topic]
12
13
  });
13
- created.push({ config: sub, subscription, secret: webhookSecret, fullUrl });
14
+ created.push({ config: sub, subscription, secret: signingSecret, fullUrl });
14
15
  }
15
16
  return created;
16
17
  }
@@ -26,6 +27,7 @@ function spawnDevServer(opts) {
26
27
  const command = opts.config.build?.dev ?? "bun run dev";
27
28
  const proc = spawn(command, {
28
29
  cwd: opts.cwd,
30
+ detached: process.platform !== "win32",
29
31
  shell: true,
30
32
  stdio: "inherit",
31
33
  env: {
@@ -53,14 +55,28 @@ function writeDevEnvFile(cwd, registered) {
53
55
  lines.push(`ODEVA_WEBHOOK_SECRET=${primary.secret}`);
54
56
  for (const reg of registered) {
55
57
  lines.push(`# ${reg.config.topic} \u2192 ${reg.fullUrl}`);
56
- lines.push(`ODEVA_WEBHOOK_SECRET__${secretEnvKey(reg.config.topic)}=${reg.secret}`);
58
+ lines.push(`ODEVA_WEBHOOK_SECRET__${webhookSecretEnvKey(reg.config.topic)}=${reg.secret}`);
57
59
  }
58
60
  writeFileSync(path, lines.join("\n") + "\n", { mode: 384 });
59
61
  return path;
60
62
  }
61
- function secretEnvKey(topic) {
63
+ function registeredWebhookEnv(registered) {
64
+ const env = {};
65
+ if (registered[0]) env.ODEVA_WEBHOOK_SECRET = registered[0].secret;
66
+ for (const reg of registered) {
67
+ env[`ODEVA_WEBHOOK_SECRET__${webhookSecretEnvKey(reg.config.topic)}`] = reg.secret;
68
+ }
69
+ return env;
70
+ }
71
+ function webhookSecretEnvKey(topic) {
62
72
  return topic.toUpperCase().replace(/[^A-Z0-9]+/g, "_");
63
73
  }
74
+ function webhookSecretForEvent(event, env) {
75
+ return env[`ODEVA_WEBHOOK_SECRET__${webhookSecretEnvKey(event)}`] ?? env["ODEVA_WEBHOOK_SECRET"];
76
+ }
77
+ function watchedDevInputPaths(appConfigPath) {
78
+ return [appConfigPath, join(dirname(appConfigPath), APP_ENV_FILE)];
79
+ }
64
80
  function joinUrl(base, path) {
65
81
  const trimmedBase = base.replace(/\/$/, "");
66
82
  const trimmedPath = path.startsWith("/") ? path : `/${path}`;
@@ -71,7 +87,7 @@ function stopProcess(proc) {
71
87
  if (proc.exitCode !== null) return resolve();
72
88
  proc.once("exit", () => resolve());
73
89
  try {
74
- proc.kill("SIGTERM");
90
+ killDevProcess(proc, "SIGTERM");
75
91
  } catch {
76
92
  resolve();
77
93
  return;
@@ -79,13 +95,24 @@ function stopProcess(proc) {
79
95
  setTimeout(() => {
80
96
  if (proc.exitCode === null) {
81
97
  try {
82
- proc.kill("SIGKILL");
98
+ killDevProcess(proc, "SIGKILL");
83
99
  } catch {
84
100
  }
85
101
  }
86
102
  }, 2e3);
87
103
  });
88
104
  }
105
+ function killDevProcess(proc, signal) {
106
+ if (process.platform === "win32") {
107
+ proc.kill(signal);
108
+ return;
109
+ }
110
+ if (proc.pid) {
111
+ process.kill(-proc.pid, signal);
112
+ } else {
113
+ proc.kill(signal);
114
+ }
115
+ }
89
116
  function preflightChecks(cwd) {
90
117
  const warnings = [];
91
118
  if (!existsSync(join(cwd, "node_modules"))) {
@@ -110,6 +137,10 @@ export {
110
137
  preflightChecks,
111
138
  readEnvFile,
112
139
  registerWebhookSubscriptions,
140
+ registeredWebhookEnv,
113
141
  spawnDevServer,
142
+ watchedDevInputPaths,
143
+ webhookSecretEnvKey,
144
+ webhookSecretForEvent,
114
145
  writeDevEnvFile
115
146
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@odeva/cli",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "Build apps on the Odeva booking platform — scaffold, develop, deploy.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,7 +1,9 @@
1
1
  import { Hono } from "hono";
2
+ import { OdevaClient } from "@odeva/booking-sdk";
2
3
  import { verifyWebhook } from "./webhook.js";
3
4
  import { makeInstallHandler } from "./install.js";
4
5
  import { SqliteInstallationStore } from "./installations.js";
6
+ import { logger } from "./logger.js";
5
7
 
6
8
  // Default store: SQLite file next to the app. Swap to your own
7
9
  // `InstallationStore` impl (e.g. Postgres) for multi-instance deploys.
@@ -13,12 +15,34 @@ app.get("/", (c) =>
13
15
  c.json({
14
16
  app: "{{slug}}",
15
17
  ok: true,
16
- routes: ["GET /", "GET /healthz", "GET /install", "POST /webhooks/:topic"],
18
+ routes: ["GET /", "GET /healthz", "GET /install", "GET /sdk/accommodations", "POST /webhooks/:topic"],
17
19
  }),
18
20
  );
19
21
 
20
22
  app.get("/healthz", (c) => c.json({ ok: true }));
21
23
 
24
+ // SDK example. Set ODEVA_ORGANIZATION_SLUG to query public booking data for an
25
+ // organization, then visit /sdk/accommodations while the app is running.
26
+ app.get("/sdk/accommodations", async (c) => {
27
+ const organizationSlug = process.env.ODEVA_ORGANIZATION_SLUG;
28
+ if (!organizationSlug) {
29
+ return c.json({
30
+ error: "missing ODEVA_ORGANIZATION_SLUG",
31
+ hint: "Set ODEVA_ORGANIZATION_SLUG in .odeva.env, then let `odeva app dev` reload.",
32
+ }, 400);
33
+ }
34
+
35
+ const odeva = new OdevaClient({ organizationSlug });
36
+ const accommodations = await odeva.searchAccommodations({
37
+ startDate: "2026-07-01",
38
+ endDate: "2026-07-08",
39
+ guests: 2,
40
+ limit: 5,
41
+ });
42
+
43
+ return c.json({ accommodations });
44
+ });
45
+
22
46
  // Install handshake. Odeva redirects here with `?install_code=...`.
23
47
  app.get("/install", makeInstallHandler(installations));
24
48
 
@@ -28,14 +52,16 @@ app.post("/webhooks/:topic", async (c) => {
28
52
  const topic = c.req.param("topic");
29
53
  const rawBody = await c.req.text();
30
54
  const signature = c.req.header("x-odeva-signature");
31
- const secret = process.env.ODEVA_WEBHOOK_SECRET;
55
+ const secret =
56
+ process.env[`ODEVA_WEBHOOK_SECRET__${topic.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}`] ??
57
+ process.env.ODEVA_WEBHOOK_SECRET;
32
58
 
33
59
  if (secret && !verifyWebhook(rawBody, signature, secret)) {
34
60
  return c.json({ error: "invalid signature" }, 401);
35
61
  }
36
62
 
37
63
  const payload = JSON.parse(rawBody) as Record<string, unknown>;
38
- console.log(`[webhook] ${topic}`, payload);
64
+ logger.info(`[webhook] ${topic}`, payload);
39
65
 
40
66
  // TODO: handle the event.
41
67
  // Tip: run `odeva webhook trigger <topic>` to fire a sample payload at this handler.
@@ -44,6 +70,6 @@ app.post("/webhooks/:topic", async (c) => {
44
70
  });
45
71
 
46
72
  const port = Number(process.env.PORT ?? 3000);
47
- console.log(`{{name}} listening on http://localhost:${port}`);
73
+ logger.info(`{{name}} listening on http://localhost:${port}`);
48
74
 
49
75
  export default { port, fetch: app.fetch };
@@ -0,0 +1,20 @@
1
+ import { inspect } from "node:util";
2
+
3
+ type LogLevel = "info" | "warn" | "error";
4
+
5
+ function log(level: LogLevel, message: string, details?: unknown): void {
6
+ const prefix = `[${new Date().toISOString()}] ${level.toUpperCase()} ${message}`;
7
+ if (details === undefined) {
8
+ console.log(prefix);
9
+ return;
10
+ }
11
+
12
+ console.log(prefix);
13
+ console.log(inspect(details, { colors: true, depth: 6, compact: false }));
14
+ }
15
+
16
+ export const logger = {
17
+ info: (message: string, details?: unknown) => log("info", message, details),
18
+ warn: (message: string, details?: unknown) => log("warn", message, details),
19
+ error: (message: string, details?: unknown) => log("error", message, details),
20
+ };