@nebulr-group/bridge-svelte 0.9.0-beta.0 → 0.9.0-beta.2

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 (53) hide show
  1. package/dist/client/BridgeBootstrap.d.ts +6 -0
  2. package/dist/client/BridgeBootstrap.js +79 -10
  3. package/dist/client/BridgeBootstrap.svelte +39 -4
  4. package/dist/client/auth-routes.d.ts +33 -0
  5. package/dist/client/auth-routes.js +70 -0
  6. package/dist/client/billing-role.d.ts +9 -0
  7. package/dist/client/billing-role.js +36 -0
  8. package/dist/client/billing-routes.d.ts +72 -0
  9. package/dist/client/billing-routes.js +97 -0
  10. package/dist/client/components/sdk-auth/BridgeAuthRoutes.svelte +202 -0
  11. package/dist/client/components/sdk-auth/BridgeAuthRoutes.svelte.d.ts +25 -0
  12. package/dist/client/components/sdk-auth/ForgotPassword.svelte +10 -0
  13. package/dist/client/components/sdk-auth/ForgotPassword.svelte.d.ts +8 -0
  14. package/dist/client/components/sdk-auth/MagicLink.svelte +10 -0
  15. package/dist/client/components/sdk-auth/MagicLink.svelte.d.ts +8 -0
  16. package/dist/client/components/sdk-auth/PasskeySetup.svelte +16 -1
  17. package/dist/client/components/sdk-auth/PasskeySetup.svelte.d.ts +8 -0
  18. package/dist/client/components/sdk-auth/SignupForm.svelte +15 -1
  19. package/dist/client/components/sdk-auth/SignupForm.svelte.d.ts +7 -0
  20. package/dist/client/components/subscription/BillingPortalButton.svelte +68 -0
  21. package/dist/client/components/subscription/BillingPortalButton.svelte.d.ts +8 -0
  22. package/dist/client/components/subscription/BridgeBillingNotice.svelte +5 -9
  23. package/dist/client/components/subscription/BridgeBillingRoutes.svelte +174 -0
  24. package/dist/client/components/subscription/BridgeBillingRoutes.svelte.d.ts +14 -0
  25. package/dist/client/components/subscription/BridgePaywallPage.svelte +96 -0
  26. package/dist/client/components/subscription/BridgePaywallPage.svelte.d.ts +20 -0
  27. package/dist/client/components/subscription/BridgeQuotaBanner.svelte +13 -17
  28. package/dist/client/components/subscription/BridgeUpgradeDialog.svelte +69 -0
  29. package/dist/client/components/subscription/BridgeUpgradeDialog.svelte.d.ts +4 -0
  30. package/dist/client/components/subscription/Entitled.svelte +43 -0
  31. package/dist/client/components/subscription/Entitled.svelte.d.ts +14 -0
  32. package/dist/client/components/subscription/QuotaGate.svelte +76 -0
  33. package/dist/client/components/subscription/QuotaGate.svelte.d.ts +18 -0
  34. package/dist/client/upgrade-dialog.d.ts +16 -0
  35. package/dist/client/upgrade-dialog.js +26 -0
  36. package/dist/core/bridge-fetch.d.ts +22 -0
  37. package/dist/core/bridge-fetch.js +106 -9
  38. package/dist/core/bridge.d.ts +42 -4
  39. package/dist/core/bridge.js +23 -3
  40. package/dist/core/entitlements.d.ts +35 -0
  41. package/dist/core/entitlements.js +64 -0
  42. package/dist/core/quota-refusal.d.ts +92 -0
  43. package/dist/core/quota-refusal.js +168 -0
  44. package/dist/core/use-bridge.d.ts +6 -6
  45. package/dist/core/use-bridge.js +18 -17
  46. package/dist/core/use-quota.d.ts +36 -0
  47. package/dist/core/use-quota.js +191 -0
  48. package/dist/index.d.ts +31 -2
  49. package/dist/index.js +48 -3
  50. package/dist/shared/types/config.d.ts +50 -10
  51. package/package.json +3 -3
  52. package/dist/client/BridgeProvider.svelte +0 -31
  53. package/dist/client/BridgeProvider.svelte.d.ts +0 -8
