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

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 (31) hide show
  1. package/README.md +37 -1
  2. package/dist/client/BridgeBootstrap.svelte +26 -0
  3. package/dist/client/billing-role.d.ts +9 -0
  4. package/dist/client/billing-role.js +36 -0
  5. package/dist/client/components/subscription/BridgeQuotaBanner.svelte +6 -9
  6. package/dist/client/components/subscription/BridgeUpgradeDialog.svelte +69 -0
  7. package/dist/client/components/subscription/BridgeUpgradeDialog.svelte.d.ts +4 -0
  8. package/dist/client/components/subscription/Entitled.svelte +43 -0
  9. package/dist/client/components/subscription/Entitled.svelte.d.ts +14 -0
  10. package/dist/client/components/subscription/QuotaGate.svelte +76 -0
  11. package/dist/client/components/subscription/QuotaGate.svelte.d.ts +18 -0
  12. package/dist/client/upgrade-dialog.d.ts +16 -0
  13. package/dist/client/upgrade-dialog.js +26 -0
  14. package/dist/core/bridge-fetch.d.ts +22 -0
  15. package/dist/core/bridge-fetch.js +106 -9
  16. package/dist/core/bridge.d.ts +42 -4
  17. package/dist/core/bridge.js +23 -3
  18. package/dist/core/entitlements.d.ts +35 -0
  19. package/dist/core/entitlements.js +64 -0
  20. package/dist/core/quota-refusal.d.ts +92 -0
  21. package/dist/core/quota-refusal.js +168 -0
  22. package/dist/core/use-bridge.d.ts +6 -6
  23. package/dist/core/use-bridge.js +18 -17
  24. package/dist/core/use-quota.d.ts +36 -0
  25. package/dist/core/use-quota.js +191 -0
  26. package/dist/index.d.ts +23 -2
  27. package/dist/index.js +37 -3
  28. package/dist/shared/types/config.d.ts +31 -0
  29. package/package.json +3 -3
  30. package/dist/client/BridgeProvider.svelte +0 -31
  31. package/dist/client/BridgeProvider.svelte.d.ts +0 -8
package/README.md CHANGED
@@ -14,7 +14,43 @@ npm i @nebulr-group/bridge-svelte
14
14
 
15
15
  ### Usage
16
16
 
17
- See the `demo/` app in the monorepo for end-to-end wiring.
17
+ The whole integration is one `.env` line and three files:
18
+
19
+ ```env
20
+ # .env
21
+ VITE_BRIDGE_APP_ID=your-app-id
22
+ ```
23
+
24
+ ```ts
25
+ // src/routes/+layout.ts
26
+ import { bridgeBootstrap } from '@nebulr-group/bridge-svelte';
27
+ export const ssr = false;
28
+ export const load = bridgeBootstrap({ rules: [{ match: new RegExp('^/auth($|/)'), public: true }] });
29
+ ```
30
+
31
+ ```svelte
32
+ <!-- src/routes/+layout.svelte -->
33
+ <script lang="ts">
34
+ import { BridgeBootstrap } from '@nebulr-group/bridge-svelte';
35
+ import '@nebulr-group/bridge-svelte/styles';
36
+ let { children } = $props();
37
+ </script>
38
+ <BridgeBootstrap>
39
+ {@render children()}
40
+ </BridgeBootstrap>
41
+ ```
42
+
43
+ ```svelte
44
+ <!-- src/routes/auth/[...bridge]/+page.svelte — every sign-in page -->
45
+ <script lang="ts">
46
+ import { BridgeAuthRoutes } from '@nebulr-group/bridge-svelte';
47
+ </script>
48
+ <BridgeAuthRoutes />
49
+ ```
50
+
51
+ A stage or local app also sets `VITE_BRIDGE_API_BASE_URL`. Add `loginRoute: '/auth/login'` for sign-in inside the app, and `src/routes/subscription/[...bridge]/+page.svelte` rendering `<BridgeBillingRoutes />` for subscriptions.
52
+
53
+ [How Bridge works](https://github.com/thebridgedev/bridge-svelte/blob/main/learning/mechanisms.md) explains plan limits, the three UI levels and the four customisation levels; the [learning docs](https://github.com/thebridgedev/bridge-svelte/tree/main/learning) cover everything else. Coding agents: `npx @nebulr-group/bridge-cli guide svelte`.
18
54
 
19
55
  ### Build
20
56
 
@@ -24,6 +24,10 @@
24
24
  type StartBridgeRuntimeOptions,
25
25
  } from '../core/bridge-runtime.js';
