spaps-sdk 1.5.1 → 1.6.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.
package/dist/index.d.mts CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as spaps_types from 'spaps-types';
2
- import { CreateProductRequest, Product, UpdateProductRequest, CreatePriceRequest, Price, ProductSyncResult, CryptoReconcileRequest, CreateSecureMessageRequest, SecureMessage, AuthResponse, User as User$1, CreateCryptoInvoiceRequest, CryptoInvoiceStatusSnapshot, CheckoutSession, DayrateAvailabilityResponse, DayrateBookingRequest, DayrateBookingResponse, DayrateMultiBookingRequest, DayrateMultiBookingResponse, Subscription, UsageBalance, VerifyCryptoWebhookSignatureOptions } from 'spaps-types';
3
- export { AdminPermission, AdminRole, AdminUser, ApiResponse, AuthResponse, CheckoutSession, CreateCryptoInvoiceRequest, CreatePriceRequest, CreateProductRequest, CreateSecureMessageInput, CreateSecureMessageRequest, CryptoInvoice, CryptoInvoiceResponse, CryptoInvoiceStatusSnapshot, CryptoReconcileRequest, DayrateAvailabilityResponse, DayrateAvailableSlot, DayrateBookingRequest, DayrateBookingResponse, DayrateDayOfWeek, DayrateMultiBookingRequest, DayrateMultiBookingResponse, DayratePriceBreakdown, DayrateSlotType, Price, Product, ProductSyncResult, SecureMessage, SecureMessageOutput, Subscription, TokenPair, UpdateProductRequest, UsageBalance, User, UserProfile, UserRole, UserWallet, VerifyCryptoWebhookSignatureOptions, createSecureMessageRequestSchema, secureMessageMetadataSchema, secureMessageSchema } from 'spaps-types';
2
+ import { ResourceType, Entitlement, CreateProductRequest, Product, UpdateProductRequest, CreatePriceRequest, Price, ProductSyncResult, CryptoReconcileRequest, CreateSecureMessageRequest, SecureMessage, AuthResponse, User as User$1, CreateCryptoInvoiceRequest, CryptoInvoiceStatusSnapshot, CheckoutSession, DayrateAvailabilityResponse, DayrateBookingRequest, DayrateBookingResponse, DayrateMultiBookingRequest, DayrateMultiBookingResponse, Subscription, UsageBalance, VerifyCryptoWebhookSignatureOptions } from 'spaps-types';
3
+ export { AdminPermission, AdminRole, AdminUser, ApiResponse, AuthResponse, CheckoutSession, CreateCryptoInvoiceRequest, CreatePriceRequest, CreateProductRequest, CreateSecureMessageInput, CreateSecureMessageRequest, CryptoInvoice, CryptoInvoiceResponse, CryptoInvoiceStatusSnapshot, CryptoReconcileRequest, DayrateAvailabilityResponse, DayrateAvailableSlot, DayrateBookingRequest, DayrateBookingResponse, DayrateDayOfWeek, DayrateMultiBookingRequest, DayrateMultiBookingResponse, DayratePriceBreakdown, DayrateSlotType, Entitlement, Price, Product, ProductSyncResult, ResourceType, SecureMessage, SecureMessageOutput, Subscription, TokenPair, UpdateProductRequest, UsageBalance, User, UserProfile, UserRole, UserWallet, VerifyCryptoWebhookSignatureOptions, createSecureMessageRequestSchema, secureMessageMetadataSchema, secureMessageSchema } from 'spaps-types';
4
4
 
5
5
  /**
6
6
  * Permission checking utilities for SPAPS SDK
@@ -85,6 +85,164 @@ declare class PermissionChecker {
85
85
  declare function createPermissionChecker(customAdmins?: (string | AdminConfig)[]): PermissionChecker;
86
86
  declare const defaultPermissionChecker: PermissionChecker;
87
87
 
88
+ /**
89
+ * RoleHierarchy - Hierarchical role comparison utility
90
+ *
91
+ * Allows defining a numeric level for each role and comparing
92
+ * whether a user's role meets or exceeds a required minimum.
93
+ */
94
+ declare class RoleHierarchy {
95
+ private levels;
96
+ /**
97
+ * @param levels - Mapping of role name to numeric level.
98
+ * Higher numbers indicate greater privilege.
99
+ * Example: { guest: 0, user: 10, accountant: 20, admin: 30 }
100
+ */
101
+ constructor(levels: {
102
+ [role: string]: number;
103
+ });
104
+ /**
105
+ * Get the numeric level for a role.
106
+ * Returns undefined if the role is not registered.
107
+ */
108
+ getLevel(role: string): number | undefined;
109
+ /**
110
+ * Check whether `userRole` meets or exceeds `requiredRole` in the hierarchy.
111
+ * Returns false if either role is unknown (not registered in the hierarchy).
112
+ */
113
+ hasMinimumRole(userRole: string, requiredRole: string): boolean;
114
+ }
115
+
116
+ /**
117
+ * FeatureEvaluator - 3-layer feature gate evaluation
118
+ *
119
+ * Evaluation order (stops at first failing layer):
120
+ * 1. System kill switch - If a system entitlement with key "kill:{featureName}" exists, feature is DISABLED.
121
+ * 2. Resource block - If a resource-scoped block entitlement exists for a specific company/org, feature is DISABLED.
122
+ * 3. Role minimum - If user's role does not meet the required minimum, feature is DISABLED.
123
+ *
124
+ * Admin bypass: Only SPAPS super admins bypass all checks (NOT app-level admins).
125
+ * Unknown / unconfigured features are enabled by default.
126
+ */
127
+
128
+ /** Definition of a feature's gating layers (all optional). */
129
+ interface FeatureDefinition {
130
+ /** System kill-switch entitlement key. When this entitlement exists the feature is globally disabled. */
131
+ killSwitchKey?: string;
132
+ /** Resource-scoped block entitlement key. When this entitlement exists for a given resource the feature is disabled for that resource. */
133
+ resourceBlockKey?: string;
134
+ /** The resource type used for resource-block lookups (e.g. "company", "org"). */
135
+ resourceBlockType?: ResourceType;
136
+ /** Minimum role required (evaluated via the attached RoleHierarchy). */
137
+ minimumRole?: string;
138
+ }
139
+ /** Runtime context supplied when evaluating a feature. */
140
+ interface FeatureContext {
141
+ /** The user's current role name. */
142
+ userRole?: string;
143
+ /** Whether the user is a SPAPS super admin. */
144
+ isSuperAdmin?: boolean;
145
+ /** Active entitlements to evaluate against. */
146
+ entitlements?: Entitlement[];
147
+ /** Resource ID to check for resource-scoped blocks (e.g. company ID). */
148
+ resourceId?: string;
149
+ }
150
+ declare class FeatureEvaluator {
151
+ private features;
152
+ private roleHierarchy;
153
+ /**
154
+ * @param roleHierarchy - Optional RoleHierarchy instance used for role-minimum checks.
155
+ */
156
+ constructor(roleHierarchy?: RoleHierarchy);
157
+ /**
158
+ * Register (or replace) a feature definition.
159
+ */
160
+ registerFeature(name: string, definition: FeatureDefinition): void;
161
+ /**
162
+ * Evaluate whether a feature is enabled for the given context.
163
+ *
164
+ * Returns `true` (enabled) or `false` (disabled).
165
+ */
166
+ isEnabled(featureName: string, context?: FeatureContext): boolean;
167
+ }
168
+
169
+ /**
170
+ * WebSocketAuthHelper - Authenticated WebSocket connection manager
171
+ *
172
+ * Features:
173
+ * - Builds authenticated WebSocket URLs with access tokens
174
+ * - Refreshes tokens before expiry (configurable buffer)
175
+ * - Reconnects on auth-related close codes (4001, 4003)
176
+ * - Exponential backoff for network failures
177
+ * - Ping/pong mechanism to detect stale connections
178
+ */
179
+ interface WebSocketAuthHelperConfig {
180
+ /** Base WebSocket URL (e.g. "wss://api.sweetpotato.dev/ws") */
181
+ url: string;
182
+ /** Function that returns the current access token. */
183
+ getAccessToken: () => string | undefined;
184
+ /**
185
+ * Function to refresh the access token.
186
+ * Should perform the refresh and return the new access token.
187
+ */
188
+ refreshAccessToken: () => Promise<string>;
189
+ /** Seconds before token expiry to proactively refresh (default 30). */
190
+ refreshBufferSeconds?: number;
191
+ /** Maximum number of reconnection attempts on auth failures (default 3). */
192
+ maxAuthRetries?: number;
193
+ /** Maximum number of reconnection attempts on network failures (default 5). */
194
+ maxNetworkRetries?: number;
195
+ /** Initial backoff delay in ms for network reconnections (default 1000). */
196
+ initialBackoffMs?: number;
197
+ /** Maximum backoff delay in ms (default 30000). */
198
+ maxBackoffMs?: number;
199
+ /** Ping interval in ms to detect stale connections. 0 disables (default 30000). */
200
+ pingIntervalMs?: number;
201
+ /** Callback invoked when a message is received. */
202
+ onMessage?: (data: string) => void;
203
+ /** Callback invoked when the connection opens. */
204
+ onOpen?: () => void;
205
+ /** Callback invoked when the connection closes. */
206
+ onClose?: (code: number, reason: string) => void;
207
+ /** Callback invoked on errors. */
208
+ onError?: (error: Event) => void;
209
+ }
210
+ declare class WebSocketAuthHelper {
211
+ private config;
212
+ private ws;
213
+ private authRetries;
214
+ private networkRetries;
215
+ private pingTimer;
216
+ private refreshTimer;
217
+ private intentionalClose;
218
+ constructor(config: WebSocketAuthHelperConfig);
219
+ /**
220
+ * Open an authenticated WebSocket connection.
221
+ * Appends the access token as a query parameter.
222
+ */
223
+ connect(): Promise<void>;
224
+ /**
225
+ * Cleanly disconnect. No reconnection attempts will be made.
226
+ */
227
+ disconnect(): void;
228
+ /**
229
+ * Send data over the WebSocket.
230
+ * Throws if the connection is not open.
231
+ */
232
+ send(data: string): void;
233
+ /** Build the authenticated WebSocket URL. */
234
+ buildAuthUrl(): string;
235
+ private openConnection;
236
+ private handleAuthClose;
237
+ private handleNetworkClose;
238
+ private startPing;
239
+ /**
240
+ * Schedule a proactive token refresh before the current token expires.
241
+ */
242
+ private scheduleTokenRefresh;
243
+ private cleanup;
244
+ }
245
+
88
246
  type ApiKeyType = 'publishable' | 'secret';
