@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.
- package/README.md +152 -7
- package/dist/{chunk-IEYXT3LA.js → chunk-GCUVFSB2.js} +89 -9
- package/dist/{chunk-EK2W67BW.cjs → chunk-KNEZ5OUQ.cjs} +89 -8
- package/dist/components/index.cjs +8 -8
- package/dist/components/index.d.cts +1 -1
- package/dist/components/index.d.ts +1 -1
- package/dist/components/index.js +1 -1
- package/dist/ffid-client-Cjm_TKUc.d.cts +1123 -0
- package/dist/ffid-client-Cjm_TKUc.d.ts +1123 -0
- package/dist/{index--S6rLHjr.d.cts → index-0D2vYSLq.d.cts} +87 -2
- package/dist/{index--S6rLHjr.d.ts → index-0D2vYSLq.d.ts} +87 -2
- package/dist/index.cjs +62 -28
- package/dist/index.d.cts +181 -11
- package/dist/index.d.ts +181 -11
- package/dist/index.js +33 -4
- package/dist/server/index.cjs +1 -1
- package/dist/server/index.d.cts +3 -1123
- package/dist/server/index.d.ts +3 -1123
- package/dist/server/index.js +1 -1
- package/dist/server/test/index.cjs +105 -0
- package/dist/server/test/index.d.cts +125 -0
- package/dist/server/test/index.d.ts +125 -0
- package/dist/server/test/index.js +100 -0
- package/dist/webhooks/index.d.cts +71 -5
- package/dist/webhooks/index.d.ts +71 -5
- package/package.json +6 -1
package/dist/server/index.js
CHANGED
|
@@ -802,7 +802,7 @@ function createProfileMethods(deps) {
|
|
|
802
802
|
}
|
|
803
803
|
|
|
804
804
|
// src/client/version-check.ts
|
|
805
|
-
var SDK_VERSION = "2.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
|
|
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/dist/webhooks/index.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
|
|
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.
|
|
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": [
|