@nebulr-group/bridge-svelte 0.8.0-beta.1 → 0.8.0

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 (71) hide show
  1. package/dist/auth/guard-cache.d.ts +30 -0
  2. package/dist/auth/guard-cache.js +102 -0
  3. package/dist/auth/route-guard.d.ts +10 -1
  4. package/dist/auth/route-guard.js +126 -22
  5. package/dist/client/BridgeBootstrap.d.ts +24 -1
  6. package/dist/client/BridgeBootstrap.js +198 -75
  7. package/dist/client/BridgeBootstrap.svelte +30 -0
  8. package/dist/client/components/developer/RealtimeDevBadge.svelte +6 -0
  9. package/dist/client/components/sdk-auth/ForgotPassword.svelte +3 -2
  10. package/dist/client/components/sdk-auth/LoginForm.svelte +42 -8
  11. package/dist/client/components/sdk-auth/MagicLink.svelte +2 -1
  12. package/dist/client/components/sdk-auth/MfaChallenge.svelte +4 -3
  13. package/dist/client/components/sdk-auth/MfaSetup.svelte +4 -3
  14. package/dist/client/components/sdk-auth/PasskeyLogin.svelte +2 -1
  15. package/dist/client/components/sdk-auth/PasskeyRequestSetupLink.svelte +2 -1
  16. package/dist/client/components/sdk-auth/PasskeySetup.svelte +2 -1
  17. package/dist/client/components/sdk-auth/SignupForm.svelte +2 -1
  18. package/dist/client/components/sdk-auth/SsoButton.svelte +2 -1
  19. package/dist/client/components/sdk-auth/TenantSelector.svelte +2 -1
  20. package/dist/client/components/sdk-auth/WorkspaceSelector.svelte +3 -2
  21. package/dist/client/components/sdk-auth/shared/auth-error.d.ts +28 -0
  22. package/dist/client/components/sdk-auth/shared/auth-error.js +67 -0
  23. package/dist/core/bridge-runtime.d.ts +13 -0
  24. package/dist/core/bridge-runtime.js +192 -8
  25. package/dist/core/realtime-dev-badge.d.ts +2 -0
  26. package/dist/core/realtime-dev-badge.js +13 -1
  27. package/dist/core/realtime-status.d.ts +7 -0
  28. package/dist/core/realtime-status.js +15 -1
  29. package/dist/core/snapshot-stores.d.ts +11 -0
  30. package/dist/core/snapshot-stores.js +30 -1
  31. package/dist/shared/allowed-origins.d.ts +14 -0
  32. package/dist/shared/allowed-origins.js +19 -0
  33. package/package.json +4 -2
  34. package/dist/auth/route-guard.test.d.ts +0 -1
  35. package/dist/auth/route-guard.test.js +0 -48
  36. package/dist/client/components/sdk-auth/auth-form-description.test.d.ts +0 -1
  37. package/dist/client/components/sdk-auth/auth-form-description.test.js +0 -470
  38. package/dist/client/components/sdk-auth/login-settling.test.d.ts +0 -27
  39. package/dist/client/components/sdk-auth/login-settling.test.js +0 -219
  40. package/dist/client/components/sdk-auth/selector-i18n.test.d.ts +0 -1
  41. package/dist/client/components/sdk-auth/selector-i18n.test.js +0 -248
  42. package/dist/client/components/subscription/billing-manage-route.test.d.ts +0 -1
  43. package/dist/client/components/subscription/billing-manage-route.test.js +0 -258
  44. package/dist/client/components/subscription/billing-notice-gate.test.d.ts +0 -1
  45. package/dist/client/components/subscription/billing-notice-gate.test.js +0 -83
  46. package/dist/client/components/subscription/plan-selector.test.d.ts +0 -1
  47. package/dist/client/components/subscription/plan-selector.test.js +0 -391
  48. package/dist/client/tracking/pii-hashing.spec.d.ts +0 -1
  49. package/dist/client/tracking/pii-hashing.spec.js +0 -47
  50. package/dist/client/tracking/reddit-tracking.spec.d.ts +0 -1
  51. package/dist/client/tracking/reddit-tracking.spec.js +0 -126
  52. package/dist/core/bridge-runtime.test.d.ts +0 -1
  53. package/dist/core/bridge-runtime.test.js +0 -507
  54. package/dist/core/bridge.test.d.ts +0 -1
  55. package/dist/core/bridge.test.js +0 -308
  56. package/dist/core/events.test.d.ts +0 -1
  57. package/dist/core/events.test.js +0 -116
  58. package/dist/core/lazy-slice.test.d.ts +0 -1
  59. package/dist/core/lazy-slice.test.js +0 -144
  60. package/dist/core/realtime-dev-badge.test.d.ts +0 -1
  61. package/dist/core/realtime-dev-badge.test.js +0 -145
  62. package/dist/core/use-bridge.test.d.ts +0 -1
  63. package/dist/core/use-bridge.test.js +0 -24
  64. package/dist/flags/bootstrap.test.d.ts +0 -1
  65. package/dist/flags/bootstrap.test.js +0 -200
  66. package/dist/flags/index.test.d.ts +0 -1
  67. package/dist/flags/index.test.js +0 -81
  68. package/dist/flags/us13-integration.test.d.ts +0 -1
  69. package/dist/flags/us13-integration.test.js +0 -168
  70. package/dist/shared/types/published-config-type.test.d.ts +0 -1
  71. package/dist/shared/types/published-config-type.test.js +0 -92
