@odeva/cli 0.0.3 → 0.0.5

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/README.md CHANGED
@@ -101,6 +101,14 @@ port = 3000
101
101
  [access_scopes]
102
102
  scopes = ["reservations:read", "reservations:write"]
103
103
 
104
+ # Omit this section if the app doesn't surface inside the merchant admin.
105
+ [admin]
106
+ entry_url = "https://app.example.com/admin"
107
+
108
+ [admin.sidebar]
109
+ label = "Cabin Manager"
110
+ icon = "puzzle"
111
+
104
112
  [webhooks]
105
113
  api_version = "2026-01"
106
114
 
@@ -115,6 +123,76 @@ uri = "/webhooks/payment.succeeded"
115
123
 
116
124
  This file is the source of truth for app config. `odeva app config link` pushes it to the platform; `odeva app dev` reads webhook subscriptions from it to wire up the tunnel.
117
125
 
126
+ ## Embedding apps in the merchant admin
127
+
128
+ The admin embed surface lets your app appear as a sidebar entry inside the Odeva merchant admin, mounting your UI in an iframe. It is opt-in: the feature activates only when you declare an `[admin]` block in `odeva.app.toml` (see the Config example above) and run `odeva app config link`.
129
+
130
+ ### How it works
131
+
132
+ 1. Developer declares `[admin]` in `odeva.app.toml` (see the Config example above) and runs `odeva app config link`.
133
+ 2. Merchant installs the app from the marketplace.
134
+ 3. Merchant clicks the sidebar entry. `odeva-admin` mints a 5-min EdDSA session token via the `mintAppSessionToken` GraphQL mutation, then loads the iframe at `entry_url?session_token=<jwt>&host=<base_url>`.
135
+ 4. The app verifies the token against the platform JWKS at `/.well-known/odeva/apps/jwks.json`, checking `iss` and `aud`.
136
+ 5. Before the token expires, the app's **backend** calls `POST /api/apps/session-token/refresh` with `X-Api-Key: <api_key>` and hands the new token to the iframe.
137
+
138
+ ### Session token claims
139
+
140
+ Tokens are standard JWTs signed with `EdDSA`; the JOSE header carries `alg`, `typ`, and `kid`.
141
+
142
+ | Claim | Value |
143
+ | --- | --- |
144
+ | `iss` | Platform base URL (matches your `ODEVA_API_URL`) |
145
+ | `aud` | The app's `client_id` (matches your `ODEVA_APP_CLIENT_ID`) |
146
+ | `sub` | The app installation ID (string) |
147
+ | `org_id` | The merchant organization ID (string) |
148
+ | `app_id` | The app ID (string) |
149
+ | `exp` | 5 minutes after `iat` |
150
+ | `iat` | Issued-at (unix seconds) |
151
+ | `jti` | Unique token nonce |
152
+
153
+ ### Verifying the token
154
+
155
+ The JWKS endpoint is `${ODEVA_API_URL}/.well-known/odeva/apps/jwks.json`. The scaffolded template (`odeva app init`) provides a reference implementation in `src/admin.ts`; it uses the `jose` library with `createRemoteJWKSet` and `jwtVerify`.
156
+
157
+ Key points when verifying:
158
+
159
+ - Check `iss` matches your `ODEVA_API_URL`.
160
+ - Check `aud` matches your `ODEVA_APP_CLIENT_ID`.
161
+ - Never accept `alg: "none"` — `jose`'s `createRemoteJWKSet` enforces this automatically; if you roll your own verifier you must enforce it yourself.
162
+
163
+ ### Refreshing the token
164
+
165
+ Tokens have a 5-minute TTL. Long-lived iframe sessions must refresh the token before it expires.
166
+
167
+ Refresh from the app's **backend** only — the `api_key` must not be exposed to the browser.
168
+
169
+ ```sh
170
+ curl -X POST "$ODEVA_API_URL/api/apps/session-token/refresh" \
171
+ -H "X-Api-Key: $ODEVA_API_KEY"
172
+ # -> { "token": "...", "expires_at": "2026-..." }
173
+ ```
174
+
175
+ Success response: `{ token, expires_at }` where `expires_at` is an ISO8601 timestamp.
176
+
177
+ Error codes:
178
+
179
+ | Status | Meaning |
180
+ | --- | --- |
181
+ | `401` | Missing, invalid, or revoked `api_key` |
182
+ | `403` | `api_key` is not an app-installation key (e.g. a merchant key) |
183
+ | `409` | Installation is not active, or the app has no `[admin]` declared |
184
+ | `503` | Platform signing key not configured (operator-side; retry) |
185
+
186
+ Typical pattern: the backend refreshes ~30 s before `expires_at` and pushes the new token to the iframe via your own channel (postMessage, polling, or whatever fits the app).
187
+
188
+ ### Quick reference
189
+
190
+ - JWKS: `${ODEVA_API_URL}/.well-known/odeva/apps/jwks.json`
191
+ - Refresh: `${ODEVA_API_URL}/api/apps/session-token/refresh`
192
+ - Reference implementation: `src/admin.ts` (scaffolded by `odeva app init`)
193
+ - Env vars: `ODEVA_API_URL`, `ODEVA_APP_CLIENT_ID`, `ODEVA_API_KEY`
194
+ - Not yet shipped: host↔app `postMessage` bridge, URL sync between iframe and host.
195
+
118
196
  ## Templates
119
197
 
120
198
  Bundled templates live under `templates/` in this repo:
