@feelflow/ffid-sdk 2.18.0 → 2.20.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.
@@ -802,7 +802,7 @@ function createProfileMethods(deps) {
802
802
  }
803
803
 
804
804
  // src/client/version-check.ts
805
- var SDK_VERSION = "2.18.0";
805
+ var SDK_VERSION = "2.20.0";
806
806
  var SDK_USER_AGENT = `FFID-SDK/${SDK_VERSION} (TypeScript)`;
807
807
  var SDK_VERSION_HEADER = "X-FFID-SDK-Version";
808
808
  function sdkHeaders() {
@@ -0,0 +1,105 @@
1
+ 'use strict';
2
+
3
+ // src/server/test-client.ts
4
+ var TEST_CLIENT_BRAND = /* @__PURE__ */ Symbol("@feelflow/ffid-sdk/test-client");
5
+ var TEST_CLIENT_ALLOW_IN_PRODUCTION_ACK = "I-acknowledge-this-bypasses-real-auth";
6
+ function isTestFFIDClient(client) {
7
+ return typeof client === "object" && client !== null && TEST_CLIENT_BRAND in client && client[TEST_CLIENT_BRAND] === true;
8
+ }
9
+ var ERROR_CODE_TOKEN_VERIFICATION = "TOKEN_VERIFICATION_ERROR";
10
+ var PRODUCTION_REFUSAL_HINT = "If this is intentional (staging that mirrors production env), pass `allowInProduction: TEST_CLIENT_ALLOW_IN_PRODUCTION_ACK` and ensure the bypass tokens are not reachable from customer traffic.";
11
+ function readNormalizedNodeEnv() {
12
+ if (typeof process === "undefined" || typeof process.env === "undefined") {
13
+ return void 0;
14
+ }
15
+ const raw = process.env.NODE_ENV;
16
+ return typeof raw === "string" ? raw.trim().toLowerCase() : void 0;
17
+ }
18
+ function emitProductionAcknowledgmentWarning() {
19
+ const msg = "[FFID] createTestFFIDClient: allowInProduction is set with NODE_ENV=production. Bypass tokens authenticate real requests in this process. Confirm this is staging.";
20
+ if (typeof process !== "undefined" && typeof process.emitWarning === "function") {
21
+ process.emitWarning(msg, "FFIDTestModeInProduction");
22
+ } else {
23
+ console.warn(msg);
24
+ }
25
+ }
26
+ function assertSafeEnvironment(acknowledged) {
27
+ const hasProcessEnv = typeof process !== "undefined" && typeof process.env !== "undefined";
28
+ const nodeEnv = readNormalizedNodeEnv();
29
+ const isProduction = nodeEnv === "production";
30
+ if (acknowledged) {
31
+ if (isProduction) emitProductionAcknowledgmentWarning();
32
+ return;
33
+ }
34
+ if (!hasProcessEnv) {
35
+ throw new Error(
36
+ "createTestFFIDClient: refused to start because the runtime does not expose process.env (likely an Edge / Cloudflare Workers / browser runtime). NODE_ENV cannot be verified here. " + PRODUCTION_REFUSAL_HINT
37
+ );
38
+ }
39
+ if (isProduction) {
40
+ throw new Error(
41
+ "createTestFFIDClient: refused to start because NODE_ENV=production. " + PRODUCTION_REFUSAL_HINT
42
+ );
43
+ }
44
+ }
45
+ function assertValidConfig(config) {
46
+ if (!config.users || config.users.length === 0) {
47
+ throw new Error("createTestFFIDClient: `users` must contain at least one entry");
48
+ }
49
+ const seenTokens = /* @__PURE__ */ new Set();
50
+ for (const user of config.users) {
51
+ if (!user.bypassToken || !user.bypassToken.trim()) {
52
+ throw new Error("createTestFFIDClient: every user requires a non-empty bypassToken");
53
+ }
54
+ if (!user.userInfo || !user.userInfo.sub) {
55
+ throw new Error("createTestFFIDClient: every user requires userInfo.sub");
56
+ }
57
+ if (seenTokens.has(user.bypassToken)) {
58
+ throw new Error(
59
+ "createTestFFIDClient: duplicate bypassToken detected (each token must map to exactly one user)"
60
+ );
61
+ }
62
+ seenTokens.add(user.bypassToken);
63
+ }
64
+ }
65
+ function createTestVerifyAccessToken(config) {
66
+ const acknowledged = config.allowInProduction === TEST_CLIENT_ALLOW_IN_PRODUCTION_ACK;
67
+ assertSafeEnvironment(acknowledged);
68
+ assertValidConfig(config);
69
+ const lookup = /* @__PURE__ */ new Map();
70
+ for (const user of config.users) {
71
+ lookup.set(user.bypassToken, { ...user.userInfo });
72
+ }
73
+ return async function verifyAccessToken(accessToken) {
74
+ if (!accessToken || !accessToken.trim()) {
75
+ return {
76
+ error: {
77
+ code: ERROR_CODE_TOKEN_VERIFICATION,
78
+ message: "\u30A2\u30AF\u30BB\u30B9\u30C8\u30FC\u30AF\u30F3\u304C\u6307\u5B9A\u3055\u308C\u3066\u3044\u307E\u305B\u3093"
79
+ }
80
+ };
81
+ }
82
+ const matched = lookup.get(accessToken);
83
+ if (!matched) {
84
+ return {
85
+ error: {
86
+ code: ERROR_CODE_TOKEN_VERIFICATION,
87
+ message: "Test mode: bearer token did not match any registered bypass token"
88
+ }
89
+ };
90
+ }
91
+ return { data: { ...matched } };
92
+ };
93
+ }
94
+ function createTestFFIDClient(config) {
95
+ const verifyAccessToken = createTestVerifyAccessToken(config);
96
+ return {
97
+ verifyAccessToken,
98
+ [TEST_CLIENT_BRAND]: true
99
+ };
100
+ }
101
+
102
+ exports.TEST_CLIENT_ALLOW_IN_PRODUCTION_ACK = TEST_CLIENT_ALLOW_IN_PRODUCTION_ACK;
103
+ exports.createTestFFIDClient = createTestFFIDClient;
104
+ exports.createTestVerifyAccessToken = createTestVerifyAccessToken;
105
+ exports.isTestFFIDClient = isTestFFIDClient;
@@ -0,0 +1,125 @@
1
+ import { g as FFIDClient, e as FFIDOAuthUserInfo } from '../../ffid-client-Cjm_TKUc.cjs';
2
+
3
+ /**
4
+ * FFID SDK - Test mode client (E2E / integration test bypass)
5
+ *
6
+ * Provides a synthetic `verifyAccessToken` implementation that returns
7
+ * predetermined user info for known bypass tokens, **without ever calling
8
+ * the real FFID introspect endpoint**. Intended for consumer-side E2E
9
+ * tests where exercising the full OAuth flow is impractical (e.g.,
10
+ * automated browser tests against a staging environment).
11
+ *
12
+ * ⚠️ SECURITY: Construction is **refused** when `NODE_ENV` resolves to
13
+ * `production` (after trim + case-fold) or when the runtime does not
14
+ * expose `process.env` at all (e.g., Edge / Cloudflare Workers). The
15
+ * single escape hatch is the `allowInProduction` literal acknowledgment
16
+ * string. This is a defense-in-depth measure — never enable in
17
+ * customer-facing production.
18
+ *
19
+ * Recommended import path (sub-path keeps this out of production bundles):
20
+ *
21
+ * import { createTestFFIDClient } from '@feelflow/ffid-sdk/server/test'
22
+ */
23
+
24
+ /**
25
+ * Module-private symbol used to brand `TestFFIDClient` instances. Because
26
+ * the symbol is created here (not via `Symbol.for`), it cannot be obtained
27
+ * outside this module — which makes the brand unforgeable from foreign
28
+ * code. The brand still exists only for runtime narrowing convenience and
29
+ * is **not a security boundary**: enforcement of test-only behavior comes
30
+ * from the production guard, not from this brand.
31
+ */
32
+ declare const TEST_CLIENT_BRAND: unique symbol;
33
+ /**
34
+ * Acknowledgment string a caller must pass to `allowInProduction` when
35
+ * intentionally constructing the test client in a `NODE_ENV=production`
36
+ * runtime (typical for staging that mirrors production env). The literal
37
+ * value is grep-able so audits can find every legitimate use.
38
+ */
39
+ declare const TEST_CLIENT_ALLOW_IN_PRODUCTION_ACK: "I-acknowledge-this-bypasses-real-auth";
40
+ type AllowInProductionAck = typeof TEST_CLIENT_ALLOW_IN_PRODUCTION_ACK;
41
+ /**
42
+ * A synthetic user that the test client recognises.
43
+ *
44
+ * `bypassToken` is the Bearer token a test caller sends in `Authorization`;
45
+ * the remaining fields populate the `FFIDOAuthUserInfo` returned by
46
+ * `verifyAccessToken`. Use distinct, non-trivial bypass tokens (≥ 32 chars
47
+ * recommended) — they grant full session impersonation when leaked.
48
+ */
49
+ interface TestFFIDUser {
50
+ /** Bearer token value that resolves to this user */
51
+ bypassToken: string;
52
+ /** Synthetic FFID OAuth userinfo returned for matching bypass tokens */
53
+ userInfo: FFIDOAuthUserInfo;
54
+ }
55
+ /**
56
+ * Configuration accepted by `createTestFFIDClient` / `createTestVerifyAccessToken`.
57
+ */
58
+ interface TestFFIDClientConfig {
59
+ /**
60
+ * Synthetic users keyed by `bypassToken`. Must contain at least one
61
+ * entry — the type encodes the non-empty rule via tuple syntax.
62
+ */
63
+ users: readonly [TestFFIDUser, ...TestFFIDUser[]];
64
+ /**
65
+ * Escape hatch to allow test-mode usage when `NODE_ENV` resolves to
66
+ * `production` (or when the runtime does not expose `process.env`).
67
+ *
68
+ * Must be set to the exact literal {@link TEST_CLIENT_ALLOW_IN_PRODUCTION_ACK}
69
+ * — a `boolean` is intentionally rejected at the type level so a stray
70
+ * `allowInProduction: someFlag` cannot slip through code review. When
71
+ * honored in a production runtime, a runtime warning is emitted via
72
+ * `process.emitWarning('...', 'FFIDTestModeInProduction')`.
73
+ */
74
+ allowInProduction?: AllowInProductionAck;
75
+ }
76
+ /**
77
+ * Narrow client surface returned by `createTestFFIDClient`.
78
+ *
79
+ * Only `verifyAccessToken` is implemented — other `FFIDClient` methods
80
+ * would either need network access or have no meaningful test stub. The
81
+ * `verifyAccessToken` signature is bound to `FFIDClient['verifyAccessToken']`
82
+ * so any future production change (e.g., new options) becomes a TypeScript
83
+ * error here, surfacing drift instead of letting the test client silently
84
+ * lag the real client.
85
+ */
86
+ interface TestFFIDClient {
87
+ verifyAccessToken: FFIDClient['verifyAccessToken'];
88
+ /** Module-private symbol used by {@link isTestFFIDClient} for runtime narrowing */
89
+ readonly [TEST_CLIENT_BRAND]: true;
90
+ }
91
+ /**
92
+ * Returns true when the given client was produced by `createTestFFIDClient`.
93
+ *
94
+ * Intended as a **runtime-narrowing** helper for code holding a
95
+ * `FFIDClient | TestFFIDClient` union. The brand symbol is module-private
96
+ * and unforgeable from outside this module, but `isTestFFIDClient` is not
97
+ * a trust boundary — production code paths should rely on the construction-
98
+ * time `NODE_ENV` guard, not on this check.
99
+ */
100
+ declare function isTestFFIDClient(client: unknown): client is TestFFIDClient;
101
+ /**
102
+ * Build a `verifyAccessToken` function that bypasses the real FFID
103
+ * introspect call and returns synthetic user info for known tokens.
104
+ *
105
+ * @throws {Error} when `NODE_ENV` resolves to `production` and
106
+ * `allowInProduction` is not set, when `process.env` is unavailable
107
+ * (Edge / Workers) and `allowInProduction` is not set, or when
108
+ * `config` fails validation (empty users / blank or duplicate
109
+ * bypassToken / missing userInfo.sub).
110
+ */
111
+ declare function createTestVerifyAccessToken(config: TestFFIDClientConfig): TestFFIDClient['verifyAccessToken'];
112
+ /**
113
+ * Create a minimal FFID client suitable for E2E tests.
114
+ *
115
+ * Returns an object exposing only `verifyAccessToken`; other methods that
116
+ * would normally call the real FFID API are intentionally **not** stubbed
117
+ * to avoid silent fallbacks. Callers that need additional methods should
118
+ * mock those at the test boundary (e.g., MSW for HTTP) rather than
119
+ * extending this client.
120
+ *
121
+ * @throws {Error} same conditions as {@link createTestVerifyAccessToken}.
122
+ */
123
+ declare function createTestFFIDClient(config: TestFFIDClientConfig): TestFFIDClient;
124
+
125
+ export { TEST_CLIENT_ALLOW_IN_PRODUCTION_ACK, type TestFFIDClient, type TestFFIDClientConfig, type TestFFIDUser, createTestFFIDClient, createTestVerifyAccessToken, isTestFFIDClient };
@@ -0,0 +1,125 @@
1
+ import { g as FFIDClient, e as FFIDOAuthUserInfo } from '../../ffid-client-Cjm_TKUc.js';
2
+
3
+ /**
4
+ * FFID SDK - Test mode client (E2E / integration test bypass)
5
+ *
6
+ * Provides a synthetic `verifyAccessToken` implementation that returns
7
+ * predetermined user info for known bypass tokens, **without ever calling
8
+ * the real FFID introspect endpoint**. Intended for consumer-side E2E
9
+ * tests where exercising the full OAuth flow is impractical (e.g.,
10
+ * automated browser tests against a staging environment).
11
+ *
12
+ * ⚠️ SECURITY: Construction is **refused** when `NODE_ENV` resolves to
13
+ * `production` (after trim + case-fold) or when the runtime does not
14
+ * expose `process.env` at all (e.g., Edge / Cloudflare Workers). The
15
+ * single escape hatch is the `allowInProduction` literal acknowledgment
16
+ * string. This is a defense-in-depth measure — never enable in
17
+ * customer-facing production.
18
+ *
19
+ * Recommended import path (sub-path keeps this out of production bundles):
20
+ *
21
+ * import { createTestFFIDClient } from '@feelflow/ffid-sdk/server/test'
22
+ */
23
+
24
+ /**
25
+ * Module-private symbol used to brand `TestFFIDClient` instances. Because
26
+ * the symbol is created here (not via `Symbol.for`), it cannot be obtained
27
+ * outside this module — which makes the brand unforgeable from foreign
28
+ * code. The brand still exists only for runtime narrowing convenience and
29
+ * is **not a security boundary**: enforcement of test-only behavior comes
30
+ * from the production guard, not from this brand.
31
+ */
32
+ declare const TEST_CLIENT_BRAND: unique symbol;
33
+ /**
34
+ * Acknowledgment string a caller must pass to `allowInProduction` when
35
+ * intentionally constructing the test client in a `NODE_ENV=production`
36
+ * runtime (typical for staging that mirrors production env). The literal
37
+ * value is grep-able so audits can find every legitimate use.
38
+ */
39
+ declare const TEST_CLIENT_ALLOW_IN_PRODUCTION_ACK: "I-acknowledge-this-bypasses-real-auth";
40
+ type AllowInProductionAck = typeof TEST_CLIENT_ALLOW_IN_PRODUCTION_ACK;
41
+ /**
42
+ * A synthetic user that the test client recognises.
43
+ *
44
+ * `bypassToken` is the Bearer token a test caller sends in `Authorization`;
45
+ * the remaining fields populate the `FFIDOAuthUserInfo` returned by
46
+ * `verifyAccessToken`. Use distinct, non-trivial bypass tokens (≥ 32 chars
47
+ * recommended) — they grant full session impersonation when leaked.
48
+ */
49
+ interface TestFFIDUser {
50
+ /** Bearer token value that resolves to this user */
51
+ bypassToken: string;
52
+ /** Synthetic FFID OAuth userinfo returned for matching bypass tokens */
53
+ userInfo: FFIDOAuthUserInfo;
54
+ }
55
+ /**
56
+ * Configuration accepted by `createTestFFIDClient` / `createTestVerifyAccessToken`.
57
+ */
58
+ interface TestFFIDClientConfig {
59
+ /**
60
+ * Synthetic users keyed by `bypassToken`. Must contain at least one
61
+ * entry — the type encodes the non-empty rule via tuple syntax.
62
+ */
63
+ users: readonly [TestFFIDUser, ...TestFFIDUser[]];
64
+ /**
65
+ * Escape hatch to allow test-mode usage when `NODE_ENV` resolves to
66
+ * `production` (or when the runtime does not expose `process.env`).
67
+ *
68
+ * Must be set to the exact literal {@link TEST_CLIENT_ALLOW_IN_PRODUCTION_ACK}
69
+ * — a `boolean` is intentionally rejected at the type level so a stray
70
+ * `allowInProduction: someFlag` cannot slip through code review. When
71
+ * honored in a production runtime, a runtime warning is emitted via
72
+ * `process.emitWarning('...', 'FFIDTestModeInProduction')`.
73
+ */
74
+ allowInProduction?: AllowInProductionAck;
75
+ }
76
+ /**
77
+ * Narrow client surface returned by `createTestFFIDClient`.
78
+ *
79
+ * Only `verifyAccessToken` is implemented — other `FFIDClient` methods
80
+ * would either need network access or have no meaningful test stub. The
81
+ * `verifyAccessToken` signature is bound to `FFIDClient['verifyAccessToken']`
82
+ * so any future production change (e.g., new options) becomes a TypeScript
83
+ * error here, surfacing drift instead of letting the test client silently
84
+ * lag the real client.
85
+ */
86
+ interface TestFFIDClient {
87
+ verifyAccessToken: FFIDClient['verifyAccessToken'];
88
+ /** Module-private symbol used by {@link isTestFFIDClient} for runtime narrowing */
89
+ readonly [TEST_CLIENT_BRAND]: true;
90
+ }
91
+ /**
92
+ * Returns true when the given client was produced by `createTestFFIDClient`.
93
+ *
94
+ * Intended as a **runtime-narrowing** helper for code holding a
95
+ * `FFIDClient | TestFFIDClient` union. The brand symbol is module-private
96
+ * and unforgeable from outside this module, but `isTestFFIDClient` is not
97
+ * a trust boundary — production code paths should rely on the construction-
98
+ * time `NODE_ENV` guard, not on this check.
99
+ */
100
+ declare function isTestFFIDClient(client: unknown): client is TestFFIDClient;
101
+ /**
102
+ * Build a `verifyAccessToken` function that bypasses the real FFID
103
+ * introspect call and returns synthetic user info for known tokens.
104
+ *
105
+ * @throws {Error} when `NODE_ENV` resolves to `production` and
106
+ * `allowInProduction` is not set, when `process.env` is unavailable
107
+ * (Edge / Workers) and `allowInProduction` is not set, or when
108
+ * `config` fails validation (empty users / blank or duplicate
109
+ * bypassToken / missing userInfo.sub).
110
+ */
111
+ declare function createTestVerifyAccessToken(config: TestFFIDClientConfig): TestFFIDClient['verifyAccessToken'];
112
+ /**
113
+ * Create a minimal FFID client suitable for E2E tests.
114
+ *
115
+ * Returns an object exposing only `verifyAccessToken`; other methods that
116
+ * would normally call the real FFID API are intentionally **not** stubbed
117
+ * to avoid silent fallbacks. Callers that need additional methods should
118
+ * mock those at the test boundary (e.g., MSW for HTTP) rather than
119
+ * extending this client.
120
+ *
121
+ * @throws {Error} same conditions as {@link createTestVerifyAccessToken}.
122
+ */
123
+ declare function createTestFFIDClient(config: TestFFIDClientConfig): TestFFIDClient;
124
+
125
+ export { TEST_CLIENT_ALLOW_IN_PRODUCTION_ACK, type TestFFIDClient, type TestFFIDClientConfig, type TestFFIDUser, createTestFFIDClient, createTestVerifyAccessToken, isTestFFIDClient };
@@ -0,0 +1,100 @@
1
+ // src/server/test-client.ts
2
+ var TEST_CLIENT_BRAND = /* @__PURE__ */ Symbol("@feelflow/ffid-sdk/test-client");
3
+ var TEST_CLIENT_ALLOW_IN_PRODUCTION_ACK = "I-acknowledge-this-bypasses-real-auth";
4
+ function isTestFFIDClient(client) {
5
+ return typeof client === "object" && client !== null && TEST_CLIENT_BRAND in client && client[TEST_CLIENT_BRAND] === true;
6
+ }
7
+ var ERROR_CODE_TOKEN_VERIFICATION = "TOKEN_VERIFICATION_ERROR";
8
+ var PRODUCTION_REFUSAL_HINT = "If this is intentional (staging that mirrors production env), pass `allowInProduction: TEST_CLIENT_ALLOW_IN_PRODUCTION_ACK` and ensure the bypass tokens are not reachable from customer traffic.";
9
+ function readNormalizedNodeEnv() {
10
+ if (typeof process === "undefined" || typeof process.env === "undefined") {
11
+ return void 0;
12
+ }
13
+ const raw = process.env.NODE_ENV;
14
+ return typeof raw === "string" ? raw.trim().toLowerCase() : void 0;
15
+ }
16
+ function emitProductionAcknowledgmentWarning() {
17
+ const msg = "[FFID] createTestFFIDClient: allowInProduction is set with NODE_ENV=production. Bypass tokens authenticate real requests in this process. Confirm this is staging.";
18
+ if (typeof process !== "undefined" && typeof process.emitWarning === "function") {
19
+ process.emitWarning(msg, "FFIDTestModeInProduction");
20
+ } else {
21
+ console.warn(msg);
22
+ }
23
+ }
24
+ function assertSafeEnvironment(acknowledged) {
25
+ const hasProcessEnv = typeof process !== "undefined" && typeof process.env !== "undefined";
26
+ const nodeEnv = readNormalizedNodeEnv();
27
+ const isProduction = nodeEnv === "production";
28
+ if (acknowledged) {
29
+ if (isProduction) emitProductionAcknowledgmentWarning();
30
+ return;
31
+ }
32
+ if (!hasProcessEnv) {
33
+ throw new Error(
34
+ "createTestFFIDClient: refused to start because the runtime does not expose process.env (likely an Edge / Cloudflare Workers / browser runtime). NODE_ENV cannot be verified here. " + PRODUCTION_REFUSAL_HINT
35
+ );
36
+ }
37
+ if (isProduction) {
38
+ throw new Error(
39
+ "createTestFFIDClient: refused to start because NODE_ENV=production. " + PRODUCTION_REFUSAL_HINT
40
+ );
41
+ }
42
+ }
43
+ function assertValidConfig(config) {
44
+ if (!config.users || config.users.length === 0) {
45
+ throw new Error("createTestFFIDClient: `users` must contain at least one entry");
46
+ }
47
+ const seenTokens = /* @__PURE__ */ new Set();
48
+ for (const user of config.users) {
49
+ if (!user.bypassToken || !user.bypassToken.trim()) {
50
+ throw new Error("createTestFFIDClient: every user requires a non-empty bypassToken");
51
+ }
52
+ if (!user.userInfo || !user.userInfo.sub) {
53
+ throw new Error("createTestFFIDClient: every user requires userInfo.sub");
54
+ }
55
+ if (seenTokens.has(user.bypassToken)) {
56
+ throw new Error(
57
+ "createTestFFIDClient: duplicate bypassToken detected (each token must map to exactly one user)"
58
+ );
59
+ }
60
+ seenTokens.add(user.bypassToken);
61
+ }
62
+ }
63
+ function createTestVerifyAccessToken(config) {
64
+ const acknowledged = config.allowInProduction === TEST_CLIENT_ALLOW_IN_PRODUCTION_ACK;
65
+ assertSafeEnvironment(acknowledged);
66
+ assertValidConfig(config);
67
+ const lookup = /* @__PURE__ */ new Map();
68
+ for (const user of config.users) {
69
+ lookup.set(user.bypassToken, { ...user.userInfo });
70
+ }
71
+ return async function verifyAccessToken(accessToken) {
72
+ if (!accessToken || !accessToken.trim()) {
73
+ return {
74
+ error: {
75
+ code: ERROR_CODE_TOKEN_VERIFICATION,
76
+ message: "\u30A2\u30AF\u30BB\u30B9\u30C8\u30FC\u30AF\u30F3\u304C\u6307\u5B9A\u3055\u308C\u3066\u3044\u307E\u305B\u3093"
77
+ }
78
+ };
79
+ }
80
+ const matched = lookup.get(accessToken);
81
+ if (!matched) {
82
+ return {
83
+ error: {
84
+ code: ERROR_CODE_TOKEN_VERIFICATION,
85
+ message: "Test mode: bearer token did not match any registered bypass token"
86
+ }
87
+ };
88
+ }
89
+ return { data: { ...matched } };
90
+ };
91
+ }
92
+ function createTestFFIDClient(config) {
93
+ const verifyAccessToken = createTestVerifyAccessToken(config);
94
+ return {
95
+ verifyAccessToken,
96
+ [TEST_CLIENT_BRAND]: true
97
+ };
98
+ }
99
+
100
+ export { TEST_CLIENT_ALLOW_IN_PRODUCTION_ACK, createTestFFIDClient, createTestVerifyAccessToken, isTestFFIDClient };
@@ -59,11 +59,33 @@ interface FFIDSubscriptionUpdatedPayload {
59
59
  status?: string;
60
60
  }
61
61
  interface FFIDSubscriptionCanceledPayload {
62
+ /** FFID subscription UUID. */
62
63
  subscriptionId: string;
63
- organizationId?: string;
64
+ /**
65
+ * Organization UUID that owned the subscription. Always emitted by the FFID
66
+ * server (see `src/lib/webhooks/types.ts > SubscriptionCanceledPayload`).
67
+ */
68
+ organizationId: string;
69
+ /**
70
+ * Stripe subscription id (`sub_...`). `null` when the subscription was
71
+ * cancelled before Stripe provisioning (e.g. free plans / manual cancels).
72
+ * Always present on the wire.
73
+ */
74
+ stripeSubscriptionId: string | null;
64
75
  reason?: string;
65
76
  cancelAt?: string;
66
- source?: 'user_initiated' | 'stripe_confirmed';
77
+ /**
78
+ * Origin of the cancellation. Always emitted by the FFID server — required
79
+ * by the wire shape in `SubscriptionCanceledPayload`.
80
+ */
81
+ source: 'user_initiated' | 'stripe_confirmed' | 'payment_failure_auto_cancel';
82
+ /** Whether this cancellation can be resumed via resubscribe flow. */
83
+ reactivatable?: boolean;
84
+ /**
85
+ * ISO timestamp at which access was actually lost. May differ from `cancelAt`
86
+ * when Stripe runs out the paid period before emitting the deletion event.
87
+ */
88
+ expiredAt?: string;
67
89
  }
68
90
  interface FFIDSubscriptionTrialEndingPayload {
69
91
  subscriptionId: string;
@@ -71,10 +93,54 @@ interface FFIDSubscriptionTrialEndingPayload {
71
93
  trialEndDate: string;
72
94
  }
73
95
  interface FFIDSubscriptionPaymentFailedPayload {
74
- subscriptionId: string;
96
+ /**
97
+ * Stripe invoice id (`in_...`) whose payment failed. Always emitted by the
98
+ * FFID server (see `src/lib/webhooks/types.ts > SubscriptionPaymentFailedPayload`).
99
+ */
100
+ stripeInvoiceId: string;
101
+ /**
102
+ * Stripe subscription id (`sub_...`) tied to the failed invoice, or `null`
103
+ * when the invoice was not linked to a subscription. Always emitted by the
104
+ * FFID server.
105
+ */
106
+ stripeSubscriptionId: string | null;
107
+ /**
108
+ * FFID subscription UUID resolved from `stripeSubscriptionId`.
109
+ *
110
+ * Optional because the server only spreads this field when it could
111
+ * correlate the Stripe subscription to an FFID row. Consumers should check
112
+ * {@link correlationUnavailable} before assuming a lookup failure is a
113
+ * data-quality issue rather than a legitimate unlinked payload.
114
+ */
115
+ subscriptionId?: string;
116
+ /**
117
+ * Organization UUID that owns the subscription.
118
+ *
119
+ * Optional for the same reason as `subscriptionId` (conditionally emitted
120
+ * when correlation succeeded).
121
+ */
75
122
  organizationId?: string;
76
- failureReason?: string;
77
- attemptCount?: number;
123
+ /**
124
+ * 'warning' during Stripe dunning retries — keep access, surface UI banner.
125
+ * 'blocking' once retries are exhausted or the grace period has passed — cut off access.
126
+ */
127
+ severity?: 'warning' | 'blocking';
128
+ /**
129
+ * ISO timestamp at which the grace period ends and severity flips to 'blocking'.
130
+ * `null` means already blocking.
131
+ */
132
+ graceUntil?: string | null;
133
+ /**
134
+ * `true` when the FFID server could not resolve `stripeSubscriptionId` to an
135
+ * FFID subscription row (e.g. race with a not-yet-synced sub, or invoice
136
+ * without a linked subscription). In that case both `subscriptionId` and
137
+ * `organizationId` will be absent. Consumers should treat the event as a
138
+ * best-effort signal and avoid hard failures when this flag is set.
139
+ *
140
+ * Added alongside backend change (#2444) so SDK consumers can distinguish
141
+ * "backend has no mapping yet" from "payload shape regression".
142
+ */
143
+ correlationUnavailable?: boolean;
78
144
  }
79
145
  interface FFIDUserCreatedPayload {
80
146
  userId: string;
@@ -59,11 +59,33 @@ interface FFIDSubscriptionUpdatedPayload {
59
59
  status?: string;
60
60
  }
61
61
  interface FFIDSubscriptionCanceledPayload {
62
+ /** FFID subscription UUID. */
62
63
  subscriptionId: string;
63
- organizationId?: string;
64
+ /**
65
+ * Organization UUID that owned the subscription. Always emitted by the FFID
66
+ * server (see `src/lib/webhooks/types.ts > SubscriptionCanceledPayload`).
67
+ */
68
+ organizationId: string;
69
+ /**
70
+ * Stripe subscription id (`sub_...`). `null` when the subscription was
71
+ * cancelled before Stripe provisioning (e.g. free plans / manual cancels).
72
+ * Always present on the wire.
73
+ */
74
+ stripeSubscriptionId: string | null;
64
75
  reason?: string;
65
76
  cancelAt?: string;
66
- source?: 'user_initiated' | 'stripe_confirmed';
77
+ /**
78
+ * Origin of the cancellation. Always emitted by the FFID server — required
79
+ * by the wire shape in `SubscriptionCanceledPayload`.
80
+ */
81
+ source: 'user_initiated' | 'stripe_confirmed' | 'payment_failure_auto_cancel';
82
+ /** Whether this cancellation can be resumed via resubscribe flow. */
83
+ reactivatable?: boolean;
84
+ /**
85
+ * ISO timestamp at which access was actually lost. May differ from `cancelAt`
86
+ * when Stripe runs out the paid period before emitting the deletion event.
87
+ */
88
+ expiredAt?: string;
67
89
  }
68
90
  interface FFIDSubscriptionTrialEndingPayload {
69
91
  subscriptionId: string;
@@ -71,10 +93,54 @@ interface FFIDSubscriptionTrialEndingPayload {
71
93
  trialEndDate: string;
72
94
  }
73
95
  interface FFIDSubscriptionPaymentFailedPayload {
74
- subscriptionId: string;
96
+ /**
97
+ * Stripe invoice id (`in_...`) whose payment failed. Always emitted by the
98
+ * FFID server (see `src/lib/webhooks/types.ts > SubscriptionPaymentFailedPayload`).
99
+ */
100
+ stripeInvoiceId: string;
101
+ /**
102
+ * Stripe subscription id (`sub_...`) tied to the failed invoice, or `null`
103
+ * when the invoice was not linked to a subscription. Always emitted by the
104
+ * FFID server.
105
+ */
106
+ stripeSubscriptionId: string | null;
107
+ /**
108
+ * FFID subscription UUID resolved from `stripeSubscriptionId`.
109
+ *
110
+ * Optional because the server only spreads this field when it could
111
+ * correlate the Stripe subscription to an FFID row. Consumers should check
112
+ * {@link correlationUnavailable} before assuming a lookup failure is a
113
+ * data-quality issue rather than a legitimate unlinked payload.
114
+ */
115
+ subscriptionId?: string;
116
+ /**
117
+ * Organization UUID that owns the subscription.
118
+ *
119
+ * Optional for the same reason as `subscriptionId` (conditionally emitted
120
+ * when correlation succeeded).
121
+ */
75
122
  organizationId?: string;
76
- failureReason?: string;
77
- attemptCount?: number;
123
+ /**
124
+ * 'warning' during Stripe dunning retries — keep access, surface UI banner.
125
+ * 'blocking' once retries are exhausted or the grace period has passed — cut off access.
126
+ */
127
+ severity?: 'warning' | 'blocking';
128
+ /**
129
+ * ISO timestamp at which the grace period ends and severity flips to 'blocking'.
130
+ * `null` means already blocking.
131
+ */
132
+ graceUntil?: string | null;
133
+ /**
134
+ * `true` when the FFID server could not resolve `stripeSubscriptionId` to an
135
+ * FFID subscription row (e.g. race with a not-yet-synced sub, or invoice
136
+ * without a linked subscription). In that case both `subscriptionId` and
137
+ * `organizationId` will be absent. Consumers should treat the event as a
138
+ * best-effort signal and avoid hard failures when this flag is set.
139
+ *
140
+ * Added alongside backend change (#2444) so SDK consumers can distinguish
141
+ * "backend has no mapping yet" from "payload shape regression".
142
+ */
143
+ correlationUnavailable?: boolean;
78
144
  }
79
145
  interface FFIDUserCreatedPayload {
80
146
  userId: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feelflow/ffid-sdk",
3
- "version": "2.18.0",
3
+ "version": "2.20.0",
4
4
  "description": "FeelFlow ID Platform SDK for React/Next.js applications",
5
5
  "keywords": [
6
6
  "feelflow",
@@ -60,6 +60,11 @@
60
60
  "types": "./dist/server/index.d.ts",
61
61
  "import": "./dist/server/index.js",
62
62
  "require": "./dist/server/index.cjs"
63
+ },
64
+ "./server/test": {
65
+ "types": "./dist/server/test/index.d.ts",
66
+ "import": "./dist/server/test/index.js",
67
+ "require": "./dist/server/test/index.cjs"
63
68
  }
64
69
  },
65
70
  "files": [