acls / products 1/2

This commit is contained in:
lovebird 2026-04-02 22:59:54 +02:00
parent ea4cbfde07
commit d66f31c9e9
6 changed files with 1232 additions and 66 deletions

View File

@ -0,0 +1,594 @@
```mermaid
erDiagram
acl_group_members {
string created_at
string created_by
string group_id
string user_id
}
acl_groups {
string created_at
string created_by
string description
string id
string modified_at
string name
string native_type
string parent_id
Json settings
}
campaigns {
string completed_at
string created_at
string group_ids
string id
string lang
Json meta
string name
string owner_id
string page_id
string page_slug
string scheduled_at
string started_at
Json stats
string status
string subject
string tracking_id
string updated_at
Json vars
}
categories {
string created_at
string description
string id
Json meta
string name
string owner_id
string slug
string updated_at
json visibility
}
category_relations {
string child_category_id
string created_at
string parent_category_id
json relation_type
}
collection_pictures {
string added_at
string collection_id
string id
string picture_id
}
collection_posts {
string collection_id
string created_at
string id
string post_id
}
collections {
Json content
string created_at
string description
string id
boolean is_public
Json layout
string name
string slug
string updated_at
string user_id
}
comment_likes {
string comment_id
string created_at
string id
string user_id
}
comments {
string content
string created_at
string id
number likes_count
string parent_comment_id
string picture_id
string updated_at
string user_id
}
contact_group_members {
string added_at
string contact_id
string group_id
}
contact_groups {
string created_at
string description
string id
Json meta
string name
string owner_id
string updated_at
}
contacts {
Json address
string created_at
Json emails
string first_name
string id
string language
string last_name
Json log
Json meta
string name
string notes
string organization
string owner_id
string phone
string source
string status
string tags
string title
string updated_at
}
context_definitions {
string created_at
Json default_filters
Json default_templates
string description
string display_name
string icon
string id
boolean is_active
string name
string updated_at
}
filter_usage_logs {
string context
string created_at
string error_message
string filters_applied
string id
number input_length
string model
number output_length
number processing_time_ms
string provider
boolean success
string template_id
string user_id
}
grid_area_places {
string created_at
string grid_area_id
string id
string place_id
number rank
}
grid_areas {
number area_sqkm
Json bbox
Json center
string created_at
Json geometry
string gid
string id
number level
number max_dist_km
Json meta
string name
string region
Json stats
string updated_at
string user_id
}
grid_search_runs {
string created_at
string id
string parent
Json request
Json result
string run_id
Json settings
string status
string updated_at
string user_id
}
n_glossaries {
string creation_time
number entry_count
string glossary_id
string hash
string local_created_at
string local_updated_at
string name
boolean ready
string source_lang
string target_lang
}
n_glossary_terms {
string created_at
string id
string source_lang
string target_lang
string term
string translation
string updated_at
}
n_translations {
string created_at
string dst_lang
string dst_text
string id
Json meta
string src_lang
string src_text
string updated_at
}
layouts {
string created_at
string id
boolean is_predefined
Json layout_json
Json meta
string name
string owner_id
string type
string updated_at
json visibility
}
likes {
string created_at
string id
string picture_id
string user_id
}
marketing_emails {
string campaign
string created_at
string email
string error_message
string from_address
string id
string lang
string last_retry_at
Json meta
string name
string page_slug
number retry_count
string sender_id
string sent_at
string status
string subject
string tracking_id
string unsubscribe_token
boolean unsubscribed
string unsubscribed_at
string updated_at
}
organizations {
string created_at
string id
string name
string slug
string updated_at
}
page_collaborators {
string created_at
string id
string page_id
json role
string user_id
}
pages {
Json content
string created_at
string id
boolean is_public
string is_version_of
Json meta
string owner
string parent
string slug
string tags
string title
string type
string updated_at
boolean visible
}
pictures {
string created_at
string description
string flags
string id
string image_url
boolean is_selected
number likes_count
Json meta
string organization_id
string parent_id
number position
string post_id
string tags
string thumbnail_url
string title
string type
string updated_at
string user_id
boolean visible
}
place_searches {
string created_at
string id
string input_hash
Json input_params
string result_place_ids
string run_id
string user_id
}
places {
string address
string city
Json contacts
string continent
string country
string created_at
string description
Json gps_coordinates
Json media
Json meta
Json operating_hours
string phone
string place_id
Json raw_data
string thumbnail
string title
string types
string updated_at
string user_id
string website
}
posts {
string created_at
string description
string id
Json meta
Json settings
string title
string updated_at
string user_id
}
products {
string created_at
string description
string id
string name
Json settings
string slug
string updated_at
}
profiles {
string aimlapi_api_key
string avatar_url
string bio
string bria_api_key
string created_at
string display_name
string google_api_key
string huggingface_api_key
string id
string openai_api_key
Json pages
string replicate_api_key
Json settings
string updated_at
string user_id
string username
}
provider_configs {
string base_url
string created_at
string display_name
string id
boolean is_active
Json models
string name
Json rate_limits
Json settings
string updated_at
string user_id
}
resource_acl {
string created_at
string group_name
string id
Json log
Json meta
string path
string permissions
string resource_id
string resource_owner_id
string resource_type
string updated_at
string user_id
}
role_permissions {
string created_at
string id
json permission
json role
}
searches {
string created_at
string id
string input_hash
Json input_params
string result_place_ids
}
transactions {
string buyer_email
unknown buyer_ip
string buyer_name
Json buyer_profile
string created_at
string currency
string external_checkout_id
string external_order_id
string id
Json metadata
string note
string payment_provider
Json product_info
Json shipping_info
string status
number total_amount
string updated_at
string user_id
Json vendor_info
}
type_casts {
json cast_kind
string description
string from_type_id
string to_type_id
}
type_enum_values {
string id
string label
number order
string type_id
string value
}
type_flag_values {
number bit
string id
string name
string type_id
}
type_structure_fields {
Json default_value
string field_name
string field_type_id
string id
number order
boolean required
string structure_type_id
}
types {
string created_at
string description
string id
Json json_schema
json kind
Json meta
string name
string owner_id
string parent_type_id
Json settings
string updated_at
json visibility
}
user_filter_configs {
string context
string created_at
Json custom_filters
string default_templates
string id
boolean is_default
string model
string provider
string updated_at
string user_id
Json variables
}
user_organizations {
string created_at
string id
string organization_id
string role
string updated_at
string user_id
}
user_roles {
string created_at
string id
string organization_id
json role
string updated_at
string user_id
}
user_secrets {
string aimlapi_api_key
string bria_api_key
string created_at
string google_api_key
string huggingface_api_key
boolean is_admin
string openai_api_key
string replicate_api_key
Json settings
string updated_at
string user_id
}
user_templates {
string context
string created_at
string description
string filters
string format
string id
boolean is_public
string model
string name
string prompt
string provider
string updated_at
number usage_count
string user_id
}
vfs_document_chunks {
number chunk_index
string content
string created_at
string embedding
string id
string vfs_id
}
vfs_index {
string content
unknown fts
string id
boolean is_vectorized
string mime
string mount
string mtime
string name
string path
number size
string type
}
widget_translations {
string created_at
string entity_id
string entity_type
string id
Json meta
string prop_path
string source_lang
string source_text
number source_version
json status
string target_lang
string translated_text
string updated_at
string widget_id
}
wizard_sessions {
string created_at
string generated_image_url
string id
string input_images
string prompt
string status
string updated_at
string user_id
}
```

