@cosmoops/connect-sdk 0.20.0 → 0.21.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/CHANGELOG.md +117 -0
- package/README.md +2 -0
- package/dist/cjs/client/components/identity.js +24 -0
- package/dist/cjs/client/components/one-tap-state.js +96 -0
- package/dist/cjs/client/components/one-tap.js +106 -0
- package/dist/cjs/client/components/user-menu.js +2 -6
- package/dist/cjs/client/index.js +5 -3
- package/dist/cjs/client/provider.js +88 -0
- package/dist/cjs/server/client.js +9 -1
- package/dist/cjs/server/handler.js +126 -2
- package/dist/cjs/server/resources/calls.js +57 -0
- package/dist/cjs/server/resources/channels.js +115 -0
- package/dist/cjs/server/resources/identity.js +40 -0
- package/dist/esm/client/components/identity.js +21 -0
- package/dist/esm/client/components/one-tap-state.js +90 -0
- package/dist/esm/client/components/one-tap.js +103 -0
- package/dist/esm/client/components/user-menu.js +1 -5
- package/dist/esm/client/index.js +1 -0
- package/dist/esm/client/provider.js +88 -0
- package/dist/esm/server/client.js +9 -1
- package/dist/esm/server/handler.js +127 -3
- package/dist/esm/server/resources/calls.js +53 -0
- package/dist/esm/server/resources/channels.js +111 -0
- package/dist/esm/server/resources/identity.js +39 -0
- package/dist/types/client/components/identity.d.ts +17 -0
- package/dist/types/client/components/one-tap-state.d.ts +39 -0
- package/dist/types/client/components/one-tap.d.ts +50 -0
- package/dist/types/client/index.d.ts +3 -1
- package/dist/types/client/provider.d.ts +81 -0
- package/dist/types/server/client.d.ts +7 -1
- package/dist/types/server/index.d.ts +2 -0
- package/dist/types/server/resources/calls.d.ts +45 -0
- package/dist/types/server/resources/channels.d.ts +91 -0
- package/dist/types/server/resources/identity.d.ts +35 -0
- package/dist/types/types.d.ts +172 -0
- package/package.json +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
3
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { clearOneTapSuppression, suppressOneTapAfterSignOut } from "./components/one-tap-state.js";
|
|
4
5
|
export const CONNECT_APP_URL = "https://connect.cosmoops.com";
|
|
5
6
|
const ConnectContext = createContext(null);
|
|
6
7
|
/** Marks that this tab has already spent its one silent attempt - see `ConnectProviderProps.silentSignIn`. */
|
|
@@ -185,8 +186,91 @@ export function ConnectProvider({ clientId = "", basePath = "/api/connect", redi
|
|
|
185
186
|
credentials: "same-origin",
|
|
186
187
|
}).catch(() => { });
|
|
187
188
|
cache.current.clear();
|
|
189
|
+
// Someone who just signed out must not be offered the same account back on
|
|
190
|
+
// the next render - see `suppressOneTapAfterSignOut`.
|
|
191
|
+
suppressOneTapAfterSignOut();
|
|
188
192
|
setResult({ session: { user: null, subscription: null, role: null, permissions: [], staff: false, audience: null }, error: null });
|
|
189
193
|
}, [basePath, fetchImpl]);
|
|
194
|
+
/**
|
|
195
|
+
* Where Connect answers One Tap, taken from the session read rather than
|
|
196
|
+
* assembled here - the bridge knows the `client_id` and the browser need not.
|
|
197
|
+
*/
|
|
198
|
+
const oneTapEndpoints = result?.session.oneTap ?? null;
|
|
199
|
+
const readOneTapHint = useCallback(async () => {
|
|
200
|
+
if (!oneTapEndpoints)
|
|
201
|
+
return null;
|
|
202
|
+
try {
|
|
203
|
+
// `credentials: "include"` because this is the one request in the SDK
|
|
204
|
+
// that leaves the application's own origin. It carries the visitor's
|
|
205
|
+
// Connect cookie only when the two are same-site; everywhere else it is
|
|
206
|
+
// simply sent without one and comes back `signed_out`.
|
|
207
|
+
const response = await (fetchImpl ?? globalThis.fetch)(oneTapEndpoints.hintUrl, {
|
|
208
|
+
credentials: "include",
|
|
209
|
+
headers: { accept: "application/json" },
|
|
210
|
+
});
|
|
211
|
+
if (!response.ok)
|
|
212
|
+
return null;
|
|
213
|
+
return (await response.json());
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
// Offline, blocked, or Connect is down. None of those is evidence about
|
|
217
|
+
// whether this visitor has a session, so the honest answer is silence.
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
}, [fetchImpl, oneTapEndpoints]);
|
|
221
|
+
const oneTapSignIn = useCallback(async () => {
|
|
222
|
+
if (!oneTapEndpoints)
|
|
223
|
+
return { ok: false, reason: "unavailable" };
|
|
224
|
+
try {
|
|
225
|
+
// 1. The bridge mints the PKCE pair and keeps the verifier in an
|
|
226
|
+
// `HttpOnly` cookie on this origin.
|
|
227
|
+
const start = await mutateResource("/one-tap/start");
|
|
228
|
+
// 2. Connect mints a code against that challenge. The custom header is
|
|
229
|
+
// what forces the preflight, so an origin this client has not
|
|
230
|
+
// registered is refused before the request is ever sent.
|
|
231
|
+
const authorize = await (fetchImpl ?? globalThis.fetch)(oneTapEndpoints.authorizeUrl, {
|
|
232
|
+
method: "POST",
|
|
233
|
+
credentials: "include",
|
|
234
|
+
headers: {
|
|
235
|
+
accept: "application/json",
|
|
236
|
+
"content-type": "application/json",
|
|
237
|
+
"x-connect-one-tap": "1",
|
|
238
|
+
},
|
|
239
|
+
body: JSON.stringify({
|
|
240
|
+
redirect_uri: start.redirectUri,
|
|
241
|
+
code_challenge: start.codeChallenge,
|
|
242
|
+
}),
|
|
243
|
+
});
|
|
244
|
+
const body = (await authorize.json().catch(() => null));
|
|
245
|
+
if (!authorize.ok || !body?.code) {
|
|
246
|
+
// Connect names the reason, and each one has a different next step -
|
|
247
|
+
// `consent_required` wants the interactive screen, `rate_limited` wants
|
|
248
|
+
// to be left alone. Anything unrecognised is reported as a plain
|
|
249
|
+
// failure rather than guessed at.
|
|
250
|
+
const reason = body?.error;
|
|
251
|
+
return {
|
|
252
|
+
ok: false,
|
|
253
|
+
reason: reason === "login_required" ||
|
|
254
|
+
reason === "consent_required" ||
|
|
255
|
+
reason === "interaction_required" ||
|
|
256
|
+
reason === "rate_limited"
|
|
257
|
+
? reason
|
|
258
|
+
: "failed",
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
// 3. The bridge exchanges the code with the verifier the page never saw,
|
|
262
|
+
// and stores the session.
|
|
263
|
+
await mutateResource("/one-tap/complete", { code: body.code });
|
|
264
|
+
// The prompt was accepted, so whatever backoff earlier dismissals had
|
|
265
|
+
// accumulated is spent - it described reluctance that no longer holds.
|
|
266
|
+
clearOneTapSuppression();
|
|
267
|
+
await refresh();
|
|
268
|
+
return { ok: true };
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
return { ok: false, reason: "failed" };
|
|
272
|
+
}
|
|
273
|
+
}, [fetchImpl, mutateResource, oneTapEndpoints, refresh]);
|
|
190
274
|
const value = useMemo(() => ({
|
|
191
275
|
clientId,
|
|
192
276
|
appUrl,
|
|
@@ -200,6 +284,8 @@ export function ConnectProvider({ clientId = "", basePath = "/api/connect", redi
|
|
|
200
284
|
mutateResource,
|
|
201
285
|
invalidateResource,
|
|
202
286
|
streamResource,
|
|
287
|
+
readOneTapHint,
|
|
288
|
+
oneTapSignIn,
|
|
203
289
|
}), [
|
|
204
290
|
clientId,
|
|
205
291
|
appUrl,
|
|
@@ -213,6 +299,8 @@ export function ConnectProvider({ clientId = "", basePath = "/api/connect", redi
|
|
|
213
299
|
mutateResource,
|
|
214
300
|
invalidateResource,
|
|
215
301
|
streamResource,
|
|
302
|
+
readOneTapHint,
|
|
303
|
+
oneTapSignIn,
|
|
216
304
|
]);
|
|
217
305
|
return _jsx(ConnectContext.Provider, { value: value, children: children });
|
|
218
306
|
}
|
|
@@ -5,6 +5,8 @@ import { DataResource, storePassthrough } from "./resources/data.js";
|
|
|
5
5
|
import { WorkspacesResource } from "./resources/workspaces.js";
|
|
6
6
|
import { SchedulingResource } from "./resources/scheduling.js";
|
|
7
7
|
import { ContactsResource } from "./resources/contacts.js";
|
|
8
|
+
import { ChannelsResource } from "./resources/channels.js";
|
|
9
|
+
import { CallsResource } from "./resources/calls.js";
|
|
8
10
|
import { KvResource } from "./resources/kv.js";
|
|
9
11
|
import { BillingResource } from "./resources/billing.js";
|
|
10
12
|
import { DriveResource } from "./resources/drive.js";
|
|
@@ -82,6 +84,10 @@ export class ConnectClient {
|
|
|
82
84
|
scheduling;
|
|
83
85
|
/** Who a workspace already knows how to reach, by phone or by id. */
|
|
84
86
|
contacts;
|
|
87
|
+
/** The phone numbers Connect has assigned to your application, and where each one routes. */
|
|
88
|
+
channels;
|
|
89
|
+
/** The calls that arrived on those channels, with their transcripts. */
|
|
90
|
+
calls;
|
|
85
91
|
/** RAG chat and structured extraction. */
|
|
86
92
|
ai;
|
|
87
93
|
/** Your application's own document store - see `DataResource`. */
|
|
@@ -132,6 +138,8 @@ export class ConnectClient {
|
|
|
132
138
|
this.drive = new DriveResource(transport);
|
|
133
139
|
this.scheduling = new SchedulingResource(transport);
|
|
134
140
|
this.contacts = new ContactsResource(transport);
|
|
141
|
+
this.channels = new ChannelsResource(transport);
|
|
142
|
+
this.calls = new CallsResource(transport);
|
|
135
143
|
this.ai = new AiResource(transport);
|
|
136
144
|
this.data = new DataResource(transport);
|
|
137
145
|
this.kv = new KvResource(transport);
|
|
@@ -146,4 +154,4 @@ export class ConnectClient {
|
|
|
146
154
|
}
|
|
147
155
|
}
|
|
148
156
|
/** Kept in step with `package.json` by `sdk.test.ts`. */
|
|
149
|
-
export const SDK_VERSION = "0.
|
|
157
|
+
export const SDK_VERSION = "0.21.0";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ConnectError } from "../errors.js";
|
|
2
|
-
import { createState } from "./resources/identity.js";
|
|
2
|
+
import { createPkcePair, createState } from "./resources/identity.js";
|
|
3
3
|
/**
|
|
4
4
|
* A sentinel origin to resolve a candidate destination against. Never reached -
|
|
5
5
|
* `.invalid` is reserved by RFC 2606 precisely so it cannot resolve.
|
|
@@ -102,6 +102,7 @@ export function createConnectHandler(options) {
|
|
|
102
102
|
const defaultRedirect = options.defaultRedirect ?? "/";
|
|
103
103
|
const stateCookieName = options.stateCookieName ?? "connect_oauth_state";
|
|
104
104
|
const silentCookieName = `${stateCookieName}_silent`;
|
|
105
|
+
const pkceCookieName = `${stateCookieName}_pkce`;
|
|
105
106
|
const callbackPath = `${basePath}/callback`;
|
|
106
107
|
/**
|
|
107
108
|
* Short-lived, httpOnly, and `SameSite=Lax` - `Strict` would not be sent on
|
|
@@ -148,6 +149,35 @@ export function createConnectHandler(options) {
|
|
|
148
149
|
.filter(Boolean)
|
|
149
150
|
.join("; ");
|
|
150
151
|
}
|
|
152
|
+
/**
|
|
153
|
+
* Holds the One Tap PKCE **verifier** for the few seconds between the tap and
|
|
154
|
+
* the exchange.
|
|
155
|
+
*
|
|
156
|
+
* This is the cookie the whole no-navigation flow rests on. Connect hands the
|
|
157
|
+
* authorization code to the *page*, so the code alone must not be enough to
|
|
158
|
+
* mint a session - the verifier is what completes it, and it never leaves
|
|
159
|
+
* this origin's cookie jar. `SameSite=Lax` is sufficient and correct: unlike
|
|
160
|
+
* the state cookie there is no cross-site redirect that has to carry it, and
|
|
161
|
+
* `Lax` additionally means a cross-site `POST` to `/one-tap/complete` arrives
|
|
162
|
+
* without it and simply fails.
|
|
163
|
+
*
|
|
164
|
+
* Five minutes, not the state cookie's fifteen: it is minted on the tap and
|
|
165
|
+
* spent on the next request, so a long window is only a longer chance for a
|
|
166
|
+
* stale one to be lying around.
|
|
167
|
+
*/
|
|
168
|
+
function pkceCookie(value, secure) {
|
|
169
|
+
const cleared = value === "";
|
|
170
|
+
return [
|
|
171
|
+
`${pkceCookieName}=${encodeURIComponent(value)}`,
|
|
172
|
+
`Path=${basePath}`,
|
|
173
|
+
"HttpOnly",
|
|
174
|
+
"SameSite=Lax",
|
|
175
|
+
secure ? "Secure" : "",
|
|
176
|
+
cleared ? "Max-Age=0" : "Max-Age=300",
|
|
177
|
+
]
|
|
178
|
+
.filter(Boolean)
|
|
179
|
+
.join("; ");
|
|
180
|
+
}
|
|
151
181
|
async function requireToken(request) {
|
|
152
182
|
const token = await session.read(request);
|
|
153
183
|
if (!token) {
|
|
@@ -255,6 +285,14 @@ export function createConnectHandler(options) {
|
|
|
255
285
|
},
|
|
256
286
|
});
|
|
257
287
|
}
|
|
288
|
+
/**
|
|
289
|
+
* Where the browser asks Connect about the visitor, and where it asks for a
|
|
290
|
+
* code once they tap - or `null` when this deployment cannot run One Tap at
|
|
291
|
+
* all (no `clientId`, or a session store that cannot `write`, in which case
|
|
292
|
+
* `/one-tap/complete` does not exist and offering the prompt would strand
|
|
293
|
+
* anyone who used it).
|
|
294
|
+
*/
|
|
295
|
+
const oneTapEndpoints = session.write ? client.identity.oneTapEndpoints() : null;
|
|
258
296
|
const routes = {
|
|
259
297
|
"GET /session": async (request) => {
|
|
260
298
|
const token = await session.read(request);
|
|
@@ -264,9 +302,21 @@ export function createConnectHandler(options) {
|
|
|
264
302
|
// the client never has to tell "signed out" from "signed in with no
|
|
265
303
|
// role" by which keys happen to be present.
|
|
266
304
|
if (!token) {
|
|
267
|
-
return json({
|
|
305
|
+
return json({
|
|
306
|
+
user: null,
|
|
307
|
+
subscription: null,
|
|
308
|
+
role: null,
|
|
309
|
+
permissions: [],
|
|
310
|
+
staff: false,
|
|
311
|
+
// Carried on the read the provider already makes on mount, so One Tap
|
|
312
|
+
// costs no extra round trip to discover where to ask. Present on both
|
|
313
|
+
// branches even though only the signed-out one can use it: a response
|
|
314
|
+
// whose shape depends on the answer is a response consumers start
|
|
315
|
+
// testing for key presence instead of value.
|
|
316
|
+
oneTap: oneTapEndpoints,
|
|
317
|
+
});
|
|
268
318
|
}
|
|
269
|
-
return json(await client.identity.getUser(token));
|
|
319
|
+
return json({ ...(await client.identity.getUser(token)), oneTap: oneTapEndpoints });
|
|
270
320
|
},
|
|
271
321
|
"GET /entitlements": async (request) => {
|
|
272
322
|
const { workspaceId } = await requireWorkspace(request);
|
|
@@ -701,6 +751,74 @@ export function createConnectHandler(options) {
|
|
|
701
751
|
await session.write(request, response, result.sessionToken);
|
|
702
752
|
return response;
|
|
703
753
|
},
|
|
754
|
+
/**
|
|
755
|
+
* One Tap, step one: mint the PKCE pair, keep the verifier here, hand the
|
|
756
|
+
* challenge to the page.
|
|
757
|
+
*
|
|
758
|
+
* Called on the *tap*, not on mount, so the five-minute verifier is minted
|
|
759
|
+
* seconds before it is spent rather than sitting in a cookie for as long as
|
|
760
|
+
* someone leaves the prompt on screen.
|
|
761
|
+
*
|
|
762
|
+
* A `POST` despite reading nothing: it sets a cookie, and a `GET` that
|
|
763
|
+
* changes state is both wrong and prefetchable - a link prefetcher or a
|
|
764
|
+
* scanner would silently rotate the verifier out from under an open prompt.
|
|
765
|
+
*/
|
|
766
|
+
"POST /one-tap/start": async (request) => {
|
|
767
|
+
const url = new URL(request.url);
|
|
768
|
+
const { verifier, challenge } = await createPkcePair();
|
|
769
|
+
const response = json({
|
|
770
|
+
codeChallenge: challenge,
|
|
771
|
+
// The already-registered callback URI. One Tap never navigates to it -
|
|
772
|
+
// it is the value the code is *bound* to, and it must be byte-identical
|
|
773
|
+
// at `/one-tap/complete`, so both ends derive it the same way the
|
|
774
|
+
// redirect flow does rather than accepting it from the page.
|
|
775
|
+
redirectUri: `${url.origin}${callbackPath}`,
|
|
776
|
+
});
|
|
777
|
+
response.headers.append("set-cookie", pkceCookie(verifier, url.protocol === "https:"));
|
|
778
|
+
return response;
|
|
779
|
+
},
|
|
780
|
+
/**
|
|
781
|
+
* One Tap, step two: trade the page's code for a session, using the
|
|
782
|
+
* verifier the page never saw.
|
|
783
|
+
*
|
|
784
|
+
* This is `GET /callback`'s body without the navigation - same exchange,
|
|
785
|
+
* same verify-before-store ordering, same session store. The verifier is
|
|
786
|
+
* what makes the difference: a code lifted out of the browser cannot be
|
|
787
|
+
* redeemed anywhere else, because the exchange is refused without it.
|
|
788
|
+
*/
|
|
789
|
+
"POST /one-tap/complete": async (request) => {
|
|
790
|
+
const url = new URL(request.url);
|
|
791
|
+
const secure = url.protocol === "https:";
|
|
792
|
+
const clearPkce = pkceCookie("", secure);
|
|
793
|
+
const verifier = readCookie(request, pkceCookieName);
|
|
794
|
+
if (!verifier) {
|
|
795
|
+
// The prompt sat unanswered past the cookie's life, or a cross-site
|
|
796
|
+
// `POST` arrived without it. Either way there is nothing to complete,
|
|
797
|
+
// and the client falls back to the interactive flow.
|
|
798
|
+
const expired = json({ error: "expired" }, 400);
|
|
799
|
+
expired.headers.append("set-cookie", clearPkce);
|
|
800
|
+
return expired;
|
|
801
|
+
}
|
|
802
|
+
const body = (await request.json().catch(() => null));
|
|
803
|
+
const code = typeof body?.code === "string" ? body.code : null;
|
|
804
|
+
if (!code) {
|
|
805
|
+
const invalid = json({ error: "invalid_request" }, 400);
|
|
806
|
+
invalid.headers.append("set-cookie", clearPkce);
|
|
807
|
+
return invalid;
|
|
808
|
+
}
|
|
809
|
+
const result = await client.identity.exchangeCode({
|
|
810
|
+
code,
|
|
811
|
+
redirectUri: `${url.origin}${callbackPath}`,
|
|
812
|
+
codeVerifier: verifier,
|
|
813
|
+
});
|
|
814
|
+
// Verified before the session is stored, exactly as the callback does -
|
|
815
|
+
// an unverifiable token must never become a signed-in user.
|
|
816
|
+
await client.identity.verifyIdToken(result.idToken);
|
|
817
|
+
const response = json({ ok: true });
|
|
818
|
+
response.headers.append("set-cookie", clearPkce);
|
|
819
|
+
await session.write(request, response, result.sessionToken);
|
|
820
|
+
return response;
|
|
821
|
+
},
|
|
704
822
|
"POST /logout": async (request) => {
|
|
705
823
|
const token = await session.read(request);
|
|
706
824
|
const response = json({ ok: true });
|
|
@@ -719,6 +837,12 @@ export function createConnectHandler(options) {
|
|
|
719
837
|
if (!session.write) {
|
|
720
838
|
delete routes["GET /signin"];
|
|
721
839
|
delete routes["GET /callback"];
|
|
840
|
+
// Same reason, and `oneTapEndpoints` is already `null` above so the client
|
|
841
|
+
// never offers the prompt in the first place - this is the second half of
|
|
842
|
+
// that, so a hand-written call cannot reach a route that would throw on
|
|
843
|
+
// `session.write!`.
|
|
844
|
+
delete routes["POST /one-tap/start"];
|
|
845
|
+
delete routes["POST /one-tap/complete"];
|
|
722
846
|
// A switch rotates the session token. Without somewhere to persist the new
|
|
723
847
|
// one, the old one would be revoked and the user silently signed out - so
|
|
724
848
|
// the route does not exist rather than working once and breaking.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { request } from "../http.js";
|
|
2
|
+
/**
|
|
3
|
+
* Every call that arrived on a channel assigned to your application.
|
|
4
|
+
*
|
|
5
|
+
* The application is resolved from your API key and is never a parameter, so
|
|
6
|
+
* there is no request that reads another partner's calls.
|
|
7
|
+
*
|
|
8
|
+
* `channelId` is the only filter, and that is a budget decision rather than an
|
|
9
|
+
* oversight: each filter costs a Firestore composite index against a project
|
|
10
|
+
* already near its ceiling. Filter the rest client-side, or read the
|
|
11
|
+
* aggregates from `analytics`.
|
|
12
|
+
*/
|
|
13
|
+
export class CallsResource {
|
|
14
|
+
transport;
|
|
15
|
+
constructor(transport) {
|
|
16
|
+
this.transport = transport;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Your calls, newest first, across all of your subscribing workspaces.
|
|
20
|
+
*
|
|
21
|
+
* **Page until `hasMore` is `false`; do not stop on an empty `items`.** A
|
|
22
|
+
* page can come back empty with more behind it.
|
|
23
|
+
*/
|
|
24
|
+
list(input = {}) {
|
|
25
|
+
return request(this.transport, {
|
|
26
|
+
method: "GET",
|
|
27
|
+
path: "/calls",
|
|
28
|
+
query: {
|
|
29
|
+
channelId: input.channelId,
|
|
30
|
+
cursor: input.cursor,
|
|
31
|
+
limit: input.limit === undefined ? undefined : String(input.limit),
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* One call and every transcript turn, ordered by `sequence` - which is the
|
|
37
|
+
* sort to trust, since two turns can share a millisecond.
|
|
38
|
+
*
|
|
39
|
+
* `recordingFileId` and `transcriptFileId` are Drive file ids, readable with
|
|
40
|
+
* a `drive:read` key. Both are `null` until the carrier posts the audio,
|
|
41
|
+
* which is the normal state for a call that has only just ended - branch on
|
|
42
|
+
* `recordingStatus` rather than treating `null` as a failure.
|
|
43
|
+
*
|
|
44
|
+
* Throws `ConnectError` with `code: "not_found"` for a call that does not
|
|
45
|
+
* exist *or* belongs to another application.
|
|
46
|
+
*/
|
|
47
|
+
get(callId) {
|
|
48
|
+
return request(this.transport, {
|
|
49
|
+
method: "GET",
|
|
50
|
+
path: `/calls/${encodeURIComponent(callId)}`,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { request } from "../http.js";
|
|
2
|
+
/**
|
|
3
|
+
* The phone numbers Connect has assigned to your application.
|
|
4
|
+
*
|
|
5
|
+
* **Inventory is platform-owned.** A number is a regulated asset bought under
|
|
6
|
+
* Connect's carrier account and reassignable between workspaces, so your
|
|
7
|
+
* application never provisions, prices or releases one - it claims from a pool
|
|
8
|
+
* it cannot see, and reads back what it was given. That is why `claim` takes a
|
|
9
|
+
* workspace and a kind rather than a number, and why nothing here exposes a
|
|
10
|
+
* carrier, a provider reference or a monthly rate.
|
|
11
|
+
*
|
|
12
|
+
* The application is resolved from your API key and is never a parameter, so
|
|
13
|
+
* no request shape here can reach another partner's channels. A channel
|
|
14
|
+
* belonging to someone else answers `404`, not `403` - confirming it exists
|
|
15
|
+
* would make these reads an oracle for enumerating their channel ids.
|
|
16
|
+
*/
|
|
17
|
+
export class ChannelsResource {
|
|
18
|
+
transport;
|
|
19
|
+
constructor(transport) {
|
|
20
|
+
this.transport = transport;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Every channel assigned to your application, across all of your subscribing
|
|
24
|
+
* workspaces.
|
|
25
|
+
*
|
|
26
|
+
* Page until `hasMore` is `false`.
|
|
27
|
+
*/
|
|
28
|
+
list(input = {}) {
|
|
29
|
+
return request(this.transport, {
|
|
30
|
+
method: "GET",
|
|
31
|
+
path: "/channels",
|
|
32
|
+
query: {
|
|
33
|
+
workspaceId: input.workspaceId,
|
|
34
|
+
cursor: input.cursor,
|
|
35
|
+
limit: input.limit === undefined ? undefined : String(input.limit),
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The channel behind an id you already hold - a webhook's `channelId`, or a
|
|
41
|
+
* call record's.
|
|
42
|
+
*
|
|
43
|
+
* Throws `ConnectError` with `code: "not_found"` for an id that does not
|
|
44
|
+
* exist *or* belongs to another application.
|
|
45
|
+
*/
|
|
46
|
+
get(channelId) {
|
|
47
|
+
return request(this.transport, {
|
|
48
|
+
method: "GET",
|
|
49
|
+
path: `/channels/${encodeURIComponent(channelId)}`,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Takes a number out of Connect's inventory and binds it to one of your
|
|
54
|
+
* subscribing workspaces.
|
|
55
|
+
*
|
|
56
|
+
* **You do not pick the number.** A workspace may hold exactly one, across
|
|
57
|
+
* every application it uses, and it is then fixed for the life of the
|
|
58
|
+
* engagement - there is no re-claim and no swap. Only a Connect operator can
|
|
59
|
+
* unassign one.
|
|
60
|
+
*
|
|
61
|
+
* Two different `409`s, and they need different handling: the workspace
|
|
62
|
+
* already holds a channel of this kind (fix the call), or the pool is empty
|
|
63
|
+
* right now (retry - Connect is notified, and this is not permanent).
|
|
64
|
+
*
|
|
65
|
+
* The claimed channel comes back without routing, because it has none yet.
|
|
66
|
+
* Call `setRouting` next, or the number refuses every call that reaches it.
|
|
67
|
+
*/
|
|
68
|
+
claim(input) {
|
|
69
|
+
return request(this.transport, {
|
|
70
|
+
method: "POST",
|
|
71
|
+
path: "/channels/claim",
|
|
72
|
+
json: { workspaceId: input.workspaceId, kind: input.kind ?? "voice" },
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Points a channel at wherever inbound conversations should go.
|
|
77
|
+
*
|
|
78
|
+
* `destination` is yours: Connect stores it verbatim and never reads into
|
|
79
|
+
* it. It **replaces** the stored record rather than merging, so send the
|
|
80
|
+
* whole destination each time; it must serialize to 16 KB or fewer.
|
|
81
|
+
*
|
|
82
|
+
* Throws `code: "conflict"` when the channel is suspended or released - use
|
|
83
|
+
* `clearRouting` to stop a suspended number answering, which is deliberately
|
|
84
|
+
* still allowed.
|
|
85
|
+
*/
|
|
86
|
+
setRouting(channelId, destination) {
|
|
87
|
+
return request(this.transport, {
|
|
88
|
+
method: "PUT",
|
|
89
|
+
path: `/channels/${encodeURIComponent(channelId)}/routing`,
|
|
90
|
+
json: { destination },
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Takes a number out of service without giving it up - the channel stays
|
|
95
|
+
* assigned to you and stays billable, and inbound calls are refused until
|
|
96
|
+
* you write routing again.
|
|
97
|
+
*
|
|
98
|
+
* This is the only way to say "stop answering": overwriting `destination`
|
|
99
|
+
* can only ever point the number somewhere *else*, and unassigning it is a
|
|
100
|
+
* Console action that returns the number to Connect's inventory.
|
|
101
|
+
*
|
|
102
|
+
* Idempotent - clearing already-clear routing answers `200` with
|
|
103
|
+
* `routing: null`, so a retry after a lost response is safe.
|
|
104
|
+
*/
|
|
105
|
+
clearRouting(channelId) {
|
|
106
|
+
return request(this.transport, {
|
|
107
|
+
method: "DELETE",
|
|
108
|
+
path: `/channels/${encodeURIComponent(channelId)}/routing`,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -12,6 +12,23 @@ function base64UrlEncode(bytes) {
|
|
|
12
12
|
binary += String.fromCharCode(byte);
|
|
13
13
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
14
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* Mints a PKCE pair for the One Tap flow.
|
|
17
|
+
*
|
|
18
|
+
* The redirect flow can take PKCE or leave it, because the code is delivered
|
|
19
|
+
* to the application's server on a redirect the browser only forwards. One Tap
|
|
20
|
+
* hands the code *to the page*, so the verifier is the thing that makes a
|
|
21
|
+
* stolen code worthless - it stays in an `HttpOnly` cookie on the application's
|
|
22
|
+
* own origin and is never sent to the browser.
|
|
23
|
+
*
|
|
24
|
+
* `crypto.subtle` rather than a Node hash, so this stays isomorphic and the
|
|
25
|
+
* `./next` entry keeps loading on the edge runtime as well as Node.
|
|
26
|
+
*/
|
|
27
|
+
export async function createPkcePair() {
|
|
28
|
+
const verifier = createState(32);
|
|
29
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
|
30
|
+
return { verifier, challenge: base64UrlEncode(new Uint8Array(digest)) };
|
|
31
|
+
}
|
|
15
32
|
function base64UrlDecode(value) {
|
|
16
33
|
const padded = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(value.length / 4) * 4, "=");
|
|
17
34
|
const binary = atob(padded);
|
|
@@ -50,6 +67,28 @@ export class IdentityResource {
|
|
|
50
67
|
}
|
|
51
68
|
return url.toString();
|
|
52
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* The two Connect endpoints One Tap talks to **from the browser**, or `null`
|
|
72
|
+
* when this client has no `clientId` configured and the flow cannot run.
|
|
73
|
+
*
|
|
74
|
+
* Handed out by the bridge rather than built in the page, so One Tap needs no
|
|
75
|
+
* client-side configuration at all - a `./next` application sets
|
|
76
|
+
* `CONNECT_CLIENT_ID` server-side and nothing else. Both URLs carry only the
|
|
77
|
+
* public `client_id`; neither is a credential.
|
|
78
|
+
*
|
|
79
|
+
* Returning `null` rather than throwing (as `authorizeUrl` does) because this
|
|
80
|
+
* is read on the `/session` route, which must keep answering for an
|
|
81
|
+
* application that has no OAuth client configured at all.
|
|
82
|
+
*/
|
|
83
|
+
oneTapEndpoints() {
|
|
84
|
+
if (!this.clientId)
|
|
85
|
+
return null;
|
|
86
|
+
const hint = new URL("/api/sso/session-hint", this.appBaseUrl);
|
|
87
|
+
hint.searchParams.set("client_id", this.clientId);
|
|
88
|
+
const authorize = new URL("/api/sso/authorize/one-tap", this.appBaseUrl);
|
|
89
|
+
authorize.searchParams.set("client_id", this.clientId);
|
|
90
|
+
return { hintUrl: hint.toString(), authorizeUrl: authorize.toString() };
|
|
91
|
+
}
|
|
53
92
|
/** Step 2 - trade the one-time `code` for a session. Server-side only. */
|
|
54
93
|
async exchangeCode(input) {
|
|
55
94
|
const wire = await this.post("/sso/token", {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a person is rendered when there is no photo to render.
|
|
3
|
+
*
|
|
4
|
+
* Shared rather than copied because two surfaces showing *different* initials
|
|
5
|
+
* for the same account reads as a bug in the account, not in the component -
|
|
6
|
+
* and the One Tap prompt and the user menu are frequently on screen within
|
|
7
|
+
* seconds of each other.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Up to two letters standing for a person.
|
|
11
|
+
*
|
|
12
|
+
* Splits on the punctuation an email address uses as well as whitespace, so
|
|
13
|
+
* `priya.nair@acme.test` yields `PN` rather than `PR` - an account with no
|
|
14
|
+
* display name is exactly the case the fallback exists for, and taking the
|
|
15
|
+
* first two characters of a local part would make it useless there.
|
|
16
|
+
*/
|
|
17
|
+
export declare function initials(name: string | null, email?: string | null): string;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How long One Tap stays quiet, and why.
|
|
3
|
+
*
|
|
4
|
+
* A prompt that reappears on every page load after being closed is not a
|
|
5
|
+
* convenience, it is an interstitial. Two things silence it, both stored in the
|
|
6
|
+
* application's own `localStorage` - nothing here leaves the browser, and
|
|
7
|
+
* nothing here is a security control:
|
|
8
|
+
*
|
|
9
|
+
* - **Dismissal, escalating.** Closing it once is "not now"; closing it three
|
|
10
|
+
* times is "stop asking". The backoff says that back.
|
|
11
|
+
* - **Signing out.** Someone who has just signed out of this application does
|
|
12
|
+
* not want to be offered the same account back on the next render. That is a
|
|
13
|
+
* different fact from a dismissal, so it does not consume a dismissal, but it
|
|
14
|
+
* buys a day of quiet.
|
|
15
|
+
*
|
|
16
|
+
* Every access is wrapped: `localStorage` throws outright in some embedded and
|
|
17
|
+
* privacy-restricted contexts, and a prompt is never worth breaking a page for.
|
|
18
|
+
* A read that fails reads as "not suppressed", which is the same state a fresh
|
|
19
|
+
* browser is in.
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* Whether the prompt may be shown at all.
|
|
23
|
+
*
|
|
24
|
+
* Read once when the component mounts, never during render: `localStorage` is
|
|
25
|
+
* external mutable state, and reading it in a render body is impure - with the
|
|
26
|
+
* React Compiler on, exactly the kind of thing that can be hoisted into the
|
|
27
|
+
* wrong answer. Same reasoning as `ConnectProvider`'s `silentAttemptSpent`.
|
|
28
|
+
*/
|
|
29
|
+
export declare function oneTapSuppressed(): boolean;
|
|
30
|
+
/** Records a close, and escalates how long the next quiet period lasts. */
|
|
31
|
+
export declare function dismissOneTap(): void;
|
|
32
|
+
/**
|
|
33
|
+
* Silences the prompt after an explicit sign-out, without spending a dismissal.
|
|
34
|
+
* Signing out is not a complaint about the prompt, so it must not push someone
|
|
35
|
+
* closer to the seven-day backoff.
|
|
36
|
+
*/
|
|
37
|
+
export declare function suppressOneTapAfterSignOut(): void;
|
|
38
|
+
/** Clears the record once a sign-in actually happens - the prompt did its job. */
|
|
39
|
+
export declare function clearOneTapSuppression(): void;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Continue as …" - a signed-out visitor who is already signed into Connect is
|
|
3
|
+
* offered their account, and one tap signs them in **without leaving the page**.
|
|
4
|
+
*
|
|
5
|
+
* The prompt is the visible half; what makes it worth having is that finding
|
|
6
|
+
* out costs one `fetch` rather than a redirect. `ConnectProviderProps.silentSignIn`
|
|
7
|
+
* answers the same question by bouncing the browser to Connect and back, which
|
|
8
|
+
* means a visible full-page navigation on the first load of every signed-out
|
|
9
|
+
* visit, nothing shown to the visitor while it happens, and no way to say no.
|
|
10
|
+
*
|
|
11
|
+
* ```tsx
|
|
12
|
+
* <ConnectProvider>
|
|
13
|
+
* <App />
|
|
14
|
+
* <ConnectOneTap />
|
|
15
|
+
* </ConnectProvider>
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* ### It only appears when it can actually help
|
|
19
|
+
*
|
|
20
|
+
* Nothing renders unless the visitor is signed out here, signed in to Connect,
|
|
21
|
+
* and has not recently dismissed the prompt or signed out of this application.
|
|
22
|
+
* A visitor with no Connect session sees nothing at all, ever - which is why
|
|
23
|
+
* this is safe to mount unconditionally, unlike `silentSignIn`, whose cost
|
|
24
|
+
* lands on exactly the people it cannot help.
|
|
25
|
+
*
|
|
26
|
+
* ### Two prompts, because consent is not assumed
|
|
27
|
+
*
|
|
28
|
+
* A visitor who has authorised this application before gets their name, email,
|
|
29
|
+
* workspace and photo, because Connect will release those to an application
|
|
30
|
+
* they have already agreed to. A visitor who has not gets an impersonal prompt
|
|
31
|
+
* and a tap that leads to the real consent screen - Connect does not hand over
|
|
32
|
+
* so much as a first name until that has happened once.
|
|
33
|
+
*
|
|
34
|
+
* ### Placement
|
|
35
|
+
*
|
|
36
|
+
* Bottom of the viewport on a phone, bottom-right from `sm:` up. Deliberately
|
|
37
|
+
* not top-right: on a phone that is where the browser chrome and the
|
|
38
|
+
* application's own header live, and this must never cover the thing the
|
|
39
|
+
* visitor came to use.
|
|
40
|
+
*/
|
|
41
|
+
export interface ConnectOneTapProps {
|
|
42
|
+
/**
|
|
43
|
+
* Where to send someone whose tap needs the interactive screen - the first
|
|
44
|
+
* connection to this application, or an account that has an MFA or
|
|
45
|
+
* subscription step outstanding. Defaults to the bridge's own `/signin`.
|
|
46
|
+
*/
|
|
47
|
+
onInteractiveSignIn?: () => void;
|
|
48
|
+
className?: string;
|
|
49
|
+
}
|
|
50
|
+
export declare function ConnectOneTap({ onInteractiveSignIn, className }: ConnectOneTapProps): import("react").JSX.Element | null;
|