@odeva/cli 0.0.1 → 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 +78 -0
- package/dist/commands/app/config/link.js +25 -3
- package/dist/commands/app/dev.js +98 -22
- package/dist/commands/auth/login.js +26 -4
- package/dist/commands/webhook/trigger.js +15 -9
- package/dist/lib/api.js +7 -2
- package/dist/lib/config.js +80 -0
- package/dist/lib/dev-runner.js +38 -7
- package/package.json +1 -1
- package/templates/hono-bun/README.md.tmpl +19 -0
- package/templates/hono-bun/odeva.app.toml.tmpl +13 -0
- package/templates/hono-bun/package.json.tmpl +2 -1
- package/templates/hono-bun/src/admin.ts +99 -0
- package/templates/hono-bun/src/index.ts +35 -4
- package/templates/hono-bun/src/logger.ts +20 -0
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/commands/app/dev.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
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,
|
|
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(
|
|
72
|
-
|
|
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
|
|
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`
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
@@ -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
|
|
@@ -246,7 +248,7 @@ class OdevaApi {
|
|
|
246
248
|
appInstallationId: $appInstallationId
|
|
247
249
|
) {
|
|
248
250
|
webhookSubscription { id name endpointUrl eventTypes status }
|
|
249
|
-
|
|
251
|
+
signingSecret
|
|
250
252
|
errors
|
|
251
253
|
}
|
|
252
254
|
}
|
|
@@ -257,7 +259,10 @@ class OdevaApi {
|
|
|
257
259
|
if (payload.errors?.length) {
|
|
258
260
|
throw new ApiError(`Could not create webhook subscription: ${payload.errors.join(", ")}`);
|
|
259
261
|
}
|
|
260
|
-
|
|
262
|
+
if (!payload.signingSecret) {
|
|
263
|
+
throw new ApiError("Could not create webhook subscription: no signing secret returned");
|
|
264
|
+
}
|
|
265
|
+
return { subscription: payload.webhookSubscription, signingSecret: payload.signingSecret };
|
|
261
266
|
}
|
|
262
267
|
async deleteWebhookSubscription(id) {
|
|
263
268
|
await this.request(
|
package/dist/lib/config.js
CHANGED
|
@@ -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) {
|
|
@@ -42,8 +53,77 @@ function validateConfig(config, path) {
|
|
|
42
53
|
{ hint: `Edit ${path}` }
|
|
43
54
|
);
|
|
44
55
|
}
|
|
56
|
+
if (config.webhooks?.subscriptions) {
|
|
57
|
+
const rawSubscriptions = config.webhooks.subscriptions;
|
|
58
|
+
config.webhooks.subscriptions = rawSubscriptions.map((subscription) => {
|
|
59
|
+
if (typeof subscription === "string") {
|
|
60
|
+
return { topic: subscription, uri: `/webhooks/${subscription}` };
|
|
61
|
+
}
|
|
62
|
+
if (!subscription.topic || typeof subscription.topic !== "string") {
|
|
63
|
+
throw new CliError(`Invalid ${APP_CONFIG_FILE}: webhook subscription missing 'topic'.`, {
|
|
64
|
+
hint: `Edit ${path}`
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
...subscription,
|
|
69
|
+
uri: subscription.uri || `/webhooks/${subscription.topic}`
|
|
70
|
+
};
|
|
71
|
+
});
|
|
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
|
+
}
|
|
45
124
|
}
|
|
46
125
|
export {
|
|
126
|
+
ADMIN_ICONS,
|
|
47
127
|
findAppConfigPath,
|
|
48
128
|
loadAppConfig,
|
|
49
129
|
saveAppConfig,
|
package/dist/lib/dev-runner.js
CHANGED
|
@@ -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,
|
|
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:
|
|
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__${
|
|
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
|
|
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
|
|
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
|
|
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
|
@@ -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"
|
|
@@ -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, "&")
|
|
31
|
+
.replace(/</g, "<")
|
|
32
|
+
.replace(/>/g, ">")
|
|
33
|
+
.replace(/"/g, """)
|
|
34
|
+
.replace(/'/g, "'");
|
|
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
|
+
});
|
|
@@ -1,7 +1,10 @@
|
|
|
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";
|
|
7
|
+
import { adminApp } from "./admin.js";
|
|
5
8
|
|
|
6
9
|
// Default store: SQLite file next to the app. Swap to your own
|
|
7
10
|
// `InstallationStore` impl (e.g. Postgres) for multi-instance deploys.
|
|
@@ -13,29 +16,57 @@ app.get("/", (c) =>
|
|
|
13
16
|
c.json({
|
|
14
17
|
app: "{{slug}}",
|
|
15
18
|
ok: true,
|
|
16
|
-
routes: ["GET /", "GET /healthz", "GET /install", "POST /webhooks/:topic"],
|
|
19
|
+
routes: ["GET /", "GET /healthz", "GET /install", "GET /sdk/accommodations", "GET /admin", "POST /webhooks/:topic"],
|
|
17
20
|
}),
|
|
18
21
|
);
|
|
19
22
|
|
|
20
23
|
app.get("/healthz", (c) => c.json({ ok: true }));
|
|
21
24
|
|
|
25
|
+
// SDK example. Set ODEVA_ORGANIZATION_SLUG to query public booking data for an
|
|
26
|
+
// organization, then visit /sdk/accommodations while the app is running.
|
|
27
|
+
app.get("/sdk/accommodations", async (c) => {
|
|
28
|
+
const organizationSlug = process.env.ODEVA_ORGANIZATION_SLUG;
|
|
29
|
+
if (!organizationSlug) {
|
|
30
|
+
return c.json({
|
|
31
|
+
error: "missing ODEVA_ORGANIZATION_SLUG",
|
|
32
|
+
hint: "Set ODEVA_ORGANIZATION_SLUG in .odeva.env, then let `odeva app dev` reload.",
|
|
33
|
+
}, 400);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const odeva = new OdevaClient({ organizationSlug });
|
|
37
|
+
const accommodations = await odeva.searchAccommodations({
|
|
38
|
+
startDate: "2026-07-01",
|
|
39
|
+
endDate: "2026-07-08",
|
|
40
|
+
guests: 2,
|
|
41
|
+
limit: 5,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
return c.json({ accommodations });
|
|
45
|
+
});
|
|
46
|
+
|
|
22
47
|
// Install handshake. Odeva redirects here with `?install_code=...`.
|
|
23
48
|
app.get("/install", makeInstallHandler(installations));
|
|
24
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
|
+
|
|
25
54
|
// Webhook handler. `odeva app dev` registers subscriptions from odeva.app.toml
|
|
26
55
|
// against the dev tunnel, so payloads from the Odeva platform arrive here.
|
|
27
56
|
app.post("/webhooks/:topic", async (c) => {
|
|
28
57
|
const topic = c.req.param("topic");
|
|
29
58
|
const rawBody = await c.req.text();
|
|
30
59
|
const signature = c.req.header("x-odeva-signature");
|
|
31
|
-
const secret =
|
|
60
|
+
const secret =
|
|
61
|
+
process.env[`ODEVA_WEBHOOK_SECRET__${topic.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}`] ??
|
|
62
|
+
process.env.ODEVA_WEBHOOK_SECRET;
|
|
32
63
|
|
|
33
64
|
if (secret && !verifyWebhook(rawBody, signature, secret)) {
|
|
34
65
|
return c.json({ error: "invalid signature" }, 401);
|
|
35
66
|
}
|
|
36
67
|
|
|
37
68
|
const payload = JSON.parse(rawBody) as Record<string, unknown>;
|
|
38
|
-
|
|
69
|
+
logger.info(`[webhook] ${topic}`, payload);
|
|
39
70
|
|
|
40
71
|
// TODO: handle the event.
|
|
41
72
|
// Tip: run `odeva webhook trigger <topic>` to fire a sample payload at this handler.
|
|
@@ -44,6 +75,6 @@ app.post("/webhooks/:topic", async (c) => {
|
|
|
44
75
|
});
|
|
45
76
|
|
|
46
77
|
const port = Number(process.env.PORT ?? 3000);
|
|
47
|
-
|
|
78
|
+
logger.info(`{{name}} listening on http://localhost:${port}`);
|
|
48
79
|
|
|
49
80
|
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
|
+
};
|