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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,11 @@
26
26
  useBridge,
27
27
  type QuotaSnapshot,
28
28
  } from '@nebulr-group/bridge-auth-core';
29
+ <<<<<<< HEAD
29
30
  import { getBridgeAuth } from '../../../core/bridge-instance.js';
31
+ =======
32
+ import { isBillingAdmin as canManageBilling, quotaMemberBody } from '../../billing-role.js';
33
+ >>>>>>> origin/feature/mcp-journey
30
34
  import { billingRoutes } from '../../billing-routes.js';
31
35
 
32
36
  type Chassis = 'rail';
@@ -81,11 +85,8 @@
81
85
  // Re-trigger hydration in case the prop changed since `$state` init.
82
86
  snapshot = useBridge().quota(metric);
83
87
 
84
- try {
85
- isBillingAdmin = getBridgeAuth().canManageBilling();
86
- } catch {
87
- // No BridgeAuth instance — render the member variant.
88
- }
88
+ // Shared with <BridgeUpgradeDialog> (TBP-703): billing-role.ts.
89
+ isBillingAdmin = canManageBilling();
89
90
  });
90
91
 
91
92
  onDestroy(() => unsubscribe?.());
@@ -187,7 +188,7 @@
187
188
  }
188
189
  : {
189
190
  title: `${displayLabel} over cap`,
190
- body: `Your workspace is over its ${displayLabel} cap. Contact your workspace owner.`,
191
+ body: quotaMemberBody(displayLabel, 'over'),
191
192
  };
192
193
  }
193
194
  if (warningLevel === 'critical') {
@@ -199,7 +200,7 @@
199
200
  }
200
201
  : {
201
202
  title: `${displayLabel} near cap`,
202
- body: `Your workspace is approaching its ${displayLabel} cap. Contact your workspace owner.`,
203
+ body: quotaMemberBody(displayLabel, 'critical'),
203
204
  };
204
205
  }
205
206
  // approaching
@@ -211,7 +212,7 @@
211
212
  }
212
213
  : {
213
214
  title: `${displayLabel} approaching cap`,
214
- body: `Your workspace is approaching its ${displayLabel} cap.`,
215
+ body: quotaMemberBody(displayLabel, 'approaching'),
215
216
  };
216
217
  }
217
218
 
@@ -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
+ }
@@ -12,14 +12,13 @@
12
12
  * (`subscriptionStore`, `appConfigStore`, etc.) continue to exist and are
13
13
  * populated by the same internal state; both surfaces coexist.
14
14
  *
15
- * Note: `useBridge()` (Svelte context hook) lands in TBP-320. This module
16
- * only exposes the singleton aggregate `bridge`; consumers can import it
17
- * directly until the context hook ships.
15
+ * `useBridge()` (use-bridge.ts) returns this same object, or a component-scoped
16
+ * override set with `setBridgeContext()`.
18
17
  */
19
18
  import { type Readable } from 'svelte/store';
20
19
  import { type BrandingSnapshot, type SubscriptionSnapshot, type UserSnapshot } from './snapshot-stores.js';
21
20
  import { LazySlice } from './lazy-slice.js';
22
- import type { Plan } from '@nebulr-group/bridge-auth-core';
21
+ import type { BridgeAuth, Plan } from '@nebulr-group/bridge-auth-core';
23
22
  import { DevAttributeProvider } from '@nebulr-group/bridge-auth-core';
24
23
  import { type BridgeEventsDispatcher } from './events.js';
25
24
  export interface BridgeAppSurface {
@@ -54,6 +53,39 @@ export interface BridgeTenantSurface {
54
53
  can(key: string): boolean;
55
54
  };
56
55
  }
56
+ /**
57
+ * TBP-697 — usage reporting from the browser.
58
+ *
59
+ * **Self-reported: this is a trusted-client path.** Anything running in the
60
+ * user's browser can call it with any value, so a frontend-only app cannot
61
+ * *enforce* a quota with it — only a backend can refuse a write. Use it when the
62
+ * app has no backend that sees the action: a local-first or mobile app whose
63
+ * data lives on the device. When you do have a backend, report there instead
64
+ * (bridge-nestjs `@RequireQuota`, `usage.report` on the server SDK).
65
+ *
66
+ * Which call: *if deleting it frees room, it's a gauge and your app counts it
67
+ * (`set`); if it happened, it's a counter and Bridge counts it (`report`).*
68
+ */
69
+ export interface BridgeUsageSurface {
70
+ /**
71
+ * Count something that happened (a counter): `report('ai_completions')`,
72
+ * `report('tokens', 1375)`. Fire-and-forget; queued durably and sent in
73
+ * batches. Pass an `idempotencyKey` when the same event could be reported
74
+ * twice (a retry), so Bridge counts it once.
75
+ */
76
+ report(metric: string, value?: number, idempotencyKey?: string): void;
77
+ /**
78
+ * Say how many of something exist right now (a gauge): `set('projects', 8)`
79
+ * after the app creates or deletes one. Absolute, never added up; resolves
80
+ * once Bridge has stored it. Needs `@nebulr-group/bridge-auth-core`
81
+ * 0.8.0-beta.0 or later.
82
+ */
83
+ set(metric: string, value: number): Promise<void>;
84
+ /** Queue depth, retries and the last flush — for a debug panel. */
85
+ getQueueStatus(): Promise<UsageQueueStatus>;
86
+ }
87
+ /** What `bridge.usage.getQueueStatus()` resolves to. */
88
+ export type UsageQueueStatus = Awaited<ReturnType<BridgeAuth['usage']['getQueueStatus']>>;
57
89
  export interface BridgeSurface {
58
90
  app: BridgeAppSurface;
59
91
  tenant: BridgeTenantSurface;
@@ -75,6 +107,12 @@ export interface BridgeSurface {
75
107
  * handlers (`useBridge().handle({...})`, `realtime.setOnXyz()`, etc.).
76
108
  */
77
109
  events: BridgeEventsDispatcher;
110
+ /**
111
+ * TBP-697 — report usage from the browser: `bridge.usage.report(metric)` for
112
+ * a counter, `bridge.usage.set(metric, value)` for a gauge. Self-reported —
113
+ * see {@link BridgeUsageSurface}.
114
+ */
115
+ usage: BridgeUsageSurface;
78
116
  }
79
117
  export declare const bridge: BridgeSurface;
80
118
  /** Internal: createBridgeFlags imports this to register the dev provider. */