View File

@ -0,0 +1,77 @@
# Entity Types
The `serving` endpoint currently handles multiple distinct entity models within the platform. According to the route registrations in `server/src/products/serving/index.ts`, these are the primary operational entities managed by the system:
## 1. Content Entities
- **Posts** (`db-posts.ts`): Primary content entities. Used in feeds and can be exported in various formats (PDF, JSON, HTML, Markdown, Rich HTML).
- **Pages** (`pages-crud.ts`): Standalone pages within the platform. Supports full version control (`PageVersions`), email rendering, and multiple export formats.
- **Categories** (`db-categories.ts`): Taxonomies for grouping and filtering items. Includes `CategoryItems` for linking entities to a category.
## 2. Media & Engagement Entities
- **Pictures** (`db-pictures.ts`): Standalone media assets supporting versioning, linking/unlinking, and batch uploads.
- **Comments** (`db-pictures.ts`): User engagement texts, typically attached to media or content, with support for toggling likes.
## 3. Structural Entities
- **Types** (`db-types.ts`): Dynamically managed content definitions or data schemas.
- **Layouts** (`db-layouts.ts`): View templates and rendering layouts for the front-end or app components.
## 4. Internationalization (i18n) Entities
- **Translations & Widget Translations** (`db-i18n.ts`): Granular text records mapped to UI components.
- **Glossaries & Glossary Terms** (`db-i18n.ts`): Translation dictionaries and terminology synchronization mappings (e.g., DeepL integration).
## 5. User Entities (`db-user.ts`)
- **Profiles**: The public-facing identity (`profiles` table), storing fields like `username`, `display_name`, `bio`, and `avatar_url`. This entity relies on a centralized in-memory cache for high-performance reading and augmentation across the application.
- **User Secrets**: Secure configurations (`user_secrets` table) such as `api_keys`, `variables`, `shipping_addresses`, and `vendor_profiles`. The API ensures `api_keys` are dynamically masked (showing only the last 4 characters) when served.
- **Admin Users**: Management entities that bundle Supabase Auth identities with their respective `profiles` and settings, allowing backend user lifecycle management.
## 6. Access Control (ACL) Entities (`db-acl.ts`)
- **ACL Settings**: A resource-agnostic wrapper used to define access rules. It delegates to specific backends (`IAclBackend`, e.g., memory/VFS or Supabase DB) and encapsulates the owner and `AclEntry` definitions (which specify `userId` or `group`, scoped to a `path` with allowed `permissions`).
- **Global ACL Groups**: Hierarchical roles persisted in the database (`acl_groups`), supporting relationships via `parent_id`.
- **Group Memberships**: Maps users to Global Groups (`acl_group_members`). The ACL engine consolidates these explicit memberships with virtual identities (e.g., *anonymous*, *authenticated*, *registered*, *admin*) and local resource groups to evaluate effective permissions dynamically.
## 7. Platform Integration Entities (`endpoints/products.ts`)
- **Products**: Platform-level features or logical product definitions (`products` table). Each product holds an `id`, `name`, `slug`, `description`, and a flexible `settings` JSON object (supporting logic like `enabled`, `default_cost_units`, and `default_rate_limit`). Their administrative endpoints support full CRUD operations. Notably, deleting a product automatically removes any associated `product-acl` records from the `resource_acl` table.
## 8. Contacts & Integrations Entities (`products/contacts/index.ts`)
- **Contacts**: The primary address book entity (`contacts` table), storing fields like `name`, `organization`, `emails`, `phone`, and `address`. Supports bulk operations and bidirectional data exchange formats (including parsing and serialization for vCard).
- **Contact Groups & Memberships**: Organizational buckets (`contact_groups`) and their associated mapping table (`contact_group_members`) used to categorize constraints.
- **Mailboxes (IMAP Integration)**: Configuration records enabling automated connection to external email servers for contact harvesting. Includes specific OAuth2 handling for Gmail alongside traditional IMAP credential setups.
## 9. Server Middleware Layers (`index.ts` & `middleware/`)
The Polymech server pipeline implements a layered middleware architecture to enforce security, monitoring, and authentication before requests reach the endpoints.
- **Content Security & CORS (`csp.ts`)**: Sets up strict Content Security Policies, secure HTTP headers, and intercepts OPTIONS preflight requests globally.
- **Security & Blocking Pipeline**:
- **AutoBan Middleware (`autoBan.ts`)**: Applied globally (`*`). Acts as an intrusion detection system (IDS) that catches directory traversal probes and enforces rate-limit bans across all HTTP traffic.
- **Blocklist Middleware (`blocklist.ts`)**: Applied specifically to `/api/*` to enforce absolute IP or user-based blocklists before API logic is executed.
- **Analytics (`analytics.ts`)**: Traps requests globally (`*`) for telemetry, observability, and usage metric tracking.
- **Authentication & Authorization (`auth.ts`)**:
- **Optional Authentication**: By default, checks for a `Bearer` token or `?token=` query parameter (e.g., for SSE) and validates it against Supabase via an aggressive cache. Routes registered in the `PublicEndpointRegistry` (and the core `/api/products` route) bypass authentication requirements entirely.
- **Admin Enforcement**: Intercepts requests meant for the `AdminEndpointRegistry`. Requires a valid session payload and queries `user_roles` to strictly enforce the `admin` capability.
- **Content Compression**: Global `hono/compress` usage that is augmented with custom streaming logic to seamlessly gzip specialized, heavy geometry and vector assets (e.g., `model/stl`, `model/obj`, `vnd.dxf`).
## 10. Product Tiering / Monetization (Freemium, Pro) via ACL Middleware [Proposed Architecture]
To seamlessly integrate subscription tiers on the server-side without overhauling existing database models, the platform can leverage the new ACL engine and middleware layers:
- **ACL Group Hierarchy For Tiers**: Instead of building custom licensing tables, product tiers map directly to **Global ACL Groups** utilizing inheritance.
- `tier:freemium` (assigned automatically to registered users).
- `tier:pro` (inherits capabilities from `freemium`).
- Upon webhook confirmation (e.g., Stripe payment), the user is upserted into the `tier:pro` group via `acl_group_members`.
- **Product-Level Resource Gating**: The `resource_acl` table links an existing Product (from the `products` table, e.g., `contacts` module) to a tier group (`tier:pro`) with explicit `access` permissions.
- **Server Middleware Gating (`productAclMiddleware.ts`)**: A dedicated middleware sits *after* `auth.ts` (so `userId` and `user.groups` are resolved).
- Using the `IAclBackend`, it evaluates if the user's combined implicit/explicit groups trigger an `ALLOW` for the requested API route. If denied, it handles the `403 Payment/Upgrade Required` response universally, keeping endpoint code clean.
- **Dynamic Rate Limits**: The `products.settings` JSON (which holds `default_rate_limit`) can be parsed by middleware. The `apiRateLimiter` can then dynamically adjust throttles based on the user's tier, heavily restricting Freemium traffic while allowing Pro users unhindered access.
### Example Integration Flow: Upgrading a User from Free to Pro
1. **Transaction Event**: A user completes a checkout, and the external payment processor (e.g., Stripe) fires a secure webhook (like `checkout.session.completed`) to the backend (`POST /api/webhooks/billing`).
2. **Signature Verification**: The server intercepts the webhook payload and validates its cryptographic signature.
3. **Identity Resolution**: The server maps the external `customer_id` from the payload back to the core Supabase `user_id`.
4. **ACL Upsertion**: The backend inserts or modifies the `acl_group_members` table, mapping the resolved `user_id` strictly to the `tier:pro` global ACL group.
5. **Cache Invalidation**: The server evicts the user's cached identity and permissions footprint from high-speed memory, forcing a fresh lookup.
6. **Instant Authorization**: Upon the user's next API call, the request hits the `productAclMiddleware`. The `IAclBackend` recalculates the merged groups, detects the active `tier:pro` inheritance, and structurally allows access to Pro-restricted product features and modified rate limit overrides.
*This brief documentation reflects the current state of CRUD routing and API interactions across the architecture.*

