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 +206 -3
- package/dist/index.d.ts +206 -3
- package/dist/index.js +347 -0
- package/dist/index.mjs +344 -0
- package/package.json +7 -6
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.
|
|
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": "
|
|
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
|
+
}
|