@odeva/cli 0.0.1
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/LICENSE +21 -0
- package/README.md +150 -0
- package/bin/dev.js +7 -0
- package/bin/run.js +4 -0
- package/dist/commands/app/config/link.js +93 -0
- package/dist/commands/app/config/rotate-secret.js +74 -0
- package/dist/commands/app/dev.js +127 -0
- package/dist/commands/app/init.js +124 -0
- package/dist/commands/app/status.js +59 -0
- package/dist/commands/app/submit.js +85 -0
- package/dist/commands/auth/login.js +136 -0
- package/dist/commands/auth/logout.js +20 -0
- package/dist/commands/auth/select-org.js +30 -0
- package/dist/commands/auth/whoami.js +24 -0
- package/dist/commands/version.js +17 -0
- package/dist/commands/webhook/list.js +50 -0
- package/dist/commands/webhook/trigger.js +128 -0
- package/dist/index.js +4 -0
- package/dist/lib/api.js +276 -0
- package/dist/lib/app-env.js +39 -0
- package/dist/lib/cloudflared.js +93 -0
- package/dist/lib/config.js +51 -0
- package/dist/lib/credentials.js +50 -0
- package/dist/lib/dev-runner.js +115 -0
- package/dist/lib/errors.js +41 -0
- package/dist/lib/open-url.js +29 -0
- package/dist/lib/org-picker.js +34 -0
- package/dist/lib/paths.js +13 -0
- package/dist/lib/run.js +17 -0
- package/dist/lib/slug.js +10 -0
- package/dist/lib/templates.js +115 -0
- package/dist/lib/ui.js +13 -0
- package/dist/lib/webhook-fixtures.js +60 -0
- package/package.json +74 -0
- package/templates/hono-bun/.gitignore.tmpl +9 -0
- package/templates/hono-bun/README.md.tmpl +23 -0
- package/templates/hono-bun/odeva.app.toml.tmpl +25 -0
- package/templates/hono-bun/package.json.tmpl +19 -0
- package/templates/hono-bun/src/index.ts +49 -0
- package/templates/hono-bun/src/install.ts +97 -0
- package/templates/hono-bun/src/installations.ts +84 -0
- package/templates/hono-bun/src/webhook.ts +16 -0
- package/templates/hono-bun/tsconfig.json +16 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import * as p from "@clack/prompts";
|
|
2
|
+
import { CliError } from "./errors.js";
|
|
3
|
+
import { ui } from "./ui.js";
|
|
4
|
+
async function pickOrganization(api, options = {}) {
|
|
5
|
+
const session = await api.session();
|
|
6
|
+
const orgs = session.organizations;
|
|
7
|
+
if (orgs.length === 0) {
|
|
8
|
+
throw new CliError("Your account has no organization.", {
|
|
9
|
+
hint: "Create one at https://booking.odeva.app, then run `odeva auth login` again."
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
if (orgs.length === 1) {
|
|
13
|
+
return { organization: orgs[0], email: session.email };
|
|
14
|
+
}
|
|
15
|
+
const preferred = options.preferOrganizationId ? orgs.find((o) => o.id === options.preferOrganizationId) : void 0;
|
|
16
|
+
const choice = await p.select({
|
|
17
|
+
message: "Which organization should the CLI act on?",
|
|
18
|
+
options: orgs.map((o) => ({
|
|
19
|
+
value: o.id,
|
|
20
|
+
label: o.name,
|
|
21
|
+
hint: ui.dim(o.slug)
|
|
22
|
+
})),
|
|
23
|
+
initialValue: preferred?.id ?? orgs[0].id
|
|
24
|
+
});
|
|
25
|
+
if (p.isCancel(choice)) {
|
|
26
|
+
throw new CliError("Cancelled.");
|
|
27
|
+
}
|
|
28
|
+
const picked = orgs.find((o) => o.id === choice);
|
|
29
|
+
if (!picked) throw new CliError("Could not resolve selected organization.");
|
|
30
|
+
return { organization: picked, email: session.email };
|
|
31
|
+
}
|
|
32
|
+
export {
|
|
33
|
+
pickOrganization
|
|
34
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
const xdgConfigHome = process.env["XDG_CONFIG_HOME"] || join(homedir(), ".config");
|
|
4
|
+
const CONFIG_DIR = join(xdgConfigHome, "odeva");
|
|
5
|
+
const CREDENTIALS_PATH = join(CONFIG_DIR, "credentials.json");
|
|
6
|
+
const APP_CONFIG_FILE = "odeva.app.toml";
|
|
7
|
+
const DEFAULT_API_URL = process.env["ODEVA_API_URL"] || "https://booking.odeva.app";
|
|
8
|
+
export {
|
|
9
|
+
APP_CONFIG_FILE,
|
|
10
|
+
CONFIG_DIR,
|
|
11
|
+
CREDENTIALS_PATH,
|
|
12
|
+
DEFAULT_API_URL
|
|
13
|
+
};
|
package/dist/lib/run.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { CliError } from "./errors.js";
|
|
2
|
+
import { ui } from "./ui.js";
|
|
3
|
+
async function withErrorHandling(cmd, body) {
|
|
4
|
+
try {
|
|
5
|
+
await body();
|
|
6
|
+
} catch (err) {
|
|
7
|
+
if (err instanceof CliError) {
|
|
8
|
+
cmd.log(ui.err(err.message));
|
|
9
|
+
if (err.hint) cmd.log(ui.dim(` \u2192 ${err.hint}`));
|
|
10
|
+
cmd.exit(err.exitCode);
|
|
11
|
+
}
|
|
12
|
+
throw err;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export {
|
|
16
|
+
withErrorHandling
|
|
17
|
+
};
|
package/dist/lib/slug.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
function slugify(input) {
|
|
2
|
+
return input.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64);
|
|
3
|
+
}
|
|
4
|
+
function isValidSlug(input) {
|
|
5
|
+
return /^[a-z0-9][a-z0-9-]*$/.test(input) && input.length <= 64;
|
|
6
|
+
}
|
|
7
|
+
export {
|
|
8
|
+
isValidSlug,
|
|
9
|
+
slugify
|
|
10
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import {
|
|
2
|
+
cpSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
renameSync,
|
|
8
|
+
statSync,
|
|
9
|
+
writeFileSync
|
|
10
|
+
} from "node:fs";
|
|
11
|
+
import { dirname, join, relative } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { CliError } from "./errors.js";
|
|
14
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
function templatesRoot() {
|
|
16
|
+
const candidates = [
|
|
17
|
+
join(here, "..", "..", "..", "templates"),
|
|
18
|
+
// dist/lib → repo/templates
|
|
19
|
+
join(here, "..", "..", "templates")
|
|
20
|
+
// src/lib → repo/templates
|
|
21
|
+
];
|
|
22
|
+
for (const candidate of candidates) {
|
|
23
|
+
if (existsSync(candidate)) return candidate;
|
|
24
|
+
}
|
|
25
|
+
throw new CliError("Could not locate the templates directory.", {
|
|
26
|
+
hint: "Reinstall @odeva/cli \u2014 the packaged templates may be missing."
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
function listTemplates() {
|
|
30
|
+
const root = templatesRoot();
|
|
31
|
+
return readdirSync(root).filter((name) => {
|
|
32
|
+
const stat = statSync(join(root, name));
|
|
33
|
+
return stat.isDirectory();
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
function renderTemplate(opts) {
|
|
37
|
+
const root = templatesRoot();
|
|
38
|
+
const src = join(root, opts.template);
|
|
39
|
+
if (!existsSync(src)) {
|
|
40
|
+
throw new CliError(`Template '${opts.template}' not found.`, {
|
|
41
|
+
hint: `Available templates: ${listTemplates().join(", ")}`
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
if (existsSync(opts.destination) && !opts.overwrite) {
|
|
45
|
+
const entries = readdirSync(opts.destination);
|
|
46
|
+
if (entries.length > 0) {
|
|
47
|
+
throw new CliError(`Destination '${opts.destination}' is not empty.`, {
|
|
48
|
+
hint: "Choose a different directory or remove the existing files."
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
mkdirSync(opts.destination, { recursive: true });
|
|
53
|
+
cpSync(src, opts.destination, { recursive: true });
|
|
54
|
+
let filesWritten = 0;
|
|
55
|
+
walk(opts.destination, (path) => {
|
|
56
|
+
const isTmpl = path.endsWith(".tmpl");
|
|
57
|
+
const finalPath = isTmpl ? path.slice(0, -".tmpl".length) : path;
|
|
58
|
+
if (isTmpl || isTextFile(path)) {
|
|
59
|
+
const content = readFileSync(path, "utf8");
|
|
60
|
+
const rendered = substitute(content, opts.variables);
|
|
61
|
+
writeFileSync(path, rendered, "utf8");
|
|
62
|
+
}
|
|
63
|
+
if (isTmpl) {
|
|
64
|
+
renameSync(path, finalPath);
|
|
65
|
+
}
|
|
66
|
+
filesWritten++;
|
|
67
|
+
});
|
|
68
|
+
return { filesWritten };
|
|
69
|
+
}
|
|
70
|
+
function walk(dir, visit) {
|
|
71
|
+
for (const entry of readdirSync(dir)) {
|
|
72
|
+
const full = join(dir, entry);
|
|
73
|
+
const stat = statSync(full);
|
|
74
|
+
if (stat.isDirectory()) {
|
|
75
|
+
walk(full, visit);
|
|
76
|
+
} else if (stat.isFile()) {
|
|
77
|
+
visit(full);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const TEXT_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
82
|
+
".json",
|
|
83
|
+
".toml",
|
|
84
|
+
".ts",
|
|
85
|
+
".tsx",
|
|
86
|
+
".js",
|
|
87
|
+
".jsx",
|
|
88
|
+
".md",
|
|
89
|
+
".txt",
|
|
90
|
+
".yml",
|
|
91
|
+
".yaml",
|
|
92
|
+
".gitignore",
|
|
93
|
+
".env"
|
|
94
|
+
]);
|
|
95
|
+
function isTextFile(path) {
|
|
96
|
+
const lastDot = path.lastIndexOf(".");
|
|
97
|
+
if (lastDot === -1) return false;
|
|
98
|
+
return TEXT_EXTENSIONS.has(path.slice(lastDot).toLowerCase());
|
|
99
|
+
}
|
|
100
|
+
function substitute(content, variables) {
|
|
101
|
+
return content.replace(/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g, (match, key) => {
|
|
102
|
+
if (key in variables) return variables[key];
|
|
103
|
+
return match;
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
function relativeTo(from, to) {
|
|
107
|
+
const rel = relative(from, to);
|
|
108
|
+
return rel === "" ? "." : rel;
|
|
109
|
+
}
|
|
110
|
+
export {
|
|
111
|
+
listTemplates,
|
|
112
|
+
relativeTo,
|
|
113
|
+
renderTemplate,
|
|
114
|
+
templatesRoot
|
|
115
|
+
};
|
package/dist/lib/ui.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import pc from "picocolors";
|
|
2
|
+
const ui = {
|
|
3
|
+
brand: (s) => pc.bold(pc.magenta(s)),
|
|
4
|
+
ok: (s) => `${pc.green("\u2713")} ${s}`,
|
|
5
|
+
warn: (s) => `${pc.yellow("!")} ${s}`,
|
|
6
|
+
err: (s) => `${pc.red("\u2717")} ${s}`,
|
|
7
|
+
dim: (s) => pc.dim(s),
|
|
8
|
+
bold: (s) => pc.bold(s),
|
|
9
|
+
code: (s) => pc.cyan(s)
|
|
10
|
+
};
|
|
11
|
+
export {
|
|
12
|
+
ui
|
|
13
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { createHmac, randomBytes } from "node:crypto";
|
|
2
|
+
const FIXTURES = {
|
|
3
|
+
"reservation.created": {
|
|
4
|
+
reservation: {
|
|
5
|
+
id: "res_test_abc123",
|
|
6
|
+
status: "confirmed",
|
|
7
|
+
checkIn: "2026-07-01",
|
|
8
|
+
checkOut: "2026-07-05",
|
|
9
|
+
guests: { adults: 2, children: 1 },
|
|
10
|
+
accommodation: { id: "acc_test_001", name: "Sample Cabin" },
|
|
11
|
+
total: { amount: "480.00", currency: "EUR" }
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"reservation.updated": {
|
|
15
|
+
reservation: {
|
|
16
|
+
id: "res_test_abc123",
|
|
17
|
+
status: "confirmed",
|
|
18
|
+
checkIn: "2026-07-02",
|
|
19
|
+
checkOut: "2026-07-06"
|
|
20
|
+
},
|
|
21
|
+
changes: ["checkIn", "checkOut"]
|
|
22
|
+
},
|
|
23
|
+
"reservation.cancelled": {
|
|
24
|
+
reservation: {
|
|
25
|
+
id: "res_test_abc123",
|
|
26
|
+
status: "cancelled",
|
|
27
|
+
cancelledAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
28
|
+
reason: "guest_request"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"payment.succeeded": {
|
|
32
|
+
payment: {
|
|
33
|
+
id: "pay_test_xyz789",
|
|
34
|
+
reservationId: "res_test_abc123",
|
|
35
|
+
amount: "480.00",
|
|
36
|
+
currency: "EUR",
|
|
37
|
+
provider: "mollie"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
function buildFixture(event, overrides) {
|
|
42
|
+
const data = overrides ?? FIXTURES[event] ?? { note: "no fixture defined for this event" };
|
|
43
|
+
return {
|
|
44
|
+
event,
|
|
45
|
+
id: `evt_test_${randomBytes(8).toString("hex")}`,
|
|
46
|
+
occurredAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
47
|
+
data
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function signPayload(rawBody, secret) {
|
|
51
|
+
return createHmac("sha256", secret).update(rawBody).digest("hex");
|
|
52
|
+
}
|
|
53
|
+
function listFixtureEvents() {
|
|
54
|
+
return Object.keys(FIXTURES);
|
|
55
|
+
}
|
|
56
|
+
export {
|
|
57
|
+
buildFixture,
|
|
58
|
+
listFixtureEvents,
|
|
59
|
+
signPayload
|
|
60
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@odeva/cli",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Build apps on the Odeva booking platform — scaffold, develop, deploy.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"odeva": "./bin/run.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"dist",
|
|
13
|
+
"templates"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"bun": ">=1.1.0",
|
|
17
|
+
"node": ">=20"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsup",
|
|
21
|
+
"build:watch": "tsup --watch",
|
|
22
|
+
"lint": "oxlint --deny-warnings src",
|
|
23
|
+
"test": "vitest run",
|
|
24
|
+
"test:watch": "vitest",
|
|
25
|
+
"typecheck": "tsc --noEmit",
|
|
26
|
+
"odeva": "bun ./bin/run.js",
|
|
27
|
+
"dev": "bun ./bin/dev.js",
|
|
28
|
+
"prepublishOnly": "bun run typecheck && bun run lint && bun run test && bun run build"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@clack/prompts": "^0.9.0",
|
|
35
|
+
"@oclif/core": "^4.0.0",
|
|
36
|
+
"@oclif/plugin-help": "^6.2.0",
|
|
37
|
+
"@oclif/plugin-not-found": "^3.2.0",
|
|
38
|
+
"graphql-request": "^7.1.0",
|
|
39
|
+
"picocolors": "^1.1.0",
|
|
40
|
+
"smol-toml": "^1.3.0"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@types/bun": "^1.1.0",
|
|
44
|
+
"@types/node": "^22.0.0",
|
|
45
|
+
"oxlint": "^1.50.0",
|
|
46
|
+
"tsup": "^8.3.0",
|
|
47
|
+
"typescript": "^5.6.0",
|
|
48
|
+
"vitest": "^2.1.0"
|
|
49
|
+
},
|
|
50
|
+
"oclif": {
|
|
51
|
+
"bin": "odeva",
|
|
52
|
+
"dirname": "odeva",
|
|
53
|
+
"commands": "./dist/commands",
|
|
54
|
+
"topicSeparator": " ",
|
|
55
|
+
"plugins": [
|
|
56
|
+
"@oclif/plugin-help",
|
|
57
|
+
"@oclif/plugin-not-found"
|
|
58
|
+
],
|
|
59
|
+
"topics": {
|
|
60
|
+
"auth": {
|
|
61
|
+
"description": "Authenticate with Odeva"
|
|
62
|
+
},
|
|
63
|
+
"app": {
|
|
64
|
+
"description": "Manage and develop your Odeva app"
|
|
65
|
+
},
|
|
66
|
+
"app:config": {
|
|
67
|
+
"description": "Sync local config with the Odeva platform"
|
|
68
|
+
},
|
|
69
|
+
"webhook": {
|
|
70
|
+
"description": "Inspect and test webhook delivery"
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# {{name}}
|
|
2
|
+
|
|
3
|
+
An app built on the [Odeva](https://odeva.app) booking platform.
|
|
4
|
+
|
|
5
|
+
## Develop
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
bun install
|
|
9
|
+
odeva app config link # register this app on Odeva (writes client_id)
|
|
10
|
+
odeva app dev # local server + tunnel + webhook delivery
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
In another terminal:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
odeva webhook trigger reservation.created
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Project layout
|
|
20
|
+
|
|
21
|
+
- `odeva.app.toml` — app config (name, slug, webhooks, scopes)
|
|
22
|
+
- `src/index.ts` — Hono server with a webhook handler stub
|
|
23
|
+
- `src/webhook.ts` — HMAC signature verification
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
name = "{{name}}"
|
|
2
|
+
slug = "{{slug}}"
|
|
3
|
+
description = "An Odeva app."
|
|
4
|
+
|
|
5
|
+
# Where Odeva redirects users when they install this app on their org.
|
|
6
|
+
# Must be a publicly reachable URL once you submit for marketplace review.
|
|
7
|
+
# During development you can leave it blank or point it at your `odeva app dev` tunnel.
|
|
8
|
+
install_url = ""
|
|
9
|
+
|
|
10
|
+
[build]
|
|
11
|
+
dev = "bun run dev"
|
|
12
|
+
port = 3000
|
|
13
|
+
|
|
14
|
+
[access_scopes]
|
|
15
|
+
scopes = []
|
|
16
|
+
|
|
17
|
+
[webhooks]
|
|
18
|
+
api_version = "2026-01"
|
|
19
|
+
|
|
20
|
+
# Add subscriptions here. `odeva app dev` will auto-register them with your
|
|
21
|
+
# tunnel URL. List supported events with: `odeva webhook list --available`.
|
|
22
|
+
#
|
|
23
|
+
# [[webhooks.subscriptions]]
|
|
24
|
+
# topic = "reservation.created"
|
|
25
|
+
# uri = "/webhooks/reservation.created"
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{slug}}",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "bun --hot src/index.ts",
|
|
8
|
+
"start": "bun src/index.ts",
|
|
9
|
+
"typecheck": "tsc --noEmit"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@odeva/booking-sdk": "^0.1.0",
|
|
13
|
+
"hono": "^4.6.0"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@types/bun": "^1.1.0",
|
|
17
|
+
"typescript": "^5.6.0"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { verifyWebhook } from "./webhook.js";
|
|
3
|
+
import { makeInstallHandler } from "./install.js";
|
|
4
|
+
import { SqliteInstallationStore } from "./installations.js";
|
|
5
|
+
|
|
6
|
+
// Default store: SQLite file next to the app. Swap to your own
|
|
7
|
+
// `InstallationStore` impl (e.g. Postgres) for multi-instance deploys.
|
|
8
|
+
const installations = new SqliteInstallationStore();
|
|
9
|
+
|
|
10
|
+
const app = new Hono();
|
|
11
|
+
|
|
12
|
+
app.get("/", (c) =>
|
|
13
|
+
c.json({
|
|
14
|
+
app: "{{slug}}",
|
|
15
|
+
ok: true,
|
|
16
|
+
routes: ["GET /", "GET /healthz", "GET /install", "POST /webhooks/:topic"],
|
|
17
|
+
}),
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
app.get("/healthz", (c) => c.json({ ok: true }));
|
|
21
|
+
|
|
22
|
+
// Install handshake. Odeva redirects here with `?install_code=...`.
|
|
23
|
+
app.get("/install", makeInstallHandler(installations));
|
|
24
|
+
|
|
25
|
+
// Webhook handler. `odeva app dev` registers subscriptions from odeva.app.toml
|
|
26
|
+
// against the dev tunnel, so payloads from the Odeva platform arrive here.
|
|
27
|
+
app.post("/webhooks/:topic", async (c) => {
|
|
28
|
+
const topic = c.req.param("topic");
|
|
29
|
+
const rawBody = await c.req.text();
|
|
30
|
+
const signature = c.req.header("x-odeva-signature");
|
|
31
|
+
const secret = process.env.ODEVA_WEBHOOK_SECRET;
|
|
32
|
+
|
|
33
|
+
if (secret && !verifyWebhook(rawBody, signature, secret)) {
|
|
34
|
+
return c.json({ error: "invalid signature" }, 401);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const payload = JSON.parse(rawBody) as Record<string, unknown>;
|
|
38
|
+
console.log(`[webhook] ${topic}`, payload);
|
|
39
|
+
|
|
40
|
+
// TODO: handle the event.
|
|
41
|
+
// Tip: run `odeva webhook trigger <topic>` to fire a sample payload at this handler.
|
|
42
|
+
|
|
43
|
+
return c.json({ received: true });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const port = Number(process.env.PORT ?? 3000);
|
|
47
|
+
console.log(`{{name}} listening on http://localhost:${port}`);
|
|
48
|
+
|
|
49
|
+
export default { port, fetch: app.fetch };
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Install handshake. Odeva redirects the org admin here with `?install_code=...`
|
|
2
|
+
// after they click "Install" in the marketplace. We exchange that code (plus
|
|
3
|
+
// our client_id + client_secret) for an `sk_live_...` API key scoped to the
|
|
4
|
+
// installation, persist it, and show a success page.
|
|
5
|
+
//
|
|
6
|
+
// See: https://booking.odeva.app/docs/apps/install-flow
|
|
7
|
+
|
|
8
|
+
import type { Context } from "hono";
|
|
9
|
+
import type { InstallationStore } from "./installations.js";
|
|
10
|
+
|
|
11
|
+
const EXCHANGE_MUTATION = `
|
|
12
|
+
mutation ExchangeAppInstallCode($clientId: String!, $clientSecret: String!, $installCode: String!) {
|
|
13
|
+
exchangeAppInstallCode(
|
|
14
|
+
appClientId: $clientId,
|
|
15
|
+
appClientSecret: $clientSecret,
|
|
16
|
+
installCode: $installCode
|
|
17
|
+
) {
|
|
18
|
+
appInstallation {
|
|
19
|
+
id
|
|
20
|
+
status
|
|
21
|
+
grantedScopes
|
|
22
|
+
}
|
|
23
|
+
rawKey
|
|
24
|
+
errors
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
`;
|
|
28
|
+
|
|
29
|
+
interface ExchangeResponse {
|
|
30
|
+
data: {
|
|
31
|
+
exchangeAppInstallCode: {
|
|
32
|
+
appInstallation: {
|
|
33
|
+
id: string;
|
|
34
|
+
status: string;
|
|
35
|
+
grantedScopes: string[];
|
|
36
|
+
} | null;
|
|
37
|
+
rawKey: string | null;
|
|
38
|
+
errors: string[];
|
|
39
|
+
} | null;
|
|
40
|
+
};
|
|
41
|
+
errors?: Array<{ message: string }>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function makeInstallHandler(store: InstallationStore) {
|
|
45
|
+
return async (c: Context): Promise<Response> => {
|
|
46
|
+
const installCode = c.req.query("install_code");
|
|
47
|
+
if (!installCode) {
|
|
48
|
+
return c.text("Missing install_code in the redirect from Odeva.", 400);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const clientId = process.env.ODEVA_APP_CLIENT_ID;
|
|
52
|
+
const clientSecret = process.env.ODEVA_APP_CLIENT_SECRET;
|
|
53
|
+
if (!clientId || !clientSecret) {
|
|
54
|
+
return c.text(
|
|
55
|
+
"Server is missing ODEVA_APP_CLIENT_ID / ODEVA_APP_CLIENT_SECRET. " +
|
|
56
|
+
"Set them via `.odeva.env` (managed by `odeva app config link`).",
|
|
57
|
+
500,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const apiUrl = process.env.ODEVA_API_URL ?? "https://booking.odeva.app";
|
|
62
|
+
const response = await fetch(`${apiUrl}/graphql`, {
|
|
63
|
+
method: "POST",
|
|
64
|
+
headers: { "Content-Type": "application/json" },
|
|
65
|
+
body: JSON.stringify({
|
|
66
|
+
query: EXCHANGE_MUTATION,
|
|
67
|
+
variables: { clientId, clientSecret, installCode },
|
|
68
|
+
}),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const body = (await response.json()) as ExchangeResponse;
|
|
72
|
+
const topErrors = body.errors?.map((e) => e.message) ?? [];
|
|
73
|
+
const payload = body.data?.exchangeAppInstallCode ?? null;
|
|
74
|
+
const userErrors = payload?.errors ?? [];
|
|
75
|
+
|
|
76
|
+
if (topErrors.length > 0 || userErrors.length > 0 || !payload?.rawKey || !payload.appInstallation) {
|
|
77
|
+
const msg = [...topErrors, ...userErrors].join("; ") || "Exchange failed";
|
|
78
|
+
return c.text(`Install failed: ${msg}`, 400);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
await store.save({
|
|
82
|
+
installationId: payload.appInstallation.id,
|
|
83
|
+
apiKey: payload.rawKey,
|
|
84
|
+
scopes: payload.appInstallation.grantedScopes,
|
|
85
|
+
installedAt: new Date().toISOString(),
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
return c.html(`
|
|
89
|
+
<!doctype html>
|
|
90
|
+
<html><head><title>Installed</title></head>
|
|
91
|
+
<body style="font-family: system-ui; max-width: 32rem; margin: 4rem auto; padding: 0 1rem;">
|
|
92
|
+
<h1>Installed</h1>
|
|
93
|
+
<p>This organization is now connected. You can close this tab.</p>
|
|
94
|
+
</body></html>
|
|
95
|
+
`);
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Where the app remembers its per-installation API keys.
|
|
2
|
+
//
|
|
3
|
+
// Each time an organization installs this app, Odeva mints an `sk_live_...`
|
|
4
|
+
// API key scoped to that installation. The app must persist it and use it
|
|
5
|
+
// (via the `X-Api-Key` header) for every subsequent request made on that
|
|
6
|
+
// org's behalf. The key itself carries the org context — your code does not
|
|
7
|
+
// need to track org_id separately for API calls.
|
|
8
|
+
//
|
|
9
|
+
// The default implementation is a SQLite file (zero-config; bun has SQLite
|
|
10
|
+
// built in). This is fine for single-instance dev and small deployments.
|
|
11
|
+
// For production scale, implement `InstallationStore` against your real
|
|
12
|
+
// database and swap it in `index.ts`.
|
|
13
|
+
|
|
14
|
+
import { Database } from "bun:sqlite";
|
|
15
|
+
|
|
16
|
+
export interface Installation {
|
|
17
|
+
installationId: string;
|
|
18
|
+
apiKey: string;
|
|
19
|
+
scopes: string[];
|
|
20
|
+
installedAt: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface InstallationStore {
|
|
24
|
+
save(installation: Installation): Promise<void>;
|
|
25
|
+
load(installationId: string): Promise<Installation | null>;
|
|
26
|
+
list(): Promise<Installation[]>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class SqliteInstallationStore implements InstallationStore {
|
|
30
|
+
private readonly db: Database;
|
|
31
|
+
|
|
32
|
+
constructor(path = "installations.db") {
|
|
33
|
+
this.db = new Database(path);
|
|
34
|
+
this.db.exec(`
|
|
35
|
+
CREATE TABLE IF NOT EXISTS installations (
|
|
36
|
+
installation_id TEXT PRIMARY KEY,
|
|
37
|
+
api_key TEXT NOT NULL,
|
|
38
|
+
scopes TEXT NOT NULL,
|
|
39
|
+
installed_at TEXT NOT NULL
|
|
40
|
+
);
|
|
41
|
+
`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async save(installation: Installation): Promise<void> {
|
|
45
|
+
this.db
|
|
46
|
+
.prepare(
|
|
47
|
+
`INSERT INTO installations (installation_id, api_key, scopes, installed_at)
|
|
48
|
+
VALUES (?, ?, ?, ?)
|
|
49
|
+
ON CONFLICT(installation_id) DO UPDATE SET
|
|
50
|
+
api_key = excluded.api_key,
|
|
51
|
+
scopes = excluded.scopes,
|
|
52
|
+
installed_at = excluded.installed_at`,
|
|
53
|
+
)
|
|
54
|
+
.run(
|
|
55
|
+
installation.installationId,
|
|
56
|
+
installation.apiKey,
|
|
57
|
+
JSON.stringify(installation.scopes),
|
|
58
|
+
installation.installedAt,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async load(installationId: string): Promise<Installation | null> {
|
|
63
|
+
const row = this.db
|
|
64
|
+
.prepare("SELECT * FROM installations WHERE installation_id = ?")
|
|
65
|
+
.get(installationId) as Record<string, unknown> | null;
|
|
66
|
+
return row ? this.rowToInstallation(row) : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async list(): Promise<Installation[]> {
|
|
70
|
+
const rows = this.db
|
|
71
|
+
.prepare("SELECT * FROM installations ORDER BY installed_at DESC")
|
|
72
|
+
.all() as Array<Record<string, unknown>>;
|
|
73
|
+
return rows.map((r) => this.rowToInstallation(r));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private rowToInstallation(row: Record<string, unknown>): Installation {
|
|
77
|
+
return {
|
|
78
|
+
installationId: row.installation_id as string,
|
|
79
|
+
apiKey: row.api_key as string,
|
|
80
|
+
scopes: JSON.parse(row.scopes as string),
|
|
81
|
+
installedAt: row.installed_at as string,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Verify a webhook signature from the Odeva platform.
|
|
5
|
+
*
|
|
6
|
+
* The CLI's `odeva webhook trigger` and the platform itself both sign payloads
|
|
7
|
+
* with HMAC-SHA256(secret, rawBody), hex-encoded, sent as `x-odeva-signature`.
|
|
8
|
+
*/
|
|
9
|
+
export function verifyWebhook(rawBody: string, signature: string | undefined, secret: string): boolean {
|
|
10
|
+
if (!signature) return false;
|
|
11
|
+
const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
|
|
12
|
+
const a = Buffer.from(expected, "utf8");
|
|
13
|
+
const b = Buffer.from(signature, "utf8");
|
|
14
|
+
if (a.length !== b.length) return false;
|
|
15
|
+
return timingSafeEqual(a, b);
|
|
16
|
+
}
|