@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/fastify.js CHANGED
@@ -36,13 +36,30 @@ __export(fastify_exports, {
36
36
  module.exports = __toCommonJS(fastify_exports);
37
37
 
38
38
  // src/errors.ts
39
- var IQAuthError = class extends Error {
40
- constructor(code, message, status, raw) {
39
+ var IQAuthError = class _IQAuthError extends Error {
40
+ constructor(code, message, status, cause) {
41
41
  super(message);
42
42
  this.name = "IQAuthError";
43
43
  this.code = code;
44
44
  this.status = status;
45
- this.raw = raw;
45
+ this.cause = cause;
46
+ this.raw = cause;
47
+ }
48
+ /**
49
+ * Type guard: true when `value` is an `IQAuthError`. Useful for adapters
50
+ * that round-trip errors through `unknown` (e.g. fastify's `setErrorHandler`).
51
+ */
52
+ static isIQAuthError(value) {
53
+ return value instanceof _IQAuthError || typeof value === "object" && value !== null && value.name === "IQAuthError" && typeof value.code === "string";
54
+ }
55
+ /**
56
+ * Type-narrowed code check. Lets callers write
57
+ * `if (err.is("token_expired")) …` with full IntelliSense for the typed
58
+ * taxonomy without losing the ability to handle server codes via
59
+ * `err.code === "TOKEN_REVOKED"`.
60
+ */
61
+ is(code) {
62
+ return this.code === code;
46
63
  }
47
64
  };
48
65
 
@@ -157,7 +174,7 @@ var HttpClient = class {
157
174
  headers: this.buildHeaders(),
158
175
  ...this.isBrowserSession() ? { credentials: "include" } : (() => {
159
176
  const refreshToken = this.config.getRefreshToken();
160
- if (!refreshToken) throw new IQAuthError("TOKEN_INVALID", "No refresh token available");
177
+ if (!refreshToken) throw new IQAuthError("config_invalid", "No refresh token available");
161
178
  return { body: JSON.stringify({ refreshToken }) };
162
179
  })()
163
180
  });
@@ -174,7 +191,7 @@ var HttpClient = class {
174
191
  return;
175
192
  }
176
193
  if (!body.data.accessToken || !body.data.refreshToken) {
177
- throw new IQAuthError("TOKEN_INVALID", "Refresh response did not include a token pair");
194
+ throw new IQAuthError("token_invalid", "Refresh response did not include a token pair");
178
195
  }
179
196
  const tokens = {
180
197
  accessToken: body.data.accessToken,
@@ -192,7 +209,7 @@ var HttpClient = class {
192
209
  return this.requestWithRetry(method, path, body, options, false);
193
210
  }
194
211
  async requestWithRetry(method, path, body, options, hasRetried) {
195
- if (this.config.autoRefresh && !options?.skipAutoRefresh && !this.isBrowserSession() && this.config.getRefreshToken() && this.isTokenExpiringSoon()) {
212
+ if (this.config.autoRefresh && this.config.proactiveRefresh !== false && !options?.skipAutoRefresh && !this.isBrowserSession() && this.config.getRefreshToken() && this.isTokenExpiringSoon()) {
196
213
  await this.attemptRefresh();
197
214
  }
198
215
  const url = `${this.config.baseUrl}${path}`;
@@ -420,6 +437,18 @@ var DEFAULT_TOKEN_AUDIENCE = [
420
437
  "iqvalidate"
421
438
  ];
422
439
  var DEFAULT_CLOCK_TOLERANCE_SECONDS = 30;
440
+ function classifyJoseError(err) {
441
+ if (err instanceof import_jose.errors.JWTExpired) {
442
+ return { code: "token_expired", message: "Token has expired" };
443
+ }
444
+ if (err instanceof import_jose.errors.JOSEError) {
445
+ return { code: "token_invalid", message: err.message };
446
+ }
447
+ if (err instanceof Error) {
448
+ return { code: "token_invalid", message: err.message };
449
+ }
450
+ return { code: "token_invalid", message: "Token verification failed" };
451
+ }
423
452
  function decodeProtectedHeader(token) {
424
453
  const parts = token.split(".");
425
454
  if (parts.length < 2) return null;
@@ -456,11 +485,11 @@ var TokensModule = class {
456
485
  async verify(token, options = {}) {
457
486
  const header = decodeProtectedHeader(token);
458
487
  if (!header) {
459
- throw new IQAuthError("TOKEN_INVALID", "Unable to decode token");
488
+ throw new IQAuthError("token_invalid", "Unable to decode token");
460
489
  }
461
490
  const kid = header.kid;
462
491
  if (!kid) {
463
- throw new IQAuthError("TOKEN_INVALID", "Token missing kid header");
492
+ throw new IQAuthError("token_invalid", "Token missing kid header");
464
493
  }
465
494
  let cache = await this.ensureCache();
466
495
  if (!cache.byKid.has(kid)) {
@@ -468,7 +497,7 @@ var TokensModule = class {
468
497
  cache = await this.ensureCache();
469
498
  }
470
499
  if (!cache.byKid.has(kid)) {
471
- throw new IQAuthError("TOKEN_INVALID", `Unknown key ID: ${kid}`);
500
+ throw new IQAuthError("token_invalid", `Unknown key ID: ${kid}`);
472
501
  }
473
502
  const issuer = options.issuer ?? this.defaultIssuer;
474
503
  const audience = options.audience ?? this.defaultAudience;
@@ -484,16 +513,8 @@ var TokensModule = class {
484
513
  const { payload } = await (0, import_jose.jwtVerify)(token, cache.verifier, verifyOptions);
485
514
  return payload;
486
515
  } catch (err) {
487
- if (err instanceof import_jose.errors.JWTExpired) {
488
- throw new IQAuthError("TOKEN_EXPIRED", "Token has expired");
489
- }
490
- if (err instanceof import_jose.errors.JOSEError) {
491
- throw new IQAuthError("TOKEN_INVALID", err.message);
492
- }
493
- if (err instanceof Error) {
494
- throw new IQAuthError("TOKEN_INVALID", err.message);
495
- }
496
- throw new IQAuthError("TOKEN_INVALID", "Token verification failed");
516
+ const classified = classifyJoseError(err);
517
+ throw new IQAuthError(classified.code, classified.message, void 0, err);
497
518
  }
498
519
  }
499
520
  /**
@@ -535,7 +556,7 @@ var TokensModule = class {
535
556
  getClaims(token) {
536
557
  const claims = this.decode(token);
537
558
  if (!claims) {
538
- throw new IQAuthError("TOKEN_INVALID", "Unable to decode token claims");
559
+ throw new IQAuthError("token_invalid", "Unable to decode token claims");
539
560
  }
540
561
  return claims;
541
562
  }
@@ -545,7 +566,7 @@ var TokensModule = class {
545
566
  }
546
567
  await this.refreshJwks();
547
568
  if (!this.jwksCache) {
548
- throw new IQAuthError("INTERNAL_ERROR", "JWKS cache unavailable after refresh");
569
+ throw new IQAuthError("jwks_unavailable", "JWKS cache unavailable after refresh");
549
570
  }
550
571
  return this.jwksCache;
551
572
  }
@@ -555,22 +576,38 @@ var TokensModule = class {
555
576
  }
556
577
  this.inFlightRefresh = (async () => {
557
578
  try {
558
- const res = await fetch(`${this.baseUrl}/.well-known/jwks.json`);
579
+ let res;
580
+ try {
581
+ res = await fetch(`${this.baseUrl}/.well-known/jwks.json`);
582
+ } catch (err) {
583
+ throw new IQAuthError(
584
+ "network",
585
+ err instanceof Error ? err.message : "JWKS fetch network error",
586
+ void 0,
587
+ err
588
+ );
589
+ }
559
590
  if (!res.ok) {
560
591
  throw new IQAuthError(
561
- "INTERNAL_ERROR",
562
- `Failed to fetch JWKS: ${res.status}`
592
+ "jwks_fetch_failed",
593
+ `Failed to fetch JWKS: ${res.status}`,
594
+ res.status
563
595
  );
564
596
  }
565
597
  let jwks;
566
598
  try {
567
599
  jwks = await res.json();
568
- } catch {
569
- throw new IQAuthError("INTERNAL_ERROR", "Malformed JWKS response: invalid JSON");
600
+ } catch (err) {
601
+ throw new IQAuthError(
602
+ "jwks_fetch_failed",
603
+ "Malformed JWKS response: invalid JSON",
604
+ res.status,
605
+ err
606
+ );
570
607
  }
571
608
  if (!jwks || !Array.isArray(jwks.keys)) {
572
609
  throw new IQAuthError(
573
- "INTERNAL_ERROR",
610
+ "jwks_fetch_failed",
574
611
  "Malformed JWKS response: expected { keys: [...] }"
575
612
  );
576
613
  }
@@ -578,7 +615,7 @@ var TokensModule = class {
578
615
  for (const key of jwks.keys) {
579
616
  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")) {
580
617
  throw new IQAuthError(
581
- "INTERNAL_ERROR",
618
+ "jwks_fetch_failed",
582
619
  "Malformed JWKS response: key missing required fields"
583
620
  );
584
621
  }
@@ -596,6 +633,19 @@ var TokensModule = class {
596
633
  clearCache() {
597
634
  this.jwksCache = null;
598
635
  }
636
+ /**
637
+ * Task #126: Eagerly populate the JWKS cache so the first verify() call
638
+ * doesn't pay a network round-trip. Safe to call repeatedly — single-flight
639
+ * behavior is shared with the lazy refresh path. Errors are swallowed so
640
+ * callers (e.g. `attachHelpers` auto-prewarm) can fire-and-forget.
641
+ */
642
+ async prewarm() {
643
+ if (this.jwksCache && Date.now() - this.jwksCache.fetchedAt <= JWKS_CACHE_TTL_MS) return;
644
+ try {
645
+ await this.refreshJwks();
646
+ } catch {
647
+ }
648
+ }
599
649
  };
600
650
 
601
651
  // src/modules/sessions.ts
@@ -919,14 +969,14 @@ var OidcModule = class {
919
969
  */
920
970
  async handleCallback(params) {
921
971
  if (!params.state) {
922
- throw new IQAuthError("VALIDATION_ERROR", "OIDC callback missing state parameter");
972
+ throw new IQAuthError("config_invalid", "OIDC callback missing state parameter");
923
973
  }
924
974
  if (!params.code) {
925
- throw new IQAuthError("VALIDATION_ERROR", "OIDC callback missing code parameter");
975
+ throw new IQAuthError("config_invalid", "OIDC callback missing code parameter");
926
976
  }
927
977
  const stored = await this.stateStore.get(params.state);
928
978
  if (!stored) {
929
- throw new IQAuthError("VALIDATION_ERROR", "Unknown or expired OIDC state");
979
+ throw new IQAuthError("config_invalid", "Unknown or expired OIDC state");
930
980
  }
931
981
  let tokens;
932
982
  try {
@@ -944,7 +994,7 @@ var OidcModule = class {
944
994
  if (tokens.id_token) {
945
995
  if (!this.tokensModule) {
946
996
  throw new IQAuthError(
947
- "INTERNAL_ERROR",
997
+ "config_invalid",
948
998
  "OIDC handleCallback received an id_token but no TokensModule is configured for verification"
949
999
  );
950
1000
  }
@@ -955,7 +1005,7 @@ var OidcModule = class {
955
1005
  const tokenNonce = typeof claimsBag.nonce === "string" ? claimsBag.nonce : void 0;
956
1006
  if (!tokenNonce || tokenNonce !== stored.nonce) {
957
1007
  throw new IQAuthError(
958
- "TOKEN_INVALID",
1008
+ "token_invalid",
959
1009
  "OIDC id_token nonce did not match the stored value"
960
1010
  );
961
1011
  }
@@ -1156,6 +1206,9 @@ var AppsModule = class {
1156
1206
  * @remarks Wraps GET /api/v1/apps/:appKey
1157
1207
  */
1158
1208
  async get(appKey) {
1209
+ if (typeof appKey !== "string" || appKey.trim() === "") {
1210
+ throw new IQAuthError("VALIDATION_ERROR", "appKey is required (no env-var fallback).", 400);
1211
+ }
1159
1212
  return this.http.request("GET", `/api/v1/apps/${encodeURIComponent(appKey)}`);
1160
1213
  }
1161
1214
  /**
@@ -1175,6 +1228,16 @@ var AppsModule = class {
1175
1228
  401
1176
1229
  );
1177
1230
  }
1231
+ if (!manifest || typeof manifest.key !== "string" || manifest.key.trim() === "") {
1232
+ throw new IQAuthError("VALIDATION_ERROR", "manifest.key (appKey) is required for register().", 400);
1233
+ }
1234
+ if (!manifest.environment || !["production", "staging", "development"].includes(manifest.environment)) {
1235
+ throw new IQAuthError(
1236
+ "ENVIRONMENT_REQUIRED",
1237
+ "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.",
1238
+ 400
1239
+ );
1240
+ }
1178
1241
  return this.http.request("POST", "/api/v1/apps/sync", manifest);
1179
1242
  }
1180
1243
  /**
@@ -1184,11 +1247,14 @@ var AppsModule = class {
1184
1247
  * @remarks Uses GET /api/v1/apps/:appKey — catches 404 errors
1185
1248
  */
1186
1249
  async isRegistered(appKey) {
1250
+ if (typeof appKey !== "string" || appKey.trim() === "") {
1251
+ throw new IQAuthError("VALIDATION_ERROR", "appKey is required (no env-var fallback).", 400);
1252
+ }
1187
1253
  try {
1188
1254
  await this.get(appKey);
1189
1255
  return true;
1190
1256
  } catch (err) {
1191
- if (err.code === "NOT_FOUND" || err.status === 404) {
1257
+ if (err.code === "app_not_found" || err.code === "NOT_FOUND" || err.status === 404) {
1192
1258
  return false;
1193
1259
  }
1194
1260
  throw err;
@@ -1225,6 +1291,20 @@ var RolesModule = class {
1225
1291
  };
1226
1292
 
1227
1293
  // src/modules/permissionGroups.ts
1294
+ function assertAppKey(appKey, callsite) {
1295
+ if (typeof appKey !== "string" || appKey.trim() === "") {
1296
+ throw new IQAuthError(
1297
+ "VALIDATION_ERROR",
1298
+ `appKey is required for ${callsite} (no env-var fallback, no 'product' alias).`,
1299
+ 400
1300
+ );
1301
+ }
1302
+ }
1303
+ function assertNodeKey(nodeKey, callsite) {
1304
+ if (typeof nodeKey !== "string" || nodeKey.trim() === "") {
1305
+ throw new IQAuthError("VALIDATION_ERROR", `nodeKey is required for ${callsite}.`, 400);
1306
+ }
1307
+ }
1228
1308
  var PermissionGroupsModule = class {
1229
1309
  constructor(http) {
1230
1310
  this.http = http;
@@ -1245,7 +1325,14 @@ var PermissionGroupsModule = class {
1245
1325
  return this.http.request("GET", `/api/v1/tenants/${tenantId}/permission-groups/${groupId}/permissions`);
1246
1326
  }
1247
1327
  async addPermission(tenantId, groupId, data) {
1248
- return this.http.request("POST", `/api/v1/tenants/${tenantId}/permission-groups/${groupId}/permissions`, data);
1328
+ assertAppKey(data?.appKey, "permissionGroups.addPermission");
1329
+ assertNodeKey(data?.nodeKey, "permissionGroups.addPermission");
1330
+ return this.http.request("POST", `/api/v1/tenants/${tenantId}/permission-groups/${groupId}/permissions`, {
1331
+ appKey: data.appKey,
1332
+ nodeKey: data.nodeKey,
1333
+ effect: data.effect,
1334
+ weight: data.weight
1335
+ });
1249
1336
  }
1250
1337
  async removePermission(tenantId, groupId, permissionId) {
1251
1338
  return this.http.request("DELETE", `/api/v1/tenants/${tenantId}/permission-groups/${groupId}/permissions/${permissionId}`);
@@ -1269,21 +1356,51 @@ var PermissionGroupsModule = class {
1269
1356
  return this.http.request("GET", `/api/v1/tenants/${tenantId}/users/${userId}/permissions/overrides`);
1270
1357
  }
1271
1358
  async addUserOverride(tenantId, userId, data) {
1272
- return this.http.request("POST", `/api/v1/tenants/${tenantId}/users/${userId}/permissions/overrides`, data);
1359
+ assertAppKey(data?.appKey, "permissionGroups.addUserOverride");
1360
+ assertNodeKey(data?.nodeKey, "permissionGroups.addUserOverride");
1361
+ return this.http.request("POST", `/api/v1/tenants/${tenantId}/users/${userId}/permissions/overrides`, {
1362
+ appKey: data.appKey,
1363
+ nodeKey: data.nodeKey,
1364
+ effect: data.effect,
1365
+ weight: data.weight,
1366
+ expiresAt: data.expiresAt
1367
+ });
1273
1368
  }
1274
1369
  async removeUserOverride(tenantId, userId, overrideId) {
1275
1370
  return this.http.request("DELETE", `/api/v1/tenants/${tenantId}/users/${userId}/permissions/overrides/${overrideId}`);
1276
1371
  }
1372
+ /**
1373
+ * Task #130 — `appKey` is REQUIRED. The legacy `product` query alias is no
1374
+ * longer accepted at the SDK boundary; pass it as `appKey` instead. The
1375
+ * server still accepts `product=` from raw HTTP callers during the
1376
+ * deprecation window, but the SDK will not silently translate it.
1377
+ */
1277
1378
  async getEffectivePermissions(tenantId, userId, params) {
1278
- const query = new URLSearchParams();
1279
- if (params.product) query.set("product", params.product);
1280
- if (params.appKey) query.set("appKey", params.appKey);
1281
- const qs = query.toString();
1282
- return this.http.request("GET", `/api/v1/tenants/${tenantId}/users/${userId}/permissions/effective${qs ? `?${qs}` : ""}`);
1379
+ assertAppKey(params?.appKey, "permissionGroups.getEffectivePermissions");
1380
+ const qs = new URLSearchParams({ appKey: params.appKey }).toString();
1381
+ return this.http.request("GET", `/api/v1/tenants/${tenantId}/users/${userId}/permissions/effective?${qs}`);
1283
1382
  }
1284
1383
  async checkPermission(tenantId, userId, appKey, nodeKey) {
1384
+ assertAppKey(appKey, "permissionGroups.checkPermission");
1385
+ assertNodeKey(nodeKey, "permissionGroups.checkPermission");
1285
1386
  return this.http.request("POST", `/api/v1/tenants/${tenantId}/users/${userId}/permissions/check`, { appKey, nodeKey });
1286
1387
  }
1388
+ /**
1389
+ * Task #130 — every entry in `checks` must include a non-empty `appKey`
1390
+ * AND `nodeKey`. The SDK validates the whole batch before sending so a
1391
+ * single misconfigured entry can't slip through and silently report
1392
+ * `allowed: false` from the server's per-entry validation branch.
1393
+ */
1394
+ async batchCheckPermissions(tenantId, userId, checks) {
1395
+ if (!Array.isArray(checks) || checks.length === 0) {
1396
+ throw new IQAuthError("VALIDATION_ERROR", "checks must be a non-empty array of { appKey, nodeKey }.", 400);
1397
+ }
1398
+ checks.forEach((c, i) => {
1399
+ assertAppKey(c?.appKey, `permissionGroups.batchCheckPermissions[${i}]`);
1400
+ assertNodeKey(c?.nodeKey, `permissionGroups.batchCheckPermissions[${i}]`);
1401
+ });
1402
+ return this.http.request("POST", `/api/v1/tenants/${tenantId}/users/${userId}/permissions/batch-check`, { checks });
1403
+ }
1287
1404
  };
1288
1405
 
1289
1406
  // src/modules/apiKeys.ts
@@ -1708,6 +1825,10 @@ var IQAuthClient = class _IQAuthClient {
1708
1825
  this._refreshToken = tokens.refreshToken;
1709
1826
  },
1710
1827
  autoRefresh: "autoRefresh" in config ? config.autoRefresh !== false : true,
1828
+ // `'app-state'` is mobile-only — on any other environment we treat it
1829
+ // as the default `true` (proactive refresh ON). Only the mobile client
1830
+ // disables proactive refresh and replaces it with an AppState listener.
1831
+ proactiveRefresh: "autoRefresh" in config && config.autoRefresh === "app-state" && _IQAuthClient.resolveEnvironment(config) === "mobile" ? false : true,
1711
1832
  onTokenRefresh: "onTokenRefresh" in config ? config.onTokenRefresh : void 0,
1712
1833
  sessionHeaderName: config.sessionHeaderName,
1713
1834
  sessionHeaderValue: config.sessionHeaderValue,
@@ -1748,6 +1869,13 @@ var IQAuthClient = class _IQAuthClient {
1748
1869
  static forServer(config) {
1749
1870
  return new _IQAuthClient({ ...config, environment: "server" });
1750
1871
  }
1872
+ /**
1873
+ * Construct a mobile-environment client. NOTE: this constructor does NOT
1874
+ * subscribe to React Native's `AppState` even when `autoRefresh: 'app-state'`
1875
+ * is passed — it only disables the per-request proactive refresh. Use
1876
+ * `createMobileClient` from `@iqauth/sdk/mobile` if you want the full
1877
+ * AppState-driven refresh behavior (recommended for Expo / React Native).
1878
+ */
1751
1879
  static forMobile(config) {
1752
1880
  return new _IQAuthClient({ ...config, environment: "mobile" });
1753
1881
  }
@@ -1764,6 +1892,18 @@ var IQAuthClient = class _IQAuthClient {
1764
1892
  getRefreshToken() {
1765
1893
  return this._refreshToken;
1766
1894
  }
1895
+ /**
1896
+ * Task #126: Eagerly fetch JWKS + OIDC discovery so the first verify() /
1897
+ * refresh round-trip on the request hot path doesn't pay the discovery
1898
+ * fetch latency. Safe to call repeatedly. Errors are swallowed; callers
1899
+ * may fire-and-forget. Called automatically by `iqAuth({...}).attachHelpers()`.
1900
+ */
1901
+ async prewarm() {
1902
+ await Promise.all([
1903
+ this.tokens.prewarm(),
1904
+ this.oidc.getDiscovery().catch(() => void 0)
1905
+ ]);
1906
+ }
1767
1907
  getCurrentClaims() {
1768
1908
  if (!this._accessToken) return null;
1769
1909
  return this.tokens.decode(this._accessToken);
@@ -1804,14 +1944,14 @@ function assertPublishableKey(raw, opts) {
1804
1944
  const ctx = opts?.context ? `${opts.context}: ` : "";
1805
1945
  if (typeof raw !== "string" || raw.length === 0) {
1806
1946
  throw new IQAuthError(
1807
- "CONFIG_INVALID",
1947
+ "config_invalid",
1808
1948
  `${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.`
1809
1949
  );
1810
1950
  }
1811
1951
  const shapeMatch = raw.match(/^pk_(test|live)_([A-Za-z0-9_-]+)$/);
1812
1952
  if (!shapeMatch) {
1813
1953
  throw new IQAuthError(
1814
- "CONFIG_INVALID",
1954
+ "config_invalid",
1815
1955
  `${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.`
1816
1956
  );
1817
1957
  }
@@ -1820,19 +1960,19 @@ function assertPublishableKey(raw, opts) {
1820
1960
  decoded = JSON.parse(b64urlDecode(shapeMatch[2]));
1821
1961
  } catch {
1822
1962
  throw new IQAuthError(
1823
- "CONFIG_INVALID",
1963
+ "config_invalid",
1824
1964
  `${ctx}IQAuth publishable key payload is not valid base64url JSON. Regenerate the key from the IQAuth admin console.`
1825
1965
  );
1826
1966
  }
1827
1967
  if (!isPublishableKeyPayload(decoded)) {
1828
1968
  throw new IQAuthError(
1829
- "CONFIG_INVALID",
1969
+ "config_invalid",
1830
1970
  `${ctx}IQAuth publishable key payload is missing required fields {iss, appId, tenantId, kid}. Regenerate the key from the IQAuth admin console.`
1831
1971
  );
1832
1972
  }
1833
1973
  if (!isValidIssuerUrl(decoded.iss)) {
1834
1974
  throw new IQAuthError(
1835
- "CONFIG_INVALID",
1975
+ "config_invalid",
1836
1976
  `${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.`
1837
1977
  );
1838
1978
  }
@@ -1845,6 +1985,41 @@ function isPublishableKeyPayload(value) {
1845
1985
  }
1846
1986
 
1847
1987
  // src/server/handlers.ts
1988
+ async function buildUserinfoResponse(claims, opts = {}) {
1989
+ const baseUser = {
1990
+ sub: claims.sub,
1991
+ email: claims.email,
1992
+ name: claims.name,
1993
+ tenantId: claims.tenantId,
1994
+ vendorId: claims.vendorId,
1995
+ roles: claims.roles ?? [],
1996
+ entitlements: claims.entitlements ?? []
1997
+ };
1998
+ const enriched = opts.enrich ? await opts.enrich(claims) : null;
1999
+ const user = enriched ? { ...baseUser, ...enriched } : baseUser;
2000
+ return {
2001
+ success: true,
2002
+ data: {
2003
+ user,
2004
+ claims,
2005
+ tenantId: claims.tenantId ?? null
2006
+ }
2007
+ };
2008
+ }
2009
+ function emitTiming(cfg, event) {
2010
+ if (cfg.debug) {
2011
+ try {
2012
+ console.debug("[iqauth_helper]", event);
2013
+ } catch {
2014
+ }
2015
+ }
2016
+ if (cfg.onTimingEvent) {
2017
+ try {
2018
+ cfg.onTimingEvent(event);
2019
+ } catch {
2020
+ }
2021
+ }
2022
+ }
1848
2023
  var TERMINAL_REFRESH_ERROR_CODES = /* @__PURE__ */ new Set([
1849
2024
  "TOKEN_REVOKED",
1850
2025
  "SESSION_REVOKED",
@@ -1884,7 +2059,11 @@ function resolve(config) {
1884
2059
  })),
1885
2060
  appId: parsed.appId,
1886
2061
  tenantId: parsed.tenantId,
1887
- clearCookiesOnRefreshFailure: config.clearCookiesOnRefreshFailure ?? "terminal-only"
2062
+ clearCookiesOnRefreshFailure: config.clearCookiesOnRefreshFailure ?? "terminal-only",
2063
+ debug: config.debug,
2064
+ onTimingEvent: config.onTimingEvent,
2065
+ signoutRegistry: config.signoutRegistry ?? defaultSignoutRegistry,
2066
+ signoutMarkerTtlMs: config.signoutMarkerTtlMs ?? DEFAULT_SIGNOUT_TTL_MS
1888
2067
  };
1889
2068
  }
1890
2069
  function makeCookie(cfg, name, value, maxAge, httpOnly = true) {
@@ -1901,15 +2080,41 @@ function makeCookie(cfg, name, value, maxAge, httpOnly = true) {
1901
2080
  }
1902
2081
  function clearCookies(cfg) {
1903
2082
  return [
1904
- makeCookie(cfg, cfg.accessCookieName, "", 0),
1905
- makeCookie(cfg, cfg.refreshCookieName, "", 0)
2083
+ { ...makeCookie(cfg, cfg.accessCookieName, "", 0), clear: true },
2084
+ { ...makeCookie(cfg, cfg.refreshCookieName, "", 0), clear: true }
1906
2085
  ];
1907
2086
  }
2087
+ var DEFAULT_SIGNOUT_TTL_MS = 6e4;
2088
+ var inMemorySignoutMarkers = /* @__PURE__ */ new Map();
2089
+ function pruneInMemoryMarkers(now) {
2090
+ if (inMemorySignoutMarkers.size === 0) return;
2091
+ for (const [k, exp] of inMemorySignoutMarkers) {
2092
+ if (exp <= now) inMemorySignoutMarkers.delete(k);
2093
+ }
2094
+ }
2095
+ var defaultSignoutRegistry = {
2096
+ mark(token, ttlMs) {
2097
+ const now = Date.now();
2098
+ pruneInMemoryMarkers(now);
2099
+ inMemorySignoutMarkers.set(token, now + ttlMs);
2100
+ },
2101
+ has(token) {
2102
+ const now = Date.now();
2103
+ const exp = inMemorySignoutMarkers.get(token);
2104
+ if (!exp) return false;
2105
+ if (exp <= now) {
2106
+ inMemorySignoutMarkers.delete(token);
2107
+ return false;
2108
+ }
2109
+ return true;
2110
+ }
2111
+ };
1908
2112
  function serializeCookie(d) {
1909
2113
  const parts = [`${d.name}=${encodeURIComponent(d.value)}`];
1910
2114
  parts.push(`Path=${d.path}`);
1911
2115
  if (d.domain) parts.push(`Domain=${d.domain}`);
1912
2116
  parts.push(`Max-Age=${d.maxAge}`);
2117
+ if (d.clear) parts.push("Expires=Thu, 01 Jan 1970 00:00:00 GMT");
1913
2118
  if (d.secure) parts.push("Secure");
1914
2119
  if (d.httpOnly) parts.push("HttpOnly");
1915
2120
  parts.push(`SameSite=${d.sameSite}`);
@@ -1917,7 +2122,9 @@ function serializeCookie(d) {
1917
2122
  }
1918
2123
  async function handleCallback(config, input) {
1919
2124
  const cfg = resolve(config);
2125
+ const t0 = Date.now();
1920
2126
  if (!input.code || !input.redirectUri) {
2127
+ emitTiming(cfg, { phase: "callback", durationMs: Date.now() - t0, ok: false, code: "VALIDATION_ERROR" });
1921
2128
  return {
1922
2129
  status: 400,
1923
2130
  body: { success: false, error: { code: "VALIDATION_ERROR", message: "code and redirectUri are required" } },
@@ -1925,6 +2132,7 @@ async function handleCallback(config, input) {
1925
2132
  };
1926
2133
  }
1927
2134
  if (!cfg.secretKey) {
2135
+ emitTiming(cfg, { phase: "callback", durationMs: Date.now() - t0, ok: false, code: "INTERNAL_ERROR" });
1928
2136
  return {
1929
2137
  status: 500,
1930
2138
  body: { success: false, error: { code: "INTERNAL_ERROR", message: "secretKey is required for the callback handler" } },
@@ -1948,6 +2156,7 @@ async function handleCallback(config, input) {
1948
2156
  });
1949
2157
  const json = await res.json().catch(() => ({}));
1950
2158
  if (!res.ok || !json.access_token) {
2159
+ emitTiming(cfg, { phase: "callback", durationMs: Date.now() - t0, ok: false, code: json.error || "OIDC_EXCHANGE_FAILED" });
1951
2160
  return {
1952
2161
  status: res.status || 502,
1953
2162
  body: {
@@ -1967,6 +2176,7 @@ async function handleCallback(config, input) {
1967
2176
  if (json.refresh_token) {
1968
2177
  cookies.push(makeCookie(cfg, cfg.refreshCookieName, json.refresh_token, REFRESH_TOKEN_TTL_SECONDS));
1969
2178
  }
2179
+ emitTiming(cfg, { phase: "callback", durationMs: Date.now() - t0, ok: true });
1970
2180
  return {
1971
2181
  status: 200,
1972
2182
  body: { success: true, data: { authenticated: true } },
@@ -1975,8 +2185,18 @@ async function handleCallback(config, input) {
1975
2185
  }
1976
2186
  async function handleRefresh(config, input) {
1977
2187
  const cfg = resolve(config);
2188
+ const t0 = Date.now();
1978
2189
  const refreshToken = input.refreshToken;
2190
+ const idemKey = input.idempotencyToken;
2191
+ if (idemKey && await Promise.resolve(cfg.signoutRegistry.has(idemKey))) {
2192
+ return {
2193
+ status: 401,
2194
+ body: { success: false, error: { code: "SESSION_REVOKED", message: "Session was signed out" } },
2195
+ cookies: clearCookies(cfg)
2196
+ };
2197
+ }
1979
2198
  if (!refreshToken) {
2199
+ emitTiming(cfg, { phase: "refresh", durationMs: Date.now() - t0, ok: false, code: "TOKEN_INVALID" });
1980
2200
  return {
1981
2201
  status: 401,
1982
2202
  body: { success: false, error: { code: "TOKEN_INVALID", message: "Missing refresh token" } },
@@ -1992,6 +2212,7 @@ async function handleRefresh(config, input) {
1992
2212
  if (!res.ok || !json.success || !json.data?.accessToken) {
1993
2213
  const status = res.status || 401;
1994
2214
  const errorCode = json.error?.code || "TOKEN_INVALID";
2215
+ emitTiming(cfg, { phase: "refresh", durationMs: Date.now() - t0, ok: false, code: errorCode });
1995
2216
  const shouldClear = shouldClearCookiesOnFailure(
1996
2217
  cfg.clearCookiesOnRefreshFailure,
1997
2218
  status,
@@ -2015,6 +2236,7 @@ async function handleRefresh(config, input) {
2015
2236
  if (json.data.refreshToken) {
2016
2237
  cookies.push(makeCookie(cfg, cfg.refreshCookieName, json.data.refreshToken, REFRESH_TOKEN_TTL_SECONDS));
2017
2238
  }
2239
+ emitTiming(cfg, { phase: "refresh", durationMs: Date.now() - t0, ok: true });
2018
2240
  return {
2019
2241
  status: 200,
2020
2242
  body: { success: true, data: { accessToken: json.data.accessToken } },
@@ -2023,6 +2245,10 @@ async function handleRefresh(config, input) {
2023
2245
  }
2024
2246
  async function handleSignout(config, input) {
2025
2247
  const cfg = resolve(config);
2248
+ const t0 = Date.now();
2249
+ if (input.idempotencyToken) {
2250
+ await Promise.resolve(cfg.signoutRegistry.mark(input.idempotencyToken, cfg.signoutMarkerTtlMs));
2251
+ }
2026
2252
  if (input.accessToken) {
2027
2253
  try {
2028
2254
  await cfg.fetchImpl(`${cfg.issuer}${cfg.logoutPath}`, {
@@ -2044,12 +2270,52 @@ async function handleSignout(config, input) {
2044
2270
  } catch {
2045
2271
  }
2046
2272
  }
2273
+ emitTiming(cfg, { phase: "signout", durationMs: Date.now() - t0, ok: true });
2047
2274
  return {
2048
2275
  status: 200,
2049
2276
  body: { success: true, data: { signedOut: true } },
2050
2277
  cookies: clearCookies(cfg)
2051
2278
  };
2052
2279
  }
2280
+ var TOKENS_CACHE = /* @__PURE__ */ new Map();
2281
+ function getTokensFor(issuer) {
2282
+ let m = TOKENS_CACHE.get(issuer);
2283
+ if (!m) {
2284
+ m = new TokensModule(issuer);
2285
+ TOKENS_CACHE.set(issuer, m);
2286
+ }
2287
+ return m;
2288
+ }
2289
+ async function handleUserinfo(config, input) {
2290
+ const cfg = resolve(config);
2291
+ if (!input.accessToken) {
2292
+ return {
2293
+ status: 401,
2294
+ body: { success: false, error: { code: "TOKEN_INVALID", message: "Missing access token" } },
2295
+ cookies: []
2296
+ };
2297
+ }
2298
+ let claims;
2299
+ try {
2300
+ claims = await getTokensFor(cfg.issuer).verify(input.accessToken, config.verify);
2301
+ } catch (err) {
2302
+ const code = err instanceof IQAuthError ? err.code : err.code || "TOKEN_INVALID";
2303
+ const message = err instanceof Error ? err.message : "Access token verification failed";
2304
+ return {
2305
+ status: 401,
2306
+ body: { success: false, error: { code, message } },
2307
+ cookies: []
2308
+ };
2309
+ }
2310
+ const envelope = await buildUserinfoResponse(claims, {
2311
+ enrich: config.userinfoEnricher ? (c) => config.userinfoEnricher(c, input.req) : void 0
2312
+ });
2313
+ return {
2314
+ status: 200,
2315
+ body: envelope,
2316
+ cookies: []
2317
+ };
2318
+ }
2053
2319
 
2054
2320
  // src/fastify.ts
2055
2321
  var KNOWN_AUTH_ERRORS = /* @__PURE__ */ new Set([
@@ -2058,7 +2324,10 @@ var KNOWN_AUTH_ERRORS = /* @__PURE__ */ new Set([
2058
2324
  "TOKEN_REVOKED",
2059
2325
  "SESSION_EXPIRED",
2060
2326
  "SESSION_INVALID",
2061
- "AUTH_REQUIRED"
2327
+ "AUTH_REQUIRED",
2328
+ // Task #127 — typed `IQAuthErrorCode` taxonomy.
2329
+ "token_invalid",
2330
+ "token_expired"
2062
2331
  ]);
2063
2332
  function applyResponse(reply, hr) {
2064
2333
  for (const c of hr.cookies) {
@@ -2107,6 +2376,7 @@ async function iqAuth(fastify, options) {
2107
2376
  const mountHelpers = options.mountHelperRoutes !== false;
2108
2377
  const isPublic = (p) => {
2109
2378
  if (mountHelpers && p.startsWith(mount + "/")) return true;
2379
+ if (options.mountUserinfo && p === `${mount}/me`) return true;
2110
2380
  if (Array.isArray(options.publicPaths)) return options.publicPaths.includes(p);
2111
2381
  if (typeof options.publicPaths === "function") return options.publicPaths(p);
2112
2382
  return false;
@@ -2145,13 +2415,23 @@ async function iqAuth(fastify, options) {
2145
2415
  fastify.post(`${mount}/refresh`, async (req, reply) => {
2146
2416
  const body = req.body || {};
2147
2417
  const refreshToken = body.refreshToken || readCookie(req, refreshCookie);
2148
- applyResponse(reply, await handleRefresh(helperConfig, { refreshToken }));
2418
+ const idempotencyToken = req.headers?.["x-iqauth-idempotency"] || body.idempotencyToken;
2419
+ applyResponse(reply, await handleRefresh(helperConfig, { refreshToken, idempotencyToken }));
2149
2420
  });
2150
2421
  fastify.post(`${mount}/signout`, async (req, reply) => {
2151
2422
  const auth = req.headers?.authorization;
2152
2423
  const accessToken = (typeof auth === "string" ? auth.replace(/^Bearer /i, "") : void 0) || readCookie(req, accessCookie);
2424
+ const refreshToken = readCookie(req, refreshCookie);
2153
2425
  const ssoCookieHeader = typeof req.headers?.cookie === "string" ? req.headers.cookie : void 0;
2154
- applyResponse(reply, await handleSignout(helperConfig, { accessToken, ssoCookieHeader }));
2426
+ const idempotencyToken = req.headers?.["x-iqauth-idempotency"];
2427
+ applyResponse(reply, await handleSignout(helperConfig, { accessToken, refreshToken, idempotencyToken, ssoCookieHeader }));
2428
+ });
2429
+ }
2430
+ if (options.mountUserinfo) {
2431
+ fastify.get(`${mount}/me`, async (req, reply) => {
2432
+ const auth = req.headers?.authorization;
2433
+ const accessToken = (typeof auth === "string" ? auth.replace(/^Bearer /i, "") : void 0) || readCookie(req, accessCookie);
2434
+ applyResponse(reply, await handleUserinfo(helperConfig, { accessToken, req }));
2155
2435
  });
2156
2436
  }
2157
2437
  fastify.decorate("iqauth", { client, issuer });