@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.
- package/dist/client/BridgeBootstrap.d.ts +47 -0
- package/dist/client/BridgeBootstrap.js +106 -11
- package/dist/client/BridgeBootstrap.svelte +30 -5
- package/dist/client/BridgeBootstrap.svelte.d.ts +2 -0
- package/dist/client/auth-routes.d.ts +33 -0
- package/dist/client/auth-routes.js +70 -0
- package/dist/client/billing-routes.d.ts +72 -0
- package/dist/client/billing-routes.js +97 -0
- package/dist/client/components/sdk-auth/BridgeAuthRoutes.svelte +202 -0
- package/dist/client/components/sdk-auth/BridgeAuthRoutes.svelte.d.ts +25 -0
- package/dist/client/components/sdk-auth/ForgotPassword.svelte +10 -0
- package/dist/client/components/sdk-auth/ForgotPassword.svelte.d.ts +8 -0
- package/dist/client/components/sdk-auth/MagicLink.svelte +10 -0
- package/dist/client/components/sdk-auth/MagicLink.svelte.d.ts +8 -0
- package/dist/client/components/sdk-auth/PasskeySetup.svelte +16 -1
- package/dist/client/components/sdk-auth/PasskeySetup.svelte.d.ts +8 -0
- package/dist/client/components/sdk-auth/SignupForm.svelte +15 -1
- package/dist/client/components/sdk-auth/SignupForm.svelte.d.ts +7 -0
- package/dist/client/components/subscription/BillingPortalButton.svelte +68 -0
- package/dist/client/components/subscription/BillingPortalButton.svelte.d.ts +8 -0
- package/dist/client/components/subscription/BridgeBillingNotice.svelte +5 -9
- package/dist/client/components/subscription/BridgeBillingRoutes.svelte +174 -0
- package/dist/client/components/subscription/BridgeBillingRoutes.svelte.d.ts +14 -0
- package/dist/client/components/subscription/BridgePaywallPage.svelte +96 -0
- package/dist/client/components/subscription/BridgePaywallPage.svelte.d.ts +20 -0
- package/dist/client/components/subscription/BridgeQuotaBanner.svelte +4 -9
- package/dist/client/resolve-config.d.ts +43 -0
- package/dist/client/resolve-config.js +112 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +11 -0
- package/dist/shared/types/config.d.ts +19 -10
- 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
|
|
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
|
-
'
|
|
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
|
|
196
|
-
return sanitizeReturnTo(raw.split('?')[0]) ??
|
|
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
|
-
|
|
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
|
|
287
|
-
//
|
|
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
|
|
291
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|