@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.
Files changed (39) hide show
  1. package/README.md +284 -0
  2. package/dist/bridge/index.d.ts +1 -1
  3. package/dist/bridge/index.js +2 -2
  4. package/dist/{bridge-CGVtI3hr.js → bridge-QnF7P4Co.js} +2 -2
  5. package/dist/{bridge-CGVtI3hr.js.map → bridge-QnF7P4Co.js.map} +1 -1
  6. package/dist/core/index.d.ts +2 -2
  7. package/dist/core/index.js +3 -3
  8. package/dist/{core-BqdYUMrJ.js → core-BhKiA55a.js} +1318 -9
  9. package/dist/core-BhKiA55a.js.map +1 -0
  10. package/dist/index-C3CXrRiw.d.ts +1412 -0
  11. package/dist/index-C3CXrRiw.d.ts.map +1 -0
  12. package/dist/{index-BRDYLBlc.d.ts → index-CEFYZtlb.d.ts} +2 -2
  13. package/dist/{index-BRDYLBlc.d.ts.map → index-CEFYZtlb.d.ts.map} +1 -1
  14. package/dist/{index-Dr-mZ0qP.d.ts → index-cf3ZMnLJ.d.ts} +26 -2
  15. package/dist/index-cf3ZMnLJ.d.ts.map +1 -0
  16. package/dist/index-v_6Z4Q0u.d.ts +84 -0
  17. package/dist/index-v_6Z4Q0u.d.ts.map +1 -0
  18. package/dist/{schemas-DbyHF7Xi.js → index.cjs-teGsk6HB.js} +1058 -977
  19. package/dist/index.cjs-teGsk6HB.js.map +1 -0
  20. package/dist/nextjs/embedded-app/index.d.ts +2 -2
  21. package/dist/nextjs/embedded-app/index.js +3 -3
  22. package/dist/nextjs/off-platform/index.d.ts +3 -3
  23. package/dist/nextjs/off-platform/index.d.ts.map +1 -1
  24. package/dist/nextjs/off-platform/index.js +4 -4
  25. package/dist/nextjs/off-platform/index.js.map +1 -1
  26. package/dist/nextjs-CvVhtMdb.js +144 -0
  27. package/dist/nextjs-CvVhtMdb.js.map +1 -0
  28. package/dist/react/index.d.ts +2 -2
  29. package/dist/react/index.js +3 -3
  30. package/package.json +1 -1
  31. package/dist/core-BqdYUMrJ.js.map +0 -1
  32. package/dist/index-C4ewLil3.d.ts +0 -48
  33. package/dist/index-C4ewLil3.d.ts.map +0 -1
  34. package/dist/index-ClbZoV_Z.d.ts +0 -420
  35. package/dist/index-ClbZoV_Z.d.ts.map +0 -1
  36. package/dist/index-Dr-mZ0qP.d.ts.map +0 -1
  37. package/dist/nextjs-B5Tqgt_n.js +0 -80
  38. package/dist/nextjs-B5Tqgt_n.js.map +0 -1
  39. package/dist/schemas-DbyHF7Xi.js.map +0 -1
@@ -1,10 +1,105 @@
1
- import { a as number, c as string, f as require_index_cjs, o as object, r as boolean } from "./schemas-DbyHF7Xi.js";
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 failure.
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 failure.
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
- export { HEADER_UPDATED_SESSION as C, BEARER_PREFIX as S, DEFAULT_API_BASE_URL as _, requestAuthorizationCodeOnBehalf as a, DEFAULT_SCOPES as b, createAuthorizationUrl as c, AuthorizeOnBehalfResponseSchema as d, FanvueUserSchema as f, API_VERSION as g, safeJsonParse as h, getThemeFromUrl as i, exchangeCodeForToken as l, TokenResponseSchema as m, exchangeSessionToken as n, createSessionJwt as o, SessionPayloadSchema as p, getSessionTokenFromUrl as r, verifySessionJwt as s, createFanvueClient as t, refreshAccessToken as u, DEFAULT_ISSUER_URL as v, assertFanvueDomain as x, DEFAULT_PLATFORM_URL as y };
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-BqdYUMrJ.js.map
2962
+ //# sourceMappingURL=core-BhKiA55a.js.map