@iqauth/sdk 2.6.3 → 2.7.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.
Files changed (112) hide show
  1. package/README.md +173 -1
  2. package/dist/browser-session.d.mts +4 -4
  3. package/dist/browser-session.d.ts +4 -4
  4. package/dist/browser-session.js +181 -41
  5. package/dist/browser-session.mjs +3 -3
  6. package/dist/browser.d.mts +5 -5
  7. package/dist/browser.d.ts +5 -5
  8. package/dist/browser.js +271 -32
  9. package/dist/browser.mjs +10 -8
  10. package/dist/{chunk-6I6RM4MN.mjs → chunk-6PJRLRB4.mjs} +33 -3
  11. package/dist/chunk-C2ZTBOAC.mjs +36 -0
  12. package/dist/{chunk-LIZYFXH7.mjs → chunk-DFWHSDYQ.mjs} +1 -1
  13. package/dist/chunk-GLXSIGVS.mjs +66 -0
  14. package/dist/{chunk-TKZTCPEK.mjs → chunk-GN37E64I.mjs} +32 -40
  15. package/dist/{chunk-WQWBJSSS.mjs → chunk-HVHNYPDC.mjs} +6 -6
  16. package/dist/{chunk-W3F4JYGP.mjs → chunk-JXQI62A7.mjs} +108 -18
  17. package/dist/{chunk-UNYDG2L4.mjs → chunk-NUO2I65G.mjs} +56 -23
  18. package/dist/chunk-PMAFENVI.mjs +229 -0
  19. package/dist/chunk-RR2MGPTK.mjs +2724 -0
  20. package/dist/{chunk-76W5TLQQ.mjs → chunk-RTJAIBXY.mjs} +220 -20
  21. package/dist/{chunk-6TDJJER7.mjs → chunk-RUJXRTEW.mjs} +164 -5
  22. package/dist/{chunk-3JULWS6F.mjs → chunk-WCELYTJ3.mjs} +3 -3
  23. package/dist/{chunk-MKKZULZR.mjs → chunk-WIFG74IK.mjs} +1 -1
  24. package/dist/{chunk-BVV54LPI.mjs → chunk-YVALAG3B.mjs} +10 -4
  25. package/dist/cli/index.js +2 -2
  26. package/dist/cli/index.mjs +2 -2
  27. package/dist/{client-kYlJFgPv.d.mts → client-BGFnBpfc.d.mts} +47 -4
  28. package/dist/{client-BNQe3AgF.d.ts → client-CDQ21LvW.d.ts} +47 -4
  29. package/dist/{doctor-YYNHNMLD.mjs → doctor-JAFXWU3X.mjs} +2 -2
  30. package/dist/errors-Jl1Jtm-6.d.mts +107 -0
  31. package/dist/errors-Jl1Jtm-6.d.ts +107 -0
  32. package/dist/{express-B6_1vBYZ.d.mts → express-CVNQEkOr.d.mts} +2 -2
  33. package/dist/{express-CHpfa7D_.d.ts → express-Piv2WhWM.d.ts} +2 -2
  34. package/dist/express.d.mts +7 -6
  35. package/dist/express.d.ts +7 -6
  36. package/dist/express.js +349 -52
  37. package/dist/express.mjs +39 -12
  38. package/dist/fastify.d.mts +2 -0
  39. package/dist/fastify.d.ts +2 -0
  40. package/dist/fastify.js +332 -52
  41. package/dist/fastify.mjs +23 -8
  42. package/dist/hono.d.mts +2 -0
  43. package/dist/hono.d.ts +2 -0
  44. package/dist/hono.js +329 -52
  45. package/dist/hono.mjs +20 -8
  46. package/dist/index-5KSZEnDe.d.ts +1626 -0
  47. package/dist/index-CKoZHAoc.d.mts +1626 -0
  48. package/dist/index.d.mts +56 -8
  49. package/dist/index.d.ts +56 -8
  50. package/dist/index.js +565 -69
  51. package/dist/index.mjs +29 -9
  52. package/dist/{keys-NLWFAOEM.mjs → keys-6Y776TG2.mjs} +2 -2
  53. package/dist/locales.d.mts +1 -1
  54. package/dist/locales.d.ts +1 -1
  55. package/dist/mobile.d.mts +77 -7
  56. package/dist/mobile.d.ts +77 -7
  57. package/dist/mobile.js +276 -41
  58. package/dist/mobile.mjs +98 -3
  59. package/dist/next.d.mts +2 -1
  60. package/dist/next.d.ts +2 -1
  61. package/dist/next.js +391 -201
  62. package/dist/next.mjs +22 -7
  63. package/dist/pkce-7WKV4OIN.mjs +11 -0
  64. package/dist/{provisioningBridge-DnTfzdZK.d.ts → provisioningBridge-CGpMRie4.d.ts} +1 -1
  65. package/dist/{provisioningBridge-88xjOS2n.d.mts → provisioningBridge-M5G47LWO.d.mts} +1 -1
  66. package/dist/{publishableKey-BaR0HoAH.d.ts → publishableKey-f2kq-rKw.d.mts} +1 -1
  67. package/dist/{publishableKey-BaR0HoAH.d.mts → publishableKey-f2kq-rKw.d.ts} +1 -1
  68. package/dist/react-permissions.d.mts +52 -0
  69. package/dist/react-permissions.d.ts +52 -0
  70. package/dist/react-permissions.js +239 -0
  71. package/dist/react-permissions.mjs +97 -0
  72. package/dist/react.d.mts +9 -1624
  73. package/dist/react.d.ts +9 -1624
  74. package/dist/react.js +343 -36
  75. package/dist/react.mjs +59 -2611
  76. package/dist/{reverify-4UEJXUS6.mjs → reverify-C64QXKJO.mjs} +2 -2
  77. package/dist/server/handlers.d.mts +148 -3
  78. package/dist/server/handlers.d.ts +148 -3
  79. package/dist/server/handlers.js +410 -11
  80. package/dist/server/handlers.mjs +12 -3
  81. package/dist/server.d.mts +151 -8
  82. package/dist/server.d.ts +151 -8
  83. package/dist/server.js +406 -50
  84. package/dist/server.mjs +93 -11
  85. package/dist/service.d.mts +4 -4
  86. package/dist/service.d.ts +4 -4
  87. package/dist/service.js +181 -41
  88. package/dist/service.mjs +3 -3
  89. package/dist/{signIn-CiIBTJIh.d.mts → signIn-BLFnz8SV.d.ts} +78 -3
  90. package/dist/{signIn-CCY4JE5G.mjs → signIn-SHBW6Z4T.mjs} +2 -1
  91. package/dist/{signIn-OCr88Zf8.d.ts → signIn-T-CZ6t6r.d.mts} +78 -3
  92. package/dist/test.mjs +3 -3
  93. package/dist/{tokens-DCyzzn8L.d.mts → tokens-Bqhmqq_R.d.ts} +9 -2
  94. package/dist/{tokens-aHiGFr_E.d.ts → tokens-CITeoG6P.d.mts} +9 -2
  95. package/dist/{types-6bNdxesb.d.ts → types-BdQ2lqfT.d.mts} +1 -1
  96. package/dist/{types-6bNdxesb.d.mts → types-BdQ2lqfT.d.ts} +1 -1
  97. package/dist/{types-DZAflmmq.d.mts → types-XOV9XPVi.d.mts} +99 -10
  98. package/dist/{types-DZAflmmq.d.ts → types-XOV9XPVi.d.ts} +99 -10
  99. package/dist/webhooks.d.mts +100 -17
  100. package/dist/webhooks.d.ts +100 -17
  101. package/dist/webhooks.js +164 -15
  102. package/dist/webhooks.mjs +7 -1
  103. package/dist/ws.d.mts +2 -2
  104. package/dist/ws.d.ts +2 -2
  105. package/dist/ws.js +80 -30
  106. package/dist/ws.mjs +4 -4
  107. package/docs/error-handling.md +101 -0
  108. package/docs/guides/effective-permissions.md +171 -0
  109. package/package.json +13 -3
  110. package/dist/chunk-UKZLOHZG.mjs +0 -83
  111. package/dist/errors-CDdl24MP.d.mts +0 -52
  112. package/dist/errors-CDdl24MP.d.ts +0 -52
