add tests -- which have worked on Trent's machine... SMS sent!
This commit is contained in:
+4
-2
@@ -1,5 +1,5 @@
|
||||
# HTTP port (default: 3003)
|
||||
PORT=3003
|
||||
# PORT=3003
|
||||
|
||||
# Endorser API base URL (default: https://api.endorser.ch)
|
||||
# ENDORSER_URL=https://api.endorser.ch
|
||||
@@ -37,8 +37,10 @@ PORT=3003
|
||||
# one of the two "from" values are all present.
|
||||
# TWILIO_ACCOUNT_SID=
|
||||
# TWILIO_AUTH_TOKEN=
|
||||
# TWILIO_FROM_NUMBER=+15550000000
|
||||
# Prefer the Messaging Service once an A2P 10DLC campaign is approved: the
|
||||
# campaign lives on it, and it wins when both are set.
|
||||
# TWILIO_MESSAGING_SERVICE_SID=
|
||||
# TWILIO_FROM_NUMBER=+15550000000
|
||||
|
||||
# The public URL Twilio posts /notify-sms/inbound to. Twilio signs that exact
|
||||
# string, and behind a proxy or tunnel the request headers do not reproduce it.
|
||||
|
||||
@@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Added
|
||||
- `/notify-sms` SMS channel: phone registration with a 6-digit possession check, a per-channel delegated-JWT inventory and cursor, a Twilio sender, an `STOP`/`START`/`HELP` webhook, and a daily digest text alongside the FCM one; off unless `SMS_ENABLED`
|
||||
- Every log line is prefixed with an ISO-8601 UTC timestamp (`src/util/log.ts`)
|
||||
- `pnpm run sms:send` and `pnpm run sms:smoke` exercise the Twilio path and the full `/notify-sms` route flow against a stub Endorser and unsigned test-local JWTs
|
||||
- `pnpm run twilio:whoami` reports which Twilio account a send would bill and distinguishes a mismatched credential pair from a valid test pair
|
||||
### Changed
|
||||
- `loadAlertSearchCursors`, `advanceAlertSearchCursors`, `runAlertSearchCycle`, and `runDailyAlertSearch` take a channel (`"fcm"` default), selecting the JWT inventory and cursor table
|
||||
|
||||
|
||||
@@ -242,6 +242,98 @@ fails and the other's succeeds. A shared cursor was rejected: two independent
|
||||
daily runs against one row means whichever fires first consumes the delta and
|
||||
the other reports nothing.
|
||||
|
||||
### Testing SMS locally
|
||||
|
||||
Two scripts cover the two things worth checking separately. Both default to
|
||||
fake data and neither needs a real handset, a purchased number, or 10DLC
|
||||
registration.
|
||||
|
||||
`pnpm run sms:send [to] [body]` makes one send and prints the result. No server,
|
||||
no database, no auth — just the Twilio path. The destination can also come from
|
||||
`SMS_SEND_TO`.
|
||||
|
||||
```bash
|
||||
TWILIO_ACCOUNT_SID=ACxxxx TWILIO_AUTH_TOKEN=xxxx \
|
||||
TWILIO_MESSAGING_SERVICE_SID=MGxxxx \
|
||||
pnpm run sms:send +15551234567 "test from my Mac"
|
||||
```
|
||||
|
||||
`pnpm run sms:smoke [to]` runs the whole route flow: POST, PUT with the echoed
|
||||
code, both GET forms, DELETE, then a dump of `sms_phone_log`. The destination
|
||||
can also come from `SMS_SMOKE_TO`. It stubs the two things
|
||||
that otherwise need the real world — it starts a throwaway Endorser that answers
|
||||
`/api/report/rateLimits` with `200`, and mints unsigned `did:ethr` JWTs, which
|
||||
`decodeAndVerifyJwt` accepts under `NODE_ENV=test-local` without checking a
|
||||
signature. The middleware chain, the claim check, the throttles and the database
|
||||
are all real. Each run gets a fresh `NOTIFY_DATA_DIR`, so the three-codes-per-
|
||||
hour throttle never interferes.
|
||||
|
||||
With no Twilio credentials set, sends go to the console adapter and nothing
|
||||
leaves the machine.
|
||||
|
||||
`pnpm run twilio:whoami` answers "whose account am I about to bill?" — it
|
||||
fetches the Account resource with the configured SID and token, which separates
|
||||
a mismatched credential pair from a working one before any message is involved.
|
||||
It sends nothing and costs nothing.
|
||||
|
||||
```bash
|
||||
TWILIO_ACCOUNT_SID=ACxxxx TWILIO_AUTH_TOKEN=xxxx pnpm run twilio:whoami
|
||||
```
|
||||
|
||||
`200` prints the account's friendly name, status and type, and that is the
|
||||
account a send would bill — note `type: Trial` can only reach verified numbers.
|
||||
`401` / `20003` means the SID and token are not a matching pair. `403` / `20008`
|
||||
("Resource not accessible with Test Account Credentials") means the pair is a
|
||||
valid **test** pair: test credentials may not read the Accounts resource, so
|
||||
that refusal is a pass, not a fault.
|
||||
|
||||
**Twilio test credentials** are the cheapest way to exercise the real API, and
|
||||
they behave the same whether or not a 10DLC campaign is approved — nothing they
|
||||
send reaches a carrier. They are a **separate Account SID and Auth Token** from
|
||||
the live pair, under Console → API keys & tokens → Test credentials; a live SID
|
||||
with a live token sends real, billable messages. They
|
||||
are a second Account SID / Auth Token pair in the Twilio console, separate from
|
||||
the live ones; they need a (free) account but no purchased number, they deliver
|
||||
no message, they trigger no status callbacks, and they cost nothing.
|
||||
|
||||
```bash
|
||||
TWILIO_ACCOUNT_SID=ACxxxxtest TWILIO_AUTH_TOKEN=xxxx \
|
||||
TWILIO_FROM_NUMBER=+15005550006 pnpm run sms:smoke +15551234567
|
||||
```
|
||||
|
||||
`+15005550006` is the only `From` that passes validation; every other number
|
||||
returns `21606`.
|
||||
|
||||
The `To` is validated even under test credentials, so a reserved fictional
|
||||
number such as `+15555550123` is rejected with `21211`. Both scripts refuse to
|
||||
run with that placeholder once Twilio is configured, rather than spending a
|
||||
round trip to learn it.
|
||||
|
||||
A **real** `To` under test credentials is less predictable: some accounts accept
|
||||
it and return a synthetic SID, others answer `20404`
|
||||
(`resource ... Messages.json was not found`) despite the credentials being
|
||||
valid. Treat the magic `To` numbers below as the dependable path for test
|
||||
credentials, and use live credentials when a text has to actually arrive.
|
||||
|
||||
These magic `To` numbers force specific failures, useful for exercising the
|
||||
`code-send-failed` path on purpose:
|
||||
|
||||
| `To` | Twilio error |
|
||||
|---|---|
|
||||
| `+15005550001` | `21211` invalid number |
|
||||
| `+15005550002` | `21612` cannot route |
|
||||
| `+15005550003` | `21408` no permission for that region |
|
||||
| `+15005550004` | `21610` blocklisted |
|
||||
| `+15005550009` | `21614` not SMS-capable |
|
||||
|
||||
Even with deliberately wrong credentials the round trip is worth running once:
|
||||
Twilio answers `Authentication Error - invalid username`, which proves the URL,
|
||||
the Basic auth header, the form encoding and the response parsing all work and
|
||||
only the credentials are missing.
|
||||
|
||||
Sending to a real handset needs a real (trial or paid) account, a real `From`
|
||||
number, and — for a US long code — completed A2P 10DLC registration.
|
||||
|
||||
### Provider
|
||||
|
||||
Sends go to Twilio over plain `fetch` against
|
||||
@@ -263,6 +355,21 @@ application-to-person traffic on a long code. Brand and campaign registration
|
||||
take days and carry per-campaign fees. Unregistered traffic gets filtered by
|
||||
carriers silently, with a `sent` status from the API.
|
||||
|
||||
A registered campaign lives on a Messaging Service, and every number in that
|
||||
service's sender pool inherits the campaign — including numbers added later.
|
||||
**Set `TWILIO_MESSAGING_SERVICE_SID` rather than `TWILIO_FROM_NUMBER`** once a
|
||||
campaign is approved. Both deliver, since the pool carries the registration
|
||||
either way, but a bare `From` leaves the Messaging Service off the message
|
||||
record in Twilio's logs and Insights, and it makes it possible to point at a
|
||||
number outside the pool and quietly send unregistered traffic. The Messaging
|
||||
Service also picks the sender for each destination. When both variables are set
|
||||
the Messaging Service wins and `TWILIO_FROM_NUMBER` is ignored.
|
||||
|
||||
A `sent` status means Twilio accepted the message, not that a handset received
|
||||
it. This service records `alert-sent` on that acceptance and does not register a
|
||||
`StatusCallback`, so `delivered` / `undelivered` / `failed` outcomes are not
|
||||
tracked. That is a gap to close if delivery receipts matter.
|
||||
|
||||
The inbound webhook authenticates by Twilio's `X-Twilio-Signature` over the
|
||||
exact URL Twilio posted to, not by JWT — it is Twilio calling, not a user. Set
|
||||
`TWILIO_WEBHOOK_URL` to that public URL; behind a proxy or tunnel the request's
|
||||
@@ -388,7 +495,7 @@ Set `NOTIFY_DATA_DIR` to a durable directory, or keep the Docker default `/app/d
|
||||
| `SMS_ENABLED` | Optional; `/notify-sms` returns `503 SMS_DISABLED` while off | `false` |
|
||||
| `SMS_CODE_SECRET` | **Required** when `SMS_ENABLED` (startup fails without it) | None |
|
||||
| `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN` | **Required** to send; absent means `SMS_NOT_CONFIGURED` per send | None |
|
||||
| `TWILIO_FROM_NUMBER` **or** `TWILIO_MESSAGING_SERVICE_SID` | One of the two required to send | None |
|
||||
| `TWILIO_MESSAGING_SERVICE_SID` **or** `TWILIO_FROM_NUMBER` | One of the two required to send; prefer the Messaging Service, which wins when both are set | None |
|
||||
| `TWILIO_WEBHOOK_URL` | The public URL Twilio posts `/notify-sms/inbound` to; it signs that exact string | Derived from request headers |
|
||||
| `SMS_CODE_TTL_SEC` | Optional | `600` |
|
||||
| `SMS_CODE_MAX_ATTEMPTS` | Optional | `5` |
|
||||
|
||||
+4
-1
@@ -9,7 +9,10 @@
|
||||
"start": "tsx src/index.ts",
|
||||
"build": "tsc",
|
||||
"test": "tsx --test \"test/**/*.test.ts\" \"src/**/*.test.ts\"",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.test.json"
|
||||
"typecheck": "tsc --noEmit -p tsconfig.test.json",
|
||||
"sms:send": "tsx scripts/sms-send.ts",
|
||||
"sms:smoke": "tsx scripts/sms-smoke.ts",
|
||||
"twilio:whoami": "tsx scripts/twilio-whoami.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-ecc": "^2.7.0",
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Choosing the destination number for the manual scripts.
|
||||
*
|
||||
* 555-01xx numbers are reserved as fictional and do not exist, so they are a
|
||||
* fine placeholder for the console adapter and a guaranteed failure against the
|
||||
* real API: Twilio validates the destination even under test credentials and
|
||||
* answers "The 'To' number ... is not a valid phone number."
|
||||
*/
|
||||
import { missingTwilioConfig } from "../src/services/smsService.js";
|
||||
import { normalizePhoneNumber } from "../src/util/smsPhoneNumber.js";
|
||||
|
||||
export const FICTIONAL_PLACEHOLDER = "+15555550123";
|
||||
|
||||
export function twilioIsConfigured(): boolean {
|
||||
return missingTwilioConfig().length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the destination, or explains why the placeholder cannot be used.
|
||||
* Exits rather than spending a round trip discovering it at the provider.
|
||||
*/
|
||||
export function resolveTarget(explicit: string | undefined, envVar: string): string {
|
||||
const raw = explicit ?? process.env[envVar];
|
||||
const normalized = normalizePhoneNumber(raw ?? FICTIONAL_PLACEHOLDER);
|
||||
|
||||
if (normalized === undefined) {
|
||||
console.error(`Not a phone number: ${raw}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (twilioIsConfigured() && normalized === FICTIONAL_PLACEHOLDER) {
|
||||
console.error(
|
||||
`Twilio is configured, so the destination has to be a number Twilio can\n` +
|
||||
`validate. ${FICTIONAL_PLACEHOLDER} is a reserved fictional number and is\n` +
|
||||
`rejected with error 21211, even under test credentials.\n\n` +
|
||||
`Pass a real number instead — your own is the obvious choice:\n\n` +
|
||||
` ${envVar}=+15551234567 pnpm run <script>\n\n` +
|
||||
`Under test credentials nothing is delivered and nothing is charged, so\n` +
|
||||
`using your own number here is safe. Under live credentials it is a real\n` +
|
||||
`text. Test credentials have their own Account SID: Twilio Console ->\n` +
|
||||
`API keys & tokens -> Test credentials.`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* One Twilio send, nothing else. No server, no database, no auth.
|
||||
*
|
||||
* pkgx npx tsx scripts/sms-send.ts [toNumber] [body]
|
||||
*
|
||||
* With Twilio test credentials this is a real API round trip that delivers no
|
||||
* message and costs nothing: From must be +15005550006, and the To has to be a
|
||||
* number Twilio can validate — a real one, not a fictional 555-01xx. The
|
||||
* +1500555000x magic numbers force specific failures.
|
||||
*/
|
||||
import { missingTwilioConfig, sendSms } from "../src/services/smsService.js";
|
||||
import { resolveTarget } from "./smokeTarget.js";
|
||||
|
||||
const body = process.argv[3] ?? "Gift Economies: smoke test. Reply STOP to end.";
|
||||
|
||||
const missing = missingTwilioConfig();
|
||||
if (missing.length > 0) {
|
||||
console.error("Twilio is not configured. Missing: " + missing.join(", "));
|
||||
console.error(
|
||||
"\nAn account and a token are not enough; a send needs a sender too.\n" +
|
||||
"With Twilio test credentials, use the magic From number:\n\n" +
|
||||
" TWILIO_ACCOUNT_SID=ACxxxx TWILIO_AUTH_TOKEN=xxxx \\\n" +
|
||||
" TWILIO_FROM_NUMBER=+15005550006 pnpm run sms:send " +
|
||||
(process.argv[2] ?? "+15555550123") +
|
||||
"\n\nWith a live 10DLC campaign, set TWILIO_MESSAGING_SERVICE_SID=MGxxxx instead."
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const to = resolveTarget(process.argv[2], "SMS_SEND_TO");
|
||||
|
||||
console.log(`To: ${to}`);
|
||||
console.log(`From: ${process.env.TWILIO_FROM_NUMBER ?? process.env.TWILIO_MESSAGING_SERVICE_SID ?? "(unset)"}`);
|
||||
console.log(`Body: ${body} (${body.length} chars)`);
|
||||
|
||||
const result = await sendSms(to, body);
|
||||
console.log("Result:", JSON.stringify(result));
|
||||
process.exit(result.status === "sent" ? 0 : 1);
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* End-to-end /notify-sms smoke run against fake everything.
|
||||
*
|
||||
* pkgx npx tsx scripts/sms-smoke.ts
|
||||
*
|
||||
* pkgx npx tsx scripts/sms-smoke.ts +15551234567
|
||||
*
|
||||
* Stubs the two things that normally need the real world — Endorser and the
|
||||
* signing key — and leaves the SMS path itself completely real. With Twilio
|
||||
* test credentials in the environment it makes a genuine API round trip that
|
||||
* sends no message and costs nothing; without them it prints to the console.
|
||||
*/
|
||||
import { createServer } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import express from "express";
|
||||
|
||||
const USER_DID = "did:ethr:0x0000000000000000000000000000000000c0ffee";
|
||||
/** Resolved after the environment is set, since the guard reads Twilio config. */
|
||||
let TO_NUMBER = "";
|
||||
|
||||
// A fresh database each run, so the 3-per-hour code throttle never bites.
|
||||
process.env.NOTIFY_DATA_DIR = mkdtempSync(path.join(tmpdir(), "sms-smoke-"));
|
||||
// Accepts unsigned did:ethr JWTs and unlocks devCode. Never set in production.
|
||||
process.env.NODE_ENV = "test-local";
|
||||
process.env.SMS_ENABLED = "true";
|
||||
process.env.SMS_CODE_SECRET ??= "sms-smoke-secret";
|
||||
process.env.SMS_DEV_ECHO_CODE = "true";
|
||||
|
||||
/** Answers the one call requireEndorserAuth makes. */
|
||||
function startStubEndorser(): Promise<string> {
|
||||
const server = createServer((req, res) => {
|
||||
console.log(` [stub-endorser] ${req.method} ${req.url} -> 200`);
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ doneClaimsThisWeek: 0, maxClaimsPerWeek: 100 }));
|
||||
});
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, () => {
|
||||
const { port } = server.address() as AddressInfo;
|
||||
resolve(`http://127.0.0.1:${port}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* An unsigned JWT. decodeAndVerifyJwt returns verified:true for a did:ethr
|
||||
* issuer under NODE_ENV=test-local without checking the signature, so no key
|
||||
* material is needed to exercise the real middleware chain.
|
||||
*/
|
||||
function mintActionJwt(action: string, phoneNumber?: string): string {
|
||||
const b64 = (value: unknown) =>
|
||||
Buffer.from(JSON.stringify(value)).toString("base64url");
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return [
|
||||
b64({ alg: "ES256K", typ: "JWT" }),
|
||||
b64({
|
||||
iss: USER_DID,
|
||||
iat: now,
|
||||
exp: now + 300,
|
||||
claim: {
|
||||
"@context": "https://giftopia.tech",
|
||||
"@type": "SmsNotificationAction",
|
||||
action,
|
||||
...(phoneNumber === undefined ? {} : { phoneNumber }),
|
||||
},
|
||||
}),
|
||||
"unsigned",
|
||||
].join(".");
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
// ENDORSER_URL is a module-level const in env.ts, so it has to be set before
|
||||
// anything that imports env.ts is loaded — including the target guard.
|
||||
process.env.ENDORSER_URL = await startStubEndorser();
|
||||
console.log("Stub Endorser at", process.env.ENDORSER_URL);
|
||||
|
||||
const { resolveTarget } = await import("./smokeTarget.js");
|
||||
TO_NUMBER = resolveTarget(process.argv[2], "SMS_SMOKE_TO");
|
||||
|
||||
// Imported after the environment is set, since env.ts reads it at load.
|
||||
const { notifySmsRouter } = await import("../src/routes/notifySms.js");
|
||||
const { smsConfig } = await import("../src/env.js");
|
||||
|
||||
const configured =
|
||||
smsConfig().twilioAccountSid !== undefined &&
|
||||
smsConfig().twilioAuthToken !== undefined &&
|
||||
(smsConfig().twilioFromNumber !== undefined ||
|
||||
smsConfig().twilioMessagingServiceSid !== undefined);
|
||||
console.log(
|
||||
configured
|
||||
? `Twilio credentials present: real API call, From=${smsConfig().twilioFromNumber ?? smsConfig().twilioMessagingServiceSid}`
|
||||
: "No Twilio credentials: console adapter, nothing leaves the machine"
|
||||
);
|
||||
console.log("Data dir:", process.env.NOTIFY_DATA_DIR, "\n");
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/notify-sms", notifySmsRouter);
|
||||
const server = app.listen(0);
|
||||
await new Promise((resolve) => server.once("listening", resolve));
|
||||
const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
|
||||
async function call(
|
||||
label: string,
|
||||
method: string,
|
||||
urlPath: string,
|
||||
action: string,
|
||||
phoneNumber?: string,
|
||||
body?: unknown
|
||||
): Promise<Record<string, unknown>> {
|
||||
const response = await fetch(base + urlPath, {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer " + mintActionJwt(action, phoneNumber),
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
const parsed = (await response.json()) as Record<string, unknown>;
|
||||
console.log(`${label}: ${response.status}`, JSON.stringify(parsed));
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const posted = await call(
|
||||
"POST /phone ",
|
||||
"POST",
|
||||
"/notify-sms/phone",
|
||||
"register-phone",
|
||||
TO_NUMBER,
|
||||
{ phoneNumber: TO_NUMBER }
|
||||
);
|
||||
|
||||
const { smsPhoneLogDb } = await import("../src/db/smsPhoneLogSqlite.js");
|
||||
|
||||
async function dumpLog(): Promise<void> {
|
||||
const rows = await smsPhoneLogDb.listByUserId(USER_DID);
|
||||
console.log("\nsms_phone_log (newest first):");
|
||||
for (const row of rows) {
|
||||
console.log(
|
||||
` ${row.action.padEnd(26)} ${row.result.padEnd(9)} ` +
|
||||
`phone=${row.phoneE164 ?? "(scrubbed)"} ` +
|
||||
`sid=${row.providerMessageId ?? "-"}` +
|
||||
(row.detail === undefined ? "" : ` detail=${row.detail}`)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const code = posted.devCode;
|
||||
if (typeof code !== "string") {
|
||||
await dumpLog();
|
||||
console.error(
|
||||
"\nNo devCode in the response, so the code never went out. The " +
|
||||
"code-send-failed detail above is Twilio's own words.\n" +
|
||||
" \u2022 'To' rejected: use a real number Twilio can validate, not a " +
|
||||
"fictional 555-01xx one.\n" +
|
||||
" \u2022 'From' rejected (21606): under test credentials it must be " +
|
||||
"+15005550006; under live credentials it must be a number you own, or " +
|
||||
"a Messaging Service SID.\n" +
|
||||
" \u2022 'resource ... was not found' (20404): the token authenticated " +
|
||||
"but that account is not reachable. Run `pnpm run twilio:whoami`."
|
||||
);
|
||||
server.close();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await call(
|
||||
"PUT /phone ",
|
||||
"PUT",
|
||||
"/notify-sms/phone",
|
||||
"verify-phone",
|
||||
TO_NUMBER,
|
||||
{ phoneNumber: TO_NUMBER, code }
|
||||
);
|
||||
await call("GET /phone ", "GET", "/notify-sms/phone", "list-phones");
|
||||
await call(
|
||||
"GET /phone? ",
|
||||
"GET",
|
||||
`/notify-sms/phone?phoneNumber=${encodeURIComponent(TO_NUMBER)}`,
|
||||
"list-phones",
|
||||
TO_NUMBER
|
||||
);
|
||||
await call(
|
||||
"DELETE /phone ",
|
||||
"DELETE",
|
||||
"/notify-sms/phone",
|
||||
"delete-phone",
|
||||
TO_NUMBER,
|
||||
{ phoneNumber: TO_NUMBER }
|
||||
);
|
||||
|
||||
await dumpLog();
|
||||
|
||||
server.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
void main();
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Who do these credentials say I am?
|
||||
*
|
||||
* pkgx pnpm run twilio:whoami
|
||||
*
|
||||
* Fetches the Account resource with the configured SID and token, which
|
||||
* separates "wrong credential pair" from "credentials fine, problem is
|
||||
* elsewhere" in one call. Sends nothing and costs nothing.
|
||||
*/
|
||||
import { smsConfig } from "../src/env.js";
|
||||
|
||||
const config = smsConfig();
|
||||
const sid = config.twilioAccountSid;
|
||||
const token = config.twilioAuthToken;
|
||||
|
||||
if (sid === undefined || token === undefined) {
|
||||
console.error("Set TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN first.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Account SID: ${sid} (${sid.length} chars, prefix ${sid.slice(0, 2)})`);
|
||||
console.log(`Auth token: ${token.length} chars, ends ...${token.slice(-4)}`);
|
||||
|
||||
const response = await fetch(
|
||||
`https://api.twilio.com/2010-04-01/Accounts/${encodeURIComponent(sid)}.json`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: "Basic " + Buffer.from(`${sid}:${token}`).toString("base64"),
|
||||
},
|
||||
}
|
||||
);
|
||||
const body = (await response.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
|
||||
console.log(`\nHTTP ${response.status}`);
|
||||
|
||||
// Test credentials authenticate fine and are then refused most resources.
|
||||
// That refusal is itself proof the pair is valid, so report it as a pass.
|
||||
if (response.status === 403 && body.code === 20008) {
|
||||
console.log(` code: ${String(body.code)}`);
|
||||
console.log(` message: ${String(body.message)}`);
|
||||
console.log(
|
||||
"\nThese are valid Twilio Test Credentials. The pair authenticated; the\n" +
|
||||
"Accounts resource is simply not one that test credentials may read, so\n" +
|
||||
"this 403 is the expected answer and not a misconfiguration.\n\n" +
|
||||
"What they can do: create Messages with From=+15005550006. Nothing is\n" +
|
||||
"delivered and nothing is billed. The +1500555000x magic To numbers force\n" +
|
||||
"specific failures; a real To number is accepted by some accounts and\n" +
|
||||
"answered with 20404 on others, so treat the magic numbers as the reliable\n" +
|
||||
"path and use live credentials for a text that actually arrives.\n\n" +
|
||||
"To see account details here, run this with the live pair from the Twilio\n" +
|
||||
"Console dashboard instead."
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
console.log(` friendly_name: ${String(body.friendly_name)}`);
|
||||
console.log(` status: ${String(body.status)}`);
|
||||
console.log(` type: ${String(body.type)}`);
|
||||
console.log(
|
||||
"\nThe pair is valid and this is the account the send would bill. " +
|
||||
'"type: Trial" can only send to verified numbers.'
|
||||
);
|
||||
} else {
|
||||
console.log(` code: ${String(body.code)}`);
|
||||
console.log(` message: ${String(body.message)}`);
|
||||
console.log(
|
||||
"\n401/20003 means the SID and token are not a matching pair. Test\n" +
|
||||
"credentials are a matched pair of their own: Twilio Console -> Account ->\n" +
|
||||
"API keys & tokens -> Test credentials. Take BOTH values from that block;\n" +
|
||||
"the SID in the dashboard header belongs to the live pair."
|
||||
);
|
||||
}
|
||||
process.exit(response.ok ? 0 : 1);
|
||||
@@ -19,6 +19,23 @@ type TwilioCredentials = {
|
||||
from: { From: string } | { MessagingServiceSid: string };
|
||||
};
|
||||
|
||||
/**
|
||||
* Names the variables a send would need and does not have. An account and a
|
||||
* token alone cannot produce a message: there has to be something to send from.
|
||||
*/
|
||||
export function missingTwilioConfig(config: SmsConfig = smsConfig()): string[] {
|
||||
const missing: string[] = [];
|
||||
if (config.twilioAccountSid === undefined) missing.push("TWILIO_ACCOUNT_SID");
|
||||
if (config.twilioAuthToken === undefined) missing.push("TWILIO_AUTH_TOKEN");
|
||||
if (
|
||||
config.twilioMessagingServiceSid === undefined &&
|
||||
config.twilioFromNumber === undefined
|
||||
) {
|
||||
missing.push("TWILIO_MESSAGING_SERVICE_SID or TWILIO_FROM_NUMBER");
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* A sender needs an account, a token, and something to send from. Anything less
|
||||
* cannot produce a message, so it is not a partial configuration but no sender.
|
||||
@@ -113,7 +130,8 @@ export const sendSms: SmsSender = async (to, body) => {
|
||||
if (!warnedNotConfigured) {
|
||||
warnedNotConfigured = true;
|
||||
log.error(
|
||||
"[SmsService] Twilio is not configured; SMS sends will fail."
|
||||
"[SmsService] Twilio is not configured; SMS sends will fail. Missing:",
|
||||
missingTwilioConfig(config).join(", ")
|
||||
);
|
||||
}
|
||||
return { status: "failed", error: SMS_NOT_CONFIGURED };
|
||||
|
||||
+2
-1
@@ -6,7 +6,8 @@
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"test/**/*.ts"
|
||||
"test/**/*.ts",
|
||||
"scripts/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
|
||||
Reference in New Issue
Block a user