@k9o/gate 1.2.0 → 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/src/gate.ts CHANGED
@@ -1,30 +1,15 @@
1
1
  /**
2
- * Marquee `gate.*` namespace.
2
+ * The `gate.*` namespace, bound to the end-user Gate routes (project-scoped, `<org>.api.<apex>/@<projectSlug>/gate/...`):
3
+ * - `gate.signUp` POST /gate/signup
4
+ * - `gate.signIn` POST /gate/signin
5
+ * - `gate.signOut` POST /gate/signout
6
+ * - `gate.whoami` GET /gate/whoami
7
+ * - `gate.refreshToken` POST /gate/refresh
8
+ * - `gate.signInWithProvider` POST /gate/idp/{provider}/authorize
9
+ * - `gate.passwordless.*` POST /gate/otp/{request,verify} and /gate/magic-link/{send,redeem}
3
10
  *
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}`.
11
+ * Every function returns the standard envelope `{ data, meta }` and throws a `GateError` on a non-2xx gateway answer.
12
+ * The mode (native or standalone) does not change the call shapes; the client sends it as the `X-Gate-Mode` header.
28
13
  */
29
14
 
30
15
  import { getGateConfig, type GateConfig } from "./mode.ts";
@@ -35,18 +20,22 @@ import { makeMagicLinkRedirects } from "./magic-link-redirects.ts";
35
20
  import type {
36
21
  AuthResult,
37
22
  Envelope,
38
- HostedGateInfo,
39
23
  MagicLinkCallbackResult,
24
+ RefreshResult,
40
25
  RefreshTokenInput,
26
+ RequestMagicLinkResult,
27
+ RequestOtpResult,
41
28
  RequestMagicLinkInput,
42
29
  RequestOtpInput,
43
30
  SignInCallbackResult,
44
31
  SignInInput,
45
32
  SignOutInput,
33
+ SignOutResult,
46
34
  SignUpInput,
47
35
  VerifyMagicLinkInput,
36
+ VerifyMagicLinkResult,
48
37
  VerifyOtpInput,
49
- VerifySessionResult,
38
+ VerifyOtpResult,
50
39
  WhoamiResult,
51
40
  } from "./types.ts";
52
41
 
