@nebulr-group/bridge-svelte 0.8.3 → 0.9.0-beta.1

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 (32) hide show
  1. package/dist/client/BridgeBootstrap.d.ts +47 -0
  2. package/dist/client/BridgeBootstrap.js +106 -11
  3. package/dist/client/BridgeBootstrap.svelte +30 -5
  4. package/dist/client/BridgeBootstrap.svelte.d.ts +2 -0
  5. package/dist/client/auth-routes.d.ts +33 -0
  6. package/dist/client/auth-routes.js +70 -0
  7. package/dist/client/billing-routes.d.ts +72 -0
  8. package/dist/client/billing-routes.js +97 -0
  9. package/dist/client/components/sdk-auth/BridgeAuthRoutes.svelte +202 -0
  10. package/dist/client/components/sdk-auth/BridgeAuthRoutes.svelte.d.ts +25 -0
  11. package/dist/client/components/sdk-auth/ForgotPassword.svelte +10 -0
  12. package/dist/client/components/sdk-auth/ForgotPassword.svelte.d.ts +8 -0
  13. package/dist/client/components/sdk-auth/MagicLink.svelte +10 -0
  14. package/dist/client/components/sdk-auth/MagicLink.svelte.d.ts +8 -0
  15. package/dist/client/components/sdk-auth/PasskeySetup.svelte +16 -1
  16. package/dist/client/components/sdk-auth/PasskeySetup.svelte.d.ts +8 -0
  17. package/dist/client/components/sdk-auth/SignupForm.svelte +15 -1
  18. package/dist/client/components/sdk-auth/SignupForm.svelte.d.ts +7 -0
  19. package/dist/client/components/subscription/BillingPortalButton.svelte +68 -0
  20. package/dist/client/components/subscription/BillingPortalButton.svelte.d.ts +8 -0
  21. package/dist/client/components/subscription/BridgeBillingNotice.svelte +5 -9
  22. package/dist/client/components/subscription/BridgeBillingRoutes.svelte +174 -0
  23. package/dist/client/components/subscription/BridgeBillingRoutes.svelte.d.ts +14 -0
  24. package/dist/client/components/subscription/BridgePaywallPage.svelte +96 -0
  25. package/dist/client/components/subscription/BridgePaywallPage.svelte.d.ts +20 -0
  26. package/dist/client/components/subscription/BridgeQuotaBanner.svelte +4 -9
  27. package/dist/client/resolve-config.d.ts +43 -0
  28. package/dist/client/resolve-config.js +112 -0
  29. package/dist/index.d.ts +8 -0
  30. package/dist/index.js +11 -0
  31. package/dist/shared/types/config.d.ts +19 -10
  32. package/package.json +1 -1
@@ -1,6 +1,53 @@
1
1
  import type { RouteGuardConfig } from '../auth/route-guard.js';
2
2
  import { waitForBridge as _waitForBridge } from '../core/bridge-instance.js';
3
3
  import type { BridgeConfig } from '../shared/types/config.js';
4
+ /**
5
+ * Options for `bridgeBootstrap()`: any `BridgeConfig` field plus the route
6
+ * rules. Every field is optional.
7
+ *
8
+ * Precedence: an option you pass explicitly wins over the environment
9
+ * (`VITE_BRIDGE_APP_ID`, `VITE_BRIDGE_API_BASE_URL`, `VITE_BRIDGE_HOSTED_URL`,
10
+ * `VITE_BRIDGE_DEBUG`), and the environment wins over the built-in default.
11
+ */
12
+ export type BridgeBootstrapOptions = Partial<BridgeConfig> & Partial<RouteGuardConfig>;
13
+ /** What the `load` returned by `bridgeBootstrap()` hands to your layout. */
14
+ export interface BridgeBootstrapData {
15
+ /** The effective config, after options, environment and defaults. */
16
+ config: BridgeConfig;
17
+ /** The route rules Bridge is enforcing. */
18
+ routeConfig: RouteGuardConfig;
19
+ }
20
+ /** The SvelteKit `load` function `bridgeBootstrap()` returns. */
21
+ export type BridgeBootstrapLoad = (event: {
22
+ url: URL;
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
+ };
30
+ }) => Promise<BridgeBootstrapData>;
31
+ /**
32
+ * Start Bridge from your root layout. Returns the layout's `load` function.
33
+ *
34
+ * The app id and addresses come from `VITE_BRIDGE_APP_ID` (and
35
+ * `VITE_BRIDGE_API_BASE_URL` for a stage or local app); anything passed here
36
+ * wins over the environment. With no app id anywhere it throws rather than
37
+ * guessing.
38
+ *
39
+ * @example
40
+ * // src/routes/+layout.ts
41
+ * import { bridgeBootstrap } from '@nebulr-group/bridge-svelte';
42
+ * export const ssr = false;
43
+ * export const load = bridgeBootstrap({ rules: [{ match: '/', public: true }] });
44
+ */
45
+ export declare function bridgeBootstrap(options?: BridgeBootstrapOptions): BridgeBootstrapLoad;
46
+ /**
47
+ * @deprecated Use `export const load = bridgeBootstrap({ rules })` — it reads
48
+ * the app id and addresses from the `VITE_BRIDGE_*` variables. This positional
49
+ * form keeps working unchanged and reads no environment.
50
+ */
4
51
  export declare function bridgeBootstrap(url: URL, config: BridgeConfig | string, routeConfig?: RouteGuardConfig, kitFetch?: typeof globalThis.fetch): Promise<{
5
52
  flagsReady: Promise<void>;
6
53
  }>;
