@sendoka/cli 0.2.2
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 +88 -0
- package/dist/cli.js +65 -0
- package/dist/client.js +70 -0
- package/dist/commands/events.js +47 -0
- package/dist/commands/index.js +30 -0
- package/dist/commands/listen.js +379 -0
- package/dist/commands/logs.js +157 -0
- package/dist/commands/send.js +46 -0
- package/dist/flags.js +63 -0
- package/dist/version.js +16 -0
- package/package.json +32 -0
package/README.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# sendoka CLI
|
|
2
|
+
|
|
3
|
+
Developer tools for the Sendoka API — send messages, tail messages as they are created, forward webhook deliveries locally, trigger synthetic events.
|
|
4
|
+
|
|
5
|
+
Five commands, all of them below. There is no key management, no domain management and no interactive login; use the [dashboard](https://www.sendoka.com/overview) or the REST API for the rest. Full reference: [docs/developer-tools/cli.md](https://www.sendoka.com/docs/guides/developer-tools/cli).
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
Not published to npm yet, and Sendoka does not hold the `sendoka` name there:
|
|
10
|
+
a package installed under that name from the registry is not this one. From a
|
|
11
|
+
checkout of the repository:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
cd packages/cli
|
|
15
|
+
npm install && npm run build
|
|
16
|
+
npm link # puts `sendoka` on your PATH
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Requires Node 20+.
|
|
20
|
+
|
|
21
|
+
## Configure
|
|
22
|
+
|
|
23
|
+
Every command authenticates with an API key.
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
export SENDOKA_API_KEY=sok_test_...
|
|
27
|
+
|
|
28
|
+
# For `listen` only: the endpoint's whsec_ signing secret, so forwarded
|
|
29
|
+
# deliveries are re-signed. --secret works too.
|
|
30
|
+
export SENDOKA_WEBHOOK_SECRET=whsec_...
|
|
31
|
+
|
|
32
|
+
# Optional. Defaults to https://www.sendoka.com.
|
|
33
|
+
export SENDOKA_BASE_URL=https://www.sendoka.com
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The key needs the scope its endpoint needs: `send:email` / `send:sms` for the sends, `read:messages` for `logs tail`, `read:webhooks` for `listen`, `write:webhooks` for `events trigger`. A full-access key has all of them.
|
|
37
|
+
|
|
38
|
+
With a `sok_test_*` key, `listen` forwards only deliveries of test-mode events (test sends and `events trigger` fires): an endpoint also receives live traffic, and a test key cannot read it. Its list pages can come back empty with more to follow; `listen` starts below the first such page rather than forwarding older deliveries as new. To forward live deliveries, use a live key scoped to `read:webhooks` alone.
|
|
39
|
+
|
|
40
|
+
Every request counts against your org's `/api/v1` rate limit (60/min on Free), the same budget your production sends use. `listen` makes 12 requests a minute and `logs tail` 24 at the default 5-second interval; on a Free org raise `--interval`, or give the CLI a key with its own per-key limit. Both wait out a `429`'s `Retry-After` and resume without skipping anything.
|
|
41
|
+
|
|
42
|
+
`SENDOKA_SESSION_COOKIE` is no longer read. `listen` and `events trigger` used to send it to `/api/internal/*` under the cookie name NextAuth uses over plain http, which the https host never reads — both answered 401 against www.sendoka.com. They now use the public `/api/v1/webhooks` routes with the key.
|
|
43
|
+
|
|
44
|
+
## Usage
|
|
45
|
+
|
|
46
|
+
### Send
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
sendoka send email \
|
|
50
|
+
--from hello@yourdomain.com \
|
|
51
|
+
--to user@example.com \
|
|
52
|
+
--subject "Hello" \
|
|
53
|
+
--html "<p>From the CLI</p>"
|
|
54
|
+
|
|
55
|
+
sendoka send sms --from +15551234567 --to +15559876543 --body "Test"
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`--to` is comma-separated for several recipients; the flag is not repeatable. `--template` + `--variables '{"name":"Mira"}'` render a stored template instead of an inline body. Attachments, tags, scheduling and `Idempotency-Key` are not exposed here — use an HTTP client for those.
|
|
59
|
+
|
|
60
|
+
### Tail messages
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
sendoka logs tail
|
|
64
|
+
sendoka logs tail --channel sms --interval 15
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Polls `GET /api/v1/emails` and `GET /api/v1/sms` by `created_after` and prints one line per **new** message, starting from now, for the key's environment. Each message is printed once, with its status when first seen; later transitions (`delivered`, `bounced`) are what webhooks are for. `--days` from the old aggregate version is ignored.
|
|
68
|
+
|
|
69
|
+
### Forward webhook deliveries to a local URL
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
sendoka listen --endpoint whk_xxx --forward-to http://localhost:3001/hooks
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Polls `GET /api/v1/webhooks/{id}/deliveries?include=payload` for that endpoint and re-POSTs each delivery created after the command started to `--forward-to` — body and headers as production sends them, **signed with the endpoint secret** (`--secret` or `SENDOKA_WEBHOOK_SECRET`), so the SDK's `verifyWebhookSignature` passes and your handler can keep verification on. Without a secret it warns and forwards unsigned. Avoids ngrok for local webhook dev.
|
|
76
|
+
|
|
77
|
+
### Trigger a synthetic event
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
sendoka events trigger message.bounced --endpoint whk_xxx
|
|
81
|
+
sendoka events trigger message.bounced --endpoint whk_xxx --data '{"channel":"sms"}'
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`POST /api/v1/webhooks/{id}/test-fire`: fires a properly-signed test delivery at one of your webhook endpoints. Payloads carry `data.test: true` and `"environment": "test"` so your handler can distinguish them; `--data` (a JSON object) is merged over the synthetic data but cannot change those markers or the synthetic `message_id`. `--event message.bounced` is accepted as an equivalent to the positional form. Combine with `listen` for a fully local loop.
|
|
85
|
+
|
|
86
|
+
## Exit codes
|
|
87
|
+
|
|
88
|
+
`0` success, `1` the command threw (HTTP error, missing env var, bad `--variables` / `--data` JSON), `2` unknown command. There are no per-status codes — an HTTP error prints `HTTP <status>: <message> (<CODE>)` on stderr; branch on the code.
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Sendoka CLI — minimal developer loop over the REST API.
|
|
4
|
+
*
|
|
5
|
+
* sendoka send email --from a --to b --subject hi --html "<p>…</p>"
|
|
6
|
+
* sendoka send sms --from +1 --to +1 --body hi
|
|
7
|
+
* sendoka logs tail (polls /api/v1/emails + /api/v1/sms by created_after)
|
|
8
|
+
* sendoka listen --endpoint whk_xxx --forward-to http://localhost:3001/hook
|
|
9
|
+
* (polls /api/v1/webhooks/:id/deliveries and forwards
|
|
10
|
+
* each new payload, re-signed with --secret)
|
|
11
|
+
* sendoka events trigger message.bounced --endpoint whk_xxx
|
|
12
|
+
* (hits /api/v1/webhooks/:id/test-fire)
|
|
13
|
+
*
|
|
14
|
+
* Auth: every command reads SENDOKA_API_KEY from the environment. There is no
|
|
15
|
+
* session-cookie path any more — see client.ts for why it never worked on https.
|
|
16
|
+
*/
|
|
17
|
+
import { resolveCommand } from "./commands/index.js";
|
|
18
|
+
import { parseFlags, splitInvocation } from "./flags.js";
|
|
19
|
+
import { CLI_VERSION } from "./version.js";
|
|
20
|
+
async function main(argv) {
|
|
21
|
+
const [, , ...rest] = argv;
|
|
22
|
+
if (rest.length === 0 || rest[0] === "--help" || rest[0] === "-h") {
|
|
23
|
+
printUsage();
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (rest[0] === "--version" || rest[0] === "-v") {
|
|
27
|
+
console.log(`sendoka ${CLI_VERSION}`);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const { group, action, args } = splitInvocation(rest);
|
|
31
|
+
const cmd = resolveCommand(group, action);
|
|
32
|
+
if (!cmd) {
|
|
33
|
+
console.error(`Unknown command: ${group}${action ? " " + action : ""}`);
|
|
34
|
+
printUsage();
|
|
35
|
+
process.exit(2);
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
await cmd(parseFlags(args));
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
console.error(err.message);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function printUsage() {
|
|
46
|
+
console.log(`sendoka — developer CLI
|
|
47
|
+
|
|
48
|
+
Usage:
|
|
49
|
+
sendoka send email --from <addr> --to <addr> [--subject] [--html|--text] [--template]
|
|
50
|
+
sendoka send sms --from <num> --to <num> --body <text>
|
|
51
|
+
sendoka logs tail [--channel email|sms] [--interval 5]
|
|
52
|
+
sendoka listen --endpoint <whk_id> --forward-to <url> [--secret <whsec_...>] [--interval 5]
|
|
53
|
+
sendoka events trigger <event> --endpoint <whk_id> [--data <json>]
|
|
54
|
+
|
|
55
|
+
Env:
|
|
56
|
+
SENDOKA_API_KEY sok_live_* or sok_test_* — every command
|
|
57
|
+
SENDOKA_BASE_URL defaults to https://www.sendoka.com
|
|
58
|
+
SENDOKA_WEBHOOK_SECRET the endpoint's whsec_ secret, for \`listen\` (or --secret)
|
|
59
|
+
|
|
60
|
+
Run with --help on any subcommand for details.`);
|
|
61
|
+
}
|
|
62
|
+
main(process.argv).catch((err) => {
|
|
63
|
+
console.error(err);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
});
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export function baseUrl() {
|
|
2
|
+
return (process.env.SENDOKA_BASE_URL ?? "https://www.sendoka.com").replace(/\/$/, "");
|
|
3
|
+
}
|
|
4
|
+
export function apiKey() {
|
|
5
|
+
const k = process.env.SENDOKA_API_KEY;
|
|
6
|
+
if (!k)
|
|
7
|
+
throw new Error("Set SENDOKA_API_KEY to an sok_live_* / sok_test_* key.");
|
|
8
|
+
return k;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* A non-2xx answer from `/api/v1`. The message keeps the `HTTP <status>:
|
|
12
|
+
* <message> (<CODE>)` shape the CLI prints; `status` and `retryAfterSeconds`
|
|
13
|
+
* are there so a polling command can tell a 429 — back off, the rows are
|
|
14
|
+
* still there — from an answer that will not change on retry.
|
|
15
|
+
*/
|
|
16
|
+
export class ApiError extends Error {
|
|
17
|
+
status;
|
|
18
|
+
code;
|
|
19
|
+
retryAfterSeconds;
|
|
20
|
+
constructor(message, status, code, retryAfterSeconds) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.status = status;
|
|
23
|
+
this.code = code;
|
|
24
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
25
|
+
this.name = "ApiError";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export function isRateLimited(err) {
|
|
29
|
+
return err instanceof ApiError && err.status === 429;
|
|
30
|
+
}
|
|
31
|
+
/** `Retry-After` in seconds, or null. Only the delta-seconds form is sent by /v1. */
|
|
32
|
+
function retryAfter(res) {
|
|
33
|
+
const raw = typeof res.headers?.get === "function" ? res.headers.get("retry-after") : null;
|
|
34
|
+
const n = raw === null ? NaN : Number(raw);
|
|
35
|
+
return Number.isFinite(n) && n >= 0 ? n : null;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Every command goes through here, with the API key. There used to be a second
|
|
39
|
+
* client for `/api/internal/*` that sent `Cookie: next-auth.session-token=…`,
|
|
40
|
+
* for `listen` and `events trigger` — and it could not authenticate against
|
|
41
|
+
* the default host. NextAuth v4 names the cookie
|
|
42
|
+
* `__Secure-next-auth.session-token` whenever it is served over https, so the
|
|
43
|
+
* unprefixed name the CLI sent was never read and both commands answered 401
|
|
44
|
+
* on www.sendoka.com. Both now use the public `/v1/webhooks` routes instead.
|
|
45
|
+
*/
|
|
46
|
+
export async function callV1(method, path, body) {
|
|
47
|
+
const res = await fetch(`${baseUrl()}/api/v1${path}`, {
|
|
48
|
+
method,
|
|
49
|
+
headers: {
|
|
50
|
+
Authorization: `Bearer ${apiKey()}`,
|
|
51
|
+
"Content-Type": "application/json",
|
|
52
|
+
},
|
|
53
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
54
|
+
});
|
|
55
|
+
const text = await res.text();
|
|
56
|
+
let parsed;
|
|
57
|
+
try {
|
|
58
|
+
parsed = text ? JSON.parse(text) : {};
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
parsed = text;
|
|
62
|
+
}
|
|
63
|
+
if (!res.ok) {
|
|
64
|
+
const error = parsed?.error;
|
|
65
|
+
const message = error?.message ?? text;
|
|
66
|
+
// The code is what a script branches on — the message is prose and moves.
|
|
67
|
+
throw new ApiError(`HTTP ${res.status}: ${message}${error?.code ? ` (${error.code})` : ""}`, res.status, error?.code ?? null, retryAfter(res));
|
|
68
|
+
}
|
|
69
|
+
return parsed;
|
|
70
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { callV1 } from "../client.js";
|
|
2
|
+
const USAGE = "Usage: sendoka events trigger <message.bounced|...> --endpoint <whk_id> [--data '<json object>']";
|
|
3
|
+
/**
|
|
4
|
+
* Fire a synthetic, signed delivery at one of your endpoints —
|
|
5
|
+
* `POST /api/v1/webhooks/{id}/test-fire` with the API key (`write:webhooks`).
|
|
6
|
+
*
|
|
7
|
+
* This used to call `/api/internal/webhooks/{id}/trigger-test` with a session
|
|
8
|
+
* cookie under the name NextAuth only uses over plain http, so it answered 401
|
|
9
|
+
* against the default https host on every run. The v1 route is the same
|
|
10
|
+
* operation — same payload shape, same signing, same 20/min budget shared with
|
|
11
|
+
* the dashboard's test button — reachable with the key every other command
|
|
12
|
+
* already reads.
|
|
13
|
+
*/
|
|
14
|
+
export async function eventsTrigger(flags) {
|
|
15
|
+
// `--event` and the positional form are equivalent; the flag wins if both
|
|
16
|
+
// are given, since it is the one that cannot be a mistyped stray word.
|
|
17
|
+
const eventName = typeof flags.event === "string" ? flags.event : typeof flags._ === "string" ? flags._ : null;
|
|
18
|
+
const endpoint = typeof flags.endpoint === "string" ? flags.endpoint : null;
|
|
19
|
+
if (!eventName || !endpoint)
|
|
20
|
+
throw new Error(USAGE);
|
|
21
|
+
const data = parseDataFlag(flags.data);
|
|
22
|
+
const res = await callV1("POST", `/webhooks/${encodeURIComponent(endpoint)}/test-fire`, data ? { event: eventName, data } : { event: eventName });
|
|
23
|
+
process.stdout.write(JSON.stringify(res, null, 2) + "\n");
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* `--data` is merged over the synthetic `data` object server-side, so it has
|
|
27
|
+
* to be a JSON object: the route validates it as a record and would answer an
|
|
28
|
+
* array or a scalar with a 422 after the round trip. Refused here instead.
|
|
29
|
+
*/
|
|
30
|
+
function parseDataFlag(v) {
|
|
31
|
+
if (v === undefined)
|
|
32
|
+
return undefined;
|
|
33
|
+
if (typeof v !== "string") {
|
|
34
|
+
throw new Error(`--data needs a JSON object, e.g. --data '{"channel":"sms"}'`);
|
|
35
|
+
}
|
|
36
|
+
let parsed;
|
|
37
|
+
try {
|
|
38
|
+
parsed = JSON.parse(v);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
throw new Error(`--data must be JSON (got ${JSON.stringify(v)})`);
|
|
42
|
+
}
|
|
43
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
44
|
+
throw new Error(`--data must be a JSON object (got ${JSON.stringify(v)})`);
|
|
45
|
+
}
|
|
46
|
+
return parsed;
|
|
47
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { sendEmail, sendSms } from "./send.js";
|
|
2
|
+
import { logsTail } from "./logs.js";
|
|
3
|
+
import { listen } from "./listen.js";
|
|
4
|
+
import { eventsTrigger } from "./events.js";
|
|
5
|
+
const TABLE = {
|
|
6
|
+
send: {
|
|
7
|
+
email: sendEmail,
|
|
8
|
+
sms: sendSms,
|
|
9
|
+
},
|
|
10
|
+
logs: {
|
|
11
|
+
tail: logsTail,
|
|
12
|
+
},
|
|
13
|
+
listen: {
|
|
14
|
+
"": listen,
|
|
15
|
+
},
|
|
16
|
+
events: {
|
|
17
|
+
trigger: eventsTrigger,
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
export function resolveCommand(group, action) {
|
|
21
|
+
const grp = TABLE[group];
|
|
22
|
+
if (!grp)
|
|
23
|
+
return null;
|
|
24
|
+
// A flag where the action would be means the group itself is the command —
|
|
25
|
+
// `sendoka listen --forward-to <url>`. Treating `--forward-to` as an action
|
|
26
|
+
// resolved nothing and made the only group-only command unreachable.
|
|
27
|
+
if (!action || action.startsWith("-"))
|
|
28
|
+
return grp[""] ?? null;
|
|
29
|
+
return grp[action] ?? null;
|
|
30
|
+
}
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import { createHmac } from "node:crypto";
|
|
2
|
+
import { ApiError, callV1, isRateLimited } from "../client.js";
|
|
3
|
+
const PAGE_SIZE = 50;
|
|
4
|
+
/** Pages walked per poll before giving up on reaching already-seen rows. */
|
|
5
|
+
const MAX_PAGES = 10;
|
|
6
|
+
/**
|
|
7
|
+
* Upper bound on remembered delivery ids. The list is newest-first and a poll
|
|
8
|
+
* stops at the first page holding a remembered id, so only the newest ids ever
|
|
9
|
+
* matter; dropping the oldest keeps a day-long session from growing without
|
|
10
|
+
* bound.
|
|
11
|
+
*/
|
|
12
|
+
const SEEN_CAP = 5000;
|
|
13
|
+
/**
|
|
14
|
+
* Polls a delivery's payload may fail to load (5xx, network) before it is
|
|
15
|
+
* skipped. A 429 is not a failure and never counts: the row is still there,
|
|
16
|
+
* the org's budget is not.
|
|
17
|
+
*/
|
|
18
|
+
const MAX_FETCH_ATTEMPTS = 3;
|
|
19
|
+
/** Pause after a 429 that carried no usable `Retry-After`. */
|
|
20
|
+
const DEFAULT_BACKOFF_MS = 30_000;
|
|
21
|
+
/**
|
|
22
|
+
* How far behind the newest delivery a poll has read the floor is kept. A
|
|
23
|
+
* row's `created_at` is stamped when it is inserted, and one can become
|
|
24
|
+
* visible a moment after a newer one; rows inside this margin are still
|
|
25
|
+
* walked, and the seen-set keeps them from being forwarded twice.
|
|
26
|
+
*/
|
|
27
|
+
const LATE_COMMIT_SLACK_MS = 60_000;
|
|
28
|
+
const USAGE = "Usage: sendoka listen --endpoint <whk_id> --forward-to <url> [--secret <whsec_...>] [--interval 5]";
|
|
29
|
+
/**
|
|
30
|
+
* The request `attemptDelivery` (src/lib/api/webhook-fanout.ts) would have
|
|
31
|
+
* sent, rebuilt from a stored delivery. Byte-for-byte the same shape: the body
|
|
32
|
+
* is the stored payload with `delivery_id` stamped on, and the three signature
|
|
33
|
+
* headers are computed exactly as the server computes them —
|
|
34
|
+
*
|
|
35
|
+
* X-Sendoka-Signature HMAC-SHA256(secret, body) legacy
|
|
36
|
+
* X-Sendoka-Timestamp unix seconds
|
|
37
|
+
* X-Sendoka-Signature-V2 HMAC-SHA256(secret, `${timestamp}.${body}`)
|
|
38
|
+
*
|
|
39
|
+
* so a local handler calling the SDK's `verifyWebhookSignature` (or the
|
|
40
|
+
* hand-rolled check in docs/recipes/verify-webhooks.md) accepts it unchanged.
|
|
41
|
+
* The timestamp is the moment of forwarding, not of the original attempt, or
|
|
42
|
+
* the SDK's five-minute replay window would reject anything older.
|
|
43
|
+
*
|
|
44
|
+
* Without a secret the signature headers are omitted rather than faked: a
|
|
45
|
+
* handler that verifies then rejects the request, which is the honest outcome.
|
|
46
|
+
*/
|
|
47
|
+
export function buildForwardRequest(delivery, secret, nowMs = Date.now()) {
|
|
48
|
+
const payload = delivery.payload ?? {};
|
|
49
|
+
const body = JSON.stringify({ ...payload, delivery_id: delivery.id });
|
|
50
|
+
const timestamp = Math.floor(nowMs / 1000).toString();
|
|
51
|
+
const headers = {
|
|
52
|
+
"Content-Type": "application/json",
|
|
53
|
+
"X-Sendoka-Timestamp": timestamp,
|
|
54
|
+
"X-Sendoka-Event": delivery.event,
|
|
55
|
+
"X-Sendoka-Delivery-Id": delivery.id,
|
|
56
|
+
"X-Sendoka-Forwarded": "cli",
|
|
57
|
+
};
|
|
58
|
+
if (secret) {
|
|
59
|
+
headers["X-Sendoka-Signature"] = createHmac("sha256", secret).update(body).digest("hex");
|
|
60
|
+
headers["X-Sendoka-Signature-V2"] = createHmac("sha256", secret)
|
|
61
|
+
.update(`${timestamp}.${body}`)
|
|
62
|
+
.digest("hex");
|
|
63
|
+
}
|
|
64
|
+
if (typeof payload.environment === "string") {
|
|
65
|
+
headers["X-Sendoka-Environment"] = payload.environment;
|
|
66
|
+
}
|
|
67
|
+
if (typeof payload.tenant_id === "string" && payload.tenant_id) {
|
|
68
|
+
headers["X-Sendoka-Tenant-Id"] = payload.tenant_id;
|
|
69
|
+
}
|
|
70
|
+
return { body, headers };
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The instant a `next_cursor` resumes below, as an ISO string, or null when it
|
|
74
|
+
* cannot be read.
|
|
75
|
+
*
|
|
76
|
+
* Cursors are opaque to API clients, but this CLI ships from the same
|
|
77
|
+
* repository as the API: a cursor is base64url(`{ISO}|{id}`)
|
|
78
|
+
* (src/lib/api/cursor.ts). It is read because of a test key's list: a page
|
|
79
|
+
* stops after reading a bounded slice of the endpoint (mostly live rows it may
|
|
80
|
+
* not see), so it can come back short or EMPTY with `has_more`, and then only
|
|
81
|
+
* its cursor says how far back it read. Anything unreadable answers null and
|
|
82
|
+
* is treated as unknown, never as a time.
|
|
83
|
+
*/
|
|
84
|
+
export function cursorTime(cursor) {
|
|
85
|
+
if (!cursor)
|
|
86
|
+
return null;
|
|
87
|
+
try {
|
|
88
|
+
const [iso, id] = Buffer.from(cursor, "base64url").toString("utf8").split("|");
|
|
89
|
+
if (!iso || !id)
|
|
90
|
+
return null;
|
|
91
|
+
const ms = Date.parse(iso);
|
|
92
|
+
return Number.isFinite(ms) && /^\d{4}-\d{2}-\d{2}T/.test(iso) ? new Date(ms).toISOString() : null;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The polling state machine behind `sendoka listen`, separated from the
|
|
100
|
+
* infinite loop so it can be driven one step at a time.
|
|
101
|
+
*
|
|
102
|
+
* `prime()` records what already exists and forwards none of it — the command
|
|
103
|
+
* starts from now. The old version seeded an empty seen-set, so its first poll
|
|
104
|
+
* re-POSTed the twenty most recent deliveries, whatever their age, and every
|
|
105
|
+
* restart replayed them again. What it records is a FLOOR: the newest
|
|
106
|
+
* delivery the key can see, everything older than which is history. A test
|
|
107
|
+
* key's page can be empty with more to follow (it read a slice of the
|
|
108
|
+
* endpoint's live traffic and found none of its own), so prime follows the
|
|
109
|
+
* list until it has one — from the first row it finds, or, for a page that
|
|
110
|
+
* found none, from the page's cursor, below which everything it did not see
|
|
111
|
+
* lies — and failing both, starts from the current time.
|
|
112
|
+
*
|
|
113
|
+
* `poll()` walks the newest-first list until it reaches a delivery it has
|
|
114
|
+
* already seen or one older than the floor — or a cursor that resumes below
|
|
115
|
+
* the floor, which is how a walk through empty pages ends — then forwards
|
|
116
|
+
* everything new oldest-first. Within that last page it keeps scanning rather
|
|
117
|
+
* than stopping at the first known row, so a row that committed slightly out
|
|
118
|
+
* of `created_at` order is still caught. A walk that got to the end raises the
|
|
119
|
+
* floor to a minute behind the newest point it read (never past a delivery
|
|
120
|
+
* still waiting to be forwarded), so the next one does not re-read the
|
|
121
|
+
* endpoint's traffic since startup.
|
|
122
|
+
*
|
|
123
|
+
* Every request here counts against the org-wide `/v1` rate limit — the same
|
|
124
|
+
* budget the org's production sends draw on (60/min on Free), test keys
|
|
125
|
+
* included. So the list is read with `include=payload`, one request per page
|
|
126
|
+
* rather than one more per delivery, and a 429 pauses the loop for the
|
|
127
|
+
* server's `Retry-After` instead of pressing on. A delivery is only
|
|
128
|
+
* remembered once its payload is in hand (or the server says it is gone), so
|
|
129
|
+
* one that could not be loaded is still new on the next poll; the ones behind
|
|
130
|
+
* it wait too, which keeps them in order. Failing to reach `--forward-to` is
|
|
131
|
+
* different: that is reported once and not retried.
|
|
132
|
+
*/
|
|
133
|
+
export function createForwarder(opts) {
|
|
134
|
+
const log = opts.log ?? ((l) => console.log(l));
|
|
135
|
+
const warn = opts.warn ?? ((l) => console.error(l));
|
|
136
|
+
const now = opts.now ?? Date.now;
|
|
137
|
+
const base = `/webhooks/${encodeURIComponent(opts.endpoint)}/deliveries`;
|
|
138
|
+
const seen = new Set();
|
|
139
|
+
const failures = new Map();
|
|
140
|
+
let floor = null;
|
|
141
|
+
let backoffUntil = 0;
|
|
142
|
+
function remember(id) {
|
|
143
|
+
seen.add(id);
|
|
144
|
+
failures.delete(id);
|
|
145
|
+
if (seen.size > SEEN_CAP) {
|
|
146
|
+
const oldest = seen.values().next().value;
|
|
147
|
+
if (oldest !== undefined)
|
|
148
|
+
seen.delete(oldest);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function backOff(err, pending) {
|
|
152
|
+
const ms = err.retryAfterSeconds !== null ? err.retryAfterSeconds * 1000 : DEFAULT_BACKOFF_MS;
|
|
153
|
+
backoffUntil = Math.max(backoffUntil, now() + ms);
|
|
154
|
+
warn(`Rate limited (429) — this org's /v1 budget is spent. Pausing ${Math.ceil(ms / 1000)}s` +
|
|
155
|
+
(pending > 0 ? `; ${pending} ${pending === 1 ? "delivery waits" : "deliveries wait"} for the next poll.` : "."));
|
|
156
|
+
}
|
|
157
|
+
function listPath(cursor) {
|
|
158
|
+
return `${base}?limit=${PAGE_SIZE}&include=payload${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`;
|
|
159
|
+
}
|
|
160
|
+
async function prime() {
|
|
161
|
+
let cursor = null;
|
|
162
|
+
for (let pages = 0; pages < MAX_PAGES; pages++) {
|
|
163
|
+
// Ids only: nothing here is forwarded, so its payloads are not asked for.
|
|
164
|
+
const page = await callV1("GET", `${base}?limit=${PAGE_SIZE}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`);
|
|
165
|
+
if (page.data.length > 0) {
|
|
166
|
+
// Newest first, so the first row found is the newest the key can see.
|
|
167
|
+
// Oldest first into the set, so the newest ids are the last evicted.
|
|
168
|
+
for (const row of [...page.data].reverse())
|
|
169
|
+
remember(row.id);
|
|
170
|
+
floor = page.data[0].created_at;
|
|
171
|
+
return page.data.length;
|
|
172
|
+
}
|
|
173
|
+
// Nothing visible, and nothing more to read: there is no history to
|
|
174
|
+
// skip, and every delivery from here on is new.
|
|
175
|
+
if (!page.has_more || !page.next_cursor)
|
|
176
|
+
return 0;
|
|
177
|
+
// An empty page with more to follow read everything newer than its
|
|
178
|
+
// cursor and found nothing this key may see, so anything older is all
|
|
179
|
+
// there is. That is the floor.
|
|
180
|
+
const below = cursorTime(page.next_cursor);
|
|
181
|
+
if (below) {
|
|
182
|
+
floor = below;
|
|
183
|
+
return 0;
|
|
184
|
+
}
|
|
185
|
+
cursor = page.next_cursor;
|
|
186
|
+
}
|
|
187
|
+
// A server whose cursor this cannot read, and pages of nothing: start
|
|
188
|
+
// from now rather than forward whatever lies further back.
|
|
189
|
+
floor = new Date(now()).toISOString();
|
|
190
|
+
return 0;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* How long to wait before the next poll: the interval, or longer while a
|
|
194
|
+
* 429's `Retry-After` is still running.
|
|
195
|
+
*/
|
|
196
|
+
function delayBeforeNextPoll(intervalMs) {
|
|
197
|
+
return Math.max(intervalMs, backoffUntil - now());
|
|
198
|
+
}
|
|
199
|
+
async function poll() {
|
|
200
|
+
const fresh = [];
|
|
201
|
+
let cursor = null;
|
|
202
|
+
// The newest point this walk read: its newest row, or where an empty
|
|
203
|
+
// first page's slice ended. Null until known.
|
|
204
|
+
let newest = null;
|
|
205
|
+
// Whether the walk reached what was already known (or the list's end).
|
|
206
|
+
let complete = false;
|
|
207
|
+
for (let pages = 0;; pages++) {
|
|
208
|
+
if (pages === MAX_PAGES) {
|
|
209
|
+
// Only what is true: pages of a test key's list can be short or empty,
|
|
210
|
+
// so a page count says nothing about how many deliveries were new.
|
|
211
|
+
warn(`Read ${MAX_PAGES} pages without reaching deliveries already seen; forwarding the ${fresh.length} new ${fresh.length === 1 ? "one" : "ones"} found, and skipping anything older.`);
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
let page;
|
|
215
|
+
try {
|
|
216
|
+
page = await callV1("GET", listPath(cursor));
|
|
217
|
+
}
|
|
218
|
+
catch (err) {
|
|
219
|
+
if (!isRateLimited(err))
|
|
220
|
+
throw err;
|
|
221
|
+
// Nothing is remembered yet, so the whole walk simply happens again.
|
|
222
|
+
backOff(err, 0);
|
|
223
|
+
return 0;
|
|
224
|
+
}
|
|
225
|
+
let reachedKnown = false;
|
|
226
|
+
for (const row of page.data) {
|
|
227
|
+
if (newest === null || row.created_at > newest)
|
|
228
|
+
newest = row.created_at;
|
|
229
|
+
if (seen.has(row.id) || (floor !== null && row.created_at < floor)) {
|
|
230
|
+
reachedKnown = true;
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
fresh.push(row);
|
|
234
|
+
}
|
|
235
|
+
const next = page.has_more ? page.next_cursor : null;
|
|
236
|
+
const below = cursorTime(next);
|
|
237
|
+
if (pages === 0 && page.data.length === 0 && below)
|
|
238
|
+
newest = below;
|
|
239
|
+
// Past the floor, everything is history: the end of a walk through
|
|
240
|
+
// empty pages, which have no row to compare.
|
|
241
|
+
if (reachedKnown || !next || (floor !== null && below !== null && below < floor)) {
|
|
242
|
+
complete = true;
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
cursor = next;
|
|
246
|
+
}
|
|
247
|
+
fresh.sort((a, b) => a.created_at === b.created_at ? a.id.localeCompare(b.id) : a.created_at.localeCompare(b.created_at));
|
|
248
|
+
let forwarded = 0;
|
|
249
|
+
let waiting = null;
|
|
250
|
+
for (const [i, row] of fresh.entries()) {
|
|
251
|
+
const detail = await load(row, fresh.length - i);
|
|
252
|
+
if (detail === "stop") {
|
|
253
|
+
// This row and every newer one wait for the next poll.
|
|
254
|
+
waiting = row.created_at;
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
// Remembered once the payload is in hand, before the local POST: a
|
|
258
|
+
// handler that is down is reported once rather than retried every
|
|
259
|
+
// interval.
|
|
260
|
+
remember(row.id);
|
|
261
|
+
if (detail === "skip")
|
|
262
|
+
continue;
|
|
263
|
+
await forward(row, detail);
|
|
264
|
+
forwarded++;
|
|
265
|
+
}
|
|
266
|
+
if (complete)
|
|
267
|
+
raiseFloor(newest, waiting);
|
|
268
|
+
return forwarded;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* After a complete walk: move the floor up to LATE_COMMIT_SLACK_MS behind
|
|
272
|
+
* the newest point read, but never past a delivery still waiting, and never
|
|
273
|
+
* down.
|
|
274
|
+
*/
|
|
275
|
+
function raiseFloor(newest, waiting) {
|
|
276
|
+
if (newest === null)
|
|
277
|
+
return;
|
|
278
|
+
let candidate = new Date(Date.parse(newest) - LATE_COMMIT_SLACK_MS).toISOString();
|
|
279
|
+
if (waiting !== null && waiting < candidate)
|
|
280
|
+
candidate = waiting;
|
|
281
|
+
if (floor === null || candidate > floor)
|
|
282
|
+
floor = candidate;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* The payload for one row: from the list when the server sent it, else the
|
|
286
|
+
* drill-down. "stop" ends this poll with the row (and every newer one)
|
|
287
|
+
* unremembered, so the next poll finds them again; "skip" drops the row.
|
|
288
|
+
*/
|
|
289
|
+
async function load(row, pending) {
|
|
290
|
+
if (row.payload !== undefined) {
|
|
291
|
+
return { id: row.id, event: row.event, payload: row.payload, created_at: row.created_at };
|
|
292
|
+
}
|
|
293
|
+
try {
|
|
294
|
+
return await callV1("GET", `${base}/${encodeURIComponent(row.id)}`);
|
|
295
|
+
}
|
|
296
|
+
catch (err) {
|
|
297
|
+
if (isRateLimited(err)) {
|
|
298
|
+
backOff(err, pending);
|
|
299
|
+
return "stop";
|
|
300
|
+
}
|
|
301
|
+
if (err instanceof ApiError && err.status === 404) {
|
|
302
|
+
// Deleted with its endpoint, or no longer in scope: it will not come back.
|
|
303
|
+
warn(`[${row.id}] no longer exists; skipped.`);
|
|
304
|
+
return "skip";
|
|
305
|
+
}
|
|
306
|
+
const attempts = (failures.get(row.id) ?? 0) + 1;
|
|
307
|
+
failures.set(row.id, attempts);
|
|
308
|
+
if (attempts >= MAX_FETCH_ATTEMPTS) {
|
|
309
|
+
warn(`[${row.id}] could not fetch payload after ${attempts} polls; skipped: ${err.message}`);
|
|
310
|
+
return "skip";
|
|
311
|
+
}
|
|
312
|
+
warn(`[${row.id}] could not fetch payload (retrying next poll): ${err.message}`);
|
|
313
|
+
return "stop";
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
async function forward(row, detail) {
|
|
317
|
+
const { body, headers } = buildForwardRequest(detail, opts.secret);
|
|
318
|
+
try {
|
|
319
|
+
const res = await fetch(opts.forwardTo, { method: "POST", headers, body });
|
|
320
|
+
log(`[${row.created_at}] ${row.event.padEnd(22)} ${row.id} → ${res.status}`);
|
|
321
|
+
}
|
|
322
|
+
catch (err) {
|
|
323
|
+
warn(`[${row.id}] forward failed: ${err.message}`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return { prime, poll, delayBeforeNextPoll };
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* `sendoka listen` — forward one endpoint's webhook deliveries to a local URL,
|
|
330
|
+
* re-signed with the endpoint's secret, so a local handler receives real
|
|
331
|
+
* events, signature check included, without a public tunnel.
|
|
332
|
+
*
|
|
333
|
+
* Reads `GET /api/v1/webhooks/{id}/deliveries` (`read:webhooks`) with the API
|
|
334
|
+
* key, one request per page per interval — against the same org-wide `/v1`
|
|
335
|
+
* rate limit as production traffic, which is why `--interval` exists and a 429
|
|
336
|
+
* pauses the loop. It used to read `/api/internal/webhook-deliveries` with a session cookie
|
|
337
|
+
* under the unprefixed NextAuth name, which the https host never reads — a 401
|
|
338
|
+
* on every poll against www.sendoka.com.
|
|
339
|
+
*/
|
|
340
|
+
export async function listen(flags) {
|
|
341
|
+
const endpoint = typeof flags.endpoint === "string" ? flags.endpoint : null;
|
|
342
|
+
const forwardTo = typeof flags["forward-to"] === "string" ? flags["forward-to"] : null;
|
|
343
|
+
if (!endpoint || !forwardTo)
|
|
344
|
+
throw new Error(USAGE);
|
|
345
|
+
// `new URL("localhost:3001")` parses — as scheme `localhost:` — so the
|
|
346
|
+
// protocol is checked, not just that it parses.
|
|
347
|
+
let protocol = null;
|
|
348
|
+
try {
|
|
349
|
+
protocol = new URL(forwardTo).protocol;
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
352
|
+
// reported below
|
|
353
|
+
}
|
|
354
|
+
if (protocol !== "http:" && protocol !== "https:") {
|
|
355
|
+
throw new Error(`--forward-to must be an absolute http(s) URL, e.g. http://localhost:3001/hooks (got ${JSON.stringify(forwardTo)})`);
|
|
356
|
+
}
|
|
357
|
+
const intervalSeconds = typeof flags.interval === "string" ? Number(flags.interval) : 5;
|
|
358
|
+
if (!Number.isFinite(intervalSeconds) || intervalSeconds <= 0) {
|
|
359
|
+
throw new Error("--interval must be a positive number of seconds");
|
|
360
|
+
}
|
|
361
|
+
const secret = typeof flags.secret === "string" ? flags.secret : process.env.SENDOKA_WEBHOOK_SECRET || null;
|
|
362
|
+
const forwarder = createForwarder({ endpoint, forwardTo, secret });
|
|
363
|
+
// Outside the loop on purpose: a bad key, a missing scope or an unknown
|
|
364
|
+
// endpoint is a setup error and exits, rather than printing forever.
|
|
365
|
+
await forwarder.prime();
|
|
366
|
+
if (!secret) {
|
|
367
|
+
console.error("No --secret or SENDOKA_WEBHOOK_SECRET: forwarding UNSIGNED. A handler that verifies signatures will reject these.");
|
|
368
|
+
}
|
|
369
|
+
console.log(`Forwarding new deliveries for ${endpoint} → ${forwardTo} every ${intervalSeconds}s. Ctrl+C to stop.`);
|
|
370
|
+
for (;;) {
|
|
371
|
+
await new Promise((r) => setTimeout(r, forwarder.delayBeforeNextPoll(intervalSeconds * 1000)));
|
|
372
|
+
try {
|
|
373
|
+
await forwarder.poll();
|
|
374
|
+
}
|
|
375
|
+
catch (err) {
|
|
376
|
+
console.error(err.message);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { callV1, isRateLimited } from "../client.js";
|
|
2
|
+
const PAGE_SIZE = 100;
|
|
3
|
+
const MAX_PAGES = 10;
|
|
4
|
+
/**
|
|
5
|
+
* How far back each poll re-reads behind the newest row it has printed.
|
|
6
|
+
* `created_at` is stamped at insert, not at commit, so a send that commits a
|
|
7
|
+
* moment after a later one lands *behind* the watermark; without the overlap
|
|
8
|
+
* it would never be printed. Duplicates from the overlap are dropped by id.
|
|
9
|
+
*/
|
|
10
|
+
const OVERLAP_MS = 5_000;
|
|
11
|
+
const PATHS = { email: "/emails", sms: "/sms" };
|
|
12
|
+
const USAGE = "Usage: sendoka logs tail [--channel email|sms] [--interval 5]";
|
|
13
|
+
export function formatMessage(row) {
|
|
14
|
+
const route = `${row.from ?? "?"} → ${row.to ?? "?"}`;
|
|
15
|
+
const subject = row.channel === "email" && row.subject ? ` ${JSON.stringify(row.subject)}` : "";
|
|
16
|
+
return `[${row.created_at}] ${row.channel.padEnd(5)} ${row.status.padEnd(10)} ${row.id} ${route}${subject}`;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* One channel's tail: the list endpoint's `created_after` filter from a
|
|
20
|
+
* watermark taken off the server's own clock, so client clock skew cannot drop
|
|
21
|
+
* or replay messages. `prime()` records what already exists and prints
|
|
22
|
+
* nothing — the tail starts from now.
|
|
23
|
+
*/
|
|
24
|
+
export function createTail(channel, print, warn = (line) => console.error(line)) {
|
|
25
|
+
const path = PATHS[channel];
|
|
26
|
+
// id → created_at of every row inside the overlap window, printed or not.
|
|
27
|
+
const seen = new Map();
|
|
28
|
+
let watermark = null;
|
|
29
|
+
function overlapStart(at, factor = 1) {
|
|
30
|
+
return new Date(new Date(at).getTime() - factor * OVERLAP_MS).toISOString();
|
|
31
|
+
}
|
|
32
|
+
/** Every row created at or after `after` (all rows when null), newest first. */
|
|
33
|
+
async function fetchSince(after) {
|
|
34
|
+
const rows = [];
|
|
35
|
+
let cursor = null;
|
|
36
|
+
for (let pages = 0; pages < MAX_PAGES; pages++) {
|
|
37
|
+
const qs = new URLSearchParams({ limit: String(PAGE_SIZE) });
|
|
38
|
+
if (after)
|
|
39
|
+
qs.set("created_after", after);
|
|
40
|
+
if (cursor)
|
|
41
|
+
qs.set("cursor", cursor);
|
|
42
|
+
const page = await callV1("GET", `${path}?${qs}`);
|
|
43
|
+
rows.push(...page.data.map((r) => ({ ...r, channel })));
|
|
44
|
+
if (!page.has_more || !page.next_cursor)
|
|
45
|
+
return { rows, truncated: false };
|
|
46
|
+
cursor = page.next_cursor;
|
|
47
|
+
}
|
|
48
|
+
return { rows, truncated: true };
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The watermark and the seen-set come from ONE read. The first page, with
|
|
52
|
+
* no filter, fixes the watermark; any further pages follow its keyset
|
|
53
|
+
* cursor, which only ever returns rows older than that page's last. So a
|
|
54
|
+
* message created while the tail is starting cannot be marked seen here and
|
|
55
|
+
* then never printed — it used to be, when a `limit=1` read set the
|
|
56
|
+
* watermark and a second, later `created_after` read filled the seen-set.
|
|
57
|
+
*/
|
|
58
|
+
async function prime() {
|
|
59
|
+
let cursor = null;
|
|
60
|
+
for (let pages = 0; pages < MAX_PAGES; pages++) {
|
|
61
|
+
const qs = new URLSearchParams({ limit: String(PAGE_SIZE) });
|
|
62
|
+
if (cursor)
|
|
63
|
+
qs.set("cursor", cursor);
|
|
64
|
+
const page = await callV1("GET", `${path}?${qs}`);
|
|
65
|
+
if (watermark === null) {
|
|
66
|
+
const newest = page.data[0];
|
|
67
|
+
if (!newest)
|
|
68
|
+
return;
|
|
69
|
+
watermark = newest.created_at;
|
|
70
|
+
}
|
|
71
|
+
// The first poll re-reads the overlap behind the watermark, so every row
|
|
72
|
+
// already in it is history too — not only the single newest one.
|
|
73
|
+
const from = overlapStart(watermark);
|
|
74
|
+
for (const row of page.data)
|
|
75
|
+
if (row.created_at >= from)
|
|
76
|
+
seen.set(row.id, row.created_at);
|
|
77
|
+
const last = page.data[page.data.length - 1];
|
|
78
|
+
if (!last || last.created_at < from || !page.has_more || !page.next_cursor)
|
|
79
|
+
return;
|
|
80
|
+
cursor = page.next_cursor;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
async function poll() {
|
|
84
|
+
const { rows, truncated } = await fetchSince(watermark ? overlapStart(watermark) : null);
|
|
85
|
+
const fresh = rows.filter((row) => !seen.has(row.id));
|
|
86
|
+
if (truncated) {
|
|
87
|
+
warn(`More than ${MAX_PAGES * PAGE_SIZE} new ${channel} messages since the last poll; printing the newest ${fresh.length}.`);
|
|
88
|
+
}
|
|
89
|
+
fresh.sort((a, b) => a.created_at === b.created_at ? a.id.localeCompare(b.id) : a.created_at.localeCompare(b.created_at));
|
|
90
|
+
for (const row of fresh) {
|
|
91
|
+
seen.set(row.id, row.created_at);
|
|
92
|
+
if (!watermark || row.created_at > watermark)
|
|
93
|
+
watermark = row.created_at;
|
|
94
|
+
print(formatMessage(row));
|
|
95
|
+
}
|
|
96
|
+
// Only ids inside the next poll's overlap window can come back.
|
|
97
|
+
if (watermark) {
|
|
98
|
+
const keepFrom = overlapStart(watermark, 2);
|
|
99
|
+
for (const [id, at] of seen)
|
|
100
|
+
if (at < keepFrom)
|
|
101
|
+
seen.delete(id);
|
|
102
|
+
}
|
|
103
|
+
return fresh.length;
|
|
104
|
+
}
|
|
105
|
+
return { prime, poll };
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* `sendoka logs tail` — print each message as it is created.
|
|
109
|
+
*
|
|
110
|
+
* Polls `GET /api/v1/emails` and `GET /api/v1/sms` (`read:messages`) with
|
|
111
|
+
* `created_after`. It used to poll `/api/v1/activity` and print daily
|
|
112
|
+
* aggregate rows — one line per (date, channel, status), reprinted whenever a
|
|
113
|
+
* count moved — which answered "how many" but never "what just happened".
|
|
114
|
+
* Status changes after a message is first printed are not re-printed; that is
|
|
115
|
+
* what webhooks and `sendoka listen` are for.
|
|
116
|
+
*
|
|
117
|
+
* One request per channel per interval (24/min at the defaults), all counted
|
|
118
|
+
* against the org-wide `/v1` rate limit that production sends also draw on.
|
|
119
|
+
*/
|
|
120
|
+
export async function logsTail(flags) {
|
|
121
|
+
const intervalSeconds = typeof flags.interval === "string" ? Number(flags.interval) : 5;
|
|
122
|
+
if (!Number.isFinite(intervalSeconds) || intervalSeconds <= 0) {
|
|
123
|
+
throw new Error("--interval must be a positive number of seconds");
|
|
124
|
+
}
|
|
125
|
+
let channels = ["email", "sms"];
|
|
126
|
+
if (flags.channel !== undefined) {
|
|
127
|
+
if (flags.channel !== "email" && flags.channel !== "sms")
|
|
128
|
+
throw new Error(USAGE);
|
|
129
|
+
channels = [flags.channel];
|
|
130
|
+
}
|
|
131
|
+
if (flags.days !== undefined) {
|
|
132
|
+
console.error("--days is no longer used: logs tail now prints individual messages as they are created, starting now.");
|
|
133
|
+
}
|
|
134
|
+
const tails = channels.map((c) => createTail(c, (line) => console.log(line)));
|
|
135
|
+
// A bad key or a missing read:messages scope is a setup error: exit on it
|
|
136
|
+
// rather than printing the same 403 every interval.
|
|
137
|
+
await Promise.all(tails.map((t) => t.prime()));
|
|
138
|
+
console.log(`Tailing new ${channels.join(" + ")} messages. Ctrl+C to stop.`);
|
|
139
|
+
let pauseMs = 0;
|
|
140
|
+
for (;;) {
|
|
141
|
+
await new Promise((r) => setTimeout(r, Math.max(intervalSeconds * 1000, pauseMs)));
|
|
142
|
+
pauseMs = 0;
|
|
143
|
+
for (const tail of tails) {
|
|
144
|
+
try {
|
|
145
|
+
await tail.poll();
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
console.error(err.message);
|
|
149
|
+
// Each poll spends the org-wide /v1 budget production sends share; on
|
|
150
|
+
// a 429, wait out the server's Retry-After rather than the interval.
|
|
151
|
+
if (isRateLimited(err)) {
|
|
152
|
+
pauseMs = Math.max(pauseMs, (err.retryAfterSeconds ?? 30) * 1000);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { callV1 } from "../client.js";
|
|
2
|
+
function str(flags, key) {
|
|
3
|
+
const v = flags[key];
|
|
4
|
+
return typeof v === "string" ? v : undefined;
|
|
5
|
+
}
|
|
6
|
+
export async function sendEmail(flags) {
|
|
7
|
+
const from = str(flags, "from");
|
|
8
|
+
const to = str(flags, "to");
|
|
9
|
+
if (!from || !to)
|
|
10
|
+
throw new Error("--from and --to are required");
|
|
11
|
+
const res = await callV1("POST", "/emails", {
|
|
12
|
+
from,
|
|
13
|
+
to: to.split(",").map((s) => s.trim()),
|
|
14
|
+
subject: str(flags, "subject"),
|
|
15
|
+
html: str(flags, "html"),
|
|
16
|
+
text: str(flags, "text"),
|
|
17
|
+
template: str(flags, "template"),
|
|
18
|
+
variables: parseJson(str(flags, "variables")),
|
|
19
|
+
});
|
|
20
|
+
process.stdout.write(JSON.stringify(res, null, 2) + "\n");
|
|
21
|
+
}
|
|
22
|
+
export async function sendSms(flags) {
|
|
23
|
+
const from = str(flags, "from");
|
|
24
|
+
const to = str(flags, "to");
|
|
25
|
+
const body = str(flags, "body");
|
|
26
|
+
if (!to || !body)
|
|
27
|
+
throw new Error("--to and --body are required");
|
|
28
|
+
const res = await callV1("POST", "/sms", {
|
|
29
|
+
from,
|
|
30
|
+
to,
|
|
31
|
+
body,
|
|
32
|
+
template: str(flags, "template"),
|
|
33
|
+
variables: parseJson(str(flags, "variables")),
|
|
34
|
+
});
|
|
35
|
+
process.stdout.write(JSON.stringify(res, null, 2) + "\n");
|
|
36
|
+
}
|
|
37
|
+
function parseJson(v) {
|
|
38
|
+
if (!v)
|
|
39
|
+
return undefined;
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(v);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
throw new Error(`--variables must be JSON (got ${JSON.stringify(v)})`);
|
|
45
|
+
}
|
|
46
|
+
}
|
package/dist/flags.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Argv → flag bag for everything after `<group> <action>`.
|
|
3
|
+
*
|
|
4
|
+
* It used to skip anything not starting with `--` outright, which silently
|
|
5
|
+
* threw away every positional argument. Only one command takes one — and it is
|
|
6
|
+
* the shape the CLI's own usage text advertises:
|
|
7
|
+
*
|
|
8
|
+
* sendoka events trigger message.bounced --endpoint whk_01HN...
|
|
9
|
+
*
|
|
10
|
+
* `message.bounced` was dropped on the floor, `eventsTrigger` found no event
|
|
11
|
+
* name in the bag, and the command's only outcome was the usage error telling
|
|
12
|
+
* you to run exactly what you had just run. The documented invocation could
|
|
13
|
+
* never succeed.
|
|
14
|
+
*
|
|
15
|
+
* The first positional now lands under `_`, which is where `eventsTrigger` was
|
|
16
|
+
* already looking for it. No command takes a second one, so later positionals
|
|
17
|
+
* are collected under `_1`, `_2`, … rather than overwriting `_` — a mistyped
|
|
18
|
+
* extra word must not silently change which event is fired.
|
|
19
|
+
*/
|
|
20
|
+
export function parseFlags(args) {
|
|
21
|
+
const out = {};
|
|
22
|
+
let positional = 0;
|
|
23
|
+
for (let i = 0; i < args.length; i++) {
|
|
24
|
+
const a = args[i];
|
|
25
|
+
if (!a.startsWith("--")) {
|
|
26
|
+
out[positional === 0 ? "_" : `_${positional}`] = a;
|
|
27
|
+
positional++;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const key = a.slice(2);
|
|
31
|
+
const next = args[i + 1];
|
|
32
|
+
if (!next || next.startsWith("--")) {
|
|
33
|
+
out[key] = true;
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
out[key] = next;
|
|
37
|
+
i++;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Splits everything after the node binary and the script — `<group> [action]
|
|
44
|
+
* [...args]` — into the three pieces `main` dispatches on.
|
|
45
|
+
*
|
|
46
|
+
* The second token counts as an action only when it does not look like a flag.
|
|
47
|
+
* `sendoka listen --forward-to http://localhost:3001/hooks` used to be read as
|
|
48
|
+
* group `listen`, action `--forward-to`: `listen` is the one group-only
|
|
49
|
+
* command (keyed `""` in the table), no `--forward-to` entry exists, so the
|
|
50
|
+
* CLI printed `Unknown command: listen --forward-to` and exited 2 — while the
|
|
51
|
+
* URL that followed was swallowed as the action's argument. Bare `sendoka
|
|
52
|
+
* listen` then failed its own `--forward-to <url> is required` check, so the
|
|
53
|
+
* command was unusable in every shape the docs advertise.
|
|
54
|
+
*/
|
|
55
|
+
export function splitInvocation(rest) {
|
|
56
|
+
const [group, ...tail] = rest;
|
|
57
|
+
const flagFirst = tail.length > 0 && tail[0].startsWith("-");
|
|
58
|
+
return {
|
|
59
|
+
group,
|
|
60
|
+
action: flagFirst ? undefined : tail[0],
|
|
61
|
+
args: flagFirst ? tail : tail.slice(1),
|
|
62
|
+
};
|
|
63
|
+
}
|
package/dist/version.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
/**
|
|
3
|
+
* Read from package.json rather than restated, the way @sendoka/dev-mcp does:
|
|
4
|
+
* `--version` printed a hard-coded "0.1.0" that nothing kept in step with the
|
|
5
|
+
* published version. `../package.json` resolves from both `dist/version.js`
|
|
6
|
+
* and `src/version.ts`, and npm always ships package.json.
|
|
7
|
+
*/
|
|
8
|
+
export const CLI_VERSION = (() => {
|
|
9
|
+
try {
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
return require("../package.json").version ?? "0.0.0";
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return "0.0.0";
|
|
15
|
+
}
|
|
16
|
+
})();
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sendoka/cli",
|
|
3
|
+
"version": "0.2.2",
|
|
4
|
+
"description": "Sendoka command-line tools for developer workflows.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"sendoka": "dist/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"files": ["dist", "README.md"],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "tsc -p tsconfig.json",
|
|
13
|
+
"dev": "tsc -p tsconfig.json --watch",
|
|
14
|
+
"prepack": "npm run build"
|
|
15
|
+
},
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/fareed010/Sendoka.git",
|
|
22
|
+
"directory": "packages/cli"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/node": "^22.0.0",
|
|
27
|
+
"typescript": "^5.6.0"
|
|
28
|
+
},
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=20"
|
|
31
|
+
}
|
|
32
|
+
}
|