@k9o/gate-react-native 1.1.1 → 1.3.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 +19 -11
- package/package.json +1 -1
- package/src/GateAuthClient.ts +18 -4
- package/src/GateClient.ts +10 -20
- package/src/GateError.ts +3 -4
- package/src/Models.ts +21 -13
- package/src/index.ts +2 -1
- package/src/region.ts +98 -0
package/README.md
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
Kilimanjaro Gate for React Native. Sign end users in with email and password and refresh their session.
|
|
4
4
|
The refresh token stays in the device keychain.
|
|
5
5
|
|
|
6
|
+
Kilimanjaro is the AI-native foundation for building critical applications. Its products are Keep (AI Security and Governance),
|
|
7
|
+
Pulse (Data Intelligence), Gate (Customer Identity and Access Management) and Kibo (Keys and Secrets Management).
|
|
8
|
+
|
|
6
9
|
## Contents
|
|
7
10
|
|
|
8
11
|
- [Links](#links)
|
|
@@ -13,6 +16,11 @@ The refresh token stays in the device keychain.
|
|
|
13
16
|
- [Custom claims](#custom-claims)
|
|
14
17
|
- [Handle errors](#handle-errors)
|
|
15
18
|
- [Appendix](#appendix)
|
|
19
|
+
- [Requirements](#requirements)
|
|
20
|
+
- [Client options](#client-options)
|
|
21
|
+
- [Methods](#methods)
|
|
22
|
+
- [Error codes](#error-codes)
|
|
23
|
+
- [Related packages](#related-packages)
|
|
16
24
|
|
|
17
25
|
## Links
|
|
18
26
|
|
|
@@ -20,11 +28,6 @@ The refresh token stays in the device keychain.
|
|
|
20
28
|
- Documentation / Developer Hub: <https://docs.kilimanjaro.dev>
|
|
21
29
|
- Platform API: <https://api.k9o.io>
|
|
22
30
|
- Platform Console: <https://console.k9o.io>
|
|
23
|
-
- [Requirements](#requirements)
|
|
24
|
-
- [Client options](#client-options)
|
|
25
|
-
- [Methods](#methods)
|
|
26
|
-
- [Error codes](#error-codes)
|
|
27
|
-
- [Related packages](#related-packages)
|
|
28
31
|
|
|
29
32
|
## Install
|
|
30
33
|
|
|
@@ -65,15 +68,18 @@ The result has a `user` and a `session`. See [methods](#methods).
|
|
|
65
68
|
|
|
66
69
|
## Refresh and sign out
|
|
67
70
|
|
|
68
|
-
With no arguments, `refreshToken` reads the stored token and
|
|
71
|
+
With no arguments, `refreshToken` reads the stored refresh token and returns a new access token.
|
|
72
|
+
The refresh token is not rotated, so the keychain is left as it is.
|
|
69
73
|
|
|
70
74
|
```ts
|
|
71
75
|
const refreshed = await gate.refreshToken();
|
|
72
76
|
|
|
73
|
-
console.log(refreshed.
|
|
77
|
+
console.log(refreshed.accessToken.length > 0);
|
|
78
|
+
console.log(refreshed.expiresAt);
|
|
74
79
|
```
|
|
75
80
|
|
|
76
81
|
```text
|
|
82
|
+
true
|
|
77
83
|
2026-01-15T11:30:00.000Z
|
|
78
84
|
```
|
|
79
85
|
|
|
@@ -118,7 +124,8 @@ const gate = new GateClient({
|
|
|
118
124
|
|
|
119
125
|
## Custom claims
|
|
120
126
|
|
|
121
|
-
|
|
127
|
+
The token carries each extension claim as a flat key, `ext.<namespace>.<name>`.
|
|
128
|
+
`customClaims` groups them as `{ namespace: { name: value } }`. Decoding does not verify the signature.
|
|
122
129
|
|
|
123
130
|
```ts
|
|
124
131
|
import { AccessToken } from "@k9o/gate-react-native";
|
|
@@ -181,7 +188,7 @@ See [error codes](#error-codes) for all of them.
|
|
|
181
188
|
|
|
182
189
|
| Option | Required | Meaning |
|
|
183
190
|
|---|---|---|
|
|
184
|
-
| `orgBaseUrl` | Yes | Your organization's API host, such as `https://acme.api.k9o.io
|
|
191
|
+
| `orgBaseUrl` | Yes | Your organization's API host, such as `https://acme.api.k9o.io`; any apex works |
|
|
185
192
|
| `projectSlug` | Yes | The project users sign in to |
|
|
186
193
|
| `tokenStore` | No | Where the refresh token is kept; the keychain by default |
|
|
187
194
|
|
|
@@ -190,14 +197,15 @@ See [error codes](#error-codes) for all of them.
|
|
|
190
197
|
| Method | What it does |
|
|
191
198
|
|---|---|
|
|
192
199
|
| `signIn({ email, password })` | Sign in; resolves to `{ user, session }` |
|
|
193
|
-
| `refreshToken({ refreshToken
|
|
194
|
-
| `signOut({ refreshToken? })` |
|
|
200
|
+
| `refreshToken({ refreshToken? })` | Resolves to `{ accessToken, expiresAt }`; uses the stored token when none is passed |
|
|
201
|
+
| `signOut({ refreshToken? })` | Revoke the session and clear the stored token; does nothing without a token |
|
|
195
202
|
| `AccessToken.decode(jwt)` | Read an access token's claims; `null` if it is not a JWT |
|
|
196
203
|
|
|
197
204
|
| Result field | Contents |
|
|
198
205
|
|---|---|
|
|
199
206
|
| `user` | `id`, `email`, `status`, `roles`, `groups`, timestamps |
|
|
200
207
|
| `session` | `accessToken`, `refreshToken`, `expiresAt` |
|
|
208
|
+
| refresh result | `accessToken`, `expiresAt` |
|
|
201
209
|
|
|
202
210
|
### Error codes
|
|
203
211
|
|
package/package.json
CHANGED
package/src/GateAuthClient.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { GateError, gateErrorFromResponse } from "./GateError";
|
|
2
2
|
import { parseEnvelope } from "./Models";
|
|
3
|
+
import { resolveRegionalOrigin } from "./region";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Internal fetch-based transport layer for @k9o/gate-react-native.
|
|
@@ -8,25 +9,38 @@ import { parseEnvelope } from "./Models";
|
|
|
8
9
|
export class GateAuthClient {
|
|
9
10
|
private readonly baseUrl: string;
|
|
10
11
|
private readonly projectSlug: string;
|
|
12
|
+
private origin: Promise<string> | null = null;
|
|
11
13
|
|
|
12
14
|
constructor(orgBaseUrl: string, projectSlug: string) {
|
|
13
15
|
this.baseUrl = orgBaseUrl.replace(/\/$/, "");
|
|
14
16
|
this.projectSlug = projectSlug;
|
|
15
17
|
}
|
|
16
18
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
+
/** The origin credentials go to: the regional host when the base URL is a region-free org host, else the base URL. */
|
|
20
|
+
private resolveOrigin(): Promise<string> {
|
|
21
|
+
if (this.origin === null) {
|
|
22
|
+
const p = resolveRegionalOrigin(this.baseUrl).then((r) => {
|
|
23
|
+
if (!r.cacheable) this.origin = null;
|
|
24
|
+
return r.origin;
|
|
25
|
+
});
|
|
26
|
+
this.origin = p;
|
|
27
|
+
}
|
|
28
|
+
return this.origin;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private async url(suffix: string): Promise<string> {
|
|
32
|
+
return `${await this.resolveOrigin()}/@${this.projectSlug}/gate/${suffix}`;
|
|
19
33
|
}
|
|
20
34
|
|
|
21
35
|
async post<T>(path: string, body: Record<string, unknown>): Promise<T> {
|
|
22
36
|
let response: Response;
|
|
23
37
|
try {
|
|
24
|
-
response = await fetch(this.url(path), {
|
|
38
|
+
response = await fetch(await this.url(path), {
|
|
25
39
|
method: "POST",
|
|
26
40
|
headers: {
|
|
27
41
|
"Content-Type": "application/json",
|
|
28
42
|
Accept: "application/json",
|
|
29
|
-
"User-Agent": "k9o-react-native-sdk
|
|
43
|
+
"User-Agent": "k9o-react-native-sdk",
|
|
30
44
|
},
|
|
31
45
|
body: JSON.stringify(body),
|
|
32
46
|
});
|
package/src/GateClient.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { GateError } from "./GateError";
|
|
2
|
-
import { AuthResult } from "./Models";
|
|
2
|
+
import { AuthResult, RefreshResult } from "./Models";
|
|
3
3
|
import { GateAuthClient } from "./GateAuthClient";
|
|
4
4
|
import { KeychainTokenStore, TokenStore } from "./KeychainTokenStore";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* Kilimanjaro Gate
|
|
7
|
+
* Kilimanjaro Gate React Native SDK
|
|
8
8
|
*
|
|
9
9
|
* `GateClient` is the primary entry point. Initialise once per project
|
|
10
|
-
* and call `signIn`, `refreshToken`, or `signOut`.
|
|
10
|
+
* and call `signIn`, `refreshToken`, or `signOut`. `orgBaseUrl` is the org's gateway host; a region-free host
|
|
11
|
+
* (`<org>.api.<apex>`) is resolved once to its regional host before credentials are sent.
|
|
11
12
|
*
|
|
12
13
|
* Refresh tokens are automatically persisted via `tokenStore` (defaults
|
|
13
14
|
* to `KeychainTokenStore` backed by `react-native-keychain`).
|
|
@@ -58,28 +59,17 @@ export class GateClient {
|
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
/**
|
|
61
|
-
*
|
|
62
|
+
* Get a new access token using the stored (or supplied) refresh token.
|
|
62
63
|
*
|
|
63
|
-
* If `refreshToken` is omitted, loads the token from the store.
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
* `projectHint` is optional; omit for most configurations.
|
|
64
|
+
* If `refreshToken` is omitted, loads the token from the store. The gateway answers a new `accessToken` and its
|
|
65
|
+
* `expiresAt` only; the refresh token stays valid, so the store is left as it is.
|
|
67
66
|
*
|
|
68
67
|
* Mirrors: `POST /@<projectSlug>/gate/refresh`
|
|
69
68
|
*/
|
|
70
|
-
async refreshToken(input?: {
|
|
71
|
-
refreshToken?: string;
|
|
72
|
-
projectHint?: string;
|
|
73
|
-
}): Promise<AuthResult> {
|
|
69
|
+
async refreshToken(input?: { refreshToken?: string }): Promise<RefreshResult> {
|
|
74
70
|
const token = input?.refreshToken ?? (await this.tokenStore.load());
|
|
75
71
|
if (!token) throw GateError.unauthenticated();
|
|
76
|
-
|
|
77
|
-
const body: Record<string, unknown> = { refreshToken: token };
|
|
78
|
-
if (input?.projectHint) body["projectHint"] = input.projectHint;
|
|
79
|
-
|
|
80
|
-
const result = await this.client.post<AuthResult>("refresh", body);
|
|
81
|
-
await this.tokenStore.save(result.session.refreshToken);
|
|
82
|
-
return result;
|
|
72
|
+
return this.client.post<RefreshResult>("refresh", { refreshToken: token });
|
|
83
73
|
}
|
|
84
74
|
|
|
85
75
|
/**
|
|
@@ -93,7 +83,7 @@ export class GateClient {
|
|
|
93
83
|
async signOut(input?: { refreshToken?: string }): Promise<void> {
|
|
94
84
|
const token = input?.refreshToken ?? (await this.tokenStore.load());
|
|
95
85
|
if (!token) return;
|
|
96
|
-
await this.client.post<
|
|
86
|
+
await this.client.post<{ revoked: boolean }>("signout", { refreshToken: token });
|
|
97
87
|
await this.tokenStore.delete();
|
|
98
88
|
}
|
|
99
89
|
}
|
package/src/GateError.ts
CHANGED
|
@@ -67,10 +67,9 @@ export class GateError extends Error {
|
|
|
67
67
|
|
|
68
68
|
// MARK: - Wire error parsing
|
|
69
69
|
|
|
70
|
-
// The gateway emits a KError at the ROOT of the error body:
|
|
71
|
-
// `{
|
|
72
|
-
//
|
|
73
|
-
// prefix is stripped before matching the SDK's typed enum.
|
|
70
|
+
// The gateway emits a KError at the ROOT of the error body: `{ code, message, context?, recommendation? }`, not
|
|
71
|
+
// wrapped in `{ error: … }`. Codes are platform-namespaced (`K9O_*`), so the prefix is stripped before matching
|
|
72
|
+
// the SDK's typed enum.
|
|
74
73
|
interface WireKError {
|
|
75
74
|
code?: string;
|
|
76
75
|
message?: string;
|
package/src/Models.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Core model types for @k9o/gate-react-native.
|
|
3
|
-
* Mirror the @k9o/gate TypeScript types
|
|
3
|
+
* Mirror the @k9o/gate TypeScript types.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
/** Project-level end-user identity. IDs carry the `eu_` prefix. */
|
|
@@ -15,7 +15,7 @@ export interface EndUser {
|
|
|
15
15
|
groups: readonly string[];
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
/** Session tokens returned by sign-in
|
|
18
|
+
/** Session tokens returned by sign-in. */
|
|
19
19
|
export interface Session {
|
|
20
20
|
accessToken: string;
|
|
21
21
|
refreshToken: string;
|
|
@@ -23,13 +23,20 @@ export interface Session {
|
|
|
23
23
|
expiresAt: string;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
/** Returned by signIn
|
|
26
|
+
/** Returned by signIn. */
|
|
27
27
|
export interface AuthResult {
|
|
28
28
|
user: EndUser;
|
|
29
29
|
session: Session;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
/**
|
|
32
|
+
/** Returned by refreshToken: a new access token only. The refresh token in hand stays valid. */
|
|
33
|
+
export interface RefreshResult {
|
|
34
|
+
accessToken: string;
|
|
35
|
+
/** ISO 8601 instant the new access token expires. */
|
|
36
|
+
expiresAt: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The gateway's response envelope. */
|
|
33
40
|
interface Envelope<T> {
|
|
34
41
|
data: T;
|
|
35
42
|
meta?: Record<string, unknown>;
|
|
@@ -52,9 +59,7 @@ export function parseEnvelope<T>(body: string): T {
|
|
|
52
59
|
// MARK: - Lightweight JWT access-token decoder
|
|
53
60
|
|
|
54
61
|
/**
|
|
55
|
-
* Lightweight access-token decoder. Decodes the JWT payload without
|
|
56
|
-
* signature verification. Exposes `ext.<namespace>.<name>` custom-claim
|
|
57
|
-
* flatten from.
|
|
62
|
+
* Lightweight access-token decoder. Decodes the JWT payload without signature verification.
|
|
58
63
|
*/
|
|
59
64
|
export class AccessToken {
|
|
60
65
|
readonly subject: string | undefined;
|
|
@@ -72,16 +77,19 @@ export class AccessToken {
|
|
|
72
77
|
}
|
|
73
78
|
|
|
74
79
|
/**
|
|
75
|
-
* Custom claims
|
|
76
|
-
* `
|
|
80
|
+
* Custom claims, grouped by namespace. The token carries each one as a FLAT key `ext.<namespace>.<name>`, so
|
|
81
|
+
* `ext.partners.partner_role = "extension_org_admin"` reads as
|
|
82
|
+
* `customClaims["partners"]?.["partner_role"]` and gives `"extension_org_admin"`.
|
|
77
83
|
*/
|
|
78
84
|
get customClaims(): Record<string, Record<string, unknown>> {
|
|
79
85
|
const result: Record<string, Record<string, unknown>> = {};
|
|
80
86
|
for (const [key, val] of Object.entries(this.allClaims)) {
|
|
81
|
-
if (key.startsWith("ext.")
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
87
|
+
if (!key.startsWith("ext.")) continue;
|
|
88
|
+
const rest = key.slice("ext.".length);
|
|
89
|
+
const dot = rest.indexOf(".");
|
|
90
|
+
if (dot <= 0 || dot === rest.length - 1) continue;
|
|
91
|
+
const ns = rest.slice(0, dot);
|
|
92
|
+
(result[ns] ??= {})[rest.slice(dot + 1)] = val;
|
|
85
93
|
}
|
|
86
94
|
return result;
|
|
87
95
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { GateClient } from "./GateClient";
|
|
2
2
|
export { GateError, type GateErrorCode } from "./GateError";
|
|
3
3
|
export { KeychainTokenStore, type TokenStore } from "./KeychainTokenStore";
|
|
4
|
-
export { AccessToken, type AuthResult, type Session, type EndUser } from "./Models";
|
|
4
|
+
export { AccessToken, type AuthResult, type RefreshResult, type Session, type EndUser } from "./Models";
|
|
5
|
+
export { parseApiHost, regionalOriginFromLocation, type ApiHost } from "./region";
|
package/src/region.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regional host resolution for React Native.
|
|
3
|
+
*
|
|
4
|
+
* A region-free org host `<org>.api.<rest>` (or `<org>.api-staging.<rest>`) refuses a request that carries a body or an
|
|
5
|
+
* `Authorization` header, so credentials must go to the regional host `<org>.api-<rgn>.<rest>`. The client learns the
|
|
6
|
+
* region with ONE unauthenticated `HEAD` to the configured host and caches the regional origin.
|
|
7
|
+
*
|
|
8
|
+
* Nothing here knows an apex: the host comes from the configured base URL, so a public-cloud, internal-apex,
|
|
9
|
+
* air-gapped or localhost deployment all work. Only a host shaped `<org>.api.<rest>` is probed; any other base URL is
|
|
10
|
+
* used exactly as given. The regional origin is trusted only if it is `https`, has no userinfo or port, and is exactly
|
|
11
|
+
* the same org, gateway family and apex with a region code. Anything else is ignored, so a hostile redirect can never
|
|
12
|
+
* receive a credential.
|
|
13
|
+
*
|
|
14
|
+
* React Native's `fetch` follows redirects itself and does not honour `redirect: "manual"`, so the probe reads either the
|
|
15
|
+
* `308` `Location` (where the runtime exposes it) or the final `response.url` after the redirect was followed.
|
|
16
|
+
*
|
|
17
|
+
* URLs are read with a small parser rather than the `URL` class, which React Native only partly implements.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export type ApiHost = {
|
|
21
|
+
/** First label of an org host; null for a platform host (`api.<apex>`). */
|
|
22
|
+
orgSlug: string | null;
|
|
23
|
+
family: "api" | "api-staging";
|
|
24
|
+
/** 4-character region code when the host is regional (`api-de01`), else null. */
|
|
25
|
+
region: string | null;
|
|
26
|
+
/** Everything after the gateway label. */
|
|
27
|
+
apex: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const API_LABEL = /^(api|api-staging)(?:-([a-z]{2}\d{2}))?$/;
|
|
31
|
+
const SLUG_RE = /^[a-z][a-z0-9-]{1,62}$/;
|
|
32
|
+
|
|
33
|
+
/** Read the org slug and region out of a gateway host without knowing its apex; null for any other shape. */
|
|
34
|
+
export function parseApiHost(host: string): ApiHost | null {
|
|
35
|
+
const labels = host.toLowerCase().split(".");
|
|
36
|
+
if (labels.some((l) => l.length === 0)) return null;
|
|
37
|
+
const mk = (gatewayLabel: string, orgSlug: string | null, rest: string[]): ApiHost | null => {
|
|
38
|
+
const m = API_LABEL.exec(gatewayLabel);
|
|
39
|
+
if (m === null || rest.length === 0) return null;
|
|
40
|
+
return { orgSlug, family: m[1] as "api" | "api-staging", region: m[2] ?? null, apex: rest.join(".") };
|
|
41
|
+
};
|
|
42
|
+
if (labels.length >= 3 && SLUG_RE.test(labels[0]!) && !labels[0]!.endsWith("-") && API_LABEL.test(labels[1]!)) {
|
|
43
|
+
return mk(labels[1]!, labels[0]!, labels.slice(2));
|
|
44
|
+
}
|
|
45
|
+
if (labels.length >= 2 && API_LABEL.test(labels[0]!)) return mk(labels[0]!, null, labels.slice(1));
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type Origin = { protocol: string; userinfo: boolean; hostname: string; port: string };
|
|
50
|
+
|
|
51
|
+
/** Parse an absolute http(s) URL's origin parts; null when it is not one. */
|
|
52
|
+
function parseOrigin(url: string): Origin | null {
|
|
53
|
+
const m = /^(https?):\/\/([^/?#]*)/i.exec(url);
|
|
54
|
+
if (m === null) return null;
|
|
55
|
+
let authority = m[2]!;
|
|
56
|
+
const at = authority.lastIndexOf("@");
|
|
57
|
+
const userinfo = at >= 0;
|
|
58
|
+
if (userinfo) authority = authority.slice(at + 1);
|
|
59
|
+
if (authority.startsWith("[")) return null; // IPv6 literal: not a gateway host
|
|
60
|
+
const colon = authority.indexOf(":");
|
|
61
|
+
const hostname = (colon >= 0 ? authority.slice(0, colon) : authority).toLowerCase();
|
|
62
|
+
const port = colon >= 0 ? authority.slice(colon + 1) : "";
|
|
63
|
+
if (hostname.length === 0) return null;
|
|
64
|
+
return { protocol: m[1]!.toLowerCase() + ":", userinfo, hostname, port };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The regional origin a URL may name for `host`, or null if it is not exactly that. Pure. */
|
|
68
|
+
export function regionalOriginFromLocation(host: string, location: string): string | null {
|
|
69
|
+
const from = parseApiHost(host);
|
|
70
|
+
if (from === null || from.orgSlug === null || from.region !== null) return null;
|
|
71
|
+
const u = parseOrigin(location);
|
|
72
|
+
if (u === null || u.protocol !== "https:" || u.userinfo || u.port !== "") return null;
|
|
73
|
+
const to = parseApiHost(u.hostname);
|
|
74
|
+
if (to === null || to.region === null) return null;
|
|
75
|
+
if (to.orgSlug !== from.orgSlug || to.family !== from.family || to.apex !== from.apex) return null;
|
|
76
|
+
return `https://${u.hostname}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The origin requests go to: the regional origin when the configured one is region-free and redirects, else as given. */
|
|
80
|
+
export async function resolveRegionalOrigin(
|
|
81
|
+
baseUrl: string,
|
|
82
|
+
fetchImpl: typeof fetch = fetch,
|
|
83
|
+
): Promise<{ origin: string; cacheable: boolean }> {
|
|
84
|
+
const configured = baseUrl.replace(/\/+$/, "");
|
|
85
|
+
const parsed = parseOrigin(configured);
|
|
86
|
+
if (parsed === null) return { origin: configured, cacheable: true };
|
|
87
|
+
const host = parseApiHost(parsed.hostname);
|
|
88
|
+
if (host === null || host.orgSlug === null || host.region !== null) return { origin: configured, cacheable: true };
|
|
89
|
+
try {
|
|
90
|
+
const res = await fetchImpl(`${configured}/`, { method: "HEAD", redirect: "manual", headers: { Accept: "*/*" } });
|
|
91
|
+
const location = res.status === 308 ? res.headers?.get?.("location") ?? null : null;
|
|
92
|
+
const named = location ?? (typeof res.url === "string" && res.url.length > 0 ? res.url : null);
|
|
93
|
+
const origin = named === null ? null : regionalOriginFromLocation(parsed.hostname, named);
|
|
94
|
+
return { origin: origin ?? configured, cacheable: true };
|
|
95
|
+
} catch {
|
|
96
|
+
return { origin: configured, cacheable: false };
|
|
97
|
+
}
|
|
98
|
+
}
|