View File

@ -0,0 +1,107 @@
# Multi-Tenant via URL Architecture Options
Implementing multi-tenancy over the URL is a common pattern for SaaS applications. Here is an exploration of the available options, their applicability, and safety implications, focusing specifically on the Hono server architecture and Supabase database isolation.
## 1. Subdomain-Based Routing (Recommended)
**Format:** `https://tenant_slug.yourdomain.com`
**How it works:**
The tenant is identified by extracting the subdomain from the request's hostname.
**Applicability:**
Highly applicable and the industry standard for B2B SaaS. It provides the cleanest URL structure and visually assures users they are in their dedicated workspace.
**Server Implementation (Hono):**
* Add a global middleware early in the `app.use` chain that inspects the `Host` or `X-Forwarded-Host` header.
* Extract the subdomain (e.g., `tenant_slug`) and inject it into the request context (`c.set('tenant', tenant_slug)`).
* **Infrastructure**: Requires configuring a wildcard DNS record (`*.yourdomain.com`) and wildcard SSL certificates.
**Safety:**
* **High**. Cross-tenant data leakage via the UI is hard because the context is structurally tied to the origin.
* Cookies can be scoped to the specific subdomain, preventing Session/Auth token leakage to other tenants.
## 2. Path-Based Routing (URL Prefix)
**Format:** `https://yourdomain.com/t/tenant_slug/` or `https://yourdomain.com/tenant_slug/`
**How it works:**
The tenant ID is the first prefix segment of the URL path.
**Applicability:**
Very common when you cannot manage wildcard subdomains or want a simpler infrastructure setup. Useful for B2C SaaS or mixed platforms.
**Server Implementation (Hono):**
* Hono allows route grouping and middleware scoping based on paths.
* Mount the API under a tenant prefix, e.g., `app.use('/api/t/:tenantSlug/*', tenantMiddleware)`.
* The middleware extracts `:tenantSlug` from `c.req.param('tenantSlug')` and injects it into `c.set('tenant', tenantSlug)`.
* This requires refactoring existing Hono route registrations (like `app.route`) to be nested under the path parameter.
**Safety:**
* **Medium-High**. The backend middleware must strictly validate the `:tenantSlug` parameter against the authenticated user's allowed tenants.
* Cookies are shared across the root domain. A compromised session on one tenant path can theoretically affect another if the user relies on path-scoping, which browsers handle inconsistently.
## 3. Query Parameter-Based Routing (Not Recommended)
**Format:** `https://yourdomain.com/dashboard?tenant=tenant_slug`
**How it works:**
Every API call passes the `tenant` as a query parameter.
**Safety:**
* **Low**. Prone to state leakage.
---
## Server & Database Security Architecture (Supabase)
Merely parsing the tenant from the URL does not make the application secure. The server must use the URL purely as **context**, and rely on the database for **authorization**.
1. **Never Trust the URL for Auth:**
If a user navigates to `tenant-b.domain.com` but their session limits them to `tenant-a`, the server must reject the data access.
2. **Middleware Pipeline:**
Your Hono server should have a distinct sequence in `server/src/index.ts`:
* `tenantMiddleware`: Extracts tenant from URL/Host -> `c.set('tenantId', id)`.
* `authMiddleware`: Verifies the authentication JWT. Determines the user's ID.
* `authorizationMiddleware` (Optional but recommended): Verifies if `c.get('user')` is a member of `c.get('tenantId')` via a quick Supabase check or decoded JWT claims.
3. **Database Isolation using Supabase (RLS):**
The most robust way to ensure safety is pushing the multi-tenant logic down to Postgres via Row Level Security (RLS).
Instead of modifying every query (`.eq('tenant_id', tenantId)`), use Postgres local variables:
* When Hono handles a requested endpoint, generate a Supabase client using Service Role or standard Auth.
* Before running queries, set the Postgres config context:
```typescript
await supabase.rpc('set_tenant_context', { _tenant_id: c.get('tenantId') });
// Or raw query: set_config('app.current_tenant', '...', true);
```
* Your RLS policies on tables simply do:
```sql
CREATE POLICY "Tenant isolation" ON public.documents
USING (tenant_id = current_setting('app.current_tenant')::uuid);
```
This ensures that even if you forget to add a `.eq('tenant_id')` to an API call, data leakage is impossible at the database engine level.
## Deployment on Plesk (Apache Proxy to Node)
Since the application runs via an Apache proxy in a Plesk environment, the deployment implications vary significantly depending on the routing strategy:
### For Subdomain-Based Routing
* **Plesk Configuration**: You must create a "Wildcard Subdomain" (`*.yourdomain.com`) in Plesk attached to the same proxy configuration as the main application.
* **SSL Certificates**: The Let's Encrypt extension in Plesk supports issuing Wildcard certificates, but this strictly requires DNS validation via API (e.g., Cloudflare) rather than standard HTTP validation.
* **Apache Proxy Pass**: Plesk's Apache to Node.js proxy preserves the original request host internally (`ProxyPreserveHost On`), so `Hono` will natively receive `tenant.yourdomain.com` in the `Host` header, alongside `X-Forwarded-Host`. No advanced `.htaccess` editing is required; you just assign the wildcard domain to the Node app correctly.
### For Path-Based Routing
* **Plesk Configuration**: Requires zero structural changes to your Plesk or Apache setup. Everything remains under `yourdomain.com` which is already safely proxied to your application.
* **SSL Certificates**: Your existing single-domain SSL certificate handles everything automatically. No DNS API integration is needed.
* **Apache Proxy Pass**: Passes through normally; the backend Node application sees the full URI path and routes accordingly.
## Conclusion
Since the frontend is not driving the structural isolation via an OrganizationProvider, the true source of truth must live in the **Hono Server** and **Supabase Database**.
For a robust architecture:
1. Choose either **Subdomains** (preferable) or **Path Parameters** for Hono routing.
2. Implement a unified `tenantMiddleware` in `server/src/index.ts` to extract and attach the context to the Hono `c` (Context).
3. Standardize on **Supabase RLS with custom config variables** to enforce hard data isolation, preventing accidental cross-tenant queries across all API routes.