@@ -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';
@@ -8,6 +8,9 @@ import { installBridgeAuthFetch } from '../core/bridge-runtime.js';
8
8
  import { useBridge, sanitizeReturnTo, stashReturnTo, takeReturnTo, withReturnTo, } from '@nebulr-group/bridge-auth-core';
9
9
  import { logger } from '../shared/logger.js';
10
10
  import { bridgeConfig, getConfig, getRouteGuardConfig } from './stores/config.store.js';
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';
11
14
  // TBP-653 — `bridgeBootstrap` used to short-circuit on every call after the
12
15
  // first completed one, and the route guard lived below that return. SvelteKit
13
16
  // re-runs the root layout load for every navigation (it reads `url`), so the
@@ -29,7 +32,88 @@ const _configuredPromise = new Promise((resolve) => {
29
32
  // Child loads can start before the root layout load has run; this only has to
30
33
  // cover that ordering, not a missing bootstrap.
31
34
  const CONFIGURE_TIMEOUT_MS = 10_000;
32
- export async function bridgeBootstrap(url, config, routeConfig = { rules: [], defaultAccess: 'protected' }, kitFetch) {
35
+ export function bridgeBootstrap(urlOrOptions, config, routeConfig, kitFetch) {
36
+ if (urlOrOptions instanceof URL) {
37
+ if (config === undefined) {
38
+ throw new Error('[bridge] bridgeBootstrap(url, config) was called without a config.');
39
+ }
40
+ return runBootstrap(urlOrOptions, config, routeConfig, kitFetch);
41
+ }
42
+ return createBootstrapLoad(urlOrOptions ?? {});
43
+ }
44
+ function createBootstrapLoad(options) {
45
+ const { rules, defaultAccess, returnTo, ...configOptions } = options;
46
+ const routeConfig = {
47
+ rules: rules ?? [],
48
+ defaultAccess: defaultAccess ?? 'protected',
49
+ ...(returnTo ? { returnTo } : {}),
50
+ };
51
+ // Resolved on the first call, not at import: a missing app id must surface
52
+ // as a load error the developer sees, and the environment is only final then.
53
+ let resolved = null;
54
+ return async ({ url, fetch, params, route }) => {
55
+ resolved ??= resolveBridgeConfig(configOptions);
56
+ rejectUnknownBridgePage(route?.id, params, resolved);
57
+ await runBootstrap(url, resolved, routeConfig, fetch);
58
+ return { config: getConfig(), routeConfig };
59
+ };
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
+ }
116
+ async function runBootstrap(url, config, routeConfig = { rules: [], defaultAccess: 'protected' }, kitFetch) {
33
117
  // Until one call has completed, a call may be the one that lands on a
34
118
  // callback URL or needs the no-flash paywall redirect. Afterwards those are
35
119
  // owned by <BridgeBootstrap> (reactive paywall) — same split as before.
@@ -103,7 +187,7 @@ async function waitForConfigured() {
103
187
  let timer;
104
188
  const timeout = new Promise((_, reject) => {
105
189
  timer = setTimeout(() => reject(new Error('[bridge] assertAuthorized() ran but bridgeBootstrap() never configured the SDK. ' +
106
- 'Call bridgeBootstrap(url, config, routeConfig) in your root +layout.ts load.')), CONFIGURE_TIMEOUT_MS);
190
+ 'Add `export const load = bridgeBootstrap({ rules })` to your root +layout.ts.')), CONFIGURE_TIMEOUT_MS);
107
191
  });
108
192
  try {
109
193
  await Promise.race([_configuredPromise, timeout]);
@@ -169,7 +253,6 @@ function ensureInitialised() {
169
253
  })();
170
254
  return _initialisation;
171
255
  }
172
- const STRIPE_DEFAULT_RETURN = '/subscription';
173
256
  // Where a Stripe success/cancel return lands (TBP-659).
174
257
  //
175
258
  // The `redirect` parameter arrives on the app's own callback URL, so anyone can
@@ -191,9 +274,11 @@ const STRIPE_DEFAULT_RETURN = '/subscription';
191
274
  // Validation runs on the stripped string because that is the one we navigate to.
192
275
  function stripeReturnTarget(url) {
193
276
  const raw = url.searchParams.get('redirect');
277
+ // The subscription page (TBP-702: `billing.manageRoute`, `/subscription` by default).
278
+ const fallback = billingRoutes().manageRoute;
194
279
  if (raw === null)
195
- return STRIPE_DEFAULT_RETURN;
196
- return sanitizeReturnTo(raw.split('?')[0]) ?? STRIPE_DEFAULT_RETURN;
280
+ return fallback;
281
+ return sanitizeReturnTo(raw.split('?')[0]) ?? fallback;
197
282
  }
198
283
  // Unified callback handler — detects what is calling back and routes accordingly
199
284
  async function handleCallbackRoute(url, kitFetch) {
@@ -260,7 +345,8 @@ async function handleCallbackRoute(url, kitFetch) {
260
345
  if (isRedirect(err))
261
346
  throw err;
262
347
  logger.warn('[bridgeBootstrap] confirm-checkout error', err);
263
- redirect(303, getConfig().billing?.paymentErrorRoute ?? '/payment-error');
348
+ // TBP-702 — `/subscription/error` by default, served by <BridgeBillingRoutes>.
349
+ redirect(303, billingRoutes().paymentErrorRoute);
264
350
  }
265
351
  }
266
352
  else if (stripeCancel) {
@@ -283,14 +369,23 @@ async function handleCallbackRoute(url, kitFetch) {
283
369
  // decision (authenticated + shouldSelectPlan + not opted out via
284
370
  // paymentsAutoRedirect) lives in auth-core's shouldRedirectToPaywall()
285
371
  // (TBP-369). We only own the route/config guards here:
286
- // - billing.paywallRoute is configured
287
- // - 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)
288
380
  async function enforcePaywall(url) {
289
381
  try {
290
- const paywallRoute = getConfig().billing?.paywallRoute;
291
- if (paywallRoute && url.pathname !== paywallRoute) {
382
+ const routes = billingRoutes();
383
+ const paywallRoute = routes.paywallRoute;
384
+ if (paywallRoute && !isPaywallExempt(url.pathname, routes)) {
292
385
  const bridge = getBridgeAuth();
293
386
  if (await bridge.shouldRedirectToPaywall()) {
387
+ if (routes.paywallIsDefault && !appUsesBilling(await bridge.getPlans()))
388
+ return;
294
389
  logger.debug('[bridgeBootstrap] paywall redirect', paywallRoute);
295
390
  redirect(303, paywallRoute);
296
391
  }
@@ -1,11 +1,12 @@
1
1
  <script lang="ts">
2
2
  import { beforeNavigate, goto } from '$app/navigation';
3
3
  import { page } from '$app/stores';
4
- import { onMount, onDestroy } from 'svelte';
4
+ import { onMount, onDestroy, type Snippet } from 'svelte';
5
5
  import { createRouteGuard, routeRulesReferenceFlag } from '../auth/route-guard.js';
6
6
  import { stashReturnTo, withReturnTo } from '@nebulr-group/bridge-auth-core';
7
7
  import {
8
8
  getBridgeAuth,
9
+ bridgeReadyStore,
9
10
  isAuthenticated,
10
11
  subscriptionStore,
11
12
  loadSubscription,
@@ -14,6 +15,7 @@
14
15
  import { bridge as bridgeSurface } from '../core/bridge.js';
15
16
  import { setBridgeContext } from '../core/use-bridge.js';
16
17
  import { getConfig, getRouteGuardConfig } from './stores/config.store.js';
18
+ import { appUsesBilling, billingRoutes, isPaywallExempt } from './billing-routes.js';
17
19
  import {
18
20
  onBridgeAuthorizationChange,
19
21
  onBridgeFlagChange,
@@ -37,14 +39,24 @@
37
39
  // Props: optional `runtime` overrides for advanced/debug use (websocketFactory,
38
40
  // reconnect overrides, etc.); `onBootstrapComplete` callback fires after the
39
41
  // runtime + any auto-detected capabilities (flags) have attached.
42
+ //
43
+ // TBP-695 — the shell owns readiness. Wrap the app in <BridgeBootstrap> and
44
+ // `children` render only once Bridge is ready: the root `load`
45
+ // (bridgeBootstrap) has finished AND the runtime + capabilities attached
46
+ // below. The developer writes no ready flag. Self-closing use (no children)
47
+ // still works for apps that gate on `onBootstrapComplete` themselves.
40
48
  let {
41
49
  runtime,
42
50
  onBootstrapComplete,
51
+ children,
43
52
  }: {
44
53
  runtime?: StartBridgeRuntimeOptions;
45
54
  onBootstrapComplete?: () => void;
55
+ children?: Snippet;
46
56
  } = $props();
47
57
 
58
+ let runtimeAttached = $state(false);
59
+
48
60
  // Phase 4 (TBP-288/320) — expose the unified bridge surface via Svelte
49
61
  // context so descendants can call `useBridge()`.
50
62
  setBridgeContext(bridgeSurface);
@@ -61,11 +73,15 @@
61
73
  // `shouldSelectPlan` resolves true. Same data source as <BridgePaywall>, so the
62
74
  // overlay and the redirect agree. The load() redirect remains a no-flash
63
75
  // fast-path for direct loads/refreshes only.
76
+ //
77
+ // TBP-702 — the paywall defaults to `/subscription/plan` (served by
78
+ // <BridgeBillingRoutes>); `billing.paywallRoute: false` turns it off.
64
79
  $effect(() => {
65
- const paywallRoute = getConfig().billing?.paywallRoute;
80
+ const routes = billingRoutes();
81
+ const paywallRoute = routes.paywallRoute;
66
82
  if (!paywallRoute || !$isAuthenticated) return;
67
83
 
68
- const { status, loading, error } = $subscriptionStore;
84
+ const { status, plans, loading, error } = $subscriptionStore;
69
85
 
70
86
  // Status unknown → trigger a single load. Guarding on `!error` avoids a
71
87
  // tight refetch loop on persistent failure (fail-pending, not fail-open);
@@ -77,11 +93,15 @@
77
93
 
78
94
  // Status known → enforce. `$page.url.pathname` makes this re-run on
79
95
  // navigation too, so manual nav to a protected page while plan-less is
80
- // also caught. Path guard prevents a redirect loop on the paywall itself.
96
+ // also caught. Path guard prevents a redirect loop on the paywall itself,
97
+ // and leaves the payment-error page readable.
98
+ // TBP-702 — the default paywall only applies to an app that uses billing
99
+ // (has plans); an explicit paywallRoute always applies.
81
100
  if (
82
101
  status?.shouldSelectPlan === true &&
83
102
  status?.paymentsAutoRedirect !== false &&
84
- $page.url.pathname !== paywallRoute
103
+ (!routes.paywallIsDefault || appUsesBilling(plans)) &&
104
+ !isPaywallExempt($page.url.pathname, routes)
85
105
  ) {
86
106
  goto(paywallRoute);
87
107
  }
@@ -210,6 +230,7 @@
210
230
  }
211
231
 
212
232
  // Auth-core manages auto-refresh internally — no startAutoRefresh() needed
233
+ runtimeAttached = true;
213
234
  if (onBootstrapComplete) onBootstrapComplete();
214
235
  });
215
236
 
@@ -238,3 +259,7 @@
238
259
  </script>
239
260
 
240
261
  <RealtimeDevBadge enabled={devBadgeEnabled} />
262
+
263
+ {#if runtimeAttached && $bridgeReadyStore}
264
+ {@render children?.()}
265
+ {/if}
@@ -1,7 +1,9 @@
1
+ import { type Snippet } from 'svelte';
1
2
  import { type StartBridgeRuntimeOptions } from '../core/bridge-runtime.js';
2
3
  type $$ComponentProps = {
3
4
  runtime?: StartBridgeRuntimeOptions;
4
5
  onBootstrapComplete?: () => void;
6
+ children?: Snippet;
5
7
  };
6
8
  declare const BridgeBootstrap: import("svelte").Component<$$ComponentProps, {}, "">;
7
9
  type BridgeBootstrap = ReturnType<typeof BridgeBootstrap>;
@@ -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,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
+ }