@feelflow/ffid-sdk 5.18.0 → 5.21.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.
@@ -260,10 +260,12 @@ var OAUTH_INTROSPECT_ENDPOINT = "/api/v1/oauth/introspect";
260
260
  var CACHE_KEY_PREFIX = "ffid:introspect:";
261
261
  var HEX_BASE = 16;
262
262
  var HEX_BYTE_WIDTH = 2;
263
- var TOKEN_LOG_PREFIX_LENGTH = 8;
264
263
  function createVerifyAccessToken(deps) {
265
264
  const { authMode, baseUrl, serviceCode, serviceApiKey, verifyStrategy, logger, createError, errorCodes, cache, timeout } = deps;
266
265
  let jwtVerify2 = null;
266
+ let cacheReadFailureEscalated = false;
267
+ let cacheWriteFailureEscalated = false;
268
+ let missingSubtleCryptoLogged = false;
267
269
  function getJwtVerifier() {
268
270
  if (!jwtVerify2) {
269
271
  jwtVerify2 = createJwtVerifier({
@@ -323,8 +325,9 @@ function createVerifyAccessToken(deps) {
323
325
  }
324
326
  const url = `${baseUrl}${OAUTH_INTROSPECT_ENDPOINT}`;
325
327
  logger.debug("Verifying access token:", url);
326
- const cacheKey = cache ? await buildCacheKey(accessToken, cacheKeySuffix) : "";
327
- if (cache) {
328
+ const cacheKey = cache ? await buildCacheKey(accessToken, cacheKeySuffix) : null;
329
+ let verificationCacheDegraded = cache !== void 0 && cacheKey === null;
330
+ if (cache && cacheKey !== null) {
328
331
  try {
329
332
  const cached = await cache.adapter.get(cacheKey);
330
333
  if (cached && typeof cached === "object" && "sub" in cached) {
@@ -332,7 +335,16 @@ function createVerifyAccessToken(deps) {
332
335
  return { data: cached };
333
336
  }
334
337
  } catch (cacheError) {
335
- logger.warn("Cache read failed, falling back to API call:", cacheError);
338
+ verificationCacheDegraded = true;
339
+ if (!cacheReadFailureEscalated) {
340
+ cacheReadFailureEscalated = true;
341
+ logger.error(
342
+ "Cache read failed \u2014 falling back to API call. Redis \u7B49\u306E cache adapter \u969C\u5BB3\u6642\u306F\u5168 verification \u304C introspect \u306B degrade \u3057\u307E\u3059\uFF08\u3053\u306E error \u306F instance \u3054\u3068\u306B\u521D\u56DE\u306E\u307F\u3001\u4EE5\u964D\u306F warn\uFF09:",
343
+ cacheError
344
+ );
345
+ } else {
346
+ logger.warn("Cache read failed, falling back to API call:", cacheError);
347
+ }
336
348
  }
337
349
  }
338
350
  const fetchOptions = {
@@ -427,31 +439,40 @@ function createVerifyAccessToken(deps) {
427
439
  }
428
440
  } : base;
429
441
  const userinfo = normalizeUserinfo(raw);
430
- if (cache) {
442
+ if (cache && cacheKey !== null) {
431
443
  try {
432
444
  await cache.adapter.set(cacheKey, userinfo, cache.ttl);
433
445
  } catch (cacheError) {
434
- logger.warn("Cache write failed (result still returned):", cacheError);
446
+ verificationCacheDegraded = true;
447
+ if (!cacheWriteFailureEscalated) {
448
+ cacheWriteFailureEscalated = true;
449
+ logger.error(
450
+ "Cache write failed \u2014 result still returned. cache adapter \u969C\u5BB3\u6642\u306F introspect \u7D50\u679C\u304C\u30AD\u30E3\u30C3\u30B7\u30E5\u3055\u308C\u305A\u5168 verification \u304C API \u547C\u3073\u51FA\u3057\u306B degrade \u3057\u307E\u3059\uFF08\u3053\u306E error \u306F instance \u3054\u3068\u306B\u521D\u56DE\u306E\u307F\u3001\u4EE5\u964D\u306F warn\uFF09:",
451
+ cacheError
452
+ );
453
+ } else {
454
+ logger.warn("Cache write failed (result still returned):", cacheError);
455
+ }
435
456
  }
436
457
  }
437
- return { data: userinfo };
458
+ return verificationCacheDegraded ? { data: userinfo, meta: { verificationCacheDegraded: true } } : { data: userinfo };
438
459
  }
439
460
  async function buildCacheKey(token, suffix) {
440
- let key;
441
- if (typeof globalThis.crypto?.subtle?.digest === "function") {
442
- const encoder = new TextEncoder();
443
- const data = encoder.encode(token);
444
- const hashBuffer = await crypto.subtle.digest("SHA-256", data);
445
- const hashArray = Array.from(new Uint8Array(hashBuffer));
446
- const hashHex = hashArray.map((b) => b.toString(HEX_BASE).padStart(HEX_BYTE_WIDTH, "0")).join("");
447
- key = CACHE_KEY_PREFIX + hashHex;
448
- } else {
449
- const tokenPrefix = token.length > TOKEN_LOG_PREFIX_LENGTH ? token.substring(0, TOKEN_LOG_PREFIX_LENGTH) + "..." : "***";
450
- logger.warn(
451
- `crypto.subtle \u304C\u5229\u7528\u3067\u304D\u306A\u3044\u305F\u3081\u3001\u30AD\u30E3\u30C3\u30B7\u30E5\u30AD\u30FC\u306B\u30CF\u30C3\u30B7\u30E5\u5316\u3055\u308C\u3066\u3044\u306A\u3044\u30C8\u30FC\u30AF\u30F3\u3092\u4F7F\u7528\u3057\u307E\u3059 (token prefix: ${tokenPrefix})`
452
- );
453
- key = CACHE_KEY_PREFIX + token;
454
- }
461
+ if (typeof globalThis.crypto?.subtle?.digest !== "function") {
462
+ if (!missingSubtleCryptoLogged) {
463
+ missingSubtleCryptoLogged = true;
464
+ logger.error(
465
+ "crypto.subtle \u304C\u5229\u7528\u3067\u304D\u306A\u3044\u305F\u3081 introspect \u7D50\u679C\u306E\u30AD\u30E3\u30C3\u30B7\u30E5\u3092\u7121\u52B9\u5316\u3057\u307E\u3057\u305F\uFF08\u30CF\u30C3\u30B7\u30E5\u5316\u3055\u308C\u3066\u3044\u306A\u3044\u30C8\u30FC\u30AF\u30F3\u3092 cache key \u306B\u4F7F\u308F\u306A\u3044\u305F\u3081\u306E\u63AA\u7F6E #4119\uFF09\u3002\u5168 verification \u304C API \u547C\u3073\u51FA\u3057\u306B\u306A\u308A\u307E\u3059"
466
+ );
467
+ }
468
+ return null;
469
+ }
470
+ const encoder = new TextEncoder();
471
+ const data = encoder.encode(token);
472
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
473
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
474
+ const hashHex = hashArray.map((b) => b.toString(HEX_BASE).padStart(HEX_BYTE_WIDTH, "0")).join("");
475
+ const key = CACHE_KEY_PREFIX + hashHex;
455
476
  return suffix ? `${key}:${suffix}` : key;
456
477
  }
457
478
  return verifyAccessToken;
@@ -869,6 +890,93 @@ function createMembersMethods(deps) {
869
890
  return { listMembers, addMember, updateMemberRole, removeMember };
870
891
  }
871
892
 
893
+ // src/client/provisioning-methods.ts
894
+ var USER_PROVISION_ENDPOINT = "/api/v1/ext/users/provision";
895
+ var ORGANIZATION_PROVISION_ENDPOINT = "/api/v1/ext/organizations/provision";
896
+ var MAX_PROVISION_MEMBERS = 100;
897
+ var ASSIGNABLE_MEMBER_ROLES = {
898
+ admin: true,
899
+ member: true,
900
+ viewer: true
901
+ };
902
+ function isAssignableMemberRole(role) {
903
+ return Object.hasOwn(ASSIGNABLE_MEMBER_ROLES, role);
904
+ }
905
+ function createProvisioningMethods(deps) {
906
+ const { fetchWithAuth, createError, authMode } = deps;
907
+ function serviceKeyModeError() {
908
+ if (authMode === "service-key") return null;
909
+ return createError(
910
+ "VALIDATION_ERROR",
911
+ "provisionUser / provisionOrganization \u306F service-key \u8A8D\u8A3C\uFF08X-Service-Api-Key\uFF09\u3067\u306E\u307F\u5229\u7528\u3067\u304D\u307E\u3059\u3002Bearer / cookie \u30E2\u30FC\u30C9\u3067\u306F\u547C\u3073\u51FA\u305B\u307E\u305B\u3093"
912
+ );
913
+ }
914
+ async function provisionUser(params) {
915
+ const modeError = serviceKeyModeError();
916
+ if (modeError) return { error: modeError };
917
+ if (!params.email || !params.email.trim()) {
918
+ return { error: createError("VALIDATION_ERROR", "email \u306F\u5FC5\u9808\u3067\u3059") };
919
+ }
920
+ const body = { email: params.email.trim() };
921
+ if (params.profile !== void 0) body.profile = params.profile;
922
+ if (params.dryRun !== void 0) body.dryRun = params.dryRun;
923
+ return fetchWithAuth(USER_PROVISION_ENDPOINT, {
924
+ method: "POST",
925
+ body: JSON.stringify(body)
926
+ });
927
+ }
928
+ async function provisionOrganization(params) {
929
+ const modeError = serviceKeyModeError();
930
+ if (modeError) return { error: modeError };
931
+ if (!params.name || !params.name.trim()) {
932
+ return { error: createError("VALIDATION_ERROR", "name \u306F\u5FC5\u9808\u3067\u3059") };
933
+ }
934
+ if (!params.ownerEmail || !params.ownerEmail.trim()) {
935
+ return { error: createError("VALIDATION_ERROR", "ownerEmail \u306F\u5FC5\u9808\u3067\u3059") };
936
+ }
937
+ if (params.members) {
938
+ if (params.members.length > MAX_PROVISION_MEMBERS) {
939
+ return {
940
+ error: createError(
941
+ "VALIDATION_ERROR",
942
+ `members \u306F1\u30EA\u30AF\u30A8\u30B9\u30C8\u3042\u305F\u308A\u6700\u5927 ${MAX_PROVISION_MEMBERS} \u4EF6\u307E\u3067\u3067\u3059`
943
+ )
944
+ };
945
+ }
946
+ for (const member of params.members) {
947
+ if (!member.email || !member.email.trim()) {
948
+ return { error: createError("VALIDATION_ERROR", "members[].email \u306F\u5FC5\u9808\u3067\u3059") };
949
+ }
950
+ if (!isAssignableMemberRole(member.role)) {
951
+ return {
952
+ error: createError(
953
+ "VALIDATION_ERROR",
954
+ "members[].role \u306F admin\u3001member\u3001viewer \u306E\u3044\u305A\u308C\u304B\u3092\u6307\u5B9A\u3057\u3066\u304F\u3060\u3055\u3044"
955
+ )
956
+ };
957
+ }
958
+ }
959
+ }
960
+ const body = {
961
+ name: params.name.trim(),
962
+ ownerEmail: params.ownerEmail.trim()
963
+ };
964
+ if (params.billingEmail !== void 0) body.billingEmail = params.billingEmail.trim();
965
+ if (params.members !== void 0) {
966
+ body.members = params.members.map((member) => ({
967
+ email: member.email.trim(),
968
+ role: member.role
969
+ }));
970
+ }
971
+ if (params.dryRun !== void 0) body.dryRun = params.dryRun;
972
+ return fetchWithAuth(ORGANIZATION_PROVISION_ENDPOINT, {
973
+ method: "POST",
974
+ body: JSON.stringify(body)
975
+ });
976
+ }
977
+ return { provisionUser, provisionOrganization };
978
+ }
979
+
872
980
  // src/client/seat-methods.ts
873
981
  function seatsEndpoint(subscriptionId) {
874
982
  return `/api/v1/subscriptions/ext/${encodeURIComponent(subscriptionId)}/seats/assignments`;
@@ -1113,7 +1221,7 @@ function createNonContractMethods(deps) {
1113
1221
  }
1114
1222
 
1115
1223
  // src/client/version-check.ts
1116
- var SDK_VERSION = "5.18.0";
1224
+ var SDK_VERSION = "5.21.0";
1117
1225
  var SDK_USER_AGENT = `FFID-SDK/${SDK_VERSION} (TypeScript)`;
1118
1226
  var SDK_VERSION_HEADER = "X-FFID-SDK-Version";
1119
1227
  function sdkHeaders() {
@@ -1355,33 +1463,48 @@ function createOAuthTokenMethods(deps) {
1355
1463
  async function signOutToken() {
1356
1464
  const tokens = tokenStore.getTokens();
1357
1465
  tokenStore.clearTokens();
1466
+ const allRevoked = {
1467
+ revoked: true,
1468
+ accessTokenRevoked: true,
1469
+ refreshTokenRevoked: true
1470
+ };
1358
1471
  if (!tokens) {
1359
1472
  logger.debug("No tokens to revoke");
1360
- return { data: void 0 };
1473
+ return { data: allRevoked };
1361
1474
  }
1362
- const url = `${baseUrl}${OAUTH_REVOKE_ENDPOINT}`;
1363
- logger.debug("Revoking token:", url);
1364
- try {
1365
- const response = await fetch(url, {
1366
- method: "POST",
1367
- credentials: "omit",
1368
- headers: { "Content-Type": "application/x-www-form-urlencoded", ...sdkHeaders() },
1369
- body: new URLSearchParams({
1370
- token: tokens.accessToken,
1371
- client_id: clientId
1372
- }).toString()
1373
- });
1374
- if (!response.ok) {
1375
- logger.warn(
1376
- "\u30C8\u30FC\u30AF\u30F3\u7121\u52B9\u5316\u30EA\u30AF\u30A8\u30B9\u30C8\u304C\u30B5\u30FC\u30D0\u30FC\u3067\u5931\u6557\u3057\u307E\u3057\u305F:",
1377
- `status=${response.status}`
1378
- );
1475
+ async function revokeOAuthToken(token, tokenTypeHint) {
1476
+ const url = `${baseUrl}${OAUTH_REVOKE_ENDPOINT}`;
1477
+ try {
1478
+ const response = await fetch(url, {
1479
+ method: "POST",
1480
+ credentials: "omit",
1481
+ headers: { "Content-Type": "application/x-www-form-urlencoded", ...sdkHeaders() },
1482
+ body: new URLSearchParams({
1483
+ token,
1484
+ client_id: clientId,
1485
+ token_type_hint: tokenTypeHint
1486
+ }).toString()
1487
+ });
1488
+ return response.ok;
1489
+ } catch {
1490
+ return false;
1379
1491
  }
1380
- } catch (error) {
1381
- logger.warn("\u30C8\u30FC\u30AF\u30F3\u7121\u52B9\u5316\u306E\u30CD\u30C3\u30C8\u30EF\u30FC\u30AF\u30EA\u30AF\u30A8\u30B9\u30C8\u306B\u5931\u6557\u3057\u307E\u3057\u305F:", error);
1492
+ }
1493
+ logger.debug("Revoking tokens:", `${baseUrl}${OAUTH_REVOKE_ENDPOINT}`);
1494
+ const accessTokenRevoked = await revokeOAuthToken(tokens.accessToken, "access_token");
1495
+ if (!accessTokenRevoked) {
1496
+ logger.warn("\u30A2\u30AF\u30BB\u30B9\u30C8\u30FC\u30AF\u30F3\u306E\u7121\u52B9\u5316\u30EA\u30AF\u30A8\u30B9\u30C8\u304C\u30B5\u30FC\u30D0\u30FC\u3067\u5931\u6557\u3057\u307E\u3057\u305F");
1497
+ }
1498
+ const refreshTokenRevoked = tokens.refreshToken ? await revokeOAuthToken(tokens.refreshToken, "refresh_token") : true;
1499
+ if (tokens.refreshToken && !refreshTokenRevoked) {
1500
+ logger.warn("\u30EA\u30D5\u30EC\u30C3\u30B7\u30E5\u30C8\u30FC\u30AF\u30F3\u306E\u7121\u52B9\u5316\u30EA\u30AF\u30A8\u30B9\u30C8\u304C\u30B5\u30FC\u30D0\u30FC\u3067\u5931\u6557\u3057\u307E\u3057\u305F");
1501
+ }
1502
+ const revoked = accessTokenRevoked && refreshTokenRevoked;
1503
+ if (!revoked) {
1504
+ logger.warn("\u30C8\u30FC\u30AF\u30F3\u7121\u52B9\u5316\u304C\u4E00\u90E8\u307E\u305F\u306F\u5168\u90E8\u5931\u6557\u3057\u307E\u3057\u305F\uFF08\u30ED\u30FC\u30AB\u30EB\u30C8\u30FC\u30AF\u30F3\u306F\u30AF\u30EA\u30A2\u6E08\u307F\uFF09");
1382
1505
  }
1383
1506
  logger.debug("Token sign-out completed");
1384
- return { data: void 0 };
1507
+ return { data: { revoked, accessTokenRevoked, refreshTokenRevoked } };
1385
1508
  }
1386
1509
  return { exchangeCodeForTokens, refreshAccessToken, signOutToken };
1387
1510
  }
@@ -1545,7 +1668,7 @@ function createSessionMethods(deps) {
1545
1668
  }
1546
1669
  if (response.status === NO_CONTENT_STATUS) {
1547
1670
  logger.debug("Response: 204 No Content");
1548
- return { data: void 0 };
1671
+ return { data: { revoked: true } };
1549
1672
  }
1550
1673
  if (!response.ok) {
1551
1674
  try {
@@ -1572,7 +1695,7 @@ function createSessionMethods(deps) {
1572
1695
  }
1573
1696
  }
1574
1697
  logger.debug("Response:", response.status);
1575
- return { data: void 0 };
1698
+ return { data: { revoked: true } };
1576
1699
  }
1577
1700
  return { getSession, signOutCookie };
1578
1701
  }
@@ -1695,6 +1818,9 @@ var REDIRECT_LOOP_WINDOW_MS = 6e4;
1695
1818
  var REDIRECT_LOOP_THRESHOLD = 3;
1696
1819
  var storageReadFailureLogged = false;
1697
1820
  var storageWriteFailureLogged = false;
1821
+ function isLoopDetectionDegraded() {
1822
+ return storageReadFailureLogged || storageWriteFailureLogged;
1823
+ }
1698
1824
  function logStorageReadFailure(logger, err) {
1699
1825
  if (storageReadFailureLogged) return;
1700
1826
  storageReadFailureLogged = true;
@@ -1839,9 +1965,16 @@ function createRedirectMethods(deps) {
1839
1965
  }
1840
1966
  const state = generateRandomState();
1841
1967
  if (!storeState(state, logger)) {
1968
+ cleanupVerifierStorage(logger);
1969
+ cleanupStateStorage(logger);
1842
1970
  logger.error(
1843
- "redirectToAuthorize: OAuth state \u3092\u4FDD\u5B58\u3067\u304D\u307E\u305B\u3093\u3067\u3057\u305F\uFF08storage \u5229\u7528\u4E0D\u53EF\uFF09\u3002\u30B3\u30FC\u30EB\u30D0\u30C3\u30AF\u3067\u306E CSRF \u691C\u8A3C\u304C\u5931\u6557\u3057\u307E\u3059"
1971
+ "redirectToAuthorize: OAuth state \u3092\u4FDD\u5B58\u3067\u304D\u307E\u305B\u3093\u3067\u3057\u305F\uFF08storage \u5229\u7528\u4E0D\u53EF\uFF09\u3002\u8A8D\u53EF\u30EA\u30C0\u30A4\u30EC\u30AF\u30C8\u3092\u4E2D\u6B62\u3057\u307E\u3057\u305F"
1844
1972
  );
1973
+ return {
1974
+ success: false,
1975
+ code: "state_persistence_failed",
1976
+ error: "OAuth state \u3092\u4FDD\u5B58\u3067\u304D\u307E\u305B\u3093\u3067\u3057\u305F\uFF08storage \u5229\u7528\u4E0D\u53EF\uFF09\u3002\u8A8D\u53EF\u30EA\u30C0\u30A4\u30EC\u30AF\u30C8\u3092\u4E2D\u6B62\u3057\u307E\u3057\u305F"
1977
+ };
1845
1978
  }
1846
1979
  const redirectUri = resolvedRedirectUri ?? window.location.origin + window.location.pathname;
1847
1980
  const params = new URLSearchParams({
@@ -1876,7 +2009,7 @@ function createRedirectMethods(deps) {
1876
2009
  rollbackLastRedirectAttempt(authorizeKey, logger);
1877
2010
  throw err;
1878
2011
  }
1879
- return { success: true };
2012
+ return isLoopDetectionDegraded() ? { success: true, loopDetectionDisabled: true } : { success: true };
1880
2013
  }
1881
2014
  async function redirectToLogin(options) {
1882
2015
  if (typeof window === "undefined") {
@@ -2954,6 +3087,11 @@ function createFFIDClient(config) {
2954
3087
  createError,
2955
3088
  serviceCode: config.serviceCode
2956
3089
  });
3090
+ const { provisionUser, provisionOrganization } = createProvisioningMethods({
3091
+ fetchWithAuth,
3092
+ createError,
3093
+ authMode
3094
+ });
2957
3095
  const { getProfile, updateProfile } = createProfileMethods({
2958
3096
  fetchWithAuth,
2959
3097
  createError
@@ -3049,6 +3187,8 @@ function createFFIDClient(config) {
3049
3187
  addMember,
3050
3188
  updateMemberRole,
3051
3189
  removeMember,
3190
+ provisionUser,
3191
+ provisionOrganization,
3052
3192
  getProfile,
3053
3193
  updateProfile,
3054
3194
  // Non-contract ext API coverage (#3783)
@@ -1,4 +1,4 @@
1
- import { k as FFIDClient, e as FFIDOAuthUserInfo } from '../../ffid-client-Cwk6cG3b.cjs';
1
+ import { k as FFIDClient, e as FFIDOAuthUserInfo } from '../../ffid-client-BQPhm-XL.cjs';
2
2
  import '../../types-BeVl-z12.cjs';
3
3
 
4
4
  /**
@@ -1,4 +1,4 @@
1
- import { k as FFIDClient, e as FFIDOAuthUserInfo } from '../../ffid-client-w8Twi5lD.js';
1
+ import { k as FFIDClient, e as FFIDOAuthUserInfo } from '../../ffid-client-Bx8y4SwC.js';
2
2
  import '../../types-BeVl-z12.js';
3
3
 
4
4
  /**
@@ -89,7 +89,6 @@ function verifyWebhookSignature(rawBody, signatureHeader, secret, toleranceSecon
89
89
  }
90
90
 
91
91
  // src/webhooks/handler.ts
92
- var SDK_LOG_PREFIX = "[FFID Webhook SDK]";
93
92
  var HTTP_OK = 200;
94
93
  var HTTP_BAD_REQUEST = 400;
95
94
  var HTTP_INTERNAL_SERVER_ERROR = 500;
@@ -100,7 +99,8 @@ var noopLogger = {
100
99
  },
101
100
  warn: () => {
102
101
  },
103
- error: (...args) => console.error(SDK_LOG_PREFIX, ...args)
102
+ error: () => {
103
+ }
104
104
  };
105
105
  function createFFIDWebhookHandler(config) {
106
106
  if (!config.secret) {
@@ -87,7 +87,6 @@ function verifyWebhookSignature(rawBody, signatureHeader, secret, toleranceSecon
87
87
  }
88
88
 
89
89
  // src/webhooks/handler.ts
90
- var SDK_LOG_PREFIX = "[FFID Webhook SDK]";
91
90
  var HTTP_OK = 200;
92
91
  var HTTP_BAD_REQUEST = 400;
93
92
  var HTTP_INTERNAL_SERVER_ERROR = 500;
@@ -98,7 +97,8 @@ var noopLogger = {
98
97
  },
99
98
  warn: () => {
100
99
  },
101
- error: (...args) => console.error(SDK_LOG_PREFIX, ...args)
100
+ error: () => {
101
+ }
102
102
  };
103
103
  function createFFIDWebhookHandler(config) {
104
104
  if (!config.secret) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feelflow/ffid-sdk",
3
- "version": "5.18.0",
3
+ "version": "5.21.0",
4
4
  "description": "FeelFlow ID Platform SDK for React/Next.js applications",
5
5
  "keywords": [
6
6
  "feelflow",