@@ -60,13 +49,12 @@ function gatePath(config: GateConfig, suffix: string): string {
60
49
  export function makeGateNamespace(getConfig: () => GateConfig) {
61
50
  return {
62
51
  /**
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.
52
+ * Create a new end-user for the configured project. Returns the new user and an initial session. The body is
53
+ * closed: `{ email, password, displayName? }`.
66
54
  *
67
- * Errors (closed taxonomy):
55
+ * Errors:
68
56
  * - `EMAIL_ALREADY_REGISTERED` (409) when the email collides
69
- * - `INVALID_FIELD_VALUE` (400) on schema violation (password policy, malformed email)
57
+ * - `INVALID_FIELD_VALUE` (400) on a schema violation (password policy, malformed email, unknown field)
70
58
  * - `TIER_FEATURE_GATED` (403) when the project's tier blocks email/password
71
59
  */
72
60
  async signUp(input: SignUpInput): Promise<Envelope<AuthResult>> {
@@ -78,14 +66,10 @@ export function makeGateNamespace(getConfig: () => GateConfig) {
78
66
  },
79
67
 
80
68
  /**
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.
69
+ * Sign in an existing end-user with email and password. Returns the user and a fresh session.
84
70
  *
85
71
  * 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)
72
+ * - `INVALID_CREDENTIALS` (401) on a bad email or password (the same answer for both, on purpose)
89
73
  */
90
74
  async signIn(input: SignInInput): Promise<Envelope<AuthResult>> {
91
75
  return requestWith<AuthResult>(getConfig(), {
@@ -96,16 +80,11 @@ export function makeGateNamespace(getConfig: () => GateConfig) {
96
80
  },
97
81
 
98
82
  /**
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
83
+ * Sign out: revoke the session that the given refresh token belongs to. The route needs the refresh token in the
84
+ * body, so it is required here. Revoking an unknown or already-revoked token also answers `{ revoked: false }`.
106
85
  */
107
- async signOut(input: SignOutInput = {}): Promise<Envelope<{ revoked: boolean }>> {
108
- return requestWith<{ revoked: boolean }>(getConfig(), {
86
+ async signOut(input: SignOutInput): Promise<Envelope<SignOutResult>> {
87
+ return requestWith<SignOutResult>(getConfig(), {
109
88
  method: "POST",
110
89
  path: gatePath(getConfig(), "signout"),
111
90
  body: input,
@@ -113,13 +92,11 @@ export function makeGateNamespace(getConfig: () => GateConfig) {
113
92
  },
114
93
 
115
94
  /**
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.
95
+ * Return the signed-in end-user (the bearer token's subject). The answer is the user record itself, not wrapped.
119
96
  *
120
97
  * Errors:
121
98
  * - `UNAUTHENTICATED` (401) when no bearer is present
122
- * - `SESSION_EXPIRED` (401) when the bearer's exp has passed
99
+ * - `SESSION_EXPIRED` (401) when the bearer has expired or was revoked
123
100
  */
124
101
  async whoami(): Promise<Envelope<WhoamiResult>> {
125
102
  return requestWith<WhoamiResult>(getConfig(), {
@@ -129,49 +106,20 @@ export function makeGateNamespace(getConfig: () => GateConfig) {
129
106
  },
130
107
 
131
108
  /**
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).
109
+ * Exchange a refresh token for a new access token. The answer carries only the new `accessToken` and its
110
+ * `expiresAt`; the refresh token you hold stays valid and is not returned again.
135
111
  *
136
112
  * Errors:
137
- * - `REFRESH_TOKEN_INVALID` (400) when the refresh token is unknown,
138
- * expired, or revoked
113
+ * - `INVALID_CREDENTIALS` (401) when the refresh token is unknown, used or revoked
139
114
  */
140
- async refreshToken(input: RefreshTokenInput): Promise<Envelope<{ session: AuthResult["session"] }>> {
141
- return requestWith<{ session: AuthResult["session"] }>(getConfig(), {
115
+ async refreshToken(input: RefreshTokenInput): Promise<Envelope<RefreshResult>> {
116
+ return requestWith<RefreshResult>(getConfig(), {
142
117
  method: "POST",
143
118
  path: gatePath(getConfig(), "refresh"),
144
119
  body: input,
145
120
  });
146
121
  },
147
122
 
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
123
  /**
176
124
  * Initiate an external-IdP sign-in flow (Google / GitHub / Microsoft).
177
125
  * The SDK POSTs to the gateway's authorize endpoint, receives the
@@ -190,8 +138,6 @@ export function makeGateNamespace(getConfig: () => GateConfig) {
190
138
  * });
191
139
  * // …user lands at /auth/callback#access_token=…&refresh_token=…
192
140
  *
193
- * Generic OIDC requires authorization-endpoint discovery and is not
194
- * yet wired; use a hosted provider (google/github/microsoft) for now.
195
141
  */
196
142
  async signInWithProvider(input: {
197
143
  provider: "google" | "github" | "microsoft" | "oidc" | "saml";
@@ -254,25 +200,19 @@ export function makeGateNamespace(getConfig: () => GateConfig) {
254
200
  },
255
201
 
256
202
  /**
257
- * Passwordless sub-namespace — OTP + Magic Link flows
258
- * for email-based authentication without a stored password.
203
+ * Passwordless sign-in: one-time passcode and magic link, both by email.
259
204
  *
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.)
205
+ * Routes: `POST /@<projectSlug>/gate/otp/{request,verify}` and `POST /@<projectSlug>/gate/magic-link/{send,redeem}`.
206
+ * The method names do not mirror the last path segment (`requestMagicLink` calls `send`, `verifyMagicLink` calls
207
+ * `redeem`).
267
208
  */
268
209
  passwordless: {
269
210
  /**
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.
211
+ * Request a one-time passcode for the email. It is short-lived and single-use; redeem it with `verifyOtp`.
212
+ * The answer carries the code's id and expiry (`data.otp`); the code itself goes by email.
273
213
  */
274
- async requestOtp(input: RequestOtpInput): Promise<Envelope<{ requested: boolean }>> {
275
- return requestWith<{ requested: boolean }>(getConfig(), {
214
+ async requestOtp(input: RequestOtpInput): Promise<Envelope<RequestOtpResult>> {
215
+ return requestWith<RequestOtpResult>(getConfig(), {
276
216
  method: "POST",
277
217
  path: gatePath(getConfig(), "otp/request"),
278
218
  body: input,
@@ -280,16 +220,15 @@ export function makeGateNamespace(getConfig: () => GateConfig) {
280
220
  },
281
221
 
282
222
  /**
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.
223
+ * Verify a one-time passcode previously sent via `requestOtp`. For the default `signin` purpose the answer
224
+ * carries a fresh `session`; for any other purpose it only confirms (`verified: true`) and the caller continues
225
+ * its own flow.
286
226
  *
287
227
  * Errors:
288
- * - INVALID_CREDENTIALS (401) on wrong / expired / consumed OTP
289
- * (ambiguous on purpose — enumeration defense)
228
+ * - `INVALID_CREDENTIALS` (401) on a wrong, expired or used code
290
229
  */
291
- async verifyOtp(input: VerifyOtpInput): Promise<Envelope<AuthResult>> {
292
- return requestWith<AuthResult>(getConfig(), {
230
+ async verifyOtp(input: VerifyOtpInput): Promise<Envelope<VerifyOtpResult>> {
231
+ return requestWith<VerifyOtpResult>(getConfig(), {
293
232
  method: "POST",
294
233
  path: gatePath(getConfig(), "otp/verify"),
295
234
  body: input,
@@ -297,16 +236,12 @@ export function makeGateNamespace(getConfig: () => GateConfig) {
297
236
  },
298
237
 
299
238
  /**
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`.
239
+ * Request a magic link by email. The link carries an opaque short-lived token and lands at the project's
240
+ * redirect URL with the token in the query string; exchange it with `verifyMagicLink`. The answer is
241
+ * `data.magicLink` with `sent` and the expiry.
305
242
  */
306
- async requestMagicLink(
307
- input: RequestMagicLinkInput,
308
- ): Promise<Envelope<{ requested: boolean }>> {
309
- return requestWith<{ requested: boolean }>(getConfig(), {
243
+ async requestMagicLink(input: RequestMagicLinkInput): Promise<Envelope<RequestMagicLinkResult>> {
244
+ return requestWith<RequestMagicLinkResult>(getConfig(), {
310
245
  method: "POST",
311
246
  path: gatePath(getConfig(), "magic-link/send"),
312
247
  body: input,
@@ -314,23 +249,21 @@ export function makeGateNamespace(getConfig: () => GateConfig) {
314
249
  },
315
250
 
316
251
  /**
317
- * Verify a magic-link token previously delivered via
318
- * `requestMagicLink`. On success returns the same `AuthResult`
319
- * shape as `signIn`.
252
+ * Redeem a magic-link token previously delivered via `requestMagicLink`. The answer carries the new `session`
253
+ * and the `endUserId` (not the full user record; call `whoami` for that).
320
254
  *
321
255
  * Errors:
322
- * - INVALID_CREDENTIALS (401) on wrong / expired / consumed
323
- * token (same enumeration-defense rationale as verifyOtp)
256
+ * - `INVALID_CREDENTIALS` (401) on a wrong, expired or used token
324
257
  */
325
- async verifyMagicLink(input: VerifyMagicLinkInput): Promise<Envelope<AuthResult>> {
326
- return requestWith<AuthResult>(getConfig(), {
258
+ async verifyMagicLink(input: VerifyMagicLinkInput): Promise<Envelope<VerifyMagicLinkResult>> {
259
+ return requestWith<VerifyMagicLinkResult>(getConfig(), {
327
260
  method: "POST",
328
261
  path: gatePath(getConfig(), "magic-link/redeem"),
329
262
  body: input,
330
263
  });
331
264
  },
332
265
 
333
- /**
266
+ /**
334
267
  * Extract the opaque magic-link token from the landing-page URL's query
335
268
  * string (the gateway redirects to `<configured-redirect>?token=…&project=…`).
336
269
  * Pure + side-effect-free — pass `window.location.search`. Feed the
@@ -356,19 +289,14 @@ export function makeGateNamespace(getConfig: () => GateConfig) {
356
289
  },
357
290
 
358
291
  /**
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.
292
+ * Project-scoped end-user IdP provider-config management (create, list, update, test).
293
+ * PROJECT_ADMIN+ required; end-user JWTs are rejected at the route.
364
294
  */
365
295
  identityProviders: makeIdentityProviders(getConfig),
366
296
 
367
297
  /**
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.
298
+ * Per-project magic-link redirect-URL allowlist management (list, add, remove).
299
+ * PROJECT_ADMIN+ required; distinct from the end-user `passwordless` flow.
372
300
  */
373
301
  magicLinkRedirects: makeMagicLinkRedirects(getConfig),
374
302
  };
@@ -22,12 +22,10 @@
22
22
  * `partnerToken` in config, or per-call via the client's bearer plumbing.
23
23
  *
24
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
25
+ * The gateway encrypts the submitted value (AES-256-GCM under
26
26
  * `K9O_SECRET_STORAGE_MASTER_KEY`) into the opaque `client_secret_enc`
27
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
28
+ * compatibility and there is no Kibo custody on this path. Reads NEVER carry the secret or its
31
29
  * handle: they carry only `clientSecretSet` (a presence boolean,
32
30
  * the write-only fix). `serviceAccountKey` (directory sync) follows the same write-only rule.
33
31
  */
@@ -37,8 +35,7 @@ import { requestWith } from "../client.ts";
37
35
  import { resolveProjectPathRoot } from "../paths.ts";
38
36
  import type { Envelope } from "../types.ts";
39
37
 
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. */
38
+ /** Closed end-user-class provider enum (11 values). */
42
39
  export type IdpProviderKind =
43
40
  | "google" | "github" | "microsoft" | "oidc" | "saml" | "scim"
44
41
  | "ldap" | "active_directory" | "apple" | "facebook" | "twitter";
@@ -60,7 +57,7 @@ export type FederationTestProviderResult = {
60
57
  | "kind_unsupported";
61
58
  };
62
59
 
63
- /** Per the provider→tier pairing is structurally fixed:
60
+ /** The provider→tier pairing is structurally fixed:
64
61
  * `free_tier_ok ∈ {google, github, microsoft, apple, facebook, twitter}`,
65
62
  * `ridge_plus ∈ {oidc, saml, scim, ldap, active_directory}`. The gateway rejects a mismatched
66
63
  * pairing (`K9O_INVALID_FIELD_VALUE`); the SDK forwards the caller value
@@ -74,7 +71,7 @@ export type IdpTierGating = "free_tier_ok" | "ridge_plus";
74
71
  *
75
72
  * Reads carry `clientSecretSet` (presence only), never the secret or a handle (see the module header).
76
73
  * `metadataUrl`/`updatedAt` are `null` until set; `attributeMap` defaults to `{}`; `allowedRedirectUris`
77
- * defaults to `[]`. The `directory*` fields are GS-F7 scheduled directory sync (Google only,):
74
+ * defaults to `[]`. The `directory*` fields are scheduled directory sync (Google only):
78
75
  * settings plus the scheduler's last-run status; the service-account key itself is never returned.
79
76
  */
80
77
  export type IdpConfig = {
@@ -100,7 +97,7 @@ export type IdpConfig = {
100
97
  updatedAt: string | null;
101
98
  };
102
99
 
103
- /** GS-F7 directory-sync write fields — accepted on create AND update (one PROJECT_ADMIN surface). */
100
+ /** Directory-sync write fields — accepted on create AND update (one PROJECT_ADMIN surface). */
104
101
  export type DirectorySyncInput = {
105
102
  /** Enabling requires `idpProvider: "google"`, a `serviceAccountKey` (now or already stored) and an interval. */
106
103
  directoryPullEnabled?: boolean;
@@ -147,7 +144,7 @@ export type CreateIdpInput = {
147
144
  * `tierGating` are immutable after create. An empty patch returns the current config unchanged.
148
145
  *
149
146
  * 🔴 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
147
+ * stored client secret (the gateway's secret-invalidate-on-host-change control): re-supply the
151
148
  * secret whenever the metadata URL changes.
152
149
  */
153
150
  export type PatchIdpInput = {
@@ -216,7 +213,7 @@ export function makeIdentityProviders(getConfig: () => GateConfig) {
216
213
  /**
217
214
  * Update a provider config by id (PATCH; PROJECT_ADMIN, server-enforced). Partial: only supplied fields
218
215
  * 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`
216
+ * Write directory-sync settings here and read their status back off `list()`. See `PatchIdpInput`
220
217
  * for the metadataUrl-change-clears-secret rule.
221
218
  */
222
219
  async update(configId: string, patch: PatchIdpInput): Promise<Envelope<IdpConfig>> {
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `@k9o/gate` — Gate (Identity-Aware Kernel) SDK entry point.
3
3
  *
4
- * Re-exports the canonical surface +:
4
+ * Re-exports the public surface:
5
5
  * - `gate` — marquee namespace + sub-namespaces
6
6
  * - `configureGate` / `getGateConfig` / `resetGateConfig` — mode + config plumbing
7
7
  * - `verifyJwt` / `extractClaims` / `requireScope` — standalone JWT primitives
@@ -11,6 +11,7 @@
11
11
 
12
12
  export { gate, type GateNamespace } from "./gate.ts";
13
13
  export { createGateClient, type GateClient } from "./client-factory.ts";
14
+ export { parseApiHost, type ApiHost } from "./api-host.ts";
14
15
  export { resolveRegionalOrigin, regionalOriginFromLocation, type RegionConfig } from "./region.ts";
15
16
  export {
16
17
  configureGate,
@@ -19,7 +20,7 @@ export {
19
20
  type GateConfig,
20
21
  type GateMode,
21
22
  } from "./mode.ts";
22
- export { verifyJwt, extractClaims, requireScope, type VerifyJwtOptions } from "./jwt.ts";
23
+ export { verifyJwt, fetchJwks, extractClaims, requireScope, type VerifyJwtOptions } from "./jwt.ts";
23
24
  export {
24
25
  GateError,
25
26
  type GateErrorCode,
@@ -32,16 +33,20 @@ export type {
32
33
  Session,
33
34
  AuthResult,
34
35
  WhoamiResult,
35
- VerifySessionResult,
36
- HostedGateInfo,
36
+ RefreshResult,
37
+ SignOutResult,
37
38
  SignUpInput,
38
39
  SignInInput,
39
40
  SignOutInput,
40
41
  RefreshTokenInput,
41
42
  OtpPurpose,
42
43
  RequestOtpInput,
44
+ RequestOtpResult,
45
+ VerifyOtpResult,
43
46
  VerifyOtpInput,
44
47
  RequestMagicLinkInput,
48
+ RequestMagicLinkResult,
49
+ VerifyMagicLinkResult,
45
50
  VerifyMagicLinkInput,
46
51
  SignInCallbackResult,
47
52
  MagicLinkCallbackResult,
@@ -49,8 +54,7 @@ export type {
49
54
  VerifyJwtResult,
50
55
  VerifyJwtFailure,
51
56
  } from "./types.ts";
52
- // GS-F6 — project-scoped end-user IdP provider-config management types
53
- // (gate.identityProviders.create()).
57
+ // Project-scoped end-user IdP provider-config management types (gate.identityProviders.*).
54
58
  export type {
55
59
  IdpProviderKind,
56
60
  IdpTierGating,
package/src/jwt.ts CHANGED
@@ -1,58 +1,39 @@
1
1
  /**
2
- * JWT verification primitives for external-app standalone consumption.
3
- *
4
- * Standalone-mode customers consume Gate-issued JWTs in their own
5
- * application code without needing the full SDK shape — they just need
6
- * to verify the signature, extract claims, and (optionally) check
7
- * scopes. This module is the canonical entry for that surface.
8
- *
9
- * Backed by `jose` (already a peer dependency in the gateway via the
10
- * same library; chose `jose` for consistency + JWK fetch support out
11
- * of the box).
12
- *
13
- * Native-mode customers don't typically reach for this directly — the
14
- * gateway verifies project-issued tokens on every request and rehydrates
15
- * `subject.*` claims. But the same primitives are
16
- * portable: Native customers running their own services downstream of
17
- * the gateway can verify Gate-issued JWTs identically.
2
+ * JWT verification primitives for apps that consume Gate-issued tokens without the rest of the SDK: verify the
3
+ * signature, read the claims, check scopes. Backed by `jose`.
18
4
  */
19
5
 
20
- import { createRemoteJWKSet, jwtVerify, errors as joseErrors } from "jose";
6
+ import { jwtVerify, errors as joseErrors } from "jose";
21
7
  import type { GateJwtClaims, VerifyJwtFailure, VerifyJwtResult } from "./types.ts";
22
8
  import { GateError } from "./errors.ts";
23
9
 
24
10
  /**
25
- * Configuration for `verifyJwt`. The verifier can resolve signing keys
26
- * via either a remote JWKS URL (the typical Gate deployment shape) or
27
- * an inline key set (for tests + air-gapped deployments).
11
+ * Configuration for `verifyJwt`. Signing keys come from either the gateway's JWKS route (`jwksUrl`) or an inline key
12
+ * set (`jwks`, for tests and air-gapped deployments).
28
13
  */
29
14
  export type VerifyJwtOptions = {
30
15
  token: string;
31
16
  /**
32
- * JWKS endpoint URL — the gateway exposes `/@<projectSlug>/gate/.well-known/jwks.json`
33
- * by convention. Either `jwksUrl` or `jwks` must be set; not both.
17
+ * URL of the gateway's JWKS route: `https://<org>.api.<apex>/@<projectSlug>/auth/jwks` for a project's end-user
18
+ * tokens (`/auth/jwks` on the org host for org keys). The gateway wraps the key set in its response envelope
19
+ * (`{ data: { keys }, meta }`), which `jose` cannot read directly; this helper fetches the URL and unwraps it.
20
+ * Either `jwksUrl` or `jwks` must be set; not both.
34
21
  */
35
22
  jwksUrl?: string;
36
- /**
37
- * Inline JWK set — used by tests + integration scenarios that want to
38
- * avoid the network hop. The shape is the standard JWKS `{keys: [...]}`
39
- * envelope; pass through unchanged to `jose`.
40
- */
23
+ /** Inline JWK set, the standard `{ keys: [...] }` object. */
41
24
  jwks?: { keys: ReadonlyArray<Record<string, unknown>> };
25
+ /** Fetch implementation used for `jwksUrl`. Defaults to the global `fetch`. */
26
+ fetch?: typeof globalThis.fetch;
42
27
  /** Expected issuer (`iss` claim). Strict comparison. */
43
28
  expectedIssuer?: string;
44
- /** Expected audience (`aud` claim). Accepts either string or array per RFC 7519. */
29
+ /** Expected audience (`aud` claim). */
45
30
  expectedAudience?: string;
46
31
  /**
47
- * Allowed signing algorithms. Defaults to `["RS256", "ES256"]` per the
48
- * standard Gate issuer config. Reject anything outside this set
49
- * (defense against alg-confusion attacks).
32
+ * Allowed signing algorithms. Defaults to `["RS256", "ES256"]`. Anything outside this set is rejected (defense
33
+ * against algorithm confusion).
50
34
  */
51
35
  allowedAlgorithms?: ReadonlyArray<string>;
52
- /**
53
- * Clock-skew tolerance in seconds. Defaults to 30. Applies to both
54
- * `exp` and `nbf` checks.
55
- */
36
+ /** Clock-skew tolerance in seconds. Defaults to 30. Applies to `exp` and `nbf`. */
56
37
  clockToleranceSeconds?: number;
57
38
  };
58
39
 
@@ -65,7 +46,7 @@ const DEFAULT_CLOCK_TOLERANCE_SECONDS = 30;
65
46
  *
66
47
  * Throws `GateError` only when:
67
48
  * - `jwksUrl` and `jwks` are both unset or both set
68
- * - the JWKS fetch fails for transport reasons (UPSTREAM_UNAVAILABLE)
49
+ * - the JWKS fetch fails (UPSTREAM_UNAVAILABLE) or answers something that is not a key set
69
50
  */
70
51
  export async function verifyJwt(opts: VerifyJwtOptions): Promise<VerifyJwtResult> {
71
52
  if (opts.jwksUrl !== undefined && opts.jwks !== undefined) {
@@ -90,10 +71,8 @@ export async function verifyJwt(opts: VerifyJwtOptions): Promise<VerifyJwtResult
90
71
 
91
72
  let keyResolver: Parameters<typeof jwtVerify>[1];
92
73
  if (opts.jwksUrl !== undefined) {
93
- keyResolver = createRemoteJWKSet(new URL(opts.jwksUrl));
74
+ keyResolver = makeLocalJwksResolver(await fetchJwks(opts.jwksUrl, opts.fetch));
94
75
  } else {
95
- // Inline JWKS — implement the resolver inline since `jose`'s
96
- // `createLocalJWKSet` import path varies across versions.
97
76
  keyResolver = makeLocalJwksResolver(opts.jwks!);
98
77
  }
99
78
 
@@ -167,6 +146,50 @@ export function requireScope(
167
146
  // Internals
168
147
  // ----------------------------------------------------------------------
169
148
 
149
+ type Jwks = { keys: ReadonlyArray<Record<string, unknown>> };
150
+
151
+ function isJwks(v: unknown): v is Jwks {
152
+ return v !== null && typeof v === "object" && Array.isArray((v as { keys?: unknown }).keys);
153
+ }
154
+
155
+ /**
156
+ * Fetch a JWKS from the gateway. The route answers `{ data: { keys }, meta }`; a bare `{ keys }` (any other JWKS
157
+ * host) is accepted too.
158
+ */
159
+ export async function fetchJwks(url: string, fetchImpl: typeof globalThis.fetch | undefined = globalThis.fetch): Promise<Jwks> {
160
+ if (typeof fetchImpl !== "function") {
161
+ throw new GateError({
162
+ code: "INTERNAL_ERROR",
163
+ message: "No fetch implementation available to read the JWKS",
164
+ context: { url },
165
+ recommendation: null,
166
+ });
167
+ }
168
+ let body: unknown;
169
+ let status: number;
170
+ try {
171
+ const res = await fetchImpl(url, { method: "GET", headers: { accept: "application/json" } });
172
+ status = res.status;
173
+ body = await res.json();
174
+ } catch (e) {
175
+ throw new GateError({
176
+ code: "UPSTREAM_UNAVAILABLE",
177
+ message: `Could not read the JWKS at ${url}: ${e instanceof Error ? e.message : String(e)}`,
178
+ context: { url },
179
+ recommendation: null,
180
+ });
181
+ }
182
+ const inner = body !== null && typeof body === "object" ? (body as { data?: unknown }).data : undefined;
183
+ if (isJwks(inner)) return inner;
184
+ if (isJwks(body)) return body;
185
+ throw new GateError({
186
+ code: "UPSTREAM_UNAVAILABLE",
187
+ message: `The JWKS route at ${url} answered ${status} without a key set`,
188
+ context: { url, status },
189
+ recommendation: null,
190
+ });
191
+ }
192
+
170
193
  function classifyJoseFailure(e: unknown): VerifyJwtFailure {
171
194
  // Map `jose`'s typed errors to our closed reason set per `VerifyJwtFailure`.
172
195
  if (e instanceof joseErrors.JWSSignatureVerificationFailed) return "INVALID_SIGNATURE";
@@ -197,12 +220,7 @@ function base64UrlDecode(input: string): string {
197
220
  return new TextDecoder("utf-8").decode(bytes);
198
221
  }
199
222
 
200
- /**
201
- * Build a key resolver compatible with `jose.jwtVerify` from an inline
202
- * JWKS object. `jose` exports `createLocalJWKSet` from its main module
203
- * but the import path / type shape can shift across minors; reimplement
204
- * the minimal resolver here for stability.
205
- */
223
+ /** Build a key resolver for `jose.jwtVerify` from a JWKS object. */
206
224
  function makeLocalJwksResolver(
207
225
  jwks: { keys: ReadonlyArray<Record<string, unknown>> },
208
226
  ): Parameters<typeof jwtVerify>[1] {
@@ -1,7 +1,6 @@
1
1
  /**
2
2
  * `gate.magicLinkRedirects` sub-namespace — the per-project magic-link
3
- * redirect-URL allowlist, PROJECT_ADMIN+ managed. Blessed shape (
4
- * 2026-07-16): `list()`/`add()`/`remove()`, mirroring the
3
+ * redirect-URL allowlist, PROJECT_ADMIN+ managed. `list()`/`add()`/`remove()`, mirroring the
5
4
  * `gate.idpProviders.*` sub-namespace convention (gate-admin surface,
6
5
  * not the end-user-facing `passwordless.*` send/verify flow).
7
6
  *