@north-light/comms 0.0.0-stage → 0.1.0
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 +257 -2
- package/bin/comms.js +17 -0
- package/dist/client.d.ts +73 -0
- package/dist/client.js +307 -0
- package/dist/fake/cli.d.ts +14 -0
- package/dist/fake/cli.js +107 -0
- package/dist/fake/server.d.ts +33 -0
- package/dist/fake/server.js +213 -0
- package/dist/fake/store.d.ts +59 -0
- package/dist/fake/store.js +356 -0
- package/dist/helpers/agent-bridge.d.ts +72 -0
- package/dist/helpers/agent-bridge.js +48 -0
- package/dist/helpers/batching.d.ts +23 -0
- package/dist/helpers/batching.js +66 -0
- package/dist/helpers/format.d.ts +38 -0
- package/dist/helpers/format.js +164 -0
- package/dist/helpers/signup.d.ts +34 -0
- package/dist/helpers/signup.js +36 -0
- package/dist/helpers/typing.d.ts +14 -0
- package/dist/helpers/typing.js +31 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +9 -0
- package/dist/types.d.ts +175 -0
- package/dist/types.js +3 -0
- package/dist/webhooks.d.ts +30 -0
- package/dist/webhooks.js +111 -0
- package/package.json +49 -4
package/README.md
CHANGED
|
@@ -1,3 +1,258 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @north-light/comms
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Texting for your app. People text your app's phone number (an iMessage line under your app's name and avatar), Comms delivers each text to your server as a signed webhook, and your server answers through one HTTPS send API. This package is everything on your side of that line:
|
|
4
|
+
|
|
5
|
+
- a typed client for the send API (`CommsClient`),
|
|
6
|
+
- webhook verification (`verifyWebhook`),
|
|
7
|
+
- a local fake Comms for building without an account (`comms fake`),
|
|
8
|
+
- optional helpers: signup, an agent bridge, batching, typing and reply formatting.
|
|
9
|
+
|
|
10
|
+
It has no runtime dependencies. Node 22 or later.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
npm install @north-light/comms
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The agent bridge (`continueInRuntime`) drives a run in the person's runtime through `@north-light/sdk`, an optional peer dependency. Install it only if you use that helper:
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
npm install @north-light/sdk@0.3.457
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Build in fake mode
|
|
25
|
+
|
|
26
|
+
You need no approval, no account setup and no network to start. `comms fake` runs a local Comms that serves the send API's paths and bodies, keeps contacts in memory with fictional `+1555…` numbers, logs every send and reaction to its stdout, and delivers webhooks to your server signed with a secret it prints.
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
npx @north-light/comms fake --webhook http://localhost:3000/hook
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
comms fake [--port 7481] --webhook <url> [--secret whsec_…]
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
- `--port` the fake's port. Default `7481`.
|
|
37
|
+
- `--webhook` where the fake delivers events. Required.
|
|
38
|
+
- `--secret` the signing secret. Without it the fake generates one and prints it; copy it into your app.
|
|
39
|
+
|
|
40
|
+
Simulate a person texting your app, from a second terminal:
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
npx @north-light/comms fake send --from +15550001 --text "hi" [--port 7481]
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Your server receives a signed `message.received` with `contact.user` set to `null` (the sender is not a user of your app yet). `STOP`, `START` and `HELP` behave as they do in production: after `STOP`, a send to that contact answers `403 opted_out`.
|
|
47
|
+
|
|
48
|
+
Point the client at the fake. It accepts any bearer token and ignores `environment`:
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
import { CommsClient } from "@north-light/comms";
|
|
52
|
+
|
|
53
|
+
const comms = new CommsClient({
|
|
54
|
+
baseUrl: "http://localhost:7481",
|
|
55
|
+
token: "fake",
|
|
56
|
+
});
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The fake only calls the network to reach your webhook URL.
|
|
60
|
+
|
|
61
|
+
## Receive texts: verify webhooks
|
|
62
|
+
|
|
63
|
+
Every delivery carries the headers `webhook-id`, `webhook-timestamp` and `webhook-signature`, following [Standard Webhooks](https://www.standardwebhooks.com/). Verify the **raw** request body, exactly as it arrived, before you parse or act on it:
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
import express from "express";
|
|
67
|
+
import { verifyWebhook, WebhookVerificationError } from "@north-light/comms";
|
|
68
|
+
|
|
69
|
+
const SECRETS = [process.env.COMMS_WEBHOOK_SECRET!]; // whsec_…, one per environment
|
|
70
|
+
const seen = new Set<string>(); // use a database in production
|
|
71
|
+
|
|
72
|
+
express()
|
|
73
|
+
.post(
|
|
74
|
+
"/hook",
|
|
75
|
+
express.raw({ type: "application/json" }),
|
|
76
|
+
async (req, res) => {
|
|
77
|
+
let event;
|
|
78
|
+
try {
|
|
79
|
+
event = verifyWebhook(req.headers, req.body, SECRETS);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (error instanceof WebhookVerificationError)
|
|
82
|
+
return void res.sendStatus(401);
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
res.sendStatus(200); // acknowledge within 15 seconds
|
|
86
|
+
if (seen.has(event.id)) return; // deliveries are at least once
|
|
87
|
+
seen.add(event.id);
|
|
88
|
+
// …handle event
|
|
89
|
+
},
|
|
90
|
+
)
|
|
91
|
+
.listen(3000);
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
- `verifyWebhook(headers, rawBody, secrets)` returns the parsed, typed `WebhookEvent` or throws `WebhookVerificationError` (its `code` says which check failed). It refuses timestamps more than 5 minutes off.
|
|
95
|
+
- Pass every secret you still trust. When you rotate a secret, Comms signs each delivery with both the old and the new secret for 24 hours, so listing both keeps deliveries verifying through the change.
|
|
96
|
+
- Respond `2xx` within 15 seconds to acknowledge. Anything else is retried with exponential backoff for 24 hours, then the event is marked failed.
|
|
97
|
+
- Deduplicate on `event.id`. Order is not guaranteed.
|
|
98
|
+
|
|
99
|
+
Events: `message.received`, `message.status`, `contact.opted_out`, `contact.opted_in`, `contact.user_linked`, `contact.deleted` (the types are exported).
|
|
100
|
+
|
|
101
|
+
## Reply: the send API
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
await comms.sendMessage({
|
|
105
|
+
environment: "test",
|
|
106
|
+
to: { contact_id: event.data.contact.id },
|
|
107
|
+
text: "Got it!",
|
|
108
|
+
idempotency_key: `reply:${event.id}`,
|
|
109
|
+
});
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
- Address a person by `contact_id` or `user_id`, never by raw phone number.
|
|
113
|
+
- You can text only a contact who texted your number first in that environment and has not sent STOP. Otherwise the call fails with a `CommsError` whose `code` is `not_texted_first` or `opted_out`.
|
|
114
|
+
- The same `idempotency_key` returns the first answer and sends nothing, so retrying is safe. Derive it from the event you are answering.
|
|
115
|
+
- `sendReaction`, `getMessage`, `getContact`, `setTyping`, `createSignInLink` and `getPublicNumber` complete the API. Delivery results arrive later as `message.status` events; a failure carries Photon's code in `reason`.
|
|
116
|
+
|
|
117
|
+
## Environments
|
|
118
|
+
|
|
119
|
+
| | Fake | Test | Production |
|
|
120
|
+
| ------------ | ------------------------------ | ------------------------------------------------------- | -------------------------------------------------------------- |
|
|
121
|
+
| Where | `comms fake` on your machine | Comms, a free shared-pool line | Comms, your app's own dedicated numbers |
|
|
122
|
+
| Needs | Nothing | An approved request and a provisioned test line | An approved request and a working card on your billing account |
|
|
123
|
+
| Numbers | Fictional `+1555…` | Each tester texts a pool number that differs per tester | Your app's numbers |
|
|
124
|
+
| Who can text | You, through `comms fake send` | Up to 10 testers you add | Anyone |
|
|
125
|
+
| Sends | Logged, never delivered | Real texts, to testers who texted first | Real texts |
|
|
126
|
+
|
|
127
|
+
Each environment has its own webhook URL, signing secret, contacts and STOP state. Every send-API call names its environment (`"test"` or `"production"`); the fake ignores the field. Until an environment is set up, calls for it answer `409 environment_not_ready`.
|
|
128
|
+
|
|
129
|
+
To call a real Comms, authenticate with your app's own key:
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
const comms = new CommsClient({
|
|
133
|
+
baseUrl: "https://comms.northlight.ai",
|
|
134
|
+
issuer: "https://account.northlight.ai",
|
|
135
|
+
clientId: "your-app-id",
|
|
136
|
+
privateKeyJwk: JSON.parse(process.env.COMMS_PRIVATE_JWK!),
|
|
137
|
+
});
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
The client mints a short-lived token for you and renews it before it expires.
|
|
141
|
+
|
|
142
|
+
## Helpers
|
|
143
|
+
|
|
144
|
+
All optional; none is needed to use the client.
|
|
145
|
+
|
|
146
|
+
### Sign-up
|
|
147
|
+
|
|
148
|
+
A person who texts your app may not have an account yet (`contact.user === null`). `sendSignInLink` creates the link and texts it:
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
import { sendSignInLink, onUserLinked } from "@north-light/comms";
|
|
152
|
+
|
|
153
|
+
await sendSignInLink(comms, event.data.contact, {
|
|
154
|
+
environment: event.environment,
|
|
155
|
+
});
|
|
156
|
+
// default text: "Tap to finish signing up: {url}"
|
|
157
|
+
|
|
158
|
+
const handle = onUserLinked(async ({ contact_id, user }) => {
|
|
159
|
+
// the person tapped the link and now has an account: store contact_id ↔ user.id
|
|
160
|
+
});
|
|
161
|
+
await handle(event); // call with every verified event
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
The link page and the pages after it carry your app's name and logo. `sendSignInLink` throws a `CommsError` for `already_user`, `no_phone` (an email handle) and `opted_out`. Options: `text` (must contain `{url}`), `returnTo` (must be on your app's host), `idempotencyKey`.
|
|
165
|
+
|
|
166
|
+
### Agent bridge
|
|
167
|
+
|
|
168
|
+
`continueInRuntime` starts a run in the person's runtime for their first text and continues it for later ones, under your app's own grant:
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
import { continueInRuntime } from "@north-light/comms";
|
|
172
|
+
|
|
173
|
+
const { runId, reply } = await continueInRuntime({
|
|
174
|
+
runtimeUrl, // the person's runtime, from their grant
|
|
175
|
+
appToken, // your app's token for it, or a function returning a fresh one
|
|
176
|
+
message: text,
|
|
177
|
+
runId: savedRunId, // omit for the first text
|
|
178
|
+
idempotencyKey: event.id,
|
|
179
|
+
});
|
|
180
|
+
const { text: answer } = await reply.collect();
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
It uses `@north-light/sdk` (`runs.start` with a first `message`, `runs.message`, then `runs.reply`). Pass `client` instead of `runtimeUrl` and `appToken` to use a client you already hold.
|
|
184
|
+
|
|
185
|
+
### Batching
|
|
186
|
+
|
|
187
|
+
People send several texts in a row. A batcher joins one contact's texts that arrive within a window (default 2 seconds, closing after at most 30 seconds) and hands them over together:
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
import { createBatcher } from "@north-light/comms";
|
|
191
|
+
|
|
192
|
+
const batcher = createBatcher<string>({
|
|
193
|
+
onBatch: async (contactId, texts) => {
|
|
194
|
+
/* one agent turn for texts.join("\n") */
|
|
195
|
+
},
|
|
196
|
+
onError: (error) => console.error(error),
|
|
197
|
+
});
|
|
198
|
+
batcher.add(contact.id, text);
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Batches for one contact never overlap. Call `await batcher.flushAll()` on shutdown.
|
|
202
|
+
|
|
203
|
+
### Typing
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
import { withTyping } from "@north-light/comms";
|
|
207
|
+
|
|
208
|
+
const answer = await withTyping(comms, contact.id, { environment }, () =>
|
|
209
|
+
runAgent(text),
|
|
210
|
+
);
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
The indicator is renewed every 5 seconds while the work runs and switched off when it ends. A failed typing call goes to `onError` and never fails the work. `keepTyping` returns the stop function if you need manual control.
|
|
214
|
+
|
|
215
|
+
### Formatting and mapping
|
|
216
|
+
|
|
217
|
+
Agents write markdown; a text message is plain.
|
|
218
|
+
|
|
219
|
+
```ts
|
|
220
|
+
import {
|
|
221
|
+
formatReply,
|
|
222
|
+
tapbackEmoji,
|
|
223
|
+
replyTo,
|
|
224
|
+
describeReceived,
|
|
225
|
+
} from "@north-light/comms";
|
|
226
|
+
|
|
227
|
+
for (const part of formatReply(agentMarkdown)) {
|
|
228
|
+
await comms.sendMessage({
|
|
229
|
+
environment,
|
|
230
|
+
to: { contact_id: contact.id },
|
|
231
|
+
text: part,
|
|
232
|
+
...replyTo(event.data), // thread under the message you are answering
|
|
233
|
+
idempotency_key: `reply:${event.id}:${part.length}`,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
await comms.sendReaction(event.data.message_id, {
|
|
237
|
+
environment,
|
|
238
|
+
reaction: tapbackEmoji("love"),
|
|
239
|
+
idempotency_key: `react:${event.id}`,
|
|
240
|
+
});
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
- `formatReply` strips markdown to plain text (links become `label (url)`) and splits anything over the 10,000-character limit.
|
|
244
|
+
- `tapbackEmoji` / `tapbackName` map `love`, `like`, `dislike`, `laugh`, `emphasize`, `question` to the six iMessage tapbacks.
|
|
245
|
+
- `describeReceived` turns a received text, reaction or attachment (and a threaded reply) into one line an agent can read.
|
|
246
|
+
|
|
247
|
+
## Keeping your line healthy
|
|
248
|
+
|
|
249
|
+
Comms enforces who may be texted. Photon's limits on the lines themselves are yours to respect, and ignoring them mostly risks your own numbers. Good practice:
|
|
250
|
+
|
|
251
|
+
- Let the person text first. Texting first is what permits you to text them, and your numbers fare best when the conversation starts with them.
|
|
252
|
+
- Offer your contact card after the first exchange, not in the first message.
|
|
253
|
+
- Keep follow-ups to two or three, spread over days.
|
|
254
|
+
- Do not send at night.
|
|
255
|
+
- Do not send bursts, and do not pace sends per second beyond what Photon enforces.
|
|
256
|
+
- Keep the first reply free of links and media.
|
|
257
|
+
- Photon rejects sends past its limits (5,000 sends per day per server, 50 new conversations per line per day, per-recipient and send/receive-ratio limits) and Comms passes the rejection to you as `message.status` `failed` with Photon's code (`dailyLimitExceeded`, `recipientLimitExceeded`, `sendReceiveRatioExceeded`). Treat those as a signal to slow down, not a retry.
|
|
258
|
+
- A line is comfortable at about 400 to 500 people for moderate use, and 200 to 400 for heavy use. Photon adds lines as they fill and routes new people to the best one; publish your number with `comms.getPublicNumber()`, for example as an `sms:` link.
|
package/bin/comms.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { run, UsageError } from "../dist/fake/cli.js";
|
|
3
|
+
|
|
4
|
+
try {
|
|
5
|
+
const server = await run(process.argv.slice(2), {
|
|
6
|
+
out: (line) => console.log(line),
|
|
7
|
+
});
|
|
8
|
+
if (server) {
|
|
9
|
+
const stop = () => void server.close().then(() => process.exit(0));
|
|
10
|
+
process.on("SIGINT", stop);
|
|
11
|
+
process.on("SIGTERM", stop);
|
|
12
|
+
}
|
|
13
|
+
} catch (error) {
|
|
14
|
+
if (!(error instanceof UsageError)) throw error;
|
|
15
|
+
console.error(error.message);
|
|
16
|
+
process.exitCode = 2;
|
|
17
|
+
}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { Contact, CommsErrorCode, Environment, MessageRecord, PublicNumber, QueuedMessage, SendMessageRequest, SendReactionRequest, SignInLink, SignInLinkRequest, TypingRequest } from "./types.js";
|
|
2
|
+
/** The `resource` that selects the Comms audience when minting a directory token. */
|
|
3
|
+
export declare const DEFAULT_RESOURCE = "https://comms.northlight.ai";
|
|
4
|
+
/** A private ES256 (P-256) JWK, as registered with the directory. */
|
|
5
|
+
export interface PrivateKeyJwk {
|
|
6
|
+
kty: "EC";
|
|
7
|
+
crv: "P-256";
|
|
8
|
+
x: string;
|
|
9
|
+
y: string;
|
|
10
|
+
d: string;
|
|
11
|
+
kid?: string;
|
|
12
|
+
alg?: string;
|
|
13
|
+
[member: string]: unknown;
|
|
14
|
+
}
|
|
15
|
+
export interface CommsClientOptions {
|
|
16
|
+
/** Comms' base URL, for example `https://comms.northlight.ai` or the fake `http://localhost:7481`. */
|
|
17
|
+
baseUrl: string;
|
|
18
|
+
/** The directory's issuer URL; its `/.well-known/openid-configuration` names the token endpoint. */
|
|
19
|
+
issuer?: string;
|
|
20
|
+
/** Your app's id, which is also your `client_id` at the directory. */
|
|
21
|
+
clientId?: string;
|
|
22
|
+
privateKeyJwk?: PrivateKeyJwk;
|
|
23
|
+
/** The audience to mint for. Default `https://comms.northlight.ai`. */
|
|
24
|
+
resource?: string;
|
|
25
|
+
/** A fixed bearer token. With it (or `getToken`) the client never contacts a directory; the fake server accepts any bearer. */
|
|
26
|
+
token?: string;
|
|
27
|
+
/** Called before each request for a bearer token. */
|
|
28
|
+
getToken?: () => string | Promise<string>;
|
|
29
|
+
/** Request timeout in milliseconds for every call, token requests included. Default 30000. */
|
|
30
|
+
timeoutMs?: number;
|
|
31
|
+
/** A replacement for the global `fetch`. */
|
|
32
|
+
fetch?: typeof fetch;
|
|
33
|
+
/** The current time in milliseconds; for tests. */
|
|
34
|
+
now?: () => number;
|
|
35
|
+
}
|
|
36
|
+
/** A refusal from Comms (or the directory while minting a token), or a failure to reach it. */
|
|
37
|
+
export declare class CommsError extends Error {
|
|
38
|
+
readonly name = "CommsError";
|
|
39
|
+
readonly code: CommsErrorCode;
|
|
40
|
+
/** HTTP status; `0` when no response arrived. */
|
|
41
|
+
readonly status: number;
|
|
42
|
+
readonly retryable: boolean;
|
|
43
|
+
readonly requestId: string | undefined;
|
|
44
|
+
readonly param: string | undefined;
|
|
45
|
+
readonly details: unknown;
|
|
46
|
+
readonly retryAfterS: number | undefined;
|
|
47
|
+
constructor(code: CommsErrorCode, message: string, status: number, extra?: {
|
|
48
|
+
retryable?: boolean;
|
|
49
|
+
requestId?: string;
|
|
50
|
+
param?: string;
|
|
51
|
+
details?: unknown;
|
|
52
|
+
retryAfterS?: number;
|
|
53
|
+
cause?: unknown;
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
export declare class CommsClient {
|
|
57
|
+
#private;
|
|
58
|
+
constructor(options: CommsClientOptions);
|
|
59
|
+
/** `POST /v1/messages` → `202`. Refusals arrive as a `CommsError` (`opted_out`, `not_texted_first`, …). */
|
|
60
|
+
sendMessage(request: SendMessageRequest): Promise<QueuedMessage>;
|
|
61
|
+
/** `POST /v1/messages/<id>/reactions` → `202`; the answer's `message_id` is the reaction's own. */
|
|
62
|
+
sendReaction(messageId: string, request: SendReactionRequest): Promise<QueuedMessage>;
|
|
63
|
+
/** `GET /v1/messages/<id>`. */
|
|
64
|
+
getMessage(messageId: string): Promise<MessageRecord>;
|
|
65
|
+
/** `GET /v1/contacts/<id>?environment=`. */
|
|
66
|
+
getContact(contactId: string, environment: Environment): Promise<Contact>;
|
|
67
|
+
/** `POST /v1/contacts/<id>/typing` → `204`. Best effort: never queued, never retried. */
|
|
68
|
+
setTyping(contactId: string, request: TypingRequest): Promise<void>;
|
|
69
|
+
/** `POST /v1/contacts/<id>/sign-in-link` → `201`. `already_user`, `no_phone` and `opted_out` arrive as a `CommsError`. */
|
|
70
|
+
createSignInLink(contactId: string, request: SignInLinkRequest): Promise<SignInLink>;
|
|
71
|
+
/** `GET /v1/numbers/public?environment=`: the number to publish for new people. */
|
|
72
|
+
getPublicNumber(environment?: Environment): Promise<PublicNumber>;
|
|
73
|
+
}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/** The `resource` that selects the Comms audience when minting a directory token. */
|
|
2
|
+
export const DEFAULT_RESOURCE = "https://comms.northlight.ai";
|
|
3
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
4
|
+
/** A cached token is replaced this long before it expires. */
|
|
5
|
+
const REFRESH_MARGIN_MS = 30_000;
|
|
6
|
+
const ASSERTION_LIFETIME_S = 60;
|
|
7
|
+
/** A refusal from Comms (or the directory while minting a token), or a failure to reach it. */
|
|
8
|
+
export class CommsError extends Error {
|
|
9
|
+
name = "CommsError";
|
|
10
|
+
code;
|
|
11
|
+
/** HTTP status; `0` when no response arrived. */
|
|
12
|
+
status;
|
|
13
|
+
retryable;
|
|
14
|
+
requestId;
|
|
15
|
+
param;
|
|
16
|
+
details;
|
|
17
|
+
retryAfterS;
|
|
18
|
+
constructor(code, message, status, extra = {}) {
|
|
19
|
+
super(message, extra.cause === undefined ? undefined : { cause: extra.cause });
|
|
20
|
+
this.code = code;
|
|
21
|
+
this.status = status;
|
|
22
|
+
this.retryable =
|
|
23
|
+
extra.retryable ?? (status === 0 || status === 429 || status >= 500);
|
|
24
|
+
this.requestId = extra.requestId;
|
|
25
|
+
this.param = extra.param;
|
|
26
|
+
this.details = extra.details;
|
|
27
|
+
this.retryAfterS = extra.retryAfterS;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const base64url = (bytes) => Buffer.from(bytes).toString("base64url");
|
|
31
|
+
const json = (value) => base64url(new TextEncoder().encode(JSON.stringify(value)));
|
|
32
|
+
function isRecord(value) {
|
|
33
|
+
return typeof value === "object" && value !== null;
|
|
34
|
+
}
|
|
35
|
+
export class CommsClient {
|
|
36
|
+
#baseUrl;
|
|
37
|
+
#issuer;
|
|
38
|
+
#clientId;
|
|
39
|
+
#jwk;
|
|
40
|
+
#resource;
|
|
41
|
+
#fixedToken;
|
|
42
|
+
#getToken;
|
|
43
|
+
#timeoutMs;
|
|
44
|
+
#fetch;
|
|
45
|
+
#now;
|
|
46
|
+
#key;
|
|
47
|
+
#tokenEndpoint;
|
|
48
|
+
#cached;
|
|
49
|
+
#inflight;
|
|
50
|
+
constructor(options) {
|
|
51
|
+
this.#baseUrl = options.baseUrl.replace(/\/+$/, "");
|
|
52
|
+
this.#issuer = options.issuer?.replace(/\/+$/, "");
|
|
53
|
+
this.#clientId = options.clientId;
|
|
54
|
+
this.#jwk = options.privateKeyJwk;
|
|
55
|
+
this.#resource = options.resource ?? DEFAULT_RESOURCE;
|
|
56
|
+
this.#fixedToken = options.token;
|
|
57
|
+
this.#getToken = options.getToken;
|
|
58
|
+
this.#timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
59
|
+
this.#fetch = options.fetch ?? ((input, init) => fetch(input, init));
|
|
60
|
+
this.#now = options.now ?? Date.now;
|
|
61
|
+
if (this.#fixedToken === undefined && this.#getToken === undefined) {
|
|
62
|
+
if (!this.#issuer || !this.#clientId || !this.#jwk)
|
|
63
|
+
throw new TypeError("CommsClient needs `issuer`, `clientId` and `privateKeyJwk`, or a `token` / `getToken`");
|
|
64
|
+
const jwk = this.#jwk;
|
|
65
|
+
if (jwk.kty !== "EC" || jwk.crv !== "P-256" || !jwk.d || !jwk.x || !jwk.y)
|
|
66
|
+
throw new TypeError("privateKeyJwk must be a private ES256 (P-256) JWK");
|
|
67
|
+
if (jwk.alg !== undefined && jwk.alg !== "ES256")
|
|
68
|
+
throw new TypeError("privateKeyJwk.alg must be ES256");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// ---- Send API -----------------------------------------------------------
|
|
72
|
+
/** `POST /v1/messages` → `202`. Refusals arrive as a `CommsError` (`opted_out`, `not_texted_first`, …). */
|
|
73
|
+
sendMessage(request) {
|
|
74
|
+
return this.#call("POST", "/v1/messages", { body: request });
|
|
75
|
+
}
|
|
76
|
+
/** `POST /v1/messages/<id>/reactions` → `202`; the answer's `message_id` is the reaction's own. */
|
|
77
|
+
sendReaction(messageId, request) {
|
|
78
|
+
return this.#call("POST", `/v1/messages/${encodeURIComponent(messageId)}/reactions`, {
|
|
79
|
+
body: request,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
/** `GET /v1/messages/<id>`. */
|
|
83
|
+
getMessage(messageId) {
|
|
84
|
+
return this.#call("GET", `/v1/messages/${encodeURIComponent(messageId)}`);
|
|
85
|
+
}
|
|
86
|
+
/** `GET /v1/contacts/<id>?environment=`. */
|
|
87
|
+
getContact(contactId, environment) {
|
|
88
|
+
return this.#call("GET", `/v1/contacts/${encodeURIComponent(contactId)}`, {
|
|
89
|
+
query: { environment },
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
/** `POST /v1/contacts/<id>/typing` → `204`. Best effort: never queued, never retried. */
|
|
93
|
+
async setTyping(contactId, request) {
|
|
94
|
+
await this.#call("POST", `/v1/contacts/${encodeURIComponent(contactId)}/typing`, {
|
|
95
|
+
body: request,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
/** `POST /v1/contacts/<id>/sign-in-link` → `201`. `already_user`, `no_phone` and `opted_out` arrive as a `CommsError`. */
|
|
99
|
+
createSignInLink(contactId, request) {
|
|
100
|
+
return this.#call("POST", `/v1/contacts/${encodeURIComponent(contactId)}/sign-in-link`, {
|
|
101
|
+
body: request,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
/** `GET /v1/numbers/public?environment=`: the number to publish for new people. */
|
|
105
|
+
getPublicNumber(environment = "production") {
|
|
106
|
+
return this.#call("GET", "/v1/numbers/public", { query: { environment } });
|
|
107
|
+
}
|
|
108
|
+
// ---- Transport ----------------------------------------------------------
|
|
109
|
+
async #call(method, path, init = {}) {
|
|
110
|
+
const url = new URL(this.#baseUrl + path);
|
|
111
|
+
for (const [name, value] of Object.entries(init.query ?? {}))
|
|
112
|
+
url.searchParams.set(name, value);
|
|
113
|
+
// A minted token the server no longer accepts (revoked, rotated key) is replaced once.
|
|
114
|
+
for (let attempt = 0;; attempt++) {
|
|
115
|
+
const token = await this.#bearer();
|
|
116
|
+
const response = await this.#send(url, {
|
|
117
|
+
method,
|
|
118
|
+
headers: {
|
|
119
|
+
Authorization: `Bearer ${token}`,
|
|
120
|
+
Accept: "application/json",
|
|
121
|
+
...(init.body === undefined
|
|
122
|
+
? {}
|
|
123
|
+
: { "Content-Type": "application/json" }),
|
|
124
|
+
},
|
|
125
|
+
...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }),
|
|
126
|
+
});
|
|
127
|
+
if (response.status === 401 && attempt === 0 && this.#mints()) {
|
|
128
|
+
this.#cached = undefined;
|
|
129
|
+
await response.body?.cancel();
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (!response.ok)
|
|
133
|
+
throw await errorFrom(response);
|
|
134
|
+
if (response.status === 204)
|
|
135
|
+
return undefined;
|
|
136
|
+
const text = await response.text();
|
|
137
|
+
try {
|
|
138
|
+
return JSON.parse(text);
|
|
139
|
+
}
|
|
140
|
+
catch (cause) {
|
|
141
|
+
throw new CommsError("unexpected_response", "Comms answered with a body that is not JSON", response.status, { retryable: false, cause });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
async #send(url, init) {
|
|
146
|
+
try {
|
|
147
|
+
return await this.#fetch(url, {
|
|
148
|
+
...init,
|
|
149
|
+
signal: AbortSignal.timeout(this.#timeoutMs),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
catch (cause) {
|
|
153
|
+
if (cause instanceof Error &&
|
|
154
|
+
(cause.name === "TimeoutError" || cause.name === "AbortError"))
|
|
155
|
+
throw new CommsError("timeout", `Request timed out after ${this.#timeoutMs} ms`, 0, {
|
|
156
|
+
cause,
|
|
157
|
+
});
|
|
158
|
+
throw new CommsError("network_error", `Request failed: ${describe(cause)}`, 0, { cause });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// ---- Directory token ----------------------------------------------------
|
|
162
|
+
#mints() {
|
|
163
|
+
return this.#fixedToken === undefined && this.#getToken === undefined;
|
|
164
|
+
}
|
|
165
|
+
async #bearer() {
|
|
166
|
+
if (this.#fixedToken !== undefined)
|
|
167
|
+
return this.#fixedToken;
|
|
168
|
+
if (this.#getToken)
|
|
169
|
+
return this.#getToken();
|
|
170
|
+
const cached = this.#cached;
|
|
171
|
+
if (cached && this.#now() < cached.expiresAtMs - REFRESH_MARGIN_MS)
|
|
172
|
+
return cached.value;
|
|
173
|
+
// Concurrent callers share one token request.
|
|
174
|
+
this.#inflight ??= this.#mint().finally(() => {
|
|
175
|
+
this.#inflight = undefined;
|
|
176
|
+
});
|
|
177
|
+
return this.#inflight;
|
|
178
|
+
}
|
|
179
|
+
async #endpoint() {
|
|
180
|
+
this.#tokenEndpoint ??= this.#discover().catch((error) => {
|
|
181
|
+
this.#tokenEndpoint = undefined;
|
|
182
|
+
throw error;
|
|
183
|
+
});
|
|
184
|
+
return this.#tokenEndpoint;
|
|
185
|
+
}
|
|
186
|
+
async #discover() {
|
|
187
|
+
const url = `${this.#issuer}/.well-known/openid-configuration`;
|
|
188
|
+
const response = await this.#send(url, {
|
|
189
|
+
headers: { Accept: "application/json" },
|
|
190
|
+
});
|
|
191
|
+
const body = await response.json().catch(() => null);
|
|
192
|
+
if (!response.ok ||
|
|
193
|
+
!isRecord(body) ||
|
|
194
|
+
typeof body["token_endpoint"] !== "string")
|
|
195
|
+
throw new CommsError("token_discovery_failed", `Could not read the token endpoint from ${url}`, response.status, { retryable: response.status >= 500 });
|
|
196
|
+
return body["token_endpoint"];
|
|
197
|
+
}
|
|
198
|
+
async #signingKey() {
|
|
199
|
+
const jwk = this.#jwk;
|
|
200
|
+
if (!jwk)
|
|
201
|
+
throw new TypeError("no private key");
|
|
202
|
+
this.#key ??= crypto.subtle.importKey("jwk", { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y, d: jwk.d }, { name: "ECDSA", namedCurve: "P-256" }, false, ["sign"]);
|
|
203
|
+
return this.#key;
|
|
204
|
+
}
|
|
205
|
+
async #assertion(audience) {
|
|
206
|
+
const clientId = this.#clientId;
|
|
207
|
+
if (!clientId)
|
|
208
|
+
throw new TypeError("no client id");
|
|
209
|
+
const iat = Math.floor(this.#now() / 1000);
|
|
210
|
+
const header = {
|
|
211
|
+
alg: "ES256",
|
|
212
|
+
typ: "JWT",
|
|
213
|
+
...(this.#jwk?.kid ? { kid: this.#jwk.kid } : {}),
|
|
214
|
+
};
|
|
215
|
+
const claims = {
|
|
216
|
+
iss: clientId,
|
|
217
|
+
sub: clientId,
|
|
218
|
+
aud: audience,
|
|
219
|
+
iat,
|
|
220
|
+
exp: iat + ASSERTION_LIFETIME_S,
|
|
221
|
+
jti: crypto.randomUUID(),
|
|
222
|
+
};
|
|
223
|
+
const input = `${json(header)}.${json(claims)}`;
|
|
224
|
+
// WebCrypto's ECDSA output is the raw r||s form JWS requires.
|
|
225
|
+
const signature = await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, await this.#signingKey(), new TextEncoder().encode(input));
|
|
226
|
+
return `${input}.${base64url(new Uint8Array(signature))}`;
|
|
227
|
+
}
|
|
228
|
+
async #mint() {
|
|
229
|
+
const endpoint = await this.#endpoint();
|
|
230
|
+
const requestedAt = this.#now();
|
|
231
|
+
const response = await this.#send(endpoint, {
|
|
232
|
+
method: "POST",
|
|
233
|
+
headers: {
|
|
234
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
235
|
+
Accept: "application/json",
|
|
236
|
+
},
|
|
237
|
+
body: new URLSearchParams({
|
|
238
|
+
grant_type: "client_credentials",
|
|
239
|
+
resource: this.#resource,
|
|
240
|
+
client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
|
|
241
|
+
client_assertion: await this.#assertion(endpoint),
|
|
242
|
+
}),
|
|
243
|
+
});
|
|
244
|
+
const body = await response.json().catch(() => null);
|
|
245
|
+
if (!response.ok) {
|
|
246
|
+
const error = isRecord(body) && typeof body["error"] === "string"
|
|
247
|
+
? body["error"]
|
|
248
|
+
: undefined;
|
|
249
|
+
const description = isRecord(body) && typeof body["error_description"] === "string"
|
|
250
|
+
? body["error_description"]
|
|
251
|
+
: undefined;
|
|
252
|
+
throw new CommsError(error ?? "token_request_failed", `The directory refused the token request${description ? `: ${description}` : ""}`, response.status,
|
|
253
|
+
// An invalid_client will not heal on retry; a directory outage will.
|
|
254
|
+
{ retryable: response.status >= 500 || response.status === 429 });
|
|
255
|
+
}
|
|
256
|
+
if (!isRecord(body) ||
|
|
257
|
+
typeof body["access_token"] !== "string" ||
|
|
258
|
+
typeof body["expires_in"] !== "number")
|
|
259
|
+
throw new CommsError("token_request_failed", "The directory's token response had no access_token and expires_in", response.status, { retryable: false });
|
|
260
|
+
this.#cached = {
|
|
261
|
+
value: body["access_token"],
|
|
262
|
+
expiresAtMs: requestedAt + body["expires_in"] * 1000,
|
|
263
|
+
};
|
|
264
|
+
return body["access_token"];
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
function describe(error) {
|
|
268
|
+
if (error instanceof Error) {
|
|
269
|
+
const cause = error.cause instanceof Error ? `: ${error.cause.message}` : "";
|
|
270
|
+
return `${error.message}${cause}`;
|
|
271
|
+
}
|
|
272
|
+
return String(error);
|
|
273
|
+
}
|
|
274
|
+
async function errorFrom(response) {
|
|
275
|
+
const text = await response.text().catch(() => "");
|
|
276
|
+
let body;
|
|
277
|
+
try {
|
|
278
|
+
body = JSON.parse(text);
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
body = undefined;
|
|
282
|
+
}
|
|
283
|
+
const error = isRecord(body)
|
|
284
|
+
? body.error
|
|
285
|
+
: undefined;
|
|
286
|
+
const retryAfterHeader = Number(response.headers.get("retry-after"));
|
|
287
|
+
if (isRecord(error) &&
|
|
288
|
+
typeof error.code === "string" &&
|
|
289
|
+
typeof error.message === "string")
|
|
290
|
+
return new CommsError(error.code, error.message, response.status, {
|
|
291
|
+
...(typeof error.retryable === "boolean"
|
|
292
|
+
? { retryable: error.retryable }
|
|
293
|
+
: {}),
|
|
294
|
+
...(typeof error.request_id === "string"
|
|
295
|
+
? { requestId: error.request_id }
|
|
296
|
+
: {}),
|
|
297
|
+
...(typeof error.param === "string" ? { param: error.param } : {}),
|
|
298
|
+
...(error.details === undefined ? {} : { details: error.details }),
|
|
299
|
+
...(typeof error.retry_after_s === "number"
|
|
300
|
+
? { retryAfterS: error.retry_after_s }
|
|
301
|
+
: Number.isFinite(retryAfterHeader) &&
|
|
302
|
+
response.headers.has("retry-after")
|
|
303
|
+
? { retryAfterS: retryAfterHeader }
|
|
304
|
+
: {}),
|
|
305
|
+
});
|
|
306
|
+
return new CommsError("unexpected_response", `Comms answered ${response.status} with a body that is not the error envelope`, response.status);
|
|
307
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type FakeServer } from "./server.js";
|
|
2
|
+
export declare class UsageError extends Error {
|
|
3
|
+
readonly name = "UsageError";
|
|
4
|
+
}
|
|
5
|
+
export interface CliIo {
|
|
6
|
+
out: (line: string) => void;
|
|
7
|
+
/** Resolves when the server should stop (a signal). Default: never, until the process is signalled. */
|
|
8
|
+
stopped?: Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Runs the CLI. `comms fake` resolves with the running server (the caller keeps the process
|
|
12
|
+
* alive); `comms fake send` resolves with `undefined` once the fake has answered.
|
|
13
|
+
*/
|
|
14
|
+
export declare function run(argv: readonly string[], io: CliIo): Promise<FakeServer | undefined>;
|