@@ -1,10 +1,30 @@
1
1
  import { Command, Flags } from "@oclif/core";
2
2
  import * as p from "@clack/prompts";
3
3
  import { OdevaApi } from "../../../lib/api.js";
4
- import { loadAppConfig, saveAppConfig } from "../../../lib/config.js";
4
+ import { loadAppConfig, saveAppConfig, ADMIN_ICONS } from "../../../lib/config.js";
5
5
  import { saveAppEnv } from "../../../lib/app-env.js";
6
+ import { loadCredentials } from "../../../lib/credentials.js";
6
7
  import { withErrorHandling } from "../../../lib/run.js";
7
8
  import { ui } from "../../../lib/ui.js";
9
+ import { ApiError, CliError } from "../../../lib/errors.js";
10
+ import { isValidSlug } from "../../../lib/slug.js";
11
+ function buildAdminEmbedInput(admin, configPath) {
12
+ const validIcons = ADMIN_ICONS;
13
+ if (!validIcons.includes(admin.sidebar.icon)) {
14
+ const iconList = ADMIN_ICONS.join(", ");
15
+ throw new CliError(
16
+ `Invalid 'admin.sidebar.icon' in odeva.app.toml: '${admin.sidebar.icon}'. Must be one of: ${iconList}.`,
17
+ { hint: `Edit ${configPath}` }
18
+ );
19
+ }
20
+ return {
21
+ entryUrl: admin.entry_url,
22
+ sidebar: {
23
+ label: admin.sidebar.label,
24
+ icon: admin.sidebar.icon
25
+ }
26
+ };
27
+ }
8
28
  class AppConfigLink extends Command {
9
29
  static description = "Register the local app on Odeva and sync its client_id back to odeva.app.toml";
10
30
  static examples = ["$ odeva app config link"];
@@ -18,6 +38,7 @@ class AppConfigLink extends Command {
18
38
  const { flags } = await this.parse(AppConfigLink);
19
39
  await withErrorHandling(this, async () => {
20
40
  const loaded = loadAppConfig();
41
+ const adminInput = loaded.config.admin ? buildAdminEmbedInput(loaded.config.admin, loaded.path) : void 0;
21
42
  p.intro(ui.brand("odeva app config link"));
22
43
  p.note(
23
44
  [
@@ -25,7 +46,8 @@ class AppConfigLink extends Command {
25
46
  `${ui.dim("slug:")} ${loaded.config.slug}`,
26
47
  `${ui.dim("client_id:")} ${loaded.config.client_id ?? ui.dim("(new)")}`,
27
48
  `${ui.dim("homepage:")} ${loaded.config.homepage_url ?? ui.dim("(none)")}`,
28
- `${ui.dim("scopes:")} ${(loaded.config.access_scopes?.scopes ?? []).join(", ") || ui.dim("(none)")}`
49
+ `${ui.dim("scopes:")} ${(loaded.config.access_scopes?.scopes ?? []).join(", ") || ui.dim("(none)")}`,
50
+ ...loaded.config.admin ? [`${ui.dim("admin:")} ${loaded.config.admin.sidebar.label} (${loaded.config.admin.entry_url})`] : []
29
51
  ].join("\n"),
30
52
  "Will register this app"
31
53
  );
@@ -43,28 +65,18 @@ class AppConfigLink extends Command {
43
65
  if (match) existingId = match.id;
44
66
  }
45
67
  spinner.message(existingId ? "Updating app on Odeva" : "Creating app on Odeva");
46
- const { app, rawClientSecret } = await api.upsertDeveloperApp({
47
- id: existingId,
48
- name: loaded.config.name,
49
- slug: loaded.config.slug,
50
- description: loaded.config.description,
51
- homepageUrl: loaded.config.homepage_url,
52
- installUrl: loaded.config.install_url,
53
- privacyUrl: loaded.config.privacy_url,
54
- requestedScopes: loaded.config.access_scopes?.scopes
55
- // `webhookUrl` is the install-time URL recorded on the App; per-event
56
- // subscriptions are managed separately by `odeva app dev` against the
57
- // tunnel URL.
58
- });
68
+ const { app, rawClientSecret } = await upsertWithSlugRetry(api, spinner, loaded, existingId, adminInput);
59
69
  spinner.stop(`${existingId ? "Updated" : "Registered"} app ${ui.code(app.slug)} (client_id ${ui.code(app.clientId)})`);
60
70
  loaded.config.client_id = app.clientId;
61
71
  saveAppConfig(loaded);
62
- let envPath = null;
72
+ const creds = loadCredentials();
73
+ const envValues = {
74
+ ODEVA_APP_CLIENT_ID: app.clientId,
75
+ ...rawClientSecret ? { ODEVA_APP_CLIENT_SECRET: rawClientSecret } : {},
76
+ ...creds?.organizationSlug ? { ODEVA_ORGANIZATION_SLUG: creds.organizationSlug } : {}
77
+ };
78
+ const envPath = saveAppEnv(loaded.path, envValues);
63
79
  if (rawClientSecret) {
64
- envPath = saveAppEnv(loaded.path, {
65
- ODEVA_APP_CLIENT_ID: app.clientId,
66
- ODEVA_APP_CLIENT_SECRET: rawClientSecret
67
- });
68
80
  p.note(
69
81
  [
70
82
  "This is the only time the secret is shown.",
@@ -80,7 +92,8 @@ class AppConfigLink extends Command {
80
92
  p.outro(
81
93
  [
82
94
  ui.ok(`Wrote client_id to ${ui.code(loaded.path)}`),
83
- envPath ? ui.ok(`Wrote client_secret to ${ui.code(envPath)}`) : null,
95
+ rawClientSecret ? ui.ok(`Wrote client_secret to ${ui.code(envPath)}`) : null,
96
+ creds?.organizationSlug ? ui.ok(`Wrote organization slug to ${ui.code(envPath)}`) : null,
84
97
  "",
85
98
  ` Next: ${ui.code("odeva app dev")} to run the app locally with a public tunnel.`
86
99
  ].filter((line) => line !== null).join("\n")
@@ -88,6 +101,51 @@ class AppConfigLink extends Command {
88
101
  });
89
102
  }
90
103
  }
104
+ const SLUG_TAKEN_RE = /slug.*(?:already been taken|taken|in use|exists)/i;
105
+ async function upsertWithSlugRetry(api, spinner, loaded, existingId, adminInput) {
106
+ for (; ; ) {
107
+ try {
108
+ return await api.upsertDeveloperApp({
109
+ id: existingId,
110
+ name: loaded.config.name,
111
+ slug: loaded.config.slug,
112
+ description: loaded.config.description,
113
+ homepageUrl: loaded.config.homepage_url,
114
+ installUrl: loaded.config.install_url,
115
+ privacyUrl: loaded.config.privacy_url,
116
+ requestedScopes: loaded.config.access_scopes?.scopes,
117
+ ...adminInput ? { adminEmbed: adminInput } : {}
118
+ });
119
+ } catch (err) {
120
+ if (!(err instanceof ApiError) || !SLUG_TAKEN_RE.test(err.message)) throw err;
121
+ spinner.stop(ui.warn(`Slug '${loaded.config.slug}' is already taken.`));
122
+ const creds = loadCredentials();
123
+ const suggestion = creds?.organizationSlug && !loaded.config.slug.startsWith(`${creds.organizationSlug}-`) ? `${creds.organizationSlug}-${loaded.config.slug}` : `${loaded.config.slug}-2`;
124
+ const next = await p.text({
125
+ message: "Pick a new slug",
126
+ placeholder: suggestion,
127
+ initialValue: suggestion,
128
+ validate: (v) => {
129
+ const trimmed = v.trim();
130
+ if (!isValidSlug(trimmed)) {
131
+ return "Slugs are lowercase letters, digits, and hyphens (e.g. 'cabin-manager').";
132
+ }
133
+ if (trimmed === loaded.config.slug) {
134
+ return "Pick a different slug than the one that's taken.";
135
+ }
136
+ return void 0;
137
+ }
138
+ });
139
+ if (p.isCancel(next)) {
140
+ throw new CliError("link cancelled.");
141
+ }
142
+ loaded.config.slug = next.trim();
143
+ saveAppConfig(loaded);
144
+ spinner.start(existingId ? "Updating app on Odeva" : "Creating app on Odeva");
145
+ }
146
+ }
147
+ }
91
148
  export {
149
+ buildAdminEmbedInput,
92
150
  AppConfigLink as default
93
151
  };
@@ -5,6 +5,8 @@ import { OdevaApi } from "../../lib/api.js";
5
5
  import { loadAppConfig } from "../../lib/config.js";
6
6
  import { loadAppEnv } from "../../lib/app-env.js";
7
7
  import { startQuickTunnel } from "../../lib/cloudflared.js";
8
+ import { loadCredentials } from "../../lib/credentials.js";
9
+ import { adminUrl } from "../../lib/paths.js";
8
10
  import {
9
11
  cleanupSubscriptions,
10
12
  preflightChecks,
@@ -64,6 +66,7 @@ class AppDev extends Command {
64
66
  this.log(` ${ui.dim("\u2192")} ${reg.config.topic.padEnd(28)} ${reg.fullUrl}`);
65
67
  }
66
68
  this.log("");
69
+ this.printInstallHint(loaded, tunnel.url);
67
70
  this.log(ui.dim("Press Ctrl-C to stop. Subscriptions will be cleaned up on exit."));
68
71
  this.log("");
69
72
  let server = spawnDevServer({
@@ -142,6 +145,26 @@ class AppDev extends Command {
142
145
  await Promise.allSettled([tunnel.stop(), cleanupSubscriptions(api, registered)]);
143
146
  });
144
147
  }
148
+ printInstallHint(loaded, tunnelUrl) {
149
+ const creds = loadCredentials();
150
+ const apps = adminUrl(creds?.apiUrl);
151
+ this.log(`${ui.bold("Install:")} ${apps}/settings/apps ${ui.dim('(find your draft app under "Your apps")')}`);
152
+ if (creds?.organizationSlug) {
153
+ this.log(` ${ui.dim("org:")} ${creds.organizationSlug}`);
154
+ }
155
+ const installUrl = loaded.config.install_url?.trim();
156
+ const expected = `${tunnelUrl}/install`;
157
+ if (!installUrl) {
158
+ this.log(ui.warn(
159
+ `install_url in odeva.app.toml is empty \u2014 installs won't redirect back. Set it to ${expected} and re-run \`odeva app config link\` (or use a stable URL once you deploy).`
160
+ ));
161
+ } else if (!installUrl.startsWith(tunnelUrl) && !/localhost|127\.0\.0\.1/.test(installUrl)) {
162
+ this.log(ui.warn(
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
+ ));
165
+ }
166
+ this.log("");
167
+ }
145
168
  async registerDevWebhooks(api, loaded, tunnelUrl) {
146
169
  const subscriptions = loaded.config.webhooks?.subscriptions ?? [];
147
170
  if (subscriptions.length === 0) {
@@ -2,6 +2,8 @@ import { Args, Command, Flags } from "@oclif/core";
2
2
  import * as p from "@clack/prompts";
3
3
  import { existsSync } from "node:fs";
4
4
  import { basename, resolve } from "node:path";
5
+ import { OdevaApi } from "../../lib/api.js";
6
+ import { loadCredentials } from "../../lib/credentials.js";
5
7
  import { CliError } from "../../lib/errors.js";
6
8
  import { withErrorHandling } from "../../lib/run.js";
7
9
  import { isValidSlug, slugify } from "../../lib/slug.js";
@@ -43,8 +45,12 @@ class AppInit extends Command {
43
45
  await withErrorHandling(this, async () => {
44
46
  p.intro(ui.brand("odeva app init"));
45
47
  const directory = await this.resolveDirectory(args.name, flags.yes);
46
- const slug = this.resolveSlug(flags.slug ?? slugify(basename(directory)));
47
48
  const displayName = flags["display-name"] ?? toTitleCase(basename(directory));
49
+ const slug = await this.resolveSlug({
50
+ explicit: flags.slug,
51
+ dirBase: basename(directory),
52
+ skipPrompts: flags.yes
53
+ });
48
54
  const template = flags.template;
49
55
  if (!listTemplates().includes(template)) {
50
56
  throw new CliError(`Unknown template: '${template}'.`, {
@@ -106,14 +112,63 @@ class AppInit extends Command {
106
112
  }
107
113
  return directory;
108
114
  }
109
- resolveSlug(candidate) {
110
- const slug = candidate.trim();
111
- if (!isValidSlug(slug)) {
112
- throw new CliError(`Invalid slug '${slug}'.`, {
113
- hint: "Slugs are lowercase letters, digits, and hyphens (e.g. 'cabin-manager')."
115
+ async resolveSlug(opts) {
116
+ const creds = loadCredentials();
117
+ const orgSlug = creds?.organizationSlug;
118
+ const derived = slugify(opts.dirBase);
119
+ const initial = opts.explicit?.trim() || (orgSlug ? `${orgSlug}-${derived}` : derived);
120
+ validateSlug(initial);
121
+ if (opts.explicit || opts.skipPrompts || !creds) {
122
+ if (creds && opts.explicit) await assertAvailable(initial);
123
+ return initial;
124
+ }
125
+ let candidate = initial;
126
+ const api = new OdevaApi({ credentials: creds });
127
+ for (; ; ) {
128
+ const available = await isAvailable(api, candidate);
129
+ if (available !== false) return candidate;
130
+ const next = await p.text({
131
+ message: `Slug '${candidate}' is already taken \u2014 pick another`,
132
+ placeholder: `${candidate}-2`,
133
+ initialValue: `${candidate}-2`,
134
+ validate: (v) => {
135
+ const trimmed = v.trim();
136
+ if (!isValidSlug(trimmed)) {
137
+ return "Slugs are lowercase letters, digits, and hyphens (e.g. 'cabin-manager').";
138
+ }
139
+ return void 0;
140
+ }
114
141
  });
142
+ if (p.isCancel(next)) {
143
+ throw new CliError("init cancelled.");
144
+ }
145
+ candidate = next.trim();
115
146
  }
116
- return slug;
147
+ }
148
+ }
149
+ function validateSlug(slug) {
150
+ if (!isValidSlug(slug)) {
151
+ throw new CliError(`Invalid slug '${slug}'.`, {
152
+ hint: "Slugs are lowercase letters, digits, and hyphens (e.g. 'cabin-manager')."
153
+ });
154
+ }
155
+ }
156
+ async function isAvailable(api, slug) {
157
+ try {
158
+ return await api.developerAppSlugAvailable(slug);
159
+ } catch {
160
+ return null;
161
+ }
162
+ }
163
+ async function assertAvailable(slug) {
164
+ const creds = loadCredentials();
165
+ if (!creds) return;
166
+ const api = new OdevaApi({ credentials: creds });
167
+ const ok = await isAvailable(api, slug);
168
+ if (ok === false) {
169
+ throw new CliError(`Slug '${slug}' is already taken.`, {
170
+ hint: "Choose another with --slug, or omit --slug to be prompted."
171
+ });
117
172
  }
118
173
  }
119
174
  function toTitleCase(input) {
@@ -0,0 +1,158 @@
1
+ import { Args, Command } from "@oclif/core";
2
+ import { CliError } from "../lib/errors.js";
3
+ import { withErrorHandling } from "../lib/run.js";
4
+ const SUPPORTED_SHELLS = ["fish", "zsh"];
5
+ class Autocomplete extends Command {
6
+ static description = "Print a shell completion script for the odeva CLI";
7
+ static examples = [
8
+ "$ odeva autocomplete fish > ~/.config/fish/completions/odeva.fish",
9
+ "$ odeva autocomplete zsh > ~/.odeva/_odeva # then source it from .zshrc"
10
+ ];
11
+ static args = {
12
+ shell: Args.string({
13
+ description: "Target shell",
14
+ options: [...SUPPORTED_SHELLS],
15
+ required: true
16
+ })
17
+ };
18
+ async run() {
19
+ const { args } = await this.parse(Autocomplete);
20
+ await withErrorHandling(this, async () => {
21
+ const shell = args.shell;
22
+ if (!SUPPORTED_SHELLS.includes(shell)) {
23
+ throw new CliError(`Unsupported shell '${shell}'.`, {
24
+ hint: `Supported: ${SUPPORTED_SHELLS.join(", ")}.`
25
+ });
26
+ }
27
+ const bin = this.config.bin;
28
+ const tree = buildCommandTree(this.config.commands, bin);
29
+ const script = shell === "fish" ? renderFish(bin, tree) : renderZsh(bin, tree);
30
+ process.stdout.write(script);
31
+ });
32
+ }
33
+ }
34
+ function buildCommandTree(commands, binName) {
35
+ const root = { name: "", description: "", children: /* @__PURE__ */ new Map() };
36
+ for (const cmd of commands) {
37
+ if (cmd.hidden || cmd.id === "autocomplete") continue;
38
+ const segments = cmd.id.split(":");
39
+ let node = root;
40
+ for (let i = 0; i < segments.length; i++) {
41
+ const seg = segments[i];
42
+ const isLeaf = i === segments.length - 1;
43
+ let child = node.children.get(seg);
44
+ if (!child) {
45
+ child = { name: seg, description: "", children: /* @__PURE__ */ new Map() };
46
+ node.children.set(seg, child);
47
+ }
48
+ if (isLeaf) {
49
+ child.command = cmd;
50
+ const rawDesc = cmd.summary || cmd.description?.split("\n")[0] || "";
51
+ child.description = rawDesc.replace(/<%=\s*config\.bin\s*%>/g, binName);
52
+ }
53
+ node = child;
54
+ }
55
+ }
56
+ return root;
57
+ }
58
+ function shellSingleQuote(s) {
59
+ return `'${s.replace(/'/g, "'\\''")}'`;
60
+ }
61
+ function renderFish(bin, root) {
62
+ const lines = [
63
+ `# fish completion for ${bin}`,
64
+ `# Generated by \`${bin} autocomplete fish\``,
65
+ "",
66
+ `function __${bin}_at_path`,
67
+ " # Returns 0 if the current command line is exactly `bin arg1 arg2 ...`",
68
+ " set -l tokens (commandline -opc)",
69
+ " set -e tokens[1]",
70
+ " test (count $tokens) -eq (count $argv); or return 1",
71
+ " for i in (seq (count $argv))",
72
+ ' test "$tokens[$i]" = "$argv[$i]"; or return 1',
73
+ " end",
74
+ " return 0",
75
+ "end",
76
+ ""
77
+ ];
78
+ const walk = (node, path) => {
79
+ if (node.children.size > 0) {
80
+ const condition = path.length === 0 ? `__${bin}_at_path` : `__${bin}_at_path ${path.join(" ")}`;
81
+ for (const child of node.children.values()) {
82
+ const desc = child.description.replace(/\s+/g, " ").trim();
83
+ const descPart = desc ? ` -d ${shellSingleQuote(desc)}` : "";
84
+ lines.push(`complete -c ${bin} -n ${shellSingleQuote(condition)} -f -a ${shellSingleQuote(child.name)}${descPart}`);
85
+ }
86
+ }
87
+ if (node.command) {
88
+ const condition = `__${bin}_at_path ${path.join(" ")}`;
89
+ for (const [flagName, flag] of Object.entries(node.command.flags)) {
90
+ if (flag.hidden) continue;
91
+ const desc = (flag.summary || flag.description || "").replace(/\s+/g, " ").trim();
92
+ const descPart = desc ? ` -d ${shellSingleQuote(desc)}` : "";
93
+ const takesValue = flag.type === "option" ? " -r" : "";
94
+ const short = flag.char ? ` -s ${flag.char}` : "";
95
+ lines.push(`complete -c ${bin} -n ${shellSingleQuote(condition)} -l ${flagName}${short}${takesValue}${descPart}`);
96
+ }
97
+ }
98
+ for (const child of node.children.values()) {
99
+ walk(child, [...path, child.name]);
100
+ }
101
+ };
102
+ walk(root, []);
103
+ return lines.join("\n") + "\n";
104
+ }
105
+ function renderZsh(bin, root) {
106
+ const dispatch = [];
107
+ const collect = (node, path) => {
108
+ const key = path.join(" ");
109
+ const subcommands = [];
110
+ for (const child of node.children.values()) {
111
+ const desc = child.description.replace(/[:\s]+/g, " ").trim();
112
+ subcommands.push(`'${child.name}:${desc}'`);
113
+ }
114
+ const flagLines = [];
115
+ if (node.command) {
116
+ for (const [flagName, flag] of Object.entries(node.command.flags)) {
117
+ if (flag.hidden) continue;
118
+ const desc = (flag.summary || flag.description || "").replace(/[:[\]]/g, " ").trim();
119
+ const arg = flag.type === "option" ? ":value:" : "";
120
+ flagLines.push(`'--${flagName}[${desc}]${arg}'`);
121
+ if (flag.char) flagLines.push(`'-${flag.char}[${desc}]${arg}'`);
122
+ }
123
+ }
124
+ dispatch.push(
125
+ ` ${shellSingleQuote(key)})`,
126
+ ` _values 'subcommand or flag' ${[...subcommands, ...flagLines].join(" ")}`,
127
+ ` ;;`
128
+ );
129
+ for (const child of node.children.values()) {
130
+ collect(child, [...path, child.name]);
131
+ }
132
+ };
133
+ collect(root, []);
134
+ return `#compdef ${bin}
135
+ # zsh completion for ${bin}
136
+ # Generated by \`${bin} autocomplete zsh\`
137
+
138
+ _${bin}() {
139
+ local -a words
140
+ words=("\${(@)words[2,$CURRENT - 1]}")
141
+ local key="\${(j: :)words}"
142
+ case "$key" in
143
+ ${dispatch.join("\n")}
144
+ *)
145
+ _values 'subcommand'
146
+ ;;
147
+ esac
148
+ }
149
+
150
+ _${bin} "$@"
151
+ `;
152
+ }
153
+ export {
154
+ buildCommandTree,
155
+ Autocomplete as default,
156
+ renderFish,
157
+ renderZsh
158
+ };
package/dist/lib/api.js CHANGED
@@ -89,6 +89,21 @@ class OdevaApi {
89
89
  );
90
90
  return data.cliAuthPoll;
91
91
  }
92
+ /**
93
+ * Check whether a developer-app slug is free. Returns `null` when the CLI
94
+ * isn't authenticated yet (init can be invoked before `auth login`) so
95
+ * callers can fall back to local validation instead of bailing.
96
+ */
97
+ async developerAppSlugAvailable(slug, excludeId) {
98
+ if (!this.authenticated) return null;
99
+ const data = await this.request(
100
+ `query OdevaCliDeveloperAppSlugAvailable($slug: String!, $excludeId: ID) {
101
+ developerAppSlugAvailable(slug: $slug, excludeId: $excludeId)
102
+ }`,
103
+ { slug, excludeId }
104
+ );
105
+ return data.developerAppSlugAvailable;
106
+ }
92
107
  async listDeveloperApps() {
93
108
  const data = await this.request(`
94
109
  query OdevaCliListDeveloperApps {
@@ -153,6 +168,7 @@ class OdevaApi {
153
168
  $privacyUrl: String
154
169
  $requestedScopes: [String!]
155
170
  $webhookUrl: String
171
+ $adminEmbed: AdminEmbedInput
156
172
  ) {
157
173
  upsertDeveloperApp(
158
174
  id: $id
@@ -165,6 +181,7 @@ class OdevaApi {
165
181
  privacyUrl: $privacyUrl
166
182
  requestedScopes: $requestedScopes
167
183
  webhookUrl: $webhookUrl
184
+ adminEmbed: $adminEmbed
168
185
  ) {
169
186
  app {
170
187
  id
@@ -3,6 +3,17 @@ import { dirname, join, resolve } from "node:path";
3
3
  import { parse, stringify } from "smol-toml";
4
4
  import { APP_CONFIG_FILE } from "./paths.js";
5
5
  import { AppConfigNotFoundError, CliError } from "./errors.js";
6
+ const ADMIN_ICONS = [
7
+ "puzzle",
8
+ "box",
9
+ "plug",
10
+ "bot",
11
+ "sparkles",
12
+ "wrench",
13
+ "database",
14
+ "bar-chart",
15
+ "wallet"
16
+ ];
6
17
  function findAppConfigPath(startDir = process.cwd()) {
7
18
  let dir = resolve(startDir);
8
19
  while (true) {
@@ -59,8 +70,60 @@ function validateConfig(config, path) {
59
70
  };
60
71
  });
61
72
  }
73
+ if (config.admin !== void 0) {
74
+ validateAdminSection(config.admin, path);
75
+ }
76
+ }
77
+ function validateAdminSection(admin, path) {
78
+ if (typeof admin !== "object" || admin === null || Array.isArray(admin)) {
79
+ throw new CliError(`Invalid ${APP_CONFIG_FILE}: 'admin' must be a table.`, {
80
+ hint: `Edit ${path}`
81
+ });
82
+ }
83
+ const a = admin;
84
+ if (typeof a["entry_url"] !== "string" || !a["entry_url"]) {
85
+ throw new CliError(`Invalid ${APP_CONFIG_FILE}: missing 'admin.entry_url'.`, {
86
+ hint: `Edit ${path}`
87
+ });
88
+ }
89
+ if (!a["entry_url"].startsWith("https://")) {
90
+ throw new CliError(
91
+ `Invalid 'admin.entry_url' in ${APP_CONFIG_FILE}: must start with https://.`,
92
+ { hint: `Edit ${path}` }
93
+ );
94
+ }
95
+ const sidebar = a["sidebar"];
96
+ if (typeof sidebar !== "object" || sidebar === null || Array.isArray(sidebar)) {
97
+ throw new CliError(`Invalid ${APP_CONFIG_FILE}: missing 'admin.sidebar' table.`, {
98
+ hint: `Edit ${path}`
99
+ });
100
+ }
101
+ const s = sidebar;
102
+ if (typeof s["label"] !== "string" || s["label"].length === 0) {
103
+ throw new CliError(`Invalid ${APP_CONFIG_FILE}: missing 'admin.sidebar.label'.`, {
104
+ hint: `Edit ${path}`
105
+ });
106
+ }
107
+ if (s["label"].length > 30) {
108
+ throw new CliError(
109
+ `Invalid 'admin.sidebar.label' in ${APP_CONFIG_FILE}: must be 30 characters or fewer.`,
110
+ { hint: `Edit ${path}` }
111
+ );
112
+ }
113
+ if (typeof s["icon"] !== "string" || !s["icon"]) {
114
+ throw new CliError(`Invalid ${APP_CONFIG_FILE}: missing 'admin.sidebar.icon'.`, { hint: `Edit ${path}` });
115
+ }
116
+ const validIcons = ADMIN_ICONS;
117
+ if (!validIcons.includes(s["icon"])) {
118
+ const iconList = ADMIN_ICONS.join(", ");
119
+ throw new CliError(
120
+ `Invalid 'admin.sidebar.icon' in ${APP_CONFIG_FILE}: '${s["icon"]}'. Must be one of: ${iconList}.`,
121
+ { hint: `Edit ${path}` }
122
+ );
123
+ }
62
124
  }
63
125
  export {
126
+ ADMIN_ICONS,
64
127
  findAppConfigPath,
65
128
  loadAppConfig,
66
129
  saveAppConfig,
package/dist/lib/paths.js CHANGED
@@ -5,9 +5,16 @@ const CONFIG_DIR = join(xdgConfigHome, "odeva");
5
5
  const CREDENTIALS_PATH = join(CONFIG_DIR, "credentials.json");
6
6
  const APP_CONFIG_FILE = "odeva.app.toml";
7
7
  const DEFAULT_API_URL = process.env["ODEVA_API_URL"] || "https://booking.odeva.app";
8
+ function adminUrl(apiUrl = DEFAULT_API_URL) {
9
+ const override = process.env["ODEVA_ADMIN_URL"];
10
+ if (override) return override.replace(/\/$/, "");
11
+ const base = apiUrl.replace(/\/$/, "");
12
+ return /localhost|127\.0\.0\.1/.test(base) ? base : `${base}/admin`;
13
+ }
8
14
  export {
9
15
  APP_CONFIG_FILE,
10
16
  CONFIG_DIR,
11
17
  CREDENTIALS_PATH,
12
- DEFAULT_API_URL
18
+ DEFAULT_API_URL,
19
+ adminUrl
13
20
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@odeva/cli",
3
- "version": "0.0.3",
3
+ "version": "0.0.5",
4
4
  "description": "Build apps on the Odeva booking platform — scaffold, develop, deploy.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -21,3 +21,22 @@ odeva webhook trigger reservation.created
21
21
  - `odeva.app.toml` — app config (name, slug, webhooks, scopes)
22
22
  - `src/index.ts` — Hono server with a webhook handler stub
23
23
  - `src/webhook.ts` — HMAC signature verification
24
+ - `src/admin.ts` — iframe session-token verification
25
+
26
+ ## Embed in the merchant admin
27
+
28
+ This template ships a `/admin` route that verifies Odeva session tokens against
29
+ the platform's JWKS. To surface your app inside the merchant admin sidebar:
30
+
31
+ 1. Uncomment the `[admin]` block at the bottom of `odeva.app.toml` and set
32
+ `entry_url` to a publicly reachable URL (your `odeva app dev` tunnel works).
33
+ 2. Run `odeva app config link` to push the embed config to Odeva.
34
+ 3. Reinstall the app on a test org; the sidebar entry appears.
35
+
36
+ `/admin` reads `?session_token=<jwt>` from the iframe URL, verifies it via
37
+ `jose` + the JWKS at `${ODEVA_API_URL}/.well-known/odeva/apps/jwks.json`,
38
+ and renders `Hello, org <org_id>`. See `src/admin.ts` for the verification
39
+ recipe and a comment showing how to refresh tokens server-side.
40
+
41
+ Env: `ODEVA_APP_CLIENT_ID` is written into `.odeva.env` by `config link`;
42
+ `ODEVA_API_URL` overrides the default `https://booking.odeva.app`.
@@ -23,3 +23,16 @@ api_version = "2026-01"
23
23
  # [[webhooks.subscriptions]]
24
24
  # topic = "reservation.created"
25
25
  # uri = "/webhooks/reservation.created"
26
+
27
+ # Uncomment to embed this app inside the merchant admin sidebar.
28
+ # The merchant admin will iframe `entry_url?session_token=<short-lived JWT>`
29
+ # and your `/admin` route verifies that token against the Odeva JWKS.
30
+ #
31
+ # Icons: puzzle, box, plug, bot, sparkles, wrench, database, bar-chart, wallet.
32
+ #
33
+ # [admin]
34
+ # entry_url = "https://<your-app-dev-url>/admin"
35
+ #
36
+ # [admin.sidebar]
37
+ # label = "{{name}}"
38
+ # icon = "puzzle"
@@ -10,7 +10,8 @@
10
10
  },
11
11
  "dependencies": {
12
12
  "@odeva/booking-sdk": "^0.1.0",
13
- "hono": "^4.6.0"
13
+ "hono": "^4.6.0",
14
+ "jose": "^6.0.0"
14
15
  },
15
16
  "devDependencies": {
16
17
  "@types/bun": "^1.1.0",
@@ -0,0 +1,99 @@
1
+ // Admin embed route. The Odeva merchant admin iframes this route with
2
+ // `?session_token=<short-lived EdDSA JWT>`. We verify the token against
3
+ // the platform's JWKS and render a personalised page for the org.
4
+ //
5
+ // Opt in by uncommenting the `[admin]` block in `odeva.app.toml` and
6
+ // running `odeva app config link`.
7
+
8
+ import { Hono } from "hono";
9
+ import { createRemoteJWKSet, jwtVerify } from "jose";
10
+
11
+ const ODEVA_API_URL = process.env.ODEVA_API_URL ?? "https://booking.odeva.app";
12
+ const ODEVA_APP_CLIENT_ID = process.env.ODEVA_APP_CLIENT_ID;
13
+
14
+ // Lazily initialised on first request so module load stays side-effect-free.
15
+ let _jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
16
+
17
+ function getJwks(): ReturnType<typeof createRemoteJWKSet> {
18
+ if (!_jwks) {
19
+ _jwks = createRemoteJWKSet(
20
+ new URL("/.well-known/odeva/apps/jwks.json", ODEVA_API_URL),
21
+ );
22
+ }
23
+ return _jwks;
24
+ }
25
+
26
+ // Cheap HTML escaping — values come from a verified JWT (Odeva-issued) but
27
+ // defence costs nothing.
28
+ function escape(s: string): string {
29
+ return s
30
+ .replace(/&/g, "&amp;")
31
+ .replace(/</g, "&lt;")
32
+ .replace(/>/g, "&gt;")
33
+ .replace(/"/g, "&quot;")
34
+ .replace(/'/g, "&#39;");
35
+ }
36
+
37
+ export const adminApp = new Hono();
38
+
39
+ adminApp.get("/", async (c) => {
40
+ if (!ODEVA_APP_CLIENT_ID) {
41
+ return c.text(
42
+ "Server is missing ODEVA_APP_CLIENT_ID. " +
43
+ "Set it via `.odeva.env` (managed by `odeva app config link`).",
44
+ 500,
45
+ );
46
+ }
47
+
48
+ const token = c.req.query("session_token");
49
+ if (!token) {
50
+ return c.text("Missing session_token query parameter.", 400);
51
+ }
52
+
53
+ // Session tokens are short-lived (5 minutes). To refresh from your backend,
54
+ // POST to the refresh endpoint with your installation's api_key:
55
+ //
56
+ // const res = await fetch(`${process.env.ODEVA_API_URL}/api/apps/session-token/refresh`, {
57
+ // method: "POST",
58
+ // headers: { "X-Api-Key": installationApiKey },
59
+ // });
60
+ // const { token, expires_at } = await res.json();
61
+ //
62
+ // The api_key is the one Odeva minted at install time (see src/install.ts).
63
+ // NEVER call this from the iframe — the api_key would be exposed to the
64
+ // browser. Mint a fresh token server-side and hand it to your frontend.
65
+
66
+ let payload: { org_id?: unknown; sub?: string };
67
+ try {
68
+ const result = await jwtVerify(token, getJwks(), {
69
+ issuer: ODEVA_API_URL,
70
+ audience: ODEVA_APP_CLIENT_ID,
71
+ });
72
+ payload = result.payload as { org_id?: unknown; sub?: string };
73
+ } catch {
74
+ return c.text("Invalid session token.", 401);
75
+ }
76
+
77
+ const rawOrgId = payload.org_id;
78
+ const rawSub = payload.sub;
79
+ if (
80
+ typeof rawOrgId !== "string" ||
81
+ !rawOrgId ||
82
+ typeof rawSub !== "string" ||
83
+ !rawSub
84
+ ) {
85
+ return c.text("Invalid session token.", 401);
86
+ }
87
+ const orgId = escape(rawOrgId);
88
+ const installationId = escape(rawSub);
89
+
90
+ return c.html(`
91
+ <!doctype html>
92
+ <html><head><title>{{name}}</title></head>
93
+ <body style="font-family: system-ui; max-width: 32rem; margin: 4rem auto; padding: 0 1rem;">
94
+ <h1>Hello, org ${orgId}</h1>
95
+ <p>This page was rendered by your app inside the merchant admin iframe.</p>
96
+ <p>Installation: <code>${installationId}</code></p>
97
+ </body></html>
98
+ `);
99
+ });
@@ -4,6 +4,7 @@ import { verifyWebhook } from "./webhook.js";
4
4
  import { makeInstallHandler } from "./install.js";
5
5
  import { SqliteInstallationStore } from "./installations.js";
6
6
  import { logger } from "./logger.js";
7
+ import { adminApp } from "./admin.js";
7
8
 
8
9
  // Default store: SQLite file next to the app. Swap to your own
9
10
  // `InstallationStore` impl (e.g. Postgres) for multi-instance deploys.
@@ -15,7 +16,7 @@ app.get("/", (c) =>
15
16
  c.json({
16
17
  app: "{{slug}}",
17
18
  ok: true,
18
- routes: ["GET /", "GET /healthz", "GET /install", "GET /sdk/accommodations", "POST /webhooks/:topic"],
19
+ routes: ["GET /", "GET /healthz", "GET /install", "GET /sdk/accommodations", "GET /admin", "POST /webhooks/:topic"],
19
20
  }),
20
21
  );
21
22
 
@@ -46,6 +47,10 @@ app.get("/sdk/accommodations", async (c) => {
46
47
  // Install handshake. Odeva redirects here with `?install_code=...`.
47
48
  app.get("/install", makeInstallHandler(installations));
48
49
 
50
+ // Admin embed. Verifies the session_token JWT issued by Odeva and renders
51
+ // a page inside the merchant admin sidebar iframe.
52
+ app.route("/admin", adminApp);
53
+
49
54
  // Webhook handler. `odeva app dev` registers subscriptions from odeva.app.toml
50
55
  // against the dev tunnel, so payloads from the Odeva platform arrive here.
51
56
  app.post("/webhooks/:topic", async (c) => {