A complete SaaS Enablement Layer providing identity, multi-tenancy, plans, entitlements, team management, webhooks, and an embedded control panel UI for your applications.
SassyEL (SaaS Enablement Layer) is a self-hostable backend service that handles all the common SaaS infrastructure concerns, allowing you to focus on building your product. It acts as the authority for users, tenants, memberships, plans, and entitlements, while your remote app enforces these rules and handles domain-specific logic.
┌─────────────────────────┐ ┌─────────────────────────┐
│ SassyEL Server │ │ Your Remote App │
│ (Authority) │◄───────►│ (Enforcer) │
├─────────────────────────┤ ├─────────────────────────┤
│ • User management │ │ • App domain data │
│ • Tenant management │ Webhooks│ • Business logic │
│ • Plans & entitlements │ ───────►│ • Enforce entitlements │
│ • Team invitations │ │ • Credit metering │
│ • JWT issuance & JWKS │ JWT │ • Validate tokens (JWKS)│
│ • Embedded control panel│ ───────►│ • Apply Sassy webhooks │
└─────────────────────────┘ └─────────────────────────┘
- User signup, login, and password reset
- Email verification (optional, via Resend)
- Magic link passwordless authentication
- JWT access tokens with JWKS publishing
- Refresh token rotation
- Multi-tenant session management
- Automatic tenant creation on signup
- Multiple tenants per user
- Tenant switching
- Tenant status management (active, past_due, suspended)
- Role-based access control (owner, admin, member, billing)
- Email invitations with expiry
- Invitation accept/revoke workflow
- Team member listing and management
- Flexible plan definitions with pricing tiers
- Payment provider price IDs (Stripe/Paddle) per plan
- Support for monthly, yearly, and one-time pricing
- Feature flags (boolean entitlements)
- Numeric and string limits
- Per-tenant entitlement overrides (set, add, remove)
- Entitlement versioning for cache invalidation
- Computed entitlement snapshots
- Server-to-server webhook delivery
- HMAC signature verification
- Exponential backoff retries
- Delivery logging and monitoring
- Events:
tenant.entitlements.updated,membership.updated,tenant.status.updated
- Lightweight JavaScript SDK
- Docked side panel UI
- Event-driven state management
- Feature checking helpers
- Automatic session persistence
- TypeScript/JavaScript SDK for backend integration
- JWT/JWKS token verification
- Webhook signature verification
- Entitlement checking helpers
- Express.js middleware
- Headless mode for custom auth UI with Sassy backend
- Multi-app management
- Per-app configuration
- Secret management (Resend, Stripe, Paddle keys)
- Plan management per app
- Usage statistics
- Node.js 18+
- npm or yarn
# Clone the repository
git clone https://github.com/adamlauzon/sassy.git
cd sassy
# Install dependencies
npm install
# Initialize the database
npm run init-db
# Generate JWT signing keys
npm run generate-keys
# Start the server
npm startThe server will start at http://localhost:8080.
| Endpoint | Description |
|---|---|
http://localhost:8080/health |
Health check |
http://localhost:8080/sdk.js |
Browser SDK |
http://localhost:8080/panel/frame |
Control panel iframe |
http://localhost:8080/developer/ |
Developer dashboard |
| Variable | Description | Default |
|---|---|---|
PORT |
Server port | 8080 |
SASSY_ALLOWED_ORIGINS |
Comma-separated CORS origins | http://localhost:3000,http://localhost:5173 |
NODE_ENV |
Environment mode | development |
SassyEL uses SQLite (via better-sqlite3) for data persistence. The database file is stored at data/sassy.db.
# Initialize a fresh database with default plans
npm run init-db
# Run migrations (for multi-tenant schema)
npm run migrateSQLite is suitable for development and small deployments, but for production environments we strongly recommend using PostgreSQL:
| SQLite | PostgreSQL | |
|---|---|---|
| Concurrent writes | Limited | Excellent |
| Horizontal scaling | No | Yes (read replicas) |
| Backup strategies | File copy | pg_dump, streaming |
| High availability | Manual | Built-in/managed |
| Connection pooling | N/A | PgBouncer, built-in |
Recommended options:
- Supabase (managed PostgreSQL) - Enable "Supabase as Primary Datasource" in Integrations
- Neon - Serverless PostgreSQL with branching
- Railway/Render - Simple managed PostgreSQL
- Self-hosted PostgreSQL - For full control
Note: PostgreSQL support is planned. Currently, enabling "Supabase as Primary Datasource" syncs data to your Supabase project while Sassy's SQLite remains the source of truth for authentication operations.
<script src="https://your-sassy-server.com/sdk.js"></script>
<script>
sassy.init({
apiUrl: 'https://your-sassy-server.com',
appId: 'pk_your_app_public_key',
showLauncher: true,
panelPosition: 'right', // or 'left'
});
</script>// Wait for SDK to be ready
await sassy.ready;
// Listen for auth changes
sassy.on('auth:changed', ({ user }) => {
if (user) {
console.log('User logged in:', user);
} else {
console.log('User logged out');
}
});
// Listen for entitlements updates
sassy.on('entitlements:updated', (entitlements) => {
console.log('Entitlements updated:', entitlements);
});
// Listen for tenant changes
sassy.on('tenant:changed', ({ tenant }) => {
console.log('Switched to tenant:', tenant);
});// Check feature flags
if (sassy.hasFeature('pdf.generate')) {
// Enable PDF generation feature
}
// Get limits
const maxTeamMembers = sassy.getLimit('limit.max_team_members');
const monthlyCredits = sassy.getLimit('limit.monthly_credits');// Open/close the control panel
sassy.ui.open();
sassy.ui.close();
sassy.ui.toggle();
// Check panel state
if (sassy.ui.isOpen()) {
// Panel is open
}Your backend should validate JWTs using the JWKS endpoint:
// Fetch JWKS from: GET /api/auth/.well-known/jwks.json
// Validate JWT signature using the public keys
// JWT Claims:
// - sub: User ID
// - tid: Tenant ID
// - roles: Array of roles in the tenant
// - ent_ver: Entitlements version (for cache invalidation)
// - exp: Expiration timestampConfigure a webhook endpoint to receive events from SassyEL:
app.post('/webhooks/sassy', (req, res) => {
// Verify HMAC signature
const signature = req.headers['x-sassy-signature'];
const { type, payload, eventId, entVer } = req.body;
switch (type) {
case 'tenant.entitlements.updated':
// Update cached entitlements if entVer > local version
break;
case 'membership.updated':
// Update team member cache
break;
case 'tenant.status.updated':
// Handle status change (active, past_due, suspended)
break;
}
res.json({ received: true });
});| Method | Endpoint | Description |
|---|---|---|
| POST | /api/auth/signup |
Create account |
| POST | /api/auth/login |
Login |
| POST | /api/auth/refresh |
Refresh access token |
| POST | /api/auth/logout |
Logout |
| POST | /api/auth/forgot-password |
Request password reset |
| POST | /api/auth/reset-password |
Reset password |
| POST | /api/auth/verify-email |
Verify email address |
| POST | /api/auth/magic-link |
Request magic link |
| POST | /api/auth/verify-magic-link |
Verify magic link |
| POST | /api/auth/switch-tenant |
Switch tenant context |
| GET | /api/auth/.well-known/jwks.json |
Get JWKS for token verification |
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/tenants/current |
Get current tenant with entitlements |
| GET | /api/tenants/members |
List tenant members |
| PATCH | /api/tenants/current |
Update tenant |
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/invitations |
List tenant invitations |
| POST | /api/invitations |
Create invitation |
| POST | /api/invitations/:id/accept |
Accept invitation |
| DELETE | /api/invitations/:id |
Revoke invitation |
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/plans |
List available plans with entitlements and price IDs |
| GET | /api/plans/:planId |
Get a specific plan |
The Plans API returns all public plans with their entitlements and payment provider price IDs:
GET /api/plans
X-Sassy-App-Id: pk_your_app_public_key{
"plans": [
{
"id": "plan_abc123",
"name": "Pro",
"description": "For growing teams",
"priceMonthly": 2900,
"priceYearly": 29000,
"isPublic": true,
"isDefault": false,
"priceIdMonthly": "price_1ABC...",
"priceIdYearly": "price_2DEF...",
"priceIdOnetime": null,
"entitlements": {
"features": {
"pdf.generate": true,
"excel.to_json": true,
"api.access": false
},
"limits": {
"limit.max_file_mb": 100,
"limit.max_team_members": 10,
"limit.monthly_credits": 1000
}
}
}
],
"plansEnabled": true
}Plans include price IDs that link to your Stripe or Paddle prices. These are set in the Developer Portal and returned via the API so your host app can create checkout sessions.
| Field | Description |
|---|---|
priceIdMonthly |
Stripe/Paddle price ID for monthly subscriptions |
priceIdYearly |
Stripe/Paddle price ID for yearly subscriptions |
priceIdOnetime |
Stripe/Paddle price ID for one-time/lifetime purchases |
Host app usage:
// Fetch plans from Sassy
const response = await fetch('https://sassy.example.com/api/plans', {
headers: { 'X-Sassy-App-Id': 'pk_your_app_public_key' }
});
const { plans } = await response.json();
// Find the plan user wants to purchase
const plan = plans.find(p => p.id === selectedPlanId);
// Determine which price ID to use based on billing interval
const priceId = billingInterval === 'once' ? plan.priceIdOnetime
: billingInterval === 'year' ? plan.priceIdYearly
: plan.priceIdMonthly;
// Create Stripe checkout session
const checkout = await stripe.checkout.sessions.create({
line_items: [{ price: priceId, quantity: 1 }],
mode: billingInterval === 'once' ? 'payment' : 'subscription',
success_url: 'https://yourapp.com/success',
cancel_url: 'https://yourapp.com/pricing',
metadata: {
tenant_id: tenantId,
plan_id: plan.id,
},
});Configuring price IDs:
- Create prices in your Stripe/Paddle dashboard
- Go to Developer Portal → Your App → Plans
- Edit each plan and enter the corresponding price IDs
- Your host app will receive these IDs when fetching plans
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/webhooks/endpoints |
Register webhook endpoint |
| GET | /api/webhooks/endpoints |
List webhook endpoints |
| DELETE | /api/webhooks/endpoints/:id |
Remove webhook endpoint |
- users - User accounts
- tenants - Organizations/workspaces
- memberships - User-tenant relationships with roles
- plans - Subscription plan definitions
- plan_entitlements - Default entitlements per plan
- tenant_overrides - Per-tenant entitlement overrides
- entitlement_snapshots - Computed entitlements cache
- refresh_tokens - Active refresh tokens
- password_resets - Password reset tokens
- api_keys - Programmatic API access
- invitations - Team invitations
- webhook_endpoints - Configured webhook URLs
- webhook_deliveries - Delivery logs and retry queue
- jwks_keys - JWT signing keys (supports rotation)
- developers - Developer accounts
- apps - Registered applications
- app_secrets - Per-app API keys and secrets
- app_settings - Per-app configuration
SassyEL comes with three pre-configured plans:
- PDF generation: enabled
- Excel to JSON: disabled
- Image resize: enabled
- Max file size: 10 MB
- Max team members: 3
- Monthly credits: 100
- PDF generation: enabled
- Excel to JSON: enabled
- Image resize: enabled
- Max file size: 100 MB
- Max team members: 10
- Monthly credits: 1,000
- All Pro features
- API access: enabled
- SSO: enabled
- Max file size: 500 MB
- Max team members: unlimited
- Monthly credits: 10,000
npm start # Start production server
npm run dev # Start with auto-reload
npm run init-db # Initialize database
npm run migrate # Run all migrations
npm run migrate:billing # Run billing price IDs migration only
npm run generate-keys # Generate JWT signing keyssassy/
├── src/
│ ├── server.js # Express app entry point
│ ├── routes/
│ │ ├── auth.js # Authentication endpoints
│ │ ├── tenants.js # Tenant management
│ │ ├── invitations.js # Team invitations
│ │ ├── plans.js # Plan listing
│ │ ├── webhooks.js # Webhook management
│ │ └── developer.js # Developer portal API
│ ├── services/
│ │ ├── auth.js # Auth business logic
│ │ ├── jwt.js # JWT/JWKS handling
│ │ ├── tenants.js # Tenant operations
│ │ ├── entitlements.js # Entitlement computation
│ │ ├── invitations.js # Invitation workflow
│ │ ├── webhooks.js # Webhook delivery
│ │ └── email.js # Email via Resend
│ ├── middleware/
│ │ ├── auth.js # JWT validation
│ │ └── app-context.js # Multi-app resolution
│ ├── scripts/
│ │ ├── init-db.js # Database initialization
│ │ ├── migrate-multi-tenant.js # Multi-app support migration
│ │ ├── migrate-billing-price-ids.js # Payment provider price IDs
│ │ └── generate-keys.js
│ ├── utils/
│ │ └── db.js # Database connection
│ └── public/
│ ├── js/sdk.js # Browser SDK
│ ├── css/panel.css # Panel styles
│ ├── panel.html # Control panel
│ └── developer/ # Developer dashboard
├── data/
│ └── sassy.db # SQLite database
├── demo.html # Integration demo
├── package.json
└── SPEC.json # Full specification
SassyEL supports two integration patterns depending on your application architecture.
If your application has a backend server, it acts as the enforcer. Your backend validates JWTs directly using Sassy's JWKS endpoint - no additional components needed.
┌──────────────┐ ┌──────────────────────┐ ┌─────────────┐
│ Frontend │ │ Your Backend │ │ Sassy │
│ (SDK) │──────▶│ (Enforcer) │ │ Server │
└──────────────┘ └──────────────────────┘ └─────────────┘
│ │
│ 1. Validate JWT via JWKS ───▶│
│◀─────────────────────────────│
│ │
│ 2. Receive webhooks ◀────────│
│ │
Your backend responsibilities:
- Fetch and cache JWKS from
/api/auth/.well-known/jwks.json - Validate JWT signature on incoming requests
- Extract claims (sub, tid, roles, ent_ver)
- Enforce entitlements before serving protected resources
- Handle webhooks for cache invalidation
Helper libraries (planned) will simplify this:
| Package | Language | Status |
|---|---|---|
@sassy/node |
Node.js | Planned |
sassy-python |
Python | Planned |
sassy-go |
Go | Planned |
sassy-php |
PHP | Planned |
These are thin wrappers around standard JWT libraries that understand Sassy's token format and provide convenience methods like hasFeature() and getLimit().
For applications without a backend (static sites, SPAs), the SDK can call Sassy directly for verification. This provides protection through Sassy acting as the enforcer.
┌──────────────┐ ┌─────────────┐
│ Frontend │ ──── /api/verify ──▶│ Sassy │
│ (SDK) │ ◀─── allow/deny ────│ Server │
└──────────────┘ └─────────────┘
SDK Configuration:
sassy.init({
apiUrl: 'https://your-sassy-server.com',
appId: 'pk_your_app_public_key',
enforceMode: 'strict', // Require Sassy verification
verifyInterval: 300, // Re-verify every 5 minutes (seconds)
});Enforce modes:
| Mode | Behavior |
|---|---|
soft |
SDK works without verification (UI gating only) |
strict |
SDK requires successful Sassy verification to enable features |
Note: Frontend-only enforcement can be bypassed by determined users modifying JavaScript. For applications where security is critical, use Pattern 1 with a backend.
For frontend-only apps, Sassy provides a verification endpoint:
GET /api/verify
Authorization: Bearer <access_token>
Response (200 OK):
{
"valid": true,
"user": {
"id": "usr_xxx",
"email": "user@example.com",
"name": "User Name"
},
"tenant": {
"id": "ten_xxx",
"name": "Acme Corp",
"status": "active",
"plan_name": "Pro"
},
"entitlements": {
"features": { "pdf.generate": true, "excel.to_json": true },
"limits": { "limit.monthly_credits": 1000 }
},
"ent_ver": 42
}
Response (401 Unauthorized):
{
"valid": false,
"reason": "token_expired" | "token_invalid" | "tenant_suspended"
}
- Two-Server Model: SassyEL is the authority; your app (backend) is the enforcer
- No Per-Request Calls: Apps cache entitlements and validate tokens locally via JWKS
- Credits Stay Local: Your app owns credit metering and enforcement
- Tenant-Scoped Entitlements: No user-scoped overrides in v1
- Versioned Entitlements:
ent_verenables efficient cache invalidation - Server-to-Server Webhooks: Browsers don't receive webhooks directly
- SDK is Not a Security Boundary: Backend enforcement is required for true security
- Frontend-Only Option: Sassy can act as direct verifier for apps without backends
- Password reset emails
- Email verification
- Team invitations
- Magic link authentication
Sassy stores payment provider price IDs per plan, which your host app uses to create checkout sessions.
Setup:
- In Stripe/Paddle: Create products and prices matching your Sassy plans
- In Sassy Developer Portal: Enter the price IDs for each plan (monthly, yearly, one-time)
- In your host app: Fetch plans via
/api/plans, use the price IDs to create checkouts
Supported billing models:
| Model | How it works |
|---|---|
| Monthly subscription | Use priceIdMonthly |
| Yearly subscription | Use priceIdYearly |
| Lifetime / One-time | Use priceIdOnetime |
| Per-seat pricing | Set limit.max_team_members in entitlements, enforce in your app |
| Usage credits | Set limit.monthly_credits in entitlements, meter in your app |
Webhook flow (your host app handles this):
Stripe/Paddle ──webhook──► Your Host App ──API call──► Sassy
│
├─► Update tenant.billing_customer_id
├─► Update tenant.billing_subscription_id
├─► Update tenant.plan_id (on upgrade/downgrade)
└─► Update tenant.status (active/past_due/suspended)
Note: Sassy does not handle payment processing directly. Your host app:
- Creates checkout sessions using price IDs from Sassy
- Receives webhooks from Stripe/Paddle
- Calls Sassy's API to update tenant billing info and plan
MIT