@fonoster/sipnet 0.36.0 → 0.37.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.
@@ -19,8 +19,14 @@
19
19
  import { NumberPreconditionsCheck } from "@fonoster/common";
20
20
  import { ClientOptions } from "../types";
21
21
  import { NumberRouting } from "./engineRouting";
22
- /** `routing` picks the peer app-linked numbers point at (default: the roomr peer). */
23
- declare function buildService(clientOptions: ClientOptions, checkNumberPreconditions: NumberPreconditionsCheck, routing?: NumberRouting): {
22
+ /**
23
+ * `routing` picks the peer app-linked numbers point at (default: the roomr peer). `twilioLink`
24
+ * is the install's PSTN root and public signaling addresses, for LinkTwilio.
25
+ */
26
+ declare function buildService(clientOptions: ClientOptions, checkNumberPreconditions: NumberPreconditionsCheck, routing?: NumberRouting, twilioLink?: {
27
+ pstnRoot: string;
28
+ signalingAddresses: string[];
29
+ }): {
24
30
  definition: {
25
31
  serviceName: string;
26
32
  pckg: string;
@@ -43,6 +49,9 @@ declare function buildService(clientOptions: ClientOptions, checkNumberPrecondit
43
49
  deleteNumber: (call: {
44
50
  request: unknown;
45
51
  }, callback: (error?: import("@fonoster/common").GrpcErrorMessage, response?: unknown) => void) => Promise<void>;
52
+ linkTwilio: (call: {
53
+ request: unknown;
54
+ }, callback: (error?: import("@fonoster/common").GrpcErrorMessage, response?: unknown) => void) => Promise<void>;
46
55
  };
47
56
  };
48
57
  export { buildService };
@@ -8,11 +8,18 @@ const sdk_1 = __importDefault(require("@routr/sdk"));
8
8
  const checkRefs_1 = require("../resources/checkRefs");
9
9
  const createNumber_1 = require("./createNumber");
10
10
  const getNumber_1 = require("./getNumber");
11
+ const linkTwilio_1 = require("./linkTwilio");
11
12
  const listNumbers_1 = require("./listNumbers");
12
13
  const operations_1 = require("./operations");
13
14
  const updateNumber_1 = require("./updateNumber");
14
- /** `routing` picks the peer app-linked numbers point at (default: the roomr peer). */
15
- function buildService(clientOptions, checkNumberPreconditions, routing) {
15
+ /**
16
+ * `routing` picks the peer app-linked numbers point at (default: the roomr peer). `twilioLink`
17
+ * is the install's PSTN root and public signaling addresses, for LinkTwilio.
18
+ */
19
+ function buildService(clientOptions, checkNumberPreconditions, routing, twilioLink = {
20
+ pstnRoot: "",
21
+ signalingAddresses: []
22
+ }) {
16
23
  const client = new sdk_1.default.Numbers(clientOptions);
17
24
  const checkRefs = (0, checkRefs_1.createCheckRefs)((0, checkRefs_1.createRefLookups)(clientOptions));
18
25
  return {
@@ -27,7 +34,10 @@ function buildService(clientOptions, checkNumberPreconditions, routing) {
27
34
  updateNumber: (0, updateNumber_1.updateNumber)(client, checkNumberPreconditions, routing, checkRefs),
28
35
  getNumber: (0, getNumber_1.getNumber)(client),
29
36
  listNumbers: (0, listNumbers_1.listNumbers)(client),
30
- deleteNumber: (0, operations_1.deleteNumber)(client)
37
+ deleteNumber: (0, operations_1.deleteNumber)(client),
38
+ linkTwilio: (0, linkTwilio_1.linkTwilio)(Object.assign({ numbers: client, trunks: new sdk_1.default.Trunks(clientOptions), credentials: new sdk_1.default.Credentials(clientOptions), checkNumberPreconditions,
39
+ routing,
40
+ checkRefs }, twilioLink))
31
41
  }
32
42
  };
33
43
  }
@@ -0,0 +1,26 @@
1
+ import { assignTwilioNumberToTrunk, GrpcErrorMessage, NumberPreconditionsCheck, provisionTwilioTrunk, removeOtherTwilioSipCredentials } from "@fonoster/common";
2
+ import { CredentialsApi, NumbersApi, TrunkApi } from "@fonoster/types";
3
+ import { CheckRefs } from "../resources/checkRefs";
4
+ import { NumberRouting } from "./engineRouting";
5
+ type TwilioLink = {
6
+ provisionTwilioTrunk: typeof provisionTwilioTrunk;
7
+ assignTwilioNumberToTrunk: typeof assignTwilioNumberToTrunk;
8
+ removeOtherTwilioSipCredentials: typeof removeOtherTwilioSipCredentials;
9
+ };
10
+ type LinkTwilioDeps = {
11
+ numbers: NumbersApi;
12
+ trunks: TrunkApi;
13
+ credentials: CredentialsApi;
14
+ checkNumberPreconditions: NumberPreconditionsCheck;
15
+ routing?: NumberRouting;
16
+ checkRefs?: CheckRefs;
17
+ /** Where carriers reach this install's trunks (`pstn.<domain>`). */
18
+ pstnRoot: string;
19
+ /** The install's public signaling addresses; only IPv4 literals are allowed in Twilio. */
20
+ signalingAddresses: string[];
21
+ twilio?: TwilioLink;
22
+ };
23
+ declare function linkTwilio(deps: LinkTwilioDeps): (call: {
24
+ request: unknown;
25
+ }, callback: (error?: import("@fonoster/common").GrpcErrorMessage, response?: unknown) => void) => Promise<void>;
26
+ export { linkTwilio, LinkTwilioDeps };
@@ -0,0 +1,228 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ var __importDefault = (this && this.__importDefault) || function (mod) {
12
+ return (mod && mod.__esModule) ? mod : { "default": mod };
13
+ };
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.linkTwilio = linkTwilio;
16
+ /**
17
+ * Copyright (C) 2025 by Fonoster Inc (https://fonoster.com)
18
+ * http://github.com/fonoster/fonoster
19
+ *
20
+ * This file is part of Fonoster
21
+ *
22
+ * Licensed under the MIT License (the "License");
23
+ * you may not use this file except in compliance with
24
+ * the License. You may obtain a copy of the License at
25
+ *
26
+ * https://opensource.org/licenses/MIT
27
+ *
28
+ * Unless required by applicable law or agreed to in writing, software
29
+ * distributed under the License is distributed on an "AS IS" BASIS,
30
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
31
+ * See the License for the specific language governing permissions and
32
+ * limitations under the License.
33
+ */
34
+ const node_crypto_1 = require("node:crypto");
35
+ const node_net_1 = __importDefault(require("node:net"));
36
+ const common_1 = require("@fonoster/common");
37
+ const logger_1 = require("@fonoster/logger");
38
+ const types_1 = require("@fonoster/types");
39
+ const grpc_js_1 = require("@grpc/grpc-js");
40
+ const phone_1 = require("phone");
41
+ const checkRefs_1 = require("../resources/checkRefs");
42
+ const paginationUtils_1 = require("../resources/paginationUtils");
43
+ const operations_1 = require("../trunks/operations");
44
+ const convertToRoutrNumber_1 = require("./convertToRoutrNumber");
45
+ const twilioLinkErrorToGrpc_1 = require("./twilioLinkErrorToGrpc");
46
+ const logger = (0, logger_1.getLogger)({ service: "sipnet", filePath: __filename });
47
+ const TWILIO_PSTN_URI_BASE = "pstn.twilio.com";
48
+ const PAGE_SIZE = 100;
49
+ const PASSWORD_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
50
+ const isIpv4Entry = (address) => {
51
+ const [ip, prefix, ...rest] = address.split("/");
52
+ return (rest.length === 0 &&
53
+ node_net_1.default.isIPv4(ip) &&
54
+ (prefix === undefined || (/^\d{1,2}$/.test(prefix) && +prefix <= 32)));
55
+ };
56
+ function generatePassword() {
57
+ for (;;) {
58
+ const password = Array.from({ length: 24 }, () => PASSWORD_ALPHABET[(0, node_crypto_1.randomInt)(PASSWORD_ALPHABET.length)]).join("");
59
+ if (/[A-Z]/.test(password) &&
60
+ /[a-z]/.test(password) &&
61
+ /\d/.test(password)) {
62
+ return password;
63
+ }
64
+ }
65
+ }
66
+ // The caller's first resource that matches, through every page Routr has.
67
+ function findOwned(list, accessKeyId, match) {
68
+ return __awaiter(this, void 0, void 0, function* () {
69
+ let pageToken;
70
+ for (;;) {
71
+ const { items = [], nextPageToken } = yield list({
72
+ pageSize: PAGE_SIZE,
73
+ pageToken
74
+ });
75
+ const found = (0, paginationUtils_1.filterByAccessKeyId)(items, accessKeyId).find(match);
76
+ if (found || !nextPageToken || items.length < PAGE_SIZE) {
77
+ return found;
78
+ }
79
+ pageToken = nextPageToken;
80
+ }
81
+ });
82
+ }
83
+ function linkTwilio(deps) {
84
+ const { numbers, trunks, credentials, checkNumberPreconditions, routing, checkRefs, pstnRoot, twilio = {
85
+ provisionTwilioTrunk: common_1.provisionTwilioTrunk,
86
+ assignTwilioNumberToTrunk: common_1.assignTwilioNumberToTrunk,
87
+ removeOtherTwilioSipCredentials: common_1.removeOtherTwilioSipCredentials
88
+ } } = deps;
89
+ const aclEntries = deps.signalingAddresses.filter(isIpv4Entry);
90
+ const fn = (call, callback) => __awaiter(this, void 0, void 0, function* () {
91
+ const { request } = call;
92
+ const { phoneNumber, appRef, agentAor } = request;
93
+ const twilioCredentials = request.credentials;
94
+ const accessKeyId = (0, common_1.getAccessKeyIdFromCall)(call);
95
+ const label = accessKeyId.toLowerCase();
96
+ const inboundUri = `${label}.${pstnRoot}`;
97
+ const telUrl = `tel:${phoneNumber}`;
98
+ const context = {
99
+ accountSid: twilioCredentials.accountSid,
100
+ phoneNumber,
101
+ label
102
+ };
103
+ // Never the request itself: it carries the Twilio secret.
104
+ logger.verbose("call to linkTwilio", {
105
+ phoneNumber,
106
+ credentialKind: "authToken" in twilioCredentials && twilioCredentials.authToken
107
+ ? "authToken"
108
+ : "apiKey"
109
+ });
110
+ try {
111
+ // Validates that the appRef exists in the caller's workspace (RV-B4)
112
+ yield checkNumberPreconditions({ appRef, accessKeyId });
113
+ }
114
+ catch (e) {
115
+ // The contract reports an app outside the workspace as NOT_FOUND, with the usual message.
116
+ if (e instanceof common_1.GrpcError && e.code === grpc_js_1.status.INVALID_ARGUMENT) {
117
+ callback({ code: grpc_js_1.status.NOT_FOUND, message: e.message });
118
+ return;
119
+ }
120
+ throw e;
121
+ }
122
+ yield (checkRefs === null || checkRefs === void 0 ? void 0 : checkRefs(accessKeyId, (0, checkRefs_1.numberRefs)(request)));
123
+ if (aclEntries.length === 0) {
124
+ callback({
125
+ code: grpc_js_1.status.FAILED_PRECONDITION,
126
+ message: "This install has no public SIP address to allow in Twilio"
127
+ });
128
+ return;
129
+ }
130
+ const sipCredential = {
131
+ username: `fonoster-${(0, node_crypto_1.randomBytes)(4).toString("hex")}`,
132
+ password: generatePassword()
133
+ };
134
+ let twilioTrunkSid;
135
+ try {
136
+ ({ twilioTrunkSid } = yield twilio.provisionTwilioTrunk(twilioCredentials, {
137
+ phoneNumber,
138
+ label,
139
+ originationUri: `sip:${inboundUri}`,
140
+ aclEntries,
141
+ sipCredential
142
+ }));
143
+ }
144
+ catch (e) {
145
+ callback((0, twilioLinkErrorToGrpc_1.twilioLinkErrorToGrpc)(e, context));
146
+ return;
147
+ }
148
+ const { ref: credentialsRef } = yield credentials.createCredentials(Object.assign(Object.assign({ name: `Twilio ${label}` }, sipCredential), { extended: { accessKeyId } }));
149
+ const trunk = yield findOwned((r) => trunks.listTrunks(r), accessKeyId, (t) => t.inboundUri === inboundUri);
150
+ let trunkRef;
151
+ if (trunk) {
152
+ yield trunks.updateTrunk((0, operations_1.mergeTrunkUpdate)({ ref: trunk.ref, outboundCredentialsRef: credentialsRef }, trunk));
153
+ trunkRef = trunk.ref;
154
+ }
155
+ else {
156
+ ({ ref: trunkRef } = yield trunks.createTrunk({
157
+ name: "Twilio Trunk",
158
+ inboundUri,
159
+ sendRegister: false,
160
+ outboundCredentialsRef: credentialsRef,
161
+ uris: [
162
+ {
163
+ host: `${label}.${TWILIO_PSTN_URI_BASE}`,
164
+ port: 5060,
165
+ transport: types_1.Transport.UDP,
166
+ enabled: true,
167
+ weight: 10,
168
+ priority: 10
169
+ }
170
+ ],
171
+ extended: { accessKeyId }
172
+ }));
173
+ }
174
+ const number = yield findOwned((r) => numbers.listNumbers(r), accessKeyId, (n) => n.telUrl === telUrl);
175
+ const appPeerAor = routing && appRef ? yield routing.aorFor({ appRef, telUrl }) : undefined;
176
+ let ref;
177
+ if (number) {
178
+ yield numbers.updateNumber((0, convertToRoutrNumber_1.convertToRoutrNumberUpdate)({
179
+ ref: number.ref,
180
+ name: request.name || number.name,
181
+ trunkRef,
182
+ appRef,
183
+ agentAor
184
+ }, appPeerAor));
185
+ ref = number.ref;
186
+ }
187
+ else {
188
+ const { countryIso2, countryIso3 } = (0, phone_1.phone)(phoneNumber);
189
+ ({ ref } = yield numbers.createNumber((0, convertToRoutrNumber_1.convertToRoutrNumber)({
190
+ name: request.name || phoneNumber,
191
+ telUrl,
192
+ city: "unknown",
193
+ country: countryIso3,
194
+ countryIsoCode: countryIso2,
195
+ trunkRef,
196
+ appRef,
197
+ agentAor
198
+ }, accessKeyId, appPeerAor)));
199
+ }
200
+ // The last write to Twilio: until it succeeds, the number keeps its previous routing.
201
+ try {
202
+ yield twilio.assignTwilioNumberToTrunk(twilioCredentials, {
203
+ phoneNumber,
204
+ twilioTrunkSid
205
+ });
206
+ }
207
+ catch (e) {
208
+ callback((0, twilioLinkErrorToGrpc_1.twilioLinkErrorToGrpc)(e, context));
209
+ return;
210
+ }
211
+ try {
212
+ yield twilio.removeOtherTwilioSipCredentials(twilioCredentials, {
213
+ label,
214
+ keepUsername: sipCredential.username
215
+ });
216
+ if (trunk === null || trunk === void 0 ? void 0 : trunk.outboundCredentialsRef) {
217
+ yield credentials.deleteCredentials(trunk.outboundCredentialsRef);
218
+ }
219
+ }
220
+ catch (_a) {
221
+ logger.warn("could not remove the previous SIP credentials", {
222
+ phoneNumber
223
+ });
224
+ }
225
+ callback(null, { ref, trunkRef });
226
+ });
227
+ return (0, common_1.withErrorHandlingAndValidation)(fn, common_1.Validators.linkTwilioRequestSchema);
228
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Copyright (C) 2025 by Fonoster Inc (https://fonoster.com)
3
+ * http://github.com/fonoster/fonoster
4
+ *
5
+ * This file is part of Fonoster
6
+ *
7
+ * Licensed under the MIT License (the "License");
8
+ * you may not use this file except in compliance with
9
+ * the License. You may obtain a copy of the License at
10
+ *
11
+ * https://opensource.org/licenses/MIT
12
+ *
13
+ * Unless required by applicable law or agreed to in writing, software
14
+ * distributed under the License is distributed on an "AS IS" BASIS,
15
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ * See the License for the specific language governing permissions and
17
+ * limitations under the License.
18
+ */
19
+ import { GrpcErrorMessage } from "@fonoster/common";
20
+ /**
21
+ * The gRPC error for a failed Twilio call. Clients place errors by these messages, so they are
22
+ * part of the contract. Twilio's own text never reaches the caller or the logs, and anything
23
+ * unrecognized is reported as Twilio being unavailable.
24
+ */
25
+ declare function twilioLinkErrorToGrpc(error: unknown, context: {
26
+ accountSid: string;
27
+ phoneNumber: string;
28
+ label: string;
29
+ }): GrpcErrorMessage;
30
+ export { twilioLinkErrorToGrpc };
@@ -0,0 +1,65 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.twilioLinkErrorToGrpc = twilioLinkErrorToGrpc;
4
+ /**
5
+ * Copyright (C) 2025 by Fonoster Inc (https://fonoster.com)
6
+ * http://github.com/fonoster/fonoster
7
+ *
8
+ * This file is part of Fonoster
9
+ *
10
+ * Licensed under the MIT License (the "License");
11
+ * you may not use this file except in compliance with
12
+ * the License. You may obtain a copy of the License at
13
+ *
14
+ * https://opensource.org/licenses/MIT
15
+ *
16
+ * Unless required by applicable law or agreed to in writing, software
17
+ * distributed under the License is distributed on an "AS IS" BASIS,
18
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
19
+ * See the License for the specific language governing permissions and
20
+ * limitations under the License.
21
+ */
22
+ const common_1 = require("@fonoster/common");
23
+ const grpc_js_1 = require("@grpc/grpc-js");
24
+ const UNAVAILABLE = {
25
+ code: grpc_js_1.status.UNAVAILABLE,
26
+ message: "Twilio is unavailable; try again"
27
+ };
28
+ // What a key without the permission for a Twilio call can't do, by the call's prefix
29
+ function missingPermission(step = "") {
30
+ if (step.startsWith("sip.")) {
31
+ return "manage SIP IP access control lists and credential lists";
32
+ }
33
+ if (step.startsWith("incomingPhoneNumbers.")) {
34
+ return "read or update phone numbers";
35
+ }
36
+ return "manage Elastic SIP Trunking";
37
+ }
38
+ /**
39
+ * The gRPC error for a failed Twilio call. Clients place errors by these messages, so they are
40
+ * part of the contract. Twilio's own text never reaches the caller or the logs, and anything
41
+ * unrecognized is reported as Twilio being unavailable.
42
+ */
43
+ function twilioLinkErrorToGrpc(error, context) {
44
+ if (!(error instanceof common_1.TwilioLinkError)) {
45
+ return UNAVAILABLE;
46
+ }
47
+ const failedPrecondition = (message) => ({
48
+ code: grpc_js_1.status.FAILED_PRECONDITION,
49
+ message
50
+ });
51
+ switch (error.reason) {
52
+ case "INVALID_CREDENTIALS":
53
+ return { code: grpc_js_1.status.INVALID_ARGUMENT, message: error.message };
54
+ case "CREDENTIALS_REFUSED":
55
+ return failedPrecondition("Twilio didn't accept these credentials");
56
+ case "PERMISSION_MISSING":
57
+ return failedPrecondition(`This Twilio key can't ${missingPermission(error.step)}. Twilio's Restricted keys can't link numbers; use a Standard API key or the Auth Token`);
58
+ case "NUMBER_NOT_FOUND":
59
+ return failedPrecondition(`${context.phoneNumber} isn't in Twilio account AC…${context.accountSid.slice(-4)}`);
60
+ case "DOMAIN_TAKEN":
61
+ return failedPrecondition(`Another Twilio account already uses ${context.label}.pstn.twilio.com; link from the Twilio account this workspace linked first`);
62
+ default:
63
+ return UNAVAILABLE;
64
+ }
65
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fonoster/sipnet",
3
- "version": "0.36.0",
3
+ "version": "0.37.0",
4
4
  "description": "Routr-based SIP stack",
5
5
  "author": "Pedro Sanders <psanders@fonoster.com>",
6
6
  "homepage": "https://github.com/fonoster/fonoster#readme",
@@ -19,12 +19,13 @@
19
19
  "fonoster": "./dist/index.js"
20
20
  },
21
21
  "dependencies": {
22
- "@fonoster/common": "^0.36.0",
23
- "@fonoster/identity": "^0.36.0",
24
- "@fonoster/logger": "^0.36.0",
25
- "@fonoster/types": "^0.36.0",
22
+ "@fonoster/common": "^0.37.0",
23
+ "@fonoster/identity": "^0.37.0",
24
+ "@fonoster/logger": "^0.37.0",
25
+ "@fonoster/types": "^0.37.0",
26
26
  "@grpc/grpc-js": "~1.14.6",
27
27
  "@routr/sdk": "2.24.0",
28
+ "phone": "^3.1.72",
28
29
  "zod": "^3.23.8"
29
30
  },
30
31
  "files": [
@@ -40,5 +41,5 @@
40
41
  "bugs": {
41
42
  "url": "https://github.com/fonoster/fonoster/issues"
42
43
  },
43
- "gitHead": "939b219d83506d7480c60813b3e58034cbede14f"
44
+ "gitHead": "e5ffbec7f8864b75df09ec414d16124e0ad379e7"
44
45
  }