diff --git a/packages/ui/docs/entities-erd.md b/packages/ui/docs/entities-erd.md new file mode 100644 index 00000000..bf6199c1 --- /dev/null +++ b/packages/ui/docs/entities-erd.md @@ -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 + } +``` diff --git a/packages/ui/docs/entities.md b/packages/ui/docs/entities.md new file mode 100644 index 00000000..35c3fc25 --- /dev/null +++ b/packages/ui/docs/entities.md @@ -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.* diff --git a/packages/ui/docs/multi-tenant.md b/packages/ui/docs/multi-tenant.md new file mode 100644 index 00000000..cd428818 --- /dev/null +++ b/packages/ui/docs/multi-tenant.md @@ -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. diff --git a/packages/ui/docs/products-acl.md b/packages/ui/docs/products-acl.md new file mode 100644 index 00000000..03c5574c --- /dev/null +++ b/packages/ui/docs/products-acl.md @@ -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. diff --git a/packages/ui/src/components/admin/GroupPicker.tsx b/packages/ui/src/components/admin/GroupPicker.tsx new file mode 100644 index 00000000..8d94213e --- /dev/null +++ b/packages/ui/src/components/admin/GroupPicker.tsx @@ -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({ + 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 = ( + Select group... + ); + + if (multi && multiValueArray.length > 0) { + displayContent = ( +
+ {multiValueArray.map(val => { + const matched = groups.find(g => g.name === val || g.id === val); + const displayName = matched ? matched.name : val; + return ( + { + if (onRemove) { + e.stopPropagation(); + onRemove(val); + } + }}> + {displayName} + {onRemove && } + + ); + })} +
+ ); + } else if (!multi && value) { + const matched = selectedGroups[0]; + displayContent = ( +
+ + {matched ? matched.name : value} +
+ ); + } + + return ( + + +
+
+ {displayContent} +
+ +
+
+ + + + + {isLoading &&
} + + {!isLoading && groups.length === 0 && ( + No groups found. + )} + + + {groups.map((group) => ( + { + // 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(""); + }} + > + + +
+ {group.name} + {group.native_type && {group.native_type}} +
+
+ ))} +
+
+
+
+
+ ); +} diff --git a/packages/ui/src/components/admin/ProductsManager.tsx b/packages/ui/src/components/admin/ProductsManager.tsx index 4e6d04a2..43a8c643 100644 --- a/packages/ui/src/components/admin/ProductsManager.tsx +++ b/packages/ui/src/components/admin/ProductsManager.tsx @@ -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([]); const [loading, setLoading] = useState(true); - + `` const [isCreateOpen, setIsCreateOpen] = useState(false); const [editingProduct, setEditingProduct] = useState(null); const [deletingProduct, setDeletingProduct] = useState(null); + const [settingsProduct, setSettingsProduct] = useState(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 }); 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) => { + if (!settingsProduct) return; + await updateProduct(settingsProduct.slug, { settings: data }); + setSettingsProduct({ ...settingsProduct, settings: data }); + loadProducts(); + }, [settingsProduct]); + if (loading) { return
Loading products...
; } @@ -140,7 +151,7 @@ const ProductsManager = () => { Create Product - +
@@ -149,6 +160,7 @@ const ProductsManager = () => { Slug Status Cost Units + Groups Actions @@ -179,6 +191,16 @@ const ProductsManager = () => { {product.settings?.default_cost_units ?? 0} + +
+ {(product.settings?.groups || []).map((group: string) => ( + {group} + ))} + {(!product.settings?.groups || product.settings.groups.length === 0) && ( + None + )} +
+
@@ -188,6 +210,7 @@ const ProductsManager = () => { handleEditOpen(product)}>Edit + setSettingsProduct(product)}>Settings setDeletingProduct(product)} @@ -201,7 +224,7 @@ const ProductsManager = () => { ))} {products.length === 0 && ( - + No products found. Create one to get started. @@ -250,46 +273,37 @@ const ProductsManager = () => { placeholder="Brief description of the product" /> -
-
- - setFormData({ ...formData, default_cost_units: Number(e.target.value) })} - /> -
-
- setFormData({ ...formData, enabled: checked })} - /> - -
-
-
-
- - setFormData({ ...formData, default_rate_limit: e.target.value })} - placeholder="e.g. 100" - /> -
-
- - setFormData({ ...formData, default_rate_window: e.target.value })} - placeholder="Seconds e.g. 3600" - /> -
+
+ + { + 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) + } + }); + }} + /> +

+ Members of these groups will inherently be granted access to this product. +

@@ -314,7 +328,7 @@ const ProductsManager = () => {

- {translate("Are you sure you want to delete")} {deletingProduct?.name}? + {translate("Are you sure you want to delete")} {deletingProduct?.name}? {translate("This action cannot be undone and will disconnect any associated ACLs.")}

@@ -328,6 +342,22 @@ const ProductsManager = () => {
+ + {/* Settings Dialog */} + !open && setSettingsProduct(null)}> + + + Product Settings: {settingsProduct?.name} + + {!!settingsProduct && ( + + )} + + ); };