| author | |
| committer | |
| log | eb77683e5544e071db74831bae052bbd8a7d5f88 |
| tree | 0d3ff9f6576423328b875c2fe3df870eedb3bc1a |
| parent | 14bf3f6d1644a37029be58429e8f0138e1ceb743 |
| signature | Signed by PGP key B5690EEEBB952194 |
Replace stale local desktop sessions in one transaction. Preserve paired clients and browser sessions, and keep the previous credential valid if replacement fails.
Closes https://github.com/pingdotgg/t3code/issues/6283.
Original implementation by seeb1337. Reviewed and verified with GPT-6 Astra (preview) in Codex.
Co-authored-by: seeb1337 <63622047+seeb1337@users.noreply.github.com>
Co-authored-by: Theo Browne <me@t3.gg>8 files changed, 284 insertions(+), 18 deletions(-)
apps/server/src/auth/EnvironmentAuth.test.ts+77| ... | ... | @@ -52,6 +52,18 @@ const makeCookieRequest = ( |
| 52 | 52 | EnvironmentAuth.EnvironmentAuth["Service"]["authenticateHttpRequest"] |
| 53 | 53 | >[0]; |
| 54 | 54 | |
| 55 | const makeBearerRequest = ( | |
| 56 | token: string, | |
| 57 | ): Parameters<EnvironmentAuth.EnvironmentAuth["Service"]["authenticateHttpRequest"]>[0] => | |
| 58 | ({ | |
| 59 | cookies: {}, | |
| 60 | headers: { | |
| 61 | authorization: `Bearer ${token}`, | |
| 62 | }, | |
| 63 | }) as unknown as Parameters< | |
| 64 | EnvironmentAuth.EnvironmentAuth["Service"]["authenticateHttpRequest"] | |
| 65 | >[0]; | |
| 66 | ||
| 55 | 67 | const requestMetadata = { |
| 56 | 68 | deviceType: "desktop" as const, |
| 57 | 69 | os: "macOS", |
| ... | ... | @@ -159,6 +171,71 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { |
| 159 | 171 | }).pipe(Effect.provide(makeEnvironmentAuthLayer())), |
| 160 | 172 | ); |
| 161 | 173 | |
| 174 | it.effect("rotates desktop bearer sessions without accumulating authorized clients", () => | |
| 175 | Effect.gen(function* () { | |
| 176 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; | |
| 177 | const sessions = yield* SessionStore.SessionStore; | |
| 178 | const browser = yield* serverAuth.createBrowserSession( | |
| 179 | "desktop-bootstrap-token", | |
| 180 | requestMetadata, | |
| 181 | ); | |
| 182 | const browserSession = yield* serverAuth.authenticateHttpRequest( | |
| 183 | makeCookieRequest(sessions.cookieName, browser.sessionToken), | |
| 184 | ); | |
| 185 | const staleSessions = yield* Effect.forEach([1, 2, 3], () => | |
| 186 | sessions.issue({ subject: "desktop-bootstrap", method: "bearer-access-token" }), | |
| 187 | ); | |
| 188 | const pairing = yield* serverAuth.issuePairingCredential(); | |
| 189 | const paired = yield* serverAuth.exchangeBootstrapCredentialForAccessToken( | |
| 190 | pairing.credential, | |
| 191 | undefined, | |
| 192 | { ...requestMetadata, label: "T3 Code Desktop" }, | |
| 193 | ); | |
| 194 | const first = yield* serverAuth.exchangeBootstrapCredentialForAccessToken( | |
| 195 | "desktop-bootstrap-token", | |
| 196 | undefined, | |
| 197 | requestMetadata, | |
| 198 | ); | |
| 199 | const firstSession = yield* serverAuth.authenticateHttpRequest( | |
| 200 | makeBearerRequest(first.access_token), | |
| 201 | ); | |
| 202 | const second = yield* serverAuth.exchangeBootstrapCredentialForAccessToken( | |
| 203 | "desktop-bootstrap-token", | |
| 204 | undefined, | |
| 205 | requestMetadata, | |
| 206 | ); | |
| 207 | ||
| 208 | const active = yield* serverAuth.listSessions(); | |
| 209 | const firstError = yield* serverAuth | |
| 210 | .authenticateHttpRequest(makeBearerRequest(first.access_token)) | |
| 211 | .pipe(Effect.flip); | |
| 212 | const secondSession = yield* serverAuth.authenticateHttpRequest( | |
| 213 | makeBearerRequest(second.access_token), | |
| 214 | ); | |
| 215 | ||
| 216 | expect(active).toHaveLength(3); | |
| 217 | expect(active.map((entry) => entry.sessionId)).toContain(browserSession.sessionId); | |
| 218 | expect(active.map((entry) => entry.sessionId)).toContain(secondSession.sessionId); | |
| 219 | expect(active.map((entry) => entry.sessionId)).not.toContain(firstSession.sessionId); | |
| 220 | expect(firstError._tag).toBe("ServerAuthInvalidCredentialError"); | |
| 221 | for (const stale of staleSessions) { | |
| 222 | const error = yield* sessions.verify(stale.token).pipe(Effect.flip); | |
| 223 | expect(error._tag).toBe("SessionTokenRevokedError"); | |
| 224 | } | |
| 225 | const pairedSession = yield* serverAuth.authenticateHttpRequest( | |
| 226 | makeBearerRequest(paired.access_token), | |
| 227 | ); | |
| 228 | expect(pairedSession.subject).toBe("one-time-token"); | |
| 229 | expect(active.map((entry) => entry.sessionId)).toContain(pairedSession.sessionId); | |
| 230 | }).pipe( | |
| 231 | Effect.provide( | |
| 232 | makeEnvironmentAuthLayer({ | |
| 233 | desktopBootstrapToken: "desktop-bootstrap-token", | |
| 234 | }), | |
| 235 | ), | |
| 236 | ), | |
| 237 | ); | |
| 238 | ||
| 162 | 239 | it.effect("keeps user-issued administrative pairing links manageable", () => |
| 163 | 240 | Effect.gen(function* () { |
| 164 | 241 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; |
apps/server/src/auth/EnvironmentAuth.ts+3| ... | ... | @@ -750,6 +750,9 @@ export const make = Effect.gen(function* () { |
| 750 | 750 | ttl: Duration.hours(1), |
| 751 | 751 | } |
| 752 | 752 | : {}), |
| 753 | // Desktop restarts forget the previous bearer token. Replace | |
| 754 | // its session, including stale entries left by older versions. | |
| 755 | replaceActiveForSubjectAndMethod: grant.method === "desktop-bootstrap", | |
| 753 | 756 | client: { |
| 754 | 757 | ...requestMetadata, |
| 755 | 758 | ...(grant.label ? { label: grant.label } : {}), |
apps/server/src/auth/SessionStore.test.ts+74| ... | ... | @@ -4,6 +4,7 @@ import { expect, it } from "@effect/vitest"; |
| 4 | 4 | import * as Duration from "effect/Duration"; |
| 5 | 5 | import * as Effect from "effect/Effect"; |
| 6 | 6 | import * as Layer from "effect/Layer"; |
| 7 | import * as Option from "effect/Option"; | |
| 7 | 8 | import * as TestClock from "effect/testing/TestClock"; |
| 8 | 9 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 9 | 10 | |
| ... | ... | @@ -50,6 +51,7 @@ const repositoryFailure = new PersistenceSqlError({ |
| 50 | 51 | |
| 51 | 52 | const failingSessionLookupRepositoryLayer = Layer.succeed(AuthSessions.AuthSessionRepository, { |
| 52 | 53 | create: () => Effect.void, |
| 54 | createReplacingActive: () => Effect.succeed([]), | |
| 53 | 55 | getById: () => Effect.fail(repositoryFailure), |
| 54 | 56 | listActive: () => Effect.succeed([]), |
| 55 | 57 | revoke: () => Effect.fail(repositoryFailure), |
| ... | ... | @@ -181,6 +183,78 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { |
| 181 | 183 | }).pipe(Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer()))), |
| 182 | 184 | ); |
| 183 | 185 | |
| 186 | it.effect("atomically replaces active sessions with the same subject and method", () => | |
| 187 | Effect.gen(function* () { | |
| 188 | const sessions = yield* SessionStore.SessionStore; | |
| 189 | const browser = yield* sessions.issue({ | |
| 190 | subject: "desktop-bootstrap", | |
| 191 | method: "browser-session-cookie", | |
| 192 | }); | |
| 193 | const [firstBearer, secondBearer] = yield* Effect.all( | |
| 194 | [ | |
| 195 | sessions.issue({ | |
| 196 | subject: "desktop-bootstrap", | |
| 197 | method: "bearer-access-token", | |
| 198 | replaceActiveForSubjectAndMethod: true, | |
| 199 | }), | |
| 200 | sessions.issue({ | |
| 201 | subject: "desktop-bootstrap", | |
| 202 | method: "bearer-access-token", | |
| 203 | replaceActiveForSubjectAndMethod: true, | |
| 204 | }), | |
| 205 | ], | |
| 206 | { concurrency: "unbounded" }, | |
| 207 | ); | |
| 208 | ||
| 209 | const active = yield* sessions.listActive(); | |
| 210 | const bearerVerification = yield* Effect.all([ | |
| 211 | sessions.verify(firstBearer.token).pipe(Effect.option), | |
| 212 | sessions.verify(secondBearer.token).pipe(Effect.option), | |
| 213 | ]); | |
| 214 | ||
| 215 | expect(active).toHaveLength(2); | |
| 216 | expect(active.find((entry) => entry.sessionId === browser.sessionId)).toBeDefined(); | |
| 217 | expect( | |
| 218 | active.filter( | |
| 219 | (entry) => | |
| 220 | entry.subject === "desktop-bootstrap" && entry.method === "bearer-access-token", | |
| 221 | ), | |
| 222 | ).toHaveLength(1); | |
| 223 | expect(bearerVerification.filter(Option.isSome)).toHaveLength(1); | |
| 224 | }).pipe(Effect.provide(makeSessionStoreLayer())), | |
| 225 | ); | |
| 226 | ||
| 227 | it.effect("keeps the previous desktop session valid when replacement fails", () => | |
| 228 | Effect.gen(function* () { | |
| 229 | const sessions = yield* SessionStore.SessionStore; | |
| 230 | const sql = yield* SqlClient.SqlClient; | |
| 231 | const previous = yield* sessions.issue({ | |
| 232 | subject: "desktop-bootstrap", | |
| 233 | method: "bearer-access-token", | |
| 234 | }); | |
| 235 | yield* sql` | |
| 236 | CREATE TRIGGER reject_auth_session_insert BEFORE INSERT ON auth_sessions | |
| 237 | BEGIN | |
| 238 | SELECT RAISE(ABORT, 'simulated insert failure'); | |
| 239 | END | |
| 240 | `; | |
| 241 | ||
| 242 | const error = yield* sessions | |
| 243 | .issue({ | |
| 244 | subject: "desktop-bootstrap", | |
| 245 | method: "bearer-access-token", | |
| 246 | replaceActiveForSubjectAndMethod: true, | |
| 247 | }) | |
| 248 | .pipe(Effect.flip); | |
| 249 | ||
| 250 | expect(error._tag).toBe("SessionCredentialIssueError"); | |
| 251 | expect((yield* sessions.verify(previous.token)).sessionId).toBe(previous.sessionId); | |
| 252 | expect((yield* sessions.listActive()).map((session) => session.sessionId)).toEqual([ | |
| 253 | previous.sessionId, | |
| 254 | ]); | |
| 255 | }).pipe(Effect.provide(Layer.mergeAll(makeSessionStoreLayer(), SqlitePersistenceMemory))), | |
| 256 | ); | |
| 257 | ||
| 184 | 258 | it.effect("rejects websocket tokens once the parent session has expired", () => |
| 185 | 259 | Effect.gen(function* () { |
| 186 | 260 | const sessions = yield* SessionStore.SessionStore; |
apps/server/src/auth/SessionStore.ts+39-18| ... | ... | @@ -370,6 +370,11 @@ export class SessionStore extends Context.Service< |
| 370 | 370 | readonly scopes?: ReadonlyArray<AuthEnvironmentScope>; |
| 371 | 371 | readonly client?: AuthClientMetadata; |
| 372 | 372 | readonly proofKeyThumbprint?: string; |
| 373 | /** | |
| 374 | * Atomically revoke active sessions with the same subject and method | |
| 375 | * before storing this session. | |
| 376 | */ | |
| 377 | readonly replaceActiveForSubjectAndMethod?: boolean; | |
| 373 | 378 | }) => Effect.Effect<IssuedSession, SessionCredentialInternalError>; |
| 374 | 379 | readonly verify: (token: string) => Effect.Effect<VerifiedSession, SessionCredentialError>; |
| 375 | 380 | readonly issueWebSocketToken: ( |
| ... | ... | @@ -647,24 +652,40 @@ export const make = Effect.gen(function* () { |
| 647 | 652 | ); |
| 648 | 653 | const signature = signPayload(encodedPayload, signingSecret); |
| 649 | 654 | const client = input?.client ?? createDefaultClientMetadata(); |
| 650 | yield* authSessions | |
| 651 | .create({ | |
| 652 | sessionId, | |
| 653 | subject: claims.sub, | |
| 654 | scopes: claims.scopes, | |
| 655 | method: claims.method, | |
| 656 | client: { | |
| 657 | label: client.label ?? null, | |
| 658 | ipAddress: client.ipAddress ?? null, | |
| 659 | userAgent: client.userAgent ?? null, | |
| 660 | deviceType: client.deviceType, | |
| 661 | os: client.os ?? null, | |
| 662 | browser: client.browser ?? null, | |
| 663 | }, | |
| 664 | issuedAt, | |
| 665 | expiresAt, | |
| 666 | }) | |
| 667 | .pipe(Effect.mapError((cause) => new SessionCredentialIssueError({ sessionId, cause }))); | |
| 655 | const sessionRecord = { | |
| 656 | sessionId, | |
| 657 | subject: claims.sub, | |
| 658 | scopes: claims.scopes, | |
| 659 | method: claims.method, | |
| 660 | client: { | |
| 661 | label: client.label ?? null, | |
| 662 | ipAddress: client.ipAddress ?? null, | |
| 663 | userAgent: client.userAgent ?? null, | |
| 664 | deviceType: client.deviceType, | |
| 665 | os: client.os ?? null, | |
| 666 | browser: client.browser ?? null, | |
| 667 | }, | |
| 668 | issuedAt, | |
| 669 | expiresAt, | |
| 670 | } satisfies AuthSessions.CreateAuthSessionInput; | |
| 671 | const replacedSessionIds = yield* ( | |
| 672 | input?.replaceActiveForSubjectAndMethod | |
| 673 | ? authSessions.createReplacingActive({ session: sessionRecord, revokedAt: issuedAt }) | |
| 674 | : authSessions.create(sessionRecord).pipe(Effect.as([] as ReadonlyArray<AuthSessionId>)) | |
| 675 | ).pipe(Effect.mapError((cause) => new SessionCredentialIssueError({ sessionId, cause }))); | |
| 676 | if (replacedSessionIds.length > 0) { | |
| 677 | yield* Ref.update(connectedSessionsRef, (current) => { | |
| 678 | const next = new Map(current); | |
| 679 | for (const replacedSessionId of replacedSessionIds) { | |
| 680 | next.delete(replacedSessionId); | |
| 681 | } | |
| 682 | return next; | |
| 683 | }); | |
| 684 | yield* Effect.forEach(replacedSessionIds, emitRemoved, { | |
| 685 | concurrency: "unbounded", | |
| 686 | discard: true, | |
| 687 | }); | |
| 688 | } | |
| 668 | 689 | yield* emitUpsert( |
| 669 | 690 | toAuthClientSession({ |
| 670 | 691 | sessionId, |
apps/server/src/persistence/AuthSessions.ts+49| ... | ... | @@ -55,6 +55,13 @@ export const CreateAuthSessionInput = Schema.Struct({ |
| 55 | 55 | }); |
| 56 | 56 | export type CreateAuthSessionInput = typeof CreateAuthSessionInput.Type; |
| 57 | 57 | |
| 58 | export const CreateReplacingActiveAuthSessionInput = Schema.Struct({ | |
| 59 | session: CreateAuthSessionInput, | |
| 60 | revokedAt: Schema.DateTimeUtcFromString, | |
| 61 | }); | |
| 62 | export type CreateReplacingActiveAuthSessionInput = | |
| 63 | typeof CreateReplacingActiveAuthSessionInput.Type; | |
| 64 | ||
| 58 | 65 | export const GetAuthSessionByIdInput = Schema.Struct({ |
| 59 | 66 | sessionId: AuthSessionId, |
| 60 | 67 | }); |
| ... | ... | @@ -96,6 +103,9 @@ export class AuthSessionRepository extends Context.Service< |
| 96 | 103 | readonly create: ( |
| 97 | 104 | input: CreateAuthSessionInput, |
| 98 | 105 | ) => Effect.Effect<void, AuthSessionRepositoryError>; |
| 106 | readonly createReplacingActive: ( | |
| 107 | input: CreateReplacingActiveAuthSessionInput, | |
| 108 | ) => Effect.Effect<ReadonlyArray<AuthSessionId>, AuthSessionRepositoryError>; | |
| 99 | 109 | readonly getById: ( |
| 100 | 110 | input: GetAuthSessionByIdInput, |
| 101 | 111 | ) => Effect.Effect<Option.Option<AuthSessionRecord>, AuthSessionRepositoryError>; |
| ... | ... | @@ -254,6 +264,21 @@ export const make = Effect.gen(function* () { |
| 254 | 264 | `, |
| 255 | 265 | }); |
| 256 | 266 | |
| 267 | const revokeActiveSessionsForReplacement = SqlSchema.findAll({ | |
| 268 | Request: CreateReplacingActiveAuthSessionInput, | |
| 269 | Result: Schema.Struct({ sessionId: AuthSessionId }), | |
| 270 | execute: ({ session, revokedAt }) => | |
| 271 | sql` | |
| 272 | UPDATE auth_sessions | |
| 273 | SET revoked_at = ${revokedAt} | |
| 274 | WHERE subject = ${session.subject} | |
| 275 | AND method = ${session.method} | |
| 276 | AND revoked_at IS NULL | |
| 277 | AND expires_at > ${revokedAt} | |
| 278 | RETURNING session_id AS "sessionId" | |
| 279 | `, | |
| 280 | }); | |
| 281 | ||
| 257 | 282 | const listActiveSessionRows = SqlSchema.findAll({ |
| 258 | 283 | Request: ListActiveAuthSessionsInput, |
| 259 | 284 | Result: AuthSessionRawDbRow, |
| ... | ... | @@ -343,6 +368,29 @@ export const make = Effect.gen(function* () { |
| 343 | 368 | ), |
| 344 | 369 | ); |
| 345 | 370 | |
| 371 | const createReplacingActive: AuthSessionRepository["Service"]["createReplacingActive"] = ( | |
| 372 | input, | |
| 373 | ) => | |
| 374 | sql | |
| 375 | .withTransaction( | |
| 376 | revokeActiveSessionsForReplacement(input).pipe( | |
| 377 | Effect.flatMap((revokedRows) => | |
| 378 | createSessionRow(input.session).pipe( | |
| 379 | Effect.as(revokedRows.map((row) => row.sessionId)), | |
| 380 | ), | |
| 381 | ), | |
| 382 | ), | |
| 383 | ) | |
| 384 | .pipe( | |
| 385 | Effect.mapError( | |
| 386 | toPersistenceSqlOrDecodeError( | |
| 387 | "AuthSessionRepository.createReplacingActive:query", | |
| 388 | "AuthSessionRepository.createReplacingActive:encodeRequest", | |
| 389 | { sessionId: input.session.sessionId }, | |
| 390 | ), | |
| 391 | ), | |
| 392 | ); | |
| 393 | ||
| 346 | 394 | const getById: AuthSessionRepository["Service"]["getById"] = (input) => |
| 347 | 395 | getSessionRowById(input).pipe( |
| 348 | 396 | Effect.mapError( |
| ... | ... | @@ -442,6 +490,7 @@ export const make = Effect.gen(function* () { |
| 442 | 490 | |
| 443 | 491 | return { |
| 444 | 492 | create, |
| 493 | createReplacingActive, | |
| 445 | 494 | getById, |
| 446 | 495 | listActive, |
| 447 | 496 | revoke, |
apps/server/src/server.test.ts+32| ... | ... | @@ -1905,6 +1905,38 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1905 | 1905 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 1906 | 1906 | ); |
| 1907 | 1907 | |
| 1908 | it.effect("replaces the local desktop credential on repeated bootstrap exchanges", () => | |
| 1909 | Effect.gen(function* () { | |
| 1910 | yield* buildAppUnderTest(); | |
| 1911 | const first = yield* exchangeAccessToken(); | |
| 1912 | const second = yield* exchangeAccessToken(); | |
| 1913 | const third = yield* exchangeAccessToken(); | |
| 1914 | assert.equal(first.response.status, 200); | |
| 1915 | assert.equal(second.response.status, 200); | |
| 1916 | assert.equal(third.response.status, 200); | |
| 1917 | ||
| 1918 | const clientsResponse = yield* HttpClient.get("/api/auth/clients", { | |
| 1919 | headers: { authorization: `Bearer ${third.body.access_token}` }, | |
| 1920 | }); | |
| 1921 | const clients = (yield* clientsResponse.json) as ReadonlyArray<{ | |
| 1922 | readonly current: boolean; | |
| 1923 | readonly subject: string; | |
| 1924 | }>; | |
| 1925 | assert.equal(clientsResponse.status, 200); | |
| 1926 | assert.equal(clients.length, 1); | |
| 1927 | assert.equal(clients[0]?.current, true); | |
| 1928 | assert.equal(clients[0]?.subject, "desktop-bootstrap"); | |
| 1929 | ||
| 1930 | for (const previous of [first, second]) { | |
| 1931 | const response = yield* HttpClient.get("/api/auth/session", { | |
| 1932 | headers: { authorization: `Bearer ${previous.body.access_token}` }, | |
| 1933 | }); | |
| 1934 | const state = (yield* response.json) as { readonly authenticated: boolean }; | |
| 1935 | assert.equal(state.authenticated, false); | |
| 1936 | } | |
| 1937 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 1938 | ); | |
| 1939 | ||
| 1908 | 1940 | it.effect("persists token exchange client display metadata for authorized-client listings", () => |
| 1909 | 1941 | Effect.gen(function* () { |
| 1910 | 1942 | yield* buildAppUnderTest({ |
docs/internals/environment-auth.md+6| ... | ... | @@ -121,6 +121,12 @@ Sessions issued from a plain bearer exchange use the store's |
| 121 | 121 | only to DPoP-bound exchanges, where the token is additionally constrained by a |
| 122 | 122 | proof key. See `SessionStore.ts` and `EnvironmentAuth.ts`. |
| 123 | 123 | |
| 124 | The reusable `desktop-bootstrap` grant replaces active sessions with the same | |
| 125 | subject and authentication method. Revocation and insertion share one database | |
| 126 | transaction, so a failed insertion preserves the previous credential. This also | |
| 127 | removes stale local desktop entries from earlier launches. Browser-cookie sessions | |
| 128 | and sessions issued through pairing links are not replaced. | |
| 129 | ||
| 124 | 130 | Requested scopes must be a subset of the one-time bootstrap credential grant. |
| 125 | 131 | An ordinary paired client therefore cannot exchange its grant for |
| 126 | 132 | `access:read`, `access:write`, or `relay:write`. |
docs/user/remote-access.md+4| ... | ... | @@ -80,6 +80,10 @@ and expiry, and can revoke it if they have access management permission. |
| 80 | 80 | |
| 81 | 81 | The default endpoint controls the QR code and primary copy action for pairing links. You can change it from the expanded endpoint list. The preference is stored by endpoint type, so choosing the local LAN endpoint survives normal IP address changes when you move between networks. |
| 82 | 82 | |
| 83 | After an app restart, the desktop app replaces its previous | |
| 84 | local credential. Old local desktop entries are removed from **Authorized clients** | |
| 85 | automatically. Paired phones, browsers, and remote desktop clients keep their access. | |
| 86 | ||
| 83 | 87 | When no user default is saved, the app uses the built-in LAN endpoint for pairing links when |
| 84 | 88 | available. You can set another endpoint as the default from the expanded endpoint list. |
| 85 | 89 |