@k9o/gate 0.0.0-stage → 1.1.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/src/gate.ts ADDED
@@ -0,0 +1,384 @@
1
+ /**
2
+ * Marquee `gate.*` namespace.
3
+ *
4
+ * surfaces (canonical slug routing — `<orgSlug>.api.k9o.io/@<projectSlug>/...`):
5
+ * - `gate.signUp` — POST /@<projectSlug>/gate/signup
6
+ * - `gate.signIn` — POST /@<projectSlug>/gate/signin
7
+ * - `gate.signOut` — POST /@<projectSlug>/gate/signout
8
+ * - `gate.whoami` — GET /@<projectSlug>/gate/whoami
9
+ * - `gate.refreshToken` — POST /@<projectSlug>/gate/refresh
10
+ * - `gate.verifySession` — GET /@<projectSlug>/gate/verify
11
+ * - `gate.hosted` — GET /@<projectSlug>/gate/hosted (unauthenticated; `{ signInUrl, rpId }`)
12
+ *
13
+ * Sub-namespaces (passwordless / passkeys / social / mfa / password /
14
+ * users / email / sms / federation / admin) deferred to subsequent
15
+ * cycles per the README catalogue.
16
+ *
17
+ * Wire shape: every function returns the standard envelope
18
+ * `{ data, meta }` and throws a `GateError` on non-2xx
19
+ * gateway responses. Native vs Standalone mode does not affect SDK
20
+ * ergonomics; the mode header is set internally by the
21
+ * client + does not surface in the function-call ergonomics.
22
+ *
23
+ * Endpoint paths are reconciled to the LIVE gateway routes (verified
24
+ * against staging): signup/signin/signout/whoami/refresh/verify under
25
+ * `/@<projectSlug>/gate/*`, OTP under `/gate/otp/{request,verify}`,
26
+ * Magic Link under `/gate/magic-link/{send,redeem}`, IdP under
27
+ * `/gate/idp/:provider/{authorize,callback}`.
28
+ */
29
+
30
+ import { getGateConfig, type GateConfig } from "./mode.ts";
31
+ import { requestWith } from "./client.ts";
32
+ import { resolveProjectPathRoot } from "./paths.ts";
33
+ import { makeIdentityProviders } from "./idp-providers/identity-providers.ts";
34
+ import { makeMagicLinkRedirects } from "./magic-link-redirects.ts";
35
+ import type {
36
+ AuthResult,
37
+ Envelope,
38
+ HostedGateInfo,
39
+ MagicLinkCallbackResult,
40
+ RefreshTokenInput,
41
+ RequestMagicLinkInput,
42
+ RequestOtpInput,
43
+ SignInCallbackResult,
44
+ SignInInput,
45
+ SignOutInput,
46
+ SignUpInput,
47
+ VerifyMagicLinkInput,
48
+ VerifyOtpInput,
49
+ VerifySessionResult,
50
+ WhoamiResult,
51
+ } from "./types.ts";
52
+
53
+ function gatePath(config: GateConfig, suffix: string): string {
54
+ return `${resolveProjectPathRoot(config)}/gate/${suffix}`;
55
+ }
56
+
57
+ /** Factory — produces a `gate.*` namespace bound to the supplied config getter.
58
+ * Used by both the module-level `gate` singleton (config from `configureGate`)
59
+ * and `createGateClient(config)` (config supplied at construction time). */
60
+ export function makeGateNamespace(getConfig: () => GateConfig) {
61
+ return {
62
+ /**
63
+ * Create a new end-user identity for the configured project. Returns
64
+ * the new user record + an initial session. Emits a
65
+ * `data.write.auth.signup` audit event.
66
+ *
67
+ * Errors (closed taxonomy):
68
+ * - `EMAIL_ALREADY_REGISTERED` (409) when the email collides
69
+ * - `INVALID_FIELD_VALUE` (400) on schema violation (password policy, malformed email)
70
+ * - `TIER_FEATURE_GATED` (403) when the project's tier blocks email/password
71
+ */
72
+ async signUp(input: SignUpInput): Promise<Envelope<AuthResult>> {
73
+ return requestWith<AuthResult>(getConfig(), {
74
+ method: "POST",
75
+ path: gatePath(getConfig(), "signup"),
76
+ body: input,
77
+ });
78
+ },
79
+
80
+ /**
81
+ * Sign in an existing end-user with email + password. Returns the
82
+ * user record + a fresh session. Emits a `data.write.auth.signin`
83
+ * audit event.
84
+ *
85
+ * Errors:
86
+ * - `INVALID_CREDENTIALS` (401) on bad email/password (ambiguous on
87
+ * purpose — does not distinguish "user not found" vs "password
88
+ * wrong" to avoid enumeration)
89
+ */
90
+ async signIn(input: SignInInput): Promise<Envelope<AuthResult>> {
91
+ return requestWith<AuthResult>(getConfig(), {
92
+ method: "POST",
93
+ path: gatePath(getConfig(), "signin"),
94
+ body: input,
95
+ });
96
+ },
97
+
98
+ /**
99
+ * Sign out — revoke the bound refresh token (or an explicit one, if
100
+ * supplied). Emits a `data.write.auth.signout` audit event.
101
+ *
102
+ * Errors:
103
+ * - `UNAUTHENTICATED` (401) when no session is present
104
+ * - `REFRESH_TOKEN_INVALID` (400) when the supplied refresh token
105
+ * doesn't match the bearer's session
106
+ */
107
+ async signOut(input: SignOutInput = {}): Promise<Envelope<{ revoked: boolean }>> {
108
+ return requestWith<{ revoked: boolean }>(getConfig(), {
109
+ method: "POST",
110
+ path: gatePath(getConfig(), "signout"),
111
+ body: input,
112
+ });
113
+ },
114
+
115
+ /**
116
+ * Return the caller's identity claims from the bearer JWT. Useful in
117
+ * Standalone mode where the SDK consumer wants to surface the user's
118
+ * roles / groups without parsing the JWT itself.
119
+ *
120
+ * Errors:
121
+ * - `UNAUTHENTICATED` (401) when no bearer is present
122
+ * - `SESSION_EXPIRED` (401) when the bearer's exp has passed
123
+ */
124
+ async whoami(): Promise<Envelope<WhoamiResult>> {
125
+ return requestWith<WhoamiResult>(getConfig(), {
126
+ method: "GET",
127
+ path: gatePath(getConfig(), "whoami"),
128
+ });
129
+ },
130
+
131
+ /**
132
+ * Rotate the access token using a refresh token. Returns a fresh
133
+ * session pair (new access token + same refresh token; gateway may
134
+ * also rotate the refresh token per project policy).
135
+ *
136
+ * Errors:
137
+ * - `REFRESH_TOKEN_INVALID` (400) when the refresh token is unknown,
138
+ * expired, or revoked
139
+ */
140
+ async refreshToken(input: RefreshTokenInput): Promise<Envelope<{ session: AuthResult["session"] }>> {
141
+ return requestWith<{ session: AuthResult["session"] }>(getConfig(), {
142
+ method: "POST",
143
+ path: gatePath(getConfig(), "refresh"),
144
+ body: input,
145
+ });
146
+ },
147
+
148
+ /**
149
+ * Check whether the bearer's session is currently valid. Does NOT
150
+ * throw on expiry or invalid token — returns `{ valid: false }`
151
+ * inside the envelope. The intended use is health-checking a session
152
+ * without surfacing an error to the end-user (the SDK consumer
153
+ * typically wants to redirect to sign-in rather than surface a 401).
154
+ */
155
+ async verifySession(): Promise<Envelope<VerifySessionResult>> {
156
+ return requestWith<VerifySessionResult>(getConfig(), {
157
+ method: "GET",
158
+ path: gatePath(getConfig(), "verify"),
159
+ });
160
+ },
161
+
162
+ /**
163
+ * The project's hosted sign-in page URL and WebAuthn `rpId`. Unauthenticated and cacheable; no credential is sent,
164
+ * even when a partner token is configured. Take both values as given: an unknown org, a deleted project and a
165
+ * non-Gate project all answer the same `PROJECT_NOT_FOUND`, so a failure here says nothing about which.
166
+ */
167
+ async hosted(): Promise<Envelope<HostedGateInfo>> {
168
+ return requestWith<HostedGateInfo>(getConfig(), {
169
+ method: "GET",
170
+ path: gatePath(getConfig(), "hosted"),
171
+ bearerToken: "",
172
+ });
173
+ },
174
+
175
+ /**
176
+ * Initiate an external-IdP sign-in flow (Google / GitHub / Microsoft).
177
+ * The SDK POSTs to the gateway's authorize endpoint, receives the
178
+ * provider's authorization URL with a signed-state JWT (carrying PKCE
179
+ * verifier + the supplied `redirectUri`), and redirects the browser.
180
+ *
181
+ * After the user authenticates with the IdP, the gateway processes the
182
+ * callback and redirects the browser back to `redirectUri` with session
183
+ * tokens in the URL fragment. Use `parseSignInCallback()` on the
184
+ * destination page to extract them.
185
+ *
186
+ * Example:
187
+ * await gate.signInWithProvider({
188
+ * provider: 'google',
189
+ * redirectUri: 'https://app.example.com/auth/callback',
190
+ * });
191
+ * // …user lands at /auth/callback#access_token=…&refresh_token=…
192
+ *
193
+ * Generic OIDC requires authorization-endpoint discovery and is not
194
+ * yet wired; use a hosted provider (google/github/microsoft) for now.
195
+ */
196
+ async signInWithProvider(input: {
197
+ provider: "google" | "github" | "microsoft" | "oidc" | "saml";
198
+ redirectUri: string;
199
+ }): Promise<void> {
200
+ const env = await requestWith<{ authorizationUrl: string }>(getConfig(), {
201
+ method: "POST",
202
+ path: `${resolveProjectPathRoot(getConfig())}/gate/idp/${input.provider}/authorize`,
203
+ body: { redirectUri: input.redirectUri },
204
+ });
205
+ const w = (globalThis as { window?: { location: { assign: (url: string) => void } } }).window;
206
+ if (w !== undefined) {
207
+ w.location.assign(env.data.authorizationUrl);
208
+ } else {
209
+ throw new Error(
210
+ "signInWithProvider requires a browser environment (window.location); for server-side initiation, fetch the authorize endpoint directly.",
211
+ );
212
+ }
213
+ },
214
+
215
+ /**
216
+ * Parse session tokens out of the URL fragment delivered by an
217
+ * IdP callback redirect. Returns the session payload on success or an
218
+ * error object on failure. Pass `window.location.hash` (or any
219
+ * `#`-prefixed string) — leading `#` is stripped automatically.
220
+ *
221
+ * Side-effect-free: does not call the gateway and does not mutate
222
+ * any session storage. The consumer is responsible for persisting
223
+ * the returned tokens (e.g. via the app's auth context).
224
+ */
225
+ parseSignInCallback(hash: string): SignInCallbackResult {
226
+ const raw = hash.startsWith("#") ? hash.slice(1) : hash;
227
+ const params = new URLSearchParams(raw);
228
+ const error = params.get("error");
229
+ if (error !== null) {
230
+ return {
231
+ ok: false,
232
+ error,
233
+ errorDescription: params.get("error_description") ?? error,
234
+ };
235
+ }
236
+ const accessToken = params.get("access_token");
237
+ const refreshToken = params.get("refresh_token");
238
+ const expiresAt = params.get("expires_at");
239
+ if (accessToken === null || refreshToken === null || expiresAt === null) {
240
+ return {
241
+ ok: false,
242
+ error: "malformed_callback",
243
+ errorDescription:
244
+ "Callback URL fragment missing one or more required fields (access_token, refresh_token, expires_at).",
245
+ };
246
+ }
247
+ return {
248
+ ok: true,
249
+ accessToken,
250
+ refreshToken,
251
+ expiresAt,
252
+ tokenType: params.get("token_type") ?? "Bearer",
253
+ };
254
+ },
255
+
256
+ /**
257
+ * Passwordless sub-namespace — OTP + Magic Link flows
258
+ * for email-based authentication without a stored password.
259
+ *
260
+ * Wire endpoints (LIVE on the Gate end-user plane — verified against
261
+ * staging): OTP → `POST /@<projectSlug>/gate/otp/{request,verify}`
262
+ * (routes/otp.ts), Magic Link → `POST /@<projectSlug>/gate/magic-link/
263
+ * {send,redeem}` (routes/magic-link.ts). NOTE: these are the gateway's
264
+ * canonical paths; the SDK ergonomic method names (`requestMagicLink`/
265
+ * `verifyMagicLink`) do not mirror the path segments (`send`/`redeem`).
266
+ * (The SDK paths match the shipped gateway routes.)
267
+ */
268
+ passwordless: {
269
+ /**
270
+ * Request a one-time passcode delivered to the supplied email.
271
+ * The OTP is short-lived (typically 5 min) and
272
+ * single-use; redemption via `verifyOtp` mints a session.
273
+ */
274
+ async requestOtp(input: RequestOtpInput): Promise<Envelope<{ requested: boolean }>> {
275
+ return requestWith<{ requested: boolean }>(getConfig(), {
276
+ method: "POST",
277
+ path: gatePath(getConfig(), "otp/request"),
278
+ body: input,
279
+ });
280
+ },
281
+
282
+ /**
283
+ * Verify a one-time passcode previously sent via `requestOtp`.
284
+ * On success returns the same `AuthResult` shape as `signIn` —
285
+ * a fresh session bound to the end-user identity.
286
+ *
287
+ * Errors:
288
+ * - INVALID_CREDENTIALS (401) on wrong / expired / consumed OTP
289
+ * (ambiguous on purpose — enumeration defense)
290
+ */
291
+ async verifyOtp(input: VerifyOtpInput): Promise<Envelope<AuthResult>> {
292
+ return requestWith<AuthResult>(getConfig(), {
293
+ method: "POST",
294
+ path: gatePath(getConfig(), "otp/verify"),
295
+ body: input,
296
+ });
297
+ },
298
+
299
+ /**
300
+ * Request a magic-link emailed to the supplied address. The link
301
+ * carries an opaque short-lived token (typically 15 min
302
+ *); clicking the link lands at the customer's chosen
303
+ * redirect URL with the token in the query string, which the
304
+ * customer's frontend then exchanges via `verifyMagicLink`.
305
+ */
306
+ async requestMagicLink(
307
+ input: RequestMagicLinkInput,
308
+ ): Promise<Envelope<{ requested: boolean }>> {
309
+ return requestWith<{ requested: boolean }>(getConfig(), {
310
+ method: "POST",
311
+ path: gatePath(getConfig(), "magic-link/send"),
312
+ body: input,
313
+ });
314
+ },
315
+
316
+ /**
317
+ * Verify a magic-link token previously delivered via
318
+ * `requestMagicLink`. On success returns the same `AuthResult`
319
+ * shape as `signIn`.
320
+ *
321
+ * Errors:
322
+ * - INVALID_CREDENTIALS (401) on wrong / expired / consumed
323
+ * token (same enumeration-defense rationale as verifyOtp)
324
+ */
325
+ async verifyMagicLink(input: VerifyMagicLinkInput): Promise<Envelope<AuthResult>> {
326
+ return requestWith<AuthResult>(getConfig(), {
327
+ method: "POST",
328
+ path: gatePath(getConfig(), "magic-link/redeem"),
329
+ body: input,
330
+ });
331
+ },
332
+
333
+ /**
334
+ * Extract the opaque magic-link token from the landing-page URL's query
335
+ * string (the gateway redirects to `<configured-redirect>?token=…&project=…`).
336
+ * Pure + side-effect-free — pass `window.location.search`. Feed the
337
+ * returned `token` to `verifyMagicLink({ token })` to mint the session.
338
+ *
339
+ * This is the magic-link counterpart to `parseSignInCallback` (which parses
340
+ * the SSO *hash fragment* for session tokens) — magic-link delivers an
341
+ * opaque token in the *query string*, so the two are not interchangeable.
342
+ */
343
+ parseMagicLinkCallback(search: string): MagicLinkCallbackResult {
344
+ const raw = search.startsWith("?") ? search.slice(1) : search;
345
+ const params = new URLSearchParams(raw);
346
+ const error = params.get("error");
347
+ if (error !== null) {
348
+ return { ok: false, error };
349
+ }
350
+ const token = params.get("token");
351
+ if (token === null || token.length === 0) {
352
+ return { ok: false, error: "missing_token" };
353
+ }
354
+ return { ok: true, token };
355
+ },
356
+ },
357
+
358
+ /**
359
+ * Project-scoped end-user IdP provider-config management.
360
+ * PROJECT_ADMIN+ required; end-user JWTs rejected at the route. Only
361
+ * `create()` is bound — the REST create the Gate Studio
362
+ * `FederationPresetsPanel` routes to when it drops the `gate_flows`
363
+ * `__addFromPreset` sentinel. Gate-admin surface.
364
+ */
365
+ identityProviders: makeIdentityProviders(getConfig),
366
+
367
+ /**
368
+ * Per-project magic-link redirect-URL allowlist management (blessed
369
+ * shape). Gate-admin surface alongside `identityProviders`
370
+ * — PROJECT_ADMIN+ required, distinct from the end-user-facing
371
+ * `passwordless.requestMagicLink`/`verifyMagicLink` send/verify flow.
372
+ */
373
+ magicLinkRedirects: makeMagicLinkRedirects(getConfig),
374
+ };
375
+ }
376
+
377
+ /** Singleton bound to the module-level `configureGate()` config.
378
+ * Per-instance clients: `createGateClient(config)`. */
379
+ export const gate = makeGateNamespace(getGateConfig);
380
+
381
+ /** Convenience type alias mirroring the `gate` runtime export.
382
+ * Identical shape as the per-instance `GateClient` returned from
383
+ * `createGateClient`. */
384
+ export type GateNamespace = ReturnType<typeof makeGateNamespace>;
@@ -0,0 +1,244 @@
1
+ /**
2
+ * `gate.identityProviders` namespace — project-scoped end-user IdP
3
+ * provider-config management.
4
+ *
5
+ * Wraps the live gateway route family shipped in
6
+ * `services/gateway/src/routes/project-identity-providers.ts`, mounted on
7
+ * the project plane (`<orgSlug>.api.k9o.io/@<projectSlug>/identity-providers`):
8
+ *
9
+ * POST /@<projectSlug>/identity-providers — create (PROJECT_ADMIN)
10
+ * GET /@<projectSlug>/identity-providers — list (READ_ROLES)
11
+ * PATCH /@<projectSlug>/identity-providers/:epcId — update (PROJECT_ADMIN)
12
+ * POST /@<projectSlug>/identity-providers/:epcId/test — testProvider (PROJECT_ADMIN)
13
+ *
14
+ * `create()` + `list()` back the Gate Studio `FederationPresetsPanel`; `update()` backs its
15
+ * `DirectorySyncPanel` (directory-sync settings are read off `list()` and written via `update()`, replacing
16
+ * the unanswered `federation.getDirectorySync`/`updateDirectorySync` bridge messages). Read-one/delete stay
17
+ * unbound until a caller needs them (the SDK does not speculatively surface routes).
18
+ *
19
+ * Auth: operator JWT with PROJECT_ADMIN+ role, server-enforced by the
20
+ * gateway RBAC layer (`requireProjectAdmin`); end-user JWTs (`eu_`-prefixed
21
+ * subjects) are rejected at the route. The bearer is taken from
22
+ * `partnerToken` in config, or per-call via the client's bearer plumbing.
23
+ *
24
+ * 🔴 `clientSecretRef` is a RAW SECRET on the way in, not a reference.
25
+ * Post-v105 the gateway encrypts the submitted value (AES-256-GCM under
26
+ * `K9O_SECRET_STORAGE_MASTER_KEY`) into the opaque `client_secret_enc`
27
+ * handle and persists only that; the field name is retained for wire
28
+ * compatibility but the create handler comment
29
+ * (`project-identity-providers.ts`, the v105 INSERT) is authoritative —
30
+ * there is NO Kibo custody on this path. Reads NEVER carry the secret or its
31
+ * handle: they carry only `clientSecretSet` (a presence boolean,
32
+ * the write-only fix). `serviceAccountKey` (directory sync) follows the same write-only rule.
33
+ */
34
+
35
+ import { getGateConfig, type GateConfig } from "../mode.ts";
36
+ import { requestWith } from "../client.ts";
37
+ import { resolveProjectPathRoot } from "../paths.ts";
38
+ import type { Envelope } from "../types.ts";
39
+
40
+ /** Closed end-user-class provider enum — 11 values since v221 (the project-plane substrate
41
+ * `_sys.end_user_idp_provider_configs`); the org plane carries the same 11 since v222/v223. */
42
+ export type IdpProviderKind =
43
+ | "google" | "github" | "microsoft" | "oidc" | "saml" | "scim"
44
+ | "ldap" | "active_directory" | "apple" | "facebook" | "twitter";
45
+
46
+ /**
47
+ * The result of a provider connectivity test (`testProvider`). `ok` is the reachability + metadata-validity
48
+ * verdict; `reason` (present only when `!ok`) is a CLOSED-enum machine code, NEVER the upstream response body
49
+ * or any credential material. v1 is a NO-SECRET probe: it validates the issuer/metadata
50
+ * endpoint is reachable + structurally valid; it never transmits the stored client secret. `credentials_rejected`
51
+ * is deliberately NOT a v1 reason — the secret is never used, so it cannot be validated (a v2 concern).
52
+ */
53
+ export type FederationTestProviderResult = {
54
+ ok: boolean;
55
+ reason?:
56
+ | "endpoint_unreachable"
57
+ | "endpoint_forbidden"
58
+ | "metadata_invalid"
59
+ | "secret_not_provisioned"
60
+ | "kind_unsupported";
61
+ };
62
+
63
+ /** Per the provider→tier pairing is structurally fixed:
64
+ * `free_tier_ok ∈ {google, github, microsoft, apple, facebook, twitter}`,
65
+ * `ridge_plus ∈ {oidc, saml, scim, ldap, active_directory}`. The gateway rejects a mismatched
66
+ * pairing (`K9O_INVALID_FIELD_VALUE`); the SDK forwards the caller value
67
+ * verbatim rather than second-guessing it. */
68
+ export type IdpTierGating = "free_tier_ok" | "ridge_plus";
69
+
70
+ /**
71
+ * The provider-config metadata shape returned by every read surface and
72
+ * mutation response (`shapeIdpConfig`). Mirrors the gateway wire shape
73
+ * one-to-one.
74
+ *
75
+ * Reads carry `clientSecretSet` (presence only), never the secret or a handle (see the module header).
76
+ * `metadataUrl`/`updatedAt` are `null` until set; `attributeMap` defaults to `{}`; `allowedRedirectUris`
77
+ * defaults to `[]`. The `directory*` fields are GS-F7 scheduled directory sync (Google only,):
78
+ * settings plus the scheduler's last-run status; the service-account key itself is never returned.
79
+ */
80
+ export type IdpConfig = {
81
+ id: string;
82
+ projectId: string;
83
+ idpProvider: IdpProviderKind;
84
+ displayName: string;
85
+ clientId: string;
86
+ /** Whether a client secret is stored. The secret (and any handle to it) is write-only. */
87
+ clientSecretSet: boolean;
88
+ redirectUri: string;
89
+ metadataUrl: string | null;
90
+ attributeMap: Record<string, unknown>;
91
+ tierGating: IdpTierGating;
92
+ enabled: boolean;
93
+ allowedRedirectUris: string[];
94
+ directoryPullEnabled: boolean;
95
+ directorySyncSubject: string | null;
96
+ directorySyncIntervalMins: number | null;
97
+ directorySyncLastRunAt: string | null;
98
+ directorySyncLastStatus: string | null;
99
+ createdAt: string;
100
+ updatedAt: string | null;
101
+ };
102
+
103
+ /** GS-F7 directory-sync write fields — accepted on create AND update (one PROJECT_ADMIN surface). */
104
+ export type DirectorySyncInput = {
105
+ /** Enabling requires `idpProvider: "google"`, a `serviceAccountKey` (now or already stored) and an interval. */
106
+ directoryPullEnabled?: boolean;
107
+ directorySyncIntervalMins?: number;
108
+ /** The raw Google service-account key JSON. Write-only: encrypted at rest server-side, never returned. */
109
+ serviceAccountKey?: string;
110
+ /** The Workspace admin user the service account impersonates (domain-wide delegation subject). */
111
+ directorySyncSubject?: string;
112
+ };
113
+
114
+ /**
115
+ * Create-request body — mirrors the gateway `CreateIdpBody` (t.Object)
116
+ * exactly, including which fields are optional.
117
+ */
118
+ export type CreateIdpInput = {
119
+ idpProvider: IdpProviderKind;
120
+ displayName: string;
121
+ clientId: string;
122
+ /**
123
+ * The OAuth2 client secret. Sent over TLS; encrypted-at-rest server-side
124
+ * (AES-256-GCM) and never returned. Named `clientSecretRef` for wire
125
+ * compatibility — it is NOT a reference on the way in (see module header).
126
+ */
127
+ clientSecretRef: string;
128
+ redirectUri: string;
129
+ /** Optional; `null`/omitted for providers that don't use discovery metadata. */
130
+ metadataUrl?: string | null;
131
+ /** Optional claim→attribute mapping; defaults to `{}` server-side. */
132
+ attributeMap?: Record<string, string>;
133
+ tierGating: IdpTierGating;
134
+ /** Defaults to `false` server-side — a provider is created disabled. */
135
+ enabled?: boolean;
136
+ /**
137
+ * Customer SPA origins the SDK gate flow (`gate.signInWithProvider`) may
138
+ * hand session tokens back to. Origin-matched + fail-closed at authorize,
139
+ * so an empty/omitted list means SDK sign-in is disabled for this provider
140
+ * until an origin is registered. Defaults to `[]` server-side.
141
+ */
142
+ allowedRedirectUris?: string[] | null;
143
+ } & DirectorySyncInput;
144
+
145
+ /**
146
+ * Update-request body — mirrors the gateway `PatchIdpBody` exactly: every field optional; `idpProvider` and
147
+ * `tierGating` are immutable after create. An empty patch returns the current config unchanged.
148
+ *
149
+ * 🔴 For `oidc`/`microsoft`, changing `metadataUrl` WITHOUT `clientSecretRef` in the same patch CLEARS the
150
+ * stored client secret (the gateway's secret-invalidate-on-host-change control,): re-supply the
151
+ * secret whenever the metadata URL changes.
152
+ */
153
+ export type PatchIdpInput = {
154
+ displayName?: string;
155
+ clientId?: string;
156
+ /** The raw client secret (write-only; see the module header). */
157
+ clientSecretRef?: string;
158
+ redirectUri?: string;
159
+ metadataUrl?: string | null;
160
+ attributeMap?: Record<string, string>;
161
+ enabled?: boolean;
162
+ allowedRedirectUris?: string[] | null;
163
+ } & DirectorySyncInput;
164
+
165
+ function identityProvidersPath(config: GateConfig): string {
166
+ return `${resolveProjectPathRoot(config)}/identity-providers`;
167
+ }
168
+
169
+ /** Factory — produces an `identityProviders` namespace bound to the supplied
170
+ * config getter. Used by both the `gate` singleton + `createGateClient`. */
171
+ export function makeIdentityProviders(getConfig: () => GateConfig) {
172
+ return {
173
+ /**
174
+ * Register a new end-user IdP provider config for the configured
175
+ * project. PROJECT_ADMIN+ required (server-enforced). The plaintext
176
+ * client secret is encrypted-at-rest server-side; the returned config
177
+ * carries only the opaque secret handle.
178
+ *
179
+ * The gateway validates the `idpProvider`→`tierGating` pairing
180
+ * and rejects a mismatch with `K9O_INVALID_FIELD_VALUE`.
181
+ */
182
+ async create(input: CreateIdpInput): Promise<Envelope<IdpConfig>> {
183
+ const config = getConfig();
184
+ return requestWith<IdpConfig>(config, {
185
+ method: "POST",
186
+ path: identityProvidersPath(config),
187
+ body: input,
188
+ });
189
+ },
190
+
191
+ /**
192
+ * List the configured end-user IdP provider configs for the project.
193
+ * READ_ROLES-gated server-side (PROJECT_ADMIN/EDITOR/MEMBER/VIEWER/AUTHOR/
194
+ * ANALYST) — the SAME shape + roles as the existing GET route
195
+ * (`listProviders` returns the existing route's shape, no
196
+ * confidentiality-narrower endpoint). Each `IdpConfig` carries the opaque
197
+ * secret handle, never the secret. Callers that only need the summary
198
+ * (`id`/`idpProvider`/`displayName`/`enabled`/`tierGating`) pick fields off
199
+ * the result — no server change needed for that shaping.
200
+ */
201
+ async list(): Promise<Envelope<IdpConfig[]>> {
202
+ const config = getConfig();
203
+ return requestWith<IdpConfig[]>(config, {
204
+ method: "GET",
205
+ path: identityProvidersPath(config),
206
+ });
207
+ },
208
+
209
+ /**
210
+ * Test connectivity to a configured provider by id — a NO-SECRET reachability + metadata-validity probe
211
+ * (no-secret): the gateway resolve-and-pins the issuer/metadata endpoint (SSRF-fenced) and
212
+ * validates it structurally, WITHOUT decrypting or transmitting the stored client secret. Returns
213
+ * `{ok, reason?}`; the `reason` is a closed machine code, never the upstream body or any credential.
214
+ * PROJECT_ADMIN required (server-enforced) — it triggers live external egress on the platform's behalf.
215
+ */
216
+ /**
217
+ * Update a provider config by id (PATCH; PROJECT_ADMIN, server-enforced). Partial: only supplied fields
218
+ * change; `idpProvider`/`tierGating` are immutable. Returns the updated config (secrets never included).
219
+ * GS-F7: write directory-sync settings here and read their status back off `list()`. See `PatchIdpInput`
220
+ * for the metadataUrl-change-clears-secret rule.
221
+ */
222
+ async update(configId: string, patch: PatchIdpInput): Promise<Envelope<IdpConfig>> {
223
+ const config = getConfig();
224
+ return requestWith<IdpConfig>(config, {
225
+ method: "PATCH",
226
+ path: `${identityProvidersPath(config)}/${encodeURIComponent(configId)}`,
227
+ body: patch,
228
+ });
229
+ },
230
+
231
+ async testProvider(configId: string): Promise<Envelope<FederationTestProviderResult>> {
232
+ const config = getConfig();
233
+ return requestWith<FederationTestProviderResult>(config, {
234
+ method: "POST",
235
+ path: `${identityProvidersPath(config)}/${encodeURIComponent(configId)}/test`,
236
+ });
237
+ },
238
+ };
239
+ }
240
+
241
+ /** Singleton bound to the module-level `configureGate()` config. */
242
+ export const identityProviders = makeIdentityProviders(getGateConfig);
243
+
244
+ export type IdentityProvidersNamespace = ReturnType<typeof makeIdentityProviders>;
package/src/index.ts ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * `@k9o/gate` — Gate (Identity-Aware Kernel) SDK entry point.
3
+ *
4
+ * Re-exports the canonical surface +:
5
+ * - `gate` — marquee namespace + sub-namespaces
6
+ * - `configureGate` / `getGateConfig` / `resetGateConfig` — mode + config plumbing
7
+ * - `verifyJwt` / `extractClaims` / `requireScope` — standalone JWT primitives
8
+ * - `GateError` + `GateErrorCode` — KError-compatible error surface
9
+ * - All public types via `* from "./types.ts"`
10
+ */
11
+
12
+ export { gate, type GateNamespace } from "./gate.ts";
13
+ export { createGateClient, type GateClient } from "./client-factory.ts";
14
+ export { resolveRegionalOrigin, regionalOriginFromLocation, type RegionConfig } from "./region.ts";
15
+ export {
16
+ configureGate,
17
+ getGateConfig,
18
+ resetGateConfig,
19
+ type GateConfig,
20
+ type GateMode,
21
+ } from "./mode.ts";
22
+ export { verifyJwt, extractClaims, requireScope, type VerifyJwtOptions } from "./jwt.ts";
23
+ export {
24
+ GateError,
25
+ type GateErrorCode,
26
+ type KErrorWire,
27
+ gateErrorFromWire,
28
+ } from "./errors.ts";
29
+ export type {
30
+ Envelope,
31
+ EndUser,
32
+ Session,
33
+ AuthResult,
34
+ WhoamiResult,
35
+ VerifySessionResult,
36
+ HostedGateInfo,
37
+ SignUpInput,
38
+ SignInInput,
39
+ SignOutInput,
40
+ RefreshTokenInput,
41
+ OtpPurpose,
42
+ RequestOtpInput,
43
+ VerifyOtpInput,
44
+ RequestMagicLinkInput,
45
+ VerifyMagicLinkInput,
46
+ SignInCallbackResult,
47
+ MagicLinkCallbackResult,
48
+ GateJwtClaims,
49
+ VerifyJwtResult,
50
+ VerifyJwtFailure,
51
+ } from "./types.ts";
52
+ // GS-F6 — project-scoped end-user IdP provider-config management types
53
+ // (gate.identityProviders.create()).
54
+ export type {
55
+ IdpProviderKind,
56
+ IdpTierGating,
57
+ IdpConfig,
58
+ CreateIdpInput,
59
+ PatchIdpInput,
60
+ DirectorySyncInput,
61
+ IdentityProvidersNamespace,
62
+ } from "./idp-providers/identity-providers.ts";