@odeva/cli 0.0.3 → 0.0.4

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,28 @@
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
6
  import { withErrorHandling } from "../../../lib/run.js";
7
7
  import { ui } from "../../../lib/ui.js";
8
+ import { CliError } from "../../../lib/errors.js";
9
+ function buildAdminEmbedInput(admin, configPath) {
10
+ const validIcons = ADMIN_ICONS;
11
+ if (!validIcons.includes(admin.sidebar.icon)) {
12
+ const iconList = ADMIN_ICONS.join(", ");
13
+ throw new CliError(
14
+ `Invalid 'admin.sidebar.icon' in odeva.app.toml: '${admin.sidebar.icon}'. Must be one of: ${iconList}.`,
15
+ { hint: `Edit ${configPath}` }
16
+ );
17
+ }
18
+ return {
19
+ entryUrl: admin.entry_url,
20
+ sidebar: {
21
+ label: admin.sidebar.label,
22
+ icon: admin.sidebar.icon
23
+ }
24
+ };
25
+ }
8
26
  class AppConfigLink extends Command {
9
27
  static description = "Register the local app on Odeva and sync its client_id back to odeva.app.toml";
10
28
  static examples = ["$ odeva app config link"];
@@ -18,6 +36,7 @@ class AppConfigLink extends Command {
18
36
  const { flags } = await this.parse(AppConfigLink);
19
37
  await withErrorHandling(this, async () => {
20
38
  const loaded = loadAppConfig();
39
+ const adminInput = loaded.config.admin ? buildAdminEmbedInput(loaded.config.admin, loaded.path) : void 0;
21
40
  p.intro(ui.brand("odeva app config link"));
22
41
  p.note(
23
42
  [
@@ -25,7 +44,8 @@ class AppConfigLink extends Command {
25
44
  `${ui.dim("slug:")} ${loaded.config.slug}`,
26
45
  `${ui.dim("client_id:")} ${loaded.config.client_id ?? ui.dim("(new)")}`,
27
46
  `${ui.dim("homepage:")} ${loaded.config.homepage_url ?? ui.dim("(none)")}`,
28
- `${ui.dim("scopes:")} ${(loaded.config.access_scopes?.scopes ?? []).join(", ") || ui.dim("(none)")}`
47
+ `${ui.dim("scopes:")} ${(loaded.config.access_scopes?.scopes ?? []).join(", ") || ui.dim("(none)")}`,
48
+ ...loaded.config.admin ? [`${ui.dim("admin:")} ${loaded.config.admin.sidebar.label} (${loaded.config.admin.entry_url})`] : []
29
49
  ].join("\n"),
30
50
  "Will register this app"
31
51
  );
@@ -51,10 +71,11 @@ class AppConfigLink extends Command {
51
71
  homepageUrl: loaded.config.homepage_url,
52
72
  installUrl: loaded.config.install_url,
53
73
  privacyUrl: loaded.config.privacy_url,
54
- requestedScopes: loaded.config.access_scopes?.scopes
74
+ requestedScopes: loaded.config.access_scopes?.scopes,
55
75
  // `webhookUrl` is the install-time URL recorded on the App; per-event
56
76
  // subscriptions are managed separately by `odeva app dev` against the
57
77
  // tunnel URL.
78
+ ...adminInput ? { adminEmbed: adminInput } : {}
58
79
  });
59
80
  spinner.stop(`${existingId ? "Updated" : "Registered"} app ${ui.code(app.slug)} (client_id ${ui.code(app.clientId)})`);
60
81
  loaded.config.client_id = app.clientId;
@@ -89,5 +110,6 @@ class AppConfigLink extends Command {
89
110
  }
90
111
  }
91
112
  export {
113
+ buildAdminEmbedInput,
92
114
  AppConfigLink as default
93
115
  };
package/dist/lib/api.js CHANGED
@@ -153,6 +153,7 @@ class OdevaApi {
153
153
  $privacyUrl: String
154
154
  $requestedScopes: [String!]
155
155
  $webhookUrl: String
156
+ $adminEmbed: AdminEmbedInput
156
157
  ) {
157
158
  upsertDeveloperApp(
158
159
  id: $id
@@ -165,6 +166,7 @@ class OdevaApi {
165
166
  privacyUrl: $privacyUrl
166
167
  requestedScopes: $requestedScopes
167
168
  webhookUrl: $webhookUrl
169
+ adminEmbed: $adminEmbed
168
170
  ) {
169
171
  app {
170
172
  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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@odeva/cli",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
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) => {