@fanvue/builder-sdk 0.4.0 → 0.5.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 +284 -0
- package/dist/bridge/index.d.ts +1 -1
- package/dist/bridge/index.js +2 -2
- package/dist/{bridge-CGVtI3hr.js → bridge-QnF7P4Co.js} +2 -2
- package/dist/{bridge-CGVtI3hr.js.map → bridge-QnF7P4Co.js.map} +1 -1
- package/dist/core/index.d.ts +2 -2
- package/dist/core/index.js +3 -3
- package/dist/{core-BqdYUMrJ.js → core-BhKiA55a.js} +1318 -9
- package/dist/core-BhKiA55a.js.map +1 -0
- package/dist/index-C3CXrRiw.d.ts +1412 -0
- package/dist/index-C3CXrRiw.d.ts.map +1 -0
- package/dist/{index-BRDYLBlc.d.ts → index-CEFYZtlb.d.ts} +2 -2
- package/dist/{index-BRDYLBlc.d.ts.map → index-CEFYZtlb.d.ts.map} +1 -1
- package/dist/{index-Dr-mZ0qP.d.ts → index-cf3ZMnLJ.d.ts} +26 -2
- package/dist/index-cf3ZMnLJ.d.ts.map +1 -0
- package/dist/index-v_6Z4Q0u.d.ts +84 -0
- package/dist/index-v_6Z4Q0u.d.ts.map +1 -0
- package/dist/{schemas-DbyHF7Xi.js → index.cjs-teGsk6HB.js} +1058 -977
- package/dist/index.cjs-teGsk6HB.js.map +1 -0
- package/dist/nextjs/embedded-app/index.d.ts +2 -2
- package/dist/nextjs/embedded-app/index.js +3 -3
- package/dist/nextjs/off-platform/index.d.ts +3 -3
- package/dist/nextjs/off-platform/index.d.ts.map +1 -1
- package/dist/nextjs/off-platform/index.js +4 -4
- package/dist/nextjs/off-platform/index.js.map +1 -1
- package/dist/nextjs-CvVhtMdb.js +144 -0
- package/dist/nextjs-CvVhtMdb.js.map +1 -0
- package/dist/react/index.d.ts +2 -2
- package/dist/react/index.js +3 -3
- package/package.json +1 -1
- package/dist/core-BqdYUMrJ.js.map +0 -1
- package/dist/index-C4ewLil3.d.ts +0 -48
- package/dist/index-C4ewLil3.d.ts.map +0 -1
- package/dist/index-ClbZoV_Z.d.ts +0 -420
- package/dist/index-ClbZoV_Z.d.ts.map +0 -1
- package/dist/index-Dr-mZ0qP.d.ts.map +0 -1
- package/dist/nextjs-B5Tqgt_n.js +0 -80
- package/dist/nextjs-B5Tqgt_n.js.map +0 -1
- package/dist/schemas-DbyHF7Xi.js.map +0 -1
|
@@ -1,10 +1,105 @@
|
|
|
1
|
-
import { a as
|
|
1
|
+
import { a as discriminatedUnion, c as number, f as string, h as url, i as boolean, l as object, n as _enum, o as literal, p as union, r as array, s as looseObject, t as require_index_cjs, u as preprocess } from "./index.cjs-teGsk6HB.js";
|
|
2
2
|
//#region src/core/constants.ts
|
|
3
3
|
/** The `Bearer ` token prefix (includes trailing space). */
|
|
4
4
|
const BEARER_PREFIX = "Bearer ";
|
|
5
5
|
/** Response header carrying a refreshed session JWT back to the client. */
|
|
6
6
|
const HEADER_UPDATED_SESSION = "X-Updated-Session";
|
|
7
7
|
//#endregion
|
|
8
|
+
//#region src/core/contracts/access-mode.ts
|
|
9
|
+
/**
|
|
10
|
+
* Experience access modes and the entitlement reasons the platform reports
|
|
11
|
+
* alongside them.
|
|
12
|
+
*
|
|
13
|
+
* The platform's own identifier for this enum is `AccessMode`;
|
|
14
|
+
* {@link FanvueAccessMode} is the SDK alias, named so it does not collide with
|
|
15
|
+
* an app's own access-mode types.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Access modes an experience can propose or carry on Fanvue.
|
|
19
|
+
*
|
|
20
|
+
* - `FREE` — visible and playable by anyone.
|
|
21
|
+
* - `SUBSCRIPTION` — requires an active subscription to the creator.
|
|
22
|
+
* - `PAID` — requires a one-off purchase.
|
|
23
|
+
* - `HIDDEN` — reachable only through a share grant, never listed.
|
|
24
|
+
*/
|
|
25
|
+
const FANVUE_ACCESS_MODES = [
|
|
26
|
+
"FREE",
|
|
27
|
+
"SUBSCRIPTION",
|
|
28
|
+
"PAID",
|
|
29
|
+
"HIDDEN"
|
|
30
|
+
];
|
|
31
|
+
/** Zod schema for {@link FanvueAccessMode}. */
|
|
32
|
+
const FanvueAccessModeSchema = _enum(FANVUE_ACCESS_MODES);
|
|
33
|
+
/**
|
|
34
|
+
* Every reason the platform reports when a fan **is** entitled to an
|
|
35
|
+
* experience, as emitted by fanguard's `resolveExperienceAccess`.
|
|
36
|
+
*
|
|
37
|
+
* Documented for completeness and for exhaustive logging/analytics; apps should
|
|
38
|
+
* branch on `entitlement.isEntitled` rather than on a specific reason, because
|
|
39
|
+
* this list grows without an API version bump.
|
|
40
|
+
*
|
|
41
|
+
* - `owner` — the requester is the creator who owns the experience.
|
|
42
|
+
* - `entitled_via_free`, `free` — the experience is `FREE`.
|
|
43
|
+
* - `entitled_via_subscription`, `subscribed` — active creator subscription.
|
|
44
|
+
* - `entitled_via_share` — reached through a share grant.
|
|
45
|
+
* - `purchased` — one-off purchase of the experience.
|
|
46
|
+
* - `app_subscribed` — subscription to the app itself.
|
|
47
|
+
* - `purchased_before_mode_change`, `app_subscribed_before_mode_change` —
|
|
48
|
+
* entitlement grandfathered from before the creator changed the access mode.
|
|
49
|
+
* - `hidden_token_exchange` — the documented exemption to the fail-closed rule:
|
|
50
|
+
* a `HIDDEN` experience exchanges its token without a prior grant.
|
|
51
|
+
*/
|
|
52
|
+
const EXPERIENCE_ENTITLED_REASONS = [
|
|
53
|
+
"owner",
|
|
54
|
+
"entitled_via_free",
|
|
55
|
+
"entitled_via_share",
|
|
56
|
+
"entitled_via_subscription",
|
|
57
|
+
"free",
|
|
58
|
+
"subscribed",
|
|
59
|
+
"purchased",
|
|
60
|
+
"app_subscribed",
|
|
61
|
+
"purchased_before_mode_change",
|
|
62
|
+
"app_subscribed_before_mode_change",
|
|
63
|
+
"hidden_token_exchange"
|
|
64
|
+
];
|
|
65
|
+
/**
|
|
66
|
+
* Every reason the platform reports when a fan is **denied** an experience.
|
|
67
|
+
*
|
|
68
|
+
* These are the only three values fanguard emits today; the type stays open for
|
|
69
|
+
* forward compatibility.
|
|
70
|
+
*/
|
|
71
|
+
const EXPERIENCE_DENIAL_REASONS = [
|
|
72
|
+
"not_subscribed",
|
|
73
|
+
"not_purchased",
|
|
74
|
+
"no_share_grant"
|
|
75
|
+
];
|
|
76
|
+
/**
|
|
77
|
+
* Recovers the access mode a denied experience must be in, from the denial
|
|
78
|
+
* reason.
|
|
79
|
+
*
|
|
80
|
+
* The token exchange fails closed: an unentitled fan gets a 403 carrying only a
|
|
81
|
+
* denial `reason`, and the experience payload — with it the access mode — is
|
|
82
|
+
* withheld. Mapping the reason back is the only way a locked screen can show
|
|
83
|
+
* the right copy ("subscribe" vs "buy" vs "ask for the link"). An unrecognised
|
|
84
|
+
* reason maps to `null`, meaning "show the generic locked copy".
|
|
85
|
+
*
|
|
86
|
+
* @param reason - The `reason` field from the platform's 403 body.
|
|
87
|
+
* @returns The implied access mode, or `null` when the reason is unrecognised.
|
|
88
|
+
* @example
|
|
89
|
+
* accessModeFromDenialReason('not_subscribed'); // 'SUBSCRIPTION'
|
|
90
|
+
* accessModeFromDenialReason('not_purchased'); // 'PAID'
|
|
91
|
+
* accessModeFromDenialReason('no_share_grant'); // 'HIDDEN'
|
|
92
|
+
* accessModeFromDenialReason('something_new'); // null
|
|
93
|
+
*/
|
|
94
|
+
function accessModeFromDenialReason(reason) {
|
|
95
|
+
switch (reason) {
|
|
96
|
+
case "not_subscribed": return "SUBSCRIPTION";
|
|
97
|
+
case "not_purchased": return "PAID";
|
|
98
|
+
case "no_share_grant": return "HIDDEN";
|
|
99
|
+
default: return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
//#endregion
|
|
8
103
|
//#region src/core/defaults.ts
|
|
9
104
|
/** Default OAuth scopes requested during authorization. */
|
|
10
105
|
const DEFAULT_SCOPES = "openid offline_access offline";
|
|
@@ -32,6 +127,587 @@ function assertFanvueDomain(url) {
|
|
|
32
127
|
}
|
|
33
128
|
if (hostname !== "fanvue.com" && !hostname.endsWith(".fanvue.com")) throw new Error(`Invalid apiBaseUrl: "${url}" must be a fanvue.com domain (e.g. https://api.fanvue.com).`);
|
|
34
129
|
}
|
|
130
|
+
//#endregion
|
|
131
|
+
//#region src/core/contracts/env.ts
|
|
132
|
+
/**
|
|
133
|
+
* The canonical `FANVUE_*` environment contract.
|
|
134
|
+
*
|
|
135
|
+
* Every app that embeds on Fanvue reads the same nine variables and, before
|
|
136
|
+
* this module, validated them with nine slightly different schemas — one repo
|
|
137
|
+
* had two incompatible ones in the same process, each with its own cache, so
|
|
138
|
+
* whichever accessor ran first froze its own view of `process.env`.
|
|
139
|
+
*
|
|
140
|
+
* Two deliberate properties, both inherited from the app that got them right:
|
|
141
|
+
*
|
|
142
|
+
* - **Lazily parsed and cached.** A build must succeed without secrets, so
|
|
143
|
+
* nothing is read at import time; a bad value surfaces as a runtime failure on
|
|
144
|
+
* first use rather than at boot.
|
|
145
|
+
* - **Credentials are schema-optional.** `FANVUE_APP_UUID`, `FANVUE_CLIENT_ID`,
|
|
146
|
+
* `FANVUE_CLIENT_SECRET` and `FANVUE_OAUTH_REDIRECT_URI` default to `""`
|
|
147
|
+
* instead of being required, so a preview deploy or a local checkout boots
|
|
148
|
+
* without them. {@link isFanvueConfigured} is the separate, explicit check
|
|
149
|
+
* callers run before touching an OAuth path.
|
|
150
|
+
*/
|
|
151
|
+
/**
|
|
152
|
+
* Treats a blank string as unset.
|
|
153
|
+
*
|
|
154
|
+
* `.env` templates ship lines like `FANVUE_API_BASE_URL=`, and a platform
|
|
155
|
+
* dashboard saves an emptied field as `""` rather than deleting it. For a
|
|
156
|
+
* variable with a real default, blank must mean "use the default" — a literal
|
|
157
|
+
* empty base URL would turn every request into a relative fetch.
|
|
158
|
+
*/
|
|
159
|
+
function blankToUndefined(value) {
|
|
160
|
+
return typeof value === "string" && value.trim() === "" ? void 0 : value;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Zod schema for the `FANVUE_*` environment contract.
|
|
164
|
+
*
|
|
165
|
+
* - `FANVUE_APP_UUID` — the app's UUID on Fanvue. Checked against the `appUuid`
|
|
166
|
+
* an exchanged experience token carries, so a token minted for another app is
|
|
167
|
+
* rejected. Defaults to `""`.
|
|
168
|
+
* - `FANVUE_CLIENT_ID` / `FANVUE_CLIENT_SECRET` / `FANVUE_OAUTH_REDIRECT_URI` —
|
|
169
|
+
* OAuth client credentials. Default to `""`; see {@link isFanvueConfigured}.
|
|
170
|
+
* - `FANVUE_API_BASE_URL` — bare origin of the Fanvue API (no `/v0` suffix).
|
|
171
|
+
* Blank falls back to the default.
|
|
172
|
+
* - `FANVUE_AUTH_BASE_URL` — origin of the Fanvue authorization server. Blank
|
|
173
|
+
* falls back to the default.
|
|
174
|
+
* - `FANVUE_WEB_ORIGIN` — origin of the Fanvue web shell. The only variable
|
|
175
|
+
* with real URL validation, because it is concatenated into hrefs a creator
|
|
176
|
+
* or fan clicks: a schemeless value ships a dead link. Blank falls back to
|
|
177
|
+
* the default. Note this does not constrain the scheme.
|
|
178
|
+
* - `FANVUE_API_VERSION` — value sent as `X-Fanvue-API-Version`. Blank falls
|
|
179
|
+
* back to the default.
|
|
180
|
+
* - `FANVUE_EXPERIENCE_URL_TEMPLATE` — optional operator override for
|
|
181
|
+
* per-experience share links. Empty means "derive the URL".
|
|
182
|
+
*/
|
|
183
|
+
const FanvueEnvSchema = object({
|
|
184
|
+
FANVUE_APP_UUID: string().default(""),
|
|
185
|
+
FANVUE_CLIENT_ID: string().default(""),
|
|
186
|
+
FANVUE_CLIENT_SECRET: string().default(""),
|
|
187
|
+
FANVUE_OAUTH_REDIRECT_URI: string().default(""),
|
|
188
|
+
FANVUE_API_BASE_URL: preprocess(blankToUndefined, string().default(DEFAULT_API_BASE_URL)),
|
|
189
|
+
FANVUE_AUTH_BASE_URL: preprocess(blankToUndefined, string().default(DEFAULT_ISSUER_URL)),
|
|
190
|
+
FANVUE_WEB_ORIGIN: preprocess(blankToUndefined, url().default(DEFAULT_PLATFORM_URL)),
|
|
191
|
+
FANVUE_API_VERSION: preprocess(blankToUndefined, string().default(API_VERSION)),
|
|
192
|
+
FANVUE_EXPERIENCE_URL_TEMPLATE: string().default("")
|
|
193
|
+
});
|
|
194
|
+
/**
|
|
195
|
+
* Validates an environment bag against {@link FanvueEnvSchema}.
|
|
196
|
+
*
|
|
197
|
+
* Pure and uncached — takes the environment as a parameter so it is unit
|
|
198
|
+
* testable. Prefer {@link fanvueEnv} in application code.
|
|
199
|
+
*
|
|
200
|
+
* @param source - The variables to validate.
|
|
201
|
+
* @returns The validated environment.
|
|
202
|
+
* @throws If a present value fails validation (today: a non-URL
|
|
203
|
+
* `FANVUE_WEB_ORIGIN`).
|
|
204
|
+
*/
|
|
205
|
+
function parseFanvueEnv(source) {
|
|
206
|
+
return FanvueEnvSchema.parse(source);
|
|
207
|
+
}
|
|
208
|
+
let cachedEnv = null;
|
|
209
|
+
const warnedFlags = /* @__PURE__ */ new Set();
|
|
210
|
+
/**
|
|
211
|
+
* Returns the validated `FANVUE_*` environment, parsing `process.env` on first
|
|
212
|
+
* use and caching the result.
|
|
213
|
+
*
|
|
214
|
+
* @returns The validated environment.
|
|
215
|
+
* @throws If a present value fails validation.
|
|
216
|
+
* @example
|
|
217
|
+
* const baseUrl = fanvueEnv().FANVUE_API_BASE_URL;
|
|
218
|
+
*/
|
|
219
|
+
function fanvueEnv() {
|
|
220
|
+
if (cachedEnv === null) cachedEnv = parseFanvueEnv(process.env);
|
|
221
|
+
return cachedEnv;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Clears every piece of memoised state in this module: the {@link fanvueEnv}
|
|
225
|
+
* cache and {@link flagEnabled}'s warned-flag set.
|
|
226
|
+
*
|
|
227
|
+
* Intended for tests, which mutate `process.env` between cases and would
|
|
228
|
+
* otherwise see whichever value the first case happened to freeze. The warn-once
|
|
229
|
+
* set is cleared for the same reason — left in place, whether a case sees the
|
|
230
|
+
* warning depends on which case ran first.
|
|
231
|
+
*/
|
|
232
|
+
function resetFanvueEnvCache() {
|
|
233
|
+
cachedEnv = null;
|
|
234
|
+
warnedFlags.clear();
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Whether the four Fanvue credential variables are all set.
|
|
238
|
+
*
|
|
239
|
+
* Covers exactly the values OAuth configuration requires, because config
|
|
240
|
+
* creation throws on a missing one and an uncaught throw in a route surfaces as
|
|
241
|
+
* a 500. Call this first and return a "not configured" response instead.
|
|
242
|
+
*
|
|
243
|
+
* @param env - The environment to check. Defaults to the cached
|
|
244
|
+
* {@link fanvueEnv}.
|
|
245
|
+
* @returns `true` when app UUID, client id, client secret and redirect URI are
|
|
246
|
+
* all non-blank. Whitespace-only counts as unset, matching how the schema
|
|
247
|
+
* treats blank values everywhere else in this module.
|
|
248
|
+
* @example
|
|
249
|
+
* if (!isFanvueConfigured()) {
|
|
250
|
+
* return Response.json({ error: { code: 'fanvue_not_configured', message: '…' } }, { status: 503 });
|
|
251
|
+
* }
|
|
252
|
+
*/
|
|
253
|
+
function isFanvueConfigured(env = fanvueEnv()) {
|
|
254
|
+
return env.FANVUE_APP_UUID.trim() !== "" && env.FANVUE_CLIENT_ID.trim() !== "" && env.FANVUE_CLIENT_SECRET.trim() !== "" && env.FANVUE_OAUTH_REDIRECT_URI.trim() !== "";
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Reads a boolean environment flag.
|
|
258
|
+
*
|
|
259
|
+
* A strict `=== "true"` is how one app's `MCP_ENABLED="TRUE"` switched a whole
|
|
260
|
+
* subsystem off in staging: every route answered 404 and the value looked
|
|
261
|
+
* correct in the dashboard. A flag that fails closed on a casing typo and says
|
|
262
|
+
* nothing costs more than one that is simply off, because there is no signal to
|
|
263
|
+
* find. So surrounding whitespace and case do not matter, and a value that
|
|
264
|
+
* still cannot be read as a boolean is warned about **once per flag name** and
|
|
265
|
+
* treated as off — off is the safe answer, but not a silent one.
|
|
266
|
+
*
|
|
267
|
+
* @param name - The environment variable name.
|
|
268
|
+
* @param source - The variables to read from. Defaults to `process.env`.
|
|
269
|
+
* @returns `true` only for `"true"` (after trimming and lowercasing).
|
|
270
|
+
* @see {@link resetFanvueEnvCache} to clear the warned-flag set between tests.
|
|
271
|
+
* @example
|
|
272
|
+
* flagEnabled('MY_APP_DEV_MODE'); // reads process.env.MY_APP_DEV_MODE
|
|
273
|
+
* flagEnabled('MY_APP_DEV_MODE', { MY_APP_DEV_MODE: ' TRUE ' }); // true
|
|
274
|
+
*/
|
|
275
|
+
function flagEnabled(name, source = process.env) {
|
|
276
|
+
const raw = source[name];
|
|
277
|
+
const value = (raw ?? "").trim().toLowerCase();
|
|
278
|
+
if (value === "true") return true;
|
|
279
|
+
if (value !== "false" && value !== "" && !warnedFlags.has(name)) {
|
|
280
|
+
warnedFlags.add(name);
|
|
281
|
+
console.warn(`${name} is set to ${JSON.stringify(raw)}, which is not true or false. Treating it as off.`);
|
|
282
|
+
}
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
//#endregion
|
|
286
|
+
//#region src/core/contracts/errors.ts
|
|
287
|
+
/**
|
|
288
|
+
* Error contracts: the shapes the Fanvue API puts on the wire, the envelope an
|
|
289
|
+
* app puts on its own responses, and the codes that travel between them.
|
|
290
|
+
*
|
|
291
|
+
* The platform has **no single error envelope**. Four shapes are in production
|
|
292
|
+
* use, and a machine-readable `reason` rides alongside the human text on the
|
|
293
|
+
* experience-exchange 403 — losing it collapses "this fan is not entitled" into
|
|
294
|
+
* "this token belongs to another app". {@link parseFanvueErrorBody} normalises
|
|
295
|
+
* all four into one struct without discarding `reason`.
|
|
296
|
+
*/
|
|
297
|
+
/**
|
|
298
|
+
* `{ message }` — 404s, contactability rejections, invalid creator UUIDs, and
|
|
299
|
+
* the experience-exchange 403 (which adds `reason`). `code` appears on
|
|
300
|
+
* bulk-media per-item errors.
|
|
301
|
+
*/
|
|
302
|
+
const FanvueMessageErrorBodySchema = looseObject({
|
|
303
|
+
message: string(),
|
|
304
|
+
reason: string().nullish().catch(null),
|
|
305
|
+
code: string().nullish().catch(null)
|
|
306
|
+
});
|
|
307
|
+
/**
|
|
308
|
+
* `{ error }` — 401/403/500/503 and upstream tRPC failures, where `error` is the
|
|
309
|
+
* human text; and `{ error, message }` — version and age-verification failures,
|
|
310
|
+
* where `error` is a machine-readable code and `message` the human text.
|
|
311
|
+
*/
|
|
312
|
+
const FanvueErrorFieldBodySchema = looseObject({
|
|
313
|
+
error: string(),
|
|
314
|
+
message: string().nullish().catch(null),
|
|
315
|
+
reason: string().nullish().catch(null)
|
|
316
|
+
});
|
|
317
|
+
/** `{ errors: [{ code, message }] }` — Zod request-validation failures. */
|
|
318
|
+
const FanvueIssueListErrorBodySchema = looseObject({ errors: array(looseObject({
|
|
319
|
+
code: string().nullish().catch(null),
|
|
320
|
+
message: string()
|
|
321
|
+
})).min(1) });
|
|
322
|
+
/** `{ errors: ["Invalid page: too small"] }` — the pagination middleware. */
|
|
323
|
+
const FanvueStringListErrorBodySchema = looseObject({ errors: array(string()).min(1) });
|
|
324
|
+
/**
|
|
325
|
+
* Any of the four documented Fanvue API error bodies.
|
|
326
|
+
*
|
|
327
|
+
* Member order matters: the `errors` array shapes are tried first because they
|
|
328
|
+
* carry no `message`/`error` key of their own, then `{ error }` (which may also
|
|
329
|
+
* carry a `message`), then `{ message }` alone. Exported for callers that want
|
|
330
|
+
* to validate a body in one step; {@link parseFanvueErrorBody} tries the members
|
|
331
|
+
* individually so that one unreadable field cannot cost the whole body.
|
|
332
|
+
*/
|
|
333
|
+
const FanvueErrorBodySchema = union([
|
|
334
|
+
FanvueStringListErrorBodySchema,
|
|
335
|
+
FanvueIssueListErrorBodySchema,
|
|
336
|
+
FanvueErrorFieldBodySchema,
|
|
337
|
+
FanvueMessageErrorBodySchema
|
|
338
|
+
]);
|
|
339
|
+
/**
|
|
340
|
+
* Normalises any Fanvue API error body into {@link NormalisedFanvueError}.
|
|
341
|
+
*
|
|
342
|
+
* Never throws and never inspects the HTTP status: a body it cannot read yields
|
|
343
|
+
* an empty `message`, which the transport replaces with its own status-derived
|
|
344
|
+
* text. Multiple validation issues are joined with `; ` so a single log line
|
|
345
|
+
* carries all of them.
|
|
346
|
+
*
|
|
347
|
+
* The `{ error }` shape is disambiguated by whether a `message` sits next to it:
|
|
348
|
+
* `{ error: 'age_verification_required', message: 'Verify…' }` puts the code in
|
|
349
|
+
* `code` and the text in `message`, while a bare `{ error: 'Unauthorized' }` is
|
|
350
|
+
* treated as text only.
|
|
351
|
+
*
|
|
352
|
+
* @param body - A parsed JSON body (or anything at all).
|
|
353
|
+
* @returns The normalised error fields.
|
|
354
|
+
* @example
|
|
355
|
+
* parseFanvueErrorBody({ message: 'Fan is not entitled', reason: 'not_subscribed' });
|
|
356
|
+
* // { message: 'Fan is not entitled', reason: 'not_subscribed', code: null }
|
|
357
|
+
* parseFanvueErrorBody({ errors: [{ code: 'invalid_type', message: 'Expected string' }] });
|
|
358
|
+
* // { message: 'Expected string', reason: null, code: 'invalid_type' }
|
|
359
|
+
* parseFanvueErrorBody('<html>502</html>');
|
|
360
|
+
* // { message: '', reason: null, code: null }
|
|
361
|
+
*/
|
|
362
|
+
function parseFanvueErrorBody(body) {
|
|
363
|
+
const stringList = FanvueStringListErrorBodySchema.safeParse(body);
|
|
364
|
+
if (stringList.success) return {
|
|
365
|
+
message: stringList.data.errors.join("; "),
|
|
366
|
+
reason: null,
|
|
367
|
+
code: null
|
|
368
|
+
};
|
|
369
|
+
const issueList = FanvueIssueListErrorBodySchema.safeParse(body);
|
|
370
|
+
if (issueList.success) {
|
|
371
|
+
const issues = issueList.data.errors;
|
|
372
|
+
return {
|
|
373
|
+
message: issues.map((issue) => issue.message).join("; "),
|
|
374
|
+
reason: null,
|
|
375
|
+
code: issues.find((issue) => typeof issue.code === "string")?.code ?? null
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
const errorField = FanvueErrorFieldBodySchema.safeParse(body);
|
|
379
|
+
if (errorField.success) {
|
|
380
|
+
const { error, message, reason } = errorField.data;
|
|
381
|
+
return {
|
|
382
|
+
message: message ?? error,
|
|
383
|
+
reason: reason ?? null,
|
|
384
|
+
code: typeof message === "string" ? error : null
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
const messageBody = FanvueMessageErrorBodySchema.safeParse(body);
|
|
388
|
+
if (messageBody.success) return {
|
|
389
|
+
message: messageBody.data.message,
|
|
390
|
+
reason: messageBody.data.reason ?? null,
|
|
391
|
+
code: messageBody.data.code ?? null
|
|
392
|
+
};
|
|
393
|
+
return {
|
|
394
|
+
message: "",
|
|
395
|
+
reason: null,
|
|
396
|
+
code: null
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Zod schema for an app's own error envelope: `{ error: { code, message } }`.
|
|
401
|
+
*
|
|
402
|
+
* Every Fanvue app helper produces this shape and every client parses it, so it
|
|
403
|
+
* is a contract rather than a convention. The `message` is what an end user
|
|
404
|
+
* reads — upstream and internal text belongs in the log, never in this body.
|
|
405
|
+
*/
|
|
406
|
+
const AppErrorEnvelopeSchema = object({ error: object({
|
|
407
|
+
code: string().min(1),
|
|
408
|
+
message: string()
|
|
409
|
+
}) });
|
|
410
|
+
/**
|
|
411
|
+
* Canonical app-facing error codes for Fanvue-grant failures.
|
|
412
|
+
*
|
|
413
|
+
* - `fanvue_reconnect_required` (401) — the stored Fanvue grant is missing or
|
|
414
|
+
* dead. The creator must reopen the app from Fanvue.
|
|
415
|
+
* - `fanvue_permission_denied` (403) — the grant is alive but lacks the scope
|
|
416
|
+
* for this capability. Reconnecting re-consents the new scope.
|
|
417
|
+
* - `fanvue_api_error` (502) — the Fanvue API failed for any other reason.
|
|
418
|
+
*/
|
|
419
|
+
const FANVUE_APP_ERROR_CODES = {
|
|
420
|
+
reconnectRequired: "fanvue_reconnect_required",
|
|
421
|
+
permissionDenied: "fanvue_permission_denied",
|
|
422
|
+
apiError: "fanvue_api_error"
|
|
423
|
+
};
|
|
424
|
+
/**
|
|
425
|
+
* The two 401 codes that mean the **Fanvue grant** is gone while the **app
|
|
426
|
+
* session** is still valid.
|
|
427
|
+
*
|
|
428
|
+
* A client that treats every 401 as session expiry signs the creator out of a
|
|
429
|
+
* working session and offers "Reload" — the one action that cannot restore a
|
|
430
|
+
* missing grant. Check membership of this set before running expiry handling,
|
|
431
|
+
* and route these to a reconnect prompt instead.
|
|
432
|
+
*
|
|
433
|
+
* @example
|
|
434
|
+
* if (status === 401 && !NON_SESSION_401_CODES.has(code)) {
|
|
435
|
+
* onSessionExpired();
|
|
436
|
+
* }
|
|
437
|
+
*/
|
|
438
|
+
const NON_SESSION_401_CODES = new Set([FANVUE_APP_ERROR_CODES.reconnectRequired, FANVUE_APP_ERROR_CODES.permissionDenied]);
|
|
439
|
+
/**
|
|
440
|
+
* Zod schema for an OAuth 2.0 error response body, normalised to camelCase.
|
|
441
|
+
*
|
|
442
|
+
* Only `error` and `error_description` are kept. Hydra also returns
|
|
443
|
+
* `error_hint` and `error_debug`, which can echo request details back and have
|
|
444
|
+
* no place in an app's own error path.
|
|
445
|
+
*/
|
|
446
|
+
const OAuthErrorBodySchema = looseObject({
|
|
447
|
+
error: string().min(1),
|
|
448
|
+
error_description: string().nullish()
|
|
449
|
+
}).transform((raw) => ({
|
|
450
|
+
error: raw.error,
|
|
451
|
+
errorDescription: raw.error_description ?? null
|
|
452
|
+
}));
|
|
453
|
+
//#endregion
|
|
454
|
+
//#region src/core/contracts/experience-protocol.ts
|
|
455
|
+
/**
|
|
456
|
+
* The `fanvue:experience:*` postMessage protocol between an embedded app and
|
|
457
|
+
* the Fanvue web shell (Eden).
|
|
458
|
+
*
|
|
459
|
+
* Publishing is Fanvue-mediated: the app's backend mints an opaque request
|
|
460
|
+
* token from the Fanvue API (`POST /v0/experiences/request-token`, scope
|
|
461
|
+
* `write:experience`), the iframe posts `{ type, token }` to the parent
|
|
462
|
+
* window, Fanvue verifies the token server-side and shows its native
|
|
463
|
+
* "Configure Experience" modal, then replies with a result message. An app
|
|
464
|
+
* never mutates Fanvue experience state directly.
|
|
465
|
+
*
|
|
466
|
+
* This module is the wire contract only — schemas, type guards and origin
|
|
467
|
+
* validation. The browser bridge that drives the handshake (timeouts, escalation
|
|
468
|
+
* to `window.top`, listener cleanup) lives outside this module.
|
|
469
|
+
*/
|
|
470
|
+
/** Message type an app posts to request the publish modal. */
|
|
471
|
+
const PUBLISH_REQUEST_MESSAGE = "fanvue:experience:publish-request";
|
|
472
|
+
/** Message type Fanvue posts back with the outcome of a publish request. */
|
|
473
|
+
const PUBLISH_RESULT_MESSAGE = "fanvue:experience:publish-result";
|
|
474
|
+
/** Message type an app posts to request the unpublish confirmation. */
|
|
475
|
+
const UNPUBLISH_REQUEST_MESSAGE = "fanvue:experience:unpublish-request";
|
|
476
|
+
/** Message type Fanvue posts back with the outcome of an unpublish request. */
|
|
477
|
+
const UNPUBLISH_RESULT_MESSAGE = "fanvue:experience:unpublish-result";
|
|
478
|
+
/**
|
|
479
|
+
* Every message type in the protocol, in request/result pairs.
|
|
480
|
+
*
|
|
481
|
+
* Useful for filtering an incoming `MessageEvent` before schema validation.
|
|
482
|
+
*/
|
|
483
|
+
const EXPERIENCE_MESSAGE_TYPES = [
|
|
484
|
+
PUBLISH_REQUEST_MESSAGE,
|
|
485
|
+
PUBLISH_RESULT_MESSAGE,
|
|
486
|
+
UNPUBLISH_REQUEST_MESSAGE,
|
|
487
|
+
UNPUBLISH_RESULT_MESSAGE
|
|
488
|
+
];
|
|
489
|
+
/**
|
|
490
|
+
* Zod schema for the app → Fanvue publish request.
|
|
491
|
+
*
|
|
492
|
+
* `token` is the opaque request token minted by
|
|
493
|
+
* `POST /v0/experiences/request-token` with `action: "publish"`.
|
|
494
|
+
*/
|
|
495
|
+
const PublishRequestMessageSchema = object({
|
|
496
|
+
type: literal(PUBLISH_REQUEST_MESSAGE),
|
|
497
|
+
token: string().min(1)
|
|
498
|
+
});
|
|
499
|
+
/**
|
|
500
|
+
* Zod schema for the app → Fanvue unpublish request.
|
|
501
|
+
*
|
|
502
|
+
* `token` is the opaque request token minted by
|
|
503
|
+
* `POST /v0/experiences/request-token` with `action: "unpublish"`.
|
|
504
|
+
*/
|
|
505
|
+
const UnpublishRequestMessageSchema = object({
|
|
506
|
+
type: literal(UNPUBLISH_REQUEST_MESSAGE),
|
|
507
|
+
token: string().min(1)
|
|
508
|
+
});
|
|
509
|
+
/**
|
|
510
|
+
* Zod schema for the Fanvue → app publish result.
|
|
511
|
+
*
|
|
512
|
+
* `experienceId` is optional rather than nullable because it mirrors the wire
|
|
513
|
+
* shape: Fanvue omits the key entirely when the creator cancels. `cancelled`
|
|
514
|
+
* covers both a dismissed modal and a server-side rejection of the request
|
|
515
|
+
* token — the two are not distinguishable from this side.
|
|
516
|
+
*
|
|
517
|
+
* Any string is accepted, including `""`. A receiver must not reject an
|
|
518
|
+
* otherwise well-formed result over a useless id: from the app's side a rejected
|
|
519
|
+
* message is indistinguishable from no reply at all, so a publish that did
|
|
520
|
+
* happen would hang until the bridge timed out. Treat a falsy `experienceId` the
|
|
521
|
+
* way an absent one is treated.
|
|
522
|
+
*/
|
|
523
|
+
const PublishResultMessageSchema = object({
|
|
524
|
+
type: literal(PUBLISH_RESULT_MESSAGE),
|
|
525
|
+
status: _enum(["published", "cancelled"]),
|
|
526
|
+
experienceId: string().optional()
|
|
527
|
+
});
|
|
528
|
+
/** Zod schema for the Fanvue → app unpublish result. */
|
|
529
|
+
const UnpublishResultMessageSchema = object({
|
|
530
|
+
type: literal(UNPUBLISH_RESULT_MESSAGE),
|
|
531
|
+
status: _enum(["unpublished", "cancelled"])
|
|
532
|
+
});
|
|
533
|
+
/**
|
|
534
|
+
* Zod schema for any message in the protocol, discriminated on `type`.
|
|
535
|
+
*
|
|
536
|
+
* @example
|
|
537
|
+
* const parsed = ExperienceMessageSchema.safeParse(event.data);
|
|
538
|
+
* if (parsed.success && parsed.data.type === PUBLISH_RESULT_MESSAGE) {
|
|
539
|
+
* // parsed.data is a PublishResultMessage
|
|
540
|
+
* }
|
|
541
|
+
*/
|
|
542
|
+
const ExperienceMessageSchema = discriminatedUnion("type", [
|
|
543
|
+
PublishRequestMessageSchema,
|
|
544
|
+
PublishResultMessageSchema,
|
|
545
|
+
UnpublishRequestMessageSchema,
|
|
546
|
+
UnpublishResultMessageSchema
|
|
547
|
+
]);
|
|
548
|
+
/**
|
|
549
|
+
* Narrows unknown `MessageEvent` data to a {@link PublishResultMessage}.
|
|
550
|
+
*
|
|
551
|
+
* @param value - The raw `event.data` from a `message` event.
|
|
552
|
+
* @returns `true` when the value validates against the publish-result schema.
|
|
553
|
+
* @example
|
|
554
|
+
* if (isPublishResultMessage(event.data) && isFanvueOrigin(event.origin)) {
|
|
555
|
+
* settle(event.data);
|
|
556
|
+
* }
|
|
557
|
+
*/
|
|
558
|
+
function isPublishResultMessage(value) {
|
|
559
|
+
return PublishResultMessageSchema.safeParse(value).success;
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Narrows unknown `MessageEvent` data to an {@link UnpublishResultMessage}.
|
|
563
|
+
*
|
|
564
|
+
* @param value - The raw `event.data` from a `message` event.
|
|
565
|
+
* @returns `true` when the value validates against the unpublish-result schema.
|
|
566
|
+
*/
|
|
567
|
+
function isUnpublishResultMessage(value) {
|
|
568
|
+
return UnpublishResultMessageSchema.safeParse(value).success;
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Whether an origin belongs to the Fanvue web shell.
|
|
572
|
+
*
|
|
573
|
+
* Mirrors the `frame-ancestors 'self' https://fanvue.com https://*.fanvue.com`
|
|
574
|
+
* CSP allowlist every embedded app ships. Eden posts its replies with
|
|
575
|
+
* `targetOrigin: "*"`, so validating `event.origin` on receipt is the only
|
|
576
|
+
* app-side control against a sibling frame forging a publish success. Requires
|
|
577
|
+
* `https:` — a plaintext origin is never Fanvue.
|
|
578
|
+
*
|
|
579
|
+
* @param origin - The `event.origin` of an incoming message.
|
|
580
|
+
* @returns `true` for `https://fanvue.com` and any `https://*.fanvue.com` host.
|
|
581
|
+
* @example
|
|
582
|
+
* isFanvueOrigin('https://www.fanvue.com'); // true
|
|
583
|
+
* isFanvueOrigin('http://fanvue.com'); // false (not https)
|
|
584
|
+
* isFanvueOrigin('https://fanvue.com.evil.test'); // false
|
|
585
|
+
*/
|
|
586
|
+
function isFanvueOrigin(origin) {
|
|
587
|
+
let protocol;
|
|
588
|
+
let hostname;
|
|
589
|
+
try {
|
|
590
|
+
({protocol, hostname} = new URL(origin));
|
|
591
|
+
} catch {
|
|
592
|
+
return false;
|
|
593
|
+
}
|
|
594
|
+
return protocol === "https:" && (hostname === "fanvue.com" || hostname.endsWith(".fanvue.com"));
|
|
595
|
+
}
|
|
596
|
+
//#endregion
|
|
597
|
+
//#region src/core/contracts/pagination.ts
|
|
598
|
+
/**
|
|
599
|
+
* Shared pagination fragments for the Fanvue API.
|
|
600
|
+
*
|
|
601
|
+
* The platform ships three conventions and every resource module has to pick
|
|
602
|
+
* one, so the fragments live here rather than being re-declared per resource:
|
|
603
|
+
*
|
|
604
|
+
* 1. **Offset** (`v0`) — `?page=&size=`, response `{ data, pagination }`.
|
|
605
|
+
* 2. **Cursor** (`v1` keyset and some `v0` routes) — response
|
|
606
|
+
* `{ data, nextCursor, total }`, where `total` is `null` on large keyset
|
|
607
|
+
* lists and absent entirely on the `v0` cursor routes.
|
|
608
|
+
* 3. **Hybrid** (`GET /v0/subscribers`) — offset plus an optional `nextCursor`,
|
|
609
|
+
* which is `null` when sorting by name.
|
|
610
|
+
*
|
|
611
|
+
* This module is schemas and clamping only. The async-iterator helper that
|
|
612
|
+
* walks pages while respecting rate-limit headers is a separate concern and
|
|
613
|
+
* ships with the transport.
|
|
614
|
+
*/
|
|
615
|
+
/**
|
|
616
|
+
* Largest `size` the platform accepts on an offset-paginated route.
|
|
617
|
+
*
|
|
618
|
+
* The API responds `400` above this, so callers clamp client-side (see
|
|
619
|
+
* {@link clampPageSize}) rather than turning an over-large request into a
|
|
620
|
+
* failure the caller cannot act on.
|
|
621
|
+
*/
|
|
622
|
+
const MAX_PAGE_SIZE = 50;
|
|
623
|
+
/** The platform's default `size` when the query parameter is omitted. */
|
|
624
|
+
const DEFAULT_PAGE_SIZE = 15;
|
|
625
|
+
/**
|
|
626
|
+
* Clamps a requested page size into the platform's accepted range.
|
|
627
|
+
*
|
|
628
|
+
* Non-finite and non-integer inputs fall back to {@link DEFAULT_PAGE_SIZE}
|
|
629
|
+
* rather than being rounded, because a fractional `size` is a caller bug and
|
|
630
|
+
* the platform would reject it.
|
|
631
|
+
*
|
|
632
|
+
* @param requested - The page size a caller asked for.
|
|
633
|
+
* @returns An integer in `[1, MAX_PAGE_SIZE]`.
|
|
634
|
+
* @example
|
|
635
|
+
* clampPageSize(200); // 50
|
|
636
|
+
* clampPageSize(0); // 1
|
|
637
|
+
* clampPageSize(NaN); // 15
|
|
638
|
+
*/
|
|
639
|
+
function clampPageSize(requested) {
|
|
640
|
+
if (!Number.isInteger(requested)) return 15;
|
|
641
|
+
if (requested < 1) return 1;
|
|
642
|
+
return Math.min(requested, 50);
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Zod schema for the `pagination` object on offset-paginated `v0` responses.
|
|
646
|
+
*
|
|
647
|
+
* `size` is the number of items actually returned, not the requested page size
|
|
648
|
+
* (styx builds it as `data.length`), and `hasMore` is the only reliable
|
|
649
|
+
* end-of-list signal — the platform does not send a total on most routes.
|
|
650
|
+
*
|
|
651
|
+
* `size` therefore allows `0`: paging past the end returns `{ data: [], size: 0 }`,
|
|
652
|
+
* which is a well-formed response and must not fail validation. The `1..50`
|
|
653
|
+
* bound belongs to the *request* parameter — see {@link clampPageSize}.
|
|
654
|
+
*/
|
|
655
|
+
const OffsetPaginationSchema = object({
|
|
656
|
+
page: number().int().min(1),
|
|
657
|
+
size: number().int().min(0),
|
|
658
|
+
hasMore: boolean()
|
|
659
|
+
});
|
|
660
|
+
/**
|
|
661
|
+
* Zod schema for `GET /v0/subscribers`' hybrid pagination.
|
|
662
|
+
*
|
|
663
|
+
* Offset fields plus a keyset cursor that is `null` when the caller sorts by
|
|
664
|
+
* name (that ordering cannot be resumed from a cursor).
|
|
665
|
+
*/
|
|
666
|
+
const HybridPaginationSchema = OffsetPaginationSchema.extend({ nextCursor: string().nullable() });
|
|
667
|
+
/**
|
|
668
|
+
* Builds the response schema for an offset-paginated list of `item`.
|
|
669
|
+
*
|
|
670
|
+
* @param item - Schema for a single row of `data`.
|
|
671
|
+
* @returns A schema for `{ data, pagination }`.
|
|
672
|
+
* @example
|
|
673
|
+
* const SubscribersPageSchema = offsetPageSchema(SubscriberSchema);
|
|
674
|
+
* const page = SubscribersPageSchema.parse(body);
|
|
675
|
+
* page.pagination.hasMore; // boolean
|
|
676
|
+
*/
|
|
677
|
+
function offsetPageSchema(item) {
|
|
678
|
+
return object({
|
|
679
|
+
data: array(item),
|
|
680
|
+
pagination: OffsetPaginationSchema
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Builds the response schema for a cursor-paginated list of `item`.
|
|
685
|
+
*
|
|
686
|
+
* `nextCursor` is `null` on the last page. Cursors are opaque and pin the
|
|
687
|
+
* sort, filters and creator they were minted for, so they cannot be replayed
|
|
688
|
+
* against a different query.
|
|
689
|
+
*
|
|
690
|
+
* `total` is the third field of the platform's `v1` keyset envelope
|
|
691
|
+
* (`{ data, nextCursor, total }`): the platform sends a number only where a
|
|
692
|
+
* count is free or cheap, `null` on large keyset lists (the count is the
|
|
693
|
+
* expensive query keyset avoids), and the older `v0` cursor routes omit the
|
|
694
|
+
* key entirely. All three normalise to `total: null` or a number here, so a
|
|
695
|
+
* caller never branches on "absent vs null".
|
|
696
|
+
*
|
|
697
|
+
* @param item - Schema for a single row of `data`.
|
|
698
|
+
* @returns A schema for `{ data, nextCursor, total }`.
|
|
699
|
+
* @example
|
|
700
|
+
* const PaymentsPageSchema = cursorPageSchema(CheckoutPaymentSchema);
|
|
701
|
+
* const page = PaymentsPageSchema.parse(body);
|
|
702
|
+
* const done = page.nextCursor === null;
|
|
703
|
+
*/
|
|
704
|
+
function cursorPageSchema(item) {
|
|
705
|
+
return object({
|
|
706
|
+
data: array(item),
|
|
707
|
+
nextCursor: string().nullable(),
|
|
708
|
+
total: number().nullable().default(null)
|
|
709
|
+
});
|
|
710
|
+
}
|
|
35
711
|
if (typeof navigator === "undefined" || !navigator.userAgent?.startsWith?.("Mozilla/5.0 "));
|
|
36
712
|
const ERR_INVALID_ARG_VALUE = "ERR_INVALID_ARG_VALUE";
|
|
37
713
|
const ERR_INVALID_ARG_TYPE = "ERR_INVALID_ARG_TYPE";
|
|
@@ -199,6 +875,27 @@ const SessionPayloadSchema = object({
|
|
|
199
875
|
const CONTENT_TYPE_FORM_URLENCODED = "application/x-www-form-urlencoded";
|
|
200
876
|
const OAUTH_TOKEN_ENDPOINT = "/oauth2/token";
|
|
201
877
|
/**
|
|
878
|
+
* Reads the authorization server's error body off a failed token response.
|
|
879
|
+
*
|
|
880
|
+
* The status line alone cannot separate a retired refresh token from bad client
|
|
881
|
+
* credentials — both are `400` — so the body is worth keeping. Any failure to
|
|
882
|
+
* read or validate it yields `null`: a missing diagnostic must never replace the
|
|
883
|
+
* error it was attached to. Only `error` and `error_description` survive; the
|
|
884
|
+
* provider's hint/debug fields can echo request details back.
|
|
885
|
+
*/
|
|
886
|
+
async function readOAuthErrorBody(response) {
|
|
887
|
+
let text;
|
|
888
|
+
try {
|
|
889
|
+
text = await response.text();
|
|
890
|
+
} catch {
|
|
891
|
+
return null;
|
|
892
|
+
}
|
|
893
|
+
const parsed = safeJsonParse(text);
|
|
894
|
+
if (parsed.isErr()) return null;
|
|
895
|
+
const validated = OAuthErrorBodySchema.safeParse(parsed.value);
|
|
896
|
+
return validated.success ? validated.data : null;
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
202
899
|
* Builds an OAuth 2.0 authorization URL with PKCE parameters.
|
|
203
900
|
*
|
|
204
901
|
* @param config - The OAuth configuration.
|
|
@@ -232,7 +929,9 @@ async function createAuthorizationUrl(config, opts = null) {
|
|
|
232
929
|
*
|
|
233
930
|
* @param config - The OAuth configuration.
|
|
234
931
|
* @param opts - The authorization code, PKCE code verifier, and optional redirect URI override.
|
|
235
|
-
* @returns A `Result` containing `TokenResponse` on success or `OAuthError` on
|
|
932
|
+
* @returns A `Result` containing `TokenResponse` on success or `OAuthError` on
|
|
933
|
+
* failure. A `TOKEN_EXCHANGE_FAILED` error carries the authorization server's
|
|
934
|
+
* own `oauthError` body when it sent a readable one.
|
|
236
935
|
*/
|
|
237
936
|
async function exchangeCodeForToken(config, opts) {
|
|
238
937
|
const issuerUrl = config.issuerUrl ?? "https://auth.fanvue.com";
|
|
@@ -257,13 +956,15 @@ async function exchangeCodeForToken(config, opts) {
|
|
|
257
956
|
return (0, import_index_cjs.err)({
|
|
258
957
|
code: "TOKEN_EXCHANGE_FAILED",
|
|
259
958
|
statusCode: 0,
|
|
260
|
-
message: `Network error during token exchange: ${error instanceof Error ? error.message : String(error)}
|
|
959
|
+
message: `Network error during token exchange: ${error instanceof Error ? error.message : String(error)}`,
|
|
960
|
+
oauthError: null
|
|
261
961
|
});
|
|
262
962
|
}
|
|
263
963
|
if (!response.ok) return (0, import_index_cjs.err)({
|
|
264
964
|
code: "TOKEN_EXCHANGE_FAILED",
|
|
265
965
|
statusCode: response.status,
|
|
266
|
-
message: `Token exchange failed: ${response.status} ${response.statusText}
|
|
966
|
+
message: `Token exchange failed: ${response.status} ${response.statusText}`,
|
|
967
|
+
oauthError: await readOAuthErrorBody(response)
|
|
267
968
|
});
|
|
268
969
|
const parseResult = safeJsonParse(await response.text());
|
|
269
970
|
if (parseResult.isErr()) {
|
|
@@ -286,7 +987,10 @@ async function exchangeCodeForToken(config, opts) {
|
|
|
286
987
|
*
|
|
287
988
|
* @param config - The OAuth configuration.
|
|
288
989
|
* @param refreshToken - The refresh token to use.
|
|
289
|
-
* @returns A `Result` containing `TokenResponse` on success or `OAuthError` on
|
|
990
|
+
* @returns A `Result` containing `TokenResponse` on success or `OAuthError` on
|
|
991
|
+
* failure. A `TOKEN_REFRESH_FAILED` error carries the authorization server's
|
|
992
|
+
* own `oauthError` body when it sent a readable one — `invalid_grant` there
|
|
993
|
+
* means the stored refresh token is retired and the creator must reconnect.
|
|
290
994
|
*/
|
|
291
995
|
async function refreshAccessToken(config, refreshToken) {
|
|
292
996
|
const issuerUrl = config.issuerUrl ?? "https://auth.fanvue.com";
|
|
@@ -309,13 +1013,15 @@ async function refreshAccessToken(config, refreshToken) {
|
|
|
309
1013
|
return (0, import_index_cjs.err)({
|
|
310
1014
|
code: "TOKEN_REFRESH_FAILED",
|
|
311
1015
|
statusCode: 0,
|
|
312
|
-
message: `Network error during token refresh: ${error instanceof Error ? error.message : String(error)}
|
|
1016
|
+
message: `Network error during token refresh: ${error instanceof Error ? error.message : String(error)}`,
|
|
1017
|
+
oauthError: null
|
|
313
1018
|
});
|
|
314
1019
|
}
|
|
315
1020
|
if (!response.ok) return (0, import_index_cjs.err)({
|
|
316
1021
|
code: "TOKEN_REFRESH_FAILED",
|
|
317
1022
|
statusCode: response.status,
|
|
318
|
-
message: `Token refresh failed: ${response.status} ${response.statusText}
|
|
1023
|
+
message: `Token refresh failed: ${response.status} ${response.statusText}`,
|
|
1024
|
+
oauthError: await readOAuthErrorBody(response)
|
|
319
1025
|
});
|
|
320
1026
|
const parseResult = safeJsonParse(await response.text());
|
|
321
1027
|
if (parseResult.isErr()) {
|
|
@@ -1648,6 +2354,609 @@ function createFanvueClient(accessToken, apiBaseUrl) {
|
|
|
1648
2354
|
return { getCurrentUser };
|
|
1649
2355
|
}
|
|
1650
2356
|
//#endregion
|
|
1651
|
-
|
|
2357
|
+
//#region src/core/machine-auth/machine-auth.ts
|
|
2358
|
+
/**
|
|
2359
|
+
* Shortest bearer secret the SDK will compare against.
|
|
2360
|
+
*
|
|
2361
|
+
* A cron endpoint is reachable by anyone on the internet and its secret is
|
|
2362
|
+
* never rotated by a user, so a guessable one is a standing invitation. Below
|
|
2363
|
+
* this length the secret is treated as absent — the endpoint reports
|
|
2364
|
+
* `not_configured` instead of quietly accepting a weak credential.
|
|
2365
|
+
*/
|
|
2366
|
+
const MINIMUM_BEARER_SECRET_LENGTH = 32;
|
|
2367
|
+
/**
|
|
2368
|
+
* The `Bearer` auth scheme and its separating whitespace.
|
|
2369
|
+
*
|
|
2370
|
+
* Case-insensitive because RFC 7235 defines auth scheme names as
|
|
2371
|
+
* case-insensitive — `bearer` is as valid on the wire as `Bearer`. Non-global,
|
|
2372
|
+
* so the shared instance carries no `lastIndex` between calls.
|
|
2373
|
+
*/
|
|
2374
|
+
const BEARER_SCHEME_PATTERN = /^bearer[ \t]+/i;
|
|
2375
|
+
const textEncoder = new TextEncoder();
|
|
2376
|
+
/**
|
|
2377
|
+
* Compares two strings in time independent of their content.
|
|
2378
|
+
*
|
|
2379
|
+
* Hand-rolled rather than `node:crypto`'s `timingSafeEqual` because
|
|
2380
|
+
* `src/core` must stay runtime-agnostic — it is also imported by browser and
|
|
2381
|
+
* edge bundles, which a `node:crypto` import would break. This is a byte
|
|
2382
|
+
* comparison, not a cryptographic primitive: every byte is XOR-accumulated with
|
|
2383
|
+
* no early exit, and the length check happens before any comparison (an
|
|
2384
|
+
* unavoidable leak of length, matching `timingSafeEqual`'s own precondition).
|
|
2385
|
+
*/
|
|
2386
|
+
function constantTimeTextEqual(left, right) {
|
|
2387
|
+
const leftBytes = textEncoder.encode(left);
|
|
2388
|
+
const rightBytes = textEncoder.encode(right);
|
|
2389
|
+
if (leftBytes.byteLength !== rightBytes.byteLength) return false;
|
|
2390
|
+
let difference = 0;
|
|
2391
|
+
for (let index = 0; index < leftBytes.byteLength; index += 1) difference |= (leftBytes[index] ?? 0) ^ (rightBytes[index] ?? 0);
|
|
2392
|
+
return difference === 0;
|
|
2393
|
+
}
|
|
2394
|
+
/**
|
|
2395
|
+
* Reads the bearer credential from an `Authorization` header.
|
|
2396
|
+
*
|
|
2397
|
+
* The scheme is required: a bare secret in the header is rejected, so a client
|
|
2398
|
+
* that forgets the scheme fails loudly rather than working by accident against
|
|
2399
|
+
* one deployment and not another. The scheme name itself is matched
|
|
2400
|
+
* case-insensitively, per RFC 7235 — rejecting `bearer` would be a lie about
|
|
2401
|
+
* what the header means.
|
|
2402
|
+
*/
|
|
2403
|
+
function bearerToken(request) {
|
|
2404
|
+
const header = request.headers.get("authorization");
|
|
2405
|
+
if (header === null) return null;
|
|
2406
|
+
const scheme = BEARER_SCHEME_PATTERN.exec(header);
|
|
2407
|
+
if (scheme === null) return null;
|
|
2408
|
+
return header.slice(scheme[0].length);
|
|
2409
|
+
}
|
|
2410
|
+
async function verifiesSignature(request, verifier, rawBody) {
|
|
2411
|
+
try {
|
|
2412
|
+
return await verifier.verify(request, rawBody ?? new Uint8Array(0));
|
|
2413
|
+
} catch {
|
|
2414
|
+
return false;
|
|
2415
|
+
}
|
|
2416
|
+
}
|
|
2417
|
+
/**
|
|
2418
|
+
* Authenticates a machine-invoked route (cron tick, queue drain, sweeper).
|
|
2419
|
+
*
|
|
2420
|
+
* Two strategies, tried in order, either of which is sufficient:
|
|
2421
|
+
*
|
|
2422
|
+
* 1. a shared bearer secret compared in constant time, and
|
|
2423
|
+
* 2. an app-supplied {@link SignedRequestVerifier}.
|
|
2424
|
+
*
|
|
2425
|
+
* Deny by default. When neither strategy is usable — no verifier and a missing
|
|
2426
|
+
* or too-short `bearerSecret` — the result is `not_configured`, which the
|
|
2427
|
+
* caller should surface as 503; `unauthorized` (401) means a strategy was
|
|
2428
|
+
* available and the request failed it.
|
|
2429
|
+
*
|
|
2430
|
+
* Web-standard `Request` only, so this works unchanged in a Next.js route
|
|
2431
|
+
* handler, a Node server and an edge function.
|
|
2432
|
+
*
|
|
2433
|
+
* @param request - The incoming machine request.
|
|
2434
|
+
* @param options - Available credentials and strategies.
|
|
2435
|
+
* @returns The discriminated {@link MachineAuthResult}.
|
|
2436
|
+
* @example
|
|
2437
|
+
* // Bearer-only cron route.
|
|
2438
|
+
* const auth = await requireMachineAuth(request, {
|
|
2439
|
+
* bearerSecret: process.env.CRON_SECRET ?? null,
|
|
2440
|
+
* signedRequestVerifier: null,
|
|
2441
|
+
* rawBody: null,
|
|
2442
|
+
* });
|
|
2443
|
+
* if (auth.status === 'not_configured') return new Response(null, { status: 503 });
|
|
2444
|
+
* if (auth.status === 'unauthorized') return new Response(null, { status: 401 });
|
|
2445
|
+
* @example
|
|
2446
|
+
* // Queue drain that also accepts QStash-signed deliveries. `@upstash/qstash`
|
|
2447
|
+
* // is intentionally NOT a dependency of this package — the app owns it:
|
|
2448
|
+
* //
|
|
2449
|
+
* // import { Receiver } from '@upstash/qstash';
|
|
2450
|
+
* //
|
|
2451
|
+
* // const qstashVerifier: SignedRequestVerifier = {
|
|
2452
|
+
* // verify: async (request, rawBody) => {
|
|
2453
|
+
* // const signature = request.headers.get('upstash-signature');
|
|
2454
|
+
* // const currentSigningKey = process.env.QSTASH_CURRENT_SIGNING_KEY;
|
|
2455
|
+
* // const nextSigningKey = process.env.QSTASH_NEXT_SIGNING_KEY;
|
|
2456
|
+
* // if (!signature || !currentSigningKey || !nextSigningKey) return false;
|
|
2457
|
+
* // return new Receiver({ currentSigningKey, nextSigningKey }).verify({
|
|
2458
|
+
* // signature,
|
|
2459
|
+
* // body: new TextDecoder().decode(rawBody),
|
|
2460
|
+
* // url: request.url,
|
|
2461
|
+
* // upstashRegion: request.headers.get('upstash-region') ?? undefined,
|
|
2462
|
+
* // });
|
|
2463
|
+
* // },
|
|
2464
|
+
* // };
|
|
2465
|
+
* //
|
|
2466
|
+
* // Pass the raw bytes, never a reparsed body:
|
|
2467
|
+
* const rawBody = new Uint8Array(await request.arrayBuffer());
|
|
2468
|
+
* const auth = await requireMachineAuth(request, {
|
|
2469
|
+
* bearerSecret: process.env.CRON_SECRET ?? null,
|
|
2470
|
+
* signedRequestVerifier: qstashVerifier,
|
|
2471
|
+
* rawBody,
|
|
2472
|
+
* });
|
|
2473
|
+
*/
|
|
2474
|
+
async function requireMachineAuth(request, options) {
|
|
2475
|
+
const { bearerSecret, signedRequestVerifier, rawBody } = options;
|
|
2476
|
+
const usableSecret = bearerSecret !== null && bearerSecret.length >= 32 ? bearerSecret : null;
|
|
2477
|
+
if (usableSecret === null && signedRequestVerifier === null) return { status: "not_configured" };
|
|
2478
|
+
if (usableSecret !== null) {
|
|
2479
|
+
const provided = bearerToken(request);
|
|
2480
|
+
if (provided !== null && constantTimeTextEqual(provided, usableSecret)) return { status: "authorized" };
|
|
2481
|
+
}
|
|
2482
|
+
if (signedRequestVerifier !== null) {
|
|
2483
|
+
if (await verifiesSignature(request, signedRequestVerifier, rawBody)) return { status: "authorized" };
|
|
2484
|
+
}
|
|
2485
|
+
return { status: "unauthorized" };
|
|
2486
|
+
}
|
|
2487
|
+
/**
|
|
2488
|
+
* Clamps a caller-supplied batch size to a safe positive bound.
|
|
2489
|
+
*
|
|
2490
|
+
* Machine routes take `?limit=` to let an operator drain faster, which is also
|
|
2491
|
+
* an unauthenticated-shaped lever on database load, so the value is bounded
|
|
2492
|
+
* rather than trusted. Anything unparseable, non-integer, zero or negative
|
|
2493
|
+
* falls back to the default; the default itself is capped by `max`, so a
|
|
2494
|
+
* misconfigured default cannot exceed the ceiling either.
|
|
2495
|
+
*
|
|
2496
|
+
* The result is always a positive safe integer, even when `defaultSize` or
|
|
2497
|
+
* `max` is itself invalid (non-integer, zero or negative): a query builder
|
|
2498
|
+
* handed a negative limit can interpret it as "no limit" — the exact fail-open
|
|
2499
|
+
* this function exists to prevent — so a misconfigured constant degrades to a
|
|
2500
|
+
* batch size of 1, never to an unbounded one.
|
|
2501
|
+
*
|
|
2502
|
+
* @param requested - Raw query value, e.g. `searchParams.get('limit')`.
|
|
2503
|
+
* @param defaultSize - Size to use when `requested` is absent or invalid.
|
|
2504
|
+
* @param max - Hard ceiling.
|
|
2505
|
+
* @returns A positive safe integer batch size.
|
|
2506
|
+
* @example
|
|
2507
|
+
* boundedBatchSize(null, 20, 100); // 20
|
|
2508
|
+
* boundedBatchSize('0', 20, 100); // 20
|
|
2509
|
+
* boundedBatchSize('1000', 20, 100); // 100
|
|
2510
|
+
*/
|
|
2511
|
+
function boundedBatchSize(requested, defaultSize, max) {
|
|
2512
|
+
const ceiling = Number.isSafeInteger(max) && max >= 1 ? max : 1;
|
|
2513
|
+
const fallback = Number.isSafeInteger(defaultSize) && defaultSize >= 1 ? Math.min(defaultSize, ceiling) : 1;
|
|
2514
|
+
if (requested === null) return fallback;
|
|
2515
|
+
const parsed = Number(requested);
|
|
2516
|
+
if (!Number.isSafeInteger(parsed) || parsed < 1) return fallback;
|
|
2517
|
+
return Math.min(parsed, ceiling);
|
|
2518
|
+
}
|
|
2519
|
+
//#endregion
|
|
2520
|
+
//#region src/core/observability/email.ts
|
|
2521
|
+
/**
|
|
2522
|
+
* An email address, as a pattern source.
|
|
2523
|
+
*
|
|
2524
|
+
* Deliberately loose rather than RFC 5322: redaction must fail closed, so
|
|
2525
|
+
* anything shaped like `local@domain.tld` is treated as an address. The TLD is
|
|
2526
|
+
* required to be alphabetic so version-shaped strings (`jose@6.0.11`) and
|
|
2527
|
+
* `host:port` fragments are not mistaken for addresses.
|
|
2528
|
+
*/
|
|
2529
|
+
const EMAIL_SOURCE = "[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,}";
|
|
2530
|
+
/**
|
|
2531
|
+
* Matches an email address anywhere in a string.
|
|
2532
|
+
*
|
|
2533
|
+
* Non-global, so `.test()` is stateless and safe to share.
|
|
2534
|
+
*
|
|
2535
|
+
* @internal
|
|
2536
|
+
*/
|
|
2537
|
+
const EMAIL_PATTERN = new RegExp(EMAIL_SOURCE, "i");
|
|
2538
|
+
/**
|
|
2539
|
+
* The global variant of {@link EMAIL_PATTERN}, for replacing every address
|
|
2540
|
+
* inside a string. Only ever used with `String.prototype.replace`, which
|
|
2541
|
+
* resets `lastIndex`, so the shared instance cannot carry state between calls.
|
|
2542
|
+
*
|
|
2543
|
+
* @internal
|
|
2544
|
+
*/
|
|
2545
|
+
const EMAIL_PATTERN_GLOBAL = new RegExp(EMAIL_SOURCE, "gi");
|
|
2546
|
+
/**
|
|
2547
|
+
* The placeholder substituted for a redacted email address.
|
|
2548
|
+
*
|
|
2549
|
+
* @internal
|
|
2550
|
+
*/
|
|
2551
|
+
const REDACTED_EMAIL = "[redacted-email]";
|
|
2552
|
+
//#endregion
|
|
2553
|
+
//#region src/core/observability/uuid.ts
|
|
2554
|
+
/** The one pattern source both exported regexes are built from. */
|
|
2555
|
+
const UUID_SOURCE = `(^|[^0-9a-f])[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?![0-9a-f])`;
|
|
2556
|
+
/**
|
|
2557
|
+
* Matches a canonically-formatted UUID anywhere in a string.
|
|
2558
|
+
*
|
|
2559
|
+
* Non-global, so `.test()` is stateless and safe to share.
|
|
2560
|
+
*
|
|
2561
|
+
* @internal
|
|
2562
|
+
*/
|
|
2563
|
+
const UUID_PATTERN = new RegExp(UUID_SOURCE, "i");
|
|
2564
|
+
/**
|
|
2565
|
+
* The global variant of {@link UUID_PATTERN}, for replacing every UUID inside a
|
|
2566
|
+
* string rather than testing for one.
|
|
2567
|
+
*
|
|
2568
|
+
* Group 1 is whatever preceded the UUID and must be re-emitted by the
|
|
2569
|
+
* replacement, hence {@link REDACTED_UUID_REPLACEMENT}. Only ever used with
|
|
2570
|
+
* `String.prototype.replace`, which resets `lastIndex`, so the shared instance
|
|
2571
|
+
* cannot carry state between calls.
|
|
2572
|
+
*
|
|
2573
|
+
* @internal
|
|
2574
|
+
*/
|
|
2575
|
+
const UUID_PATTERN_GLOBAL = new RegExp(UUID_SOURCE, "gi");
|
|
2576
|
+
/**
|
|
2577
|
+
* The placeholder substituted for a redacted UUID.
|
|
2578
|
+
*
|
|
2579
|
+
* @internal
|
|
2580
|
+
*/
|
|
2581
|
+
const REDACTED_UUID = "[redacted-uuid]";
|
|
2582
|
+
/**
|
|
2583
|
+
* The replacement string to pair with {@link UUID_PATTERN_GLOBAL}: re-emits the
|
|
2584
|
+
* captured preceding character, then the placeholder.
|
|
2585
|
+
*
|
|
2586
|
+
* @internal
|
|
2587
|
+
*/
|
|
2588
|
+
const REDACTED_UUID_REPLACEMENT = `$1${REDACTED_UUID}`;
|
|
2589
|
+
//#endregion
|
|
2590
|
+
//#region src/core/observability/log-fields.ts
|
|
2591
|
+
/**
|
|
2592
|
+
* The generic Fanvue log keys every app shares.
|
|
2593
|
+
*
|
|
2594
|
+
* Anything app-specific (a wheel id, a bounty id, a claim status) is added per
|
|
2595
|
+
* app via `createSafeLogFields(['wheelId', ...])`.
|
|
2596
|
+
*/
|
|
2597
|
+
const BASE_ALLOWED_LOG_KEYS = [
|
|
2598
|
+
"cause",
|
|
2599
|
+
"code",
|
|
2600
|
+
"creatorId",
|
|
2601
|
+
"errorName",
|
|
2602
|
+
"httpStatus",
|
|
2603
|
+
"reason",
|
|
2604
|
+
"retryCount",
|
|
2605
|
+
"status"
|
|
2606
|
+
];
|
|
2607
|
+
/**
|
|
2608
|
+
* Builds an allowlisting log-field filter.
|
|
2609
|
+
*
|
|
2610
|
+
* The returned function is the only sanctioned way to get caller-supplied
|
|
2611
|
+
* fields into a log line. It fails closed in three independent ways:
|
|
2612
|
+
*
|
|
2613
|
+
* 1. A key that is not allowlisted is dropped entirely (so a new field cannot
|
|
2614
|
+
* reach the logs until someone adds it to the list on purpose).
|
|
2615
|
+
* 2. A value that is not `string | number | boolean | null` is dropped — an
|
|
2616
|
+
* `Error`, a response body or a token bag is never serialised, even under an
|
|
2617
|
+
* allowlisted key.
|
|
2618
|
+
* 3. An allowlisted string value containing a UUID becomes `'[redacted-uuid]'`
|
|
2619
|
+
* (platform UUIDs identify real people; any version matches, including v7 —
|
|
2620
|
+
* see `UUID_PATTERN`), and one containing an email address becomes
|
|
2621
|
+
* `'[redacted-email]'`. The whole value is replaced, not just the match,
|
|
2622
|
+
* because the surrounding text is usually the same identifier restated.
|
|
2623
|
+
*
|
|
2624
|
+
* `undefined` values are dropped rather than logged as `undefined`.
|
|
2625
|
+
*
|
|
2626
|
+
* @param extraAllowedKeys - App-specific keys to allow in addition to
|
|
2627
|
+
* {@link BASE_ALLOWED_LOG_KEYS}.
|
|
2628
|
+
* @returns A `safeLogFields(fields)` filter.
|
|
2629
|
+
* @example
|
|
2630
|
+
* const safeLogFields = createSafeLogFields(['wheelId']);
|
|
2631
|
+
* safeLogFields({ wheelId: 'wheel_1', retryCount: 2, accessToken: 'secret' });
|
|
2632
|
+
* // => { wheelId: 'wheel_1', retryCount: 2 }
|
|
2633
|
+
*/
|
|
2634
|
+
function createSafeLogFields(extraAllowedKeys = []) {
|
|
2635
|
+
const allowed = new Set([...BASE_ALLOWED_LOG_KEYS, ...extraAllowedKeys]);
|
|
2636
|
+
return function safeLogFields(fields = {}) {
|
|
2637
|
+
const safe = {};
|
|
2638
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
2639
|
+
if (!allowed.has(key) || value === void 0) continue;
|
|
2640
|
+
if (typeof value === "string") {
|
|
2641
|
+
if (UUID_PATTERN.test(value)) safe[key] = REDACTED_UUID;
|
|
2642
|
+
else if (EMAIL_PATTERN.test(value)) safe[key] = REDACTED_EMAIL;
|
|
2643
|
+
else safe[key] = value;
|
|
2644
|
+
continue;
|
|
2645
|
+
}
|
|
2646
|
+
if (value === null || typeof value === "number" || typeof value === "boolean") safe[key] = value;
|
|
2647
|
+
}
|
|
2648
|
+
return safe;
|
|
2649
|
+
};
|
|
2650
|
+
}
|
|
2651
|
+
/**
|
|
2652
|
+
* Reads a loggable name out of an unknown thrown value.
|
|
2653
|
+
*
|
|
2654
|
+
* Only the class name is returned — never `error.message`, which routinely
|
|
2655
|
+
* carries the very data the allowlist exists to keep out of the logs (upstream
|
|
2656
|
+
* response bodies, signed URLs, secrets embedded in connection strings).
|
|
2657
|
+
*
|
|
2658
|
+
* @param error - Any caught value.
|
|
2659
|
+
* @returns The `Error` subclass name, or the `typeof` of a non-Error throw.
|
|
2660
|
+
* @example
|
|
2661
|
+
* errorName(new TypeError('secret=abc')); // 'TypeError'
|
|
2662
|
+
* errorName('boom'); // 'string'
|
|
2663
|
+
*/
|
|
2664
|
+
function errorName(error) {
|
|
2665
|
+
return error instanceof Error ? error.name : typeof error;
|
|
2666
|
+
}
|
|
2667
|
+
/**
|
|
2668
|
+
* Builds a structured event logger over an allowlist.
|
|
2669
|
+
*
|
|
2670
|
+
* The emitted shape is `prefix` followed by `{ event, ...safeFields }`, matching
|
|
2671
|
+
* the convention the apps already log in, so existing log-based dashboards keep
|
|
2672
|
+
* working after the lift.
|
|
2673
|
+
*
|
|
2674
|
+
* @param options - Prefix, allowlist and sink overrides. Every field is
|
|
2675
|
+
* optional; the defaults are `'[fanvue]'`, `createSafeLogFields()` and the
|
|
2676
|
+
* global `console`.
|
|
2677
|
+
* @returns A `logEvent(level, event, fields)` function.
|
|
2678
|
+
* @example
|
|
2679
|
+
* const logEvent = createLogEvent({ prefix: '[spinwheel]' });
|
|
2680
|
+
* logEvent('warn', 'exchange.retry', { httpStatus: 503, retryCount: 1 });
|
|
2681
|
+
* // [spinwheel] { event: 'exchange.retry', httpStatus: 503, retryCount: 1 }
|
|
2682
|
+
*/
|
|
2683
|
+
function createLogEvent(options) {
|
|
2684
|
+
const prefix = options?.prefix ?? "[fanvue]";
|
|
2685
|
+
const safeLogFields = options?.safeLogFields ?? createSafeLogFields();
|
|
2686
|
+
const sink = options?.sink ?? console;
|
|
2687
|
+
return function logEvent(level, event, fields = {}) {
|
|
2688
|
+
const payload = {
|
|
2689
|
+
event,
|
|
2690
|
+
...safeLogFields(fields)
|
|
2691
|
+
};
|
|
2692
|
+
if (level === "error") {
|
|
2693
|
+
sink.error(prefix, payload);
|
|
2694
|
+
return;
|
|
2695
|
+
}
|
|
2696
|
+
if (level === "warn") {
|
|
2697
|
+
sink.warn(prefix, payload);
|
|
2698
|
+
return;
|
|
2699
|
+
}
|
|
2700
|
+
sink.info(prefix, payload);
|
|
2701
|
+
};
|
|
2702
|
+
}
|
|
2703
|
+
/**
|
|
2704
|
+
* A ready-made {@link LogEvent} over the base allowlist, prefixed `[fanvue]`.
|
|
2705
|
+
*
|
|
2706
|
+
* Use {@link createLogEvent} instead when the app has its own prefix or extra
|
|
2707
|
+
* allowlisted keys.
|
|
2708
|
+
*
|
|
2709
|
+
* @example
|
|
2710
|
+
* logEvent('error', 'session.exchange_failed', { errorName: errorName(err) });
|
|
2711
|
+
*/
|
|
2712
|
+
const logEvent = createLogEvent();
|
|
2713
|
+
//#endregion
|
|
2714
|
+
//#region src/core/observability/readiness.ts
|
|
2715
|
+
/**
|
|
2716
|
+
* The four OAuth credential variables an embedded Fanvue app needs before it
|
|
2717
|
+
* can complete a creator connection.
|
|
2718
|
+
*/
|
|
2719
|
+
const FANVUE_OAUTH_VARS = [
|
|
2720
|
+
"FANVUE_APP_UUID",
|
|
2721
|
+
"FANVUE_CLIENT_ID",
|
|
2722
|
+
"FANVUE_CLIENT_SECRET",
|
|
2723
|
+
"FANVUE_OAUTH_REDIRECT_URI"
|
|
2724
|
+
];
|
|
2725
|
+
/**
|
|
2726
|
+
* The app's own public base URL, checked for an https origin.
|
|
2727
|
+
*
|
|
2728
|
+
* `FANVUE_APP_BASE_URL` is the SDK-prefixed name; `APP_BASE_URL` is accepted
|
|
2729
|
+
* because the apps this was lifted from already set it.
|
|
2730
|
+
*/
|
|
2731
|
+
const APP_BASE_URL_VARS = ["FANVUE_APP_BASE_URL", "APP_BASE_URL"];
|
|
2732
|
+
function firstSetValue(env, names) {
|
|
2733
|
+
for (const name of names) {
|
|
2734
|
+
const value = env[name];
|
|
2735
|
+
if (value !== void 0 && value.length > 0) return value;
|
|
2736
|
+
}
|
|
2737
|
+
return null;
|
|
2738
|
+
}
|
|
2739
|
+
function isHttpsUrl(value) {
|
|
2740
|
+
if (value === null) return false;
|
|
2741
|
+
try {
|
|
2742
|
+
return new URL(value).protocol === "https:";
|
|
2743
|
+
} catch {
|
|
2744
|
+
return false;
|
|
2745
|
+
}
|
|
2746
|
+
}
|
|
2747
|
+
/**
|
|
2748
|
+
* Reports whether the generic Fanvue configuration an embedded app depends on
|
|
2749
|
+
* is present, as a list of named checks suitable for a `/api/readyz` body.
|
|
2750
|
+
*
|
|
2751
|
+
* Pure by construction — the environment is a parameter, so a deployment's
|
|
2752
|
+
* readiness can be asserted in tests without mutating `process.env`. Two
|
|
2753
|
+
* checks:
|
|
2754
|
+
*
|
|
2755
|
+
* - `app_url` — the app's public base URL is set and https. Fanvue refuses to
|
|
2756
|
+
* embed a non-https origin, so an http (or unset) value means creators would
|
|
2757
|
+
* see an empty frame rather than an error.
|
|
2758
|
+
* - `fanvue_oauth` — all four OAuth credential variables are present. The
|
|
2759
|
+
* detail names the missing variables, never their values.
|
|
2760
|
+
*
|
|
2761
|
+
* Provider-specific checks are deliberately out of scope: webhook signature
|
|
2762
|
+
* readiness belongs to the webhooks module (which owns the contract), and
|
|
2763
|
+
* queue/cron credentials belong to whichever machine-auth strategy the app
|
|
2764
|
+
* plugs in.
|
|
2765
|
+
*
|
|
2766
|
+
* @param env - Environment to read. Defaults to `process.env`; on a runtime
|
|
2767
|
+
* with no `process` global (browser, some edge workers) it defaults to an
|
|
2768
|
+
* empty environment, so the probe reports not-ready instead of throwing a
|
|
2769
|
+
* `ReferenceError`.
|
|
2770
|
+
* @returns One {@link ReadinessCheck} per generic concern, in a stable order.
|
|
2771
|
+
* @example
|
|
2772
|
+
* const checks = configurationReadiness();
|
|
2773
|
+
* const ready = checks.every((check) => check.ready);
|
|
2774
|
+
* return Response.json({ ready, checks }, { status: ready ? 200 : 503 });
|
|
2775
|
+
*/
|
|
2776
|
+
function configurationReadiness(env = typeof process === "undefined" ? {} : process.env) {
|
|
2777
|
+
const appUrlReady = isHttpsUrl(firstSetValue(env, APP_BASE_URL_VARS));
|
|
2778
|
+
const missingOauthVars = FANVUE_OAUTH_VARS.filter((name) => {
|
|
2779
|
+
const value = env[name];
|
|
2780
|
+
return value === void 0 || value.length === 0;
|
|
2781
|
+
});
|
|
2782
|
+
return [{
|
|
2783
|
+
name: "app_url",
|
|
2784
|
+
ready: appUrlReady,
|
|
2785
|
+
detail: appUrlReady ? null : `set ${APP_BASE_URL_VARS[0]} to an https:// origin`
|
|
2786
|
+
}, {
|
|
2787
|
+
name: "fanvue_oauth",
|
|
2788
|
+
ready: missingOauthVars.length === 0,
|
|
2789
|
+
detail: missingOauthVars.length === 0 ? null : `missing ${missingOauthVars.join(", ")}`
|
|
2790
|
+
}];
|
|
2791
|
+
}
|
|
2792
|
+
//#endregion
|
|
2793
|
+
//#region src/core/observability/sentry-scrubber.ts
|
|
2794
|
+
/**
|
|
2795
|
+
* Key-name fragments whose values are never allowed to reach an error tracker.
|
|
2796
|
+
*
|
|
2797
|
+
* Matched as a substring, case-insensitively, so `Authorization`,
|
|
2798
|
+
* `authorizationHeader`, `x-authorization`, `x-api-key` and `apiKey` all hit.
|
|
2799
|
+
* Substring matching over-redacts on purpose (`emailSent`, `iphoneModel`) —
|
|
2800
|
+
* losing a benign field fails closed, letting a credential or a fan's contact
|
|
2801
|
+
* detail through does not. Anything app-specific (`prizedetail`, `fanhmac`,
|
|
2802
|
+
* `seed`, …) is added per app via the `extraSensitiveKeyPattern` argument.
|
|
2803
|
+
*/
|
|
2804
|
+
const BASE_SENSITIVE_KEY_PATTERN = /(authorization|cookie|token|secret|password|signedurl|api[-_]?key|credential|email|phone|ip[-_]?address)/i;
|
|
2805
|
+
/** The placeholder substituted for a value under a sensitive key. */
|
|
2806
|
+
const REDACTED = "[redacted]";
|
|
2807
|
+
/**
|
|
2808
|
+
* The placeholder substituted for an object already visited anywhere in this
|
|
2809
|
+
* event.
|
|
2810
|
+
*
|
|
2811
|
+
* The guard is per-event rather than per-branch, so a value referenced twice
|
|
2812
|
+
* shows as `[circular]` the second time even when there is no cycle. That is
|
|
2813
|
+
* deliberate: it bounds the work to one visit per node, which a branch-scoped
|
|
2814
|
+
* guard would not (a shallow object graph that shares children can be walked an
|
|
2815
|
+
* exponential number of times), and it matches how Sentry's own `normalize`
|
|
2816
|
+
* memoises. Same reasoning, same trade-off.
|
|
2817
|
+
*/
|
|
2818
|
+
const CIRCULAR = "[circular]";
|
|
2819
|
+
/** The placeholder substituted for a property whose getter threw. */
|
|
2820
|
+
const UNREADABLE = "[unreadable]";
|
|
2821
|
+
/** The placeholder substituted for a node nested deeper than {@link MAX_DEPTH}. */
|
|
2822
|
+
const TOO_DEEP = "[max-depth]";
|
|
2823
|
+
/**
|
|
2824
|
+
* How far into an event the scrubber will walk.
|
|
2825
|
+
*
|
|
2826
|
+
* Recursion has to be bounded: an event can carry an arbitrarily nested object
|
|
2827
|
+
* (a linked list, a parsed AST, a deeply chained `cause`), and blowing the call
|
|
2828
|
+
* stack inside `beforeSend` turns a scrub into a thrown `RangeError` — the event
|
|
2829
|
+
* is lost and the throw surfaces wherever Sentry was invoked. Anything deeper is
|
|
2830
|
+
* replaced rather than traversed, which fails closed: an untraversed subtree
|
|
2831
|
+
* cannot leak. Generous next to Sentry's own default normalisation depth of 3.
|
|
2832
|
+
*/
|
|
2833
|
+
const MAX_DEPTH = 32;
|
|
2834
|
+
/**
|
|
2835
|
+
* Merges the base sensitive-key pattern with an app-supplied one.
|
|
2836
|
+
*
|
|
2837
|
+
* The result is rebuilt from `source` with only the `i` flag so a caller
|
|
2838
|
+
* passing a `/g` pattern cannot make matching stateful (a shared `lastIndex`
|
|
2839
|
+
* would make redaction depend on call order — i.e. fail open intermittently).
|
|
2840
|
+
*/
|
|
2841
|
+
function mergeSensitiveKeyPattern(extra) {
|
|
2842
|
+
if (extra === null) return BASE_SENSITIVE_KEY_PATTERN;
|
|
2843
|
+
return new RegExp(`(?:${BASE_SENSITIVE_KEY_PATTERN.source})|(?:${extra.source})`, "i");
|
|
2844
|
+
}
|
|
2845
|
+
/**
|
|
2846
|
+
* Removes the credential-bearing parts of anything that parses as a URL.
|
|
2847
|
+
*
|
|
2848
|
+
* The whole query string goes, not named parameters — signed media URLs carry
|
|
2849
|
+
* their credential in parameters whose names differ per provider, so an
|
|
2850
|
+
* allowlist there would fail open. The fragment and the userinfo go for the same
|
|
2851
|
+
* reason: an OAuth redirect puts `access_token` in the fragment, and a
|
|
2852
|
+
* connection string puts the password in `user:pass@`, so keying redaction on
|
|
2853
|
+
* the query alone would leak both.
|
|
2854
|
+
*
|
|
2855
|
+
* A string that parses as a URL but carries none of those parts is returned
|
|
2856
|
+
* unchanged, so an ordinary log line that happens to look like a non-special URI
|
|
2857
|
+
* (`'note: something happened'`) is not mangled.
|
|
2858
|
+
*/
|
|
2859
|
+
function stripUrlCredentials(value) {
|
|
2860
|
+
let url;
|
|
2861
|
+
try {
|
|
2862
|
+
url = new URL(value);
|
|
2863
|
+
} catch {
|
|
2864
|
+
return value;
|
|
2865
|
+
}
|
|
2866
|
+
if (url.search === "" && url.hash === "" && url.username === "" && url.password === "") return value;
|
|
2867
|
+
url.search = "";
|
|
2868
|
+
url.hash = "";
|
|
2869
|
+
url.username = "";
|
|
2870
|
+
url.password = "";
|
|
2871
|
+
return `${url.toString()}?${REDACTED}`;
|
|
2872
|
+
}
|
|
2873
|
+
/**
|
|
2874
|
+
* Scrubs a string value: strips every UUID, then removes the credential-bearing
|
|
2875
|
+
* parts of anything that parses as a URL, then strips every email address.
|
|
2876
|
+
*
|
|
2877
|
+
* Emails go last so an address that survives URL stripping — a `mailto:` path,
|
|
2878
|
+
* a verification link's path segment — is still caught. A fan's email is PII in
|
|
2879
|
+
* its own right, and error messages routinely embed one (`user x@y.com not
|
|
2880
|
+
* found`).
|
|
2881
|
+
*/
|
|
2882
|
+
function scrubString(value) {
|
|
2883
|
+
return stripUrlCredentials(value.replace(UUID_PATTERN_GLOBAL, REDACTED_UUID_REPLACEMENT)).replace(EMAIL_PATTERN_GLOBAL, REDACTED_EMAIL);
|
|
2884
|
+
}
|
|
2885
|
+
/**
|
|
2886
|
+
* Reads one property without letting a throwing getter abort the whole scrub.
|
|
2887
|
+
*
|
|
2888
|
+
* Sentry events routinely carry app objects, and a getter that throws (a lazy
|
|
2889
|
+
* field, a revoked proxy, a Vue/Mobx observable read outside its scope) would
|
|
2890
|
+
* otherwise propagate out of `beforeSend`.
|
|
2891
|
+
*/
|
|
2892
|
+
function readProperty(source, key) {
|
|
2893
|
+
try {
|
|
2894
|
+
return Reflect.get(source, key);
|
|
2895
|
+
} catch {
|
|
2896
|
+
return UNREADABLE;
|
|
2897
|
+
}
|
|
2898
|
+
}
|
|
2899
|
+
function scrubValue(value, key, sensitiveKeyPattern, seen, depth) {
|
|
2900
|
+
if (sensitiveKeyPattern.test(key)) return REDACTED;
|
|
2901
|
+
if (typeof value === "string") return scrubString(value);
|
|
2902
|
+
if (value === null || typeof value !== "object") return value;
|
|
2903
|
+
if (seen.has(value)) return CIRCULAR;
|
|
2904
|
+
if (depth >= MAX_DEPTH) return TOO_DEEP;
|
|
2905
|
+
seen.add(value);
|
|
2906
|
+
if (Array.isArray(value)) {
|
|
2907
|
+
const items = [];
|
|
2908
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
2909
|
+
const item = readProperty(value, index);
|
|
2910
|
+
items.push(scrubValue(item, key, sensitiveKeyPattern, seen, depth + 1));
|
|
2911
|
+
}
|
|
2912
|
+
return items;
|
|
2913
|
+
}
|
|
2914
|
+
const scrubbed = {};
|
|
2915
|
+
for (const childKey of Object.keys(value)) scrubbed[childKey] = scrubValue(readProperty(value, childKey), childKey, sensitiveKeyPattern, seen, depth + 1);
|
|
2916
|
+
return scrubbed;
|
|
2917
|
+
}
|
|
2918
|
+
/**
|
|
2919
|
+
* Builds a recursive `beforeSend` scrubber for Sentry (or any error tracker
|
|
2920
|
+
* with the same hook shape).
|
|
2921
|
+
*
|
|
2922
|
+
* Four redaction rules, applied to every node of the event:
|
|
2923
|
+
*
|
|
2924
|
+
* - a key matching {@link BASE_SENSITIVE_KEY_PATTERN} (or the app's extra
|
|
2925
|
+
* pattern) has its value replaced with `'[redacted]'`, whatever its type;
|
|
2926
|
+
* - every UUID inside a string becomes `'[redacted-uuid]'` — any version,
|
|
2927
|
+
* including v7;
|
|
2928
|
+
* - a string that parses as a URL keeps its origin and path but loses its query
|
|
2929
|
+
* string, fragment and userinfo;
|
|
2930
|
+
* - every email address inside a string becomes `'[redacted-email]'`.
|
|
2931
|
+
*
|
|
2932
|
+
* Bounded and total by construction, because a `beforeSend` that throws loses
|
|
2933
|
+
* the event and raises inside the caller: repeat visits yield `'[circular]'`
|
|
2934
|
+
* (`WeakSet`), nodes past a depth of 32 yield `'[max-depth]'`, and a property
|
|
2935
|
+
* whose getter throws yields `'[unreadable]'`. Each of those fails closed — an
|
|
2936
|
+
* untraversed value cannot leak.
|
|
2937
|
+
*
|
|
2938
|
+
* Deliberately dependency-free: the event is typed structurally, so the privacy
|
|
2939
|
+
* behaviour is unit-testable without a Sentry client and this package takes no
|
|
2940
|
+
* dependency on `@sentry/*`.
|
|
2941
|
+
*
|
|
2942
|
+
* @param extraSensitiveKeyPattern - Extra key fragments to redact, merged with
|
|
2943
|
+
* the base pattern. Its flags are ignored; matching is always
|
|
2944
|
+
* case-insensitive and stateless.
|
|
2945
|
+
* @returns A function suitable for `Sentry.init({ beforeSend })`.
|
|
2946
|
+
* @example
|
|
2947
|
+
* Sentry.init({
|
|
2948
|
+
* dsn,
|
|
2949
|
+
* enabled: Boolean(dsn),
|
|
2950
|
+
* beforeSend: createSentryScrubber(/(prizedetail|fanhmac|seed)/i),
|
|
2951
|
+
* });
|
|
2952
|
+
*/
|
|
2953
|
+
function createSentryScrubber(extraSensitiveKeyPattern = null) {
|
|
2954
|
+
const sensitiveKeyPattern = mergeSensitiveKeyPattern(extraSensitiveKeyPattern);
|
|
2955
|
+
return function scrubSentryEvent(event) {
|
|
2956
|
+
return scrubValue(event, "", sensitiveKeyPattern, /* @__PURE__ */ new WeakSet(), 0);
|
|
2957
|
+
};
|
|
2958
|
+
}
|
|
2959
|
+
//#endregion
|
|
2960
|
+
export { FanvueStringListErrorBodySchema as $, OffsetPaginationSchema as A, UNPUBLISH_REQUEST_MESSAGE as B, FanvueUserSchema as C, DEFAULT_PAGE_SIZE as D, safeJsonParse as E, ExperienceMessageSchema as F, isPublishResultMessage as G, UnpublishRequestMessageSchema as H, PUBLISH_REQUEST_MESSAGE as I, FANVUE_APP_ERROR_CODES as J, isUnpublishResultMessage as K, PUBLISH_RESULT_MESSAGE as L, cursorPageSchema as M, offsetPageSchema as N, HybridPaginationSchema as O, EXPERIENCE_MESSAGE_TYPES as P, FanvueMessageErrorBodySchema as Q, PublishRequestMessageSchema as R, AuthorizeOnBehalfResponseSchema as S, TokenResponseSchema as T, UnpublishResultMessageSchema as U, UNPUBLISH_RESULT_MESSAGE as V, isFanvueOrigin as W, FanvueErrorFieldBodySchema as X, FanvueErrorBodySchema as Y, FanvueIssueListErrorBodySchema as Z, createSessionJwt as _, FANVUE_ACCESS_MODES as _t, createLogEvent as a, flagEnabled as at, exchangeCodeForToken as b, BEARER_PREFIX as bt, logEvent as c, resetFanvueEnvCache as ct, requireMachineAuth as d, DEFAULT_ISSUER_URL as dt, NON_SESSION_401_CODES as et, createFanvueClient as f, DEFAULT_PLATFORM_URL as ft, requestAuthorizationCodeOnBehalf as g, EXPERIENCE_ENTITLED_REASONS as gt, getThemeFromUrl as h, EXPERIENCE_DENIAL_REASONS as ht, BASE_ALLOWED_LOG_KEYS as i, fanvueEnv as it, clampPageSize as j, MAX_PAGE_SIZE as k, MINIMUM_BEARER_SECRET_LENGTH as l, API_VERSION as lt, getSessionTokenFromUrl as m, assertFanvueDomain as mt, createSentryScrubber as n, parseFanvueErrorBody as nt, createSafeLogFields as o, isFanvueConfigured as ot, exchangeSessionToken as p, DEFAULT_SCOPES as pt, AppErrorEnvelopeSchema as q, configurationReadiness as r, FanvueEnvSchema as rt, errorName as s, parseFanvueEnv as st, BASE_SENSITIVE_KEY_PATTERN as t, OAuthErrorBodySchema as tt, boundedBatchSize as u, DEFAULT_API_BASE_URL as ut, verifySessionJwt as v, FanvueAccessModeSchema as vt, SessionPayloadSchema as w, refreshAccessToken as x, HEADER_UPDATED_SESSION as xt, createAuthorizationUrl as y, accessModeFromDenialReason as yt, PublishResultMessageSchema as z };
|
|
1652
2961
|
|
|
1653
|
-
//# sourceMappingURL=core-
|
|
2962
|
+
//# sourceMappingURL=core-BhKiA55a.js.map
|