@@ -0,0 +1,30 @@
1
+ /**
2
+ * How long a route decision waits for the token refresh a plan, entitlements or
3
+ * user-state change started. Past it the guard decides with the token it has,
4
+ * which for a protected route is the old (fail-closed) verdict.
5
+ */
6
+ export declare const AUTHORIZATION_CHANGE_WAIT_MS = 3000;
7
+ /** Drop the route-guard flag cache so the next route evaluation asks the server again. */
8
+ export declare function invalidateRouteGuardCache(): void;
9
+ /** Current cache generation; changes on every `invalidateRouteGuardCache()`. */
10
+ export declare function guardCacheGeneration(): number;
11
+ /**
12
+ * Drop the underlying cache WITHOUT bumping the generation — used to discard a
13
+ * result that landed after an invalidation, which is not itself a new change.
14
+ */
15
+ export declare function dropFlagCache(): void;
16
+ /**
17
+ * Register the token refresh started by an authorization-affecting event.
18
+ * The tracked promise never rejects; it clears itself once settled.
19
+ */
20
+ export declare function trackAuthorizationChange(refresh: Promise<unknown>): Promise<void>;
21
+ /** The refresh in flight for an authorization change, or `null` when there is none. */
22
+ export declare function pendingAuthorizationChange(): Promise<void> | null;
23
+ /** Forget any pending change (runtime stop / test reset). */
24
+ export declare function clearPendingAuthorizationChange(): void;
25
+ /**
26
+ * Wait until no authorization-change refresh is in flight, or until `deadline`
27
+ * (epoch ms). Never throws. Returns immediately — without yielding — when
28
+ * nothing is pending, so a signed-out visitor's navigation is not delayed.
29
+ */
30
+ export declare function settleAuthorizationChange(deadline: number): Promise<void>;
@@ -0,0 +1,102 @@
1
+ // Route-guard cache invalidation (TBP-654). Internal — not re-exported.
2
+ //
3
+ // Route guards read auth-core's FeatureFlagService, a cache with a 5-minute
4
+ // TTL. A plan-targeted rule's verdict depends on the user's plan and token, not
5
+ // on the flag definition, so anything that can change those has to drop the
6
+ // cache — otherwise an upgraded user stays locked out of the page they just
7
+ // paid for until the TTL runs out.
8
+ //
9
+ // The generation counter exists for one race: an evaluation that was already
10
+ // in flight when the cache was invalidated writes its (pre-change) result back
11
+ // into the cache when it lands, with a fresh timestamp. Callers compare the
12
+ // generation before and after reading the cache and re-read when it moved.
13
+ //
14
+ // The pending-change promise exists for a second race: the page learns about a
15
+ // plan change (and shows the new plan) a few hundred ms before the token that
16
+ // carries it arrives. A route decision taken in that window is evaluated with
17
+ // the old token and refuses the page the user can already see they have. The
18
+ // runtime registers the token refresh an authorization-affecting event starts;
19
+ // guards wait for it, bounded, before deciding.
20
+ import { getBridgeAuth } from '../core/bridge-instance.js';
21
+ /**
22
+ * How long a route decision waits for the token refresh a plan, entitlements or
23
+ * user-state change started. Past it the guard decides with the token it has,
24
+ * which for a protected route is the old (fail-closed) verdict.
25
+ */
26
+ export const AUTHORIZATION_CHANGE_WAIT_MS = 3_000;
27
+ let _generation = 0;
28
+ let _pendingChange = null;
29
+ /** Drop the route-guard flag cache so the next route evaluation asks the server again. */
30
+ export function invalidateRouteGuardCache() {
31
+ _generation += 1;
32
+ dropFlagCache();
33
+ }
34
+ /** Current cache generation; changes on every `invalidateRouteGuardCache()`. */
35
+ export function guardCacheGeneration() {
36
+ return _generation;
37
+ }
38
+ /**
39
+ * Drop the underlying cache WITHOUT bumping the generation — used to discard a
40
+ * result that landed after an invalidation, which is not itself a new change.
41
+ */
42
+ export function dropFlagCache() {
43
+ try {
44
+ getBridgeAuth().invalidateFeatureFlagCache();
45
+ }
46
+ catch {
47
+ // BridgeAuth not initialised yet — there is no cache to drop.
48
+ }
49
+ }
50
+ /**
51
+ * Register the token refresh started by an authorization-affecting event.
52
+ * The tracked promise never rejects; it clears itself once settled.
53
+ */
54
+ export function trackAuthorizationChange(refresh) {
55
+ const tracked = refresh.then(() => undefined, () => undefined).finally(() => {
56
+ if (_pendingChange === tracked)
57
+ _pendingChange = null;
58
+ });
59
+ _pendingChange = tracked;
60
+ return tracked;
61
+ }
62
+ /** The refresh in flight for an authorization change, or `null` when there is none. */
63
+ export function pendingAuthorizationChange() {
64
+ return _pendingChange;
65
+ }
66
+ /** Forget any pending change (runtime stop / test reset). */
67
+ export function clearPendingAuthorizationChange() {
68
+ _pendingChange = null;
69
+ }
70
+ /**
71
+ * Wait until no authorization-change refresh is in flight, or until `deadline`
72
+ * (epoch ms). Never throws. Returns immediately — without yielding — when
73
+ * nothing is pending, so a signed-out visitor's navigation is not delayed.
74
+ */
75
+ export async function settleAuthorizationChange(deadline) {
76
+ let timer;
77
+ try {
78
+ // A change that lands while we wait (a second event after the first
79
+ // refresh finished) starts a new refresh; wait for that too, within the
80
+ // same deadline.
81
+ for (let pending = _pendingChange; pending; pending = _pendingChange) {
82
+ const remaining = deadline - Date.now();
83
+ if (remaining <= 0)
84
+ return;
85
+ const expired = await Promise.race([
86
+ pending.then(() => false),
87
+ new Promise((resolve) => {
88
+ timer = setTimeout(() => resolve(true), remaining);
89
+ }),
90
+ ]);
91
+ clearTimeout(timer);
92
+ if (expired)
93
+ return;
94
+ if (_pendingChange === pending)
95
+ return; // settled but not yet cleared
96
+ }
97
+ }
98
+ finally {
99
+ if (timer)
100
+ clearTimeout(timer);
101
+ }
102
+ }
@@ -1,5 +1,14 @@
1
+ import type { NavigationDecision } from '@nebulr-group/bridge-auth-core';
1
2
  export type { FlagRequirement, NavigationDecision, RouteGuard, RouteGuardConfig, RouteRule } from '@nebulr-group/bridge-auth-core';