@@ -21,6 +21,12 @@ export interface BridgeBootstrapData {
21
21
  export type BridgeBootstrapLoad = (event: {
22
22
  url: URL;
23
23
  fetch: typeof globalThis.fetch;
24
+ /** Route params — read to 404 an unknown `[...bridge]` page (TBP-696, TBP-702). */
25
+ params?: Record<string, string>;
26
+ /** The matched route — only its id is read. */
27
+ route?: {
28
+ id: string | null;
29
+ };
24
30
  }) => Promise<BridgeBootstrapData>;
25
31
  /**
26
32
  * Start Bridge from your root layout. Returns the layout's `load` function.
@@ -1,5 +1,5 @@
1
1
  // src/lib/bridge/bootstrap.ts
2
- import { redirect, isRedirect } from '@sveltejs/kit';
2
+ import { error, redirect, isRedirect } from '@sveltejs/kit';
3
3
  import { get } from 'svelte/store';
4
4
  import { createRouteGuard } from '../auth/route-guard.js';
5
5
  import { dropFlagCache, guardCacheGeneration } from '../auth/guard-cache.js';
@@ -9,6 +9,8 @@ import { useBridge, sanitizeReturnTo, stashReturnTo, takeReturnTo, withReturnTo,
9
9
  import { logger } from '../shared/logger.js';
10
10
  import { bridgeConfig, getConfig, getRouteGuardConfig } from './stores/config.store.js';
11
11
  import { resolveBridgeConfig } from './resolve-config.js';
12
+ import { BRIDGE_AUTH_ROUTE_PARAM, isBridgeAuthRouteId, parseBridgeAuthRoute } from './auth-routes.js';
13
+ import { appUsesBilling, billingRoutes, isPaywallExempt, parseBridgeBillingRoute, resolveBillingRoutes, } from './billing-routes.js';
12
14
  // TBP-653 — `bridgeBootstrap` used to short-circuit on every call after the
13
15
  // first completed one, and the route guard lived below that return. SvelteKit
14
16
  // re-runs the root layout load for every navigation (it reads `url`), so the
@@ -49,12 +51,68 @@ function createBootstrapLoad(options) {
49
51
  // Resolved on the first call, not at import: a missing app id must surface
50
52
  // as a load error the developer sees, and the environment is only final then.
51
53
  let resolved = null;
52
- return async ({ url, fetch }) => {
54
+ return async ({ url, fetch, params, route }) => {
53
55
  resolved ??= resolveBridgeConfig(configOptions);
56
+ rejectUnknownBridgePage(route?.id, params, resolved);
54
57
  await runBootstrap(url, resolved, routeConfig, fetch);
55
58
  return { config: getConfig(), routeConfig };
56
59
  };
57
60
  }
61
+ /**
62
+ * TBP-696 — `src/routes/auth/[...bridge]/+page.svelte` matches every address
63
+ * under `/auth`, including ones Bridge serves no page for. Those must get the
64
+ * app's own 404, and only a `load` can produce it: a component that throws
65
+ * while rendering never reaches the app's error page. The root layout load is
66
+ * the one `load` every Bridge app already has, so the check lives here and the
67
+ * app writes no `+page.ts` of its own.
68
+ *
69
+ * Only a route whose rest param is literally `[...bridge]` is checked, so an
70
+ * app's own catch-all is never touched. SvelteKit re-runs this load with empty
71
+ * params to render its error page; `params.bridge` is then absent and the
72
+ * check stands aside.
73
+ *
74
+ * TBP-702 — the billing catch-all (`src/routes/subscription/[...bridge]`) uses
75
+ * the same param, and the load cannot see which component a page renders. It
76
+ * tells them apart by where the catch-all lives: under `manageRoute`
77
+ * (`/subscription` by default) it is the billing one; under the directory of
78
+ * `loginRoute` (`/auth` for `/auth/login`) it is the auth one. A catch-all
79
+ * anywhere else accepts a page of either kind.
80
+ */
81
+ function rejectUnknownBridgePage(routeId, params, config) {
82
+ if (!isBridgeAuthRouteId(routeId))
83
+ return;
84
+ const rest = params?.[BRIDGE_AUTH_ROUTE_PARAM];
85
+ if (rest === undefined)
86
+ return;
87
+ const kind = catchAllKind(routeId, config);
88
+ const known = kind === 'billing'
89
+ ? parseBridgeBillingRoute(rest)
90
+ : kind === 'auth'
91
+ ? parseBridgeAuthRoute(rest)
92
+ : parseBridgeAuthRoute(rest) ?? parseBridgeBillingRoute(rest);
93
+ if (!known)
94
+ error(404, 'Not Found');
95
+ }
96
+ /** Which Bridge catch-all a `…/[...bridge]` route id is, from where it lives. */
97
+ function catchAllKind(routeId, config) {
98
+ // The address the catch-all serves: drop the rest param and any `(group)`
99
+ // segments, which never appear in a URL.
100
+ const base = routeId
101
+ .split('/')
102
+ .filter((s) => s !== '' && !/^\(.*\)$/.test(s))
103
+ .slice(0, -1)
104
+ .join('/');
105
+ const at = `/${base}`;
106
+ const trim = (p) => (p.length > 1 ? p.replace(/\/+$/, '') : p);
107
+ if (at === trim(resolveBillingRoutes(config.billing).manageRoute))
108
+ return 'billing';
109
+ if (config.loginRoute) {
110
+ const loginDir = trim(config.loginRoute).replace(/\/[^/]*$/, '') || '/';
111
+ if (at === loginDir)
112
+ return 'auth';
113
+ }
114
+ return 'either';
115
+ }
58
116
  async function runBootstrap(url, config, routeConfig = { rules: [], defaultAccess: 'protected' }, kitFetch) {
59
117
  // Until one call has completed, a call may be the one that lands on a
60
118
  // callback URL or needs the no-flash paywall redirect. Afterwards those are
@@ -195,7 +253,6 @@ function ensureInitialised() {
195
253
  })();
196
254
  return _initialisation;
197
255
  }