26
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';
27
31
 
28
32
  // TBP-644 — the "Live updates off — why?" badge is mounted here so every app
29
33
  // gets it without code changes. It renders in development builds only;
@@ -36,6 +40,24 @@
36
40
  }
37
41
  })();
38
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
+
39
61
  // Props: optional `runtime` overrides for advanced/debug use (websocketFactory,
40
62
  // reconnect overrides, etc.); `onBootstrapComplete` callback fires after the
41
63
  // runtime + any auto-detected capabilities (flags) have attached.
@@ -260,6 +282,10 @@
260
282
 
261
283
  <RealtimeDevBadge enabled={devBadgeEnabled} />
262
284
 
285
+ {#if UpgradeDialog}
286
+ <UpgradeDialog refusal={$quotaRefusal} {upgradeHref} {canUpgrade} onclose={dismissQuotaRefusal} />
287
+ {/if}
288
+
263
289
  {#if runtimeAttached && $bridgeReadyStore}
264
290
  {@render children?.()}
265
291
  {/if}
@@ -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
+ }
@@ -26,7 +26,7 @@
26
26
  useBridge,
27
27
  type QuotaSnapshot,
28
28
  } from '@nebulr-group/bridge-auth-core';
29
- import { getBridgeAuth } from '../../../core/bridge-instance.js';
29
+ import { isBillingAdmin as canManageBilling, quotaMemberBody } from '../../billing-role.js';
30
30
  import { billingRoutes } from '../../billing-routes.js';
31
31
 
32
32
  type Chassis = 'rail';
@@ -81,11 +81,8 @@
81
81
  // Re-trigger hydration in case the prop changed since `$state` init.
82
82
  snapshot = useBridge().quota(metric);
83
83
 
84
- try {
85
- isBillingAdmin = getBridgeAuth().canManageBilling();
86
- } catch {
87
- // No BridgeAuth instance — render the member variant.
88
- }
84
+ // Shared with <BridgeUpgradeDialog> (TBP-703): billing-role.ts.
85
+ isBillingAdmin = canManageBilling();
89
86
  });
90
87
 
91
88
  onDestroy(() => unsubscribe?.());
@@ -187,7 +184,7 @@
187
184
  }
188
185
  : {
189
186
  title: `${displayLabel} over cap`,
190
- body: `Your workspace is over its ${displayLabel} cap. Contact your workspace owner.`,
187
+ body: quotaMemberBody(displayLabel, 'over'),
191
188
  };
192
189
  }
193
190
  if (warningLevel === 'critical') {
@@ -199,7 +196,7 @@
199
196
  }
200
197
  : {
201
198
  title: `${displayLabel} near cap`,
202
- body: `Your workspace is approaching its ${displayLabel} cap. Contact your workspace owner.`,
199
+ body: quotaMemberBody(displayLabel, 'critical'),
203
200
  };
204
201
  }
205
202
  // approaching
@@ -211,7 +208,7 @@
211
208
  }
212
209
  : {
213
210
  title: `${displayLabel} approaching cap`,
214
- body: `Your workspace is approaching its ${displayLabel} cap.`,
211
+ body: quotaMemberBody(displayLabel, 'approaching'),
215
212
  };
216
213
  }
217
214
 
