@tumbaland/frontend-core 1.7.0 → 1.9.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.
@@ -1,5 +1,30 @@
1
1
  import { getCorrelationId, getSessionId, logger } from './monitoring';
2
+ import { clearSessionScopedStorage } from './sessionStorage';
2
3
  const AUTH_CACHE_TTL = 5 * 60 * 1000;
4
+ /**
5
+ * Tell the shell its session just ended.
6
+ *
7
+ * Each app runs in the shell's iframe with its own copy of this module, so a
8
+ * logout inside the frame clears only the frame's cache and reloads only the
9
+ * frame. The shell keeps serving its own five-minute-old `authenticated:
10
+ * true` from memory, which is why signing out and clicking home still showed
11
+ * the signed-in page instead of the landing page.
12
+ *
13
+ * The message carries nothing, so `'*'` gives nothing away; the shell is the
14
+ * side that authenticates it, by origin and by frame (see `useShellBridge`).
15
+ */
16
+ function notifyShellOfLogout() {
17
+ if (typeof window === 'undefined' || window.parent === window.self)
18
+ return;
19
+ try {
20
+ // Contract lives in @tumbaland/components `routing/messages` — a lower
21
+ // layer cannot import it, so the literal is repeated here.
22
+ window.parent.postMessage({ source: 'tumbaland-app', type: 'app:logout' }, '*');
23
+ }
24
+ catch (error) {
25
+ logger.warn('Failed to notify shell of logout', { error });
26
+ }
27
+ }
3
28
  function authHeaders() {
4
29
  return {
5
30
  'Content-Type': 'application/json',
@@ -81,6 +106,8 @@ export function createAuthService(config) {
81
106
  if (data.success) {
82
107
  authCache = null;
83
108
  authCacheTime = 0;
109
+ clearSessionScopedStorage();
110
+ notifyShellOfLogout();
84
111
  window.location.reload();
85
112
  }
86
113
  return data.success;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The caller's own plan, limits and live meter readings.
3
+ *
4
+ * **The vocabulary is duplicated from `@tumbaland/backend-core`; the numbers
5
+ * never are.** A browser bundle cannot import backend-core — it pulls in
6
+ * mongoose — so the meter *keys* are restated here. That is a deliberate line:
7
+ * duplicating the key names costs a rename, while duplicating the limits would
8
+ * recreate exactly the defect Phase 0 removed, where `PlansPage` advertised six
9
+ * ceilings that no service enforced. Every number below arrives from the API at
10
+ * runtime, resolved by the same `normalizeLimits` the middleware enforces from.
11
+ */
12
+ export type MeterKey = 'storageBytes' | 'aiJobs' | 'trackedTickers' | 'seats';
13
+ export type FeatureKey = 'cleanExport';
14
+ /** Matches `UNLIMITED` in backend-core. Chosen over `Infinity` because it survives JSON. */
15
+ export declare const UNLIMITED = -1;
16
+ export interface PlanLimits {
17
+ meters: Record<MeterKey, number>;
18
+ features: Record<FeatureKey, boolean>;
19
+ /** How far back reads may reach. `-1` means the full history. */
20
+ retentionDays: number;
21
+ }
22
+ export interface Entitlements {
23
+ email: string;
24
+ planCode: string;
25
+ /** Subscription status backing the plan; `null` on the implicit free plan. */
26
+ status: string | null;
27
+ limits: PlanLimits;
28
+ /** Cheapest active plan priced above the current one, for an upgrade CTA. */
29
+ upgradeTo?: string;
30
+ /** Granted by an admin rather than bought — render as "Complimentary". */
31
+ isComp?: boolean;
32
+ hasOverride?: boolean;
33
+ /** Live meter readings, same keys as `limits.meters`. */
34
+ usage: Record<MeterKey, number>;
35
+ }
36
+ export interface EntitlementsServiceConfig {
37
+ /** Read fresh at call time (config may not be resolved yet at module load). */
38
+ getPaymentApiUrl: () => string;
39
+ onUnauthorized?: () => void;
40
+ }
41
+ /**
42
+ * Builds a per-front entitlements service.
43
+ *
44
+ * export const { getMyEntitlements } = createEntitlementsService({
45
+ * getPaymentApiUrl: () => getGlobalConfig().PAYMENT_API_URL!
46
+ * });
47
+ */
48
+ export declare function createEntitlementsService(config: EntitlementsServiceConfig): {
49
+ getMyEntitlements(): Promise<Entitlements>;
50
+ };
51
+ export type EntitlementsService = ReturnType<typeof createEntitlementsService>;
52
+ /** True when a meter has no ceiling. */
53
+ export declare function isUnlimited(limit: number): boolean;
54
+ /**
55
+ * Share of a meter consumed, 0–1, clamped. Unlimited meters report 0 — there is
56
+ * no ceiling to be a fraction of, and reporting 1 would paint them as full.
57
+ */
58
+ export declare function usageRatio(used: number, limit: number): number;
@@ -0,0 +1,53 @@
1
+ import { createApiClient } from './apiClient';
2
+ /** Matches `UNLIMITED` in backend-core. Chosen over `Infinity` because it survives JSON. */
3
+ export const UNLIMITED = -1;
4
+ const ZERO_USAGE = {
5
+ storageBytes: 0,
6
+ aiJobs: 0,
7
+ trackedTickers: 0,
8
+ seats: 0
9
+ };
10
+ /**
11
+ * Builds a per-front entitlements service.
12
+ *
13
+ * export const { getMyEntitlements } = createEntitlementsService({
14
+ * getPaymentApiUrl: () => getGlobalConfig().PAYMENT_API_URL!
15
+ * });
16
+ */
17
+ export function createEntitlementsService(config) {
18
+ const client = createApiClient({
19
+ baseUrl: () => config.getPaymentApiUrl(),
20
+ onUnauthorized: config.onUnauthorized
21
+ });
22
+ return {
23
+ async getMyEntitlements() {
24
+ const body = await client.get('/api/subscriptions/me/entitlements');
25
+ // Reject rather than return a half-built object. Without `limits` there
26
+ // is no ceiling to meter against, and handing back a truthy
27
+ // `Entitlements` whose `limits` is undefined pushes the crash into every
28
+ // call site's property access instead of failing here, once.
29
+ if (!body?.limits?.meters) {
30
+ throw new Error('Entitlements response is missing limits');
31
+ }
32
+ return {
33
+ ...body,
34
+ // A meter with no counter yet is simply absent from the snapshot, which
35
+ // would otherwise render as `undefined of 1 GB`.
36
+ usage: { ...ZERO_USAGE, ...(body.usage ?? {}) }
37
+ };
38
+ }
39
+ };
40
+ }
41
+ /** True when a meter has no ceiling. */
42
+ export function isUnlimited(limit) {
43
+ return limit === UNLIMITED || limit < 0;
44
+ }
45
+ /**
46
+ * Share of a meter consumed, 0–1, clamped. Unlimited meters report 0 — there is
47
+ * no ceiling to be a fraction of, and reporting 1 would paint them as full.
48
+ */
49
+ export function usageRatio(used, limit) {
50
+ if (isUnlimited(limit) || limit === 0)
51
+ return 0;
52
+ return Math.min(1, Math.max(0, used / limit));
53
+ }
package/dist/index.d.ts CHANGED
@@ -4,6 +4,9 @@ export { createAuthService, createAppAuthService } from './authService';
4
4
  export type { AuthServiceConfig, AuthService, AppAuthConfig } from './authService';
5
5
  export { createGroupService } from './groupService';
6
6
  export type { GroupServiceConfig, GroupService } from './groupService';
7
+ export { createEntitlementsService, isUnlimited, usageRatio, UNLIMITED } from './entitlementsService';
8
+ export type { EntitlementsServiceConfig, EntitlementsService, Entitlements, PlanLimits, MeterKey, FeatureKey } from './entitlementsService';
7
9
  export type { User, AuthResponse, Group, ApiResponse } from './types';
10
+ export { TENANT_STORAGE_KEY, clearSessionScopedStorage } from './sessionStorage';
8
11
  export { initMonitoring, captureException, captureMessage, setUser, setTag, startTransaction, recordMetric, initWebVitals, flushLogs, initConsoleInterception, generateCorrelationId, getCorrelationId, getSessionId, logger } from './monitoring';
9
12
  export type { MonitoringConfig } from './monitoring';
package/dist/index.js CHANGED
@@ -2,5 +2,7 @@
2
2
  export { createApiClient, ApiError } from './apiClient';
3
3
  export { createAuthService, createAppAuthService } from './authService';
4
4
  export { createGroupService } from './groupService';
5
+ export { createEntitlementsService, isUnlimited, usageRatio, UNLIMITED } from './entitlementsService';
6
+ export { TENANT_STORAGE_KEY, clearSessionScopedStorage } from './sessionStorage';
5
7
  // Monitoring: headless logging, error capture, correlation IDs, and web-vitals
6
8
  export { initMonitoring, captureException, captureMessage, setUser, setTag, startTransaction, recordMetric, initWebVitals, flushLogs, initConsoleInterception, generateCorrelationId, getCorrelationId, getSessionId, logger } from './monitoring';
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Browser storage that belongs to the signed-in session rather than to the
3
+ * browser.
4
+ *
5
+ * localStorage is scoped per origin, so anything written here outlives a
6
+ * sign-out and is handed to whoever signs in next. For preferences that is
7
+ * fine — for anything the backend authorizes against, it is a bug: the
8
+ * incoming account sends the previous account's context and gets 403s it
9
+ * cannot explain, from a UI that looks correctly configured.
10
+ *
11
+ * `logout()` clears every key listed here. Add a key to this list rather than
12
+ * clearing it at the call site, so a new front or a second logout button
13
+ * cannot miss it.
14
+ */
15
+ /** Tenant (personal vs group) context — owned by `useTenant` in @tumbaland/components. */
16
+ export declare const TENANT_STORAGE_KEY = "selectedTenant";
17
+ export declare function clearSessionScopedStorage(): void;
@@ -0,0 +1,28 @@
1
+ import { logger } from './monitoring';
2
+ /**
3
+ * Browser storage that belongs to the signed-in session rather than to the
4
+ * browser.
5
+ *
6
+ * localStorage is scoped per origin, so anything written here outlives a
7
+ * sign-out and is handed to whoever signs in next. For preferences that is
8
+ * fine — for anything the backend authorizes against, it is a bug: the
9
+ * incoming account sends the previous account's context and gets 403s it
10
+ * cannot explain, from a UI that looks correctly configured.
11
+ *
12
+ * `logout()` clears every key listed here. Add a key to this list rather than
13
+ * clearing it at the call site, so a new front or a second logout button
14
+ * cannot miss it.
15
+ */
16
+ /** Tenant (personal vs group) context — owned by `useTenant` in @tumbaland/components. */
17
+ export const TENANT_STORAGE_KEY = 'selectedTenant';
18
+ const SESSION_SCOPED_KEYS = [TENANT_STORAGE_KEY];
19
+ export function clearSessionScopedStorage() {
20
+ for (const key of SESSION_SCOPED_KEYS) {
21
+ try {
22
+ localStorage.removeItem(key);
23
+ }
24
+ catch (err) {
25
+ logger.warn('Failed to clear session-scoped storage', { key, error: err });
26
+ }
27
+ }
28
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tumbaland/frontend-core",
3
- "version": "1.7.0",
3
+ "version": "1.9.0",
4
4
  "description": "Shared frontend auth/group/API-client logic for Tumbaland frontends",
5
5
  "author": "Tumbaland",
6
6
  "license": "MIT",