View File

@ -0,0 +1,211 @@
# Product Tiering and ACL Integration
The Product ACL Middleware in Polymech is a centralized gatekeeping layer designed to enforce dynamic, product-level routing conditions. It bridges the platform's multi-tenant Product definitions with the core Access Control List (ACL) system, enabling declarative endpoint protection (such as locking API access behind premium subscription tiers or feature flags) without modifying the endpoint handlers themselves.
## 1. Core Component Layering
Product tiering is resolved dynamically through three primary systems:
* **`productAclMiddleware`**: Sits directly behind the core `auth` handler in the server pipeline. It intercepts incoming requests, cross-references them against active product definitions, and gates access.
* **`DbAclBackend`**: Provides a singleton, unified mechanism for resolving ACL structures from PostgreSQL (`db-acl.ts`). It handles cache management, ensures schema adherence, and acts as the transport layer for queries.
* **Dynamic Route Registry**: Managed inside the `products` table via the JSON `settings.routes` definition.
### Product Route Definitions
A product asserts ownership of specific routes via its JSON settings block. If an endpoint is listed here, the middleware will trap the request before the target Hono handler.
```json
{
"enabled": true,
"default_cost_units": 0,
"routes": [
{
"url": "/api/places/gridsearch",
"method": "post",
"rate": 1
}
]
}
```
The middleware checks incoming requests for a matching path and HTTP verb. If a product claims the route, the evaluation logic maps standard REST verbs to abstract actions (`GET` → `read`, `POST|PUT|PATCH|DELETE``write`) and hands evaluation down to `evaluateAcl`.
**Monetization Metrics:** In addition to strict ACL gating, the settings block supports tiered consumption metrics:
* `default_cost_units`: A universal baseline payload cost or operational limit assigned to the product broadly.
* `rate`: Used inside the routes array. If a user queries the `gridsearch` endpoint, it consumes `1` unit of rate limit or credit allocation. This architecture supports tracking specific high-computation endpoints dynamically.
If the evaluation fails, the middleware aborts the request universally with a `403 Payment/Upgrade Required`.
## 2. Pricing and Monetization Models
The flexibility of the `settings` JSON blob combined with the ACL group system allows Polymech to support multiple different monetization models simultaneously. This enables distinct billing paradigms on a per-product basis without hardcoding specific logic into endpoints.
### Tier-Based Models (Subscriptions)
The most common model relies on static access grants. Rather than consuming a dynamic balance, access is binary based on whether a user's subscription tier matches the required ACL group.
1. **Group Assignments**: A product's settings define an array of `groups` (e.g., `["Pro Subscribers", "Enterprise"]`).
2. **Access Control**: The `productAclMiddleware` validates if the incoming user profile intersects with these product-assigned groups. This is a pure-functional check; if the user possesses the role, they pass transparently.
3. **Billing Enforcement**: A backend billing service (like Stripe webhooks) manages mapping users into these Global ACL groups when their subscription is active, and removes them if payment fails.
4. **Use Case**: Unlimited access SaaS paradigms, features like "Advanced Reporting," or premium dashboard views where usage volumes are not strictly tracked.
### Credits-Based Models (Metered)
For highly computational operations (like GenAI image generation or complex search aggregations), access is gated dynamically based on a diminishing credit ledger.
1. **Cost Configuration**: The product defines a global `default_cost_units` value. Granular endpoint costs are defined within the `routes` array mapping (e.g., calling `/api/search` costs `5` units via `route.rate`).
2. **Ledger Validation**: Unlike Tier-based models, pure group membership is insufficient. The billing middleware or endpoint handler dynamically assesses the user's current "credit balance" against the defined `rate` or `default_cost_units` for the active product logic.
3. **Deduction**: If constraints are met, the database deducts the exact units specified by the product definition.
4. **Use Case**: Pay-as-you-go API access, generation limits, or API-as-a-Service architecture where customers pre-purchase bulk credits.
### Hybrid Strategy (Tiered Allowances)
You can blend these architectures by applying a Tier Subscription model that grants access to a recurring monthly bundle of credits.
1. Users are mapped into a specific ACL `group` defining their tier ("Gold Subscriber").
2. The product strictly checks for that group membership before allowing interaction.
3. If they hold the required tier, they receive a localized balance renewal (e.g., 1000 requests/month constraint) against the product's `cost_units`.
4. Once exhausted, they can either trigger an upsell or be blocked by rate limiters until the next billing cycle.
## 3. Resource Identity and Database Constraints
Products are system-level abstractions. In the underlying database `resource_acl` schema, rows are uniquely identified by a combination of `resource_type`, `resource_id`, and `resource_owner_id`.
Because products are global to the entire platform (not owned by any single user), the `resolveId` abstraction ignores the owner identity parameters, and queries against the Postgres database pass `ownerId = null`. This satisfies the strict `UUID` constraint on the `resource_owner_id` column while correctly mapping to a system-wide entity singleton.
## 4. Global Roles vs User Evaluation
During ACL execution, an administrator or backend script defines rules allocating `READ` or `WRITE` access to specific paths. The identities defined in these rules can be explicit `userId` entries, OR they map to `groups`.
When the `evaluateAcl` function assesses a payload, it builds an identity profile for the user via `fetchUserEffectiveGroups`. The user adopts:
1. Implicit static strings (e.g., `'anonymous'`, `'authenticated'`, `'registered'`).
2. Custom Virtual local groups defined directly in the ACL rules.
3. Persistent Database Global Groups.
To provide compatibility between the generated UI tools and dynamic API access, User database groups append *both* their descriptive string `name` (e.g., `"Pacbot Test Tier"`) AND their UUID `id` strings into the evaluation identity array. This allows API grants targeting raw UUIDs from standard REST flows to safely map to system users.
## 5. Performance & Caching
The `productAclMiddleware` sits in front of potentially high-throughput data operations. Evaluating ACL schemas across PostgreSQL directly per request would induce a severe N+1 overhead latency.
Instead, the `DbAclBackend` utilizes an in-memory `LRUCache`:
* `fetchAclSettings` queries and caches the serialized ruleset representation for a TTL of 5 minutes (`ACL_CACHE_TTL`).
* Database interaction operations (`grantAcl`, `revokeAcl`, `putAclGroup`) automatically invoke `flushAclCache` with the corresponding `resourceType` and `resourceId`.
* This architecture guarantees that the next request made after a monetization tier upgrade (grant execution) fetches fresh permissions, satisfying strong consistency while maintaining sub-millisecond validations across repeat requests.
## 6. Administrative Endpoints
User mapping and Tier granting is abstracted away from the core products engine into the central `/api/admin/acl` paths. Endpoints defined in `server/src/endpoints/acl.ts`:
* **`POST /api/admin/acl/groups`**: Upserts a global application-level ACL Group allowing `(id?: uuid, name: string, description?: string)`. Let the database auto-generate UUIDs to prevent collision and constraints issues.
* **`POST /api/admin/acl/groups/:id/members`**: Maps a specific `{ userId }` payload representation into the target global ACL group using `acl_group_members`.
* **`POST /api/acl/product/:slug/grant`**: Accepts the previously defined `group` id representation, binding strict evaluation endpoints to the product module.
## 7. Audit & Logging
The Product ACL infrastructure relies on dedicated Pino loggers to produce auditable trails of authorization activity, separated from the main application logs.
* **Middleware Telemetry**: By initializing `createLogger('product-acl')` within the `productAclMiddleware.ts`, all ACL evaluation statuses—both allowed and denied—are flushed independently to `logs/product-acl.json`. This targeted log file guarantees isolated monitoring of access patterns, premium tier rejections, and endpoint security.
* **Test Harness Logging**: Custom loggers (e.g., `createLogger('test-products-acl')`) are utilized extensively in E2E tests to funnel verbose output and debugging logs (like Group UUID assignments and ACL rule validation paths) into separate files, keeping the `vitest` CLI clean while preserving deep inspection capabilities.
## 8. Proposed Architecture: Route-Level Tiering (Draft Design)
To solve the complex requirement where a single product exposes URLs tiered across different monetization brackets (e.g. "Free" vs "Pro" endpoints within the same product logic), we have implemented dynamic Route-Level Tiering directly within the configuration schema.
This avoids splitting unified codebases into artificial "sub-products" just for ACL, and ensures `resource_acl` remains scalable.
### The Schema Expansion
We augment the existing `product.settings.routes` definition to accept `groups` properties for access tiering, and `pricing` properties for exact cost allocations per endpoint.
```json
{
"enabled": true,
"default_cost_units": 5, // Fallback cost if a route doesn't specify
"groups": ["Registered"], // Overall product minimum requirements (if any)
"routes": [
{
"url": "/api/video/export-720p",
"method": "post",
"groups": ["Free", "Pro"], // Available without strict tier requirements
"rate": 10 // Deducts 10 general system credits per execution
},
{
"url": "/api/video/export-4k",
"method": "post",
"groups": ["Pro", "Enterprise"], // Restricted to upper tiers
"pricing": {
"provider": "stripe", // e.g. 'stripe', 'paddle', 'lemonsqueezy'
"provider_price_id": "price_1Pkx...", // The upstream gateway ID
"amount": 0.50, // Reference amount for internal display
"currency": "usd"
}
}
]
}
```
### Extending Pricing Resolution
When an endpoint is metered or requires direct fiat payment per use, the `productAclMiddleware` or a downstream billing handler can read the `matchedRoute.pricing` or `matchedRoute.rate`.
* **Token/Credit Drain**: Immediately deduct from the user's `cost_units` wallet mapped to `rate`.
* **Metered Billing**: Fire a usage record to the abstracted Billing Gateway API using the `provider` and `provider_price_id` (e.g., Stripe, Paddle, LemonSqueezy) associated specifically with this route, allowing a single product configuration to emit distinct billing lines to any upstream gateway.
## 9. Pluggable Add-on: Per-User Rate Limiting
We've established that rate linking (like `RATE_LIMIT_MAX=500` in `rateLimiter.ts`) restricts API abuse globally. To allow users to purchase higher rate limits as an "add-on", we can shift from static env vars to dynamic per-user token buckets.
### The Mechanism
1. **Profile Extension**: The user's `profiles.settings` JSON block is expanded to optionally cache a `rate_limit_override` property: `{"rate_limit_override": {"max": 5000, "windowMs": 60000}}`.
2. **Purchase / Webhook Hook**: You create an Add-on product ("API Booster Pack") in Stripe/System. When purchased, your webhook simply patches the user's `profiles.settings.rate_limit_override` to the boosted values.
3. **Dynamic Middleware**:
Currently, `apiRateLimiter` is statically initialized via `hono-rate-limiter`. We would refactor the rate limit middleware to intercept the authorization token, query Redis (or the cached DB profile), and apply a custom limit.
### Why User Settings over ACL for Rate Limits?
While ACL is great for binary *"Can they access this URL?"* checks, Rate Limits are quantitative properties. By storing an explicit `rate_limit_override` directly inside `profiles.settings`:
* Your billing webhooks don't need to interact with the complicated `resource_acl` table; they just patch a single JSON field on the user profile.
* It cleanly separates "Authorization" (Products ACL) from "Traffic Shaping" (Rate Limiting).
* The routing layer checks ACL first. If allowed, it then checks `rateLimit`. If the profile has a rate limit override, it consumes out of their massive bucket. Otherwise, it consumes from the global default bucket.
## 10. Usage Tracking & Metering Hits
If a route is configured with a `rate` (currency) or `pricing` (fiat), the system must track hits reliably. It's critical to separate **Flood Control** from **Quota Metering**:
1. **Flood Control (Rate Limiting)**: Tracked ephemerally via `hono-rate-limiter` using Redis or an LRU MemoryStore. The key is simply mapped to the `userId` or `IP`. This resets every `windowMs` and requires zero persistent database writes.
2. **Quota Metering (Internal Credits)**: If a route requires `rate: 10`, the middleware intercepts the *successful* response and decrements the user's running credit balance in Postgres (e.g., `UPDATE profiles SET settings = jsonb_set(...)`). To avoid database locks on high-throughput routes, these hits can be batched in memory and flushed to the database every 10 seconds.
3. **External Metering (Payment Gateways)**: If a route defines fiat `pricing`, the middleware fires an async call to an internal **Payment Provider Abstraction Layer**. This internal layer reads the `pricing.provider` (e.g., Stripe, Paddle) and `provider_price_id` and forwards the usage data to that specific gateway. This ensures your core system isn't tightly coupled to Stripe, and you can switch to Paddle or Braintree later without rewriting the API middleware!
## 11. Usage Visibility & Dashboard Integration
To ensure transparency and reduce customer support overhead, users should be able to view their API usage natively inside our application's dashboard without being forcefully redirected to external payment portals.
### Internal Credits (`rate`)
For endpoints utilizing the internal token/credit system based on `rate`:
* **User Dashboard**: Because the running balance is stored natively inside Postgres (`profiles.settings.credits`), the React frontend can trivially query a `GET /api/profile` endpoint to render real-time credit burn alongside predictive usage charts.
* **Admin Dashboard**: Support staff can view and manually adjust ("Grant Bonus Credits") this exact JSON value using the standard Admin User manager.
### External Fiat (`pricing.provider`)
For fiat-based metered billing handled by upstream gateways:
* **API Polling**: Providers like Stripe and Paddle offer APIs to query unbilled metered usage (e.g., Stripe's upcoming invoices or meter event summaries).
* **Native Surfacing**: We establish a proxy endpoint `GET /api/billing/usage` inside our platform. This endpoint reaches out to the defined `pricing.provider`, aggregates the user's running usage data for the current cycle, and passes it to the frontend.
* **Seamless Experience**: This guarantees our users view their accumulated `$15.54` usage natively inside *our* UI, maintaining a complete, white-labeled experience that fits within our dashboard aesthetic constraints.
### Middleware Execution Path
Modifications restricted to the `productAclMiddleware.ts` logic flow:
1. **Route Match Evaluation:** `matchProductRoute` finds the specific route object `matchedRoute` alongside the `matchedProduct`.
2. **Fetch User Groups:** Execute `const effectiveGroups = await fetchUserEffectiveGroups(userId)`.
3. **Route-Level Resolution:** Check if `effectiveGroups` intersects with `matchedRoute.groups`. If true, bypass traditional DB `evaluateAcl` checks and gracefully allow access.
4. **Fallback:** If `matchedRoute.groups` does not explicitly approve access, fall back to the existing `evaluateAcl('product', aclSettings, userId, '/')` logic which continues to serve global DB-enforced overrides.
### Why This Design Works
1. **No Database Migrations:** Uses the flexible JSON payload in `products` instead of enforcing strict SQL constraints.
2. **UI Compatible:** The `ProductsManager` UI (specifically the `VariablesEditor` for the JSON payload) inherently supports modifying these arrays on the fly. We do not need a custom UI immediately, though replacing the Variables array with inline `GroupPicker` instances for each route later would be trivial.
3. **Graceful Degradation:** Routes without a `groups` array natively fall back to the existing ACL framework without breaking backward compatibility.
4. **Declarative Control:** Admins can view *exactly* who has access to which endpoint from a single, centralized JSON blob.

View File

@ -0,0 +1,147 @@
import { useState, useEffect } from "react";
import { Check, ChevronsUpDown, Loader2, Shield, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Badge } from "@/components/ui/badge";
import { useQuery } from "@tanstack/react-query";
import { fetchGlobalGroups, GlobalAclGroup } from "@/modules/user/client-acl";
interface GroupPickerProps {
value?: string | string[];
onSelect: (groupIdOrName: string, group: any) => void;
onRemove?: (groupIdOrName: string) => void;
disabled?: boolean;
multi?: boolean;
}
export function GroupPicker({ value, onSelect, onRemove, disabled, multi = false }: GroupPickerProps) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const { data: groups = [], isLoading } = useQuery<GlobalAclGroup[]>({
queryKey: ['global-acl-groups'],
queryFn: async () => await fetchGlobalGroups()
});
const isSelected = (identifier: string) => {
if (multi && Array.isArray(value)) {
return value.includes(identifier);
}
return value === identifier;
};
const selectedGroups = groups.filter(g => isSelected(g.name) || isSelected(g.id));
// For values that might not be existing IDs but just text strings
const multiValueArray = Array.isArray(value) ? value : [];
// Resolve what to display on the trigger button
let displayContent = (
<span className="text-muted-foreground truncate">Select group...</span>
);
if (multi && multiValueArray.length > 0) {
displayContent = (
<div className="flex gap-1 flex-wrap items-center">
{multiValueArray.map(val => {
const matched = groups.find(g => g.name === val || g.id === val);
const displayName = matched ? matched.name : val;
return (
<Badge key={val} variant="secondary" className="mr-1 mb-1 truncate max-w-[120px]" onClick={(e) => {
if (onRemove) {
e.stopPropagation();
onRemove(val);
}
}}>
{displayName}
{onRemove && <X className="h-3 w-3 ml-1 hover:text-destructive cursor-pointer" />}
</Badge>
);
})}
</div>
);
} else if (!multi && value) {
const matched = selectedGroups[0];
displayContent = (
<div className="flex items-center gap-2 truncate">
<Shield className="h-4 w-4 text-primary" />
<span className="truncate">{matched ? matched.name : value}</span>
</div>
);
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<div
role="combobox"
aria-expanded={open}
className={cn(
"flex min-h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer",
disabled && "opacity-50 cursor-not-allowed pointer-events-none"
)}
>
<div className="flex-1 flex flex-wrap gap-1 overflow-hidden">
{displayContent}
</div>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</div>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0" align="start">
<Command>
<CommandInput placeholder="Search groups..." value={query} onValueChange={setQuery} />
<CommandList>
{isLoading && <div className="p-4 flex justify-center"><Loader2 className="h-4 w-4 animate-spin" /></div>}
{!isLoading && groups.length === 0 && (
<CommandEmpty>No groups found.</CommandEmpty>
)}
<CommandGroup>
{groups.map((group) => (
<CommandItem
key={group.id}
value={group.name}
onSelect={() => {
// Store by name for explicit readability in ACLs / settings, or by ID?
// Typically name is used if settings represent textual groups
onSelect(group.name, group);
if (!multi) {
setOpen(false);
}
setQuery("");
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
isSelected(group.name) || isSelected(group.id) ? "opacity-100" : "opacity-0"
)}
/>
<Shield className="h-4 w-4 mr-2 text-muted-foreground" />
<div className="flex flex-col">
<span className="font-medium">{group.name}</span>
{group.native_type && <span className="text-[10px] text-muted-foreground">{group.native_type}</span>}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { toast } from 'sonner';
import { T, translate } from '@/i18n';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
@ -11,24 +11,32 @@ import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import { fetchProducts, createProduct, updateProduct, deleteProduct, Product } from '@/modules/ecommerce/client-products';
import { VariablesEditor } from '@/components/variables/VariablesEditor';
import { GroupPicker } from '@/components/admin/GroupPicker';
import { Badge } from '@/components/ui/badge';
const PRODUCT_VARIABLE_SCHEMA = {
enabled: { label: translate("Enabled"), description: translate("Is the product enabled?") },
default_cost_units: { label: translate("Default Cost Units"), description: translate("Cost per use") },
default_rate_limit: { label: translate("Default Rate Limit"), description: translate("Requests per window limit") },
default_rate_window: { label: translate("Default Rate Window Time"), description: translate("Time window in seconds") }
};
const ProductsManager = () => {
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
``
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
const [deletingProduct, setDeletingProduct] = useState<Product | null>(null);
const [settingsProduct, setSettingsProduct] = useState<Product | null>(null);
// Form states
const [formData, setFormData] = useState({
name: '',
slug: '',
description: '',
enabled: true,
default_cost_units: 0,
default_rate_limit: '',
default_rate_window: ''
settings: {} as Record<string, any>
});
useEffect(() => {
@ -53,10 +61,7 @@ const ProductsManager = () => {
name: '',
slug: '',
description: '',
enabled: true,
default_cost_units: 0,
default_rate_limit: '',
default_rate_window: ''
settings: {}
});
setIsCreateOpen(true);
};
@ -66,28 +71,18 @@ const ProductsManager = () => {
name: product.name,
slug: product.slug, // Can't typically change slug but we might allow or hide
description: product.description || '',
enabled: product.settings?.enabled ?? true,
default_cost_units: product.settings?.default_cost_units ?? 0,
default_rate_limit: product.settings?.default_rate_limit?.toString() ?? '',
default_rate_window: product.settings?.default_rate_window?.toString() ?? ''
settings: product.settings || {}
});
setEditingProduct(product);
};
const handleSave = async () => {
try {
const settings = {
enabled: formData.enabled,
default_cost_units: Number(formData.default_cost_units),
...(formData.default_rate_limit ? { default_rate_limit: Number(formData.default_rate_limit) } : {}),
...(formData.default_rate_window ? { default_rate_window: Number(formData.default_rate_window) } : {})
};
if (editingProduct) {
await updateProduct(editingProduct.slug, {
name: formData.name,
description: formData.description,
settings
settings: formData.settings
});
toast.success(translate('Product updated successfully'));
setIsCreateOpen(false);
@ -97,7 +92,7 @@ const ProductsManager = () => {
name: formData.name,
slug: formData.slug || undefined,
description: formData.description,
settings
settings: formData.settings
});
toast.success(translate('Product created successfully'));
setIsCreateOpen(false);
@ -128,6 +123,22 @@ const ProductsManager = () => {
}
};
const settingsProductRef = useRef(settingsProduct);
useEffect(() => {
settingsProductRef.current = settingsProduct;
}, [settingsProduct]);
const handleLoadSettings = useCallback(async () => {
return settingsProductRef.current?.settings || {};
}, []);
const handleSaveSettings = useCallback(async (data: Record<string, any>) => {
if (!settingsProduct) return;
await updateProduct(settingsProduct.slug, { settings: data });
setSettingsProduct({ ...settingsProduct, settings: data });
loadProducts();
}, [settingsProduct]);
if (loading) {
return <div><T>Loading products...</T></div>;
}
@ -140,7 +151,7 @@ const ProductsManager = () => {
<T>Create Product</T>
</Button>
</div>
<div className="border rounded-lg">
<Table>
<TableHeader>
@ -149,6 +160,7 @@ const ProductsManager = () => {
<TableHead><T>Slug</T></TableHead>
<TableHead><T>Status</T></TableHead>
<TableHead><T>Cost Units</T></TableHead>
<TableHead><T>Groups</T></TableHead>
<TableHead className="text-right"><T>Actions</T></TableHead>
</TableRow>
</TableHeader>
@ -179,6 +191,16 @@ const ProductsManager = () => {
<TableCell>
{product.settings?.default_cost_units ?? 0}
</TableCell>
<TableCell>
<div className="flex gap-1 flex-wrap max-w-[200px]">
{(product.settings?.groups || []).map((group: string) => (
<Badge key={group} variant="secondary" className="text-[10px] truncate max-w-[120px]">{group}</Badge>
))}
{(!product.settings?.groups || product.settings.groups.length === 0) && (
<span className="text-xs text-muted-foreground italic"><T>None</T></span>
)}
</div>
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
@ -188,6 +210,7 @@ const ProductsManager = () => {
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleEditOpen(product)}><T>Edit</T></DropdownMenuItem>
<DropdownMenuItem onClick={() => setSettingsProduct(product)}><T>Settings</T></DropdownMenuItem>
<DropdownMenuItem
className="text-red-500 hover:text-red-600 focus:text-red-600"
onClick={() => setDeletingProduct(product)}
@ -201,7 +224,7 @@ const ProductsManager = () => {
))}
{products.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
<T>No products found. Create one to get started.</T>
</TableCell>
</TableRow>
@ -250,46 +273,37 @@ const ProductsManager = () => {
placeholder="Brief description of the product"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="cost"><T>Default Cost Units</T></Label>
<Input
id="cost"
type="number"
value={formData.default_cost_units}
onChange={(e) => setFormData({ ...formData, default_cost_units: Number(e.target.value) })}
/>
</div>
<div className="flex items-center gap-2 mt-8">
<Switch
id="enabled"
checked={formData.enabled}
onCheckedChange={(checked) => setFormData({ ...formData, enabled: checked })}
/>
<Label htmlFor="enabled"><T>Enabled</T></Label>
</div>
</div>
<div className="grid grid-cols-2 gap-4 mt-2">
<div className="grid gap-2">
<Label htmlFor="rate_limit"><T>Rate Limit</T> <span className="text-muted-foreground text-xs">(optional)</span></Label>
<Input
id="rate_limit"
type="number"
value={formData.default_rate_limit}
onChange={(e) => setFormData({ ...formData, default_rate_limit: e.target.value })}
placeholder="e.g. 100"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="rate_window"><T>Rate Window Time</T> <span className="text-muted-foreground text-xs">(optional)</span></Label>
<Input
id="rate_window"
type="number"
value={formData.default_rate_window}
onChange={(e) => setFormData({ ...formData, default_rate_window: e.target.value })}
placeholder="Seconds e.g. 3600"
/>
</div>
<div className="grid gap-2 mt-2 border-t pt-4">
<Label><T>Assigned ACL Groups</T></Label>
<GroupPicker
multi={true}
value={formData.settings?.groups || []}
onSelect={(groupIdOrName) => {
const currentGroups = formData.settings?.groups || [];
if (!currentGroups.includes(groupIdOrName)) {
setFormData({
...formData,
settings: {
...(formData.settings || {}),
groups: [...currentGroups, groupIdOrName]
}
});
}
}}
onRemove={(groupIdOrName) => {
const currentGroups = formData.settings?.groups || [];
setFormData({
...formData,
settings: {
...(formData.settings || {}),
groups: currentGroups.filter((g: string) => g !== groupIdOrName)
}
});
}}
/>
<p className="text-xs text-muted-foreground">
<T>Members of these groups will inherently be granted access to this product.</T>
</p>
</div>
</div>
<DialogFooter>
@ -314,7 +328,7 @@ const ProductsManager = () => {
</DialogHeader>
<div className="py-4">
<p className="text-muted-foreground">
{translate("Are you sure you want to delete")} <span className="font-semibold text-foreground">{deletingProduct?.name}</span>?
{translate("Are you sure you want to delete")} <span className="font-semibold text-foreground">{deletingProduct?.name}</span>?
{translate("This action cannot be undone and will disconnect any associated ACLs.")}
</p>
</div>
@ -328,6 +342,22 @@ const ProductsManager = () => {
</DialogFooter>
</DialogContent>
</Dialog>
{/* Settings Dialog */}
<Dialog open={!!settingsProduct} onOpenChange={(open) => !open && setSettingsProduct(null)}>
<DialogContent className="max-w-5xl">
<DialogHeader>
<DialogTitle><T>Product Settings</T>: {settingsProduct?.name}</DialogTitle>
</DialogHeader>
{!!settingsProduct && (
<VariablesEditor
onLoad={handleLoadSettings}
onSave={handleSaveSettings}
variableSchema={PRODUCT_VARIABLE_SCHEMA}
/>
)}
</DialogContent>
</Dialog>
</div>
);
};