@@ -0,0 +1,69 @@
1
+ <!--
2
+ TBP-703 — the upgrade dialog. <BridgeBootstrap /> mounts it; the app writes
3
+ nothing. When the app's backend refuses a request because a plan limit is
4
+ reached (402, code QUOTA_EXCEEDED — bridge-nestjs's @RequireQuota), it opens,
5
+ names the metric and the numbers, and links to the refusal's `fix` path or
6
+ `billing.manageRoute` (default /subscription). A member who cannot manage
7
+ billing (the <BridgeQuotaBanner> rule, billing-role.ts) is told to contact the
8
+ workspace owner instead, with no Upgrade link.
9
+
10
+ `billing.upgradeDialog: false` turns it off; a component there replaces it
11
+ and receives the same props.
12
+
13
+ Decoration only: the backend already refused the write. This explains why.
14
+ -->
15
+ <script lang="ts">
16
+ import type { BridgeUpgradeDialogProps } from '../../../shared/types/config.js';
17
+ import { quotaMemberBody } from '../../billing-role.js';
18
+
19
+ let { refusal, upgradeHref, canUpgrade, onclose }: BridgeUpgradeDialogProps = $props();
20
+
21
+ let dialogEl: HTMLDialogElement | undefined = $state();
22
+
23
+ $effect(() => {
24
+ if (!dialogEl) return;
25
+ if (refusal && !dialogEl.open) dialogEl.showModal();
26
+ else if (!refusal && dialogEl.open) dialogEl.close();
27
+ });
28
+
29
+ const hasNumbers = $derived(refusal?.used != null && refusal?.limit != null);
30
+ </script>
31
+
32
+ <dialog
33
+ bind:this={dialogEl}
34
+ class="bridge-team-dialog bridge-upgrade-dialog"
35
+ data-bridge-upgrade-dialog
36
+ data-metric={refusal?.metric}
37
+ aria-labelledby="bridge-upgrade-dialog-title"
38
+ onclose={() => {
39
+ if (refusal) onclose();
40
+ }}
41
+ >
42
+ {#if refusal}
43
+ <div class="bridge-team-dialog-content">
44
+ <h3 id="bridge-upgrade-dialog-title" class="bridge-team-dialog-title">You've reached your plan's limit</h3>
45
+ <p class="bridge-team-dialog-message" data-bridge-upgrade-dialog-message data-variant={canUpgrade ? 'admin' : 'member'}>
46
+ {#if !canUpgrade}
47
+ {quotaMemberBody(refusal.metric, 'over')}
48
+ {:else if hasNumbers}
49
+ This workspace has used <strong>{refusal.used?.toLocaleString()}</strong> of
50
+ <strong>{refusal.limit?.toLocaleString()}</strong>
51
+ <strong data-bridge-upgrade-dialog-metric>{refusal.metric}</strong> on its current plan.
52
+ {:else}
53
+ This workspace has reached its <strong data-bridge-upgrade-dialog-metric>{refusal.metric}</strong> limit.
54
+ {/if}
55
+ {#if canUpgrade}Upgrade the plan to keep going.{/if}
56
+ </p>
57
+ <div class="bridge-team-dialog-actions">
58
+ {#if canUpgrade}
59
+ <button type="button" class="bridge-btn bridge-btn-secondary" onclick={() => onclose()}>Not now</button>
60
+ <a class="bridge-btn bridge-btn-primary" href={upgradeHref} data-bridge-upgrade-dialog-cta onclick={() => onclose()}>
61
+ Upgrade plan
62
+ </a>
63
+ {:else}
64
+ <button type="button" class="bridge-btn bridge-btn-primary" onclick={() => onclose()}>OK</button>
65
+ {/if}
66
+ </div>
67
+ </div>
68
+ {/if}
69
+ </dialog>
@@ -0,0 +1,4 @@
1
+ import type { BridgeUpgradeDialogProps } from '../../../shared/types/config.js';
2
+ declare const BridgeUpgradeDialog: import("svelte").Component<BridgeUpgradeDialogProps, {}, "">;
3
+ type BridgeUpgradeDialog = ReturnType<typeof BridgeUpgradeDialog>;
4
+ export default BridgeUpgradeDialog;
@@ -0,0 +1,43 @@
1
+ <!--
2
+ TBP-703 — <Entitled to="analytics">: the children render when the workspace's
3
+ plan grants the entitlement, the `fallback` snippet when it does not.
4
+
5
+ <Entitled to="analytics">
6
+ <AnalyticsPanel />
7
+ {#snippet fallback()}
8
+ <a href="/subscription">Upgrade for analytics</a>
9
+ {/snippet}
10
+ </Entitled>
11
+
12
+ Until Bridge has answered ($entitlements.ready) it renders neither — only the
13
+ optional `loading` snippet — so a cold start never flashes the upgrade prompt
14
+ at a paying workspace, nor the paid feature at a free one.
15
+
16
+ The markup form of `$entitlements.can('analytics')`. Decoration only: the
17
+ backend's @RequireEntitlement is what refuses the request.
18
+ -->
19
+ <script lang="ts">
20
+ import type { Snippet } from 'svelte';
21
+ import { entitlements } from '../../../core/entitlements.js';
22
+
23
+ interface Props {
24
+ /** The entitlement key, e.g. `'analytics'`. */
25
+ to: string;
26
+ /** Rendered when the plan grants `to`. */
27
+ children: Snippet;
28
+ /** Rendered when Bridge has answered and the plan does not grant `to`. */
29
+ fallback?: Snippet;
30
+ /** Rendered until Bridge has answered. Nothing by default. */
31
+ loading?: Snippet;
32
+ }
33
+
34
+ let { to, children, fallback, loading }: Props = $props();
35
+ </script>
36
+
37
+ {#if !$entitlements.ready}
38
+ {@render loading?.()}
39
+ {:else if $entitlements.can(to)}
40
+ {@render children()}
41
+ {:else}
42
+ {@render fallback?.()}
43
+ {/if}
@@ -0,0 +1,14 @@
1
+ import type { Snippet } from 'svelte';
2
+ interface Props {
3
+ /** The entitlement key, e.g. `'analytics'`. */
4
+ to: string;
5
+ /** Rendered when the plan grants `to`. */
6
+ children: Snippet;
7
+ /** Rendered when Bridge has answered and the plan does not grant `to`. */
8
+ fallback?: Snippet;
9
+ /** Rendered until Bridge has answered. Nothing by default. */
10
+ loading?: Snippet;
11
+ }
12
+ declare const Entitled: import("svelte").Component<Props, {}, "">;
13
+ type Entitled = ReturnType<typeof Entitled>;
14
+ export default Entitled;
@@ -0,0 +1,76 @@
1
+ <!--
2
+ TBP-703 — <QuotaGate metric="tickets">: the action inside is disabled once the
3
+ workspace is at its plan's hard cap, and an upgrade prompt shows beside it.
4
+
5
+ <QuotaGate metric="tickets">
6
+ <button onclick={createTicket}>New ticket</button>
7
+ {#snippet atLimit(quota)}
8
+ {quota.used} of {quota.limit} tickets used. <a href="/subscription">Upgrade</a>
9
+ {/snippet}
10
+ </QuotaGate>
11
+
12
+ Disabling is done by a <fieldset disabled> around the children, so every
13
+ button, input, select and textarea inside is disabled natively and announced
14
+ as such — no prop threading into your markup. (Links are not form controls;
15
+ put a link's action behind a button.)
16
+
17
+ Never disables on "don't know yet": while the quota is loading, the plan has
18
+ no quota on the metric, or the quota is metered (it bills overage instead of
19
+ blocking), the children are enabled. Only a known hard cap with nothing left
20
+ disables them.
21
+
22
+ Decoration only. The backend's @RequireQuota is what refuses the write; this
23
+ just saves the user a click that would be refused.
24
+ -->
25
+ <script lang="ts">
26
+ import type { Snippet } from 'svelte';
27
+ import { useQuota, type QuotaState } from '../../../core/use-quota.js';
28
+ import { billingRoutes } from '../../billing-routes.js';
29
+
30
+ interface Props {
31
+ /** The quota metric key, e.g. `'tickets'`. */
32
+ metric: string;
33
+ /** The action(s) to disable at the cap. */
34
+ children: Snippet;
35
+ /**
36
+ * What to show at the cap, instead of the default "limit reached — Upgrade"
37
+ * line. Receives the live quota (`used`, `limit`, `remaining`, …).
38
+ */
39
+ atLimit?: Snippet<[QuotaState]>;
40
+ /** Class on the wrapper. */
41
+ class?: string;
42
+ }
43
+
44
+ let { metric, children, atLimit, class: className = '' }: Props = $props();
45
+
46
+ const quota = useQuota(() => metric);
47
+
48
+ // 'loading' | 'unlimited' | 'metered' | 'available' | 'at-limit'
49
+ const gateState = $derived.by(() => {
50
+ if (quota.loading) return 'loading';
51
+ if (quota.unlimited) return 'unlimited';
52
+ if (quota.snapshot?.policy === 'metered') return 'metered';
53
+ const atCap =
54
+ (quota.remaining !== null && quota.remaining <= 0) ||
55
+ (quota.used !== null && quota.limit !== null && quota.used >= quota.limit);
56
+ return atCap ? 'at-limit' : 'available';
57
+ });
58
+ const blocked = $derived(gateState === 'at-limit');
59
+ const manageRoute = $derived(billingRoutes().manageRoute);
60
+ </script>
61
+
62
+ <div class="bridge-quota-gate {className}" data-bridge-quota-gate data-metric={metric} data-state={gateState}>
63
+ <fieldset disabled={blocked} class="bridge-quota-gate-controls" style="display: contents">
64
+ {@render children()}
65
+ </fieldset>
66
+ {#if blocked}
67
+ <div class="bridge-quota-gate-limit" data-bridge-quota-gate-limit role="status">
68
+ {#if atLimit}
69
+ {@render atLimit(quota)}
70
+ {:else}
71
+ You've used all {quota.limit?.toLocaleString()} {metric} on your plan.
72
+ <a href={manageRoute}>Upgrade</a>
73
+ {/if}
74
+ </div>
75
+ {/if}
76
+ </div>
@@ -0,0 +1,18 @@
1
+ import type { Snippet } from 'svelte';
2
+ import { type QuotaState } from '../../../core/use-quota.js';
3
+ interface Props {
4
+ /** The quota metric key, e.g. `'tickets'`. */
5
+ metric: string;
6
+ /** The action(s) to disable at the cap. */
7
+ children: Snippet;
8
+ /**
9
+ * What to show at the cap, instead of the default "limit reached — Upgrade"
10
+ * line. Receives the live quota (`used`, `limit`, `remaining`, …).
11
+ */
12
+ atLimit?: Snippet<[QuotaState]>;
13
+ /** Class on the wrapper. */
14
+ class?: string;
15
+ }
16
+ declare const QuotaGate: import("svelte").Component<Props, {}, "">;
17
+ type QuotaGate = ReturnType<typeof QuotaGate>;
18
+ export default QuotaGate;
@@ -0,0 +1,16 @@
1
+ import type { Component } from 'svelte';
2
+ import type { BridgeQuotaRefusal } from '../core/quota-refusal.js';
3
+ import type { BridgeConfig, BridgeUpgradeDialogProps } from '../shared/types/config.js';
4
+ /**
5
+ * The dialog to mount for a `billing` config: the built-in one (`default`), the
6
+ * app's own component, or none (`false`). Anything that is not `false` and not
7
+ * a component — `true`, `undefined`, a stray string — is the built-in default:
8
+ * the owner decision is that the dialog is on unless turned off.
9
+ */
10
+ export declare function resolveUpgradeDialog(billing: BridgeConfig['billing'] | undefined): 'default' | Component<BridgeUpgradeDialogProps> | null;
11
+ /**
12
+ * Where the upgrade button goes: the refusal's own `fix` path (already limited
13
+ * to a same-app path by `parseQuotaRefusal`), else `billing.manageRoute`
14
+ * (default `/subscription`).
15
+ */
16
+ export declare function upgradeHrefFor(refusal: Pick<BridgeQuotaRefusal, 'fix'> | null, billing: BridgeConfig['billing'] | undefined): string;
@@ -0,0 +1,26 @@
1
+ // TBP-703 — which upgrade dialog <BridgeBootstrap> mounts, and where its button
2
+ // goes. Kept out of the component so both are plain functions a unit test can
3
+ // call with a config.
4
+ import { resolveBillingRoutes } from './billing-routes.js';
5
+ /**
6
+ * The dialog to mount for a `billing` config: the built-in one (`default`), the
7
+ * app's own component, or none (`false`). Anything that is not `false` and not
8
+ * a component — `true`, `undefined`, a stray string — is the built-in default:
9
+ * the owner decision is that the dialog is on unless turned off.
10
+ */
11
+ export function resolveUpgradeDialog(billing) {
12
+ const setting = billing?.upgradeDialog;
13
+ if (setting === false)
14
+ return null;
15
+ if (typeof setting === 'function')
16
+ return setting;
17
+ return 'default';
18
+ }
19
+ /**
20
+ * Where the upgrade button goes: the refusal's own `fix` path (already limited
21
+ * to a same-app path by `parseQuotaRefusal`), else `billing.manageRoute`
22
+ * (default `/subscription`).
23
+ */
24
+ export function upgradeHrefFor(refusal, billing) {
25
+ return refusal?.fix ?? resolveBillingRoutes(billing).manageRoute;
26
+ }
@@ -16,3 +16,25 @@
16
16
  * Installed by `startBridgeRuntime()` patching `globalThis.fetch`.
17
17
  */
18
18
  export declare function wrapFetchWithBridgeAuth(baseFetch: typeof fetch, apiBaseUrl: string): typeof fetch;
19
+ /**
20
+ * TBP-697 — `bridgeFetch(url, init)`: `fetch` for calls to **your own backend**
21
+ * that carry the signed-in user's Bridge access token.
22
+ *
23
+ * import { bridgeFetch } from '@nebulr-group/bridge-svelte';
24
+ * const res = await bridgeFetch('/api/projects', { method: 'POST', body });
25
+ *
26
+ * Adds `Authorization: Bearer <access token>` (when signed in), and on a `401`
27
+ * refreshes the token once and retries — so an expired token mid-session is
28
+ * not an error the page has to handle. Same signature as `fetch`.
29
+ *
30
+ * Bridge's own API calls do not need it: those already carry the token.
31
+ *
32
+ * It sends the user's token to whatever URL you give it, so call it for your
33
+ * backend only — never for a third-party URL.
34
+ *
35
+ * TBP-703 — a `402 { code: 'QUOTA_EXCEEDED', … }` answer (what bridge-nestjs's
36
+ * `@RequireQuota` sends at the plan's cap) opens the upgrade dialog that
37
+ * `<BridgeBootstrap>` mounts, whatever origin your backend is on. The response
38
+ * is still returned to you unchanged.
39
+ */
40
+ export declare function bridgeFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
@@ -1,4 +1,42 @@
1
1
  import { getBridgeAuth } from './bridge-instance.js';
2
+ import { observeQuotaRefusal, watchesQuotaOrigin } from './quota-refusal.js';
3
+ import { getConfig } from '../client/stores/config.store.js';
4
+ function requestUrl(input) {
5
+ return typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
6
+ }
7
+ function pageHref() {
8
+ const href = globalThis.location?.href;
9
+ return typeof href === 'string' ? href : undefined;
10
+ }
11
+ /** `url` made absolute against the page, so a relative `/api/x` has an origin. */
12
+ function absoluteUrl(url) {
13
+ try {
14
+ return new URL(url, pageHref()).href;
15
+ }
16
+ catch {
17
+ return url;
18
+ }
19
+ }
20
+ /**
21
+ * TBP-703 — hand a 402 from a watched origin to the upgrade-dialog check.
22
+ * Watched: the page's own origin, Bridge's API, and `billing.apiOrigins`.
23
+ */
24
+ function observeIfWatched(response, url, apiBaseUrl) {
25
+ if (response.status !== 402)
26
+ return;
27
+ let apiOrigins;
28
+ try {
29
+ apiOrigins = getConfig().billing?.apiOrigins;
30
+ }
31
+ catch {
32
+ apiOrigins = undefined;
33
+ }
34
+ const href = pageHref();
35
+ const pageOrigin = href ? new URL(href).origin : undefined;
36
+ if (watchesQuotaOrigin(url, { pageOrigin, apiBaseUrl, apiOrigins })) {
37
+ void observeQuotaRefusal(response, absoluteUrl(url));
38
+ }
39
+ }
2
40
  /**
3
41
  * Wraps a fetch function with Bridge auth concerns for requests to `apiBaseUrl`.
4
42
  * Requests to other URLs pass through completely untouched.
@@ -18,14 +56,16 @@ import { getBridgeAuth } from './bridge-instance.js';
18
56
  */
19
57
  export function wrapFetchWithBridgeAuth(baseFetch, apiBaseUrl) {
20
58
  return async function bridgeAuthFetch(input, init) {
21
- const url = typeof input === 'string'
22
- ? input
23
- : input instanceof URL
24
- ? input.href
25
- : input.url;
26
- // Only act on requests to the bridge API — everything else passes through.
27
- if (!url.startsWith(apiBaseUrl))
28
- return baseFetch(input, init);
59
+ const url = requestUrl(input);
60
+ // Only act on requests to the bridge API — everything else passes through
61
+ // untouched. TBP-703: a 402 from the app's own backend is still LOOKED at
62
+ // (never changed or delayed) so a plan-limit refusal opens the upgrade
63
+ // dialog with no code on the page.
64
+ if (!url.startsWith(apiBaseUrl)) {
65
+ const passthrough = await baseFetch(input, init);
66
+ observeIfWatched(passthrough, url, apiBaseUrl);
67
+ return passthrough;
68
+ }
29
69
  // 1. Inject current access token.
30
70
  const token = getBridgeAuth().getTokens()?.accessToken;
31
71
  const headers = new Headers(init?.headers);
@@ -33,8 +73,10 @@ export function wrapFetchWithBridgeAuth(baseFetch, apiBaseUrl) {
33
73
  headers.set('Authorization', `Bearer ${token}`);
34
74
  const response = await baseFetch(input, { ...init, headers });
35
75
  // Non-200s are returned as-is; httpFetch handles REST auth errors separately.
36
- if (!response.ok)
76
+ if (!response.ok) {
77
+ observeIfWatched(response, url, apiBaseUrl);
37
78
  return response;
79
+ }
38
80
  // 2. Inspect body for TOKEN_VERSION_STALE without consuming the original
39
81
  // response (URQL / callers need to read it themselves).
40
82
  const clone = response.clone();
@@ -57,3 +99,58 @@ export function wrapFetchWithBridgeAuth(baseFetch, apiBaseUrl) {
57
99
  return baseFetch(input, { ...init, headers: freshHeaders });
58
100
  };
59
101
  }
102
+ /**
103
+ * TBP-697 — `bridgeFetch(url, init)`: `fetch` for calls to **your own backend**
104
+ * that carry the signed-in user's Bridge access token.
105
+ *
106
+ * import { bridgeFetch } from '@nebulr-group/bridge-svelte';
107
+ * const res = await bridgeFetch('/api/projects', { method: 'POST', body });
108
+ *
109
+ * Adds `Authorization: Bearer <access token>` (when signed in), and on a `401`
110
+ * refreshes the token once and retries — so an expired token mid-session is
111
+ * not an error the page has to handle. Same signature as `fetch`.
112
+ *
113
+ * Bridge's own API calls do not need it: those already carry the token.
114
+ *
115
+ * It sends the user's token to whatever URL you give it, so call it for your
116
+ * backend only — never for a third-party URL.
117
+ *
118
+ * TBP-703 — a `402 { code: 'QUOTA_EXCEEDED', … }` answer (what bridge-nestjs's
119
+ * `@RequireQuota` sends at the plan's cap) opens the upgrade dialog that
120
+ * `<BridgeBootstrap>` mounts, whatever origin your backend is on. The response
121
+ * is still returned to you unchanged.
122
+ */
123
+ export async function bridgeFetch(input, init) {
124
+ const response = await fetchWithToken(input, init);
125
+ void observeQuotaRefusal(response, absoluteUrl(requestUrl(input)));
126
+ return response;
127
+ }
128
+ async function fetchWithToken(input, init) {
129
+ let auth;
130
+ try {
131
+ auth = getBridgeAuth();
132
+ }
133
+ catch {
134
+ // Bridge not initialised (SSR, a test) — behave exactly like fetch.
135
+ return fetch(input, init);
136
+ }
137
+ const withToken = (token) => {
138
+ const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
139
+ if (token)
140
+ headers.set('Authorization', `Bearer ${token}`);
141
+ return { ...init, headers };
142
+ };
143
+ const sentToken = auth.getTokens()?.accessToken;
144
+ const response = await fetch(input, withToken(sentToken));
145
+ if (response.status !== 401 || !sentToken)
146
+ return response;
147
+ // A streamed body is gone after the first attempt; it cannot be replayed.
148
+ const replayable = !(input instanceof Request) && !(init?.body instanceof ReadableStream);
149
+ if (!replayable)
150
+ return response;
151
+ const fresh = await auth.refreshTokens().catch(() => null);
152
+ const freshToken = fresh?.accessToken ?? auth.getTokens()?.accessToken;
153
+ if (!freshToken || freshToken === sentToken)
154
+ return response;
155
+ return fetch(input, withToken(freshToken));
156
+ }