package/dist/hono.js CHANGED
@@ -35,13 +35,30 @@ __export(hono_exports, {
35
35
  module.exports = __toCommonJS(hono_exports);
36
36
 
37
37
  // src/errors.ts
38
- var IQAuthError = class extends Error {
39
- constructor(code, message, status, raw) {
38
+ var IQAuthError = class _IQAuthError extends Error {
39
+ constructor(code, message, status, cause) {
40
40
  super(message);
41
41
  this.name = "IQAuthError";
42
42
  this.code = code;
43
43
  this.status = status;
44
- this.raw = raw;
44
+ this.cause = cause;
45
+ this.raw = cause;
46
+ }
47
+ /**
48
+ * Type guard: true when `value` is an `IQAuthError`. Useful for adapters
49
+ * that round-trip errors through `unknown` (e.g. fastify's `setErrorHandler`).
50
+ */
51
+ static isIQAuthError(value) {
52
+ return value instanceof _IQAuthError || typeof value === "object" && value !== null && value.name === "IQAuthError" && typeof value.code === "string";
53
+ }
54
+ /**
55
+ * Type-narrowed code check. Lets callers write
56
+ * `if (err.is("token_expired")) …` with full IntelliSense for the typed
57
+ * taxonomy without losing the ability to handle server codes via
58
+ * `err.code === "TOKEN_REVOKED"`.
59
+ */
60
+ is(code) {
61
+ return this.code === code;
45
62
  }
46
63
  };
47
64
 
@@ -156,7 +173,7 @@ var HttpClient = class {
156
173
  headers: this.buildHeaders(),
157
174
  ...this.isBrowserSession() ? { credentials: "include" } : (() => {
158
175
  const refreshToken = this.config.getRefreshToken();
159
- if (!refreshToken) throw new IQAuthError("TOKEN_INVALID", "No refresh token available");
176
+ if (!refreshToken) throw new IQAuthError("config_invalid", "No refresh token available");
160
177
  return { body: JSON.stringify({ refreshToken }) };
161
178
  })()
162
179
  });
@@ -173,7 +190,7 @@ var HttpClient = class {
173
190
  return;
174
191
  }
175
192
  if (!body.data.accessToken || !body.data.refreshToken) {
176
- throw new IQAuthError("TOKEN_INVALID", "Refresh response did not include a token pair");
193
+ throw new IQAuthError("token_invalid", "Refresh response did not include a token pair");
177
194
  }
178
195
  const tokens = {
179
196
  accessToken: body.data.accessToken,
@@ -191,7 +208,7 @@ var HttpClient = class {
191
208
  return this.requestWithRetry(method, path, body, options, false);
192
209
  }
193
210
  async requestWithRetry(method, path, body, options, hasRetried) {
194
- if (this.config.autoRefresh && !options?.skipAutoRefresh && !this.isBrowserSession() && this.config.getRefreshToken() && this.isTokenExpiringSoon()) {
211
+ if (this.config.autoRefresh && this.config.proactiveRefresh !== false && !options?.skipAutoRefresh && !this.isBrowserSession() && this.config.getRefreshToken() && this.isTokenExpiringSoon()) {
195
212
  await this.attemptRefresh();
196
213
  }
197
214
  const url = `${this.config.baseUrl}${path}`;
@@ -419,6 +436,18 @@ var DEFAULT_TOKEN_AUDIENCE = [
419
436
  "iqvalidate"
420
437
  ];
421
438
  var DEFAULT_CLOCK_TOLERANCE_SECONDS = 30;
439
+ function classifyJoseError(err) {
440
+ if (err instanceof import_jose.errors.JWTExpired) {
441
+ return { code: "token_expired", message: "Token has expired" };
442
+ }
443
+ if (err instanceof import_jose.errors.JOSEError) {
444
+ return { code: "token_invalid", message: err.message };
445
+ }
446
+ if (err instanceof Error) {
447
+ return { code: "token_invalid", message: err.message };
448
+ }
449
+ return { code: "token_invalid", message: "Token verification failed" };
450
+ }
422
451
  function decodeProtectedHeader(token) {
423
452
  const parts = token.split(".");
424
453
  if (parts.length < 2) return null;
@@ -455,11 +484,11 @@ var TokensModule = class {
455
484
  async verify(token, options = {}) {
456
485
  const header = decodeProtectedHeader(token);
457
486
  if (!header) {
458
- throw new IQAuthError("TOKEN_INVALID", "Unable to decode token");
487
+ throw new IQAuthError("token_invalid", "Unable to decode token");
459
488
  }
460
489
  const kid = header.kid;
461
490
  if (!kid) {
462
- throw new IQAuthError("TOKEN_INVALID", "Token missing kid header");
491
+ throw new IQAuthError("token_invalid", "Token missing kid header");
463
492
  }
464
493
  let cache = await this.ensureCache();
465
494
  if (!cache.byKid.has(kid)) {
@@ -467,7 +496,7 @@ var TokensModule = class {
467
496
  cache = await this.ensureCache();
468
497
  }
469
498
  if (!cache.byKid.has(kid)) {
470
- throw new IQAuthError("TOKEN_INVALID", `Unknown key ID: ${kid}`);
499
+ throw new IQAuthError("token_invalid", `Unknown key ID: ${kid}`);
471
500
  }
472
501
  const issuer = options.issuer ?? this.defaultIssuer;
473
502
  const audience = options.audience ?? this.defaultAudience;
@@ -483,16 +512,8 @@ var TokensModule = class {
483
512
  const { payload } = await (0, import_jose.jwtVerify)(token, cache.verifier, verifyOptions);
484
513
  return payload;
485
514
  } catch (err) {
486
- if (err instanceof import_jose.errors.JWTExpired) {
487
- throw new IQAuthError("TOKEN_EXPIRED", "Token has expired");
488
- }
489
- if (err instanceof import_jose.errors.JOSEError) {
490
- throw new IQAuthError("TOKEN_INVALID", err.message);
491
- }
492
- if (err instanceof Error) {
493
- throw new IQAuthError("TOKEN_INVALID", err.message);
494
- }
495
- throw new IQAuthError("TOKEN_INVALID", "Token verification failed");
515
+ const classified = classifyJoseError(err);
516
+ throw new IQAuthError(classified.code, classified.message, void 0, err);
496
517
  }
497
518
  }
498
519
  /**
@@ -534,7 +555,7 @@ var TokensModule = class {
534
555
  getClaims(token) {
535
556
  const claims = this.decode(token);
536
557
  if (!claims) {
537
- throw new IQAuthError("TOKEN_INVALID", "Unable to decode token claims");
558
+ throw new IQAuthError("token_invalid", "Unable to decode token claims");
538
559
  }
539
560
  return claims;
540
561
  }
@@ -544,7 +565,7 @@ var TokensModule = class {
544
565
  }
545
566
  await this.refreshJwks();
546
567
  if (!this.jwksCache) {
547
- throw new IQAuthError("INTERNAL_ERROR", "JWKS cache unavailable after refresh");
568
+ throw new IQAuthError("jwks_unavailable", "JWKS cache unavailable after refresh");
548
569
  }
549
570
  return this.jwksCache;
550
571
  }
@@ -554,22 +575,38 @@ var TokensModule = class {
554
575
  }
555
576
  this.inFlightRefresh = (async () => {
556
577
  try {
557
- const res = await fetch(`${this.baseUrl}/.well-known/jwks.json`);
578
+ let res;
579
+ try {
580
+ res = await fetch(`${this.baseUrl}/.well-known/jwks.json`);
581
+ } catch (err) {
582
+ throw new IQAuthError(
583
+ "network",
584
+ err instanceof Error ? err.message : "JWKS fetch network error",
585
+ void 0,
586
+ err
587
+ );
588
+ }
558
589
  if (!res.ok) {
559
590
  throw new IQAuthError(
560
- "INTERNAL_ERROR",
561
- `Failed to fetch JWKS: ${res.status}`
591
+ "jwks_fetch_failed",
592
+ `Failed to fetch JWKS: ${res.status}`,
593
+ res.status
562
594
  );
563
595
  }
564
596
  let jwks;
565
597
  try {
566
598
  jwks = await res.json();
567
- } catch {
568
- throw new IQAuthError("INTERNAL_ERROR", "Malformed JWKS response: invalid JSON");
599
+ } catch (err) {
600
+ throw new IQAuthError(
601
+ "jwks_fetch_failed",
602
+ "Malformed JWKS response: invalid JSON",
603
+ res.status,
604
+ err
605
+ );
569
606
  }
570
607
  if (!jwks || !Array.isArray(jwks.keys)) {
571
608
  throw new IQAuthError(
572
- "INTERNAL_ERROR",
609
+ "jwks_fetch_failed",
573
610
  "Malformed JWKS response: expected { keys: [...] }"
574
611
  );
575
612
  }
@@ -577,7 +614,7 @@ var TokensModule = class {
577
614
  for (const key of jwks.keys) {
578
615
  if (!key || typeof key.kid !== "string" || typeof key.n !== "string" && typeof key.x !== "string" || key.kty === "RSA" && (typeof key.n !== "string" || typeof key.e !== "string")) {
579
616
  throw new IQAuthError(
580
- "INTERNAL_ERROR",
617
+ "jwks_fetch_failed",
581
618
  "Malformed JWKS response: key missing required fields"
582
619
  );
583
620
  }
@@ -595,6 +632,19 @@ var TokensModule = class {
595
632
  clearCache() {
596
633
  this.jwksCache = null;
597
634
  }
635
+ /**
636
+ * Task #126: Eagerly populate the JWKS cache so the first verify() call
637
+ * doesn't pay a network round-trip. Safe to call repeatedly — single-flight
638
+ * behavior is shared with the lazy refresh path. Errors are swallowed so
639
+ * callers (e.g. `attachHelpers` auto-prewarm) can fire-and-forget.
640
+ */
641
+ async prewarm() {
642
+ if (this.jwksCache && Date.now() - this.jwksCache.fetchedAt <= JWKS_CACHE_TTL_MS) return;
643
+ try {
644
+ await this.refreshJwks();
645
+ } catch {
646
+ }
647
+ }
598
648
  };
599
649
 
600
650
  // src/modules/sessions.ts
@@ -918,14 +968,14 @@ var OidcModule = class {
918
968
  */
919
969
  async handleCallback(params) {
920
970
  if (!params.state) {
921
- throw new IQAuthError("VALIDATION_ERROR", "OIDC callback missing state parameter");
971
+ throw new IQAuthError("config_invalid", "OIDC callback missing state parameter");
922
972
  }
923
973
  if (!params.code) {
924
- throw new IQAuthError("VALIDATION_ERROR", "OIDC callback missing code parameter");
974
+ throw new IQAuthError("config_invalid", "OIDC callback missing code parameter");
925
975
  }
926
976
  const stored = await this.stateStore.get(params.state);
927
977
  if (!stored) {
928
- throw new IQAuthError("VALIDATION_ERROR", "Unknown or expired OIDC state");
978
+ throw new IQAuthError("config_invalid", "Unknown or expired OIDC state");
929
979
  }
930
980
  let tokens;
931
981
  try {
@@ -943,7 +993,7 @@ var OidcModule = class {
943
993
  if (tokens.id_token) {
944
994
  if (!this.tokensModule) {
945
995
  throw new IQAuthError(
946
- "INTERNAL_ERROR",
996
+ "config_invalid",
947
997
  "OIDC handleCallback received an id_token but no TokensModule is configured for verification"
948
998
  );
949
999
  }
@@ -954,7 +1004,7 @@ var OidcModule = class {
954
1004
  const tokenNonce = typeof claimsBag.nonce === "string" ? claimsBag.nonce : void 0;
955
1005
  if (!tokenNonce || tokenNonce !== stored.nonce) {
956
1006
  throw new IQAuthError(
957
- "TOKEN_INVALID",
1007
+ "token_invalid",
958
1008
  "OIDC id_token nonce did not match the stored value"
959
1009
  );
960
1010
  }
@@ -1155,6 +1205,9 @@ var AppsModule = class {
1155
1205
  * @remarks Wraps GET /api/v1/apps/:appKey
1156
1206
  */
1157
1207
  async get(appKey) {
1208
+ if (typeof appKey !== "string" || appKey.trim() === "") {
1209
+ throw new IQAuthError("VALIDATION_ERROR", "appKey is required (no env-var fallback).", 400);
1210
+ }
1158
1211
  return this.http.request("GET", `/api/v1/apps/${encodeURIComponent(appKey)}`);
1159
1212
  }
1160
1213
  /**
@@ -1174,6 +1227,16 @@ var AppsModule = class {
1174
1227
  401
1175
1228
  );
1176
1229
  }
1230
+ if (!manifest || typeof manifest.key !== "string" || manifest.key.trim() === "") {
1231
+ throw new IQAuthError("VALIDATION_ERROR", "manifest.key (appKey) is required for register().", 400);
1232
+ }
1233
+ if (!manifest.environment || !["production", "staging", "development"].includes(manifest.environment)) {
1234
+ throw new IQAuthError(
1235
+ "ENVIRONMENT_REQUIRED",
1236
+ "manifest.environment is required and must be 'production', 'staging', or 'development'. This guards against a dev workstation silently overwriting a production app's permission tree.",
1237
+ 400
1238
+ );
1239
+ }
1177
1240
  return this.http.request("POST", "/api/v1/apps/sync", manifest);
1178
1241
  }
1179
1242
  /**
@@ -1183,11 +1246,14 @@ var AppsModule = class {
1183
1246
  * @remarks Uses GET /api/v1/apps/:appKey — catches 404 errors
1184
1247
  */
1185
1248
  async isRegistered(appKey) {
1249
+ if (typeof appKey !== "string" || appKey.trim() === "") {
1250
+ throw new IQAuthError("VALIDATION_ERROR", "appKey is required (no env-var fallback).", 400);
1251
+ }
1186
1252
  try {
1187
1253
  await this.get(appKey);
1188
1254
  return true;
1189
1255
  } catch (err) {
1190
- if (err.code === "NOT_FOUND" || err.status === 404) {
1256
+ if (err.code === "app_not_found" || err.code === "NOT_FOUND" || err.status === 404) {
1191
1257
  return false;
1192
1258
  }
1193
1259
  throw err;
@@ -1224,6 +1290,20 @@ var RolesModule = class {
1224
1290
  };
1225
1291
 
1226
1292
  // src/modules/permissionGroups.ts
1293
+ function assertAppKey(appKey, callsite) {
1294
+ if (typeof appKey !== "string" || appKey.trim() === "") {
1295
+ throw new IQAuthError(
1296
+ "VALIDATION_ERROR",
1297
+ `appKey is required for ${callsite} (no env-var fallback, no 'product' alias).`,
1298
+ 400
1299
+ );
1300
+ }
1301
+ }
1302
+ function assertNodeKey(nodeKey, callsite) {
1303
+ if (typeof nodeKey !== "string" || nodeKey.trim() === "") {
1304
+ throw new IQAuthError("VALIDATION_ERROR", `nodeKey is required for ${callsite}.`, 400);
1305
+ }
1306
+ }
1227
1307
  var PermissionGroupsModule = class {
1228
1308
  constructor(http) {
1229
1309
  this.http = http;
@@ -1244,7 +1324,14 @@ var PermissionGroupsModule = class {
1244
1324
  return this.http.request("GET", `/api/v1/tenants/${tenantId}/permission-groups/${groupId}/permissions`);
1245
1325
  }
1246
1326
  async addPermission(tenantId, groupId, data) {
1247
- return this.http.request("POST", `/api/v1/tenants/${tenantId}/permission-groups/${groupId}/permissions`, data);
1327
+ assertAppKey(data?.appKey, "permissionGroups.addPermission");
1328
+ assertNodeKey(data?.nodeKey, "permissionGroups.addPermission");
1329
+ return this.http.request("POST", `/api/v1/tenants/${tenantId}/permission-groups/${groupId}/permissions`, {
1330
+ appKey: data.appKey,
1331
+ nodeKey: data.nodeKey,
1332
+ effect: data.effect,
1333
+ weight: data.weight
1334
+ });
1248
1335
  }
1249
1336
  async removePermission(tenantId, groupId, permissionId) {
1250
1337
  return this.http.request("DELETE", `/api/v1/tenants/${tenantId}/permission-groups/${groupId}/permissions/${permissionId}`);
@@ -1268,21 +1355,51 @@ var PermissionGroupsModule = class {
1268
1355
  return this.http.request("GET", `/api/v1/tenants/${tenantId}/users/${userId}/permissions/overrides`);
1269
1356
  }
1270
1357
  async addUserOverride(tenantId, userId, data) {
1271
- return this.http.request("POST", `/api/v1/tenants/${tenantId}/users/${userId}/permissions/overrides`, data);
1358
+ assertAppKey(data?.appKey, "permissionGroups.addUserOverride");
1359
+ assertNodeKey(data?.nodeKey, "permissionGroups.addUserOverride");
1360
+ return this.http.request("POST", `/api/v1/tenants/${tenantId}/users/${userId}/permissions/overrides`, {
1361
+ appKey: data.appKey,
1362
+ nodeKey: data.nodeKey,
1363
+ effect: data.effect,
1364
+ weight: data.weight,
1365
+ expiresAt: data.expiresAt
1366
+ });
1272
1367
  }
1273
1368
  async removeUserOverride(tenantId, userId, overrideId) {
1274
1369
  return this.http.request("DELETE", `/api/v1/tenants/${tenantId}/users/${userId}/permissions/overrides/${overrideId}`);
1275
1370
  }
1371
+ /**
1372
+ * Task #130 — `appKey` is REQUIRED. The legacy `product` query alias is no
1373
+ * longer accepted at the SDK boundary; pass it as `appKey` instead. The
1374
+ * server still accepts `product=` from raw HTTP callers during the
1375
+ * deprecation window, but the SDK will not silently translate it.
1376
+ */
1276
1377
  async getEffectivePermissions(tenantId, userId, params) {
1277
- const query = new URLSearchParams();
1278
- if (params.product) query.set("product", params.product);
1279
- if (params.appKey) query.set("appKey", params.appKey);
1280
- const qs = query.toString();
1281
- return this.http.request("GET", `/api/v1/tenants/${tenantId}/users/${userId}/permissions/effective${qs ? `?${qs}` : ""}`);
1378
+ assertAppKey(params?.appKey, "permissionGroups.getEffectivePermissions");
1379
+ const qs = new URLSearchParams({ appKey: params.appKey }).toString();
1380
+ return this.http.request("GET", `/api/v1/tenants/${tenantId}/users/${userId}/permissions/effective?${qs}`);
1282
1381
  }
1283
1382
  async checkPermission(tenantId, userId, appKey, nodeKey) {
1383
+ assertAppKey(appKey, "permissionGroups.checkPermission");
1384
+ assertNodeKey(nodeKey, "permissionGroups.checkPermission");
1284
1385
  return this.http.request("POST", `/api/v1/tenants/${tenantId}/users/${userId}/permissions/check`, { appKey, nodeKey });
1285
1386
  }
1387
+ /**
1388
+ * Task #130 — every entry in `checks` must include a non-empty `appKey`
1389
+ * AND `nodeKey`. The SDK validates the whole batch before sending so a
1390
+ * single misconfigured entry can't slip through and silently report
1391
+ * `allowed: false` from the server's per-entry validation branch.
1392
+ */
1393
+ async batchCheckPermissions(tenantId, userId, checks) {
1394
+ if (!Array.isArray(checks) || checks.length === 0) {
1395
+ throw new IQAuthError("VALIDATION_ERROR", "checks must be a non-empty array of { appKey, nodeKey }.", 400);
1396
+ }
1397
+ checks.forEach((c, i) => {
1398
+ assertAppKey(c?.appKey, `permissionGroups.batchCheckPermissions[${i}]`);
1399
+ assertNodeKey(c?.nodeKey, `permissionGroups.batchCheckPermissions[${i}]`);
1400
+ });
1401
+ return this.http.request("POST", `/api/v1/tenants/${tenantId}/users/${userId}/permissions/batch-check`, { checks });
1402
+ }
1286
1403
  };
1287
1404
 
1288
1405
  // src/modules/apiKeys.ts
@@ -1707,6 +1824,10 @@ var IQAuthClient = class _IQAuthClient {
1707
1824
  this._refreshToken = tokens.refreshToken;
1708
1825
  },
1709
1826
  autoRefresh: "autoRefresh" in config ? config.autoRefresh !== false : true,
1827
+ // `'app-state'` is mobile-only — on any other environment we treat it
1828
+ // as the default `true` (proactive refresh ON). Only the mobile client
1829
+ // disables proactive refresh and replaces it with an AppState listener.
1830
+ proactiveRefresh: "autoRefresh" in config && config.autoRefresh === "app-state" && _IQAuthClient.resolveEnvironment(config) === "mobile" ? false : true,
1710
1831
  onTokenRefresh: "onTokenRefresh" in config ? config.onTokenRefresh : void 0,
1711
1832
  sessionHeaderName: config.sessionHeaderName,
1712
1833
  sessionHeaderValue: config.sessionHeaderValue,
@@ -1747,6 +1868,13 @@ var IQAuthClient = class _IQAuthClient {
1747
1868
  static forServer(config) {
1748
1869
  return new _IQAuthClient({ ...config, environment: "server" });
1749
1870
  }
1871
+ /**
1872
+ * Construct a mobile-environment client. NOTE: this constructor does NOT
1873
+ * subscribe to React Native's `AppState` even when `autoRefresh: 'app-state'`
1874
+ * is passed — it only disables the per-request proactive refresh. Use
1875
+ * `createMobileClient` from `@iqauth/sdk/mobile` if you want the full
1876
+ * AppState-driven refresh behavior (recommended for Expo / React Native).
1877
+ */
1750
1878
  static forMobile(config) {
1751
1879
  return new _IQAuthClient({ ...config, environment: "mobile" });
1752
1880
  }
@@ -1763,6 +1891,18 @@ var IQAuthClient = class _IQAuthClient {
1763
1891
  getRefreshToken() {
1764
1892
  return this._refreshToken;
1765
1893
  }
1894
+ /**
1895
+ * Task #126: Eagerly fetch JWKS + OIDC discovery so the first verify() /
1896
+ * refresh round-trip on the request hot path doesn't pay the discovery
1897
+ * fetch latency. Safe to call repeatedly. Errors are swallowed; callers
1898
+ * may fire-and-forget. Called automatically by `iqAuth({...}).attachHelpers()`.
1899
+ */
1900
+ async prewarm() {
1901
+ await Promise.all([
1902
+ this.tokens.prewarm(),
1903
+ this.oidc.getDiscovery().catch(() => void 0)
1904
+ ]);
1905
+ }
1766
1906
  getCurrentClaims() {
1767
1907
  if (!this._accessToken) return null;
1768
1908
  return this.tokens.decode(this._accessToken);
@@ -1803,14 +1943,14 @@ function assertPublishableKey(raw, opts) {
1803
1943
  const ctx = opts?.context ? `${opts.context}: ` : "";
1804
1944
  if (typeof raw !== "string" || raw.length === 0) {
1805
1945
  throw new IQAuthError(
1806
- "CONFIG_INVALID",
1946
+ "config_invalid",
1807
1947
  `${ctx}IQAuth publishable key is missing. Set IQAUTH_PUBLISHABLE_KEY (or pass publishableKey) to a pk_test_\u2026 or pk_live_\u2026 value from the IQAuth admin console.`
1808
1948
  );
1809
1949
  }
1810
1950
  const shapeMatch = raw.match(/^pk_(test|live)_([A-Za-z0-9_-]+)$/);
1811
1951
  if (!shapeMatch) {
1812
1952
  throw new IQAuthError(
1813
- "CONFIG_INVALID",
1953
+ "config_invalid",
1814
1954
  `${ctx}IQAuth publishable key is malformed (got ${raw.slice(0, 12)}\u2026). Expected pk_test_\u2026 or pk_live_\u2026; regenerate the key from the IQAuth admin console.`
1815
1955
  );
1816
1956
  }
@@ -1819,19 +1959,19 @@ function assertPublishableKey(raw, opts) {
1819
1959
  decoded = JSON.parse(b64urlDecode(shapeMatch[2]));
1820
1960
  } catch {
1821
1961
  throw new IQAuthError(
1822
- "CONFIG_INVALID",
1962
+ "config_invalid",
1823
1963
  `${ctx}IQAuth publishable key payload is not valid base64url JSON. Regenerate the key from the IQAuth admin console.`
1824
1964
  );
1825
1965
  }
1826
1966
  if (!isPublishableKeyPayload(decoded)) {
1827
1967
  throw new IQAuthError(
1828
- "CONFIG_INVALID",
1968
+ "config_invalid",
1829
1969
  `${ctx}IQAuth publishable key payload is missing required fields {iss, appId, tenantId, kid}. Regenerate the key from the IQAuth admin console.`
1830
1970
  );
1831
1971
  }
1832
1972
  if (!isValidIssuerUrl(decoded.iss)) {
1833
1973
  throw new IQAuthError(
1834
- "CONFIG_INVALID",
1974
+ "config_invalid",
1835
1975
  `${ctx}IQAuth publishable key encodes an invalid issuer (iss=${JSON.stringify(decoded.iss)}). Expected a fully-qualified URL like "https://auth.example.com" (scheme required). Regenerate the key from the IQAuth admin console \u2014 the new key will encode a valid issuer URL.`
1836
1976
  );
1837
1977
  }
@@ -1844,6 +1984,41 @@ function isPublishableKeyPayload(value) {
1844
1984
  }
1845
1985
 
1846
1986
  // src/server/handlers.ts
1987
+ async function buildUserinfoResponse(claims, opts = {}) {
1988
+ const baseUser = {
1989
+ sub: claims.sub,
1990
+ email: claims.email,
1991
+ name: claims.name,
1992
+ tenantId: claims.tenantId,
1993
+ vendorId: claims.vendorId,
1994
+ roles: claims.roles ?? [],
1995
+ entitlements: claims.entitlements ?? []
1996
+ };
1997
+ const enriched = opts.enrich ? await opts.enrich(claims) : null;
1998
+ const user = enriched ? { ...baseUser, ...enriched } : baseUser;
1999
+ return {
2000
+ success: true,
2001
+ data: {
2002
+ user,
2003
+ claims,
2004
+ tenantId: claims.tenantId ?? null
2005
+ }
2006
+ };
2007
+ }
2008
+ function emitTiming(cfg, event) {
2009
+ if (cfg.debug) {
2010
+ try {
2011
+ console.debug("[iqauth_helper]", event);
2012
+ } catch {
2013
+ }
2014
+ }
2015
+ if (cfg.onTimingEvent) {
2016
+ try {
2017
+ cfg.onTimingEvent(event);
2018
+ } catch {
2019
+ }
2020
+ }
2021
+ }
1847
2022
  var TERMINAL_REFRESH_ERROR_CODES = /* @__PURE__ */ new Set([
1848
2023
  "TOKEN_REVOKED",
1849
2024
  "SESSION_REVOKED",
@@ -1883,7 +2058,11 @@ function resolve(config) {
1883
2058
  })),
1884
2059
  appId: parsed.appId,
1885
2060
  tenantId: parsed.tenantId,
1886
- clearCookiesOnRefreshFailure: config.clearCookiesOnRefreshFailure ?? "terminal-only"
2061
+ clearCookiesOnRefreshFailure: config.clearCookiesOnRefreshFailure ?? "terminal-only",
2062
+ debug: config.debug,
2063
+ onTimingEvent: config.onTimingEvent,
2064
+ signoutRegistry: config.signoutRegistry ?? defaultSignoutRegistry,
2065
+ signoutMarkerTtlMs: config.signoutMarkerTtlMs ?? DEFAULT_SIGNOUT_TTL_MS
1887
2066
  };
1888
2067
  }
1889
2068
  function makeCookie(cfg, name, value, maxAge, httpOnly = true) {
@@ -1900,15 +2079,41 @@ function makeCookie(cfg, name, value, maxAge, httpOnly = true) {
1900
2079
  }
1901
2080
  function clearCookies(cfg) {
1902
2081
  return [
1903
- makeCookie(cfg, cfg.accessCookieName, "", 0),
1904
- makeCookie(cfg, cfg.refreshCookieName, "", 0)
2082
+ { ...makeCookie(cfg, cfg.accessCookieName, "", 0), clear: true },
2083
+ { ...makeCookie(cfg, cfg.refreshCookieName, "", 0), clear: true }
1905
2084
  ];
1906
2085
  }
2086
+ var DEFAULT_SIGNOUT_TTL_MS = 6e4;
2087
+ var inMemorySignoutMarkers = /* @__PURE__ */ new Map();
2088
+ function pruneInMemoryMarkers(now) {
2089
+ if (inMemorySignoutMarkers.size === 0) return;
2090
+ for (const [k, exp] of inMemorySignoutMarkers) {
2091
+ if (exp <= now) inMemorySignoutMarkers.delete(k);
2092
+ }
2093
+ }
2094
+ var defaultSignoutRegistry = {
2095
+ mark(token, ttlMs) {
2096
+ const now = Date.now();
2097
+ pruneInMemoryMarkers(now);
2098
+ inMemorySignoutMarkers.set(token, now + ttlMs);
2099
+ },
2100
+ has(token) {
2101
+ const now = Date.now();
2102
+ const exp = inMemorySignoutMarkers.get(token);
2103
+ if (!exp) return false;
2104
+ if (exp <= now) {
2105
+ inMemorySignoutMarkers.delete(token);
2106
+ return false;
2107
+ }
2108
+ return true;
2109
+ }
2110
+ };
1907
2111
  function serializeCookie(d) {
1908
2112
  const parts = [`${d.name}=${encodeURIComponent(d.value)}`];
1909
2113
  parts.push(`Path=${d.path}`);
1910
2114
  if (d.domain) parts.push(`Domain=${d.domain}`);
1911
2115
  parts.push(`Max-Age=${d.maxAge}`);
2116
+ if (d.clear) parts.push("Expires=Thu, 01 Jan 1970 00:00:00 GMT");
1912
2117
  if (d.secure) parts.push("Secure");
1913
2118
  if (d.httpOnly) parts.push("HttpOnly");
1914
2119
  parts.push(`SameSite=${d.sameSite}`);
@@ -1916,7 +2121,9 @@ function serializeCookie(d) {
1916
2121
  }
1917
2122
  async function handleCallback(config, input) {
1918
2123
  const cfg = resolve(config);
2124
+ const t0 = Date.now();
1919
2125
  if (!input.code || !input.redirectUri) {
2126
+ emitTiming(cfg, { phase: "callback", durationMs: Date.now() - t0, ok: false, code: "VALIDATION_ERROR" });
1920
2127
  return {
1921
2128
  status: 400,
1922
2129
  body: { success: false, error: { code: "VALIDATION_ERROR", message: "code and redirectUri are required" } },
@@ -1924,6 +2131,7 @@ async function handleCallback(config, input) {
1924
2131
  };
1925
2132
  }
1926
2133
  if (!cfg.secretKey) {
2134
+ emitTiming(cfg, { phase: "callback", durationMs: Date.now() - t0, ok: false, code: "INTERNAL_ERROR" });
1927
2135
  return {
1928
2136
  status: 500,
1929
2137
  body: { success: false, error: { code: "INTERNAL_ERROR", message: "secretKey is required for the callback handler" } },
@@ -1947,6 +2155,7 @@ async function handleCallback(config, input) {
1947
2155
  });
1948
2156
  const json = await res.json().catch(() => ({}));
1949
2157
  if (!res.ok || !json.access_token) {
2158
+ emitTiming(cfg, { phase: "callback", durationMs: Date.now() - t0, ok: false, code: json.error || "OIDC_EXCHANGE_FAILED" });
1950
2159
  return {
1951
2160
  status: res.status || 502,
1952
2161
  body: {
@@ -1966,6 +2175,7 @@ async function handleCallback(config, input) {
1966
2175
  if (json.refresh_token) {
1967
2176
  cookies.push(makeCookie(cfg, cfg.refreshCookieName, json.refresh_token, REFRESH_TOKEN_TTL_SECONDS));
1968
2177
  }
2178
+ emitTiming(cfg, { phase: "callback", durationMs: Date.now() - t0, ok: true });
1969
2179
  return {
1970
2180
  status: 200,
1971
2181
  body: { success: true, data: { authenticated: true } },
@@ -1974,8 +2184,18 @@ async function handleCallback(config, input) {
1974
2184
  }
1975
2185
  async function handleRefresh(config, input) {
1976
2186
  const cfg = resolve(config);
2187
+ const t0 = Date.now();
1977
2188
  const refreshToken = input.refreshToken;
2189
+ const idemKey = input.idempotencyToken;
2190
+ if (idemKey && await Promise.resolve(cfg.signoutRegistry.has(idemKey))) {
2191
+ return {
2192
+ status: 401,
2193
+ body: { success: false, error: { code: "SESSION_REVOKED", message: "Session was signed out" } },
2194
+ cookies: clearCookies(cfg)
2195
+ };
2196
+ }
1978
2197
  if (!refreshToken) {
2198
+ emitTiming(cfg, { phase: "refresh", durationMs: Date.now() - t0, ok: false, code: "TOKEN_INVALID" });
1979
2199
  return {
1980
2200
  status: 401,
1981
2201
  body: { success: false, error: { code: "TOKEN_INVALID", message: "Missing refresh token" } },
@@ -1991,6 +2211,7 @@ async function handleRefresh(config, input) {
1991
2211
  if (!res.ok || !json.success || !json.data?.accessToken) {
1992
2212
  const status = res.status || 401;
1993
2213
  const errorCode = json.error?.code || "TOKEN_INVALID";
2214
+ emitTiming(cfg, { phase: "refresh", durationMs: Date.now() - t0, ok: false, code: errorCode });
1994
2215
  const shouldClear = shouldClearCookiesOnFailure(
1995
2216
  cfg.clearCookiesOnRefreshFailure,
1996
2217
  status,
@@ -2014,6 +2235,7 @@ async function handleRefresh(config, input) {
2014
2235
  if (json.data.refreshToken) {
2015
2236
  cookies.push(makeCookie(cfg, cfg.refreshCookieName, json.data.refreshToken, REFRESH_TOKEN_TTL_SECONDS));
2016
2237
  }
2238
+ emitTiming(cfg, { phase: "refresh", durationMs: Date.now() - t0, ok: true });
2017
2239
  return {
2018
2240
  status: 200,
2019
2241
  body: { success: true, data: { accessToken: json.data.accessToken } },
@@ -2022,6 +2244,10 @@ async function handleRefresh(config, input) {
2022
2244
  }
2023
2245
  async function handleSignout(config, input) {
2024
2246
  const cfg = resolve(config);
2247
+ const t0 = Date.now();
2248
+ if (input.idempotencyToken) {
2249
+ await Promise.resolve(cfg.signoutRegistry.mark(input.idempotencyToken, cfg.signoutMarkerTtlMs));
2250
+ }
2025
2251
  if (input.accessToken) {
2026
2252
  try {
2027
2253
  await cfg.fetchImpl(`${cfg.issuer}${cfg.logoutPath}`, {
@@ -2043,12 +2269,52 @@ async function handleSignout(config, input) {
2043
2269
  } catch {
2044
2270
  }
2045
2271
  }
2272
+ emitTiming(cfg, { phase: "signout", durationMs: Date.now() - t0, ok: true });
2046
2273
  return {
2047
2274
  status: 200,
2048
2275
  body: { success: true, data: { signedOut: true } },
2049
2276
  cookies: clearCookies(cfg)
2050
2277
  };
2051
2278
  }
2279
+ var TOKENS_CACHE = /* @__PURE__ */ new Map();
2280
+ function getTokensFor(issuer) {
2281
+ let m = TOKENS_CACHE.get(issuer);
2282
+ if (!m) {
2283
+ m = new TokensModule(issuer);
2284
+ TOKENS_CACHE.set(issuer, m);
2285
+ }
2286
+ return m;
2287
+ }
2288
+ async function handleUserinfo(config, input) {
2289
+ const cfg = resolve(config);
2290
+ if (!input.accessToken) {
2291
+ return {
2292
+ status: 401,
2293
+ body: { success: false, error: { code: "TOKEN_INVALID", message: "Missing access token" } },
2294
+ cookies: []
2295
+ };
2296
+ }
2297
+ let claims;
2298
+ try {
2299
+ claims = await getTokensFor(cfg.issuer).verify(input.accessToken, config.verify);
2300
+ } catch (err) {
2301
+ const code = err instanceof IQAuthError ? err.code : err.code || "TOKEN_INVALID";
2302
+ const message = err instanceof Error ? err.message : "Access token verification failed";
2303
+ return {
2304
+ status: 401,
2305
+ body: { success: false, error: { code, message } },
2306
+ cookies: []
2307
+ };
2308
+ }
2309
+ const envelope = await buildUserinfoResponse(claims, {
2310
+ enrich: config.userinfoEnricher ? (c) => config.userinfoEnricher(c, input.req) : void 0
2311
+ });
2312
+ return {
2313
+ status: 200,
2314
+ body: envelope,
2315
+ cookies: []
2316
+ };
2317
+ }
2052
2318
 
2053
2319
  // src/hono.ts
2054
2320
  var KNOWN_AUTH_ERRORS = /* @__PURE__ */ new Set([
@@ -2057,7 +2323,10 @@ var KNOWN_AUTH_ERRORS = /* @__PURE__ */ new Set([
2057
2323
  "TOKEN_REVOKED",
2058
2324
  "SESSION_EXPIRED",
2059
2325
  "SESSION_INVALID",
2060
- "AUTH_REQUIRED"
2326
+ "AUTH_REQUIRED",
2327
+ // Task #127 — typed `IQAuthErrorCode` taxonomy.
2328
+ "token_invalid",
2329
+ "token_expired"
2061
2330
  ]);
2062
2331
  function readCookieFromHeader(header, name) {
2063
2332
  if (!header) return void 0;
@@ -2096,6 +2365,11 @@ function iqAuth(options) {
2096
2365
  return async (c, next) => {
2097
2366
  const url = new URL(c.req.url);
2098
2367
  const path = url.pathname;
2368
+ if (options.mountUserinfo && path === `${mount}/me` && c.req.method === "GET") {
2369
+ const auth2 = c.req.header("authorization");
2370
+ const accessToken = auth2 && auth2.replace(/^Bearer /i, "") || readCookieFromHeader(c.req.header("cookie"), accessCookie);
2371
+ return honoResponse(await handleUserinfo(helperConfig, { accessToken, req: c.req }));
2372
+ }
2099
2373
  if (mountHelpers && path.startsWith(mount + "/") && c.req.method === "POST") {
2100
2374
  const body = await c.req.json().catch(() => ({}));
2101
2375
  const cookieHeader = c.req.header("cookie");
@@ -2108,12 +2382,15 @@ function iqAuth(options) {
2108
2382
  }
2109
2383
  if (path === `${mount}/refresh`) {
2110
2384
  const refreshToken = body.refreshToken || readCookieFromHeader(cookieHeader, refreshCookie);
2111
- return honoResponse(await handleRefresh(helperConfig, { refreshToken }));
2385
+ const idempotencyToken = c.req.header("x-iqauth-idempotency") || body.idempotencyToken;
2386
+ return honoResponse(await handleRefresh(helperConfig, { refreshToken, idempotencyToken }));
2112
2387
  }
2113
2388
  if (path === `${mount}/signout`) {
2114
2389
  const auth2 = c.req.header("authorization");
2115
2390
  const accessToken = auth2 && auth2.replace(/^Bearer /i, "") || readCookieFromHeader(cookieHeader, accessCookie);
2116
- return honoResponse(await handleSignout(helperConfig, { accessToken, ssoCookieHeader: cookieHeader }));
2391
+ const refreshToken = readCookieFromHeader(cookieHeader, refreshCookie);
2392
+ const idempotencyToken = c.req.header("x-iqauth-idempotency");
2393
+ return honoResponse(await handleSignout(helperConfig, { accessToken, refreshToken, idempotencyToken, ssoCookieHeader: cookieHeader }));
2117
2394
  }
2118
2395
  }
2119
2396
  if (isPublic(path)) return next();