pombus 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -1
- package/dist/bin.js +12 -1
- package/dist/client.js +13 -4
- package/dist/http/do-class.js +77 -0
- package/dist/http/do-entry.js +10 -0
- package/dist/http/oauth-i18n.js +69 -0
- package/dist/http/oauth.js +588 -0
- package/dist/http/root.js +80 -0
- package/dist/http/store-do.js +64 -0
- package/dist/http/worker.js +238 -0
- package/dist/i18n.js +36 -0
- package/dist/init.js +53 -2
- package/dist/server.js +23 -10
- package/dist/tools.js +119 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -28,9 +28,14 @@ uses TOML, so `init` prints the block for you to paste).
|
|
|
28
28
|
```sh
|
|
29
29
|
npx pombus init --print # print the config block, write nothing
|
|
30
30
|
npx pombus init --client cursor # target one client
|
|
31
|
-
npx pombus init --create-inbox
|
|
31
|
+
npx pombus init --create-inbox --domain acme.com # create an inbox on YOUR verified domain
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
+
`--create-inbox` needs `--domain`: an extra inbox can only be created on a domain
|
|
35
|
+
you own and have verified in Pombus. (Your account's `@pombus.com` inbox is the one
|
|
36
|
+
from signup; an API key cannot create more of those —
|
|
37
|
+
`403 platform_inbox_not_allowed`.)
|
|
38
|
+
|
|
34
39
|
## Commands
|
|
35
40
|
|
|
36
41
|
| Command | What it does |
|
package/dist/bin.js
CHANGED
|
@@ -23,6 +23,7 @@ export function parseArgs(argv) {
|
|
|
23
23
|
const out = {
|
|
24
24
|
cmd: undefined,
|
|
25
25
|
createInbox: false,
|
|
26
|
+
domain: undefined,
|
|
26
27
|
print: false,
|
|
27
28
|
help: false,
|
|
28
29
|
locale: undefined,
|
|
@@ -50,6 +51,14 @@ export function parseArgs(argv) {
|
|
|
50
51
|
if (v)
|
|
51
52
|
out.client = v;
|
|
52
53
|
}
|
|
54
|
+
else if (a === "--domain" || a.startsWith("--domain=")) {
|
|
55
|
+
// `--domain --print` must not swallow the next flag as the value: a value starting with "-" is
|
|
56
|
+
// left for the loop (domain stays unset -> init explains --domain is needed).
|
|
57
|
+
const next = argv[i + 1];
|
|
58
|
+
const v = a.includes("=") ? a.slice(a.indexOf("=") + 1) : next && !next.startsWith("-") ? argv[++i] : "";
|
|
59
|
+
if (v)
|
|
60
|
+
out.domain = v;
|
|
61
|
+
}
|
|
53
62
|
}
|
|
54
63
|
return out;
|
|
55
64
|
}
|
|
@@ -62,7 +71,8 @@ const USAGE = [
|
|
|
62
71
|
" doctor check your API key and report its scope",
|
|
63
72
|
"",
|
|
64
73
|
"init options:",
|
|
65
|
-
" --create-inbox create a new inbox and bind the config to it",
|
|
74
|
+
" --create-inbox create a new inbox and bind the config to it (needs --domain)",
|
|
75
|
+
" --domain <name> your verified domain the new inbox is created on",
|
|
66
76
|
" --print print the config block instead of writing it",
|
|
67
77
|
" --client <id> target one client (claude-code|cursor|gemini|codex)",
|
|
68
78
|
" --locale <loc> en-US | pt-BR | es-419",
|
|
@@ -82,6 +92,7 @@ async function main() {
|
|
|
82
92
|
await runInit({
|
|
83
93
|
apiKey: readApiKey() ?? undefined,
|
|
84
94
|
createInbox: args.createInbox,
|
|
95
|
+
...(args.domain !== undefined ? { domain: args.domain } : {}),
|
|
85
96
|
print: args.print,
|
|
86
97
|
...(args.client !== undefined ? { client: args.client } : {}),
|
|
87
98
|
...(args.locale !== undefined ? { locale: args.locale } : {}),
|
package/dist/client.js
CHANGED
|
@@ -16,10 +16,16 @@ const DEFAULT_BASE = "https://mail-api.pombus.com/v0";
|
|
|
16
16
|
* would distinguish "forbidden" from "not found". */
|
|
17
17
|
export class CoreError extends Error {
|
|
18
18
|
code;
|
|
19
|
-
|
|
19
|
+
/** The core's OWN stable code behind a collapsed 401/403/404 (e.g. `platform_inbox_not_allowed`).
|
|
20
|
+
* For the HUMAN installer only (`pombus init`), which runs with the user's own key and must say WHY;
|
|
21
|
+
* never surfaced to the model — server.ts modelError() prints `code` alone, so the anti-enumeration
|
|
22
|
+
* collapse above stands for every tool call. */
|
|
23
|
+
coreCode;
|
|
24
|
+
constructor(code, message, coreCode) {
|
|
20
25
|
super(message);
|
|
21
26
|
this.name = "CoreError";
|
|
22
27
|
this.code = code;
|
|
28
|
+
this.coreCode = coreCode;
|
|
23
29
|
}
|
|
24
30
|
}
|
|
25
31
|
/** Resolve the core base URL from an explicit option or the environment, else the production default.
|
|
@@ -41,8 +47,11 @@ export class CoreClient {
|
|
|
41
47
|
constructor(opts) {
|
|
42
48
|
this.apiKey = opts.apiKey;
|
|
43
49
|
this.baseUrl = resolveBaseUrl(opts.baseUrl);
|
|
44
|
-
//
|
|
45
|
-
this
|
|
50
|
+
// The option exists only so tests can inject a fake core. The default must be BOUND to globalThis:
|
|
51
|
+
// the workerd (Cloudflare Workers) native fetch requires `this === globalThis`, so a detached
|
|
52
|
+
// reference throws `TypeError: Illegal invocation` in production. Node's undici tolerates a
|
|
53
|
+
// detached fetch, which is why the omission passed every node:test — see client.test.ts.
|
|
54
|
+
this.fetchImpl = opts.fetchImpl ?? globalThis.fetch.bind(globalThis);
|
|
46
55
|
}
|
|
47
56
|
/** Build an absolute URL from a path and query params. Values are URL-encoded. The API key is
|
|
48
57
|
* NEVER a query param — it only ever rides in the Authorization header (keyHygiene). */
|
|
@@ -96,7 +105,7 @@ export class CoreClient {
|
|
|
96
105
|
}
|
|
97
106
|
// Anti-enumeration: do NOT distinguish "forbidden" from "not found" to the model.
|
|
98
107
|
if (res.status === 401 || res.status === 403 || res.status === 404) {
|
|
99
|
-
throw new CoreError("not_found", "resource not found");
|
|
108
|
+
throw new CoreError("not_found", "resource not found", await safeCode(res));
|
|
100
109
|
}
|
|
101
110
|
// Other errors: a stable code from the core body when present, else the status. The message is
|
|
102
111
|
// generic; the body is read but only its `code`/`detail` string is used, never echoed verbatim
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// R2c — the Durable Object shell. This is the ONE place that imports cloudflare:workers, so it is the
|
|
2
|
+
// ONE file the `node --test` runner must never load: `cloudflare:workers` throws
|
|
3
|
+
// ERR_UNSUPPORTED_ESM_URL_SCHEME in Node. It is kept off the test import graph (only do-entry.ts, the
|
|
4
|
+
// wrangler main, pulls it) and off `tsc`'s unresolved-module error by the ambient declaration below.
|
|
5
|
+
//
|
|
6
|
+
// Why this exists: Cloudflare Workers do NOT pin the same isolate across the three hops of the OAuth
|
|
7
|
+
// flow, so an in-isolate OAuthStore loses the authorization code between POST /authorize and POST
|
|
8
|
+
// /token → `invalid_grant` in production. ONE Durable Object instance fixes it: every isolate reaches
|
|
9
|
+
// the same object (DurableObjectStore resolves it by a fixed name), and the DO's input gate serializes
|
|
10
|
+
// calls so single-use take-and-delete stays atomic.
|
|
11
|
+
//
|
|
12
|
+
// MEMORY, not ctx.storage (deliberate, do not "fix"): the store holds each token-set's minted childKeys
|
|
13
|
+
// (one per authorized org, inside every OrgEntry) and the ownerHash that gates attachOrg. Persisting
|
|
14
|
+
// them to ctx.storage would put those secrets on disk at rest — a worse security posture. In memory they
|
|
15
|
+
// never touch disk. The only cost is benign: if the DO is evicted after a long idle, live tokens vanish
|
|
16
|
+
// and the agent re-authorizes (a UX degradation, never a security failure). So this class does NOT touch
|
|
17
|
+
// ctx.storage at all.
|
|
18
|
+
//
|
|
19
|
+
// This shell carries NO logic and logs NOTHING: every method is a 1:1 pass-through to the in-memory
|
|
20
|
+
// OAuthStore it wraps. All behaviour under test (single-use, expiry, caps, PKCE, scope, rate-limit)
|
|
21
|
+
// lives in OAuthStore (oauth.ts), proven in node. The shell is workerd-specific and verified by reading
|
|
22
|
+
// plus the founder's `wrangler dev` smoke — it has no automated test, by design (no Miniflare is added;
|
|
23
|
+
// the package's stack is fixed, AGENTS.md §4).
|
|
24
|
+
// The runtime base class comes from `cloudflare:workers`, declared minimally in
|
|
25
|
+
// cloudflare-workers.d.ts (the npx package has no @cloudflare/workers-types; see that file).
|
|
26
|
+
import { DurableObject } from "cloudflare:workers";
|
|
27
|
+
import { OAuthStore, } from "./oauth.js";
|
|
28
|
+
/** The singleton OAuth store as a Durable Object. Its public methods ARE the RPC surface the Worker's
|
|
29
|
+
* DurableObjectStore calls on the stub; each forwards straight to the wrapped in-memory OAuthStore. */
|
|
30
|
+
export class OAuthStoreDO extends DurableObject {
|
|
31
|
+
store = new OAuthStore();
|
|
32
|
+
putRequest(id, r) {
|
|
33
|
+
return this.store.putRequest(id, r);
|
|
34
|
+
}
|
|
35
|
+
takeRequest(id) {
|
|
36
|
+
return this.store.takeRequest(id);
|
|
37
|
+
}
|
|
38
|
+
putCode(code, c) {
|
|
39
|
+
return this.store.putCode(code, c);
|
|
40
|
+
}
|
|
41
|
+
takeCode(code) {
|
|
42
|
+
return this.store.takeCode(code);
|
|
43
|
+
}
|
|
44
|
+
tokenRoom() {
|
|
45
|
+
return this.store.tokenRoom();
|
|
46
|
+
}
|
|
47
|
+
putToken(token, t) {
|
|
48
|
+
return this.store.putToken(token, t);
|
|
49
|
+
}
|
|
50
|
+
getToken(token) {
|
|
51
|
+
return this.store.getToken(token);
|
|
52
|
+
}
|
|
53
|
+
attachOrg(token, orgId, entry, ownerHash) {
|
|
54
|
+
return this.store.attachOrg(token, orgId, entry, ownerHash);
|
|
55
|
+
}
|
|
56
|
+
setActiveOrg(token, orgId) {
|
|
57
|
+
return this.store.setActiveOrg(token, orgId);
|
|
58
|
+
}
|
|
59
|
+
getActiveChild(token) {
|
|
60
|
+
return this.store.getActiveChild(token);
|
|
61
|
+
}
|
|
62
|
+
putLink(linkToken, accessToken) {
|
|
63
|
+
return this.store.putLink(linkToken, accessToken);
|
|
64
|
+
}
|
|
65
|
+
takeLink(linkToken) {
|
|
66
|
+
return this.store.takeLink(linkToken);
|
|
67
|
+
}
|
|
68
|
+
putClient(clientId, redirectUris) {
|
|
69
|
+
return this.store.putClient(clientId, redirectUris);
|
|
70
|
+
}
|
|
71
|
+
getClient(clientId) {
|
|
72
|
+
return this.store.getClient(clientId);
|
|
73
|
+
}
|
|
74
|
+
reset() {
|
|
75
|
+
return this.store.reset();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// R2c — the wrangler `main`. It exists so the Durable Object class is a named export of the deployed
|
|
2
|
+
// entry (wrangler requires the DO class to be exported from main) WITHOUT putting the toxic
|
|
3
|
+
// cloudflare:workers import on the test path: the tests import ./worker.js directly and never touch this
|
|
4
|
+
// file, so `node --test` never loads do-class.js. workerd loads this module and sees both the fetch
|
|
5
|
+
// handler and OAuthStoreDO.
|
|
6
|
+
//
|
|
7
|
+
// Keep this file a pure re-export — no logic. The worker's fetch handler stays in worker.ts (fully
|
|
8
|
+
// tested in node); the DO shell stays in do-class.ts (workerd-only).
|
|
9
|
+
export { default } from "./worker.js";
|
|
10
|
+
export { OAuthStoreDO } from "./do-class.js";
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// OAuth i18n — the locale machinery + the root-page copy, in the three mandated locales (AGENTS.md §2):
|
|
2
|
+
// en-US, pt-BR, es-419. Keyed by a stable, locale-free id; nothing visible is hardcoded.
|
|
3
|
+
//
|
|
4
|
+
// R3 note: the consent screen moved to the WEBMAIL (the Worker delegates the human decision), so the
|
|
5
|
+
// consent dictionary that used to live here is gone. What remains is used by the Worker itself: the
|
|
6
|
+
// locale negotiation (pickLocale / parseAcceptLanguage) and the friendly root page (ROOT_TEXT), both
|
|
7
|
+
// consumed by root.ts and worker.ts.
|
|
8
|
+
export const OAUTH_LOCALES = ["en-US", "pt-BR", "es-419"];
|
|
9
|
+
/** Match a single raw tag ("pt", "pt-BR", "es-419", "en-GB"...) to one of the three supported locales
|
|
10
|
+
* by language prefix (region only matters for Spanish, which is always es-419). Returns null when it
|
|
11
|
+
* matches none — so a garbage ?hl= simply doesn't resolve and the caller falls through to the next
|
|
12
|
+
* source (the lixo is NEVER reflected). This is the one place the prefix rule lives. */
|
|
13
|
+
function matchLocale(raw) {
|
|
14
|
+
const v = (raw ?? "").trim().toLowerCase();
|
|
15
|
+
if (v.startsWith("pt"))
|
|
16
|
+
return "pt-BR";
|
|
17
|
+
if (v.startsWith("es"))
|
|
18
|
+
return "es-419";
|
|
19
|
+
if (v.startsWith("en"))
|
|
20
|
+
return "en-US";
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
/** Pick the screen locale by precedence: scan the sources in order and take the FIRST that resolves to
|
|
24
|
+
* a supported locale; en-US is the last resort. The caller passes, in order of strength:
|
|
25
|
+
* [ ?hl= , Accept-Language , ?locale= ]
|
|
26
|
+
* — the human's explicit click, then their browser default, then the agent's hint. A source that
|
|
27
|
+
* matches nothing is skipped, so a garbage value never wins and never surfaces. */
|
|
28
|
+
export function pickLocale(sources) {
|
|
29
|
+
for (const s of sources) {
|
|
30
|
+
const m = matchLocale(s);
|
|
31
|
+
if (m)
|
|
32
|
+
return m;
|
|
33
|
+
}
|
|
34
|
+
return "en-US";
|
|
35
|
+
}
|
|
36
|
+
/** Extract the highest-priority language tag from an Accept-Language header — just the first tag,
|
|
37
|
+
* before the first comma or ';q='. ponytail: we don't parse the full q-value list; the browser lists
|
|
38
|
+
* its preferred language first, and if that first tag matches none of our three, pickLocale falls
|
|
39
|
+
* through to the next source anyway. Returns null for an empty/absent header. */
|
|
40
|
+
export function parseAcceptLanguage(header) {
|
|
41
|
+
const first = (header ?? "").split(",")[0]?.split(";")[0]?.trim();
|
|
42
|
+
return first ? first : null;
|
|
43
|
+
}
|
|
44
|
+
/** The friendly root page (R2d) — the page a HUMAN sees if they open the MCP endpoint in a browser
|
|
45
|
+
* (a real MCP client never hits it: it GETs with Accept text/html and no Bearer). Three locales like
|
|
46
|
+
* everything else; keyed by a stable id. Content-free of any secret — it only names the product and
|
|
47
|
+
* how to use the endpoint. */
|
|
48
|
+
export const ROOT_TEXT = {
|
|
49
|
+
root_title: {
|
|
50
|
+
"en-US": "Pombus — MCP endpoint",
|
|
51
|
+
"pt-BR": "Pombus — endpoint MCP",
|
|
52
|
+
"es-419": "Pombus — endpoint MCP",
|
|
53
|
+
},
|
|
54
|
+
root_heading: {
|
|
55
|
+
"en-US": "This is the Pombus MCP endpoint",
|
|
56
|
+
"pt-BR": "Este é o endpoint MCP do Pombus",
|
|
57
|
+
"es-419": "Este es el endpoint MCP de Pombus",
|
|
58
|
+
},
|
|
59
|
+
root_body: {
|
|
60
|
+
"en-US": "Add this URL to your AI client (Claude and others) as a remote MCP connector. It is an API surface, not a website.",
|
|
61
|
+
"pt-BR": "Adicione esta URL ao seu cliente de IA (Claude e outros) como conector MCP remoto. É uma superfície de API, não um site.",
|
|
62
|
+
"es-419": "Agrega esta URL a tu cliente de IA (Claude y otros) como conector MCP remoto. Es una superficie de API, no un sitio web.",
|
|
63
|
+
},
|
|
64
|
+
root_site: {
|
|
65
|
+
"en-US": "Learn more at pombus.com",
|
|
66
|
+
"pt-BR": "Saiba mais em pombus.com",
|
|
67
|
+
"es-419": "Más información en pombus.com",
|
|
68
|
+
},
|
|
69
|
+
};
|