89
247
  interface SPAPSConfig {
90
248
  apiUrl?: string;
@@ -172,6 +330,20 @@ interface EmailTemplatePreview {
172
330
  html: string;
173
331
  text?: string;
174
332
  }
333
+ interface EntitlementListParams {
334
+ /** Filter by entitlement key */
335
+ key?: string;
336
+ /** Filter by source type */
337
+ source?: 'crypto' | 'stripe_subscription' | 'stripe_checkout' | 'manual';
338
+ /** Include revoked entitlements (default false) */
339
+ include_revoked?: boolean;
340
+ }
341
+ interface EntitlementCheckResult {
342
+ /** Whether the user has the entitlement */
343
+ entitled: boolean;
344
+ /** The matching entitlement, if any */
345
+ entitlement?: Entitlement;
346
+ }
175
347
 
176
348
  declare class SPAPSClient<SecureMessageMetadata extends Record<string, any> = Record<string, any>> {
177
349
  private client;
@@ -226,6 +398,37 @@ declare class SPAPSClient<SecureMessageMetadata extends Record<string, any> = Re
226
398
  getTemplate: (templateKey: string) => Promise<EmailTemplate>;
227
399
  previewTemplate: (templateKey: string, context?: Record<string, unknown>) => Promise<EmailTemplatePreview>;
228
400
  };
401
+ /**
402
+ * Entitlements namespace for querying user and resource entitlements.
403
+ *
404
+ * Scope rules:
405
+ * - Publishable key contexts (browser): JWT required, user derived from JWT, can only query user-scoped entitlements.
406
+ * - Secret key contexts (server): can query any resource scope.
407
+ * - `listByResource` with non-user resource types will fail with 403 for publishable keys (enforced server-side).
408
+ */
409
+ entitlements: {
410
+ /**
411
+ * List the current user's entitlements.
412
+ * Requires authentication (JWT).
413
+ */
414
+ list: (params?: EntitlementListParams) => Promise<Entitlement[]>;
415
+ /**
416
+ * Boolean access check — does the user hold an active entitlement with the given key?
417
+ * @param key - The entitlement key to check (e.g. "premium").
418
+ * @param userId - Optional user ID override (secret key contexts only).
419
+ */
420
+ check: (key: string, userId?: string) => Promise<EntitlementCheckResult>;
421
+ /**
422
+ * List entitlements scoped to a specific resource type and optional resource ID.
423
+ *
424
+ * Note: For publishable key contexts, only `resource_type=user` is permitted.
425
+ * Non-user resource types will return a 403 error (enforced server-side).
426
+ *
427
+ * @param resourceType - The resource type (e.g. "user", "company", "org", "system").
428
+ * @param resourceId - Optional specific resource ID.
429
+ */
430
+ listByResource: (resourceType: ResourceType, resourceId?: string) => Promise<Entitlement[]>;
431
+ };
229
432
  constructor(config?: SPAPSConfig);
230
433
  /** Raw API request helper that returns an ApiResponse-like shape */
231
434
  request<T = any>(method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH', url: string, data?: any, requiresAuth?: boolean): Promise<{
@@ -692,4 +895,4 @@ declare function createServerClient(secretKey: string, options?: Omit<SPAPSConfi
692
895
  */
693
896
  declare function detectKeyType(key: string): ApiKeyType | null;
694
897
 
695
- export { type AdminConfig, type ApiKeyType, type CheckoutLineItem, type CheckoutLineItemPriceData, type CreateCheckoutSessionPayload, DEFAULT_ADMIN_ACCOUNTS, type EmailSendOptions, type EmailSendResult, type EmailTemplate, type EmailTemplatePreview, type PermissionCheckResult, PermissionChecker, SPAPSClient as SPAPS, SPAPSClient, type SPAPSConfig, type TemplateVariable, TokenManager, WalletUtils, canAccessAdmin, createBrowserClient, createPermissionChecker, createServerClient, SPAPSClient as default, defaultPermissionChecker, detectKeyType, getRoleAwareErrorMessage, getUserDisplay, getUserRole, hasPermission, isAdminAccount, verifyCryptoWebhookSignature };
898
+ export { type AdminConfig, type ApiKeyType, type CheckoutLineItem, type CheckoutLineItemPriceData, type CreateCheckoutSessionPayload, DEFAULT_ADMIN_ACCOUNTS, type EmailSendOptions, type EmailSendResult, type EmailTemplate, type EmailTemplatePreview, type EntitlementCheckResult, type EntitlementListParams, type FeatureContext, type FeatureDefinition, FeatureEvaluator, type PermissionCheckResult, PermissionChecker, RoleHierarchy, SPAPSClient as SPAPS, SPAPSClient, type SPAPSConfig, type TemplateVariable, TokenManager, WalletUtils, WebSocketAuthHelper, type WebSocketAuthHelperConfig, canAccessAdmin, createBrowserClient, createPermissionChecker, createServerClient, SPAPSClient as default, defaultPermissionChecker, detectKeyType, getRoleAwareErrorMessage, getUserDisplay, getUserRole, hasPermission, isAdminAccount, verifyCryptoWebhookSignature };
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as spaps_types from 'spaps-types';
2
- import { CreateProductRequest, Product, UpdateProductRequest, CreatePriceRequest, Price, ProductSyncResult, CryptoReconcileRequest, CreateSecureMessageRequest, SecureMessage, AuthResponse, User as User$1, CreateCryptoInvoiceRequest, CryptoInvoiceStatusSnapshot, CheckoutSession, DayrateAvailabilityResponse, DayrateBookingRequest, DayrateBookingResponse, DayrateMultiBookingRequest, DayrateMultiBookingResponse, Subscription, UsageBalance, VerifyCryptoWebhookSignatureOptions } from 'spaps-types';
3
- export { AdminPermission, AdminRole, AdminUser, ApiResponse, AuthResponse, CheckoutSession, CreateCryptoInvoiceRequest, CreatePriceRequest, CreateProductRequest, CreateSecureMessageInput, CreateSecureMessageRequest, CryptoInvoice, CryptoInvoiceResponse, CryptoInvoiceStatusSnapshot, CryptoReconcileRequest, DayrateAvailabilityResponse, DayrateAvailableSlot, DayrateBookingRequest, DayrateBookingResponse, DayrateDayOfWeek, DayrateMultiBookingRequest, DayrateMultiBookingResponse, DayratePriceBreakdown, DayrateSlotType, Price, Product, ProductSyncResult, SecureMessage, SecureMessageOutput, Subscription, TokenPair, UpdateProductRequest, UsageBalance, User, UserProfile, UserRole, UserWallet, VerifyCryptoWebhookSignatureOptions, createSecureMessageRequestSchema, secureMessageMetadataSchema, secureMessageSchema } from 'spaps-types';
2
+ import { ResourceType, Entitlement, CreateProductRequest, Product, UpdateProductRequest, CreatePriceRequest, Price, ProductSyncResult, CryptoReconcileRequest, CreateSecureMessageRequest, SecureMessage, AuthResponse, User as User$1, CreateCryptoInvoiceRequest, CryptoInvoiceStatusSnapshot, CheckoutSession, DayrateAvailabilityResponse, DayrateBookingRequest, DayrateBookingResponse, DayrateMultiBookingRequest, DayrateMultiBookingResponse, Subscription, UsageBalance, VerifyCryptoWebhookSignatureOptions } from 'spaps-types';
3
+ export { AdminPermission, AdminRole, AdminUser, ApiResponse, AuthResponse, CheckoutSession, CreateCryptoInvoiceRequest, CreatePriceRequest, CreateProductRequest, CreateSecureMessageInput, CreateSecureMessageRequest, CryptoInvoice, CryptoInvoiceResponse, CryptoInvoiceStatusSnapshot, CryptoReconcileRequest, DayrateAvailabilityResponse, DayrateAvailableSlot, DayrateBookingRequest, DayrateBookingResponse, DayrateDayOfWeek, DayrateMultiBookingRequest, DayrateMultiBookingResponse, DayratePriceBreakdown, DayrateSlotType, Entitlement, Price, Product, ProductSyncResult, ResourceType, SecureMessage, SecureMessageOutput, Subscription, TokenPair, UpdateProductRequest, UsageBalance, User, UserProfile, UserRole, UserWallet, VerifyCryptoWebhookSignatureOptions, createSecureMessageRequestSchema, secureMessageMetadataSchema, secureMessageSchema } from 'spaps-types';
4
4
 
5
5
  /**
6
6
  * Permission checking utilities for SPAPS SDK
@@ -85,6 +85,164 @@ declare class PermissionChecker {
85
85
  declare function createPermissionChecker(customAdmins?: (string | AdminConfig)[]): PermissionChecker;
86
86
  declare const defaultPermissionChecker: PermissionChecker;
87
87
 
88
+ /**
89
+ * RoleHierarchy - Hierarchical role comparison utility
90
+ *
91
+ * Allows defining a numeric level for each role and comparing
92
+ * whether a user's role meets or exceeds a required minimum.
93
+ */
94
+ declare class RoleHierarchy {
95
+ private levels;
96
+ /**
97
+ * @param levels - Mapping of role name to numeric level.
98
+ * Higher numbers indicate greater privilege.
99
+ * Example: { guest: 0, user: 10, accountant: 20, admin: 30 }
100
+ */
101
+ constructor(levels: {
102
+ [role: string]: number;
103
+ });
104
+ /**
105
+ * Get the numeric level for a role.
106
+ * Returns undefined if the role is not registered.
107
+ */
108
+ getLevel(role: string): number | undefined;
109
+ /**
110
+ * Check whether `userRole` meets or exceeds `requiredRole` in the hierarchy.
111
+ * Returns false if either role is unknown (not registered in the hierarchy).
112
+ */
113
+ hasMinimumRole(userRole: string, requiredRole: string): boolean;
114
+ }
115
+
116
+ /**
117
+ * FeatureEvaluator - 3-layer feature gate evaluation
118
+ *
119
+ * Evaluation order (stops at first failing layer):
120
+ * 1. System kill switch - If a system entitlement with key "kill:{featureName}" exists, feature is DISABLED.
121
+ * 2. Resource block - If a resource-scoped block entitlement exists for a specific company/org, feature is DISABLED.
122
+ * 3. Role minimum - If user's role does not meet the required minimum, feature is DISABLED.
123
+ *
124
+ * Admin bypass: Only SPAPS super admins bypass all checks (NOT app-level admins).
125
+ * Unknown / unconfigured features are enabled by default.
126
+ */
127
+
128
+ /** Definition of a feature's gating layers (all optional). */
129
+ interface FeatureDefinition {
130
+ /** System kill-switch entitlement key. When this entitlement exists the feature is globally disabled. */
131
+ killSwitchKey?: string;
132
+ /** Resource-scoped block entitlement key. When this entitlement exists for a given resource the feature is disabled for that resource. */
133
+ resourceBlockKey?: string;
134
+ /** The resource type used for resource-block lookups (e.g. "company", "org"). */
135
+ resourceBlockType?: ResourceType;
136
+ /** Minimum role required (evaluated via the attached RoleHierarchy). */
137
+ minimumRole?: string;
138
+ }
139
+ /** Runtime context supplied when evaluating a feature. */
140
+ interface FeatureContext {
141
+ /** The user's current role name. */
142
+ userRole?: string;
143
+ /** Whether the user is a SPAPS super admin. */
144
+ isSuperAdmin?: boolean;
145
+ /** Active entitlements to evaluate against. */
146
+ entitlements?: Entitlement[];
147
+ /** Resource ID to check for resource-scoped blocks (e.g. company ID). */
148
+ resourceId?: string;
149
+ }
150
+ declare class FeatureEvaluator {
151
+ private features;
152
+ private roleHierarchy;
153
+ /**
154
+ * @param roleHierarchy - Optional RoleHierarchy instance used for role-minimum checks.
155
+ */
156
+ constructor(roleHierarchy?: RoleHierarchy);
157
+ /**
158
+ * Register (or replace) a feature definition.
159
+ */
160
+ registerFeature(name: string, definition: FeatureDefinition): void;
161
+ /**
162
+ * Evaluate whether a feature is enabled for the given context.
163
+ *
164
+ * Returns `true` (enabled) or `false` (disabled).
165
+ */
166
+ isEnabled(featureName: string, context?: FeatureContext): boolean;
167
+ }
168
+
169
+ /**
170
+ * WebSocketAuthHelper - Authenticated WebSocket connection manager
171
+ *
172
+ * Features:
173
+ * - Builds authenticated WebSocket URLs with access tokens
174
+ * - Refreshes tokens before expiry (configurable buffer)
175
+ * - Reconnects on auth-related close codes (4001, 4003)
176
+ * - Exponential backoff for network failures
177
+ * - Ping/pong mechanism to detect stale connections
178
+ */
179
+ interface WebSocketAuthHelperConfig {
180
+ /** Base WebSocket URL (e.g. "wss://api.sweetpotato.dev/ws") */
181
+ url: string;
182
+ /** Function that returns the current access token. */
183
+ getAccessToken: () => string | undefined;
184
+ /**
185
+ * Function to refresh the access token.
186
+ * Should perform the refresh and return the new access token.
187
+ */
188
+ refreshAccessToken: () => Promise<string>;
189
+ /** Seconds before token expiry to proactively refresh (default 30). */
190
+ refreshBufferSeconds?: number;
191
+ /** Maximum number of reconnection attempts on auth failures (default 3). */
192
+ maxAuthRetries?: number;
193
+ /** Maximum number of reconnection attempts on network failures (default 5). */
194
+ maxNetworkRetries?: number;
195
+ /** Initial backoff delay in ms for network reconnections (default 1000). */
196
+ initialBackoffMs?: number;
197
+ /** Maximum backoff delay in ms (default 30000). */
198
+ maxBackoffMs?: number;
199
+ /** Ping interval in ms to detect stale connections. 0 disables (default 30000). */
200
+ pingIntervalMs?: number;
201
+ /** Callback invoked when a message is received. */
202
+ onMessage?: (data: string) => void;
203
+ /** Callback invoked when the connection opens. */
204
+ onOpen?: () => void;
205
+ /** Callback invoked when the connection closes. */
206
+ onClose?: (code: number, reason: string) => void;
207
+ /** Callback invoked on errors. */
208
+ onError?: (error: Event) => void;
209
+ }
210
+ declare class WebSocketAuthHelper {
211
+ private config;
212
+ private ws;
213
+ private authRetries;
214
+ private networkRetries;
215
+ private pingTimer;
216
+ private refreshTimer;
217
+ private intentionalClose;
218
+ constructor(config: WebSocketAuthHelperConfig);
219
+ /**
220
+ * Open an authenticated WebSocket connection.
221
+ * Appends the access token as a query parameter.
222
+ */
223
+ connect(): Promise<void>;
224
+ /**
225
+ * Cleanly disconnect. No reconnection attempts will be made.
226
+ */
227
+ disconnect(): void;
228
+ /**
229
+ * Send data over the WebSocket.
230
+ * Throws if the connection is not open.
231
+ */
232
+ send(data: string): void;
233
+ /** Build the authenticated WebSocket URL. */
234
+ buildAuthUrl(): string;
235
+ private openConnection;
236
+ private handleAuthClose;
237
+ private handleNetworkClose;
238
+ private startPing;
239
+ /**
240
+ * Schedule a proactive token refresh before the current token expires.
241
+ */
242
+ private scheduleTokenRefresh;
243
+ private cleanup;
244
+ }
245
+
88
246
  type ApiKeyType = 'publishable' | 'secret';
89
247
  interface SPAPSConfig {
90
248
  apiUrl?: string;
@@ -172,6 +330,20 @@ interface EmailTemplatePreview {
172
330
  html: string;
173
331
  text?: string;
174
332
  }
333
+ interface EntitlementListParams {
334
+ /** Filter by entitlement key */
335
+ key?: string;
336
+ /** Filter by source type */
337
+ source?: 'crypto' | 'stripe_subscription' | 'stripe_checkout' | 'manual';
338
+ /** Include revoked entitlements (default false) */
339
+ include_revoked?: boolean;
340
+ }
341
+ interface EntitlementCheckResult {
342
+ /** Whether the user has the entitlement */
343
+ entitled: boolean;
344
+ /** The matching entitlement, if any */
345
+ entitlement?: Entitlement;
346
+ }
175
347
 
176
348
  declare class SPAPSClient<SecureMessageMetadata extends Record<string, any> = Record<string, any>> {
177
349
  private client;
@@ -226,6 +398,37 @@ declare class SPAPSClient<SecureMessageMetadata extends Record<string, any> = Re
226
398
  getTemplate: (templateKey: string) => Promise<EmailTemplate>;
227
399
  previewTemplate: (templateKey: string, context?: Record<string, unknown>) => Promise<EmailTemplatePreview>;
228
400
  };
401
+ /**
402
+ * Entitlements namespace for querying user and resource entitlements.
403
+ *
404
+ * Scope rules:
405
+ * - Publishable key contexts (browser): JWT required, user derived from JWT, can only query user-scoped entitlements.
406
+ * - Secret key contexts (server): can query any resource scope.
407
+ * - `listByResource` with non-user resource types will fail with 403 for publishable keys (enforced server-side).
408
+ */
409
+ entitlements: {
410
+ /**
411
+ * List the current user's entitlements.
412
+ * Requires authentication (JWT).
413
+ */
414
+ list: (params?: EntitlementListParams) => Promise<Entitlement[]>;
415
+ /**
416
+ * Boolean access check — does the user hold an active entitlement with the given key?
417
+ * @param key - The entitlement key to check (e.g. "premium").
418
+ * @param userId - Optional user ID override (secret key contexts only).
419
+ */
420
+ check: (key: string, userId?: string) => Promise<EntitlementCheckResult>;
421
+ /**
422
+ * List entitlements scoped to a specific resource type and optional resource ID.
423
+ *
424
+ * Note: For publishable key contexts, only `resource_type=user` is permitted.
425
+ * Non-user resource types will return a 403 error (enforced server-side).
426
+ *
427
+ * @param resourceType - The resource type (e.g. "user", "company", "org", "system").
428
+ * @param resourceId - Optional specific resource ID.
429
+ */
430
+ listByResource: (resourceType: ResourceType, resourceId?: string) => Promise<Entitlement[]>;
431
+ };
229
432
  constructor(config?: SPAPSConfig);
230
433
  /** Raw API request helper that returns an ApiResponse-like shape */
231
434
  request<T = any>(method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH', url: string, data?: any, requiresAuth?: boolean): Promise<{
@@ -692,4 +895,4 @@ declare function createServerClient(secretKey: string, options?: Omit<SPAPSConfi
692
895
  */
693
896
  declare function detectKeyType(key: string): ApiKeyType | null;
694
897
 
695
- export { type AdminConfig, type ApiKeyType, type CheckoutLineItem, type CheckoutLineItemPriceData, type CreateCheckoutSessionPayload, DEFAULT_ADMIN_ACCOUNTS, type EmailSendOptions, type EmailSendResult, type EmailTemplate, type EmailTemplatePreview, type PermissionCheckResult, PermissionChecker, SPAPSClient as SPAPS, SPAPSClient, type SPAPSConfig, type TemplateVariable, TokenManager, WalletUtils, canAccessAdmin, createBrowserClient, createPermissionChecker, createServerClient, SPAPSClient as default, defaultPermissionChecker, detectKeyType, getRoleAwareErrorMessage, getUserDisplay, getUserRole, hasPermission, isAdminAccount, verifyCryptoWebhookSignature };
898
+ export { type AdminConfig, type ApiKeyType, type CheckoutLineItem, type CheckoutLineItemPriceData, type CreateCheckoutSessionPayload, DEFAULT_ADMIN_ACCOUNTS, type EmailSendOptions, type EmailSendResult, type EmailTemplate, type EmailTemplatePreview, type EntitlementCheckResult, type EntitlementListParams, type FeatureContext, type FeatureDefinition, FeatureEvaluator, type PermissionCheckResult, PermissionChecker, RoleHierarchy, SPAPSClient as SPAPS, SPAPSClient, type SPAPSConfig, type TemplateVariable, TokenManager, WalletUtils, WebSocketAuthHelper, type WebSocketAuthHelperConfig, canAccessAdmin, createBrowserClient, createPermissionChecker, createServerClient, SPAPSClient as default, defaultPermissionChecker, detectKeyType, getRoleAwareErrorMessage, getUserDisplay, getUserRole, hasPermission, isAdminAccount, verifyCryptoWebhookSignature };
package/dist/index.js CHANGED
@@ -195,11 +195,14 @@ var init_permissions = __esm({
195
195
  var index_exports = {};
196
196
  __export(index_exports, {
197
197
  DEFAULT_ADMIN_ACCOUNTS: () => DEFAULT_ADMIN_ACCOUNTS,
198
+ FeatureEvaluator: () => FeatureEvaluator,
198
199
  PermissionChecker: () => PermissionChecker,
200
+ RoleHierarchy: () => RoleHierarchy,
199
201
  SPAPS: () => SPAPSClient,
200
202
  SPAPSClient: () => SPAPSClient,
201
203
  TokenManager: () => TokenManager,
202
204
  WalletUtils: () => WalletUtils,
205
+ WebSocketAuthHelper: () => WebSocketAuthHelper,
203
206
  canAccessAdmin: () => canAccessAdmin,
204
207
  createBrowserClient: () => createBrowserClient,
205
208
  createPermissionChecker: () => createPermissionChecker,
@@ -222,6 +225,281 @@ var import_crypto = __toESM(require("crypto"));
222
225
  var import_axios = __toESM(require("axios"));
223
226
  var import_spaps_types = require("spaps-types");
224
227
  init_permissions();
228
+
229
+ // src/role-hierarchy.ts
230
+ var RoleHierarchy = class {
231
+ levels;
232
+ /**
233
+ * @param levels - Mapping of role name to numeric level.
234
+ * Higher numbers indicate greater privilege.
235
+ * Example: { guest: 0, user: 10, accountant: 20, admin: 30 }
236
+ */
237
+ constructor(levels) {
238
+ this.levels = new Map(Object.entries(levels));
239
+ }
240
+ /**
241
+ * Get the numeric level for a role.
242
+ * Returns undefined if the role is not registered.
243
+ */
244
+ getLevel(role) {
245
+ return this.levels.get(role);
246
+ }
247
+ /**
248
+ * Check whether `userRole` meets or exceeds `requiredRole` in the hierarchy.
249
+ * Returns false if either role is unknown (not registered in the hierarchy).
250
+ */
251
+ hasMinimumRole(userRole, requiredRole) {
252
+ const userLevel = this.levels.get(userRole);
253
+ const requiredLevel = this.levels.get(requiredRole);
254
+ if (userLevel === void 0 || requiredLevel === void 0) {
255
+ return false;
256
+ }
257
+ return userLevel >= requiredLevel;
258
+ }
259
+ };
260
+
261
+ // src/feature-evaluator.ts
262
+ var FeatureEvaluator = class {
263
+ features = /* @__PURE__ */ new Map();
264
+ roleHierarchy;
265
+ /**
266
+ * @param roleHierarchy - Optional RoleHierarchy instance used for role-minimum checks.
267
+ */
268
+ constructor(roleHierarchy) {
269
+ this.roleHierarchy = roleHierarchy;
270
+ }
271
+ /**
272
+ * Register (or replace) a feature definition.
273
+ */
274
+ registerFeature(name, definition) {
275
+ this.features.set(name, definition);
276
+ }
277
+ /**
278
+ * Evaluate whether a feature is enabled for the given context.
279
+ *
280
+ * Returns `true` (enabled) or `false` (disabled).
281
+ */
282
+ isEnabled(featureName, context = {}) {
283
+ if (context.isSuperAdmin) {
284
+ return true;
285
+ }
286
+ const definition = this.features.get(featureName);
287
+ if (!definition) {
288
+ return true;
289
+ }
290
+ if (definition.killSwitchKey && context.entitlements) {
291
+ const killed = context.entitlements.some(
292
+ (e) => e.entitlement_key === definition.killSwitchKey && e.resource_type === "system" && !e.revoked_at
293
+ );
294
+ if (killed) {
295
+ return false;
296
+ }
297
+ }
298
+ if (definition.resourceBlockKey && definition.resourceBlockType && context.resourceId && context.entitlements) {
299
+ const blocked = context.entitlements.some(
300
+ (e) => e.entitlement_key === definition.resourceBlockKey && e.resource_type === definition.resourceBlockType && e.resource_id === context.resourceId && !e.revoked_at
301
+ );
302
+ if (blocked) {
303
+ return false;
304
+ }
305
+ }
306
+ if (definition.minimumRole && this.roleHierarchy) {
307
+ const userRole = context.userRole;
308
+ if (!userRole) {
309
+ return false;
310
+ }
311
+ if (!this.roleHierarchy.hasMinimumRole(userRole, definition.minimumRole)) {
312
+ return false;
313
+ }
314
+ }
315
+ return true;
316
+ }
317
+ };
318
+
319
+ // src/websocket-auth-helper.ts
320
+ var AUTH_CLOSE_CODES = /* @__PURE__ */ new Set([4001, 4003]);
321
+ var WebSocketAuthHelper = class {
322
+ config;
323
+ ws = null;
324
+ authRetries = 0;
325
+ networkRetries = 0;
326
+ pingTimer = null;
327
+ refreshTimer = null;
328
+ intentionalClose = false;
329
+ constructor(config) {
330
+ this.config = {
331
+ url: config.url,
332
+ getAccessToken: config.getAccessToken,
333
+ refreshAccessToken: config.refreshAccessToken,
334
+ refreshBufferSeconds: config.refreshBufferSeconds ?? 30,
335
+ maxAuthRetries: config.maxAuthRetries ?? 3,
336
+ maxNetworkRetries: config.maxNetworkRetries ?? 5,
337
+ initialBackoffMs: config.initialBackoffMs ?? 1e3,
338
+ maxBackoffMs: config.maxBackoffMs ?? 3e4,
339
+ pingIntervalMs: config.pingIntervalMs ?? 3e4,
340
+ onMessage: config.onMessage,
341
+ onOpen: config.onOpen,
342
+ onClose: config.onClose,
343
+ onError: config.onError
344
+ };
345
+ }
346
+ // ── Public API ──────────────────────────────────────────────────────────
347
+ /**
348
+ * Open an authenticated WebSocket connection.
349
+ * Appends the access token as a query parameter.
350
+ */
351
+ async connect() {
352
+ this.intentionalClose = false;
353
+ this.authRetries = 0;
354
+ this.networkRetries = 0;
355
+ await this.openConnection();
356
+ }
357
+ /**
358
+ * Cleanly disconnect. No reconnection attempts will be made.
359
+ */
360
+ disconnect() {
361
+ this.intentionalClose = true;
362
+ this.cleanup();
363
+ if (this.ws) {
364
+ this.ws.close(1e3, "Client disconnect");
365
+ this.ws = null;
366
+ }
367
+ }
368
+ /**
369
+ * Send data over the WebSocket.
370
+ * Throws if the connection is not open.
371
+ */
372
+ send(data) {
373
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
374
+ throw new Error("WebSocket is not connected");
375
+ }
376
+ this.ws.send(data);
377
+ }
378
+ // ── Internal helpers ────────────────────────────────────────────────────
379
+ /** Build the authenticated WebSocket URL. */
380
+ buildAuthUrl() {
381
+ const token = this.config.getAccessToken();
382
+ if (!token) {
383
+ throw new Error("No access token available for WebSocket authentication");
384
+ }
385
+ const separator = this.config.url.includes("?") ? "&" : "?";
386
+ return `${this.config.url}${separator}token=${encodeURIComponent(token)}`;
387
+ }
388
+ async openConnection() {
389
+ const url = this.buildAuthUrl();
390
+ return new Promise((resolve, reject) => {
391
+ try {
392
+ this.ws = new WebSocket(url);
393
+ } catch (err) {
394
+ reject(err);
395
+ return;
396
+ }
397
+ this.ws.onopen = () => {
398
+ this.networkRetries = 0;
399
+ this.startPing();
400
+ this.scheduleTokenRefresh();
401
+ this.config.onOpen?.();
402
+ resolve();
403
+ };
404
+ this.ws.onmessage = (event) => {
405
+ this.config.onMessage?.(String(event.data));
406
+ };
407
+ this.ws.onerror = (event) => {
408
+ this.config.onError?.(event);
409
+ };
410
+ this.ws.onclose = (event) => {
411
+ this.cleanup();
412
+ this.config.onClose?.(event.code, event.reason);
413
+ if (this.intentionalClose) {
414
+ return;
415
+ }
416
+ if (AUTH_CLOSE_CODES.has(event.code)) {
417
+ this.handleAuthClose();
418
+ } else {
419
+ this.handleNetworkClose();
420
+ }
421
+ };
422
+ });
423
+ }
424
+ async handleAuthClose() {
425
+ if (this.authRetries >= this.config.maxAuthRetries) {
426
+ return;
427
+ }
428
+ this.authRetries++;
429
+ try {
430
+ await this.config.refreshAccessToken();
431
+ await this.openConnection();
432
+ } catch {
433
+ }
434
+ }
435
+ handleNetworkClose() {
436
+ if (this.networkRetries >= this.config.maxNetworkRetries) {
437
+ return;
438
+ }
439
+ this.networkRetries++;
440
+ const delay = Math.min(
441
+ this.config.initialBackoffMs * Math.pow(2, this.networkRetries - 1),
442
+ this.config.maxBackoffMs
443
+ );
444
+ setTimeout(async () => {
445
+ if (this.intentionalClose) return;
446
+ try {
447
+ await this.openConnection();
448
+ } catch {
449
+ }
450
+ }, delay);
451
+ }
452
+ startPing() {
453
+ if (this.config.pingIntervalMs <= 0) return;
454
+ this.pingTimer = setInterval(() => {
455
+ if (this.ws?.readyState === WebSocket.OPEN) {
456
+ this.ws.send(JSON.stringify({ type: "ping" }));
457
+ }
458
+ }, this.config.pingIntervalMs);
459
+ }
460
+ /**
461
+ * Schedule a proactive token refresh before the current token expires.
462
+ */
463
+ scheduleTokenRefresh() {
464
+ const token = this.config.getAccessToken();
465
+ if (!token) return;
466
+ try {
467
+ const parts = token.split(".");
468
+ if (parts.length !== 3 || !parts[1]) return;
469
+ const payload = JSON.parse(
470
+ typeof atob === "function" ? atob(parts[1]) : Buffer.from(parts[1], "base64").toString("utf8")
471
+ );
472
+ if (typeof payload.exp !== "number") return;
473
+ const msUntilExpiry = payload.exp * 1e3 - Date.now();
474
+ const refreshIn = msUntilExpiry - this.config.refreshBufferSeconds * 1e3;
475
+ if (refreshIn <= 0) {
476
+ this.config.refreshAccessToken().catch(() => {
477
+ });
478
+ return;
479
+ }
480
+ this.refreshTimer = setTimeout(async () => {
481
+ try {
482
+ await this.config.refreshAccessToken();
483
+ this.scheduleTokenRefresh();
484
+ } catch {
485
+ }
486
+ }, refreshIn);
487
+ } catch {
488
+ }
489
+ }
490
+ cleanup() {
491
+ if (this.pingTimer) {
492
+ clearInterval(this.pingTimer);
493
+ this.pingTimer = null;
494
+ }
495
+ if (this.refreshTimer) {
496
+ clearTimeout(this.refreshTimer);
497
+ this.refreshTimer = null;
498
+ }
499
+ }
500
+ };
501
+
502
+ // src/index.ts
225
503
  if (typeof globalThis.fetch === "undefined") {
226
504
  require("cross-fetch/polyfill");
227
505
  }
@@ -286,6 +564,72 @@ var SPAPSClient = class {
286
564
  getTemplate: (templateKey) => this.getEmailTemplate(templateKey),
287
565
  previewTemplate: (templateKey, context) => this.previewEmailTemplate(templateKey, context)
288
566
  };
567
+ /**
568
+ * Entitlements namespace for querying user and resource entitlements.
569
+ *
570
+ * Scope rules:
571
+ * - Publishable key contexts (browser): JWT required, user derived from JWT, can only query user-scoped entitlements.
572
+ * - Secret key contexts (server): can query any resource scope.
573
+ * - `listByResource` with non-user resource types will fail with 403 for publishable keys (enforced server-side).
574
+ */
575
+ entitlements = {
576
+ /**
577
+ * List the current user's entitlements.
578
+ * Requires authentication (JWT).
579
+ */
580
+ list: async (params) => {
581
+ const q = new URLSearchParams();
582
+ if (params?.key) q.append("entitlement_key", params.key);
583
+ if (params?.source) q.append("source", params.source);
584
+ if (params?.include_revoked) q.append("include_revoked", "true");
585
+ const qs = q.toString();
586
+ const res = await this.client.get(
587
+ `/api/entitlements${qs ? `?${qs}` : ""}`,
588
+ this.accessToken ? { headers: { Authorization: `Bearer ${this.accessToken}` } } : void 0
589
+ );
590
+ const payload = this.unwrapApiResponse(res, "Failed to list entitlements");
591
+ if (payload && Array.isArray(payload.entitlements)) {
592
+ return payload.entitlements;
593
+ }
594
+ return payload;
595
+ },
596
+ /**
597
+ * Boolean access check — does the user hold an active entitlement with the given key?
598
+ * @param key - The entitlement key to check (e.g. "premium").
599
+ * @param userId - Optional user ID override (secret key contexts only).
600
+ */
601
+ check: async (key, userId) => {
602
+ const q = new URLSearchParams({ key });
603
+ if (userId) q.append("user_id", userId);
604
+ const res = await this.client.get(
605
+ `/api/entitlements/check?${q.toString()}`,
606
+ this.accessToken ? { headers: { Authorization: `Bearer ${this.accessToken}` } } : void 0
607
+ );
608
+ return this.unwrapApiResponse(res, "Failed to check entitlement");
609
+ },
610
+ /**
611
+ * List entitlements scoped to a specific resource type and optional resource ID.
612
+ *
613
+ * Note: For publishable key contexts, only `resource_type=user` is permitted.
614
+ * Non-user resource types will return a 403 error (enforced server-side).
615
+ *
616
+ * @param resourceType - The resource type (e.g. "user", "company", "org", "system").
617
+ * @param resourceId - Optional specific resource ID.
618
+ */
619
+ listByResource: async (resourceType, resourceId) => {
620
+ const q = new URLSearchParams({ resource_type: resourceType });
621
+ if (resourceId) q.append("resource_id", resourceId);
622
+ const res = await this.client.get(
623
+ `/api/entitlements?${q.toString()}`,
624
+ this.accessToken ? { headers: { Authorization: `Bearer ${this.accessToken}` } } : void 0
625
+ );
626
+ const payload = this.unwrapApiResponse(res, "Failed to list entitlements by resource");
627
+ if (payload && Array.isArray(payload.entitlements)) {
628
+ return payload.entitlements;
629
+ }
630
+ return payload;
631
+ }
632
+ };
289
633
  constructor(config = {}) {
290
634
  const apiUrl = config.apiUrl || process.env.SPAPS_API_URL || process.env.NEXT_PUBLIC_SPAPS_API_URL;
291
635
  const isBrowser = typeof window !== "undefined";
@@ -1317,11 +1661,14 @@ function detectKeyType(key) {
1317
1661
  // Annotate the CommonJS export names for ESM import in node:
1318
1662
  0 && (module.exports = {
1319
1663
  DEFAULT_ADMIN_ACCOUNTS,
1664
+ FeatureEvaluator,
1320
1665
  PermissionChecker,
1666
+ RoleHierarchy,
1321
1667
  SPAPS,
1322
1668
  SPAPSClient,
1323
1669
  TokenManager,
1324
1670
  WalletUtils,
1671
+ WebSocketAuthHelper,
1325
1672
  canAccessAdmin,
1326
1673
  createBrowserClient,
1327
1674
  createPermissionChecker,
package/dist/index.mjs CHANGED
@@ -195,6 +195,281 @@ import {
195
195
  secureMessageSchema,
196
196
  secureMessageMetadataSchema
197
197
  } from "spaps-types";
198
+
199
+ // src/role-hierarchy.ts
200
+ var RoleHierarchy = class {
201
+ levels;
202
+ /**
203
+ * @param levels - Mapping of role name to numeric level.
204
+ * Higher numbers indicate greater privilege.
205
+ * Example: { guest: 0, user: 10, accountant: 20, admin: 30 }
206
+ */
207
+ constructor(levels) {
208
+ this.levels = new Map(Object.entries(levels));
209
+ }
210
+ /**
211
+ * Get the numeric level for a role.
212
+ * Returns undefined if the role is not registered.
213
+ */
214
+ getLevel(role) {
215
+ return this.levels.get(role);
216
+ }
217
+ /**
218
+ * Check whether `userRole` meets or exceeds `requiredRole` in the hierarchy.
219
+ * Returns false if either role is unknown (not registered in the hierarchy).
220
+ */
221
+ hasMinimumRole(userRole, requiredRole) {
222
+ const userLevel = this.levels.get(userRole);
223
+ const requiredLevel = this.levels.get(requiredRole);
224
+ if (userLevel === void 0 || requiredLevel === void 0) {
225
+ return false;
226
+ }
227
+ return userLevel >= requiredLevel;
228
+ }
229
+ };
230
+
231
+ // src/feature-evaluator.ts
232
+ var FeatureEvaluator = class {
233
+ features = /* @__PURE__ */ new Map();
234
+ roleHierarchy;
235
+ /**
236
+ * @param roleHierarchy - Optional RoleHierarchy instance used for role-minimum checks.
237
+ */
238
+ constructor(roleHierarchy) {
239
+ this.roleHierarchy = roleHierarchy;
240
+ }
241
+ /**
242
+ * Register (or replace) a feature definition.
243
+ */
244
+ registerFeature(name, definition) {
245
+ this.features.set(name, definition);
246
+ }
247
+ /**
248
+ * Evaluate whether a feature is enabled for the given context.
249
+ *
250
+ * Returns `true` (enabled) or `false` (disabled).
251
+ */
252
+ isEnabled(featureName, context = {}) {
253
+ if (context.isSuperAdmin) {
254
+ return true;
255
+ }
256
+ const definition = this.features.get(featureName);
257
+ if (!definition) {
258
+ return true;
259
+ }
260
+ if (definition.killSwitchKey && context.entitlements) {
261
+ const killed = context.entitlements.some(
262
+ (e) => e.entitlement_key === definition.killSwitchKey && e.resource_type === "system" && !e.revoked_at
263
+ );
264
+ if (killed) {
265
+ return false;
266
+ }
267
+ }
268
+ if (definition.resourceBlockKey && definition.resourceBlockType && context.resourceId && context.entitlements) {
269
+ const blocked = context.entitlements.some(
270
+ (e) => e.entitlement_key === definition.resourceBlockKey && e.resource_type === definition.resourceBlockType && e.resource_id === context.resourceId && !e.revoked_at
271
+ );
272
+ if (blocked) {
273
+ return false;
274
+ }
275
+ }
276
+ if (definition.minimumRole && this.roleHierarchy) {
277
+ const userRole = context.userRole;
278
+ if (!userRole) {
279
+ return false;
280
+ }
281
+ if (!this.roleHierarchy.hasMinimumRole(userRole, definition.minimumRole)) {
282
+ return false;
283
+ }
284
+ }
285
+ return true;
286
+ }
287
+ };
288
+
289
+ // src/websocket-auth-helper.ts
290
+ var AUTH_CLOSE_CODES = /* @__PURE__ */ new Set([4001, 4003]);
291
+ var WebSocketAuthHelper = class {
292
+ config;
293
+ ws = null;
294
+ authRetries = 0;
295
+ networkRetries = 0;
296
+ pingTimer = null;
297
+ refreshTimer = null;
298
+ intentionalClose = false;
299
+ constructor(config) {
300
+ this.config = {
301
+ url: config.url,
302
+ getAccessToken: config.getAccessToken,
303
+ refreshAccessToken: config.refreshAccessToken,
304
+ refreshBufferSeconds: config.refreshBufferSeconds ?? 30,
305
+ maxAuthRetries: config.maxAuthRetries ?? 3,
306
+ maxNetworkRetries: config.maxNetworkRetries ?? 5,
307
+ initialBackoffMs: config.initialBackoffMs ?? 1e3,
308
+ maxBackoffMs: config.maxBackoffMs ?? 3e4,
309
+ pingIntervalMs: config.pingIntervalMs ?? 3e4,
310
+ onMessage: config.onMessage,
311
+ onOpen: config.onOpen,
312
+ onClose: config.onClose,
313
+ onError: config.onError
314
+ };
315
+ }
316
+ // ── Public API ──────────────────────────────────────────────────────────
317
+ /**
318
+ * Open an authenticated WebSocket connection.
319
+ * Appends the access token as a query parameter.
320
+ */
321
+ async connect() {
322
+ this.intentionalClose = false;
323
+ this.authRetries = 0;
324
+ this.networkRetries = 0;
325
+ await this.openConnection();
326
+ }
327
+ /**
328
+ * Cleanly disconnect. No reconnection attempts will be made.
329
+ */
330
+ disconnect() {
331
+ this.intentionalClose = true;
332
+ this.cleanup();
333
+ if (this.ws) {
334
+ this.ws.close(1e3, "Client disconnect");
335
+ this.ws = null;
336
+ }
337
+ }
338
+ /**
339
+ * Send data over the WebSocket.
340
+ * Throws if the connection is not open.
341
+ */
342
+ send(data) {
343
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
344
+ throw new Error("WebSocket is not connected");
345
+ }
346
+ this.ws.send(data);
347
+ }
348
+ // ── Internal helpers ────────────────────────────────────────────────────
349
+ /** Build the authenticated WebSocket URL. */
350
+ buildAuthUrl() {
351
+ const token = this.config.getAccessToken();
352
+ if (!token) {
353
+ throw new Error("No access token available for WebSocket authentication");
354
+ }
355
+ const separator = this.config.url.includes("?") ? "&" : "?";
356
+ return `${this.config.url}${separator}token=${encodeURIComponent(token)}`;
357
+ }
358
+ async openConnection() {
359
+ const url = this.buildAuthUrl();
360
+ return new Promise((resolve, reject) => {
361
+ try {
362
+ this.ws = new WebSocket(url);
363
+ } catch (err) {
364
+ reject(err);
365
+ return;
366
+ }
367
+ this.ws.onopen = () => {
368
+ this.networkRetries = 0;
369
+ this.startPing();
370
+ this.scheduleTokenRefresh();
371
+ this.config.onOpen?.();
372
+ resolve();
373
+ };
374
+ this.ws.onmessage = (event) => {
375
+ this.config.onMessage?.(String(event.data));
376
+ };
377
+ this.ws.onerror = (event) => {
378
+ this.config.onError?.(event);
379
+ };
380
+ this.ws.onclose = (event) => {
381
+ this.cleanup();
382
+ this.config.onClose?.(event.code, event.reason);
383
+ if (this.intentionalClose) {
384
+ return;
385
+ }
386
+ if (AUTH_CLOSE_CODES.has(event.code)) {
387
+ this.handleAuthClose();
388
+ } else {
389
+ this.handleNetworkClose();
390
+ }
391
+ };
392
+ });
393
+ }
394
+ async handleAuthClose() {
395
+ if (this.authRetries >= this.config.maxAuthRetries) {
396
+ return;
397
+ }
398
+ this.authRetries++;
399
+ try {
400
+ await this.config.refreshAccessToken();
401
+ await this.openConnection();
402
+ } catch {
403
+ }
404
+ }
405
+ handleNetworkClose() {
406
+ if (this.networkRetries >= this.config.maxNetworkRetries) {
407
+ return;
408
+ }
409
+ this.networkRetries++;
410
+ const delay = Math.min(
411
+ this.config.initialBackoffMs * Math.pow(2, this.networkRetries - 1),
412
+ this.config.maxBackoffMs
413
+ );
414
+ setTimeout(async () => {
415
+ if (this.intentionalClose) return;
416
+ try {
417
+ await this.openConnection();
418
+ } catch {
419
+ }
420
+ }, delay);
421
+ }
422
+ startPing() {
423
+ if (this.config.pingIntervalMs <= 0) return;
424
+ this.pingTimer = setInterval(() => {
425
+ if (this.ws?.readyState === WebSocket.OPEN) {
426
+ this.ws.send(JSON.stringify({ type: "ping" }));
427
+ }
428
+ }, this.config.pingIntervalMs);
429
+ }
430
+ /**
431
+ * Schedule a proactive token refresh before the current token expires.
432
+ */
433
+ scheduleTokenRefresh() {
434
+ const token = this.config.getAccessToken();
435
+ if (!token) return;
436
+ try {
437
+ const parts = token.split(".");
438
+ if (parts.length !== 3 || !parts[1]) return;
439
+ const payload = JSON.parse(
440
+ typeof atob === "function" ? atob(parts[1]) : Buffer.from(parts[1], "base64").toString("utf8")
441
+ );
442
+ if (typeof payload.exp !== "number") return;
443
+ const msUntilExpiry = payload.exp * 1e3 - Date.now();
444
+ const refreshIn = msUntilExpiry - this.config.refreshBufferSeconds * 1e3;
445
+ if (refreshIn <= 0) {
446
+ this.config.refreshAccessToken().catch(() => {
447
+ });
448
+ return;
449
+ }
450
+ this.refreshTimer = setTimeout(async () => {
451
+ try {
452
+ await this.config.refreshAccessToken();
453
+ this.scheduleTokenRefresh();
454
+ } catch {
455
+ }
456
+ }, refreshIn);
457
+ } catch {
458
+ }
459
+ }
460
+ cleanup() {
461
+ if (this.pingTimer) {
462
+ clearInterval(this.pingTimer);
463
+ this.pingTimer = null;
464
+ }
465
+ if (this.refreshTimer) {
466
+ clearTimeout(this.refreshTimer);
467
+ this.refreshTimer = null;
468
+ }
469
+ }
470
+ };
471
+
472
+ // src/index.ts
198
473
  if (typeof globalThis.fetch === "undefined") {
199
474
  __require("cross-fetch/polyfill");
200
475
  }
@@ -259,6 +534,72 @@ var SPAPSClient = class {
259
534
  getTemplate: (templateKey) => this.getEmailTemplate(templateKey),
260
535
  previewTemplate: (templateKey, context) => this.previewEmailTemplate(templateKey, context)
261
536
  };
537
+ /**
538
+ * Entitlements namespace for querying user and resource entitlements.
539
+ *
540
+ * Scope rules:
541
+ * - Publishable key contexts (browser): JWT required, user derived from JWT, can only query user-scoped entitlements.
542
+ * - Secret key contexts (server): can query any resource scope.
543
+ * - `listByResource` with non-user resource types will fail with 403 for publishable keys (enforced server-side).
544
+ */
545
+ entitlements = {
546
+ /**
547
+ * List the current user's entitlements.
548
+ * Requires authentication (JWT).
549
+ */
550
+ list: async (params) => {
551
+ const q = new URLSearchParams();
552
+ if (params?.key) q.append("entitlement_key", params.key);
553
+ if (params?.source) q.append("source", params.source);
554
+ if (params?.include_revoked) q.append("include_revoked", "true");
555
+ const qs = q.toString();
556
+ const res = await this.client.get(
557
+ `/api/entitlements${qs ? `?${qs}` : ""}`,
558
+ this.accessToken ? { headers: { Authorization: `Bearer ${this.accessToken}` } } : void 0
559
+ );
560
+ const payload = this.unwrapApiResponse(res, "Failed to list entitlements");
561
+ if (payload && Array.isArray(payload.entitlements)) {
562
+ return payload.entitlements;
563
+ }
564
+ return payload;
565
+ },
566
+ /**
567
+ * Boolean access check — does the user hold an active entitlement with the given key?
568
+ * @param key - The entitlement key to check (e.g. "premium").
569
+ * @param userId - Optional user ID override (secret key contexts only).
570
+ */
571
+ check: async (key, userId) => {
572
+ const q = new URLSearchParams({ key });
573
+ if (userId) q.append("user_id", userId);
574
+ const res = await this.client.get(
575
+ `/api/entitlements/check?${q.toString()}`,
576
+ this.accessToken ? { headers: { Authorization: `Bearer ${this.accessToken}` } } : void 0
577
+ );
578
+ return this.unwrapApiResponse(res, "Failed to check entitlement");
579
+ },
580
+ /**
581
+ * List entitlements scoped to a specific resource type and optional resource ID.
582
+ *
583
+ * Note: For publishable key contexts, only `resource_type=user` is permitted.
584
+ * Non-user resource types will return a 403 error (enforced server-side).
585
+ *
586
+ * @param resourceType - The resource type (e.g. "user", "company", "org", "system").
587
+ * @param resourceId - Optional specific resource ID.
588
+ */
589
+ listByResource: async (resourceType, resourceId) => {
590
+ const q = new URLSearchParams({ resource_type: resourceType });
591
+ if (resourceId) q.append("resource_id", resourceId);
592
+ const res = await this.client.get(
593
+ `/api/entitlements?${q.toString()}`,
594
+ this.accessToken ? { headers: { Authorization: `Bearer ${this.accessToken}` } } : void 0
595
+ );
596
+ const payload = this.unwrapApiResponse(res, "Failed to list entitlements by resource");
597
+ if (payload && Array.isArray(payload.entitlements)) {
598
+ return payload.entitlements;
599
+ }
600
+ return payload;
601
+ }
602
+ };
262
603
  constructor(config = {}) {
263
604
  const apiUrl = config.apiUrl || process.env.SPAPS_API_URL || process.env.NEXT_PUBLIC_SPAPS_API_URL;
264
605
  const isBrowser = typeof window !== "undefined";
@@ -1289,11 +1630,14 @@ function detectKeyType(key) {
1289
1630
  }
1290
1631
  export {
1291
1632
  DEFAULT_ADMIN_ACCOUNTS,
1633
+ FeatureEvaluator,
1292
1634
  PermissionChecker,
1635
+ RoleHierarchy,
1293
1636
  SPAPSClient as SPAPS,
1294
1637
  SPAPSClient,
1295
1638
  TokenManager,
1296
1639
  WalletUtils,
1640
+ WebSocketAuthHelper,
1297
1641
  canAccessAdmin,
1298
1642
  createBrowserClient,
1299
1643
  createPermissionChecker,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spaps-sdk",
3
- "version": "1.5.1",
3
+ "version": "1.6.0",
4
4
  "description": "Sweet Potato Authentication & Payment Service SDK - Zero-config client with built-in permission checking, role-based access control, and dayrate scheduling",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -19,7 +19,7 @@
19
19
  "scripts": {
20
20
  "build": "tsup --tsconfig tsconfig.json src/index.ts --format cjs,esm --dts --clean",
21
21
  "dev": "tsup --tsconfig tsconfig.json src/index.ts --format cjs,esm --dts --watch",
22
- "test": "echo \"No tests yet\"",
22
+ "test": "vitest run",
23
23
  "smoke-test": "npm run build && node smoke-test.js",
24
24
  "prepublishOnly": "npm run build && npm run smoke-test"
25
25
  },
@@ -46,14 +46,15 @@
46
46
  "email": "buildooor@gmail.com"
47
47
  },
48
48
  "dependencies": {
49
- "spaps-types": "^1.0.60",
50
49
  "axios": "^1.6.0",
51
- "cross-fetch": "^4.0.0"
50
+ "cross-fetch": "^4.0.0",
51
+ "spaps-types": "^1.1.0"
52
52
  },
53
53
  "devDependencies": {
54
54
  "@types/node": "^20.10.0",
55
55
  "tsup": "^8.0.1",
56
- "typescript": "^5.3.2"
56
+ "typescript": "^5.3.2",
57
+ "vitest": "^4.0.18"
57
58
  },
58
59
  "peerDependencies": {
59
60
  "typescript": ">=4.5.0"
@@ -74,4 +75,4 @@
74
75
  "engines": {
75
76
  "node": ">=14.0.0"
76
77
  }
77
- }
78
+ }