@odeva/cli 0.0.6 → 0.0.8
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/dist/commands/app/dev.js +102 -26
- package/dist/commands/app/init.js +10 -17
- package/dist/commands/autocomplete.js +13 -70
- package/dist/commands/webhook/trigger.js +3 -1
- package/dist/lib/api.js +14 -0
- package/dist/lib/cloudflared.js +32 -14
- package/dist/lib/dev-runner.js +229 -2
- package/dist/lib/webhook-fixtures.js +13 -2
- package/package.json +1 -1
- package/templates/hono-bun/README.md.tmpl +3 -2
- package/templates/hono-bun/odeva.app.toml.tmpl +6 -2
- package/templates/hono-bun/src/index.ts +4 -3
- package/templates/hono-bun/src/install.ts +18 -4
- package/templates/hono-bun/src/webhook.ts +11 -4
package/dist/commands/app/dev.js
CHANGED
|
@@ -9,11 +9,15 @@ import { loadCredentials } from "../../lib/credentials.js";
|
|
|
9
9
|
import { adminUrl } from "../../lib/paths.js";
|
|
10
10
|
import {
|
|
11
11
|
cleanupSubscriptions,
|
|
12
|
+
ensureDevAppInstalled,
|
|
12
13
|
preflightChecks,
|
|
13
14
|
registeredWebhookEnv,
|
|
14
15
|
registerWebhookSubscriptions,
|
|
15
16
|
spawnDevServer,
|
|
17
|
+
syncDevAppUrls,
|
|
16
18
|
watchedDevInputPaths,
|
|
19
|
+
waitForLocalDevServer,
|
|
20
|
+
waitForDevServerReachable,
|
|
17
21
|
writeDevEnvFile
|
|
18
22
|
} from "../../lib/dev-runner.js";
|
|
19
23
|
import { CliError } from "../../lib/errors.js";
|
|
@@ -53,11 +57,19 @@ class AppDev extends Command {
|
|
|
53
57
|
return;
|
|
54
58
|
}
|
|
55
59
|
const api = new OdevaApi();
|
|
56
|
-
const tunnelSpinner = p.spinner();
|
|
57
|
-
tunnelSpinner.start("Starting Cloudflare tunnel");
|
|
58
|
-
const tunnel = await startQuickTunnel(port);
|
|
59
|
-
tunnelSpinner.stop(`Tunnel ready at ${ui.code(tunnel.url)}`);
|
|
60
60
|
let registered = [];
|
|
61
|
+
let server = spawnDevServer({
|
|
62
|
+
cwd: loaded.root,
|
|
63
|
+
config: loaded.config,
|
|
64
|
+
env: {
|
|
65
|
+
...appEnv,
|
|
66
|
+
PORT: String(port)
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
await this.verifyLocalDevServer(port, server, api, registered);
|
|
70
|
+
this.log(ui.dim("Starting Cloudflare tunnel..."));
|
|
71
|
+
const tunnel = await startQuickTunnel(port);
|
|
72
|
+
this.log(ui.ok(`Tunnel ready at ${ui.code(tunnel.url)}`));
|
|
61
73
|
registered = await this.registerDevWebhooks(api, loaded, tunnel.url);
|
|
62
74
|
this.log("");
|
|
63
75
|
this.log(`${ui.bold("Tunnel:")} ${tunnel.url}`);
|
|
@@ -66,19 +78,12 @@ class AppDev extends Command {
|
|
|
66
78
|
this.log(` ${ui.dim("\u2192")} ${reg.config.topic.padEnd(28)} ${reg.fullUrl}`);
|
|
67
79
|
}
|
|
68
80
|
this.log("");
|
|
69
|
-
this.
|
|
81
|
+
await this.verifyDevServerReachable(tunnel, port, server, api, registered);
|
|
82
|
+
const syncedUrls = await this.syncDevAppUrls(api, loaded, tunnel.url);
|
|
83
|
+
this.printDevAppInfo(syncedUrls);
|
|
84
|
+
await this.ensureDevAppInstalled(api, loaded, tunnel, port, server, registered);
|
|
70
85
|
this.log(ui.dim("Press Ctrl-C to stop. Subscriptions will be cleaned up on exit."));
|
|
71
86
|
this.log("");
|
|
72
|
-
let server = spawnDevServer({
|
|
73
|
-
cwd: loaded.root,
|
|
74
|
-
config: loaded.config,
|
|
75
|
-
env: {
|
|
76
|
-
...appEnv,
|
|
77
|
-
PORT: String(port),
|
|
78
|
-
ODEVA_TUNNEL_URL: tunnel.url,
|
|
79
|
-
...registeredWebhookEnv(registered)
|
|
80
|
-
}
|
|
81
|
-
});
|
|
82
87
|
let shuttingDown = false;
|
|
83
88
|
let restartingServer = false;
|
|
84
89
|
let serverExited = () => {
|
|
@@ -120,6 +125,8 @@ class AppDev extends Command {
|
|
|
120
125
|
});
|
|
121
126
|
server = nextServer;
|
|
122
127
|
watchServerExit(server);
|
|
128
|
+
await this.verifyDevServerReachable(tunnel, port, server, api, registered);
|
|
129
|
+
await this.syncDevAppUrls(api, nextLoaded, tunnel.url);
|
|
123
130
|
this.log(ui.ok("Reloaded app dev config."));
|
|
124
131
|
}).catch((err) => {
|
|
125
132
|
restartingServer = false;
|
|
@@ -145,26 +152,36 @@ class AppDev extends Command {
|
|
|
145
152
|
await Promise.allSettled([tunnel.stop(), cleanupSubscriptions(api, registered)]);
|
|
146
153
|
});
|
|
147
154
|
}
|
|
148
|
-
|
|
155
|
+
printDevAppInfo(syncedUrls) {
|
|
149
156
|
const creds = loadCredentials();
|
|
150
157
|
const apps = adminUrl(creds?.apiUrl);
|
|
151
158
|
this.log(`${ui.bold("Install:")} ${apps}/settings/apps ${ui.dim('(find your draft app under "Your apps")')}`);
|
|
152
159
|
if (creds?.organizationSlug) {
|
|
153
160
|
this.log(` ${ui.dim("org:")} ${creds.organizationSlug}`);
|
|
154
161
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
));
|
|
162
|
+
this.log(` ${ui.dim("install:")} ${syncedUrls.installUrl}`);
|
|
163
|
+
if (syncedUrls.adminEntryUrl) {
|
|
164
|
+
this.log(` ${ui.dim("admin:")} ${syncedUrls.adminEntryUrl}`);
|
|
165
165
|
}
|
|
166
|
+
this.log(ui.dim(" dev URLs are synced to this tunnel for the current app record."));
|
|
166
167
|
this.log("");
|
|
167
168
|
}
|
|
169
|
+
async syncDevAppUrls(api, loaded, tunnelUrl) {
|
|
170
|
+
const spinner = p.spinner();
|
|
171
|
+
spinner.start("Syncing dev app URLs");
|
|
172
|
+
try {
|
|
173
|
+
const synced = await syncDevAppUrls({
|
|
174
|
+
api,
|
|
175
|
+
config: loaded.config,
|
|
176
|
+
tunnelUrl
|
|
177
|
+
});
|
|
178
|
+
spinner.stop(`Synced app URLs for ${ui.code(synced.app.slug)}`);
|
|
179
|
+
return synced;
|
|
180
|
+
} catch (err) {
|
|
181
|
+
spinner.stop(ui.err("Dev app URL sync failed"));
|
|
182
|
+
throw err instanceof CliError ? err : new CliError(`Dev app URL sync failed: ${err.message}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
168
185
|
async registerDevWebhooks(api, loaded, tunnelUrl) {
|
|
169
186
|
const subscriptions = loaded.config.webhooks?.subscriptions ?? [];
|
|
170
187
|
if (subscriptions.length === 0) {
|
|
@@ -186,6 +203,65 @@ class AppDev extends Command {
|
|
|
186
203
|
throw err instanceof CliError ? err : new CliError(`Webhook registration failed: ${err.message}`);
|
|
187
204
|
}
|
|
188
205
|
}
|
|
206
|
+
async ensureDevAppInstalled(api, loaded, tunnel, port, server, registered) {
|
|
207
|
+
const spinner = p.spinner();
|
|
208
|
+
spinner.start("Ensuring app is installed in this organization");
|
|
209
|
+
try {
|
|
210
|
+
const installed = await ensureDevAppInstalled({
|
|
211
|
+
api,
|
|
212
|
+
config: loaded.config,
|
|
213
|
+
tunnelUrl: tunnel.url,
|
|
214
|
+
localPort: port
|
|
215
|
+
});
|
|
216
|
+
if (installed.alreadyInstalled) {
|
|
217
|
+
spinner.stop(`App ${ui.code(installed.app.slug)} is already installed`);
|
|
218
|
+
} else if (installed.callbackUrl) {
|
|
219
|
+
spinner.stop(`Installed app ${ui.code(installed.app.slug)} via ${ui.code(installed.callbackUrl)}`);
|
|
220
|
+
} else {
|
|
221
|
+
spinner.stop(`Installed app ${ui.code(installed.app.slug)}`);
|
|
222
|
+
}
|
|
223
|
+
} catch (err) {
|
|
224
|
+
spinner.stop(ui.err("App installation failed"));
|
|
225
|
+
await Promise.allSettled([
|
|
226
|
+
server.stop(),
|
|
227
|
+
tunnel.stop(),
|
|
228
|
+
cleanupSubscriptions(api, registered)
|
|
229
|
+
]);
|
|
230
|
+
throw err;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
async verifyLocalDevServer(port, server, api, registered) {
|
|
234
|
+
this.log(ui.dim("Waiting for local dev server..."));
|
|
235
|
+
try {
|
|
236
|
+
const result = await waitForLocalDevServer({ localPort: port });
|
|
237
|
+
this.log(ui.ok(`Local dev server ready via ${ui.code(result.path)} (HTTP ${result.status})`));
|
|
238
|
+
} catch (err) {
|
|
239
|
+
this.log(ui.err("Local dev server failed to start"));
|
|
240
|
+
await Promise.allSettled([
|
|
241
|
+
server.stop(),
|
|
242
|
+
cleanupSubscriptions(api, registered)
|
|
243
|
+
]);
|
|
244
|
+
throw err;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
async verifyDevServerReachable(tunnel, port, server, api, registered) {
|
|
248
|
+
this.log(ui.dim("Verifying dev server and tunnel... (trycloudflare can take up to 60s to propagate)"));
|
|
249
|
+
try {
|
|
250
|
+
const result = await waitForDevServerReachable({
|
|
251
|
+
localPort: port,
|
|
252
|
+
tunnelUrl: tunnel.url
|
|
253
|
+
});
|
|
254
|
+
this.log(ui.ok(`Tunnel verified via ${ui.code(result.path)} (HTTP ${result.tunnelStatus})`));
|
|
255
|
+
} catch (err) {
|
|
256
|
+
this.log(ui.err("Dev server tunnel check failed"));
|
|
257
|
+
await Promise.allSettled([
|
|
258
|
+
server.stop(),
|
|
259
|
+
tunnel.stop(),
|
|
260
|
+
cleanupSubscriptions(api, registered)
|
|
261
|
+
]);
|
|
262
|
+
throw err;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
189
265
|
watchDevInputs(appConfigPath, onChange) {
|
|
190
266
|
const paths = watchedDevInputPaths(appConfigPath);
|
|
191
267
|
let timer;
|
|
@@ -9,7 +9,6 @@ import { withErrorHandling } from "../../lib/run.js";
|
|
|
9
9
|
import { isValidSlug, slugify } from "../../lib/slug.js";
|
|
10
10
|
import { listTemplates, renderTemplate } from "../../lib/templates.js";
|
|
11
11
|
import { ui } from "../../lib/ui.js";
|
|
12
|
-
import { detectShell, isCompletionInstalled } from "../autocomplete.js";
|
|
13
12
|
const DEFAULT_TEMPLATE = "hono-bun";
|
|
14
13
|
class AppInit extends Command {
|
|
15
14
|
static description = "Scaffold a new Odeva app";
|
|
@@ -69,23 +68,17 @@ class AppInit extends Command {
|
|
|
69
68
|
}
|
|
70
69
|
});
|
|
71
70
|
spinner.stop(`Wrote ${filesWritten} file${filesWritten === 1 ? "" : "s"} from template '${template}'.`);
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
" Next steps:",
|
|
76
|
-
` ${ui.code(`cd ${basename(directory)}`)}`,
|
|
77
|
-
` ${ui.code("bun install")}`,
|
|
78
|
-
` ${ui.code("odeva app config link")} ${ui.dim("# register on Odeva")}`,
|
|
79
|
-
` ${ui.code("odeva app dev")} ${ui.dim("# start local dev with a tunnel")}`
|
|
80
|
-
];
|
|
81
|
-
const shell = detectShell();
|
|
82
|
-
if (shell && !isCompletionInstalled(shell, "odeva")) {
|
|
83
|
-
outroLines.push(
|
|
71
|
+
p.outro(
|
|
72
|
+
[
|
|
73
|
+
ui.ok(`Created ${ui.bold(displayName)}`),
|
|
84
74
|
"",
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
75
|
+
" Next steps:",
|
|
76
|
+
` ${ui.code(`cd ${basename(directory)}`)}`,
|
|
77
|
+
` ${ui.code("bun install")}`,
|
|
78
|
+
` ${ui.code("odeva app config link")} ${ui.dim("# register on Odeva")}`,
|
|
79
|
+
` ${ui.code("odeva app dev")} ${ui.dim("# start local dev with a tunnel")}`
|
|
80
|
+
].join("\n")
|
|
81
|
+
);
|
|
89
82
|
});
|
|
90
83
|
}
|
|
91
84
|
async resolveDirectory(nameArg, skipPrompts) {
|
|
@@ -1,90 +1,36 @@
|
|
|
1
|
-
import { Args, Command
|
|
2
|
-
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { homedir } from "node:os";
|
|
4
|
-
import { basename, dirname, join } from "node:path";
|
|
1
|
+
import { Args, Command } from "@oclif/core";
|
|
5
2
|
import { CliError } from "../lib/errors.js";
|
|
6
3
|
import { withErrorHandling } from "../lib/run.js";
|
|
7
|
-
import { ui } from "../lib/ui.js";
|
|
8
4
|
const SUPPORTED_SHELLS = ["fish", "zsh"];
|
|
9
5
|
class Autocomplete extends Command {
|
|
10
|
-
static description = "Print
|
|
6
|
+
static description = "Print a shell completion script for the odeva CLI";
|
|
11
7
|
static examples = [
|
|
12
|
-
"$ odeva autocomplete
|
|
13
|
-
"$ odeva autocomplete
|
|
14
|
-
"$ odeva autocomplete fish > ~/.config/fish/completions/odeva.fish"
|
|
8
|
+
"$ odeva autocomplete fish > ~/.config/fish/completions/odeva.fish",
|
|
9
|
+
"$ odeva autocomplete zsh > ~/.odeva/_odeva # then source it from .zshrc"
|
|
15
10
|
];
|
|
16
11
|
static args = {
|
|
17
12
|
shell: Args.string({
|
|
18
|
-
description: "Target shell
|
|
13
|
+
description: "Target shell",
|
|
19
14
|
options: [...SUPPORTED_SHELLS],
|
|
20
|
-
required:
|
|
21
|
-
})
|
|
22
|
-
};
|
|
23
|
-
static flags = {
|
|
24
|
-
install: Flags.boolean({
|
|
25
|
-
description: "Write the completion script to the conventional path for the shell",
|
|
26
|
-
default: false
|
|
15
|
+
required: true
|
|
27
16
|
})
|
|
28
17
|
};
|
|
29
18
|
async run() {
|
|
30
|
-
const { args
|
|
19
|
+
const { args } = await this.parse(Autocomplete);
|
|
31
20
|
await withErrorHandling(this, async () => {
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
throw new CliError("Couldn't detect shell from $SHELL.", {
|
|
37
|
-
hint: `Pass it explicitly: \`${bin} autocomplete <${SUPPORTED_SHELLS.join("|")}> --install\`.`
|
|
38
|
-
});
|
|
39
|
-
}
|
|
40
|
-
throw new CliError("A shell is required.", {
|
|
41
|
-
hint: `Usage: \`${bin} autocomplete <${SUPPORTED_SHELLS.join("|")}>\`. Add --install to write the script to the conventional path.`
|
|
21
|
+
const shell = args.shell;
|
|
22
|
+
if (!SUPPORTED_SHELLS.includes(shell)) {
|
|
23
|
+
throw new CliError(`Unsupported shell '${shell}'.`, {
|
|
24
|
+
hint: `Supported: ${SUPPORTED_SHELLS.join(", ")}.`
|
|
42
25
|
});
|
|
43
26
|
}
|
|
27
|
+
const bin = this.config.bin;
|
|
44
28
|
const tree = buildCommandTree(this.config.commands, bin);
|
|
45
29
|
const script = shell === "fish" ? renderFish(bin, tree) : renderZsh(bin, tree);
|
|
46
|
-
|
|
47
|
-
process.stdout.write(script);
|
|
48
|
-
return;
|
|
49
|
-
}
|
|
50
|
-
const target = completionInstallPath(shell, bin);
|
|
51
|
-
mkdirSync(dirname(target), { recursive: true });
|
|
52
|
-
writeFileSync(target, script, { mode: 420 });
|
|
53
|
-
this.log(ui.ok(`Wrote ${shell} completion to ${ui.code(target)}`));
|
|
54
|
-
if (shell === "fish") {
|
|
55
|
-
this.log(ui.dim(" Open a new fish shell \u2014 completions auto-load from this path."));
|
|
56
|
-
} else {
|
|
57
|
-
const fpathDir = dirname(target);
|
|
58
|
-
this.log("");
|
|
59
|
-
this.log(" Add this to your ~/.zshrc (if you haven't already):");
|
|
60
|
-
this.log("");
|
|
61
|
-
this.log(ui.code(` fpath=(${fpathDir} $fpath)`));
|
|
62
|
-
this.log(ui.code(` autoload -U compinit && compinit`));
|
|
63
|
-
this.log("");
|
|
64
|
-
this.log(ui.dim(" Then open a new zsh shell."));
|
|
65
|
-
}
|
|
30
|
+
process.stdout.write(script);
|
|
66
31
|
});
|
|
67
32
|
}
|
|
68
33
|
}
|
|
69
|
-
function detectShell(env = process.env) {
|
|
70
|
-
const shell = env["SHELL"];
|
|
71
|
-
if (!shell) return null;
|
|
72
|
-
const name = basename(shell);
|
|
73
|
-
if (name === "fish" || name === "zsh") return name;
|
|
74
|
-
return null;
|
|
75
|
-
}
|
|
76
|
-
function completionInstallPath(shell, bin, env = process.env) {
|
|
77
|
-
const home = env["HOME"] || homedir();
|
|
78
|
-
if (shell === "fish") {
|
|
79
|
-
const config = env["XDG_CONFIG_HOME"] || join(home, ".config");
|
|
80
|
-
return join(config, "fish", "completions", `${bin}.fish`);
|
|
81
|
-
}
|
|
82
|
-
const data = env["XDG_DATA_HOME"] || join(home, ".local", "share");
|
|
83
|
-
return join(data, bin, "completions", `_${bin}`);
|
|
84
|
-
}
|
|
85
|
-
function isCompletionInstalled(shell, bin, env = process.env) {
|
|
86
|
-
return existsSync(completionInstallPath(shell, bin, env));
|
|
87
|
-
}
|
|
88
34
|
function buildCommandTree(commands, binName) {
|
|
89
35
|
const root = { name: "", description: "", children: /* @__PURE__ */ new Map() };
|
|
90
36
|
for (const cmd of commands) {
|
|
@@ -206,10 +152,7 @@ _${bin} "$@"
|
|
|
206
152
|
}
|
|
207
153
|
export {
|
|
208
154
|
buildCommandTree,
|
|
209
|
-
completionInstallPath,
|
|
210
155
|
Autocomplete as default,
|
|
211
|
-
detectShell,
|
|
212
|
-
isCompletionInstalled,
|
|
213
156
|
renderFish,
|
|
214
157
|
renderZsh
|
|
215
158
|
};
|
|
@@ -61,7 +61,9 @@ class WebhookTrigger extends Command {
|
|
|
61
61
|
hint: flags.url || configSubscription ? "Run `odeva app dev` to generate one, or pass --secret <value>, or use --no-sign for unsigned testing." : `Add a '${event}' subscription to odeva.app.toml, let \`odeva app dev\` reload, then try again.`
|
|
62
62
|
});
|
|
63
63
|
}
|
|
64
|
-
|
|
64
|
+
const timestamp = Math.floor(Date.now() / 1e3).toString();
|
|
65
|
+
headers["x-odeva-timestamp"] = timestamp;
|
|
66
|
+
headers["x-odeva-signature"] = signPayload(body, secret, timestamp);
|
|
65
67
|
}
|
|
66
68
|
this.log(`${ui.bold("POST")} ${url}`);
|
|
67
69
|
this.log(` ${ui.dim("event:")} ${event}`);
|
package/dist/lib/api.js
CHANGED
|
@@ -139,6 +139,20 @@ class OdevaApi {
|
|
|
139
139
|
`);
|
|
140
140
|
return data.developerApps.find((a) => a.clientId === clientId) ?? null;
|
|
141
141
|
}
|
|
142
|
+
async installApp(appId, grantedScopes) {
|
|
143
|
+
const data = await this.request(
|
|
144
|
+
`mutation OdevaCliInstallApp($appId: ID!, $grantedScopes: [String!]) {
|
|
145
|
+
installApp(appId: $appId, grantedScopes: $grantedScopes) {
|
|
146
|
+
appInstallation { id status grantedScopes }
|
|
147
|
+
installCode
|
|
148
|
+
installUrl
|
|
149
|
+
errors
|
|
150
|
+
}
|
|
151
|
+
}`,
|
|
152
|
+
{ appId, grantedScopes }
|
|
153
|
+
);
|
|
154
|
+
return data.installApp;
|
|
155
|
+
}
|
|
142
156
|
async submitDeveloperApp(id) {
|
|
143
157
|
const data = await this.request(
|
|
144
158
|
`mutation OdevaCliSubmitDeveloperApp($id: ID!) {
|
package/dist/lib/cloudflared.js
CHANGED
|
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
3
|
import { CliError } from "./errors.js";
|
|
4
4
|
const TRYCLOUDFLARE_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
|
|
5
|
+
const REGISTERED_CONNECTION_RE = /Registered tunnel connection/i;
|
|
5
6
|
function isCloudflaredInstalled() {
|
|
6
7
|
const result = spawnSync("cloudflared", ["--version"], { stdio: "ignore" });
|
|
7
8
|
return result.status === 0;
|
|
@@ -15,32 +16,28 @@ function startQuickTunnel(localPort, options = {}) {
|
|
|
15
16
|
return new Promise((resolve, reject) => {
|
|
16
17
|
const proc = spawn(
|
|
17
18
|
"cloudflared",
|
|
18
|
-
["tunnel", "--no-autoupdate", "--url", `http://
|
|
19
|
+
["tunnel", "--no-autoupdate", "--url", `http://127.0.0.1:${localPort}`],
|
|
19
20
|
{ stdio: ["ignore", "pipe", "pipe"] }
|
|
20
21
|
);
|
|
21
22
|
let resolved = false;
|
|
23
|
+
let url = null;
|
|
24
|
+
let registered = false;
|
|
22
25
|
const timeoutMs = options.timeoutMs ?? 3e4;
|
|
23
26
|
const timer = setTimeout(() => {
|
|
24
27
|
if (resolved) return;
|
|
25
28
|
cleanup();
|
|
26
29
|
reject(
|
|
27
30
|
new CliError(`cloudflared did not produce a tunnel URL within ${timeoutMs / 1e3}s.`, {
|
|
28
|
-
hint: "Check your network connection or run `cloudflared tunnel --url http://
|
|
31
|
+
hint: "Check your network connection or run `cloudflared tunnel --url http://127.0.0.1:" + localPort + "` manually."
|
|
29
32
|
})
|
|
30
33
|
);
|
|
31
34
|
}, timeoutMs);
|
|
32
35
|
const onData = (chunk) => {
|
|
33
36
|
const text = chunk.toString("utf8");
|
|
34
37
|
const match = TRYCLOUDFLARE_RE.exec(text);
|
|
35
|
-
if (match
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
resolve({
|
|
39
|
-
url: match[0],
|
|
40
|
-
process: proc,
|
|
41
|
-
stop: () => stopProcess(proc)
|
|
42
|
-
});
|
|
43
|
-
}
|
|
38
|
+
if (match) url = match[0];
|
|
39
|
+
if (REGISTERED_CONNECTION_RE.test(text)) registered = true;
|
|
40
|
+
maybeResolve();
|
|
44
41
|
};
|
|
45
42
|
proc.stdout?.on("data", onData);
|
|
46
43
|
proc.stderr?.on("data", onData);
|
|
@@ -60,16 +57,32 @@ function startQuickTunnel(localPort, options = {}) {
|
|
|
60
57
|
} catch {
|
|
61
58
|
}
|
|
62
59
|
}
|
|
60
|
+
function maybeResolve() {
|
|
61
|
+
if (!url || !registered || resolved) return;
|
|
62
|
+
resolved = true;
|
|
63
|
+
clearTimeout(timer);
|
|
64
|
+
resolve({
|
|
65
|
+
url,
|
|
66
|
+
process: proc,
|
|
67
|
+
stop: () => stopProcess(proc)
|
|
68
|
+
});
|
|
69
|
+
}
|
|
63
70
|
});
|
|
64
71
|
}
|
|
65
72
|
function stopProcess(proc) {
|
|
66
73
|
return new Promise((resolve) => {
|
|
67
74
|
if (proc.exitCode !== null) return resolve();
|
|
68
|
-
|
|
75
|
+
let resolved = false;
|
|
76
|
+
const finish = () => {
|
|
77
|
+
if (resolved) return;
|
|
78
|
+
resolved = true;
|
|
79
|
+
resolve();
|
|
80
|
+
};
|
|
81
|
+
proc.once("exit", finish);
|
|
69
82
|
try {
|
|
70
83
|
proc.kill("SIGTERM");
|
|
71
84
|
} catch {
|
|
72
|
-
|
|
85
|
+
finish();
|
|
73
86
|
return;
|
|
74
87
|
}
|
|
75
88
|
setTimeout(() => {
|
|
@@ -80,14 +93,19 @@ function stopProcess(proc) {
|
|
|
80
93
|
}
|
|
81
94
|
}
|
|
82
95
|
}, 2e3);
|
|
96
|
+
setTimeout(finish, 5e3);
|
|
83
97
|
});
|
|
84
98
|
}
|
|
85
99
|
function parseTunnelUrl(text) {
|
|
86
100
|
const match = TRYCLOUDFLARE_RE.exec(text);
|
|
87
101
|
return match ? match[0] : null;
|
|
88
102
|
}
|
|
103
|
+
function tunnelConnectionRegistered(text) {
|
|
104
|
+
return REGISTERED_CONNECTION_RE.test(text);
|
|
105
|
+
}
|
|
89
106
|
export {
|
|
90
107
|
isCloudflaredInstalled,
|
|
91
108
|
parseTunnelUrl,
|
|
92
|
-
startQuickTunnel
|
|
109
|
+
startQuickTunnel,
|
|
110
|
+
tunnelConnectionRegistered
|
|
93
111
|
};
|
package/dist/lib/dev-runner.js
CHANGED
|
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { APP_ENV_FILE } from "./app-env.js";
|
|
5
|
+
import { CliError } from "./errors.js";
|
|
5
6
|
async function registerWebhookSubscriptions(api, appName, tunnelUrl, subscriptions) {
|
|
6
7
|
const created = [];
|
|
7
8
|
for (const sub of subscriptions) {
|
|
@@ -23,6 +24,57 @@ async function cleanupSubscriptions(api, registered) {
|
|
|
23
24
|
}
|
|
24
25
|
}
|
|
25
26
|
}
|
|
27
|
+
async function syncDevAppUrls(opts) {
|
|
28
|
+
const app = await findLinkedApp(opts.api, opts.config.client_id);
|
|
29
|
+
const installUrl = devAppTunnelUrl({
|
|
30
|
+
tunnelUrl: opts.tunnelUrl,
|
|
31
|
+
configuredUrl: opts.config.install_url,
|
|
32
|
+
defaultPath: "/install"
|
|
33
|
+
});
|
|
34
|
+
const adminEntryUrl = opts.config.admin ? devAppTunnelUrl({
|
|
35
|
+
tunnelUrl: opts.tunnelUrl,
|
|
36
|
+
configuredUrl: opts.config.admin.entry_url,
|
|
37
|
+
defaultPath: "/admin"
|
|
38
|
+
}) : null;
|
|
39
|
+
const { app: updated } = await opts.api.upsertDeveloperApp({
|
|
40
|
+
id: app.id,
|
|
41
|
+
name: opts.config.name,
|
|
42
|
+
slug: opts.config.slug,
|
|
43
|
+
description: opts.config.description,
|
|
44
|
+
homepageUrl: opts.config.homepage_url,
|
|
45
|
+
installUrl,
|
|
46
|
+
privacyUrl: opts.config.privacy_url,
|
|
47
|
+
requestedScopes: opts.config.access_scopes?.scopes,
|
|
48
|
+
...opts.config.admin && adminEntryUrl ? {
|
|
49
|
+
adminEmbed: {
|
|
50
|
+
entryUrl: adminEntryUrl,
|
|
51
|
+
sidebar: opts.config.admin.sidebar
|
|
52
|
+
}
|
|
53
|
+
} : {}
|
|
54
|
+
});
|
|
55
|
+
return { app: updated, installUrl, adminEntryUrl };
|
|
56
|
+
}
|
|
57
|
+
async function ensureDevAppInstalled(opts) {
|
|
58
|
+
const app = await findLinkedApp(opts.api, opts.config.client_id);
|
|
59
|
+
const result = await opts.api.installApp(app.id, opts.config.access_scopes?.scopes ?? app.requestedScopes);
|
|
60
|
+
if (isAlreadyInstalled(result)) {
|
|
61
|
+
return { app, result, callbackUrl: null, alreadyInstalled: true };
|
|
62
|
+
}
|
|
63
|
+
if (result.errors.length > 0) {
|
|
64
|
+
throw new CliError(`Could not install app: ${result.errors.join(", ")}`);
|
|
65
|
+
}
|
|
66
|
+
if (!result.installCode) {
|
|
67
|
+
return { app, result, callbackUrl: null, alreadyInstalled: false };
|
|
68
|
+
}
|
|
69
|
+
const callbackUrl = devInstallCallbackUrl({
|
|
70
|
+
tunnelUrl: opts.tunnelUrl,
|
|
71
|
+
installUrl: result.installUrl ?? opts.config.install_url,
|
|
72
|
+
installCode: result.installCode
|
|
73
|
+
});
|
|
74
|
+
await waitForDevInstallRoute({ localPort: opts.localPort, callbackUrl });
|
|
75
|
+
await completeInstallCallback(callbackUrl);
|
|
76
|
+
return { app, result, callbackUrl, alreadyInstalled: false };
|
|
77
|
+
}
|
|
26
78
|
function spawnDevServer(opts) {
|
|
27
79
|
const command = opts.config.build?.dev ?? "bun run dev";
|
|
28
80
|
const proc = spawn(command, {
|
|
@@ -44,6 +96,75 @@ function spawnDevServer(opts) {
|
|
|
44
96
|
})
|
|
45
97
|
};
|
|
46
98
|
}
|
|
99
|
+
async function waitForDevServerReachable(opts) {
|
|
100
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
101
|
+
const deadline = Date.now() + (opts.timeoutMs ?? 6e4);
|
|
102
|
+
const requestTimeoutMs = opts.requestTimeoutMs ?? 2500;
|
|
103
|
+
const mismatchGraceMs = opts.mismatchGraceMs ?? 45e3;
|
|
104
|
+
const probe = await waitForLocalProbe(opts.localPort, deadline, requestTimeoutMs, fetchImpl);
|
|
105
|
+
const tunnelUrl = new URL(opts.tunnelUrl);
|
|
106
|
+
tunnelUrl.pathname = probe.path;
|
|
107
|
+
tunnelUrl.search = "";
|
|
108
|
+
let lastError = "not ready";
|
|
109
|
+
let mismatchSince = null;
|
|
110
|
+
while (Date.now() < deadline) {
|
|
111
|
+
try {
|
|
112
|
+
const response = await fetchWithTimeout(fetchImpl, tunnelUrl, requestTimeoutMs);
|
|
113
|
+
if (response.status === probe.status) {
|
|
114
|
+
return {
|
|
115
|
+
path: probe.path,
|
|
116
|
+
localStatus: probe.status,
|
|
117
|
+
tunnelStatus: response.status
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
lastError = `HTTP ${response.status}`;
|
|
121
|
+
mismatchSince ??= Date.now();
|
|
122
|
+
if (Date.now() - mismatchSince >= mismatchGraceMs) break;
|
|
123
|
+
} catch (err) {
|
|
124
|
+
lastError = err instanceof Error ? err.message : String(err);
|
|
125
|
+
mismatchSince = null;
|
|
126
|
+
}
|
|
127
|
+
await delay(500);
|
|
128
|
+
}
|
|
129
|
+
throw new CliError(`Cloudflare tunnel did not reach the local dev server at ${tunnelUrl.toString()}.`, {
|
|
130
|
+
hint: `Local ${probe.path} returned HTTP ${probe.status}, but the tunnel returned ${lastError}. Restart \`odeva app dev\` if the quick tunnel URL expired.`
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
async function waitForLocalDevServer(opts) {
|
|
134
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
135
|
+
const deadline = Date.now() + (opts.timeoutMs ?? 3e4);
|
|
136
|
+
const requestTimeoutMs = opts.requestTimeoutMs ?? 1500;
|
|
137
|
+
return waitForLocalProbe(opts.localPort, deadline, requestTimeoutMs, fetchImpl);
|
|
138
|
+
}
|
|
139
|
+
function devInstallCallbackUrl(opts) {
|
|
140
|
+
const url = new URL(devAppTunnelUrl({
|
|
141
|
+
tunnelUrl: opts.tunnelUrl,
|
|
142
|
+
configuredUrl: opts.installUrl,
|
|
143
|
+
defaultPath: "/install",
|
|
144
|
+
configField: "install_url"
|
|
145
|
+
}));
|
|
146
|
+
url.searchParams.set("install_code", opts.installCode);
|
|
147
|
+
return url.toString();
|
|
148
|
+
}
|
|
149
|
+
function devAppTunnelUrl(opts) {
|
|
150
|
+
const url = new URL(opts.tunnelUrl);
|
|
151
|
+
if (opts.configuredUrl) {
|
|
152
|
+
let configured;
|
|
153
|
+
try {
|
|
154
|
+
configured = new URL(opts.configuredUrl);
|
|
155
|
+
} catch {
|
|
156
|
+
throw new CliError(`Invalid ${opts.configField ?? "URL"}: ${opts.configuredUrl}`, {
|
|
157
|
+
hint: "Fix odeva.app.toml or run `odeva app config link` with a valid URL."
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
url.pathname = configured.pathname;
|
|
161
|
+
url.search = configured.search;
|
|
162
|
+
} else {
|
|
163
|
+
url.pathname = opts.defaultPath;
|
|
164
|
+
url.search = "";
|
|
165
|
+
}
|
|
166
|
+
return url.toString();
|
|
167
|
+
}
|
|
47
168
|
function writeDevEnvFile(cwd, registered) {
|
|
48
169
|
if (registered.length === 0) return null;
|
|
49
170
|
const path = join(cwd, ".env.odeva.local");
|
|
@@ -82,14 +203,113 @@ function joinUrl(base, path) {
|
|
|
82
203
|
const trimmedPath = path.startsWith("/") ? path : `/${path}`;
|
|
83
204
|
return `${trimmedBase}${trimmedPath}`;
|
|
84
205
|
}
|
|
206
|
+
function isAlreadyInstalled(result) {
|
|
207
|
+
return result.errors.some((error) => /already installed/i.test(error));
|
|
208
|
+
}
|
|
209
|
+
async function findLinkedApp(api, clientId) {
|
|
210
|
+
if (!clientId) {
|
|
211
|
+
throw new CliError("App has not been registered yet.", {
|
|
212
|
+
hint: "Run `odeva app config link` first."
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
const app = await api.findAppByClientId(clientId);
|
|
216
|
+
if (!app) {
|
|
217
|
+
throw new CliError(`No app with client_id ${clientId} is owned by your active organization.`, {
|
|
218
|
+
hint: "If you registered against a different org, run `odeva auth select-org`."
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
return app;
|
|
222
|
+
}
|
|
223
|
+
async function waitForDevInstallRoute(opts) {
|
|
224
|
+
const deadline = Date.now() + (opts.timeoutMs ?? 15e3);
|
|
225
|
+
const callback = new URL(opts.callbackUrl);
|
|
226
|
+
const localUrl = new URL(`http://127.0.0.1:${opts.localPort}`);
|
|
227
|
+
localUrl.pathname = callback.pathname;
|
|
228
|
+
let lastError = "not ready";
|
|
229
|
+
while (Date.now() < deadline) {
|
|
230
|
+
try {
|
|
231
|
+
const response = await fetch(localUrl);
|
|
232
|
+
if (response.status < 500) return;
|
|
233
|
+
lastError = `HTTP ${response.status}`;
|
|
234
|
+
} catch (err) {
|
|
235
|
+
lastError = err instanceof Error ? err.message : String(err);
|
|
236
|
+
}
|
|
237
|
+
await delay(250);
|
|
238
|
+
}
|
|
239
|
+
throw new CliError(`Dev server did not become ready at ${localUrl.toString()}.`, {
|
|
240
|
+
hint: `Last probe failed with: ${lastError}`
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
async function waitForLocalProbe(localPort, deadline, requestTimeoutMs, fetchImpl) {
|
|
244
|
+
const candidates = [
|
|
245
|
+
{ path: "/healthz", accepts: (status) => status < 400 },
|
|
246
|
+
{ path: "/", accepts: (status) => status < 500 }
|
|
247
|
+
];
|
|
248
|
+
let lastError = "not ready";
|
|
249
|
+
while (Date.now() < deadline) {
|
|
250
|
+
for (const candidate of candidates) {
|
|
251
|
+
const url = new URL(`http://127.0.0.1:${localPort}`);
|
|
252
|
+
url.pathname = candidate.path;
|
|
253
|
+
try {
|
|
254
|
+
const response = await fetchWithTimeout(fetchImpl, url, requestTimeoutMs);
|
|
255
|
+
if (candidate.accepts(response.status)) return { path: candidate.path, status: response.status };
|
|
256
|
+
lastError = `${candidate.path} returned HTTP ${response.status}`;
|
|
257
|
+
} catch (err) {
|
|
258
|
+
lastError = err instanceof Error ? err.message : String(err);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
await delay(250);
|
|
262
|
+
}
|
|
263
|
+
throw new CliError(`Dev server did not become reachable at http://127.0.0.1:${localPort}.`, {
|
|
264
|
+
hint: `Last probe failed with: ${lastError}`
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
async function fetchWithTimeout(fetchImpl, url, timeoutMs) {
|
|
268
|
+
const controller = new AbortController();
|
|
269
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
270
|
+
try {
|
|
271
|
+
return await fetchImpl(url, { signal: controller.signal });
|
|
272
|
+
} finally {
|
|
273
|
+
clearTimeout(timer);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
async function completeInstallCallback(callbackUrl) {
|
|
277
|
+
let response;
|
|
278
|
+
try {
|
|
279
|
+
response = await fetch(callbackUrl);
|
|
280
|
+
} catch (err) {
|
|
281
|
+
throw new CliError(`Could not reach app install callback at ${callbackUrl}.`, {
|
|
282
|
+
hint: err instanceof Error ? err.message : String(err)
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
if (response.ok) return;
|
|
286
|
+
let body = "";
|
|
287
|
+
try {
|
|
288
|
+
body = (await response.text()).trim().slice(0, 300);
|
|
289
|
+
} catch {
|
|
290
|
+
body = "";
|
|
291
|
+
}
|
|
292
|
+
throw new CliError(`App install callback failed with HTTP ${response.status}.`, {
|
|
293
|
+
hint: body || callbackUrl
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
function delay(ms) {
|
|
297
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
298
|
+
}
|
|
85
299
|
function stopProcess(proc) {
|
|
86
300
|
return new Promise((resolve) => {
|
|
87
301
|
if (proc.exitCode !== null) return resolve();
|
|
88
|
-
|
|
302
|
+
let resolved = false;
|
|
303
|
+
const finish = () => {
|
|
304
|
+
if (resolved) return;
|
|
305
|
+
resolved = true;
|
|
306
|
+
resolve();
|
|
307
|
+
};
|
|
308
|
+
proc.once("exit", finish);
|
|
89
309
|
try {
|
|
90
310
|
killDevProcess(proc, "SIGTERM");
|
|
91
311
|
} catch {
|
|
92
|
-
|
|
312
|
+
finish();
|
|
93
313
|
return;
|
|
94
314
|
}
|
|
95
315
|
setTimeout(() => {
|
|
@@ -100,6 +320,7 @@ function stopProcess(proc) {
|
|
|
100
320
|
}
|
|
101
321
|
}
|
|
102
322
|
}, 2e3);
|
|
323
|
+
setTimeout(finish, 5e3);
|
|
103
324
|
});
|
|
104
325
|
}
|
|
105
326
|
function killDevProcess(proc, signal) {
|
|
@@ -134,11 +355,17 @@ function readEnvFile(path) {
|
|
|
134
355
|
}
|
|
135
356
|
export {
|
|
136
357
|
cleanupSubscriptions,
|
|
358
|
+
devAppTunnelUrl,
|
|
359
|
+
devInstallCallbackUrl,
|
|
360
|
+
ensureDevAppInstalled,
|
|
137
361
|
preflightChecks,
|
|
138
362
|
readEnvFile,
|
|
139
363
|
registerWebhookSubscriptions,
|
|
140
364
|
registeredWebhookEnv,
|
|
141
365
|
spawnDevServer,
|
|
366
|
+
syncDevAppUrls,
|
|
367
|
+
waitForDevServerReachable,
|
|
368
|
+
waitForLocalDevServer,
|
|
142
369
|
watchedDevInputPaths,
|
|
143
370
|
webhookSecretEnvKey,
|
|
144
371
|
webhookSecretForEvent,
|
|
@@ -20,6 +20,16 @@ const FIXTURES = {
|
|
|
20
20
|
},
|
|
21
21
|
changes: ["checkIn", "checkOut"]
|
|
22
22
|
},
|
|
23
|
+
"reservation.confirmed": {
|
|
24
|
+
reservation: {
|
|
25
|
+
id: "res_test_abc123",
|
|
26
|
+
reservationNumber: "R-2026-0001",
|
|
27
|
+
status: "confirmed",
|
|
28
|
+
checkIn: "2026-07-01",
|
|
29
|
+
checkOut: "2026-07-05",
|
|
30
|
+
total: { amount: "480.00", currency: "EUR" }
|
|
31
|
+
}
|
|
32
|
+
},
|
|
23
33
|
"reservation.cancelled": {
|
|
24
34
|
reservation: {
|
|
25
35
|
id: "res_test_abc123",
|
|
@@ -47,8 +57,9 @@ function buildFixture(event, overrides) {
|
|
|
47
57
|
data
|
|
48
58
|
};
|
|
49
59
|
}
|
|
50
|
-
function signPayload(rawBody, secret) {
|
|
51
|
-
|
|
60
|
+
function signPayload(rawBody, secret, timestamp) {
|
|
61
|
+
const digest = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
|
|
62
|
+
return `sha256=${digest}`;
|
|
52
63
|
}
|
|
53
64
|
function listFixtureEvents() {
|
|
54
65
|
return Object.keys(FIXTURES);
|
package/package.json
CHANGED
|
@@ -13,14 +13,15 @@ odeva app dev # local server + tunnel + webhook delivery
|
|
|
13
13
|
In another terminal:
|
|
14
14
|
|
|
15
15
|
```sh
|
|
16
|
-
odeva webhook trigger reservation.
|
|
16
|
+
odeva webhook trigger reservation.confirmed
|
|
17
|
+
odeva webhook trigger payment.succeeded
|
|
17
18
|
```
|
|
18
19
|
|
|
19
20
|
## Project layout
|
|
20
21
|
|
|
21
22
|
- `odeva.app.toml` — app config (name, slug, webhooks, scopes)
|
|
22
23
|
- `src/index.ts` — Hono server with a webhook handler stub
|
|
23
|
-
- `src/webhook.ts` — HMAC signature verification
|
|
24
|
+
- `src/webhook.ts` — timestamped `sha256=` HMAC signature verification
|
|
24
25
|
- `src/admin.ts` — iframe session-token verification
|
|
25
26
|
|
|
26
27
|
## Embed in the merchant admin
|
|
@@ -21,8 +21,12 @@ api_version = "2026-01"
|
|
|
21
21
|
# tunnel URL. List supported events with: `odeva webhook list --available`.
|
|
22
22
|
#
|
|
23
23
|
# [[webhooks.subscriptions]]
|
|
24
|
-
# topic = "reservation.
|
|
25
|
-
# uri = "/webhooks/reservation.
|
|
24
|
+
# topic = "reservation.confirmed"
|
|
25
|
+
# uri = "/webhooks/reservation.confirmed"
|
|
26
|
+
#
|
|
27
|
+
# [[webhooks.subscriptions]]
|
|
28
|
+
# topic = "payment.succeeded"
|
|
29
|
+
# uri = "/webhooks/payment.succeeded"
|
|
26
30
|
|
|
27
31
|
# Uncomment to embed this app inside the merchant admin sidebar.
|
|
28
32
|
# The merchant admin will iframe `entry_url?session_token=<short-lived JWT>`
|
|
@@ -57,19 +57,20 @@ app.post("/webhooks/:topic", async (c) => {
|
|
|
57
57
|
const topic = c.req.param("topic");
|
|
58
58
|
const rawBody = await c.req.text();
|
|
59
59
|
const signature = c.req.header("x-odeva-signature");
|
|
60
|
+
const timestamp = c.req.header("x-odeva-timestamp");
|
|
60
61
|
const secret =
|
|
61
62
|
process.env[`ODEVA_WEBHOOK_SECRET__${topic.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}`] ??
|
|
62
63
|
process.env.ODEVA_WEBHOOK_SECRET;
|
|
63
64
|
|
|
64
|
-
if (secret && !verifyWebhook(rawBody, signature, secret)) {
|
|
65
|
+
if (secret && !verifyWebhook(rawBody, signature, secret, timestamp)) {
|
|
65
66
|
return c.json({ error: "invalid signature" }, 401);
|
|
66
67
|
}
|
|
67
68
|
|
|
68
69
|
const payload = JSON.parse(rawBody) as Record<string, unknown>;
|
|
69
70
|
logger.info(`[webhook] ${topic}`, payload);
|
|
70
71
|
|
|
71
|
-
//
|
|
72
|
-
//
|
|
72
|
+
// Route work by topic here. Tip: run `odeva webhook trigger <topic>` to fire
|
|
73
|
+
// a signed sample payload at this handler.
|
|
73
74
|
|
|
74
75
|
return c.json({ received: true });
|
|
75
76
|
});
|
|
@@ -27,7 +27,7 @@ const EXCHANGE_MUTATION = `
|
|
|
27
27
|
`;
|
|
28
28
|
|
|
29
29
|
interface ExchangeResponse {
|
|
30
|
-
data
|
|
30
|
+
data?: {
|
|
31
31
|
exchangeAppInstallCode: {
|
|
32
32
|
appInstallation: {
|
|
33
33
|
id: string;
|
|
@@ -37,10 +37,14 @@ interface ExchangeResponse {
|
|
|
37
37
|
rawKey: string | null;
|
|
38
38
|
errors: string[];
|
|
39
39
|
} | null;
|
|
40
|
-
};
|
|
40
|
+
} | null;
|
|
41
41
|
errors?: Array<{ message: string }>;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
function isExchangeResponse(value: unknown): value is ExchangeResponse {
|
|
45
|
+
return typeof value === "object" && value !== null;
|
|
46
|
+
}
|
|
47
|
+
|
|
44
48
|
export function makeInstallHandler(store: InstallationStore) {
|
|
45
49
|
return async (c: Context): Promise<Response> => {
|
|
46
50
|
const installCode = c.req.query("install_code");
|
|
@@ -68,13 +72,23 @@ export function makeInstallHandler(store: InstallationStore) {
|
|
|
68
72
|
}),
|
|
69
73
|
});
|
|
70
74
|
|
|
71
|
-
|
|
75
|
+
let body: ExchangeResponse;
|
|
76
|
+
try {
|
|
77
|
+
const parsed: unknown = await response.json();
|
|
78
|
+
if (!isExchangeResponse(parsed)) {
|
|
79
|
+
return c.text(`Install failed: Odeva returned an unexpected response (${response.status}).`, 502);
|
|
80
|
+
}
|
|
81
|
+
body = parsed;
|
|
82
|
+
} catch {
|
|
83
|
+
return c.text(`Install failed: Could not parse Odeva response (${response.status}).`, 502);
|
|
84
|
+
}
|
|
85
|
+
|
|
72
86
|
const topErrors = body.errors?.map((e) => e.message) ?? [];
|
|
73
87
|
const payload = body.data?.exchangeAppInstallCode ?? null;
|
|
74
88
|
const userErrors = payload?.errors ?? [];
|
|
75
89
|
|
|
76
90
|
if (topErrors.length > 0 || userErrors.length > 0 || !payload?.rawKey || !payload.appInstallation) {
|
|
77
|
-
const msg = [...topErrors, ...userErrors].join("; ") ||
|
|
91
|
+
const msg = [...topErrors, ...userErrors].join("; ") || `Exchange failed (${response.status})`;
|
|
78
92
|
return c.text(`Install failed: ${msg}`, 400);
|
|
79
93
|
}
|
|
80
94
|
|
|
@@ -3,12 +3,19 @@ import { createHmac, timingSafeEqual } from "node:crypto";
|
|
|
3
3
|
/**
|
|
4
4
|
* Verify a webhook signature from the Odeva platform.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* Webhooks are signed as HMAC-SHA256(secret, `${timestamp}.${rawBody}`),
|
|
7
|
+
* hex-encoded and sent as `x-odeva-signature: sha256=<digest>`.
|
|
8
8
|
*/
|
|
9
|
-
export function verifyWebhook(
|
|
9
|
+
export function verifyWebhook(
|
|
10
|
+
rawBody: string,
|
|
11
|
+
signature: string | undefined,
|
|
12
|
+
secret: string,
|
|
13
|
+
timestamp: string | undefined,
|
|
14
|
+
): boolean {
|
|
10
15
|
if (!signature) return false;
|
|
11
|
-
|
|
16
|
+
if (!timestamp) return false;
|
|
17
|
+
const digest = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
|
|
18
|
+
const expected = `sha256=${digest}`;
|
|
12
19
|
const a = Buffer.from(expected, "utf8");
|
|
13
20
|
const b = Buffer.from(signature, "utf8");
|
|
14
21
|
if (a.length !== b.length) return false;
|