2
- export declare function createRouteGuard(flagsReady?: Promise<void>): import("@nebulr-group/bridge-auth-core").RouteGuard;
3
+ export declare function createRouteGuard(flagsReady?: Promise<void>): {
4
+ checkRouteRestrictions(pathname: string): Promise<string | null>;
5
+ getNavigationDecision(pathname: string, attempted?: string): Promise<NavigationDecision>;
6
+ isPublicRoute(pathname: string): boolean;
7
+ isProtectedRoute(pathname: string): boolean;
8
+ shouldRedirectToLogin(pathname: string): boolean;
9
+ getLoginRedirect(): string;
10
+ resolveReturnTo(attempted: string | null | undefined): string | null;
11
+ };
3
12
  /**
4
13
  * Does any route rule's `featureFlag` requirement mention this key?
5
14
  * (TBP-575.)
@@ -1,6 +1,11 @@
1
1
  // src/lib/auth/route-guard.ts — thin wrapper delegating to auth-core via bridge-instance
2
2
  import { getBridgeAuth } from '../core/bridge-instance.js';
3
3
  import { getConfig, getRouteGuardConfig } from '../client/stores/config.store.js';
4
+ import { logger } from '../shared/logger.js';
5
+ import { AUTHORIZATION_CHANGE_WAIT_MS, dropFlagCache, guardCacheGeneration, settleAuthorizationChange, } from './guard-cache.js';
6
+ // How many times a restriction check is re-run when the cache was invalidated
7
+ // underneath it (TBP-654). Bounded so a burst of invalidations cannot spin.
8
+ const MAX_FRESH_READS = 3;
4
9
  export function createRouteGuard(flagsReady) {
5
10
  const config = getRouteGuardConfig();
6
11
  // TBP-629 — feed the app's own loginRoute into the guard so it can refuse to
@@ -17,38 +22,137 @@ export function createRouteGuard(flagsReady) {
17
22
  },
18
23
  };
19
24
  const guard = getBridgeAuth().createRouteGuard(guardConfig);
20
- if (!flagsReady)
21
- return guard;
22
- // Wrap checkRouteRestrictions to await flagsReady before evaluating
25
+ // TBP-654 — a restriction check reads the flag cache. If the cache was
26
+ // invalidated while the check was in flight (plan change, token refresh),
27
+ // the answer may predate the change AND has just been written back into the
28
+ // cache. Discard it and ask again.
29
+ //
30
+ // Each read first waits (bounded by `deadline`) for a token refresh that a
31
+ // plan / entitlements / user-state change started: the page shows the new
32
+ // plan a few hundred ms before the token carrying it lands, and a verdict
33
+ // taken in between would be evaluated with the old one.
34
+ async function checkRestrictionsFresh(pathname, deadline) {
35
+ for (let attempt = 1;; attempt++) {
36
+ await settleAuthorizationChange(deadline);
37
+ const generation = guardCacheGeneration();
38
+ const redirectTo = await guard.checkRouteRestrictions(pathname);
39
+ if (generation === guardCacheGeneration())
40
+ return redirectTo;
41
+ dropFlagCache();
42
+ if (attempt >= MAX_FRESH_READS)
43
+ return redirectTo;
44
+ }
45
+ }
46
+ function loginDecision(pathname, attempted) {
47
+ // TBP-629 — the attempted target (path + query) rides along on every
48
+ // login decision, including the fail-closed ones below.
49
+ let returnTo = null;
50
+ try {
51
+ returnTo = guard.resolveReturnTo(attempted ?? pathname);
52
+ }
53
+ catch {
54
+ returnTo = null;
55
+ }
56
+ let loginUrl = '';
57
+ try {
58
+ loginUrl = guard.getLoginRedirect();
59
+ }
60
+ catch {
61
+ // SDK mode never reads loginUrl; hosted mode rebuilds it at redirect time.
62
+ }
63
+ return { type: 'login', loginUrl, ...(returnTo ? { returnTo } : {}) };
64
+ }
65
+ // TBP-653 — the guard could not reach a decision (network error on a flag
66
+ // check, malformed config, an exception anywhere in the chain). That must
67
+ // never let a restricted route through:
68
+ // - a public route with no flag/billing requirement stays reachable;
69
+ // - a signed-out visitor is sent to login;
70
+ // - a signed-in user is treated as failing the route's requirement and
71
+ // sent where the rule says a failing user goes.
72
+ function failClosed(pathname, attempted, err) {
73
+ logger.error('[route-guard] could not evaluate route; denying access', pathname, err);
74
+ let rule = null;
75
+ try {
76
+ rule = findMatchingRule(pathname, config?.rules ?? []);
77
+ }
78
+ catch {
79
+ rule = null;
80
+ }
81
+ const restricted = !!(rule?.featureFlag || rule?.billing === 'hard');
82
+ let isPublic = false;
83
+ try {
84
+ isPublic = guard.isPublicRoute(pathname);
85
+ }
86
+ catch {
87
+ isPublic = false;
88
+ }
89
+ if (isPublic && !restricted)
90
+ return { type: 'allow' };
91
+ let authenticated = false;
92
+ try {
93
+ authenticated = getBridgeAuth().isAuthenticated();
94
+ }
95
+ catch {
96
+ authenticated = false;
97
+ }
98
+ if (authenticated) {
99
+ const to = rule?.redirectTo ?? '/';
100
+ if (to !== pathname)
101
+ return { type: 'redirect', to };
102
+ }
103
+ return loginDecision(pathname, attempted);
104
+ }
23
105
  return {
24
106
  ...guard,
25
107
  async checkRouteRestrictions(pathname) {
108
+ const deadline = Date.now() + AUTHORIZATION_CHANGE_WAIT_MS;
26
109
  await flagsReady;
27
- return guard.checkRouteRestrictions(pathname);
110
+ return checkRestrictionsFresh(pathname, deadline);
28
111
  },
29
112
  async getNavigationDecision(pathname, attempted) {
30
- if (guard.shouldRedirectToLogin(pathname)) {
31
- // TBP-629 — this branch short-circuits before flagsReady on purpose
32
- // (an unauthenticated visitor needs no flag evaluation), which is
33
- // exactly why `attempted` has to be threaded through here too. The
34
- // wrapper previously rebuilt the decision by hand and would silently
35
- // drop any argument auth-core's version learned to accept.
36
- const returnTo = guard.resolveReturnTo(attempted ?? pathname);
37
- return {
38
- type: 'login',
39
- loginUrl: guard.getLoginRedirect(),
40
- ...(returnTo ? { returnTo } : {}),
41
- };
113
+ // TBP-654 — one bound for the whole decision, however many reads it takes.
114
+ const deadline = Date.now() + AUTHORIZATION_CHANGE_WAIT_MS;
115
+ try {
116
+ // A refresh that fails can sign the session out, so let it land before
117
+ // the signed-in check too. Nothing pending (always the case for a
118
+ // signed-out visitor) → no wait at all.
119
+ await settleAuthorizationChange(deadline);
120
+ if (guard.shouldRedirectToLogin(pathname)) {
121
+ // TBP-629 — this branch short-circuits before flagsReady on purpose
122
+ // (an unauthenticated visitor needs no flag evaluation), which is
123
+ // exactly why `attempted` has to be threaded through here too.
124
+ return loginDecision(pathname, attempted);
125
+ }
126
+ await flagsReady;
127
+ const redirectTo = await checkRestrictionsFresh(pathname, deadline);
128
+ if (redirectTo) {
129
+ return { type: 'redirect', to: redirectTo };
130
+ }
131
+ return { type: 'allow' };
42
132
  }
43
- await flagsReady;
44
- const redirectTo = await guard.checkRouteRestrictions(pathname);
45
- if (redirectTo) {
46
- return { type: 'redirect', to: redirectTo };
133
+ catch (err) {
134
+ return failClosed(pathname, attempted, err);
47
135
  }
48
- return { type: 'allow' };
49
- }
136
+ },
50
137
  };
51
138
  }
139
+ // Same matching semantics as auth-core's route guard: a RegExp is tested as-is,
140
+ // a string is an exact match unless it contains `*` wildcards. First match wins.
141
+ function toRegExp(pattern) {
142
+ if (pattern instanceof RegExp)
143
+ return pattern;
144
+ const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
145
+ if (!pattern.includes('*'))
146
+ return new RegExp(`^${escaped}$`);
147
+ return new RegExp(`^${escaped.replace(/\\\*/g, '.*')}$`);
148
+ }
149
+ function findMatchingRule(pathname, rules) {
150
+ for (const rule of rules) {
151
+ if (toRegExp(rule.match).test(pathname))
152
+ return rule;
153
+ }
154
+ return null;
155
+ }
52
156
  /**
53
157
  * Does any route rule's `featureFlag` requirement mention this key?
54
158
  * (TBP-575.)
@@ -3,6 +3,29 @@ import { waitForBridge as _waitForBridge } from '../core/bridge-instance.js';
3
3
  import type { BridgeConfig } from '../shared/types/config.js';
4
4
  export declare function bridgeBootstrap(url: URL, config: BridgeConfig | string, routeConfig?: RouteGuardConfig, kitFetch?: typeof globalThis.fetch): Promise<{
5
5
  flagsReady: Promise<void>;
6
- } | undefined>;
6
+ }>;
7
+ /**
8
+ * Re-check Bridge's route rules for `url` from any `load` function, and throw
9
+ * the same redirect `bridgeBootstrap` would: to your `loginRoute` (or the hosted
10
+ * login page) for a signed-out visitor on a protected route, or to the rule's
11
+ * `redirectTo` when a feature-flag or billing requirement is not met.
12
+ *
13
+ * `bridgeBootstrap` in your root `+layout.ts` already guards every navigation.
14
+ * Use this as a second line of defence on pages that must never render for the
15
+ * wrong visitor. It fails closed: if the decision cannot be made, a protected
16
+ * route is denied.
17
+ *
18
+ * Route guards control what the browser renders. They are not authorization —
19
+ * your API must still verify the user's token on every request.
20
+ *
21
+ * @example
22
+ * // src/routes/admin/+page.ts
23
+ * import { assertAuthorized } from '@nebulr-group/bridge-svelte';
24
+ * export const load = async ({ url }) => {
25
+ * await assertAuthorized(url);
26
+ * // ...
27
+ * };
28
+ */
29
+ export declare function assertAuthorized(url: URL): Promise<void>;
7
30
  export declare const bridgeReady: import("svelte/store").Readable<boolean>;
8
31
  export { _waitForBridge as waitForBridge };