@k9o/gate 0.0.0-stage → 1.0.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 +41 -0
- package/LICENSE +201 -0
- package/NOTICE +4 -0
- package/README.md +110 -2
- package/dist/chunk-LZQ7MBXS.js +41 -0
- package/dist/chunk-LZQ7MBXS.js.map +1 -0
- package/dist/chunk-PEW6HZFB.js +51 -0
- package/dist/chunk-PEW6HZFB.js.map +1 -0
- package/dist/chunk-ZNFI2CQA.js +125 -0
- package/dist/chunk-ZNFI2CQA.js.map +1 -0
- package/dist/errors.d.ts +55 -0
- package/dist/errors.js +9 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +717 -0
- package/dist/index.js +614 -0
- package/dist/index.js.map +1 -0
- package/dist/jwt.d.ts +84 -0
- package/dist/jwt.js +12 -0
- package/dist/jwt.js.map +1 -0
- package/dist/mode.d.ts +82 -0
- package/dist/mode.js +13 -0
- package/dist/mode.js.map +1 -0
- package/dist/types.d.ts +250 -0
- package/dist/types.js +1 -0
- package/dist/types.js.map +1 -0
- package/package.json +59 -4
- package/src/client-factory.ts +39 -0
- package/src/client.ts +175 -0
- package/src/errors.ts +117 -0
- package/src/gate.ts +385 -0
- package/src/idp-providers/identity-providers.ts +244 -0
- package/src/index.ts +62 -0
- package/src/jwt.ts +229 -0
- package/src/magic-link-redirects.ts +103 -0
- package/src/mode.ts +115 -0
- package/src/paths.ts +21 -0
- package/src/region.ts +85 -0
- package/src/types.ts +264 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `createGateClient(config)` — per-instance Gate SDK factory (v0.4.0).
|
|
3
|
+
*
|
|
4
|
+
* Returns a self-contained `GateClient` that does not depend on the
|
|
5
|
+
* module-level `configureGate()` singleton. Multiple clients can coexist
|
|
6
|
+
* in the same process, each scoped to its own project + baseUrl + mode —
|
|
7
|
+
* useful for multi-tenant Backends-For-Frontends (one client per
|
|
8
|
+
* incoming request's resolved tenant) or for apps that maintain both a
|
|
9
|
+
* Standalone and Native binding against the same SDK instance.
|
|
10
|
+
*
|
|
11
|
+
* Per v2.5.68 URL grammar, supply `projectSlug` for
|
|
12
|
+
* canonical `/@<slug>/gate/...` routing. Slug is the only customer-facing
|
|
13
|
+
* project addressing form — project ids are internal-only
|
|
14
|
+
* (`feedback_slug_addressing_v2_5_68`); the retired `/v2/projects/...`
|
|
15
|
+
* mounts were dropped at pass17 (`feedback_v2_path_is_an_error`).
|
|
16
|
+
*
|
|
17
|
+
* Example:
|
|
18
|
+
* const client = createGateClient({
|
|
19
|
+
* baseUrl: 'https://formelo.api.k9o.io',
|
|
20
|
+
* projectSlug: 'partner-console-staging',
|
|
21
|
+
* mode: 'native',
|
|
22
|
+
* });
|
|
23
|
+
* await client.signIn({ email, password });
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { makeGateNamespace } from "./gate.ts";
|
|
27
|
+
import { validateGateConfig, type GateConfig } from "./mode.ts";
|
|
28
|
+
|
|
29
|
+
/** Shape of the per-instance client returned by `createGateClient`. */
|
|
30
|
+
export type GateClient = ReturnType<typeof makeGateNamespace>;
|
|
31
|
+
|
|
32
|
+
/** Construct a Gate client bound to the supplied config. The config is
|
|
33
|
+
* validated up-front and stored on the instance; subsequent calls do not
|
|
34
|
+
* re-read process-level state. Throws `TypeError` on invalid config. */
|
|
35
|
+
export function createGateClient(config: GateConfig): GateClient {
|
|
36
|
+
validateGateConfig(config);
|
|
37
|
+
const frozen: GateConfig = { ...config };
|
|
38
|
+
return makeGateNamespace(() => frozen);
|
|
39
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP client abstraction for the Gate SDK.
|
|
3
|
+
*
|
|
4
|
+
* Centralises:
|
|
5
|
+
* - URL construction against the configured `baseUrl` + per-call path
|
|
6
|
+
* - the standard ch01 §1 envelope unwrap on success
|
|
7
|
+
* - the standard ch01 §2 `KError` rehydration on failure via `gateErrorFromWire`
|
|
8
|
+
* - mode-routing via the `X-Gate-Mode` header (so the gateway routes
|
|
9
|
+
* consumption to the right billing surface without per-function
|
|
10
|
+
* branching at the SDK)
|
|
11
|
+
*
|
|
12
|
+
* Customers don't reach for this directly; it's the internal client
|
|
13
|
+
* every `gate.*` function consumes. `request`/`requestWith` are
|
|
14
|
+
* module-internal only — neither `index.ts` nor the `package.json`
|
|
15
|
+
* `exports` map re-exports them, so they are not part of the public
|
|
16
|
+
* `@k9o/gate` surface. In-package `gate.*` bindings and the test suite
|
|
17
|
+
* import them from this module directly.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { getGateConfig, type GateConfig } from "./mode.ts";
|
|
21
|
+
import { resolveRegionalOrigin } from "./region.ts";
|
|
22
|
+
import { GateError, type KErrorWire, gateErrorFromWire } from "./errors.ts";
|
|
23
|
+
import type { Envelope } from "./types.ts";
|
|
24
|
+
|
|
25
|
+
export type RequestInit = {
|
|
26
|
+
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
27
|
+
path: string;
|
|
28
|
+
/** Optional JSON body — serialised by the client. */
|
|
29
|
+
body?: unknown;
|
|
30
|
+
/** Optional per-call bearer token. Falls back to `partnerToken` from config when omitted. */
|
|
31
|
+
bearerToken?: string;
|
|
32
|
+
/** Optional additional headers; merged with the defaults. */
|
|
33
|
+
headers?: Record<string, string>;
|
|
34
|
+
/** Optional query parameters. */
|
|
35
|
+
query?: Record<string, string | number | boolean | undefined>;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Issue a request against the Gate API. Returns the unwrapped envelope
|
|
40
|
+
* data on 2xx; throws a `GateError` carrying the closed-taxonomy `code`
|
|
41
|
+
* + structured `context` on non-2xx.
|
|
42
|
+
*
|
|
43
|
+
* Implementation notes:
|
|
44
|
+
* - The function pulls config lazily so `configureGate()` order doesn't
|
|
45
|
+
* have to predate import-time module evaluation.
|
|
46
|
+
* - `fetch` is taken from config when set, else from the global; this
|
|
47
|
+
* keeps the SDK portable across Bun + Node 18+ + browsers + test
|
|
48
|
+
* fixtures that supply a stub.
|
|
49
|
+
* - JSON parsing of non-JSON responses throws — gateway always returns
|
|
50
|
+
* JSON per ch01 §1 + §2, so a parse failure is an upstream defect.
|
|
51
|
+
*/
|
|
52
|
+
export async function request<T>(init: RequestInit): Promise<Envelope<T>> {
|
|
53
|
+
return requestWith<T>(getGateConfig(), init);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Issue a request using an explicit config (used by per-instance clients
|
|
58
|
+
* returned from `createGateClient`). Singleton callers reach for
|
|
59
|
+
* `request()` which delegates here against the module-level config.
|
|
60
|
+
*/
|
|
61
|
+
export async function requestWith<T>(
|
|
62
|
+
config: GateConfig,
|
|
63
|
+
init: RequestInit,
|
|
64
|
+
): Promise<Envelope<T>> {
|
|
65
|
+
const fetchImpl = config.fetch ?? globalThis.fetch;
|
|
66
|
+
if (typeof fetchImpl !== "function") {
|
|
67
|
+
throw new GateError({
|
|
68
|
+
code: "INTERNAL_ERROR",
|
|
69
|
+
message:
|
|
70
|
+
"No fetch implementation available — configure {fetch} in configureGate() for non-browser, non-Bun, non-Node-18+ environments",
|
|
71
|
+
context: {},
|
|
72
|
+
recommendation: null,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const url = buildUrl(await resolveRegionalOrigin(config), init.path, init.query);
|
|
77
|
+
const headers: Record<string, string> = {
|
|
78
|
+
accept: "application/json",
|
|
79
|
+
"x-gate-mode": config.mode,
|
|
80
|
+
...(init.headers ?? {}),
|
|
81
|
+
};
|
|
82
|
+
const bearer = init.bearerToken ?? config.partnerToken;
|
|
83
|
+
if (typeof bearer === "string" && bearer.length > 0) {
|
|
84
|
+
headers["authorization"] = `Bearer ${bearer}`;
|
|
85
|
+
}
|
|
86
|
+
const requestInit: Record<string, unknown> = {
|
|
87
|
+
method: init.method,
|
|
88
|
+
headers,
|
|
89
|
+
};
|
|
90
|
+
if (init.body !== undefined) {
|
|
91
|
+
headers["content-type"] = "application/json";
|
|
92
|
+
requestInit["body"] = JSON.stringify(init.body);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let response: Response;
|
|
96
|
+
try {
|
|
97
|
+
response = await fetchImpl(url, requestInit as Parameters<typeof fetchImpl>[1]);
|
|
98
|
+
} catch (e) {
|
|
99
|
+
throw new GateError({
|
|
100
|
+
code: "UPSTREAM_UNAVAILABLE",
|
|
101
|
+
message: `Network error reaching ${url}: ${e instanceof Error ? e.message : String(e)}`,
|
|
102
|
+
context: { url },
|
|
103
|
+
recommendation: null,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let parsed: unknown;
|
|
108
|
+
try {
|
|
109
|
+
parsed = await response.json();
|
|
110
|
+
} catch (e) {
|
|
111
|
+
throw new GateError({
|
|
112
|
+
code: "INTERNAL_ERROR",
|
|
113
|
+
message: `Failed to parse JSON response from ${url}: ${e instanceof Error ? e.message : String(e)}`,
|
|
114
|
+
context: { url, status: response.status },
|
|
115
|
+
recommendation: null,
|
|
116
|
+
status: response.status,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (response.status >= 200 && response.status < 300) {
|
|
121
|
+
if (!isEnvelope<T>(parsed)) {
|
|
122
|
+
throw new GateError({
|
|
123
|
+
code: "INTERNAL_ERROR",
|
|
124
|
+
message: `Malformed envelope from ${url}: expected {data, meta}`,
|
|
125
|
+
context: { url, status: response.status },
|
|
126
|
+
recommendation: null,
|
|
127
|
+
status: response.status,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return parsed;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Non-2xx: the gateway returns a KError-shaped JSON payload per ch01 §2.
|
|
134
|
+
if (isKErrorWire(parsed)) {
|
|
135
|
+
throw gateErrorFromWire(parsed, response.status);
|
|
136
|
+
}
|
|
137
|
+
throw new GateError({
|
|
138
|
+
code: "INTERNAL_ERROR",
|
|
139
|
+
message: `Upstream returned ${response.status} with non-KError body`,
|
|
140
|
+
context: { url, status: response.status, body: parsed },
|
|
141
|
+
recommendation: null,
|
|
142
|
+
status: response.status,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function buildUrl(
|
|
147
|
+
baseUrl: string,
|
|
148
|
+
path: string,
|
|
149
|
+
query: RequestInit["query"],
|
|
150
|
+
): string {
|
|
151
|
+
const trimmedBase = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
|
|
152
|
+
const trimmedPath = path.startsWith("/") ? path : `/${path}`;
|
|
153
|
+
let url = `${trimmedBase}${trimmedPath}`;
|
|
154
|
+
if (query !== undefined) {
|
|
155
|
+
const params = new URLSearchParams();
|
|
156
|
+
for (const [k, v] of Object.entries(query)) {
|
|
157
|
+
if (v !== undefined) params.set(k, String(v));
|
|
158
|
+
}
|
|
159
|
+
const qs = params.toString();
|
|
160
|
+
if (qs.length > 0) url += `?${qs}`;
|
|
161
|
+
}
|
|
162
|
+
return url;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function isEnvelope<T>(value: unknown): value is Envelope<T> {
|
|
166
|
+
if (value === null || typeof value !== "object") return false;
|
|
167
|
+
const v = value as { data?: unknown; meta?: unknown };
|
|
168
|
+
return "data" in v && "meta" in v && typeof v.meta === "object" && v.meta !== null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function isKErrorWire(value: unknown): value is KErrorWire {
|
|
172
|
+
if (value === null || typeof value !== "object") return false;
|
|
173
|
+
const v = value as { code?: unknown; message?: unknown };
|
|
174
|
+
return typeof v.code === "string" && typeof v.message === "string";
|
|
175
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gate error shapes. `GateError` is `KError`-compatible per ch01 §2 —
|
|
3
|
+
* downstream catch blocks can `instanceof GateError`-narrow without
|
|
4
|
+
* importing the gateway's `KError` class directly (the SDK is a customer-
|
|
5
|
+
* facing dependency; reaching across to the gateway's internal error
|
|
6
|
+
* type would be a layering violation).
|
|
7
|
+
*
|
|
8
|
+
* The taxonomy below is the v0.1.0 closed set; additions follow the
|
|
9
|
+
* ch11 error-catalogue freeze cycle.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Closed-taxonomy error code per ch01 §2. Strings are the wire format —
|
|
14
|
+
* camelCase-cased KError families per the convention.
|
|
15
|
+
*/
|
|
16
|
+
export type GateErrorCode =
|
|
17
|
+
| "UNAUTHENTICATED"
|
|
18
|
+
| "INVALID_FIELD_VALUE"
|
|
19
|
+
| "EMAIL_ALREADY_REGISTERED"
|
|
20
|
+
| "INVALID_CREDENTIALS"
|
|
21
|
+
| "SESSION_EXPIRED"
|
|
22
|
+
| "REFRESH_TOKEN_INVALID"
|
|
23
|
+
| "TIER_FEATURE_GATED"
|
|
24
|
+
| "RBAC_DENY"
|
|
25
|
+
// FORBIDDEN + NOT_FOUND added at v0.3.0 — surfaced by the
|
|
26
|
+
// gate.idpProviders.* sub-namespaces (project-membership gate +
|
|
27
|
+
// resource-existence checks per gateway v2.5.57 RBAC + NOT_FOUND
|
|
28
|
+
// paths).
|
|
29
|
+
| "FORBIDDEN"
|
|
30
|
+
| "NOT_FOUND"
|
|
31
|
+
// GATE_REDIRECT_NOT_ALLOWED — magic-link `redirectUrl` not in the project's
|
|
32
|
+
// redirect allowlist (open-redirect-safe exact-match, per-project
|
|
33
|
+
// allowlist capability).
|
|
34
|
+
| "GATE_REDIRECT_NOT_ALLOWED"
|
|
35
|
+
| "POLICY_AUTHORING_AUTHORITY_INSUFFICIENT"
|
|
36
|
+
| "UPSTREAM_UNAVAILABLE"
|
|
37
|
+
| "INTERNAL_ERROR";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Surface error thrown by the SDK. Mirrors the gateway's `KError` shape
|
|
41
|
+
* per ch01 §2.1 — `code` (closed taxonomy), `message` (human-readable),
|
|
42
|
+
* `context` (structured per-code payload), `recommendation` (action the
|
|
43
|
+
* caller can take, or null when there's no canonical recommendation).
|
|
44
|
+
*/
|
|
45
|
+
export class GateError extends Error {
|
|
46
|
+
readonly code: GateErrorCode;
|
|
47
|
+
readonly context: Readonly<Record<string, unknown>>;
|
|
48
|
+
readonly recommendation: string | null;
|
|
49
|
+
/** HTTP status the gateway returned (if the error came from a wire response). */
|
|
50
|
+
readonly status: number | null;
|
|
51
|
+
|
|
52
|
+
constructor(init: {
|
|
53
|
+
code: GateErrorCode;
|
|
54
|
+
message: string;
|
|
55
|
+
context?: Record<string, unknown>;
|
|
56
|
+
recommendation?: string | null;
|
|
57
|
+
status?: number | null;
|
|
58
|
+
}) {
|
|
59
|
+
super(init.message);
|
|
60
|
+
this.name = "GateError";
|
|
61
|
+
this.code = init.code;
|
|
62
|
+
this.context = Object.freeze({ ...(init.context ?? {}) });
|
|
63
|
+
this.recommendation = init.recommendation ?? null;
|
|
64
|
+
this.status = init.status ?? null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Wire-shape sentinel matching the gateway's `KError` JSON payload. The
|
|
70
|
+
* client uses this to recognise gateway responses and rehydrate them
|
|
71
|
+
* into a `GateError` instance with the right code + context preserved.
|
|
72
|
+
*/
|
|
73
|
+
export type KErrorWire = {
|
|
74
|
+
code: string;
|
|
75
|
+
message: string;
|
|
76
|
+
context?: Record<string, unknown>;
|
|
77
|
+
recommendation?: string | null;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Narrow a wire `KError` to a strongly-typed `GateError`. Unknown codes
|
|
82
|
+
* pass through as `INTERNAL_ERROR` with the original code preserved in
|
|
83
|
+
* `context.originalCode` so consumers don't lose telemetry when the
|
|
84
|
+
* server-side taxonomy outpaces the SDK's bundled enum.
|
|
85
|
+
*/
|
|
86
|
+
export function gateErrorFromWire(wire: KErrorWire, status: number): GateError {
|
|
87
|
+
const knownCodes: ReadonlyArray<GateErrorCode> = [
|
|
88
|
+
"UNAUTHENTICATED",
|
|
89
|
+
"INVALID_FIELD_VALUE",
|
|
90
|
+
"EMAIL_ALREADY_REGISTERED",
|
|
91
|
+
"INVALID_CREDENTIALS",
|
|
92
|
+
"SESSION_EXPIRED",
|
|
93
|
+
"REFRESH_TOKEN_INVALID",
|
|
94
|
+
"TIER_FEATURE_GATED",
|
|
95
|
+
"RBAC_DENY",
|
|
96
|
+
"FORBIDDEN",
|
|
97
|
+
"NOT_FOUND",
|
|
98
|
+
"GATE_REDIRECT_NOT_ALLOWED",
|
|
99
|
+
"POLICY_AUTHORING_AUTHORITY_INSUFFICIENT",
|
|
100
|
+
"UPSTREAM_UNAVAILABLE",
|
|
101
|
+
"INTERNAL_ERROR",
|
|
102
|
+
];
|
|
103
|
+
// Wire codes are platform-namespaced (`K9O_*` per the ch11 error namespace);
|
|
104
|
+
// strip the prefix to match the SDK's bundled bare-code enum. The full wire
|
|
105
|
+
// code is always preserved in `context.originalCode` for telemetry.
|
|
106
|
+
const stripped = wire.code.replace(/^K9O_/, "");
|
|
107
|
+
const isKnown = (knownCodes as ReadonlyArray<string>).includes(stripped);
|
|
108
|
+
const code: GateErrorCode = isKnown ? (stripped as GateErrorCode) : "INTERNAL_ERROR";
|
|
109
|
+
const context = { ...(wire.context ?? {}), originalCode: wire.code };
|
|
110
|
+
return new GateError({
|
|
111
|
+
code,
|
|
112
|
+
message: wire.message,
|
|
113
|
+
context,
|
|
114
|
+
recommendation: wire.recommendation ?? null,
|
|
115
|
+
status,
|
|
116
|
+
});
|
|
117
|
+
}
|