198
- const STRIPE_DEFAULT_RETURN = '/subscription';
199
256
  // Where a Stripe success/cancel return lands (TBP-659).
200
257
  //
201
258
  // The `redirect` parameter arrives on the app's own callback URL, so anyone can
@@ -217,9 +274,11 @@ const STRIPE_DEFAULT_RETURN = '/subscription';
217
274
  // Validation runs on the stripped string because that is the one we navigate to.
218
275
  function stripeReturnTarget(url) {
219
276
  const raw = url.searchParams.get('redirect');
277
+ // The subscription page (TBP-702: `billing.manageRoute`, `/subscription` by default).
278
+ const fallback = billingRoutes().manageRoute;
220
279
  if (raw === null)
221
- return STRIPE_DEFAULT_RETURN;
222
- return sanitizeReturnTo(raw.split('?')[0]) ?? STRIPE_DEFAULT_RETURN;
280
+ return fallback;
281
+ return sanitizeReturnTo(raw.split('?')[0]) ?? fallback;
223
282
  }
224
283
  // Unified callback handler — detects what is calling back and routes accordingly
225
284
  async function handleCallbackRoute(url, kitFetch) {
@@ -286,7 +345,8 @@ async function handleCallbackRoute(url, kitFetch) {
286
345
  if (isRedirect(err))
287
346
  throw err;
288
347
  logger.warn('[bridgeBootstrap] confirm-checkout error', err);
289
- redirect(303, getConfig().billing?.paymentErrorRoute ?? '/payment-error');
348
+ // TBP-702 — `/subscription/error` by default, served by <BridgeBillingRoutes>.
349
+ redirect(303, billingRoutes().paymentErrorRoute);
290
350
  }
291
351
  }
292
352
  else if (stripeCancel) {
@@ -309,14 +369,23 @@ async function handleCallbackRoute(url, kitFetch) {
309
369
  // decision (authenticated + shouldSelectPlan + not opted out via
310
370
  // paymentsAutoRedirect) lives in auth-core's shouldRedirectToPaywall()
311
371
  // (TBP-369). We only own the route/config guards here:
312
- // - billing.paywallRoute is configured
313
- // - the current path is not already the paywall route (no redirect loop)
372
+ // - billing.paywallRoute is not turned off (TBP-702: it defaults to
373
+ // `/subscription/plan`, served by <BridgeBillingRoutes>)
374
+ // - an explicit paywallRoute always applies; the default only when the app
375
+ // uses billing (it has plans) — an app without billing has only plan-less
376
+ // workspaces and no paywall page. The plan list is fetched only here, for a
377
+ // workspace that would otherwise be redirected.
378
+ // - the current path is not the paywall (no redirect loop) or the
379
+ // payment-error page (a failed checkout must be readable)
314
380
  async function enforcePaywall(url) {
315
381
  try {
316
- const paywallRoute = getConfig().billing?.paywallRoute;
317
- if (paywallRoute && url.pathname !== paywallRoute) {
382
+ const routes = billingRoutes();
383
+ const paywallRoute = routes.paywallRoute;
384
+ if (paywallRoute && !isPaywallExempt(url.pathname, routes)) {
318
385
  const bridge = getBridgeAuth();
319
386
  if (await bridge.shouldRedirectToPaywall()) {
387
+ if (routes.paywallIsDefault && !appUsesBilling(await bridge.getPlans()))
388
+ return;
320
389
  logger.debug('[bridgeBootstrap] paywall redirect', paywallRoute);
321
390
  redirect(303, paywallRoute);
322
391
  }
@@ -15,6 +15,7 @@
15
15
  import { bridge as bridgeSurface } from '../core/bridge.js';
16
16
  import { setBridgeContext } from '../core/use-bridge.js';
17
17
  import { getConfig, getRouteGuardConfig } from './stores/config.store.js';
18
+ import { appUsesBilling, billingRoutes, isPaywallExempt } from './billing-routes.js';
18
19
  import {
19
20
  onBridgeAuthorizationChange,
20
21
  onBridgeFlagChange,
@@ -23,6 +24,10 @@
23
24
  type StartBridgeRuntimeOptions,
24
25
  } from '../core/bridge-runtime.js';
25
26
  import RealtimeDevBadge from './components/developer/RealtimeDevBadge.svelte';
27
+ import BridgeUpgradeDialog from './components/subscription/BridgeUpgradeDialog.svelte';
28
+ import { dismissQuotaRefusal, quotaRefusal } from '../core/quota-refusal.js';
29
+ import { resolveUpgradeDialog, upgradeHrefFor } from './upgrade-dialog.js';
30
+ import { isBillingAdmin } from './billing-role.js';
26
31
 
27
32
  // TBP-644 — the "Live updates off — why?" badge is mounted here so every app
28
33
  // gets it without code changes. It renders in development builds only;
@@ -35,6 +40,24 @@
35
40
  }
36
41
  })();
37
42
 
43
+ // TBP-703 — the upgrade dialog is mounted here so a page needs no Bridge code:
44
+ // when the app's backend refuses a request at a plan limit (402
45
+ // QUOTA_EXCEEDED), the fetch wrapper / bridgeFetch report it and this opens.
46
+ // On by default; `billing.upgradeDialog: false` turns it off, a component
47
+ // replaces it.
48
+ const billingConfig = (() => {
49
+ try {
50
+ return getConfig().billing;
51
+ } catch {
52
+ return undefined;
53
+ }
54
+ })();
55
+ const upgradeDialog = resolveUpgradeDialog(billingConfig);
56
+ const UpgradeDialog = upgradeDialog === 'default' ? BridgeUpgradeDialog : upgradeDialog;
57
+ const upgradeHref = $derived(upgradeHrefFor($quotaRefusal, billingConfig));
58
+ // Re-read for every refusal: the same owner rule as <BridgeQuotaBanner>.
59
+ const canUpgrade = $derived($quotaRefusal ? isBillingAdmin() : false);
60
+
38
61
  // Props: optional `runtime` overrides for advanced/debug use (websocketFactory,
39
62
  // reconnect overrides, etc.); `onBootstrapComplete` callback fires after the
40
63
  // runtime + any auto-detected capabilities (flags) have attached.
@@ -72,11 +95,15 @@
72
95
  // `shouldSelectPlan` resolves true. Same data source as <BridgePaywall>, so the
73
96
  // overlay and the redirect agree. The load() redirect remains a no-flash
74
97
  // fast-path for direct loads/refreshes only.
98
+ //
99
+ // TBP-702 — the paywall defaults to `/subscription/plan` (served by
100
+ // <BridgeBillingRoutes>); `billing.paywallRoute: false` turns it off.
75
101
  $effect(() => {
76
- const paywallRoute = getConfig().billing?.paywallRoute;
102
+ const routes = billingRoutes();
103
+ const paywallRoute = routes.paywallRoute;
77
104
  if (!paywallRoute || !$isAuthenticated) return;
78
105
 
79
- const { status, loading, error } = $subscriptionStore;
106
+ const { status, plans, loading, error } = $subscriptionStore;
80
107
 
81
108
  // Status unknown → trigger a single load. Guarding on `!error` avoids a
82
109
  // tight refetch loop on persistent failure (fail-pending, not fail-open);
@@ -88,11 +115,15 @@
88
115
 
89
116
  // Status known → enforce. `$page.url.pathname` makes this re-run on
90
117
  // navigation too, so manual nav to a protected page while plan-less is
91
- // also caught. Path guard prevents a redirect loop on the paywall itself.
118
+ // also caught. Path guard prevents a redirect loop on the paywall itself,
119
+ // and leaves the payment-error page readable.
120
+ // TBP-702 — the default paywall only applies to an app that uses billing
121
+ // (has plans); an explicit paywallRoute always applies.
92
122
  if (
93
123
  status?.shouldSelectPlan === true &&
94
124
  status?.paymentsAutoRedirect !== false &&
95
- $page.url.pathname !== paywallRoute
125
+ (!routes.paywallIsDefault || appUsesBilling(plans)) &&
126
+ !isPaywallExempt($page.url.pathname, routes)
96
127
  ) {
97
128
  goto(paywallRoute);
98
129
  }
@@ -251,6 +282,10 @@
251
282
 
252
283
  <RealtimeDevBadge enabled={devBadgeEnabled} />
253
284
 
285
+ {#if UpgradeDialog}
286
+ <UpgradeDialog refusal={$quotaRefusal} {upgradeHref} {canUpgrade} onclose={dismissQuotaRefusal} />
287
+ {/if}
288
+
254
289
  {#if runtimeAttached && $bridgeReadyStore}
255
290
  {@render children?.()}
256
291
  {/if}
@@ -0,0 +1,33 @@
1
+ /** Every page `<BridgeAuthRoutes>` serves, by its first path segment. */
2
+ export declare const BRIDGE_AUTH_PAGES: readonly ["login", "signup", "oauth-callback", "set-password", "forgot-password", "magic-link", "setup-passkey", "workspaces"];
3
+ /** One of the pages `<BridgeAuthRoutes>` serves. */
4
+ export type BridgeAuthPage = (typeof BRIDGE_AUTH_PAGES)[number];
5
+ /** A parsed auth route: which page, and the email-link token where it has one. */
6
+ export interface BridgeAuthRoute {
7
+ page: BridgeAuthPage;
8
+ /** The one-time token of `set-password/[token]` and `setup-passkey/[token]`. */
9
+ token?: string;
10
+ }
11
+ /** The rest parameter name the catch-all route must use: `[...bridge]`. */
12
+ export declare const BRIDGE_AUTH_ROUTE_PARAM = "bridge";
13
+ /**
14
+ * Parse the `[...bridge]` rest parameter into a page, or `null` when it names
15
+ * no page Bridge serves — the caller then answers with the app's own 404.
16
+ *
17
+ * Exact shapes only: `login` but not `login/extra`, and `set-password/<token>`
18
+ * but not a bare `set-password`. A page that half-matches would otherwise render
19
+ * a form at an address nobody links to.
20
+ */
21
+ export declare function parseBridgeAuthRoute(rest: string | undefined | null): BridgeAuthRoute | null;
22
+ /**
23
+ * True when a SvelteKit route id is a Bridge auth catch-all, e.g.
24
+ * `/auth/[...bridge]`. Only that exact param name counts, so an app's own
25
+ * unrelated catch-all is never 404'd by Bridge.
26
+ */
27
+ export declare function isBridgeAuthRouteId(routeId: string | null | undefined): boolean;
28
+ /**
29
+ * The URL prefix the catch-all lives under: `/auth` for `/auth/login` when the
30
+ * rest parameter is `login`. Links between the pages are built from it, so the
31
+ * catch-all can live anywhere, not only at `/auth`.
32
+ */
33
+ export declare function bridgeAuthBase(pathname: string, rest: string | undefined | null): string;
@@ -0,0 +1,70 @@
1
+ // TBP-696 — one file serves every auth page.
2
+ //
3
+ // An app used to hand-write seven near-identical pages under `src/routes/auth/`,
4
+ // and the one it most often skipped — `set-password/[token]`, because "we don't
5
+ // use passwords" — is the address bridge-api writes into every signup
6
+ // verification email. Skipping it sent every new signup to a 404. With
7
+ // `src/routes/auth/[...bridge]/+page.svelte` rendering `<BridgeAuthRoutes />`,
8
+ // the plugin owns that list, so a page cannot be forgotten.
9
+ //
10
+ // This module is the list and its parser. It is shared by the component (which
11
+ // page to render) and by `bridgeBootstrap()`'s load (which answers an unknown
12
+ // segment with a real 404 — a component cannot, since only a `load` reaches the
13
+ // app's own error page).
14
+ /** Every page `<BridgeAuthRoutes>` serves, by its first path segment. */
15
+ export const BRIDGE_AUTH_PAGES = [
16
+ 'login',
17
+ 'signup',
18
+ 'oauth-callback',
19
+ 'set-password',
20
+ 'forgot-password',
21
+ 'magic-link',
22
+ 'setup-passkey',
23
+ 'workspaces',
24
+ ];
25
+ /** Pages reached from an email link, whose second segment is the token. */
26
+ const TOKEN_PAGES = new Set(['set-password', 'setup-passkey']);
27
+ /** The rest parameter name the catch-all route must use: `[...bridge]`. */
28
+ export const BRIDGE_AUTH_ROUTE_PARAM = 'bridge';
29
+ /**
30
+ * Parse the `[...bridge]` rest parameter into a page, or `null` when it names
31
+ * no page Bridge serves — the caller then answers with the app's own 404.
32
+ *
33
+ * Exact shapes only: `login` but not `login/extra`, and `set-password/<token>`
34
+ * but not a bare `set-password`. A page that half-matches would otherwise render
35
+ * a form at an address nobody links to.
36
+ */
37
+ export function parseBridgeAuthRoute(rest) {
38
+ if (typeof rest !== 'string')
39
+ return null;
40
+ const segments = rest.split('/').filter((s) => s !== '');
41
+ const [first, token] = segments;
42
+ if (!first || !BRIDGE_AUTH_PAGES.includes(first))
43
+ return null;
44
+ const page = first;
45
+ if (TOKEN_PAGES.has(page)) {
46
+ return segments.length === 2 && token ? { page, token } : null;
47
+ }
48
+ return segments.length === 1 ? { page } : null;
49
+ }
50
+ /**
51
+ * True when a SvelteKit route id is a Bridge auth catch-all, e.g.
52
+ * `/auth/[...bridge]`. Only that exact param name counts, so an app's own
53
+ * unrelated catch-all is never 404'd by Bridge.
54
+ */
55
+ export function isBridgeAuthRouteId(routeId) {
56
+ return typeof routeId === 'string' && routeId.endsWith(`/[...${BRIDGE_AUTH_ROUTE_PARAM}]`);
57
+ }
58
+ /**
59
+ * The URL prefix the catch-all lives under: `/auth` for `/auth/login` when the
60
+ * rest parameter is `login`. Links between the pages are built from it, so the
61
+ * catch-all can live anywhere, not only at `/auth`.
62
+ */
63
+ export function bridgeAuthBase(pathname, rest) {
64
+ // Count segments rather than comparing text: `pathname` is URL-encoded and the
65
+ // param is decoded, so a token with an escaped character would not match.
66
+ const restCount = (rest ?? '').split('/').filter((s) => s !== '').length;
67
+ const segments = pathname.split('/').filter((s) => s !== '');
68
+ const kept = segments.slice(0, Math.max(0, segments.length - restCount));
69
+ return kept.length ? `/${kept.join('/')}` : '';
70
+ }
@@ -0,0 +1,9 @@
1
+ /** True when the signed-in user may manage this workspace's billing. Fails closed to "member". */
2
+ export declare function isBillingAdmin(): boolean;
3
+ /** Where a member is pointed, in place of an Upgrade button. */
4
+ export declare const CONTACT_WORKSPACE_OWNER = "Contact your workspace owner.";
5
+ /**
6
+ * The member-facing sentence for a quota, by how close it is to the cap.
7
+ * `over` is also what a refused request (the upgrade dialog) says.
8
+ */
9
+ export declare function quotaMemberBody(label: string, state: 'over' | 'critical' | 'approaching'): string;
@@ -0,0 +1,36 @@
1
+ // Who may act on a plan limit, and what a member who may not is told.
2
+ //
3
+ // One source for <BridgeQuotaBanner> and <BridgeUpgradeDialog> (TBP-703), so
4
+ // the two never disagree about who gets an Upgrade button and what everyone
5
+ // else reads. The rule is the banner's original one: the Upgrade call to
6
+ // action is for whoever `canManageBilling()` says may manage billing (v1: the
7
+ // workspace owner); anyone else — including when Bridge is not initialised —
8
+ // is a member and is told to contact the workspace owner instead of being
9
+ // sent to a subscription page they cannot act on.
10
+ import { getBridgeAuth } from '../core/bridge-instance.js';
11
+ /** True when the signed-in user may manage this workspace's billing. Fails closed to "member". */
12
+ export function isBillingAdmin() {
13
+ try {
14
+ return getBridgeAuth().canManageBilling() === true;
15
+ }
16
+ catch {
17
+ // No BridgeAuth instance — the member variant.
18
+ return false;
19
+ }
20
+ }
21
+ /** Where a member is pointed, in place of an Upgrade button. */
22
+ export const CONTACT_WORKSPACE_OWNER = 'Contact your workspace owner.';
23
+ /**
24
+ * The member-facing sentence for a quota, by how close it is to the cap.
25
+ * `over` is also what a refused request (the upgrade dialog) says.
26
+ */
27
+ export function quotaMemberBody(label, state) {
28
+ switch (state) {
29
+ case 'over':
30
+ return `Your workspace is over its ${label} cap. ${CONTACT_WORKSPACE_OWNER}`;
31
+ case 'critical':
32
+ return `Your workspace is approaching its ${label} cap. ${CONTACT_WORKSPACE_OWNER}`;
33
+ case 'approaching':
34
+ return `Your workspace is approaching its ${label} cap.`;
35
+ }
36
+ }
@@ -0,0 +1,72 @@
1
+ import type { BridgeConfig } from '../shared/types/config.js';
2
+ /** Every page `<BridgeBillingRoutes>` serves. `manage` is the catch-all's own address. */
3
+ export declare const BRIDGE_BILLING_PAGES: readonly ["manage", "plan", "success", "error"];
4
+ /** One of the pages `<BridgeBillingRoutes>` serves. */
5
+ export type BridgeBillingPage = (typeof BRIDGE_BILLING_PAGES)[number];
6
+ /** A parsed billing route. */
7
+ export interface BridgeBillingRoute {
8
+ page: BridgeBillingPage;
9
+ }
10
+ /**
11
+ * Parse the `[...bridge]` rest parameter into a billing page, or `null` when it
12
+ * names none — the caller then answers with the app's own 404.
13
+ *
14
+ * The bare address (`/subscription`, rest `''`) is the manage page; `plan`,
15
+ * `success` and `error` are one segment each. Nothing deeper matches.
16
+ */
17
+ export declare function parseBridgeBillingRoute(rest: string | undefined | null): BridgeBillingRoute | null;
18
+ /** Where each billing destination points when the app configures nothing. */
19
+ export declare const BRIDGE_BILLING_DEFAULTS: {
20
+ readonly manageRoute: "/subscription";
21
+ readonly paywallRoute: "/subscription/plan";
22
+ readonly paymentErrorRoute: "/subscription/error";
23
+ };
24
+ /** The billing destinations in effect. */
25
+ export interface BridgeBillingRoutes {
26
+ /** The subscription page — where Manage/Upgrade buttons go. */
27
+ manageRoute: string;
28
+ /** Where a plan-less workspace is sent; `null` when the redirect is turned off. */
29
+ paywallRoute: string | null;
30
+ /**
31
+ * True when `paywallRoute` is the built-in default rather than the app's own
32
+ * choice. The default only applies to an app that uses billing — see
33
+ * `appUsesBilling` — so an app that never set billing up, where every
34
+ * workspace is plan-less, is not sent to a page it does not have.
35
+ */
36
+ paywallIsDefault: boolean;
37
+ /** Where a failed checkout confirmation lands. */
38
+ paymentErrorRoute: string;
39
+ /** Where a completed checkout lands by default: `<manageRoute>/success`. */
40
+ successRoute: string;
41
+ }
42
+ /**
43
+ * Resolve the billing destinations from a `billing` config block. An unset
44
+ * route takes its default; `paywallRoute: false` turns the paywall redirect off
45
+ * (for an app that gates with the `<BridgePaywall>` overlay, or not at all).
46
+ */
47
+ export declare function resolveBillingRoutes(billing?: BridgeConfig['billing']): BridgeBillingRoutes;
48
+ /**
49
+ * The billing destinations for the running app. Before the config exists (a
50
+ * component rendered outside `<BridgeBootstrap>`, or a unit test) the defaults
51
+ * apply — the same answer an app that configures nothing gets.
52
+ */
53
+ export declare function billingRoutes(): BridgeBillingRoutes;
54
+ /**
55
+ * Whether the app uses billing, for the default paywall (TBP-702): it has at
56
+ * least one plan. Every workspace of an app with no billing is plan-less
57
+ * (`shouldSelectPlan` is true whenever a workspace has no plan), so without this
58
+ * the default would send all of its users to `/subscription/plan`.
59
+ *
60
+ * The plan catalogue is the app-level signal the client can see. The
61
+ * subscription status's `paymentsEnabled` is per workspace (it has a Stripe
62
+ * customer and subscription), so it is false for every plan-less workspace, and
63
+ * the app config and the token carry no billing flag. `paymentsAutoRedirect` is
64
+ * checked separately, by the paywall decision itself.
65
+ */
66
+ export declare function appUsesBilling(plans: readonly unknown[] | null | undefined): boolean;
67
+ /**
68
+ * Whether the paywall redirect must leave `pathname` alone: the paywall itself
69
+ * (no loop), and the payment-error page — a plan-less workspace whose checkout
70
+ * failed has to be able to read why, not be bounced straight back to the plans.
71
+ */
72
+ export declare function isPaywallExempt(pathname: string, routes: BridgeBillingRoutes): boolean;
@@ -0,0 +1,97 @@
1
+ // TBP-702 — one file serves the subscription page, the paywall, and the pages a
2
+ // checkout returns to.
3
+ //
4
+ // The plugin used to redirect to `/payment-error` and point every Manage/Upgrade
5
+ // button at `/billing`, and no guide told anyone to create either page — so a
6
+ // guide-following app had two 404s waiting for the first failed checkout and the
7
+ // first "Manage billing" click. With `src/routes/subscription/[...bridge]/+page.svelte`
8
+ // rendering `<BridgeBillingRoutes />`, the defaults below point at pages that
9
+ // exist.
10
+ //
11
+ // This module is the page list, its parser and the route defaults. It is shared
12
+ // by the component (which page to render), by the CTA components (where Manage
13
+ // goes), and by `bridgeBootstrap()` (where the paywall and a failed checkout go,
14
+ // and which unknown segment is a 404).
15
+ import { getConfig } from './stores/config.store.js';
16
+ /** Every page `<BridgeBillingRoutes>` serves. `manage` is the catch-all's own address. */
17
+ export const BRIDGE_BILLING_PAGES = ['manage', 'plan', 'success', 'error'];
18
+ /**
19
+ * Parse the `[...bridge]` rest parameter into a billing page, or `null` when it
20
+ * names none — the caller then answers with the app's own 404.
21
+ *
22
+ * The bare address (`/subscription`, rest `''`) is the manage page; `plan`,
23
+ * `success` and `error` are one segment each. Nothing deeper matches.
24
+ */
25
+ export function parseBridgeBillingRoute(rest) {
26
+ if (typeof rest !== 'string')
27
+ return null;
28
+ const segments = rest.split('/').filter((s) => s !== '');
29
+ if (segments.length === 0)
30
+ return { page: 'manage' };
31
+ if (segments.length !== 1)
32
+ return null;
33
+ const [first] = segments;
34
+ if (first === 'plan' || first === 'success' || first === 'error')
35
+ return { page: first };
36
+ return null;
37
+ }
38
+ /** Where each billing destination points when the app configures nothing. */
39
+ export const BRIDGE_BILLING_DEFAULTS = {
40
+ manageRoute: '/subscription',
41
+ paywallRoute: '/subscription/plan',
42
+ paymentErrorRoute: '/subscription/error',
43
+ };
44
+ /**
45
+ * Resolve the billing destinations from a `billing` config block. An unset
46
+ * route takes its default; `paywallRoute: false` turns the paywall redirect off
47
+ * (for an app that gates with the `<BridgePaywall>` overlay, or not at all).
48
+ */
49
+ export function resolveBillingRoutes(billing) {
50
+ const manageRoute = billing?.manageRoute || BRIDGE_BILLING_DEFAULTS.manageRoute;
51
+ const paywall = billing?.paywallRoute;
52
+ return {
53
+ manageRoute,
54
+ paywallRoute: paywall === false ? null : paywall || BRIDGE_BILLING_DEFAULTS.paywallRoute,
55
+ paywallIsDefault: paywall !== false && !paywall,
56
+ paymentErrorRoute: billing?.paymentErrorRoute || BRIDGE_BILLING_DEFAULTS.paymentErrorRoute,
57
+ successRoute: `${manageRoute.replace(/\/+$/, '')}/success`,
58
+ };
59
+ }
60
+ /**
61
+ * The billing destinations for the running app. Before the config exists (a
62
+ * component rendered outside `<BridgeBootstrap>`, or a unit test) the defaults
63
+ * apply — the same answer an app that configures nothing gets.
64
+ */
65
+ export function billingRoutes() {
66
+ let billing;
67
+ try {
68
+ billing = getConfig().billing;
69
+ }
70
+ catch {
71
+ billing = undefined;
72
+ }
73
+ return resolveBillingRoutes(billing);
74
+ }
75
+ /**
76
+ * Whether the app uses billing, for the default paywall (TBP-702): it has at
77
+ * least one plan. Every workspace of an app with no billing is plan-less
78
+ * (`shouldSelectPlan` is true whenever a workspace has no plan), so without this
79
+ * the default would send all of its users to `/subscription/plan`.
80
+ *
81
+ * The plan catalogue is the app-level signal the client can see. The
82
+ * subscription status's `paymentsEnabled` is per workspace (it has a Stripe
83
+ * customer and subscription), so it is false for every plan-less workspace, and
84
+ * the app config and the token carry no billing flag. `paymentsAutoRedirect` is
85
+ * checked separately, by the paywall decision itself.
86
+ */
87
+ export function appUsesBilling(plans) {
88
+ return Array.isArray(plans) && plans.length > 0;
89
+ }
90
+ /**
91
+ * Whether the paywall redirect must leave `pathname` alone: the paywall itself
92
+ * (no loop), and the payment-error page — a plan-less workspace whose checkout
93
+ * failed has to be able to read why, not be bounced straight back to the plans.
94
+ */
95
+ export function isPaywallExempt(pathname, routes) {
96
+ return pathname === routes.paywallRoute || pathname === routes.paymentErrorRoute;
97
+ }